@bitkyc08/opencodex 2.6.26-preview.20260705 → 2.6.28-preview.20260707

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 (118) hide show
  1. package/README.md +1 -0
  2. package/bin/ocx.mjs +4 -4
  3. package/gui/dist/assets/index-ByGC8-Bm.css +1 -0
  4. package/gui/dist/assets/index-CkV5xFA8.js +15 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -4
  7. package/src/adapters/anthropic-image-guard.ts +195 -0
  8. package/src/adapters/anthropic.ts +85 -14
  9. package/src/adapters/cursor/cursor-errors.ts +2 -2
  10. package/src/adapters/cursor/live-transport.ts +1 -1
  11. package/src/adapters/cursor/transport-retry.ts +2 -2
  12. package/src/adapters/google-errors.ts +1 -1
  13. package/src/adapters/google-http.ts +1 -1
  14. package/src/adapters/google-truncation.ts +1 -1
  15. package/src/adapters/google.ts +1 -1
  16. package/src/adapters/kiro-errors.ts +1 -1
  17. package/src/adapters/kiro-retry.ts +1 -1
  18. package/src/adapters/kiro-truncation.ts +1 -1
  19. package/src/adapters/kiro.ts +1 -1
  20. package/src/adapters/openai-chat.ts +12 -2
  21. package/src/adapters/openai-responses.ts +126 -3
  22. package/src/bridge.ts +164 -7
  23. package/src/{doctor.ts → cli/doctor.ts} +21 -4
  24. package/src/{cli-help.ts → cli/help.ts} +1 -1
  25. package/src/cli/index.ts +584 -0
  26. package/src/{init.ts → cli/init.ts} +6 -6
  27. package/src/{cli-models.ts → cli/models.ts} +2 -2
  28. package/src/{cli-provider.ts → cli/provider.ts} +12 -8
  29. package/src/{star-prompt.ts → cli/star-prompt.ts} +1 -1
  30. package/src/{cli-status.ts → cli/status.ts} +7 -7
  31. package/src/cli.ts +9 -575
  32. package/src/{codex-account-label.ts → codex/account-label.ts} +1 -1
  33. package/src/{codex-account-lifecycle.ts → codex/account-lifecycle.ts} +6 -6
  34. package/src/{codex-account-store.ts → codex/account-store.ts} +2 -2
  35. package/src/{codex-account-usability.ts → codex/account-usability.ts} +4 -4
  36. package/src/{codex-auth-api.ts → codex/auth-api.ts} +18 -18
  37. package/src/{codex-auth-collision.ts → codex/auth-collision.ts} +4 -4
  38. package/src/{codex-auth-context.ts → codex/auth-context.ts} +9 -9
  39. package/src/{codex-catalog.ts → codex/catalog.ts} +49 -20
  40. package/src/codex/history-migration-guardian.ts +102 -0
  41. package/src/{codex-history-provider.ts → codex/history-provider.ts} +111 -7
  42. package/src/{codex-home.ts → codex/home.ts} +1 -1
  43. package/src/{codex-inject.ts → codex/inject.ts} +204 -26
  44. package/src/{codex-journal.ts → codex/journal.ts} +2 -2
  45. package/src/{codex-main-account.ts → codex/main-account.ts} +2 -2
  46. package/src/{model-cache.ts → codex/model-cache.ts} +1 -1
  47. package/src/{codex-paths.ts → codex/paths.ts} +2 -2
  48. package/src/{codex-plugins-doctor.ts → codex/plugins-doctor.ts} +2 -2
  49. package/src/{codex-refresh.ts → codex/refresh.ts} +4 -4
  50. package/src/{codex-routing.ts → codex/routing.ts} +8 -8
  51. package/src/{codex-shim.ts → codex/shim.ts} +6 -5
  52. package/src/{codex-sync.ts → codex/sync.ts} +4 -4
  53. package/src/{codex-websocket-registry.ts → codex/websocket-registry.ts} +1 -1
  54. package/src/config.ts +2 -0
  55. package/src/generated/jawcode-model-metadata.ts +2 -0
  56. package/src/{bun-runtime.ts → lib/bun-runtime.ts} +1 -1
  57. package/src/{crash-guard.ts → lib/crash-guard.ts} +1 -1
  58. package/src/{process-control.ts → lib/process-control.ts} +1 -1
  59. package/src/{service-secrets.ts → lib/service-secrets.ts} +1 -1
  60. package/src/oauth/callback-server.ts +1 -1
  61. package/src/oauth/google-antigravity.ts +7 -4
  62. package/src/oauth/index.ts +67 -16
  63. package/src/oauth/login-cli.ts +2 -2
  64. package/src/oauth/store.ts +236 -20
  65. package/src/oauth/token-guardian.ts +24 -20
  66. package/src/oauth/types.ts +16 -0
  67. package/src/providers/api-keys.ts +121 -0
  68. package/src/{provider-context-cap.ts → providers/context-cap.ts} +1 -1
  69. package/src/providers/derive.ts +2 -0
  70. package/src/providers/key-failover.ts +145 -0
  71. package/src/{provider-label.ts → providers/label.ts} +1 -1
  72. package/src/{provider-quota.ts → providers/quota.ts} +12 -7
  73. package/src/providers/registry.ts +66 -4
  74. package/src/responses/compaction.ts +117 -0
  75. package/src/responses/parser.ts +89 -13
  76. package/src/responses/reasoning-envelope.ts +52 -0
  77. package/src/responses/schema.ts +15 -3
  78. package/src/responses/state.ts +117 -2
  79. package/src/router.ts +2 -0
  80. package/src/server/auth-cors.ts +231 -0
  81. package/src/server/index.ts +523 -0
  82. package/src/server/lifecycle.ts +73 -0
  83. package/src/server/management-api.ts +628 -0
  84. package/src/{proxy-liveness.ts → server/proxy-liveness.ts} +1 -1
  85. package/src/server/relay.ts +534 -0
  86. package/src/server/request-decompress.ts +46 -0
  87. package/src/server/request-log.ts +310 -0
  88. package/src/server/responses.ts +775 -0
  89. package/src/{ws-bridge.ts → server/ws-bridge.ts} +44 -13
  90. package/src/service.ts +19 -11
  91. package/src/types.ts +27 -0
  92. package/src/{update.ts → update/index.ts} +5 -5
  93. package/src/{update-job.ts → update/job.ts} +7 -6
  94. package/src/{update-notify.ts → update/notify.ts} +3 -3
  95. package/src/{usage-debug.ts → usage/debug.ts} +3 -3
  96. package/src/{usage-log.ts → usage/log.ts} +3 -3
  97. package/src/{usage-summary.ts → usage/summary.ts} +3 -3
  98. package/src/{usage-totals.ts → usage/totals.ts} +1 -1
  99. package/src/vision/describe.ts +3 -3
  100. package/src/vision/index.ts +21 -1
  101. package/src/web-search/executor.ts +4 -4
  102. package/src/web-search/index.ts +1 -1
  103. package/src/web-search/loop.ts +84 -24
  104. package/gui/dist/assets/index-BcHhxo1I.css +0 -1
  105. package/gui/dist/assets/index-DCC1q_Jx.js +0 -15
  106. package/src/server.ts +0 -2501
  107. /package/src/{codex-account-runtime-state.ts → codex/account-runtime-state.ts} +0 -0
  108. /package/src/{codex-quota.ts → codex/quota.ts} +0 -0
  109. /package/src/{abort.ts → lib/abort.ts} +0 -0
  110. /package/src/{debug.ts → lib/debug.ts} +0 -0
  111. /package/src/{errors.ts → lib/errors.ts} +0 -0
  112. /package/src/{open-url.ts → lib/open-url.ts} +0 -0
  113. /package/src/{privacy.ts → lib/privacy.ts} +0 -0
  114. /package/src/{redact.ts → lib/redact.ts} +0 -0
  115. /package/src/{sidecar-tracker.ts → lib/sidecar-tracker.ts} +0 -0
  116. /package/src/{upstream-retry.ts → lib/upstream-retry.ts} +0 -0
  117. /package/src/{win-paths.ts → lib/win-paths.ts} +0 -0
  118. /package/src/{ports.ts → server/ports.ts} +0 -0
