@bitkyc08/opencodex 2.6.26-preview.20260705 → 2.6.28-preview.20260707

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 (118) hide show
  1. package/README.md +1 -0
  2. package/bin/ocx.mjs +4 -4
  3. package/gui/dist/assets/index-ByGC8-Bm.css +1 -0
  4. package/gui/dist/assets/index-CkV5xFA8.js +15 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -4
  7. package/src/adapters/anthropic-image-guard.ts +195 -0
  8. package/src/adapters/anthropic.ts +85 -14
  9. package/src/adapters/cursor/cursor-errors.ts +2 -2
  10. package/src/adapters/cursor/live-transport.ts +1 -1
  11. package/src/adapters/cursor/transport-retry.ts +2 -2
  12. package/src/adapters/google-errors.ts +1 -1
  13. package/src/adapters/google-http.ts +1 -1
  14. package/src/adapters/google-truncation.ts +1 -1
  15. package/src/adapters/google.ts +1 -1
  16. package/src/adapters/kiro-errors.ts +1 -1
  17. package/src/adapters/kiro-retry.ts +1 -1
  18. package/src/adapters/kiro-truncation.ts +1 -1
  19. package/src/adapters/kiro.ts +1 -1
  20. package/src/adapters/openai-chat.ts +12 -2
  21. package/src/adapters/openai-responses.ts +126 -3
  22. package/src/bridge.ts +164 -7
  23. package/src/{doctor.ts → cli/doctor.ts} +21 -4
  24. package/src/{cli-help.ts → cli/help.ts} +1 -1
  25. package/src/cli/index.ts +584 -0
  26. package/src/{init.ts → cli/init.ts} +6 -6
  27. package/src/{cli-models.ts → cli/models.ts} +2 -2
  28. package/src/{cli-provider.ts → cli/provider.ts} +12 -8
  29. package/src/{star-prompt.ts → cli/star-prompt.ts} +1 -1
  30. package/src/{cli-status.ts → cli/status.ts} +7 -7
  31. package/src/cli.ts +9 -575
  32. package/src/{codex-account-label.ts → codex/account-label.ts} +1 -1
  33. package/src/{codex-account-lifecycle.ts → codex/account-lifecycle.ts} +6 -6
  34. package/src/{codex-account-store.ts → codex/account-store.ts} +2 -2
  35. package/src/{codex-account-usability.ts → codex/account-usability.ts} +4 -4
  36. package/src/{codex-auth-api.ts → codex/auth-api.ts} +18 -18
  37. package/src/{codex-auth-collision.ts → codex/auth-collision.ts} +4 -4
  38. package/src/{codex-auth-context.ts → codex/auth-context.ts} +9 -9
  39. package/src/{codex-catalog.ts → codex/catalog.ts} +49 -20
  40. package/src/codex/history-migration-guardian.ts +102 -0
  41. package/src/{codex-history-provider.ts → codex/history-provider.ts} +111 -7
  42. package/src/{codex-home.ts → codex/home.ts} +1 -1
  43. package/src/{codex-inject.ts → codex/inject.ts} +204 -26
  44. package/src/{codex-journal.ts → codex/journal.ts} +2 -2
  45. package/src/{codex-main-account.ts → codex/main-account.ts} +2 -2
  46. package/src/{model-cache.ts → codex/model-cache.ts} +1 -1
  47. package/src/{codex-paths.ts → codex/paths.ts} +2 -2
  48. package/src/{codex-plugins-doctor.ts → codex/plugins-doctor.ts} +2 -2
  49. package/src/{codex-refresh.ts → codex/refresh.ts} +4 -4
  50. package/src/{codex-routing.ts → codex/routing.ts} +8 -8
  51. package/src/{codex-shim.ts → codex/shim.ts} +6 -5
  52. package/src/{codex-sync.ts → codex/sync.ts} +4 -4
  53. package/src/{codex-websocket-registry.ts → codex/websocket-registry.ts} +1 -1
  54. package/src/config.ts +2 -0
  55. package/src/generated/jawcode-model-metadata.ts +2 -0
  56. package/src/{bun-runtime.ts → lib/bun-runtime.ts} +1 -1
  57. package/src/{crash-guard.ts → lib/crash-guard.ts} +1 -1
  58. package/src/{process-control.ts → lib/process-control.ts} +1 -1
  59. package/src/{service-secrets.ts → lib/service-secrets.ts} +1 -1
  60. package/src/oauth/callback-server.ts +1 -1
  61. package/src/oauth/google-antigravity.ts +7 -4
  62. package/src/oauth/index.ts +67 -16
  63. package/src/oauth/login-cli.ts +2 -2
  64. package/src/oauth/store.ts +236 -20
  65. package/src/oauth/token-guardian.ts +24 -20
  66. package/src/oauth/types.ts +16 -0
  67. package/src/providers/api-keys.ts +121 -0
  68. package/src/{provider-context-cap.ts → providers/context-cap.ts} +1 -1
  69. package/src/providers/derive.ts +2 -0
  70. package/src/providers/key-failover.ts +145 -0
  71. package/src/{provider-label.ts → providers/label.ts} +1 -1
  72. package/src/{provider-quota.ts → providers/quota.ts} +12 -7
  73. package/src/providers/registry.ts +66 -4
  74. package/src/responses/compaction.ts +117 -0
  75. package/src/responses/parser.ts +89 -13
  76. package/src/responses/reasoning-envelope.ts +52 -0
  77. package/src/responses/schema.ts +15 -3
  78. package/src/responses/state.ts +117 -2
  79. package/src/router.ts +2 -0
  80. package/src/server/auth-cors.ts +231 -0
  81. package/src/server/index.ts +523 -0
  82. package/src/server/lifecycle.ts +73 -0
  83. package/src/server/management-api.ts +628 -0
  84. package/src/{proxy-liveness.ts → server/proxy-liveness.ts} +1 -1
  85. package/src/server/relay.ts +534 -0
  86. package/src/server/request-decompress.ts +46 -0
  87. package/src/server/request-log.ts +310 -0
  88. package/src/server/responses.ts +775 -0
  89. package/src/{ws-bridge.ts → server/ws-bridge.ts} +44 -13
  90. package/src/service.ts +19 -11
  91. package/src/types.ts +27 -0
  92. package/src/{update.ts → update/index.ts} +5 -5
  93. package/src/{update-job.ts → update/job.ts} +7 -6
  94. package/src/{update-notify.ts → update/notify.ts} +3 -3
  95. package/src/{usage-debug.ts → usage/debug.ts} +3 -3
  96. package/src/{usage-log.ts → usage/log.ts} +3 -3
  97. package/src/{usage-summary.ts → usage/summary.ts} +3 -3
  98. package/src/{usage-totals.ts → usage/totals.ts} +1 -1
  99. package/src/vision/describe.ts +3 -3
  100. package/src/vision/index.ts +21 -1
  101. package/src/web-search/executor.ts +4 -4
  102. package/src/web-search/index.ts +1 -1
  103. package/src/web-search/loop.ts +84 -24
  104. package/gui/dist/assets/index-BcHhxo1I.css +0 -1
  105. package/gui/dist/assets/index-DCC1q_Jx.js +0 -15
  106. package/src/server.ts +0 -2501
  107. /package/src/{codex-account-runtime-state.ts → codex/account-runtime-state.ts} +0 -0
  108. /package/src/{codex-quota.ts → codex/quota.ts} +0 -0
  109. /package/src/{abort.ts → lib/abort.ts} +0 -0
  110. /package/src/{debug.ts → lib/debug.ts} +0 -0
  111. /package/src/{errors.ts → lib/errors.ts} +0 -0
  112. /package/src/{open-url.ts → lib/open-url.ts} +0 -0
  113. /package/src/{privacy.ts → lib/privacy.ts} +0 -0
  114. /package/src/{redact.ts → lib/redact.ts} +0 -0
  115. /package/src/{sidecar-tracker.ts → lib/sidecar-tracker.ts} +0 -0
  116. /package/src/{upstream-retry.ts → lib/upstream-retry.ts} +0 -0
  117. /package/src/{win-paths.ts → lib/win-paths.ts} +0 -0
  118. /package/src/{ports.ts → server/ports.ts} +0 -0
