@bitkyc08/opencodex 2.40.0 → 2.42.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 (97) hide show
  1. package/README.md +4 -0
  2. package/gui/dist/assets/index-BU1tE0sr.js +112 -0
  3. package/gui/dist/assets/index-DL9-iS6J.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/meta.svg +1 -0
  6. package/package.json +4 -3
  7. package/src/adapters/cursor/catalog.ts +71 -29
  8. package/src/adapters/cursor/claude-id.ts +76 -0
  9. package/src/adapters/cursor/discovery.ts +16 -3
  10. package/src/adapters/cursor/effort-map.ts +27 -12
  11. package/src/adapters/cursor/protobuf-request.ts +41 -21
  12. package/src/adapters/google.ts +39 -2
  13. package/src/adapters/identity.ts +8 -2
  14. package/src/adapters/openai-responses.ts +57 -4
  15. package/src/bridge.ts +25 -3
  16. package/src/cli/account-auth.ts +28 -3
  17. package/src/cli/account-extended.ts +7 -1
  18. package/src/cli/capabilities.ts +2 -2
  19. package/src/cli/claude.ts +11 -2
  20. package/src/cli/connect.ts +7 -1
  21. package/src/cli/observe.ts +3 -1
  22. package/src/cli/registry.ts +1 -1
  23. package/src/cli/status.ts +19 -4
  24. package/src/client/connect.ts +5 -1
  25. package/src/client/hub-client.ts +29 -5
  26. package/src/clients/config-export.ts +12 -2
  27. package/src/codex/auth-api.ts +102 -9
  28. package/src/codex/catalog/aggregation.ts +8 -0
  29. package/src/codex/catalog/effort.ts +15 -2
  30. package/src/codex/catalog/metadata.ts +119 -9
  31. package/src/codex/catalog/native-models.ts +71 -0
  32. package/src/codex/catalog/parsing.ts +5 -3
  33. package/src/codex/catalog/provider-fetch.ts +166 -28
  34. package/src/codex/catalog.ts +1 -1
  35. package/src/codex/convergence-types.ts +1 -0
  36. package/src/codex/data/upstream-models.json +169 -0
  37. package/src/codex/desired-state.ts +18 -11
  38. package/src/codex/inject.ts +96 -6
  39. package/src/codex/injected-marker.ts +30 -4
  40. package/src/codex/journal.ts +14 -0
  41. package/src/combos/failover.ts +185 -6
  42. package/src/combos/index.ts +6 -0
  43. package/src/combos/resolve.ts +43 -6
  44. package/src/config.ts +5 -1
  45. package/src/generated/compatibility-version.json +115 -83
  46. package/src/generated/model-metadata.ts +1 -1
  47. package/src/grok/sync.ts +10 -2
  48. package/src/integrations/cursor-effort-table.ts +143 -0
  49. package/src/integrations/state.ts +1 -1
  50. package/src/integrations/writer.ts +2 -2
  51. package/src/lib/app-owned-memory-stores.ts +27 -8
  52. package/src/lib/bounded-body.ts +16 -1
  53. package/src/oauth/account-quota-rank.ts +40 -1
  54. package/src/oauth/chatgpt-device.ts +187 -0
  55. package/src/oauth/chatgpt.ts +31 -4
  56. package/src/oauth/generic-account-failover.ts +2 -2
  57. package/src/oauth/index.ts +24 -3
  58. package/src/oauth/log.ts +3 -0
  59. package/src/oauth/meta-muse.ts +235 -0
  60. package/src/providers/antigravity-models.ts +71 -13
  61. package/src/providers/command-code-efforts.ts +15 -0
  62. package/src/providers/free-directory.ts +4 -1
  63. package/src/providers/muse-subscription-usage.ts +95 -0
  64. package/src/providers/quota.ts +96 -0
  65. package/src/providers/registry.ts +116 -8
  66. package/src/responses/code-mode-helper-compat.ts +4 -1
  67. package/src/responses/state.ts +5 -4
  68. package/src/server/auth-cors.ts +241 -56
  69. package/src/server/chat-completions.ts +11 -2
  70. package/src/server/chat-native.ts +30 -4
  71. package/src/server/claude-messages.ts +17 -3
  72. package/src/server/effort-row.ts +131 -0
  73. package/src/server/index.ts +82 -45
  74. package/src/server/live.ts +18 -4
  75. package/src/server/management/api-key-rotation.ts +2 -1
  76. package/src/server/management/api-key-usage.ts +97 -43
  77. package/src/server/management/context.ts +3 -0
  78. package/src/server/management/cursor-integration-routes.ts +36 -7
  79. package/src/server/management/logs-usage-routes.ts +64 -87
  80. package/src/server/management/oauth-account-routes.ts +10 -3
  81. package/src/server/management/provider-routes.ts +218 -1
  82. package/src/server/management/route-registry.ts +1 -0
  83. package/src/server/management/usage-aggregate-cache.ts +464 -0
  84. package/src/server/management/usage-summary-cache.ts +4 -0
  85. package/src/server/models-capabilities.ts +60 -5
  86. package/src/server/responses/core.ts +95 -7
  87. package/src/server/responses/empty-completion-guard.ts +4 -0
  88. package/src/types/config.ts +10 -1
  89. package/src/types/request.ts +8 -0
  90. package/src/types/tools.ts +12 -9
  91. package/src/usage/expected-prices.ts +43 -7
  92. package/src/usage/ledger-scanner.ts +448 -0
  93. package/src/usage/log.ts +1 -1
  94. package/src/usage/summary.ts +915 -655
  95. package/src/web-search/index.ts +1 -1
  96. package/gui/dist/assets/index-BHe2rl_C.js +0 -112
  97. package/gui/dist/assets/index-CJSb3HPe.css +0 -1
