@bitkyc08/opencodex 2.6.17 → 2.6.18

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 (84) hide show
  1. package/README.md +9 -0
  2. package/bin/ocx.mjs +70 -5
  3. package/gui/dist/assets/index-DDcEW0Cm.css +1 -0
  4. package/gui/dist/assets/index-DbTEyo46.js +9 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -1
  7. package/src/adapters/anthropic.ts +9 -2
  8. package/src/adapters/base.ts +6 -0
  9. package/src/adapters/cursor/arg-codec.ts +38 -0
  10. package/src/adapters/cursor/arg-normalize.ts +88 -0
  11. package/src/adapters/cursor/cursor-errors.ts +85 -0
  12. package/src/adapters/cursor/discovery.ts +144 -0
  13. package/src/adapters/cursor/effort-map.ts +74 -0
  14. package/src/adapters/cursor/exec-policy.ts +44 -0
  15. package/src/adapters/cursor/framing.ts +136 -0
  16. package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
  17. package/src/adapters/cursor/kv-store.ts +25 -0
  18. package/src/adapters/cursor/live-models.ts +93 -0
  19. package/src/adapters/cursor/live-smoke-gate.ts +41 -0
  20. package/src/adapters/cursor/live-transport.ts +758 -0
  21. package/src/adapters/cursor/mcp-config.ts +42 -0
  22. package/src/adapters/cursor/mcp-manager.ts +236 -0
  23. package/src/adapters/cursor/message-mapper.ts +46 -0
  24. package/src/adapters/cursor/native-exec-common.ts +55 -0
  25. package/src/adapters/cursor/native-exec-desktop.ts +177 -0
  26. package/src/adapters/cursor/native-exec-fs.ts +284 -0
  27. package/src/adapters/cursor/native-exec-mcp.ts +151 -0
  28. package/src/adapters/cursor/native-exec-network.ts +32 -0
  29. package/src/adapters/cursor/native-exec-shell.ts +191 -0
  30. package/src/adapters/cursor/native-exec-tools.ts +118 -0
  31. package/src/adapters/cursor/native-exec.ts +177 -0
  32. package/src/adapters/cursor/protobuf-events.ts +309 -0
  33. package/src/adapters/cursor/protobuf-request.ts +347 -0
  34. package/src/adapters/cursor/request-builder.ts +98 -0
  35. package/src/adapters/cursor/tool-definitions.ts +301 -0
  36. package/src/adapters/cursor/transport-retry.ts +116 -0
  37. package/src/adapters/cursor/transport.ts +47 -0
  38. package/src/adapters/cursor/types.ts +36 -0
  39. package/src/adapters/cursor.ts +99 -0
  40. package/src/adapters/google.ts +7 -1
  41. package/src/adapters/kiro.ts +15 -0
  42. package/src/adapters/openai-chat.ts +7 -2
  43. package/src/adapters/run-turn-queue.ts +58 -0
  44. package/src/adapters/tool-catalog-nudge.ts +71 -0
  45. package/src/bridge.ts +7 -1
  46. package/src/cli-help.ts +9 -2
  47. package/src/cli-status.ts +7 -5
  48. package/src/cli.ts +122 -79
  49. package/src/codex-catalog.ts +213 -71
  50. package/src/codex-history-provider.ts +31 -14
  51. package/src/codex-inject.ts +17 -9
  52. package/src/codex-paths.ts +2 -1
  53. package/src/codex-shim.ts +30 -7
  54. package/src/codex-sync.ts +70 -0
  55. package/src/config.ts +58 -2
  56. package/src/doctor.ts +4 -2
  57. package/src/index.ts +1 -0
  58. package/src/model-cache.ts +22 -2
  59. package/src/oauth/callback-server.ts +44 -16
  60. package/src/oauth/cursor.ts +188 -0
  61. package/src/oauth/index.ts +29 -3
  62. package/src/oauth/key-providers.ts +20 -33
  63. package/src/oauth/login-cli.ts +7 -4
  64. package/src/open-url.ts +5 -1
  65. package/src/ports.ts +13 -0
  66. package/src/process-control.ts +76 -0
  67. package/src/provider-label.ts +10 -5
  68. package/src/providers/derive.ts +30 -3
  69. package/src/providers/registry.ts +39 -1
  70. package/src/proxy-liveness.ts +122 -0
  71. package/src/responses/parser.ts +1 -0
  72. package/src/responses/state.ts +83 -0
  73. package/src/router.ts +38 -23
  74. package/src/server/adapter-resolve.ts +3 -0
  75. package/src/server.ts +130 -18
  76. package/src/service.ts +94 -32
  77. package/src/types.ts +24 -1
  78. package/src/update-job.ts +360 -0
  79. package/src/update.ts +73 -11
  80. package/src/usage-log.ts +3 -3
  81. package/src/usage-summary.ts +3 -2
  82. package/src/win-paths.ts +68 -0
  83. package/gui/dist/assets/index-DIBiVVC0.css +0 -1
  84. package/gui/dist/assets/index-DcnD944i.js +0 -9
