@bitkyc08/opencodex 2.35.0 → 2.36.0-preview.20260830

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 (155) hide show
  1. package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
  2. package/gui/dist/assets/index-DPl4nBMA.js +112 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +2 -1
  5. package/src/AGENTS.md +2 -1
  6. package/src/adapters/agentrouter.ts +50 -0
  7. package/src/adapters/anthropic.ts +1 -51
  8. package/src/adapters/cursor/call-id.ts +76 -8
  9. package/src/adapters/cursor/checkpoint-store.ts +6 -1
  10. package/src/adapters/cursor/cursor-errors.ts +44 -0
  11. package/src/adapters/cursor/native-exec.ts +13 -0
  12. package/src/adapters/cursor/protobuf-request.ts +651 -29
  13. package/src/adapters/cursor/tool-result-normalize.ts +3 -3
  14. package/src/adapters/cursor/transport-retry.ts +5 -1
  15. package/src/adapters/cursor.ts +15 -1
  16. package/src/adapters/empty-tool-output-annotation.ts +43 -0
  17. package/src/adapters/exec-tool-result-normalize.ts +70 -5
  18. package/src/adapters/google.ts +22 -2
  19. package/src/adapters/kiro.ts +26 -2
  20. package/src/adapters/ollama-native-url.ts +111 -0
  21. package/src/adapters/ollama-native.ts +1131 -0
  22. package/src/adapters/openai-chat.ts +30 -7
  23. package/src/adapters/openai-responses.ts +72 -4
  24. package/src/adapters/registry.ts +7 -0
  25. package/src/adapters/xai-web-search.ts +58 -0
  26. package/src/claude/desktop-3p.ts +21 -1
  27. package/src/claude/desktop-policy.ts +149 -0
  28. package/src/cli/account.ts +16 -2
  29. package/src/cli/claude-desktop.ts +13 -3
  30. package/src/cli/combo.ts +8 -5
  31. package/src/cli/doctor.ts +77 -11
  32. package/src/cli/help.ts +1 -1
  33. package/src/cli/index.ts +16 -0
  34. package/src/cli/models.ts +20 -3
  35. package/src/cli/registry.ts +2 -1
  36. package/src/cli/status.ts +140 -2
  37. package/src/cli/storage.ts +10 -1
  38. package/src/codex/account-runtime-state.ts +39 -5
  39. package/src/codex/account-store.ts +393 -13
  40. package/src/codex/account-usability.ts +11 -4
  41. package/src/codex/app-server-processes.ts +46 -5
  42. package/src/codex/auth-context.ts +160 -32
  43. package/src/codex/catalog/bundled.ts +7 -5
  44. package/src/codex/catalog/metadata.ts +1 -1
  45. package/src/codex/catalog/parsing.ts +57 -1
  46. package/src/codex/catalog/provider-fetch.ts +61 -4
  47. package/src/codex/catalog/sync.ts +4 -3
  48. package/src/codex/convergence.ts +3 -2
  49. package/src/codex/data/upstream-models.json +40 -8
  50. package/src/codex/inject-coordination.ts +111 -14
  51. package/src/codex/integration-record.ts +12 -2
  52. package/src/codex/main-account.ts +225 -1
  53. package/src/codex/model-entitlements.ts +339 -27
  54. package/src/codex/prompt-layers.ts +346 -7
  55. package/src/codex/prompt-text-probe.ts +272 -21
  56. package/src/codex/routing.ts +693 -132
  57. package/src/codex/runtime.ts +12 -0
  58. package/src/codex/subagent-model-fallback.ts +62 -24
  59. package/src/codex/user-identity.ts +33 -25
  60. package/src/combos/index.ts +1 -0
  61. package/src/combos/reset-window.ts +46 -0
  62. package/src/combos/resolve.ts +84 -2
  63. package/src/combos/types.ts +5 -2
  64. package/src/config/atomic-write.ts +104 -22
  65. package/src/config/provider-validation.ts +11 -0
  66. package/src/config.ts +75 -3
  67. package/src/generated/compatibility-version.json +207 -131
  68. package/src/generated/model-metadata.ts +1 -1
  69. package/src/grok/catalog.ts +71 -0
  70. package/src/grok/effort.ts +83 -0
  71. package/src/grok/inject.ts +952 -127
  72. package/src/grok/models.ts +56 -0
  73. package/src/grok/status.ts +21 -8
  74. package/src/grok/sync.ts +10 -18
  75. package/src/images/loop.ts +6 -3
  76. package/src/integrations/native/ownership-preflight.ts +4 -1
  77. package/src/lab/fabric/producer-isolate.ts +36 -3
  78. package/src/lib/destination-policy.ts +93 -7
  79. package/src/lib/redact.ts +6 -1
  80. package/src/lib/shadow-call.ts +38 -3
  81. package/src/lib/test-home-guard.ts +18 -3
  82. package/src/lib/upstream-retry.ts +43 -6
  83. package/src/lib/windows-secret-acl.ts +66 -0
  84. package/src/lib/windows-text.ts +28 -2
  85. package/src/lib/windows-user-principal.ts +35 -23
  86. package/src/oauth/account-quota-rank.ts +107 -0
  87. package/src/oauth/anthropic-routing.ts +125 -30
  88. package/src/oauth/chatgpt.ts +5 -1
  89. package/src/oauth/generic-account-failover.ts +114 -7
  90. package/src/oauth/index.ts +15 -8
  91. package/src/oauth/store.ts +16 -0
  92. package/src/providers/account-quota-disk.ts +79 -0
  93. package/src/providers/command-code-efforts.ts +24 -0
  94. package/src/providers/derive.ts +6 -0
  95. package/src/providers/key-failover.ts +33 -1
  96. package/src/providers/kiro-usage.ts +272 -0
  97. package/src/providers/ollama-show.ts +311 -0
  98. package/src/providers/openai-sidecar.ts +5 -0
  99. package/src/providers/quota-routing-cache.ts +32 -0
  100. package/src/providers/quota-types.ts +36 -0
  101. package/src/providers/quota-wire.ts +102 -0
  102. package/src/providers/quota.ts +208 -147
  103. package/src/providers/registry.ts +68 -8
  104. package/src/providers/slug-codec.ts +12 -4
  105. package/src/providers/vercel-gateway-routing.ts +108 -0
  106. package/src/router.ts +22 -12
  107. package/src/server/auth-cors.ts +26 -0
  108. package/src/server/catalog-download.ts +73 -0
  109. package/src/server/chat-native.ts +12 -2
  110. package/src/server/gui-static.ts +4 -1
  111. package/src/server/index.ts +132 -9
  112. package/src/server/management/agent-settings-routes.ts +38 -5
  113. package/src/server/management/codex-prompt-routes.ts +7 -1
  114. package/src/server/management/combo-routes.ts +10 -1
  115. package/src/server/management/config-routes.ts +9 -1
  116. package/src/server/management/context.ts +5 -0
  117. package/src/server/management/model-routes.ts +16 -6
  118. package/src/server/management/native-integration-routes.ts +12 -17
  119. package/src/server/management/oauth-account-routes.ts +13 -0
  120. package/src/server/management/provider-routes.ts +32 -5
  121. package/src/server/management/routing-profile-routes.ts +15 -0
  122. package/src/server/management/shadow-call-validation.ts +29 -0
  123. package/src/server/management-api.ts +7 -3
  124. package/src/server/request-log.ts +3 -5
  125. package/src/server/responses/agent-task-recovery-cache.ts +8 -0
  126. package/src/server/responses/agent-task-recovery.ts +52 -20
  127. package/src/server/responses/codex-auth-error.ts +26 -0
  128. package/src/server/responses/compact.ts +345 -10
  129. package/src/server/responses/core.ts +736 -108
  130. package/src/server/responses/empty-completion-guard.ts +16 -0
  131. package/src/server/responses/fetch-helpers.ts +42 -0
  132. package/src/server/responses/policy-fallback.ts +11 -6
  133. package/src/server/responses-undeclared-tool-guard.ts +16 -3
  134. package/src/server/startup-health-cache.ts +59 -13
  135. package/src/service-manager-probe.ts +115 -9
  136. package/src/service.ts +139 -40
  137. package/src/storage/cleanup.ts +10 -0
  138. package/src/storage/storage-mutation-coordinator.ts +14 -3
  139. package/src/tray/windows-tray.ps1 +10 -4
  140. package/src/tray/windows.ts +30 -2
  141. package/src/types/config.ts +27 -14
  142. package/src/types/provider.ts +54 -0
  143. package/src/types/tools.ts +13 -3
  144. package/src/types.ts +4 -0
  145. package/src/usage/summary.ts +421 -177
  146. package/src/vision/anthropic-describe.ts +3 -3
  147. package/src/vision/describe.ts +5 -3
  148. package/src/web-search/anthropic-executor.ts +9 -2
  149. package/src/web-search/exa-executor.ts +3 -3
  150. package/src/web-search/executor.ts +8 -3
  151. package/src/web-search/gemini-executor.ts +3 -3
  152. package/src/web-search/loop.ts +11 -3
  153. package/src/web-search/xai-executor.ts +3 -3
  154. package/gui/dist/assets/index-DNdRKXK9.js +0 -112
  155. package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
