@bitkyc08/opencodex 2.26.0 → 2.27.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 (55) hide show
  1. package/gui/dist/assets/{index-RL6b1bTV.js → index-7jlKgmJd.js} +14 -14
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic.ts +1 -1
  5. package/src/adapters/base.ts +14 -2
  6. package/src/adapters/command-code.ts +4 -3
  7. package/src/adapters/cursor/cursor-errors.ts +15 -0
  8. package/src/adapters/cursor/live-transport.ts +14 -1
  9. package/src/adapters/google.ts +1 -1
  10. package/src/adapters/openai-chat.ts +38 -6
  11. package/src/adapters/tool-catalog-nudge.ts +1 -1
  12. package/src/bridge.ts +11 -5
  13. package/src/cli/doctor.ts +76 -0
  14. package/src/cli/help.ts +2 -0
  15. package/src/cli/models.ts +13 -6
  16. package/src/codex/app-server-processes.ts +269 -37
  17. package/src/codex/auth-context.ts +53 -1
  18. package/src/codex/catalog/aggregation.ts +3 -0
  19. package/src/codex/catalog/parsing.ts +20 -3
  20. package/src/codex/catalog/provider-fetch.ts +8 -0
  21. package/src/codex/catalog/sync.ts +6 -4
  22. package/src/codex/log-guard/path-safety.ts +52 -3
  23. package/src/codex/native-profile-startup.ts +100 -2
  24. package/src/codex/user-identity.ts +21 -1
  25. package/src/config/provider-name.ts +24 -0
  26. package/src/config.ts +11 -24
  27. package/src/generated/compatibility-version.json +73 -45
  28. package/src/images/loop.ts +11 -4
  29. package/src/lib/state-store-registrations.ts +8 -2
  30. package/src/providers/antigravity-models.ts +70 -5
  31. package/src/providers/derive.ts +12 -2
  32. package/src/providers/registry.ts +46 -1
  33. package/src/providers/service-tier.ts +34 -7
  34. package/src/responses/parser.ts +56 -2
  35. package/src/responses/state.ts +162 -5
  36. package/src/router.ts +10 -3
  37. package/src/routing/compatibility/behavior.ts +3 -3
  38. package/src/routing/profile.ts +1 -1
  39. package/src/server/index.ts +5 -0
  40. package/src/server/management/shared.ts +3 -1
  41. package/src/server/responses/collaboration.ts +34 -9
  42. package/src/server/responses/core.ts +13 -4
  43. package/src/server/responses/input-admission.ts +7 -2
  44. package/src/service-manager-probe.ts +99 -0
  45. package/src/service.ts +86 -6
  46. package/src/tray/windows.ts +25 -5
  47. package/src/types/accounts.ts +37 -0
  48. package/src/types/config.ts +818 -0
  49. package/src/types/provider.ts +521 -0
  50. package/src/types/request.ts +358 -0
  51. package/src/types/tools.ts +131 -0
  52. package/src/types/wire.ts +80 -0
  53. package/src/types.ts +103 -1883
  54. package/src/usage/cost.ts +37 -1
  55. package/src/web-search/loop.ts +11 -4
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-RL6b1bTV.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-7jlKgmJd.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-DQsMZzI5.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.26.0",
3
+ "version": "2.27.0",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -743,7 +743,7 @@ function toolsToAnthropicFormat(parsed: OcxParsedRequest, toolNames: { toWire: (
743
743
  ? new Set(parsed.options.toolChoice.allowedTools)
744
744
  : undefined;
745
745
  const tools = allowed
746
- ? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed))
746
+ ? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed, parsed.context.tools))
747
747
  : parsed.context.tools;
748
748
  if (tools.length === 0) return undefined;
