@bitkyc08/opencodex 2.5.5-preview.1 → 2.5.5

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.
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-CKX3MGK9.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-qkvcDJZw.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-CJF4_jax.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.5.5-preview.1",
3
+ "version": "2.5.5",
4
4
  "description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/cli.ts CHANGED
@@ -3,9 +3,9 @@ import { execFileSync, spawn } from "node:child_process";
3
3
  import { rmSync } from "node:fs";
4
4
  import { restoreNativeCodex } from "./codex-inject";
5
5
  import { restoreLegacyOpenaiHistory } from "./codex-history-provider";
6
- import { codexAutoStartEnabled, getConfigDir, loadConfig, readPid, removePid, saveConfig, writePid } from "./config";
6
+ import { codexAutoStartEnabled, getConfigDir, getConfigPath, loadConfig, readPid, removePid, saveConfig, writePid } from "./config";
7
7
  import { findAvailablePort } from "./ports";
8
- import { serviceCommand, stopServiceIfInstalled, uninstallServiceIfInstalled } from "./service";
8
+ import { serviceCommand, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "./service";
9
9
  import { drainAndShutdown, startServer } from "./server";
10
10
  import { maybeShowStarPrompt } from "./star-prompt";
11
11
 
@@ -191,7 +191,7 @@ async function handleStart(options: { block?: boolean } = {}) {
191
191
  console.error(`⚠️ Proxy already running (PID ${existingPid}). Use 'ocx stop' first.`);
192
192
  process.exit(1);
193
193
  }
194
- removePid();
194
+ removePid(existingPid);
195
195
  }
196
196
 
197
197
  const requestedPort = parsePortOption();
