@bitkyc08/opencodex 2.50.0 → 2.52.0-preview.20260911

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 (95) hide show
  1. package/bin/ocx.mjs +222 -71
  2. package/gui/dist/assets/{index-C39tnjXO.js → index-Dx0xv2EA.js} +1 -1
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/qoder/adapter.ts +69 -1
  6. package/src/adapters/qoder/scaffold-guard.ts +233 -0
  7. package/src/claude/agents-inject.ts +29 -5
  8. package/src/claude/desktop-3p.ts +31 -3
  9. package/src/claude/gateway-cache.ts +12 -21
  10. package/src/cli/capabilities.ts +28 -0
  11. package/src/cli/claude-agent-startup-sync.ts +26 -1
  12. package/src/cli/claude.ts +138 -20
  13. package/src/cli/config-command.ts +67 -1
  14. package/src/cli/connect.ts +181 -14
  15. package/src/cli/dispatch.ts +53 -9
  16. package/src/cli/doctor.ts +9 -2
  17. package/src/cli/ensure-desired-integrations.ts +10 -0
  18. package/src/cli/gui-pair-client.ts +1 -12
  19. package/src/cli/help.ts +4 -1
  20. package/src/cli/hub.ts +367 -0
  21. package/src/cli/index.ts +94 -30
  22. package/src/cli/launcher-context.ts +1 -1
  23. package/src/cli/registry.ts +43 -3
  24. package/src/cli/status.ts +325 -5
  25. package/src/cli/version-skew.ts +4 -1
  26. package/src/cli.ts +2 -2
  27. package/src/client/catalog-compatibility.ts +192 -0
  28. package/src/client/connect.ts +31 -0
  29. package/src/client/hub-client.ts +52 -0
  30. package/src/client/hub-state.ts +214 -0
  31. package/src/codex/account-usability.ts +48 -12
  32. package/src/codex/auth-api.ts +49 -5
  33. package/src/codex/catalog/effort.ts +67 -8
  34. package/src/codex/catalog/sync.ts +85 -0
  35. package/src/codex/codex-write-lock.ts +11 -2
  36. package/src/codex/desired-state.ts +47 -1
  37. package/src/codex/inject-coordination.ts +10 -5
  38. package/src/codex/inject.ts +26 -10
  39. package/src/codex/loopback-target.ts +45 -0
  40. package/src/codex/routing.ts +48 -1
  41. package/src/codex/runtime.ts +37 -3
  42. package/src/codex/sync.ts +29 -9
  43. package/src/codex/warmup.ts +21 -4
  44. package/src/config/pending-teardown.ts +1 -1
  45. package/src/config.ts +126 -12
  46. package/src/generated/compatibility-version.json +136 -76
  47. package/src/grok/status.ts +9 -1
  48. package/src/integrations/config-io.ts +54 -1
  49. package/src/lib/bun-runtime.ts +1 -1
  50. package/src/lib/gui-pair-capability.ts +27 -0
  51. package/src/lib/local-destinations.ts +162 -0
  52. package/src/lib/package-tree-integrity.ts +1 -1
  53. package/src/lib/process-control.ts +130 -20
  54. package/src/lib/service-secrets.ts +28 -0
  55. package/src/lib/test-home-guard.ts +49 -0
  56. package/src/providers/opencode-go-transport.ts +9 -1
  57. package/src/providers/quota.ts +5 -1
  58. package/src/providers/registry.ts +34 -5
  59. package/src/remote/hub-state.ts +182 -0
  60. package/src/server/auth-cors.ts +5 -0
  61. package/src/server/chat-completions.ts +6 -3
  62. package/src/server/claude-messages.ts +7 -1
  63. package/src/server/hub-state.ts +98 -0
  64. package/src/server/index.ts +124 -6
  65. package/src/server/management/api-access.ts +14 -3
  66. package/src/server/management/config-routes.ts +2 -2
  67. package/src/server/management/cursor-integration-routes.ts +13 -4
  68. package/src/server/proxy-liveness.ts +7 -1
  69. package/src/server/request-log-conversation.ts +41 -1
  70. package/src/server/responses/codex-auth-error.ts +18 -1
  71. package/src/server/responses/codex-ws-exchange.ts +36 -4
  72. package/src/server/responses/codex-ws-wire.ts +75 -4
  73. package/src/server/responses/compact.ts +20 -9
  74. package/src/server/responses/core.ts +57 -10
  75. package/src/server/responses/policy-fallback.ts +7 -1
  76. package/src/server/system-env-shell.ts +14 -2
  77. package/src/server/system-env.ts +106 -14
  78. package/src/service.ts +906 -94
  79. package/src/types/config.ts +57 -4
  80. package/src/update/badge.ts +3 -2
  81. package/src/update/index.ts +317 -64
  82. package/src/update/install-detection.d.mts +6 -0
  83. package/src/update/install-detection.mjs +73 -0
  84. package/src/update/job.ts +101 -49
  85. package/src/update/pnpm-global-install.d.mts +144 -0
  86. package/src/update/pnpm-global-install.mjs +591 -0
  87. package/src/update/pnpm-invocation.d.mts +43 -0
  88. package/src/update/pnpm-invocation.mjs +141 -0
  89. package/src/update/registry-integrity.d.mts +16 -0
  90. package/src/update/registry-integrity.mjs +37 -0
  91. package/src/update/transactional-install.d.mts +1 -1
  92. package/src/update/transactional-install.mjs +101 -7
  93. package/src/update/tray-update-plan.mjs +1 -1
  94. package/src/vision/plan.ts +13 -3
  95. package/src/vision/routed-describe.ts +51 -20
