@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
@@ -17,7 +17,7 @@ import { redactSecretString } from "../lib/redact";
17
17
 
18
18
  const USAGE = `Usage:
19
19
  ocx observe logs [--provider <name>] [--model <id>] [--status <code>]
20
- [--conversation <id>] [--limit <n>] [--follow] [--json|--jsonl]
20
+ [--conversation <id>] [--account <label>] [--limit <n>] [--follow] [--json|--jsonl]
21
21
  ocx logs explain <request-id> [--json]
22
22
  ocx logs rebuild-index
23
23
  ocx logs index-status
@@ -58,7 +58,14 @@ function formatLog(row: LogEntry): string {
58
58
  const conversation = typeof row.conversationId === "string" && row.conversationId.length > 0
59
59
  ? `conv=${row.conversationId}`
60
60
  : "";
61
- return [time, String(status), route, duration, conversation].filter(Boolean).join(" ");
61
+ // The account label is printed for the same reason, and for one more: it is the answer to
62
+ // "which of my accounts served this?" (#4057). It is only ever the stable non-PII label the
63
+ // proxy already persists (`main`, `p<hex6>`, `o<hex6>`) — never an email, a key, or an
64
+ // upstream account id. Rows from a single-account provider carry no label and print none.
65
+ const account = typeof row.accountLogLabel === "string" && row.accountLogLabel.length > 0
66
+ ? `acct=${row.accountLogLabel}`
67
+ : "";
68
+ return [time, String(status), route, duration, account, conversation].filter(Boolean).join(" ");
62
69
  }
63
70
 
64
71
  async function logs(argv: string[], deps: RuntimeApiDeps): Promise<void> {
@@ -72,6 +79,9 @@ async function logs(argv: string[], deps: RuntimeApiDeps): Promise<void> {
72
79
  // Both spellings, because the server accepts both (`request-log.ts:1032`) and an operator
73
80
  // should not have to remember which one this surface wanted.
74
81
  const conversationId = takeOption(args, "--conversation") ?? takeOption(args, "--conversationId");
82
+ // Server-side, so `--limit` caps the rows that MATCHED rather than the rows scanned; a
83
+ // client-side filter after a 200-row cap would silently hide older matches.
84
+ const account = takeOption(args, "--account");
75
85
  const limit = takeIntegerOption(args, "--limit", { min: 1 }) ?? 200;
76
86
  rejectArgs(args, USAGE);
77
87
  if (wantsJson && wantsJsonl) throw new CliUsageError("--json and --jsonl cannot be combined", USAGE);
@@ -80,7 +90,7 @@ async function logs(argv: string[], deps: RuntimeApiDeps): Promise<void> {
80
90
  }
81
91
  let seen = new Set<string>();
82
92
  do {
83
- const data = await runtimeRequest(`/api/logs${query({ provider, model, status, conversationId, limit })}`, {}, deps);
93
+ const data = await runtimeRequest(`/api/logs${query({ provider, model, status, conversationId, account, limit })}`, {}, deps);
84
94
  const rows = logRows(data);
85
95
  if (!follow && wantsJson) printData(data, true);
86
96
  else {
@@ -64,9 +64,17 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
64
64
  usage: "ocx service [install|repair|restart|start|stop|status|uninstall|remove]",
65
65
  summary: "Run as a background service.",
66
66
  details: [
67
- "With no subcommand, installs when absent or repairs/restarts an existing service.",
68
- "`restart` aliases `repair`; healthy Windows tasks are reused, while stale definitions may re-register and elevate.",
69
- "Use `ocx service status` to see diagnostics and log paths.",
67
+ "With no subcommand, installs when absent or repairs an existing service.",
68
+ "`repair` refreshes the definition and reloads the manager only when something changed, so repairing a healthy service is not an outage.",
69
+ "`restart` is the same refresh but always restarts: on macOS an unchanged, already-loaded job is kickstarted in place. Healthy Windows tasks are reused, while stale definitions may re-register and elevate.",
70
+ "Use `ocx service status` to see diagnostics and log paths. On macOS it reports four states:",
71
+ "loaded from the current plist, loaded from an OLDER plist (repair), not loaded (repair), or",
72
+ "`launchd state could not be verified` -- which is an unanswerable probe, NOT a down service.",
73
+ "Data-plane token: nothing has to be exported by hand. On a non-loopback hostname install/repair uses",
74
+ "OPENCODEX_API_AUTH_TOKEN when set, otherwise reuses the existing owner-only ~/.opencodex/service-api-token,",
75
+ "otherwise generates one; the launch wrapper reads that file at start and the value never enters a plist,",
76
+ "a unit file, or argv. An ADMIN token in OPENCODEX_API_AUTH_TOKEN is refused -- unset it and rerun.",
77
+ "Repair and restart never ask for the environment variable again once the token file exists.",
70
78
  ],
71
79
  },
72
80
  {
@@ -155,6 +163,38 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
155
163
  "The printed grant is secret, single-use, short-lived, and must not be persisted.",
156
164
  ],
157
165
  },
166
+ {
167
+ name: "hub",
168
+ usage: "ocx hub invite [--json] [--data-url <origin>] [--management-url <origin>] [--clients codex,claude]",
169
+ summary: "Hub-side commands. `invite` prints a ready-to-run `ocx connect` line for one more machine.",
170
+ details: [
171
+ "Topology: a hub serves ONE port. Remote machines dial `hostname:port` with their own per-client",
172
+ "key; the hub's own local processes dial `127.0.0.1:<same port>` with no credential, through the",
173
+ "loopback companion listener enabled by `unauthenticatedLoopbackListener: {\"enabled\": true}`.",
174
+ "The browser-facing management plane is separate: a loopback-only ingress published by an",
175
+ "operator-owned HTTPS frontend (Tailscale Serve) and advertised as hub.managementPublicOrigin.",
176
+ "",
177
+ "Nothing needs to be exported by hand. `ocx service install` provisions an owner-only data-plane",
178
+ "token at ~/.opencodex/service-api-token and the launch wrapper reads it at start; the value never",
179
+ "enters a plist, a unit file, argv, or the environment you typed in. Never copy that file to another",
180
+ "machine -- every client gets its own revocable key from the pairing exchange.",
181
+ "",
182
+ "invite requires a running hub and mints a single-use, short-lived pairing code through the same",
183
+ "attested local route `ocx gui pair` uses, then prints the exact command to run on the other",
184
+ "machine. The code is bound to hub.managementPublicOrigin and to the connecting machine's local",
185
+ "browser origin (`http://localhost:10100` unless corsAllowOrigins names another loopback origin);",
186
+ "the bound origin is always printed, and a different one names the port the client needs.",
187
+ "--data-url overrides the advertised data origin; hub.dataPublicOrigin is the persistent form, and",
188
+ "the fallback is http://<bind address>:<port>. A loopback or wildcard bind has no such address, so",
189
+ "invite refuses instead of advertising http://localhost:<port>, which would tell the other machine",
190
+ "to dial itself and spend the code.",
191
+ "--management-url is a CONFIRMATION, not an override: the grant is bound to",
192
+ "hub.managementPublicOrigin, so a differing value is refused instead of printed.",
193
+ "--clients chooses which client configs the printed ocx connect line will point at the hub.",
194
+ "--json emits { code, expiresAt, dataUrl, managementUrl, command }. Do not persist the code.",
195
+ "Hub state, including the data token and the companion listener, is reported by `ocx status`.",
196
+ ],
197
+ },
158
198
  {
159
199
  name: "update",
160
200
  usage: "ocx update [--tag latest|preview]",
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";