@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
package/src/router.ts CHANGED
@@ -8,17 +8,26 @@ interface RouteResult {
8
8
  modelId: string;
9
9
  }
10
10
 
11
- const MODEL_PROVIDER_PATTERNS: Record<string, string[]> = {
12
- anthropic: [
11
+ const MODEL_PROVIDER_PATTERNS: Array<{ providerNames: string[]; prefixes: string[] }> = [
12
+ {
13
+ providerNames: ["anthropic"],
14
+ prefixes: [
13
15
  "claude-", "claude-sonnet-", "claude-opus-", "claude-haiku-",
14
- ],
15
- chatgpt: [
16
+ ],
17
+ },
18
+ {
19
+ providerNames: ["openai", "chatgpt", "openai-apikey"],
20
+ prefixes: [
16
21
  "gpt-", "o1-", "o3-", "o4-",
17
- ],
18
- groq: [
22
+ ],
23
+ },
24
+ {
25
+ providerNames: ["groq"],
26
+ prefixes: [
19
27
  "llama-", "mixtral-", "gemma-",
20
- ],
21
- };
28
+ ],
29
+ },
30
+ ];
22
31
 
23
32
  // Merge registry-default effort maps under user values so persisted built-in provider configs
24
33
  // that predate reasoningEffortMap/modelReasoningEffortMap still get correct wire translations
@@ -151,6 +160,9 @@ export function routeModel(config: OcxConfig, modelId: string): RouteResult {
151
160
  }
152
161
  }
153
162
 