package/src/cli/status.ts CHANGED
@@ -8,21 +8,103 @@ import { diagnoseService, serviceLogPath } from "../service";
8
8
  import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health";
9
9
  import { getCodexRoutingKind } from "../codex/inject";
10
10
  import { diagnoseCodexShim } from "../codex/shim";
11
- import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortClamp, resolveCodexRuntime } from "../codex/runtime";
11
+ import { displayCodexRuntimePath, effortClampAppliesToRuntime, liveRemovedEfforts, loadLastEffortClamp, resolveCodexRuntime } from "../codex/runtime";
12
12
  import { packageVersion } from "./help";
13
13
  import { computeVersionSkew, type VersionSkew } from "./version-skew";
14
14
  import { redactSecretString, redactUserPath } from "../lib/redact";
15
15
  import { collectOrcaCodexHomeDiagnostic, type OrcaCodexHomeDiagnostic } from "../codex/home";
16
16
  import { grokFenceEndpointDrift, readGrokStatus } from "../grok/status";
17
+ import { effectiveLoopbackListenerPort } from "../codex/loopback-target";
17
18
  import { claudeDesktopIntegrationEnabled } from "../codex/desired-state";
18
19
  import { claudeDesktopPolicyHealth, probeClaudeDesktopPolicy, type ClaudeDesktopPolicyHealth } from "../claude/desktop-policy";
19
- import { collectClientConnectionStatus } from "./connect";
20
+ import { collectClientConnectionStatus, type ClientConnectionStatus } from "./connect";
21
+ import type { HubStateOAuthEntry, HubStateProvider } from "../remote/hub-state";
22
+ import type { HubStateSource } from "../client/hub-state";
23
+ import { readServiceApiTokenState, serviceApiTokenFilePath } from "../lib/service-secrets";
24
+ import { tokenCollidesWithAdmin } from "../lib/admin-secrets";
20
25
  export { proxyHealthFailureReason, isConnectionRefused, isUncleanExitEvidence, probeUncleanExitState } from "./status-probes";
21
26
  export type { ListenTarget } from "./status-probes";
22
27
  import { checkProxyHealth, probeUncleanExitState, type ListenTarget } from "./status-probes";
23
28
 