@@ -205,7 +205,7 @@ async function handleStart(options: { block?: boolean } = {}) {
205
205
  console.log("\n🛑 Shutting down opencodex proxy...");
206
206
  void (async () => {
207
207
  await drainAndShutdown(server, config.shutdownTimeoutMs ?? 5000);
208
- removePid();
208
+ removePid(process.pid);
209
209
  if (!process.env.OCX_SERVICE) { try { restoreNativeCodex(); } catch { /* best-effort restore */ } }
210
210
  process.exit(0);
211
211
  })();
@@ -300,7 +300,7 @@ function handleStop() {
300
300
  try {
301
301
  killProxy(pid);
302
302
  console.log(`✅ Proxy (PID ${pid}) stopped.`);
303
- removePid();
303
+ removePid(pid);
304
304
  } catch {
305
305
  stopFailed = true;
306
306
  console.error(`❌ Failed to stop proxy (PID ${pid}).`);
@@ -336,7 +336,7 @@ async function handleUninstall() {
336
336
  const pid = readPid();
337
337
  if (!pid) return false;
338
338
  killProxy(pid);
339
- removePid();
339
+ removePid(pid);
340
340
  return true;
341
341
  });
342
342
 
@@ -365,13 +365,56 @@ async function handleUninstall() {
365
365
  console.log("\n✅ opencodex local state removed. Remove the package with: npm uninstall -g @bitkyc08/opencodex");
366
366
  }
367
367
 
368
- function handleStatus() {
368
+ type HealthCheck = {
369
+ ok: boolean;
370
+ label: string;
371
+ };
372
+
373
+ async function checkProxyHealth(port: number, hostname?: string): Promise<HealthCheck> {
374
+ const url = `http://${healthHost(hostname)}:${port}/healthz`;
375
+ const controller = new AbortController();
376
+ const timer = setTimeout(() => controller.abort(), 800);
377
+ try {
378
+ const response = await fetch(url, { signal: controller.signal });
379
+ if (!response.ok) return { ok: false, label: `${url} returned HTTP ${response.status}` };
380
+ const body = await response.json().catch(() => null) as { version?: unknown; uptime?: unknown } | null;
381
+ const version = typeof body?.version === "string" ? ` v${body.version}` : "";
382
+ const uptime = typeof body?.uptime === "number" ? `, uptime ${Math.round(body.uptime)}s` : "";
383
+ return { ok: true, label: `${url} ok${version}${uptime}` };
384
+ } catch (error) {
385
+ const reason = error instanceof Error && error.name === "AbortError" ? "timed out" : "unreachable";
386
+ return { ok: false, label: `${url} ${reason}` };
387
+ } finally {
388
+ clearTimeout(timer);
389
+ }
390
+ }
391
+
392
+ async function handleStatus() {
393
+ const config = loadConfig();
394
+ const port = config.port ?? 10100;
369
395
  const pid = readPid();
370
- if (pid) {
371
- console.log(`✅ Proxy running (PID ${pid})`);
396
+ const health = await checkProxyHealth(port, config.hostname);
397
+ const proxyLabel = pid && health.ok
398
+ ? `running (PID ${pid})`
399
+ : pid
400
+ ? `PID file points to PID ${pid}, but health check failed`
401
+ : health.ok
402
+ ? "reachable, but PID file is missing or stale"
403
+ : "not running";
404
+
405
+ if (pid || health.ok) {
406
+ console.log(`✅ Proxy: ${proxyLabel}`);
372
407
  } else {
373
- console.log("❌ Proxy not running");
408
+ console.log(`❌ Proxy: ${proxyLabel}`);
374
409
  }
410
+ console.log(` Health: ${health.label}`);
411
+ console.log(` Dashboard: http://localhost:${port}/`);
412
+ console.log(` Config: ${getConfigPath()}`);
413
+ console.log(` Default provider: ${config.defaultProvider}`);
414
+ console.log(` Codex autostart: ${codexAutoStartEnabled(config) ? "enabled" : "disabled"}`);
415
+ console.log(` Service: ${serviceStatusSummary()}`);
416
+ const { codexShimStatus } = await import("./codex-shim");
417
+ console.log(` ${codexShimStatus()}`);
375
418
  }
376
419
 
377
420
  function handleRecoverHistory() {
@@ -411,7 +454,7 @@ switch (command) {
411
454
  await handleUninstall();
412
455
  break;
413
456
  case "status":
414
- handleStatus();
457
+ await handleStatus();
415
458
  break;
416
459
  case "ensure":
417
460
  await handleEnsure();
@@ -1,7 +1,8 @@
1
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
4
- import { atomicWriteFile, websocketsEnabled } from "./config";
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
4
+ import { delimiter, dirname, join, resolve } from "node:path";
5
+ import { atomicWriteFile, getConfigDir, websocketsEnabled } from "./config";
5
6
  import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "./codex-paths";
6
7
  import { DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, setCached } from "./model-cache";
7
8
  import { buildModelsRequest, resolveModelsAuthToken } from "./oauth/index";
@@ -10,8 +11,28 @@ import { CODEX_REASONING_LEVELS, configuredReasoningEfforts, modelRecordValue, s
10
11
  import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "./generated/jawcode-model-metadata";
11
12
  import { shouldCaseFoldMetadataModelId } from "./providers/derive";
12
13
 
13
- const OCX_DIR = join(homedir(), ".opencodex");
14
- const CATALOG_BACKUP_PATH = join(OCX_DIR, "catalog-backup.json");
14
+ const BUNDLED_CATALOG_CACHE_MS = 60_000;
15
+ let bundledCatalogCache: { expiresAt: number; value: RawCatalog | null } | null = null;
16
+
17
+ function legacyCatalogBackupPath(): string {
18
+ return join(getConfigDir(), "catalog-backup.json");
19
+ }
20
+
21
+ function catalogBackupPathFor(catalogPath: string): string {
22
+ const normalized = process.platform === "win32" ? resolve(catalogPath).toLowerCase() : resolve(catalogPath);
23
+ const id = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
24
+ return join(getConfigDir(), `catalog-backup-${id}.json`);
25
+ }
26
+
27
+ function samePath(a: string, b: string): boolean {
28
+ const left = resolve(a);
29
+ const right = resolve(b);
30
+ return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
31
+ }
32
+
33
+ function isDefaultCatalogPath(path: string): boolean {
34
+ return samePath(path, DEFAULT_CATALOG_PATH);
35
+ }
15
36
 
16
37
  /**
17
38
  * Native OpenAI / Codex models served via ChatGPT OAuth passthrough — FALLBACK only. The ChatGPT
@@ -36,6 +57,7 @@ export function nativeOpenAiSlugs(): string[] {
36
57
 
37
58
  export interface CatalogModel { id: string; provider: string; owned_by?: string; reasoningEfforts?: string[]; contextWindow?: number; inputModalities?: string[]; }
38
59
  type RawEntry = Record<string, unknown>;
60
+ type RawCatalog = { models?: RawEntry[]; [k: string]: unknown };
39
61
  const JAWCODE_CATALOG_AUGMENT_PROVIDERS = new Set(["opencode-go"]);
40
62
 
41
63
  /**
@@ -74,15 +96,21 @@ export function readCodexCatalogPath(): string {
74
96
  return DEFAULT_CATALOG_PATH;
75
97
  }
76
98
 
77
- function readCatalog(path: string): { models?: RawEntry[]; [k: string]: unknown } | null {
99
+ function parseCatalogJson(raw: string): RawCatalog | null {
78
100
  try {
79
- if (!existsSync(path)) return null;
80
- const cat = JSON.parse(readFileSync(path, "utf-8"));
101
+ const cat = JSON.parse(raw);
81
102
  return (cat && Array.isArray(cat.models)) ? cat : null;
82
103
  } catch { return null; }
83
104
  }
84
105
 
85
- function findNativeTemplate(catalog: { models?: RawEntry[] } | null): RawEntry | null {
106
+ function readCatalog(path: string): RawCatalog | null {
107
+ try {
108
+ if (!existsSync(path)) return null;
109
+ return parseCatalogJson(readFileSync(path, "utf-8"));
110
+ } catch { return null; }
111
+ }
112
+
113
+ function findNativeTemplate(catalog: RawCatalog | null): RawEntry | null {
86
114
  return catalog?.models?.find(
87
115
  m => typeof m.slug === "string" && !m.slug.includes("/") && "base_instructions" in m,
88
116
  ) ?? null;
@@ -179,14 +207,121 @@ function applyJawcodeCatalogMetadata(entry: RawEntry, slug: string): void {
179
207
  }
180
208
  }
181
209
 
182
- function loadCatalogForSync(path: string): { models?: RawEntry[]; [k: string]: unknown } | null {
210
+ type ExecFile = (
211
+ file: string,
212
+ args: string[],
213
+ options: {
214
+ encoding: "utf8";
215
+ stdio: ["ignore", "pipe", "ignore"];
216
+ timeout: number;
217
+ windowsHide: boolean;
218
+ },
219
+ ) => string;
220
+
221
+ interface BundledCatalogDeps {
222
+ commandCandidates?: () => string[];
223
+ execFileSync?: ExecFile;
224
+ }
225
+
226
+ function unique(values: string[]): string[] {
227
+ return [...new Set(values.filter(Boolean))];
228
+ }
229
+
230
+ function codexCommandCandidates(): string[] {
231
+ const envPath = process.env.CODEX_CLI_PATH?.trim();
232
+ const candidates = envPath ? [envPath] : [];
233
+ candidates.push(...codexShimCommandCandidates());
234
+ if (process.platform === "win32") {
235
+ for (const dir of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) {
236
+ candidates.push(join(dir, "codex.exe"), join(dir, "codex.cmd"));
237
+ }
238
+ }
239
+ candidates.push("codex");
240
+ return unique(candidates);
241
+ }
242
+
243
+ function codexShimCommandCandidates(): string[] {
244
+ try {
245
+ const state = JSON.parse(readFileSync(join(getConfigDir(), "codex-shim.json"), "utf8")) as {
246
+ wrapperPath?: unknown;
247
+ originalPath?: unknown;
248
+ backupPath?: unknown;
249
+ wrappers?: Array<{ wrapperPath?: unknown; originalPath?: unknown; backupPath?: unknown }>;
250
+ };
251
+ const files = Array.isArray(state.wrappers) && state.wrappers.length > 0 ? state.wrappers : [state];
252
+ const out: string[] = [];
253
+ for (const file of files) {
254
+ for (const value of [file.backupPath, file.originalPath, file.wrapperPath]) {
255
+ if (typeof value !== "string" || value.length === 0) continue;
256
+ if (process.platform === "win32" && value.toLowerCase().endsWith(".ps1")) continue;
257
+ out.push(value);
258
+ }
259
+ }
260
+ return out;
261
+ } catch {
262
+ return [];
263
+ }
264
+ }
265
+
266
+ function runCodexDebugModels(command: string, execFile: ExecFile): string {
267
+ const args = ["debug", "models", "--bundled"];
268
+ return execFile(command, args, {
269
+ encoding: "utf8" as const,
270
+ stdio: ["ignore", "pipe", "ignore"] as ["ignore", "pipe", "ignore"],
271
+ timeout: 10_000,
272
+ windowsHide: true,
273
+ });
274
+ }
275
+
276
+ export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): RawCatalog | null {
277
+ const useCache = !deps.commandCandidates && !deps.execFileSync;
278
+ if (useCache && bundledCatalogCache && bundledCatalogCache.expiresAt > Date.now()) {
279
+ return bundledCatalogCache.value;
280
+ }
281
+ const candidates = deps.commandCandidates?.() ?? codexCommandCandidates();
282
+ const execFile = deps.execFileSync ?? (execFileSync as unknown as ExecFile);
283
+ for (const command of candidates) {
284
+ try {
285
+ const catalog = parseCatalogJson(runCodexDebugModels(command, execFile));
286
+ if (catalog && findNativeTemplate(catalog)) {
287
+ if (useCache) bundledCatalogCache = { expiresAt: Date.now() + BUNDLED_CATALOG_CACHE_MS, value: catalog };
288
+ return catalog;
289
+ }
290
+ } catch { /* try next candidate */ }
291
+ }
292
+ if (useCache) bundledCatalogCache = { expiresAt: Date.now() + BUNDLED_CATALOG_CACHE_MS, value: null };
293
+ return null;
294
+ }
295
+
296
+ export function materializeBundledCodexCatalog(path: string, deps: BundledCatalogDeps = {}): RawCatalog | null {
297
+ const catalog = loadBundledCodexCatalog(deps);
298
+ if (!catalog) return null;
299
+ try {
300
+ mkdirSync(dirname(path), { recursive: true });
301
+ atomicWriteFile(path, JSON.stringify(catalog, null, 2) + "\n");
302
+ } catch {
303
+ return null;
304
+ }
305
+ return catalog;
306
+ }
307
+
308
+ function loadCatalogForSync(path: string): RawCatalog | null {
309
+ const bundled = isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null;
310
+ if (bundled) return bundled;
183
311
  const catalog = readCatalog(path);
184
312
  if (catalog && findNativeTemplate(catalog)) return catalog;
185
- return readCatalog(CATALOG_BACKUP_PATH) ?? readCatalog(CODEX_MODELS_CACHE_PATH) ?? catalog;
313
+ return readCatalog(catalogBackupPathFor(path))
314
+ ?? readCatalog(legacyCatalogBackupPath())
315
+ ?? readCatalog(CODEX_MODELS_CACHE_PATH)
316
+ ?? materializeBundledCodexCatalog(path)
317
+ ?? catalog;
186
318
  }
187
319
 
188
- function readCurrentCatalogOrCache(): { models?: RawEntry[]; [k: string]: unknown } | null {
189
- return readCatalog(readCodexCatalogPath()) ?? readCatalog(CODEX_MODELS_CACHE_PATH);
320
+ function readCurrentCatalogOrCache(): RawCatalog | null {
321
+ const path = readCodexCatalogPath();
322
+ return (isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null)
323
+ ?? readCatalog(path)
324
+ ?? readCatalog(CODEX_MODELS_CACHE_PATH);
190
325
  }
191
326
 
192
327
  /**
@@ -195,9 +330,12 @@ function readCurrentCatalogOrCache(): { models?: RawEntry[]; [k: string]: unknow
195
330
  * Returns a deep copy, or null if no catalog/native entry exists.
196
331
  */
197
332
  export function loadCatalogTemplate(): RawEntry | null {
198
- const native = findNativeTemplate(readCatalog(readCodexCatalogPath()))
199
- ?? findNativeTemplate(readCatalog(CATALOG_BACKUP_PATH))
200
- ?? findNativeTemplate(readCatalog(CODEX_MODELS_CACHE_PATH));
333
+ const catalogPath = readCodexCatalogPath();
334
+ const native = findNativeTemplate(readCatalog(catalogPath))
335
+ ?? findNativeTemplate(readCatalog(catalogBackupPathFor(catalogPath)))
336
+ ?? findNativeTemplate(readCatalog(legacyCatalogBackupPath()))
337
+ ?? findNativeTemplate(readCatalog(CODEX_MODELS_CACHE_PATH))
338
+ ?? findNativeTemplate(loadBundledCodexCatalog());
201
339
  return native ? JSON.parse(JSON.stringify(native)) : null;
202
340
  }
203
341
 
@@ -324,8 +462,35 @@ export function listCatalogNativeSlugs(): string[] {
324
462
  * a featured native gets its low rank, and un-featuring restores its original catalog priority
325
463
  * (rather than the modified value left in the live catalog by a previous sync).
326
464
  */
327
- function readNativeBaseline(): Map<string, number> {
328
- const backup = readCatalog(CATALOG_BACKUP_PATH);
465
+ function readCatalogBackup(catalogPath: string): RawCatalog | null {
466
+ return readCatalog(catalogBackupPathFor(catalogPath)) ?? readCatalog(legacyCatalogBackupPath());
467
+ }
468
+
469
+ function catalogHasRoutedEntries(catalog: RawCatalog | null): boolean {
470
+ return (catalog?.models ?? []).some(m => typeof m.slug === "string" && m.slug.includes("/"));
471
+ }
472
+
473
+ function writePristineCatalogBackup(backupPath: string, catalogPath: string, catalog: RawCatalog): void {
474
+ if (existsSync(backupPath)) return;
475
+ const onDisk = readCatalog(catalogPath);
476
+ if (onDisk && !catalogHasRoutedEntries(onDisk)) {
477
+ copyFileSync(catalogPath, backupPath);
478
+ return;
479
+ }
480
+ if (!catalogHasRoutedEntries(catalog)) {
481
+ atomicWriteFile(backupPath, JSON.stringify(catalog, null, 2) + "\n");
482
+ }
483
+ }
484
+
485
+ function ensureCatalogBackup(catalogPath: string, catalog: RawCatalog): void {
486
+ const dir = getConfigDir();
487
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
488
+ writePristineCatalogBackup(catalogBackupPathFor(catalogPath), catalogPath, catalog);
489
+ writePristineCatalogBackup(legacyCatalogBackupPath(), catalogPath, catalog);
490
+ }
491
+
492
+ function readNativeBaseline(catalogPath: string): Map<string, number> {
493
+ const backup = readCatalogBackup(catalogPath);
329
494
  const out = new Map<string, number>();
330
495
  for (const e of backup?.models ?? []) {
331
496
  if (typeof e.slug === "string" && !e.slug.includes("/") && typeof e.priority === "number") {
@@ -531,6 +696,11 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
531
696
 
532
697
  const goModels = await gatherRoutedModels(config);
533
698
  if (goModels.length === 0) return { added: 0, path: catalogPath };
699
+ try {
700
+ // Once-only: preserve the PRISTINE pre-opencodex catalog as the native-priority baseline
701
+ // (later syncs would otherwise overwrite it with featured-modified priorities).
702
+ ensureCatalogBackup(catalogPath, catalog);
703
+ } catch { /* backup best-effort */ }
534
704
 
535
705
  // Hide disabled models from Codex, then feature the chosen subagent models (native OR routed)
536
706
  // by giving them the lowest priority — see buildCatalogEntries for why priority, not array order.
@@ -543,7 +713,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
543
713
  // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields, but drop bare
544
714
  // duplicates of routed models (replaced by namespaced entries) + any prior "/" entries. Re-derive
545
715
  // each native's priority from the pristine baseline so featuring a native is reversible.
546
- const baseline = readNativeBaseline();
716
+ const baseline = readNativeBaseline(catalogPath);
547
717
  const goIds = new Set(enabledGo.map(m => m.id));
548
718
  const native = (catalog.models ?? [])
549
719
  .filter(m => typeof m.slug === "string" && !(m.slug as string).includes("/") && !goIds.has(m.slug as string))
@@ -563,12 +733,6 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
563
733
  return e;
564
734
  });
565
735
 
566
- try {
567
- if (!existsSync(OCX_DIR)) mkdirSync(OCX_DIR, { recursive: true });
568
- // Once-only: preserve the PRISTINE pre-opencodex catalog as the native-priority baseline
569
- // (later syncs would otherwise overwrite it with featured-modified priorities).
570
- if (!existsSync(CATALOG_BACKUP_PATH)) copyFileSync(catalogPath, CATALOG_BACKUP_PATH);
571
- } catch { /* backup best-effort */ }
572
736
  atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
573
737
  return { added: goEntries.length, path: catalogPath };
574
738
  }
@@ -582,6 +746,12 @@ export function restoreCodexCatalog(): { removed: number; kept: number; path: st
582
746
  const catalogPath = readCodexCatalogPath();
583
747
  const catalog = readCatalog(catalogPath);
584
748
  if (!catalog || !Array.isArray(catalog.models)) return { removed: 0, kept: 0, path: catalogPath };
749
+ const exactBackup = readCatalog(catalogBackupPathFor(catalogPath));
750
+ if (exactBackup && Array.isArray(exactBackup.models)) {
751
+ const removed = Math.max(0, catalog.models.length - exactBackup.models.length);
752
+ atomicWriteFile(catalogPath, JSON.stringify(exactBackup, null, 2) + "\n");
753
+ return { removed, kept: exactBackup.models.length, path: catalogPath };
754
+ }
585
755
  const before = catalog.models.length;
586
756
  const native = catalog.models.filter(m => !(typeof m.slug === "string" && m.slug.includes("/")));
587
757
  const removed = before - native.length;
@@ -106,7 +106,7 @@ function updateSessionMeta(path: string, patch: { provider?: string; source?: st
106
106
  }
107
107
  if (!changed) return false;
108
108
 
109
- writeFileSync(path, `${JSON.stringify(record)}${rest}`, "utf8");
109
+ atomicWriteFile(path, `${JSON.stringify(record)}${rest}`);
110
110
  utimesSync(path, stat.atime, stat.mtime);
111
111
  return true;
112
112
  }
@@ -162,7 +162,31 @@ function ejectRemainingOpencodexHistory(db: Database): { rows: number; files: nu
162
162
  return { rows: rows.length, files };
163
163
  }
164
164
 
165
+ function isRecoverableHistoryError(error: unknown): boolean {
166
+ const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
167
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
168
+ return code === "SQLITE_BUSY"
169
+ || code === "SQLITE_LOCKED"
170
+ || code === "EBUSY"
171
+ || code === "EPERM"
172
+ || code === "EACCES"
173
+ || message.includes("database is locked")
174
+ || message.includes("database is busy")
175
+ || message.includes("resource busy")
176
+ || message.includes("operation not permitted")
177
+ || message.includes("permission denied");
178
+ }
179
+
165
180
  export function syncCodexHistoryProvider(provider: CodexHistoryProvider, stateDbPath = STATE_DB_PATH, backupPath = HISTORY_BACKUP_PATH): CodexHistorySyncResult {
181
+ try {
182
+ return syncCodexHistoryProviderUnsafe(provider, stateDbPath, backupPath);
183
+ } catch (error) {
184
+ if (isRecoverableHistoryError(error)) return { rows: 0, files: 0 };
185
+ throw error;
186
+ }
187
+ }
188
+
189
+ function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbPath: string, backupPath: string): CodexHistorySyncResult {
166
190
  if (!existsSync(stateDbPath)) return { rows: 0, files: 0 };
167
191
  if (provider === "openai") return restoreCodexHistoryProvider(stateDbPath, backupPath);
168
192
 
@@ -283,11 +307,16 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C
283
307
  }
284
308
 
285
309
  export function restoreLegacyOpenaiHistory(stateDbPath = STATE_DB_PATH): { rows: number; files: number } {
286
- if (!existsSync(stateDbPath)) return { rows: 0, files: 0 };
287
- const db = new Database(stateDbPath);
288
310
  try {
289
- return ejectRemainingOpencodexHistory(db);
290
- } finally {
291
- db.close();
311
+ if (!existsSync(stateDbPath)) return { rows: 0, files: 0 };
312
+ const db = new Database(stateDbPath);
313
+ try {
314
+ return ejectRemainingOpencodexHistory(db);
315
+ } finally {
316
+ db.close();
317
+ }
318
+ } catch (error) {
319
+ if (isRecoverableHistoryError(error)) return { rows: 0, files: 0 };
320
+ throw error;
292
321
  }
293
322
  }