@bitkyc08/opencodex 2.76.0 → 2.77.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 (189) hide show
  1. package/gui/dist/assets/App-COdEeqSj.css +1 -0
  2. package/gui/dist/assets/App-DEpCJEP_.js +54 -0
  3. package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-gloIYNaW.js} +1 -1
  4. package/gui/dist/assets/index-Cd7ittMv.css +1 -0
  5. package/gui/dist/assets/index-DwJKFJm5.js +94 -0
  6. package/gui/dist/assets/quota-summary-DGcEJU3h.js +1 -0
  7. package/gui/dist/index.html +4 -3
  8. package/package.json +1 -1
  9. package/src/adapters/antigravity-refusal.ts +28 -0
  10. package/src/adapters/cursor/protobuf-request.ts +6 -0
  11. package/src/adapters/cursor/request-builder.ts +3 -0
  12. package/src/adapters/cursor/structured-output.ts +13 -0
  13. package/src/adapters/cursor/types.ts +2 -0
  14. package/src/adapters/ollama-native.ts +29 -2
  15. package/src/adapters/openai-responses/passthrough.ts +133 -103
  16. package/src/adapters/openai-responses/request-strips.ts +10 -4
  17. package/src/adapters/zed.ts +26 -4
  18. package/src/bridge/response-json.ts +14 -14
  19. package/src/bridge/sse.ts +15 -5
  20. package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
  21. package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
  22. package/src/claude/agents-inject.ts +4 -43
  23. package/src/claude/gateway-cache.ts +16 -13
  24. package/src/claude/inbound-model-options.ts +9 -2
  25. package/src/claude/intercept/cli-catalog.ts +27 -16
  26. package/src/claude/intercept/picker-bootstrap.ts +37 -16
  27. package/src/claude/intercept/picker-budget.ts +41 -0
  28. package/src/claude/outbound.ts +2 -0
  29. package/src/claude/subagent-force-status.ts +53 -0
  30. package/src/claude/subagent-model.ts +93 -0
  31. package/src/cli/account-api.ts +3 -0
  32. package/src/cli/account.ts +3 -1
  33. package/src/cli/agent.ts +10 -0
  34. package/src/cli/capabilities.ts +9 -0
  35. package/src/cli/chatgpt-command.ts +10 -7
  36. package/src/cli/claude.ts +22 -6
  37. package/src/cli/codex-shim-readiness.ts +89 -0
  38. package/src/cli/connect.ts +19 -0
  39. package/src/cli/dispatch.ts +3 -3
  40. package/src/cli/export-command.ts +20 -9
  41. package/src/cli/gui.ts +8 -3
  42. package/src/cli/help-catalog.ts +84 -0
  43. package/src/cli/help-models-context.ts +17 -0
  44. package/src/cli/help-navigation.ts +50 -0
  45. package/src/cli/help-recovery.ts +66 -0
  46. package/src/cli/help.ts +52 -8
  47. package/src/cli/models-runtime.ts +2 -1
  48. package/src/cli/opencode.ts +54 -10
  49. package/src/cli/provider.ts +3 -29
  50. package/src/cli/registry.ts +29 -5
  51. package/src/cli/root.ts +30 -9
  52. package/src/cli/runtime-api.ts +1 -1
  53. package/src/cli/tray-proxy.ts +3 -2
  54. package/src/clients/config-export/contracts.ts +100 -0
  55. package/src/clients/config-export/droid.ts +54 -2
  56. package/src/clients/config-export/kilo.ts +12 -2
  57. package/src/clients/config-export/model-metadata.ts +10 -4
  58. package/src/clients/config-export/reasoning-metadata.ts +47 -0
  59. package/src/clients/config-export.ts +46 -66
  60. package/src/clients/omo-role-models.ts +17 -3
  61. package/src/codex/account-credit-use.ts +4 -2
  62. package/src/codex/account-runtime-state.ts +21 -0
  63. package/src/codex/account-store.ts +11 -47
  64. package/src/codex/account-usability.ts +11 -8
  65. package/src/codex/auth-api/account-list.ts +73 -63
  66. package/src/codex/auth-api/main-account-probe.ts +25 -12
  67. package/src/codex/auth-api/pool-mode-gate.ts +6 -9
  68. package/src/codex/auth-api/pool-quota-probe.ts +2 -4
  69. package/src/codex/auth-api/reset-credit-service.ts +3 -2
  70. package/src/codex/auth-context.ts +30 -11
  71. package/src/codex/catalog/aggregation.ts +3 -0
  72. package/src/codex/catalog/model-hints.ts +2 -1
  73. package/src/codex/catalog/model-visibility.ts +6 -3
  74. package/src/codex/catalog/parsing.ts +2 -0
  75. package/src/codex/catalog/provider-models.ts +13 -4
  76. package/src/codex/catalog/retained-sync.ts +2 -1
  77. package/src/codex/chatgpt-refresh-failure.ts +56 -0
  78. package/src/codex/convergence.ts +2 -1
  79. package/src/codex/main-account.ts +144 -11
  80. package/src/codex/quota-refresh-outcome.ts +17 -2
  81. package/src/codex/quota-types.ts +33 -1
  82. package/src/codex/quota.ts +56 -15
  83. package/src/codex/routing/cooldown-math.ts +10 -4
  84. package/src/codex/routing/selection.ts +11 -4
  85. package/src/codex/routing.ts +9 -9
  86. package/src/codex/shim-path-resolution.ts +133 -0
  87. package/src/codex/shim.ts +39 -39
  88. package/src/codex/subagent-model-fallback.ts +2 -1
  89. package/src/combos/failover.ts +82 -1
  90. package/src/combos/jev.ts +13 -3
  91. package/src/config/diagnostics.ts +4 -0
  92. package/src/config/load-degrade.ts +4 -0
  93. package/src/config/provider-validation.ts +3 -0
  94. package/src/config/schema/compaction-triggers.ts +8 -0
  95. package/src/config/schema/config-schema.ts +11 -0
  96. package/src/config/schema/leaf-validators.ts +5 -1
  97. package/src/config/subagent-models.ts +12 -0
  98. package/src/generated/compatibility-version.json +245 -153
  99. package/src/grok/session-identity.ts +33 -0
  100. package/src/integrations/catalog-refresh.ts +1 -1
  101. package/src/integrations/mutation-plan.ts +4 -2
  102. package/src/integrations/state.ts +68 -6
  103. package/src/integrations/writer.ts +18 -4
  104. package/src/lib/gui-pair-capability.ts +17 -0
  105. package/src/lib/provider-client-headers.ts +62 -0
  106. package/src/oauth/anthropic-continuity.ts +65 -0
  107. package/src/oauth/anthropic-identity.ts +75 -0
  108. package/src/oauth/anthropic-routing.ts +3 -3
  109. package/src/oauth/anthropic.ts +7 -1
  110. package/src/oauth/generic-account-failover.ts +21 -0
  111. package/src/oauth/health.ts +32 -5
  112. package/src/oauth/index.ts +36 -27
  113. package/src/oauth/local-token-detect.ts +7 -0
  114. package/src/oauth/refresh-policy.ts +2 -0
  115. package/src/oauth/store.ts +55 -6
  116. package/src/oauth/types.ts +9 -0
  117. package/src/protocols/encoders/messages.ts +4 -3
  118. package/src/providers/antigravity-effort-families.ts +75 -0
  119. package/src/providers/antigravity-models.ts +54 -16
  120. package/src/providers/antigravity-wire-snapshot.ts +66 -0
  121. package/src/providers/derive.ts +2 -0
  122. package/src/providers/model-rename-fields.ts +3 -0
  123. package/src/providers/new-model-policy.ts +48 -10
  124. package/src/providers/registry/entries-extended.ts +3 -0
  125. package/src/providers/registry/model-ids.ts +2 -0
  126. package/src/providers/registry/model-seeds.ts +5 -0
  127. package/src/providers/registry/types.ts +4 -0
  128. package/src/responses/compaction.ts +19 -0
  129. package/src/router.ts +6 -0
  130. package/src/server/auth-cors.ts +9 -0
  131. package/src/server/chat-completions.ts +10 -0
  132. package/src/server/claude-messages.ts +18 -11
  133. package/src/server/droid-reasoning-default.ts +50 -0
  134. package/src/server/github-copilot-responses-repair.ts +3 -6
  135. package/src/server/grok-responses-control-frame.ts +2 -2
  136. package/src/server/grok-responses-snapshot-repair.ts +4 -5
  137. package/src/server/gui-session.ts +33 -11
  138. package/src/server/index/claude-intercept-lifecycle.ts +4 -0
  139. package/src/server/index/serve-options.ts +2 -1
  140. package/src/server/management/agent-settings-routes.ts +6 -129
  141. package/src/server/management/config-routes.ts +1 -1
  142. package/src/server/management/integration-routes.ts +64 -6
  143. package/src/server/management/link-routes.ts +20 -13
  144. package/src/server/management/model-row-export-metadata.ts +150 -0
  145. package/src/server/management/model-rows.ts +148 -9
  146. package/src/server/management/route-registry.ts +4 -3
  147. package/src/server/management/shared.ts +19 -5
  148. package/src/server/management/subagent-model-routes.ts +214 -0
  149. package/src/server/relay.ts +14 -44
  150. package/src/server/request-log-generation-window.ts +38 -0
  151. package/src/server/request-log.ts +13 -0
  152. package/src/server/responses/adapter-dispatch.ts +100 -1
  153. package/src/server/responses/codex-auth-error.ts +10 -2
  154. package/src/server/responses/combo-stream-preflight.ts +29 -3
  155. package/src/server/responses/compaction-routing.ts +10 -1
  156. package/src/server/responses/core-auth.ts +57 -26
  157. package/src/server/responses/core-codex-account.ts +6 -3
  158. package/src/server/responses/core-combo-failure.ts +5 -1
  159. package/src/server/responses/core-combo-native.ts +7 -1
  160. package/src/server/responses/core-combo.ts +18 -1
  161. package/src/server/responses/core-normalize.ts +7 -0
  162. package/src/server/responses/core-options.ts +5 -0
  163. package/src/server/responses/fetch-helpers.ts +9 -0
  164. package/src/server/responses/passthrough-delivery.ts +1 -1
  165. package/src/server/responses/policy-fallback.ts +1 -1
  166. package/src/server/responses/request-prepare.ts +5 -0
  167. package/src/server/responses/request-spend.ts +30 -30
  168. package/src/server/responses-custom-tool-repair.ts +2 -2
  169. package/src/server/responses-hosted-image-display.ts +70 -9
  170. package/src/server/responses-terminal-repair.ts +15 -2
  171. package/src/server/sse-frame-buffer.ts +38 -36
  172. package/src/server/sse-payload-rewrite.ts +78 -49
  173. package/src/server/ws-bridge.ts +1 -11
  174. package/src/service/windows-taskxml.ts +1 -2
  175. package/src/service-manager-probe.ts +36 -2
  176. package/src/types/config.ts +4 -0
  177. package/src/types/provider.ts +18 -0
  178. package/src/update/npm-invocation.mjs +35 -3
  179. package/src/update/proxy-liveness-probe.mjs +38 -8
  180. package/src/usage/expected-prices.ts +9 -0
  181. package/src/usage/log.ts +7 -0
  182. package/src/usage/summary.ts +82 -1
  183. package/src/web-search/loop.ts +11 -8
  184. package/src/web-search/run-turn-loop.ts +10 -7
  185. package/gui/dist/assets/App-CddorHdA.js +0 -51
  186. package/gui/dist/assets/App-DMudtISW.css +0 -1
  187. package/gui/dist/assets/index-Btze1I3T.css +0 -1
  188. package/gui/dist/assets/index-CvnY7E6i.js +0 -86
  189. package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