@@ -1,5 +1,5 @@
1
- import { loadConfig, saveConfig } from "./config";
2
- import { withCodexAccountLogLabel } from "./codex-account-label";
1
+ import { loadConfig, saveConfig } from "../config";
2
+ import { withCodexAccountLogLabel } from "./account-label";
3
3
  import {
4
4
  getCodexAccountCredential,
5
5
  getValidCodexToken,
@@ -7,12 +7,12 @@ import {
7
7
  CodexCredentialGenerationConflictError,
8
8
  CodexCredentialRefreshLockTimeoutError,
9
9
  TokenRefreshError,
10
- } from "./codex-account-store";
11
- import { deleteCodexAccount } from "./codex-account-lifecycle";
12
- import { checkAccountIdCollision, readCodexTokens } from "./codex-auth-collision";
13
- export { checkAccountIdCollision, getMainChatgptAccountId } from "./codex-auth-collision";
14
- export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./codex-account-runtime-state";
15
- import { clearAccountNeedsReauth, isAccountNeedsReauth } from "./codex-account-runtime-state";
10
+ } from "./account-store";
11
+ import { deleteCodexAccount } from "./account-lifecycle";
12
+ import { checkAccountIdCollision, readCodexTokens } from "./auth-collision";
13
+ export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision";
14
+ export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
15
+ import { clearAccountNeedsReauth, isAccountNeedsReauth } from "./account-runtime-state";
16
16
  import {
17
17
  clearAccountQuota,
18
18
  getAccountQuota,
@@ -21,13 +21,13 @@ import {
21
21
  updateAccountQuota,
22
22
  type StoredAccountQuota,
23
23
  type WhamUsageResponse,
24
- } from "./codex-quota";
25
- export { clearAccountQuota, getAccountQuota, parseUsageQuota, updateAccountQuota } from "./codex-quota";
26
- import { extractAccountId, decodeJwtPayload } from "./oauth/chatgpt";
27
- import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./codex-main-account";
28
- import { maskEmail } from "./privacy";
29
- export { maskEmail } from "./privacy";
30
- import type { CodexAccount, OcxConfig } from "./types";
24
+ } from "./quota";
25
+ export { clearAccountQuota, getAccountQuota, parseUsageQuota, updateAccountQuota } from "./quota";
26
+ import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt";
27
+ import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
28
+ import { maskEmail } from "../lib/privacy";
29
+ export { maskEmail } from "../lib/privacy";
30
+ import type { CodexAccount, OcxConfig } from "../types";
31
31
 