@@ -1,5 +1,7 @@
1
1
  import type { IncomingMeta, ProviderAdapter } from "./base";
2
2
  import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../types";
3
+ import { decodeCompactionSummary, SUMMARY_PREFIX } from "../responses/compaction";
4
+ import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
3
5
 
4
6
  // Headers relayed verbatim from the caller in OAuth-passthrough ("forward") mode.
5
7
  // Exported so the web-search sidecar reuses the exact same forwarded-auth set for its ChatGPT call.
@@ -31,17 +33,50 @@ function sanitizeReasoningInputContent(body: unknown): unknown {
31
33
  const input = raw.input.map(item => {
32
34
  if (!item || typeof item !== "object" || Array.isArray(item)) return item;
33
35
  const rec = item as Record<string, unknown>;
34
- if (rec.type !== "reasoning" || !Array.isArray(rec.content) || rec.content.length === 0) return item;
36
+ if (rec.type !== "reasoning") return item;
37
+ const hasRawContent = Array.isArray(rec.content) && rec.content.length > 0;
38
+ // ocxr1 envelopes are proxy-minted (Anthropic signatures), not OpenAI encryption — the native
39
+ // backend cannot decrypt them and would reject the request. Strip regardless of content shape.
40
+ const hasOcxEnvelope = typeof rec.encrypted_content === "string" && rec.encrypted_content.startsWith(OCX_REASONING_PREFIX);
41
+ if (!hasRawContent && !hasOcxEnvelope) return item;
35
42
  changed = true;
36
43
  // Routed models can produce raw `reasoning_text` output items. Codex echoes those in later
37
44
  // native GPT requests, but ChatGPT's Responses backend accepts reasoning input only with empty
38
45
  // `content`; keep summaries/ids and drop the raw content so native passthrough does not 400.
39
- return { ...rec, content: [] };
46
+ const next: Record<string, unknown> = { ...rec, content: [] };
47
+ if (hasOcxEnvelope) delete next.encrypted_content;
48
+ return next;
40
49
  });
41
50
 
42
51
  return changed ? { ...raw, input } : body;
43
52
  }
