@bitkyc08/opencodex 2.89.0 → 2.91.0

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 (242) hide show
  1. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  2. package/gui/dist/assets/App-nZ34cR5G.js +58 -0
  3. package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-BuLy7hL1.js} +1 -1
  4. package/gui/dist/assets/{index-uchD-ngf.js → index-DGXS51sp.js} +15 -15
  5. package/gui/dist/assets/quota-summary-DO1Sx5TM.js +1 -0
  6. package/gui/dist/index.html +1 -1
  7. package/package.json +2 -2
  8. package/src/adapters/anthropic.ts +2 -5
  9. package/src/adapters/base.ts +4 -0
  10. package/src/adapters/claude-cli/adapter.ts +13 -7
  11. package/src/adapters/claude-cli/usage-admission.ts +28 -2
  12. package/src/adapters/coding-agent/turn.ts +14 -4
  13. package/src/adapters/cursor/protobuf-request.ts +3 -0
  14. package/src/adapters/google-progress-history.ts +19 -1
  15. package/src/adapters/google.ts +28 -9
  16. package/src/adapters/image.ts +5 -3
  17. package/src/adapters/input-media-guard.ts +5 -2
  18. package/src/adapters/kiro-refusal.ts +41 -1
  19. package/src/adapters/ollama-native.ts +1 -0
  20. package/src/adapters/openai-chat/messages.ts +34 -4
  21. package/src/adapters/openai-chat/passthrough.ts +37 -4
  22. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  23. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  24. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  25. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  26. package/src/adapters/openai-chat.ts +7 -6
  27. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  28. package/src/adapters/openai-responses/passthrough.ts +7 -2
  29. package/src/adapters/openai-responses/request-strips.ts +32 -0
  30. package/src/adapters/openai-responses/transport.ts +19 -1
  31. package/src/chat/inbound.ts +20 -2
  32. package/src/chat/outbound.ts +36 -4
  33. package/src/chat/reasoning-details.ts +137 -0
  34. package/src/claude/desktop-3p-library.ts +43 -3
  35. package/src/claude/desktop-3p.ts +20 -10
  36. package/src/claude/desktop-picker-profile.ts +18 -9
  37. package/src/claude/desktop-remote-store-io.ts +2 -2
  38. package/src/cli/capabilities-agents-routing.ts +50 -0
  39. package/src/cli/capabilities-base.ts +15 -2
  40. package/src/cli/capabilities-observe-system.ts +4 -4
  41. package/src/cli/codex-shim-autorestore.ts +5 -0
  42. package/src/cli/dispatch.ts +30 -0
  43. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  44. package/src/cli/fallback.ts +127 -0
  45. package/src/cli/help.ts +1 -0
  46. package/src/cli/index.ts +1 -2
  47. package/src/cli/link-apply-restart.ts +72 -0
  48. package/src/cli/link-child.ts +6 -2
  49. package/src/cli/link-cleanup.ts +95 -0
  50. package/src/cli/link-enrollment.ts +4 -0
  51. package/src/cli/link.ts +84 -9
  52. package/src/cli/provider.ts +44 -2
  53. package/src/cli/registry.ts +13 -1
  54. package/src/cli/restart-failure.ts +4 -0
  55. package/src/cli/runtime-api.ts +1 -1
  56. package/src/cli/status-codex-settings.ts +56 -0
  57. package/src/cli/status-fallback.ts +29 -0
  58. package/src/cli/status-install.ts +52 -0
  59. package/src/cli/status.ts +20 -1
  60. package/src/cli/system-restart-client.ts +32 -1
  61. package/src/cli/update-restart-child.ts +4 -3
  62. package/src/cli/update-restart-home.ts +20 -8
  63. package/src/client/connect.ts +82 -6
  64. package/src/client/home-unreachable.ts +16 -0
  65. package/src/client/hub-relay.ts +8 -2
  66. package/src/client/link-home.ts +119 -6
  67. package/src/client/link-parity-reporter.ts +107 -0
  68. package/src/client/link-relay-transport.ts +8 -1
  69. package/src/client/link-relay.ts +27 -3
  70. package/src/client/machine-listener.ts +83 -1
  71. package/src/client/runtime-restart.ts +127 -0
  72. package/src/client/runtime.ts +137 -25
  73. package/src/codex/account-lifecycle.ts +19 -6
  74. package/src/codex/account-store.ts +27 -8
  75. package/src/codex/account-usability.ts +4 -1
  76. package/src/codex/auth-api/account-list.ts +10 -5
  77. package/src/codex/auth-api/account-pause-group.ts +2 -22
  78. package/src/codex/auth-api/login-collision.ts +59 -0
  79. package/src/codex/auth-api/login-flow.ts +12 -7
  80. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  81. package/src/codex/auth-collision.ts +31 -4
  82. package/src/codex/auth-context.ts +58 -12
  83. package/src/codex/catalog/parsing.ts +4 -0
  84. package/src/codex/cli-update-plan.ts +18 -3
  85. package/src/codex/collision-identity.ts +78 -0
  86. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  87. package/src/codex/main-account-hard-lock.ts +14 -0
  88. package/src/codex/quota-history.ts +7 -0
  89. package/src/codex/quota.ts +10 -0
  90. package/src/codex/routing/cooldown-math.ts +5 -0
  91. package/src/codex/routing/failure-window.ts +61 -9
  92. package/src/codex/routing.ts +4 -3
  93. package/src/codex/shim-diagnostics.ts +2 -1
  94. package/src/codex/shim-fingerprint.ts +11 -2
  95. package/src/codex/shim-templates.ts +16 -5
  96. package/src/codex/shim-windows-refresh.ts +224 -0
  97. package/src/codex/shim.ts +45 -21
  98. package/src/codex/subagent-model-fallback.ts +24 -1
  99. package/src/combos/failover.ts +8 -1
  100. package/src/combos/index.ts +1 -0
  101. package/src/combos/resolve.ts +12 -0
  102. package/src/config/diagnostics.ts +5 -0
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/provider-validation.ts +7 -2
  105. package/src/config/schema/config-schema.ts +3 -0
  106. package/src/config/schema/fallback-policy.ts +40 -0
  107. package/src/generated/compatibility-version.json +360 -192
  108. package/src/generated/model-metadata.ts +1 -1
  109. package/src/images/loop.ts +2 -2
  110. package/src/lib/authorization-scheme.ts +95 -0
  111. package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
  112. package/src/lib/provider-admission-wait.ts +14 -0
  113. package/src/lib/provider-client-headers.ts +1 -0
  114. package/src/lib/redact.ts +2 -2
  115. package/src/lib/service-secrets.ts +37 -2
  116. package/src/lib/system-restart-contract.ts +24 -0
  117. package/src/lib/test-home-guard.ts +78 -11
  118. package/src/lib/translator-budget.ts +1 -1
  119. package/src/lib/upstream-retry.ts +15 -0
  120. package/src/link/child-parity.ts +124 -0
  121. package/src/link/codex-settings.ts +37 -0
  122. package/src/link/compatibility.ts +119 -0
  123. package/src/link/management-routes.ts +2 -0
  124. package/src/link/peer-report.ts +100 -0
  125. package/src/link/remote-platform.ts +78 -0
  126. package/src/link/ssh-argv.ts +19 -0
  127. package/src/link/status-projection.ts +24 -2
  128. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  129. package/src/oauth/account-import/parser.ts +15 -2
  130. package/src/oauth/account-import/types.ts +7 -0
  131. package/src/oauth/chatgpt-shared.ts +10 -1
  132. package/src/oauth/dsh.ts +45 -2
  133. package/src/oauth/index.ts +1 -1
  134. package/src/oauth/kimi.ts +2 -0
  135. package/src/oauth/kiro.ts +44 -1
  136. package/src/oauth/login-flow-state.ts +11 -2
  137. package/src/oauth/nous.ts +4 -1
  138. package/src/oauth/store.ts +2 -1
  139. package/src/protocols/codecs/chat.ts +3 -3
  140. package/src/protocols/encoders/adapter-events.ts +26 -2
  141. package/src/protocols/encoders/chat.ts +55 -3
  142. package/src/providers/claude-cli-usage.ts +20 -5
  143. package/src/providers/initial-model-selection.ts +2 -2
  144. package/src/providers/key-failover.ts +10 -2
  145. package/src/providers/key-store.ts +8 -3
  146. package/src/providers/mistral-transport.ts +240 -0
  147. package/src/providers/openai-sidecar.ts +5 -0
  148. package/src/providers/quota/anthropic-account-quota.ts +18 -6
  149. package/src/providers/quota/antigravity.ts +8 -10
  150. package/src/providers/quota/failure-codes.ts +18 -0
  151. package/src/providers/quota/vendor-probes-oauth.ts +12 -3
  152. package/src/providers/quota-routing-cache.ts +1 -1
  153. package/src/providers/quota.ts +5 -1
  154. package/src/providers/registry/entries-extended.ts +6 -1
  155. package/src/providers/routed-config.ts +1 -1
  156. package/src/providers/xai-transport.ts +2 -1
  157. package/src/quota-pool/assemble.ts +326 -0
  158. package/src/quota-pool/contract.ts +58 -0
  159. package/src/quota-pool/identity.ts +203 -0
  160. package/src/remote/features.ts +74 -0
  161. package/src/remote/protocol.ts +2 -0
  162. package/src/remote/served-by.ts +57 -0
  163. package/src/responses/audio.ts +46 -0
  164. package/src/responses/input-media.ts +37 -15
  165. package/src/responses/parser-content.ts +22 -27
  166. package/src/responses/parser.ts +7 -0
  167. package/src/responses/reasoning-envelope.ts +12 -0
  168. package/src/responses/reasoning-retention.ts +218 -11
  169. package/src/responses/schema.ts +3 -4
  170. package/src/responses/web-search-replay.ts +11 -2
  171. package/src/routing/fallback-policy.ts +290 -0
  172. package/src/routing/fallback-state.ts +179 -0
  173. package/src/server/audio-client.ts +2 -1
  174. package/src/server/audio-dictation.ts +2 -1
  175. package/src/server/audio-upstream.ts +2 -1
  176. package/src/server/auth-cors.ts +6 -5
  177. package/src/server/background-lifecycle.ts +4 -0
  178. package/src/server/chat-completions.ts +8 -1
  179. package/src/server/chat-native.ts +8 -7
  180. package/src/server/gui-session.ts +2 -1
  181. package/src/server/index/link-listener.ts +11 -1
  182. package/src/server/index/link-management.ts +47 -0
  183. package/src/server/index/link-relay-sessions.ts +11 -2
  184. package/src/server/index/optional-listeners.ts +7 -2
  185. package/src/server/index/serve-options.ts +27 -4
  186. package/src/server/index/websocket-handler.ts +10 -0
  187. package/src/server/lifecycle.ts +17 -1
  188. package/src/server/live.ts +5 -0
  189. package/src/server/management/context.ts +1 -1
  190. package/src/server/management/fallback-routes.ts +75 -0
  191. package/src/server/management/link-enrollment.ts +21 -3
  192. package/src/server/management/link-route-state.ts +2 -0
  193. package/src/server/management/link-routes.ts +12 -1
  194. package/src/server/management/oauth-account-routes.ts +2 -1
  195. package/src/server/management/quota-pool-routes.ts +24 -0
  196. package/src/server/management/route-registry.ts +3 -0
  197. package/src/server/management-api.ts +16 -0
  198. package/src/server/management-auth.ts +2 -1
  199. package/src/server/messages-native.ts +11 -0
  200. package/src/server/request-log.ts +16 -7
  201. package/src/server/responses/adapter-continuation.ts +2 -2
  202. package/src/server/responses/adapter-dispatch.ts +2 -2
  203. package/src/server/responses/agent-task-recovery.ts +1 -1
  204. package/src/server/responses/codex-ws-exchange.ts +3 -0
  205. package/src/server/responses/codex-ws-metadata.ts +44 -0
  206. package/src/server/responses/codex-ws-session.ts +21 -1
  207. package/src/server/responses/combo-stream-preflight.ts +7 -2
  208. package/src/server/responses/compact.ts +4 -2
  209. package/src/server/responses/core-auth.ts +31 -1
  210. package/src/server/responses/core-codex-account.ts +21 -14
  211. package/src/server/responses/core-combo.ts +75 -1
  212. package/src/server/responses/core-opaque-recovery.ts +20 -11
  213. package/src/server/responses/deepseek-image-files.ts +121 -0
  214. package/src/server/responses/empty-completion-guard.ts +8 -0
  215. package/src/server/responses/encrypted-payload.ts +3 -3
  216. package/src/server/responses/fetch-helpers.ts +30 -10
  217. package/src/server/responses/input-admission.ts +6 -2
  218. package/src/server/responses/main-hard-lock-routing.ts +85 -8
  219. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  220. package/src/server/responses/passthrough-completion.ts +41 -0
  221. package/src/server/responses/passthrough-delivery.ts +2 -0
  222. package/src/server/responses/passthrough-dispatch.ts +13 -13
  223. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  224. package/src/server/responses/policy-fallback.ts +44 -1
  225. package/src/server/responses/reasoning-archive-error.ts +21 -0
  226. package/src/server/responses/request-prepare.ts +19 -1
  227. package/src/server/responses/request-transport.ts +61 -3
  228. package/src/server/restart-replacement.ts +50 -2
  229. package/src/server/ws-bridge.ts +5 -0
  230. package/src/service/cli.ts +25 -2
  231. package/src/service/diagnostics.ts +20 -6
  232. package/src/service/orchestration.ts +17 -0
  233. package/src/service/status-json.ts +116 -0
  234. package/src/types/config.ts +7 -0
  235. package/src/types/fallback.ts +38 -0
  236. package/src/types/provider.ts +2 -0
  237. package/src/types/request.ts +16 -1
  238. package/src/types.ts +1 -0
  239. package/src/web-search/loop.ts +2 -2
  240. package/gui/dist/assets/App-BnRSujOq.js +0 -58
  241. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  242. package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
