@bitkyc08/opencodex 2.60.0 → 2.61.0-preview.20260922

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 (249) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-E64Rzjap.js +50 -0
  5. package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
  6. package/gui/dist/assets/index-DpdfZWMK.js +86 -0
  7. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  8. package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +5 -1
  13. package/src/adapters/anthropic.ts +16 -0
  14. package/src/adapters/coding-agent/protocol.ts +36 -6
  15. package/src/adapters/coding-agent/turn.ts +10 -2
  16. package/src/adapters/command-code.ts +2 -1
  17. package/src/adapters/cursor/catalog.ts +51 -7
  18. package/src/adapters/cursor/protobuf-request.ts +6 -3
  19. package/src/adapters/cursor/request-builder.ts +13 -3
  20. package/src/adapters/cursor.ts +11 -2
  21. package/src/adapters/declaration-carrier.ts +45 -0
  22. package/src/adapters/devin.ts +75 -23
  23. package/src/adapters/google-antigravity-wire.ts +5 -2
  24. package/src/adapters/google-errors.ts +7 -1
  25. package/src/adapters/google.ts +29 -5
  26. package/src/adapters/image.ts +4 -1
  27. package/src/adapters/input-media-guard.ts +21 -9
  28. package/src/adapters/kiro/usage.ts +3 -2
  29. package/src/adapters/kiro-tool-fallback.ts +1 -1
  30. package/src/adapters/ollama-native.ts +6 -0
  31. package/src/adapters/openai-chat/developer-role.ts +61 -0
  32. package/src/adapters/openai-chat/messages.ts +46 -27
  33. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  34. package/src/adapters/openai-chat/passthrough.ts +33 -9
  35. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  36. package/src/adapters/openai-chat.ts +18 -57
  37. package/src/adapters/openai-responses/passthrough.ts +2 -0
  38. package/src/adapters/registry.ts +3 -2
  39. package/src/adapters/run-turn-queue.ts +178 -29
  40. package/src/adapters/xai-web-search.ts +16 -1
  41. package/src/bridge/errors.ts +8 -2
  42. package/src/bridge/response-json.ts +9 -1
  43. package/src/bridge/sse.ts +10 -0
  44. package/src/chat/inbound.ts +141 -5
  45. package/src/claude/desktop-3p.ts +7 -1
  46. package/src/claude/desktop-first-party.ts +183 -0
  47. package/src/claude/desktop-gateway-state.ts +41 -0
  48. package/src/claude/inbound-content-options.ts +6 -0
  49. package/src/claude/inbound.ts +32 -6
  50. package/src/claude/intercept/connect-proxy.ts +179 -0
  51. package/src/claude/intercept/listener.ts +122 -0
  52. package/src/claude/intercept/local-ca.ts +298 -0
  53. package/src/claude/intercept/runtime.ts +98 -0
  54. package/src/claude/intercept/settings.ts +189 -0
  55. package/src/cli/access.ts +87 -0
  56. package/src/cli/account-auth.ts +19 -0
  57. package/src/cli/capabilities.ts +31 -0
  58. package/src/cli/claude-desktop.ts +206 -16
  59. package/src/cli/codex-shim-autorestore.ts +3 -0
  60. package/src/cli/companion.ts +56 -0
  61. package/src/cli/dispatch.ts +43 -4
  62. package/src/cli/ensure-desired-integrations.ts +43 -5
  63. package/src/cli/help.ts +7 -9
  64. package/src/cli/index.ts +200 -61
  65. package/src/cli/init.ts +8 -0
  66. package/src/cli/integrations.ts +7 -1
  67. package/src/cli/registry.ts +41 -2
  68. package/src/cli/resolve.ts +230 -0
  69. package/src/cli/root.ts +24 -1
  70. package/src/cli/start-ownership-publication.ts +56 -0
  71. package/src/cli/status-probes.ts +2 -18
  72. package/src/cli/status.ts +62 -0
  73. package/src/cli/stop-report.ts +143 -0
  74. package/src/cli/uninstall-plan.ts +9 -0
  75. package/src/client/machine-listener.ts +2 -5
  76. package/src/clients/aside-profiles.ts +4 -0
  77. package/src/clients/config-export/zcode-store.ts +157 -0
  78. package/src/clients/config-export.ts +36 -0
  79. package/src/codex/app-server-processes.ts +72 -40
  80. package/src/codex/auth-api/login-flow.ts +6 -1
  81. package/src/codex/autostart-health.ts +28 -0
  82. package/src/codex/catalog/build-entries.ts +2 -2
  83. package/src/codex/catalog/effort.ts +3 -3
  84. package/src/codex/catalog/provider-models.ts +24 -15
  85. package/src/codex/catalog/retained-sync.ts +2 -2
  86. package/src/codex/convergence.ts +2 -2
  87. package/src/codex/history-provider.ts +12 -1
  88. package/src/codex/inject/config-toml.ts +41 -6
  89. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  90. package/src/codex/inject.ts +18 -15
  91. package/src/codex/injected-marker.ts +18 -0
  92. package/src/codex/main-account.ts +6 -0
  93. package/src/codex/model-cache.ts +52 -6
  94. package/src/codex/model-entitlement-admission.ts +59 -0
  95. package/src/codex/model-entitlements.ts +87 -44
  96. package/src/codex/native-main-admission.ts +83 -0
  97. package/src/codex/routing/health-store.ts +39 -0
  98. package/src/codex/routing/selection.ts +37 -1
  99. package/src/codex/routing.ts +5 -41
  100. package/src/codex/shim-templates.ts +29 -3
  101. package/src/companion/settings.ts +132 -0
  102. package/src/config/atomic-write.ts +117 -5
  103. package/src/config/load-degrade.ts +34 -7
  104. package/src/config/process-state.ts +1 -1
  105. package/src/config/schema/config-schema.ts +27 -1
  106. package/src/config/schema/leaf-validators.ts +47 -0
  107. package/src/config.ts +1 -1
  108. package/src/generated/compatibility-version.json +418 -174
  109. package/src/integrations/config-io.ts +44 -10
  110. package/src/integrations/merge.ts +120 -13
  111. package/src/integrations/mutation-plan.ts +124 -18
  112. package/src/integrations/registry.ts +38 -0
  113. package/src/integrations/state.ts +78 -45
  114. package/src/integrations/target.ts +208 -0
  115. package/src/integrations/writer.ts +49 -11
  116. package/src/lab/conformance/fixture-provider.ts +5 -0
  117. package/src/lib/browser-launch-notice.ts +59 -0
  118. package/src/lib/bun-runtime.ts +6 -2
  119. package/src/lib/debug.ts +40 -0
  120. package/src/lib/open-url.ts +51 -7
  121. package/src/lib/package-tree-integrity.ts +2 -1
  122. package/src/lib/package-version.ts +8 -0
  123. package/src/lib/provider-egress.ts +310 -0
  124. package/src/lib/provider-outbound.ts +59 -14
  125. package/src/lib/proxy-env.ts +82 -7
  126. package/src/lib/request-execution-budget.ts +72 -0
  127. package/src/lib/request-failure-attribution.ts +183 -0
  128. package/src/lib/request-failure-model.ts +236 -0
  129. package/src/lib/request-resend-gate.ts +138 -0
  130. package/src/lib/standalone.ts +16 -0
  131. package/src/lib/upstream-retry.ts +167 -16
  132. package/src/lib/winsw.ts +2 -2
  133. package/src/oauth/index.ts +24 -1
  134. package/src/oauth/login-cli.ts +80 -29
  135. package/src/providers/api-key-resolve.ts +133 -0
  136. package/src/providers/api-key-selection.ts +5 -1
  137. package/src/providers/key-failover.ts +31 -1
  138. package/src/providers/key-store.ts +34 -110
  139. package/src/providers/model-rename-fields.ts +147 -0
  140. package/src/providers/model-rename-migration.ts +124 -37
  141. package/src/providers/quota/vendor-probes-key.ts +37 -22
  142. package/src/providers/reasoning-metadata.ts +43 -18
  143. package/src/providers/registry/entries-core.ts +9 -4
  144. package/src/providers/registry/entries-extended.ts +29 -4
  145. package/src/providers/registry/model-seeds.ts +47 -10
  146. package/src/providers/xai-transport.ts +12 -1
  147. package/src/reasoning-effort.ts +8 -0
  148. package/src/responses/function-call-compat.ts +38 -1
  149. package/src/responses/inline-document.ts +65 -0
  150. package/src/responses/input-media.ts +42 -8
  151. package/src/responses/muse-tool-name-alias.ts +19 -0
  152. package/src/responses/parser-content.ts +8 -2
  153. package/src/responses/parser-tools.ts +3 -0
  154. package/src/responses/parser.ts +3 -1
  155. package/src/responses/schema.ts +3 -0
  156. package/src/router.ts +17 -2
  157. package/src/server/admission-model-scope.ts +219 -0
  158. package/src/server/audio-live.ts +9 -3
  159. package/src/server/audio-upstream.ts +18 -0
  160. package/src/server/auth-cors.ts +26 -0
  161. package/src/server/chat-completions.ts +55 -2
  162. package/src/server/chat-native.ts +19 -4
  163. package/src/server/claude-messages.ts +55 -17
  164. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  165. package/src/server/gui-freshness.ts +103 -0
  166. package/src/server/gui-static.ts +7 -9
  167. package/src/server/images.ts +59 -6
  168. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  169. package/src/server/index/serve-options.ts +56 -10
  170. package/src/server/index/spend-ledger-lifecycle.ts +34 -8
  171. package/src/server/index/startup-warnings.ts +24 -0
  172. package/src/server/index.ts +21 -28
  173. package/src/server/lifecycle.ts +4 -4
  174. package/src/server/live-call-bindings.ts +6 -0
  175. package/src/server/live.ts +88 -3
  176. package/src/server/management/agent-settings-routes.ts +121 -36
  177. package/src/server/management/companion-routes.ts +77 -0
  178. package/src/server/management/logs-usage-routes.ts +19 -0
  179. package/src/server/management/native-integration-routes.ts +103 -6
  180. package/src/server/management/oauth-account-routes.ts +45 -7
  181. package/src/server/management/route-registry.ts +6 -0
  182. package/src/server/management/shared.ts +18 -1
  183. package/src/server/management/usage-timeline-routes.ts +44 -0
  184. package/src/server/management-api.ts +8 -9
  185. package/src/server/proxy-liveness.ts +75 -0
  186. package/src/server/relay.ts +19 -2
  187. package/src/server/request-log-failure-attribution.ts +99 -0
  188. package/src/server/request-log.ts +114 -0
  189. package/src/server/request-metrics.ts +92 -30
  190. package/src/server/responses/codex-ws-wire.ts +34 -8
  191. package/src/server/responses/combo-stream-preflight.ts +168 -6
  192. package/src/server/responses/compact.ts +11 -0
  193. package/src/server/responses/core-opaque-recovery.ts +90 -0
  194. package/src/server/responses/fetch-helpers.ts +124 -8
  195. package/src/server/responses/input-admission.ts +10 -0
  196. package/src/server/responses/passthrough-delivery.ts +14 -1
  197. package/src/server/responses/passthrough-dispatch.ts +179 -35
  198. package/src/server/responses/passthrough-error.ts +27 -8
  199. package/src/server/responses/request-prepare.ts +42 -1
  200. package/src/server/responses/request-send-budget.ts +12 -0
  201. package/src/server/responses/request-transport.ts +24 -4
  202. package/src/server/responses/reset-replay.ts +108 -0
  203. package/src/server/responses-request-tool-scope.ts +214 -0
  204. package/src/server/responses-undeclared-tool-guard.ts +4 -1
  205. package/src/server/search.ts +25 -1
  206. package/src/server/usage-ledger-retention.ts +73 -0
  207. package/src/service/cli.ts +48 -2
  208. package/src/service/health.ts +3 -2
  209. package/src/service/install-state-contract.d.mts +27 -0
  210. package/src/service/install-state-contract.mjs +34 -0
  211. package/src/service/launchd.ts +1 -1
  212. package/src/service/orchestration.ts +2 -4
  213. package/src/service/ownership-compatibility.ts +164 -0
  214. package/src/service/ownership-mutation-lease.d.mts +32 -0
  215. package/src/service/ownership-mutation-lease.mjs +211 -0
  216. package/src/service/repair.ts +45 -1
  217. package/src/service/state-lock.ts +269 -0
  218. package/src/service/state-record.d.mts +36 -0
  219. package/src/service/state-record.mjs +138 -0
  220. package/src/service/state.ts +582 -68
  221. package/src/service/windows-taskxml.ts +11 -10
  222. package/src/service.ts +7 -3
  223. package/src/tray/windows-tray.ps1 +1 -1
  224. package/src/types/config.ts +37 -0
  225. package/src/types/provider.ts +73 -0
  226. package/src/types/request.ts +28 -2
  227. package/src/types/tools.ts +19 -0
  228. package/src/types.ts +3 -0
  229. package/src/update/index.ts +207 -63
  230. package/src/update/job.ts +9 -5
  231. package/src/update/ownership-transaction.ts +47 -0
  232. package/src/update/restart-ownership.ts +54 -0
  233. package/src/update/runtime-ownership.d.mts +40 -0
  234. package/src/update/runtime-ownership.mjs +122 -0
  235. package/src/usage/attempt-delivery.ts +198 -0
  236. package/src/usage/cache-diagnostic.ts +305 -0
  237. package/src/usage/failure-fingerprint.ts +118 -0
  238. package/src/usage/failure-projection-cache.ts +174 -0
  239. package/src/usage/failure-projection.ts +174 -0
  240. package/src/usage/ledger-retention.ts +165 -0
  241. package/src/usage/log.ts +126 -79
  242. package/src/usage/request-outcome.ts +150 -0
  243. package/src/usage/retention-contract.ts +28 -0
  244. package/src/usage/summary.ts +2 -2
  245. package/src/usage/telemetry-contract.ts +237 -0
  246. package/src/usage/timeline.ts +236 -0
  247. package/src/web-search/alpha-search.ts +21 -1
  248. package/gui/dist/assets/index-BTuCbqQd.css +0 -1
  249. package/gui/dist/assets/index-DoBVdPHP.js +0 -134
