@yansigit/opencodex 2.36.1-dev.20260829.48 → 2.36.1

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 (94) hide show
  1. package/gui/dist/assets/{ApiKeys-LOOFiZfv.js → ApiKeys-CsmNyf4I.js} +1 -1
  2. package/gui/dist/assets/{Claude-BwKzpXe3.js → Claude-B4sHsGTD.js} +1 -1
  3. package/gui/dist/assets/{CodexSet-B5qi9KeE.js → CodexSet-BuAQ4YQn.js} +1 -1
  4. package/gui/dist/assets/{FileIntegrationPage-B0sUef6W.js → FileIntegrationPage-BC65sEaS.js} +1 -1
  5. package/gui/dist/assets/{Grok-bsb4n-f4.js → Grok-DNJWlISf.js} +1 -1
  6. package/gui/dist/assets/{Integrations-CSMwFDvM.js → Integrations-vGJshFZK.js} +2 -2
  7. package/gui/dist/assets/{IntegrationsOverview-CTORdKJA.js → IntegrationsOverview-DYMkDFHE.js} +1 -1
  8. package/gui/dist/assets/{Logs-BWYbfELf.js → Logs-Coc7_RPP.js} +1 -1
  9. package/gui/dist/assets/{Models-CexEtdT1.js → Models-CKVT8Meb.js} +1 -1
  10. package/gui/dist/assets/{NumberStepper-BapSFQnW.js → NumberStepper-Dug4wR8n.js} +1 -1
  11. package/gui/dist/assets/{Providers-BwUFeAgA.js → Providers-DyaCHRWL.js} +1 -1
  12. package/gui/dist/assets/{RestoreDialog-CD8piq90.js → RestoreDialog-BpuvVU2w.js} +1 -1
  13. package/gui/dist/assets/{Startup-BgX731C2.js → Startup-DAUQqNEN.js} +1 -1
  14. package/gui/dist/assets/{Storage-Bgq7HphP.js → Storage-DfP95SlJ.js} +1 -1
  15. package/gui/dist/assets/{Subagents-ChxItbeF.js → Subagents-CMNsZLfk.js} +1 -1
  16. package/gui/dist/assets/{Usage-BkylzP50.js → Usage-ESFRfBMO.js} +1 -1
  17. package/gui/dist/assets/{codex-stale-banner-D4gvSWnO.js → codex-stale-banner-W4FhikN7.js} +1 -1
  18. package/gui/dist/assets/{data-surface-BtO3lwam.js → data-surface-B8PX8rjm.js} +1 -1
  19. package/gui/dist/assets/{data-surface-pCXChiBf.js → data-surface-CETFXqiA.js} +1 -1
  20. package/gui/dist/assets/{index-CNopOid3.js → index-DHQHBRhb.js} +3 -3
  21. package/gui/dist/assets/{model-display-CiUpg8T9.js → model-display-OBTJhQwT.js} +1 -1
  22. package/gui/dist/assets/{provider-payload-b2jlS-On.js → provider-payload-CJRJUn7w.js} +1 -1
  23. package/gui/dist/assets/{section-tabs-BP7gEPK6.js → section-tabs-B8sLMiz6.js} +1 -1
  24. package/gui/dist/assets/shared-DI18-uF7.js +69 -0
  25. package/gui/dist/index.html +2 -2
  26. package/package.json +3 -1
  27. package/src/adapters/base.ts +26 -0
  28. package/src/adapters/cursor/catalog.ts +541 -0
  29. package/src/adapters/cursor/cursor-errors.ts +15 -0
  30. package/src/adapters/cursor/discovery.ts +34 -41
  31. package/src/adapters/cursor/envelope-echo.ts +128 -0
  32. package/src/adapters/cursor/request-builder.ts +19 -12
  33. package/src/adapters/cursor/tool-definitions.ts +2 -1
  34. package/src/adapters/cursor/tool-result-normalize.ts +23 -31
  35. package/src/adapters/cursor.ts +21 -2
  36. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  37. package/src/adapters/google-antigravity-replay.ts +71 -2
  38. package/src/adapters/google.ts +19 -4
  39. package/src/adapters/kiro-constants.ts +12 -0
  40. package/src/adapters/kiro.ts +128 -11
  41. package/src/adapters/openai-chat.ts +16 -2
  42. package/src/adapters/openai-responses.ts +15 -2
  43. package/src/adapters/tool-catalog-nudge.ts +2 -1
  44. package/src/adapters/xai-web-search.ts +10 -14
  45. package/src/claude/outbound.ts +14 -3
  46. package/src/cli/access.ts +46 -3
  47. package/src/cli/account-api.ts +93 -16
  48. package/src/cli/account-extended.ts +262 -36
  49. package/src/cli/account-main.ts +12 -12
  50. package/src/cli/account.ts +40 -10
  51. package/src/cli/agent.ts +8 -1
  52. package/src/cli/capabilities-command.ts +94 -0
  53. package/src/cli/capabilities.ts +535 -0
  54. package/src/cli/claude-desktop.ts +31 -11
  55. package/src/cli/dispatch.ts +195 -27
  56. package/src/cli/doctor.ts +100 -1
  57. package/src/cli/help.ts +11 -2
  58. package/src/cli/index.ts +19 -3
  59. package/src/cli/inspect.ts +230 -0
  60. package/src/cli/observe.ts +11 -3
  61. package/src/cli/registry.ts +34 -2
  62. package/src/cli/runtime-api.ts +51 -7
  63. package/src/cli/status.ts +16 -0
  64. package/src/cli/storage.ts +234 -0
  65. package/src/cli/system-command.ts +16 -0
  66. package/src/cli/usage-report.ts +52 -2
  67. package/src/cli/version-skew.ts +46 -0
  68. package/src/codex/account-label.ts +21 -0
  69. package/src/codex/catalog/provider-fetch.ts +4 -0
  70. package/src/codex/transition-state.ts +12 -3
  71. package/src/compatibility/openai-responses.ts +9 -1
  72. package/src/generated/compatibility-version.json +94 -58
  73. package/src/integrations/ownership-policy.ts +24 -5
  74. package/src/integrations/ownership.ts +36 -2
  75. package/src/integrations/state.ts +40 -7
  76. package/src/integrations/writer.ts +21 -3
  77. package/src/lib/admin-secrets.ts +24 -0
  78. package/src/lib/errors.ts +25 -1
  79. package/src/lib/service-secrets.ts +15 -0
  80. package/src/oauth/store.ts +16 -6
  81. package/src/providers/label.ts +34 -1
  82. package/src/responses/turn-termination.ts +107 -0
  83. package/src/server/management/logs-usage-routes.ts +5 -27
  84. package/src/server/management/route-registry.ts +317 -0
  85. package/src/server/proxy-liveness.ts +27 -4
  86. package/src/server/request-log.ts +37 -17
  87. package/src/server/responses/core.ts +80 -4
  88. package/src/server/responses/policy-fallback.ts +1 -1
  89. package/src/service.ts +34 -0
  90. package/src/storage/policy-job.ts +14 -4
  91. package/src/storage/policy.ts +79 -33
  92. package/src/usage/log.ts +32 -6
  93. package/src/usage/summary.ts +22 -34
  94. package/gui/dist/assets/shared-BdYGV-yJ.js +0 -69
