@bitkyc08/opencodex 2.7.42 → 2.7.43

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 (143) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +13 -14
  3. package/gui/dist/assets/index-Czw-jpTU.css +1 -0
  4. package/gui/dist/assets/index-cmds12BG.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  10. package/src/adapters/cursor/protobuf-events.ts +24 -2
  11. package/src/adapters/cursor/protobuf-request.ts +1 -2
  12. package/src/adapters/cursor/tool-definitions.ts +68 -29
  13. package/src/adapters/google-wire-compiler.ts +4 -0
  14. package/src/adapters/google.ts +128 -2
  15. package/src/adapters/identity.ts +12 -2
  16. package/src/adapters/kiro.ts +49 -6
  17. package/src/adapters/mimo-free.ts +2 -0
  18. package/src/adapters/openai-responses.ts +246 -59
  19. package/src/claude/agents-inject.ts +5 -0
  20. package/src/claude/inbound.ts +26 -9
  21. package/src/cli/account-auth.ts +1 -1
  22. package/src/cli/agent-driven.ts +37 -0
  23. package/src/cli/claude.ts +3 -3
  24. package/src/cli/doctor.ts +23 -18
  25. package/src/cli/help.ts +42 -6
  26. package/src/cli/index.ts +88 -19
  27. package/src/cli/interactive-confirm.ts +129 -0
  28. package/src/cli/opencode.ts +701 -0
  29. package/src/cli/provider-runtime.ts +3 -0
  30. package/src/cli/provider.ts +31 -10
  31. package/src/cli/star-prompt.ts +55 -16
  32. package/src/cli/status.ts +47 -13
  33. package/src/codex/account-id.ts +34 -0
  34. package/src/codex/account-lifecycle.ts +4 -1
  35. package/src/codex/account-namespace-match.ts +63 -0
  36. package/src/codex/account-namespaces.ts +149 -0
  37. package/src/codex/account-pause.ts +20 -0
  38. package/src/codex/account-usability.ts +6 -1
  39. package/src/codex/app-server-processes.ts +511 -0
  40. package/src/codex/auth-api.ts +293 -34
  41. package/src/codex/auth-collision.ts +2 -1
  42. package/src/codex/auth-context.ts +60 -17
  43. package/src/codex/catalog/parsing.ts +16 -1
  44. package/src/codex/catalog/provider-fetch.ts +48 -12
  45. package/src/codex/catalog/sync.ts +18 -3
  46. package/src/codex/catalog.ts +1 -1
  47. package/src/codex/history-provider.ts +145 -1
  48. package/src/codex/inject.ts +114 -14
  49. package/src/codex/main-account.ts +2 -8
  50. package/src/codex/pool-rotation.ts +186 -0
  51. package/src/codex/quota.ts +15 -0
  52. package/src/codex/routing.ts +695 -106
  53. package/src/codex/shim.ts +4 -1
  54. package/src/codex/subagent-defaults.ts +550 -0
  55. package/src/codex/subagent-model-fallback.ts +2 -0
  56. package/src/codex/sync.ts +3 -0
  57. package/src/config.ts +566 -25
  58. package/src/images/artifacts.ts +516 -0
  59. package/src/images/fulfill-video.ts +163 -0
  60. package/src/images/fulfill.ts +111 -0
  61. package/src/images/index.ts +4 -0
  62. package/src/images/loop.ts +789 -0
  63. package/src/images/plan.ts +133 -0
  64. package/src/images/synthetic-tool.ts +133 -0
  65. package/src/images/types.ts +41 -0
  66. package/src/images/xai-client.ts +141 -0
  67. package/src/images/xai-video-client.ts +163 -0
  68. package/src/lib/admin-secrets.ts +25 -0
  69. package/src/lib/config-ownership.ts +327 -0
  70. package/src/lib/crash-guard.ts +2 -0
  71. package/src/lib/destination-policy.ts +122 -4
  72. package/src/lib/pinned-http.ts +151 -0
  73. package/src/lib/process-control.ts +2 -2
  74. package/src/lib/provider-outbound.ts +164 -0
  75. package/src/lib/provider-url.ts +14 -0
  76. package/src/lib/proxy-env.ts +18 -0
  77. package/src/lib/windows-elevation.ts +81 -3
  78. package/src/lib/windows-secret-acl.ts +189 -12
  79. package/src/lib/winsw.ts +2 -0
  80. package/src/oauth/anthropic-routing.ts +570 -0
  81. package/src/oauth/health.ts +6 -0
  82. package/src/oauth/index.ts +283 -72
  83. package/src/oauth/key-providers.ts +17 -6
  84. package/src/oauth/kimi.ts +2 -0
  85. package/src/oauth/kiro-credentials.ts +322 -10
  86. package/src/oauth/kiro.ts +423 -43
  87. package/src/oauth/login-cli.ts +32 -5
  88. package/src/oauth/store.ts +54 -4
  89. package/src/oauth/types.ts +11 -0
  90. package/src/providers/alibaba-region-migration.ts +16 -3
  91. package/src/providers/antigravity-models.ts +3 -0
  92. package/src/providers/api-keys.ts +13 -6
  93. package/src/providers/derive.ts +6 -0
  94. package/src/providers/key-failover.ts +24 -4
  95. package/src/providers/quota.ts +233 -29
  96. package/src/providers/registry.ts +11 -3
  97. package/src/responses/parser.ts +11 -0
  98. package/src/responses/state.ts +22 -8
  99. package/src/responses/tool-groups.ts +19 -0
  100. package/src/router.ts +14 -4
  101. package/src/server/auth-cors.ts +110 -22
  102. package/src/server/claude-messages.ts +8 -1
  103. package/src/server/gui-static.ts +30 -6
  104. package/src/server/images.ts +303 -9
  105. package/src/server/index.ts +77 -9
  106. package/src/server/lifecycle.ts +25 -1
  107. package/src/server/management/agent-settings-routes.ts +28 -4
  108. package/src/server/management/combo-routes.ts +7 -0
  109. package/src/server/management/config-routes.ts +5 -2
  110. package/src/server/management/logs-usage-routes.ts +167 -3
  111. package/src/server/management/oauth-account-routes.ts +163 -17
  112. package/src/server/management/provider-routes.ts +35 -7
  113. package/src/server/management/system-restart.ts +172 -0
  114. package/src/server/management/system-routes.ts +33 -10
  115. package/src/server/management-api.ts +2 -2
  116. package/src/server/management-auth.ts +216 -0
  117. package/src/server/proxy-liveness.ts +14 -3
  118. package/src/server/responses/compact.ts +21 -13
  119. package/src/server/responses/core.ts +583 -152
  120. package/src/server/responses-image-gen-repair.ts +118 -0
  121. package/src/server/responses-item-id-repair.ts +10 -85
  122. package/src/server/sse-payload-rewrite.ts +116 -0
  123. package/src/server/system-env.ts +28 -10
  124. package/src/service.ts +49 -2
  125. package/src/storage/cleanup-job.ts +57 -0
  126. package/src/storage/cleanup.ts +1504 -28
  127. package/src/storage/policy-job.ts +366 -0
  128. package/src/storage/policy-scheduler.ts +40 -0
  129. package/src/storage/policy-worker.ts +53 -0
  130. package/src/storage/policy.ts +522 -0
  131. package/src/storage/restore-job.ts +242 -0
  132. package/src/storage/restore-worker.ts +52 -0
  133. package/src/storage/storage-mutation-coordinator.ts +109 -0
  134. package/src/tray/windows.ts +2 -0
  135. package/src/types.ts +96 -1
  136. package/src/update/index.ts +36 -18
  137. package/src/update/job.ts +103 -12
  138. package/src/update/npm-invocation.d.mts +23 -0
  139. package/src/update/npm-invocation.mjs +94 -0
  140. package/src/usage/debug.ts +2 -0
  141. package/src/usage/log.ts +4 -0
  142. package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
  143. package/gui/dist/assets/index-DfVGuN88.css +0 -1
