@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.
Files changed (46) 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-B-cheu55.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/index.ts +6 -2
  27. package/src/codex/auth-context.ts +18 -2
  28. package/src/codex/catalog/provider-fetch.ts +31 -8
  29. package/src/codex/model-cache.ts +44 -0
  30. package/src/codex/runtime.ts +17 -1
  31. package/src/codex/shim.ts +608 -10
  32. package/src/combos/resolve.ts +7 -2
  33. package/src/config.ts +11 -0
  34. package/src/lib/sse-decoder.ts +25 -6
  35. package/src/responses/parser.ts +1 -1
  36. package/src/responses/state.ts +10 -2
  37. package/src/server/auth-cors.ts +4 -1
  38. package/src/server/index.ts +182 -0
  39. package/src/server/live.ts +491 -0
  40. package/src/server/management/provider-routes.ts +2 -0
  41. package/src/server/responses/core.ts +184 -20
  42. package/src/server/responses/encrypted-payload.ts +118 -41
  43. package/src/server/ws-bridge.ts +7 -0
  44. package/src/types.ts +14 -0
  45. package/gui/dist/assets/index-BpX-hoSd.css +0 -1
  46. package/gui/dist/assets/index-CprFnVjr.js +0 -52
@@ -49,6 +49,7 @@ import {
49
49
  headersForCodexAuthContext,
50
50
  isCodexAuthContextUsable,
51
51
  resolveCodexAuthContext,
52
+ stripCodexRuntimeProviderFields,
52
53
  type CodexAuthContext,
53
54
  } from "../../codex/auth-context";
