@bitkyc08/opencodex 2.25.0 → 2.26.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 (99) hide show
  1. package/bin/ocx.mjs +59 -7
  2. package/gui/dist/assets/index-RL6b1bTV.js +102 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/base.ts +18 -0
  6. package/src/adapters/client-fingerprint.ts +0 -2
  7. package/src/adapters/cursor/http1-bidi.ts +361 -0
  8. package/src/adapters/cursor/live-models.ts +117 -30
  9. package/src/adapters/cursor/live-transport.ts +137 -56
  10. package/src/adapters/cursor/native-exec-fs.ts +1 -1
  11. package/src/adapters/cursor/native-exec-network.ts +1 -1
  12. package/src/adapters/cursor/native-exec-shell.ts +0 -1
  13. package/src/adapters/cursor/protobuf-request.ts +89 -21
  14. package/src/adapters/cursor/tool-definitions.ts +18 -7
  15. package/src/adapters/cursor/tool-result-normalize.ts +92 -0
  16. package/src/adapters/cursor/transport.ts +7 -0
  17. package/src/adapters/cursor.ts +2 -0
  18. package/src/adapters/google-http.ts +38 -10
  19. package/src/adapters/google.ts +23 -3
  20. package/src/adapters/openai-chat.ts +38 -13
  21. package/src/adapters/openai-responses.ts +129 -9
  22. package/src/adapters/registry.ts +30 -1
  23. package/src/bridge.ts +50 -12
  24. package/src/chat/inbound.ts +1 -0
  25. package/src/claude/agents-inject.ts +3 -2
  26. package/src/cli/dispatch.ts +19 -6
  27. package/src/cli/help.ts +2 -1
  28. package/src/cli/integrations.ts +35 -0
  29. package/src/cli/minimax.ts +8 -2
  30. package/src/cli/registry.ts +13 -2
  31. package/src/clients/config-export.ts +120 -1
  32. package/src/codex/account-store.ts +17 -1
  33. package/src/codex/auth-api.ts +51 -17
  34. package/src/codex/catalog/effort.ts +8 -5
  35. package/src/codex/catalog/provider-fetch.ts +39 -27
  36. package/src/codex/catalog/sync.ts +30 -8
  37. package/src/codex/inject.ts +39 -13
  38. package/src/codex/main-account.ts +29 -1
  39. package/src/codex/plan-from-token.ts +140 -0
  40. package/src/codex/plan.ts +25 -0
  41. package/src/codex/prompt-journal.ts +6 -2
  42. package/src/codex/quota-rejection.ts +21 -7
  43. package/src/codex/refresh.ts +4 -2
  44. package/src/codex/sync.ts +106 -3
  45. package/src/codex/warmup.ts +187 -81
  46. package/src/config.ts +85 -46
  47. package/src/generated/compatibility-version.json +127 -83
  48. package/src/grok/inject.ts +1 -1
  49. package/src/images/loop.ts +1 -0
  50. package/src/integrations/registry.ts +7 -0
  51. package/src/lab/automation/config-persistence.ts +2 -2
  52. package/src/lab/automation/persistence.ts +2 -2
  53. package/src/lab/ledger/purge.ts +2 -2
  54. package/src/lab/subject/behavior-fingerprint.ts +2 -2
  55. package/src/lib/config-ownership.ts +5 -2
  56. package/src/lib/destination-policy.ts +18 -1
  57. package/src/lib/provider-outbound.ts +7 -0
  58. package/src/lib/redact.ts +11 -0
  59. package/src/lib/tool-argument-integers.ts +50 -6
  60. package/src/lib/upstream-http-version.ts +57 -0
  61. package/src/lib/upstream-retry.ts +2 -1
  62. package/src/lib/windows-atomic-replace.ts +155 -0
  63. package/src/lib/windows-service-wrappers.ts +72 -0
  64. package/src/oauth/google-antigravity.ts +2 -2
  65. package/src/oauth/index.ts +60 -8
  66. package/src/providers/antigravity-models.ts +58 -4
  67. package/src/providers/command-code-efforts.ts +36 -9
  68. package/src/providers/context-cap.ts +9 -0
  69. package/src/providers/derive.ts +4 -0
  70. package/src/providers/fastwire.ts +453 -0
  71. package/src/providers/registry.ts +21 -1
  72. package/src/providers/service-tier.ts +173 -89
  73. package/src/responses/parser.ts +25 -16
  74. package/src/responses/reasoning-replay-cache.ts +30 -0
  75. package/src/responses/thought-signature-replay.ts +74 -4
  76. package/src/router.ts +6 -0
  77. package/src/routing/compatibility/behavior.ts +27 -16
  78. package/src/server/index.ts +10 -1
  79. package/src/server/management/oauth-account-routes.ts +10 -2
  80. package/src/server/management/shared.ts +1 -1
  81. package/src/server/management/system-routes.ts +15 -0
  82. package/src/server/request-log.ts +57 -3
  83. package/src/server/responses/agent-task-recovery.ts +6 -1
  84. package/src/server/responses/core.ts +209 -40
  85. package/src/server/responses/fetch-helpers.ts +15 -36
  86. package/src/server/responses/responses-field-backfill.ts +173 -0
  87. package/src/server/responses-reasoning-summary-rewrite.ts +171 -0
  88. package/src/service.ts +38 -36
  89. package/src/storage/cleanup.ts +2 -2
  90. package/src/tray/windows.ts +3 -2
  91. package/src/types.ts +101 -14
  92. package/src/update/job.ts +9 -18
  93. package/src/update/transactional-install.d.mts +22 -0
  94. package/src/update/transactional-install.mjs +259 -0
  95. package/src/usage/cost.ts +34 -5
  96. package/src/usage/log.ts +74 -4
  97. package/src/vision/anthropic-describe.ts +10 -6
  98. package/src/web-search/anthropic-executor.ts +8 -6
  99. package/gui/dist/assets/index-DxJ7kXj9.js +0 -102
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { IncomingMeta, ProviderAdapter } from "./base";
3
- import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage } from "../types";
3
+ import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../types";
4
4
  import { catalogModelSupportsReasoningSummaries } from "../codex/catalog";