@@ -513,10 +513,10 @@ export function antigravityReplaySessionKeysForTests(): string[] {
513
513
 
514
514
  function extractSignature(part: Record<string, unknown>): string | undefined {
515
515
  const direct = part.thoughtSignature ?? part.thought_signature;
516
- if (typeof direct === "string" && direct.length >= MIN_SIGNATURE_LEN && direct !== ANTIGRAVITY_SIGNATURE_BYPASS_SENTINEL) return direct;
516
+ if (typeof direct === "string" && direct.length >= MIN_SIGNATURE_LEN && direct !== THOUGHT_SIGNATURE_BYPASS) return direct;
517
517
  const extra = part.extra_content as { google?: { thought_signature?: unknown } } | undefined;
518
518
  const nested = extra?.google?.thought_signature;
519
- if (typeof nested === "string" && nested.length >= MIN_SIGNATURE_LEN && nested !== ANTIGRAVITY_SIGNATURE_BYPASS_SENTINEL) return nested;
519
+ if (typeof nested === "string" && nested.length >= MIN_SIGNATURE_LEN && nested !== THOUGHT_SIGNATURE_BYPASS) return nested;
520
520
  return undefined;
521
521
  }
522
522
 
@@ -625,6 +625,75 @@ export function antigravityUsesReplayCache(model: string): boolean {
625
625
  return !/claude/i.test(model);
626
626
  }
627
627
 
628
+ /**
629
+ * Gemini 3 rejects a turn whose FIRST functionCall part carries no thought signature. When
630
+ * neither the wire metadata nor the replay cache can supply a real one, this is the official
631
+ * validator-bypass token.
632
+ */
633
+ const THOUGHT_SIGNATURE_BYPASS = "skip_thought_signature_validator";
634
+
635
+ /**
636
+ * True when the model speaks the Gemini wire dialect that requires a thought signature on the
637
+ * first functionCall of a turn — and therefore accepts the validator-bypass sentinel.
638
+ *
639
+ * Deliberately NOT `antigravityUsesReplayCache`. That predicate is broad on purpose (every
640
+ * non-Claude model participates in signature replay), and reusing it for the sentinel is how a
641
+ * Gemini-only control token was observed being injected into `gpt-oss-120b-medium`. Replaying a
642
+ * signature upstream gave us is harmless for any model; *fabricating* a Gemini token is not.
643
+ *
644
+ * The identity must be REDUCED to its model component before matching, not scanned whole. The
645
+ * Vertex replay key is built in `src/adapters/google.ts` as
646
+ * `vertex:<project>:<location>:<modelId>`, and the project id is operator-chosen: a project
647
+ * named `gemini-prod` made a whole-string scan return true for
648
+ * `vertex:gemini-prod:global:gpt-oss-120b`, arming the Gemini-only sentinel for a non-Gemini
649
+ * model — the exact class of defect this predicate exists to prevent, reintroduced one layer up.
650
+ *
651
+ * So: take the last `:` segment for a Vertex identity, then the last `/` segment for a
652
+ * namespaced id (`google/gemini-3-pro`), and match only that. The trailing `[-.\d]` keeps
653
+ * `geminibot` and `my-gemini-clone` out. A model outside this set that genuinely needs the
654
+ * sentinel must arrive with a captured accepted CCA contract, not by widening this predicate.
655
+ */
656
+ export function antigravitySupportsThoughtSignatureSentinel(model: string): boolean {
657
+ const afterTransport = model.slice(model.lastIndexOf(":") + 1);
658
+ const wireModel = afterTransport.slice(afterTransport.lastIndexOf("/") + 1);
659
+ return /^gemini[-.\d]/i.test(wireModel);
660
+ }
661
+
662
+ /**
663
+ * Ensure every model turn's FIRST functionCall carries a thought signature, injecting the
664
+ * validator-bypass sentinel only where one is genuinely absent.
665
+ *
666
+ * Split out of `applyAntigravityReplay` on purpose. Replay answers "what did upstream already
667
+ * tell us about this call", and its absence of a signature is meaningful — 18 assertions in the
668
+ * suite read `thoughtSignature === undefined` as "the cache did not match", covering eviction,
669
+ * TTL expiry, oversize refusal and clear-on-invalid. Folding a fabricated token into that
670
+ * function would overwrite the very signal those tests read. Keeping the sentinel as its own
671
+ * pass means a cache miss still looks like a cache miss.
672
+ *
673
+ * Three properties this must hold, each of which a naive presence-check gets wrong:
674
+ * - it decides from `extractSignature`, so a valid NESTED
675
+ * `extra_content.google.thought_signature` counts as signed (no competing sentinel) and a
676
+ * present-but-too-short value does not (the fallback still fires);
677
+ * - it looks at the FIRST functionCall only, so a later sibling receiving a cached signature
678
+ * cannot vote away the sentinel the first call requires;
679
+ * - it is gated on the Gemini wire dialect, not on replay-cache participation.
680
+ */
681
+ export function applyAntigravityThoughtSignatureFallback(model: string, contents: unknown[]): unknown[] {
682
+ if (!antigravitySupportsThoughtSignatureSentinel(model) || !Array.isArray(contents)) return contents;
683
+ for (const rawContent of contents as { role?: string; parts?: unknown[] }[]) {
684
+ if (!rawContent || typeof rawContent !== "object" || rawContent.role !== "model") continue;
685
+ if (!Array.isArray(rawContent.parts)) continue;
686
+ for (const rawPart of rawContent.parts) {
687
+ if (!rawPart || typeof rawPart !== "object") continue;
688
+ const part = rawPart as Record<string, unknown>;
689
+ if (!part.functionCall) continue;
690
+ if (!extractSignature(part)) part.thoughtSignature = THOUGHT_SIGNATURE_BYPASS;
691
+ break;
692
+ }
693
+ }
694
+ return contents;
695
+ }
696
+
628
697
  /**
629
698
  * Observe a parsed CCA chunk's `candidates[0].content.parts` and record thought signatures keyed by
630
699
  * the functionCall identity (name + args). Accumulates across the whole session so a sequential
@@ -28,7 +28,13 @@ import { canonicalAntigravityHttpsHost, isAntigravityHttpsHost } from "./google-
28
28
  import { compileGoogleWireBody } from "./google-wire-compiler";
29
29
  import { sanitizeGeminiToolParameters } from "./google-tool-schema";
30
30
  import { identifyRoutedModel } from "./identity";
31
- import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay";
31
+ import {
32
+ antigravityUsesReplayCache,
33
+ applyAntigravityReplay,
34
+ applyAntigravityThoughtSignatureFallback,
35
+ clearAntigravityReplay,
36
+ observeAntigravityReplay,
37
+ } from "./google-antigravity-replay";
32
38
  import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models";
33
39
  import {
34
40
  extractCcaGroundingSources,
@@ -996,6 +1002,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
996
1002
  } else {
997
1003
  sanitizeAntigravityClaudeSignatures(contents);
998
1004
  }
1005
+ // After replay, not instead of it: a real signature always wins, and the sentinel only
1006
+ // fills a first functionCall that replay could not sign. Outside the cache branch too,
1007
+ // because the turn still needs a signature when no session was ever recorded.
1008
+ applyAntigravityThoughtSignatureFallback(wireModelId, contents);
999
1009
  // Claude-on-Antigravity rejects assistant-tail (model-tail in Gemini terms) histories
1000
1010
  // as prefill: "This model does not support assistant message prefill. The conversation
1001
1011
  // must end with a user message." Context compaction, previous_response_id expansion,
@@ -1045,6 +1055,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
1045
1055
  vertexReplaySession,
1046
1056
  (compiled.body as { contents: unknown[] }).contents,
1047
1057
  );
1058
+ applyAntigravityThoughtSignatureFallback(
1059
+ vertexReplayModel,
1060
+ (compiled.body as { contents: unknown[] }).contents,
1061
+ );
1048
1062
  }
1049
1063
  // Vertex AI: project/location endpoint with GCP ADC, or x-goog-api-key fast path.
1050
1064
  const apiKey = resolveVertexApiKey(provider.apiKey);
@@ -1678,9 +1692,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
1678
1692
  if (invalidFunctionCall) return finish([invalidGoogleFunctionCallEvent(invalidFunctionCall)]);
1679
1693
  // Non-streaming Google-family response: observe thought signatures for the next turn,
1680
1694
  // using the same transport-scoped namespace as the streaming path.
1681
- const replayModel = vertexReplayModel;
1682
- const replaySession = vertexReplaySession;
1683
- if (provider.googleMode === "vertex" && replayModel && replaySession) {
1695
+ const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel;
1696
+ const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession;
1697
+ if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex")
1698
+ && replayModel && replaySession) {
1684
1699
  observeAntigravityReplay(replayModel, replaySession, parts as unknown[]);
1685
1700
  }
1686
1701
  let pendingThoughtSig: string | undefined;
@@ -22,6 +22,18 @@ export const KIRO_COMPLETION_RETRY_MESSAGE =
22
22
  export const KIRO_TOOL_RESULT_CARRIER_MESSAGE = "The requested tool result is attached.";
23
23
  export const KIRO_EMPTY_TOOL_RESULT_MESSAGE = "The tool completed without textual output.";
24
24
 
25
+ /**
26
+ * Placeholder for the user turn Kiro requires after an assistant turn that ALREADY delivered its
27
+ * final answer.
28
+ *
29
+ * The protocol needs a trailing user turn, but the usual continuation/retry text instructs the
30
+ * model to keep working, which reopens a finished task and reads as a still-open goal. This states
31
+ * the delivered state and explicitly withholds a new request, so the turn stays structurally valid
32
+ * without asking for more work.
33
+ */
34
+ export const KIRO_ANSWER_DELIVERED_MESSAGE =
35
+ "The previous final answer was delivered to the user and that task is closed. No new request has been made yet. Do not repeat, revise, or continue that work; wait for the user's next instruction.";
36
+
25
37
  export const KIRO_COMPLETION_INSTRUCTIONS =
26
38
  `When tools are available, ordinary assistant text is mid-task commentary and does not end the turn. Continue using tools after progress updates. When the task is fully complete and no more tool calls are needed, call ${KIRO_COMPLETION_TOOL_NAME} exactly once with the complete user-facing final answer in \`answer\`. Do not provide the final answer as ordinary assistant text.`;
27
39
 
@@ -36,15 +36,18 @@ import type {
36
36
  OcxTool,
37
37
  OcxUsage,
38
38
  } from "../types";
39
+ import { hasRecordedTrailingDeliveredFinalAnswer } from "../responses/turn-termination";
39
40
  import type { ProviderAdapter } from "./base";
40
41
  import type { AdapterFetchContext, AdapterRequest } from "./base";
41
42
  import { extractKiroImages, normalizeKiroImages, type KiroImage } from "./kiro-images";
42
43
  import { sniffImageDimensions } from "./anthropic-image-guard";
43
44
  import { fetchKiroWithRetry, noteKiroTransientThrottle } from "./kiro-retry";
44
45
  import { convertKiroToolContext } from "./kiro-tools";
46
+ import { normalizeEmptyExecToolResultText } from "./exec-tool-result-normalize";
45
47
  import { identifyRoutedModel } from "./identity";
46
48
  import { buildNonOpenAIToolCatalogNudgeFromNames, isBareShellBridgeTool, isCodexCodeModeExecTool } from "./tool-catalog-nudge";
47
49
  import {
50
+ KIRO_ANSWER_DELIVERED_MESSAGE,
48
51
  KIRO_COMPLETION_INSTRUCTIONS,
49
52
  KIRO_COMPLETION_RETRY_MESSAGE,
50
53
  KIRO_COMPLETION_TOOL_NAME,
@@ -343,8 +346,54 @@ function validateKiroCapabilities(parsed: OcxParsedRequest): void {
343
346
  }
344
347
 
345
348
  type KiroTurn =
346
- | { kind: "user"; content: string; images: KiroImage[]; toolResults: KiroToolResult[] }
347
- | { kind: "assistant"; content: string; toolUses: KiroToolUse[]; redactedReasoning?: string };
349
+ | {
350
+ kind: "user";
351
+ content: string;
352
+ images: KiroImage[];
353
+ toolResults: KiroToolResult[];
354
+ /**
355
+ * True only for the proxy-generated acknowledgement that follows a delivered final answer.
356
+ * A flag rather than a content comparison: a real user message may legitimately quote the
357
+ * same sentence, and treating that as internal state would strip its thinking tags and
358
+ * completion retry.
359
+ */
360
+ answerDeliveredAck?: boolean;
361
+ }
362
+ | {
363
+ kind: "assistant";
364
+ content: string;
365
+ toolUses: KiroToolUse[];
366
+ redactedReasoning?: string;
367
+ /**
368
+ * True when this assistant turn was the DELIVERED final answer (Responses
369
+ * `phase: "final_answer"`). A trailing assistant turn normally means the model stopped
370
+ * mid-task and needs a continuation prompt, but a delivered final answer already ended its
371
+ * turn — prompting it again restarts finished work as if a goal were still open.
372
+ */
373
+ finalAnswer?: boolean;
374
+ };
375
+
376
+ /**
377
+ * True when the LAST content-bearing message is an assistant final answer that closed its turn.
378
+ *
379
+ * Mirrors the turn-merge rule: a tool call in that message, or any later user/tool-result message,
380
+ * means work continued, so the turn is no longer terminal. Empty assistant messages are skipped
381
+ * rather than treated as continuation, since they carry no visible turn.
382
+ */
383
+ function hasTrailingDeliveredFinalAnswer(messages: readonly OcxMessage[], parsed?: OcxParsedRequest): boolean {
384
+ for (let i = messages.length - 1; i >= 0; i--) {
385
+ const msg = messages[i];
386
+ if (msg.role !== "assistant") return false;
387
+ const aMsg = msg as OcxAssistantMessage;
388
+ const hasToolCall = (aMsg.content ?? []).some(part => part.type === "toolCall");
389
+ if (hasToolCall) return false;
390
+ const hasText = (aMsg.content ?? []).some(part => part.type === "text" && part.text.trim());
391
+ if (!hasText) continue;
392
+ return aMsg.phase === "final_answer"
393
+ || (parsed !== undefined && hasRecordedTrailingDeliveredFinalAnswer(parsed, messages));
394
+ }
395
+ return false;
396
+ }
348
397
 
349
398
  function appendTurnText(target: string, next: string): string {
350
399
  if (!next) return target;
@@ -453,8 +502,19 @@ export function buildKiroPayload(
453
502
  const registry = createKiroToolNameRegistry();
454
503
  const toolContext = convertKiroToolContext(parsed, registry);
455
504
  const ordinaryTools = toolContext.tools;
505
+ // A turn whose history already ENDS with a delivered final answer has nothing to complete.
506
+ // Leaving completion "required" here would keep advertising codex_kiro_final_answer with its
507
+ // instructions, so the model answers again, or replies with ordinary text and trips the
508
+ // `needsFallback` retry, which ends its payload with KIRO_COMPLETION_RETRY_MESSAGE and reopens
509
+ // the finished task. Suppressing the mode is what actually closes that loop; the neutral
510
+ // acknowledgement below only stops the resume wording.
511
+ //
512
+ // Read from parsed messages because `completionMode` is needed to build the tool catalog, which
513
+ // happens before the turn list exists. `forcedCompletionMode` still wins: the fallback retry
514
+ // passes "text_fallback" explicitly and must not be silently downgraded.
515
+ const trailingDeliveredAnswer = hasTrailingDeliveredFinalAnswer(kiroPayloadMessages(parsed), parsed);
456
516
  const completionMode: KiroCompletionMode = forcedCompletionMode
457
- ?? (ordinaryTools.length > 0 ? "required" : "disabled");
517
+ ?? (ordinaryTools.length > 0 && !trailingDeliveredAnswer ? "required" : "disabled");
458
518
  const kiroTools = completionMode === "disabled"
459
519
  ? ordinaryTools
460
520
  : [...ordinaryTools, kiroCompletionTool()];
@@ -521,15 +581,24 @@ export function buildKiroPayload(
521
581
  turns.push({ kind: "user", content, images: [...images], toolResults: [...toolResults] });
522
582
  }
523
583
  };
524
- const pushAssistant = (content: string, toolUses: KiroToolUse[], redactedReasoning?: string): void => {
584
+ const pushAssistant = (content: string, toolUses: KiroToolUse[], redactedReasoning?: string, finalAnswer?: boolean): void => {
525
585
  const last = turns.at(-1);
526
586
  if (last?.kind === "assistant") {
527
587
  last.content = appendTurnText(last.content, content);
528
588
  last.toolUses.push(...toolUses);
529
589
  // Merged turns keep the newest blob: it covers the reasoning up to the merged turn's end.
530
590
  if (redactedReasoning) last.redactedReasoning = redactedReasoning;
591
+ // A merged turn is final only if its LAST component was: commentary appended after a final
592
+ // answer means the model kept working, so the turn is no longer terminal.
593
+ last.finalAnswer = finalAnswer === true;
531
594
  } else {
532
- turns.push({ kind: "assistant", content, toolUses: [...toolUses], ...(redactedReasoning ? { redactedReasoning } : {}) });
595
+ turns.push({
596
+ kind: "assistant",
597
+ content,
598
+ toolUses: [...toolUses],
599
+ ...(redactedReasoning ? { redactedReasoning } : {}),
600
+ ...(finalAnswer ? { finalAnswer: true } : {}),
601
+ });
533
602
  }
534
603
  };
535
604
 
@@ -559,14 +628,24 @@ export function buildKiroPayload(
559
628
  const hasReasoning = aMsg.content.some(part => part.type === "thinking" && part.thinking.trim());
560
629
  if (hasReasoning) continue;
561
630
  }
562
- pushAssistant(text, toolUses, aMsg.kiroRedactedReasoning);
631
+ // `phase` survives the Responses round trip (parser.ts assistant branch), so a replayed
632
+ // final answer is identifiable here rather than guessed from turn position.
633
+ pushAssistant(text, toolUses, aMsg.kiroRedactedReasoning, aMsg.phase === "final_answer" && toolUses.length === 0);
563
634
  } else if (msg.role === "toolResult") {
564
635
  const tr = msg as OcxToolResultMessage;
565
636
  if (tr.containsEncryptedContent) {
566
637
  throw new Error(`Kiro cannot translate encrypted output for tool call ${JSON.stringify(tr.toolCallId)}`);
567
638
  }
568
639
  const text = userContentText(tr.content);
569
- const resultText = text.trim() ? text : KIRO_EMPTY_TOOL_RESULT_MESSAGE;
640
+ // An empty code-mode exec result needs the SPECIFIC reason, not the generic fallback: the
641
+ // model otherwise reads a blank result, concludes its earlier context was lost, and restarts
642
+ // the task instead of calling text()/notify(). Checked before `text.trim()` because the
643
+ // wrapper form ("Script completed\nWall time ...\nOutput:\n") is non-blank and would
644
+ // otherwise pass through as if it were real output.
645
+ const resultText = normalizeEmptyExecToolResultText(text, {
646
+ toolName: tr.toolName,
647
+ toolNamespace: tr.toolNamespace,
648
+ }) ?? (text.trim() ? text : KIRO_EMPTY_TOOL_RESULT_MESSAGE);
570
649
  const images = extractKiroImages(tr.content);
571
650
  const toolUseId = normalizeToolId(tr.toolCallId);
572
651
  if (!priorCalls.has(toolUseId)) {
@@ -587,12 +666,23 @@ export function buildKiroPayload(
587
666
  if (turns.length === 0 || turns[0].kind === "assistant") {
588
667
  turns.unshift({ kind: "user", content: KIRO_CONTINUATION_MESSAGE, images: [], toolResults: [] });
589
668
  }
590
- if (turns.at(-1)?.kind === "assistant") {
669
+ // Kiro requires the request to end with a user turn, so a trailing assistant turn always gets
670
+ // one appended (the pop below throws otherwise). What that turn SAYS is the load-bearing part.
671
+ //
672
+ // Normally a trailing assistant turn means the model stopped mid-task, and a continuation/retry
673
+ // prompt is correct. A DELIVERED final answer is the exception: the turn already ended, and
674
+ // telling that model to "continue" or to call the completion tool again reopens finished work —
675
+ // the completed-task-behaves-like-an-open-goal loop. It gets a neutral acknowledgement instead:
676
+ // structurally valid, but carrying no instruction to resume.
677
+ const trailing = turns.at(-1);
678
+ if (trailing?.kind === "assistant") {
679
+ const resumeText = completionMode === "text_fallback" ? KIRO_COMPLETION_RETRY_MESSAGE : KIRO_CONTINUATION_MESSAGE;
591
680
  turns.push({
592
681
  kind: "user",
593
- content: completionMode === "text_fallback" ? KIRO_COMPLETION_RETRY_MESSAGE : KIRO_CONTINUATION_MESSAGE,
682
+ content: trailing.finalAnswer ? KIRO_ANSWER_DELIVERED_MESSAGE : resumeText,
594
683
  images: [],
595
684
  toolResults: [],
685
+ ...(trailing.finalAnswer ? { answerDeliveredAck: true } : {}),
596
686
  });
597
687
  }
598
688
 
@@ -608,6 +698,8 @@ export function buildKiroPayload(
608
698
 
609
699
  const currentTurn = turns.pop();
610
700
  if (!currentTurn || currentTurn.kind !== "user") throw new Error("Kiro request must end with a user turn");
701
+ // Survives the pop as state, so the checks below never infer intent from user-supplied text.
702
+ const answerDeliveredAck = currentTurn.answerDeliveredAck === true;
611
703
  const toEntry = (turn: KiroTurn): KiroHistoryEntry => turn.kind === "assistant"
612
704
  ? {
613
705
  assistantResponseMessage: {
@@ -638,10 +730,17 @@ export function buildKiroPayload(
638
730
  currentUim.userInputMessageContext = { ...(currentUim.userInputMessageContext ?? {}), tools: kiroTools };
639
731
  }
640
732
  if (completionMode === "text_fallback") {
641
- if (currentUim.content !== KIRO_COMPLETION_RETRY_MESSAGE) {
733
+ // Never append the retry instruction onto the answer-delivered acknowledgement: it exists
734
+ // precisely to avoid asking a finished turn for another completion call, and appending here
735
+ // would reinstate the loop it prevents.
736
+ if (currentUim.content !== KIRO_COMPLETION_RETRY_MESSAGE && !answerDeliveredAck) {
642
737
  currentUim.content = appendTurnText(currentUim.content, KIRO_COMPLETION_RETRY_MESSAGE);
643
738
  }
644
- } else if (!currentUim.userInputMessageContext?.toolResults && currentUim.content !== KIRO_CONTINUATION_MESSAGE) {
739
+ } else if (
740
+ !currentUim.userInputMessageContext?.toolResults
741
+ && currentUim.content !== KIRO_CONTINUATION_MESSAGE
742
+ && !answerDeliveredAck
743
+ ) {
645
744
  currentUim.content = injectKiroThinkingTags(currentUim.content, parsed);
646
745
  }
647
746
 
@@ -1905,6 +2004,24 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
1905
2004
 
1906
2005
  return {
1907
2006
  name: "kiro",
2007
+ // A replayed history that already ENDS with a delivered final answer has nothing to ask Kiro.
2008
+ // Before this hook the adapter still appended a trailing user turn — a neutral acknowledgement,
2009
+ // but structurally still a prompt — and performed a real inference, so the model answered the
2010
+ // closed task again and the finished turn behaved like a still-open goal.
2011
+ //
2012
+ // Suppressing the completion contract (above) removed the instruction to complete; it could not
2013
+ // remove the inference. This is the boundary: no request is built, nothing is sent, and no token
2014
+ // estimate is recorded.
2015
+ //
2016
+ // The forced-fallback build is deliberately NOT consulted here: this hook runs on the inbound
2017
+ // turn only, and the adapter-owned bounded retry passes "text_fallback" through `build`
2018
+ // directly, never through this path.
2019
+ localTerminal(parsed: OcxParsedRequest) {
2020
+ return hasTrailingDeliveredFinalAnswer(kiroPayloadMessages(parsed), parsed)
2021
+ ? { reason: "kiro_final_answer_already_delivered" }
2022
+ : undefined;
2023
+ },
2024
+
1908
2025
  async buildRequest(parsed: OcxParsedRequest, incoming) {
1909
2026
  const built = await build(parsed);
1910
2027
  modelId = parsed.modelId;
@@ -1113,7 +1113,8 @@ function intersectBound(target: unknown, sibling: unknown, direction: "max" | "m
1113
1113
  * Compose two `properties` maps. A property named in BOTH the referenced target and the
1114
1114
  * node is the same conjunction problem `required` had: letting the sibling win discards
1115
1115
  * the target's constraints for that member. Merge the two member schemas so neither side
1116
- * loses its keywords, and let the node narrow on a genuine conflict.
1116
+ * loses its keywords. Shared member bounds are the same conjunction one level down,
1117
+ * and nested object members recurse through this helper instead of replacing the target.
1117
1118
  */
1118
1119
  function composeProperties(
1119
1120
  target: Record<string, unknown>,
@@ -1127,7 +1128,20 @@ function composeProperties(
1127
1128
  const member: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
1128
1129
  for (const [k, v] of Object.entries(existing)) member[k] = v;
1129
1130
  for (const [k, v] of Object.entries(sub)) {
1130
- member[k] = k === "required" ? unionRequired(member[k], v) : v;
1131
+ if (k === "required") {
1132
+ member[k] = unionRequired(member[k], v);
1133
+ continue;
1134
+ }
1135
+ if (k === "properties" && isXaiObjectSchema(member[k]) && isXaiObjectSchema(v)) {
1136
+ member[k] = composeProperties(member[k] as Record<string, unknown>, v);
1137
+ continue;
1138
+ }
1139
+ const boundDirection = MOONSHOT_BOUND_KEYWORDS[k];
1140
+ if (boundDirection && k in member) {
1141
+ member[k] = intersectBound(member[k], v, boundDirection);
1142
+ continue;
1143
+ }
1144
+ member[k] = v;
1131
1145
  }
1132
1146
  combined[name] = member;
1133
1147
  continue;
@@ -319,8 +319,8 @@ function stripUnsupportedReasoningParams(body: unknown): unknown {
319
319
  }
320
320
 
321
321
  /**
322
- * GPT-5.6 replaced the legacy 24-hour retention field with `prompt_cache_options.ttl`, and the
323
- * ChatGPT backend 400s the whole request when the retired field is present (issue #2092).
322
+ * GPT-5.6 retired the legacy 24-hour retention field, and the ChatGPT backend 400s the whole
323
+ * request when that field is present (issue #2092).
324
324
  *
325
325
  * The retired field is NOT translated to the replacement: 5.6 carries a different TTL contract,
326
326
  * and implicit caching still applies when the caller sent no replacement options. Inventing a
@@ -340,6 +340,18 @@ function stripDeprecatedPromptCacheRetention(body: unknown, modelId: unknown): u
340
340
  return rest;
341
341
  }
342
342
 
343
+ /**
344
+ * Public Responses clients can send `prompt_cache_options`, but the canonical ChatGPT Codex
345
+ * backend rejects the top-level field before inference (issue #2765). Custom forward gateways and
346
+ * API-key Responses providers own different wire contracts, so the caller applies this only after
347
+ * the canonical destination predicate succeeds.
348
+ */
349
+ function stripCanonicalForwardPromptCacheOptions(body: unknown): unknown {
350
+ if (!isPlainObject(body) || !Object.hasOwn(body, "prompt_cache_options")) return body;
351
+ const { prompt_cache_options: _options, ...rest } = body;
352
+ return rest;
353
+ }
354
+
343
355
  /**
344
356
  * A false model capability prevents Codex from emitting summary fields after the catalog refresh.
345
357
  * Strip them here as well so an already-running client with a stale catalog cannot keep sending an
@@ -2004,6 +2016,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
2004
2016
  // third-party forward gateway may still accept it, so this must not be widened.
2005
2017
  if (isCanonicalOpenAiForwardProvider(provider)) {
2006
2018
  outBody = stripDeprecatedPromptCacheRetention(outBody, parsed.modelId);
2019
+ outBody = stripCanonicalForwardPromptCacheOptions(outBody);
2007
2020
  outBody = normalizeCanonicalForwardPromptEnvelope(outBody);
2008
2021
  outBody = normalizeCanonicalForwardContinuationEnvelope(outBody);
2009
2022
  }
@@ -5,6 +5,7 @@ import {
5
5
  type OcxTool,
6
6
  type OcxProviderConfig,
7
7
  } from "../types";
8
+ import { CODE_MODE_RESULT_ECHO_SENTENCE } from "./exec-tool-result-normalize";
8
9
 
9
10
  // Tool names that exist only in OTHER agent harnesses (Claude Code and friends). Naming one
10
11
  // here tells a routed model not to call it unless this turn's catalog really lists it.
@@ -120,7 +121,7 @@ export function buildNonOpenAIToolCatalogNudgeFromNames(
120
121
  "Call only listed names with their listed argument keys; do not invent, translate, or rename tools.",
121
122
  "Names mentioned only in instructions, tool descriptions, argument descriptions, or nested helper APIs are not additional top-level tools.",
122
123
  verifiedCodeModeExecName
123
- ? "`" + verifiedCodeModeExecName + "` is Codex code mode: its body is JavaScript evaluated in a V8 isolate. Nested helpers are called INSIDE that body as `await tools.<name>(...)`, for example `await tools.exec_command({cmd: \"ls\"})` or `await tools.codex_app__list_threads({})`. Absence from the top-level catalog or from `" + verifiedCodeModeExecName + "`'s description is not absence: deferred helpers stay callable on `tools.<name>`. Discover them from the isolate global `ALL_TOOLS`, not `tools.ALL_TOOLS`. Do not skip an available nested helper because it is omitted from the listed top-level names. Nested `tools.apply_patch(input)` is host-executed: the string must begin exactly with `*** Begin Patch` and end with `*** End Patch` (no trailing `***` on those lines). OpenCodex does not rewrite JavaScript inside exec, so a decorated `*** Begin Patch ***` envelope is rejected by Codex before the file is touched."
124
+ ? "`" + verifiedCodeModeExecName + "` is Codex code mode: its body is JavaScript evaluated in a V8 isolate. Nested helpers are called INSIDE that body as `await tools.<name>(...)`, for example `await tools.exec_command({cmd: \"ls\"})` or `await tools.codex_app__list_threads({})`. Absence from the top-level catalog or from `" + verifiedCodeModeExecName + "`'s description is not absence: deferred helpers stay callable on `tools.<name>`. Discover them from the isolate global `ALL_TOOLS`, not `tools.ALL_TOOLS`. Do not skip an available nested helper because it is omitted from the listed top-level names. " + CODE_MODE_RESULT_ECHO_SENTENCE + " Nested `tools.apply_patch(input)` is host-executed: the string must begin exactly with `*** Begin Patch` and end with `*** End Patch` (no trailing `***` on those lines). OpenCodex does not rewrite JavaScript inside exec, so a decorated `*** Begin Patch ***` envelope is rejected by Codex before the file is touched."
124
125
  : "If a listed tool exposes nested helpers such as a tools.* API, call the listed parent tool and use those helpers only inside that tool's input.",
125
126
  unavailableNeighborNames.length > 0
126
127
  ? "Do not use neighboring-agent tool names " + quoteNames(unavailableNeighborNames) + " unless this turn's catalog lists those exact names."
@@ -1,8 +1,8 @@
1
1
  import type { OcxProviderConfig } from "../types";
2
+ import { isXaiResponsesDestination } from "../providers/xai-transport";
2
3
 
3
4
  const CODEX_WEB_SEARCH_TOOL = "web_search";
4
5
  const CODEX_WEB_SEARCH_PREVIEW_TOOL = "web_search_preview";
5
- const XAI_API_HOST = "api.x.ai";
6
6
 
7
7
  function isPlainObject(value: unknown): value is Record<string, unknown> {
8
8
  return !!value && typeof value === "object" && !Array.isArray(value);
@@ -12,18 +12,6 @@ function isCodexWebSearchToolType(value: unknown): boolean {
12
12
  return value === CODEX_WEB_SEARCH_TOOL || value === CODEX_WEB_SEARCH_PREVIEW_TOOL;
13
13
  }
14
14
 
15
- /** Match only xAI's documented public API, not arbitrary Responses-compatible gateways. */
16
- function isXaiPublicApi(provider: Pick<OcxProviderConfig, "baseUrl">): boolean {
17
- try {
18
- const url = new URL(provider.baseUrl);
19
- return url.protocol === "https:"
20
- && url.hostname.toLowerCase() === XAI_API_HOST
21
- && (url.port === "" || url.port === "443");
22
- } catch {
23
- return false;
24
- }
25
- }
26
-
27
15
  type ToolGroupRewrite = {
28
16
  tools: unknown[];
29
17
  changed: boolean;
@@ -150,12 +138,20 @@ function normalizeToolChoice(body: Record<string, unknown>): Record<string, unkn
150
138
  /**
151
139
  * Make Codex's hosted web-search declaration acceptable to xAI Responses without changing other
152
140
  * providers or mutating the caller-owned request body.
141
+ *
142
+ * Scoped to BOTH xAI Responses hosts, not just the public API. The 2026-08-22 probe recorded in
143
+ * `normalizeToolGroup` and in `isXaiResponsesDestination` already found the two hosts to be one
144
+ * dialect, but this gate stayed on `api.x.ai` alone, so the Grok CLI proxy — the OAuth lane — was
145
+ * left unnormalized. Re-probed 2026-08-27 against `cli-chat-proxy.grok.com`:
146
+ * `web_search_preview` -> 422 `unknown variant`, `external_web_access` -> 400 on every value,
147
+ * `search_context_size` -> 400, while `user_location` and `search_content_types` -> 200. Identical
148
+ * to the public API, which is what makes one shared gate correct.
153
149
  */
154
150
  export function normalizeXaiResponsesWebSearch(
155
151
  body: unknown,
156
152
  provider: Pick<OcxProviderConfig, "baseUrl">,
157
153
  ): unknown {
158
- if (!isXaiPublicApi(provider) || !isPlainObject(body)) return body;
154
+ if (!isXaiResponsesDestination(provider) || !isPlainObject(body)) return body;
159
155
 
160
156
  let next: Record<string, unknown> = body;
161
157
  if (Array.isArray(body.tools)) {
@@ -11,6 +11,7 @@
11
11
  * - errors: {type:"error", error:{type,message}}; may arrive mid-stream after HTTP 200.
12
12
  */
13
13
  import { createHash } from "node:crypto";
14
+ import { httpStatusFromTerminalError } from "../lib/errors";
14
15
  import { isTransientUpstreamStatus } from "../lib/upstream-retry";
15
16
  import {
16
17
  isTranslatorBudgetExceededError,
@@ -555,9 +556,19 @@ export function responsesSseToAnthropicSse(
555
556
  }
556
557
  const status = code === "translation_buffer_limit"
557
558
  ? 413
558
- : typeof error.status === "number" ? error.status : 500;
559
- // status-absent response.failed (relaySseWithFailedTail synthetic tail) defaults
560
- // to 500, which is in the transient set the mid-stream reset shape maps to
559
+ : typeof error.status === "number"
560
+ ? error.status
561
+ // Internal response.failed envelopes carry the classified {type, code, message}
562
+ // but no numeric status. Derive it with the same mapping /api/logs uses so a
563
+ // classified 429/401/400 reaches Claude Code as its real Anthropic error type
564
+ // instead of being masked as retryable overload.
565
+ : httpStatusFromTerminalError({
566
+ type: typeof error.type === "string" ? error.type : undefined,
567
+ code: typeof error.code === "string" ? error.code : null,
568
+ message,
569
+ });
570
+ // Unclassified status-absent response.failed (relaySseWithFailedTail synthetic
571
+ // tail) still lands on a transient 5xx here — the mid-stream reset shape maps to
561
572
  // overloaded_error by design.
562
573
  fail(
563
574
  status,
package/src/cli/access.ts CHANGED
@@ -17,6 +17,51 @@ const USAGE = `Usage:
17
17
  ocx access models [--json]
18
18
  ocx access test <model> [--protocol <chat|responses|messages>] [--json]`;
19
19
 
20
+ /**
21
+ * Render the key table with the usage fields the API already returns (#2705).
22
+ *
23
+ * `usage` is a DISCRIMINATED UNION server-side (`api-key-usage.ts`): the `{ambiguous:true}`
24
+ * variant carries no numbers at all, because when two config entries share an id there IS no
25
+ * per-key total. The union exists specifically so a consumer cannot print a number beside an
26
+ * ambiguity marker, so this renders the word `ambiguous` across the numeric columns rather
27
+ * than a fabricated 0 -- reporting 0 requests for a key that may be in heavy use is the
28
+ * dangerous answer to hand someone deciding what to delete.
29
+ *
30
+ * `attributionSince` and `historyTruncated` describe the DATA SET, not a key, so they print
31
+ * once as a footer. Without `attributionSince`, an absent `lastUsedAt` is unreadable: it
32
+ * could mean "never used" or "nothing is attributable yet".
33
+ */
34
+ function formatKeyRows(payload: Record<string, unknown>, keys: Array<Record<string, unknown>>): string[] {
35
+ const cells: string[][] = [["ID", "NAME", "PREFIX", "REQ 7D", "TOTAL", "LAST USED"]];
36
+ for (const entry of keys) {
37
+ const usage = (entry.usage ?? {}) as Record<string, unknown>;
38
+ const ambiguous = usage.ambiguous === true;
39
+ const num = (value: unknown): string => (typeof value === "number" ? value.toLocaleString("en-US") : "-");
40
+ cells.push([
41
+ String(entry.id ?? ""),
42
+ String(entry.name ?? ""),
43
+ String(entry.prefix ?? ""),
44
+ // One marker spanning both numeric columns: the union guarantees neither exists.
45
+ ambiguous ? "ambiguous" : num(usage.requests7d),
46
+ ambiguous ? "" : num(usage.totalRequests),
47
+ ambiguous ? "" : (typeof usage.lastUsedAt === "string" ? usage.lastUsedAt : "never"),
48
+ ]);
49
+ }
50
+ const widths = cells[0]!.map((_, column) => Math.max(...cells.map(row => (row[column] ?? "").length)));
51
+ const lines = cells.map(row => row.map((cell, i) => (cell ?? "").padEnd(widths[i]!)).join(" ").trimEnd());
52
+ const footer: string[] = [];
53
+ if (typeof payload.attributionSince === "string") {
54
+ footer.push(`attribution since ${payload.attributionSince}`);
55
+ }
56
+ if (payload.historyTruncated === true) {
57
+ footer.push("older history truncated");
58
+ }
59
+ if (keys.some(entry => (entry.usage as Record<string, unknown> | undefined)?.ambiguous === true)) {
60
+ footer.push("ambiguous: two configured keys share an id, so per-key totals do not exist");
61
+ }
62
+ return footer.length > 0 ? [...lines, "", ...footer] : lines;
63
+ }
64
+
20
65
  async function key(argv: string[], deps: RuntimeApiDeps): Promise<void> {
21
66
  const args = [...argv];
22
67
  const action = (args.shift() ?? "list").toLowerCase();
@@ -25,9 +70,7 @@ async function key(argv: string[], deps: RuntimeApiDeps): Promise<void> {
25
70
  rejectArgs(args, USAGE);
26
71
  const result = await runtimeRequest<Record<string, unknown>>("/api/keys", {}, deps);
27
72
  const keys = Array.isArray(result.keys) ? result.keys as Array<Record<string, unknown>> : [];
28
- printData(result, wantsJson, keys.length
29
- ? keys.map(entry => `${String(entry.id)} ${String(entry.name)} ${String(entry.prefix ?? "")}`)
30
- : ["No API access keys configured."]);
73
+ printData(result, wantsJson, keys.length ? formatKeyRows(result, keys) : ["No API access keys configured."]);
31
74
  return;
32
75
  }
33
76
  if (action === "create") {