54
55
  import {
@@ -97,6 +98,7 @@ import {
97
98
  } from "../relay";
98
99
  import { relaySseEagerBounded } from "../relay-eager";
99
100
  import { decideEagerRelay } from "../../lib/bun-stream-caps";
101
+ import { cancelBodyOnAbort } from "../../lib/abort";
100
102
  import { hasResponsesItemIdRepair, relaySseWithResponsesItemIdRepair } from "../responses-item-id-repair";
101
103
  import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog";
102
104
 
@@ -150,6 +152,45 @@ export function usesCodexForwardPoolAuth(
150
152
  && provider.authMode === "forward" && provider.adapter === "openai-responses";
151
153
  }
152
154
 
155
+ function normalizeCodexUnsupportedModelDetail(value: string): string {
156
+ return value.trim().replace(/\s+/gu, " ").toLocaleLowerCase("en-US");
157
+ }
158
+
159
+ function isAllowListedCodexAccountModel400(
160
+ status: number,
161
+ bodyText: string,
162
+ modelId: string,
163
+ ): boolean {
164
+ if (status !== 400) return false;
165
+ try {
166
+ const payload = JSON.parse(bodyText) as unknown;
167
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
168
+ const detail = (payload as { detail?: unknown }).detail;
169
+ if (typeof detail !== "string") return false;
170
+ const expected = `The '${modelId}' model is not supported when using Codex with a ChatGPT account.`;
171
+ return normalizeCodexUnsupportedModelDetail(detail)
172
+ === normalizeCodexUnsupportedModelDetail(expected);
173
+ } catch {
174
+ return false;
175
+ }
176
+ }
177
+
178
+ async function shouldRetryCodexPoolAccountModel400(
179
+ response: Response,
180
+ modelId: string,
181
+ signal?: AbortSignal,
182
+ ): Promise<boolean> {
183
+ if (response.status !== 400) return false;
184
+ try {
185
+ const body = await readBoundedResponseBody(response.clone(), { signal });
186
+ return body.displaySafe
187
+ && !body.truncated
188
+ && isAllowListedCodexAccountModel400(response.status, body.text, modelId);
189
+ } catch {
190
+ return false;
191
+ }
192
+ }
193
+
153
194
 
154
195
 
155
196
  export function codexForwardTerminalOutcomeRecorder(
@@ -345,6 +386,22 @@ export function buildComboChildHeaders(parentHeaders: HeadersInit): Headers {
345
386
  return childHeaders;
346
387
  }
347
388
 
389
+ const UNREADABLE_ENCRYPTED_AGENT_TASK_MESSAGE =
390
+ "Routed V2 worker task is encrypted for the native ChatGPT backend and cannot be read by the selected provider. Use plaintext V2 agent-message delivery or select a native ChatGPT model.";
391
+
392
+ function unreadableEncryptedAgentTaskResponse(): Response {
393
+ return new Response(
394
+ JSON.stringify({
395
+ error: {
396
+ message: UNREADABLE_ENCRYPTED_AGENT_TASK_MESSAGE,
397
+ type: "invalid_request_error",
398
+ code: "unreadable_encrypted_agent_task",
399
+ },
400
+ }),
401
+ { status: 400, headers: { "Content-Type": "application/json" } },
402
+ );
403
+ }
404
+
348
405
 
349
406
 
350
407
  export async function handleComboResponses(
@@ -369,9 +426,30 @@ export async function handleComboResponses(
369
426
  return formatErrorResponse(404, "invalid_request_error", `Unknown combo: ${comboId}`);
370
427
  }
371
428
 
429
+ const unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
430
+ (rawBody as { input?: unknown } | undefined)?.input,
431
+ );
432
+ const canDecryptUnreadableAgentTask = (target: (typeof combo.targets)[number]): boolean => {
433
+ const provider = config.providers[target.provider];
434
+ if (!provider || provider.disabled === true) return false;
435
+ try {
436
+ const route = routeModel(config, `${target.provider}/${target.model}`);
437
+ return isCanonicalOpenAiForwardProvider(route.provider);
438
+ } catch {
439
+ return false;
440
+ }
441
+ };
442
+ const payloadEligible = (target: (typeof combo.targets)[number]): boolean =>
443
+ !unreadableEncryptedAgentTask || canDecryptUnreadableAgentTask(target);
444
+
445
+ if (unreadableEncryptedAgentTask && !combo.targets.some(canDecryptUnreadableAgentTask)) {
446
+ return unreadableEncryptedAgentTaskResponse();
447
+ }
448
+
372
449
  const initialNow = Date.now();
373
450
  let pick = pickComboTarget(config, comboId, {
374
- eligible: target => !isComboTargetInCooldown(comboId, target, initialNow),
451
+ eligible: target => payloadEligible(target)
452
+ && !isComboTargetInCooldown(comboId, target, initialNow),
375
453
  });
376
454
  if (!pick) {
377
455
  return comboUnavailableResponse(`No available targets for combo: ${comboId}`);
@@ -532,6 +610,7 @@ export async function handleComboResponses(
532
610
  pick = advanceComboAfterFailure(config, pick, {
533
611
  retryAfter: failure.retryAfter,
534
612
  now: Date.now(),
613
+ eligible: payloadEligible,
535
614
  });
536
615
  }
537
616
  return lastFailure!;
@@ -555,12 +634,12 @@ export async function handleResponses(
555
634
  if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) {
556
635
  return handleComboResponses(req, body, comboId, config, logCtx, options);
557
636
  }
558
- const originalBody = body;
559
- body = expandPreviousResponseInput(body);
560
- const previousResponseInputExpanded = body !== originalBody;
561
637
  const unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
562
638
  (body as { input?: unknown } | undefined)?.input,
563
639
  );
640
+ const originalBody = body;
641
+ body = expandPreviousResponseInput(body);
642
+ const previousResponseInputExpanded = body !== originalBody;
564
643
 
565
644
  // Spawn-message compatibility (both directions): agent_message task payloads ride in
566
645
  // encrypted_content slots as plaintext. Rewrite them to input_text on the RAW body BEFORE
@@ -583,6 +662,8 @@ export async function handleResponses(
583
662
  if (previousResponseInputExpanded) parsed._previousResponseInputExpanded = true;
584
663
  parsed._providerContinuation = previousResponseProviderState(parsed.previousResponseId);
585
664
  parsed._cursorConversationId = parsed._providerContinuation?.cursor?.conversationId;
665
+ const clientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim();
666
+ if (clientThreadId) parsed._clientThreadId = clientThreadId;
586
667
  } catch (err) {
587
668
  return formatErrorResponse(400, "invalid_request_error", err instanceof Error ? err.message : String(err));
588
669
  }
@@ -608,7 +689,10 @@ export async function handleResponses(
608
689
  (parsed._rawBody as Record<string, unknown>).reasoning = { effort: "low" };
609
690
  }
610
691
  (logCtx as unknown as Record<string, unknown>).shadowCallRewrittenFrom = _sciOriginal;
692
+ // Helpers must not resume/append into the parent thread's Cursor conversation.
693
+ parsed._cursorIsolateConversation = true;
611
694
  }
695
+ if (parsed._compactionRequest === true) parsed._cursorIsolateConversation = true;
612
696
 
613
697
  let route;
614
698
  try {
@@ -624,11 +708,7 @@ export async function handleResponses(
624
708
  // providers cannot. Reject the raw-input classification before adapter construction
625
709
  // or provider dispatch so an unreadable worker task cannot trigger a cost storm.
626
710
  if (!isCanonicalOpenAiForwardProvider(route.provider) && unreadableEncryptedAgentTask) {
627
- return formatErrorResponse(
628
- 400,
629
- "invalid_request_error",
630
- "Routed V2 worker task is encrypted for the native ChatGPT backend and cannot be read by the selected provider. Use plaintext V2 agent-message delivery or select a native ChatGPT model.",
631
- );
711
+ return unreadableEncryptedAgentTaskResponse();
632
712
  }
633
713
 
634
714
  // Apply the routed model id upstream: routing may strip a "<provider>/" namespace
@@ -775,6 +855,10 @@ export async function handleResponses(
775
855
  }
776
856
  route.provider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
777
857
  logCtx.provider = formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config);
858
+ // Prefer Codex pool account as the Cursor thread namespace when present. Cursor routes without
859
+ // codexAccountMode still get a credential-derived scope inside the Cursor adapter.
860
+ const identityScope = codexLogAccountId(authCtx);
861
+ if (identityScope) parsed._cursorIdentityScope = identityScope;
778
862
 
779
863
  // OAuth providers: swap in a fresh access token (auto-refreshed) as the Bearer key, so the
780
864
  // existing openai-chat / anthropic adapters authenticate with no change.
@@ -920,7 +1004,7 @@ export async function handleResponses(
920
1004
  + `(model ${parsed.modelId}); forwarding without it — earlier turns may be missing from this request`,
921
1005
  );
922
1006
  }
923
- const request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders });
1007
+ let request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders });
924
1008
  const passthroughEstimate = typeof request.usageLog?.inputTokens === "number"