44
53
 
54
+ /**
55
+ * Replace proxy-minted compaction items (`encrypted_content` starting with `ocx1:`) with plain
56
+ * user messages before forwarding to the ChatGPT backend. Our envelope is transparent base64, not
57
+ * OpenAI encryption — the native backend cannot decrypt it and would reject the request. Real
58
+ * OpenAI-encrypted compaction items are forwarded untouched.
59
+ */
60
+ function scrubOcxCompactionItems(body: unknown): unknown {
61
+ if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
62
+
63
+ let changed = false;
64
+ const input = body.input.map(item => {
65
+ if (!isPlainObject(item)) return item;
66
+ if (item.type !== "compaction" && item.type !== "compaction_summary" && item.type !== "context_compaction") return item;
67
+ const decoded = typeof item.encrypted_content === "string" ? decodeCompactionSummary(item.encrypted_content) : null;
68
+ if (decoded === null) return item;
69
+ changed = true;
70
+ return {
71
+ type: "message",
72
+ role: "user",
73
+ content: [{ type: "input_text", text: `${SUMMARY_PREFIX}\n\n${decoded}` }],
74
+ };
75
+ });
76
+
77
+ return changed ? { ...body, input } : body;
78
+ }
79
+
45
80
  /**
46
81
  * Hosted (OpenAI-executed) tool types that specific native slugs reject at request time. Codex
47
82
  * attaches these for app skills (e.g. `image_generation` for imagegen) regardless of the target
@@ -58,6 +93,87 @@ function isPlainObject(v: unknown): v is Record<string, unknown> {
58
93
  return !!v && typeof v === "object" && !Array.isArray(v);
59
94
  }
60
95
 
96
+ /** Flatten a Responses tool-output `output` value (string or content-part array) to plain text. */
97
+ function toolOutputText(output: unknown): string {
98
+ if (typeof output === "string") return output;
99
+ if (!Array.isArray(output)) return JSON.stringify(output ?? "");
100
+ return output.map(part => {
101
+ if (!isPlainObject(part)) return "";
102
+ if (typeof part.text === "string") return part.text;
103
+ if (part.type === "refusal" && typeof part.refusal === "string") return `[refusal] ${part.refusal}`;
104
+ return "";
105
+ }).filter(Boolean).join("\n");
106
+ }
107
+
108
+ /**
109
+ * Repair a forward-mode input array whose continuation context was lost. When the replay
110
+ * expansion misses (proxy restart, unrecorded prior turn), previous_response_id is stripped
111
+ * (the ChatGPT backend rejects it), so the delta may carry items that reference now-absent
112
+ * prior items and 400 upstream:
113
+ * - `function_call_output`/`custom_tool_call_output` without their paired call item
114
+ * ("No tool call found for function call output with call_id ..."). Converted to user
115
+ * messages so the result text survives. `function_call_output` also pairs with
116
+ * `local_shell_call` (codex-rs emits shell outputs as function_call_output).
117
+ * - `reasoning` items ("Item 'rs_*' ... was provided without its required following item").
118
+ * Dropped, but only when `dropReasoning` (unexpanded miss): on a replay hit the prior
119
+ * reasoning chain is intact and must be preserved.
120
+ * Runs on every forward request; with intact pairs it returns the original reference.
121
+ */
122
+ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean): unknown {
123
+ if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
124
+ const input = body.input;
125
+
126
+ const functionCallIds = new Set<string>();
127
+ const customCallIds = new Set<string>();
128
+ for (const item of input) {
129
+ if (!isPlainObject(item) || typeof item.call_id !== "string") continue;
130
+ if (item.type === "function_call" || item.type === "local_shell_call") functionCallIds.add(item.call_id);
131
+ else if (item.type === "custom_tool_call") customCallIds.add(item.call_id);
132
+ }
133
+
134
+ let changed = false;
135
+ const repaired: unknown[] = [];
136
+ for (const item of input) {
137
+ if (!isPlainObject(item)) { repaired.push(item); continue; }
138
+ if (dropReasoning && item.type === "reasoning") { changed = true; continue; }
139
+ const isFnOutput = item.type === "function_call_output";
140
+ const isCustomOutput = item.type === "custom_tool_call_output";
141
+ if (isFnOutput || isCustomOutput) {
142
+ const callId = typeof item.call_id === "string" ? item.call_id : "";
143
+ const paired = isFnOutput ? functionCallIds.has(callId) : customCallIds.has(callId);
144
+ if (!paired) {
145
+ changed = true;
146
+ repaired.push({
147
+ type: "message",
148
+ role: "user",
149
+ content: [{ type: "input_text", text: `[tool output for ${callId || "unknown call"}]\n${toolOutputText(item.output)}` }],
150
+ });
151
+ continue;
152
+ }
153
+ }
154
+ repaired.push(item);
155
+ }
156
+
157
+ return changed ? { ...body, input: repaired } : body;
158
+ }
159
+
160
+ /**
161
+ * Remove `previous_response_id` before forwarding. Two triggers:
162
+ * - the proxy expanded the request into a full input replay (the id is now redundant), or
163
+ * - the target is the ChatGPT backend (`authMode: "forward"`), whose Codex REST endpoint
164
+ * categorically rejects the parameter with `{"detail":"Unsupported parameter:
165
+ * previous_response_id"}` (strict allowlist; it also rejects `metadata` and
166
+ * `max_output_tokens`). Codex only sends the id on WS turns, and ocx converts those to
167
+ * internal HTTP requests, so forwarding it upstream is a guaranteed 400 — stripping is
168
+ * strictly better even when the local replay state missed. API-key mode keeps the field on
169
+ * unexpanded requests: the platform `/v1/responses` supports real server-side storage.
170
+ */
171
+ function stripPreviousResponseId(body: unknown, strip: boolean): unknown {
172
+ if (!strip || !isPlainObject(body) || !Object.prototype.hasOwnProperty.call(body, "previous_response_id")) return body;
173
+ const { previous_response_id: _previousResponseId, ...rest } = body;
174
+ return rest;
175
+ }
176
+
61
177
  /**
62
178
  * Remove hosted tool entries the target native slug rejects, so the OAuth-passthrough body never
63
179
  * carries a tool the upstream model 400s on. No-op (returns the original reference) when nothing
@@ -113,11 +229,18 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
113
229
  if (provider.headers) Object.assign(headers, provider.headers);
114
230
  }
115
231
 
232
+ const forward = provider.authMode === "forward";
233
+ const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true;
234
+ let outBody = stripPreviousResponseId(
235
+ parsed._rawBody,
236
+ forward || parsed._previousResponseInputExpanded === true,
237
+ );
238
+ if (forward) outBody = repairOrphanedInputItems(outBody, unexpandedMiss);
116
239
  return {
117
240
  url,
118
241
  method: "POST",
119
242
  headers,
120
- body: JSON.stringify(stripUnsupportedHostedTools(sanitizeReasoningInputContent(parsed._rawBody))),
243
+ body: JSON.stringify(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody)))),
121
244
  };
122
245
  },
123
246
 
package/src/bridge.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { AdapterEvent, OcxUsage } from "./types";
2
- import { classifyError, type OcxErrorPayload } from "./errors";
3
- import { usageDisplayTotalTokens, usageInputTokensWithCacheDetail } from "./usage-totals";
2
+ import { classifyError, type OcxErrorPayload } from "./lib/errors";
3
+ import { encodeCompactionSummary } from "./responses/compaction";
4
+ import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope";
5
+ import { usageDisplayTotalTokens, usageInputTokensWithCacheDetail } from "./usage/totals";
4
6
 
5
7
  function uuid(): string {
6
8
  return crypto.randomUUID().replace(/-/g, "");
@@ -62,6 +64,12 @@ export function bridgeToResponsesSSE(
62
64
  responseId?: string;
63
65
  stallTimeoutSec?: number;
64
66
  hideThinkingSummary?: boolean;
67
+ /**
68
+ * Remote compaction v2 turn: accumulate all assistant text and, on done, emit ONE synthetic
69
+ * `{type:"compaction", encrypted_content:"ocx1:"+base64(text)}` output item before
70
+ * response.completed — codex-rs collect_compaction_output requires exactly one.
71
+ */
72
+ compaction?: boolean;
65
73
  onTerminal?: (status: ResponsesTerminalStatus) => void;