package/src/cli/doctor.ts CHANGED
@@ -10,8 +10,9 @@
10
10
  import { accessSync, constants, existsSync, readFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { dirname, join } from "node:path";
13
- import { getConfigDir, getConfigPath, readConfigDiagnostics, resolveEnvValue } from "../config";
13
+ import { getConfigDir, getConfigPath, readConfigDiagnostics } from "../config";
14
14
  import { readPid } from "../config/process-state";
15
+ import { probeUncleanExitState } from "./status";
15
16
  import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness";
16
17
  import { BUN_RUNTIME_SOURCES } from "../lib/bun-runtime";
17
18
  import type { BunRuntimeSource } from "../lib/bun-runtime";
@@ -26,7 +27,8 @@ import { probeNativeProfileRecoveryState, resolveNativeProfileContext } from "..
26
27
  import { NativeProfileError } from "../codex/native-profile-types";
27
28
  import { collectOrcaCodexHomeDiagnostic, resolveCodexHomeDir as resolveCodexHomeDirImpl, isWslRuntime, listWslWindowsCodexHomes, wslAutomountRoot, type CodexHomeDeps } from "../codex/home";
28
29
  import { scanCodexAgentRolesWithTomlModelFallback } from "../codex/subagent-model-fallback";
29
- import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim";
30
+ import { diagnoseCodexShim, findCodexOnPath, isWindowsInteropDir, type CodexShimDiagnostic } from "../codex/shim";
31
+ import { providerTableString, rootTomlString } from "../codex/injected-marker";
30
32
  import { countPendingOpencodexHistory } from "../codex/history-provider";
31
33
  import {
32
34
  inspectCodexCoordinator,
@@ -403,6 +405,10 @@ export function collectWslDualInstall(deps: WslDualInstallDeps = {}): WslDualIns
403
405
  export type ProxyEnvRow = { key: string; present: boolean };
404
406
  export type EnvMap = Record<string, string | undefined>;
405
407
 
408
+ function ownEnvValue(env: EnvMap, name: string): string | undefined {
409
+ return Object.hasOwn(env, name) ? env[name] : undefined;
410
+ }
411
+
406
412
  /** Report only presence/absence of proxy env vars - never the value (it may
407
413
  * embed credentials). Checks both upper- and lower-case forms. */
408
414
  export function collectProxyEnv(env: EnvMap = process.env): ProxyEnvRow[] {
@@ -438,11 +444,6 @@ export function collectProviderApiKeyDiagnostics(
438
444
  providers: Record<string, { authMode?: string; apiKey?: string }> = readConfigDiagnostics().config.providers ?? {},
439
445
  env: EnvMap = process.env,
440
446
  ): ProviderApiKeyDiagnostic[] {
441
- const resolveInEnv = (value: string): string | undefined => {
442
- const name = envReferenceName(value);
443
- if (!name) return value;
444
- return env[name];
445
- };
446
447
  const rows: ProviderApiKeyDiagnostic[] = [];
447
448
  for (const [provider, config] of Object.entries(providers)) {
448
449
  if (config.authMode !== "key") continue;
@@ -450,7 +451,7 @@ export function collectProviderApiKeyDiagnostics(
450
451
  if (!raw) continue;
451
452
  const envName = envReferenceName(raw);
452
453
  if (!envName) continue;
453
- const resolved = resolveInEnv(raw);
454
+ const resolved = ownEnvValue(env, envName);
454
455
  if (resolved?.trim()) continue;
455
456
  rows.push({
456
457
  provider,
@@ -461,6 +462,33 @@ export function collectProviderApiKeyDiagnostics(
461
462
  return rows;
462
463
  }
463
464
 
465
+ export type CodexEnvKeyReadinessDiagnostic = {
466
+ envName: string;
467
+ shimState: "missing" | "unhealthy";
468
+ detail: string;
469
+ action: string;
470
+ };
471
+
472
+ /** Warn when routed Codex cannot obtain its configured admission token at launch. */
473
+ export function collectCodexEnvKeyReadiness(
474
+ configText: string | null,
475
+ env: EnvMap,
476
+ shim: CodexShimDiagnostic,
477
+ serviceTokenPresent: boolean,
478
+ ): CodexEnvKeyReadinessDiagnostic | null {
479
+ if (!configText || rootTomlString(configText, "model_provider") !== "opencodex") return null;
480
+ const envName = providerTableString(configText, "opencodex", "env_key")?.trim();
481
+ const envValue = envName ? ownEnvValue(env, envName) : undefined;
482
+ if (!envName || envValue?.trim() || shim.healthy || !serviceTokenPresent) return null;
483
+ const shimState = shim.installed ? "unhealthy" : "missing";
484
+ return {
485
+ envName,
486
+ shimState,
487
+ detail: `Codex uses env_key ${envName}, but that variable is unset and the OpenCodex shim is ${shimState}; the service token file exists but plain Codex does not load it`,
488
+ action: `Run 'ocx codex-shim install' to repair launch-time token injection, or export ${envName} in the process that starts Codex`,
489
+ };
490
+ }
491
+
464
492
  export function collectConfiguredProxy(): ConfiguredProxyDiagnostic {
465
493
  const diagnostics = readConfigDiagnostics();
466
494
  const rawProxy = typeof diagnostics.config.proxy === "string" ? diagnostics.config.proxy.trim() : "";
@@ -484,7 +512,9 @@ export function collectConfiguredProxy(): ConfiguredProxyDiagnostic {
484
512
  }
485
513
 
486
514
  const envName = envReferenceName(rawProxy);
487
- const resolved = resolveEnvValue(rawProxy);
515
+ const resolved = rawProxy.startsWith("$")
516
+ ? ownEnvValue(process.env, envName ?? rawProxy.slice(1))
517
+ : rawProxy;
488
518
  if (resolved?.trim()) {
489
519
  return {
490
520
  key: "config.proxy",
@@ -505,7 +535,7 @@ export function collectConfiguredProxy(): ConfiguredProxyDiagnostic {
505
535
  }
506
536
 
507
537
  export function parseProcessEnvBlock(content: string): EnvMap {
508
- const env: EnvMap = {};
538
+ const env: EnvMap = Object.create(null);
509
539
  for (const entry of content.split("\0")) {
510
540
  if (!entry) continue;
511
541
  const separator = entry.indexOf("=");
@@ -943,6 +973,11 @@ export function proxyDownRestartHint(input: {
943
973
  /** Absent means "unknown"; the hint then keeps its pre-repair wording. */
944
974
  serviceInstalled?: boolean;
945
975
  serviceConflict?: boolean;
976
+ /**
977
+ * Persisted owner records outlived their process (#1419). Cause-neutral: what is on
978
+ * disk proves an unclean exit, not which signal caused it.
979
+ */
980
+ staleProcessState?: boolean;
946
981
  }): string | null {
947
982
  if (input.proxyRunning) return null;
948
983
  // `serviceViable` alone conflates "no service at all" with "registered but stale or
@@ -955,7 +990,10 @@ export function proxyDownRestartHint(input: {
955
990
  : installedButBroken
956
991
  ? "Restart it with 'ocx start', or refresh the installed service: 'ocx service repair'."
957
992
  : "Restart it with 'ocx start', or install the persistent service: 'ocx service install'.";
958
- return `The ocx proxy is not running. Codex/Claude clients pinned to 127.0.0.1:${input.port} fail with errors like "error sending request for url (http://127.0.0.1:${input.port}/v1/responses)". ${restart}`;
993
+ const uncleanExit = input.staleProcessState === true
994
+ ? "Stale process records remain, so the previous run may have exited unexpectedly. "
995
+ : "";
996
+ return `The ocx proxy is not running. ${uncleanExit}Codex/Claude clients pinned to 127.0.0.1:${input.port} fail with errors like "error sending request for url (http://127.0.0.1:${input.port}/v1/responses)". ${restart}`;
959
997
  }
960
998
 
961
999
  export async function runDoctor(args: string[] = []): Promise<void> {
@@ -1059,6 +1097,17 @@ export async function runDoctor(args: string[] = []): Promise<void> {
1059
1097
  }
1060
1098
 
1061
1099
  const doctorConfig = readConfigDiagnostics().config;
1100
+ const codexConfigPath = join(resolveCodexHomeDirImpl(), "config.toml");
1101
+ const codexConfigText = (() => {
1102
+ try { return readFileSync(codexConfigPath, "utf8"); } catch { return null; }
1103
+ })();
1104
+ const serviceTokenPresent = Boolean(readInstalledServiceToken()?.trim());
1105
+ const codexEnvKeyReadiness = collectCodexEnvKeyReadiness(
1106
+ codexConfigText,
1107
+ process.env,
1108
+ diagnoseCodexShim(),
1109
+ serviceTokenPresent,
1110
+ );
1062
1111
  const startup = collectStartupHealth(doctorConfig);
1063
1112
  console.log("\nCodex restart safety");
1064
1113
  console.log(` ${startup.rebootSafe ? "ok " : "!! "} ${startupHealthSummary(startup)}`);
@@ -1137,6 +1186,14 @@ export async function runDoctor(args: string[] = []): Promise<void> {
1137
1186
  }
1138
1187
  }
1139
1188
 
1189
+ console.log("\nCodex env_key launch readiness");
1190
+ if (codexEnvKeyReadiness) {
1191
+ console.log(` !! ${codexEnvKeyReadiness.detail}`);
1192
+ console.log(` Action: ${codexEnvKeyReadiness.action}`);
1193
+ } else {
1194
+ console.log(" ok no broken OpenCodex env_key launch path detected");
1195
+ }
1196
+
1140
1197
  console.log("\nRunning proxy process proxy env (presence only)");
1141
1198
  if (runningProxyEnv.status === "not_running") {
1142
1199
  console.log(" -- no running ocx proxy process found");
@@ -1269,11 +1326,20 @@ export async function runDoctor(args: string[] = []): Promise<void> {
1269
1326
  serviceViable: startup.serviceViable,
1270
1327
  serviceInstalled: startup.serviceInstalled,
1271
1328
  serviceConflict: startup.serviceConflict,
1329
+ // Threaded through the same decision helper `ocx status` uses, so the two
1330
+ // diagnostics cannot drift. A helper-only change would satisfy a unit test while
1331
+ // real `ocx doctor` output never mentioned the crash (#1419).
1332
+ staleProcessState: await probeUncleanExitState({
1333
+ live: Boolean(live),
1334
+ port: doctorConfig.port,
1335
+ hostname: doctorConfig.hostname,
1336
+ }),
1272
1337
  });
1273
1338
  if (proxyDown) hints.push(proxyDown);
1274
1339
  for (const row of providerApiKeys) {
1275
1340
  hints.push(`${row.detail}. Set ${row.envName} in the shell that starts the proxy, or store a literal key in config (value hidden here).`);
1276
1341
  }
1342
+ if (codexEnvKeyReadiness) hints.push(`${codexEnvKeyReadiness.detail}. ${codexEnvKeyReadiness.action}.`);
1277
1343
  const anyDrvfs = paths.some(p => detectFsType(p.path, mounts).isDrvfs || detectFsType(p.path, mounts).isMntDrive);
1278
1344
  const noProxy = currentProxyEnv.every(p => !p.present) && !configuredProxy.present;
1279
1345
  if (!startup.rebootSafe) {
package/src/cli/help.ts CHANGED
@@ -61,7 +61,7 @@ Usage:
61
61
  ocx account <sub> Accounts, login/reauth, key pools, and quota controls
62
62
  ocx models <sub> Live/custom models, visibility, context, and shadow calls
63
63
  ocx alias <sub> Short names for providers and models (list, set, rm, defaults)
64
- ocx combo <sub> Combo failover/round-robin routing
64
+ ocx combo <sub> Combo routing strategies and failover
65
65
  ocx agent <sub> Subagents, injection, effort caps, and sidecars
66
66
  ocx observe <sub> Logs, usage, storage, memory, and debug data
67
67
  ocx inspect <sub> Effective config, catalog, analytics, pacing, client-config
package/src/cli/index.ts CHANGED
@@ -858,6 +858,11 @@ async function handleStatus() {
858
858
  console.log(`❌ Proxy: ${status.proxyLabel}`);
859
859
  }
860
860
  console.log(` Health: ${status.healthLabel}`);
861
+ if (status.json.claudeDesktop.desiredEnabled && !status.json.claudeDesktop.policy.ok) {
862
+ console.log(` ⚠️ Claude Desktop 3P health: ${status.json.claudeDesktop.policy.status}`);
863
+ console.log(` ${status.json.claudeDesktop.policy.message}`);
864
+ console.log(` Action: ${status.json.claudeDesktop.policy.action}`);
865
+ }
861
866
  // Printed here, not only in --json: a stale ocx on PATH is exactly the situation where
862
867
  // the operator is reading human output and wondering why the CLI disagrees with the
863
868
  // dashboard. Adding the JSON field alone would satisfy a test and help nobody (#2701).
@@ -877,6 +882,17 @@ async function handleStatus() {
877
882
  // contradicted it in the same report, and install re-registers: UAC on Windows and a
878
883
  // possible WinSW-to-scheduler switch for someone who already has a service.
879
884
  const installed = status.json.startup.serviceInstalled && !status.json.startup.serviceConflict;
885
+ // #1419: the records outliving the process is the only evidence the user gets that a
886
+ // previous run ended without cleanup. Deliberately hedged and cause-neutral — cleanup
887
+ // ignores unlink failures and the records carry no session provenance, so this cannot
888
+ // prove a crash, only that the last run left state behind. The restart advice below is
889
+ // not repeated here; one recommendation per report.
890
+ if (status.json.proxy.staleProcessState) {
891
+ console.log(" Stale process records remain, so the previous run may have exited unexpectedly.");
892
+ if (!installed) {
893
+ console.log(" No background service was available to restart it.");
894
+ }
895
+ }
880
896
  console.log(installed
881
897
  ? " Restart with 'ocx start', or refresh the installed service: 'ocx service repair'."
882
898
  : " Restart with 'ocx start', or install the persistent service: 'ocx service install'.");
package/src/cli/models.ts CHANGED
@@ -283,10 +283,27 @@ async function handleCustomRemove(args: string[]): Promise<void> {
283
283
  // that row while the dash form matched both — the two relations disagreed on the same
284
284
  // config. Removal stays exact-or-refuse: an ambiguous selector still aborts below, which is
285
285
  // the right default for a destructive command.
286
+ const separator = target.indexOf("/");
287
+ const selectedProvider = separator >= 0 ? target.slice(0, separator) : undefined;
288
+ // Resolve ONCE against the provider's whole roster, then map the decision back onto rows.
289
+ // Calling the resolver per row with a singleton roster hid every cross-row fact it needs:
290
+ // a self-namespaced `acme/turbo` and a sibling `turbo` each matched their own singleton,
291
+ // so the command saw two matches and aborted as ambiguous even though the selector names
292
+ // one row exactly.
293
+ const rosterMatched = selectedProvider === undefined
294
+ ? undefined
295
+ : resolveSlugSelection(
296
+ selectedProvider,
297
+ target,
298
+ existing.filter(model => model.provider === selectedProvider).map(model => model.modelId),
299
+ );
300
+ // Deliberately admit the whole matched set rather than narrowing to `exact`: an encoded
301
+ // selector that spans a real collision must still abort below. Removal stays exact-or-refuse.
302
+ const admitted = new Set(rosterMatched?.matched ?? []);
286
303
  const matchingIndexes = existing.flatMap((model, index) => {
287
- if (!target.includes("/")) return model.id === target ? [index] : [];
288
- const resolved = resolveSlugSelection(model.provider, target, [model.modelId]);
289
- return resolved.matched.length > 0 ? [index] : [];
304
+ if (selectedProvider === undefined) return model.id === target ? [index] : [];
305
+ if (model.provider !== selectedProvider) return [];
306
+ return admitted.has(model.modelId) ? [index] : [];
290
307
  });
291
308
  if (matchingIndexes.length === 0) fail(`custom model "${target}" not found`);
292
309
  if (matchingIndexes.length > 1) {
@@ -194,7 +194,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
194
194
  {
195
195
  name: "combo",
196
196
  usage: "ocx combo <list|show|set|remove> ...",
197
- summary: "Manage combo failover and round-robin virtual models.",
197
+ summary: "Manage combo virtual models and routing strategies.",
198
198
  details: ["Alias hierarchy: ocx route combo ...", "Use --targets provider/model[:weight],provider/model[:weight]."],
199
199
  },
200
200
  {
@@ -291,6 +291,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
291
291
  "Claude Desktop profile:",
292
292
  " ocx claude desktop [apply] Save and apply the four-family profile",
293
293
  " ocx claude desktop show [--json] Show routes, families, and defaults",
294
+ " ocx claude desktop status [--json] Show applied state, drift, and health",
294
295
  " ocx claude desktop move <route> <family> [--default]",
295
296
  " ocx claude desktop default <family> <route|none>",
296
297
  " ocx claude desktop export <path|-> Export versioned JSON (`-` = stdout)",
package/src/cli/status.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { durableBunRuntime } from "../lib/bun-runtime";
2
2
  import { codexAutoStartEnabled, getConfigPath, readConfigDiagnostics } from "../config";
3
- import { getPidPath, readPid, readRuntimePort, type RuntimePortState } from "../config/process-state";
3
+ import { getPidPath, readPid, readPidFileValue, readRuntimePort, type RuntimePortState } from "../config/process-state";
4
4
  import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../codex/plugins-doctor";
5
5
  import { findLiveProxy, isOpencodexHealthz, probeHostname } from "../server/proxy-liveness";
6
6
  import { directLocalHttpFetch } from "../server/direct-local-http";
7
7
  import type { OcxConfig } from "../types";
8
8
  import { diagnoseService, serviceLogPath } from "../service";
9
9
  import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health";
10
+ import { isProcessAlive } from "../lib/process-control";
10
11
  import { getCodexRoutingKind } from "../codex/inject";
11
12
  import { diagnoseCodexShim } from "../codex/shim";
12
13
  import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortClamp, resolveCodexRuntime } from "../codex/runtime";
@@ -15,12 +16,16 @@ import { computeVersionSkew, type VersionSkew } from "./version-skew";
15
16
  import { redactSecretString, redactUserPath } from "../lib/redact";
16
17
  import { collectOrcaCodexHomeDiagnostic, type OrcaCodexHomeDiagnostic } from "../codex/home";
17
18
  import { grokFenceEndpointDrift, readGrokStatus } from "../grok/status";
19
+ import { claudeDesktopIntegrationEnabled } from "../codex/desired-state";
20
+ import { claudeDesktopPolicyHealth, probeClaudeDesktopPolicy, type ClaudeDesktopPolicyHealth } from "../claude/desktop-policy";
18
21
 
19
22
  type HealthCheck = {
20
23
  ok: boolean;
21
24
  url: string;
22
25
  message: string;
23
26
  label: string;
27
+ /** True only for a connect-phase refusal: proof that nothing holds the port. */
28
+ refused?: boolean;
24
29
  };
25
30
 
26
31
  export type CliStatusJson = {
@@ -28,6 +33,8 @@ export type CliStatusJson = {
28
33
  proxy: {
29
34
  running: boolean;
30
35
  pid: number | null;
36
+ /** Persisted owner records outlived their process: the last proxy did not exit cleanly. */
37
+ staleProcessState: boolean;
31
38
  health: {
32
39
  ok: boolean;
33
40
  url: string;
@@ -72,6 +79,10 @@ export type CliStatusJson = {
72
79
  };
73
80
  };
74
81
  codexHome: OrcaCodexHomeDiagnostic;
82
+ claudeDesktop: {
83
+ desiredEnabled: boolean;
84
+ policy: ClaudeDesktopPolicyHealth;
85
+ };
75
86
  /**
76
87
  * This CLI's version against the running proxy's (#2701).
77
88
  *
@@ -127,6 +138,69 @@ export function proxyHealthFailureReason(error: unknown, signal: AbortSignal): "
127
138
  : "unreachable";
128
139
  }
129
140
 
141
+ /**
142
+ * "Nothing is listening" is narrower than "the probe failed". `unreachable` covers every
143
+ * non-abort failure, including a socket that was ACCEPTED and then reset — which is what
144
+ * an in-flight start looks like mid-bind. Only a connect-phase refusal proves the port is
145
+ * free, so this reads the underlying errno instead of the display string.
146
+ */
147
+ export function isConnectionRefused(error: unknown): boolean {
148
+ for (let current: unknown = error, depth = 0; current instanceof Error && depth < 4; depth++) {
149
+ const code = (current as { code?: unknown }).code;
150
+ if (code === "ECONNREFUSED" || code === "ConnectionRefused") return true;
151
+ // Bun surfaces the refusal as a plain message on some platforms; the errno name is
152
+ // still the discriminator, not a substring of arbitrary prose.
153
+ if (typeof code === "string" && code.endsWith("ECONNREFUSED")) return true;
154
+ current = (current as { cause?: unknown }).cause;
155
+ }
156
+ return false;
157
+ }
158
+
159
+ /**
160
+ * A proxy killed by a native trap or SIGKILL never runs the exit cleanup that removes
161
+ * `ocx.pid` and `runtime-port.json` (only SIGINT/SIGTERM/SIGHUP and normal exit are
162
+ * wired to it), so both records outlive it. That makes "crashed" and "never started"
163
+ * distinguishable — and #1419 is what it costs when we discard the distinction: the
164
+ * reporter's unsupervised `ocx gui` proxy died and every later command said only
165
+ * "not running", never that a previous process had exited or that a service would
166
+ * have restarted it.
167
+ *
168
+ * Two races have to stay closed, because a false "it crashed" is worse than a missing
169
+ * hint. `handleStart` binds the port BEFORE it publishes either record, so:
170
+ *
171
+ * - a start that publishes between two reads is caught by comparing the raw records
172
+ * observed before and after the probes (the same snapshot discipline
173
+ * `removePidIfValueIs` uses for deletion);
174
+ * - a start that has bound but not yet published leaves both snapshots identical, so
175
+ * records alone cannot see it. That one is excluded on the port instead: the probe
176
+ * must have been REFUSED at connect, which is the only outcome proving nothing holds
177
+ * the port. A socket that is accepted and then reset — an in-flight bind — is not a
178
+ * refusal, so review caught `unreachable` being too broad for this job.
179
+ *
180
+ * What this can and cannot prove: the records outliving their process establish that the
181
+ * previous run did not complete its cleanup. It does not establish a cause, and it cannot
182
+ * fully exclude a clean exit whose `unlinkSync` failed, because cleanup ignores that
183
+ * error (`src/cli/index.ts:324-325`) and the records carry no session provenance. The
184
+ * wording therefore says the records remain and the run MAY have exited unexpectedly.
185
+ */
186
+ export function isUncleanExitEvidence(input: {
187
+ live: boolean;
188
+ healthOk: boolean;
189
+ healthRefused: boolean;
190
+ ownerPidAlive: boolean;
191
+ pidRecordBefore: number | null;
192
+ pidRecordAfter: number | null;
193
+ runtimePidBefore: number | null;
194
+ runtimePidAfter: number | null;
195
+ }): boolean {
196
+ if (input.live || input.healthOk) return false;
197
+ if (!input.healthRefused) return false;
198
+ if (input.ownerPidAlive) return false;
199
+ if (input.pidRecordBefore !== input.pidRecordAfter) return false;
200
+ if (input.runtimePidBefore !== input.runtimePidAfter) return false;
201
+ return input.pidRecordAfter !== null || input.runtimePidAfter !== null;
202
+ }
203
+
130
204
  /**
131
205
  * `ocx status` greens on process liveness alone, so a proxy that answers
132
206
  * /healthz reads healthy even when Codex is not pointed at it and every routed
@@ -171,15 +245,70 @@ async function checkProxyHealth(target: ListenTarget): Promise<HealthCheck> {
171
245
  return { ok: true, url, message, label: `${url} ${message}` };
172
246
  } catch (error) {
173
247
  const reason = proxyHealthFailureReason(error, controller.signal);
174
- return { ok: false, url, message: reason, label: `${url} ${reason}` };
248
+ return { ok: false, url, message: reason, label: `${url} ${reason}`, refused: isConnectionRefused(error) };
175
249
  } finally {
176
250
  clearTimeout(timer);
177
251
  }
178
252
  }
179
253
 
254
+ /**
255
+ * The ONE evidence gatherer for stale-process state, shared by `ocx status` and
256
+ * `ocx doctor`.
257
+ *
258
+ * It deliberately probes the port named by the STALE RECORD, not the configured display
259
+ * port. Review found the two commands disagreeing precisely here: a proxy that hopped to
260
+ * a fallback port, or a config whose port changed after the crash, left status probing
261
+ * the configured port while doctor probed the recorded one, so one reported a crash and
262
+ * the other did not. The question being asked is "is the process that wrote this record
263
+ * gone?", and only that record's own port can answer it.
264
+ *
265
+ * `live` short-circuits before the probe so a healthy install pays nothing.
266
+ */
267
+ export async function probeUncleanExitState(input: {
268
+ live: boolean;
269
+ port?: number;
270
+ hostname?: string | null;
271
+ }): Promise<boolean> {
272
+ if (input.live) return false;
273
+ const pidRecordBefore = readPidFileValue();
274
+ const runtimeBefore = readRuntimePort();
275
+ const runtimePidBefore = runtimeBefore?.pid ?? null;
276
+ if (pidRecordBefore === null && runtimePidBefore === null) return false;
277
+ const ownerPid = pidRecordBefore ?? runtimePidBefore;
278
+ if (ownerPid !== null && isProcessAlive(ownerPid)) return false;
279
+ // The recorded port is the evidence target. Fall back to the configured port only when
280
+ // no runtime record exists, which is the pid-file-only case.
281
+ const port = runtimeBefore?.port ?? input.port ?? 10100;
282
+ const hostname = runtimeBefore?.hostname ?? input.hostname ?? undefined;
283
+ const health = await checkProxyHealth({
284
+ port,
285
+ hostname,
286
+ source: runtimeBefore ? "runtime" : "config",
287
+ healthUrl: `http://${probeHostname(hostname)}:${port}/healthz`,
288
+ dashboardUrl: `http://localhost:${port}/`,
289
+ });
290
+ const pidRecordAfter = readPidFileValue();
291
+ const runtimePidAfter = readRuntimePort()?.pid ?? null;
292
+ const ownerPidAfter = pidRecordAfter ?? runtimePidAfter;
293
+ return isUncleanExitEvidence({
294
+ live: false,
295
+ healthOk: health.ok,
296
+ healthRefused: health.refused === true,
297
+ ownerPidAlive: ownerPidAfter !== null && isProcessAlive(ownerPidAfter),
298
+ pidRecordBefore,
299
+ pidRecordAfter,
300
+ runtimePidBefore,
301
+ runtimePidAfter,
302
+ });
303
+ }
304
+
180
305
  export async function collectStatus(): Promise<CliStatusView> {
181
306
  const configDiagnostics = readConfigDiagnostics();
182
307
  const config = configDiagnostics.config;
308
+ const claudeDesktop = {
309
+ desiredEnabled: claudeDesktopIntegrationEnabled(config),
310
+ policy: claudeDesktopPolicyHealth(probeClaudeDesktopPolicy()),
311
+ };
183
312
  // Prefer identity-verified liveness (runtime-port + /healthz) over ocx.pid alone (#618).
184
313
  // Pass the already-resolved diagnostics config so findLiveProxy does not re-load and
185
314
  // warn on malformed config.json (status --json must stay stderr-clean).
@@ -210,6 +339,13 @@ export async function collectStatus(): Promise<CliStatusView> {
210
339
  label: `${listen.healthUrl} ok (live)`,
211
340
  }
212
341
  : await checkProxyHealth(listen);
342
+ // Same gatherer `ocx doctor` uses, so the two commands cannot reach different verdicts
343
+ // about the same on-disk state (review found them diverging on fallback ports).
344
+ const staleProcessState = await probeUncleanExitState({
345
+ live: Boolean(live),
346
+ port: config.port,
347
+ hostname: config.hostname,
348
+ });
213
349
  const bunRuntime = durableBunRuntime();
214
350
  const service = diagnoseService();
215
351
  // A service can be registered and still not serve: the manager reports the job
@@ -326,6 +462,7 @@ export async function collectStatus(): Promise<CliStatusView> {
326
462
  proxy: {
327
463
  running: Boolean(live) || Boolean(pid && health.ok),
328
464
  pid: live?.pid ?? pid,
465
+ staleProcessState,
329
466
  health: {
330
467
  ok: health.ok,
331
468
  url: health.url,
@@ -359,6 +496,7 @@ export async function collectStatus(): Promise<CliStatusView> {
359
496
  codexPlugins,
360
497
  codexRuntime,
361
498
  codexHome,
499
+ claudeDesktop,
362
500
  // Own field rather than a line in `codexRuntime.warning`: a stale ocx on PATH is a
363
501
  // fact about this install, not about the Codex runtime, and filing it there would
364
502
  // print it under the wrong heading (#2701).
@@ -173,7 +173,16 @@ async function policy(argv: string[], deps: RuntimeApiDeps): Promise<void> {
173
173
  }
174
174
  const body: Record<string, unknown> = {};
175
175
  if (enabled !== undefined) body.enabled = enabled === "true";
176
- if (percent !== undefined) body.percent = percent;
176
+ // The policy target is nested. A top-level `percent` is not part of the PUT contract:
177
+ // `normalizeStorageCleanupPolicy` reads only `target`, so the field was dropped and the
178
+ // previously stored target survived. `--percent 10` on a policy still holding the
179
+ // default 25% therefore reported success while leaving cleanup authorized to delete
180
+ // more than the operator asked for.
181
+ //
182
+ // An out-of-range value is deliberately still sent: the server owns the 1-100
183
+ // vocabulary and answers with a named 400, which is a rejected write rather than the
184
+ // silent wrong write this replaces.
185
+ if (percent !== undefined) body.target = { removeOldestPercent: percent };
177
186
  if (mode !== undefined) body.mode = mode;
178
187
  if (schedule !== undefined) body.schedule = schedule;
179
188
  if (Object.keys(body).length === 0) {
@@ -1,18 +1,43 @@
1
1
  import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
2
+ import { isCodexAccountGenerationLive } from "./account-store";
2
3
 
3
- const reauthAccounts = new Set<string>();
4
+ /**
5
+ * Accounts quarantined for reauthentication, each remembering WHICH credential produced the
6
+ * evidence (#2892 gap 4).
7
+ *
8
+ * A 401 describes one credential, not an account. Recording only the id let a 401 raced by a
9
+ * cross-process credential replacement quarantine the replacement: the flag outlived the credential
10
+ * it was evidence about, and routing then refused a perfectly good credential until a restart. A
11
+ * post-write re-read cannot fix that — the replacement may land at any point after the write — so
12
+ * the generation travels WITH the flag and is checked when the flag is read.
13
+ *
14
+ * `undefined` means "no credential generation was supplied", which stays account-wide: callers such
15
+ * as a login flow have no specific credential in hand, and their quarantine must not silently expire.
16
+ */
17
+ const reauthAccounts = new Map<string, number | undefined>();
4
18
  let lastReconciledGeneration = 0;
5
19
  let liveAccountIds = new Set<string>();
6
20
 
7
- export function markAccountNeedsReauth(id: string, writerGeneration = captureConfigGeneration()): void {
21
+ export function markAccountNeedsReauth(
22
+ id: string,
23
+ writerGeneration = captureConfigGeneration(),
24
+ credentialGeneration?: number,
25
+ ): void {
8
26
  if (writerGeneration < lastReconciledGeneration && !liveAccountIds.has(id)) return;
9
- reauthAccounts.add(id);
27
+ // An account-wide mark supersedes a generation-scoped one: it is the stronger claim.
28
+ if (credentialGeneration === undefined || !reauthAccounts.has(id)) {
29
+ reauthAccounts.set(id, credentialGeneration);
30
+ return;
31
+ }
32
+ const existing = reauthAccounts.get(id);
33
+ if (existing === undefined) return;
34
+ reauthAccounts.set(id, Math.max(existing, credentialGeneration));
10
35
  }
11
36
 
12
37
  export function reconcileCodexReauthState(context: GenerationContext): number {
13
38
  if (context.generation <= lastReconciledGeneration) return 0;
14
39
  let removed = 0;
15
- for (const id of reauthAccounts) {
40
+ for (const id of [...reauthAccounts.keys()]) {
16
41
  if (context.codexAccountIds.has(id)) continue;
17
42
  reauthAccounts.delete(id);
18
43
  removed += 1;
@@ -23,7 +48,16 @@ export function reconcileCodexReauthState(context: GenerationContext): number {
23
48
  }
24
49
 
25
50
  export function isAccountNeedsReauth(id: string): boolean {
26
- return reauthAccounts.has(id);
51
+ if (!reauthAccounts.has(id)) return false;
52
+ const credentialGeneration = reauthAccounts.get(id);
53
+ if (credentialGeneration === undefined) return true;
54
+ // The credential this evidence describes is gone, so the evidence is spent. Drop it rather than
55
+ // re-deriving the same answer on every read.
56
+ if (!isCodexAccountGenerationLive(id, credentialGeneration)) {
57
+ reauthAccounts.delete(id);
58
+ return false;
59
+ }
60
+ return true;
27
61
  }
28
62
 
29
63
  export function clearAccountNeedsReauth(id: string): void {