29
+ /**
30
+ * The state of the data-plane admission secret the SERVICE will use. State only -- never the value.
31
+ *
32
+ * Always about the file, because the file is what the service reads: the launchd plist and the
33
+ * systemd unit `cat` it into `OPENCODEX_API_AUTH_TOKEN` before exec, so a token in the CLI's own
34
+ * shell says nothing about the running hub. `present (env)` used to be reported here and was
35
+ * simply wrong about whose environment it meant (see `dataTokenEnvInShell`).
36
+ *
37
+ * `admin-collision (file)` is the #4236 incident shape: the file holds the MANAGEMENT token, so
38
+ * the server fences the whole management plane closed at boot and the hub crash-loops. It used
39
+ * to report `present (file)`, which is how the cause stayed invisible.
40
+ */
41
+ export type HubDataTokenState =
42
+ | "present (file)"
43
+ | "unsafe (file)"
44
+ | "admin-collision (file)"
45
+ | "missing";
46
+
47
+ export type HubStatus = {
48
+ /** Advertised data origin: hub.dataPublicOrigin, else derived from the bind address. */
49
+ dataOrigin: string;
50
+ /** True when dataOrigin came from config rather than being derived from the bind. */
51
+ dataOriginConfigured: boolean;
52
+ /**
53
+ * The unauthenticated loopback listener, in PR2's two forms: `companion` shares the public
54
+ * port (the one-port hub), `ported` binds its own. `off` means the hub does not serve its
55
+ * own local clients at all.
56
+ */
57
+ loopbackListener: { state: "off" | "companion" | "ported"; port: number | null };
58
+ managementIngress: { enabled: boolean; port: number | null };
59
+ managementPublicOrigin: string | null;
60
+ dataToken: HubDataTokenState;
61
+ dataTokenPath: string;
62
+ /**
63
+ * `OPENCODEX_API_AUTH_TOKEN` is set in the shell that ran `ocx status` — which is NOT the
64
+ * environment the installed service runs in. Reported separately, and honestly, because it
65
+ * does decide what a FOREGROUND `ocx start` in this same shell would admit.
66
+ */
67
+ dataTokenEnvInShell: boolean;
68
+ };
69
+
70
+ /**
71
+ * What a connected client learned from its hub, and how reliable that answer is (#4236).
72
+ *
73
+ * `stateSource` is the load-bearing field. "hub" is a live read; "cache" is the last good read
74
+ * from THIS connection, still the hub's state rather than this machine's; "unavailable" means
75
+ * nothing true is known, and the consumer must say so rather than substitute local facts. A
76
+ * client's own `providers`/`oauth` sections are empty by design, so presenting them as the
77
+ * answer is not a degraded report — it is a wrong one.
78
+ */
79
+ export type CliRemoteHubStatus = {
80
+ connected: boolean;
81
+ /** The hub's advertised data origin when it publishes one, else the URL this client dials. */
82
+ origin: string | null;
83
+ stateSource: HubStateSource;
84
+ /** Present whenever `stateSource` is not "hub". Operator-facing, never a bare error code. */
85
+ reason?: string;
86
+ fetchedAt?: string;
87
+ ageSeconds?: number;
88
+ hubVersion: string | null;
89
+ providers: HubStateProvider[];
90
+ oauth: HubStateOAuthEntry[];
91
+ subagentModels: string[];
92
+ /** The hub hit one of its own caps, so the lists above are a prefix and the report says so. */
93
+ truncated: boolean;
94
+ /** The hub's own Claude Code toggle; a client cannot infer it from local config. */
95
+ claudeCodeEnabled: boolean | null;
96
+ };
97
+
24
98
  export type CliStatusJson = {
25
99
  schemaVersion: 1;
100
+ /**
101
+ * This machine's topology role, named rather than inferred (#4236).
102
+ *
103
+ * Every other field in this report was already ambiguous without it: an agent reading
104
+ * `providers: {}` on a client could not tell "nothing is configured" from "the provider
105
+ * configuration lives on the hub". Additive, so `schemaVersion` stays 1.
106
+ */
107
+ runtimeRole: "standalone" | "hub" | "client";
26
108
  proxy: {
27
109
  running: boolean;
28
110
  pid: number | null;
@@ -67,6 +149,14 @@ export type CliStatusJson = {
67
149
  catalog?: "present" | "missing" | "unsafe";
68
150
  catalogAgeSeconds?: number;
69
151
  credentialFile: "owned" | "missing" | "changed" | "unsafe";
152
+ /**
153
+ * #4207: whether the selected local Codex CLI can consume the catalog this client
154
+ * installed. Absent unless a client connection exists. `connected` alone proved only the
155
+ * hub and the credential, and a reader who stopped there saw a healthy connection while
156
+ * `codex exec` was exiting before its first request.
157
+ */
158
+ readiness?: "ready" | "unverified" | "incompatible";
159
+ readinessReason?: string;
70
160
  };
71
161
  service: { summary: string };
72
162
  codexShim: { summary: string };
@@ -88,6 +178,26 @@ export type CliStatusJson = {
88
178
  desiredEnabled: boolean;
89
179
  policy: ClaudeDesktopPolicyHealth;
90
180
  };
181
+ /**
182
+ * The hub-only facts an operator needs in one place, or null on a standalone/client machine.
183
+ *
184
+ * Scattered across the report they were unusable: the public data origin came from `listen`,
185
+ * the management origin was folded into `dashboard.url`, the loopback companion appeared
186
+ * nowhere, and the data-plane token appeared nowhere at all -- so the one question a hub
187
+ * operator actually asks ("is this reachable, and can another machine join?") took four other
188
+ * commands to answer. Additive and nullable, so `schemaVersion` stays 1.
189
+ *
190
+ * Never carries a token value; only which source holds one.
191
+ */
192
+ hub: HubStatus | null;
193
+ /**
194
+ * The hub's answer on a connected client, or a not-connected placeholder (#4236).
195
+ *
196
+ * Separate from `connection`, which describes the LINK (is the key owned, is the catalog
197
+ * present). This describes what is on the other end of it. Additive and always present, so
198
+ * `schemaVersion` stays 1 and a consumer never has to branch on the key existing.
199
+ */
200
+ remoteHub: CliRemoteHubStatus;
91
201
  /**
92
202
  * This CLI's version against the running proxy's (#2701).
93
203
  *
@@ -119,6 +229,199 @@ function statusDashboardUrl(config: StatusListenConfig, hostname: string | undef
119
229
  return `http://${dashboardHostname}:${port}/`;
120
230
  }
121
231
 
232
+ /**
233
+ * The hub block, or null when this machine is not a hub.
234
+ *
235
+ * The token line is about the FILE, not this shell. `ocx status` used to print `present (env)`
236
+ * whenever the calling shell happened to export `OPENCODEX_API_AUTH_TOKEN`, but the service
237
+ * wrapper overwrites that variable from the token file before exec — so the label described the
238
+ * operator's terminal and not the hub. The shell's variable is reported as its own flag instead.
239
+ *
240
+ * The token VALUE is never read into the report. `readServiceApiTokenState` returns it; the only
241
+ * things derived from it are `kind` and the admin-token comparison, neither of which can carry
242
+ * bytes of the secret.
243
+ */
244
+ export function collectHubStatus(
245
+ config: Pick<OcxConfig, "runtimeRole" | "hostname" | "port" | "hub" | "unauthenticatedLoopbackListener">,
246
+ listen: { port: number; hostname?: string | null },
247
+ env: NodeJS.ProcessEnv = process.env,
248
+ ): HubStatus | null {
249
+ if (config.runtimeRole !== "hub") return null;
250
+ const listener = config.unauthenticatedLoopbackListener;
251
+ const loopbackPort = effectiveLoopbackListenerPort(config, listen.port);
252
+ const ingress = config.hub?.managementIngress;
253
+ const configuredDataOrigin = config.hub?.dataPublicOrigin;
254
+ const host = probeHostname(listen.hostname ?? config.hostname);
255
+ const tokenState = ((): HubDataTokenState => {
256
+ const state = readServiceApiTokenState();
257
+ if (state.kind === "unsafe") return "unsafe (file)";
258
+ if (state.kind !== "present") return "missing";
259
+ return tokenCollidesWithAdmin(state.token, env) ? "admin-collision (file)" : "present (file)";
260
+ })();
261
+ return {
262
+ dataOrigin: configuredDataOrigin
263
+ ?? `http://${host === "127.0.0.1" ? "localhost" : host}:${listen.port}`,
264
+ dataOriginConfigured: Boolean(configuredDataOrigin),
265
+ loopbackListener: loopbackPort === null
266
+ ? { state: "off", port: null }
267
+ : { state: listener?.enabled && listener.port === undefined ? "companion" : "ported", port: loopbackPort },
268
+ managementIngress: {
269
+ enabled: ingress?.enabled === true,
270
+ port: ingress?.enabled === true ? ingress.port : null,
271
+ },
272
+ managementPublicOrigin: config.hub?.managementPublicOrigin ?? null,
273
+ dataToken: tokenState,
274
+ dataTokenPath: serviceApiTokenFilePath(),
275
+ dataTokenEnvInShell: Boolean(env.OPENCODEX_API_AUTH_TOKEN?.trim()),
276
+ };
277
+ }
278
+
279
+ /**
280
+ * The human rendering of the hub block, owned here rather than in the `ocx status` printer so
281
+ * the sentences are testable without spawning the CLI. Indentation is the caller's.
282
+ */
283
+ export function hubStatusLines(hub: HubStatus): string[] {
284
+ const listener = hub.loopbackListener.state === "off"
285
+ ? "off — this hub does not route its own local Codex/Claude clients"
286
+ : hub.loopbackListener.state === "companion"
287
+ ? `companion on http://127.0.0.1:${hub.loopbackListener.port} — same port as the public listener, no credential needed locally`
288
+ : `ported on http://127.0.0.1:${hub.loopbackListener.port} — a second port local clients must be pointed at`;
289
+ const tokenLines = [` Data token: ${hub.dataToken}${hub.dataToken === "missing" ? "" : ` at ${hub.dataTokenPath}`}`];
290
+ if (hub.dataToken === "admin-collision (file)") {
291
+ // Naming the consequence matters more than naming the state: this is what a crash-looping
292
+ // hub looks like from `ocx status`, and nothing else in the report says so (#4236).
293
+ tokenLines.push(
294
+ " that file holds the MANAGEMENT token, so the hub fences its management API closed at boot —",
295
+ " delete it and run 'ocx service repair' to generate a data-plane token",
296
+ );
297
+ }
298
+ if (hub.dataTokenEnvInShell) {
299
+ tokenLines.push(" OPENCODEX_API_AUTH_TOKEN is also set in this shell; the installed service reads the file, not this");
300
+ }
301
+ return [
302
+ "Hub:",
303
+ ` Data origin: ${hub.dataOrigin}${hub.dataOriginConfigured ? " (hub.dataPublicOrigin)" : " (derived from the bind address)"}`,
304
+ ` Loopback listener: ${listener}`,
305
+ ` Management ingress: ${hub.managementIngress.enabled ? `http://127.0.0.1:${hub.managementIngress.port}` : "disabled"}`,
306
+ ` Management origin: ${hub.managementPublicOrigin ?? "unset — remote pairing and the remote dashboard need hub.managementPublicOrigin"}`,
307
+ ...tokenLines,
308
+ " Invite a machine: ocx hub invite",
309
+ ];
310
+ }
311
+
312
+ /** The not-connected placeholder. Arrays are empty because nothing was asked, not because nothing exists. */
313
+ export function disconnectedRemoteHubStatus(): CliRemoteHubStatus {
314
+ return {
315
+ connected: false,
316
+ origin: null,
317
+ stateSource: "unavailable",
318
+ hubVersion: null,
319
+ providers: [],
320
+ oauth: [],
321
+ subagentModels: [],
322
+ truncated: false,
323
+ claudeCodeEnabled: null,
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Ask the hub what it can serve, with a bounded read and a cache fallback.
329
+ *
330
+ * `ocx status` must answer while the hub is offline, so the fetch is bounded and a failure is
331
+ * reported rather than thrown. It must also never answer from local provider/login state — see
332
+ * `src/client/hub-state.ts` for why that substitution is the defect rather than a graceful
333
+ * degradation.
334
+ */
335
+ export async function collectRemoteHubStatus(
336
+ connection: Pick<ClientConnectionStatus, "state" | "serverUrl" | "apiKeyId" | "connectedAt">,
337
+ options: { fetchImpl?: typeof fetch; timeoutMs?: number; now?: number } = {},
338
+ ): Promise<CliRemoteHubStatus> {
339
+ if (connection.state !== "connected" || !connection.serverUrl || !connection.apiKeyId || !connection.connectedAt) {
340
+ return disconnectedRemoteHubStatus();
341
+ }
342
+ const { resolveHubState } = await import("../client/hub-state");
343
+ const token = readServiceApiTokenState();
344
+ const resolved = await resolveHubState({
345
+ owner: {
346
+ serverUrl: connection.serverUrl,
347
+ apiKeyId: connection.apiKeyId,
348
+ connectedAt: connection.connectedAt,
349
+ },
350
+ token: token.kind === "present" ? token.token : null,
351
+ ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
352
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
353
+ ...(options.now === undefined ? {} : { now: options.now }),
354
+ });
355
+ return {
356
+ connected: true,
357
+ // The hub's own advertised origin when it publishes one; otherwise the URL this client
358
+ // dials, which is the origin the operator would recognize.
359
+ origin: resolved.state?.origin ?? connection.serverUrl,
360
+ stateSource: resolved.stateSource,
361
+ ...(resolved.reason ? { reason: resolved.reason } : {}),
362
+ ...(resolved.fetchedAt ? { fetchedAt: resolved.fetchedAt } : {}),
363
+ ...(resolved.ageSeconds === undefined ? {} : { ageSeconds: resolved.ageSeconds }),
364
+ hubVersion: resolved.state?.hubVersion ?? null,
365
+ providers: resolved.state?.providers ?? [],
366
+ oauth: resolved.state?.oauth ?? [],
367
+ subagentModels: resolved.state?.subagentModels ?? [],
368
+ truncated: resolved.state?.truncated === true,
369
+ claudeCodeEnabled: resolved.state ? resolved.state.claudeCode.enabled : null,
370
+ };
371
+ }
372
+
373
+ /**
374
+ * The one line that has to be read before anything else in the report.
375
+ *
376
+ * It goes FIRST, above the proxy line, because the failure mode is an agent or operator reading
377
+ * the provider and login lines in isolation and concluding the hub cannot do something it can.
378
+ * A buried `Remote hub: connected (<url>)` line — which is all this report had — does not stop
379
+ * that, as #4236 demonstrated.
380
+ */
381
+ export function remoteHubBannerLine(remoteHub: CliRemoteHubStatus): string | null {
382
+ if (!remoteHub.connected) return null;
383
+ const origin = remoteHub.origin ?? "the hub";
384
+ if (remoteHub.stateSource === "unavailable") {
385
+ return `⚠️ Hub ${origin}: state unavailable (${remoteHub.reason ?? "unknown reason"}) — provider and login lines below are LOCAL and do not describe the hub.`;
386
+ }
387
+ const staleness = remoteHub.stateSource === "cache"
388
+ ? ` — cached ${remoteHub.ageSeconds ?? "?"}s ago (${remoteHub.reason ?? "live read failed"})`
389
+ : "";
390
+ return `🔗 State from hub ${origin}${staleness}: provider credentials, logins and delegable models below are the HUB's, not this machine's.`;
391
+ }
392
+
393
+ /**
394
+ * The hub-sourced provider/login/model block, owned here so the sentences are testable without
395
+ * spawning the CLI. Indentation is the caller's. Empty when nothing true is known.
396
+ */
397
+ export function remoteHubStatusLines(remoteHub: CliRemoteHubStatus): string[] {
398
+ if (!remoteHub.connected || remoteHub.stateSource === "unavailable") return [];
399
+ const origin = remoteHub.origin ?? "hub";
400
+ const lines = [
401
+ `OAuth logins (hub ${origin}):`,
402
+ ...(remoteHub.oauth.length === 0
403
+ ? [" (the hub reported no OAuth providers)"]
404
+ : remoteHub.oauth.map(entry => ` ${entry.provider.padEnd(10)} ${entry.loggedIn ? "✓ logged in" : "✗ not logged in"}`)),
405
+ `Providers (hub ${origin}):`,
406
+ ...(remoteHub.providers.length === 0
407
+ ? [" (the hub reported no providers)"]
408
+ : remoteHub.providers.map(provider => {
409
+ // `authMode` is what keeps "no API key" from reading as "not configured": an `oauth`
410
+ // provider legitimately has no key and is still fully usable.
411
+ const credential = provider.hasCredential ? "credential stored" : `no API key (authMode ${provider.authMode ?? "key"})`;
412
+ return ` ${provider.name.padEnd(10)} ${provider.adapter} — ${credential}${provider.disabled ? ", disabled" : ""}`;
413
+ })),
414
+ `Delegable models (hub ${origin}): ${remoteHub.subagentModels.length === 0 ? "none" : remoteHub.subagentModels.join(", ")}`,
415
+ ];
416
+ if (remoteHub.hubVersion) lines.push(`Hub version: ${remoteHub.hubVersion}`);
417
+ // The hub told us its own lists are a prefix. Saying nothing here would make this report claim
418
+ // completeness it does not have — the same shape of confident-and-wrong answer as #4236.
419
+ if (remoteHub.truncated) {
420
+ lines.push(" (the hub truncated this state to fit its response caps; some rows are not listed)");
421
+ }
422
+ return lines;
423
+ }
424
+
122
425
  export function selectListenTarget(
123
426
  config: StatusListenConfig,
124
427
  pid: number | null,
@@ -175,6 +478,10 @@ export async function collectStatus(): Promise<CliStatusView> {
175
478
  policy: claudeDesktopPolicyHealth(probeClaudeDesktopPolicy()),
176
479
  };
177
480
  const clientConnection = collectClientConnectionStatus();
481
+ // Asked before the local probes below so a connected client's report is hub-sourced from its
482
+ // first line. Bounded and failure-tolerant: an offline hub degrades the remoteHub block, it
483
+ // does not fail `ocx status`.
484
+ const remoteHub = await collectRemoteHubStatus(clientConnection);
178
485
  // Prefer identity-verified liveness (runtime-port + /healthz) over ocx.pid alone (#618).
179
486
  // Pass the already-resolved diagnostics config so findLiveProxy does not re-load and
180
487
  // warn on malformed config.json (status --json must stay stderr-clean).
@@ -274,7 +581,7 @@ export async function collectStatus(): Promise<CliStatusView> {
274
581
  }
275
582
  if (clampActive) {
276
583
  warningParts.push(
277
- `Catalog clamp removed: ${lastClamp!.removedEfforts.join(", ")}. Run ocx doctor for diagnosis and recovery.`,
584
+ `Catalog clamp removed: ${liveRemovedEfforts(lastClamp).join(", ")}. Run ocx doctor for diagnosis and recovery.`,
278
585
  );
279
586
  }
280
587
  // A Grok fence naming a port we are not listening on is invisible everywhere else:
@@ -282,7 +589,13 @@ export async function collectStatus(): Promise<CliStatusView> {
282
589
  // no log line — ever reaches us. Surface it here, where the live port is already known.
283
590
  const grokDrift = (() => {
284
591
  try {
285
- return grokFenceEndpointDrift(readGrokStatus(), health.ok ? listen.port : undefined);
592
+ // Locally reachable is a set: the public port plus the unauthenticated loopback
593
+ // listener's port. A fence naming either is correct; only a third port is drift (#4236).
594
+ return grokFenceEndpointDrift(
595
+ readGrokStatus(),
596
+ health.ok ? listen.port : undefined,
597
+ health.ok ? effectiveLoopbackListenerPort(config, listen.port) : null,
598
+ );
286
599
  } catch {
287
600
  return null; // reading grok's config must never break `ocx status`
288
601
  }
@@ -306,7 +619,9 @@ export async function collectStatus(): Promise<CliStatusView> {
306
619
  warning: warningParts.length > 0 ? warningParts.join(" ") : null,
307
620
  catalogClamp: {
308
621
  active: clampActive,
309
- removedEfforts: clampActive ? (lastClamp?.removedEfforts ?? []) : [],
622
+ // Report what is still clamped, not what the file happens to name: a leftover written
623
+ // before the max/ultra exemption lists rungs nothing removes any more.
624
+ removedEfforts: clampActive ? [...liveRemovedEfforts(lastClamp)] : [],
310
625
  runtimeVersion: clampActive ? (lastClamp?.runtimeVersion ?? null) : null,
311
626
  },
312
627
  };
@@ -325,6 +640,7 @@ export async function collectStatus(): Promise<CliStatusView> {
325
640
  healthLabel: health.label,
326
641
  json: {
327
642
  schemaVersion: 1,
643
+ runtimeRole: config.runtimeRole ?? "standalone",
328
644
  proxy: {
329
645
  running: Boolean(live) || Boolean(pid && health.ok),
330
646
  pid: live?.pid ?? pid,
@@ -350,6 +666,8 @@ export async function collectStatus(): Promise<CliStatusView> {
350
666
  source: bunRuntime.source,
351
667
  ...(bunRuntime.source === "override" ? { overrideEnv: bunRuntime.overrideEnv } : {}),
352
668
  },
669
+ hub: collectHubStatus(config, listen),
670
+ remoteHub,
353
671
  codexAutostart: codexAutoStartEnabled(config),
354
672
  startup,
355
673
  defaultProvider: typeof config.defaultProvider === "string" ? config.defaultProvider : null,
@@ -368,6 +686,8 @@ export async function collectStatus(): Promise<CliStatusView> {
368
686
  catalog: clientConnection.catalog,
369
687
  ...(clientConnection.catalogAgeSeconds !== undefined ? { catalogAgeSeconds: clientConnection.catalogAgeSeconds } : {}),
370
688
  credentialFile: clientConnection.token,
689
+ ...(clientConnection.readiness ? { readiness: clientConnection.readiness } : {}),
690
+ ...(clientConnection.readinessReason ? { readinessReason: clientConnection.readinessReason } : {}),
371
691
  },
372
692
  service: { summary: serviceSummary },
373
693
  codexShim: { summary: codexShimSummary },
@@ -66,7 +66,10 @@ export function computeVersionSkew(cliVersion: string, proxyVersion: string | un
66
66
  const order = cliSemver && proxySemver ? compareVersions(cliSemver, proxySemver) : 0;
67
67
  const advice = order > 0
68
68
  ? "the running proxy is older than this CLI. Restart the proxy using the intended current installation. "
69
- + "For a background service, run ocx service repair (ocx service restart is an alias)."
69
+ // `restart`, not `repair`: a version skew leaves the service DEFINITION unchanged, and
70
+ // repair reloads only when something changed, so it would no-op and keep the old
71
+ // process serving (#4249).
72
+ + "For a background service, run ocx service restart (repair reloads only a changed definition)."
70
73
  : order < 0
71
74
  ? "this ocx on PATH is older than the running proxy. Upgrade the CLI or resolve PATH to the intended installation."
72
75
  : "the versions differ, but neither can be identified as older. Check which installations the CLI and proxy use.";
package/src/cli.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  //
4
4
  // Before the src/ restructure the CLI lived at src/cli.ts, and durable launchers
5
5
  // (codex shim wrappers, installed service definitions) baked that absolute path
6
- // into their command lines. Users who upgrade in place with a bare
7
- // `npm install -g @bitkyc08/opencodex` (instead of `ocx update`, which repairs the
6
+ // into their command lines. Users who upgrade in place with a bare package-manager
7
+ // install (`npm install -g` or `pnpm add -g`) (instead of `ocx update`, which repairs the
8
8
  // shim/service) would otherwise be stranded on a dead path. Keep this stub for at
9
9
  // least one release cycle after the restructure ships.
10
10
  import "./cli/index.ts";
@@ -0,0 +1,192 @@
1
+ /**
2
+ * #4207: a connected client reported `connected` with a present, freshly synced catalog while
3
+ * its installed Codex CLI exited before making a request, because the hub's catalog contained a
4
+ * reasoning level that CLI does not know:
5
+ *
6
+ * failed to parse model_catalog_json ... unknown variant `max`,
7
+ * expected one of `none`, `minimal`, `low`, `medium`, `high`, `xhigh`
8
+ *
9
+ * The connection state answered a different question from the one the operator was asking. It
10
+ * proved the hub was reachable and the credential worked; it never proved the selected local
11
+ * runtime could consume what was downloaded. This module supplies the missing half, and the
12
+ * connect path fails closed on it: an incompatible catalog is refused before it is written, so
13
+ * the previous known-good file survives and no success is reported.
14
+ *
15
+ * What it deliberately does not do: rewrite the hub's catalog into a locally compatible
16
+ * projection (the client would then silently disagree with hub truth) and terminate running
17
+ * Codex processes. Both are ruled out by the issue.
18
+ */
19
+ import { catalogEffortCompatibility, codexSupportedReasoningEfforts } from "../codex/catalog/effort";
20
+ import type { RawEntry } from "../codex/catalog/parsing";
21
+
22
+ export type ClientCatalogCompatibility =
23
+ | { kind: "compatible" }
24
+ /** The runtime ladder could not be observed, so incompatibility cannot be established. */
25
+ | { kind: "unverified"; reason: string }
26
+ | {
27
+ kind: "incompatible";
28
+ unsupportedEfforts: readonly string[];
29
+ affectedModels: readonly string[];
30
+ };
31
+
32
+ export interface CatalogCompatibilityDeps {
33
+ /** Injected in tests; defaults to observing the selected local Codex runtime. */
34
+ supportedEfforts?: () => ReadonlySet<string> | null;
35
+ }
36
+
37
+ /** State of the materialized client catalog file, as `ocx connect status` already reports it. */
38
+ export type ClientCatalogFileState = "present" | "missing" | "unsafe";
39
+
40
+ /**
41
+ * Whether the selected local Codex runtime can consume the catalog that is *already on disk* —
42
+ * a different question from the write-time gate, and the one #4207 was actually asking.
43
+ *
44
+ * The gate runs once, on bytes about to be written. It cannot speak for a file that predates it,
45
+ * for a file written while the ladder was {@link ClientCatalogCompatibility} `unverified`, or for
46
+ * a runtime that was swapped after the write. Those are exactly the states that kept reporting
47
+ * `connected` while `codex exec` died on `unknown variant \`max\``.
48
+ *
49
+ * Only `ready` means ready. `unverified` is not `incompatible`: a client machine may legitimately
50
+ * have no observable Codex CLI, and calling that an incompatibility would condemn a working
51
+ * install on absent evidence — the same mistake the write-time gate refuses to make.
52
+ */
53
+ export type ClientCatalogReadiness =
54
+ | { kind: "ready" }
55
+ | { kind: "unverified"; reason: string }
56
+ | {
57
+ kind: "incompatible";
58
+ reason: string;
59
+ unsupportedEfforts: readonly string[];
60
+ affectedModels: readonly string[];
61
+ };
62
+
63
+ function parseModels(body: string): RawEntry[] | null {
64
+ try {
65
+ const parsed = JSON.parse(body) as { models?: unknown };
66
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
67
+ return Array.isArray(parsed.models) ? parsed.models as RawEntry[] : [];
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Assess a downloaded catalog against the reasoning efforts the selected local Codex runtime
75
+ * accepts. Unreadable bytes are reported as unverified rather than incompatible: the hub
76
+ * client already rejects a malformed body, and inventing a second cause for it here would
77
+ * repeat the mistake #4169 was filed for.
78
+ */
79
+ export function assessClientCatalogCompatibility(
80
+ body: string,
81
+ deps: CatalogCompatibilityDeps = {},
82
+ ): ClientCatalogCompatibility {
83
+ const models = parseModels(body);
84
+ if (!models) return { kind: "unverified", reason: "the downloaded catalog could not be read" };
85
+ const supported = (deps.supportedEfforts ?? (() => codexSupportedReasoningEfforts()))();
86
+ if (!supported) {
87
+ return {
88
+ kind: "unverified",
89
+ reason: "the selected local Codex runtime did not report the reasoning levels it supports",
90
+ };
91
+ }
92
+ const result = catalogEffortCompatibility(models, supported);
93
+ if (result.compatible) return { kind: "compatible" };
94
+ return {
95
+ kind: "incompatible",
96
+ unsupportedEfforts: result.unsupportedEfforts,
97
+ affectedModels: result.affectedModels,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Raised instead of writing an incompatible catalog. It names both remedies the issue asks
103
+ * for, because the operator cannot act on "incompatible" alone, and it never suggests editing
104
+ * the hub.
105
+ */
106
+ export class ClientCatalogIncompatibleError extends Error {
107
+ readonly unsupportedEfforts: readonly string[];
108
+ readonly affectedModels: readonly string[];
109
+
110
+ constructor(unsupportedEfforts: readonly string[], affectedModels: readonly string[]) {
111
+ const efforts = unsupportedEfforts.join(", ");
112
+ const models = affectedModels.length > 3
113
+ ? `${affectedModels.slice(0, 3).join(", ")} and ${affectedModels.length - 3} more`
114
+ : affectedModels.join(", ");
115
+ super(
116
+ `catalog_incompatible: the hub catalog uses reasoning ${unsupportedEfforts.length === 1 ? "level" : "levels"} `
117
+ + `${efforts}, which the selected local Codex CLI rejects${models ? ` (${models})` : ""}. `
118
+ + "The previous catalog was kept and nothing was changed. Upgrade the Codex CLI to a "
119
+ + "version that supports those levels, or point CODEX_CLI_PATH at one that does and run "
120
+ + "`ocx sync`, then retry. `ocx doctor` reports which runtime is selected.",
121
+ );
122
+ this.name = "ClientCatalogIncompatibleError";
123
+ this.unsupportedEfforts = unsupportedEfforts;
124
+ this.affectedModels = affectedModels;
125
+ }
126
+ }
127
+
128
+ /** Fail closed: refuse an incompatible catalog before anything is written. */
129
+ export function assertClientCatalogCompatible(body: string, deps: CatalogCompatibilityDeps = {}): void {
130
+ const assessment = assessClientCatalogCompatibility(body, deps);
131
+ if (assessment.kind !== "incompatible") return;
132
+ throw new ClientCatalogIncompatibleError(assessment.unsupportedEfforts, assessment.affectedModels);
133
+ }
134
+
135
+ /**
136
+ * Why an already-installed incompatible catalog does not reuse the refusal message above:
137
+ * nothing was kept back. The unusable bytes are the ones Codex will read on its next launch,
138
+ * so "the previous catalog was kept" would be false. The two remedies are the same, because
139
+ * the operator's options do not depend on when the file arrived.
140
+ */
141
+ function installedCatalogRejectionReason(
142
+ unsupportedEfforts: readonly string[],
143
+ affectedModels: readonly string[],
144
+ ): string {
145
+ const models = affectedModels.length > 3
146
+ ? `${affectedModels.slice(0, 3).join(", ")} and ${affectedModels.length - 3} more`
147
+ : affectedModels.join(", ");
148
+ return `the installed catalog uses reasoning ${unsupportedEfforts.length === 1 ? "level" : "levels"} `
149
+ + `${unsupportedEfforts.join(", ")}, which the selected local Codex CLI rejects`
150
+ + `${models ? ` (${models})` : ""}. Codex exits before its first request until the CLI is `
151
+ + "upgraded to a version that supports those levels, or CODEX_CLI_PATH points at one that "
152
+ + "does and `ocx sync` is run. `ocx doctor` reports which runtime is selected.";
153
+ }
154
+
155
+ /**
156
+ * Assess the catalog this machine has already installed, so a surface can stop calling a
157
+ * connection ready when the local runtime cannot launch against it.
158
+ *
159
+ * `body` is the file's bytes, or `null` when they could not be read; `file` is the state the
160
+ * caller already established by stat. Neither non-present file state is an incompatibility: an
161
+ * absent or non-regular catalog is a different fault, and this function only ever claims an
162
+ * incompatibility it has proven.
163
+ */
164
+ export function inspectClientCatalogReadiness(
165
+ file: ClientCatalogFileState,
166
+ body: string | null,
167
+ deps: CatalogCompatibilityDeps = {},
168
+ ): ClientCatalogReadiness {
169
+ if (file === "missing") {
170
+ return { kind: "unverified", reason: "no catalog is installed for the local Codex CLI to read" };
171
+ }
172
+ if (file === "unsafe") {
173
+ return { kind: "unverified", reason: "the catalog path is not a regular file, so its bytes were not read" };
174
+ }
175
+ if (body === null) return { kind: "unverified", reason: "the installed catalog could not be read" };
176
+ const assessment = assessClientCatalogCompatibility(body, deps);
177
+ if (assessment.kind === "compatible") return { kind: "ready" };
178
+ if (assessment.kind === "unverified") {
179
+ // assessClientCatalogCompatibility words its parse failure for bytes that have just been
180
+ // downloaded. These bytes are already installed, so blaming a download would send the
181
+ // operator to the wrong place; name the file that is actually unusable.
182
+ return parseModels(body) === null
183
+ ? { kind: "unverified", reason: "the installed catalog is not readable JSON, so the local Codex CLI cannot parse it either" }
184
+ : assessment;
185
+ }
186
+ return {
187
+ kind: "incompatible",
188
+ reason: installedCatalogRejectionReason(assessment.unsupportedEfforts, assessment.affectedModels),
189
+ unsupportedEfforts: assessment.unsupportedEfforts,
190
+ affectedModels: assessment.affectedModels,
191
+ };
192
+ }