@bitkyc08/opencodex 2.7.35 → 2.7.36

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 (80) hide show
  1. package/README.ja.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +4 -2
  4. package/README.ru.md +1 -1
  5. package/README.zh-CN.md +1 -1
  6. package/bin/ocx.mjs +52 -0
  7. package/gui/dist/assets/index-BpX-hoSd.css +1 -0
  8. package/gui/dist/assets/index-ZmFopEYw.js +52 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -1
  11. package/src/adapters/cursor/cursor-errors.ts +38 -1
  12. package/src/adapters/cursor/discovery.ts +1 -0
  13. package/src/adapters/cursor/effort-map.ts +1 -0
  14. package/src/adapters/cursor/live-models.ts +22 -5
  15. package/src/adapters/cursor/live-transport.ts +82 -7
  16. package/src/adapters/cursor/transport.ts +2 -0
  17. package/src/adapters/cursor.ts +5 -2
  18. package/src/adapters/openai-responses.ts +64 -1
  19. package/src/cli/doctor.ts +10 -0
  20. package/src/cli/help.ts +10 -0
  21. package/src/cli/index.ts +88 -9
  22. package/src/cli/internal-dispatch.ts +20 -0
  23. package/src/cli/status.ts +15 -4
  24. package/src/cli/tray-proxy.ts +52 -0
  25. package/src/codex/auth-api.ts +46 -5
  26. package/src/codex/autostart-health.ts +149 -0
  27. package/src/codex/catalog/aggregation.ts +268 -0
  28. package/src/codex/catalog/bundled.ts +188 -0
  29. package/src/codex/catalog/effort.ts +263 -0
  30. package/src/codex/catalog/metadata.ts +176 -0
  31. package/src/codex/catalog/parsing.ts +399 -0
  32. package/src/codex/catalog/provider-fetch.ts +609 -0
  33. package/src/codex/catalog/sync.ts +540 -0
  34. package/src/codex/catalog.ts +11 -2426
  35. package/src/codex/inject.ts +165 -3
  36. package/src/codex/shim.ts +141 -8
  37. package/src/codex/sync.ts +17 -2
  38. package/src/config.ts +23 -0
  39. package/src/lib/errors.ts +11 -0
  40. package/src/providers/antigravity-models.ts +33 -0
  41. package/src/providers/kiro-models.ts +2 -0
  42. package/src/providers/registry.ts +2 -2
  43. package/src/responses/state.ts +69 -6
  44. package/src/server/auth-cors.ts +3 -0
  45. package/src/server/management/agent-settings-routes.ts +536 -0
  46. package/src/server/management/combo-routes.ts +210 -0
  47. package/src/server/management/config-routes.ts +302 -0
  48. package/src/server/management/context.ts +21 -0
  49. package/src/server/management/logs-usage-routes.ts +176 -0
  50. package/src/server/management/model-routes.ts +253 -0
  51. package/src/server/management/oauth-account-routes.ts +301 -0
  52. package/src/server/management/provider-routes.ts +408 -0
  53. package/src/server/management/shared.ts +186 -0
  54. package/src/server/management-api.ts +23 -1806
  55. package/src/server/responses/collaboration.ts +300 -0
  56. package/src/server/responses/compact.ts +342 -0
  57. package/src/server/responses/core.ts +1498 -0
  58. package/src/server/responses/encrypted-payload.ts +231 -0
  59. package/src/server/responses/fetch-helpers.ts +157 -0
  60. package/src/server/responses.ts +9 -2172
  61. package/src/server/startup-action-control.ts +41 -0
  62. package/src/server/startup-health-cache.ts +100 -0
  63. package/src/server/windows-tray-control.ts +41 -0
  64. package/src/service.ts +171 -19
  65. package/src/tray/assets/opencodex-tray-offline.ico +0 -0
  66. package/src/tray/assets/opencodex-tray-online.ico +0 -0
  67. package/src/tray/assets/opencodex-tray-warning.ico +0 -0
  68. package/src/tray/assets/opencodex-tray.png +0 -0
  69. package/src/tray/windows-tray.ps1 +290 -0
  70. package/src/tray/windows.ts +628 -0
  71. package/src/types.ts +5 -0
  72. package/src/update/index.ts +43 -0
  73. package/src/update/job.ts +46 -0
  74. package/src/update/tray-update-plan.d.mts +18 -0
  75. package/src/update/tray-update-plan.mjs +38 -0
  76. package/src/usage/cost.ts +0 -0
  77. package/src/usage/expected-prices.ts +9 -2
  78. package/src/usage/summary.ts +42 -7
  79. package/gui/dist/assets/index-BunUANVE.js +0 -52
  80. package/gui/dist/assets/index-Sg-7L_oZ.css +0 -1