163
+ const patternRoute = routeByKnownModelPattern(config, modelId);
164
+ if (patternRoute) return patternRoute;
165
+
154
166
  for (const [provName, prov] of activeProviderEntries(config)) {
155
167
  if (prov.models && Array.isArray(prov.models) && (prov.models as string[]).includes(modelId)) {
156
168
  return {
@@ -161,10 +173,24 @@ export function routeModel(config: OcxConfig, modelId: string): RouteResult {
161
173
  }
162
174
  }
163
175
 
164
- for (const [patternKey, prefixes] of Object.entries(MODEL_PROVIDER_PATTERNS)) {
176
+ if (hasOwnProvider(config.providers, config.defaultProvider)) {
177
+ const defaultProv = config.providers[config.defaultProvider];
178
+ if (defaultProv.disabled === true) throw new Error(`Default provider is disabled: ${config.defaultProvider}`);
179
+ return {
180
+ providerName: config.defaultProvider,
181
+ provider: routedProviderConfig(config.defaultProvider, defaultProv),
182
+ modelId,
183
+ };
184
+ }
185
+
186
+ throw new Error(`No provider configured for model: ${modelId}`);
187
+ }
188
+
189
+ function routeByKnownModelPattern(config: OcxConfig, modelId: string): RouteResult | undefined {
190
+ for (const { providerNames, prefixes } of MODEL_PROVIDER_PATTERNS) {
165
191
  if (prefixes.some(prefix => modelId.startsWith(prefix))) {
166
- const matchingProvider = activeProviderEntries(config).find(
167
- ([name]) => name === patternKey || name.startsWith(patternKey)
192
+ const matchingProvider = Object.entries(config.providers).find(
193
+ ([name]) => providerNames.some(providerName => name === providerName || name.startsWith(`${providerName}-`))
168
194
  );
169
195
  if (matchingProvider) {
170
196
  const [provName, prov] = matchingProvider;
@@ -176,16 +202,5 @@ export function routeModel(config: OcxConfig, modelId: string): RouteResult {
176
202
  }
177
203
  }
178
204
  }
179
-
180
- if (hasOwnProvider(config.providers, config.defaultProvider)) {
181
- const defaultProv = config.providers[config.defaultProvider];
182
- if (defaultProv.disabled === true) throw new Error(`Default provider is disabled: ${config.defaultProvider}`);
183
- return {
184
- providerName: config.defaultProvider,
185
- provider: routedProviderConfig(config.defaultProvider, defaultProv),
186
- modelId,
187
- };
188
- }
189
-
190
- throw new Error(`No provider configured for model: ${modelId}`);
205
+ return undefined;
191
206
  }
@@ -1,5 +1,6 @@
1
1
  import { createAnthropicAdapter } from "../adapters/anthropic";
2
2
  import { createAzureAdapter } from "../adapters/azure";
3
+ import { createCursorAdapter } from "../adapters/cursor";
3
4
  import { createGoogleAdapter } from "../adapters/google";
4
5
  import { createKiroAdapter } from "../adapters/kiro";
5
6
  import { createOpenAIChatAdapter } from "../adapters/openai-chat";
@@ -37,6 +38,8 @@ export function resolveAdapter(providerConfig: OcxProviderConfig) {
37
38
  case "azure":
38
39
  case "azure-openai":
39
40
  return createAzureAdapter(providerConfig);
41
+ case "cursor":
42
+ return createCursorAdapter(providerConfig);
40
43
  default:
41
44
  throw new Error(`Unknown adapter: ${providerConfig.adapter}`);
42
45
  }
package/src/server.ts CHANGED
@@ -15,6 +15,7 @@ import type { Server, ServerWebSocket } from "bun";
15
15
  import {
16
16
  DEFAULT_SUBAGENT_MODELS,
17
17
  codexAutoStartEnabled,
18
+ applyProxyEnv,
18
19
  getConfigPath,
19
20
  hasOwnProvider,
20
21
  isValidProviderName,
@@ -25,6 +26,7 @@ import {
25
26
  websocketsEnabled,
26
27
  } from "./config";
27
28
  import { parseRequest } from "./responses/parser";
29
+ import { expandPreviousResponseInput, previousResponseConversationId, rememberResponseState } from "./responses/state";
28
30
  import { routeModel } from "./router";
29
31
  import { namespacedToolName } from "./types";
30
32
  import {
@@ -39,7 +41,8 @@ import { describeImagesInPlace, planVisionSidecar } from "./vision";
39
41
  import { removeCredential } from "./oauth/store";
40
42
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "./oauth/key-providers";
41
43
  import { deriveProviderPresets } from "./providers/derive";
42
- import type { AdapterEvent, OcxConfig, OcxProviderConfig } from "./types";
44
+ import { createAdapterEventQueue } from "./adapters/run-turn-queue";
45
+ import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig } from "./types";
43
46
  import type { OcxUsage } from "./types";
44
47
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "./provider-context-cap";
45
48
  import {
@@ -190,6 +193,22 @@ const VERSION = (() => {
190
193
 
191
194
  // Adapter resolution + wire-protocol override extracted to ./server/adapter-resolve.
192
195
 
196
+ function buildToolBridgeMaps(parsed: OcxParsedRequest): {
197
+ toolNsMap: Map<string, { namespace: string; name: string }>;
198
+ freeformToolNames: Set<string>;
199
+ toolSearchToolNames: Set<string>;
200
+ } {
201
+ const toolNsMap = new Map<string, { namespace: string; name: string }>();
202
+ const freeformToolNames = new Set<string>();
203
+ const toolSearchToolNames = new Set<string>();
204
+ for (const t of parsed.context.tools ?? []) {
205
+ if (t.namespace) toolNsMap.set(namespacedToolName(t.namespace, t.name), { namespace: t.namespace, name: t.name });
206
+ if (t.freeform) freeformToolNames.add(t.name);
207
+ if (t.toolSearch) toolSearchToolNames.add(t.name);
208
+ }
209
+ return { toolNsMap, freeformToolNames, toolSearchToolNames };
210
+ }
211
+
193
212
  function sidecarOutcomeRecorder(config: OcxConfig, authCtx: CodexAuthContext): ((outcome: CodexUpstreamOutcome) => void) | undefined {
194
213
  return authCtx.kind === "pool" || authCtx.kind === "main-pool"
195
214
  ? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome)
@@ -239,10 +258,12 @@ async function handleResponses(
239
258
  } catch {
240
259
  return formatErrorResponse(400, "invalid_request_error", "Invalid JSON body");
241
260
  }
261
+ body = expandPreviousResponseInput(body);
242
262
 
243
263
  let parsed;
244
264
  try {
245
265
  parsed = parseRequest(body);
266
+ parsed._cursorConversationId = previousResponseConversationId(parsed.previousResponseId);
246
267
  } catch (err) {
247
268
  return formatErrorResponse(400, "invalid_request_error", err instanceof Error ? err.message : String(err));
248
269
  }
@@ -455,6 +476,62 @@ async function handleResponses(
455
476
  });
456
477
  }
457
478
 
479
+ if (adapter.runTurn) {
480
+ const runTurnAbort = new AbortController();
481
+ linkAbortSignal(runTurnAbort, options.abortSignal);
482
+ const queue = createAdapterEventQueue();
483
+ const runTurn = async (): Promise<void> => {
484
+ try {
485
+ await adapter.runTurn?.(
486
+ parsed,
487
+ { headers: selectedForwardHeaders, abortSignal: runTurnAbort.signal },
488
+ queue.push,
489
+ );
490
+ } catch (err) {
491
+ queue.push({
492
+ type: "error",
493
+ message: err instanceof Error ? err.message : String(err),
494
+ });
495
+ } finally {
496
+ queue.close();
497
+ }
498
+ };
499
+
500
+ const { toolNsMap, freeformToolNames, toolSearchToolNames } = buildToolBridgeMaps(parsed);
501
+ if (parsed.stream) {
502
+ void runTurn();
503
+ const sseStream = bridgeToResponsesSSE(
504
+ queue.stream(), parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
505
+ () => {
506
+ runTurnAbort.abort();
507
+ queue.close();
508
+ }, 2_000,
509
+ {
510
+ ...(options.forceEmptyResponseId ? { responseId: "" } : {}),
511
+ stallTimeoutSec: config.stallTimeoutSec,
512
+ hideThinkingSummary: parsed.options.hideThinkingSummary,
513
+ onCompletedResponse: response => rememberResponseState(parsed._rawBody, response, parsed._cursorConversationId),
514
+ },
515
+ );
516
+ const bridgeTurnAc = new AbortController();
517
+ const trackedSse = trackStreamLifetime(sseStream, bridgeTurnAc);
518
+ return new Response(trackedSse, {
519
+ headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" },
520
+ });
521
+ }
522
+
523
+ await runTurn();
524
+ const events = await queue.collect();
525
+ const json = buildResponseJSON(events, parsed.modelId, {
526
+ hideThinkingSummary: parsed.options.hideThinkingSummary,
527
+ toolNsMap,
528
+ freeformToolNames,
529
+ toolSearchToolNames,
530
+ });
531
+ rememberResponseState(parsed._rawBody, json, parsed._cursorConversationId);
532
+ return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
533
+ }
534
+
458
535
  // Web-search sidecar: Codex enabled web_search but this is a routed (non-OpenAI) model that can't
459
536
  // run it server-side. Expose web_search as a function tool and run searches via the gpt-mini sidecar
460
537
  // through the ChatGPT passthrough, looping until the model answers. Otherwise take the normal path.
@@ -506,14 +583,7 @@ async function handleResponses(
506
583
 
507
584
  if (parsed.stream) {
508
585
  const eventStream = adapter.parseStream(upstreamResponse);
509
- const toolNsMap = new Map<string, { namespace: string; name: string }>();
510
- const freeformToolNames = new Set<string>();
511
- const toolSearchToolNames = new Set<string>();
512
- for (const t of parsed.context.tools ?? []) {
513
- if (t.namespace) toolNsMap.set(namespacedToolName(t.namespace, t.name), { namespace: t.namespace, name: t.name });
514
- if (t.freeform) freeformToolNames.add(t.name);
515
- if (t.toolSearch) toolSearchToolNames.add(t.name);
516
- }
586
+ const { toolNsMap, freeformToolNames, toolSearchToolNames } = buildToolBridgeMaps(parsed);
517
587
  const sseStream = bridgeToResponsesSSE(
518
588
  eventStream, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
519
589
  () => upstream.abort(), 2_000,
@@ -521,6 +591,7 @@ async function handleResponses(
521
591
  ...(options.forceEmptyResponseId ? { responseId: "" } : {}),
522
592
  stallTimeoutSec: config.stallTimeoutSec,
523
593
  hideThinkingSummary: parsed.options.hideThinkingSummary,
594
+ onCompletedResponse: response => rememberResponseState(parsed._rawBody, response, parsed._cursorConversationId),
524
595
  },
525
596
  );
526
597
  const bridgeTurnAc = new AbortController();
@@ -537,20 +608,14 @@ async function handleResponses(
537
608
  } finally {
538
609
  cleanupUpstreamAbort();
539
610
  }
540
- const toolNsMap = new Map<string, { namespace: string; name: string }>();
541
- const freeformToolNames = new Set<string>();
542
- const toolSearchToolNames = new Set<string>();
543
- for (const t of parsed.context.tools ?? []) {
544
- if (t.namespace) toolNsMap.set(namespacedToolName(t.namespace, t.name), { namespace: t.namespace, name: t.name });
545
- if (t.freeform) freeformToolNames.add(t.name);
546
- if (t.toolSearch) toolSearchToolNames.add(t.name);
547
- }
611
+ const { toolNsMap, freeformToolNames, toolSearchToolNames } = buildToolBridgeMaps(parsed);
548
612
  const json = buildResponseJSON(events, parsed.modelId, {
549
613
  hideThinkingSummary: parsed.options.hideThinkingSummary,
550
614
  toolNsMap,
551
615
  freeformToolNames,
552
616
  toolSearchToolNames,
553
617
  });
618
+ rememberResponseState(parsed._rawBody, json, parsed._cursorConversationId);
554
619
  return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
555
620
  }
556
621
 
@@ -1542,6 +1607,51 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
1542
1607
  return jsonResponse({ ok: true, codexAutoStart: codexAutoStartEnabled(config) });
1543
1608
  }
1544
1609
 
1610
+ if (url.pathname === "/api/sync" && req.method === "POST") {
1611
+ const { syncModelsToCodex } = await import("./codex-sync");
1612
+ const result = await syncModelsToCodex(undefined, config, null);
1613
+ return jsonResponse({
1614
+ ...result,
1615
+ staleAppServerHint: "If Codex App still shows an older model list, restart its long-lived app-server process after sync.",
1616
+ }, result.ok ? 200 : 500);
1617
+ }
1618
+
1619
+ if (url.pathname === "/api/update/check" && req.method === "GET") {
1620
+ const { checkForUpdate, normalizeUpdateChannel } = await import("./update-job");
1621
+ const rawTag = url.searchParams.get("tag");
1622
+ if (rawTag && rawTag !== "latest" && rawTag !== "preview") {
1623
+ return jsonResponse({ error: "tag must be latest or preview" }, 400);
1624
+ }
1625
+ return jsonResponse(checkForUpdate(normalizeUpdateChannel(rawTag)));
1626
+ }
1627
+
1628
+ if (url.pathname === "/api/update/run" && req.method === "POST") {
1629
+ const { normalizeUpdateChannel, startUpdateJob, UpdateJobError } = await import("./update-job");
1630
+ let body: { tag?: unknown; restart?: unknown };
1631
+ try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
1632
+ if (body.tag !== undefined && body.tag !== "latest" && body.tag !== "preview") {
1633
+ return jsonResponse({ error: "tag must be latest or preview" }, 400);
1634
+ }
1635
+ if (body.restart !== undefined && typeof body.restart !== "boolean") {
1636
+ return jsonResponse({ error: "restart boolean is required" }, 400);
1637
+ }
1638
+ try {
1639
+ return jsonResponse({ ok: true, job: startUpdateJob(normalizeUpdateChannel(body.tag as string | undefined), body.restart !== false) });
1640
+ } catch (err) {
1641
+ if (err instanceof UpdateJobError) {
1642
+ return jsonResponse({ error: err.message, code: err.code }, err.status);
1643
+ }
1644
+ return jsonResponse({ error: err instanceof Error ? err.message : String(err) }, 500);
1645
+ }
1646
+ }
1647
+
1648
+ if (url.pathname === "/api/update/status" && req.method === "GET") {
1649
+ const { readUpdateJob } = await import("./update-job");
1650
+ const job = readUpdateJob(url.searchParams.get("jobId"));
1651
+ if (!job) return jsonResponse({ error: "update job not found" }, 404);
1652
+ return jsonResponse({ ok: true, job });
1653
+ }
1654
+
1545
1655
  if (url.pathname === "/api/sidecar-settings" && req.method === "GET") {
1546
1656
  const ws = config.webSearchSidecar ?? {};
1547
1657
  const vs = config.visionSidecar ?? {};
@@ -1869,6 +1979,7 @@ async function fetchAllModels(config: OcxConfig): Promise<CatalogModel[]> {
1869
1979
 
1870
1980
  export function startServer(port?: number) {
1871
1981
  const config = loadConfig();
1982
+ applyProxyEnv(config);
1872
1983
  assertServerAuthConfig(config);
1873
1984
  // Refresh OAuth provider presets (models/noReasoningModels) from the registry so a proxy update
1874
1985
  // adding/dropping models reaches existing configs on start — not just fresh installs.
@@ -1942,7 +2053,8 @@ export function startServer(port?: number) {
1942
2053
  }
1943
2054
 
1944
2055
  if (url.pathname === "/healthz" && req.method === "GET") {
1945
- return jsonResponse({ status: "ok", version: VERSION, uptime: process.uptime() }, 200, req, config);
2056
+ // service/pid/port let CLI liveness reject foreign 200s and verify pid identity.
2057
+ return jsonResponse({ status: "ok", service: "opencodex", version: VERSION, uptime: process.uptime(), pid: process.pid, port: listenPort }, 200, req, config);
1946
2058
  }
1947
2059
 
1948
2060
  if (url.pathname.startsWith("/api/")) {
package/src/service.ts CHANGED
@@ -9,12 +9,13 @@ import { execFileSync, execSync } from "node:child_process";
9
9
  import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { dirname, join, resolve } from "node:path";
12
- import { getConfigDir, readPid, removePid } from "./config";
12
+ import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort } from "./config";
13
13
  import { loadConfig } from "./config";
14
14
  import { restoreNativeCodex } from "./codex-inject";
15
15
  import { durableBunPath, durableBunRuntime } from "./bun-runtime";
16
- import { isProcessAlive, killProxy } from "./process-control";
16
+ import { isProcessAlive, stopProxy } from "./process-control";
17
17
  import { serviceApiTokenFilePath } from "./service-secrets";
18
+ import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./win-paths";
18
19
 
19
20
  const LABEL = "com.opencodex.proxy";
20
21
  const TASK = "opencodex-proxy";
@@ -62,11 +63,15 @@ function serviceStatePaths(): string[] {
62
63
  }
63
64
 
64
65
  function currentCodexHome(): string {
65
- return resolve(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"));
66
+ const raw = process.env.CODEX_HOME?.trim();
67
+ return raw ? resolve(expandUserPath(raw)) : join(homedir(), ".codex");
66
68
  }
67
69
 
68
70
  function currentOpenCodexHome(): string {
69
- return resolve(process.env.OPENCODEX_HOME?.trim() || getConfigDir());
71
+ // getConfigDir() already resolves OPENCODEX_HOME with ~ expansion; keep the
72
+ // install-state comparison on the same normalization or `~/...` values falsely
73
+ // fail the environment-match check depending on cwd.
74
+ return getConfigDir();
70
75
  }
71
76
 
72
77
  function normalizePathForCompare(path: string): string {
@@ -78,13 +83,19 @@ interface ServiceInstallState {
78
83
  version: 1;
79
84
  codexHome: string;
80
85
  opencodexHome: string;
86
+ /** Baked at install; lets status flag paths gone stale after npm prefix/nvm moves. */
87
+ bunPath?: string;
88
+ cliPath?: string;
81
89
  }
82
90
 
83
91
  function writeServiceInstallState(): void {
92
+ const { bun, cli } = cliEntry();
84
93
  const state: ServiceInstallState = {
85
94
  version: 1,
86
95
  codexHome: currentCodexHome(),
87
96
  opencodexHome: currentOpenCodexHome(),
97
+ bunPath: bun,
98
+ cliPath: cli,
88
99
  };
89
100
  for (const path of serviceStatePaths()) {
90
101
  const dir = dirname(path);
@@ -252,9 +263,15 @@ function windowsBatchValue(value: string): string {
252
263
  .replace(/[\r\n]/g, "");
253
264
  }
254
265
 
255
- function windowsBatchSet(name: string, value: string | undefined): string | null {
266
+ type WindowsBatchValueKind = "raw" | "path" | "pathList";
267
+
268
+ function windowsBatchSet(name: string, value: string | undefined, kind: WindowsBatchValueKind = "raw"): string | null {
256
269
  if (!value) return null;
257
- return `set "${name}=${windowsBatchValue(value)}"`;
270
+ const rendered =
271
+ kind === "path" ? windowsEnvIndirectBatchValue(value, windowsBatchValue)
272
+ : kind === "pathList" ? windowsEnvIndirectBatchPathList(value, windowsBatchValue)
273
+ : windowsBatchValue(value);
274
+ return `set "${name}=${rendered}"`;
258
275
  }
259
276
 
260
277
  function taskXmlString(value: string): string {
@@ -273,14 +290,17 @@ export function buildWindowsServiceScript(entry = cliEntry()): string {
273
290
  const lines = [
274
291
  "@echo off",
275
292
  "setlocal",
293
+ // The wrapper runs in its own hidden console, so switching that console to UTF-8 is
294
+ // safe (no leak into user shells) and lets cmd parse any UTF-8 remnants correctly.
295
+ "chcp 65001 >nul",
276
296
  windowsBatchSet("OCX_SERVICE", "1"),
277
- windowsBatchSet("PATH", path),
278
- windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim()),
279
- windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim()),
280
- windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath()),
281
- windowsBatchSet("OCX_SERVICE_LOG", serviceLogPath()),
282
- windowsBatchSet("OCX_BUN", bun),
283
- windowsBatchSet("OCX_CLI", cli),
297
+ windowsBatchSet("PATH", path, "pathList"),
298
+ windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"),
299
+ windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"),
300
+ windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath(), "path"),
301
+ windowsBatchSet("OCX_SERVICE_LOG", serviceLogPath(), "path"),
302
+ windowsBatchSet("OCX_BUN", bun, "path"),
303
+ windowsBatchSet("OCX_CLI", cli, "path"),
284
304
  'if exist "%OCX_API_TOKEN_FILE%" (',
285
305
  ' set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"',
286
306
  ")",
@@ -295,7 +315,9 @@ export function buildWindowsServiceScript(entry = cliEntry()): string {
295
315
  '"%OCX_BUN%" "%OCX_CLI%" start >>"%OCX_SERVICE_LOG%" 2>&1',
296
316
  "if %ERRORLEVEL% NEQ 0 (",
297
317
  ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] child exited with code %ERRORLEVEL%; restarting in 5s',
298
- " timeout /t 5 /nobreak >nul",
318
+ // `timeout` needs console stdin and dies with "Input redirection is not supported"
319
+ // under Task Scheduler, turning the 5s cooldown into a hot restart loop; ping doesn't.
320
+ " ping -n 6 127.0.0.1 >nul",
299
321
  " goto loop",
300
322
  ")",
301
323
  "endlocal",
@@ -374,13 +396,32 @@ function uninstallLaunchd(): void {
374
396
  }
375
397
 
376
398
  // ── Windows (Task Scheduler) ──
399
+ /**
400
+ * In-place service-asset write that tolerates the transient EBUSY/EPERM/EACCES Windows
401
+ * throws while the just-ended task's cmd.exe (or an AV scanner) still holds the file.
402
+ */
403
+ function writeServiceAssetWithRetry(path: string, content: string, encoding: "utf8" | "utf16le"): void {
404
+ for (let attempt = 0; ; attempt++) {
405
+ try {
406
+ writeFileSync(path, content, encoding);
407
+ return;
408
+ } catch (err) {
409
+ const code = (err as NodeJS.ErrnoException).code;
410
+ if (attempt >= 2 || (code !== "EBUSY" && code !== "EPERM" && code !== "EACCES")) throw err;
411
+ Bun.sleepSync(150);
412
+ }
413
+ }
414
+ }
415
+
377
416
  function installWindows(): void {
378
417
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
379
418
  writeServiceApiTokenFile();
380
- const script = windowsServiceScriptPath();
381
- writeFileSync(script, buildWindowsServiceScript(), "utf8");
382
- writeFileSync(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le");
419
+ // End a running task BEFORE rewriting the assets it is executing — cmd.exe reading the
420
+ // script mid-rewrite runs a torn batch file, and its open handle can fail the write.
383
421
  try { stopWindows(); } catch { /* not running */ }
422
+ const script = windowsServiceScriptPath();
423
+ writeServiceAssetWithRetry(script, buildWindowsServiceScript(), "utf8");
424
+ writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le");
384
425
  schtasks(buildWindowsSchtasksCreateArgs(script));
385
426
  schtasks(["/run", "/tn", TASK]);
386
427
  writeServiceInstallState();
@@ -394,8 +435,22 @@ function uninstallWindows(): void {
394
435
  if (existsSync(windowsTaskXmlPath())) unlinkSync(windowsTaskXmlPath());
395
436
  }
396
437
 
438
+ /**
439
+ * Warn when the paths baked into installed service assets no longer exist (npm prefix
440
+ * moved, nvm switch, reinstall) — the service manager would restart-loop on a dead path
441
+ * while `schtasks`/`launchctl` still report "installed".
442
+ */
443
+ export function bakedServicePathsDiagnostic(): string | null {
444
+ const state = readServiceInstallState();
445
+ if (!state?.bunPath || !state?.cliPath) return null;
446
+ const missing = [state.bunPath, state.cliPath].filter(path => !existsSync(path));
447
+ if (missing.length === 0) return null;
448
+ return `STALE baked paths (missing: ${missing.join(", ")}) — run 'ocx service install' to re-bake`;
449
+ }
450
+
397
451
  function serviceDiagnosticsSummary(): string {
398
- return `logs: ${serviceLogPath()}`;
452
+ const stale = bakedServicePathsDiagnostic();
453
+ return stale ? `${stale}; logs: ${serviceLogPath()}` : `logs: ${serviceLogPath()}`;
399
454
  }
400
455
 
401
456
  // ── Linux (systemd user unit) ──
@@ -489,21 +544,23 @@ function platformOps(): ServiceOps | null {
489
544
 
490
545
  type TrackedProxyCleanupResult = "none" | "stale" | "stopped";
491
546
 
492
- function stopTrackedProxyIfRunning(): TrackedProxyCleanupResult {
547
+ async function stopTrackedProxyIfRunning(): Promise<TrackedProxyCleanupResult> {
493
548
  const pid = readPid();
494
549
  if (!pid) return "none";
495
550
  if (!isProcessAlive(pid)) {
496
551
  removePid(pid);
552
+ removeRuntimePort(pid);
497
553
  return "stale";
498
554
  }
499
- killProxy(pid);
555
+ await stopProxy(pid);
500
556
  removePid(pid);
557
+ removeRuntimePort(pid);
501
558
  return "stopped";
502
559
  }
503
560
 
504
- function stopTrackedProxyForServiceCommand(): TrackedProxyCleanupResult {
561
+ async function stopTrackedProxyForServiceCommand(): Promise<TrackedProxyCleanupResult> {
505
562
  try {
506
- return stopTrackedProxyIfRunning();
563
+ return await stopTrackedProxyIfRunning();
507
564
  } catch (err) {
508
565
  console.error(`⚠️ Failed to stop proxy: ${err instanceof Error ? err.message : String(err)}`);
509
566
  return "none";
@@ -531,6 +588,13 @@ export function stopServiceIfInstalled(): boolean {
531
588
  return false;
532
589
  }
533
590
 
591
+ /** Delete install-state files; stale state would make `ocx update` "reinstall" a service that no longer exists. */
592
+ function removeServiceInstallState(): void {
593
+ for (const path of serviceStatePaths()) {
594
+ try { if (existsSync(path)) unlinkSync(path); } catch { /* best-effort */ }
595
+ }
596
+ }
597
+
534
598
  /**
535
599
  * Best-effort service removal for full uninstall. Unlike `ocx service uninstall`, this is quiet
536
600
  * when no service exists and never exits the process just because the platform has no service
@@ -540,16 +604,16 @@ export function uninstallServiceIfInstalled(): boolean {
540
604
  assertServiceEnvironmentMatchesInstall();
541
605
  if (process.platform === "darwin") {
542
606
  if (existsSync(plistPath())) {
543
- try { uninstallLaunchd(); return true; } catch { return false; }
607
+ try { uninstallLaunchd(); removeServiceInstallState(); return true; } catch { return false; }
544
608
  }
545
609
  } else if (process.platform === "win32") {
546
610
  try {
547
611
  const q = schtasks(["/query", "/tn", TASK]);
548
- if (q.includes(TASK)) { uninstallWindows(); return true; }
612
+ if (q.includes(TASK)) { uninstallWindows(); removeServiceInstallState(); return true; }
549
613
  } catch { /* task not found */ }
550
614
  } else if (process.platform === "linux" && existsSync(unitPath())) {
551
- try { uninstallSystemd(); return true; } catch {
552
- try { unlinkSync(unitPath()); return true; } catch { return false; }
615
+ try { uninstallSystemd(); removeServiceInstallState(); return true; } catch {
616
+ try { unlinkSync(unitPath()); removeServiceInstallState(); return true; } catch { return false; }
553
617
  }
554
618
  }
555
619
  return false;
@@ -581,7 +645,7 @@ export function serviceStatusSummary(): string {
581
645
  return `unsupported on ${process.platform}`;
582
646
  }
583
647
 
584
- export function serviceCommand(sub?: string): void {
648
+ export async function serviceCommand(sub?: string): Promise<void> {
585
649
  const ops = platformOps();
586
650
  if (!ops) {
587
651
  console.error("ocx service supports macOS (launchd), Windows (Task Scheduler), and Linux (systemd).");
@@ -602,7 +666,7 @@ export function serviceCommand(sub?: string): void {
602
666
  case "stop":
603
667
  assertServiceEnvironmentMatchesInstall();
604
668
  ops.stop();
605
- stopTrackedProxyForServiceCommand();
669
+ await stopTrackedProxyForServiceCommand();
606
670
  restoreNativeCodex();
607
671
  console.log("✅ service stopped + native Codex restored.");
608
672
  break;
@@ -616,12 +680,10 @@ export function serviceCommand(sub?: string): void {
616
680
  case "remove":
617
681
  assertServiceEnvironmentMatchesInstall();
618
682
  ops.stop();
619
- stopTrackedProxyForServiceCommand();
683
+ await stopTrackedProxyForServiceCommand();
620
684
  ops.uninstall();
621
685
  restoreNativeCodex();
622
- for (const path of serviceStatePaths()) {
623
- try { if (existsSync(path)) unlinkSync(path); } catch { /* best-effort */ }
624
- }
686
+ removeServiceInstallState();
625
687
  try { if (existsSync(serviceApiTokenFilePath())) unlinkSync(serviceApiTokenFilePath()); } catch { /* best-effort */ }
626
688
  console.log("✅ service uninstalled + native Codex restored.");
627
689
  break;
package/src/types.ts CHANGED
@@ -5,6 +5,8 @@ export interface OcxParsedRequest {
5
5
  stream: boolean;
6
6
  options: OcxRequestOptions;
7
7
  _rawBody?: unknown;
8
+ /** Provider-private stable Cursor conversation id resolved from the Responses previous_response_id chain. */
9
+ _cursorConversationId?: string;
8
10
  /**
9
11
  * The hosted `{type:"web_search", ...}` tool config, stashed when Codex enables web search. Routed
10
12
  * (non-OpenAI) providers can't run it server-side, so the proxy re-exposes it as a function tool and
@@ -166,6 +168,7 @@ export interface OcxRequestOptions {
166
168
  topP?: number;
167
169
  stopSequences?: string[];
168
170
  toolChoice?: OcxToolChoice;
171
+ parallelToolCalls?: boolean;
169
172
  reasoning?: string;
170
173
  hideThinkingSummary?: boolean;
171
174
  serviceTier?: string;
@@ -192,7 +195,9 @@ export type AdapterEvent =
192
195
  | { type: "web_search_call_begin"; id: string }
193
196
  | { type: "web_search_call_end"; id: string; queries: string[]; status?: "completed" | "failed"; sources?: OcxUrlCitation[] }
194
197
  | { type: "done"; usage?: OcxUsage }
195
- | { type: "error"; message: string };
198
+ // `usage` carries best-effort partial consumption when a turn dies before a clean done
199
+ // (e.g. cursor upstream 502 mid-stream), so failed requests can log real token counts.
200
+ | { type: "error"; message: string; usage?: OcxUsage };
196
201
 
197
202
  /**
198
203
  * A web source backing a search answer. Surfaced on the search-end event and rendered by the bridge
@@ -232,6 +237,12 @@ export interface OcxConfig {
232
237
  contextCapValue?: number;
233
238
  /** Bind hostname. Default "127.0.0.1" (loopback only). Set "0.0.0.0" to expose on all interfaces. */
234
239
  hostname?: string;
240
+ /**
241
+ * Outbound HTTP(S) proxy URL for provider requests (e.g. "http://user:pass@proxy:8080", or
242
+ * "${HTTPS_PROXY}"-style env reference). Mirrored into HTTP_PROXY/HTTPS_PROXY at startup when
243
+ * those are unset — Bun's fetch honors them for all outbound calls; localhost is excluded.
244
+ */
245
+ proxy?: string;
235
246
  /** Upstream stall timeout (seconds). After this many seconds of no upstream data, emits response.incomplete. Default 90. Min 1. */
236
247
  stallTimeoutSec?: number;
237
248
  /** Connect timeout (ms) for upstream fetch — covers DNS, TCP, TLS, and response header. Default 30000. */
@@ -359,6 +370,18 @@ export interface OcxProviderConfig {
359
370
  project?: string;
360
371
  /** Vertex AI location, e.g. "us-central1" or "global" (or GOOGLE_CLOUD_LOCATION env). */
361
372
  location?: string;
373
+ /**
374
+ * Cursor adapter only: MCP servers opencodex starts/connects and exposes to the Cursor agent
375
+ * as callable tools. Each entry is spawned (stdio `command`) or connected (`url`) lazily per
376
+ * stream; their tools are advertised to the Cursor server and executed against the live server.
377
+ */
378
+ mcpServers?: Record<string, import("./adapters/cursor/mcp-config").CursorMcpServerConfig>;
379
+ /**
380
+ * Cursor adapter only: opt-in external executor for computer-use / record-screen. opencodex is
381
+ * headless and cannot control a screen itself; provide commands here only when running on a host
382
+ * that can. With no executor, these tools honestly report "not supported".
383
+ */
384
+ desktopExecutor?: import("./adapters/cursor/native-exec-desktop").DesktopExecutorConfig;
362
385
  }
363
386
 
364
387
  export interface CodexAccount {