@@ -1965,6 +1965,18 @@ export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown {
1965
1965
  return changed ? next : body;
1966
1966
  }
1967
1967
 
1968
+ /**
1969
+ * Muse Spark ids whose Responses gateway refuses `search_content_types` on a plain
1970
+ * `web_search` tool. Membership, not equality: 1.3 shipped 2026-09-02 as the
1971
+ * same-shaped successor to 1.2 on the same Zen wire, and an equality check would
1972
+ * have let a Codex-emitted `web_search` + `search_content_types` body reach the
1973
+ * gateway and come back 400 for every request the moment 1.3 was selected.
1974
+ */
1975
+ const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([
1976
+ "muse-spark-1.3-contributor",
1977
+ "muse-spark-1.2-contributor",
1978
+ ]);
1979
+
1968
1980
  /**
1969
1981
  * OpenCode Zen / Go Muse Spark Responses gateway refuses `search_content_types`
1970
1982
  * on a plain `web_search` tool (400) but accepts it on `web_search_preview`; a
@@ -1976,7 +1988,8 @@ export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown {
1976
1988
  */
1977
1989
  function stripMuseSparkUnsupportedWebSearchFields(body: unknown, modelId: unknown): unknown {
1978
1990
  if (!isPlainObject(body)) return body;
1979
- if (typeof modelId !== "string" || modelId.trim().toLowerCase() !== "muse-spark-1.2-contributor") return body;
1991
+ if (typeof modelId !== "string") return body;
1992
+ if (!MUSE_SPARK_WEB_SEARCH_STRICT_MODELS.has(modelId.trim().toLowerCase())) return body;
1980
1993
 
1981
1994
  const rewriteTools = (tools: unknown[]): { tools: unknown[]; changed: boolean } => {
1982
1995
  let changed = false;
@@ -2062,11 +2075,26 @@ function usageFromResponsesPayload(payload: unknown): OcxUsage | undefined {
2062
2075
  const usage = payload.usage;
2063
2076
  const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
2064
2077
  const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
2065
- if (inputTokens === 0 && outputTokens === 0) return undefined;
2078
+ // openai/codex#41980: the raw usage object is wire data a rebuilt response.completed must keep —
2079
+ // unknown keys (subscription metadata, future counters) ride along even when the token counts
2080
+ // themselves are zero or absent (metadata-only usage).
2081
+ const knownKeys = new Set(["input_tokens", "output_tokens", "total_tokens", "input_tokens_details", "output_tokens_details"]);
2082
+ const hasExtras = Object.keys(usage).some(key => !knownKeys.has(key))
2083
+ || (isPlainObject(usage.input_tokens_details)
2084
+ && Object.keys(usage.input_tokens_details).some(key => key !== "cached_tokens" && key !== "cache_write_tokens"))
2085
+ || (isPlainObject(usage.output_tokens_details)
2086
+ && Object.keys(usage.output_tokens_details).some(key => key !== "reasoning_tokens"));
2087
+ if (inputTokens === 0 && outputTokens === 0 && !hasExtras) return undefined;
2088
+ const inputDetails = isPlainObject(usage.input_tokens_details) ? usage.input_tokens_details : undefined;
2089
+ const outputDetails = isPlainObject(usage.output_tokens_details) ? usage.output_tokens_details : undefined;
2066
2090
  return {
2067
2091
  inputTokens,
2068
2092
  outputTokens,
2069
2093
  ...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}),
2094
+ ...(typeof inputDetails?.cached_tokens === "number" ? { cachedInputTokens: inputDetails.cached_tokens } : {}),
2095
+ ...(typeof inputDetails?.cache_write_tokens === "number" ? { cacheCreationInputTokens: inputDetails.cache_write_tokens } : {}),
2096
+ ...(typeof outputDetails?.reasoning_tokens === "number" ? { reasoningOutputTokens: outputDetails.reasoning_tokens } : {}),
2097
+ ...(hasExtras ? { rawUsage: { ...usage } } : {}),
2070
2098
  };
2071
2099
  }
2072
2100
 
@@ -2378,7 +2406,26 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
2378
2406
  reservation.commitRetained();
2379
2407
  budget.releaseRetained(previousBytes, { kind: "retained_collectors" });