@@ -61,6 +61,7 @@ import { resolveWireProtocolOverride } from "./adapter-resolve";
61
61
  import type { OcxConfig } from "../types";
62
62
  import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress";
63
63
  import { addFinalRequestLog, httpStatusForRequestLogTerminal, recordFirstOutput, type RequestLogContext } from "./request-log";
64
+ import { recordGenerationEvent } from "./request-log-generation-window";
64
65
  import { createFinalRequestLog } from "./inference/final-log";
65
66
  import {
66
67
  conversationIdFromClaudeMetadata,
@@ -143,10 +144,10 @@ function decodeClaudeFastSelector(raw: string, cc?: OcxConfig["claudeCode"]): st
143
144
  return decodedBase === bare ? exact : `${decodedBase}--fast`;
144
145
  }
145
146
 
146
- /** Restore the reversible Fable picker alias before Anthropic passthrough checks. */
147
- function decodeFablePickerAlias(raw: string, cc?: OcxConfig["claudeCode"]): string {
147
+ /** Restore reversible native Claude picker aliases before Anthropic passthrough checks. */
148
+ function decodeNativeClaudePickerAlias(raw: string, cc?: OcxConfig["claudeCode"]): string {
148
149
  const decoded = resolveInboundModel(raw, cc);
149
- if (!decoded.startsWith("claude-fable-")) return raw;
150
+ if (!decoded.startsWith("claude-")) return raw;
150
151
  // A picker value saved before the ocx-claude spelling keeps the native passthrough too.
151
152
  return claudeCodeNativeAlias(decoded) === raw || legacyAliasForNative(decoded) === raw ? decoded : raw;
152
153
  }
@@ -334,6 +335,7 @@ export function tapAnthropicSseForLog(
334
335
  let data: unknown;
335
336
  try { data = JSON.parse(dataLine); } catch { return; }
336
337
  if (!isRec(data)) return;
338
+ recordGenerationEvent(logCtx, data.type);
337
339
  if (data.type === "message_start" && isRec(data.message) && isRec(data.message.usage)) {
338
340
  usageAcc = { ...usageAcc, ...data.message.usage };
339
341
  } else if (data.type === "message_delta" && isRec(data.usage)) {
@@ -894,7 +896,7 @@ async function handleClaudeMessagesWithBudget(
894
896
  }
895
897
  }
896
898
  if (isRec(anthropicBody) && typeof anthropicBody.model === "string") {
897
- anthropicBody.model = decodeFablePickerAlias(anthropicBody.model, cc);
899
+ anthropicBody.model = decodeNativeClaudePickerAlias(anthropicBody.model, cc);
898
900
  }
899
901
  if (isRec(anthropicBody) && typeof anthropicBody.model === "string") {
900
902
  requestedModel = anthropicBody.model;
@@ -1313,10 +1315,12 @@ async function handleClaudeMessagesWithBudget(
1313
1315
  const replayRefusal = isReplayRefusalResponse(response);
1314
1316
  // Re-shape the OpenAI-style error envelope into the Anthropic one, preserving status.
1315
1317
  let message = `upstream error (${response.status})`;
1318
+ let contextError = false;
1316
1319
  try {
1317
1320
  const text = await response.text();
1318
1321
  try {
1319
- const parsed = JSON.parse(text) as { error?: { message?: string; type?: string } | string; message?: string };
1322
+ const parsed = JSON.parse(text) as { error?: { message?: string; type?: string; code?: unknown } | string; message?: string };
1323
+ contextError = typeof parsed?.error === "object" && parsed.error?.code === "context_length_exceeded";
1320
1324
  const nested = typeof parsed?.error === "object" && parsed.error ? parsed.error.message : undefined;
1321
1325
  const flat = typeof parsed?.error === "string" ? parsed.error : parsed?.message;
1322
1326
  message = nested || flat || (text ? `upstream error (${response.status}): ${text.slice(0, 400)}` : message);
@@ -1325,7 +1329,7 @@ async function handleClaudeMessagesWithBudget(
1325
1329
  }
1326
1330
  } catch { /* keep fallback message */ }
1327
1331
  const upstreamRetryAfter = response.headers.get("retry-after");
1328
- const retryAfter = replayRefusal
1332
+ const retryAfter = replayRefusal || contextError
1329
1333
  ? undefined
1330
1334
  : resolveClientRetryAfter({
1331
1335
  status: response.status,
@@ -1345,10 +1349,10 @@ async function handleClaudeMessagesWithBudget(
1345
1349
  const nativeMainFence = response.status === 503
1346
1350
  && upstreamRetryAfter?.trim() === "1"
1347
1351
  && message === CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE;
1348
- const transient = !replayRefusal && !nativeMainFence && isTransientUpstreamStatus(response.status);
1352
+ const transient = !replayRefusal && !nativeMainFence && !contextError && isTransientUpstreamStatus(response.status);
1349
1353
  const outStatus = replayRefusal
1350
1354
  ? REPLAY_REFUSED_STATUS
1351
- : nativeMainFence ? 503 : transient ? 529 : response.status;
1355
+ : nativeMainFence ? 503 : contextError ? 400 : transient ? 529 : response.status;
1352
1356
  const outHeaders = new Headers({ "Content-Type": "application/json" });
1353
1357
  if (retryAfter) outHeaders.set("Retry-After", retryAfter);
1354
1358
  else if (transient) outHeaders.set("Retry-After", "2");
@@ -1357,7 +1361,7 @@ async function handleClaudeMessagesWithBudget(
1357
1361
  outStatus,
1358
1362
  message,
1359
1363
  undefined,
1360
- replayRefusal ? UPSTREAM_RESET_REPLAY_REFUSED_CODE : undefined,
1364
+ replayRefusal ? UPSTREAM_RESET_REPLAY_REFUSED_CODE : contextError ? "context_length_exceeded" : undefined,
1361
1365
  )), {
1362
1366
  status: outStatus,
1363
1367
  headers: outHeaders,
@@ -1408,7 +1412,7 @@ async function handleClaudeMessagesWithBudget(
1408
1412
  );
1409
1413
  }
1410
1414
  return new Response(JSON.stringify(message), {
1411
- status: isError ? 502 : 200,
1415
+ status: isError ? (translatedError?.code === "context_length_exceeded" ? 400 : 502) : 200,
1412
1416
  headers: { "Content-Type": "application/json" },
1413
1417
  });
1414
1418
  }
@@ -1431,6 +1435,9 @@ async function handleClaudeMessagesWithBudget(
1431
1435
  "translation_buffer_limit",
1432
1436
  );
1433
1437
  }
1438
+ if (error?.code === "context_length_exceeded") {
1439
+ return anthropicErrorResponse(400, error.message ?? "upstream context limit exceeded", "invalid_request_error", error.code);
1440
+ }
1434
1441
  return anthropicErrorResponse(502, error?.message ?? "upstream request failed", "api_error");
1435
1442
  }
1436
1443
  let message: Rec;
@@ -1659,7 +1666,7 @@ export async function handleClaudeCountTokens(
1659
1666
  model = stripOneMillionMarker(countRoute);
1660
1667
  raw.model = model;
1661
1668
  }
1662
- model = decodeFablePickerAlias(model, cc);
1669
+ model = decodeNativeClaudePickerAlias(model, cc);
1663
1670
  raw.model = model;
1664
1671
  // Fast-only: count_tokens never parsed an effort row, so it must not start. It returns a
1665
1672
  // token estimate and sends no tier, so only the IDENTITY is corrected - without this the
@@ -0,0 +1,50 @@
1
+ import { CODEX_REASONING_LEVELS, configuredReasoningEfforts, isDeclaredReasoningEffort } from "../reasoning-effort";
2
+ import { isPlainObject } from "../lib/plain-data";
3
+ import type { OcxProviderConfig } from "../types";
4
+
5
+ export { DROID_DEFAULT_EFFORT_HEADER } from "../clients/config-export/contracts";
6
+
7
+ type ChatRequestBody = Record<string, unknown>;
8
+ type DroidReasoningTarget = { provider: OcxProviderConfig; modelId: string };
9
+
10
+ function hasExplicitEffort(body: ChatRequestBody): boolean {
11
+ return Object.hasOwn(body, "reasoning_effort")
12
+ || (isPlainObject(body.reasoning) && Object.hasOwn(body.reasoning, "effort"));
13
+ }
14
+
15
+ /** Read before the Chat body is bridged: the bridge drops null and unknown efforts, which still count as explicit. */
16
+ export function droidReasoningDefault(header: string | null, chatBody: ChatRequestBody): string | undefined {
17
+ return header && isDeclaredReasoningEffort(header) && !hasExplicitEffort(chatBody) ? header : undefined;
18
+ }
19
+
20
+ function pendingDefault(
21
+ body: ChatRequestBody,
22
+ defaultEffort: string | undefined,
23
+ target: DroidReasoningTarget,
24
+ ): string | undefined {
25
+ if (!defaultEffort || hasExplicitEffort(body)) return undefined;
26
+ const supportedEfforts = configuredReasoningEfforts(target.provider, target.modelId)
27
+ ?? CODEX_REASONING_LEVELS.map(level => level.effort);
28
+ return supportedEfforts.includes(defaultEffort) ? defaultEffort : undefined;
29
+ }
30
+
31
+ export function applyDroidReasoningDefault(
32
+ body: ChatRequestBody,
33
+ defaultEffort: string | undefined,
34
+ target: DroidReasoningTarget,
35
+ ): void {
36
+ const effort = pendingDefault(body, defaultEffort, target);
37
+ if (effort) body.reasoning_effort = effort;
38
+ }
39
+
40
+ export function applyDroidResponsesReasoningDefault(
41
+ body: unknown,
42
+ defaultEffort: string | undefined,
43
+ target: DroidReasoningTarget,
44
+ ): boolean {
45
+ if (!isPlainObject(body)) return false;
46
+ const effort = pendingDefault(body, defaultEffort, target);
47
+ if (!effort) return false;
48
+ body.reasoning = { ...(isPlainObject(body.reasoning) ? body.reasoning : {}), effort };
49
+ return true;
50
+ }
@@ -14,6 +14,8 @@ import {
14
14
  } from "../lib/translator-budget";
15
15
  import {
16
16
  replaceSseDataPayload,
17
+ splitSseBlock,
18
+ sseLineEnding as blockNewline,
17
19
  sseDataPayload,
18
20
  type SseBlockRewrite,
19
21
  } from "./sse-payload-rewrite";
@@ -46,14 +48,9 @@ function byteLength(value: unknown): number {
46
48
  return Buffer.byteLength(JSON.stringify(value), "utf8");
47
49
  }
48
50
 
49
- function blockNewline(block: string): "\r\n" | "\n" {
50
- return block.includes("\r\n") ? "\r\n" : "\n";
51
- }
52
-
53
51
  /** Keep a named SSE block's `event:` field aligned with its JSON `type`. */
54
52
  function withEventName(block: string, eventName: string): string {
55
- const newline = blockNewline(block);
56
- const lines = block.split(/\r?\n/);
53
+ const { newline, lines } = splitSseBlock(block);
57
54
  let replaced = false;
58
55
  const rewritten = lines.flatMap((line) => {
59
56
  if (!line.startsWith("event:")) return [line];
@@ -1,4 +1,4 @@
1
- import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
1
+ import { replaceSseDataPayload, splitSseBlock, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
2
2
 
3
3
  const GROK_CONTROL_FRAME_TYPES: Record<string, true> = {
4
4
  "codex.rate_limits": true,
@@ -144,7 +144,7 @@ export function createGrokResponsesControlFrameBlockRewrite(): SseBlockRewrite {
144
144
  let eventName = "";
145
145
  // SSE overwrites the event type on every event field, including empty resets.
146
146
  // Like sseDataPayload, remove only one optional ASCII space after the colon.
147
- for (const line of block.split(/\r?\n/)) {
147
+ for (const line of splitSseBlock(block).lines) {
148
148
  if (line === "event") eventName = "";
149
149
  else if (line.startsWith("event:")) {
150
150
  const value = line.slice("event:".length);
@@ -1,7 +1,7 @@
1
1
  /** Strict terminal reconstruction selected by the Grok compatibility marker. */
2
2
  import type { TranslatorBudget } from "../lib/translator-budget";
3
3
  import { MAX_COMPLETED_OUTPUT_ITEMS, MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES } from "./relay";
4
- import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
4
+ import { replaceSseDataPayload, splitSseBlock, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
5
5
  import { isPlainObject, jsonBlock, type RetainedOutputItem } from "./responses-snapshot-codec";
6
6
  import {
7
7
  requestToolScope,
@@ -215,14 +215,13 @@ function refusedTerminalBlock(
215
215
  },
216
216
  });
217
217
  const rewritten = replaceSseDataPayload(block, payload);
218
- const newline = block.includes("\r\n") ? "\r\n" : "\n";
218
+ const { newline, lines } = splitSseBlock(rewritten);
219
219
  let eventRewritten = false;
220
- const lines = rewritten.split(/\r?\n/).map(line => {
220
+ return lines.map(line => {
221
221
  if (eventRewritten || !line.startsWith("event:")) return line;
222
222
  eventRewritten = true;
223
223
  return `event: ${GROK_REFUSED_TERMINAL_EVENT_TYPE}`;
224
- });
225
- return lines.join(newline);
224
+ }).join(newline);
226
225
  }
227
226
 
228
227
  /**
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import type { OcxConfig } from "../types";
3
- import { canonicalGuiBrowserOrigin } from "../lib/gui-pair-capability";
3
+ import { canonicalGuiBrowserOrigin, standaloneGuiPairingOrigin } from "../lib/gui-pair-capability";
4
4
  import {
5
5
  isAllowedManagementOrigin,
6
6
  isApiAuthRequired,
@@ -20,6 +20,8 @@ export interface GuiSessionRecord {
20
20
  csrfToken: string;
21
21
  expiresAt: number;
22
22
  issuance: GuiSessionIssuance;
23
+ /** Process-local marker; never accepted from a browser. */
24
+ localPairing?: true;
23
25
  }
24
26
 
25
27
  export interface GuiSessionBootstrap extends GuiSessionRecord {
@@ -31,6 +33,7 @@ export interface GuiPairingGrantRecord {
31
33
  browserOrigin: string;
32
34
  expiresAt: number;
33
35
  failedAttempts?: number;
36
+ localPairing?: true;
34
37
  }
35
38
 
36
39
  export interface PairingAttemptContext {
@@ -131,6 +134,7 @@ function mintSession(
131
134
  issuance: GuiSessionIssuance,
132
135
  state: GuiSessionState,
133
136
  now: number,
137
+ localPairing = false,
134
138
  ): GuiSessionBootstrap {
135
139
  pruneExpired(state, now);
136
140
  evictOldestSession(state);
@@ -142,8 +146,9 @@ function mintSession(
142
146
  serverOrigin,
143
147
  browserOrigin,
144
148
  csrfToken: randomBytes(32).toString("base64url"),
145
- expiresAt: now + (issuance === "loopback" ? LOOPBACK_GUI_SESSION_TTL_MS : REMOTE_GUI_SESSION_TTL_MS),
149
+ expiresAt: now + (issuance === "loopback" || localPairing ? LOOPBACK_GUI_SESSION_TTL_MS : REMOTE_GUI_SESSION_TTL_MS),
146
150
  issuance,
151
+ ...(localPairing ? { localPairing: true as const } : {}),
147
152
  };
148
153
  state.sessions.set(token, session);
149
154
  return {
@@ -258,13 +263,16 @@ export function createGuiPairingGrant(
258
263
  now = Date.now(),
259
264
  ): { grant: string; browserOrigin: string; serverOrigin: string; expiresAt: number } {
260
265
  const canonicalBrowserOrigin = canonicalGuiBrowserOrigin(browserOrigin);
261
- const serverOrigin = canonicalHttpOrigin(config.hub?.managementPublicOrigin);
266
+ const localOrigin = standaloneGuiPairingOrigin(config);
267
+ const localPairing = localOrigin !== null && canonicalBrowserOrigin === localOrigin;
268
+ const serverOrigin = config.runtimeRole === "hub"
269
+ ? canonicalHttpOrigin(config.hub?.managementPublicOrigin) : localOrigin;
262
270
  if (
263
- config.runtimeRole !== "hub"
264
- || !canonicalBrowserOrigin
271
+ !canonicalBrowserOrigin
265
272
  || canonicalBrowserOrigin !== browserOrigin
266
273
  || !serverOrigin
267
- || !isRemoteGuiBrowserOriginAllowed(canonicalBrowserOrigin, config)
274
+ || (config.runtimeRole === "hub"
275
+ ? !isRemoteGuiBrowserOriginAllowed(canonicalBrowserOrigin, config) : !localPairing)
268
276
  ) throw new TypeError("remote GUI origin is not allowed");
269
277
  pruneExpired(state, now);
270
278
  consumeGrantRateSlot(state, now);
@@ -276,7 +284,8 @@ export function createGuiPairingGrant(
276
284
  digest = pairingGrantDigest(grant);
277
285
  } while (state.pairingGrants.has(digest));
278
286
  const expiresAt = now + GUI_PAIRING_GRANT_TTL_MS;
279
- state.pairingGrants.set(digest, { browserOrigin: canonicalBrowserOrigin, serverOrigin, expiresAt });
287
+ state.pairingGrants.set(digest, { browserOrigin: canonicalBrowserOrigin, serverOrigin, expiresAt,
288
+ ...(localPairing ? { localPairing: true as const } : {}) });
280
289
  return { grant, browserOrigin: canonicalBrowserOrigin, serverOrigin, expiresAt };
281
290
  }
282
291
 
@@ -316,7 +325,8 @@ export function consumeGuiPairingGrant(
316
325
  now = Date.now(),
317
326
  attemptContext?: PairingAttemptContext,
318
327
  ): GuiSessionBootstrap | PairingAttemptRefusal | null {
319
- if (req.method !== "POST" || hasAlternateCredential(req) || config.runtimeRole !== "hub") return null;
328
+ const localOrigin = standaloneGuiPairingOrigin(config);
329
+ if (req.method !== "POST" || hasAlternateCredential(req) || (config.runtimeRole !== "hub" && !localOrigin)) return null;
320
330
  // Scheme check FIRST, before the grant is parsed or looked up.
321
331
  //
322
332
  // A grant is single-use, so consuming one and then refusing to mint would burn the
@@ -333,6 +343,11 @@ export function consumeGuiPairingGrant(
333
343
  const grant = strictPairingGrantBody(body);
334
344
  const browserOrigin = canonicalGuiBrowserOrigin(req.headers.get("Origin"));
335
345
  if (!grant || !browserOrigin) return null;
346
+ // Unlike the hub path, standalone redemption never accepts a proxy-origin claim:
347
+ // both origins must be the configured loopback origin and the kernel peer must be local.
348
+ if (localOrigin && (destination !== localOrigin || browserOrigin !== localOrigin
349
+ || attemptContext?.ingress !== "public"
350
+ || !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(attemptContext.peerAddress ?? ""))) return null;
336
351
  const context = attemptContext ?? {
337
352
  ingress: "public",
338
353
  peerAddress: null,
@@ -344,11 +359,14 @@ export function consumeGuiPairingGrant(
344
359
  const found = findPairingGrant(grant, state);
345
360
  if (!found) {
346
361
  // Cross-origin browser requests must not create limiter state as a side effect.
347
- if (!isRemoteGuiBrowserOriginAllowed(browserOrigin, config)) return null;
362
+ if (localOrigin ? browserOrigin !== localOrigin : !isRemoteGuiBrowserOriginAllowed(browserOrigin, config)) return null;
348
363
  const source = recordSourceFailure(state, context, now);
349
364
  return attemptContext && !source.allowed ? source : null;
350
365
  }
351
366
  const [digest, record] = found;
367
+ // A live role/config change must not turn a hub grant into local operator consent, or vice versa.
368
+ if (record.localPairing ? !localOrigin || record.serverOrigin !== localOrigin
369
+ : config.runtimeRole !== "hub") return null;
352
370
  if (record.expiresAt <= now) {
353
371
  state.pairingGrants.delete(digest);
354
372
  return null;
@@ -369,7 +387,7 @@ export function consumeGuiPairingGrant(
369
387
  // the session is actually minted from.
370
388
  if (!isPairingTransportPermitted(record.serverOrigin)) return null;
371
389
  state.pairingGrants.delete(digest);
372
- return mintSession(record.serverOrigin, record.browserOrigin, "pairing", state, now);
390
+ return mintSession(record.serverOrigin, record.browserOrigin, "pairing", state, now, record.localPairing === true);
373
391
  }
374
392
 
375
393
  /**
@@ -420,6 +438,10 @@ export function authorizeGuiSessionRequest(
420
438
  state.sessions.delete(token);
421
439
  return { ok: false, reason: "expired" };
422
440
  }
441
+ if (session.localPairing && standaloneGuiPairingOrigin(config) !== session.serverOrigin) {
442
+ state.sessions.delete(token);
443
+ return { ok: false, reason: "server-origin" };
444
+ }
423
445
  if (managementRequestOrigin(req, config) !== session.serverOrigin) {
424
446
  return { ok: false, reason: "server-origin" };
425
447
  }
@@ -435,6 +457,6 @@ export function authorizeGuiSessionRequest(
435
457
  const csrf = req.headers.get("x-opencodex-csrf-token")?.trim();
436
458
  if (!csrf || !equalSecret(csrf, session.csrfToken)) return { ok: false, reason: "csrf" };
437
459
  }
438
- if (session.issuance !== "loopback") session.expiresAt = now + REMOTE_GUI_SESSION_TTL_MS;
460
+ if (session.issuance !== "loopback" && !session.localPairing) session.expiresAt = now + REMOTE_GUI_SESSION_TTL_MS;
439
461
  return { ok: true, principal: "gui-session", session };
440
462
  }
@@ -1,5 +1,7 @@
1
1
  import type { Server } from "bun";
2
2
  import type { OcxConfig } from "../../types";
3
+ import { getConfigDir } from "../../config/paths";
4
+ import { discardPickerCaKey } from "../../claude/intercept/picker-ca";
3
5
  import type { PickerRouteInput } from "../../claude/intercept/picker-models";
4
6
  import { observeClaudeDesktopMode, type ClaudeDesktopModeObservation } from "../../claude/desktop-first-party";
5
7
  import { firstPartyDesired, type ClaudeFirstPartyDesired } from "../../claude/first-party-settings";
@@ -79,6 +81,8 @@ export function createClaudeInterceptLifecycle<T>(): ClaudeInterceptLifecycle<T>
79
81
  if (!options) return Promise.resolve(outcome = { ok: false, reason: "failed" });
80
82
  const opts = options;
81
83
  const run = async (): Promise<ClaudeInterceptOutcome> => {
84
+ // Legacy key cleanup is independent of listener eligibility, including on-demand retries.
85
+ try { discardPickerCaKey(opts.configDir ?? getConfigDir()); } catch { /* best-effort; retried on the next attempt */ }
82
86
  if (opts.config.runtimeRole === "client") return { ok: false, reason: "client_role" };
83
87
  if (!claudeInterceptEnabled(opts.config)) return { ok: false, reason: "disabled" };
84
88
  if (opts.requestedPort === 0 && typeof opts.config.claudeCode?.intercept?.port !== "number")
@@ -202,6 +202,7 @@ import type { WorkflowRefusalLog } from "../workflow-refusal";
202
202
  import { readyProtocolMetadata } from "../../remote/protocol";
203
203
  import { modelCapabilityFields } from "../models-capabilities";
204
204
  import { createWebsocketHandler } from "./websocket-handler";
205
+ import { withGrokSessionIdentity } from "../../grok/session-identity";
205
206
 
206
207
  export type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management" | "claude-intercept" | "hub-link";
207
208
 
@@ -1476,7 +1477,7 @@ export function createServeOptions(ctx: ServeOptionsContext) {
1476
1477
  return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
1477
1478
  let response: Response;
1478
1479
  try {
1479
- response = await handleResponses(req, config, logCtx, {
1480
+ response = await handleResponses(withGrokSessionIdentity(req), config, logCtx, {
1480
1481
  turnAdmissionLease,
1481
1482
  admission,
1482
1483
  onRequestBodyRead: () => disableResponsesRequestTimeout(req, requestServer),
@@ -761,135 +761,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
761
761
  });
762
762
  }
763
763
 
764
- // Featured roster and saved picker order are separate settings. Native Codex advertises
765
- // the first five eligible visible rows by display priority; OCX guidance uses natural ranks.
766
- if (url.pathname === "/api/subagent-models" && req.method === "GET") {
767
- const models = await (deps.fetchAllModels ?? fetchAllModels)(config);
768
- // Native gpt (passthrough) are also valid subagent picks — they're picker-visible models in the
769
- // catalog, just buried by priority. List them first so the user can feature them over routed.
770
- const [{ listCatalogNativeSlugs }, { subagentSelectableModels }] = await Promise.all([
771
- import("../../codex/catalog"),
772
- import("../../codex/subagent-selectable-models"),
773
- ]);
774
- const chosen = config.subagentModels ?? [];
775
- // A saved roster slot must stay representable even after its model is disabled
776
- // elsewhere (Models page, provider allowlist, a provider row going away). The
777
- // dashboard treats `available` as the set of rows it can render, so a chosen id
778
- // missing from it disappears from the roster UI and the next Save — which PUTs
779
- // exactly what the UI holds — silently truncates the persisted list. Losing a
780
- // deliberate 5-model roster to an unrelated visibility toggle is data loss, not a
781
- // filter. Same reasoning as `fetchGrokCandidateModels`, which deliberately lists a
782
- // model the user already excluded so its switch remains reachable.
783
- const available = subagentSelectableModels(config, models, listCatalogNativeSlugs());
784
- // #857: let CLI/GUI show when a running Codex app-server keeps an older
785
- // in-memory catalog than the one on disk. Bounded request-path read: the synchronous
786
- // collector blocked the event loop for the whole Windows CIM walk (4-7s measured).
787
- const {
788
- collectCodexAppServerCatalogStateWithin,
789
- DASHBOARD_CATALOG_STATE_DEADLINE_MS,
790
- } = await import("../../codex/app-server-processes");
791
- const catalogState = await collectCodexAppServerCatalogStateWithin(DASHBOARD_CATALOG_STATE_DEADLINE_MS);
792
- return jsonResponse({
793
- chosen, available, catalogState,
794
- pickerAvailable: [...new Set(filterCatalogVisibleModels(models, config).map(catalogModelSlug).filter(slug => slug.includes("/")))],
795
- pickerOrder: config.modelPickerOrder ?? [],
796
- pickerOrderMode: config.modelPickerOrderMode ?? null,
797
- });
798
- }
799
- if (url.pathname === "/api/subagent-models" && req.method === "PUT") {
800
- let rawBody: unknown;
801
- try { rawBody = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
802
- if (!isPlainRecord(rawBody)) return jsonResponse({ error: "JSON body must be an object" }, 400);
803
- const body = rawBody as { models?: unknown; pickerOrder?: unknown; pickerOrderMode?: unknown };
804
- const updatesRoster = body.models !== undefined;
805
- const updatesPicker = body.pickerOrder !== undefined;
806
- if (!updatesRoster && !updatesPicker) return jsonResponse({ error: "models or pickerOrder is required" }, 400);
807
- let chosen: string[] | undefined;
808
- if (updatesRoster) {
809
- if (!Array.isArray(body.models) || body.models.some(model => typeof model !== "string")) {
810
- return jsonResponse({ error: "models must be an array of strings" }, 400);
811
- }
812
- // Keep the original valid roster contract: no discovery validation, trimming or deduping.
813
- chosen = body.models.slice(0, 5);
814
- }
815
- const mode = body.pickerOrderMode;
816
- if (mode !== undefined && (!updatesPicker || (mode !== null
817
- && mode !== "alphabetical" && mode !== "provider" && mode !== "most-used"))) {
818
- return jsonResponse({ error: "pickerOrderMode requires pickerOrder and must be alphabetical, provider, most-used, or null" }, 400);
819
- }
820
- let pickerOrder: string[] | undefined;
821
- if (updatesPicker) {
822
- if (body.pickerOrder !== null && (!Array.isArray(body.pickerOrder)
823
- || body.pickerOrder.some(model => typeof model !== "string" || model.trim() === ""))) {
824
- return jsonResponse({ error: "pickerOrder must be an array of non-empty routed model ids, or null" }, 400);
825
- }
826
- pickerOrder = body.pickerOrder === null ? [] : (body.pickerOrder as string[]).map(model => model.trim());
827
- if (new Set(pickerOrder).size !== pickerOrder.length) {
828
- return jsonResponse({ error: "pickerOrder must not contain duplicate ids" }, 400);
829
- }
830
- if (pickerOrder.length > 0) {
831
- const models = await (deps.fetchAllModels ?? fetchAllModels)(config);
832
- // Evaluate visibility AFTER discovery: a concurrent visibility write may have completed.
833
- const visible = new Set(filterCatalogVisibleModels(models, config).map(catalogModelSlug).filter(slug => slug.includes("/")));
834
- // A bare native id orders the complete picker (docs: guides/model-ordering.md).
835
- for (const row of nativeModelRows(config)) if (!row.disabled) visible.add(row.slug);
836
- if (pickerOrder.some(model => !visible.has(model))) {
837
- return jsonResponse({ error: "pickerOrder must contain visible routed or native model ids, each at most once" }, 400);
838
- }
839
- }
840
- }
841
-
842
- // Everything above can await. From this snapshot through persistence there is no yield.
843
- // Stage deletion intent before adopting the touched fields through the canonical
844
- // live deletion owner. A failed save restores both fields and pending intent.
845
- if (updatesPicker && config.configRebaseProvenance !== undefined
846
- && parsedConfigRebaseDeletionKeys(config) === null) {
847
- // A newer provenance format must not silently discard this clear's intent on rebase.
848
- return jsonResponse({ error: "unsupported config deletion provenance" }, 409);
849
- }
850
- const draft = { ...projectConfigRebaseProvenance(config) };
851
- if (chosen !== undefined) draft.subagentModels = chosen;
852
- if (pickerOrder !== undefined) {
853
- if (pickerOrder.length === 0) {
854
- deleteConfigTopLevelKey(draft, "modelPickerOrder");
855
- deleteConfigTopLevelKey(draft, "modelPickerOrderMode");
856
- } else {
857
- draft.modelPickerOrder = pickerOrder;
858
- if (mode === "alphabetical" || mode === "provider" || mode === "most-used") draft.modelPickerOrderMode = mode;
859
- else deleteConfigTopLevelKey(draft, "modelPickerOrderMode");
860
- }
861
- }
862
- const projected = projectConfigRebaseProvenance(draft);
863
- const touched = [
864
- ...(updatesRoster ? ["subagentModels" as const] : []),
865
- ...(updatesPicker ? ["modelPickerOrder" as const, "modelPickerOrderMode" as const] : []),
866
- "configRebaseProvenance" as const,
867
- ];
868
- const rollback = captureConfigTopLevelRollback(config, touched);
869
- try {
870
- for (const key of touched) {
871
- if (Object.hasOwn(projected, key)) Object.defineProperty(config, key, {
872
- value: projected[key], writable: true, enumerable: true, configurable: true,
873
- });
874
- else deleteConfigTopLevelKey(config, key);
875
- }
876
- (deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode)(config);
877
- } catch (error) {
878
- rollback();
879
- throw error;
880
- }
881
- // Capture the result before convergence yields to another settings mutation.
882
- const saved = {
883
- applied: [...(config.subagentModels ?? [])],
884
- pickerOrder: [...(config.modelPickerOrder ?? [])],
885
- pickerOrderMode: config.modelPickerOrderMode ?? null,
886
- };
887
- const catalogRefresh = await convergeCodexCatalog();
888
- if (updatesRoster) {
889
- await syncClaudeAgentDefsBestEffort();
890
- await autoApplyDesktopBestEffort();
891
- }
892
- return jsonResponse({ ok: true, ...saved, catalogRefresh });
764
+ if (url.pathname === "/api/subagent-models") {
765
+ const { handleSubagentModelRoutes } = await import("./subagent-model-routes");
766
+ return handleSubagentModelRoutes(ctx, autoApplyDesktopBestEffort);
893
767
  }
894
768
 
895
769
  // Priority-ordered subagent model fallback chain for quota-aware spawn routing.
@@ -1194,6 +1068,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1194
1068
  const picker = modeSaved.ok && pickerPreferenceOn(loadConfig())
1195
1069
  ? await ops.enableLocked({ persist: false, context: "server" })
1196
1070
  : await pickerStatusFor(loadConfig());
1071
+ if (modeSaved.ok) await syncClaudeAgentDefsBestEffort();
1197
1072
  return jsonResponse({
1198
1073
  ok: true,
1199
1074
  mode: "first-party",
@@ -1542,6 +1417,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1542
1417
  ? { visionSidecar: { backend: visionOverride.backend, model: visionOverride.model } }
1543
1418
  : {}),
1544
1419
  fastMode: config.fastMode,
1420
+ forceAvailable: [...visibleNativeSlugs(config), ...filterCatalogVisibleModels(models, config).map(catalogModelSlug)],
1545
1421
  contextWindows,
1546
1422
  effectiveModelEnv: effectiveModelEnv(config.claudeCode, contextWindows),
1547
1423
  available,
@@ -1678,6 +1554,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1678
1554
  const finalDesired = firstPartyDesired(config, observeClaudeDesktopMode(config));
1679
1555
  const residual = !finalDesired.desktop && !finalDesired.cli
1680
1556
  && readFirstPartyProxyStatus(config, bound?.proxyPort ?? null) !== "none";
1557
+ await syncClaudeAgentDefsBestEffort();
1681
1558
  return jsonResponse({ ok: true, enabled: config.claudeCode?.enabled !== false,
1682
1559
  cliFirstParty: body.cliFirstParty,
1683
1560
  warnings: [
@@ -276,7 +276,7 @@ export async function syncEnabledClientIntegrations(
276
276
  },
277
277
  config,
278
278
  port,
279
- }, ["mcode", "pi", "aside", "raycast", "omo", "cline", "droid"]));
279
+ }, ["mcode", "pi", "aside", "raycast", "omo", "cline", "droid", "opencode", "kilo"]));
280
280
 
281
281
  return out;
282
282
  }