66
74
  onCompletedResponse?: (response: Record<string, unknown>) => void;
67
75
  },
@@ -72,6 +80,34 @@ export function bridgeToResponsesSSE(
72
80
  try { const o = JSON.parse(args); if (o && typeof o.input === "string") return o.input; } catch { /* raw */ }
73
81
  return args;
74
82
  };
83
+ // Best-effort unwrap of a PARTIAL freeform arg buffer for live input streaming
84
+ // (`response.custom_tool_call_input.delta` — codex-rs uses it for UI preview only;
85
+ // the completed custom_tool_call item stays authoritative). Compact `{"input":"...`
86
+ // buffers get their string value progressively unescaped; anything else streams raw.
87
+ const FREEFORM_WRAP_PREFIX = '{"input":"';
88
+ const freeformPartialInput = (args: string): string => {
89
+ if (!args.startsWith(FREEFORM_WRAP_PREFIX)) return args;
90
+ const body = args.slice(FREEFORM_WRAP_PREFIX.length);
91
+ let out = "";
92
+ for (let i = 0; i < body.length; i++) {
93
+ const c = body[i];
94
+ if (c === '"') break; // unescaped closing quote: value complete
95
+ if (c === "\\") {
96
+ const n = body[i + 1];
97
+ if (n === undefined) break; // escape split across chunks: wait for more
98
+ i++;
99
+ if (n === "n") out += "\n";
100
+ else if (n === "t") out += "\t";
101
+ else if (n === "r") out += "\r";
102
+ else if (n === "u") {
103
+ const hex = body.slice(i + 1, i + 5);
104
+ if (hex.length === 4 && /^[0-9a-fA-F]{4}$/.test(hex)) { out += String.fromCharCode(parseInt(hex, 16)); i += 4; }
105
+ else break; // incomplete \uXXXX: wait for more
106
+ } else out += n; // \" \\ \/ etc.
107
+ } else out += c;
108
+ }
109
+ return out;
110
+ };
75
111
  // tool_search_call carries arguments as a JSON object ({query, limit}); parse the model's arg string.
