@bitkyc08/opencodex 2.49.0 → 2.51.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 (141) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +3 -0
  3. package/bin/ocx.mjs +222 -71
  4. package/gui/dist/assets/index-D7BdZpZm.js +115 -0
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +1 -1
  7. package/src/adapters/qoder/adapter.ts +69 -1
  8. package/src/adapters/qoder/scaffold-guard.ts +233 -0
  9. package/src/claude/agents-inject.ts +29 -5
  10. package/src/claude/desktop-3p.ts +31 -3
  11. package/src/claude/gateway-cache.ts +12 -21
  12. package/src/claude/inbound.ts +17 -5
  13. package/src/cli/account-api.ts +18 -3
  14. package/src/cli/account-auth.ts +8 -1
  15. package/src/cli/account-extended.ts +2 -1
  16. package/src/cli/account.ts +1 -0
  17. package/src/cli/capabilities.ts +43 -1
  18. package/src/cli/claude-agent-startup-sync.ts +26 -1
  19. package/src/cli/claude.ts +138 -20
  20. package/src/cli/config-command.ts +67 -1
  21. package/src/cli/connect.ts +181 -14
  22. package/src/cli/dispatch.ts +53 -9
  23. package/src/cli/doctor.ts +9 -2
  24. package/src/cli/ensure-desired-integrations.ts +10 -0
  25. package/src/cli/gui-pair-client.ts +1 -12
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/hub.ts +367 -0
  28. package/src/cli/index.ts +99 -31
  29. package/src/cli/launcher-context.ts +1 -1
  30. package/src/cli/models-runtime.ts +8 -3
  31. package/src/cli/observe.ts +13 -3
  32. package/src/cli/registry.ts +43 -3
  33. package/src/cli/status.ts +325 -5
  34. package/src/cli/version-skew.ts +4 -1
  35. package/src/cli.ts +2 -2
  36. package/src/client/catalog-compatibility.ts +192 -0
  37. package/src/client/connect.ts +31 -0
  38. package/src/client/hub-client.ts +52 -0
  39. package/src/client/hub-state.ts +214 -0
  40. package/src/clients/config-export/zcode.ts +24 -0
  41. package/src/codex/account-runtime-state.ts +6 -1
  42. package/src/codex/account-store.ts +72 -9
  43. package/src/codex/account-usability.ts +50 -13
  44. package/src/codex/auth-api.ts +156 -28
  45. package/src/codex/auth-context.ts +21 -0
  46. package/src/codex/catalog/effort.ts +67 -8
  47. package/src/codex/catalog/parsing.ts +23 -0
  48. package/src/codex/catalog/provider-fetch.ts +71 -2
  49. package/src/codex/catalog/sync.ts +99 -0
  50. package/src/codex/codex-write-lock.ts +11 -2
  51. package/src/codex/desired-state.ts +47 -1
  52. package/src/codex/inject-coordination.ts +10 -5
  53. package/src/codex/inject.ts +29 -12
  54. package/src/codex/loopback-target.ts +45 -0
  55. package/src/codex/quota-auto-refresh.ts +6 -1
  56. package/src/codex/quota.ts +54 -8
  57. package/src/codex/routing.ts +48 -1
  58. package/src/codex/runtime.ts +37 -3
  59. package/src/codex/sync.ts +29 -9
  60. package/src/codex/warmup.ts +21 -4
  61. package/src/combos/index.ts +2 -0
  62. package/src/combos/resolve.ts +52 -0
  63. package/src/config/pending-teardown.ts +1 -1
  64. package/src/config.ts +184 -12
  65. package/src/generated/compatibility-version.json +188 -116
  66. package/src/grok/status.ts +9 -1
  67. package/src/integrations/config-io.ts +54 -1
  68. package/src/lib/bun-runtime.ts +1 -1
  69. package/src/lib/errors.ts +8 -0
  70. package/src/lib/gui-pair-capability.ts +27 -0
  71. package/src/lib/local-destinations.ts +162 -0
  72. package/src/lib/package-tree-integrity.ts +1 -1
  73. package/src/lib/privacy.ts +25 -0
  74. package/src/lib/process-control.ts +130 -20
  75. package/src/lib/service-secrets.ts +28 -0
  76. package/src/lib/test-home-guard.ts +49 -0
  77. package/src/oauth/health.ts +47 -12
  78. package/src/oauth/index.ts +46 -8
  79. package/src/oauth/token-guardian.ts +32 -6
  80. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  81. package/src/providers/opencode-go-transport.ts +9 -1
  82. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  83. package/src/providers/quota.ts +20 -1
  84. package/src/providers/registry.ts +35 -6
  85. package/src/remote/hub-state.ts +182 -0
  86. package/src/server/auth-cors.ts +11 -0
  87. package/src/server/chat-completions.ts +10 -7
  88. package/src/server/chat-native.ts +10 -1
  89. package/src/server/claude-messages.ts +12 -6
  90. package/src/server/hub-state.ts +98 -0
  91. package/src/server/images.ts +2 -2
  92. package/src/server/index.ts +149 -8
  93. package/src/server/management/api-access.ts +14 -3
  94. package/src/server/management/config-routes.ts +2 -2
  95. package/src/server/management/cursor-integration-routes.ts +13 -4
  96. package/src/server/management/logs-usage-routes.ts +4 -1
  97. package/src/server/management/model-rows.ts +16 -1
  98. package/src/server/management/oauth-account-routes.ts +6 -2
  99. package/src/server/management/provider-routes.ts +9 -2
  100. package/src/server/management/request-history-routes.ts +4 -2
  101. package/src/server/management/route-registry.ts +5 -4
  102. package/src/server/management/shared.ts +66 -3
  103. package/src/server/management-api.ts +1 -1
  104. package/src/server/proxy-liveness.ts +7 -1
  105. package/src/server/request-decompress.ts +91 -3
  106. package/src/server/request-log-conversation.ts +41 -1
  107. package/src/server/request-log.ts +10 -0
  108. package/src/server/responses/codex-auth-error.ts +18 -1
  109. package/src/server/responses/codex-ws-exchange.ts +36 -4
  110. package/src/server/responses/codex-ws-wire.ts +76 -5
  111. package/src/server/responses/compact.ts +28 -11
  112. package/src/server/responses/context-overflow.ts +11 -0
  113. package/src/server/responses/core.ts +201 -48
  114. package/src/server/responses/policy-fallback.ts +13 -3
  115. package/src/server/search.ts +2 -2
  116. package/src/server/system-env-shell.ts +14 -2
  117. package/src/server/system-env.ts +106 -14
  118. package/src/service.ts +965 -68
  119. package/src/types/accounts.ts +18 -0
  120. package/src/types/config.ts +93 -4
  121. package/src/types/provider.ts +56 -0
  122. package/src/types.ts +4 -0
  123. package/src/update/badge.ts +3 -2
  124. package/src/update/index.ts +317 -64
  125. package/src/update/install-detection.d.mts +6 -0
  126. package/src/update/install-detection.mjs +73 -0
  127. package/src/update/job.ts +101 -49
  128. package/src/update/pnpm-global-install.d.mts +144 -0
  129. package/src/update/pnpm-global-install.mjs +591 -0
  130. package/src/update/pnpm-invocation.d.mts +43 -0
  131. package/src/update/pnpm-invocation.mjs +141 -0
  132. package/src/update/registry-integrity.d.mts +16 -0
  133. package/src/update/registry-integrity.mjs +37 -0
  134. package/src/update/transactional-install.d.mts +1 -1
  135. package/src/update/transactional-install.mjs +101 -7
  136. package/src/update/tray-update-plan.mjs +1 -1
  137. package/src/vision/plan.ts +13 -3
  138. package/src/vision/routed-describe.ts +51 -20
  139. package/src/web-search/ollama-executor.ts +127 -0
  140. package/src/web-search/passthrough-bridge.ts +761 -0
  141. package/gui/dist/assets/index-BtyONQrZ.js +0 -115