925
1009
  ? request.usageLog.inputTokens
926
1010
  : undefined;
@@ -934,6 +1018,20 @@ export async function handleResponses(
934
1018
  linkAbortSignal(upstream, options.abortSignal);
935
1019
  const connectMs = config.connectTimeoutMs ?? 200_000;
936
1020
  let upstreamResponse: Response;
1021
+ const transportFailureResponse = (err: unknown): Response => {
1022
+ upstream.abort();
1023
+ if (options.abortSignal?.aborted) return clientCancelledResponse();
1024
+ const outcome = err instanceof Error && err.name === "TimeoutError" ? "timeout" : "connect_error";
1025
+ if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
1026
+ recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
1027
+ threadId: req.headers.get("x-codex-parent-thread-id"),
1028
+ });
1029
+ }
1030
+ const msg = outcome === "timeout"
1031
+ ? `Provider connect timeout after ${connectMs}ms`
1032
+ : `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
1033
+ return formatErrorResponse(502, "upstream_error", msg);
1034
+ };
937
1035
  try {
938
1036
  // Transient-5xx pre-stream retry (devlog/_plan/260716_claudecode_hardening/010):
939
1037
  // the ChatGPT backend emits transient 502/520s that an immediate retry absorbs.
@@ -950,18 +1048,80 @@ export async function handleResponses(
950
1048
  { abortSignal: upstream.signal, label: safeHostLabel(request.url) },
951
1049
  );
952
1050
  } catch (err) {
953
- upstream.abort();
954
- if (options.abortSignal?.aborted) return clientCancelledResponse();
955
- const outcome = err instanceof Error && err.name === "TimeoutError" ? "timeout" : "connect_error";
956
- if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
957
- recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
1051
+ return transportFailureResponse(err);
1052
+ }
1053
+
1054
+ if (
1055
+ usesCodexForwardPoolAuth(authCtx, route.provider)
1056
+ && await shouldRetryCodexPoolAccountModel400(
1057
+ upstreamResponse,
1058
+ route.modelId,
1059
+ options.abortSignal,
1060
+ )
1061
+ ) {
1062
+ const firstAuthCtx = authCtx;
1063
+ let retryAuthCtx: CodexAuthContext | undefined;
1064
+ try {
1065
+ retryAuthCtx = await resolveCodexAuthContext(
1066
+ req.headers,
1067
+ config,
1068
+ "pool",
1069
+ { excludeAccountId: firstAuthCtx.accountId },
1070
+ );
1071
+ } catch (error) {
1072
+ if (
1073
+ !(error instanceof CodexPoolAuthenticationError)
1074
+ && !(error instanceof CodexAuthContextError)
1075
+ && !(error instanceof CodexAccountCooldownError)
1076
+ ) throw error;
1077
+ }
1078
+
1079
+ if (retryAuthCtx?.kind === "pool" || retryAuthCtx?.kind === "main-pool") {
1080
+ recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, 400, {
958
1081
  threadId: req.headers.get("x-codex-parent-thread-id"),
959
1082
  });
1083
+
1084
+ const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx);
1085
+ const retryProvider = applyCodexAuthContextToProvider(
1086
+ stripCodexRuntimeProviderFields(route.provider),
1087
+ retryAuthCtx,
1088
+ "pool",
1089
+ );
1090
+ const retryAdapter = resolveAdapter(
1091
+ resolveWireProtocolOverride(route.providerName, route.modelId, retryProvider),
1092
+ config.cacheRetention,
1093
+ );
1094
+ request = await retryAdapter.buildRequest(parsed, { headers: retryHeaders });
1095
+
1096
+ await upstreamResponse.body?.cancel().catch(() => undefined);
1097
+ authCtx = retryAuthCtx;
1098
+ options.onCodexAuthContextResolved?.(retryAuthCtx);
1099
+ selectedForwardHeaders = retryHeaders;
1100
+ route.provider = retryProvider;
1101
+ logCtx.provider = formatCodexProviderForLog(
1102
+ route.providerName,
1103
+ retryAuthCtx.accountId,
1104
+ config,
1105
+ );
1106
+
1107
+ noteAttemptSend(logCtx.activeAttempt, passthroughEstimate);
1108
+ try {
1109
+ upstreamResponse = await fetchWithHeaderTimeout(
1110
+ request.url,
1111
+ {
1112
+ method: request.method,
1113
+ headers: request.headers,
1114
+ body: request.body,
1115
+ },
1116
+ upstream.signal,
1117
+ connectMs,
1118
+ parsed.stream,
1119
+ providerFetch(route.provider),
1120
+ );
1121
+ } catch (err) {
1122
+ return transportFailureResponse(err);
1123
+ }
960
1124
  }
961
- const msg = outcome === "timeout"
962
- ? `Provider connect timeout after ${connectMs}ms`
963
- : `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
964
- return formatErrorResponse(502, "upstream_error", msg);
965
1125
  }