@@ -71,7 +71,8 @@ import {
71
71
  resolveCurrentProviderApiKeyTransport,
72
72
  } from "../../providers/api-key-selection";
73
73
  import { resolveAdapter, resolveWireProtocolOverride } from "../adapter-resolve";
74
- import { physicalSendObserverInit, providerFetch, sendWithConnectionPolicy } from "./fetch-helpers";
74
+ import { codexWsExecutorBinding, physicalSendObserverInit, providerFetch, sendWithConnectionPolicy } from "./fetch-helpers";
75
+ import { deepSeekImageFileBody, needsDeepSeekImageFiles } from "./deepseek-image-files";
75
76
  import type { ProviderFetchOptions } from "./fetch-helpers";
76
77
  import { captureConfigGeneration } from "../../lib/state-store-sweeper";
77
78
  import { recordAnthropicAccountQuotaFromHeadersForInstance, hasPassiveAccountQuota } from "../../providers/quota";
@@ -590,10 +591,62 @@ export async function prepareResponsesTransport(
590
591
  };
591
592
  }
592
593
  const build = resolved.buildRequest.bind(resolved);
594
+ const fileProvider = route.provider;
595
+ const fileProviderName = route.providerName;
596
+ // Persistence consent is configuration: capture its generation with the provider snapshot.
597
+ const fileConsentGeneration = captureConfigGeneration();
593
598
  resolved.buildRequest = async (requestParsed, incoming) => {
594
599
  const request = await build(requestParsed, incoming);
595
600
  // Capture at adapter creation, never from mutable serving state after an await.
596
601
  if (binding) requestBindings.set(request, binding);
602
+ const allowedImageFileModels = fileProvider.modelCapabilities
603
+ ? Object.entries(fileProvider.modelCapabilities)
604
+ .filter(([, cap]) => cap?.deepseekImageFiles === true)
605
+ .map(([model]) => model)
606
+ : [];
607
+ if (allowedImageFileModels.includes(request.outboundModelId ?? requestParsed.modelId) && needsDeepSeekImageFiles(request)) {
608
+ const consentModel = request.outboundModelId ?? requestParsed.modelId;
609
+ try {
610
+ // Ordinary uploads use provider pacing. A queued rebuild retains its enclosing
611
+ // fetch lease; reacquiring that same concurrency slot would deadlock at a limit of one.
612
+ const uploadFetch = providerFetch(fileProvider, options.codexWsRuntimeIdentity, {
613
+ httpOnly: true, providerName: fileProviderName, modelId: requestParsed.modelId,
614
+ beforeDispatch: () => {
615
+ if (!selectionIsCurrent(binding)) throw new Error("Credential selection changed before image upload");
616
+ // A queued upload must not outlive a revoked opt-in or a configuration reload.
617
+ if (captureConfigGeneration() !== fileConsentGeneration
618
+ || fileProvider.modelCapabilities?.[consentModel]?.deepseekImageFiles !== true) {
619
+ throw new Error("DeepSeek image file consent changed before image upload");
620
+ }
621
+ },
622
+ });
623
+ const uploadSignal = incoming.abortSignal ?? options.abortSignal ?? req.signal;
624
+ const pacedUpload: typeof fetch = incoming.dispatchPacingHeld
625
+ ? (async (input, init) => {
626
+ // The inference lease owns concurrency, but each physical upload still
627
+ // consumes an interval slot, including after a credential rebuild.
628
+ const slot = await waitForProviderRequestSlot(fileProviderName, fileProvider, requestParsed.modelId,
629
+ init?.signal ?? uploadSignal, { concurrency: false });
630
+ try { return await uploadFetch.unpacedFetch!(input, init); }
631
+ finally { releaseProviderRequestSlot(slot); }
632
+ }) as typeof fetch
633
+ : uploadFetch;
634
+ const body = await deepSeekImageFileBody(request, pacedUpload,
635
+ uploadSignal, {
636
+ allowedModels: allowedImageFileModels, budget: incoming.translatorBudget,
637
+ });
638
+ if (body !== undefined) {
639
+ request.releaseBodyObservation?.();
640
+ request.body = body;
641
+ const headers = new Headers(request.headers);
642
+ headers.delete("content-length");
643
+ request.headers = Object.fromEntries(headers);
644
+ request.releaseBodyObservation = incoming.translatorBudget.observeExternallyCapped(
645
+ "passthrough_serialization", Buffer.byteLength(body, "utf8"),
646
+ );
647
+ }
648
+ } catch (error) { request.releaseBodyObservation?.(); throw error; }
649
+ }
597
650
  return request;
598
651
  };
