@bitkyc08/opencodex 2.7.40 → 2.7.41

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 (110) hide show
  1. package/README.md +63 -12
  2. package/assets/claude-code-models.gif +0 -0
  3. package/gui/dist/assets/index-B2J4t3te.css +1 -0
  4. package/gui/dist/assets/index-BmvM6wRb.js +65 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -2
  7. package/src/adapters/google.ts +48 -9
  8. package/src/adapters/kiro-events.ts +15 -3
  9. package/src/adapters/kiro.ts +292 -28
  10. package/src/adapters/openai-chat.ts +63 -15
  11. package/src/adapters/openai-responses.ts +18 -0
  12. package/src/bridge.ts +76 -21
  13. package/src/chat/outbound.ts +66 -34
  14. package/src/claude/auth-detect.ts +229 -0
  15. package/src/claude/auth-mode-migration.ts +32 -0
  16. package/src/claude/auth-mode.ts +62 -0
  17. package/src/claude/desktop-3p-guard.ts +35 -0
  18. package/src/claude/desktop-3p.ts +121 -21
  19. package/src/claude/desktop-health.ts +26 -0
  20. package/src/claude/desktop-profile.ts +263 -0
  21. package/src/claude/inbound-debug.ts +4 -0
  22. package/src/claude/model-info.ts +9 -3
  23. package/src/cli/account-extended.ts +34 -0
  24. package/src/cli/account.ts +3 -1
  25. package/src/cli/claude-desktop.ts +152 -0
  26. package/src/cli/claude.ts +37 -3
  27. package/src/cli/doctor.ts +187 -6
  28. package/src/cli/help.ts +13 -1
  29. package/src/cli/index.ts +151 -57
  30. package/src/cli/status-oauth.ts +68 -0
  31. package/src/cli/status.ts +4 -0
  32. package/src/codex/account-lifecycle.ts +31 -0
  33. package/src/codex/auth-api.ts +133 -26
  34. package/src/codex/auth-collision.ts +55 -11
  35. package/src/codex/auth-context.ts +50 -6
  36. package/src/codex/catalog/provider-fetch.ts +6 -2
  37. package/src/codex/home.ts +61 -1
  38. package/src/codex/inject.ts +28 -66
  39. package/src/codex/injected-marker.ts +72 -0
  40. package/src/codex/journal.ts +39 -3
  41. package/src/codex/main-account-cache.ts +25 -0
  42. package/src/codex/model-cache.ts +20 -1
  43. package/src/codex/paths.ts +5 -0
  44. package/src/codex/routing.ts +138 -11
  45. package/src/codex/subagent-model-fallback.ts +455 -0
  46. package/src/codex/sync.ts +17 -0
  47. package/src/combos/failover.ts +10 -2
  48. package/src/combos/index.ts +1 -0
  49. package/src/combos/types.ts +9 -0
  50. package/src/config.ts +117 -0
  51. package/src/grok/inject.ts +339 -0
  52. package/src/grok/status.ts +88 -0
  53. package/src/grok/sync.ts +66 -0
  54. package/src/lib/destination-policy.ts +13 -0
  55. package/src/lib/errors.ts +59 -0
  56. package/src/lib/privacy.ts +9 -0
  57. package/src/lib/process-control.ts +57 -4
  58. package/src/oauth/health.ts +375 -0
  59. package/src/oauth/index.ts +69 -22
  60. package/src/oauth/kiro.ts +5 -3
  61. package/src/oauth/log.ts +48 -0
  62. package/src/oauth/store.ts +55 -6
  63. package/src/providers/alibaba-region-backup.ts +75 -0
  64. package/src/providers/alibaba-region-migration.ts +143 -0
  65. package/src/providers/alibaba-region-startup.ts +36 -0
  66. package/src/providers/api-keys.ts +5 -5
  67. package/src/providers/derive.ts +22 -1
  68. package/src/providers/free-directory.ts +181 -0
  69. package/src/providers/key-failover.ts +2 -2
  70. package/src/providers/kiro-models.ts +3 -2
  71. package/src/providers/openai-tiers.ts +1 -1
  72. package/src/providers/provider-id-rewrite.ts +150 -0
  73. package/src/providers/registry.ts +49 -2
  74. package/src/responses/parser.ts +49 -20
  75. package/src/responses/state.ts +156 -2
  76. package/src/router.ts +73 -5
  77. package/src/server/chat-completions.ts +78 -22
  78. package/src/server/claude-messages.ts +8 -0
  79. package/src/server/images.ts +2 -1
  80. package/src/server/index.ts +50 -30
  81. package/src/server/live.ts +2 -1
  82. package/src/server/management/agent-settings-routes.ts +337 -18
  83. package/src/server/management/api-access.ts +141 -0
  84. package/src/server/management/combo-routes.ts +3 -3
  85. package/src/server/management/config-routes.ts +4 -4
  86. package/src/server/management/logs-usage-routes.ts +64 -4
  87. package/src/server/management/model-routes.ts +114 -8
  88. package/src/server/management/oauth-account-routes.ts +31 -5
  89. package/src/server/management/provider-routes.ts +42 -11
  90. package/src/server/management/shared.ts +81 -2
  91. package/src/server/management/system-routes.ts +6 -1
  92. package/src/server/management-api.ts +24 -7
  93. package/src/server/port-reclaim.ts +261 -0
  94. package/src/server/request-log.ts +11 -5
  95. package/src/server/responses/collaboration.ts +11 -6
  96. package/src/server/responses/compact.ts +2 -1
  97. package/src/server/responses/core.ts +358 -155
  98. package/src/server/responses/passthrough-error.ts +53 -0
  99. package/src/server/search.ts +2 -1
  100. package/src/server/system-env.ts +23 -6
  101. package/src/server/windows-tcp-drop.ts +174 -0
  102. package/src/service.ts +43 -3
  103. package/src/types.ts +74 -6
  104. package/src/update/index.ts +30 -19
  105. package/src/update/job.ts +30 -15
  106. package/src/usage/log.ts +158 -2
  107. package/src/usage/summary.ts +8 -4
  108. package/src/web-search/loop.ts +4 -1
  109. package/gui/dist/assets/index-CMip1DzF.css +0 -1
  110. package/gui/dist/assets/index-cydcmbzC.js +0 -52