76
112
  const parseArgsObj = (args: string): Record<string, unknown> => {
77
113
  try { const o = JSON.parse(args); return o && typeof o === "object" ? o : {}; } catch { return {}; }
@@ -163,7 +199,40 @@ export function bridgeToResponsesSSE(
163
199
  let currentMsg: { itemId: string; outputIndex: number; text: string } | null = null;
164
200
  let currentReasoning: { itemId: string; outputIndex: number; text: string } | null = null;
165
201
  let currentRawReasoning: { itemId: string; outputIndex: number; text: string } | null = null;
166
- let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; namespace?: string; freeform?: boolean; toolSearch?: boolean } | null = null;
202
+ // Anthropic extended-thinking round-trip state: the signature signs the CURRENT thinking
203
+ // block; redacted blocks are opaque payloads replayed verbatim. Attached to the reasoning
204
+ // item as an ocxr1 encrypted_content envelope on close. hiddenThinkingText collects the
205
+ // suppressed text under hideThinkingSummary so the signed text still round-trips.
206
+ let pendingSignature: string | undefined;
207
+ let pendingRedacted: string[] = [];
208
+ let hiddenThinkingText = "";
209
+ const takeReasoningEnvelope = (hiddenText?: string): string | undefined => {
210
+ if (!pendingSignature && pendingRedacted.length === 0) return undefined;
211
+ const envelope: ReasoningEnvelope = {};
212
+ if (pendingSignature) envelope.sig = pendingSignature;
213
+ if (pendingRedacted.length > 0) envelope.red = pendingRedacted;
214
+ if (hiddenText) envelope.txt = hiddenText;
215
+ pendingSignature = undefined;
216
+ pendingRedacted = [];
217
+ return encodeReasoningEnvelope(envelope);
218
+ };
219
+ // hideThinkingSummary path: no visible reasoning item exists, but a signed thinking block
220
+ // must still round-trip — emit an envelope-only reasoning item (empty summary, no text leak).
221
+ const flushHiddenReasoningEnvelope = () => {
222
+ const encrypted = takeReasoningEnvelope(hiddenThinkingText || undefined);
223
+ hiddenThinkingText = "";
224
+ if (!encrypted) return;
225
+ const itemId = `rs_${uuid()}`;
226
+ const item = { type: "reasoning", id: itemId, summary: [] as never[], encrypted_content: encrypted };
227
+ emit("response.output_item.added", { output_index: outputIndex, item });
228
+ emit("response.output_item.done", { output_index: outputIndex, item });
229
+ finishedItems.push(item as OutputItem);
230
+ outputIndex++;
231
+ };
232
+ // Full assistant text of a compaction turn (across message boundaries) — becomes the
233
+ // synthetic compaction item's payload on done.
234
+ let compactionText = "";
235
+ let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string } | null = null;
167
236
  // Open native web-search cell (between begin and end). Holds the output index allocated on