749
749
  const converted = tools.map(t => ({
@@ -39,8 +39,20 @@ export interface ProviderAdapter {
39
39
 
40
40
  fetchResponse?(request: AdapterRequest, ctx?: AdapterFetchContext): Promise<Response>;
41
41
 
42
- parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent>;
43
- parseResponse?(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]>;
42
+ /**
43
+ * Parse one upstream response. `tierMetadata` is the same live observer returned on the
44
+ * corresponding AdapterRequest; adapters that receive a documented tier echo may update it.
45
+ */
46
+ parseStream(
47
+ response: Response,
48
+ budget: TranslatorBudget,
49
+ tierMetadata?: AdapterTierMetadata,
50
+ ): AsyncGenerator<AdapterEvent>;
51
+ parseResponse?(
52
+ response: Response,
53
+ budget: TranslatorBudget,
54
+ tierMetadata?: AdapterTierMetadata,
55
+ ): Promise<AdapterEvent[]>;
44
56
  runTurn?(
45
57
  parsed: OcxParsedRequest,
46
58
  incoming: IncomingMeta,
@@ -3,7 +3,7 @@ import { execFile as execFileCallback } from "node:child_process";
3
3
  import { promisify } from "node:util";
4
4
  import { opendir } from "node:fs/promises";
5
5
  import type { AdapterEvent, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTool, OcxUsage } from "../types";
6
- import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice, toolChoiceAliases } from "../types";
6
+ import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types";
7
7
  import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base";
8
8
  import type { TranslatorBudget } from "../lib/translator-budget";
9
9
  import { readBoundedResponseBody } from "../lib/bounded-body";
@@ -157,10 +157,11 @@ function visibleTools(parsed: OcxParsedRequest): OcxTool[] {
157
157
  const tools = parsed.context.tools ?? [];
158
158
  if (isAllowedToolChoice(choice)) {
159
159
  const allowed = new Set(choice.allowedTools);
160
- return tools.filter(tool => toolAllowedByChoice(tool, allowed));
160
+ return tools.filter(tool => toolAllowedByChoice(tool, allowed, tools));
161
161
  }
162
162
  if (choice && typeof choice !== "string") {
163
- return tools.filter(tool => toolChoiceAliases(tool).includes(choice.name));
163
+ const selected = resolveToolChoiceWireName(tools, choice.name);
164
+ return tools.filter(tool => namespacedToolName(tool.namespace, tool.name) === selected);
164
165
  }
165
166
  return tools;
166
167
  }
@@ -85,6 +85,21 @@ export function isCursorBenignCancelError(value: unknown): boolean {
85
85
  return false;
86
86
  }
87
87
 
88
+ /**
89
+ * True when the turn was torn down by an `AbortSignal` rather than by a transport fault.
90
+ *
91
+ * This is deliberately NOT part of `isCursorBenignCancelError`: an abort mid-turn is a real
92
+ * failure and must still surface. It is only meaningful in combination with a terminal frame
93
+ * having already been emitted, where it means "the answer landed and then the connection went
94
+ * away" (#1527).
95
+ */
96
+ export function isCursorAbortError(value: unknown): boolean {
97
+ const message = errorMessage(value).toLowerCase();
98
+ if (message.includes("cursor request was aborted")) return true;
99
+ const name = (value as { name?: unknown })?.name;
100
+ return typeof name === "string" && name === "AbortError";
101
+ }
102
+
88
103
  /**
89
104
  * True when Cursor Connect rejected the turn with invalid_argument.
90
105
  * Seen after stepCompleted on brittle external-model continuations.
@@ -48,7 +48,7 @@ import {
48
48
  type InteractionResponse,
49
49
  } from "./gen/agent_pb";
50
50
  import { debugProviderDiagnostic } from "../../lib/debug";
51
- import { classifyCursorError, CursorUnexpectedCancelError, isCursorBenignCancelError, safeCursorErrorMessage } from "./cursor-errors";
51
+ import { classifyCursorError, CursorUnexpectedCancelError, isCursorAbortError, isCursorBenignCancelError, safeCursorErrorMessage } from "./cursor-errors";
52
52
  import { mcpArgsFromToolCall } from "./protobuf-events";
53
53
  import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions";
54
54
  import {
@@ -650,6 +650,18 @@ class LiveCursorTransport implements CursorTransport {
650
650
  // A CANCEL is benign only on the client-tool suspend path (expectedClose); an
651
651
  // unexpected server-side NGHTTP2_CANCEL must surface as a real transport error.
652
652
  if (this.expectedClose && isCursorBenignCancelError(failure)) return;
653
+ // A teardown error arriving AFTER the turn's terminal frame describes the connection,
654
+ // not the turn: the answer is committed and every queued message has been yielded.
655
+ //
656
+ // Narrow on purpose. A benign cancel after a terminal is already swallowed one layer
657
+ // up (`cursor.ts:183`), so widening this to every post-terminal error would change
658
+ // what the adapter sees for genuine faults. What it does cover is the abort case
659
+ // from #1527: `signal.abort` fires `failAndClear(new Error("Cursor request was
660
+ // aborted"))`, which is NOT benign (`cursor-errors.ts:74`), so an ordinary completed
661
+ // turn that is then torn down still surfaced as `turn-failed` with
662
+ // `expectedClose:false`. Only `cancelCursorRun()` sets `expectedClose`, so a normal
663
+ // completion never qualified for the branch above.
664
+ if (this.emittedTerminal && isCursorAbortError(failure)) return;
653
665
  throw attachPartialUsage(classifyTurnFailure(failure), state);
654
666
  }
655
667
  if (done) break;
@@ -659,6 +671,7 @@ class LiveCursorTransport implements CursorTransport {
659
671
  }
660
672
  if (failure) {
661
673
  if (this.expectedClose && isCursorBenignCancelError(failure)) return;
674
+ if (this.emittedTerminal && isCursorAbortError(failure)) return;
662
675
  throw attachPartialUsage(classifyTurnFailure(failure), state);
663
676
  }
664
677
  }
@@ -262,7 +262,7 @@ function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined {
262
262
  ? new Set(parsed.options.toolChoice.allowedTools)
263
263
  : undefined;
264
264
  const tools = allowed
265
- ? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed))
265
+ ? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed, parsed.context.tools))
266
266
  : parsed.context.tools;
267
267
  if (tools.length === 0) return undefined;
268
268
  return [{
@@ -19,6 +19,7 @@ import {
19
19
  import {
20
20
  canonicalFastTierMarker,
21
21
  createAdapterTierMetadata,
22
+ type AdapterTierMetadata,
22
23
  } from "../providers/fastwire";
23
24
  import { openaiChatCompletionsUrl } from "./openai-chat-url";
24
25
  import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema";
@@ -1156,7 +1157,11 @@ function normalizeXaiToolParameters(parameters: unknown): Record<string, unknown
1156
1157
  if (!isXaiObjectSchema(parameters)) return undefined;
1157
1158
  const resolved = resolveXaiSchemaRefs(parameters, parameters);
1158
1159
  if (!isXaiObjectSchema(resolved)) return undefined;
1159
- const variants = expandXaiRootObjectSchemas(resolved);
1160
+
1161
+ const normalizedRoot = { ...resolved };
1162
+ delete normalizedRoot.$schema;
1163
+
1164
+ const variants = expandXaiRootObjectSchemas(normalizedRoot);
1160
1165
  if (!variants) return undefined;
1161
1166
  if (variants.length === 1) {
1162
1167
  return xaiVariantIsConcreteObject(variants[0]) ? variants[0] : undefined;
@@ -1166,7 +1171,7 @@ function normalizeXaiToolParameters(parameters: unknown): Record<string, unknown
1166
1171
  if (!additionalProperties.ok) return undefined;
1167
1172
  if (!xaiPropertyMergeIsLossless(variants)) return undefined;
1168
1173
 
1169
- const metadata = Object.fromEntries(Object.entries(resolved).filter(([key]) => key !== "oneOf" && key !== "anyOf" && key !== "type"));
1174
+ const metadata = Object.fromEntries(Object.entries(normalizedRoot).filter(([key]) => key !== "oneOf" && key !== "anyOf" && key !== "type"));
1170
1175
  delete metadata.properties;
1171
1176
  delete metadata.required;
1172
1177
  delete metadata.additionalProperties;
@@ -1180,6 +1185,7 @@ function normalizeXaiToolParameters(parameters: unknown): Record<string, unknown
1180
1185
  propertyValues.set(name, values);
1181
1186
  }
1182
1187
  }
1188
+
1183
1189
  const properties = Object.fromEntries(
1184
1190
  [...propertyValues].map(([name, values]) => [name, mergeXaiPropertySchemas(values)]),
1185
1191
  );
@@ -1196,7 +1202,7 @@ function normalizeXaiToolParameters(parameters: unknown): Record<string, unknown
1196
1202
 
1197
1203
  function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined {
1198
1204
  if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined;
1199
- const tools = parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice));
1205
+ const tools = parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice, parsed.context.tools));
1200
1206
  if (tools.length === 0) return undefined;
1201
1207
  const xaiTarget = isXaiSchemaTarget(provider);
1202
1208
  const formatted = tools.flatMap(t => {
@@ -1478,7 +1484,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1478
1484
  };
1479
1485
  },
1480
1486
 
1481
- async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> {
1487
+ async *parseStream(
1488
+ response: Response,
1489
+ budget: TranslatorBudget,
1490
+ tierMetadata?: AdapterTierMetadata,
1491
+ ): AsyncGenerator<AdapterEvent> {
1482
1492
  if (!response.body) {
1483
1493
  yield { type: "error", message: "No response body" };
1484
1494
  return;
@@ -1547,11 +1557,15 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1547
1557
  try {
1548
1558
  parsed = JSON.parse(payload);
1549
1559
  } catch {
1560
+ tierMetadata?.markResponseUnparseable();
1550
1561
  yield { type: "error", message: "malformed upstream SSE data frame" };
1551
1562
  return "terminate";
1552
1563
  }
1553
1564
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return "continue";
1554
1565
  const chunk = parsed as Record<string, unknown>;
1566
+ if (Object.hasOwn(chunk, "service_tier")) {
1567
+ tierMetadata?.observeResponseServiceTier(chunk.service_tier);
1568
+ }
1555
1569
 
1556
1570
  if (chunk.error !== undefined && chunk.error !== null) {
1557
1571
  const event = upstreamErrorEvent(chunk.error, pendingUsage);
@@ -1752,8 +1766,26 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1752
1766
  }
1753
1767
  },
1754
1768
 
1755
- async parseResponse(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]> {
1756
- const json = await response.json() as Record<string, unknown>;
1769
+ async parseResponse(
1770
+ response: Response,
1771
+ budget: TranslatorBudget,
1772
+ tierMetadata?: AdapterTierMetadata,
1773
+ ): Promise<AdapterEvent[]> {
1774
+ let parsed: unknown;
1775
+ try {
1776
+ parsed = await response.json();
1777
+ } catch (error) {
1778
+ tierMetadata?.markResponseUnparseable();
1779
+ throw error;
1780
+ }
1781
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1782
+ tierMetadata?.markResponseUnparseable();
1783
+ throw new Error("upstream response was not a JSON object");
1784
+ }
1785
+ const json = parsed as Record<string, unknown>;
1786
+ if (Object.hasOwn(json, "service_tier")) {
1787
+ tierMetadata?.observeResponseServiceTier(json.service_tier);
1788
+ }
1757
1789
  const responseBytes = new TextEncoder().encode(JSON.stringify(json)).byteLength;
1758
1790
  budget.chargeRetained(responseBytes, { kind: "retained_collectors" });
1759
1791
  try {
@@ -135,7 +135,7 @@ export function buildNonOpenAIToolCatalogNudgeForTools(
135
135
  toolChoice?: OcxRequestOptions["toolChoice"],
136
136
  toWireName: (tool: Pick<OcxTool, "namespace" | "name">) => string = tool => namespacedToolName(tool.namespace, tool.name),
137
137
  ): string | undefined {
138
- const visible = tools?.filter(toolChoiceToolPredicate(toolChoice));
138
+ const visible = tools?.filter(toolChoiceToolPredicate(toolChoice, tools));
139
139
  const visibleNames = visible?.map(toWireName);
140
140
  // Decide code mode from the tool OBJECTS, while the `freeform` flag still exists — reducing
141
141
  // to wire names first throws away the only thing that distinguishes Codex's JavaScript
package/src/bridge.ts CHANGED
@@ -167,7 +167,7 @@ export type ResponsesTerminalStatus = "completed" | "failed" | "incomplete";
167
167
  export function bridgeToResponsesSSE(
168
168
  events: AsyncIterable<AdapterEvent>,
169
169
  modelId: string,
170
- toolNsMap?: Map<string, { namespace: string; name: string }>,
170
+ toolNsMap?: Map<string, { namespace: string; name: string; freeform?: true }>,
171
171
  freeformToolNames?: Set<string>,
172
172
  toolSearchToolNames?: Set<string>,
173
173
  onCancel?: () => void,
@@ -1050,7 +1050,9 @@ export function bridgeToResponsesSSE(
1050
1050
  }
1051
1051
  const ns = mapped?.namespace;
1052
1052
  const toolSearch = toolSearchToolNames?.has(realName) ?? false;
1053
- const freeform = !toolSearch && (freeformToolNames?.has(realName) ?? false);
1053
+ const freeform = !toolSearch && (mapped
1054
+ ? mapped.freeform === true
1055
+ : (freeformToolNames?.has(realName) ?? false));
1054
1056
  const itemId = `${toolSearch ? "tsc" : freeform ? "ctc" : "fc"}_${uuid()}`;
1055
1057
  const item = toolSearch
1056
1058
  ? { type: "tool_search_call", id: itemId, call_id: event.id, execution: "client", arguments: {}, status: "in_progress" }
@@ -1451,7 +1453,7 @@ function buildResponseJSONWithBudget(
1451
1453
  modelId: string,
1452
1454
  options?: {
1453
1455
  hideThinkingSummary?: boolean;
1454
- toolNsMap?: Map<string, { namespace: string; name: string }>;
1456
+ toolNsMap?: Map<string, { namespace: string; name: string; freeform?: true }>;
1455
1457
  /** Request-visible tool names. When present, an upstream call outside this set fails closed. */
1456
1458
  declaredToolNames?: ReadonlySet<string>;
1457
1459
  /** Declared parameter schema per tool name; repairs integral-float integer args (#1611). */
@@ -1632,7 +1634,9 @@ function buildResponseJSONWithBudget(
1632
1634
  const realName = mapped?.name ?? currentToolCallName;
1633
1635
  const ns = mapped?.namespace;
1634
1636
  const toolSearch = options?.toolSearchToolNames?.has(realName) ?? false;
1635
- const freeform = !toolSearch && (options?.freeformToolNames?.has(realName) ?? false);
1637
+ const freeform = !toolSearch && (mapped
1638
+ ? mapped.freeform === true
1639
+ : (options?.freeformToolNames?.has(realName) ?? false));
1636
1640
  // #1611: same integral-float repair as the streaming path. Keyed by the wire name
1637
1641
  // the request declared, which is the pre-namespace-mapping `currentToolCallName`.
1638
1642
  const coercedArgs = coerceIntegerToolArguments(
@@ -1796,7 +1800,9 @@ function buildResponseJSONWithBudget(
1796
1800
  const mapped = options?.toolNsMap?.get(currentToolCallName);
1797
1801
  const realName = mapped?.name ?? currentToolCallName;
1798
1802
  const toolSearch = options?.toolSearchToolNames?.has(realName) ?? false;
1799
- const freeform = !toolSearch && (options?.freeformToolNames?.has(realName) ?? false);
1803
+ const freeform = !toolSearch && (mapped
1804
+ ? mapped.freeform === true
1805
+ : (options?.freeformToolNames?.has(realName) ?? false));
1800
1806
  if (!freeform && !toolSearch) {
1801
1807
  flushToolCall("incomplete");
1802
1808
  errorEvent = {
package/src/cli/doctor.ts CHANGED
@@ -25,6 +25,11 @@ import { collectOrcaCodexHomeDiagnostic, resolveCodexHomeDir as resolveCodexHome
25
25
  import { scanCodexAgentRolesWithTomlModelFallback } from "../codex/subagent-model-fallback";
26
26
  import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim";
27
27
  import { countPendingOpencodexHistory } from "../codex/history-provider";
28
+ import {
29
+ inspectAbandonedResponseStateTemps,
30
+ reclaimAbandonedResponseStateTemps,
31
+ type ResponseStateTempRecoveryResult,
32
+ } from "../responses/state";
28
33
  import {
29
34
  CodexUserIdentityRefusal,
30
35
  probeCodexCoordinatorNamespace,
@@ -678,6 +683,57 @@ export async function fetchServiceMemory(
678
683
 
679
684
  const mb = (bytes: number): string => `${Math.round(bytes / (1024 * 1024))}MB`;
680
685
 
686
+ export const RECLAIM_RESPONSE_TEMPS_FLAG = "--reclaim-response-temps";
687
+ /** Matches the dry run's entry bound so report and reclaim agree on a large backlog. */
688
+ const RESPONSE_TEMP_RECLAIM_MAX_CLEANUPS = 4_096;
689
+ /** Names the subsystem: other components mint temps with the same shape and are not covered. */
690
+ const CLEAN_RESPONSE_TEMP_LINE = " ok No abandoned response-state temp files.";
691
+
692
+ /**
693
+ * Render the abandoned-temp section (testable without console capture).
694
+ *
695
+ * Report is the DEFAULT and reclaim is opt-in: `doctor` is a diagnostic an operator runs
696
+ * to understand a machine, so deleting files as a side effect of asking a question is the
697
+ * wrong default even for cache files.
698
+ *
699
+ * Counts come from `eligible`/`eligibleBytes`, never `matched`: `matched` is incremented
700
+ * before the file-type, age, boot-floor, and liveness gates, so reporting it would tell an
701
+ * operator that live-pid temps and young temps are "abandoned".
702
+ */
703
+ export function formatResponseTempLines(
704
+ result: ResponseStateTempRecoveryResult,
705
+ reclaimed: boolean,
706
+ ): string[] {
707
+ if (reclaimed) {
708
+ if (result.removed === 0 && result.failed === 0) return [CLEAN_RESPONSE_TEMP_LINE];
709
+ const lines = [` ok Reclaimed ${result.removed} abandoned response-state temp file(s), ${mb(result.bytesRemoved)} freed.`];
710
+ if (result.failed > 0) {
711
+ // Never "retried automatically": this command exists for the operator whose proxy will
712
+ // NOT start, and in that state nothing retries anything.
713
+ lines.push(` !! ${result.failed} file(s) could not be removed (in use or locked). Retried on the next reclaim — automatically while the proxy runs, otherwise re-run this command.`);
714
+ }
715
+ // `truncated`, not `eligible > removed + failed`: outside a dry run every eligible entry
716
+ // is unlinked or failed on the same iteration it is counted, so those two are always
717
+ // equal and the comparison never fired. An operator with a backlog past the budget was
718
+ // told the reclaim had finished.
719
+ if (result.truncated) {
720
+ lines.push(" !! Cleanup budget reached; files remain. Run the command again to continue.");
721
+ }
722
+ return lines;
723
+ }
724
+ if (result.eligible === 0) return [CLEAN_RESPONSE_TEMP_LINE];
725
+ const lines = [
726
+ ` !! ${result.eligible} abandoned response-state temp file(s), ${mb(result.eligibleBytes)} reclaimable.`,
727
+ " These are interrupted snapshot writes (continuation cache only) and are safe to remove.",
728
+ " Reclaim them with: ocx doctor --reclaim-response-temps",
729
+ ];
730
+ // The dry run skips the cleanup budget but is still bounded by the entry cap, so a large
731
+ // enough backlog makes this a floor rather than a total. Say so instead of letting an
732
+ // operator size the problem from a truncated count.
733
+ if (result.truncated) lines.push(" Scan stopped at its entry budget; the real total is higher.");
734
+ return lines;
735
+ }
736
+
681
737
  /** Render the doctor "Memory / runtime" section lines (testable without console capture). */
682
738
  export function formatServiceMemoryLines(report: ServiceMemoryReport): string[] {
683
739
  const lines: string[] = [];
@@ -805,6 +861,26 @@ export async function runDoctor(args: string[] = []): Promise<void> {
805
861
  console.log(` ${row.exists ? "ok " : "-- "} ${row.label}: ${row.path}${flags ? ` (${flags})` : ""}`);
806
862
  }
807
863
 
864
+ // Runs without the proxy on purpose: the worst accumulation happens when the proxy will
865
+ // not start, which is exactly when the in-process periodic reclaim never ticks.
866
+ const reclaimTemps = args.includes(RECLAIM_RESPONSE_TEMPS_FLAG);
867
+ console.log("\nResponse-state temp files");
868
+ // A typo must not silently degrade into "nothing to reclaim" — the operator would read the
869
+ // report as an answer to a question they never actually asked.
870
+ for (const arg of args) {
871
+ if (arg !== RECLAIM_RESPONSE_TEMPS_FLAG && /^--reclaim/.test(arg)) {
872
+ console.log(` !! Unrecognized flag ${arg}; did you mean ${RECLAIM_RESPONSE_TEMPS_FLAG}? Reporting only.`);
873
+ }
874
+ }
875
+ for (const line of formatResponseTempLines(
876
+ // The reclaim budget matches the report budget: a report bounded by entries and a removal
877
+ // bounded by a smaller cleanup cap would tell an operator 816 and then silently free 512.
878
+ reclaimTemps
879
+ ? reclaimAbandonedResponseStateTemps({ maxCleanups: RESPONSE_TEMP_RECLAIM_MAX_CLEANUPS })
880
+ : inspectAbandonedResponseStateTemps(),
881
+ reclaimTemps,
882
+ )) console.log(line);
883
+
808
884
  const orcaHome = collectOrcaCodexHomeDiagnostic();
809
885
  console.log("\nCodex app home targeting");
810
886
  console.log(` ${orcaHome.mismatch ? "!! " : "ok "} Effective Codex home: ${orcaHome.effectiveCodexHome}`);
package/src/cli/help.ts CHANGED
@@ -36,6 +36,8 @@ Usage:
36
36
  Refresh Codex's model cache from the active catalog
37
37
  ocx status Check proxy server status
38
38
  ocx doctor Diagnose environment/network issues (WSL, proxy, ChatGPT reachability)
39
+ ocx doctor --reclaim-response-temps
40
+ Reclaim abandoned response-state temp files (works without a running proxy)
39
41
  ocx debug <scope> provider/usage/injection/claude on|off|status|reset
40
42
  ocx login <provider> OAuth or API-key provider login
41
43
  ocx logout <provider> Remove a stored OAuth login
package/src/cli/models.ts CHANGED
@@ -5,11 +5,11 @@ import { randomUUID } from "node:crypto";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { syncModelsToCodex } from "../codex/sync";
7
7
  import { hasOwnProvider, isValidProviderName, loadConfig, saveConfig } from "../config";
8
- import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort } from "../reasoning-effort";
8
+ import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort, modelRecordValue } from "../reasoning-effort";
9
9
  import { encodedModelIdCollides, routedSlug, slugEquals } from "../providers/slug-codec";
10
10
  import { knownModelIdsForProvider } from "../router";
11
11
  import { findLiveProxy } from "../server/proxy-liveness";
12
- import type { OcxConfig, OcxCustomModel } from "../types";
12
+ import { modelInList, type OcxConfig, type OcxCustomModel } from "../types";
13
13
 
14
14
  const ADD_USAGE = "Usage: ocx models add <provider> <modelId> [--display-name <name>] [--context-window <tokens>] [--modalities text,image,audio] [--reasoning-efforts <none,minimal,low,medium,high,xhigh,max,ultra>] [--default-reasoning-effort <level>]";
15
15
  const REMOVE_USAGE = "Usage: ocx models remove <customId|provider/modelId> [--yes]";
@@ -98,15 +98,22 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[]
98
98
  if (seen.has(model)) return;
99
99
  seen.add(model);
100
100
 
101
- const noVision = prov.noVisionModels?.includes(model);
102
- const modalities = inputModalities[model] ?? (noVision ? ["text"] : null);
103
- const efforts = reasoningEfforts[model] ?? prov.reasoningEfforts ?? null;
101
+ // Resolve exactly as the runtime does, or this command reports capabilities the
102
+ // proxy will not honour: `isModelTextOnly` matches noVisionModels with modelInList
103
+ // and reads modelInputModalities with modelRecordValue, so a `gpt-oss` entry covers
104
+ // `gpt-oss:120b`. A bare lookup reported that model as unclassified on every field.
105
+ // noVisionModels is checked first because `isModelTextOnly` returns true on that
106
+ // match before it ever reads modelInputModalities: a `gpt-oss` noVision entry beats
107
+ // an exact `gpt-oss:120b` entry that lists "image", and the proxy rejects the image.
108
+ const noVision = modelInList(prov.noVisionModels, model);
109
+ const modalities = noVision ? ["text"] : (modelRecordValue(inputModalities, model) ?? null);
110
+ const efforts = modelRecordValue(reasoningEfforts, model) ?? prov.reasoningEfforts ?? null;
104
111
 
105
112
  entries.push({
106
113
  provider: provName,
107
114
  model,
108
115
  isDefault,
109
- contextWindow: contextWindows[model] ?? globalContext,
116
+ contextWindow: modelRecordValue(contextWindows, model) ?? globalContext,
110
117
  inputModalities: modalities,
111
118
  reasoningEfforts: efforts,
112
119
  });