2380
2408
  }
2381
- usage = usageFromResponsesPayload(payload.response);
2409
+ {
2410
+ const nextUsage = usageFromResponsesPayload(payload.response);
2411
+ // The attached raw usage object can be event-sized (unknown keys carry arbitrary
2412
+ // values); it stays reachable until the terminal yields, so charge it like the
2413
+ // adjacent retained collectors or it would defeat the per-request memory cap.
2414
+ const previousRawBytes = usage?.rawUsage === undefined ? 0
2415
+ : budgetEncoder.encode(JSON.stringify(usage.rawUsage)).byteLength;
2416
+ const nextRawBytes = nextUsage?.rawUsage === undefined ? 0
2417
+ : budgetEncoder.encode(JSON.stringify(nextUsage.rawUsage)).byteLength;
2418
+ if (nextRawBytes > 0) {
2419
+ const reservation = budget.reserveTransient(nextRawBytes, { kind: "retained_collectors" });
2420
+ usage = nextUsage;
2421
+ reservation.commitRetained();
2422
+ } else {
2423
+ usage = nextUsage;
2424
+ }
2425
+ if (previousRawBytes > 0) {
2426
+ budget.releaseRetained(previousRawBytes, { kind: "retained_collectors" });
2427
+ }
2428
+ }
2382
2429
  break;
2383
2430
  }
2384
2431
  }
@@ -2386,7 +2433,13 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
2386
2433
  // completed snapshot so text is never double-counted.
2387
2434
  const text = snapshot || doneText || deltas;
2388
2435
  if (text) yield { type: "text_delta", text };