168
237
  // begin so the matching done reuses it; closed as `failed` if the stream terminates early.
169
238
  let currentWebSearch: { itemId: string; outputIndex: number } | null = null;
@@ -212,9 +281,11 @@ export function bridgeToResponsesSSE(
212
281
  item_id: currentReasoning.itemId, output_index: currentReasoning.outputIndex, summary_index: 0,
213
282
  part: { type: "summary_text", text: currentReasoning.text },
214
283
  });
284
+ const encrypted = takeReasoningEnvelope();
215
285
  const item = {
216
286
  type: "reasoning", id: currentReasoning.itemId,
217
287
  summary: [{ type: "summary_text", text: currentReasoning.text }],
288
+ ...(encrypted ? { encrypted_content: encrypted } : {}),
218
289
  };
219
290
  emit("response.output_item.done", { output_index: currentReasoning.outputIndex, item });
220
291
  finishedItems.push(item as OutputItem);
@@ -246,6 +317,12 @@ export function bridgeToResponsesSSE(
246
317
  item_id: currentToolCall.itemId, output_index: currentToolCall.outputIndex, arguments: argsStr,
247
318
  });
248
319
  }
320
+ if (currentToolCall.freeform) {
321
+ emit("response.custom_tool_call_input.done", {
322
+ item_id: currentToolCall.itemId, output_index: currentToolCall.outputIndex,
323
+ input: freeformInput(currentToolCall.args),
324
+ });
325
+ }
249
326
  const item = currentToolCall.toolSearch