package/src/cli/doctor.ts CHANGED
@@ -11,9 +11,11 @@ import { accessSync, constants, existsSync, readFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { dirname, join } from "node:path";
13
13
  import { getConfigDir, getConfigPath, readConfigDiagnostics, readPid, readRuntimePort, resolveEnvValue } from "../config";
14
+ import { findLiveProxy } from "../server/proxy-liveness";
14
15
  import { gracefulStopHost } from "../lib/process-control";
15
16
  import { maskAccountId } from "../lib/privacy";
16
- import { loadServiceTokenFromFile } from "../lib/service-secrets";
17
+ import { PROXY_ENV_KEYS, proxyEnvPresent } from "../lib/proxy-env";
18
+ import { configuredAdminToken } from "../lib/admin-secrets";
17
19
  import { readCodexTokens } from "../codex/auth-collision";
18
20
  import { collectOrcaCodexHomeDiagnostic, resolveCodexHomeDir as resolveCodexHomeDirImpl, isWslRuntime, listWslWindowsCodexHomes, wslAutomountRoot, type CodexHomeDeps } from "../codex/home";
19
21
  import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim";
@@ -285,17 +287,15 @@ export function collectWslDualInstall(deps: WslDualInstallDeps = {}): WslDualIns
285
287
  };
