@bitkyc08/opencodex 2.68.0-preview.20260927 → 2.69.0-preview.20260928

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 (175) hide show
  1. package/bin/ocx.mjs +25 -17
  2. package/gui/dist/assets/App-cDPyCb_V.js +51 -0
  3. package/gui/dist/assets/{Tray-DFnMiyD0.js → Tray-H1IAB8mN.js} +1 -1
  4. package/gui/dist/assets/index-BF9QnKO_.css +1 -0
  5. package/gui/dist/assets/index-CI6YTp8z.js +86 -0
  6. package/gui/dist/assets/{quota-summary-i84eOU3o.js → quota-summary-CutOlPu2.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/factory-droid.svg +8 -0
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-output-schema.ts +109 -11
  11. package/src/adapters/coding-agent/protocol.ts +106 -11
  12. package/src/adapters/coding-agent/turn.ts +23 -18
  13. package/src/adapters/google-errors.ts +16 -1
  14. package/src/adapters/google-http.ts +14 -0
  15. package/src/adapters/openai-chat/passthrough.ts +2 -0
  16. package/src/adapters/openai-chat/summary-budget.ts +72 -0
  17. package/src/adapters/openai-chat/tool-call-id-remint.ts +11 -2
  18. package/src/adapters/openai-chat.ts +6 -9
  19. package/src/bridge/response-json.ts +3 -1
  20. package/src/bridge/sse.ts +3 -1
  21. package/src/claude/desktop-picker.ts +6 -2
  22. package/src/claude/desktop-system-proxy.ts +155 -0
  23. package/src/claude/inbound-model-options.ts +10 -2
  24. package/src/claude/inbound.ts +1 -1
  25. package/src/claude/intercept/picker-ca-cleanup.ts +31 -0
  26. package/src/claude/intercept/picker-ca.ts +283 -83
  27. package/src/claude/intercept/picker-models.ts +4 -1
  28. package/src/claude/intercept/picker-trust.ts +15 -2
  29. package/src/claude/intercept/runtime.ts +99 -32
  30. package/src/cli/account-api.ts +5 -2
  31. package/src/cli/account-extended.ts +47 -11
  32. package/src/cli/account-target.ts +12 -7
  33. package/src/cli/account.ts +47 -3
  34. package/src/cli/capabilities.ts +33 -7
  35. package/src/cli/claude-agent-startup-sync.ts +2 -0
  36. package/src/cli/claude-desktop.ts +37 -13
  37. package/src/cli/cross-home-owner.ts +138 -0
  38. package/src/cli/dispatch.ts +1 -1
  39. package/src/cli/doctor.ts +18 -8
  40. package/src/cli/ensure-desired-integrations.ts +5 -0
  41. package/src/cli/export-command.ts +6 -6
  42. package/src/cli/help.ts +1 -1
  43. package/src/cli/index.ts +15 -18
  44. package/src/cli/integrations.ts +8 -1
  45. package/src/cli/models-runtime-subcommands.ts +1 -0
  46. package/src/cli/models-runtime.ts +106 -4
  47. package/src/cli/models.ts +6 -1
  48. package/src/cli/opencode.ts +2 -83
  49. package/src/cli/registry.ts +3 -2
  50. package/src/cli/status.ts +86 -7
  51. package/src/client/connect.ts +24 -10
  52. package/src/client/link-ingress.ts +3 -1
  53. package/src/client/link-join.ts +76 -9
  54. package/src/client/link-relay-transport.ts +184 -0
  55. package/src/client/link-relay.ts +16 -4
  56. package/src/client/machine-listener.ts +2 -0
  57. package/src/clients/config-export/constants.ts +8 -0
  58. package/src/clients/config-export/contracts.ts +13 -1
  59. package/src/clients/config-export/droid.ts +71 -0
  60. package/src/clients/config-export/kilo.ts +116 -0
  61. package/src/clients/config-export.ts +33 -2
  62. package/src/codex/auth-api/routes.ts +1 -1
  63. package/src/codex/catalog/access-programs.ts +14 -2
  64. package/src/codex/catalog/provider-models.ts +1 -1
  65. package/src/codex/catalog-auto-refresh.ts +122 -0
  66. package/src/codex/config-drift-heal.ts +80 -0
  67. package/src/codex/desktop-switches.ts +7 -5
  68. package/src/codex/inject/bounded-config-reader.ts +69 -0
  69. package/src/codex/inject/config-toml.ts +31 -3
  70. package/src/codex/inject/routing-target.ts +6 -1
  71. package/src/codex/inject.ts +4 -0
  72. package/src/codex/model-entitlements.ts +32 -5
  73. package/src/codex/native-profile-startup.ts +17 -5
  74. package/src/codex/project-config-warnings.ts +72 -21
  75. package/src/codex/quota-auto-refresh-state.ts +8 -2
  76. package/src/codex/quota-auto-refresh.ts +80 -9
  77. package/src/codex/sync.ts +10 -0
  78. package/src/combos/failover.ts +4 -1
  79. package/src/combos/jev.ts +15 -4
  80. package/src/combos/types.ts +16 -0
  81. package/src/config/diagnostics.ts +19 -1
  82. package/src/config/load-degrade.ts +29 -0
  83. package/src/config/macos-system-proxy.ts +114 -0
  84. package/src/config/proxy-env.ts +67 -20
  85. package/src/config/schema/config-schema.ts +15 -0
  86. package/src/config/schema/leaf-validators.ts +23 -0
  87. package/src/config.ts +2 -0
  88. package/src/generated/compatibility-version.json +220 -144
  89. package/src/integrations/catalog-refresh.ts +1 -1
  90. package/src/integrations/config-io.ts +12 -3
  91. package/src/integrations/droid-settings.ts +65 -0
  92. package/src/integrations/kilo-candidates.ts +30 -0
  93. package/src/integrations/mutation-plan.ts +62 -7
  94. package/src/integrations/native/ownership-preflight.ts +46 -12
  95. package/src/integrations/registry.ts +136 -7
  96. package/src/integrations/state.ts +52 -4
  97. package/src/integrations/target.ts +3 -0
  98. package/src/integrations/writer.ts +55 -14
  99. package/src/lib/account-selection-events.ts +14 -0
  100. package/src/lib/jsonc.ts +110 -0
  101. package/src/lib/standalone.ts +9 -1
  102. package/src/lib/windows-system-proxy.ts +101 -1
  103. package/src/link/relay-auth.ts +29 -0
  104. package/src/link/ssh-argv.ts +6 -3
  105. package/src/link/ssh-runner.ts +5 -5
  106. package/src/oauth/generic-account-failover.ts +52 -8
  107. package/src/oauth/index.ts +37 -14
  108. package/src/oauth/store.ts +96 -11
  109. package/src/oauth/token-guardian.ts +7 -3
  110. package/src/oauth/types.ts +2 -0
  111. package/src/plugins/loader.ts +5 -3
  112. package/src/providers/fastwire.ts +3 -0
  113. package/src/providers/key-failover.ts +13 -5
  114. package/src/providers/kiro-model-catalog.ts +4 -3
  115. package/src/providers/model-rename-fields.ts +1 -0
  116. package/src/providers/openai-tiers-destination.ts +9 -0
  117. package/src/providers/quota/account-cache.ts +17 -3
  118. package/src/providers/quota/vendor-probes-oauth.ts +2 -0
  119. package/src/providers/quota.ts +2 -2
  120. package/src/responses/code-mode-helper-compat.ts +14 -1
  121. package/src/responses/custom-tool-compat.ts +1 -1
  122. package/src/server/auth-cors.ts +1 -0
  123. package/src/server/images.ts +70 -20
  124. package/src/server/index/link-listener.ts +20 -3
  125. package/src/server/index/link-relay-sessions.ts +138 -0
  126. package/src/server/index/optional-listeners.ts +11 -1
  127. package/src/server/index/serve-options.ts +12 -0
  128. package/src/server/inference/client-encoder-delivery.ts +1 -0
  129. package/src/server/local-management-read-client.ts +21 -2
  130. package/src/server/management/agent-settings-routes.ts +19 -5
  131. package/src/server/management/combo-routes.ts +5 -3
  132. package/src/server/management/config-routes.ts +32 -6
  133. package/src/server/management/model-routes.ts +232 -7
  134. package/src/server/management/model-rows.ts +199 -1
  135. package/src/server/management/oauth-account-routes.ts +63 -3
  136. package/src/server/management/route-registry.ts +2 -0
  137. package/src/server/port-reclaim.ts +170 -32
  138. package/src/server/responses/adapter-delivery.ts +6 -4
  139. package/src/server/responses/adapter-dispatch.ts +72 -2
  140. package/src/server/responses/core-combo.ts +6 -1
  141. package/src/server/responses/core-normalize.ts +19 -4
  142. package/src/server/responses/core-options.ts +2 -0
  143. package/src/server/responses/memory-models.ts +169 -0
  144. package/src/server/responses/passthrough-delivery.ts +4 -0
  145. package/src/server/responses/passthrough-dispatch.ts +10 -0
  146. package/src/server/responses/request-prepare.ts +86 -4
  147. package/src/server/responses/request-transport.ts +4 -0
  148. package/src/server/responses/run-turn-execution.ts +5 -2
  149. package/src/server/responses/shadow-target-availability.ts +17 -4
  150. package/src/server/responses/sidecar-execution.ts +18 -2
  151. package/src/server/responses/skills-snapshot.ts +239 -0
  152. package/src/server/responses-undeclared-tool-guard.ts +43 -6
  153. package/src/server/search.ts +1 -1
  154. package/src/service/guards.ts +4 -5
  155. package/src/service/state.ts +39 -2
  156. package/src/service.ts +2 -2
  157. package/src/types/config.ts +46 -4
  158. package/src/types/provider.ts +9 -0
  159. package/src/types/request.ts +6 -0
  160. package/src/types/tools.ts +38 -8
  161. package/src/types.ts +3 -0
  162. package/src/update/async-check.ts +5 -1
  163. package/src/update/index.ts +30 -12
  164. package/src/update/job.ts +3 -1
  165. package/src/update/pnpm-read-policy.d.mts +4 -0
  166. package/src/update/pnpm-read-policy.mjs +44 -0
  167. package/src/update/worker-launch.ts +152 -11
  168. package/src/usage/cost.ts +6 -3
  169. package/src/usage/log.ts +4 -0
  170. package/src/web-search/backends.ts +2 -2
  171. package/src/web-search/devin-executor.ts +1 -1
  172. package/src/web-search/sidecar-providers.ts +5 -5
  173. package/gui/dist/assets/App-B5-cAEd2.js +0 -51
  174. package/gui/dist/assets/index-BDUBS8PW.css +0 -1
  175. package/gui/dist/assets/index-BcXblvem.js +0 -86
@@ -0,0 +1,138 @@
1
+ /** Best-effort discovery of a live proxy named by shared, OpenCodex-managed client state. */
2
+ import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { getConfigDir } from "../config/paths";
6
+ import { readRuntimePort } from "../config/process-state";
7
+ import { getCodexHome } from "../codex/paths";
8
+ import { detectCodexRoutingDrift } from "../codex/routing-drift";
9
+ import { readBoundedCodexConfig } from "../codex/inject/bounded-config-reader";
10
+ import { currentExternalCodexModelProvider } from "../codex/inject";
11
+ import { reconcileJournal } from "../codex/journal";
12
+ import { markSiblingStart, siblingOfLivePort } from "../codex/sibling-start";
13
+ import { readClientConnectionState } from "../client/state";
14
+ import { findManagedRegion, resolveGrokHome } from "../grok/inject";
15
+ import { providerTableString } from "../codex/injected-marker";
16
+ import { probePortOwner, START_OWNERSHIP_LIVENESS } from "../server/proxy-liveness";
17
+
18
+ const MAX_HINT_BYTES = 256 * 1024;
19
+ // Far above any real Grok config; discovery must not stall startup when Grok sync is off.
20
+ const MAX_GROK_CONFIG_BYTES = 16 * 1024 * 1024;
21
+
22
+ /** Nonblocking open (a FIFO cannot stall startup), regular files only, capped at maxBytes. */
23
+ function readBoundedRegularFile(path: string, maxBytes: number): string | null {
24
+ let fd: number | undefined;
25
+ try {
26
+ fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
27
+ const stat = fstatSync(fd);
28
+ if (!stat.isFile() || stat.size > maxBytes) return null;
29
+ const bytes = Buffer.alloc(stat.size + 1);
30
+ let count = 0;
31
+ while (count < bytes.length) {
32
+ const next = readSync(fd, bytes, count, bytes.length - count, count);
33
+ if (next === 0) break;
34
+ count += next;
35
+ }
36
+ return count > maxBytes || count > stat.size ? null : bytes.toString("utf8", 0, count);
37
+ } catch {
38
+ return null;
39
+ } finally {
40
+ if (fd !== undefined) closeSync(fd);
41
+ }
42
+ }
43
+
44
+ function validPort(value: unknown): value is number {
45
+ return Number.isInteger(value) && Number(value) > 0 && Number(value) <= 65535;
46
+ }
47
+
48
+ function loopbackPort(raw: string | null): number | null {
49
+ if (!raw) return null;
50
+ try {
51
+ const url = new URL(raw);
52
+ if (!url.port || !["http:", "https:"].includes(url.protocol)) return null;
53
+ const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
54
+ if (!["127.0.0.1", "localhost", "::1"].includes(host)) return null;
55
+ const port = Number(url.port);
56
+ return validPort(port) ? port : null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /** Returns only a different process with an identity-checked /healthz response. */
63
+ export async function findCrossHomeOwner(options: { homeDir?: string } = {}): Promise<number | null> {
64
+ const candidates = new Set<number>();
65
+ const defaultHome = join(options.homeDir ?? homedir(), ".opencodex");
66
+ if (resolve(getConfigDir()) !== resolve(defaultHome)) {
67
+ const raw = readBoundedRegularFile(join(defaultHome, "runtime-port.json"), MAX_HINT_BYTES);
68
+ if (raw) {
69
+ try {
70
+ const record: unknown = JSON.parse(raw);
71
+ if (record && typeof record === "object" && validPort((record as { port?: unknown }).port)) {
72
+ candidates.add((record as { port: number }).port);
73
+ }
74
+ } catch { /* stale or malformed hint */ }
75
+ }
76
+ }
77
+
78
+ // Grok's writer reads its config in full; cap discovery separately so startup stays bounded.
79
+ const grok = readBoundedRegularFile(join(resolveGrokHome(), "config.toml"), MAX_GROK_CONFIG_BYTES);
80
+ const region = grok === null ? null : findManagedRegion(grok);
81
+ if (grok && region && !region.orphaned) {
82
+ const port = loopbackPort(providerTableString(grok.slice(region.start, region.end), "opencodex", "base_url"));
83
+ if (port !== null) candidates.add(port);
84
+ }
85
+
86
+ try {
87
+ const codex = readBoundedCodexConfig(join(getCodexHome(), "config.toml"));
88
+ if (codex) {
89
+ const drift = detectCodexRoutingDrift(codex, { ownPorts: [] });
90
+ if (drift.kind === "foreign") {
91
+ for (const target of drift.targets) {
92
+ // Every drift target is owned, loopback, and has an explicit port (including Design B roots).
93
+ candidates.add(target.port);
94
+ }
95
+ }
96
+ }
97
+ } catch { /* an absent or invalid client home is not owner evidence */ }
98
+
99
+ for (const port of candidates) {
100
+ const owner = await probePortOwner(port, {}, START_OWNERSHIP_LIVENESS);
101
+ if (owner && Number.isSafeInteger(owner.pid) && owner.pid! > 0 && owner.pid !== process.pid) return port;
102
+ }
103
+ return null;
104
+ }
105
+
106
+ /** Mark this process before any shared-client write when another home owns the clients. */
107
+ export async function markCrossHomeSibling(): Promise<boolean> {
108
+ const port = await findCrossHomeOwner();
109
+ if (port === null) return false;
110
+ markSiblingStart(port);
111
+ return true;
112
+ }
113
+
114
+ /** A live proxy in this home can publish its sibling owner even while that owner is down. */
115
+ export async function markLiveHomeSibling(live: { pid: number | null; port: number }): Promise<boolean> {
116
+ const runtime = readRuntimePort();
117
+ if (live.pid !== null && runtime?.pid === live.pid && runtime.port === live.port
118
+ && runtime.siblingOfPort !== undefined && runtime.siblingOfPort !== live.port) {
119
+ markSiblingStart(runtime.siblingOfPort);
120
+ return true;
121
+ }
122
+ const otherPort = await findCrossHomeOwner();
123
+ if (otherPort === null || otherPort === live.port) return false;
124
+ markSiblingStart(otherPort);
125
+ return true;
126
+ }
127
+
128
+ /**
129
+ * Recovery follows the full owner decision; a sibling never replays another home's journal.
130
+ * A marked sibling's owner can be down mid-restart; its journal is still not ours to replay.
131
+ */
132
+ export function reconcileStartupJournal(): void {
133
+ if (currentExternalCodexModelProvider() || siblingOfLivePort() !== null) return;
134
+ const clientState = readClientConnectionState();
135
+ reconcileJournal(clientState.kind === "connected"
136
+ ? { activeClientApiKeyId: clientState.value.apiKeyId }
137
+ : undefined);
138
+ }
@@ -512,7 +512,7 @@ const commandRunners: Record<string, CommandRunner> = {
512
512
  },
513
513
  config,
514
514
  port: live.port,
515
- }, ["mcode", "pi", "raycast", "omo", "cline"]));
515
+ }, ["mcode", "pi", "raycast", "omo", "cline", "droid"]));
516
516
  } catch (error) {
517
517
  console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`);
518
518
  }
package/src/cli/doctor.ts CHANGED
@@ -12,7 +12,7 @@ import { homedir } from "node:os";
12
12
  import { dirname, join } from "node:path";
13
13
  import { getConfigDir, getConfigPath, readConfigDiagnostics } from "../config";
14
14
  import { readPid } from "../config/process-state";
15
- import { probeUncleanExitState } from "./status";
15
+ import { fetchLiveStartupHealth, probeUncleanExitState, selectStatusStartupHealth } from "./status";
16
16
  import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
17
17
  import { directLocalHttpFetch } from "../server/direct-local-http";
18
18
  import { BUN_RUNTIME_SOURCES } from "../lib/bun-runtime";
@@ -61,6 +61,7 @@ import {
61
61
  formatLegacyCodexConfigKeyDiagnosticsForDoctor,
62
62
  } from "../codex/legacy-config-keys";
63
63
  import { collectStartupHealth, formatStartupRoutingDetail, startupHealthSummary } from "../codex/autostart-health";
64
+ import { collectDesktopSystemProxy, formatDesktopSystemProxyLines } from "../claude/desktop-system-proxy";
64
65
  import {
65
66
  displayCodexRuntimePath,
66
67
  effortClampAppliesToRuntime,
@@ -1354,7 +1355,14 @@ export async function runDoctor(args: string[] = []): Promise<void> {
1354
1355
  diagnoseCodexShim(),
1355
1356
  serviceTokenPresent,
1356
1357
  );
1357
- const startup = collectStartupHealth(doctorConfig);
1358
+ // Use the same attested live startup verdict as `ocx status` when the proxy is already
1359
+ // identity-verified. A shell-local systemd probe can be a false negative for a system-wide
1360
+ // service because the shell does not inherit the manager-owned environment.
1361
+ const live = await findLiveProxy({
1362
+ configFn: () => ({ port: doctorConfig.port, hostname: doctorConfig.hostname }),
1363
+ });
1364
+ const liveStartup = live ? await fetchLiveStartupHealth(live) : null;
1365
+ const startup = selectStatusStartupHealth(liveStartup, () => collectStartupHealth(doctorConfig));
1358
1366
  console.log("\nCodex restart safety");
1359
1367
  console.log(` ${startup.rebootSafe ? "ok " : "!! "} ${startupHealthSummary(startup)}`);
1360
1368
  console.log(` ${formatStartupRoutingDetail(startup)}`);
@@ -1393,12 +1401,6 @@ export async function runDoctor(args: string[] = []): Promise<void> {
1393
1401
  }
1394
1402
  }
1395
1403
 
1396
- // #618: identity-verified liveness first so pid-file absence does not hide a live service.
1397
- // Reuse the diagnostics config already loaded above so doctor stays read-only on malformed JSON.
1398
- const live = await findLiveProxy({
1399
- configFn: () => ({ port: doctorConfig.port, hostname: doctorConfig.hostname }),
1400
- });
1401
-
1402
1404
  // Mirrors `ocx status` through the same comparison rather than a second implementation:
1403
1405
  // two diagnostics disagreeing about whether an install is stale is worse than one (#2701).
1404
1406
  // No extra probe -- findLiveProxy already carried the version back.
@@ -1427,6 +1429,14 @@ export async function runDoctor(args: string[] = []): Promise<void> {
1427
1429
  console.log("\nConfigured proxy (value hidden)");
1428
1430
  console.log(` ${configuredProxy.present ? "set " : "unset "} ${configuredProxy.key} (${configuredProxy.source}; ${configuredProxy.detail})`);
1429
1431
 
1432
+ // Observe-only and warning-level: a bypassed Desktop first-party install still serves the
1433
+ // CLI and every non-Desktop client, so this never records a doctor failure.
1434
+ const desktopSystemProxyLines = formatDesktopSystemProxyLines(collectDesktopSystemProxy(doctorConfig));
1435
+ if (desktopSystemProxyLines.length > 0) {
1436
+ console.log("\nClaude Desktop first-party vs Windows system proxy");
1437
+ for (const line of desktopSystemProxyLines) console.log(line);
1438
+ }
1439
+
1430
1440
  const providerApiKeys = collectProviderApiKeyDiagnostics(doctorConfig.providers);
1431
1441
  console.log("\nProvider API keys (value hidden)");
1432
1442
  if (providerApiKeys.length === 0) {
@@ -30,6 +30,7 @@ import {
30
30
  shouldSyncGrokOnStart,
31
31
  } from "../codex/desired-state";
32
32
  import type { OcxConfig } from "../types";
33
+ import { siblingOfLivePort, siblingSkipMessage } from "../codex/sibling-start";
33
34
 
34
35
  export function grokSyncFailureMessage(err: unknown): string {
35
36
  const detail = err instanceof Error ? err.message : String(err);
@@ -101,6 +102,10 @@ export async function ensureGrokFenceMatchesDesired(
101
102
  ): Promise<void> {
102
103
  const config = deps.loadConfig();
103
104
  const { log, error } = io(deps);
105
+ if (siblingOfLivePort() !== null) {
106
+ log(` ${siblingSkipMessage()} ~/.grok/config.toml was left exactly as it is.`);
107
+ return;
108
+ }
104
109
  // A hub-gated skip is NOT "the user turned Grok off" (#4236). Stripping the managed block
105
110
  // there deleted a fence the operator still wants — and `ocx ensure` reported it as the
106
111
  // Grok toggle doing its job. Only an explicit OFF authorizes the strip; the gate just
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * `ocx export --client <id>` — print a client config for the live proxy.
3
3
  *
4
- * Fourteen clients, five formats. The accepted list is `EXPORT_CLIENT_IDS`, not
5
- * this comment: OpenCode, Pi, Prime, Aside, ZCode and omo are JSON; OMP,
4
+ * The accepted clients span five formats. The accepted list is `EXPORT_CLIENT_IDS`, not
5
+ * this comment: OpenCode, Pi, Prime, Aside, ZCode, omo and Kilo are JSON; OMP,
6
6
  * Hermes, gjc, DSH, MiniMax Code and Raycast are YAML; OpenClaw is JSON5; Kimi
7
7
  * is TOML.
8
8
  *
@@ -159,16 +159,16 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep
159
159
  const spec = EXPORT_CLIENTS[client];
160
160
  const root = await runtimeBaseUrl(deps);
161
161
  let built: { document: unknown; text: string };
162
- if (client === "raycast") {
162
+ if (client === "raycast" || client === "droid") {
163
163
  // The dial address alone cannot distinguish a wildcard authenticated bind
164
164
  // from loopback. Let the live server resolve its admission/listener policy;
165
165
  // saved config can differ from the process serving this request.
166
166
  const exported = await runtimeRequest<{
167
167
  client: string; format: string; config: unknown; text: string;
168
- }>("/api/client-config?client=raycast", {}, { ...deps, baseUrl: root });
169
- if (!exported || exported.client !== "raycast" || exported.format !== "yaml"
168
+ }>(`/api/client-config?client=${client}`, {}, { ...deps, baseUrl: root });
169
+ if (!exported || exported.client !== client || exported.format !== spec.format
170
170
  || typeof exported.text !== "string" || exported.config === undefined) {
171
- throw new RuntimeApiError("Management API returned an unexpected Raycast export payload.", 502, null);
171
+ throw new RuntimeApiError(`Management API returned an unexpected ${client} export payload.`, 502, null);
172
172
  }
173
173
  built = { document: exported.config, text: exported.text };
174
174
  } else {
package/src/cli/help.ts CHANGED
@@ -83,7 +83,7 @@ Usage:
83
83
  ocx api-key <sub> Alias of ocx access key
84
84
  ocx access <sub> External API keys and endpoint information
85
85
  ocx api <sub> Protocol paths: vocabulary, request-path preview, and policy
86
- ocx export --client <id> Print a client config wired to the running proxy (15 clients)
86
+ ocx export --client <id> Print a client config wired to the running proxy (17 clients)
87
87
  ocx integration client <sub> Enable, disable, inspect or roll back a client integration
88
88
  ocx grok <sub> Grok Build model selection and apply
89
89
  ocx system <sub> Runtime settings, startup, sync, OpenCodex updates, and Codex CLI inspection
package/src/cli/index.ts CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  resolveCodexHistoryJobTarget,
39
39
  runCodexHistoryJob,
40
40
  } from "../codex/history-job";
41
- import { reconcileJournal } from "../codex/journal";
41
+ import { findCrossHomeOwner, markCrossHomeSibling, markLiveHomeSibling, reconcileStartupJournal } from "./cross-home-owner";
42
42
  import { inspectClientRotationRecoveryGate, readClientConnectionState } from "../client/state";
43
43
  import {
44
44
  codexAutoStartEnabled,
@@ -355,24 +355,15 @@ async function findProxyOwnerBeforeJournalRecovery(
355
355
  const pidSnapshot = readPidFileValue();
356
356
  const hasRuntimeOwner = readRuntimePort() !== null;
357
357
  const shouldProbe = pidSnapshot !== null || hasRuntimeOwner || options.probeConfiguredPort === true;
358
- // A negative answer here is acted on twice over: the caller walks past a proxy it was
359
- // supposed to find, and the lines below delete this home's pid record and reconcile the
360
- // journal. One 750ms probe is not enough evidence for either (#5004) — a transport
358
+ // A negative answer lets the caller walk past a proxy it was supposed to find and
359
+ // deletes this home's stale pid record. Journal recovery follows cross-home discovery.
360
+ // One 750ms probe is not enough evidence for that (#5004) — a transport
361
361
  // failure is indistinguishable from an empty port, and the reported Windows duplicate
362
362
  // came from exactly that answer on a proxy the previous command had just found healthy.
363
363
  const live = shouldProbe ? await findLiveProxy(START_OWNERSHIP_LIVENESS) : null;
364
364
  if (live) return { live, pidSnapshot };
365
365
 
366
- // The probe established that the snapshotted owner is stale. Compare before
367
- // deleting so a concurrent start that rewrote the PID file keeps its state.
368
366
  removePidIfValueIs(pidSnapshot);
369
- // A marked sibling's owner can be down mid-restart; its journal is still not ours to replay.
370
- if (!currentExternalCodexModelProvider() && siblingOfLivePort() === null) {
371
- const clientState = readClientConnectionState();
372
- reconcileJournal(clientState.kind === "connected"
373
- ? { activeClientApiKeyId: clientState.value.apiKeyId }
374
- : undefined);
375
- }
376
367
  return { live: null, pidSnapshot };
377
368
  }
378
369
 
@@ -413,8 +404,7 @@ async function handleStart(options: { block?: boolean } = {}) {
413
404
  }
414
405
  }
415
406
  const requestedPort = startOpts.port;
416
- // Probe the configured port even without state files: a fallback sibling can remove them,
417
- // and an unprobed start could shadow the owner and reroute Codex to a short-lived port.
407
+ // Probe the configured port even without state files: a fallback sibling can remove them.
418
408
  // Consume a sibling replacement's handoff before probing, even if its owner is momentarily down.
419
409
  let siblingStart = honorSiblingMarker(process.env, consumeSiblingHandoff) !== null;
420
410
  // A restart replacement waits out its draining parent instead of refusing it, and bounds its handoff log (restart-handoff.ts).
@@ -448,6 +438,8 @@ async function handleStart(options: { block?: boolean } = {}) {
448
438
  + `Startup continues only for an independent OPENCODEX_HOME; one state directory has one spend-ledger writer.`,
449
439
  );
450
440
  }
441
+ if (!owner.live && !siblingStart) siblingStart = await markCrossHomeSibling();
442
+ if (!siblingStart) reconcileStartupJournal();
451
443
 
452
444
  const clientState = readClientConnectionState();
453
445
  if (clientState.kind === "invalid" || clientState.kind === "mismatched") {
@@ -504,6 +496,7 @@ async function handleStart(options: { block?: boolean } = {}) {
504
496
  siblingStart = true;
505
497
  markSiblingStart(fencedLive.port);
506
498
  }
499
+ if (!fencedLive && !siblingStart) siblingStart = await markCrossHomeSibling();
507
500
 
508
501
  // Port selection is check-then-bind. The lease prevents every cooperating start or
509
502
  // updater from turning that check into a different ownership decision.
@@ -733,6 +726,7 @@ async function handleStart(options: { block?: boolean } = {}) {
733
726
  // to explain it. Name the failure and the one command that repairs it.
734
727
  console.error(`⚠️ ${grokSyncFailureMessage(err)}`);
735
728
  }
729
+ console.log("Client startup work complete.");
736
730
  if (options.block ?? true) {
737
731
  setInterval(() => {}, 60_000);
738
732
  await new Promise<void>(() => {});
@@ -749,6 +743,9 @@ function detachedStartEnvironment(): NodeJS.ProcessEnv {
749
743
 
750
744
  async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Promise<boolean> {
751
745
  const owner = await findProxyOwnerBeforeJournalRecovery({ probeConfiguredPort: true });
746
+ if (!owner.live && !(await markCrossHomeSibling())) reconcileStartupJournal();
747
+ // A later ensure can find this home's sibling alive; the other port still owns shared clients.
748
+ if (owner.live) await markLiveHomeSibling(owner.live);
752
749
  const config = loadConfig();
753
750
  if (!codexAutoStartEnabled(config)) {
754
751
  console.log("Codex autostart is disabled.");
@@ -772,13 +769,13 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom
772
769
  // owns catalog refresh; ensure must not overwrite a working destination.
773
770
  // Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature).
774
771
  const systemEnv = await injectSystemEnv(live.port, config).catch(() => ({ injected: false }));
775
- reportShellHookFailure(reconcileShellHook(systemEnv.injected));
772
+ if (siblingOfLivePort() === null) reportShellHookFailure(reconcileShellHook(systemEnv.injected));
776
773
  if (!systemEnv.injected) await syncClaudeAgentDefsAtProxyStartup(config, live.port);
777
774
  // Refresh the Grok Build fence too (same contract as start). live.hostname is the
778
775
  // hostname the running proxy actually bound — config.hostname may have drifted.
779
776
  // The reconciler re-reads immediately before each client-file mutation; only
780
777
  // the live proxy's observed bind host is safe to carry across this boundary.
781
- await reconcileEnsureDesiredIntegrations(
778
+ if (siblingOfLivePort() === null) await reconcileEnsureDesiredIntegrations(
782
779
  live.port,
783
780
  { kind: "live", hostname: live.hostname },
784
781
  );
@@ -806,7 +803,7 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom
806
803
  // responds — align here too so `ocx ensure` never returns with a stale ON/OFF mismatch.
807
804
  // Persisted state is loaded inside each mutation after waitForProxy, so a
808
805
  // toggle while the child starts wins over the pre-spawn snapshot.
809
- await reconcileEnsureDesiredIntegrations(port, { kind: "spawned" });
806
+ if (siblingOfLivePort() === null) await reconcileEnsureDesiredIntegrations(port, { kind: "spawned" });
810
807
  // Always sync the LIVE port: after a fallback-port start, config.port still names the
811
808
  // busy preferred port — syncing that would point Codex at a dead listener.
812
809
  const synced = await syncModelsToCodex(port).catch(e => {
@@ -126,7 +126,14 @@ export async function handleClaudeConfigCommand(argv: string[], deps: RuntimeApi
126
126
  }
127
127
  if (Object.keys(body).length === 0) throw new CliUsageError("at least one Claude setting is required", CLAUDE_USAGE);
128
128
  const result = await runtimeRequest("/api/claude-code", { method: "PUT", body: JSON.stringify(body) }, deps);
129
- printData(result, wantsJson, ["Claude Code settings updated."]);
129
+ const warnings = (result as { warnings?: unknown }).warnings;
130
+ const retained = Array.isArray(warnings) && warnings.includes("shared_proxy_retained");
131
+ printData(result, wantsJson, [
132
+ "Claude Code settings updated.",
133
+ // The route kept the shared proxy env because Claude Desktop may still rely on it. Say so,
134
+ // or an operator who turned first-party off believes the local interception is gone.
135
+ ...(retained ? ["Warning: Claude Desktop still uses the shared proxy settings. Run `ocx claude desktop apply --gateway` to release them."] : []),
136
+ ]);
130
137
  });
131
138
  }
132
139
 
@@ -18,6 +18,7 @@ export const MODELS_RUNTIME_SUBCOMMANDS = [
18
18
  "price",
19
19
  "set-price",
20
20
  "edit",
21
+ "set",
21
22
  "enable",
22
23
  "disable",
23
24
  "provider",
@@ -18,6 +18,7 @@ import { isValidProviderName } from "../config/provider-name";
18
18
  import { isValidModelDiscoveryModelId } from "../providers/model-discovery-limits";
19
19
  import { redactSecretString } from "../lib/redact";
20
20
  import type { ProviderCostOverlay } from "../types";
21
+ import { resolveMatchedPrice } from "../usage/cost";
21
22
  import { MAX_COST4_RATE } from "../usage/expected-prices";
22
23
  import { isValidCost4Rate } from "../usage/user-cost-overlays";
23
24
 
@@ -30,6 +31,10 @@ const USAGE = `Usage:
30
31
  [--context-window <tokens|0>] [--modalities <text,image,audio|->]
31
32
  [--reasoning-efforts <none,minimal,low,medium,high,xhigh,max,ultra|->]
32
33
  [--default-reasoning-effort <level|->] [--json]
34
+ ocx models set <provider/model> [--context-window <tokens|0|->]
35
+ [--modalities <text,image,audio|->]
36
+ [--reasoning-efforts <none,minimal,low,medium,high,xhigh,max,ultra|"">|->]
37
+ [--default-reasoning-effort <level|->] [--reset] [--json]
33
38
  ocx models <enable|disable> <provider/model|native-model> [--native] [--json]
34
39
  ocx models provider <name> <on|off> [--json]
35
40
  ocx models selected <provider> [--set <id,id...>|--clear] [--json]
@@ -124,8 +129,12 @@ async function priceRequest(write: boolean, argv: string[], deps: RuntimeApiDeps
124
129
  if (!validPriceCost(stored)) throw new Error("Invalid model price response");
125
130
  cost = { ...stored };
126
131
  }
127
- printData({ provider, modelId, cost }, wantsJson, [
128
- cost === null ? `${selector}: automatic pricing` : `${selector}: ${JSON.stringify(cost)} USD per 1M tokens`,
132
+ // The API map owns manual overrides; bundled defaults remain derived rather
133
+ // than being persisted as overrides that would mask later catalog updates.
134
+ const effectiveCost = cost ?? resolveMatchedPrice(provider, modelId, undefined, [])?.cost4 ?? null;
135
+ printData({ provider, modelId, cost, effectiveCost }, wantsJson, [
136
+ effectiveCost === null ? `${selector}: automatic pricing (unknown)`
137
+ : `${selector}: ${JSON.stringify(effectiveCost)} USD per 1M tokens${cost === null ? " (automatic estimate)" : ""}`,
129
138
  ]);
130
139
  return;
131
140
  }
@@ -199,7 +208,7 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise<void> {
199
208
  if (displayName !== undefined) patch.displayName = displayName === "-" ? "" : displayName;
200
209
  if (contextRaw !== undefined) {
201
210
  const value = Number(contextRaw.replace(/[_,]/g, ""));
202
- if (!Number.isInteger(value) || value < 0) throw new CliUsageError("--context-window must be an integer >= 0", USAGE);
211
+ if (!Number.isSafeInteger(value) || value < 0) throw new CliUsageError("--context-window must be a safe integer >= 0", USAGE);
203
212
  patch.contextWindow = value === 0 ? null : value;
204
213
  }
205
214
  if (modalitiesRaw !== undefined) patch.inputModalities = modalitiesRaw === "-" ? [] : csv(modalitiesRaw);
@@ -240,6 +249,98 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise<void> {
240
249
  printData(result, wantsJson, [`Updated custom model ${id}.`]);
241
250
  }
242
251
 
252
+ /**
253
+ * Per-model overrides on a routed row: the twin of `ocx models edit` for rows that already
254
+ * exist, rather than for a custom definition.
255
+ *
256
+ * Every option has a clear spelling instead of an empty-value sentinel, because the API treats
257
+ * `null` as "hand this fact back to the registry, the catalog and the provider default". That is
258
+ * a different intent from storing a value that merely looks blank, and collapsing the two is how
259
+ * an override outlives the data it was copied from.
260
+ */
261
+ async function setModelSettings(argv: string[], deps: RuntimeApiDeps): Promise<void> {
262
+ const args = [...argv];
263
+ const selector = args.shift()?.trim();
264
+ const wantsJson = takeFlag(args, "--json");
265
+ const reset = takeFlag(args, "--reset");
266
+ if (!selector) throw new CliUsageError("model selector is required", USAGE);
267
+ const contextRaw = takeOption(args, "--context-window");
268
+ const modalitiesRaw = takeOption(args, "--modalities");
269
+ const reasoningEffortsRaw = takeOption(args, "--reasoning-efforts");
270
+ const defaultEffortRaw = takeOption(args, "--default-reasoning-effort");
271
+ rejectArgs(args, USAGE);
272
+ const target = parseSelector(selector, false);
273
+ if (target.native) {
274
+ throw new CliUsageError(
275
+ "model settings address a routed model; use provider/model (the native openai lane has no per-model overrides)",
276
+ USAGE,
277
+ );
278
+ }
279
+ const patch: Record<string, unknown> = {};
280
+ if (contextRaw !== undefined) {
281
+ const raw = contextRaw.trim();
282
+ if (raw === "-") patch.contextWindow = null;
283
+ else {
284
+ const value = Number(raw.replace(/[_,]/g, ""));
285
+ if (!Number.isSafeInteger(value) || value < 0) {
286
+ throw new CliUsageError("--context-window must be a safe integer >= 0 (0 or - clears the override)", USAGE);
287
+ }
288
+ patch.contextWindow = value === 0 ? null : value;
289
+ }
290
+ }
291
+ if (modalitiesRaw !== undefined) {
292
+ // "-" is the one spelling that clears. A blank value or a blank member would otherwise
293
+ // become [] after CSV normalization, which the server also reads as "clear".
294
+ const trimmed = modalitiesRaw.trim();
295
+ if (trimmed === "-") patch.inputModalities = null;
296
+ else {
297
+ const members = trimmed.split(",").map(item => item.trim());
298
+ if (members.some(item => !["text", "image", "audio"].includes(item))) {
299
+ throw new CliUsageError("--modalities must list text, image or audio (comma-separated), or - to clear", USAGE);
300
+ }
301
+ patch.inputModalities = [...new Set(members)];
302
+ }
303
+ }
304
+ if (reasoningEffortsRaw !== undefined) {
305
+ // "-" restores inheritance by clearing the stored ladder (null); "" stores an explicit empty
306
+ // ladder, which is the "this model does not reason" override. Embedded blank CSV members
307
+ // (`low,,high`) are malformed and rejected rather than normalized away.
308
+ const trimmed = reasoningEffortsRaw.trim();
309
+ if (trimmed === "-") {
310
+ patch.reasoningEfforts = null;
311
+ } else if (trimmed === "") {
312
+ patch.reasoningEfforts = [];
313
+ } else {
314
+ const values = trimmed.split(",").map(value => value.trim());
315
+ if (values.some(value => value === "")) {
316
+ throw new CliUsageError("--reasoning-efforts must be comma-separated values from none, minimal, low, medium, high, xhigh, max, ultra (\"\" for no reasoning, \"-\" to inherit)", USAGE);
317
+ }
318
+ patch.reasoningEfforts = values;
319
+ }
320
+ }
321
+ if (defaultEffortRaw !== undefined) patch.defaultReasoningEffort = defaultEffortRaw.trim() === "-" ? null : defaultEffortRaw.trim();
322
+ if (reset) {
323
+ if (Object.keys(patch).length > 0) {
324
+ throw new CliUsageError("--reset clears every override and cannot be combined with other options", USAGE);
325
+ }
326
+ for (const key of ["contextWindow", "inputModalities", "reasoningEfforts", "defaultReasoningEffort"]) patch[key] = null;
327
+ }
328
+ if (Object.keys(patch).length === 0) throw new CliUsageError("at least one setting or --reset is required", USAGE);
329
+ const result = await runtimeRequest<{ changed?: boolean; hasOverrides?: boolean; saved?: boolean; catalogRefresh?: { status?: string; retryable?: boolean } }>("/api/model-settings", {
330
+ method: "PUT",
331
+ body: JSON.stringify({ provider: target.provider, modelId: target.id, ...patch }),
332
+ }, deps);
333
+ const message = result?.changed === false
334
+ ? (reset && result.hasOverrides === false ? "Nothing to restore for " + selector + "." : "No settings changed for " + selector + ".")
335
+ : "Updated model settings for " + selector + ".";
336
+ printData(result, wantsJson, [message,
337
+ // A non-retryable skip means there is no managed Codex catalog to refresh, which is normal.
338
+ ...(result?.saved && (result.catalogRefresh?.status === "failed"
339
+ || (result.catalogRefresh?.status === "skipped" && result.catalogRefresh.retryable === true))
340
+ ? ["Settings saved, but the Codex model catalog did not refresh. Run `ocx sync` so Codex picks up the change."] : []),
341
+ ]);
342
+ }
343
+
243
344
  function parseSelector(selector: string, forceNative: boolean): { provider: string; id: string; native: boolean } {
244
345
  if (forceNative || !selector.includes("/")) return { provider: "openai", id: selector, native: true };
245
346
  const slash = selector.indexOf("/");
@@ -410,7 +511,7 @@ async function context(argv: string[], deps: RuntimeApiDeps): Promise<void> {
410
511
  const raw = args.shift();
411
512
  if (!raw) throw new CliUsageError("context value is required", USAGE);
412
513
  const value = Number(raw.replace(/[_,]/g, ""));
413
- if (!Number.isInteger(value) || value <= 0) throw new CliUsageError("context value must be a positive integer", USAGE);
514
+ if (!Number.isSafeInteger(value) || value <= 0) throw new CliUsageError("context value must be a positive safe integer", USAGE);
414
515
  body = { value };
415
516
  // Explicit apply-to-all switch for headless use: re-points every routed provider to
416
517
  // the new value, mirroring the dashboard's "apply to every routed provider" toggle.
@@ -470,6 +571,7 @@ export async function handleModelsRuntimeCommand(sub: string, argv: string[], de
470
571
  else if (sub === "price") action = () => price(false, argv, deps);
471
572
  else if (sub === "set-price") action = () => price(true, argv, deps);
472
573
  else if (sub === "edit") action = () => edit(argv, deps);
574
+ else if (sub === "set") action = () => setModelSettings(argv, deps);
473
575
  else if (sub === "enable") action = () => visibility(true, argv, deps);
474
576
  else if (sub === "disable") action = () => visibility(false, argv, deps);
475
577
  else if (sub === "provider") action = () => providerVisibility(argv, deps);
package/src/cli/models.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * `ocx models` subcommand — list configured models and manage custom models.
3
3
  */
4
+ import { resolveMatchedPrice, type MatchedPrice } from "../usage/cost";
4
5
  import { randomUUID } from "node:crypto";
5
6
  import { createInterface } from "node:readline/promises";
6
7
  import { syncModelsToCodex } from "../codex/sync";
@@ -85,6 +86,7 @@ interface ModelEntry {
85
86
  contextWindow: number | null;
86
87
  inputModalities: string[] | null;
87
88
  reasoningEfforts: string[] | null;
89
+ price: MatchedPrice | null;
88
90
  }
89
91
 
90
92
  /**
@@ -132,6 +134,7 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[]
132
134
  contextWindow: configuredContextWindow(prov, model) ?? null,
133
135
  inputModalities: modalities,
134
136
  reasoningEfforts: efforts,
137
+ price: resolveMatchedPrice(provName, model),
135
138
  });
136
139
  };
137
140
 
@@ -428,7 +431,9 @@ function handleConfiguredModels(args: string[]): void {
428
431
  for (const m of provModels) {
429
432
  const marker = m.isDefault ? " *" : "";
430
433
  const ctx = m.contextWindow ? ` (${Math.round(m.contextWindow / 1000)}k)` : "";
431
- console.log(` ${m.model}${marker}${ctx}`);
434
+ const rates = m.price?.cost4;
435
+ const pricing = rates ? ` ~$${rates.input}/$${rates.output} input/output per 1M tokens` : " price unknown";
436
+ console.log(` ${m.model}${marker}${ctx}${pricing}`);
432
437
  }
433
438
  console.log();
434
439
  }