966
1126
  const headers = sanitizePassthroughHeaders(upstreamResponse.headers);
967
1127
  const resolvedModel = headers.get("openai-model")?.trim();
@@ -1153,7 +1313,9 @@ export async function handleResponses(
1153
1313
  if (adapter.runTurn) {
1154
1314
  const runTurnAbort = new AbortController();
1155
1315
  linkAbortSignal(runTurnAbort, options.abortSignal);
1156
- const queue = createAdapterEventQueue();
1316
+ const queue = createAdapterEventQueue({
1317
+ onBacklogExceeded: () => runTurnAbort.abort(),
1318
+ });
1157
1319
  const runTurn = async (): Promise<void> => {
1158
1320
  try {
1159
1321
  noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens);
@@ -1470,6 +1632,8 @@ export async function handleResponses(
1470
1632
  }
1471
1633
  }
1472
1634
 
1635
+ cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
1636
+
1473
1637
  if (parsed.stream) {
1474
1638
  const eventStream = activeAdapter.parseStream(upstreamResponse);
1475
1639
  const { toolNsMap, freeformToolNames, toolSearchToolNames } = buildToolBridgeMaps(parsed);
@@ -104,52 +104,131 @@ export function looksLikeBackendCiphertext(payload: string): boolean {
104
104
 
105
105
 
106
106
 
107
- export const FERNET_TOKEN_RUN = /gAAAA[A-Za-z0-9_-]{60,}={0,2}/g;
107
+ /**
108
+ * Backend-minted ciphertext runs are Fernet tokens (base64url, version byte 0x80).
109
+ * Used to carve embedded blobs out of MIXED slots: plugin hooks may prepend
110
+ * plaintext control metadata to a task body that is already backend-encrypted.
111
+ */
112
+ const FERNET_TOKEN_CANDIDATE = /g[A-Za-z0-9_-]{97,}={0,2}/g;
113
+ const FERNET_TOKEN_BOUNDARY_CHAR = /[A-Za-z0-9_=-]/;
114
+
115
+ interface FernetTokenRun {
116
+ index: number;
117
+ token: string;
118
+ }
119
+
120
+ /**
121
+ * Validate only the key-independent Fernet wire structure. Authenticity cannot be
122
+ * checked without the backend key, but a real token must still be canonical base64url
123
+ * containing version(1) + timestamp(8) + IV(16) + AES-CBC ciphertext(16*n) + HMAC(32).
124
+ * Timestamp freshness is deliberately not enforced: old history can contain valid tokens.
125
+ */
126
+ function isStructurallyValidFernetToken(token: string): boolean {
127
+ if (token.length < 100 || token.length % 4 !== 0) return false;
128
+ if (!/^[A-Za-z0-9_-]+={0,2}$/.test(token)) return false;
129
+
130
+ const unpadded = token.replace(/=+$/, "");
131
+ const paddingLength = token.length - unpadded.length;
132
+ const expectedPadding = (4 - (unpadded.length % 4)) % 4;
133
+ if (expectedPadding > 2 || paddingLength !== expectedPadding) return false;
134
+
135
+ let decoded: Buffer;
136
+ try {
137
+ decoded = Buffer.from(unpadded, "base64url");
138
+ } catch {
139
+ return false;
140
+ }
141
+ if (decoded.toString("base64url") !== unpadded) return false;
142
+ if (decoded.length < 73 || decoded[0] !== 0x80) return false;
143
+
144
+ const ciphertextLength = decoded.length - 57;
145
+ return ciphertextLength >= 16 && ciphertextLength % 16 === 0;
146
+ }
147
+
148
+ /** Maximal, boundary-delimited and structurally valid Fernet runs embedded in a slot. */
149
+ function fernetTokenRuns(payload: string): FernetTokenRun[] {
150
+ const runs: FernetTokenRun[] = [];
151
+ for (const match of payload.matchAll(FERNET_TOKEN_CANDIDATE)) {
152
+ const index = match.index ?? 0;
153
+ const token = match[0];
154
+ const before = index > 0 ? payload[index - 1] : undefined;
155
+ const after = payload[index + token.length];
156
+ if (before && FERNET_TOKEN_BOUNDARY_CHAR.test(before)) continue;
157
+ if (after && FERNET_TOKEN_BOUNDARY_CHAR.test(after)) continue;
158
+ if (!isStructurallyValidFernetToken(token)) continue;
159
+ runs.push({ index, token });
160
+ }
161
+ return runs;
162
+ }
163
+
164
+ function textWithoutFernetRuns(payload: string, runs: readonly FernetTokenRun[]): string {
165
+ let last = 0;
166
+ let text = "";
167
+ for (const run of runs) {
168
+ text += `${payload.slice(last, run.index)}\n\n`;
169
+ last = run.index + run.token.length;
170
+ }
171
+ return `${text}${payload.slice(last)}`;
172
+ }
108
173
 
109
174
  export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*NEW_TASK[^\n]*\nTask name\s*:[^\n]*\nSender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)/gi;
110
175
 
111
- export const AGENT_MESSAGE_CONTROL_PREAMBLE = /(?:^|\n)\[CXC-(?:LEAF-GUARD|SKILL-AFFORDANCE)\][\s\S]*?(?=\n{2,}|$)/g;
176
+ // CXC is the compatibility-hook control namespace. Strip only the tagged paragraph:
177
+ // later untagged paragraphs may be genuine task text. Repeated CXC paragraphs are
178
+ // removed independently, and a following routing envelope remains available to the
179
+ // envelope stripper below.
180
+ export const AGENT_MESSAGE_CONTROL_PREAMBLE = /(?:^|\n)\[CXC-[A-Z0-9-]+\][^\n]*(?:\n(?!\n|Message Type\s*:)[^\n]*)*(?=\n{2,}|\nMessage Type\s*:|$)/gi;
112
181
 
113
182
  export function hasUnreadableEncryptedAgentTask(input: unknown): boolean {
114
183
  if (!Array.isArray(input)) return false;
115
184
 
116
- return input.some(item => {
117
- if (!item || typeof item !== "object" || (item as { type?: unknown }).type !== "agent_message") {
118
- return false;
119
- }
185
+ // codex-rs appends one NEW_TASK agent_message at the current input tail. Historical
186
+ // agent messages may be adjacent in full-history bodies; they must not poison the
187
+ // later task. compaction_trigger/additional_tools are trailing metadata rather than
188
+ // a newer user turn.
189
+ let index = input.length - 1;
190
+ while (index >= 0) {
191
+ const item = input[index];
192
+ const type = item && typeof item === "object" ? (item as { type?: unknown }).type : undefined;
193
+ if (type !== "compaction_trigger" && type !== "additional_tools") break;
194
+ index -= 1;
195
+ }
196
+ const item = input[index];
197
+ if (!item || typeof item !== "object" || (item as { type?: unknown }).type !== "agent_message") {
198
+ return false;
199
+ }
120
200
 
121
- const content = (item as { content?: unknown }).content;
122
- if (!Array.isArray(content)) return false;
123
-
124
- let hasFernetTask = false;
125
- const readableParts: string[] = [];
126
- for (const part of content) {
127
- if (!part || typeof part !== "object") continue;
128
- const record = part as { type?: unknown; text?: unknown; encrypted_content?: unknown };
129
- if (
130
- (record.type === "input_text" || record.type === "text" || record.type === "output_text")
131
- && typeof record.text === "string"
132
- ) {
133
- readableParts.push(record.text);
134
- continue;
135
- }
136
- if (record.type !== "encrypted_content" || typeof record.encrypted_content !== "string") {
137
- continue;
138
- }
201
+ const content = (item as { content?: unknown }).content;
202
+ if (!Array.isArray(content)) return false;
139
203
 
140
- const withoutFernet = record.encrypted_content.replace(FERNET_TOKEN_RUN, "\n\n");
141
- if (withoutFernet !== record.encrypted_content) hasFernetTask = true;
142
- readableParts.push(withoutFernet);
204
+ let hasFernetTask = false;
205
+ const readableParts: string[] = [];
206
+ for (const part of content) {
207
+ if (!part || typeof part !== "object") continue;
208
+ const record = part as { type?: unknown; text?: unknown; encrypted_content?: unknown };
209
+ if (
210
+ (record.type === "input_text" || record.type === "text")
211
+ && typeof record.text === "string"
212
+ ) {
213
+ readableParts.push(record.text);
214
+ continue;
215
+ }
216
+ if (record.type !== "encrypted_content" || typeof record.encrypted_content !== "string") {
217
+ continue;
143
218
  }
144
219
 
145
- if (!hasFernetTask) return false;
146
- const readableTask = readableParts
147
- .join("\n\n")
148
- .replace(AGENT_MESSAGE_ROUTING_ENVELOPE, "\n")
149
- .replace(AGENT_MESSAGE_CONTROL_PREAMBLE, "\n")
150
- .trim();
151
- return readableTask.length === 0;
152
- });
220
+ const runs = fernetTokenRuns(record.encrypted_content);
221
+ if (runs.length > 0) hasFernetTask = true;
222
+ readableParts.push(textWithoutFernetRuns(record.encrypted_content, runs));
223
+ }
224
+
225
+ if (!hasFernetTask) return false;
226
+ const readableTask = readableParts
227
+ .join("\n\n")
228
+ .replace(AGENT_MESSAGE_CONTROL_PREAMBLE, "\n")
229
+ .replace(AGENT_MESSAGE_ROUTING_ENVELOPE, "\n")
230
+ .trim();
231
+ return readableTask.length === 0;
153
232
  }
154
233
 
155
234
 
@@ -157,12 +236,11 @@ export function hasUnreadableEncryptedAgentTask(input: unknown): boolean {
157
236
  export function encryptedSlotParts(payload: string): Array<Record<string, string>> {
158
237
  const parts: Array<Record<string, string>> = [];
159
238
  let last = 0;
160
- for (const match of payload.matchAll(FERNET_TOKEN_RUN)) {
161
- const index = match.index ?? 0;
162
- const before = payload.slice(last, index);
239
+ for (const run of fernetTokenRuns(payload)) {
240
+ const before = payload.slice(last, run.index);
163
241
  if (before.trim().length > 0) parts.push({ type: "input_text", text: before });
164
- parts.push({ type: "encrypted_content", encrypted_content: match[0] });
165
- last = index + match[0].length;
242
+ parts.push({ type: "encrypted_content", encrypted_content: run.token });
243
+ last = run.index + run.token.length;
166
244
  }
167
245
  const rest = payload.slice(last);
168
246
  if (rest.trim().length > 0) parts.push({ type: "input_text", text: rest });
@@ -228,4 +306,3 @@ export function sanitizeEncryptedContentInPlace(input: unknown): number {
228
306
  return rewritten;
229
307
  }
230
308
 
231
-
@@ -22,6 +22,13 @@ export interface WsData {
22
22
  authContext?: CodexAuthContext; // last resolved account decision for observability/registry cleanup
23
23
  cancel?: () => void; // cancels the in-flight stream reader/fetch
24
24
  turnId?: number; // monotonically increasing per socket; prevents stale frames after replacement turns
25
+ /** Discriminator: Responses reframing vs transparent live/realtime sideband relay. */
26
+ kind?: "responses" | "live-sideband";
27
+ liveUpstream?: WebSocket;
28
+ liveUpstreamUrl?: string;
29
+ liveUpstreamHeaders?: Record<string, string>;
30
+ livePending?: Array<string | Buffer>;
31
+ liveOpened?: boolean;
25
32
  }
26
33
 
27
34
  export class WsSendDroppedError extends Error {
package/src/types.ts CHANGED
@@ -11,6 +11,18 @@ export interface OcxParsedRequest {
11
11
  _previousResponseInputExpanded?: boolean;
12
12
  /** Provider-private stable Cursor conversation id resolved from the Responses previous_response_id chain. */
13
13
  _cursorConversationId?: string;
14
+ /** Stable upstream client thread identity, used only to derive provider-scoped continuation ids. */
15
+ _clientThreadId?: string;
16
+ /**
17
+ * Optional authenticated tenant/operator namespace for Cursor thread→conversation derivation.
18
+ * When absent (single-operator local proxy), derivation stays local-scoped.
19
+ */
20
+ _cursorIdentityScope?: string;
21
+ /**
22
+ * True for helper/shadow/compaction turns that must not append into the main Cursor conversation
23
+ * derived from the parent thread id.
24
+ */
25
+ _cursorIsolateConversation?: boolean;
14
26
  /** Provider-private continuation metadata resolved from the Responses previous_response_id chain. */
15
27
  _providerContinuation?: OcxProviderContinuationState;
16
28
  /**
@@ -540,6 +552,8 @@ export interface OcxConfig {
540
552
  apiKeys?: Array<{ id: string; name: string; key: string; createdAt: string }>;
541
553
  /** Auto-start/sync the proxy from the Codex shim before launching Codex. Default true. */
542
554
  codexAutoStart?: boolean;
555
+ /** Restore an installed shim after a stable external Codex update replaces it. Default true. */
556
+ codexShimAutoRestore?: boolean;
543
557
  /**
544
558
  * Compatibility mode: temporarily rewrite Codex resume-history metadata while the proxy is active
545
559
  * so Codex App can show old OpenAI chats and opencodex-created exec chats under its default