250
327
  ? {
251
328
  type: "tool_search_call", id: currentToolCall.itemId,
@@ -295,6 +372,15 @@ export function bridgeToResponsesSSE(
295
372
  for await (const event of events) {
296
373
  activity = true;
297
374
  stallTicks = 0;
375
+ // Compaction turns emit ONLY the synthetic compaction item + response.completed. The
376
+ // summary text is accumulated silently: emitting it as a normal assistant message would
377
+ // duplicate the summary if this response is ever replayed via previous_response_id
378
+ // expansion (rememberResponseState stores input + output). Codex ignores extra items but
379
+ // its compaction UI renders nothing mid-turn, so nothing is lost visually.
380
+ if (options?.compaction) {
381
+ if (event.type === "text_delta") { compactionText += event.text; continue; }
382
+ if (event.type !== "done" && event.type !== "error") continue;
383
+ }
298
384
  switch (event.type) {
299
385
  case "text_delta": {
300
386
  if (currentReasoning) closeCurrentReasoning();
@@ -321,7 +407,7 @@ export function bridgeToResponsesSSE(
321
407
  break;
322
408
  }
323
409
  case "thinking_delta": {
324
- if (options?.hideThinkingSummary) break;
410
+ if (options?.hideThinkingSummary) { hiddenThinkingText += event.thinking; break; }
325
411
  if (currentMsg) closeCurrentMessage();
326
412
  if (currentRawReasoning) closeCurrentRawReasoning();
327
413
  if (currentToolCall) closeCurrentToolCall();
@@ -342,6 +428,18 @@ export function bridgeToResponsesSSE(
342
428
  });
343
429
  break;
344
430
  }
431
+ case "thinking_signature": {
432
+ pendingSignature = event.signature;
433
+ // Signature arrives at the end of the thinking block. With a visible reasoning item
434
+ // open, closeCurrentReasoning attaches the envelope; hidden/suppressed blocks flush
435
+ // an envelope-only reasoning item now.
436
+ if (!currentReasoning) flushHiddenReasoningEnvelope();
437
+ break;
438
+ }
439
+ case "redacted_thinking": {
440
+ pendingRedacted.push(event.data);
441
+ break;
442
+ }
345
443
  case "reasoning_raw_delta": {
346
444
  if (currentMsg) closeCurrentMessage();
347
445
  if (currentReasoning) closeCurrentReasoning();
@@ -388,6 +486,21 @@ export function bridgeToResponsesSSE(
388
486
  delta: event.arguments,
389
487
  });
390
488
  }
489
+ if (currentToolCall.freeform) {
490
+ // Hold while the buffer is still an ambiguous prefix of the JSON wrapper,
491
+ // then stream only the unwrapped input suffix (never rewind on mode flips).
492
+ if (!FREEFORM_WRAP_PREFIX.startsWith(currentToolCall.args)) {
493
+ const full = freeformPartialInput(currentToolCall.args);
494
+ const emitted = currentToolCall.inputEmitted ?? "";
495
+ if (full.startsWith(emitted) && full.length > emitted.length) {
496
+ emit("response.custom_tool_call_input.delta", {
497
+ item_id: currentToolCall.itemId, output_index: currentToolCall.outputIndex,
498
+ delta: full.slice(emitted.length),
499
+ });
500
+ currentToolCall.inputEmitted = full;
501
+ }
502
+ }
503
+ }
391
504
  }
392
505
  break;
393
506
  }
@@ -438,6 +551,19 @@ export function bridgeToResponsesSSE(
438
551
  if (currentRawReasoning) closeCurrentRawReasoning();
439
552
  if (currentToolCall) closeCurrentToolCall();
440
553
  if (currentWebSearch) closeCurrentWebSearch("completed", []);
554
+ // Redacted-only turns (or hidden thinking without a trailing signature event) still
555
+ // need their envelope-only reasoning item so the blocks replay next turn.
556
+ flushHiddenReasoningEnvelope();
557
+ if (options?.compaction) {
558
+ // Exactly one compaction item per turn; codex-rs takes the first and fatals on 0.
559
+ const item = {
560
+ type: "compaction", id: `cmp_${uuid()}`,
561
+ encrypted_content: encodeCompactionSummary(compactionText),
562
+ };
563
+ emit("response.output_item.done", { output_index: outputIndex, item });
564
+ finishedItems.push(item as OutputItem);
565
+ outputIndex++;
566
+ }
441
567
  const response = { ...responseSnapshot("completed", finishedItems), usage: responsesUsage(event.usage) };
442
568
  options?.onCompletedResponse?.(response);
443
569
  emit("response.completed", {
@@ -528,16 +654,22 @@ export function buildResponseJSON(
528
654
  toolNsMap?: Map<string, { namespace: string; name: string }>;
529
655
  freeformToolNames?: Set<string>;
530
656
  toolSearchToolNames?: Set<string>;
657
+ /** Remote compaction v2 turn — append one synthetic compaction output item (see bridgeToResponsesSSE). */
658
+ compaction?: boolean;
531
659
  },
532
660
  ): Record<string, unknown> {
533
661
  const responseId = `resp_${uuid()}`;
534
662
  const output: OutputItem[] = [];
535
663
  let usage: OcxUsage | undefined;
536
664
  let errorMessage: string | undefined;
665
+ let compactionText = "";
537
666
 
538
667
  let currentText = "";
539
668
  let currentSummaryReasoning = "";
540
669
  let currentRawReasoning = "";
670
+ // Anthropic extended-thinking round-trip (batch): see bridgeToResponsesSSE counterpart.
671
+ let batchSignature: string | undefined;
672
+ let batchRedacted: string[] = [];
541
673
  let currentToolCallId = "";
542
674
  let currentToolCallName = "";
543
675
  let currentToolCallArgs = "";
@@ -565,10 +697,20 @@ export function buildResponseJSON(
565
697
  currentText = "";
566
698
  };
567
699
  const flushSummaryReasoning = () => {
568
- if (!currentSummaryReasoning || options?.hideThinkingSummary) { currentSummaryReasoning = ""; return; }
700
+ if (!currentSummaryReasoning && !batchSignature && batchRedacted.length === 0) return;
701
+ const envelope: ReasoningEnvelope = {};
702
+ if (batchSignature) envelope.sig = batchSignature;
703
+ if (batchRedacted.length > 0) envelope.red = batchRedacted;
704
+ const hidden = options?.hideThinkingSummary === true;
705
+ if (hidden && currentSummaryReasoning && (envelope.sig || envelope.red)) envelope.txt = currentSummaryReasoning;
706
+ const encrypted = envelope.sig || envelope.red || envelope.txt ? encodeReasoningEnvelope(envelope) : undefined;
707
+ batchSignature = undefined;
708
+ batchRedacted = [];
709
+ if (hidden && !encrypted) { currentSummaryReasoning = ""; return; }
569
710
  output.push({
570
711
  type: "reasoning", id: `rs_${uuid()}`,
571
- summary: [{ type: "summary_text", text: currentSummaryReasoning }],
712
+ summary: !hidden && currentSummaryReasoning ? [{ type: "summary_text", text: currentSummaryReasoning }] : [],
713
+ ...(encrypted ? { encrypted_content: encrypted } : {}),
572
714
  });
573
715
  currentSummaryReasoning = "";
574
716
  };
@@ -618,7 +760,10 @@ export function buildResponseJSON(
618
760
  if (currentSummaryReasoning) flushSummaryReasoning();
619
761
  if (currentRawReasoning) flushRawReasoning();
620
762
  if (currentToolCallId) flushToolCall();
621
- currentText += e.text;
763
+ // Compaction turns keep the summary out of normal message output (replay dedup — see
764
+ // bridgeToResponsesSSE); it ships only inside the synthetic compaction item below.
765
+ if (options?.compaction) compactionText += e.text;
766
+ else currentText += e.text;
622
767
  break;
623
768
  case "thinking_delta":
624
769
  if (currentText) flushText();
@@ -626,6 +771,15 @@ export function buildResponseJSON(
626
771
  if (currentToolCallId) flushToolCall();
627
772
  currentSummaryReasoning += e.thinking;
628
773
  break;
774
+ case "thinking_signature":
775
+ // End of the current thinking block — flush it WITH the signature envelope so the
776
+ // block/signature pairing survives multi-block turns.
777
+ batchSignature = e.signature;
778
+ flushSummaryReasoning();
779
+ break;
780
+ case "redacted_thinking":
781
+ batchRedacted.push(e.data);
782
+ break;
629
783
  case "reasoning_raw_delta":
630
784
  if (currentText) flushText();
631
785
  if (currentSummaryReasoning) flushSummaryReasoning();
@@ -679,6 +833,9 @@ export function buildResponseJSON(
679
833
  flushSummaryReasoning();
680
834
  flushRawReasoning();
681
835
  flushToolCall();
836
+ if (options?.compaction && !errorMessage) {
837
+ output.push({ type: "compaction", id: `cmp_${uuid()}`, encrypted_content: encodeCompactionSummary(compactionText) });
838
+ }
682
839
 
683
840
  return {
684
841
  id: responseId, object: "response",
@@ -9,10 +9,11 @@
9
9
  */
10
10
  import { existsSync, readFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
- import { getConfigDir, getConfigPath, readConfigDiagnostics, readPid, resolveEnvValue } from "./config";
13
- import { readCodexTokens } from "./codex-auth-collision";
14
- import { resolveCodexHomeDir as resolveCodexHomeDirImpl } from "./codex-home";
15
- export { resolveCodexHomeDir } from "./codex-home";
12
+ import { getConfigDir, getConfigPath, readConfigDiagnostics, readPid, resolveEnvValue } from "../config";
13
+ import { readCodexTokens } from "../codex/auth-collision";
14
+ import { resolveCodexHomeDir as resolveCodexHomeDirImpl } from "../codex/home";
15
+ import { countPendingOpencodexHistory } from "../codex/history-provider";
16
+ export { resolveCodexHomeDir } from "../codex/home";
16
17
 
17
18
  const WHAM_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
18
19
  const PROBE_TIMEOUT_MS = 8000;
@@ -293,6 +294,19 @@ export async function runDoctor(): Promise<void> {
293
294
  console.log(` ${probe.ok ? "ok " : "-- "} ${WHAM_USAGE_URL}`);
294
295
  console.log(` ${detail}, ${probe.durationMs}ms, ${probe.authenticated ? "authenticated" : "unauthenticated"}`);
295
296
 
297
+ // Design B upgrade visibility: threads still tagged opencodex are invisible to the native
298
+ // Codex app until the one-time migration lands. Read-only probe (readonly sqlite, 100ms
299
+ // busy timeout) — reports state, never mutates.
300
+ console.log("\nCodex history migration");
301
+ const pending = countPendingOpencodexHistory();
302
+ if (pending.failed) {
303
+ console.log(" -- state DB locked or unreadable (Codex app open?) — migration state unknown");
304
+ } else if (pending.pendingRows === 0 && pending.backupEntries === 0) {
305
+ console.log(" ok no legacy opencodex-tagged threads pending");
306
+ } else {
307
+ console.log(` -- ${pending.pendingRows} thread(s) still tagged opencodex, ${pending.backupEntries} backup manifest entr${pending.backupEntries === 1 ? "y" : "ies"}`);
308
+ }
309
+
296
310
  // Hints, not fixes.
297
311
  const hints: string[] = [];
298
312
  const anyDrvfs = paths.some(p => detectFsType(p.path, mounts).isDrvfs || detectFsType(p.path, mounts).isMntDrive);
@@ -308,6 +322,9 @@ export async function runDoctor(): Promise<void> {
308
322
  }
309
323
  }
310
324
  }
325
+ if (pending.failed || pending.pendingRows > 0 || pending.backupEntries > 0) {
326
+ hints.push("Legacy chat threads are still tagged opencodex (or the DB was locked). The running proxy retries the migration automatically; to force it now, close the Codex app and run 'ocx sync'.");
327
+ }
311
328
  if (hints.length > 0) {
312
329
  console.log("\nHints");
313
330
  for (const h of hints) console.log(` - ${h}`);
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
- const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url)));
5
+ const repoRoot = dirname(fileURLToPath(new URL("../../package.json", import.meta.url)));
6
6
 
7
7
  type HelpEntry = {
8
8
  usage: string;