@@ -1,5 +1,13 @@
1
- import { existsSync, lstatSync } from "node:fs";
1
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
2
2
  import { DEFAULT_CATALOG_PATH } from "../codex/paths";
3
+ import { codexSupportedReasoningEfforts } from "../codex/catalog/effort";
4
+ import { resolveCodexRuntime } from "../codex/runtime";
5
+ import {
6
+ inspectClientCatalogReadiness,
7
+ type CatalogCompatibilityDeps,
8
+ type ClientCatalogFileState,
9
+ type ClientCatalogReadiness,
10
+ } from "../client/catalog-compatibility";
3
11
  import {
4
12
  disconnectClient,
5
13
  revokeConnectedClientKey,
@@ -26,6 +34,12 @@ import {
26
34
 
27
35
  export interface ClientCommandDeps extends RuntimeApiDeps {
28
36
  lifecycleLockDeps?: ClientLifecycleLockDeps;
37
+ catalogProbeDeps?: ClientCatalogProbeDeps;
38
+ }
39
+
40
+ export interface ClientCatalogProbeDeps extends CatalogCompatibilityDeps {
41
+ /** Injected in tests; defaults to reading the materialized client catalog off disk. */
42
+ readCatalogBody?: () => string | null;
29
43
  }
30
44
 
31
45
  export const CONNECT_USAGE = `Usage:
@@ -56,21 +70,96 @@ export type ClientConnectionStatus = {
56
70
  catalog: "present" | "missing" | "unsafe";
57
71
  token: "owned" | "missing" | "changed" | "unsafe";
58
72
  rotation: "clean" | "orphan-cleaned" | "recovery-required" | "unsafe";
73
+ /**
74
+ * Whether the selected local Codex CLI can actually launch against this connection (#4207).
75
+ *
76
+ * `state: "connected"` proves the hub answered and the credential works. It never proved the
77
+ * local runtime could consume what was downloaded, which is how a connection kept reporting
78
+ * itself healthy while `codex exec` exited on `unknown variant` before its first request.
79
+ *
80
+ * Reported only while connected, and reported as its own field rather than as a fourth
81
+ * `catalog` value: the status JSON is documented additive-only, so widening an existing
82
+ * field's value domain would change what `catalog: "present"` means for every consumer that
83
+ * already reads it.
84
+ */
85
+ readiness?: ClientCatalogReadiness["kind"];
86
+ /** Present whenever readiness is not `ready`; names the fault and the way out. */
87
+ readinessReason?: string;
59
88
  };
60
89
 
61
- export function collectClientConnectionStatus(now = Date.now(), lifecycleLockDeps?: ClientLifecycleLockDeps): ClientConnectionStatus {
90
+ function readInstalledCatalogBody(): string | null {
91
+ try {
92
+ return readFileSync(DEFAULT_CATALOG_PATH, "utf8");
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * The ladder the selected Codex CLI accepts, observed without persisting anything.
100
+ *
101
+ * `codexSupportedReasoningEfforts()` with no deps reaches `resolveAndPersistCodexRuntime`, which
102
+ * writes codex-runtime.json. `ocx status` deliberately resolves without persisting, and a
103
+ * read-only diagnostics command should not start writing runtime selection state because a
104
+ * readiness check was added to it. Handing the already-resolved command in as the only candidate
105
+ * skips that path and reuses the resolve cache `ocx status` has usually already filled.
106
+ */
107
+ function observeLocalCodexEffortLadder(): ReadonlySet<string> | null {
108
+ const command = resolveCodexRuntime().runtime.command;
109
+ return codexSupportedReasoningEfforts({ commandCandidates: () => [command] });
110
+ }
111
+
112
+ /**
113
+ * One observer per command, for the write-time gate and the readiness check alike. Built even
114
+ * when nothing was injected, so production does not silently fall back to the default inside
115
+ * {@link assertClientCatalogCompatible} — that default persists runtime selection state and
116
+ * would run its own probe, which is how the two checks could disagree about the ladder within
117
+ * a single `ocx connect`.
118
+ */
119
+ function catalogObserver(deps: ClientCatalogProbeDeps | undefined): CatalogCompatibilityDeps {
120
+ return { supportedEfforts: deps?.supportedEfforts ?? observeLocalCodexEffortLadder };
121
+ }
122
+
123
+ /** The stat half of the catalog verdict, shared by the status collector and `ocx connect`. */
124
+ function installedCatalogFileState(): ClientCatalogFileState {
125
+ if (!existsSync(DEFAULT_CATALOG_PATH)) return "missing";
126
+ try {
127
+ const stat = lstatSync(DEFAULT_CATALOG_PATH);
128
+ return !stat.isSymbolicLink() && stat.isFile() ? "present" : "unsafe";
129
+ } catch {
130
+ return "unsafe";
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Observing the runtime spawns `codex debug models`, so this runs only for a connected client —
136
+ * the one configuration that installs hub bytes the local clamp never touched. A standalone or
137
+ * hub install pays nothing for it.
138
+ *
139
+ * Never throws. A status command that dies because a Codex probe failed would replace one wrong
140
+ * answer with a worse one.
141
+ */
142
+ function inspectInstalledCatalogReadiness(
143
+ file: ClientCatalogFileState,
144
+ deps: ClientCatalogProbeDeps,
145
+ ): ClientCatalogReadiness {
146
+ try {
147
+ const read = deps.readCatalogBody ?? readInstalledCatalogBody;
148
+ return inspectClientCatalogReadiness(file, file === "present" ? read() : null, catalogObserver(deps));
149
+ } catch {
150
+ return { kind: "unverified", reason: "the selected local Codex runtime could not be inspected" };
151
+ }
152
+ }
153
+
154
+ export function collectClientConnectionStatus(
155
+ now = Date.now(),
156
+ lifecycleLockDeps?: ClientLifecycleLockDeps,
157
+ catalogProbeDeps: ClientCatalogProbeDeps = {},
158
+ ): ClientConnectionStatus {
62
159
  const state = readClientConnectionState();
63
160
  const tokenState = readServiceApiTokenState();
64
161
  const rotation = inspectClientRotationRecoveryGate(state, lifecycleLockDeps).kind;
65
- let catalog: ClientConnectionStatus["catalog"] = "missing";
66
- if (existsSync(DEFAULT_CATALOG_PATH)) {
67
- try {
68
- const stat = lstatSync(DEFAULT_CATALOG_PATH);
69
- catalog = !stat.isSymbolicLink() && stat.isFile() ? "present" : "unsafe";
70
- } catch {
71
- catalog = "unsafe";
72
- }
73
- }
162
+ const catalog = installedCatalogFileState();
74
163
  if (state.kind !== "connected") {
75
164
  return {
76
165
  state: state.kind,
@@ -88,6 +177,7 @@ export function collectClientConnectionStatus(now = Date.now(), lifecycleLockDep
88
177
  : tokenState.kind === "unsafe"
89
178
  ? "unsafe"
90
179
  : tokenState.fingerprint === state.value.tokenFingerprint ? "owned" : "changed";
180
+ const readiness = inspectInstalledCatalogReadiness(catalog, catalogProbeDeps);
91
181
  return {
92
182
  state: "connected",
93
183
  serverUrl: state.value.serverUrl,
@@ -102,6 +192,8 @@ export function collectClientConnectionStatus(now = Date.now(), lifecycleLockDep
102
192
  catalog,
103
193
  token,
104
194
  rotation,
195
+ readiness: readiness.kind,
196
+ ...(readiness.kind === "ready" ? {} : { readinessReason: readiness.reason }),
105
197
  };
106
198
  }
107
199
 
@@ -113,12 +205,74 @@ function parseClients(raw: string | undefined): OcxConnectedClientId[] {
113
205
  return values as OcxConnectedClientId[];
114
206
  }
115
207
 
208
+ /** Reads as a verdict, not a field dump: "ready" is the only word that means the client works. */
209
+ function readinessLine(status: ClientConnectionStatus): string {
210
+ const label = status.readiness === "ready"
211
+ ? "ready"
212
+ : status.readiness === "incompatible"
213
+ ? "not ready"
214
+ : "unverified";
215
+ return `Local Codex CLI: ${label}${status.readinessReason ? ` (${status.readinessReason})` : ""}`;
216
+ }
217
+
218
+ export type ConnectCompletionReport = {
219
+ readonly lines: readonly string[];
220
+ /** Non-null when `ocx connect` must exit non-zero rather than report success. */
221
+ readonly failure: string | null;
222
+ };
223
+
224
+ /**
225
+ * What `ocx connect` says once the hub and the credential are settled, and whether the command
226
+ * still fails (#4207).
227
+ *
228
+ * Pure so the fail-closed decision can be exercised without a hub. Two rules it encodes:
229
+ *
230
+ * On a proven incompatibility the verdict is printed FIRST and the `Connected to …` line is
231
+ * withheld, because a caller grepping for that phrase would otherwise read a catalog the local
232
+ * CLI cannot parse as a success. The connection really was saved, so the replacement line says
233
+ * where to see it.
234
+ *
235
+ * And that failure applies only when this connection selected Codex. A Claude-only connection
236
+ * never launches the Codex CLI, so an old binary somewhere on PATH is not a reason to fail the
237
+ * operator's Claude Desktop setup — it is still worth saying, which is why the line survives
238
+ * without the exit code.
239
+ */
240
+ export function connectCompletionReport(
241
+ connection: { serverUrl: string; apiKeyId: string },
242
+ selectedClients: readonly OcxConnectedClientId[],
243
+ readiness: ClientCatalogReadiness,
244
+ ): ConnectCompletionReport {
245
+ const connected = `Connected to ${connection.serverUrl} as key ${connection.apiKeyId}.`;
246
+ if (readiness.kind === "ready") {
247
+ return { lines: [connected, "Local Codex CLI: ready (it accepts every reasoning level in the installed catalog)."], failure: null };
248
+ }
249
+ if (readiness.kind === "unverified") {
250
+ // Not a failure. A client with no observable Codex CLI is a working configuration, and the
251
+ // write-time gate deliberately lets it through; saying so is the honest middle report.
252
+ return { lines: [connected, `Local Codex CLI: unverified (${readiness.reason}).`], failure: null };
253
+ }
254
+ const verdict = `Local Codex CLI: not ready (${readiness.reason})`;
255
+ if (!selectedClients.includes("codex")) {
256
+ return {
257
+ lines: [connected, `${verdict} This connection selected ${selectedClients.join(", ")}, so nothing here launches Codex.`],
258
+ failure: null,
259
+ };
260
+ }
261
+ return {
262
+ lines: [verdict, `The connection to ${connection.serverUrl} as key ${connection.apiKeyId} was saved; run 'ocx connect status' to see it.`],
263
+ failure: `client_not_ready: ${readiness.reason}`,
264
+ };
265
+ }
266
+
116
267
  function statusLines(status: ClientConnectionStatus): string[] {
117
268
  if (status.state !== "connected") {
118
269
  return [`Connection: ${status.state}${status.reason ? ` (${status.reason})` : ""}`];
119
270
  }
120
271
  return [
121
272
  "Connection: connected",
273
+ // Second line on purpose. The whole of #4207 is that a reader stopped at "connected" and
274
+ // believed the client was usable, so the local verdict has to arrive before the hub detail.
275
+ readinessLine(status),
122
276
  `Hub: ${status.serverUrl}`,
123
277
  `Management: ${status.managementUrl} (${status.managementTransport})`,
124
278
  `Protocol: ${status.protocolVersion}`,
@@ -183,8 +337,21 @@ async function runConnect(argv: string[], deps: ClientCommandDeps): Promise<void
183
337
  managementTransport,
184
338
  noSync,
185
339
  ...(catalogTimeoutSeconds === undefined ? {} : { catalogTimeoutMs: catalogTimeoutSeconds * 1_000 }),
186
- }, { fetchImpl: deps.fetchImpl, lifecycleLockDeps: deps.lifecycleLockDeps });
187
- console.log(`Connected to ${connection.serverUrl} as key ${connection.apiKeyId}.`);
340
+ }, {
341
+ fetchImpl: deps.fetchImpl,
342
+ lifecycleLockDeps: deps.lifecycleLockDeps,
343
+ // Same observer the readiness check below uses. Passed unconditionally: leaving it out in
344
+ // production would let the gate fall back to its own probing, persisting default, so one
345
+ // command could run two probes and act on two different ladders.
346
+ catalogCompatibility: catalogObserver(deps.catalogProbeDeps),
347
+ });
348
+ // The hub and the credential are proven at this point; the local runtime is not. Reporting
349
+ // only the first half is what #4207 was filed for, so the catalog now on disk is checked
350
+ // against the Codex CLI that will read it.
351
+ const readiness = inspectInstalledCatalogReadiness(installedCatalogFileState(), deps.catalogProbeDeps ?? {});
352
+ const report = connectCompletionReport(connection, clients, readiness);
353
+ for (const line of report.lines) console.log(line);
354
+ if (report.failure) throw new Error(report.failure);
188
355
  }
189
356
 
190
357
  async function runRevoke(argv: string[], deps: ClientCommandDeps): Promise<void> {
@@ -204,7 +371,7 @@ export async function handleConnectCommand(argv: string[], deps: ClientCommandDe
204
371
  const args = argv.slice(1);
205
372
  const wantsJson = takeFlag(args, "--json");
206
373
  rejectArgs(args, CONNECT_USAGE, { redactValues: true });
207
- const status = collectClientConnectionStatus(Date.now(), deps.lifecycleLockDeps);
374
+ const status = collectClientConnectionStatus(Date.now(), deps.lifecycleLockDeps, deps.catalogProbeDeps ?? {});
208
375
  printData(status, wantsJson, statusLines(status));
209
376
  return;
210
377
  }
@@ -16,7 +16,12 @@ import type { LivenessIo, LiveProxy } from "../server/proxy-liveness";
16
16
  import type { OcxConfig } from "../types";
17
17
  import type { OwnedIntegrationRefreshOutcome } from "../integrations/owned-refresh";
18
18
  import { hasHelpFlag, printSubcommandUsage, printUsage } from "./help";
19
- import { setIntegrationEnabled, shouldSyncCodexOnStart } from "../codex/desired-state";
19
+ import {
20
+ HUB_GATED_SKIP_MESSAGE,
21
+ localClientSkipMessage,
22
+ setIntegrationEnabled,
23
+ shouldSyncCodexOnStart,
24
+ } from "../codex/desired-state";
20
25
  import { syncModelsToCodex } from "../codex/sync";
21
26
  import { collectOrcaCodexHomeDiagnostic } from "../codex/home";
22
27
  import { restoreNativeCodexAsync } from "../codex/inject";
@@ -54,6 +59,24 @@ export interface CliDispatchDeps {
54
59
 
55
60
  type CommandRunner = (deps: CliDispatchDeps) => Promise<number>;
56
61
 
62
+ /**
63
+ * The hub's management ingress is deliberately loopback-only. Prefer it for
64
+ * a browser opened on the hub itself: the proxy listener may be restricted to
65
+ * a Tailscale address, while the ingress is the local authenticated dashboard.
66
+ */
67
+ export function selectDefaultGuiUrl(
68
+ config: Pick<OcxConfig, "port" | "hostname" | "runtimeRole" | "hub">,
69
+ live: Pick<LiveProxy, "port" | "hostname"> | null,
70
+ probeHostname: (hostname: string | undefined) => string,
71
+ ): string {
72
+ const ingress = config.runtimeRole === "hub" ? config.hub?.managementIngress : undefined;
73
+ if (ingress?.enabled) return `http://localhost:${ingress.port}`;
74
+
75
+ const guiHost = probeHostname(live?.hostname ?? config.hostname);
76
+ const hostname = guiHost === "127.0.0.1" ? "localhost" : guiHost;
77
+ return `http://${hostname}:${live?.port ?? config.port ?? 10100}`;
78
+ }
79
+
57
80
  const commandRunners: Record<string, CommandRunner> = {
58
81
  init: async () => {
59
82
  const { runInit } = await import("./init");
@@ -105,7 +128,17 @@ const commandRunners: Record<string, CommandRunner> = {
105
128
  }
106
129
  const synced = await syncModelsToCodex(live.port);
107
130
  if (synced.status === "skipped") {
108
- return emitBack(false, "Codex integration is OFF; restore back did not change Codex. Retry after the competing integration change finishes.", 2);
131
+ // `setIntegrationEnabled` above just committed ON, so a skip here is NOT the toggle and
132
+ // is not a competing writer either — on a hub it is the role gate. Telling the operator
133
+ // to "retry after the competing integration change finishes" sent them waiting for a
134
+ // writer that does not exist (#4236).
135
+ return emitBack(
136
+ false,
137
+ synced.skippedReason === "hub-gated"
138
+ ? `${HUB_GATED_SKIP_MESSAGE} restore back did not change Codex.`
139
+ : "Codex integration is OFF; restore back did not change Codex. Retry after the competing integration change finishes.",
140
+ 2,
141
+ );
109
142
  }
110
143
  if (!synced.ok) {
111
144
  return emitBack(false, "Plain `codex` was not switched back to opencodex. Fix the reported Codex config issue and retry.", 1);
@@ -372,7 +405,9 @@ const commandRunners: Record<string, CommandRunner> = {
372
405
  );
373
406
  let code = 0;
374
407
  if (synced.status === "skipped") {
375
- console.log("Codex integration is OFF; sync skipped and no Codex files changed.");
408
+ console.log(synced.skippedReason === "hub-gated"
409
+ ? `${HUB_GATED_SKIP_MESSAGE} sync skipped and no Codex files changed.`
410
+ : "Codex integration is OFF; sync skipped and no Codex files changed.");
376
411
  } else if (synced.status === "catalog-only") {
377
412
  // Explicit sync with the integration OFF still refreshes the catalog/cache
378
413
  // for side profiles that consume the proxy without injection.
@@ -448,7 +483,8 @@ const commandRunners: Record<string, CommandRunner> = {
448
483
  const { readCodexCatalogPathForHome } = await import("../codex/catalog/parsing");
449
484
  const { existsSync } = await import("node:fs");
450
485
  const owningCodexHome = getCodexHome();
451
- const desiredDisabled = !shouldSyncCodexOnStart(deps.loadConfig());
486
+ const cacheGateSnapshot = deps.loadConfig();
487
+ const desiredDisabled = !shouldSyncCodexOnStart(cacheGateSnapshot);
452
488
  const invalidated = withCatalogWriteSerialization(owningCodexHome, permit =>
453
489
  invalidateCodexModelsCacheWithPermit(permit, owningCodexHome, { allowWhenDesiredDisabled: true }));
454
490
  const cacheJson = cacheArgs.includes("--json");
@@ -462,7 +498,11 @@ const commandRunners: Record<string, CommandRunner> = {
462
498
  } else if (desiredDisabled && !cacheJson) {
463
499
  // Worth saying in the human path, because it explains why nothing was written.
464
500
  // Under --json this belongs on the envelope, not as a second stdout line.
465
- console.log("Codex integration is OFF; no catalog or cache write resulted.");
501
+ console.log(localClientSkipMessage(
502
+ cacheGateSnapshot,
503
+ "Codex integration is OFF; no catalog or cache write resulted.",
504
+ "No catalog or cache write resulted.",
505
+ ));
466
506
  }
467
507
  // `completed` with a falsy value means the cache was NOT rewritten. Previously every
468
508
  // outcome exited 0, so a script could not tell a refreshed cache from a skipped one.
@@ -535,10 +575,7 @@ const commandRunners: Record<string, CommandRunner> = {
535
575
  return 1;
536
576
  }
537
577
  }
538
- // Open the host the proxy actually binds — `localhost` only answers for
539
- // loopback/wildcard binds, not a concrete LAN/IPv6 hostname.
540
- const guiHost = deps.probeHostname(live?.hostname ?? config.hostname);
541
- const guiUrl = `http://${guiHost === "127.0.0.1" ? "localhost" : guiHost}:${live?.port ?? config.port}`;
578
+ const guiUrl = selectDefaultGuiUrl(config, live, deps.probeHostname);
542
579
  console.log(`Opening ${guiUrl}`);
543
580
  const { openUrl } = await import("../lib/open-url");
544
581
  openUrl(guiUrl);
@@ -546,6 +583,13 @@ const commandRunners: Record<string, CommandRunner> = {
546
583
  },
547
584
  });
548
585
  },
586
+ hub: async deps => {
587
+ const { runHubCommand } = await import("./hub");
588
+ return runHubCommand(deps.args.slice(1), {
589
+ loadConfig: deps.loadConfig,
590
+ findLiveProxy: deps.findLiveProxy,
591
+ });
592
+ },
549
593
  service: async deps => {
550
594
  process.exitCode = 0;
551
595
  await deps.serviceCommand(...deps.args.slice(1));
package/src/cli/doctor.ts CHANGED
@@ -56,6 +56,8 @@ import {
56
56
  import { collectStartupHealth, formatStartupRoutingDetail, startupHealthSummary } from "../codex/autostart-health";
57
57
  import {
58
58
  displayCodexRuntimePath,
59
+ effortClampAppliesToRuntime,
60
+ liveRemovedEfforts,
59
61
  loadLastEffortClamp,
60
62
  persistCodexRuntime,
61
63
  resolveAndPersistCodexRuntime,
@@ -1177,9 +1179,14 @@ export async function runDoctor(args: string[] = []): Promise<void> {
1177
1179
  console.log(" Suggested: set CODEX_CLI_PATH to the desired binary and run ocx sync.");
1178
1180
  console.log(" Optional: ocx doctor --fix-codex-runtime");
1179
1181
  }
1182
+ // Doctor used to warn on any non-empty `removedEfforts`, while `ocx status` asked
1183
+ // `effortClampAppliesToRuntime` — so the two could disagree about the same file, and doctor
1184
+ // would tell an operator to install a newer Codex while the resolved runtime was already
1185
+ // newer than the one the diagnostic described. Both surfaces now read the same predicate.
1180
1186
  const lastClamp = loadLastEffortClamp();
1181
- if (lastClamp && lastClamp.removedEfforts.length > 0) {
1182
- console.log(` !! ${lastClamp.removedEfforts.join(" and ")} were removed during catalog sync.`);
1187
+ if (effortClampAppliesToRuntime(lastClamp, resolved.runtime)) {
1188
+ const live = liveRemovedEfforts(lastClamp);
1189
+ console.log(` !! ${live.join(" and ")} were removed during catalog sync.`);
1183
1190
  console.log(" Suggested: set CODEX_CLI_PATH to a newer Codex binary and run ocx sync.");
1184
1191
  }
1185
1192
  }
@@ -13,6 +13,8 @@ import { stripGrokConfig, type GrokInjectResult } from "../grok/inject";
13
13
  import { removeDesktop3pStandardPivot } from "../claude/desktop-3p";
14
14
  import {
15
15
  claudeDesktopIntegrationEnabled,
16
+ grokIntegrationEnabled,
17
+ HUB_GATED_SKIP_MESSAGE,
16
18
  shouldSyncGrokOnStart,
17
19
  } from "../codex/desired-state";
18
20
  import type { OcxConfig } from "../types";
@@ -78,6 +80,14 @@ export async function ensureGrokFenceMatchesDesired(
78
80
  ): Promise<void> {
79
81
  const config = deps.loadConfig();
80
82
  const { log, error } = io(deps);
83
+ // A hub-gated skip is NOT "the user turned Grok off" (#4236). Stripping the managed block
84
+ // there deleted a fence the operator still wants — and `ocx ensure` reported it as the
85
+ // Grok toggle doing its job. Only an explicit OFF authorizes the strip; the gate just
86
+ // declines to write, and says which key would let it.
87
+ if (!shouldSyncGrokOnStart(config) && grokIntegrationEnabled(config)) {
88
+ log(` ${HUB_GATED_SKIP_MESSAGE} ~/.grok/config.toml was left exactly as it is.`);
89
+ return;
90
+ }
81
91
  if (!shouldSyncGrokOnStart(config)) {
82
92
  try {
83
93
  const grok = deps.stripGrokConfig();
@@ -17,6 +17,7 @@ import {
17
17
  GUI_PAIR_NONCE_HEADER,
18
18
  GUI_PAIR_PATH,
19
19
  canonicalGuiBrowserOrigin,
20
+ canonicalHttpOrigin,
20
21
  createGuiPairCapability,
21
22
  } from "../lib/gui-pair-capability";
22
23
  import { directLocalHttpFetch } from "../server/direct-local-http";
@@ -52,18 +53,6 @@ function sameRuntime(left: RuntimePortState, right: RuntimePortState | null): bo
52
53
  && timingSafeEqual(leftSecret, rightSecret);
53
54
  }
54
55
 
55
- function canonicalHttpOrigin(value: unknown): string | null {
56
- if (typeof value !== "string") return null;
57
- try {
58
- const parsed = new URL(value);
59
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
60
- if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) return null;
61
- return parsed.origin;
62
- } catch {
63
- return null;
64
- }
65
- }
66
-
67
56
  function parseCreatedResult(value: unknown, browserOrigin: string): GuiPairRequestResult | null {
68
57
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
69
58
  const record = value as Record<string, unknown>;
package/src/cli/help.ts CHANGED
@@ -45,7 +45,7 @@ Usage:
45
45
  ocx sync [--restart-codex] Fetch models from providers and inject into Codex config
46
46
  ocx sync-cache [--restart-codex]
47
47
  Refresh Codex's model cache from the active catalog
48
- ocx status Check proxy server status
48
+ ocx status Check proxy server status (on a hub: one block with its ports and token source)
49
49
  ocx doctor Diagnose environment/network issues (WSL, proxy, ChatGPT reachability)
50
50
  ocx doctor --reclaim-response-temps
51
51
  Reclaim abandoned response-state temp files (works without a running proxy)
@@ -56,6 +56,8 @@ Usage:
56
56
  ocx logout <provider> Remove a stored OAuth login
57
57
  ocx gui [pair --origin <browser-origin> [--json]]
58
58
  Open the dashboard or create a single-use remote pairing grant
59
+ ocx hub invite [--json] Print a ready-to-run \`ocx connect\` line for one more machine
60
+ (hub only; see \`ocx help hub\` for the one-port topology)
59
61
  ocx update [--tag <tag>] Update opencodex (keeps preview installs on @preview)
60
62
  ocx restart Stop and restart the proxy
61
63
  ocx v2 <sub> multi_agent_v2 surface (status|on|off|mode|keep-native-v1|threads|mode-hint)
@@ -99,6 +101,7 @@ Examples:
99
101
  ocx start Start on default port (10100)
100
102
  ocx start --port 8080 Start on custom port
101
103
  ocx help service Show service command help
104
+ ocx help hub Explain the hub topology, token file, and invites
102
105
  ocx sync Sync available models to Codex`);
103
106
  }
104
107