32
32
  function jsonResponse(data: unknown, status = 200): Response {
33
33
  return new Response(JSON.stringify(data), {
@@ -541,7 +541,7 @@ export async function handleCodexAuthAPI(
541
541
  }
542
542
  const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
543
543
  try {
544
- const { startLoginFlow, getLoginStatus } = await import("./oauth/index");
544
+ const { startLoginFlow, getLoginStatus } = await import("../oauth");
545
545
  const result = await startLoginFlow("chatgpt", { forceLogin: true });
546
546
 
547
547
  (async () => {
@@ -550,7 +550,7 @@ export async function handleCodexAuthAPI(
550
550
  await new Promise(r => setTimeout(r, 2000));
551
551
  const st = getLoginStatus("chatgpt");
552
552
  if (st.done && st.loggedIn) {
553
- const { getCredential } = await import("./oauth/store");
553
+ const { getCredential } = await import("../oauth/store");
554
554
  const cred = getCredential("chatgpt");
555
555
  if (cred) {
556
556
  const oauthAccountId = cred.accountId;
@@ -652,7 +652,7 @@ export async function handleCodexAuthAPI(
652
652
 
653
653
  if (url.pathname === "/api/codex-auth/login/cancel" && req.method === "POST") {
654
654
  const body = (await req.json().catch(() => ({}))) as { flowId?: string };
655
- const { cancelLoginFlow } = await import("./oauth/index");
655
+ const { cancelLoginFlow } = await import("../oauth");
656
656
  const cancelled = cancelLoginFlow("chatgpt");
657
657
  expireCodexAuthFlow(body.flowId ?? null);
658
658
  return jsonResponse({ ok: true, cancelled });
@@ -1,9 +1,9 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { getCodexAccountCredential } from "./codex-account-store";
4
- import { loadConfig } from "./config";
5
- import { resolveCodexHomeDir } from "./codex-home";
6
- import { extractAccountId } from "./oauth/chatgpt";
3
+ import { getCodexAccountCredential } from "./account-store";
4
+ import { loadConfig } from "../config";
5
+ import { resolveCodexHomeDir } from "./home";
6
+ import { extractAccountId } from "../oauth/chatgpt";
7
7
 
8
8
  export function readCodexTokens(): { access_token: string; account_id: string; id_token?: string } | null {
9
9
  try {
@@ -3,14 +3,14 @@ import {
3
3
  CodexCredentialRefreshLockTimeoutError,
4
4
  getValidCodexToken,
5
5
  isCodexAccountGenerationLive,
6
- } from "./codex-account-store";
7
- import { markAccountNeedsReauth } from "./codex-account-runtime-state";
8
- import { isCodexAccountUsable } from "./codex-account-usability";
9
- import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./codex-main-account";
10
- import { getCodexAccountCooldownUntil, resolveCodexAccountForThreadDetailed } from "./codex-routing";
11
- import { getAccountQuota } from "./codex-quota";
12
- import type { OcxConfig, OcxProviderConfig } from "./types";
13
- import { FORWARD_HEADERS } from "./adapters/openai-responses";
6
+ } from "./account-store";
7
+ import { markAccountNeedsReauth } from "./account-runtime-state";
8
+ import { isCodexAccountUsable } from "./account-usability";
9
+ import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./main-account";
10
+ import { getCodexAccountCooldownUntil, resolveCodexAccountForThreadDetailed } from "./routing";
11
+ import { getAccountQuota } from "./quota";
12
+ import type { OcxConfig, OcxProviderConfig } from "../types";
13
+ import { FORWARD_HEADERS } from "../adapters/openai-responses";
14
14
 
15
15
  export type CodexAuthContext =
16
16
  | { kind: "main"; accountId: null }
@@ -83,7 +83,7 @@ export async function resolveCodexAuthContext(headers: Headers, config: OcxConfi
83
83
  // blocks the current request, and the helper's single-flight guard collapses
84
84
  // repeated triggers into one pass.
85
85
  if (!getAccountQuota(accountId)) {
86
- import("./codex-auth-api")
86
+ import("./auth-api")
87
87
  .then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, "pre-route"))
88
88
  .catch(() => {});
89
89
  }
@@ -2,18 +2,19 @@ import { execFileSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
4
4
  import { delimiter, dirname, join, resolve } from "node:path";
5
- import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "./config";
6
- import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "./codex-paths";
5
+ import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../config";
6
+ import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "./paths";
7
7
  import { DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "./model-cache";
8
- import { buildModelsRequest, resolveModelsAuthToken } from "./oauth/index";
9
- import { effectiveGoogleMode } from "./providers/registry";
10
- import type { OcxConfig, OcxProviderConfig } from "./types";
11
- import { CODEX_REASONING_LEVELS, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "./reasoning-effort";
12
- import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "./generated/jawcode-model-metadata";
13
- import { shouldCaseFoldMetadataModelId } from "./providers/derive";
14
- import { applyProviderContextCap, providerContextCap } from "./provider-context-cap";
15
- import { CODEX_GPT5_IDENTITY_LINE } from "./adapters/identity";
16
- import { fetchCursorUsableModels } from "./adapters/cursor/live-models";
8
+ import { buildModelsRequest, resolveModelsAuthToken } from "../oauth";
9
+ import { effectiveGoogleMode } from "../providers/registry";
10
+ import type { OcxConfig, OcxProviderConfig } from "../types";
11
+ import { modelInList } from "../types";
12
+ import { CODEX_REASONING_LEVELS, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../reasoning-effort";
13
+ import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "../generated/jawcode-model-metadata";
14
+ import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../providers/derive";
15
+ import { applyProviderContextCap, providerContextCap } from "../providers/context-cap";
16
+ import { CODEX_GPT5_IDENTITY_LINE } from "../adapters/identity";
17
+ import { fetchCursorUsableModels } from "../adapters/cursor/live-models";
17
18
 
18
19
  const BUNDLED_CATALOG_CACHE_MS = 60_000;
19
20
  let bundledCatalogCache: { expiresAt: number; value: RawCatalog | null } | null = null;
@@ -103,7 +104,7 @@ function isUnsupportedOpenAiNativeSlug(slug: string): boolean {
103
104
 
104
105
  const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record<string, { contextWindow?: number; maxContextWindow?: number }> = {
105
106
  "gpt-5.5": { contextWindow: 272_000, maxContextWindow: 272_000 },
106
- "gpt-5.4": { maxContextWindow: 1_000_000 },
107
+ "gpt-5.4": { contextWindow: 1_000_000, maxContextWindow: 1_000_000 },
107
108
  "gpt-5.3-codex-spark": { contextWindow: 128_000, maxContextWindow: 128_000 },
108
109
  };
109
110
 
@@ -273,13 +274,21 @@ export function normalizeRoutedCatalogEntry(entry: RawEntry): RawEntry {
273
274
  delete entry.service_tier;
274
275
  delete entry.service_tiers;
275
276
  delete entry.default_service_tier;
277
+ const isCursorEntry = typeof entry.slug === "string" && entry.slug.startsWith("cursor/");
276
278
  // Routed providers use opencodex sidecars and client-executed tool discovery. The sidecar
277
- // runs through native gpt-5.4-mini, so image search is available and verbalized for text-only models.
278
- entry.web_search_tool_type = "text_and_image";
279
- entry.supports_search_tool = true;
279
+ // runs through native gpt-5.4-mini, so image search is available and verbalized for text-only
280
+ // models. EXCEPT cursor: its runTurn transport bypasses the web-search plan entirely and
281
+ // rejects server search queries — advertising the tool would make models call into a void.
282
+ if (isCursorEntry) {
283
+ delete entry.web_search_tool_type;
284
+ entry.supports_search_tool = false;
285
+ } else {
286
+ entry.web_search_tool_type = "text_and_image";
287
+ entry.supports_search_tool = true;
288
+ }
280
289
  // Cursor's transport already serializes overlapping tool calls into atomic Responses tool events.
281
290
  // Advertising parallel calls lets Codex send the same native capability bit it sends for OpenAI.
282
- entry.supports_parallel_tool_calls = typeof entry.slug === "string" && entry.slug.startsWith("cursor/");
291
+ entry.supports_parallel_tool_calls = isCursorEntry;
283
292
  return ensureStrictCatalogFields(entry);
284
293
  }
285
294
 
@@ -365,7 +374,7 @@ function codexShimCommandCandidates(): string[] {
365
374
  * `.cmd`/`.bat` launchers (npm's `codex.cmd`) cannot be spawned shell-less — Node ≥18.20
366
375
  * and Bun refuse with EINVAL (CVE-2024-27980 hardening), which the probe loop silently
367
376
  * swallowed, so npm-only Codex installs never loaded the bundled catalog on Windows.
368
- * Route those through the shell (repo convention — see src/update.ts, bin/ocx.mjs) and
377
+ * Route those through the shell (repo convention — see src/update/index.ts, bin/ocx.mjs) and
369
378
  * pre-quote the path: shell:true joins file+args verbatim, so an unquoted path with
370
379
  * spaces (`C:\Users\John Doe\...`) would split. Windows paths cannot contain `"`.
371
380
  */
@@ -677,10 +686,18 @@ function configuredInputModalities(prov: OcxProviderConfig, id: string): string[
677
686
  return Array.isArray(modalities) && modalities.length > 0 ? [...modalities] : undefined;
678
687
  }
679
688
 
680
- function applyProviderConfigHints(name: string, prov: OcxProviderConfig, model: CatalogModel, providerCap?: number): CatalogModel {
689
+ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, model: CatalogModel, providerCap?: number): CatalogModel {
681
690
  void name;
682
691
  const configuredCap = configuredContextWindow(prov, model.id);
683
- const inputModalities = configuredInputModalities(prov, model.id);
692
+ let inputModalities = configuredInputModalities(prov, model.id);
693
+ // Vision-sidecar coverage: `noVisionModels` marks models whose images the PROXY describes
694
+ // (src/vision/index.ts). The catalog must still advertise image input for them — the Codex app
695
+ // gates attachments client-side on input_modalities, and a text-only entry would block images
696
+ // before the sidecar ever runs ("This model does not support image inputs").
697
+ if (modelInList(prov.noVisionModels, model.id)) {
698
+ const base = inputModalities ?? model.inputModalities ?? ["text"];
699
+ inputModalities = base.includes("image") ? [...base] : [...base, "image"];
700
+ }
684
701
  const reasoningEfforts = configuredReasoningEfforts(prov, model.id);
685
702
  const hinted = {
686
703
  ...model,
@@ -847,7 +864,19 @@ export function filterCatalogVisibleModels(
847
864
  */
848
865
  export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogModel[]> {
849
866
  const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS;
850
- const activeProviders = Object.entries(config.providers).filter(([, prov]) => prov.disabled !== true);
867
+ // Persisted provider entries can predate newer registry fields (noVisionModels,
868
+ // modelInputModalities, ...). The ROUTER merges registry seeds at request time
869
+ // (routedProviderConfig), so the proxy behaves correctly — the catalog listing must see the
870
+ // same merged view or its advertisements drift from actual proxy behavior (e.g. a
871
+ // vision-sidecar model advertised text-only, blocking image attachments app-side).
872
+ // Enrich a CLONE: hydrated defaults must never leak into the persisted config.
873
+ const activeProviders = Object.entries(config.providers)
874
+ .filter(([, prov]) => prov.disabled !== true)
875
+ .map(([name, prov]): [string, OcxProviderConfig] => {
876
+ const enriched = { ...prov };
877
+ enrichProviderFromRegistry(name, enriched);
878
+ return [name, enriched];
879
+ });
851
880
  const lists = await Promise.all(
852
881
  activeProviders.map(([name, prov]) => fetchProviderModels(name, prov, ttlMs, providerContextCap(config, name))),
853
882
  );
@@ -0,0 +1,102 @@
1
+ import { countPendingOpencodexHistory, migrateHistoryToOpenai } from "./history-provider";
2
+
3
+ /**
4
+ * Daemon-side retry for the one-time Design-B history migration.
5
+ *
6
+ * Most upgrades run `ocx start` while the Codex app still holds `state_5.sqlite`,
7
+ * so the inject-time migration often fails on the FIRST start — exactly the moment
8
+ * every legacy thread is still tagged `opencodex` and invisible to the app. Instead
9
+ * of asking the user to close the app and rerun start, this guardian keeps retrying
10
+ * in the background until the migration lands.
11
+ *
12
+ * Design constraints (audit-driven):
13
+ * - Ticks use `{ attempts: 1 }`: no sleepSync inside the daemon event loop; the tick
14
+ * cadence IS the retry. Worst case per tick is one sqlite busy wait.
15
+ * - Timers are unref'd so the guardian never keeps the process alive.
16
+ * - Started ONLY from `ocx start` (cli handleStart), never from injectCodexConfig —
17
+ * `/api/sync` re-runs inject and must not double-start loops.
18
+ */
19
+
20
+ export interface HistoryMigrationGuardianHandle {
21
+ stop(): void;
22
+ }
23
+
24
+ export interface HistoryMigrationGuardianDeps {
25
+ countFn?: typeof countPendingOpencodexHistory;
26
+ migrateFn?: () => ReturnType<typeof migrateHistoryToOpenai>;
27
+ log?: Pick<Console, "log">;
28
+ tickMs?: number;
29
+ maxTicks?: number;
30
+ /** Test hook: schedule fn after ms; return a cancel handle. Defaults to setTimeout. */
31
+ scheduleFn?: (fn: () => void, ms: number) => { cancel(): void };
32
+ }
33
+
34
+ const DEFAULT_TICK_MS = 60_000;
35
+ const DEFAULT_MAX_TICKS = 60; // give up after ~an hour; doctor still surfaces the pending state
36
+
37
+ function defaultSchedule(fn: () => void, ms: number): { cancel(): void } {
38
+ const timer = setTimeout(fn, ms);
39
+ if (typeof timer.unref === "function") timer.unref();
40
+ return { cancel: () => clearTimeout(timer) };
41
+ }
42
+
43
+ export function startHistoryMigrationGuardian(deps: HistoryMigrationGuardianDeps = {}): HistoryMigrationGuardianHandle {
44
+ const countFn = deps.countFn ?? countPendingOpencodexHistory;
45
+ const migrateFn = deps.migrateFn ?? (() => migrateHistoryToOpenai(undefined, undefined, { attempts: 1 }));
46
+ const log = deps.log ?? console;
47
+ const tickMs = deps.tickMs ?? DEFAULT_TICK_MS;
48
+ const maxTicks = deps.maxTicks ?? DEFAULT_MAX_TICKS;
49
+
50
+ let stopped = false;
51
+ let pending: { cancel(): void } | undefined;
52
+ let ticks = 0;
53
+
54
+ const schedule = () => {
55
+ if (stopped) return;
56
+ pending = (deps.scheduleFn ?? defaultSchedule)(tick, tickMs);
57
+ };
58
+
59
+ const tick = () => {
60
+ if (stopped) return;
61
+ ticks++;
62
+ try {
63
+ const count = countFn();
64
+ if (!count.failed && count.pendingRows === 0 && count.backupEntries === 0) {
65
+ stopped = true; // nothing left to migrate — normal steady state, no log noise
66
+ return;
67
+ }
68
+ // Locked probe or pending work: attempt one migration pass.
69
+ const result = migrateFn();
70
+ if (!result.failed) {
71
+ const moved = result.rows + (result.ejectedRows ?? 0);
72
+ if (moved > 0) {
73
+ log.log(`🩹 history-migration: ${moved} legacy opencodex thread(s) migrated back to openai.`);
74
+ }
75
+ // A "successful" zero-row migration can also mean the DB does not exist YET while a
76
+ // backup manifest still holds restore work (fresh reinstall race). Only stop when a
77
+ // re-count proves nothing is pending; otherwise keep ticking within the budget.
78
+ const after = countFn();
79
+ if (moved > 0 || (!after.failed && after.pendingRows === 0 && after.backupEntries === 0)) {
80
+ stopped = true;
81
+ return;
82
+ }
83
+ }
84
+ } catch {
85
+ /* hard errors are not retryable state — fall through to the tick budget */
86
+ }
87
+ if (ticks >= maxTicks) {
88
+ stopped = true;
89
+ log.log("⚠️ history-migration: Codex history DB stayed locked; legacy threads not yet migrated. Close the Codex app and run 'ocx sync' (or check 'ocx doctor').");
90
+ return;
91
+ }
92
+ schedule();
93
+ };
94
+
95
+ schedule();
96
+ return {
97
+ stop() {
98
+ stopped = true;
99
+ pending?.cancel();
100
+ },
101
+ };
102
+ }
@@ -2,8 +2,8 @@ import { createHash } from "node:crypto";
2
2
  import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, unlinkSync, writeSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { Database } from "bun:sqlite";
5
- import { CODEX_HOME } from "./codex-paths";
6
- import { atomicWriteFile, getConfigDir } from "./config";
5
+ import { CODEX_HOME } from "./paths";
6
+ import { atomicWriteFile, getConfigDir } from "../config";
7
7
 
8
8
  const STATE_DB_PATH = join(CODEX_HOME, "state_5.sqlite");
9
9
  function historyBackupPathFor(stateDbPath: string): string {
@@ -20,10 +20,21 @@ const RESUMABLE_SOURCES = ["cli", "vscode"] as const;
20
20
  * connection pool into a half-applied checkpoint. The app opens this DB with `busy_timeout=5s`
21
21
  * (see codex-rs `state::runtime::base_sqlite_options`); we mirror that here.
22
22
  */
23
+ let historyDbBusyTimeoutMs = 5000;
24
+
25
+ /**
26
+ * Test-only knob: Windows CI can spend the FULL busy timeout on a transient file lock, which
27
+ * alone exceeds bun's 5s default per-test timeout. Tests shrink this so a busy DB fails fast
28
+ * into withHistoryRetry instead of stalling; production keeps the codex-rs-matching 5s.
29
+ */
30
+ export function setHistoryDbBusyTimeoutForTests(ms: number): void {
31
+ historyDbBusyTimeoutMs = ms;
32
+ }
33
+
23
34
  function openStateDb(stateDbPath: string): Database {
24
35
  const db = new Database(stateDbPath);
25
36
  try {
26
- db.exec("PRAGMA busy_timeout = 5000");
37
+ db.exec(`PRAGMA busy_timeout = ${historyDbBusyTimeoutMs}`);
27
38
  } catch {
28
39
  /* best-effort: an older sqlite without busy_timeout still works, just less politely */
29
40
  }
@@ -369,6 +380,7 @@ export function isRecoverableHistoryError(error: unknown): boolean {
369
380
  }
370
381
 
371
382
  const HISTORY_RETRY_DELAY_MS = 500;
383
+ const HISTORY_RETRY_ATTEMPTS = 2;
372
384
 
373
385
  /**
374
386
  * Run a history mutation with one retry across recoverable lock/busy errors (the app's own
@@ -377,20 +389,47 @@ const HISTORY_RETRY_DELAY_MS = 500;
377
389
  * error — callers surface that as `failed: true` instead of a silent no-op. Hard errors
378
390
  * (corruption, programming bugs) still throw.
379
391
  */
380
- export function withHistoryRetry<T>(fn: () => T, io: { sleepFn?: (ms: number) => void } = {}): T | null {
392
+ export function withHistoryRetry<T>(fn: () => T, io: { sleepFn?: (ms: number) => void; attempts?: number; delayMs?: number } = {}): T | null {
381
393
  const sleepFn = io.sleepFn ?? Bun.sleepSync;
394
+ const attempts = Math.max(1, io.attempts ?? HISTORY_RETRY_ATTEMPTS);
395
+ const delayMs = io.delayMs ?? HISTORY_RETRY_DELAY_MS;
382
396
  for (let attempt = 0; ; attempt++) {
383
397
  try {
384
398
  return fn();
385
399
  } catch (error) {
386
400
  if (!isRecoverableHistoryError(error)) throw error;
387
- if (attempt >= 1) return null;
388
- try { sleepFn(HISTORY_RETRY_DELAY_MS); } catch { /* sleep is best-effort */ }
401
+ if (attempt >= attempts - 1) return null;
402
+ try { sleepFn(delayMs); } catch { /* sleep is best-effort */ }
389
403
  }
390
404
  }
391
405
  }
392
406
 
393
- export function syncCodexHistoryProvider(provider: CodexHistoryProvider, stateDbPath = STATE_DB_PATH, backupPath = HISTORY_BACKUP_PATH): CodexHistorySyncResult {
407
+ /**
408
+ * True when a READONLY probe proves the openai-direction restore would be a no-op:
409
+ * zero threads still tagged opencodex AND an empty backup manifest. Used to skip the
410
+ * write-open entirely in the Design B steady state — on Windows the Codex app holds
411
+ * `state_5.sqlite` (WAL, busy_timeout 5s), so an unnecessary write open can stall for
412
+ * seconds and surface a false lock warning, while WAL always admits readers. A failed
413
+ * probe (locked even for readers / schema drift) returns false so callers fall through
414
+ * to the write attempt and keep today's behavior for genuinely unknown state.
415
+ */
416
+ function openaiRestoreIsNoop(stateDbPath: string, backupPath: string): boolean {
417
+ const pending = countPendingOpencodexHistory(stateDbPath, backupPath);
418
+ return !pending.failed && pending.pendingRows === 0 && pending.backupEntries === 0;
419
+ }
420
+
421
+ export function syncCodexHistoryProvider(
422
+ provider: CodexHistoryProvider,
423
+ stateDbPath = STATE_DB_PATH,
424
+ backupPath = HISTORY_BACKUP_PATH,
425
+ opts: { skipWhenProvablyNoop?: boolean } = {},
426
+ ): CodexHistorySyncResult {
427
+ // Opt-in steady-state gate (Design B loopback callers only): default semantics of
428
+ // this exported API are unchanged — legacy stop/restore paths never pass the flag.
429
+ if (opts.skipWhenProvablyNoop && provider === "openai" && existsSync(stateDbPath)
430
+ && openaiRestoreIsNoop(stateDbPath, backupPath)) {
431
+ return { rows: 0, files: 0 };
432
+ }
394
433
  return withHistoryRetry(() => syncCodexHistoryProviderUnsafe(provider, stateDbPath, backupPath))
395
434
  ?? { rows: 0, files: 0, failed: true };
396
435
  }
@@ -526,3 +565,68 @@ export function restoreLegacyOpenaiHistory(stateDbPath = STATE_DB_PATH): { rows:
526
565
  }
527
566
  }) ?? { rows: 0, files: 0, failed: true };
528
567
  }
568
+
569
+ /**
570
+ * One-time Design-B migration: restore backed-up originals, then eject any remaining
571
+ * opencodex-tagged threads to openai. Thin wrapper over the restore path with a
572
+ * configurable retry budget — the daemon migration guardian uses `{ attempts: 1 }`
573
+ * per tick so a locked DB never stalls the event loop beyond one sqlite busy wait.
574
+ */
575
+ export function migrateHistoryToOpenai(
576
+ stateDbPath = STATE_DB_PATH,
577
+ backupPath = HISTORY_BACKUP_PATH,
578
+ opts: { attempts?: number; delayMs?: number; sleepFn?: (ms: number) => void } = {},
579
+ ): CodexHistorySyncResult {
580
+ if (!existsSync(stateDbPath)) return { rows: 0, files: 0 };
581
+ // Steady-state gate: this migration is Design-B-specific (inject + guardian callers),
582
+ // and after the one-time migration every start would otherwise write-open the DB for
583
+ // nothing. A missing DB with a leftover backup manifest does NOT satisfy the gate
584
+ // (backupEntries > 0), so the guardian's fresh-reinstall re-count protection holds.
585
+ if (openaiRestoreIsNoop(stateDbPath, backupPath)) return { rows: 0, files: 0 };
586
+ return withHistoryRetry(() => syncCodexHistoryProviderUnsafe("openai", stateDbPath, backupPath), opts)
587
+ ?? { rows: 0, files: 0, failed: true };
588
+ }
589
+
590
+ export interface PendingHistoryCount {
591
+ /** Threads still tagged opencodex that the eject path WOULD move (mirrors its WHERE). */
592
+ pendingRows: number;
593
+ /** Entries still recorded in the backup manifest (restore targets). */
594
+ backupEntries: number;
595
+ /** Set when the DB could not be opened/read (locked); counts are then unknown, not zero. */
596
+ failed?: true;
597
+ }
598
+
599
+ /**
600
+ * Read-only migration progress probe for the guardian and `ocx doctor`. Opens sqlite
601
+ * readonly with a SHORT busy timeout so a locked DB cannot stall a daemon tick. The
602
+ * pending predicate mirrors ejectRemainingOpencodexHistory exactly — rows eject ignores
603
+ * (empty first_user_message) are not counted, so 0 really means "migration done".
604
+ */
605
+ export function countPendingOpencodexHistory(stateDbPath = STATE_DB_PATH, backupPath = HISTORY_BACKUP_PATH): PendingHistoryCount {
606
+ let backupEntries = 0;
607
+ try {
608
+ const manifest = readBackup(backupPath, stateDbPath);
609
+ backupEntries = Object.keys(manifest.entries).length;
610
+ } catch { /* unreadable manifest counts as 0 — restore treats it the same way */ }
611
+
612
+ if (!existsSync(stateDbPath)) return { pendingRows: 0, backupEntries };
613
+ try {
614
+ const db = new Database(stateDbPath, { readonly: true });
615
+ try {
616
+ db.exec("PRAGMA busy_timeout = 100");
617
+ const row = db.query<{ n: number }, []>(`
618
+ SELECT count(*) AS n
619
+ FROM threads
620
+ WHERE model_provider = 'opencodex'
621
+ AND trim(coalesce(first_user_message, '')) != ''
622
+ `).get();
623
+ return { pendingRows: row?.n ?? 0, backupEntries };
624
+ } finally {
625
+ db.close();
626
+ }
627
+ } catch (error) {
628
+ if (isRecoverableHistoryError(error)) return { pendingRows: 0, backupEntries, failed: true };
629
+ // Schema drift (e.g. a future codex renames the table) is a "cannot know" too, not a crash.
630
+ return { pendingRows: 0, backupEntries, failed: true };
631
+ }
632
+ }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
- import { expandUserPath } from "./config";
4
+ import { expandUserPath } from "../config";
5
5
 
6
6
  export type CodexHomeDeps = {
7
7
  env?: NodeJS.ProcessEnv;