@@ -5,6 +5,7 @@ import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoice
5
5
  import { mapReasoningEffort } from "../reasoning-effort";
6
6
  import { contentPartsToText } from "./image";
7
7
  import { neutralizeIdentity } from "./identity";
8
+ import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge";
8
9
 
9
10
  // Z.AI's "glm-5.2[1m]" 1M-context id is a Claude-Code / Anthropic-endpoint-only
10
11
  // convention; OpenAI-compatible chat-completions endpoints reject the bracketed
@@ -20,12 +21,16 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
20
21
  const { context, options } = parsed;
21
22
  let pendingToolCallIds = new Set<string>();
22
23
 
23
- if (context.systemPrompt && context.systemPrompt.length > 0) {
24
+ const toolCatalogNudge = shouldInjectNonOpenAIToolCatalogNudge(provider)
25
+ ? buildNonOpenAIToolCatalogNudgeForTools(context.tools, options.toolChoice)
26
+ : undefined;
27
+ const systemParts = [...(context.systemPrompt ?? []), ...(toolCatalogNudge ? [toolCatalogNudge] : [])];
28
+ if (systemParts.length > 0) {
24
29
  // Codex sends its GPT-5 identity prompt for EVERY model (the per-model catalog
25
30
  // base_instructions is ignored at request time). Neutralize that one identity line
26
31
  // so routed, non-OpenAI models don't misreport themselves as GPT-5 / OpenAI — without
27
32
  // leaking the proxy identity into the payload.
28
- const sys = neutralizeIdentity(context.systemPrompt.join("\n\n"));
33
+ const sys = neutralizeIdentity(systemParts.join("\n\n"));
29
34
  out.push({ role: "system", content: sys });
30
35
  }
31
36
 
@@ -0,0 +1,58 @@
1
+ import type { AdapterEvent } from "../types";
2
+
3
+ type QueueReader = (result: IteratorResult<AdapterEvent>) => void;
4
+
5
+ export interface AdapterEventQueue {
6
+ push(event: AdapterEvent): void;
7
+ close(): void;
8
+ stream(): AsyncIterable<AdapterEvent>;
9
+ collect(): Promise<AdapterEvent[]>;
10
+ }
11
+
12
+ export function createAdapterEventQueue(): AdapterEventQueue {
13
+ const queued: AdapterEvent[] = [];
14
+ const readers: QueueReader[] = [];
15
+ let closed = false;
16
+
17
+ const push = (event: AdapterEvent): void => {
18
+ if (closed) return;
19
+ const reader = readers.shift();
20
+ if (reader) {
21
+ reader({ done: false, value: event });
22
+ return;
23
+ }
24
+ queued.push(event);
25
+ };
26
+
27
+ const close = (): void => {
28
+ if (closed) return;
29
+ closed = true;
30
+ while (readers.length > 0) {
31
+ readers.shift()?.({ done: true, value: undefined as never });
32
+ }
33
+ };
34
+
35
+ async function* stream(): AsyncIterable<AdapterEvent> {
36
+ while (true) {
37
+ const next = queued.shift();
38
+ if (next) {
39
+ yield next;
40
+ continue;
41
+ }
42
+ if (closed) return;
43
+ const result = await new Promise<IteratorResult<AdapterEvent>>(resolve => {
44
+ readers.push(resolve);
45
+ });
46
+ if (result.done) return;
47
+ yield result.value;
48
+ }
49
+ }
50
+
51
+ const collect = async (): Promise<AdapterEvent[]> => {
52
+ const events: AdapterEvent[] = [];
53
+ for await (const event of stream()) events.push(event);
54
+ return events;
55
+ };
56
+
57
+ return { push, close, stream, collect };
58
+ }
@@ -0,0 +1,71 @@
1
+ import {
2
+ isAllowedToolChoice,
3
+ namespacedToolName,
4
+ toolAllowedByChoice,
5
+ toolChoiceAliases,
6
+ type OcxRequestOptions,
7
+ type OcxTool,
8
+ type OcxProviderConfig,
9
+ } from "../types";
10
+
11
+ const NEIGHBOR_AGENT_TOOL_NAMES = ["Read", "Grep", "Glob", "Bash", "LS", "apply_patch"] as const;
12
+
13
+ function quoteNames(names: readonly string[]): string {
14
+ return names.map(name => `\`${name}\``).join(", ");
15
+ }
16
+
17
+ function uniqueNames(names: readonly string[]): string[] {
18
+ return [...new Set(names.filter(name => name.trim().length > 0))];
19
+ }
20
+
21
+ function toolChoiceAllows(tool: Pick<OcxTool, "namespace" | "name">, toolChoice: OcxRequestOptions["toolChoice"] | undefined): boolean {
22
+ if (!toolChoice || toolChoice === "auto" || toolChoice === "required") return true;
23
+ if (toolChoice === "none") return false;
24
+ if (isAllowedToolChoice(toolChoice)) return toolAllowedByChoice(tool, new Set(toolChoice.allowedTools));
25
+ return toolChoiceAliases(tool).includes(toolChoice.name);
26
+ }
27
+
28
+ function isOpenAIOrChatGPTHost(hostname: string): boolean {
29
+ return hostname === "openai.com"
30
+ || hostname.endsWith(".openai.com")
31
+ || hostname === "chatgpt.com"
32
+ || hostname.endsWith(".chatgpt.com");
33
+ }
34
+
35
+ export function shouldInjectNonOpenAIToolCatalogNudge(provider: Pick<OcxProviderConfig, "baseUrl">): boolean {
36
+ try {
37
+ return !isOpenAIOrChatGPTHost(new URL(provider.baseUrl).hostname);
38
+ } catch {
39
+ return true;
40
+ }
41
+ }
42
+
43
+ export function buildNonOpenAIToolCatalogNudgeFromNames(wireNames: readonly string[] | undefined): string | undefined {
44
+ const names = uniqueNames(wireNames ?? []);
45
+ if (names.length === 0) return undefined;
46
+
47
+ const advertised = new Set(names);
48
+ const unavailableNeighborNames = NEIGHBOR_AGENT_TOOL_NAMES.filter(name => !advertised.has(name));
49
+
50
+ return [
51
+ "Tool contract: use the current tool catalog as ground truth.",
52
+ `Valid tool names for this turn are exactly ${quoteNames(names)}.`,
53
+ "Call only listed names with their listed argument keys; do not invent, translate, or rename tools.",
54
+ unavailableNeighborNames.length > 0
55
+ ? `Do not use neighboring-agent tool names ${quoteNames(unavailableNeighborNames)} unless this turn's catalog lists those exact names.`
56
+ : undefined,
57
+ "If you need shell, file search, file read, edit, or discovery behavior, choose the listed tool that provides that capability.",
58
+ "Count a tool call only after its tool result returns; batch independent read-only calls when the runtime supports it.",
59
+ ].filter((line): line is string => typeof line === "string").join(" ");
60
+ }
61
+
62
+ export function buildNonOpenAIToolCatalogNudgeForTools(
63
+ tools: readonly Pick<OcxTool, "namespace" | "name">[] | undefined,
64
+ toolChoice?: OcxRequestOptions["toolChoice"],
65
+ toWireName: (tool: Pick<OcxTool, "namespace" | "name">) => string = tool => namespacedToolName(tool.namespace, tool.name),
66
+ ): string | undefined {
67
+ const visibleNames = tools
68
+ ?.filter(tool => toolChoiceAllows(tool, toolChoice))
69
+ .map(toWireName);
70
+ return buildNonOpenAIToolCatalogNudgeFromNames(visibleNames);
71
+ }
package/src/bridge.ts CHANGED
@@ -63,6 +63,7 @@ export function bridgeToResponsesSSE(
63
63
  stallTimeoutSec?: number;
64
64
  hideThinkingSummary?: boolean;
65
65
  onTerminal?: (status: ResponsesTerminalStatus) => void;
66
+ onCompletedResponse?: (response: Record<string, unknown>) => void;
66
67
  },
67
68
  ): ReadableStream<Uint8Array> {
68
69
  // Freeform/custom tools (apply_patch) carry their body in `input`; the model is given a
@@ -437,8 +438,10 @@ export function bridgeToResponsesSSE(
437
438
  if (currentRawReasoning) closeCurrentRawReasoning();
438
439
  if (currentToolCall) closeCurrentToolCall();
439
440
  if (currentWebSearch) closeCurrentWebSearch("completed", []);
441
+ const response = { ...responseSnapshot("completed", finishedItems), usage: responsesUsage(event.usage) };
442
+ options?.onCompletedResponse?.(response);
440
443
  emit("response.completed", {
441
- response: { ...responseSnapshot("completed", finishedItems), usage: responsesUsage(event.usage) },
444
+ response,
442
445
  });
443
446
  reportTerminal("completed");
444
447
  terminated = true;
@@ -453,6 +456,9 @@ export function bridgeToResponsesSSE(
453
456
  emit("response.failed", {
454
457
  response: {
455
458
  ...responseSnapshot("failed", finishedItems),
459
+ // Partial consumption from a mid-stream upstream failure: surfaced so the request
460
+ // log can record real tokens instead of usageStatus "unreported" with 0.
461
+ ...(event.usage ? { usage: responsesUsage(event.usage) } : {}),
456
462
  error: responseError(502, "upstream_error", event.message),
457
463
  last_error: responseError(502, "upstream_error", event.message),
458
464
  },
package/src/cli-help.ts CHANGED
@@ -14,8 +14,14 @@ const helpEntries: Record<string, HelpEntry> = {
14
14
  init: { usage: "ocx init", summary: "Interactive setup for providers and Codex config injection." },
15
15
  start: { usage: "ocx start [--port <port>]", summary: "Start the proxy server and sync models to Codex." },
16
16
  stop: { usage: "ocx stop", summary: "Stop the proxy and restore native Codex config." },
17
- restore: { usage: "ocx restore", summary: "Restore native Codex config without stopping the proxy." },
18
- eject: { usage: "ocx eject", summary: "Restore native Codex config without stopping the proxy." },
17
+ restore: {
18
+ usage: "ocx restore [back]",
19
+ summary: "Restore native Codex config without stopping the proxy; `restore back` re-points codex at the running proxy.",
20
+ },
21
+ eject: {
22
+ usage: "ocx eject [back]",
23
+ summary: "Restore native Codex config without stopping the proxy; `eject back` re-points codex at the running proxy.",
24
+ },
19
25
  "recover-history": {
20
26
  usage: "ocx recover-history --legacy-openai",
21
27
  summary: "Explicitly recover pre-backup syncResumeHistory rows.",
@@ -72,6 +78,7 @@ Usage:
72
78
  ocx start [--port <port>] Start the proxy server (auto-syncs models to Codex)
73
79
  ocx stop Stop the proxy AND restore native Codex (plain codex works again)
74
80
  ocx restore Restore native Codex without stopping (alias: eject)
81
+ ocx restore back Re-point codex at the running proxy (undo restore)
75
82
  ocx recover-history --legacy-openai
76
83
  Explicitly recover pre-backup syncResumeHistory rows
77
84
  ocx uninstall Remove service/shim/config and restore native Codex (alias: remove)
package/src/cli-status.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { durableBunRuntime } from "./bun-runtime";
2
2
  import { codexAutoStartEnabled, getConfigPath, getPidPath, readConfigDiagnostics, readPid, readRuntimePort, type RuntimePortState } from "./config";
3
3
  import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "./codex-plugins-doctor";
4
+ import { isOpencodexHealthz, probeHostname } from "./proxy-liveness";
4
5
  import type { OcxConfig } from "./types";
5
6
  import { serviceStatusSummary } from "./service";
6
7
 
@@ -54,9 +55,6 @@ export type CliStatusView = {
54
55
  healthLabel: string;
55
56
  };
56
57
 
57
- function healthHost(hostname?: string): string {
58
- return !hostname || hostname === "0.0.0.0" || hostname === "::" ? "127.0.0.1" : hostname;
59
- }
60
58
 
61
59
  export type ListenTarget = {
62
60
  port: number;
@@ -78,7 +76,7 @@ export function selectListenTarget(
78
76
  port,
79
77
  hostname,
80
78
  source: currentRuntimePort ? "runtime" : "config",
81
- healthUrl: `http://${healthHost(hostname)}:${port}/healthz`,
79
+ healthUrl: `http://${probeHostname(hostname)}:${port}/healthz`,
82
80
  dashboardUrl: `http://localhost:${port}/`,
83
81
  };
84
82
  }
@@ -93,7 +91,11 @@ async function checkProxyHealth(target: ListenTarget): Promise<HealthCheck> {
93
91
  const message = `returned HTTP ${response.status}`;
94
92
  return { ok: false, url, message, label: `${url} ${message}` };
95
93
  }
96
- const body = await response.json().catch(() => null) as { version?: unknown; uptime?: unknown } | null;
94
+ const body = await response.json().catch(() => null) as { service?: unknown; status?: unknown; version?: unknown; uptime?: unknown } | null;
95
+ if (!isOpencodexHealthz(body)) {
96
+ const message = "responded, but not an opencodex proxy";
97
+ return { ok: false, url, message, label: `${url} ${message}` };
98
+ }
97
99
  const version = typeof body?.version === "string" ? ` v${body.version}` : "";
98
100
  const uptime = typeof body?.uptime === "number" ? `, uptime ${Math.round(body.uptime)}s` : "";
99
101
  const message = `ok${version}${uptime}`;
package/src/cli.ts CHANGED
@@ -9,9 +9,12 @@ import {
9
9
  getConfigDir,
10
10
  loadConfig,
11
11
  readPid,
12
+ readPidFileValue,
12
13
  readRuntimePort,
13
14
  removePid,
15
+ removePidIfValueIs,
14
16
  removeRuntimePort,
17
+ removeRuntimePortIfPidIs,
15
18
  saveConfig,
16
19
  writePid,
17
20
  writeRuntimePort,
@@ -19,12 +22,15 @@ import {
19
22
  import { collectStatus } from "./cli-status";
20
23
  import { installCrashGuards } from "./crash-guard";
21
24
  import { hasHelpFlag, printSubcommandUsage, printUsage, printVersion } from "./cli-help";
22
- import { findAvailablePort, shouldPersistSelectedPort } from "./ports";
23
- import { killProxy } from "./process-control";
25
+ import { findAvailablePort, isAddrInUse, shouldPersistSelectedPort } from "./ports";
26
+ import { findLiveProxy, probeHostname, type LiveProxy } from "./proxy-liveness";
27
+ import { stopProxy } from "./process-control";
24
28
  import { serviceCommand, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "./service";
25
29
  import { drainAndShutdown, startServer } from "./server";
26
30
  import { maybeShowStarPrompt } from "./star-prompt";
27
31
  import { maybeShowUpdatePrompt } from "./update-notify";
32
+ import { syncModelsToCodex } from "./codex-sync";
33
+ import { normalizeUpdateChannel, runGuiUpdateWorker } from "./update-job";
28
34
 
29
35
  const args = process.argv.slice(2);
30
36
  const command = args[0];
@@ -44,28 +50,6 @@ if (command !== undefined && command !== "help" && hasHelpFlag(args.slice(1))) {
44
50
  process.exit(0);
45
51
  }
46
52
 
47
- async function syncModelsToCodex(port?: number) {
48
- const config = loadConfig();
49
- const p = port ?? config.port ?? 10100;
50
- let catalogPath: string | null | undefined;
51
- try {
52
- const { refreshCodexModelCatalog } = await import("./codex-refresh");
53
- const cat = await refreshCodexModelCatalog(config);
54
- catalogPath = cat.catalogExists ? cat.path : null;
55
- if (cat.added > 0) {
56
- console.log(` + ${cat.added} models appended to Codex catalog (${cat.path})`);
57
- } else if (catalogPath === null) {
58
- console.error("catalog sync skipped: no Codex catalog source found; keeping Codex's native catalog.");
59
- }
60
- } catch (e) {
61
- console.error("catalog sync skipped:", e instanceof Error ? e.message : String(e));
62
- }
63
- const { injectCodexConfig } = await import("./codex-inject");
64
- const result = await injectCodexConfig(p, config, { catalogPath });
65
- console.log(result.message);
66
- return result;
67
- }
68
-
69
53
  function parsePortOption(): number | undefined {
70
54
  if (args.length === 1) return undefined;
71
55
  if (args.length !== 3 || args[1] !== "--port") {
@@ -83,26 +67,13 @@ function parsePortOption(): number | undefined {
83
67
  return port;
84
68
  }
85
69
 
86
- async function proxyHealthy(port?: number): Promise<boolean> {
87
- const config = loadConfig();
88
- const p = port ?? config.port ?? 10100;
89
- try {
90
- const hostname = !config.hostname || config.hostname === "0.0.0.0" || config.hostname === "::" ? "127.0.0.1" : config.hostname;
91
- const res = await fetch(`http://${hostname}:${p}/healthz`, {
92
- signal: AbortSignal.timeout(750),
93
- });
94
- return res.ok;
95
- } catch {
96
- return false;
97
- }
98
- }
99
-
100
- async function waitForProxy(timeoutMs = 8_000): Promise<number | null> {
70
+ async function waitForProxy(timeoutMs = 8_000): Promise<LiveProxy | null> {
101
71
  const deadline = Date.now() + timeoutMs;
102
72
  while (Date.now() < deadline) {
103
- const config = loadConfig();
104
- const port = config.port ?? 10100;
105
- if (await proxyHealthy(port)) return port;
73
+ // Runtime-state-first with identity: finds the proxy even when it started on a
74
+ // fallback port, and never mistakes a foreign 200 for our proxy.
75
+ const live = await findLiveProxy();
76
+ if (live) return live;
106
77
  await new Promise(resolve => setTimeout(resolve, 150));
107
78
  }
108
79
  return null;
@@ -127,9 +98,9 @@ async function handleStart(options: { block?: boolean } = {}) {
127
98
  reconcileJournal();
128
99
  const existingPid = readPid();
129
100
  if (existingPid) {
130
- const config = loadConfig();
131
- if (await proxyHealthy(config.port)) {
132
- console.error(`⚠️ Proxy already running (PID ${existingPid}). Use 'ocx stop' first.`);
101
+ const live = await findLiveProxy();
102
+ if (live) {
103
+ console.error(`⚠️ Proxy already running (PID ${live.pid ?? existingPid}, port ${live.port}). Use 'ocx stop' first.`);
133
104
  process.exit(1);
134
105
  }
135
106
  removePid(existingPid);
@@ -140,9 +111,20 @@ async function handleStart(options: { block?: boolean } = {}) {
140
111
  // live daemon holding resources while it overwrites its own binary.
141
112
  await maybeShowUpdatePrompt();
142
113
 
143
- const port = await chooseListenPort(requestedPort);
144
-
145
- const server = startServer(port);
114
+ // Port selection is check-then-bind: a concurrent `ocx start`/`ensure` can win the port
115
+ // between the probe and Bun.serve. Retry the pick instead of dying on EADDRINUSE.
116
+ let port = await chooseListenPort(requestedPort);
117
+ let server: ReturnType<typeof startServer>;
118
+ for (let attempt = 0; ; attempt++) {
119
+ try {
120
+ server = startServer(port);
121
+ break;
122
+ } catch (err) {
123
+ if (!isAddrInUse(err) || attempt >= 2) throw err;
124
+ console.log(`⚠️ Port ${port} was taken while starting; picking another...`);
125
+ port = await chooseListenPort(requestedPort);
126
+ }
127
+ }
146
128
  // A single request's streaming error must never crash the daemon serving every
147
129
  // other Codex session — capture the full stack to crash.log and stay up.
148
130
  installCrashGuards();
@@ -206,16 +188,17 @@ async function handleStart(options: { block?: boolean } = {}) {
206
188
 
207
189
  async function handleEnsure() {
208
190
  reconcileJournal();
209
- let config = loadConfig();
191
+ const config = loadConfig();
210
192
  if (!codexAutoStartEnabled(config)) {
211
193
  console.log("Codex autostart is disabled.");
212
194
  return;
213
195
  }
214
- if (await proxyHealthy(config.port)) {
215
- await syncModelsToCodex(config.port).catch(e => {
196
+ const live = await findLiveProxy();
197
+ if (live) {
198
+ await syncModelsToCodex(live.port).catch(e => {
216
199
  console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`);
217
200
  });
218
- console.log(`✅ Proxy running on port ${config.port}`);
201
+ console.log(`✅ Proxy running on port ${live.port}`);
219
202
  return;
220
203
  }
221
204
 
@@ -227,19 +210,20 @@ async function handleEnsure() {
227
210
  });
228
211
  child.unref();
229
212
 
230
- const port = await waitForProxy();
213
+ const port = (await waitForProxy())?.port;
231
214
  if (!port) {
232
215
  console.error("❌ Proxy did not become healthy after starting.");
233
216
  process.exit(1);
234
217
  }
235
- config = loadConfig();
236
- await syncModelsToCodex(config.port ?? port).catch(e => {
218
+ // Always sync the LIVE port: after a fallback-port start, config.port still names the
219
+ // busy preferred port syncing that would point Codex at a dead listener.
220
+ await syncModelsToCodex(port).catch(e => {
237
221
  console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`);
238
222
  });
239
- console.log(`✅ Proxy running on port ${config.port ?? port}`);
223
+ console.log(`✅ Proxy running on port ${port}`);
240
224
  }
241
225
 
242
- function handleStop() {
226
+ async function handleStop() {
243
227
  const stoppedService = stopServiceIfInstalled();
244
228
  if (stoppedService) console.log("🛑 Service manager stopped (won't respawn).");
245
229
 
@@ -247,7 +231,9 @@ function handleStop() {
247
231
  let stopFailed = false;
248
232
  if (pid) {
249
233
  try {
250
- killProxy(pid);
234
+ // Graceful-first (management-API drain) — on Windows this is the only path where
235
+ // the proxy's shutdown handlers actually run; taskkill /F is the fallback inside.
236
+ await stopProxy(pid);
251
237
  console.log(`✅ Proxy (PID ${pid}) stopped.`);
252
238
  removePid(pid);
253
239
  removeRuntimePort(pid);
@@ -255,8 +241,32 @@ function handleStop() {
255
241
  stopFailed = true;
256
242
  console.error(`❌ Failed to stop proxy (PID ${pid}).`);
257
243
  }
258
- } else if (!stoppedService) {
259
- console.log("No running proxy found.");
244
+ } else {
245
+ // Snapshot the stale on-disk state BEFORE the async probe: a concurrent `ocx start`
246
+ // can write fresh records mid-probe, and the purge below must never delete those.
247
+ const stalePidValue = readPidFileValue();
248
+ const staleRuntimePid = readRuntimePort()?.pid ?? null;
249
+ // Orphan recovery: a live proxy can outlive its pid file (crash, manual delete,
250
+ // corrupt file). Identity-checked liveness still finds it via the runtime record.
251
+ const live = await findLiveProxy();
252
+ if (live?.pid) {
253
+ try {
254
+ await stopProxy(live.pid);
255
+ console.log(`✅ Proxy (PID ${live.pid}) stopped.`);
256
+ } catch {
257
+ stopFailed = true;
258
+ console.error(`❌ Failed to stop proxy (PID ${live.pid}).`);
259
+ }
260
+ } else if (!stoppedService) {
261
+ console.log("No running proxy found.");
262
+ }
263
+ if (!stopFailed) {
264
+ // `readPid() === null` means the snapshotted pid file was absent, invalid, dead, or
265
+ // not ours — stale by definition. Purge (guarded by the snapshot) so `ocx update`'s
266
+ // stop gate can't wedge on it.
267
+ removePidIfValueIs(stalePidValue);
268
+ removeRuntimePortIfPidIs(staleRuntimePid);
269
+ }
260
270
  }
261
271
  const r = restoreNativeCodex();
262
272
  console.log(`↩️ ${r.message}`);
@@ -266,9 +276,9 @@ function handleStop() {
266
276
  async function handleUninstall() {
267
277
  const failures: string[] = [];
268
278
 
269
- const runStep = (label: string, step: () => void | boolean) => {
279
+ const runStep = async (label: string, step: () => void | boolean | Promise<void | boolean>) => {
270
280
  try {
271
- const changed = step();
281
+ const changed = await step();
272
282
  if (changed === false) console.log(`- ${label}: not installed`);
273
283
  else console.log(`✅ ${label}`);
274
284
  } catch (err) {
@@ -277,20 +287,20 @@ async function handleUninstall() {
277
287
  }
278
288
  };
279
289
 
280
- runStep("service stopped", () => stopServiceIfInstalled());
290
+ await runStep("service stopped", () => stopServiceIfInstalled());
281
291
 
282
- runStep("proxy stopped", () => {
292
+ await runStep("proxy stopped", async () => {
283
293
  const pid = readPid();
284
294
  if (!pid) return false;
285
- killProxy(pid);
295
+ await stopProxy(pid);
286
296
  removePid(pid);
287
297
  removeRuntimePort(pid);
288
298
  return true;
289
299
  });
290
300
 
291
- runStep("service removed", () => uninstallServiceIfInstalled());
301
+ await runStep("service removed", () => uninstallServiceIfInstalled());
292
302
 
293
- runStep("native Codex restored", () => {
303
+ await runStep("native Codex restored", () => {
294
304
  const r = restoreNativeCodex();
295
305
  if (!r.success) throw new Error(r.message);
296
306
  });
@@ -305,7 +315,7 @@ async function handleUninstall() {
305
315
  }
306
316
 
307
317
  if (failures.length === 0) {
308
- runStep("opencodex config removed", () => {
318
+ await runStep("opencodex config removed", () => {
309
319
  rmSync(getConfigDir(), { recursive: true, force: true });
310
320
  });
311
321
  } else {
@@ -355,6 +365,11 @@ async function handleStatus() {
355
365
  console.log(` Suggested: ${status.json.codexPlugins.suggestedRepair}`);
356
366
  }
357
367
  }
368
+ const { oauthLoginSummary } = await import("./oauth/index");
369
+ console.log(` OAuth logins:`);
370
+ for (const e of oauthLoginSummary()) {
371
+ console.log(` ${e.provider.padEnd(10)} ${e.loggedIn ? `✓ logged in${e.email ? ` (${e.email})` : ""}` : "✗ not logged in"}`);
372
+ }
358
373
  }
359
374
 
360
375
  function handleRecoverHistory() {
@@ -364,6 +379,12 @@ function handleRecoverHistory() {
364
379
  process.exit(1);
365
380
  }
366
381
  const r = restoreLegacyOpenaiHistory();
382
+ if (r.failed) {
383
+ console.error(
384
+ "⚠️ Recovery SKIPPED: the Codex history DB is locked (Codex app/IDE open?). Close it and rerun this command.",
385
+ );
386
+ process.exit(1);
387
+ }
367
388
  console.log(`Recovered ${r.rows} legacy thread(s) to openai (${r.files} rollout file(s) updated).`);
368
389
  }
369
390
 
@@ -377,13 +398,26 @@ switch (command) {
377
398
  await handleStart();
378
399
  break;
379
400
  case "stop":
380
- handleStop();
401
+ await handleStop();
381
402
  break;
382
403
  case "restore":
383
404
  case "eject": {
405
+ if (args[1] === "back") {
406
+ // Reverse switch: re-point plain `codex` at the RUNNING proxy without touching its
407
+ // lifecycle — the counterpart of `ocx restore`. Start/stop triggers are unchanged;
408
+ // this only re-runs the same inject (config + catalog + history) `ocx start` does.
409
+ const live = await findLiveProxy();
410
+ if (!live) {
411
+ console.error("No running proxy found. Run 'ocx start' — it injects opencodex automatically.");
412
+ process.exit(1);
413
+ }
414
+ await syncModelsToCodex(live.port);
415
+ console.log("Plain `codex` now routes through opencodex again (undo with: ocx restore).");
416
+ break;
417
+ }
384
418
  const r = restoreNativeCodex();
385
419
  console.log(r.success ? `✅ ${r.message}` : `⚠️ ${r.message}`);
386
- console.log("Plain `codex` now runs natively (no proxy).");
420
+ console.log("Plain `codex` now runs natively (no proxy). Switch back with: ocx restore back");
387
421
  break;
388
422
  }
389
423
  case "recover-history":
@@ -417,7 +451,7 @@ switch (command) {
417
451
  break;
418
452
  }
419
453
  case "sync": {
420
- await syncModelsToCodex();
454
+ await syncModelsToCodex((await findLiveProxy())?.port);
421
455
  break;
422
456
  }
423
457
  case "sync-cache": {
@@ -428,8 +462,10 @@ switch (command) {
428
462
  case "gui": {
429
463
  const cfg = await import("./config");
430
464
  const config = cfg.loadConfig();
431
- let pid = cfg.readPid();
432
- if (!pid) {
465
+ // Identity-checked liveness (not the pid file + a fixed sleep): finds a fallback-port
466
+ // proxy and waits until the spawned one actually answers before opening the browser.
467
+ let live = await findLiveProxy();
468
+ if (!live) {
433
469
  console.log("Proxy not running. Starting...");
434
470
  const child = spawn(process.execPath, [process.argv[1], "start"], {
435
471
  detached: true,
@@ -438,19 +474,19 @@ switch (command) {
438
474
  env: process.env,
439
475
  });
440
476
  child.unref();
441
- await new Promise(r => setTimeout(r, 1000));
442
- pid = cfg.readPid();
477
+ live = await waitForProxy();
443
478
  }
444
- const runtimePort = pid ? cfg.readRuntimePort(pid) : null;
445
- const guiPort = runtimePort?.port ?? config.port;
446
- const guiUrl = `http://localhost:${guiPort}`;
479
+ // Open the host the proxy actually binds — `localhost` only answers for
480
+ // loopback/wildcard binds, not a concrete LAN/IPv6 hostname.
481
+ const guiHost = probeHostname(live?.hostname ?? config.hostname);
482
+ const guiUrl = `http://${guiHost === "127.0.0.1" ? "localhost" : guiHost}:${live?.port ?? config.port}`;
447
483
  console.log(`Opening ${guiUrl}`);
448
484
  const { openUrl } = await import("./open-url");
449
485
  openUrl(guiUrl);
450
486
  break;
451
487
  }
452
488
  case "service":
453
- serviceCommand(args[1]);
489
+ await serviceCommand(args[1]);
454
490
  break;
455
491
  case "codex-shim": {
456
492
  const { codexShimStatus, installCodexShim, uninstallCodexShim } = await import("./codex-shim");
@@ -488,6 +524,13 @@ switch (command) {
488
524
  await refreshVersionCache(channel);
489
525
  break;
490
526
  }
527
+ case "__gui-update-worker": {
528
+ const jobId = args[1];
529
+ if (!jobId) process.exit(1);
530
+ const channel = normalizeUpdateChannel(args[2]);
531
+ runGuiUpdateWorker(jobId, channel, args[3] === "restart");
532
+ break;
533
+ }
491
534
  case "help":
492
535
  case "--help":
493
536
  case "-h":