599
652
  if (resolved.runTurn) {
@@ -758,7 +811,8 @@ export async function prepareResponsesTransport(
758
811
  pacingChanged = false;
759
812
  }
760
813
  if (selectionIsCurrent(requestBindings.get(wireRequest)) && copilotSessionExpiresAt > Date.now() + 1000) {
761
- const fetchImpl = (route.provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? execute;
814
+ const ws = codexWsExecutorBinding(execute);
815
+ const fetchImpl = (route.provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? ws?.http ?? execute;
762
816
  const binding = requestBindings.get(wireRequest);
763
817
  const snapshot = anthropicInstance !== undefined && binding?.kind === "oauth"
764
818
  ? binding.snapshot : undefined;
@@ -798,7 +852,10 @@ export async function prepareResponsesTransport(
798
852
  passiveQuotaWriterGeneration = writerGeneration;
799
853
  }
800
854
  commitKeyAttemptSend();
801
- response = await sendWithConnectionPolicy(
855
+ // The WS executor dials its own socket: HTTP connection policy and body coding do not apply.
856
+ // WS eligibility (egress, pacing, URL) was checked for the provider it was built for; a
857
+ // provider reselected above was not, so it takes the HTTP fallback under its own route.
858
+ response = ws?.provider === route.provider ? await execute(destination, dispatchInit) : await sendWithConnectionPolicy(
802
859
  distributedKeyPhysicalFetch(anthropicBillingPhysicalFetch(fetchImpl, route.providerName, route.provider), route.providerName, route.provider, target),
803
860
  destination,
804
861
  { ...dispatchInit, redirect: "manual",
@@ -843,6 +900,7 @@ export async function prepareResponsesTransport(
843
900
  adapterName: nextAdapter.name, oauthCredentialSnapshot: replayOAuthCredentialSnapshot });
844
901
  const rebuilt = await nextAdapter.buildRequest(requestParsed, {
845
902
  headers: requestState.selectedForwardHeaders, providerName: route.providerName, translatorBudget,
903
+ dispatchPacingHeld: true,
846
904
  ...(imageTierBias > 0 ? { imageTierBias } : {}),
847
905
  });
848
906
  const bodySize = checkOutboundBodySize(rebuilt.body, config.maxUpstreamBodyBytes);
@@ -214,6 +214,9 @@ export interface ReplacementStartIo extends ReplacementReadinessIo {
214
214
  logPath?: string | null;
215
215
  parentPid?: number;
216
216
  retryDelayMs?: number;
217
+ /** Exit waits for a terminated not-ready replacement; tests shorten them. */
218
+ termWaitMs?: number;
219
+ killWaitMs?: number;
217
220
  }
218
221
 
219
222
  export interface ReplacementStartRequest {
@@ -227,10 +230,19 @@ export interface ReplacementStartRequest {
227
230
  waitForHealth: boolean;
228
231
  /** The replacement's environment, already prepared by the caller; the parent marker is added here. */
229
232
  env: NodeJS.ProcessEnv;
233
+ /**
234
+ * What a replacement still unready at the deadline means. `handoff` (the default, every
235
+ * standalone restart) hands off to it anyway, because the parent is about to exit and release
236
+ * what it may be waiting for. `terminate` is for a parent that keeps serving when the
237
+ * replacement fails (the client runtime): the replacement is stopped, its exit is observed, and
238
+ * the call rejects with `not_ready`, so nothing that may still bind the port survives.
239
+ */
240
+ notReady?: "handoff" | "terminate";
230
241
  }
231
242
 
232
243
  type AttemptOutcome =
233
- | { kind: "spawned" | "ready" | "not-ready"; pid: number | undefined }
244
+ | { kind: "spawned" | "ready"; pid: number | undefined }
245
+ | { kind: "not-ready"; pid: number | undefined; terminate: () => Promise<boolean> }
234
246
  | { kind: "early-exit"; pid: number | undefined; code: number | null; signal: NodeJS.Signals | null };
235
247
 
236
248
  function pinnedPort(port: number | undefined): number | undefined {
@@ -314,7 +326,11 @@ function runReplacementAttempt(
314
326
  void waitForReplacementReady(child.pid, parentPid, expectedPort, { ...io, cancelled: () => settled }).then(
315
327
  // Never kill a live ordinary start at its valid reclaim boundary. Parent exit is the
316
328
  // final resource release the child may still be waiting for.
317
- ready => { settle({ kind: ready ? "ready" : "not-ready", pid: child.pid }); },
329
+ ready => {
330
+ settle(ready
331
+ ? { kind: "ready", pid: child.pid }
332
+ : { kind: "not-ready", pid: child.pid, terminate: () => terminateReplacement(child, io) });
333
+ },
318
334
  err => { settle({ error: err }); },
319
335
  );
320
336
  };
@@ -369,6 +385,11 @@ export async function spawnReplacementStart(
369
385
  continue;
370
386
  }
371
387
  if (outcome.kind === "not-ready") {
388
+ if (request.notReady === "terminate") {
389
+ const exited = await outcome.terminate();
390
+ log?.note(`replacement pid ${pid} did not answer within the readiness window; ${exited ? "stopped it" : "it did not exit after SIGKILL"}`);
391
+ throw handoffError(exited ? "not_ready" : "not_ready_unstopped");
392
+ }
372
393
  console.warn("Restart replacement is still starting after the readiness window; handing off to it anyway");
373
394
  log?.note(`replacement pid ${pid} is still starting after the readiness window; handing off anyway`);
374
395
  } else {
@@ -380,3 +401,30 @@ export async function spawnReplacementStart(
380
401
  log?.close();
381
402
  }
382
403
  }
404
+
405
+ /** Exit-observation budgets for {@link terminateReplacement}. */
406
+ export const REPLACEMENT_TERM_WAIT_MS = 3_000;
407
+ export const REPLACEMENT_KILL_WAIT_MS = 2_000;
408
+
409
+ function waitForChildExit(child: ChildProcess, ms: number, sleep: (ms: number) => Promise<void>): Promise<boolean> {
410
+ if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true);
411
+ return new Promise<boolean>(resolve => {
412
+ let done = false;
413
+ const finish = (exited: boolean) => { if (!done) { done = true; child.off("exit", onExit); resolve(exited); } };
414
+ const onExit = () => finish(true);
415
+ child.once("exit", onExit);
416
+ void sleep(ms).then(() => finish(child.exitCode !== null || child.signalCode !== null));
417
+ });
418
+ }
419
+
420
+ /**
421
+ * Stop a replacement that never answered: SIGTERM, wait for its exit, then SIGKILL and wait again.
422
+ * True only once the exit was observed; a kill call alone is not proof the port is free.
423
+ */
424
+ async function terminateReplacement(child: ChildProcess, io: ReplacementStartIo): Promise<boolean> {
425
+ const sleep = io.sleep ?? Bun.sleep;
426
+ try { child.kill("SIGTERM"); } catch { /* exited between the check and the signal */ }
427
+ if (await waitForChildExit(child, io.termWaitMs ?? REPLACEMENT_TERM_WAIT_MS, sleep)) return true;
428
+ try { child.kill("SIGKILL"); } catch { /* exited between the check and the signal */ }
429
+ return waitForChildExit(child, io.killWaitMs ?? REPLACEMENT_KILL_WAIT_MS, sleep);
430
+ }
@@ -12,6 +12,7 @@ import { sseDataPayload as parseSseBlock } from "./sse-payload-rewrite";
12
12
  import { safeResponseHeaders } from "./safe-response-headers";
13
13
  import type { AudioSocketTarget } from "./audio-dictation";
14
14
  import type { CodexWebSocketActivity } from "../codex/websocket-activity";
15
+ import { AGENT_ASSERTION_HEADER } from "../lib/authorization-scheme";
15
16
 
16
17
  export { safeResponseHeaders } from "./safe-response-headers";
17
18
 
@@ -131,6 +132,10 @@ export function selectForwardHeaders(
131
132
  if (codexOverride) {
132
133
  selected.set("authorization", `Bearer ${codexOverride.accessToken}`);
133
134
  selected.set("chatgpt-account-id", codexOverride.chatgptAccountId);
135
+ } else {
136
+ // Kept for the WS turn pipeline, which drops it again everywhere except first-party Direct.
137
+ const assertion = headers.get(AGENT_ASSERTION_HEADER);
138
+ if (assertion) selected.set(AGENT_ASSERTION_HEADER, assertion);
134
139
  }
135
140
  return selected;
136
141
  }
@@ -11,6 +11,7 @@ import { statusWinswRaw, type WinswStatus } from "../lib/winsw";
11
11
  import { withWindowsServiceMutationLock } from "../lib/windows-service-mutation-lock";
12
12
  import { maybeShowStarPrompt } from "../cli/star-prompt";
13
13
  import { serviceStatusReport } from "./diagnostics";
14
+ import { collectServiceStatusJson } from "./status-json";
14
15
  import { assertServiceAuthEnvironment } from "./auth-preflight";
15
16
  import { assertServiceEnvironmentMatchesInstall } from "./guards";
16
17
  import { resolveServiceListenPort } from "./definition";
@@ -47,10 +48,14 @@ export function normalizeServiceSubcommand(sub?: string): string {
47
48
  return sub ?? "install";
48
49
  }
49
50
 
51
+ const SERVICE_JSON_STATUS_ONLY = "--json applies to `ocx service status` only.";
52
+
50
53
  export interface ParsedServiceArgs {
51
54
  sub: string;
52
55
  backend: ServiceBackend | null;
53
56
  invalid: string[];
57
+ /** `--json`: accepted for `status` only; planServiceCommand rejects it elsewhere. */
58
+ json: boolean;
54
59
  }
55
60
 
56
61
  export type ServiceInstallationState = "installed" | "absent" | "unknown";
@@ -140,6 +145,9 @@ export function planServiceCommand(
140
145
  if (parsed.invalid.length > 0) {
141
146
  return { ok: false, message: `Unknown service option: ${parsed.invalid.join(" ")}` };
142
147
  }
148
+ if (parsed.json && parsed.sub !== "status") {
149
+ return { ok: false, message: SERVICE_JSON_STATUS_ONLY };
150
+ }
143
151
  if (parsed.backend && parsed.sub !== "install") {
144
152
  return { ok: false, message: "--native/--scheduler apply to `ocx service install` only; other subcommands use the installed backend." };
145
153
  }
@@ -174,9 +182,11 @@ export function planServiceCommand(
174
182
  export function parseServiceArgs(args: string[]): ParsedServiceArgs {
175
183
  let sub: string | undefined;
176
184
  let backend: ServiceBackend | null = null;
185
+ let json = false;
177
186
  const invalid: string[] = [];
178
187
  for (const arg of args) {
179
- if (arg === "--native") {
188
+ if (arg === "--json") json = true;
189
+ else if (arg === "--native") {
180
190
  if (backend === "scheduler") { invalid.push("--native (conflicts with --scheduler)"); continue; }
181
191
  backend = "native";
182
192
  }
@@ -188,7 +198,7 @@ export function parseServiceArgs(args: string[]): ParsedServiceArgs {
188
198
  else if (sub === undefined) sub = arg;
189
199
  else invalid.push(arg);
190
200
  }
191
- return { sub: normalizeServiceSubcommand(sub), backend, invalid };
201
+ return { sub: normalizeServiceSubcommand(sub), backend, invalid, json };
192
202
  }
193
203
 
194
204
  /** Remove the service credential only when no client connection can own it. */
@@ -243,6 +253,12 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
243
253
  process.exitCode = 1;
244
254
  return;
245
255
  }
256
+ // Answered before platformOps() resolves: the JSON status reaches no install, start, stop,
257
+ // repair or migration path, and an unsupported platform still gets a document, not an exit.
258
+ if (command === "status" && parsed.json) {
259
+ console.log(JSON.stringify(await collectServiceStatusJson(), null, 2));
260
+ return;
261
+ }
246
262
  if (command === "repair" || command === "restart") {
247
263
  const verb: ServiceRepairVerb = command === "restart" ? "restart" : "repair";
248
264
  assertServiceEnvironmentMatchesInstall();
@@ -488,6 +504,13 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
488
504
  };
489
505
 
490
506
  const preliminary = parseServiceArgs(filteredArgs);
507
+ // `claim` parses its own --json. Any other verb is refused here, before the Desktop check
508
+ // and the Windows mutation lock, so a misplaced --json never reaches either.
509
+ if (preliminary.json && preliminary.sub !== "status" && preliminary.sub !== "claim") {
510
+ console.error(SERVICE_JSON_STATUS_ONLY);
511
+ process.exitCode = 1;
512
+ return;
513
+ }
491
514
  if (preliminary.invalid.length === 0) {
492
515
  const refusal = desktopServiceCommandRefusal(preliminary.sub, undefined, supervisionLatch);
493
516
  if (refusal) {
@@ -282,16 +282,30 @@ export function diagnoseService(): ServiceDiagnostic {
282
282
  const enabled = installed && (() => { try { return sh(`systemctl --user is-enabled ${TASK}`) === "enabled"; } catch { return false; } })();
283
283
  const running = installed && (() => { try { return sh(`systemctl --user is-active ${TASK}`) === "active"; } catch { return false; } })();
284
284
  const stale = installed && bakedServicePathsDiagnostic() !== null;
285
- const viable = installed && enabled && running && !stale;
286
- const summary = !installed ? `not installed (${diagnostics})`
287
- : stale ? `installed, but stale (systemd user; ${diagnostics})`
288
- : viable ? `installed, enabled and running (systemd user; ${diagnostics})`
289
- : `installed, but ${!enabled ? "disabled" : "not running"} (systemd user; ${diagnostics})`;
290
- return { supported: true, installed, enabled, running, viable, startable: installed && !stale, stale, conflict: false, backend: "systemd", summary };
285
+ return deriveSystemdServiceDiagnostic({ installed, enabled, running, stale, diagnostics });
291
286
  }
292
287
  return { supported: false, installed: false, enabled: false, running: false, viable: false, startable: false, stale: false, conflict: false, backend: null, summary: `unsupported on ${process.platform}` };
293
288
  }
294
289
 
290
+ export interface SystemdServiceDiagnosticInputs {
291
+ installed: boolean;
292
+ enabled: boolean;
293
+ running: boolean;
294
+ stale: boolean;
295
+ diagnostics: string;
296
+ }
297
+
298
+ /** The systemd user-unit verdict, pure so its states are testable without a user bus. */
299
+ export function deriveSystemdServiceDiagnostic(inputs: SystemdServiceDiagnosticInputs): ServiceDiagnostic {
300
+ const { installed, enabled, running, stale, diagnostics } = inputs;
301
+ const viable = installed && enabled && running && !stale;
302
+ const summary = !installed ? `not installed (${diagnostics})`
303
+ : stale ? `installed, but stale (systemd user; ${diagnostics})`
304
+ : viable ? `installed, enabled and running (systemd user; ${diagnostics})`
305
+ : `installed, but ${!enabled ? "disabled" : "not running"} (systemd user; ${diagnostics})`;
306
+ return { supported: true, installed, enabled, running, viable, startable: installed && !stale, stale, conflict: false, backend: "systemd", summary };
307
+ }
308
+
295
309
  export function serviceStatusSummary(): string {
296
310
  return diagnoseService().summary;
297
311
  }
@@ -238,6 +238,11 @@ export interface ServiceInstallPreparationDeps extends RuntimePreflightDeps {
238
238
  supervisionLatch?: SupervisionLatch;
239
239
  diagnose?: () => ServiceDiagnostic;
240
240
  managerOps?: (backend: ServiceBackend) => ServiceInstallCleanupOps | null;
241
+ /**
242
+ * Task Scheduler only: ends surviving launcher/wrapper processes of THIS
243
+ * installation. Scoped to canonical script/launcher tokens, never a bare PID.
244
+ */
245
+ killServiceWrappers?: () => void;
241
246
  stopTrackedProxy?: () => Promise<unknown>;
242
247
  platform?: NodeJS.Platform;
243
248
  }
@@ -279,6 +284,18 @@ export async function prepareServiceInstall(
279
284
  if (!manager) throw new Error(`Background service manager is unavailable for ${backend}.`);
280
285
  if (manager.status() !== null) manager.stop();
281
286
  }
287
+ if (platform === "win32") {
288
+ // `schtasks /end` ends the task instance but leaves the cmd `:loop` wrapper
289
+ // alive to respawn the proxy mid-install — the same survivor
290
+ // `ocx stop` kills via killWindowsServiceWrapperProcesses. Run it
291
+ // after every manager has been asked to stop and before the tracked-proxy
292
+ // kill so a child respawned in that gap is still caught below. It also runs
293
+ // when no manager stop happened: a registration deleted mid-uninstall can
294
+ // leave the detached wrapper looping. A manager stop that refused propagates
295
+ // above before this runs, so the respawner of a still-running service is
296
+ // never killed.
297
+ (deps.killServiceWrappers ?? killWindowsServiceWrapperProcesses)();
298
+ }
282
299
  await (deps.stopTrackedProxy ?? stopTrackedProxyIfRunning)();
283
300
  }
284
301
 
@@ -0,0 +1,116 @@
1
+ import { diagnoseService, type ServiceDiagnostic } from "./diagnostics";
2
+ import { desktopLoginStatusSummary, diagnoseDesktopStartup } from "./desktop-startup";
3
+ import { confirmServiceServing } from "./health";
4
+ import { ownershipMutationLeaseStatusLine } from "./ownership-mutation-lease.mjs";
5
+ import { LABEL, TASK, serviceLogPath, serviceStatePaths } from "./state";
6
+ import { WINSW_SERVICE_ID } from "../lib/winsw";
7
+ import { runPassiveRead } from "../lib/passive-read";
8
+
9
+ /** The service manager holding the registration; `none` when nothing is registered. */
10
+ export type ServiceStatusBackend = "launchd" | "systemd" | "schtasks" | "winsw" | "none";
11
+
12
+ /**
13
+ * `ocx service status --json`. Additive within `schemaVersion: 1`.
14
+ *
15
+ * `running` is the service manager's own verdict — the value `ocx status --json` already
16
+ * reports as `startup.serviceRunning` — and `serving` says separately whether a proxy
17
+ * answers on the installed port, because a loaded job that never bound is the failure the
18
+ * text report exists to show.
19
+ */
20
+ export interface ServiceStatusJson {
21
+ schemaVersion: 1;
22
+ platform: NodeJS.Platform;
23
+ supported: boolean;
24
+ backend: ServiceStatusBackend;
25
+ label: string | null;
26
+ installed: boolean;
27
+ enabled: boolean;
28
+ running: boolean;
29
+ viable: boolean;
30
+ stale: boolean;
31
+ conflict: boolean;
32
+ serving: { probed: boolean; ok: boolean; port: number | null };
33
+ summary: string;
34
+ logPath: string;
35
+ ownershipLease: string | null;
36
+ desktopLogin: string | null;
37
+ }
38
+
39
+ export interface ServiceStatusJsonDeps {
40
+ platform?: NodeJS.Platform;
41
+ diagnose?: () => ServiceDiagnostic;
42
+ serving?: () => Promise<{ ok: boolean; port: number }>;
43
+ logPath?: () => string;
44
+ leaseLine?: () => string | null;
45
+ desktopLogin?: () => string | null;
46
+ }
47
+
48
+ export function serviceStatusBackend(diag: ServiceDiagnostic): ServiceStatusBackend {
49
+ if (!diag.supported || !diag.installed) return "none";
50
+ switch (diag.backend) {
51
+ case "launchd": return "launchd";
52
+ case "systemd": return "systemd";
53
+ case "scheduler": return "schtasks";
54
+ case "native": return "winsw";
55
+ default: return "none";
56
+ }
57
+ }
58
+
59
+ const BACKEND_LABEL: Record<ServiceStatusBackend, string | null> = {
60
+ launchd: LABEL,
61
+ systemd: `${TASK}.service`,
62
+ schtasks: TASK,
63
+ winsw: WINSW_SERVICE_ID,
64
+ none: null,
65
+ };
66
+
67
+ /**
68
+ * Passive by construction: it performs the reads `ocx service status` already performs
69
+ * (manager diagnosis, the loopback serving probe only when a registration exists, the
70
+ * ownership-lease and Desktop login lines) and nothing else. The caller answers before
71
+ * `platformOps()` is resolved, so no install, start, stop or repair path is reachable.
72
+ * The whole collection runs in a passive-read scope: the serving probe resolves the port
73
+ * through loadConfig(), which outside that scope hardens config modes and backs up a
74
+ * malformed config — writes an inventory read must not make.
75
+ */
76
+ export function collectServiceStatusJson(deps: ServiceStatusJsonDeps = {}): Promise<ServiceStatusJson> {
77
+ return runPassiveRead(() => collectPassively(deps));
78
+ }
79
+
80
+ async function collectPassively(deps: ServiceStatusJsonDeps): Promise<ServiceStatusJson> {
81
+ const diag = (deps.diagnose ?? diagnoseService)();
82
+ const backend = serviceStatusBackend(diag);
83
+ let serving: ServiceStatusJson["serving"] = { probed: false, ok: false, port: null };
84
+ if (diag.installed) {
85
+ // A probe that throws (config load, port resolution) must not cost the caller the whole
86
+ // document; it is reported as probed, not serving, with no port.
87
+ try {
88
+ const result = await (deps.serving ?? (() => confirmServiceServing({ timeoutMs: 1_500 })))();
89
+ serving = { probed: true, ok: result.ok, port: result.port };
90
+ } catch {
91
+ serving = { probed: true, ok: false, port: null };
92
+ }
93
+ }
94
+ const desktopLogin = deps.desktopLogin ?? (() => {
95
+ const desktop = diagnoseDesktopStartup();
96
+ return desktop ? desktopLoginStatusSummary(desktop) : null;
97
+ });
98
+ return {
99
+ schemaVersion: 1,
100
+ platform: deps.platform ?? process.platform,
101
+ supported: diag.supported,
102
+ backend,
103
+ label: BACKEND_LABEL[backend],
104
+ installed: diag.installed,
105
+ enabled: diag.enabled,
106
+ running: diag.running,
107
+ viable: diag.viable,
108
+ stale: diag.stale,
109
+ conflict: diag.conflict,
110
+ serving,
111
+ summary: diag.summary,
112
+ logPath: (deps.logPath ?? serviceLogPath)(),
113
+ ownershipLease: (deps.leaseLine ?? (() => ownershipMutationLeaseStatusLine(serviceStatePaths())))() ?? null,
114
+ desktopLogin: desktopLogin(),
115
+ };
116
+ }
@@ -2,7 +2,9 @@ import type { OcxProviderConfig } from "./provider";
2
2
  import type { CodexAccount } from "./accounts";
3
3
  import type { AnthropicAccountPoolConfig, OcxAccountPoolRotationStrategy } from "./anthropic-account-pool";
4
4
  import type { AnthropicInstanceId } from "../providers/anthropic-instance-id";
5
+ import type { OcxFallbackConfig } from "./fallback";
5
6
  export type { AnthropicAccountPoolConfig, AnthropicModelRoute, OcxAccountPoolRotationStrategy, OcxAccountPoolQuotaWindow } from "./anthropic-account-pool";
7
+ export type { OcxFallbackConfig, OcxFallbackDeviceConfig, OcxFallbackKind, OcxFallbackSwitches } from "./fallback";
6
8
 
7
9
  /** Public inference API exposure. Responses and Chat Completions are always served. */
8
10
  export interface OcxApiSurfacesConfig {
@@ -1361,6 +1363,11 @@ export interface OcxConfig {
1361
1363
  tokenGuardian?: OcxTokenGuardianConfig;
1362
1364
  /** Additional exact origins allowed for CORS (e.g. HTTPS or chrome-extension://<id>). Loopback origins are always allowed. */
1363
1365
  corsAllowOrigins?: string[];
1366
+ /**
1367
+ * OCX-11 fallback off: refuse model or provider fallback per scope, and record per-device local
1368
+ * fallback permission. Absent keeps every chain as it was. See src/routing/fallback-policy.ts.
1369
+ */
1370
+ fallback?: OcxFallbackConfig;
1364
1371
  }
1365
1372
 
1366
1373
  export type OcxComboStrategy = "failover" | "round-robin" | "random" | "least-used" | "reset-window" | "jev";
@@ -0,0 +1,38 @@
1
+ /**
2
+ * OCX-11 fallback policy (`fallback` in config.json). Every leaf is optional and absence keeps
3
+ * today's behavior: model and provider fallback allowed, local fallback not granted.
4
+ *
5
+ * Contract and precedence: `src/routing/fallback-policy.ts` and `structure/fallback-policy.md`.
6
+ */
7
+
8
+ /** A fallback kind this policy governs. Intra-pool account or key rotation is not one of them. */
9
+ export type OcxFallbackKind = "model" | "provider";
10
+
11
+ export interface OcxFallbackSwitches {
12
+ /** false refuses a hop to another model of the same provider. */
13
+ model?: boolean;
14
+ /** false refuses a hop to another provider. */
15
+ provider?: boolean;
16
+ }
17
+
18
+ export interface OcxFallbackDeviceConfig extends OcxFallbackSwitches {
19
+ /**
20
+ * Permission only: true tells GOAT it may move this device's work to the device's own
21
+ * providers while the Home is unreachable. OpenCodex itself never serves a Child or Hub client
22
+ * from local providers; absent or false means no permission.
23
+ */
24
+ localFallback?: boolean;
25
+ }
26
+
27
+ export interface OcxFallbackConfig extends OcxFallbackSwitches {
28
+ /**
29
+ * Keyed by the provider a hop leaves: the resolved provider of the target that just failed or
30
+ * is known unavailable. In combo [a, b, c] with providers.b.provider false, a to b is allowed and
31
+ * b to c is refused.
32
+ */
33
+ providers?: Record<string, OcxFallbackSwitches>;
34
+ /** Keyed by an exact public selector: combo/<id>, policy/<id>, provider/model or an alias. */
35
+ routes?: Record<string, OcxFallbackSwitches>;
36
+ /** Keyed by a Remote Link id (lnk_…) or a Hub-client data key id. Read on the Home. */
37
+ devices?: Record<string, OcxFallbackDeviceConfig>;
38
+ }
@@ -288,6 +288,8 @@ export interface ModelCapabilities {
288
288
  /** Requested tier only; does not imply an upstream window or activate an unverified wire. */
289
289
  contextTier?: "default" | "long_context";
290
290
  video?: { processing?: "static" | "agentic" };
291
+ /** Opt in exact first-party DeepSeek models to one-hour image file persistence. */
292
+ deepseekImageFiles?: boolean;
291
293
  }
292
294
 
293
295
  export interface OcxProviderConfig {
@@ -322,8 +322,23 @@ export interface OcxDocumentContent {
322
322
  filename?: string;
323
323
  }
324
324
 
325
+ /**
326
+ * Inline audio carried by Codex's Responses input. Chat Completions accepts this as an
327
+ * `input_audio` content part only for WAV and MP3 payloads, so the normalized form keeps the
328
+ * base64 bytes and a wire-safe format label rather than the original data URL.
329
+ */
330
+ export interface OcxAudioContent {
331
+ type: "audio";
332
+ /** Base64 payload with no `data:` prefix. */
333
+ data: string;
334
+ /** Chat Completions input_audio supports WAV and MP3. */
335
+ format: "mp3" | "wav";
336
+ /** Text marker for wires that cannot carry the bytes. */
337
+ text: string;
338
+ }
339
+
325
340
  /** A user/developer message content part: text, native media, or an attached document. */
326
- export type OcxContentPart = OcxTextContent | OcxImageContent | OcxVideoContent | OcxDocumentContent;
341
+ export type OcxContentPart = OcxTextContent | OcxImageContent | OcxVideoContent | OcxDocumentContent | OcxAudioContent;
327
342
 
328
343
  export interface OcxThinkingContent {
329
344
  type: "thinking";
package/src/types.ts CHANGED
@@ -51,6 +51,7 @@ export type {
51
51
  OcxToolResultMessage,
52
52
  OcxTextContent,
53
53
  OcxImageContent,
54
+ OcxAudioContent,
54
55
  OcxDocumentContent,
55
56
  OcxContentPart,
56
57
  OcxThinkingContent,
@@ -24,7 +24,7 @@ import type { WebSearchBackendId } from "./index";
24
24
  import { pacingHeaderDeadline } from "./header-deadline";
25
25
  import { redactSecretString } from "../lib/redact";
26
26
  import { readBoundedResponseBody } from "../lib/bounded-body";
27
- import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait, isNonReplayableResponse, SendBudgetExhaustedError } from "../lib/upstream-retry";
27
+ import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait, isNonReplayableResponse, isSameTargetRetryHold, SendBudgetExhaustedError } from "../lib/upstream-retry";
28
28
  import { rateLimitRetryDelayMs } from "../providers/key-failover";
29
29
  import { RequestPacingQueueOverloadError } from "../providers/request-pacing";
30
30
  import {
@@ -670,7 +670,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
670
670
  // Same-target 429 wait-and-retry (opt-in `retryOn429`) BEFORE key rotation: a primary-key
671
671
  // rate-limit blip replays on the SAME key; rotation only runs after attempts exhaust.
672
672
  while (
673
- prepared.response.status === 429
673
+ prepared.response.status === 429 && !isSameTargetRetryHold(prepared.response)
674
674
  && rateLimitRetryPolicy !== null
675
675
  && rateLimitRetries < rateLimitRetryPolicy.attempts
676
676
  ) {