2389
- budget.releaseRetained(budgetEncoder.encode(deltas).byteLength + budgetEncoder.encode(doneText).byteLength + budgetEncoder.encode(snapshot).byteLength, { kind: "retained_collectors" });
2436
+ budget.releaseRetained(
2437
+ budgetEncoder.encode(deltas).byteLength
2438
+ + budgetEncoder.encode(doneText).byteLength
2439
+ + budgetEncoder.encode(snapshot).byteLength
2440
+ + (usage?.rawUsage === undefined ? 0 : budgetEncoder.encode(JSON.stringify(usage.rawUsage)).byteLength),
2441
+ { kind: "retained_collectors" },
2442
+ );
2390
2443
  yield {
2391
2444
  type: "done",
2392
2445
  ...(usage ? { usage } : {}),
package/src/bridge.ts CHANGED
@@ -59,6 +59,10 @@ function sseEvent(name: string, data: Record<string, unknown>): string {
59
59
  return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
60
60
  }
61
61
 
62
+ function isRecord(value: unknown): value is Record<string, unknown> {
63
+ return value !== null && typeof value === "object" && !Array.isArray(value);
64
+ }
65
+
62
66
  function responsesUsage(usage: OcxUsage | undefined): Record<string, unknown> {
63
67
  // input_tokens_details / output_tokens_details are ALWAYS emitted (zero defaults):
64
68
  // strict Responses clients deserialize them as required fields — grok-build's pinned
@@ -80,7 +84,24 @@ function responsesUsage(usage: OcxUsage | undefined): Record<string, unknown> {
80
84
  const inputTokens = usage.contextTotalTokens !== undefined
81
85
  ? Math.max(0, usage.contextTotalTokens - usage.outputTokens)
82
86
  : usage.inputTokens;
87
+ // openai/codex#41980 parity: unknown upstream usage fields (subscription metadata, future
88
+ // counters) pass through the rebuild. Normalized values stay authoritative for the known
89
+ // keys (they are derived from the same raw values, so this never disagrees with upstream).
90
+ const raw: Record<string, unknown> = usage.rawUsage ?? {};
91
+ // cache_write_tokens is a KNOWN key: it is emitted only from the validated normalized
92
+ // value below, never copied through raw (an unknown-shaped value must not leak into the
93
+ // normalized contract).
94
+ const rawInputDetails = isRecord(raw.input_tokens_details)
95
+ ? Object.fromEntries(Object.entries(raw.input_tokens_details as Record<string, unknown>)
96
+ .filter(([key]) => key !== "cache_write_tokens"))
97
+ : {} as Record<string, unknown>;
98
+ const rawOutputDetails = isRecord(raw.output_tokens_details)
99
+ ? raw.output_tokens_details as Record<string, unknown>
100
+ : {} as Record<string, unknown>;
83
101
  const out: Record<string, unknown> = {
102
+ ...Object.fromEntries(Object.entries(raw).filter(([key]) =>
103
+ key !== "input_tokens" && key !== "output_tokens" && key !== "total_tokens"
104
+ && key !== "input_tokens_details" && key !== "output_tokens_details")),
84
105
  input_tokens: inputTokens,
85
106
  output_tokens: usage.outputTokens,
86
107
  total_tokens: usage.contextTotalTokens !== undefined
@@ -90,18 +111,19 @@ function responsesUsage(usage: OcxUsage | undefined): Record<string, unknown> {
90
111
  // cached_tokens carries cache READS only, matching OpenAI semantics, and is always present
91
112
  // (zero default) for strict clients. Clamp to inputTokens so a provider's absolute
92
113
  // checkpoint can never report more cache reads than input.
93
- const inputDetails: Record<string, number> = {
114
+ const inputDetails: Record<string, unknown> = {
115
+ ...rawInputDetails,
94
116
  cached_tokens: Math.min(usage.cachedInputTokens ?? 0, inputTokens),
95
117
  };
96
118
  if (usage.cacheCreationInputTokens !== undefined) {
97
- const cacheRead = inputDetails.cached_tokens ?? 0;
119
+ const cacheRead = typeof inputDetails.cached_tokens === "number" ? inputDetails.cached_tokens : 0;
98
120
  inputDetails.cache_write_tokens = Math.min(
99
121
  usage.cacheCreationInputTokens,
100
122
  Math.max(0, inputTokens - cacheRead),
101
123
  );
102
124
  }
103
125
  out.input_tokens_details = inputDetails;
104
- out.output_tokens_details = { reasoning_tokens: usage.reasoningOutputTokens ?? 0 };
126
+ out.output_tokens_details = { ...rawOutputDetails, reasoning_tokens: usage.reasoningOutputTokens ?? 0 };
105
127
  return out;
106
128
  }
107
129
 
@@ -31,11 +31,16 @@ function writeStdoutFully(text: string): void {
31
31
  }
32
32
 
33
33
  const USAGE = `Usage:
34
- ocx account login <provider> [--id <account-id>] [--reauth] [--code -] [--no-wait] [--json]
34
+ ocx account login <provider> [--id <account-id>] [--reauth] [--device] [--code -] [--no-wait] [--json]
35
35
  ocx account code <provider> [--flow <flow-id>] [--json] (reads the code from stdin)
36
36
  ocx account cancel <provider> [--flow <flow-id>] [--json]
37
37
  ocx account reset-credits <account-id|main> [--consume --yes] [--json]
38
38
 
39
+ --device runs the OpenAI device-code login instead of the browser callback: use
40
+ it when the proxy has no browser or nothing can reach localhost:1455, such as a
41
+ headless or remote hub. Enter the printed code at the printed URL from any other
42
+ machine.
43
+
39
44
  The redirect URL or authorization code is a short-lived credential. Pipe it in
40
45
  rather than passing it as an argument, where it lands in shell history and is
41
46
  visible to anyone who can run ps:
@@ -54,6 +59,9 @@ interface LoginStart {
54
59
  /** `-` means "read it from stdin", the documented way to pass a code silently. */
55
60
  const STDIN_SENTINEL = "-";
56
61
 
62
+ /** Providers whose ONLY login is already a device flow; --device is redundant, not wrong. */
63
+ const DEVICE_NATIVE_PROVIDERS = new Set(["kimi", "nous", "github-copilot"]);
64
+
57
65
  const ARGV_WARNING =
58
66
  "warning: the authorization code was passed as a command-line argument, so it is now in your shell history and was visible in the process list while this ran. Pipe it on stdin instead, or pass `-` to read from stdin.";
59
67
 
@@ -86,10 +94,17 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
86
94
  const wantsJson = takeFlag(args, "--json");
87
95
  const noWait = takeFlag(args, "--no-wait");
88
96
  const reauth = takeFlag(args, "--reauth");
97
+ const device = takeFlag(args, "--device");
89
98
  const id = takeOption(args, "--id");
90
99
  const suppliedCode = takeOptionWithSyntax(args, "--code");
91
100
  if (!provider) throw new CliUsageError("provider is required", USAGE);
92
101
  rejectArgs(args, USAGE);
102
+ // kimi, nous, and github-copilot are already device flows, so --device is a
103
+ // true statement about them and is accepted as a no-op rather than an error.
104
+ // Anything else has no device grant at all and must fail loudly.
105
+ if (device && !CODEX_NAMES.has(provider) && !DEVICE_NATIVE_PROVIDERS.has(provider)) {
106
+ throw new CliUsageError(`--device is not supported for provider '${provider}'`, USAGE);
107
+ }
93
108
  // Only resolve when --code was actually given: a plain `ocx account login`
94
109
  // opens the browser flow and polls, and must not block on stdin.
95
110
  const code = await resolveCode(suppliedCode, deps, false);
@@ -97,13 +112,18 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
97
112
  if (CODEX_NAMES.has(provider)) {
98
113
  const start = await runtimeRequest<LoginStart>("/api/codex-auth/login", {
99
114
  method: "POST",
100
- body: JSON.stringify({ ...(id ? { id } : {}), ...(reauth ? { reauth: true } : {}) }),
115
+ body: JSON.stringify({
116
+ ...(id ? { id } : {}),
117
+ ...(reauth ? { reauth: true } : {}),
118
+ ...(device ? { device: true } : {}),
119
+ }),
101
120
  }, deps);
102
121
  if (!wantsJson) {
103
122
  // One atomic pre-poll block, flushed synchronously so a piped parent
104
123
  // reads the URL before the polling window starts (#1007).
105
124
  const block = [
106
125
  start.url ? `Open this URL to sign in:\n${start.url}` : "",
126
+ start.deviceCode ? `Device code: ${start.deviceCode}` : "",
107
127
  start.instructions ?? "",
108
128
  start.flowId ? `Flow: ${start.flowId}` : "",
109
129
  ].filter(line => line !== "").join("\n");
@@ -120,7 +140,12 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
120
140
  return;
121
141
  }
122
142
  if (!start.flowId) throw new CliUsageError("login did not return a flow id");
123
- for (let attempt = 0; attempt < 150; attempt++) {
143
+ // A device login is deliberately slow: the user leaves this machine to
144
+ // enter the code elsewhere. Match the 15-minute grant instead of giving up
145
+ // at minute five while it is still valid, plus settlement margin for the
146
+ // token exchange and credential write after the final poll.
147
+ const maxAttempts = device ? 480 : 150;
148
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
124
149
  await Bun.sleep(2_000);
125
150
  const state = await runtimeRequest<Record<string, unknown>>(
126
151
  `/api/codex-auth/login-status?flowId=${encodeURIComponent(start.flowId)}${id ? `&accountId=${encodeURIComponent(id)}` : ""}${reauth ? "&reauth=1" : ""}`,
@@ -1,4 +1,5 @@
1
1
  import { loadConfig } from "../config";
2
+ import { hasPassiveAccountQuota } from "../providers/quota";
2
3
  import { closeSync, openSync, readSync } from "node:fs";
3
4
  import {
4
5
  MAX_ACCOUNT_PRIORITY,
@@ -330,7 +331,12 @@ export async function cmdRefresh(args: string[], deps: AccountDeps): Promise<num
330
331
  if (result.status === 0) return proxyUnreachable(result.transportError);
331
332
  if (result.status !== 200) return apiError(result.errorJson ?? {}, `failed to refresh ${name}`, result.status);
332
333
  if (wantsJson) console.log(JSON.stringify({ provider: name, report: result.report }, null, 2));
333
- else console.log(result.report ? providerQuotaLine(name, result.report) : `no quota report available for ${name}`);
334
+ else if (result.report) console.log(providerQuotaLine(name, result.report));
335
+ // A passive provider has no probe to run, so "no report available" reads as a
336
+ // failure of something that was never attempted. Say what is actually true.
337
+ else if (hasPassiveAccountQuota(name)) {
338
+ console.log(`${name} reports usage only during a streaming response; there is nothing to refresh. Run a request through this provider to update it, then see \`ocx account list ${name}\`.`);
339
+ } else console.log(`no quota report available for ${name}`);
334
340
  return 0;
335
341
  }
336
342
  const result = await fetchCodexRows(deps, baseUrl, true);
@@ -247,7 +247,7 @@ export const CAPABILITIES: readonly Capability[] = [
247
247
  "A bare invocation reads and never writes.",
248
248
  "The APPLIED value is echoed, not the requested one, so a server-side normalization stays visible.",
249
249
  "Values are not re-validated in the CLI: the server owns the strategy names and the 1-100 sticky bound.",
250
- "`anthropic` is the only OAuth pool with this setting; other OAuth providers are refused without a round-trip.",
250
+ "`anthropic` owns the full pool contract. Other OAuth providers reach the same endpoint with a generic subset (enabled/strategy/autoSwitchThreshold) whose settings persist but do not yet steer selection; `sticky` and `quotaWindow` are refused for them.",
251
251
  ],
252
252
  },
253
253
  {
@@ -274,7 +274,7 @@ export const CAPABILITIES: readonly Capability[] = [
274
274
  { name: "--conversation", value: "string", summary: "Restrict to one conversation id (`--conversationId` is accepted too)." },
275
275
  { name: "--status", value: "string", summary: "An exact code (429) or a class (5xx)." },
276
276
  { name: "--limit", value: "number", summary: "Row cap; defaults to 200." },
277
- { name: "--follow", value: "boolean", summary: "Stream new rows as JSONL; implies --jsonl." },
277
+ { name: "--follow", value: "boolean", summary: "Poll for new rows; add --jsonl to emit JSONL." },
278
278
  { name: "--json", value: "boolean", summary: "Emit the server payload as JSON." },
279
279
  { name: "--jsonl", value: "boolean", summary: "Emit one row per line." },
280
280
  ],
package/src/cli/claude.ts CHANGED
@@ -354,8 +354,17 @@ export function readConnectedClaudeContextWindows(path = DEFAULT_CATALOG_PATH):
354
354
  }
355
355
  }