package/src/bridge/sse.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  type OcxErrorPayload,
17
17
  } from "../lib/errors";
18
18
  import { redactSecretString } from "../lib/redact";
19
+ import { attemptDeliveryRecorder, classifyRelayedResponseEvent } from "../usage/attempt-delivery";
19
20
  import {
20
21
  mayBecomePatchEnvelope,
21
22
  repairFreeformToolInput,
@@ -162,6 +163,10 @@ export function bridgeToResponsesSSE(
162
163
  // at terminal/cancel below.
163
164
  const ownsBudget = !options?.translatorBudget;
164
165
  const budget = options?.translatorBudget ?? createTranslatorBudget();
166
+ // Resolved from the CALLER's budget only. A bridge that owns its budget is not serving a
167
+ // logged request -- there is no attempt to count against, and a locally created scope would
168
+ // never have had a recorder bound to it.
169
+ const delivery = attemptDeliveryRecorder(options?.translatorBudget);
165
170
  // Idempotent: safe to call at every stream-death path; disposal must come
166
171
  // AFTER the final charges (emitDone), never inside reportTerminal.
167
172
  const disposeOwnedBudget = () => { if (ownsBudget) budget.dispose(); };
@@ -278,6 +283,11 @@ export function bridgeToResponsesSSE(
278
283
  controller.enqueue(frame);
279
284
  budget?.releaseRetained(frameBytes, { kind: "live_transient" });
280
285
  emittedFrames++;
286
+ // After a SUCCESSFUL enqueue, never before it. A frame that threw on the way to the
287
+ // transport did not reach the caller, and counting it here would make the relayed
288
+ // total equal the adapter total by construction -- erasing the one discrepancy these
289
+ // counters exist to expose (#3983).
290
+ delivery?.noteRelayedEvent(classifyRelayedResponseEvent(name, data));
281
291
  } catch (error) {
282
292
  if (isTranslatorBudgetExceededError(error)) {
283
293
  terminateForTranslatorOverflow?.(error);
@@ -92,11 +92,32 @@ function userContentToBlocks(content: unknown): Rec[] {
92
92
  continue;
93
93
  }
94
94
  const videoUrl = videoUrlFromPart(raw);
95
- if (videoUrl) blocks.push({ type: "input_video", video_url: videoUrl });
95
+ if (videoUrl) {
96
+ blocks.push({ type: "input_video", video_url: videoUrl });
97
+ continue;
98
+ }
99
+ const file = fileFromPart(raw);
100
+ if (file) blocks.push(file);
96
101
  }
97
102
  return blocks;
98
103
  }
99
104
 
105
+ /**
106
+ * A Chat Completions `file` part carrying inline bytes, as the Responses `input_file` block.
107
+ *
108
+ * Nothing here recognized the shape, so the part reached the end of the loop with no branch and
109
+ * was dropped in silence (#5212). A part with no inline bytes is still not translatable and is
110
+ * left to the untranslated-media refusal, which runs before this loop.
111
+ */
112
+ function fileFromPart(part: Rec): Rec | null {
113
+ if (part.type !== "file" && part.type !== "input_file") return null;
114
+ const file = isRec(part.file) ? part.file : part;
115
+ const fileData = file.file_data;
116
+ if (typeof fileData !== "string" || fileData.length === 0) return null;
117
+ const filename = typeof file.filename === "string" && file.filename.length > 0 ? file.filename : undefined;
118
+ return { type: "input_file", file_data: fileData, ...(filename ? { filename } : {}) };
119
+ }
120
+
100
121
  /**
101
122
  * The assistant's prior thinking, as plaintext, from either Chat spelling.
102
123
  *
@@ -146,7 +167,27 @@ function pushSystemText(parts: string[], content: unknown): void {
146
167
  if (text) parts.push(text);
147
168
  }
148
169
 
149
- function toolCallsToItems(toolCalls: unknown, input: Rec[], knownNameByCallId: Map<string, string>): void {
170
+ /**
171
+ * A mid-conversation instruction, as the input item the rest of the pipeline already reads.
172
+ *
173
+ * The role is `developer` rather than `system` for two reasons that both bite. The native
174
+ * ChatGPT backend refuses a `role:"system"` item inside `input`, and canonical forwarding
175
+ * folds every message-shaped `system` item back onto `instructions`
176
+ * (src/adapters/openai-responses/canonical-forward.ts), which would undo the placement one hop
177
+ * later. `developer` is first-class in responsesRequestSchema, survives parseRequest as a
178
+ * chronological conversation message, and is exactly what src/claude/inbound.ts already emits
179
+ * for the same shape.
180
+ */
181
+ function developerInstructionItem(text: string): Rec {
182
+ return { type: "message", role: "developer", content: [{ type: "input_text", text }] };
183
+ }
184
+
185
+ function toolCallsToItems(
186
+ toolCalls: unknown,
187
+ input: Rec[],
188
+ knownNameByCallId: Map<string, string>,
189
+ awaitingToolResult: Set<string>,
190
+ ): void {
150
191
  if (!Array.isArray(toolCalls)) return;
151
192
  for (const raw of toolCalls) {
152
193
  if (!isRec(raw)) continue;
@@ -166,6 +207,7 @@ function toolCallsToItems(toolCalls: unknown, input: Rec[], knownNameByCallId: M
166
207
  if (!name) throw new ChatCompletionsRequestError("tool_calls entries require function.name");
167
208
  knownNameByCallId.set(callId, name);
168
209
  input.push({ type: "function_call", call_id: callId, name, arguments: args });
210
+ awaitingToolResult.add(callId);
169
211
  }
170
212
  }
171
213
 
@@ -218,11 +260,67 @@ function toolChoiceToResponses(choice: unknown, body: Rec): void {
218
260
  body.tool_choice = { type: "function", name };
219
261
  return;
220
262
  }
263
+ if (choice.type === "allowed_tools") {
264
+ body.tool_choice = allowedToolsChoiceToResponses(choice);
265
+ return;
266
+ }
221
267
  if (isRec(choice.function) && typeof choice.function.name === "string") {
222
268
  body.tool_choice = { type: "function", name: choice.function.name };
223
269
  }
224
270
  }
225
271
 
272
+ /**
273
+ * Chat Completions nests the subset under `allowed_tools`, Responses carries `mode`/`tools`
274
+ * on the choice itself, and each entry names its tool under a member keyed by its own type
275
+ * (`{"type":"function","function":{"name"}}`) rather than a flat `name`. Neither level lines up
276
+ * with `mapToolChoice`, so an unflattened choice fell past every branch and the caller's subset
277
+ * was dropped while the full catalogue was still advertised (#5211).
278
+ *
279
+ * An entry nobody can name is refused rather than skipped: dropping one widens the very subset
280
+ * the caller sent this field to narrow.
281
+ */
282
+ function allowedToolsChoiceToResponses(choice: Rec): Rec {
283
+ const spec = isRec(choice.allowed_tools) ? choice.allowed_tools : choice;
284
+ if (!Array.isArray(spec.tools) || spec.tools.length === 0) {
285
+ throw new ChatCompletionsRequestError("tool_choice.allowed_tools requires a non-empty tools array");
286
+ }
287
+ return {
288
+ type: "allowed_tools",
289
+ mode: spec.mode === "required" ? "required" : "auto",
290
+ tools: spec.tools.map(allowedToolEntryToResponses),
291
+ };
292
+ }
293
+
294
+ /** Hosted entries are named by their type alone; a function or custom entry must carry a name. */
295
+ const HOSTED_ALLOWED_TOOL_TYPES = new Set([
296
+ "web_search",
297
+ "web_search_preview",
298
+ "image_generation",
299
+ "image_gen",
300
+ "tool_search",
301
+ ]);
302
+ const NAMED_ALLOWED_TOOL_TYPES = new Set(["function", "custom"]);
303
+
304
+ function allowedToolEntryToResponses(raw: unknown): Rec {
305
+ if (!isRec(raw)) {
306
+ throw new ChatCompletionsRequestError("tool_choice.allowed_tools.tools entries must be objects");
307
+ }
308
+ const type = typeof raw.type === "string" && raw.type.length > 0 ? raw.type : "function";
309
+ if (!NAMED_ALLOWED_TOOL_TYPES.has(type) && !HOSTED_ALLOWED_TOOL_TYPES.has(type)) {
310
+ // An unknown selector kind is not a narrower subset, it is a subset nobody can evaluate.
311
+ throw new ChatCompletionsRequestError(`unsupported tool_choice.allowed_tools.tools entry type: ${type}`);
312
+ }
313
+ const nested = isRec(raw[type]) ? raw[type] as Rec : undefined;
314
+ const name = typeof raw.name === "string" && raw.name.length > 0
315
+ ? raw.name
316
+ : nested !== undefined && typeof nested.name === "string" && nested.name.length > 0
317
+ ? nested.name
318
+ : undefined;
319
+ if (name !== undefined) return { type, name };
320
+ if (HOSTED_ALLOWED_TOOL_TYPES.has(type)) return { type };
321
+ throw new ChatCompletionsRequestError("tool_choice.allowed_tools.tools entries require a name");
322
+ }
323
+
226
324
  function responseFormatToText(format: unknown): Rec | undefined {
227
325
  if (format === undefined) return undefined;
228
326
  if (!isRec(format)) throw new ChatCompletionsRequestError("response_format must be an object");
@@ -291,21 +389,54 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec {
291
389
  // Recover replace-style tool calls incrementally instead of rebuilding the
292
390
  // call-id index from the entire translated transcript for every message.
293
391
  const knownNameByCallId = new Map<string, string>();
392
+ // Tool calls whose result has not arrived yet. Several adapters need a call and its output
393
+ // to stay adjacent — Kiro refuses an interrupted pair (src/adapters/kiro/payload.ts) and the
394
+ // Anthropic and Google mappers synthesize a missing result — so an instruction that arrives
395
+ // inside an open batch waits for the batch to drain instead of splitting it.
396
+ const awaitingToolResult = new Set<string>();
397
+ const heldInstructions: string[] = [];
398
+ const releaseHeldInstructions = (): void => {
399
+ if (heldInstructions.length === 0) return;
400
+ input.push(developerInstructionItem(heldInstructions.join("\n\n")));
401
+ heldInstructions.length = 0;
402
+ };
403
+ // A user or assistant turn ends any open tool batch, so held text rejoins the timeline
404
+ // before that turn rather than drifting past it.
405
+ const beginConversationTurn = (): void => {
406
+ releaseHeldInstructions();
407
+ awaitingToolResult.clear();
408
+ };
294
409
 
295
410
  for (const msg of raw.messages) {
296
411
  if (!isRec(msg)) continue;
297
412
  const role = typeof msg.role === "string" ? msg.role : "";
298
413
  switch (role) {
299
414
  case "system":
300
- case "developer":
301
- pushSystemText(systemParts, msg.content);
415
+ case "developer": {
416
+ // A leading block is this request's instructions and keeps that treatment: it is the
417
+ // prompt head, and hoisting it is what the upstream prefix cache wants.
418
+ if (input.length === 0) {
419
+ pushSystemText(systemParts, msg.content);
420
+ break;
421
+ }
422
+ // Past the first turn the slot carries meaning. `U1 -> A1 -> D2 -> U2` says D2 applies
423
+ // to U2 and not to U1, and folding it into `instructions` moved it ahead of both while
424
+ // rewriting the prompt head on every turn that carried one. The outbound adapter has
425
+ // preserved this slot since #4161; the position was already gone by the time it ran.
426
+ const text = contentToText(msg.content).trim();
427
+ if (!text) break;
428
+ if (awaitingToolResult.size > 0) heldInstructions.push(text);
429
+ else input.push(developerInstructionItem(text));
302
430
  break;
431
+ }
303
432
  case "user": {
433
+ beginConversationTurn();
304
434
  const blocks = userContentToBlocks(msg.content);
305
435
  if (blocks.length > 0) input.push({ type: "message", role: "user", content: blocks });
306
436
  break;
307
437
  }
308
438
  case "assistant": {
439
+ beginConversationTurn();
309
440
  // A reasoning item precedes the assistant message it belongs to: the
310
441
  // Responses assistant item schema admits only output content blocks, so there
311
442
  // is no attachment point on the message itself, and the parser buffers a
@@ -328,7 +459,9 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec {
328
459
  }
329
460
  const blocks = assistantContentToBlocks(msg.content);
330
461
  if (blocks.length > 0) input.push({ type: "message", role: "assistant", content: blocks });
331
- if (msg.tool_calls !== undefined) toolCallsToItems(msg.tool_calls, input, knownNameByCallId);
462
+ if (msg.tool_calls !== undefined) {
463
+ toolCallsToItems(msg.tool_calls, input, knownNameByCallId, awaitingToolResult);
464
+ }
332
465
  break;
333
466
  }
334
467
  case "function": {
@@ -351,12 +484,15 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec {
351
484
  ? blocks.filter(part => part.type === "input_text" || part.type === "input_image")
352
485
  : contentToText(msg.content);
353
486
  input.push({ type: "function_call_output", call_id: callId, output });
487
+ awaitingToolResult.delete(callId);
488
+ if (awaitingToolResult.size === 0) releaseHeldInstructions();
354
489
  break;
355
490
  }
356
491
  default:
357
492
  break;
358
493
  }
359
494
  }
495
+ releaseHeldInstructions();
360
496
 
361
497
  if (input.length === 0 && systemParts.length === 0) {
362
498
  throw new ChatCompletionsRequestError("messages must include at least one user/assistant/tool turn");
@@ -473,6 +473,12 @@ export function removeDesktop3pStandardPivot(
473
473
  options: Desktop3pConfigLibraryOptions & {
474
474
  appliedFingerprint?: string | null; unlink?: (path: string) => void;
475
475
  lifecycleLockDeps?: ClientLifecycleLockDeps;
476
+ /**
477
+ * The desired-state guard below exists for OFF flows racing a concurrent enable. A
478
+ * mode switch (gateway → first-party) removes the profile while the integration stays
479
+ * ON on purpose, so the caller opts out of that guard.
480
+ */
481
+ replaceWhileEnabled?: boolean;
476
482
  } = {},
477
483
  ): Desktop3pRemovalResult {
478
484
  const libraryPath = resolveDesktop3pConfigLibraryPath(options);
@@ -488,7 +494,7 @@ export function removeDesktop3pStandardPivot(
488
494
  }
489
495
  const latest = readConfigDiagnostics();
490
496
  if (latest.source === "fallback") return { ok: false, changed: false, kind: "unsafe", libraryPath, reason: "desktop_config_invalid" };
491
- if (claudeDesktopIntegrationEnabled(latest.config)) {
497
+ if (!options.replaceWhileEnabled && claudeDesktopIntegrationEnabled(latest.config)) {
492
498
  const observed = inspectDesktop3pConfigLibrary(options);
493
499
  if (observed.kind === "not_installed" || observed.kind === "no_owned_state") {
494
500
  return { ok: true, changed: false, kind: "noop", libraryPath };
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Claude Desktop first-party mode.
3
+ *
4
+ * Desktop has two ways to reach opencodex:
5
+ *
6
+ * - `first-party` (default): the app keeps its ordinary claude.ai login, Chat tab, connectors
7
+ * and remote control. Only the Claude Code process it spawns for the Code tab (and that
8
+ * process's subagents) is redirected, through the `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` env
9
+ * in `~/.claude/settings.json` (src/claude/intercept/settings.ts) and the server's intercept
10
+ * pair (src/claude/intercept/runtime.ts). Nothing is written under Desktop's config library.
11
+ * - `gateway`: the historical third-party deployment profile (src/claude/desktop-3p.ts). The
12
+ * whole app is switched to a gateway build; picker entries are opencodex aliases.
13
+ *
14
+ * The two are mutually exclusive on disk: applying one removes the other. The mode is persisted
15
+ * in `claudeCode.desktopMode`; installs that predate the field but already carry an applied
16
+ * gateway profile keep `gateway` until they explicitly re-apply, so an update never flips a
17
+ * working Desktop under the operator.
18
+ */
19
+ import { getConfigDir } from "../config/paths";
20
+ import type { OcxConfig } from "../types";
21
+ import { claudeInterceptCaCertPath, ensureLocalInterceptCa } from "./intercept/local-ca";
22
+ import { claudeInterceptEnabled, claudeInterceptProxyPort } from "./intercept/runtime";
23
+ import {
24
+ applyClaudeInterceptSettings,
25
+ buildClaudeInterceptEnv,
26
+ captureClaudeInterceptSettingsRollback,
27
+ inspectClaudeInterceptSettings,
28
+ removeClaudeInterceptSettings,
29
+ type ClaudeInterceptEnv,
30
+ type ClaudeInterceptSettingsState,
31
+ type ClaudeInterceptSettingsWrite,
32
+ } from "./intercept/settings";
33
+
34
+ export const CLAUDE_DESKTOP_MODES = ["first-party", "gateway"] as const;
35
+ export type ClaudeDesktopMode = typeof CLAUDE_DESKTOP_MODES[number];
36
+ export const DEFAULT_CLAUDE_DESKTOP_MODE: ClaudeDesktopMode = "first-party";
37
+
38
+ export function isClaudeDesktopMode(value: unknown): value is ClaudeDesktopMode {
39
+ return typeof value === "string" && (CLAUDE_DESKTOP_MODES as readonly string[]).includes(value);
40
+ }
41
+
42
+ type DesktopModeConfig = Pick<OcxConfig, "claudeCode">;
43
+
44
+ /**
45
+ * Effective Desktop mode. An explicit `claudeCode.desktopMode` wins; otherwise a persisted
46
+ * gateway apply marker (`desktopProfile.appliedFingerprint`) means a pre-existing gateway
47
+ * install and keeps `gateway`; everything else is the first-party default.
48
+ */
49
+ export function resolveClaudeDesktopMode(config: DesktopModeConfig): ClaudeDesktopMode {
50
+ const explicit = config.claudeCode?.desktopMode;
51
+ if (isClaudeDesktopMode(explicit)) return explicit;
52
+ if (config.claudeCode?.desktopProfile?.appliedFingerprint) return "gateway";
53
+ return DEFAULT_CLAUDE_DESKTOP_MODE;
54
+ }
55
+
56
+ /**
57
+ * Config mutation that records the applied Desktop mode. Switching to first-party also drops
58
+ * the gateway apply marker: the profile assignments stay for a later gateway apply, but a
59
+ * stale `appliedFingerprint` must not make `resolveClaudeDesktopMode` read `gateway` again
60
+ * should the explicit marker ever go missing.
61
+ */
62
+ export function recordClaudeDesktopMode(
63
+ config: DesktopModeConfig,
64
+ mode: ClaudeDesktopMode,
65
+ ): { changed: boolean; value: true } {
66
+ const claudeCode = config.claudeCode ?? {};
67
+ const profile = claudeCode.desktopProfile;
68
+ const dropMarker = mode === "first-party" && profile !== undefined
69
+ && (profile.appliedFingerprint !== undefined || profile.appliedAt !== undefined);
70
+ if (claudeCode.desktopMode === mode && !dropMarker) return { changed: false, value: true };
71
+ if (dropMarker) {
72
+ const { appliedFingerprint: _fingerprint, appliedAt: _at, ...rest } = profile;
73
+ config.claudeCode = { ...claudeCode, desktopMode: mode, desktopProfile: rest };
74
+ } else {
75
+ config.claudeCode = { ...claudeCode, desktopMode: mode };
76
+ }
77
+ return { changed: true, value: true };
78
+ }
79
+
80
+ /**
81
+ * Mode an *apply* without an explicit choice should use. The first-party default only holds
82
+ * where the intercept proxy actually runs; with it disabled (or on a client role) an implied
83
+ * first-party apply would point Claude Code at a proxy that never starts, so fall back to the
84
+ * gateway profile. An explicit `desktopMode: "first-party"` is still honoured (and refused
85
+ * later with `intercept_disabled`, which names the fix).
86
+ */
87
+ export function resolveClaudeDesktopApplyMode(
88
+ config: Pick<OcxConfig, "claudeCode" | "runtimeRole">,
89
+ ): ClaudeDesktopMode {
90
+ const resolved = resolveClaudeDesktopMode(config);
91
+ if (resolved === "gateway" || isClaudeDesktopMode(config.claudeCode?.desktopMode)) return resolved;
92
+ return claudeInterceptEnabled(config) ? "first-party" : "gateway";
93
+ }
94
+
95
+ export interface DesktopFirstPartyTarget {
96
+ proxyPort: number;
97
+ caCertPath: string;
98
+ env: ClaudeInterceptEnv;
99
+ }
100
+
101
+ /** The settings env a first-party apply on this machine writes (CA is created on demand). */
102
+ export function desktopFirstPartyTarget(
103
+ config: Pick<OcxConfig, "claudeCode" | "port">,
104
+ opencodexConfigDir = getConfigDir(),
105
+ ): DesktopFirstPartyTarget {
106
+ const proxyPort = claudeInterceptProxyPort(config, config.port ?? 10100);
107
+ const caCertPath = claudeInterceptCaCertPath(opencodexConfigDir);
108
+ return { proxyPort, caCertPath, env: buildClaudeInterceptEnv(proxyPort, caCertPath) };
109
+ }
110
+
111
+ export interface DesktopFirstPartyInspection {
112
+ /** False when the server will not run the intercept pair (client role, intercept disabled). */
113
+ interceptEnabled: boolean;
114
+ proxyPort: number;
115
+ caCertPath: string;
116
+ settings: ClaudeInterceptSettingsState;
117
+ /** settings.json carries exactly the env the current config would write. */
118
+ applied: boolean;
119
+ /** Ours, but for an older port/config directory. Re-apply refreshes it. */
120
+ stale: boolean;
121
+ }
122
+
123
+ export interface DesktopFirstPartyOptions {
124
+ opencodexConfigDir?: string;
125
+ claudeConfigDir?: string;
126
+ }
127
+
128
+ /** Prepare rollback before replacing a gateway, without changing settings. */
129
+ export function captureDesktopFirstPartyRollback(
130
+ config: Pick<OcxConfig, "claudeCode" | "port">,
131
+ options: DesktopFirstPartyOptions = {},
132
+ ): () => boolean {
133
+ return captureClaudeInterceptSettingsRollback(
134
+ desktopFirstPartyTarget(config, options.opencodexConfigDir).env, options.claudeConfigDir,
135
+ );
136
+ }
137
+
138
+ export function inspectDesktopFirstParty(
139
+ config: Pick<OcxConfig, "claudeCode" | "port" | "runtimeRole">,
140
+ options: DesktopFirstPartyOptions = {},
141
+ ): DesktopFirstPartyInspection {
142
+ const target = desktopFirstPartyTarget(config, options.opencodexConfigDir);
143
+ const settings = inspectClaudeInterceptSettings(target.env, options.claudeConfigDir);
144
+ return {
145
+ interceptEnabled: claudeInterceptEnabled(config),
146
+ proxyPort: target.proxyPort,
147
+ caCertPath: target.caCertPath,
148
+ settings,
149
+ applied: settings.kind === "applied",
150
+ stale: settings.kind === "stale",
151
+ };
152
+ }
153
+
154
+ export type DesktopFirstPartyApplyResult =
155
+ | { ok: true; changed: boolean; path: string; env: ClaudeInterceptEnv; proxyPort: number }
156
+ | { ok: false; reason: "intercept_disabled" | "ca_unavailable" | "unreadable" | "foreign_env"; path: string };
157
+
158
+ /**
159
+ * Write the first-party env into Claude Code's settings. Creates the local CA first so the
160
+ * path we point `NODE_EXTRA_CA_CERTS` at exists before Claude Code ever reads it.
161
+ */
162
+ export function applyDesktopFirstParty(
163
+ config: Pick<OcxConfig, "claudeCode" | "port" | "runtimeRole">,
164
+ options: DesktopFirstPartyOptions = {},
165
+ ): DesktopFirstPartyApplyResult {
166
+ const opencodexConfigDir = options.opencodexConfigDir ?? getConfigDir();
167
+ const target = desktopFirstPartyTarget(config, opencodexConfigDir);
168
+ if (!claudeInterceptEnabled(config)) return { ok: false, reason: "intercept_disabled", path: "" };
169
+ try {
170
+ ensureLocalInterceptCa(opencodexConfigDir);
171
+ } catch {
172
+ return { ok: false, reason: "ca_unavailable", path: target.caCertPath };
173
+ }
174
+ const written = applyClaudeInterceptSettings(target.env, options.claudeConfigDir);
175
+ if (!written.ok) return { ok: false, reason: written.reason, path: written.path };
176
+ return { ok: true, changed: written.changed, path: written.path, env: target.env, proxyPort: target.proxyPort };
177
+ }
178
+
179
+ /** Remove the first-party env. Only values anchored on our CA path are touched. */
180
+ export function removeDesktopFirstParty(options: DesktopFirstPartyOptions = {}): ClaudeInterceptSettingsWrite {
181
+ const caCertPath = claudeInterceptCaCertPath(options.opencodexConfigDir ?? getConfigDir());
182
+ return removeClaudeInterceptSettings(caCertPath, options.claudeConfigDir);
183
+ }
@@ -0,0 +1,41 @@
1
+ import { mutatePersistedConfig } from "../config";
2
+ import type { OcxConfig } from "../types";
3
+ import { emptyDesktopProfile, type DesktopProfile } from "./desktop-profile";
4
+
5
+ /** Record the bytes already committed by the gateway writer, before cleanup of
6
+ * the previous mode. Mode and fingerprint belong to one config transaction. */
7
+ export function recordCommittedDesktopGateway(
8
+ config: Pick<OcxConfig, "claudeCode">,
9
+ profile: DesktopProfile | undefined,
10
+ fingerprint: string | undefined,
11
+ appliedAt: string,
12
+ ): void {
13
+ const { appliedFingerprint: _oldFingerprint, appliedAt: _oldTime, ...base } = profile ?? emptyDesktopProfile();
14
+ config.claudeCode = {
15
+ ...config.claudeCode,
16
+ desktopMode: "gateway",
17
+ desktopProfile: {
18
+ ...structuredClone(base),
19
+ ...(fingerprint ? { appliedFingerprint: fingerprint, appliedAt } : {}),
20
+ },
21
+ };
22
+ }
23
+
24
+ export function persistCommittedDesktopGateway(
25
+ snapshot: OcxConfig,
26
+ profile: DesktopProfile | undefined,
27
+ fingerprint: string | undefined,
28
+ ): { ok: true } | { ok: false; reason: "missing" | "invalid" | "conflict" | "unavailable" } {
29
+ const appliedAt = new Date().toISOString();
30
+ try {
31
+ const outcome = mutatePersistedConfig(current => {
32
+ recordCommittedDesktopGateway(current, profile, fingerprint, appliedAt);
33
+ return { changed: true, value: true };
34
+ });
35
+ if (outcome.status === "unavailable") return { ok: false, reason: outcome.reason };
36
+ recordCommittedDesktopGateway(snapshot, profile, fingerprint, appliedAt);
37
+ return { ok: true };
38
+ } catch {
39
+ return { ok: false, reason: "unavailable" };
40
+ }
41
+ }
@@ -35,6 +35,12 @@ export function toolsToResponses(tools: unknown): Rec[] | undefined {
35
35
  // call, so carry the source intent instead of the destination default. A
36
36
  // non-boolean value is not a valid Anthropic opt-in and must not become one.
37
37
  strict: typeof raw.strict === "boolean" ? raw.strict : false,
38
+ // Anthropic restricts who may invoke a tool through allowed_callers. Nothing read it,
39
+ // so the restriction never reached the internal tool and every destination rebuilt the
40
+ // declaration without it while the request still succeeded (#5210).
41
+ ...(Array.isArray(raw.allowed_callers)
42
+ ? { allowed_callers: raw.allowed_callers.filter((c): c is string => typeof c === "string") }
43
+ : {}),
38
44
  });
39
45
  continue;
40
46
  }
@@ -19,6 +19,7 @@ import { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, f
19
19
  import { systemToInstructions, toolsToResponses, toolChoiceToResponses } from "./inbound-content-options";
20
20
  import { stabilizeClaudeInstructionsForPromptCache } from "./inbound-cache-stabilize";
21
21
  import { decodeReasoningEnvelope, encodeReasoningEnvelope, OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
22
+ import { inlineDocumentMarker } from "../responses/inline-document";
22
23
  import { createTranslatorBudget, type TranslatorBudget } from "../lib/translator-budget";
23
24
 
24
25
 
@@ -41,6 +42,26 @@ function imageBlockToInputImage(block: Rec): Rec | null {
41
42
  return null;
42
43
  }
43
44
 
45
+ function documentTitle(block: Rec): string | undefined {
46
+ return typeof block.title === "string" && block.title.length > 0 ? block.title : undefined;
47
+ }
48
+
49
+ /** An Anthropic base64 document as the Responses `input_file` block that carries its bytes. */
50
+ function documentBlockToInputFile(block: Rec): Rec | null {
51
+ const source = block.source;
52
+ if (!isRec(source) || source.type !== "base64") return null;
53
+ const mediaType = typeof source.media_type === "string" && source.media_type.length > 0
54
+ ? source.media_type
55
+ : "application/octet-stream";
56
+ if (typeof source.data !== "string" || source.data.length === 0) return null;
57
+ const title = documentTitle(block);
58
+ return {
59
+ type: "input_file",
60
+ file_data: `data:${mediaType};base64,${source.data}`,
61
+ ...(title !== undefined ? { filename: title } : {}),
62
+ };
63
+ }
64
+
44
65
  function toolResultOutput(block: Rec): string | Rec[] {
45
66
  const isError = block.is_error === true;
46
67
  const content = block.content;
@@ -55,9 +76,11 @@ function toolResultOutput(block: Rec): string | Rec[] {
55
76
  const img = imageBlockToInputImage(item);
56
77
  if (img) out.push(img);
57
78
  } else if (item.type === "document") {
58
- // Same marker as the user-message document case below: the model should see the
59
- // attachment happened instead of an empty tool output.
60
- out.push({ type: "input_text", text: `[document${typeof item.title === "string" ? `: ${item.title}` : ""}]` });
79
+ // Tool output has no structured document carrier on this route — the Responses tool
80
+ // output vocabulary has no input_file block, and every adapter's tool-result path
81
+ // flattens to text — so this keeps the #939 marker. The user-message branch below is
82
+ // where bytes survive. Recorded as the remaining half of #5212.
83
+ out.push({ type: "input_text", text: inlineDocumentMarker(documentTitle(item)) });
61
84
  }
62
85
  }
63
86
  if (isError) out.unshift({ type: "input_text", text: "[tool error]" });
@@ -208,9 +231,12 @@ function userMessageToItems(content: unknown, input: Rec[], elide: SkillElisionC
208
231
  break;
209
232
  }
210
233
  case "document":
211
- // No Responses equivalent for raw document blocks; surface the title so the
212
- // model at least sees the attachment happened.
213
- pending.push({ type: "input_text", text: `[document${typeof raw.title === "string" ? `: ${raw.title}` : ""}]` });
234
+ // A base64 document now rides the Responses input_file block, so a target with a
235
+ // counterpart receives the bytes instead of a sentence about them (#5212). Every other
236
+ // source is a reference this route cannot dereference, and keeps the marker #939
237
+ // introduced — which is also what a target with no document representation still sees.
238
+ pending.push(documentBlockToInputFile(raw)
239
+ ?? { type: "input_text", text: inlineDocumentMarker(documentTitle(raw)) });
214
240
  break;
215
241
  default:
216
242
  break; // thinking/redacted_thinking never appear in user messages; ignore unknowns