5
5
  import { COMPACT_PROMPT, decodeCompactionSummary, SUMMARY_PREFIX } from "../responses/compaction";
6
6
  import { collectResponsesToolGroups } from "../responses/tool-groups";
@@ -12,6 +12,9 @@ import { modelRecordValue } from "../reasoning-effort";
12
12
  import type { TranslatorBudget } from "../lib/translator-budget";
13
13
  import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-compat";
14
14
  import { openaiResponsesUrl } from "./openai-responses-url";
15
+ import {
16
+ createAdapterTierMetadata,
17
+ } from "../providers/fastwire";
15
18
 
16
19
  // Headers relayed verbatim from the caller in OAuth-passthrough ("forward") mode.
17
20
  // Exported so the web-search sidecar reuses the exact same forwarded-auth set for its ChatGPT call.
@@ -572,6 +575,15 @@ function toolOutputText(output: unknown): string {
572
575
  * expansion misses (proxy restart, unrecorded prior turn), previous_response_id is stripped
573
576
  * (the ChatGPT backend rejects it), so the delta may carry items that reference now-absent
574
577
  * prior items and 400 upstream:
578
+ * - `function_call`/`local_shell_call`/`custom_tool_call` without their paired output item
579
+ * ("No tool output found for tool call <call_id>"). A stateless upstream cannot resolve
580
+ * the pair from its own storage, so a placeholder output is synthesized to keep the
581
+ * turn continuable without pretending the result was real. Synthetic outputs are
582
+ * emitted after the complete parallel call batch, in call order alongside any real
583
+ * outputs, so the adjacency normalizer can still recognize the batch as one
584
+ * reasoning-bearing assistant turn (#1477). Gated on
585
+ * `synthesizeMissingCallOutputs` (stateless AND non-forward wires); forward replay keeps
586
+ * fail-closed behavior.
575
587
  * - `function_call_output`/`custom_tool_call_output` without their paired call item
576
588
  * ("No tool call found for function call output with call_id ..."). Converted to user
577
589
  * messages so the result text survives. `function_call_output` also pairs with
@@ -608,26 +620,38 @@ function backfillWebSearchQueries(body: unknown): unknown {
608
620
  return changed ? { ...body, input } : body;
609
621
  }
610
622
 
611
- function repairOrphanedInputItems(body: unknown, dropReasoning: boolean): unknown {
623
+ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, synthesizeMissingCallOutputs = false): unknown {
612
624
  if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
613
625
  const input = body.input;
614
626
 
615
627
  const functionCallIds = new Set<string>();
616
628
  const customCallIds = new Set<string>();
629
+ const functionOutputIds = new Set<string>();
630
+ const customOutputIds = new Set<string>();
617
631
  for (const item of input) {
618
632
  if (!isPlainObject(item) || typeof item.call_id !== "string") continue;
619
633
  if (item.type === "function_call" || item.type === "local_shell_call") functionCallIds.add(item.call_id);
620
634
  else if (item.type === "custom_tool_call") customCallIds.add(item.call_id);
635
+ else if (item.type === "function_call_output") functionOutputIds.add(item.call_id);
636
+ else if (item.type === "custom_tool_call_output") customOutputIds.add(item.call_id);
621
637
  }
622
638
 
623
639
  let changed = false;
624
640
  const repaired: unknown[] = [];
641
+ const syntheticKeys = new Set<string>();
642
+ const pendingSyntheticOutputs: unknown[] = [];
643
+ const flushPendingSyntheticOutputs = (): void => {
644
+ if (pendingSyntheticOutputs.length === 0) return;
645
+ repaired.push(...pendingSyntheticOutputs);
646
+ pendingSyntheticOutputs.length = 0;
647
+ };
625
648
  for (const item of input) {
626
- if (!isPlainObject(item)) { repaired.push(item); continue; }
649
+ if (!isPlainObject(item)) { flushPendingSyntheticOutputs(); repaired.push(item); continue; }
627
650
  if (dropReasoning && item.type === "reasoning") { changed = true; continue; }
628
651
  const isFnOutput = item.type === "function_call_output";
629
652
  const isCustomOutput = item.type === "custom_tool_call_output";
630
653
  if (isFnOutput || isCustomOutput) {
654
+ flushPendingSyntheticOutputs();
631
655
  const callId = typeof item.call_id === "string" ? item.call_id : "";
632
656
  const paired = isFnOutput ? functionCallIds.has(callId) : customCallIds.has(callId);
633
657
  if (!paired) {
@@ -640,10 +664,83 @@ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean): unknow
640
664
  continue;
641
665
  }
642
666
  }
667
+ const isFnCall = item.type === "function_call" || item.type === "local_shell_call";
668
+ const isCustomCall = item.type === "custom_tool_call";
669
+ if (isFnCall || isCustomCall) {
670
+ repaired.push(item);
671
+ if (synthesizeMissingCallOutputs) {
672
+ const callId = typeof item.call_id === "string" ? item.call_id : "";
673
+ const hasOutput = isFnCall ? functionOutputIds.has(callId) : customOutputIds.has(callId);
674
+ if (!hasOutput && callId) {
675
+ changed = true;
676
+ const name = typeof item.name === "string" && item.name.length > 0 ? item.name : callId;
677
+ const text = `[ocx] no tool result was recorded for "${name}"; execution status unknown — do not treat this as success, failure, or user-provided input.`;
678
+ syntheticKeys.add(`${isFnCall ? "function" : "custom"}:${callId}`);
679
+ pendingSyntheticOutputs.push(isFnCall
680
+ ? { type: "function_call_output", call_id: callId, output: text }
681
+ : { type: "custom_tool_call_output", call_id: callId, output: text });
682
+ }
683
+ }
684
+ continue;
685
+ }
686
+ flushPendingSyntheticOutputs();
643
687
  repaired.push(item);
644
688
  }
689
+ flushPendingSyntheticOutputs();
690
+
691
+ const callKeyOf = (item: unknown): string | null => {
692
+ if (!isPlainObject(item) || typeof item.call_id !== "string") return null;
693
+ if (item.type === "function_call" || item.type === "local_shell_call") return `function:${item.call_id}`;
694
+ if (item.type === "custom_tool_call") return `custom:${item.call_id}`;
695
+ return null;
696
+ };
697
+ const outputKeyOf = (item: unknown): string | null => {
698
+ if (!isPlainObject(item) || typeof item.call_id !== "string") return null;
699
+ if (item.type === "function_call_output") return `function:${item.call_id}`;
700
+ if (item.type === "custom_tool_call_output") return `custom:${item.call_id}`;
701
+ return null;
702
+ };
703
+ const reorderBatchOutputs = (items: unknown[]): unknown[] => {
704
+ const ordered: unknown[] = [];
705
+ let index = 0;
706
+ while (index < items.length) {
707
+ const key = callKeyOf(items[index]);
708
+ if (key === null) { ordered.push(items[index]); index += 1; continue; }
709
+ const batch: unknown[] = [];
710
+ const batchKeys: string[] = [];
711
+ let cursor = index;
712
+ while (cursor < items.length) {
713
+ const nextKey = callKeyOf(items[cursor]);
714
+ if (nextKey === null) break;
715
+ batch.push(items[cursor]);
716
+ batchKeys.push(nextKey);
717
+ cursor += 1;
718
+ }
719
+ const hasSynthetic = batchKeys.some(batchKey => syntheticKeys.has(batchKey));
720
+ if (!hasSynthetic) {
721
+ ordered.push(...batch);
722
+ index = cursor;
723
+ continue;
724
+ }
725
+ const remainder: unknown[] = [];
726
+ const batchOutputs: Array<{ key: string; item: unknown }> = [];
727
+ for (let probe = cursor; probe < items.length; probe += 1) {
728
+ const outputKey = outputKeyOf(items[probe]);
729
+ if (outputKey !== null && batchKeys.includes(outputKey)) {
730
+ batchOutputs.push({ key: outputKey, item: items[probe] });
731
+ } else {
732
+ remainder.push(items[probe]);
733
+ }
734
+ }
735
+ batchOutputs.sort((left, right) => batchKeys.indexOf(left.key) - batchKeys.indexOf(right.key));
736
+ ordered.push(...batch, ...batchOutputs.map(output => output.item));
737
+ ordered.push(...reorderBatchOutputs(remainder));
738
+ return ordered;
739
+ }
740
+ return ordered;
741
+ };
645
742
 
646
- return changed ? { ...body, input: repaired } : body;
743
+ return changed ? { ...body, input: reorderBatchOutputs(repaired) } : body;
647
744
  }
648
745
 
649
746
  /**
@@ -764,6 +861,15 @@ function stripPreviousResponseId(body: unknown, strip: boolean): unknown {
764
861
  return rest;
765
862
  }
766
863
 
864
+ /** Apply the settled tier only to a fresh outbound object; `_rawBody` remains caller-owned. */
865
+ function applyTierDecisionToResponsesBody(body: unknown, decision: TierDecision | undefined): unknown {
866
+ if (!decision || decision.kind === "forward-caller" || !isPlainObject(body)) return body;
867
+ const next: Record<string, unknown> = { ...body };
868
+ if (decision.kind === "set") next.service_tier = decision.value;
869
+ else delete next.service_tier;
870
+ return next;
871
+ }
872
+
767
873
  /**
768
874
  * Drop request parameters a stateless Responses upstream cannot implement, and pin
769
875
  * `store` false.
@@ -778,8 +884,8 @@ function stripPreviousResponseId(body: unknown, strip: boolean): unknown {
778
884
  * `prompt` is a reference to a server-stored prompt template — the most stateful
779
885
  * field in the accepted schema.
780
886
  *
781
- * `service_tier` is deliberately NOT dropped: the server writes it for fast mode
782
- * (`responses/core.ts`), and silently deleting a configured knob inside an adapter is
887
+ * `service_tier` is deliberately NOT dropped: the final TierDecision is applied to a
888
+ * detached outbound body before this sanitizer chain, and silently deleting a configured knob is
783
889
  * worse than forwarding a parameter the upstream ignores.
784
890
  *
785
891
  * MUST run before the composed sanitize chain below: `stripItemIdsWhenUnstored` keys
@@ -1367,6 +1473,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1367
1473
  parsed._rawBody,
1368
1474
  forward || parsed._previousResponseInputExpanded === true,
1369
1475
  );
1476
+ // stripPreviousResponseId() intentionally returns its input on a no-op. Detach before the
1477
+ // tier write so a force-fast/default decision can never mutate parsed._rawBody.
1478
+ outBody = applyTierDecisionToResponsesBody(outBody, parsed.options?.tierDecision);
1370
1479
  const stateless = provider.statelessResponses === true;
1371
1480
  if (stateless) outBody = stripStatefulResponsesParams(outBody);
1372
1481
  // A replay miss can leave a function_call_output whose paired function_call sat
@@ -1375,7 +1484,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1375
1484
  // backend gets — dropping previous_response_id is not much use if the body that
1376
1485
  // reaches the wire is unparseable.
1377
1486
  if (forward || stateless) {
1378
- outBody = repairOrphanedInputItems(outBody, unexpandedMiss);
1487
+ outBody = repairOrphanedInputItems(outBody, unexpandedMiss, stateless && !forward);
1379
1488
  }
1380
1489
  if (provider.requiresAdjacentResponsesToolResults === true) {
1381
1490
  outBody = normalizeResponsesToolResultAdjacency(outBody);
@@ -1414,11 +1523,21 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1414
1523
  convertedRoutedCustomToolNames = rewritten.names;
1415
1524
  }
1416
1525
  const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true })))))));
1417
- const body = JSON.stringify(stripDisabledReasoningSummaries(
1526
+ const finalBody = stripDisabledReasoningSummaries(
1418
1527
  normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId),
1419
1528
  provider,
1420
1529
  parsed.modelId,
1421
- ));
1530
+ );
1531
+ const actualServiceTier = isPlainObject(finalBody) && typeof finalBody.service_tier === "string"
1532
+ ? finalBody.service_tier
1533
+ : null;
1534
+ const tierLog = createAdapterTierMetadata(
1535
+ parsed.options?.tierObservation,
1536
+ parsed.options?.tierDecision,
1537
+ actualServiceTier === null ? null : "service-tier",
1538
+ actualServiceTier,
1539
+ );
1540
+ const body = JSON.stringify(finalBody);
1422
1541
  const releaseBodyObservation = translatorBudget.observeExternallyCapped(
1423
1542
  "passthrough_serialization",
1424
1543
  new TextEncoder().encode(body).byteLength,
@@ -1430,6 +1549,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1430
1549
  body,
1431
1550
  releaseBodyObservation,
1432
1551
  ...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}),
1552
+ ...(tierLog ? { tierLog } : {}),
1433
1553
  };
1434
1554
  },
1435
1555
 
@@ -10,6 +10,7 @@ import { createMimoFreeAdapter } from "./mimo-free";
10
10
  import { createOpenAIChatAdapter } from "./openai-chat";
11
11
  import { createResponsesPassthroughAdapter } from "./openai-responses";
12
12
  import type { OcxProviderConfig } from "../types";
13
+ import { createAdapterTierMetadata } from "../providers/fastwire";
13
14
 
14
15
  export type AdapterCacheRetention = "none" | "short" | "long";
15
16
 
@@ -142,5 +143,33 @@ export function createRegisteredAdapter(
142
143
  ): ProviderAdapter {
143
144
  const definition = getAdapterDefinition(provider.adapter);
144
145
  if (!definition) throw new Error(`Unknown adapter: ${provider.adapter}`);
145
- return definition.create(provider, context);
146
+ const adapter = definition.create(provider, context);
147
+ const buildRequest = adapter.buildRequest.bind(adapter);
148
+ adapter.buildRequest = (parsed, incoming) => {
149
+ const attachTierMetadata = (request: Awaited<ReturnType<ProviderAdapter["buildRequest"]>>) => {
150
+ // OpenAI-family adapters report the exact emitted field themselves. Other adapters
151
+ // still report an exact absence at this serialization boundary, which makes a routed
152
+ // Fast downgrade observable without asking core to infer an outbound body shape.
153
+ request.tierLog ??= createAdapterTierMetadata(
154
+ parsed.options.tierObservation,
155
+ parsed.options.tierDecision,
156
+ null,
157
+ null,
158
+ );
159
+ return request;
160
+ };
161
+ const request = buildRequest(parsed, incoming);
162
+ return request instanceof Promise
163
+ ? request.then(attachTierMetadata)
164
+ : attachTierMetadata(request);
165
+ };
166
+ if (adapter.runTurn && !adapter.tierLogForRunTurn) {
167
+ adapter.tierLogForRunTurn = parsed => createAdapterTierMetadata(
168
+ parsed.options.tierObservation,
169
+ parsed.options.tierDecision,
170
+ null,
171
+ null,
172
+ );
173
+ }
174
+ return adapter;
146
175
  }
package/src/bridge.ts CHANGED
@@ -15,6 +15,7 @@ import { rememberReasoningForCall } from "./responses/reasoning-replay-cache";
15
15
  import {
16
16
  rememberAndSerializeExtraContent,
17
17
  rememberExtraContentForReplay,
18
+ awaitThoughtSignatureDurability,
18
19
  } from "./responses/thought-signature-replay";
19
20
  import { resolveStallTimeoutSec } from "./stall-timeout";
20
21
  import { usageDisplayTotalTokens } from "./usage/totals";
@@ -198,6 +199,16 @@ export function bridgeToResponsesSSE(
198
199
  declaredToolNames?: ReadonlySet<string>;
199
200
  /** Declared parameter schema per tool name; repairs integral-float integer args (#1611). */
200
201
  toolParameterSchemas?: ReadonlyMap<string, Record<string, unknown>>;
202
+ /**
203
+ * Wire keep-alive shape. Codex-rs parses at the EVENT level (timeout(idle_timeout,
204
+ * stream.next()) over an eventsource_stream), so an SSE comment line dispatches no event
205
+ * and does NOT re-arm its idle timer — the keep-alive must be a typed frame the parser
206
+ * ignores via its catch-all (110 RCA, 30_patch-direction.md). grok-build's strict
207
+ * async-openai fork is the opposite: it dies on the unknown `response.heartbeat`
208
+ * variant but, being eventsource-based at the byte level, its idle handling tolerates
209
+ * comment lines. Default stays the typed frame; the grok surface opts into comments.
210
+ */
211
+ heartbeatStyle?: "typed" | "comment";
201
212
  translatorBudget?: TranslatorBudget;
202
213
  /**
203
214
  * Conversation identity for the reasoning replay cache (issue #950).
@@ -326,10 +337,13 @@ export function bridgeToResponsesSSE(
326
337
  clearOwnedWatchdog();
327
338
  };
328
339
  // RC3 keep-alive: Codex's idle timer is timeout(idle_timeout, stream.next()) over an
329
- // eventsource_stream; ANY received event re-arms it, while an unknown type is ignored
330
- // (responses.rs `_ => Ok(None)`). Emit a parser-ignored `response.heartbeat` whenever the
331
- // *wire* has been silent, even if invisible adapter heartbeats are still flowing (web-search
332
- // buffering + raw-byte progress). Upstream activity only resets the stall watchdog.
340
+ // eventsource_stream, which parses at the EVENT level a comment-only frame dispatches no
341
+ // event, so it does NOT re-arm the timer (110 RCA). The default keep-alive is therefore a
342
+ // typed `response.heartbeat` frame the codex-rs parser ignores via `_ => Ok(None)`. The
343
+ // grok surface (strict async-openai decoder that dies on unknown variants) opts into SSE
344
+ // comment lines instead via options.heartbeatStyle. Emit whenever the *wire* has been
345
+ // silent, even if invisible adapter heartbeats are still flowing (web-search buffering +
346
+ // raw-byte progress). Upstream activity only resets the stall watchdog.
333
347
  let upstreamActivity = false;
334
348
  let wireActivity = false;
335
349
  let beat: unknown;
@@ -396,7 +410,9 @@ export function bridgeToResponsesSSE(
396
410
  ...(endTurn !== undefined ? { end_turn: endTurn } : {}),
397
411
  });
398
412
 
399
- const heartbeatFrame = encoder.encode('event: response.heartbeat\ndata: {"type":"response.heartbeat"}\n\n');
413
+ const heartbeatFrame = options?.heartbeatStyle === "comment"
414
+ ? encoder.encode(': opencodex heartbeat\n\n')
415
+ : encoder.encode('event: response.heartbeat\ndata: {"type":"response.heartbeat"}\n\n');
400
416
  let stallTicks = 0;
401
417
  const stallSec = resolveStallTimeoutSec(options?.stallTimeoutSec);
402
418
  const maxStallTicks = Math.ceil((stallSec * 1000) / heartbeatMs);
@@ -576,9 +592,16 @@ export function bridgeToResponsesSSE(
576
592
  const closeCurrentRawReasoning = () => {
577
593
  if (!currentRawReasoning) return;
578
594
  rawReasoningForNextToolCall = currentRawReasoning.text;
595
+ emit("response.reasoning_summary_text.done", {
596
+ item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, summary_index: 0, text: currentRawReasoning.text,
597
+ });
598
+ emit("response.reasoning_summary_part.done", {
599
+ item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, summary_index: 0,
600
+ part: { type: "summary_text", text: currentRawReasoning.text },
601
+ });
579
602
  const item = {
580
- type: "reasoning", id: currentRawReasoning.itemId, summary: [],
581
- content: [{ type: "reasoning_text", text: currentRawReasoning.text }],
603
+ type: "reasoning", id: currentRawReasoning.itemId,
604
+ summary: [{ type: "summary_text", text: currentRawReasoning.text }],
582
605
  };
583
606
  emit("response.output_item.done", { output_index: currentRawReasoning.outputIndex, item });
584
607
  retainFinishedItem(item as OutputItem, currentRawReasoning.textBytes, "reasoning");
@@ -977,8 +1000,12 @@ export function bridgeToResponsesSSE(
977
1000
  if (currentToolCall) closeCurrentToolCall();
978
1001
  if (!currentRawReasoning) {
979
1002
  const itemId = `rs_${uuid()}`;
980
- const item = { type: "reasoning", id: itemId, summary: [] as never[], content: [] as { type: string; text: string }[] };
1003
+ const item = { type: "reasoning", id: itemId, summary: [] as { type: string; text: string }[] };
981
1004
  emit("response.output_item.added", { output_index: outputIndex, item });
1005
+ emit("response.reasoning_summary_part.added", {
1006
+ item_id: itemId, output_index: outputIndex, summary_index: 0,
1007
+ part: { type: "summary_text", text: "" },
1008
+ });
982
1009
  currentRawReasoning = { itemId, outputIndex, text: "", textBytes: 0 };
983
1010
  }
984
1011
  ({ value: currentRawReasoning.text, bytes: currentRawReasoning.textBytes } = appendString(
@@ -987,9 +1014,9 @@ export function bridgeToResponsesSSE(
987
1014
  event.text,
988
1015
  "reasoning",
989
1016
  ));
990
- emit("response.reasoning_text.delta", {
1017
+ emit("response.reasoning_summary_text.delta", {
991
1018
  item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex,
992
- content_index: 0, delta: event.text,
1019
+ summary_index: 0, delta: event.text,
993
1020
  });
994
1021
  break;
995
1022
  }
@@ -1176,6 +1203,9 @@ export function bridgeToResponsesSSE(
1176
1203
  if (truncationReasonFor(event.stopReason)) {
1177
1204
  // Upstream stopped before a normal completion. Surface as incomplete so the
1178
1205
  // client can distinguish a truncated/filtered turn from a finished one.
1206
+ // #1926 gap 2: bound the window in which a handed-out thought signature is
1207
+ // not yet durable before the turn becomes externally terminal.
1208
+ await awaitThoughtSignatureDurability();
1179
1209
  const response = {
1180
1210
  ...responseSnapshot("incomplete", finishedItems, event.endTurn),
1181
1211
  usage: responsesUsage(event.usage),
@@ -1190,6 +1220,7 @@ export function bridgeToResponsesSSE(
1190
1220
  emit("response.incomplete", { response });
1191
1221
  reportTerminal("incomplete");
1192
1222
  } else {
1223
+ await awaitThoughtSignatureDurability();
1193
1224
  const response = { ...responseSnapshot("completed", finishedItems, event.endTurn), usage: responsesUsage(event.usage) };
1194
1225
  options?.onCompletedResponse?.(response, event.providerState);
1195
1226
  options?.onUsage?.(event.usage);
@@ -1210,6 +1241,7 @@ export function bridgeToResponsesSSE(
1210
1241
  if (currentWebSearch) closeCurrentWebSearch("failed", []);
1211
1242
  flushHiddenReasoningEnvelope();
1212
1243
  options?.onUsage?.(event.usage);
1244
+ await awaitThoughtSignatureDurability();
1213
1245
  emit("response.incomplete", {
1214
1246
  response: {
1215
1247
  ...responseSnapshot("incomplete", finishedItems, event.endTurn),
@@ -1238,6 +1270,7 @@ export function bridgeToResponsesSSE(
1238
1270
  if (currentWebSearch) closeCurrentWebSearch("failed", []);
1239
1271
  const failure = adapterFailureFromEvent(event);
1240
1272
  if (event.usage) options?.onUsage?.(event.usage);
1273
+ await awaitThoughtSignatureDurability();
1241
1274
  emit("response.failed", {
1242
1275
  response: {
1243
1276
  ...responseSnapshot("failed", finishedItems),
@@ -1299,6 +1332,7 @@ export function bridgeToResponsesSSE(
1299
1332
  if (currentToolCall) failCurrentToolCall();
1300
1333
  if (currentWebSearch) closeCurrentWebSearch("failed", []);
1301
1334
  options?.onUsage?.(undefined);
1335
+ await awaitThoughtSignatureDurability();
1302
1336
  emit("response.incomplete", {
1303
1337
  response: {
1304
1338
  ...responseSnapshot("incomplete", finishedItems),
@@ -1339,6 +1373,10 @@ export function bridgeToResponsesSSE(
1339
1373
  flushHiddenRawReasoning();
1340
1374
  if (currentToolCall) failCurrentToolCall();
1341
1375
  if (currentWebSearch) closeCurrentWebSearch("failed", []);
1376
+ // #1926 gap 2 residual: this beat callback is synchronous, so the durability
1377
+ // barrier is not awaited on the stall-timeout kill path. The in-memory store is
1378
+ // already updated; only a crash between here and the queued write loses it,
1379
+ // which is the pre-#1926 status quo for an already-abnormal termination.
1342
1380
  emit("response.incomplete", {
1343
1381
  response: {
1344
1382
  ...responseSnapshot("incomplete", finishedItems),
@@ -1582,8 +1620,8 @@ function buildResponseJSONWithBudget(
1582
1620
  return;
1583
1621
  }
1584
1622
  pushOutput({
1585
- type: "reasoning", id: `rs_${uuid()}`, summary: [],
1586
- content: [{ type: "reasoning_text", text: currentRawReasoning }],
1623
+ type: "reasoning", id: `rs_${uuid()}`,
1624
+ summary: [{ type: "summary_text", text: currentRawReasoning }],
1587
1625
  }, currentRawReasoningBytes, "reasoning");
1588
1626
  currentRawReasoning = "";
1589
1627
  currentRawReasoningBytes = 0;
@@ -299,6 +299,7 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec {
299
299
  if (raw.stop !== undefined) body.stop = raw.stop;
300
300
  if (typeof raw.user === "string") body.user = raw.user;
301
301
  if (typeof raw.parallel_tool_calls === "boolean") body.parallel_tool_calls = raw.parallel_tool_calls;
302
+ if (typeof raw.service_tier === "string") body.service_tier = raw.service_tier;
302
303
  if (typeof raw.prompt_cache_key === "string") body.prompt_cache_key = raw.prompt_cache_key;
303
304
  if (raw.metadata !== undefined) body.metadata = raw.metadata;
304
305
 
@@ -11,9 +11,10 @@
11
11
  * Ownership contract: this module only creates/overwrites/deletes files matching
12
12
  * `ocx-*.md` inside the agents dir. User-authored agents are never touched.
13
13
  */
14
- import { lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
14
+ import { lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
  import type { OcxConfig } from "../types";
17
+ import { renameAtomicFile } from "../lib/windows-atomic-replace";
17
18
  import { claudeCodeAlias, claudeCodeNativeAlias } from "./alias";
18
19
  import { AUTO_CONTEXT_OFF, shouldMarkOneMillion, stripOneMillionMarker, withOneMillionMarker } from "./context-windows";
19
20
  import { claudeConfigDir } from "./gateway-cache";
@@ -235,7 +236,7 @@ export function syncClaudeAgentDefs(defs: readonly ClaudeAgentDef[], configDir =
235
236
  } catch { /* does not exist: ours to create */ }
236
237
  const tmp = `${target}.tmp-${process.pid}`;
237
238
  writeFileSync(tmp, renderAgentDef(def), { encoding: "utf8", mode: 0o644 });
238
- renameSync(tmp, target);
239
+ renameAtomicFile(tmp, target, undefined, "claude-agents");
239
240
  written.push(def.file);
240
241
  }
241
242
  return written;
@@ -204,10 +204,20 @@ const commandRunners: Record<string, CommandRunner> = {
204
204
  },
205
205
  sync: async deps => {
206
206
  const restartCodex = deps.args.slice(1).includes("--restart-codex");
207
- const synced = await syncModelsToCodex((await deps.findLiveProxy())?.port);
207
+ const synced = await syncModelsToCodex(
208
+ (await deps.findLiveProxy())?.port,
209
+ undefined,
210
+ undefined,
211
+ undefined,
212
+ { catalogEvenWhenNotInjected: true },
213
+ );
208
214
  let code = 0;
209
215
  if (synced.status === "skipped") {
210
216
  console.log("Codex integration is OFF; sync skipped and no Codex files changed.");
217
+ } else if (synced.status === "catalog-only") {
218
+ // Explicit sync with the integration OFF still refreshes the catalog/cache
219
+ // for side profiles that consume the proxy without injection.
220
+ console.log(synced.message ?? "Codex integration is OFF; catalog refreshed, Codex config untouched.");
211
221
  } else if (!synced.ok) {
212
222
  code = 1;
213
223
  console.error("Codex sync did not complete. Fix the reported Codex config issue and retry.");
@@ -227,19 +237,18 @@ const commandRunners: Record<string, CommandRunner> = {
227
237
  },
228
238
  "sync-cache": async deps => {
229
239
  const restartCodex = deps.args.slice(1).includes("--restart-codex");
230
- if (!shouldSyncCodexOnStart(deps.loadConfig())) {
231
- console.log("Codex integration is OFF; cache sync skipped and no Codex files changed.");
232
- return 0;
233
- }
234
240
  const { withCatalogWriteSerialization } = await import("../codex/catalog-write-serialization");
235
241
  const { invalidateCodexModelsCacheWithPermit } = await import("../codex/catalog/sync");
236
242
  const { getCodexHome } = await import("../codex/paths");
237
243
  const owningCodexHome = getCodexHome();
244
+ const desiredDisabled = !shouldSyncCodexOnStart(deps.loadConfig());
238
245
  const invalidated = withCatalogWriteSerialization(owningCodexHome, permit =>
239
- invalidateCodexModelsCacheWithPermit(permit, owningCodexHome));
246
+ invalidateCodexModelsCacheWithPermit(permit, owningCodexHome, { allowWhenDesiredDisabled: true }));
240
247
  // Only warn/restart when models_cache was actually rewritten from a readable catalog.
241
248
  if (invalidated.kind === "completed" && invalidated.value) {
242
249
  afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console });
250
+ } else if (desiredDisabled) {
251
+ console.log("Codex integration is OFF; cache sync skipped (no catalog or cache write).");
243
252
  }
244
253
  return 0;
245
254
  },
@@ -502,6 +511,10 @@ const commandRunners: Record<string, CommandRunner> = {
502
511
  const { cmdMmx } = await import("./minimax");
503
512
  return await cmdMmx(deps.args.slice(1));
504
513
  },
514
+ zcode: async deps => {
515
+ const { handleZcodeCommand } = await import("./integrations");
516
+ return await handleZcodeCommand(deps.args.slice(1));
517
+ },
505
518
  help: async () => {
506
519
  printUsage();
507
520
  return 0;
package/src/cli/help.ts CHANGED
@@ -58,7 +58,7 @@ Usage:
58
58
  ocx memory [--json] Alias of ocx observe memory
59
59
  ocx api-key <sub> Alias of ocx access key
60
60
  ocx access <sub> External API keys and endpoint information
61
- ocx export --client <id> Print a client config wired to the running proxy (8 clients)
61
+ ocx export --client <id> Print a client config wired to the running proxy (10 clients)
62
62
  ocx integration client <sub> Enable, disable, inspect or roll back a client integration
63
63
  ocx grok <sub> Grok Build model selection and apply
64
64
  ocx system <sub> Runtime settings, startup, sync, and updates
@@ -69,6 +69,7 @@ Usage:
69
69
  ocx opencode [args...] Launch opencode wired to the proxy (runtime provider config)
70
70
  ocx mcode [args...] Launch MiniMax Code through its managed provider
71
71
  ocx mmx text <sub> [args] Launch MiniMax CLI text through the proxy
72
+ ocx zcode [sub] Connect ZCode to the proxy (managed provider)
72
73
  ocx help [command] Show help
73
74
  ocx --version | -v Print version
74
75
 
@@ -223,3 +223,38 @@ export async function handleClientIntegrationCommand(
223
223
  }
224
224
 
225
225
  export const INTEGRATION_USAGE = { claude: CLAUDE_USAGE, grok: GROK_USAGE, client: CLIENT_USAGE };
226
+
227
+ const ZCODE_USAGE = `Usage:
228
+ ocx zcode [status] [--json]
229
+ ocx zcode <enable|disable> [--json]
230
+ ocx zcode history [--json]
231
+ ocx zcode restore --op <opId> [--confirm-drift] [--json]`;
232
+
233
+ /**
234
+ * Thin alias over the client-integration surface for ZCode (Z.ai's desktop
235
+ * client). ZCode is a GUI app with no launch surface to wrap, so unlike
236
+ * `ocx mcode` there is no exec step: connecting the managed provider block is
237
+ * the whole integration, and every safety property (ownership, snapshots,
238
+ * journal, drift refusal) stays behind the shared management API. ZCode reads
239
+ * its config at startup, so enable/disable print a restart reminder.
240
+ */
241
+ export async function handleZcodeCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
242
+ const args = [...argv];
243
+ // Find the first non-flag token so `ocx zcode --json enable` still enables.
244
+ const verbIndex = args.findIndex(arg => !arg.startsWith("-"));
245
+ const action = (verbIndex === -1 ? "status" : args[verbIndex]).toLowerCase();
246
+ const known = ["status", "show", "list", "enable", "disable", "history", "journal", "restore"];
247
+ if (!known.includes(action)) {
248
+ console.error(`unknown zcode command ${action}`);
249
+ console.error(ZCODE_USAGE);
250
+ return 2;
251
+ }
252
+ const rest = verbIndex === -1 ? args : [...args.slice(0, verbIndex), ...args.slice(verbIndex + 1)];
253
+ // `restore` addresses an operation id, not a client, so nothing is injected.
254
+ const forwarded = action === "restore" ? [action, ...rest] : [action, ...rest, "--client", "zcode"];
255
+ const code = await handleClientIntegrationCommand(forwarded, deps);
256
+ if (code === 0 && (action === "enable" || action === "disable")) {
257
+ console.error("Restart ZCode to pick up the provider change.");
258
+ }
259
+ return code;
260
+ }
@@ -190,12 +190,18 @@ export function startMmxTextBridge(
190
190
  ? null
191
191
  : clearableDeadline(options.headerTimeoutMs, req.signal);
192
192
  try {
193
- return await fetch(new Request(target, {
193
+ const upstreamRequest = new Request(target, {
194
194
  method: "POST",
195
195
  headers,
196
196
  body: req.body,
197
197
  signal: headerDeadline?.signal ?? req.signal,
198
- }));
198
+ });
199
+ return await fetch(upstreamRequest, {
200
+ // Override HTTP(S)_PROXY with the loopback listener itself. Bun sends
201
+ // the HTTP proxy-form request directly to this exact origin, so the
202
+ // hop cannot leave the machine even when the parent has proxy vars.
203
+ proxy: { url: upstreamOrigin },
204
+ });
199
205
  } catch {
200
206
  return Response.json({
201
207
  type: "error",
@@ -216,8 +216,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
216
216
  { name: "api-key", usage: "ocx api-key <list|create|remove> ...", summary: "Alias of ocx access key." },
217
217
  {
218
218
  name: "export",
219
- usage: "ocx export --client <opencode|pi|omp|hermes|openclaw|kimi|gajae|dsh|mcode> [--json] [--out <path>] [--force]",
220
- summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code) wired to the running proxy.",
219
+ usage: "ocx export --client <opencode|pi|omp|hermes|openclaw|kimi|gajae|dsh|mcode|zcode> [--json] [--out <path>] [--force]",
220
+ summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode) wired to the running proxy.",
221
221
  details: [
222
222
  "--json prints the generated document as JSON on stdout; use --out for the client's native format.",
223
223
  "--out <path> writes the native config there and refuses to replace an existing file without --force.",
@@ -302,6 +302,17 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
302
302
  "The wrapper isolates ~/.mmx credentials and refuses --api-key/--base-url overrides.",
303
303
  ],
304
304
  },
305
+ {
306
+ name: "zcode",
307
+ usage: "ocx zcode [status|enable|disable|history|restore] [--json]",
308
+ summary: "Connect ZCode (Z.ai desktop client) to the proxy via its managed provider.",
309
+ details: [
310
+ "Alias of ocx integration client <sub> --client zcode.",
311
+ "enable writes the managed provider.opencodex block into ~/.zcode/v2/config.json; disable removes only that block.",
312
+ "ZCode reads its config at startup — restart ZCode after enable/disable.",
313
+ "Select OpenCodex Proxy/<provider>/<model> from ZCode's model picker.",
314
+ ],
315
+ },
305
316
  {
306
317
  name: "restart",
307
318
  usage: "ocx restart",