package/src/cli/doctor.ts CHANGED
@@ -7,14 +7,15 @@
7
7
  * it never sets proxy env, relocates state dirs, mutates quota, or changes
8
8
  * networking. See devlog/_plan/260630_wsl-account-autoswitch/30_*.
9
9
  */
10
- import { existsSync, readFileSync } from "node:fs";
10
+ import { accessSync, constants, existsSync, readFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
- import { join } from "node:path";
12
+ import { dirname, join } from "node:path";
13
13
  import { getConfigDir, getConfigPath, readConfigDiagnostics, readPid, readRuntimePort, resolveEnvValue } from "../config";
14
14
  import { gracefulStopHost } from "../lib/process-control";
15
+ import { maskAccountId } from "../lib/privacy";
15
16
  import { loadServiceTokenFromFile } from "../lib/service-secrets";
16
17
  import { readCodexTokens } from "../codex/auth-collision";
17
- import { resolveCodexHomeDir as resolveCodexHomeDirImpl, isWslRuntime, listWslWindowsCodexHomes, wslAutomountRoot, type CodexHomeDeps } from "../codex/home";
18
+ import { collectOrcaCodexHomeDiagnostic, resolveCodexHomeDir as resolveCodexHomeDirImpl, isWslRuntime, listWslWindowsCodexHomes, wslAutomountRoot, type CodexHomeDeps } from "../codex/home";
18
19
  import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim";
19
20
  import { countPendingOpencodexHistory } from "../codex/history-provider";
20
21
  import { collectProjectCodexConfigWarnings, formatProjectCodexConfigWarningsForDoctor } from "../codex/project-config-warnings";
@@ -26,8 +27,145 @@ import {
26
27
  resolveAndPersistCodexRuntime,
27
28
  resolveCodexRuntime,
28
29
  } from "../codex/runtime";
30
+ import { CODEX_REAUTH_ACTION, collectOAuthHealthEntriesForCli, MASKED_ACCOUNT_FALLBACK, type OAuthHealthEntry } from "../oauth/health";
31
+ import { getAuthRefreshIntentLockPath, getAuthStorePath } from "../oauth/store";
29
32
  export { resolveCodexHomeDir } from "../codex/home";
30
33
 
34
+ export type OAuthDoctorCheck = { level: "OK" | "WARN"; message: string };
35
+
36
+ function pathIsWritable(path: string): boolean {
37
+ try {
38
+ // Directories need execute/search as well as write for create+rename.
39
+ accessSync(path, constants.W_OK | constants.X_OK);
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ /** Observe-only: can we atomically replace auth.json (sibling tmp + rename)? */
47
+ function isOAuthCredentialStorageWritable(): boolean {
48
+ const storePath = getAuthStorePath();
49
+ const dir = existsSync(storePath) ? dirname(storePath) : getConfigDir();
50
+ if (existsSync(dir)) return pathIsWritable(dir);
51
+ // Config dir missing: check nearest existing ancestor (no mkdir — observe-only).
52
+ let parent = dirname(dir);
53
+ for (let i = 0; i < 8; i++) {
54
+ if (existsSync(parent)) return pathIsWritable(parent);
55
+ const next = dirname(parent);
56
+ if (next === parent) break;
57
+ parent = next;
58
+ }
59
+ return false;
60
+ }
61
+
62
+ /** Observe-only: refresh lock paths resolve and their parent dir is writable. */
63
+ function isOAuthRefreshSingleFlightReady(): boolean {
64
+ try {
65
+ const sample = getAuthRefreshIntentLockPath("doctor-probe", "probe-account");
66
+ if (!sample.includes("auth.refresh.")) return false;
67
+ const dir = getConfigDir();
68
+ if (existsSync(dir)) return pathIsWritable(dir);
69
+ return isOAuthCredentialStorageWritable();
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ function actionForDoctorEntry(entry: OAuthHealthEntry): string {
76
+ if (entry.action) return entry.action;
77
+ if (entry.provider === "codex") {
78
+ return CODEX_REAUTH_ACTION;
79
+ }
80
+ if (entry.health.status === "warning" && entry.health.reason === "stale_credentials") {
81
+ return `run \`ocx login ${entry.provider}\``;
82
+ }
83
+ if (entry.health.status === "warning" && entry.health.reason === "metadata_mismatch") {
84
+ return `run \`ocx login ${entry.provider}\` to refresh credentials`;
85
+ }
86
+ return `run \`ocx doctor\` again after fixing OAuth state for ${entry.provider}`;
87
+ }
88
+
89
+ function describeDoctorHealth(entry: OAuthHealthEntry): string {
90
+ const masked = maskAccountId(entry.accountId) ?? MASKED_ACCOUNT_FALLBACK;
91
+ const health = entry.health;
92
+ switch (health.status) {
93
+ case "reauth_required":
94
+ return `Account ${masked} requires reauthentication`;
95
+ case "cooldown":
96
+ return health.reason === "rate_limit"
97
+ ? `Account ${masked} is rate limited until ${health.until}`
98
+ : `Account ${masked} is quota limited until ${health.until}`;
99
+ case "warning":
100
+ switch (health.reason) {
101
+ case "refresh_conflict":
102
+ return `Account ${masked} has a refresh conflict`;
103
+ case "metadata_mismatch":
104
+ return `Account ${masked} has a metadata mismatch`;
105
+ case "stale_credentials":
106
+ return `Account ${masked} has incomplete credentials`;
107
+ }
108
+ case "healthy":
109
+ return `Account ${masked} is healthy`;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * OAuth reliability checks for `ocx doctor`. Observe-only: never mutates
115
+ * credentials, locks, or networking. Every WARN includes a recovery Action.
116
+ */
117
+ export async function collectOAuthDoctorChecks(
118
+ now = Date.now(),
119
+ deps: Parameters<typeof collectOAuthHealthEntriesForCli>[1] = {},
120
+ ): Promise<OAuthDoctorCheck[]> {
121
+ const checks: OAuthDoctorCheck[] = [];
122
+
123
+ if (isOAuthCredentialStorageWritable()) {
124
+ checks.push({ level: "OK", message: "OAuth credential storage directory is writable for atomic auth.json updates." });
125
+ } else {
126
+ checks.push({
127
+ level: "WARN",
128
+ message:
129
+ "OAuth credential storage directory is not writable. Action: fix permissions on OPENCODEX_HOME so ocx can create temp files and rename auth.json",
130
+ });
131
+ }
132
+
133
+ if (isOAuthRefreshSingleFlightReady()) {
134
+ checks.push({ level: "OK", message: "Token refresh single-flight is active." });
135
+ } else {
136
+ checks.push({
137
+ level: "WARN",
138
+ message:
139
+ "Token refresh single-flight is unavailable. Action: fix permissions on OPENCODEX_HOME so ocx can create refresh lock files",
140
+ });
141
+ }
142
+
143
+ const report = await collectOAuthHealthEntriesForCli(now, deps);
144
+ if (report.codexHealthSource === "unavailable") {
145
+ checks.push({
146
+ level: "WARN",
147
+ message:
148
+ "Codex account health unavailable (proxy not running). Action: start the proxy and re-run `ocx doctor` to inspect live cooldown/reauth",
149
+ });
150
+ }
151
+ for (const entry of report.entries) {
152
+ if (entry.health.status === "healthy") continue;
153
+ const action = actionForDoctorEntry(entry);
154
+ checks.push({
155
+ level: "WARN",
156
+ message: `${describeDoctorHealth(entry)}. Action: ${action}`,
157
+ });
158
+ }
159
+
160
+ // Build-time / architecture note — not a runtime fabrication scanner.
161
+ checks.push({
162
+ level: "OK",
163
+ message: "Codex forward path uses pass-through client metadata (build-time invariant; not a runtime scan).",
164
+ });
165
+
166
+ return checks;
167
+ }
168
+
31
169
  const WHAM_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
32
170
  const PROBE_TIMEOUT_MS = 8000;
33
171
 
@@ -441,6 +579,23 @@ export function formatServiceMemoryLines(report: ServiceMemoryReport): string[]
441
579
  return lines;
442
580
  }
443
581
 
582
+ /**
583
+ * Actionable hint for the most common confusion: Codex/Claude clients fail with raw
584
+ * connection errors (e.g. "error sending request for url (http://127.0.0.1:10100/...)")
585
+ * when the proxy is simply not running. Returns null when a live proxy was found.
586
+ */
587
+ export function proxyDownRestartHint(input: {
588
+ proxyRunning: boolean;
589
+ port: number;
590
+ serviceViable: boolean;
591
+ }): string | null {
592
+ if (input.proxyRunning) return null;
593
+ const restart = input.serviceViable
594
+ ? "Restart it with 'ocx service start' (service installed) or 'ocx start'."
595
+ : "Restart it with 'ocx start', or install the persistent service: 'ocx service install'.";
596
+ return `The ocx proxy is not running. Codex/Claude clients pinned to 127.0.0.1:${input.port} fail with errors like "error sending request for url (http://127.0.0.1:${input.port}/v1/responses)". ${restart}`;
597
+ }
598
+
444
599
  export async function runDoctor(args: string[] = []): Promise<void> {
445
600
  if (args.includes("--fix-codex-runtime")) {
446
601
  const resolved = resolveCodexRuntime();
@@ -481,7 +636,18 @@ export async function runDoctor(args: string[] = []): Promise<void> {
481
636
  console.log(` ${row.exists ? "ok " : "-- "} ${row.label}: ${row.path}${flags ? ` (${flags})` : ""}`);
482
637
  }
483
638
 
484
- const startup = collectStartupHealth(readConfigDiagnostics().config);
639
+ const orcaHome = collectOrcaCodexHomeDiagnostic();
640
+ console.log("\nCodex app home targeting");
641
+ console.log(` ${orcaHome.mismatch ? "!! " : "ok "} Effective Codex home: ${orcaHome.effectiveCodexHome}`);
642
+ if (orcaHome.mismatch) {
643
+ console.log(` !! ${orcaHome.warning}`);
644
+ console.log(` Action: ${orcaHome.action}`);
645
+ } else {
646
+ console.log(" No Orca-owned CODEX_HOME mismatch detected.");
647
+ }
648
+
649
+ const doctorConfig = readConfigDiagnostics().config;
650
+ const startup = collectStartupHealth(doctorConfig);
485
651
  console.log("\nCodex restart safety");
486
652
  console.log(` ${startup.rebootSafe ? "ok " : "!! "} ${startupHealthSummary(startup)}`);
487
653
  console.log(` routing=${startup.routingKind}, service=${startup.serviceViable ? "viable" : startup.serviceInstalled ? "installed-but-unhealthy" : "absent"}, shim=${startup.shimHealthy ? "healthy" : startup.shimInstalled ? "stale" : "absent"}`);
@@ -542,10 +708,13 @@ export async function runDoctor(args: string[] = []): Promise<void> {
542
708
  // #314: service-process memory/runtime identity via the authed management
543
709
  // endpoint. readPid() FIRST (liveness), then the pid-scoped runtime record —
544
710
  // readRuntimePort alone can serve a stale file pointing at a foreign port.
711
+ // Hoisted out of the block below: the Hints section reuses the same liveness pair
712
+ // for the proxy-down restart hint.
713
+ const livePid = readPid();
714
+ const liveRuntime = livePid ? readRuntimePort(livePid) : null;
545
715
  console.log("\nMemory / runtime");
546
716
  {
547
- const livePid = readPid();
548
- const runtime = livePid ? readRuntimePort(livePid) : null;
717
+ const runtime = liveRuntime;
549
718
  if (!runtime) {
550
719
  console.log(` -- doctor process Bun ${Bun.version} (this is NOT the service process)`);
551
720
  console.log(" -- no running ocx proxy found (no live pid/runtime record)");
@@ -600,8 +769,20 @@ export async function runDoctor(args: string[] = []): Promise<void> {
600
769
  }
601
770
  }
602
771
 
772
+ // OAuth reliability: observe-only (no mutations / auto-repair).
773
+ console.log("\nOAuth reliability");
774
+ for (const check of await collectOAuthDoctorChecks()) {
775
+ console.log(` [${check.level}] ${check.message}`);
776
+ }
777
+
603
778
  // Hints, not fixes.
604
779
  const hints: string[] = [];
780
+ const proxyDown = proxyDownRestartHint({
781
+ proxyRunning: Boolean(livePid && liveRuntime),
782
+ port: doctorConfig.port ?? 10100,
783
+ serviceViable: startup.serviceViable,
784
+ });
785
+ if (proxyDown) hints.push(proxyDown);
605
786
  const anyDrvfs = paths.some(p => detectFsType(p.path, mounts).isDrvfs || detectFsType(p.path, mounts).isMntDrive);
606
787
  const noProxy = currentProxyEnv.every(p => !p.present) && !configuredProxy.present;
607
788
  if (!startup.rebootSafe) {
package/src/cli/help.ts CHANGED
@@ -122,9 +122,20 @@ const helpEntries: Record<string, HelpEntry> = {
122
122
  details: [
123
123
  "Ensures the proxy is running, then execs `claude` with ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN,",
124
124
  "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 and model slots from config.claudeCode.",
125
- "Routed models appear in the native /model picker as claude-ocx-<provider>--<model> (Claude Code >= 2.1.129).",
125
+ "Routed models appear in the native /model picker with stable claude-opus-4-8-2026MMDD slot aliases (Claude Code >= 2.1.129).",
126
126
  "Older versions: pick models via ANTHROPIC_MODEL or /model <id> directly (any string passes through).",
127
127
  "User-exported ANTHROPIC_* variables always take precedence.",
128
+ "",
129
+ "Claude Desktop profile:",
130
+ " ocx claude desktop [apply] Save and apply the four-family profile",
131
+ " ocx claude desktop show [--json] Show routes, families, and defaults",
132
+ " ocx claude desktop move <route> <family> [--default]",
133
+ " ocx claude desktop default <family> <route|none>",
134
+ " ocx claude desktop export <path|-> Export versioned JSON (`-` = stdout)",
135
+ " ocx claude desktop import <path> [--apply] Validate and import JSON",
136
+ "Families: opus, fable, sonnet, haiku. New routes start in opus.",
137
+ "`none` is valid only when that family is empty.",
138
+ "Legacy apply flags remain supported: --static, --hybrid, --discovery-only.",
128
139
  ],
129
140
  },
130
141
  restart: {
@@ -192,6 +203,7 @@ Usage:
192
203
  ocx account <sub> Accounts/keys (list|current|use|refresh|auto-switch|remove|add-key)
193
204
  ocx models <sub> List models; manage custom models (add|remove|list-custom)
194
205
  ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on)
206
+ ocx claude desktop [sub] Manage and apply Claude Desktop's four-family profile
195
207
  ocx help [command] Show help
196
208
  ocx --version | -v Print version
197
209
 
package/src/cli/index.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  import { spawn } from "node:child_process";
3
3
  import { rmSync } from "node:fs";
4
4
  import { currentExternalCodexModelProvider, restoreNativeCodex, shouldInjectApiAuthHeader } from "../codex/inject";
5
+ import { stripGrokConfig } from "../grok/inject";
5
6
  import { restoreLegacyOpenaiHistory } from "../codex/history-provider";
6
- import { writeJournal, reconcileJournal } from "../codex/journal";
7
+ import { reconcileJournal } from "../codex/journal";
7
8
  import {
8
9
  codexAutoStartEnabled,
9
10
  getConfigDir,
@@ -28,7 +29,7 @@ import { findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSele
28
29
  import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
29
30
  import { stopProxy } from "../lib/process-control";
30
31
  import { loadServiceTokenFromFile } from "../lib/service-secrets";
31
- import { diagnoseService, serviceCommand, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service";
32
+ import { diagnoseService, isServiceOwnershipError, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service";
32
33
  import { startupHealthSummary } from "../codex/autostart-health";
33
34
  import { drainAndShutdown, startServer } from "../server";
34
35
  import { injectSystemEnv, revertSystemEnv } from "../server/system-env";
@@ -41,6 +42,7 @@ import { maybeShowStarPrompt } from "./star-prompt";
41
42
  import { maybeShowUpdatePrompt } from "../update/notify";
42
43
  import { syncModelsToCodex } from "../codex/sync";
43
44
  import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
45
+ import { collectOrcaCodexHomeDiagnostic } from "../codex/home";
44
46
 
45
47
  const args = process.argv.slice(2);
46
48
  const command = args[0];
@@ -106,10 +108,21 @@ async function chooseListenPort(requestedPort?: number): Promise<number> {
106
108
  const preferred = requestedPort ?? config.port ?? 10100;
107
109
  const hardPin = requestedPort !== undefined && requestedPort > 0;
108
110
  // Soft start: brief prefer-retry then ephemeral hop.
109
- // Explicit `--port` (service wrappers / update restart): longer prefer-retry, never hop.
111
+ // Explicit `--port` (service wrappers / update restart): wait for the pinned port
112
+ // to free without killing any listener (healthy ocx / foreign). Never hop.
113
+ if (hardPin && preferred > 0) {
114
+ const { reclaimListenPort } = await import("../server/port-reclaim");
115
+ await reclaimListenPort(preferred, config.hostname ?? "127.0.0.1", {
116
+ timeoutMs: 30_000,
117
+ intervalMs: 100,
118
+ scanIntervalMs: 500,
119
+ killOcxHolders: false,
120
+ dropTcpRows: false,
121
+ });
122
+ }
110
123
  try {
111
124
  const selected = await findAvailablePort(preferred, config.hostname ?? "127.0.0.1", {
112
- preferRetryMs: hardPin ? 8_000 : 750,
125
+ preferRetryMs: hardPin ? 0 : 750,
113
126
  preferRetryIntervalMs: 50,
114
127
  allowEphemeralFallback: !hardPin,
115
128
  });
@@ -186,7 +199,9 @@ async function handleStart(options: { block?: boolean } = {}) {
186
199
 
187
200
  const config = loadConfig();
188
201
  writeRuntimePort({ pid: process.pid, port, hostname: config.hostname });
189
- if (!currentExternalCodexModelProvider()) writeJournal();
202
+ // No pre-emptive snapshot here. `injectCodexConfig` journals the exact bytes it
203
+ // is about to transform; snapshotting earlier only captured a baseline that could
204
+ // already be stale by the time injection ran (#477).
190
205
 
191
206
  // Background proactive token refresh. No-op unless config.tokenGuardian.enabled; timer is unref'd
192
207
  // so it never keeps the process alive on its own. Stopped in syncCleanup so no refresh fires mid-drain.
@@ -208,6 +223,13 @@ async function handleStart(options: { block?: boolean } = {}) {
208
223
  if (!process.env.OCX_SERVICE && !currentExternalCodexModelProvider()) {
209
224
  try { restoreNativeCodex(); } catch { /* best-effort restore */ }
210
225
  }
226
+ // Same ownership rule as `ocx stop`: if the installed service belongs to another home, the
227
+ // Grok fence is shared state we must not remove — that service keeps running and would be
228
+ // left pointing nowhere. This guard also covers signal-driven exits, which is the path that
229
+ // would otherwise bypass handleStop's gate entirely.
230
+ if (!process.env.OCX_SERVICE && serviceEnvironmentOwnedHere()) {
231
+ try { stripGrokConfig(); } catch { /* best-effort restore */ }
232
+ }
211
233
  };
212
234
 
213
235
  let shuttingDown = false;
@@ -264,8 +286,20 @@ async function handleStart(options: { block?: boolean } = {}) {
264
286
  buildDesktop3pRegistry(
265
287
  [...visibleNativeSlugs(config)],
266
288
  models.map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })),
289
+ config.claudeCode?.desktopProfile,
267
290
  );
268
291
  } catch { /* best-effort — registry rebuilds on first /v1/models call */ }
292
+ // Grok Build auto-registration: additive fenced block in ~/.grok/config.toml so an installed
293
+ // grok CLI can pick opencodex-routed models without manual config. No-op when ~/.grok is
294
+ // absent or the bind is non-loopback; removed again by stop/eject/uninstall/shutdown.
295
+ // Deliberately a SIBLING of the Desktop-3P block above: nesting it there meant a catalog
296
+ // failure skipped the fence entirely, even though syncGrokConfig handles that case itself.
297
+ try {
298
+ const { syncGrokConfig } = await import("../grok/sync");
299
+ const r = await syncGrokConfig(port, config, config.hostname ? { hostname: config.hostname } : {});
300
+ if (r.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)");
301
+ else if (!r.ok) console.error(`⚠️ ${r.message}`);
302
+ } catch { /* best-effort — grok integration must never block startup */ }
269
303
  if (options.block ?? true) {
270
304
  setInterval(() => {}, 60_000);
271
305
  await new Promise<void>(() => {});
@@ -286,6 +320,14 @@ async function handleEnsure() {
286
320
  });
287
321
  // Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature).
288
322
  await injectSystemEnv(live.port, config).catch(() => {});
323
+ // Refresh the Grok Build fence too (same contract as start). live.hostname is the
324
+ // hostname the running proxy actually bound — config.hostname may have drifted.
325
+ try {
326
+ const { syncGrokConfig } = await import("../grok/sync");
327
+ const g = await syncGrokConfig(live.port, config, live.hostname ? { hostname: live.hostname } : {});
328
+ if (g.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)");
329
+ else if (!g.ok) console.error(`⚠️ ${g.message}`);
330
+ } catch { /* best-effort */ }
289
331
  console.log(`✅ Proxy running on port ${live.port}`);
290
332
  return;
291
333
  }
@@ -304,6 +346,15 @@ async function handleEnsure() {
304
346
  console.error("❌ Proxy did not become healthy after starting.");
305
347
  process.exit(1);
306
348
  }
349
+ // Deterministic fence guarantee: the spawned child injects late in its own startup, but
350
+ // this parent returns as soon as /healthz responds — inject here too (idempotent block
351
+ // replace) so `ocx ensure` never returns without the Grok fence in place.
352
+ try {
353
+ const { syncGrokConfig } = await import("../grok/sync");
354
+ const g = await syncGrokConfig(port, config, config.hostname ? { hostname: config.hostname } : {});
355
+ if (g.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)");
356
+ else if (!g.ok) console.error(`⚠️ ${g.message}`);
357
+ } catch { /* best-effort */ }
307
358
  // Always sync the LIVE port: after a fallback-port start, config.port still names the
308
359
  // busy preferred port — syncing that would point Codex at a dead listener.
309
360
  await syncModelsToCodex(port).catch(e => {
@@ -354,11 +405,29 @@ async function handleTrayProxyRestart(): Promise<void> {
354
405
  }
355
406
 
356
407
  async function handleStop() {
357
- const stoppedService = stopServiceIfInstalled();
358
- if (stoppedService) console.log("🛑 Service manager stopped (won't respawn).");
408
+ let stopFailed = false;
409
+ let stoppedService = false;
410
+ // An ownership mismatch means the service manager was never even contacted: the installed
411
+ // service is still live and will respawn the proxy. Tearing down SHARED state in that
412
+ // situation (native Codex config, the Grok fence) removes config out from under a running
413
+ // service — the exact failure this flag prevents. A plain stop failure is different: we
414
+ // tried, so local teardown still proceeds.
415
+ let ownershipBlocked = false;
416
+ try {
417
+ stoppedService = stopServiceIfInstalled();
418
+ if (stoppedService) console.log("🛑 Service manager stopped (won't respawn).");
419
+ } catch (err) {
420
+ if (isServiceOwnershipError(err)) {
421
+ ownershipBlocked = true;
422
+ stopFailed = true;
423
+ console.error(`❌ ${err.message}`);
424
+ console.error(" Skipping shared teardown (native Codex restore, Grok config): the installed service is still running.");
425
+ } else {
426
+ console.error(`⚠️ Service manager stop failed: ${err instanceof Error ? err.message : String(err)}`);
427
+ }
428
+ }
359
429
 
360
430
  const pid = readPid();
361
- let stopFailed = false;
362
431
  if (pid) {
363
432
  try {
364
433
  // Graceful-first (management-API drain) — on Windows this is the only path where
@@ -367,9 +436,15 @@ async function handleStop() {
367
436
  console.log(`✅ Proxy (PID ${pid}) stopped.`);
368
437
  removePid(pid);
369
438
  removeRuntimePort(pid);
370
- } catch {
439
+ } catch (err) {
371
440
  stopFailed = true;
372
441
  console.error(`❌ Failed to stop proxy (PID ${pid}).`);
442
+ // stopProxy throws with the reason — an ownership refusal (409) carries the
443
+ // remediation ("run the stop from that home"). Swallowing it leaves the operator
444
+ // with a bare failure and a manual `kill` as the obvious next move, which is the
445
+ // exact teardown the refusal exists to prevent.
446
+ const detail = err instanceof Error ? err.message : String(err);
447
+ if (detail) console.error(` ${detail}`);
373
448
  }
374
449
  } else {
375
450
  // Snapshot the stale on-disk state BEFORE the async probe: a concurrent `ocx start`
@@ -383,9 +458,11 @@ async function handleStop() {
383
458
  try {
384
459
  await stopProxy(live.pid);
385
460
  console.log(`✅ Proxy (PID ${live.pid}) stopped.`);
386
- } catch {
461
+ } catch (err) {
387
462
  stopFailed = true;
388
463
  console.error(`❌ Failed to stop proxy (PID ${live.pid}).`);
464
+ const detail = err instanceof Error ? err.message : String(err);
465
+ if (detail) console.error(` ${detail}`);
389
466
  }
390
467
  } else if (!stoppedService) {
391
468
  console.log("No running proxy found.");
@@ -398,12 +475,27 @@ async function handleStop() {
398
475
  removeRuntimePortIfPidIs(staleRuntimePid);
399
476
  }
400
477
  }
401
- const r = restoreNativeCodex();
402
- console.log(`↩️ ${r.message}`);
403
- // Safety net: revert system env vars even if the daemon's syncCleanup didn't run
404
- // (e.g. SIGKILL). revertSystemEnv is ownership-checked and idempotent.
478
+ if (!ownershipBlocked) {
479
+ const r = restoreNativeCodex();
480
+ console.log(`↩️ ${r.message}`);
481
+ }
482
+ // revertSystemEnv is NOT gated: it carries its own ownership check and concerns launchctl
483
+ // user env, not CODEX_HOME. Safety net for when the daemon's syncCleanup didn't run (SIGKILL).
405
484
  try { revertSystemEnv(); } catch { /* best-effort */ }
406
- if (stopFailed) process.exit(1);
485
+ if (!ownershipBlocked) {
486
+ // Same safety net for the Grok Build managed block (marker-owned, idempotent).
487
+ try {
488
+ const g = stripGrokConfig();
489
+ if (g.changed) console.log(`↩️ ${g.message}`);
490
+ // A refused strip (e.g. orphaned marker) leaves the fence pointing at a dead proxy —
491
+ // reporting success there hides a broken end state.
492
+ else if (!g.ok) { stopFailed = true; console.error(`⚠️ ${g.message}`); }
493
+ } catch { /* best-effort */ }
494
+ }
495
+ // Set the code rather than exiting inline: `restart` and the tray coordinator call this
496
+ // function and need it to RETURN so they can decide what to do next.
497
+ if (stopFailed) process.exitCode = 1;
498
+ return !stopFailed;
407
499
  }
408
500
 
409
501
  async function handleUninstall() {
@@ -447,6 +539,12 @@ async function handleUninstall() {
447
539
  if (!r.success) throw new Error(r.message);
448
540
  });
449
541
 
542
+ await runStep("Grok Build config restored", () => {
543
+ const r = stripGrokConfig();
544
+ if (!r.ok) throw new Error(r.message);
545
+ return r.changed;
546
+ });
547
+
450
548
  await runStep("system env vars reverted", () => {
451
549
  const r = revertSystemEnv();
452
550
  if (!r.reverted && r.reason !== "no tracking file" && r.reason !== "not macOS") throw new Error(r.reason ?? "revert failed");
@@ -501,6 +599,10 @@ async function handleStatus() {
501
599
  console.log(`❌ Proxy: ${status.proxyLabel}`);
502
600
  }
503
601
  console.log(` Health: ${status.healthLabel}`);
602
+ if (!(status.json.proxy.pid || status.json.proxy.health.ok)) {
603
+ console.log(" ↳ Not running — Codex/Claude requests will fail with connection errors.");
604
+ console.log(" Restart with 'ocx start', or install the persistent service: 'ocx service install'.");
605
+ }
504
606
  console.log(` Dashboard: ${status.json.dashboard.url}`);
505
607
  console.log(` Config: ${status.json.paths.config}`);
506
608
  console.log(` PID file: ${status.json.paths.pid}`);
@@ -514,6 +616,11 @@ async function handleStatus() {
514
616
  console.log(` Codex runtime: ${status.json.codexRuntime.path}`);
515
617
  console.log(` Codex version: ${status.json.codexRuntime.version ?? "unknown"}`);
516
618
  console.log(` Codex source: ${status.json.codexRuntime.source}`);
619
+ console.log(` Codex home: ${status.json.codexHome.effectiveCodexHome}`);
620
+ if (status.json.codexHome.warning) {
621
+ console.log(` ⚠️ ${status.json.codexHome.warning}`);
622
+ console.log(` Action: ${status.json.codexHome.action}`);
623
+ }
517
624
  console.log(` Catalog clamp: ${status.json.codexRuntime.catalogClamp.active ? "active" : "inactive"}`);
518
625
  if (status.json.codexRuntime.catalogClamp.removedEfforts.length > 0) {
519
626
  console.log(` Removed efforts: ${status.json.codexRuntime.catalogClamp.removedEfforts.join(", ")}`);
@@ -528,11 +635,18 @@ async function handleStatus() {
528
635
  console.log(` Suggested: ${status.json.codexPlugins.suggestedRepair}`);
529
636
  }
530
637
  }
531
- const { oauthLoginSummary } = await import("../oauth");
638
+ const { collectOAuthHealthEntriesForCli, oauthLoginSummary } = await import("../oauth");
639
+ const { formatOAuthHealthForStatus } = await import("./status-oauth");
532
640
  console.log(` OAuth logins:`);
533
641
  for (const e of oauthLoginSummary()) {
534
642
  console.log(` ${e.provider.padEnd(10)} ${e.loggedIn ? `✓ logged in${e.email ? ` (${e.email})` : ""}` : "✗ not logged in"}`);
535
643
  }
644
+ const oauthHealthBlock = formatOAuthHealthForStatus(await collectOAuthHealthEntriesForCli());
645
+ if (oauthHealthBlock) {
646
+ for (const line of oauthHealthBlock.split("\n")) {
647
+ console.log(` ${line}`);
648
+ }
649
+ }
536
650
  }
537
651
 
538
652
  function handleRecoverHistory() {
@@ -560,9 +674,14 @@ switch (command) {
560
674
  case "start":
561
675
  await handleStart();
562
676
  break;
563
- case "stop":
564
- await handleStop();
677
+ case "stop": {
678
+ // Downtime warning lives HERE, not in handleStop: `restart`/tray-restart callers
679
+ // re-start the proxy immediately, so warning there would contradict the next line.
680
+ if (await handleStop()) {
681
+ console.log("⚠️ Codex/Claude requests through the proxy will fail until it is restarted ('ocx start' or 'ocx service start').");
682
+ }
565
683
  break;
684
+ }
566
685
  case "restore":
567
686
  case "eject": {
568
687
  if (args[1] === "back") {
@@ -575,11 +694,17 @@ switch (command) {
575
694
  process.exit(1);
576
695
  }
577
696
  await syncModelsToCodex(live.port);
578
- console.log("Plain `codex` now routes through opencodex again (undo with: ocx restore).");
697
+ const target = collectOrcaCodexHomeDiagnostic();
698
+ console.log(`Plain \`codex\` now routes through opencodex in ${target.effectiveCodexHome} (undo with: ocx restore).`);
579
699
  break;
580
700
  }
581
701
  const r = restoreNativeCodex();
582
702
  console.log(r.success ? `✅ ${r.message}` : `⚠️ ${r.message}`);
703
+ try {
704
+ const g = stripGrokConfig();
705
+ if (g.changed) console.log(`✅ ${g.message}`);
706
+ else if (!g.ok) console.error(`⚠️ ${g.message}`);
707
+ } catch { /* best-effort */ }
583
708
  console.log("Plain `codex` now runs natively (no proxy). Switch back with: ocx restore back");
584
709
  break;
585
710
  }
@@ -737,8 +862,10 @@ switch (command) {
737
862
  break;
738
863
  }
739
864
  case "restart": {
740
- await handleStop();
741
- await handleEnsure();
865
+ // A failed stop must not be followed by a re-inject: with a foreign service still running
866
+ // (ownership mismatch) we would rewrite shared config we just declined to touch.
867
+ if (await handleStop()) await handleEnsure();
868
+ else console.error("↩️ Restart aborted: the proxy was not stopped cleanly.");
742
869
  break;
743
870
  }
744
871
  case "health": {
@@ -771,42 +898,9 @@ switch (command) {
771
898
  const { cmdClaude } = await import("./claude");
772
899
  // "ocx claude desktop" → write Desktop 3P config
773
900
  if (args[1] === "desktop") {
774
- const config = loadConfig();
775
- const { fetchAllModels } = await import("../server/management-api");
776
- const { visibleNativeSlugs, filterCatalogVisibleModels } = await import("../codex/catalog");
777
- const { parseDesktop3pModeArgs, writeDesktop3pConfig } = await import("../claude/desktop-3p");
778
- // Mutually-exclusive mode flags (devlog 138): default static (deterministic; the
779
- // static list overrides discovery anyway — no merge).
780
- const parsedMode = parseDesktop3pModeArgs(args.slice(2));
781
- if ("error" in parsedMode) {
782
- console.error(`❌ ${parsedMode.error}`);
783
- process.exit(1);
784
- }
785
- const mode = parsedMode.mode;
786
- const live = await findLiveProxy();
787
- const port = live?.port ?? config.port ?? 10100;
788
- const allModels = await fetchAllModels(config);
789
- const models = filterCatalogVisibleModels(allModels, config);
790
- const nativeSlugs = [...visibleNativeSlugs(config)];
791
- // contextWindow rides along so supports1m derives from authoritative data (감사 R1#1).
792
- const routedModels = models.map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow }));
793
- const result = writeDesktop3pConfig(port, nativeSlugs, routedModels, undefined, mode);
794
- if (result.written) {
795
- const oneM = routedModels.filter(m => typeof m.contextWindow === "number" && m.contextWindow >= 1_000_000).length;
796
- console.log(`✅ Claude Desktop 3P 설정 완료: ${result.path}`);
797
- console.log(` Gateway: http://127.0.0.1:${port}`);
798
- if (mode === "discovery") {
799
- console.log(` 모델 목록: 자동 발견만 (프록시 /v1/models에서 ${nativeSlugs.length + models.length}개)`);
800
- } else {
801
- const suffix = mode === "hybrid" ? " + 자동 발견 병행" : "";
802
- console.log(` 모델 ${nativeSlugs.length + models.length}개 고정 등록${suffix} (1M 컨텍스트 별도 행 ${oneM}개)`);
803
- if (oneM > 0) console.log(` 1M을 쓰려면 Desktop 모델 피커에서 [1M] 붙은 행을 직접 선택하세요.`);
804
- }
805
- console.log(` Claude Desktop을 재시작하면 적용됩니다.`);
806
- } else {
807
- console.error(`❌ 설정 실패: ${result.reason}`);
808
- process.exit(1);
809
- }
901
+ const { handleClaudeDesktopCommand } = await import("./claude-desktop");
902
+ const exitCode = await handleClaudeDesktopCommand(args.slice(2));
903
+ if (exitCode !== 0) process.exit(exitCode);
810
904
  break;
811
905
  }
812
906
  process.exit(await cmdClaude(args.slice(1)));