356
356
 
357
- async function ensureProxyForClaude(): Promise<number | null> {
358
- const live = await findLiveProxy();
357
+ export type ClaudeProxyEnsureDeps = {
358
+ findLiveProxy?: typeof findLiveProxy;
359
+ };
360
+
361
+ export async function ensureProxyForClaude(deps: ClaudeProxyEnsureDeps = {}): Promise<number | null> {
362
+ // A proxy that has only just bound can miss a single probe while its event loop
363
+ // is still settling startup work — the same just-started race the stop paths
364
+ // already retry for (#764, SERVICE_STOP_LIVENESS). Only the attempts budget is
365
+ // borrowed here; the probe timeout remains DEFAULT_PROBE_TIMEOUT_MS (750 ms).
366
+ // Without this, `ocx claude` can spawn a second proxy while the first is serving.
367
+ const live = await (deps.findLiveProxy ?? findLiveProxy)({ attempts: 3 });
359
368
  if (live) return live.port;
360
369
  const cfgPort = loadConfig().port;
361
370
  const pinPort = typeof cfgPort === "number" && cfgPort > 0 ? cfgPort : 10100;
@@ -17,6 +17,7 @@ import {
17
17
  rejectArgs,
18
18
  runCliAction,
19
19
  takeFlag,
20
+ takeIntegerOption,
20
21
  takeOption,
21
22
  type RuntimeApiDeps,
22
23
  } from "./runtime-api";
@@ -25,7 +26,7 @@ export const CONNECT_USAGE = `Usage:
25
26
  ocx connect <url> [--management-url <url>]
26
27
  (--pairing-code-stdin | --admin-token-stdin)
27
28
  [--clients codex,claude] [--management-transport direct|relay]
28
- [--no-sync]
29
+ [--catalog-timeout <seconds>] [--no-sync]
29
30
  ocx connect status [--json]
30
31
  ocx connect rotate (--pairing-code-stdin | --admin-token-stdin)
31
32
  [--json]
@@ -147,6 +148,10 @@ async function runConnect(argv: string[], deps: RuntimeApiDeps): Promise<void> {
147
148
  if (!serverUrl || serverUrl.startsWith("--")) throw new CliUsageError("hub URL is required", CONNECT_USAGE);
148
149
  const managementUrl = takeOption(args, "--management-url");
149
150
  const clients = parseClients(takeOption(args, "--clients"));
151
+ const catalogTimeoutSeconds = takeIntegerOption(args, "--catalog-timeout", { min: 1 });
152
+ if (catalogTimeoutSeconds !== undefined && catalogTimeoutSeconds > 120) {
153
+ throw new CliUsageError("--catalog-timeout must be an integer between 1 and 120", CONNECT_USAGE);
154
+ }
150
155
  const managementTransport = takeOption(args, "--management-transport") ?? "direct";
151
156
  if (managementTransport !== "direct" && managementTransport !== "relay") {
152
157
  throw new CliUsageError("--management-transport must be direct or relay", CONNECT_USAGE);
@@ -167,6 +172,7 @@ async function runConnect(argv: string[], deps: RuntimeApiDeps): Promise<void> {
167
172
  selectedClients: clients,
168
173
  managementTransport,
169
174
  noSync,
175
+ ...(catalogTimeoutSeconds === undefined ? {} : { catalogTimeoutMs: catalogTimeoutSeconds * 1_000 }),
170
176
  }, { fetchImpl: deps.fetchImpl });
171
177
  console.log(`Connected to ${connection.serverUrl} as key ${connection.apiKeyId}.`);
172
178
  }
@@ -72,7 +72,9 @@ async function logs(argv: string[], deps: RuntimeApiDeps): Promise<void> {
72
72
  const limit = takeIntegerOption(args, "--limit", { min: 1 }) ?? 200;
73
73
  rejectArgs(args, USAGE);
74
74
  if (wantsJson && wantsJsonl) throw new CliUsageError("--json and --jsonl cannot be combined", USAGE);
75
- if (follow && wantsJson) throw new CliUsageError("--follow uses --jsonl, not --json", USAGE);
75
+ if (follow && wantsJson) {
76
+ throw new CliUsageError("--follow cannot be combined with --json; use --jsonl for streaming JSONL", USAGE);
77
+ }
76
78
  let seen = new Set<string>();
77
79
  do {
78
80
  const data = await runtimeRequest(`/api/logs${query({ provider, model, status, conversationId, limit })}`, {}, deps);
@@ -88,7 +88,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
88
88
  { name: "ensure", usage: "ocx ensure", summary: "Ensure the proxy is running and Codex config/cache are current." },
89
89
  {
90
90
  name: "connect",
91
- usage: "ocx connect <url> [--management-url <url>] (--pairing-code-stdin | --admin-token-stdin) [--clients codex,claude] [--management-transport direct|relay] [--no-sync]",
91
+ usage: "ocx connect <url> [--management-url <url>] (--pairing-code-stdin | --admin-token-stdin) [--clients codex,claude] [--management-transport direct|relay] [--catalog-timeout <seconds>] [--no-sync]",
92
92
  summary: "Connect this machine to a remote OpenCodex hub without persisting the one-time authority.",
93
93
  details: [
94
94
  "Status: ocx connect status [--json]",
package/src/cli/status.ts CHANGED
@@ -120,20 +120,35 @@ export type ListenTarget = {
120
120
  dashboardUrl: string;
121
121
  };
122
122
 
123
+ type StatusListenConfig = Pick<OcxConfig, "port" | "hostname" | "runtimeRole" | "hub">;
124
+
125
+ function statusDashboardUrl(config: StatusListenConfig, hostname: string | undefined, port: number): string {
126
+ const managementOrigin = config.runtimeRole === "hub" ? config.hub?.managementPublicOrigin : undefined;
127
+ if (managementOrigin) return managementOrigin.endsWith("/") ? managementOrigin : `${managementOrigin}/`;
128
+
129
+ const reachableHostname = probeHostname(hostname);
130
+ const dashboardHostname = reachableHostname === "127.0.0.1"
131
+ || reachableHostname === "[::1]"
132
+ || reachableHostname.toLowerCase() === "localhost"
133
+ ? "localhost"
134
+ : reachableHostname;
135
+ return `http://${dashboardHostname}:${port}/`;
136
+ }
137
+
123
138
  export function selectListenTarget(
124
- config: Pick<OcxConfig, "port" | "hostname">,
139
+ config: StatusListenConfig,
125
140
  pid: number | null,
126
141
  runtimePort: RuntimePortState | null,
127
142
  ): ListenTarget {
128
143
  const currentRuntimePort = pid && runtimePort?.pid === pid ? runtimePort : null;
129
144
  const port = currentRuntimePort ? currentRuntimePort.port : config.port ?? 10100;
130
- const hostname = currentRuntimePort ? currentRuntimePort.hostname : config.hostname;
145
+ const hostname = currentRuntimePort?.hostname ?? config.hostname;
131
146
  return {
132
147
  port,
133
148
  hostname,
134
149
  source: currentRuntimePort ? "runtime" : "config",
135
150
  healthUrl: `http://${probeHostname(hostname)}:${port}/healthz`,
136
- dashboardUrl: `http://localhost:${port}/`,
151
+ dashboardUrl: statusDashboardUrl(config, hostname, port),
137
152
  };
138
153
  }
139
154
 
@@ -341,7 +356,7 @@ export async function collectStatus(): Promise<CliStatusView> {
341
356
  hostname: live.hostname,
342
357
  source: live.source,
343
358
  healthUrl: `http://${probeHostname(live.hostname)}:${live.port}/healthz`,
344
- dashboardUrl: `http://localhost:${live.port}/`,
359
+ dashboardUrl: statusDashboardUrl(config, live.hostname, live.port),
345
360
  }
346
361
  : selectListenTarget(config, pidFile, pidFile ? readRuntimePort(pidFile) : null);
347
362
  // findLiveProxy already identity-probed /healthz; avoid a second fetch that can race.
@@ -71,6 +71,7 @@ export interface ConnectOptions {
71
71
  selectedClients: OcxConnectedClientId[];
72
72
  managementTransport: "direct" | "relay";
73
73
  noSync?: boolean;
74
+ catalogTimeoutMs?: number;
74
75
  }
75
76
 
76
77
  export interface ClientConnectDeps {
@@ -408,7 +409,10 @@ export async function connectClient(
408
409
  const persisted = writeServiceApiTokenFile(issued.key);
409
410
  tokenFingerprint = persisted.fingerprint;
410
411
 
411
- const catalog = await downloadClientCatalog(serverUrl, issued.key, { fetchImpl: deps.fetchImpl });
412
+ const catalog = await downloadClientCatalog(serverUrl, issued.key, {
413
+ fetchImpl: deps.fetchImpl,
414
+ timeoutMs: options.catalogTimeoutMs,
415
+ });
412
416
  atomicWriteFile(DEFAULT_CATALOG_PATH, catalog.body);
413
417
  writtenCatalogFingerprint = sha256(catalog.body);
414
418
 
@@ -1,5 +1,6 @@
1
1
  import { MAX_REMOTE_CATALOG_BYTES } from "../server/catalog-download";
2
2
  import { readBoundedResponseBytes } from "../lib/bounded-body";
3
+ import { clearableDeadline } from "../lib/abort";
3
4
 
4
5
  /**
5
6
  * A pairing grant may cross loopback or authenticated HTTPS, and nothing else.
@@ -84,13 +85,17 @@ async function fetchBounded(
84
85
  url: string,
85
86
  init: RequestInit,
86
87
  timeoutMs: number | undefined,
88
+ timeoutScope: "request" | "headers" = "request",
87
89
  ): Promise<Response> {
90
+ const timeout = safeTimeout(timeoutMs);
91
+ const headerDeadline = timeoutScope === "headers" ? clearableDeadline(timeout) : null;
88
92
  try {
89
93
  const response = await fetchImpl(url, {
90
94
  ...init,
91
95
  redirect: "manual",
92
- signal: AbortSignal.timeout(safeTimeout(timeoutMs)),
96
+ signal: headerDeadline?.signal ?? AbortSignal.timeout(timeout),
93
97
  });
98
+ headerDeadline?.clear();
94
99
  if (response.status >= 300 && response.status < 400 && response.status !== 304) {
95
100
  throw new HubClientError("redirect_refused", "Hub request redirect was refused", response.status);
96
101
  }
@@ -98,15 +103,24 @@ async function fetchBounded(
98
103
  } catch (error) {
99
104
  if (error instanceof HubClientError) throw error;
100
105
  throw new HubClientError("unreachable", "Hub request did not complete", undefined, { cause: error });
106
+ } finally {
107
+ headerDeadline?.clear();
101
108
  }
102
109
  }
103
110
 
104
- async function boundedText(response: Response, maxBytes: number): Promise<string> {
111
+ async function boundedText(
112
+ response: Response,
113
+ maxBytes: number,
114
+ options: { inactivityTimeoutMs?: number } = {},
115
+ ): Promise<string> {
105
116
  const declared = Number(response.headers.get("content-length") ?? "0");
106
117
  if (Number.isFinite(declared) && declared > maxBytes) {
107
118
  throw new HubClientError("body_too_large", "Hub response exceeded the allowed size", response.status);
108
119
  }
109
- const result = await readBoundedResponseBytes(response, { maxBytes });
120
+ const result = await readBoundedResponseBytes(response, {
121
+ maxBytes,
122
+ ...(options.inactivityTimeoutMs === undefined ? {} : { inactivityTimeoutMs: options.inactivityTimeoutMs }),
123
+ });
110
124
  if (result.oversized) {
111
125
  throw new HubClientError("body_too_large", "Hub response exceeded the allowed size", response.status);
112
126
  }
@@ -422,7 +436,7 @@ export async function downloadClientCatalog(
422
436
  const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/v1/catalog`, {
423
437
  method: "GET",
424
438
  headers,
425
- }, options.timeoutMs);
439
+ }, options.timeoutMs, "headers");
426
440
  if (response.status === 304) {
427
441
  throw new HubClientError("catalog_unexpected_304", "Hub answered 304 to an unconditional catalog request", 304);
428
442
  }
@@ -434,7 +448,17 @@ export async function downloadClientCatalog(
434
448
  try { await response.body?.cancel(); } catch { /* best effort */ }
435
449
  throw new HubClientError("catalog_content_type_invalid", "Hub catalog response was not JSON", response.status);
436
450
  }
437
- const body = await boundedText(response, options.maxBytes ?? MAX_REMOTE_CATALOG_BYTES);
451
+ let body: string;
452
+ try {
453
+ body = await boundedText(response, options.maxBytes ?? MAX_REMOTE_CATALOG_BYTES, {
454
+ inactivityTimeoutMs: safeTimeout(options.timeoutMs),
455
+ });
456
+ } catch (error) {
457
+ if (error instanceof DOMException && error.name === "TimeoutError") {
458
+ throw new HubClientError("unreachable", "Hub catalog download stalled", undefined, { cause: error });
459
+ }
460
+ throw error;
461
+ }
438
462
  const parsed = parseJson(body, "catalog_invalid");
439
463
  validateRemoteCatalog(parsed);
440
464
  const keyId = response.headers.get("x-opencodex-key-id")?.trim() || undefined;
@@ -22,7 +22,7 @@
22
22
  import { homedir } from "node:os";
23
23
  import { existsSync, readFileSync } from "node:fs";
24
24
  import { isAbsolute, join, resolve } from "node:path";
25
- import { shouldInjectApiAuthHeader } from "../codex/inject";
25
+ import { shouldInjectApiAuthHeader, standaloneCodexRoutingTarget } from "../codex/inject";
26
26
  import { FORMAT_MEDIA_TYPE, serializeDocument, type ConfigFormat } from "../integrations/serialize";
27
27
  import { providerCodexAccountMode } from "../providers/registry";
28
28
  import { canonicalizeReasoningEfforts, sanitizeCodexReasoningEfforts } from "../reasoning-effort";
@@ -301,7 +301,17 @@ export function ompModelsConfigPath(env: OpencodeLaunchEnv = process.env, home:
301
301
  }
302
302
 
303
303
  /** Compose the OpenAI-compatible proxy base URL from a live probe result. */
304
- export function opencodeProxyBaseUrl(port: number, hostname?: string): string {
304
+ export function opencodeProxyBaseUrl(
305
+ port: number,
306
+ hostname?: string,
307
+ config?: Pick<OcxConfig, "unauthenticatedLoopbackListener">,
308
+ ): string {
309
+ if (config?.unauthenticatedLoopbackListener?.enabled) {
310
+ return standaloneCodexRoutingTarget(port, {
311
+ hostname,
312
+ unauthenticatedLoopbackListener: config.unauthenticatedLoopbackListener,
313
+ }).baseUrl;
314
+ }
305
315
  return `http://${probeHostname(hostname)}:${port}/v1`;
306
316
  }
307
317