package/src/cli/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { spawn } from "node:child_process";
3
3
  import { rmSync } from "node:fs";
4
- import { restoreNativeCodex, shouldInjectApiAuthHeader } from "../codex/inject";
4
+ import { currentExternalCodexModelProvider, restoreNativeCodex, shouldInjectApiAuthHeader } from "../codex/inject";
5
5
  import { restoreLegacyOpenaiHistory } from "../codex/history-provider";
6
6
  import { writeJournal, reconcileJournal } from "../codex/journal";
7
7
  import {
@@ -20,13 +20,16 @@ import {
20
20
  writeRuntimePort,
21
21
  } from "../config";
22
22
  import { collectStatus } from "./status";
23
+ import { dispatchInternalCliCommand, type InternalCliCommand } from "./internal-dispatch";
24
+ import { runTrayProxyRestart, runTrayProxyStart } from "./tray-proxy";
23
25
  import { installCrashGuards } from "../lib/crash-guard";
24
26
  import { hasHelpFlag, printSubcommandUsage, printUsage, printVersion } from "./help";
25
27
  import { findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports";
26
28
  import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
27
29
  import { stopProxy } from "../lib/process-control";
28
30
  import { loadServiceTokenFromFile } from "../lib/service-secrets";
29
- import { serviceCommand, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service";
31
+ import { diagnoseService, serviceCommand, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service";
32
+ import { startupHealthSummary } from "../codex/autostart-health";
30
33
  import { drainAndShutdown, startServer } from "../server";
31
34
  import { injectSystemEnv, revertSystemEnv } from "../server/system-env";
32
35
  import { buildDesktop3pRegistry } from "../claude/desktop-3p";
@@ -131,7 +134,7 @@ async function handleStart(options: { block?: boolean } = {}) {
131
134
  const serviceToken = loadServiceTokenFromFile(process.env);
132
135
  if (serviceToken) process.env.OPENCODEX_API_AUTH_TOKEN = serviceToken;
133
136
  const requestedPort = parsePortOption();
134
- reconcileJournal();
137
+ if (!currentExternalCodexModelProvider()) reconcileJournal();
135
138
  const existingPid = readPid();
136
139
  if (existingPid) {
137
140
  const live = await findLiveProxy();
@@ -179,7 +182,7 @@ async function handleStart(options: { block?: boolean } = {}) {
179
182
 
180
183
  const config = loadConfig();
181
184
  writeRuntimePort({ pid: process.pid, port, hostname: config.hostname });
182
- writeJournal();
185
+ if (!currentExternalCodexModelProvider()) writeJournal();
183
186
 
184
187
  // Background proactive token refresh. No-op unless config.tokenGuardian.enabled; timer is unref'd
185
188
  // so it never keeps the process alive on its own. Stopped in syncCleanup so no refresh fires mid-drain.
@@ -187,9 +190,7 @@ async function handleStart(options: { block?: boolean } = {}) {
187
190
  // Design B upgrade path: keep retrying the one-time opencodex→openai history migration in the
188
191
  // background — the first `ocx start` after an update usually races the Codex app's DB lock.
189
192
  // Loopback-only (legacy mode still forward-tags) and respects syncResumeHistory opt-out.
190
- const historyGuardian = !shouldInjectApiAuthHeader(config) && config.syncResumeHistory !== false
191
- ? startHistoryMigrationGuardian()
192
- : undefined;
193
+ let historyGuardian: ReturnType<typeof startHistoryMigrationGuardian> | undefined;
193
194
 
194
195
  let cleaned = false;
195
196
  const syncCleanup = () => {
@@ -200,7 +201,9 @@ async function handleStart(options: { block?: boolean } = {}) {
200
201
  try { revertSystemEnv(); } catch { /* best-effort */ }
201
202
  removePid(process.pid);
202
203
  removeRuntimePort(process.pid);
203
- if (!process.env.OCX_SERVICE) { try { restoreNativeCodex(); } catch { /* best-effort restore */ } }
204
+ if (!process.env.OCX_SERVICE && !currentExternalCodexModelProvider()) {
205
+ try { restoreNativeCodex(); } catch { /* best-effort restore */ }
206
+ }
204
207
  };
205
208
 
206
209
  let shuttingDown = false;
@@ -246,6 +249,9 @@ async function handleStart(options: { block?: boolean } = {}) {
246
249
 
247
250
  await maybeShowStarPrompt(); // once-only [Y/n] GitHub-star prompt on first interactive start
248
251
  await syncModelsToCodex(port).catch(() => {});
252
+ if (!currentExternalCodexModelProvider() && !shouldInjectApiAuthHeader(config) && config.syncResumeHistory !== false) {
253
+ historyGuardian = startHistoryMigrationGuardian();
254
+ }
249
255
  // Build Desktop 3P alias registry so inbound claude-opus-4-8-{code} aliases (and legacy claude-opus-4-{code}) decode correctly.
250
256
  try {
251
257
  const { fetchAllModels } = await import("../server/management-api");
@@ -263,7 +269,7 @@ async function handleStart(options: { block?: boolean } = {}) {
263
269
  }
264
270
 
265
271
  async function handleEnsure() {
266
- reconcileJournal();
272
+ if (!currentExternalCodexModelProvider()) reconcileJournal();
267
273
  const config = loadConfig();
268
274
  if (!codexAutoStartEnabled(config)) {
269
275
  console.log("Codex autostart is disabled.");
@@ -302,6 +308,47 @@ async function handleEnsure() {
302
308
  console.log(`✅ Proxy running on port ${port}`);
303
309
  }
304
310
 
311
+ /** Fixed tray action: start the proxy without depending on codexAutoStart. */
312
+ async function handleTrayProxyStart(): Promise<void> {
313
+ const ok = await runTrayProxyStart({
314
+ findLive: findLiveProxy,
315
+ diagnoseService: () => {
316
+ const service = diagnoseService();
317
+ return { installed: service.installed, startable: serviceStartableFromTray(service), summary: service.summary };
318
+ },
319
+ startService: () => serviceCommand("start"),
320
+ startDirect: () => {
321
+ const config = loadConfig();
322
+ const port = (config.port ?? 10100) > 0 ? (config.port ?? 10100) : 10100;
323
+ const child = spawn(process.execPath, startArgv(port), {
324
+ detached: true,
325
+ stdio: "ignore",
326
+ windowsHide: true,
327
+ env: { ...process.env, OCX_SERVICE: "1" },
328
+ });
329
+ child.unref();
330
+ },
331
+ waitForProxy,
332
+ info: message => console.log(message),
333
+ error: message => console.error(message),
334
+ });
335
+ if (!ok) process.exitCode = 1;
336
+ }
337
+
338
+ async function handleTrayProxyRestart(): Promise<void> {
339
+ const ok = await runTrayProxyRestart({
340
+ stop: async () => {
341
+ await handleStop();
342
+ return !process.exitCode || process.exitCode === 0;
343
+ },
344
+ start: async () => {
345
+ await handleTrayProxyStart();
346
+ return !process.exitCode || process.exitCode === 0;
347
+ },
348
+ });
349
+ if (!ok) process.exitCode = 1;
350
+ }
351
+
305
352
  async function handleStop() {
306
353
  const stoppedService = stopServiceIfInstalled();
307
354
  if (stoppedService) console.log("🛑 Service manager stopped (won't respawn).");
@@ -382,6 +429,15 @@ async function handleUninstall() {
382
429
 
383
430
  await runStep("service removed", () => uninstallServiceIfInstalled());
384
431
 
432
+ if (process.platform === "win32") {
433
+ await runStep("Windows tray removed", async () => {
434
+ const { getWindowsTrayStatus, uninstallWindowsTray } = await import("../tray/windows");
435
+ const tray = getWindowsTrayStatus();
436
+ if (!tray.installed && !tray.stale && !tray.running) return false;
437
+ uninstallWindowsTray();
438
+ });
439
+ }
440
+
385
441
  await runStep("native Codex restored", () => {
386
442
  const r = restoreNativeCodex();
387
443
  if (!r.success) throw new Error(r.message);
@@ -448,6 +504,7 @@ async function handleStatus() {
448
504
  console.log(` Runtime source: ${status.json.runtime.source}${status.json.runtime.overrideEnv ? ` (${status.json.runtime.overrideEnv})` : ""}`);
449
505
  console.log(` Default provider: ${status.json.defaultProvider}`);
450
506
  console.log(` Codex autostart: ${status.json.codexAutostart ? "enabled" : "disabled"}`);
507
+ console.log(` Restart safety: ${startupHealthSummary(status.json.startup)}`);
451
508
  console.log(` Service: ${status.json.service.summary}`);
452
509
  console.log(` ${status.json.codexShim.summary}`);
453
510
  if (status.json.codexPlugins.applicable) {
@@ -594,6 +651,11 @@ switch (command) {
594
651
  case "service":
595
652
  await serviceCommand(...args.slice(1));
596
653
  break;
654
+ case "tray": {
655
+ const { windowsTrayCommand } = await import("../tray/windows");
656
+ await windowsTrayCommand(args.slice(1));
657
+ break;
658
+ }
597
659
  case "codex-shim": {
598
660
  const { codexShimStatus, installCodexShim, uninstallCodexShim } = await import("../codex/shim");
599
661
  switch (args[1]) {
@@ -636,6 +698,23 @@ switch (command) {
636
698
  await refreshVersionCache(channel);
637
699
  break;
638
700
  }
701
+ case "__tray-start":
702
+ case "__tray-restart":
703
+ case "__startup-health":
704
+ await dispatchInternalCliCommand(command as InternalCliCommand, {
705
+ trayStart: handleTrayProxyStart,
706
+ trayRestart: handleTrayProxyRestart,
707
+ startupHealth: async () => {
708
+ const { collectStartupHealth } = await import("../codex/autostart-health");
709
+ console.log(JSON.stringify(collectStartupHealth(loadConfig())));
710
+ },
711
+ });
712
+ break;
713
+ case "__tray-host": {
714
+ const { runWindowsTrayHost } = await import("../tray/windows");
715
+ await runWindowsTrayHost();
716
+ break;
717
+ }
639
718
  case "__gui-update-worker": {
640
719
  const jobId = args[1];
641
720
  if (!jobId) process.exit(1);
@@ -0,0 +1,20 @@
1
+ export type InternalCliCommand = "__tray-start" | "__tray-restart" | "__startup-health";
2
+
3
+ export interface InternalCliHandlers {
4
+ trayStart: () => void | Promise<void>;
5
+ trayRestart: () => void | Promise<void>;
6
+ startupHealth: () => void | Promise<void>;
7
+ }
8
+
9
+ /** Dispatch fixed internal commands without accepting caller-selected process arguments. */
10
+ export async function dispatchInternalCliCommand(
11
+ command: InternalCliCommand,
12
+ handlers: InternalCliHandlers,
13
+ ): Promise<void> {
14
+ switch (command) {
15
+ case "__tray-start": return void await handlers.trayStart();
16
+ case "__tray-restart": return void await handlers.trayRestart();
17
+ case "__startup-health": return void await handlers.startupHealth();
18
+ default: throw new Error(`Unsupported internal CLI command: ${String(command)}`);
19
+ }
20
+ }
package/src/cli/status.ts CHANGED
@@ -3,7 +3,10 @@ import { codexAutoStartEnabled, getConfigPath, getPidPath, readConfigDiagnostics
3
3
  import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../codex/plugins-doctor";
4
4
  import { isOpencodexHealthz, probeHostname } from "../server/proxy-liveness";
5
5
  import type { OcxConfig } from "../types";
6
- import { serviceStatusSummary } from "../service";
6
+ import { diagnoseService } from "../service";
7
+ import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health";
8
+ import { getCodexRoutingKind } from "../codex/inject";
9
+ import { diagnoseCodexShim } from "../codex/shim";
7
10
 
8
11
  type HealthCheck = {
9
12
  ok: boolean;
@@ -39,6 +42,7 @@ export type CliStatusJson = {
39
42
  overrideEnv?: string;
40
43
  };
41
44
  codexAutostart: boolean;
45
+ startup: StartupHealth;
42
46
  defaultProvider: string | null;
43
47
  config: {
44
48
  source: "default" | "file" | "fallback";
@@ -115,9 +119,15 @@ export async function collectStatus(): Promise<CliStatusView> {
115
119
  const listen = selectListenTarget(config, pid, pid ? readRuntimePort(pid) : null);
116
120
  const health = await checkProxyHealth(listen);
117
121
  const bunRuntime = durableBunRuntime();
118
- const serviceSummary = serviceStatusSummary();
119
- const { codexShimStatus } = await import("../codex/shim");
120
- const codexShimSummary = codexShimStatus();
122
+ const service = diagnoseService();
123
+ const serviceSummary = service.summary;
124
+ const codexShim = diagnoseCodexShim();
125
+ const codexShimSummary = codexShim.summary;
126
+ const startup = collectStartupHealth(config, {
127
+ service,
128
+ shim: codexShim,
129
+ routingKind: getCodexRoutingKind(),
130
+ });
121
131
  const codexPlugins = diagnoseCodexBundledPlugins();
122
132
  const proxyLabel = pid && health.ok
123
133
  ? `running (PID ${pid})`
@@ -157,6 +167,7 @@ export async function collectStatus(): Promise<CliStatusView> {
157
167
  ...(bunRuntime.source === "override" ? { overrideEnv: bunRuntime.overrideEnv } : {}),
158
168
  },
159
169
  codexAutostart: codexAutoStartEnabled(config),
170
+ startup,
160
171
  defaultProvider: typeof config.defaultProvider === "string" ? config.defaultProvider : null,
161
172
  config: {
162
173
  source: configDiagnostics.source,
@@ -0,0 +1,52 @@
1
+ export interface TrayProxyLive { port: number }
2
+
3
+ export interface TrayProxyServiceState {
4
+ installed: boolean;
5
+ startable: boolean;
6
+ summary: string;
7
+ }
8
+
9
+ export interface TrayProxyStartIo {
10
+ findLive: () => Promise<TrayProxyLive | null>;
11
+ diagnoseService: () => TrayProxyServiceState;
12
+ startService: () => void | Promise<void>;
13
+ startDirect: () => void | Promise<void>;
14
+ waitForProxy: () => Promise<TrayProxyLive | null>;
15
+ info: (message: string) => void;
16
+ error: (message: string) => void;
17
+ }
18
+
19
+ /** Side-effect coordinator for the tray's fixed proxy-start action. */
20
+ export async function runTrayProxyStart(io: TrayProxyStartIo): Promise<boolean> {
21
+ const live = await io.findLive();
22
+ if (live) {
23
+ io.info(`Proxy already running on port ${live.port}.`);
24
+ return true;
25
+ }
26
+
27
+ const service = io.diagnoseService();
28
+ if (service.installed && !service.startable) {
29
+ io.error(`Cannot start from the tray because the installed service is not viable: ${service.summary}`);
30
+ io.error("Repair or remove the service before starting a direct proxy.");
31
+ return false;
32
+ }
33
+
34
+ if (service.startable) await io.startService();
35
+ else await io.startDirect();
36
+
37
+ const started = await io.waitForProxy();
38
+ if (!started) {
39
+ io.error("Proxy did not become healthy after the tray start action.");
40
+ return false;
41
+ }
42
+ io.info(`Proxy running on port ${started.port}.`);
43
+ return true;
44
+ }
45
+
46
+ export async function runTrayProxyRestart(io: {
47
+ stop: () => boolean | Promise<boolean>;
48
+ start: () => boolean | Promise<boolean>;
49
+ }): Promise<boolean> {
50
+ if (!await io.stop()) return false;
51
+ return io.start();
52
+ }
@@ -13,7 +13,7 @@ import { deleteCodexAccount } from "./account-lifecycle";
13
13
  import { checkAccountIdCollision, readCodexTokens } from "./auth-collision";
14
14
  export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision";
15
15
  export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
16
- import { clearAccountNeedsReauth, isAccountNeedsReauth } from "./account-runtime-state";
16
+ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
17
17
  import {
18
18
  clearAccountQuota,
19
19
  getAccountQuota,
@@ -32,6 +32,7 @@ export { maskEmail } from "../lib/privacy";
32
32
  import type { CodexAccount, OcxConfig } from "../types";
33
33
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
34
34
  import { providerCodexAccountMode } from "../providers/registry";
35
+ import { readBoundedResponseBody } from "../lib/bounded-body";
35
36
 
36
37
  function jsonResponse(data: unknown, status = 200): Response {
37
38
  return new Response(JSON.stringify(data), {
@@ -217,18 +218,55 @@ async function mapWithConcurrency<T, R>(
217
218
  return results;
218
219
  }
219
220
 
221
+ const MAIN_TERMINAL_AUTH_CODES = new Set([
222
+ "invalid_workspace_selected",
223
+ "invalid_refresh_token",
224
+ ]);
225
+
226
+ async function isTerminalMainAuthResponse(resp: Response): Promise<boolean> {
227
+ if (resp.status === 401) return true;
228
+ if (resp.status !== 403) return false;
229
+ try {
230
+ const body = await readBoundedResponseBody(resp, { totalTimeoutMs: 1_000, inactivityTimeoutMs: 1_000 });
231
+ if (!body.displaySafe) return false;
232
+ const parsed = JSON.parse(body.text) as {
233
+ detail?: { code?: unknown } | string;
234
+ error?: { code?: unknown } | string;
235
+ code?: unknown;
236
+ };
237
+ const code = typeof parsed.detail === "object" && parsed.detail !== null
238
+ ? parsed.detail.code
239
+ : typeof parsed.error === "object" && parsed.error !== null
240
+ ? parsed.error.code
241
+ : parsed.code;
242
+ return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
243
+ } catch {
244
+ return false;
245
+ }
246
+ }
247
+
220
248
  export async function fetchMainAccountInfo(forceRefresh = false): Promise<{ email: string | null; plan: string | null; quota: Omit<StoredAccountQuota, "updatedAt"> | null }> {
249
+ const tokens = readCodexTokens();
250
+ if (!tokens) {
251
+ mainAccountCache = null;
252
+ markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
253
+ return { email: null, plan: null, quota: null };
254
+ }
221
255
  if (!forceRefresh && mainAccountCache && Date.now() - mainAccountCache.ts < MAIN_CACHE_TTL) {
222
256
  return mainAccountCache;
223
257
  }
224
- const tokens = readCodexTokens();
225
- if (!tokens) return { email: null, plan: null, quota: null };
226
258
  try {
227
259
  const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
228
260
  headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
229
261
  signal: AbortSignal.timeout(8000),
230
262
  });
231
- if (!resp.ok) return { email: null, plan: null, quota: null };
263
+ if (!resp.ok) {
264
+ if (await isTerminalMainAuthResponse(resp)) {
265
+ mainAccountCache = null;
266
+ markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
267
+ }
268
+ return { email: null, plan: null, quota: null };
269
+ }
232
270
  const data = (await resp.json()) as WhamUsageResponse;
233
271
  const result = {
234
272
  email: data.email ?? null,
@@ -237,6 +275,7 @@ export async function fetchMainAccountInfo(forceRefresh = false): Promise<{ emai
237
275
  ts: Date.now(),
238
276
  };
239
277
  mainAccountCache = result;
278
+ clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
240
279
  // Mirror main quota + plan into the shared stores so the rotation engine can
241
280
  // score and auto-switch the main account exactly like a pool account (Option A).
242
281
  setMainAccountPlan(result.plan);
@@ -373,12 +412,14 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa
373
412
  : { quota: null, needsReauth: true };
374
413
  return poolAccountDto(a, quotaResult, !!cred);
375
414
  });
415
+ const hasMainCredential = readCodexTokens() !== null;
376
416
  const main: CodexAuthAccountDto = {
377
417
  id: MAIN_CODEX_ACCOUNT_ID,
378
418
  email: maskEmail(mainInfo.email) ?? "Codex App login",
379
419
  plan: mainInfo.plan,
380
420
  isMain: true,
381
- hasCredential: true,
421
+ hasCredential: hasMainCredential,
422
+ needsReauth: !hasMainCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID),
382
423
  quota: mainInfo.quota ? { ...quotaForPlan({ ...mainInfo.quota, updatedAt: Date.now() }, mainInfo.plan) } : null,
383
424
  };
384
425
  return [main, ...withQuota];
@@ -0,0 +1,149 @@
1
+ import { codexAutoStartEnabled } from "../config";
2
+ import { diagnoseService, type ServiceDiagnostic } from "../service";
3
+ import type { OcxConfig } from "../types";
4
+ import { getCodexRoutingKind, type CodexRoutingKind } from "./inject";
5
+ import { diagnoseCodexShim, type CodexShimDiagnostic } from "./shim";
6
+
7
+ export type StartupProtection = "service" | "shim" | "none";
8
+ export type StartupHealthStatus = "native" | "protected" | "at-risk";
9
+ export type ShimCoverage = "full" | "cli-only" | "none";
10
+
11
+ export interface StartupHealthInputs {
12
+ routingKind: CodexRoutingKind;
13
+ autostartEnabled: boolean;
14
+ serviceInstalled: boolean;
15
+ serviceViable: boolean;
16
+ serviceEnabled: boolean;
17
+ serviceRunning: boolean;
18
+ serviceStale: boolean;
19
+ serviceConflict: boolean;
20
+ serviceSupported: boolean;
21
+ shimInstalled: boolean;
22
+ shimHealthy: boolean;
23
+ platform: NodeJS.Platform;
24
+ diagnosticStale?: boolean;
25
+ }
26
+
27
+ export interface StartupHealth {
28
+ status: StartupHealthStatus;
29
+ routingKind: CodexRoutingKind;
30
+ routingInjected: boolean;
31
+ localRoutingDependency: boolean;
32
+ autostartEnabled: boolean;
33
+ rebootSafe: boolean;
34
+ protection: StartupProtection;
35
+ serviceInstalled: boolean;
36
+ serviceViable: boolean;
37
+ serviceEnabled: boolean;
38
+ serviceRunning: boolean;
39
+ serviceStale: boolean;
40
+ serviceConflict: boolean;
41
+ shimInstalled: boolean;
42
+ shimHealthy: boolean;
43
+ shimCoverage: ShimCoverage;
44
+ serviceSupported: boolean;
45
+ platform: NodeJS.Platform;
46
+ diagnosticStale: boolean;
47
+ recommendedCommand: string | null;
48
+ commands: {
49
+ installService: string;
50
+ installShim: string;
51
+ restoreNative: string;
52
+ };
53
+ }
54
+
55
+ const COMMANDS = {
56
+ installService: "ocx service install",
57
+ installShim: "ocx codex-shim install",
58
+ restoreNative: "ocx restore",
59
+ } as const;
60
+
61
+ export function deriveStartupHealth(inputs: StartupHealthInputs): StartupHealth {
62
+ const shimEffective = inputs.autostartEnabled && inputs.shimHealthy;
63
+ const routingInjected = inputs.routingKind === "opencodex-local";
64
+ const localRoutingDependency = inputs.routingKind === "opencodex-local"
65
+ || inputs.routingKind === "custom-local"
66
+ || inputs.routingKind === "unknown";
67
+ // Script launchers never cover Codex Desktop/app-server surfaces. This is
68
+ // intentionally conservative on every OS and for WSL-shared Codex homes.
69
+ const shimCoverage: ShimCoverage = !shimEffective
70
+ ? "none"
71
+ : "cli-only";
72
+ // We can only credit an opencodex service/shim for routing that opencodex owns.
73
+ // An arbitrary localhost gateway has an independent lifecycle that OCX cannot repair.
74
+ const ownsLocalRouting = inputs.routingKind === "opencodex-local";
75
+ const protection: StartupProtection = ownsLocalRouting && inputs.serviceViable
76
+ ? "service"
77
+ : ownsLocalRouting && shimEffective
78
+ ? "shim"
79
+ : "none";
80
+ const rebootSafe = !localRoutingDependency || (ownsLocalRouting && inputs.serviceViable);
81
+ const status: StartupHealthStatus = !localRoutingDependency
82
+ ? "native"
83
+ : rebootSafe
84
+ ? "protected"
85
+ : "at-risk";
86
+ const recommendedCommand = status !== "at-risk"
87
+ ? null
88
+ : inputs.routingKind === "custom-local" || inputs.routingKind === "unknown"
89
+ ? COMMANDS.restoreNative
90
+ : inputs.serviceSupported
91
+ ? COMMANDS.installService
92
+ : COMMANDS.restoreNative;
93
+ return {
94
+ ...inputs,
95
+ diagnosticStale: inputs.diagnosticStale ?? false,
96
+ routingInjected,
97
+ localRoutingDependency,
98
+ status,
99
+ rebootSafe,
100
+ protection,
101
+ shimCoverage,
102
+ recommendedCommand,
103
+ commands: { ...COMMANDS },
104
+ };
105
+ }
106
+
107
+ export interface StartupHealthDiagnostics {
108
+ routingKind?: CodexRoutingKind;
109
+ service?: ServiceDiagnostic;
110
+ shim?: CodexShimDiagnostic;
111
+ }
112
+
113
+ /** Collect current machine state without mutating config, services, or shims. */
114
+ export function collectStartupHealth(
115
+ config: Pick<OcxConfig, "codexAutoStart">,
116
+ diagnostics: StartupHealthDiagnostics = {},
117
+ ): StartupHealth {
118
+ const shim = diagnostics.shim ?? diagnoseCodexShim();
119
+ const service = diagnostics.service ?? diagnoseService();
120
+ return deriveStartupHealth({
121
+ routingKind: diagnostics.routingKind ?? getCodexRoutingKind(),
122
+ autostartEnabled: codexAutoStartEnabled(config),
123
+ serviceInstalled: service.installed,
124
+ serviceViable: service.viable,
125
+ serviceEnabled: service.enabled,
126
+ serviceRunning: service.running,
127
+ serviceStale: service.stale,
128
+ serviceConflict: service.conflict,
129
+ serviceSupported: service.supported,
130
+ shimInstalled: shim.installed,
131
+ shimHealthy: shim.healthy,
132
+ platform: process.platform,
133
+ });
134
+ }
135
+
136
+ export function startupHealthSummary(health: StartupHealth): string {
137
+ if (health.status === "native") return health.routingKind === "custom-remote"
138
+ ? "custom remote Codex routing (no local restart dependency)"
139
+ : "native Codex routing (no opencodex restart dependency)";
140
+ if (health.protection === "service") return "protected by background service";
141
+ const command = health.recommendedCommand ?? health.commands.restoreNative;
142
+ if (health.routingKind === "unknown") return `AT RISK after restart (Codex routing could not be verified; run '${command}')`;
143
+ if (health.routingKind === "custom-local") return `AT RISK after restart (custom local gateway lifecycle is not managed by opencodex; run '${command}')`;
144
+ if (health.shimCoverage === "cli-only") return `AT RISK for Codex Desktop after restart (launcher shim covers CLI scripts only; run '${command}')`;
145
+ if (health.serviceConflict) return `AT RISK after restart (background service managers conflict; run '${command}')`;
146
+ if (health.serviceStale) return `AT RISK after restart (background service files are stale; run '${command}')`;
147
+ if (health.serviceInstalled && !health.serviceViable) return `AT RISK after restart (installed service is disabled, stopped, or unhealthy; run '${command}')`;
148
+ return `AT RISK after restart (no viable background service; run '${command}')`;
149
+ }