286
288
  }
287
289
 
288
- const PROXY_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"] as const;
289
-
290
290
  export type ProxyEnvRow = { key: string; present: boolean };
291
291
  export type EnvMap = Record<string, string | undefined>;
292
292
 
293
293
  /** Report only presence/absence of proxy env vars - never the value (it may
294
294
  * embed credentials). Checks both upper- and lower-case forms. */
295
295
  export function collectProxyEnv(env: EnvMap = process.env): ProxyEnvRow[] {
296
- return PROXY_KEYS.map(key => ({
296
+ return PROXY_ENV_KEYS.map(key => ({
297
297
  key,
298
- present: !!(env[key]?.trim() || env[key.toLowerCase()]?.trim()),
298
+ present: proxyEnvPresent(key, env),
299
299
  }));
300
300
  }
301
301
 
@@ -571,7 +571,7 @@ export function formatServiceMemoryLines(report: ServiceMemoryReport): string[]
571
571
  const lines: string[] = [];
572
572
  lines.push(` -- doctor process Bun ${Bun.version} (this is NOT the service process)`);
573
573
  if (report.status === "unauthorized") {
574
- lines.push(" -- proxy reachable but rejected the request — set OPENCODEX_API_AUTH_TOKEN to match the service");
574
+ lines.push(" -- proxy reachable but rejected the request — set OPENCODEX_ADMIN_AUTH_TOKEN to match the service");
575
575
  return lines;
576
576
  }
577
577
  if (report.status === "unreachable") {
@@ -718,9 +718,21 @@ export async function runDoctor(args: string[] = []): Promise<void> {
718
718
  }
719
719
  }
720
720
 
721
+ // #618: identity-verified liveness first so pid-file absence does not hide a live service.
722
+ // Reuse the diagnostics config already loaded above so doctor stays read-only on malformed JSON.
723
+ const live = await findLiveProxy({
724
+ configFn: () => ({ port: doctorConfig.port, hostname: doctorConfig.hostname }),
725
+ });
726
+ const livePid = live ? live.pid : readPid();
727
+ const liveRuntime = live
728
+ ? { pid: live.pid ?? 0, port: live.port, hostname: live.hostname }
729
+ : (livePid ? readRuntimePort(livePid) : null);
730
+
721
731
  const currentProxyEnv = collectProxyEnv();
722
732
  const configuredProxy = collectConfiguredProxy();
723
- const runningProxyEnv = collectRunningProxyEnv();
733
+ const runningProxyEnv = collectRunningProxyEnv({
734
+ readPidFn: () => (live ? live.pid : readPid()),
735
+ });
724
736
 
725
737
  console.log("\nCurrent doctor process proxy env (presence only)");
726
738
  for (const row of currentProxyEnv) {
@@ -742,21 +754,14 @@ export async function runDoctor(args: string[] = []): Promise<void> {
742
754
  }
743
755
  }
744
756
 
745
- // #314: service-process memory/runtime identity via the authed management
746
- // endpoint. readPid() FIRST (liveness), then the pid-scoped runtime record —
747
- // readRuntimePort alone can serve a stale file pointing at a foreign port.
748
- // Hoisted out of the block below: the Hints section reuses the same liveness pair
749
- // for the proxy-down restart hint.
750
- const livePid = readPid();
751
- const liveRuntime = livePid ? readRuntimePort(livePid) : null;
752
757
  console.log("\nMemory / runtime");
753
758
  {
754
759
  const runtime = liveRuntime;
755
- if (!runtime) {
760
+ if (!runtime || !live) {
756
761
  console.log(` -- doctor process Bun ${Bun.version} (this is NOT the service process)`);
757
762
  console.log(" -- no running ocx proxy found (no live pid/runtime record)");
758
763
  } else {
759
- const token = process.env.OPENCODEX_API_AUTH_TOKEN ?? loadServiceTokenFromFile(process.env);
764
+ const token = configuredAdminToken();
760
765
  const report = await fetchServiceMemory(gracefulStopHost(runtime.hostname), runtime.port, token);
761
766
  for (const line of formatServiceMemoryLines(report)) console.log(line);
762
767
  }
@@ -815,8 +820,8 @@ export async function runDoctor(args: string[] = []): Promise<void> {
815
820
  // Hints, not fixes.
816
821
  const hints: string[] = [];
817
822
  const proxyDown = proxyDownRestartHint({
818
- proxyRunning: Boolean(livePid && liveRuntime),
819
- port: doctorConfig.port ?? 10100,
823
+ proxyRunning: Boolean(live),
824
+ port: live?.port ?? doctorConfig.port ?? 10100,
820
825
  serviceViable: startup.serviceViable,
821
826
  });
822
827
  if (proxyDown) hints.push(proxyDown);
package/src/cli/help.ts CHANGED
@@ -30,12 +30,18 @@ const helpEntries: Record<string, HelpEntry> = {
30
30
  uninstall: {
31
31
  usage: "ocx uninstall",
32
32
  summary: "Remove service/shim/config and restore native Codex.",
33
- details: ["Alias: ocx remove"],
33
+ details: [
34
+ "Alias: ocx remove",
35
+ "Config cleanup requires ownership metadata created by a fresh install; legacy or shared directories are left in place.",
36
+ ],
34
37
  },
35
38
  remove: {
36
39
  usage: "ocx remove",
37
40
  summary: "Remove service/shim/config and restore native Codex.",
38
- details: ["Alias of: ocx uninstall"],
41
+ details: [
42
+ "Alias of: ocx uninstall",
43
+ "Config cleanup requires ownership metadata created by a fresh install; legacy or shared directories are left in place.",
44
+ ],
39
45
  },
40
46
  service: {
41
47
  usage: "ocx service [install|start|stop|status|uninstall|remove]",
@@ -60,8 +66,22 @@ const helpEntries: Record<string, HelpEntry> = {
60
66
  ],
61
67
  },
62
68
  ensure: { usage: "ocx ensure", summary: "Ensure the proxy is running and Codex config/cache are current." },
63
- sync: { usage: "ocx sync", summary: "Fetch provider models and inject them into Codex config." },
64
- "sync-cache": { usage: "ocx sync-cache", summary: "Refresh Codex's model cache from the active catalog." },
69
+ sync: {
70
+ usage: "ocx sync [--restart-codex]",
71
+ summary: "Fetch provider models and inject them into Codex config.",
72
+ details: [
73
+ "After writing the catalog, warns if long-lived Codex app-server processes are still running.",
74
+ "--restart-codex sends SIGTERM only to matching app-server / code-mode-host processes (may interrupt active turns).",
75
+ ],
76
+ },
77
+ "sync-cache": {
78
+ usage: "ocx sync-cache [--restart-codex]",
79
+ summary: "Refresh Codex's model cache from the active catalog.",
80
+ details: [
81
+ "Warns when Codex app-server processes still hold an in-memory model list.",
82
+ "--restart-codex sends SIGTERM only to matching app-server / code-mode-host processes (may interrupt active turns).",
83
+ ],
84
+ },
65
85
  status: { usage: "ocx status", summary: "Check proxy server status." },
66
86
  doctor: { usage: "ocx doctor", summary: "Diagnose environment/network issues (paths, WSL /mnt, proxy env, ChatGPT reachability)." },
67
87
  debug: {
@@ -184,6 +204,20 @@ const helpEntries: Record<string, HelpEntry> = {
184
204
  "Claude Code settings: ocx claude config <status|set> ...",
185
205
  ],
186
206
  },
207
+ opencode: {
208
+ usage: "ocx opencode [opencode args...]",
209
+ summary: "Launch opencode wired to the proxy (runtime provider config).",
210
+ details: [
211
+ "Ensures the proxy is running, then execs `opencode` with the generated `provider.opencodex`",
212
+ "block injected through OpenCode's inline runtime layer (`OPENCODE_CONFIG_CONTENT`). Any",
213
+ "existing inline config in the environment is preserved and only `provider.opencodex` is",
214
+ "overwritten for this launch.",
215
+ "Global/project opencode.json may be read to warn about an existing provider.opencodex",
216
+ "override; on-disk files are never modified.",
217
+ "Routed models appear in the model picker as opencodex/<provider>/<model>.",
218
+ "Stop using `ocx opencode` and plain `opencode` behaves exactly as before.",
219
+ ],
220
+ },
187
221
  restart: {
188
222
  usage: "ocx restart",
189
223
  summary: "Stop the proxy and restart it (background). Equivalent to stop + ensure.",
@@ -232,8 +266,9 @@ Usage:
232
266
  ocx codex-shim <sub> Auto-start proxy when \`codex\` launches (install|status|uninstall|remove)
233
267
  ocx tray <sub> Windows status tray (install|start|stop|status|uninstall)
234
268
  ocx ensure Ensure the proxy is running and Codex config/cache are current
235
- ocx sync Fetch models from providers and inject into Codex config
236
- ocx sync-cache Refresh Codex's model cache from the active catalog
269
+ ocx sync [--restart-codex] Fetch models from providers and inject into Codex config
270
+ ocx sync-cache [--restart-codex]
271
+ Refresh Codex's model cache from the active catalog
237
272
  ocx status Check proxy server status
238
273
  ocx doctor Diagnose environment/network issues (WSL, proxy, ChatGPT reachability)
239
274
  ocx debug <scope> provider/usage/injection/claude on|off|status|reset
@@ -256,6 +291,7 @@ Usage:
256
291
  ocx config <sub> Validated configuration show/get/set/import/export
257
292
  ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on)
258
293
  ocx claude desktop [sub] Manage and apply Claude Desktop's four-family profile
294
+ ocx opencode [args...] Launch opencode wired to the proxy (runtime provider config)
259
295
  ocx help [command] Show help
260
296
  ocx --version | -v Print version
261
297
 
package/src/cli/index.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env bun
2
2
  import { spawn } from "node:child_process";
3
- import { rmSync } from "node:fs";
4
3
  import { currentExternalCodexModelProvider, restoreNativeCodex, shouldInjectApiAuthHeader } from "../codex/inject";
5
4
  import { stripGrokConfig } from "../grok/inject";
6
5
  import { restoreLegacyOpenaiHistory } from "../codex/history-provider";
@@ -31,7 +30,7 @@ import { stopProxy } from "../lib/process-control";
31
30
  import { loadServiceTokenFromFile } from "../lib/service-secrets";
32
31
  import { diagnoseService, isServiceOwnershipError, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service";
33
32
  import { startupHealthSummary } from "../codex/autostart-health";
34
- import { drainAndShutdown, startServer } from "../server";
33
+ import { drainAndShutdown, isRecyclingForExit, startServer } from "../server";
35
34
  import { injectSystemEnv, revertSystemEnv } from "../server/system-env";
36
35
  import { buildDesktop3pRegistry } from "../claude/desktop-3p";
37
36
  import { installShellHook, uninstallShellHook } from "../server/system-env";
@@ -43,6 +42,7 @@ import { maybeShowUpdatePrompt } from "../update/notify";
43
42
  import { syncModelsToCodex } from "../codex/sync";
44
43
  import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
45
44
  import { collectOrcaCodexHomeDiagnostic } from "../codex/home";
45
+ import { removeOwnedConfigState } from "../lib/config-ownership";
46
46
 
47
47
  const args = process.argv.slice(2);
48
48
  const command = args[0];
@@ -227,24 +227,40 @@ async function handleStart(options: { block?: boolean } = {}) {
227
227
  let historyGuardian: ReturnType<typeof startHistoryMigrationGuardian> | undefined;
228
228
 
229
229
  let cleaned = false;
230
+ let cleanupSucceeded = true;
230
231
  const syncCleanup = () => {
231
- if (cleaned) return;
232
+ if (cleaned) return cleanupSucceeded;
232
233
  cleaned = true;
233
234
  try { guardian.stop(); } catch { /* best-effort */ }
234
235
  try { historyGuardian?.stop(); } catch { /* best-effort */ }
235
- try { revertSystemEnv(); } catch { /* best-effort */ }
236
+ // Dashboard drain-and-restart (#563) must not tear down injection: the replacement
237
+ // process expects Codex/Grok/env fences to still be in place.
238
+ const recycling = isRecyclingForExit();
239
+ if (!recycling) {
240
+ try { revertSystemEnv(); } catch { /* best-effort */ }
241
+ }
236
242
  removePid(process.pid);
237
243
  removeRuntimePort(process.pid);
238
- if (!process.env.OCX_SERVICE && !currentExternalCodexModelProvider()) {
239
- try { restoreNativeCodex(); } catch { /* best-effort restore */ }
244
+ if (!recycling && !process.env.OCX_SERVICE && !currentExternalCodexModelProvider()) {
245
+ try {
246
+ const restored = restoreNativeCodex();
247
+ if (!restored.success) {
248
+ cleanupSucceeded = false;
249
+ console.error(`⚠️ Native Codex restore failed during shutdown: ${restored.message}`);
250
+ }
251
+ } catch (error) {
252
+ cleanupSucceeded = false;
253
+ console.error(`⚠️ Native Codex restore failed during shutdown: ${error instanceof Error ? error.message : String(error)}`);
254
+ }
240
255
  }
241
256
  // Same ownership rule as `ocx stop`: if the installed service belongs to another home, the
242
257
  // Grok fence is shared state we must not remove — that service keeps running and would be
243
258
  // left pointing nowhere. This guard also covers signal-driven exits, which is the path that
244
259
  // would otherwise bypass handleStop's gate entirely.
245
- if (!process.env.OCX_SERVICE && serviceEnvironmentOwnedHere()) {
260
+ if (!recycling && !process.env.OCX_SERVICE && serviceEnvironmentOwnedHere()) {
246
261
  try { stripGrokConfig(); } catch { /* best-effort restore */ }
247
262
  }
263
+ return cleanupSucceeded;
248
264
  };
249
265
 
250
266
  let shuttingDown = false;
@@ -269,8 +285,8 @@ async function handleStart(options: { block?: boolean } = {}) {
269
285
  try {
270
286
  await drainAndShutdown(server, config.shutdownTimeoutMs ?? 5000);
271
287
  } finally {
272
- syncCleanup(); // idempotent (cleaned-guard); also re-run by process.on("exit")
273
- process.exit(0);
288
+ const restored = syncCleanup(); // idempotent (cleaned-guard); also re-run by process.on("exit")
289
+ process.exit(restored ? 0 : 1);
274
290
  }
275
291
  })();
276
292
  };
@@ -288,7 +304,7 @@ async function handleStart(options: { block?: boolean } = {}) {
288
304
  // Auto-install .zshrc hook (idempotent — skips if already present).
289
305
  installShellHook();
290
306
 
291
- await maybeShowStarPrompt(); // once-only [y/N] GitHub-star prompt on first interactive start
307
+ await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start
292
308
  await syncModelsToCodex(port).catch(() => {});
293
309
  if (!currentExternalCodexModelProvider() && !shouldInjectApiAuthHeader(config) && config.syncResumeHistory !== false) {
294
310
  historyGuardian = startHistoryMigrationGuardian();
@@ -499,7 +515,11 @@ async function handleStop() {
499
515
  }
500
516
  if (!ownershipBlocked) {
501
517
  const r = restoreNativeCodex();
502
- console.log(`↩️ ${r.message}`);
518
+ if (r.success) console.log(`↩️ ${r.message}`);
519
+ else {
520
+ stopFailed = true;
521
+ console.error(`⚠️ ${r.message}`);
522
+ }
503
523
  }
504
524
  // revertSystemEnv is NOT gated: it carries its own ownership check and concerns launchctl
505
525
  // user env, not CODEX_HOME. Safety net for when the daemon's syncCleanup didn't run (SIGKILL).
@@ -588,7 +608,13 @@ async function handleUninstall() {
588
608
 
589
609
  if (failures.length === 0) {
590
610
  await runStep("opencodex config removed", () => {
591
- rmSync(getConfigDir(), { recursive: true, force: true });
611
+ const result = removeOwnedConfigState(getConfigDir());
612
+ if (result.status === "absent") return false;
613
+ if (result.status === "removed") return true;
614
+ const residual = result.residualPaths.length > 0
615
+ ? ` Residual path(s): ${result.residualPaths.join(", ")}`
616
+ : "";
617
+ throw new Error(`${result.status} uninstall: ${result.reason ?? "config state was not removed"}.${residual}`);
592
618
  });
593
619
  } else {
594
620
  console.error("Leaving opencodex config/backups in place so the failed restore step can be retried.");
@@ -716,19 +742,40 @@ switch (command) {
716
742
  console.error("No running proxy found. Run 'ocx start' — it injects opencodex automatically.");
717
743
  process.exit(1);
718
744
  }
719
- await syncModelsToCodex(live.port);
745
+ const synced = await syncModelsToCodex(live.port);
746
+ if (!synced.ok) {
747
+ process.exitCode = 1;
748
+ console.error("Plain `codex` was not switched back to opencodex. Fix the reported Codex config issue and retry.");
749
+ break;
750
+ }
720
751
  const target = collectOrcaCodexHomeDiagnostic();
721
752
  console.log(`Plain \`codex\` now routes through opencodex in ${target.effectiveCodexHome} (undo with: ocx restore).`);
722
753
  break;
723
754
  }
724
- const r = restoreNativeCodex();
725
- console.log(r.success ? `✅ ${r.message}` : `⚠️ ${r.message}`);
755
+ let r: { success: boolean; message: string };
756
+ try {
757
+ r = restoreNativeCodex();
758
+ } catch (err) {
759
+ r = { success: false, message: err instanceof Error ? err.message : String(err) };
760
+ }
761
+ if (r.success) console.log(`✅ ${r.message}`);
762
+ else {
763
+ console.error(`⚠️ ${r.message}`);
764
+ process.exitCode = 1;
765
+ }
726
766
  try {
727
767
  const g = stripGrokConfig();
728
768
  if (g.changed) console.log(`✅ ${g.message}`);
729
- else if (!g.ok) console.error(`⚠️ ${g.message}`);
769
+ else if (!g.ok) {
770
+ console.error(`⚠️ ${g.message}`);
771
+ process.exitCode = 1;
772
+ }
730
773
  } catch { /* best-effort */ }
731
- console.log("Plain `codex` now runs natively (no proxy). Switch back with: ocx restore back");
774
+ if (r.success) {
775
+ console.log("Plain `codex` now runs natively (no proxy). Switch back with: ocx restore back");
776
+ } else {
777
+ console.error("Plain `codex` was not fully restored. Inspect $CODEX_HOME/config.toml before using native Codex.");
778
+ }
732
779
  break;
733
780
  }
734
781
  case "recover-history":
@@ -767,7 +814,20 @@ switch (command) {
767
814
  break;
768
815
  }
769
816
  case "sync": {
770
- await syncModelsToCodex((await findLiveProxy())?.port);
817
+ const restartCodex = args.slice(1).includes("--restart-codex");
818
+ const synced = await syncModelsToCodex((await findLiveProxy())?.port);
819
+ if (!synced.ok) {
820
+ process.exitCode = 1;
821
+ console.error("Codex sync did not complete. Fix the reported Codex config issue and retry.");
822
+ }
823
+ // Only warn/restart when a catalog or models_cache write actually happened. This is
824
+ // deliberately not an `else`: refreshCodexModelCatalog runs before injectCodexConfig,
825
+ // so a sync can fail (`ok: false`) after the catalog was already rewritten — which is
826
+ // exactly when a long-lived app-server is holding the stale list.
827
+ if (synced.catalogWritten || synced.cacheSynced) {
828
+ const { afterCatalogWriteHandleAppServers } = await import("../codex/app-server-processes");
829
+ afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console });
830
+ }
771
831
  break;
772
832
  }
773
833
  case "v2": {
@@ -776,8 +836,13 @@ switch (command) {
776
836
  break;
777
837
  }
778
838
  case "sync-cache": {
839
+ const restartCodex = args.slice(1).includes("--restart-codex");
779
840
  const { invalidateCodexModelsCache } = await import("../codex/catalog");
780
- invalidateCodexModelsCache();
841
+ // Only warn/restart when models_cache was actually rewritten from a readable catalog.
842
+ if (invalidateCodexModelsCache()) {
843
+ const { afterCatalogWriteHandleAppServers } = await import("../codex/app-server-processes");
844
+ afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console });
845
+ }
781
846
  break;
782
847
  }
783
848
  case "gui": {
@@ -1005,6 +1070,10 @@ switch (command) {
1005
1070
  break;
1006
1071
  }
1007
1072
  process.exit(await cmdClaude(args.slice(1)));
1073
+ }
1074
+ case "opencode": {
1075
+ const { cmdOpencode } = await import("./opencode");
1076
+ process.exit(await cmdOpencode(args.slice(1)));
1008
1077
  }
1009
1078
  case "help":
1010
1079
  case "--help":
@@ -0,0 +1,129 @@
1
+ import { createInterface } from "node:readline/promises";
2
+
3
+ /**
4
+ * Inline yes/no selector for interactive CLI prompts.
5
+ *
6
+ * Both choices are drawn on one line: the user moves between them with the
7
+ * arrow keys (or Tab), confirms with Enter, or answers straight away with
8
+ * `y`/`n`. Escape and Ctrl-C resolve to "no", so backing out never counts as
9
+ * consent.
10
+ *
11
+ * The highlighted choice is caller-supplied and is what a bare Enter returns.
12
+ * Whatever the default, the selector always shows which side is highlighted, so
13
+ * Enter never does something the screen did not already say it would.
14
+ */
15
+
16
+ export interface InteractiveConfirmOptions {
17
+ /** Question text rendered before the choices. May contain ANSI styling. */
18
+ question: string;
19
+ /** Which choice starts highlighted, and therefore what a bare Enter returns. */
20
+ defaultYes: boolean;
21
+ /** Key hint shown after the choices. */
22
+ hint?: string;
23
+ /** Overridable for tests; defaults to the process streams. */
24
+ input?: NodeJS.ReadStream;
25
+ output?: NodeJS.WriteStream;
26
+ }
27
+
28
+ const REVERSE = "\x1b[7m";
29
+ const DIM = "\x1b[2m";
30
+ const RESET = "\x1b[0m";
31
+ const CLEAR_LINE = "\r\x1b[K";
32
+
33
+ const KEY_ENTER = new Set(["\r", "\n"]);
34
+ const KEY_YES_SIDE = new Set(["\x1b[D", "\x1b[A", "\x1bOD", "\x1bOA"]); // left / up
35
+ const KEY_NO_SIDE = new Set(["\x1b[C", "\x1b[B", "\x1bOC", "\x1bOB"]); // right / down
36
+ const KEY_ESCAPE = "\x1b";
37
+ const KEY_INTERRUPT = "\x03";
38
+ const KEY_TAB = "\t";
39
+
40
+ function renderChoices(question: string, yes: boolean, hint: string): string {
41
+ const yesLabel = yes ? `${REVERSE} Yes ${RESET}` : `${DIM} Yes ${RESET}`;
42
+ const noLabel = yes ? `${DIM} No ${RESET}` : `${REVERSE} No ${RESET}`;
43
+ return `${CLEAR_LINE}${question} ${yesLabel} ${noLabel} ${DIM}${hint}${RESET}`;
44
+ }
45
+
46
+ function renderAnswer(question: string, yes: boolean): string {
47
+ return `${CLEAR_LINE}${question} ${yes ? "Yes" : "No"}\n`;
48
+ }
49
+
50
+ /** Fallback for terminals without raw mode: a plain typed answer. */
51
+ async function readlineConfirm(
52
+ options: InteractiveConfirmOptions,
53
+ input: NodeJS.ReadStream,
54
+ output: NodeJS.WriteStream,
55
+ ): Promise<boolean> {
56
+ const suffix = options.defaultYes ? "[Y/n]" : "[y/N]";
57
+ const rl = createInterface({ input, output });
58
+ try {
59
+ const answer = (await rl.question(`${options.question} ${suffix} `)).trim().toLowerCase();
60
+ if (answer === "") return options.defaultYes;
61
+ return answer === "y" || answer === "yes";
62
+ } finally {
63
+ rl.close();
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Ask a yes/no question with an inline arrow-key selector. Resolves to the
69
+ * user's choice; never throws for input handling and always restores the
70
+ * terminal mode it changed.
71
+ */
72
+ export async function interactiveConfirm(options: InteractiveConfirmOptions): Promise<boolean> {
73
+ const input = options.input ?? process.stdin;
74
+ const output = options.output ?? process.stdout;
75
+ const hint = options.hint ?? "←/→ move · y/n · enter";
76
+
77
+ // Raw mode is what makes single-keypress navigation possible. Without it
78
+ // (pipes, some CI shells, Windows consoles without a TTY) fall back to a
79
+ // typed answer rather than silently swallowing the question.
80
+ if (typeof input.setRawMode !== "function") {
81
+ return await readlineConfirm(options, input, output);
82
+ }
83
+
84
+ return await new Promise<boolean>(resolve => {
85
+ let yes = options.defaultYes;
86
+ const wasRaw = input.isRaw === true;
87
+ const hadOtherReaders = input.listenerCount("data") > 0;
88
+
89
+ const paint = () => {
90
+ output.write(renderChoices(options.question, yes, hint));
91
+ };
92
+
93
+ const finish = (answer: boolean, interrupted: boolean) => {
94
+ input.off("data", onData);
95
+ if (!wasRaw) input.setRawMode(false);
96
+ if (!hadOtherReaders) input.pause();
97
+ output.write(renderAnswer(options.question, answer));
98
+ resolve(answer);
99
+ // A Ctrl-C during the prompt is still a Ctrl-C: hand it back to the
100
+ // process so the normal shutdown path runs instead of being eaten here.
101
+ if (interrupted) process.kill(process.pid, "SIGINT");
102
+ };
103
+
104
+ const onData = (chunk: Buffer | string) => {
105
+ const key = typeof chunk === "string" ? chunk : chunk.toString("utf8");
106
+ if (key === KEY_INTERRUPT) return finish(false, true);
107
+ if (key === KEY_ESCAPE) return finish(false, false);
108
+ if (KEY_ENTER.has(key)) return finish(yes, false);
109
+ const lower = key.toLowerCase();
110
+ if (lower === "y") return finish(true, false);
111
+ if (lower === "n") return finish(false, false);
112
+ if (KEY_YES_SIDE.has(key)) {
113
+ yes = true;
114
+ paint();
115
+ return;
116
+ }
117
+ if (KEY_NO_SIDE.has(key) || key === KEY_TAB) {
118
+ yes = key === KEY_TAB ? !yes : false;
119
+ paint();
120
+ return;
121
+ }
122
+ };
123
+
124
+ input.setRawMode(true);
125
+ input.resume();
126
+ input.on("data", onData);
127
+ paint();
128
+ });
129
+ }