@bitkyc08/opencodex 2.11.0 → 2.11.1

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 (92) hide show
  1. package/gui/dist/assets/{index-Bk-PN-70.css → index-BA1dgo4q.css} +1 -1
  2. package/gui/dist/assets/index-DDZpgzKk.js +70 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +6 -4
  5. package/src/adapters/anthropic.ts +10 -2
  6. package/src/adapters/cursor/native-exec-common.ts +6 -2
  7. package/src/adapters/google-antigravity-replay.ts +3 -2
  8. package/src/adapters/google-antigravity-wire.ts +38 -6
  9. package/src/adapters/google.ts +57 -10
  10. package/src/adapters/openai-chat.ts +19 -2
  11. package/src/claude/model-info.ts +1 -1
  12. package/src/cli/account-auth.ts +3 -1
  13. package/src/cli/account-catalog-refresh.ts +14 -0
  14. package/src/cli/account-extended.ts +15 -1
  15. package/src/cli/codex-shim-readiness.ts +69 -0
  16. package/src/cli/combo.ts +5 -0
  17. package/src/cli/config-command.ts +33 -4
  18. package/src/cli/doctor.ts +21 -0
  19. package/src/cli/export-command.ts +6 -6
  20. package/src/cli/help.ts +6 -6
  21. package/src/cli/index.ts +22 -5
  22. package/src/cli/models-runtime.ts +13 -1
  23. package/src/cli/provider.ts +7 -0
  24. package/src/clients/config-export.ts +67 -5
  25. package/src/codex/account-lifecycle.ts +99 -10
  26. package/src/codex/auth-api.ts +222 -34
  27. package/src/codex/catalog/account-models.ts +9 -4
  28. package/src/codex/catalog/aggregation.ts +41 -8
  29. package/src/codex/catalog/bundled.ts +54 -22
  30. package/src/codex/catalog/effort.ts +47 -20
  31. package/src/codex/catalog/kinds.ts +2 -0
  32. package/src/codex/catalog/metadata.ts +70 -18
  33. package/src/codex/catalog/native-models.ts +7 -0
  34. package/src/codex/catalog/parsing.ts +29 -11
  35. package/src/codex/catalog/provider-fetch.ts +335 -50
  36. package/src/codex/catalog/sync.ts +588 -126
  37. package/src/codex/catalog-refresh-status.ts +87 -0
  38. package/src/codex/catalog-write-serialization.ts +2 -1
  39. package/src/codex/catalog.ts +4 -3
  40. package/src/codex/convergence-types.ts +1 -1
  41. package/src/codex/convergence.ts +190 -52
  42. package/src/codex/custom-model-catalog-migration.ts +176 -0
  43. package/src/codex/features.ts +79 -1
  44. package/src/codex/history-job.ts +81 -1
  45. package/src/codex/history-lock.ts +2 -1
  46. package/src/codex/history-provider.ts +4 -3
  47. package/src/codex/inject.ts +56 -12
  48. package/src/codex/model-cache.ts +50 -10
  49. package/src/codex/transition-state.ts +10 -2
  50. package/src/codex/user-identity.ts +110 -2
  51. package/src/combos/index.ts +3 -0
  52. package/src/combos/types.ts +75 -9
  53. package/src/config.ts +26 -12
  54. package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +12 -10
  55. package/src/integrations/registry.ts +7 -0
  56. package/src/integrations/writer.ts +1 -1
  57. package/src/lib/pinned-http.ts +40 -9
  58. package/src/lib/process-control.ts +4 -1
  59. package/src/lib/provider-outbound.ts +42 -9
  60. package/src/oauth/cursor.ts +25 -5
  61. package/src/oauth/index.ts +59 -45
  62. package/src/providers/antigravity-models.ts +115 -3
  63. package/src/providers/context-cap.ts +13 -5
  64. package/src/providers/model-discovery-limits.ts +16 -0
  65. package/src/providers/model-discovery.ts +14 -15
  66. package/src/providers/provider-id-rewrite.ts +29 -0
  67. package/src/providers/quota.ts +93 -19
  68. package/src/providers/registry.ts +16 -5
  69. package/src/providers/slug-codec.ts +13 -6
  70. package/src/reasoning-effort.ts +22 -0
  71. package/src/router.ts +0 -20
  72. package/src/routing/history/indexer.ts +54 -39
  73. package/src/routing/quota.ts +77 -56
  74. package/src/server/index.ts +32 -7
  75. package/src/server/management/combo-routes.ts +43 -19
  76. package/src/server/management/config-routes.ts +115 -20
  77. package/src/server/management/model-routes.ts +10 -7
  78. package/src/server/management/model-rows.ts +6 -2
  79. package/src/server/management/oauth-account-routes.ts +12 -0
  80. package/src/server/management/provider-routes.ts +114 -40
  81. package/src/server/management/routing-profile-routes.ts +0 -22
  82. package/src/server/management-api.ts +7 -25
  83. package/src/server/responses/core.ts +3 -3
  84. package/src/server/responses/policy-fallback.ts +152 -0
  85. package/src/server/responses.ts +3 -2
  86. package/src/types.ts +15 -2
  87. package/src/usage/cost.ts +0 -0
  88. package/src/vision/describe.ts +3 -1
  89. package/src/vision/index.ts +17 -6
  90. package/src/vision/reasoning.ts +55 -0
  91. package/src/web-search/parse.ts +15 -3
  92. package/gui/dist/assets/index-BynIEIV-.js +0 -70
@@ -83,7 +83,23 @@ function readConfigText(configPath?: string): string | null {
83
83
  }
84
84
  }
85
85
 
86
- /** Body lines of a TOML table `[header]` up to (not including) the next table header. */
86
+ /**
87
+ * Body lines of a TOML table `[header]` up to (not including) the next table header.
88
+ *
89
+ * The implementation body is deliberately unchanged by #1295 — only this comment
90
+ * is new. The scanner is line-based and string-unaware, so it ends the table at
91
+ * the first line matching `/^\s*\[/` even inside a multi-line value. Twenty call
92
+ * sites in this file consume its output, most of them by matching a regex
93
+ * against the returned text, so widening that text changes what they match. An
94
+ * earlier attempt at #1295 made this scanner string-aware and thereby gave
95
+ * `getAgentsEnabled`, `getAgentsMaxDepth`, and `getMaxConcurrentThreads` three
96
+ * new wrong answers.
97
+ *
98
+ * The readers that matter for #1295 use a real TOML parse instead (see
99
+ * `parsedTomlTable`). This stays as the fallback for documents that do not
100
+ * parse, and as the reader for the remaining call sites until they are migrated
101
+ * the same way.
102
+ */
87
103
  function tomlTableBody(content: string, header: string): string | null {
88
104
  const lines = content.split("\n");
89
105
  const escaped = header.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -109,8 +125,34 @@ function tomlBoolInBody(body: string, key: string): boolean | null {
109
125
  */
110
126
  export function isMultiAgentV2Enabled(configPath?: string): boolean {
111
127
  const content = readConfigText(configPath);
128
+ return multiAgentV2EnabledFromConfigText(content);
129
+ }
130
+
131
+ /** Parse `multi_agent_v2` from caller-owned config.toml text without consulting disk. */
132
+ export function multiAgentV2EnabledFromConfigText(content: string | null): boolean {
112
133
  if (content === null) return false;
113
134
 
135
+ // Prefer a real parse. The hand-written table scanner below cannot distinguish
136
+ // an assignment from prose that looks like one — a `"""` value containing the
137
+ // line `enabled = true` reads as the key itself — and TOML has enough value
138
+ // shapes (multi-line arrays opening on the next line, escapes, comments) that
139
+ // each near-miss costs another special case (#1295).
140
+ //
141
+ // The scanner remains only for a document `Bun.TOML.parse` rejects. That is a
142
+ // statement about Bun's parser, not about Codex's — the two are separate
143
+ // implementations and no compatibility evidence is claimed here, so a document
144
+ // Bun rejects may still be one Codex loads. The fallback is therefore
145
+ // best-effort and inherits the ambiguity above. It exists because reporting a
146
+ // feature as disabled on account of an unreadable file presents a failure as
147
+ // a state.
148
+ const parsed = parsedTomlTable(content, "features");
149
+ if (parsed !== null) {
150
+ const table = plainTomlRecord(parsed.multi_agent_v2);
151
+ if (table !== null) return table.enabled === true;
152
+ if (typeof parsed.multi_agent_v2 === "boolean") return parsed.multi_agent_v2;
153
+ return false;
154
+ }
155
+
114
156
  const table = tomlTableBody(content, "features.multi_agent_v2");
115
157
  if (table !== null) {
116
158
  const enabled = tomlBoolInBody(table, "enabled");
@@ -134,6 +176,30 @@ export function isMultiAgentV2Enabled(configPath?: string): boolean {
134
176
  return false;
135
177
  }
136
178
 
179
+ function plainTomlRecord(value: unknown): Record<string, unknown> | null {
180
+ return value !== null && typeof value === "object" && !Array.isArray(value)
181
+ ? value as Record<string, unknown>
182
+ : null;
183
+ }
184
+
185
+ /**
186
+ * A top-level table from a full TOML parse, or null when the document does not
187
+ * parse. A parsed document with no such table yields `{}` rather than null: that
188
+ * is a real answer ("no keys"), while null means "could not read, fall back".
189
+ */
190
+ function parsedTomlTable(content: string, name: string): Record<string, unknown> | null {
191
+ const toml = (globalThis as { Bun?: { TOML?: { parse(input: string): unknown } } }).Bun?.TOML;
192
+ if (!toml) return null;
193
+ try {
194
+ const root = plainTomlRecord(toml.parse(content));
195
+ if (root === null) return null;
196
+ return plainTomlRecord(root[name]) ?? {};
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+
137
203
  /**
138
204
  * TRUE when the codex `default_mode_request_user_input` feature is enabled in
139
205
  * config.toml — lets a Default-mode session pause and ask the user questions
@@ -145,6 +211,11 @@ export function isMultiAgentV2Enabled(configPath?: string): boolean {
145
211
  export function isDefaultModeRequestUserInputEnabled(configPath?: string): boolean {
146
212
  const content = readConfigText(configPath);
147
213
  if (content === null) return false;
214
+ // Same reason as the v2 reader: a `"""` value whose prose contains
215
+ // `default_mode_request_user_input = true` is not an assignment, and a raw
216
+ // regex over the table body cannot tell the difference (#1295).
217
+ const parsed = parsedTomlTable(content, "features");
218
+ if (parsed !== null) return parsed[DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY] === true;
148
219
  const features = tomlTableBody(content, "features");
149
220
  if (features === null) return false;
150
221
  return tomlBoolInBody(features, DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY) === true;
@@ -159,6 +230,8 @@ export function isDefaultModeRequestUserInputEnabled(configPath?: string): boole
159
230
  export function hasAgentsMaxThreads(configPath?: string): boolean {
160
231
  const content = readConfigText(configPath);
161
232
  if (content === null) return false;
233
+ const parsed = parsedTomlTable(content, "agents");
234
+ if (parsed !== null) return Object.hasOwn(parsed, "max_threads");
162
235
  const agents = tomlTableBody(content, "agents");
163
236
  if (agents === null) return false;
164
237
  return /^\s*max_threads\s*=/m.test(agents);
@@ -168,6 +241,11 @@ export function hasAgentsMaxThreads(configPath?: string): boolean {
168
241
  export function getAgentsMaxThreads(configPath?: string): number | null {
169
242
  const content = readConfigText(configPath);
170
243
  if (content === null) return null;
244
+ const parsed = parsedTomlTable(content, "agents");
245
+ if (parsed !== null) {
246
+ const value = parsed.max_threads;
247
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 ? value : null;
248
+ }
171
249
  const agents = tomlTableBody(content, "agents");
172
250
  if (agents === null) return null;
173
251
  const m = agents.match(/^\s*max_threads\s*=\s*(\d+)\s*(?:#.*)?$/m);
@@ -17,6 +17,7 @@
17
17
  * Design record: devlog/_fin/260804_codex_write_substrate/020_history_isolation.md.
18
18
  */
19
19
  import { randomUUID } from "node:crypto";
20
+ import { homedir } from "node:os";
20
21
  import { join } from "node:path";
21
22
 
22
23
  import type {
@@ -135,13 +136,92 @@ export function deriveCodexHistoryOperation(intent: {
135
136
  return intent.legacyMode ? "apply-opencodex" : "migrate-openai";
136
137
  }
137
138
 
139
+ /**
140
+ * The honest failure clause for one history job outcome.
141
+ *
142
+ * The caller adds its own framing ("sync SKIPPED", "could NOT be restored").
143
+ * The point of the surface argument is that a genuine lock keeps today's
144
+ * actionable wording, while every other reason stops blaming the Codex app:
145
+ * an unsafe-path refusal, an unavailable coordinator database, a permission
146
+ * denial, or a dead worker is a different problem with a different remedy.
147
+ */
148
+ export function describeHistoryJobFailure(
149
+ outcome: CodexHistoryJobOutcome,
150
+ surface: "apply" | "restore" | "recover-legacy",
151
+ legacyMode = false,
152
+ ): string {
153
+ // Callers only invoke this after observing a failure flag, but that flag is
154
+ // derived from "not converged", which also covers "skipped". Naming those
155
+ // two kinds keeps a widened or miscast call site from printing `undefined`.
156
+ if (outcome.kind === "skipped") {
157
+ return "the history operation was skipped; no failure was recorded.";
158
+ }
159
+ if (outcome.kind === "converged") {
160
+ return "the history job reported no failure; run 'ocx doctor' if this is unexpected.";
161
+ }
162
+ // A busy database reaches here two ways: the lock itself was contended
163
+ // (blocked/busy), or the lock was acquired and the worker then found SQLite
164
+ // busy (failed with historyFailureReason "busy"). Both are the same user
165
+ // situation and deserve the same surface-specific guidance.
166
+ const busyText = surface === "apply"
167
+ ? legacyMode
168
+ ? "the history DB is locked (Codex app/IDE open?). Close it and rerun 'ocx start'."
169
+ : "the history DB is locked (Codex app/IDE open?). It is retried automatically (while the proxy runs and on every 'ocx start'); to force it now, close the Codex app and run 'ocx sync'."
170
+ : surface === "recover-legacy"
171
+ ? "the Codex history DB is locked (Codex app/IDE open?). Close it and rerun this command."
172
+ : "the Codex app appears to be holding the history database. Close Codex and run `ocx restore` again.";
173
+ if (outcome.kind === "blocked") {
174
+ if (outcome.reason === "busy") return busyText;
175
+ switch (outcome.reason) {
176
+ case "unsafe-path":
177
+ return "opencodex refused its history lock path (unsafe coordinator namespace); this is not a Codex app lock. Run 'ocx doctor' and check the opencodex runtime directory.";
178
+ case "database":
179
+ return "the history coordinator database is unavailable; this is not a Codex app lock. Run 'ocx doctor'.";
180
+ case "desired_disabled":
181
+ return "Codex integration is disabled, so the history operation was skipped.";
182
+ case "desired_enabled":
183
+ return "Codex integration is enabled, so the history operation was skipped.";
184
+ }
185
+ }
186
+ if (outcome.historyFailureReason === "busy") return busyText;
187
+ if (outcome.historyFailureReason === "permission") {
188
+ return "permission was denied while writing Codex history; this is not a Codex app lock. Run 'ocx doctor'.";
189
+ }
190
+ switch (outcome.reason) {
191
+ case "worker-error":
192
+ return `the history worker failed (${outcome.message}). Run 'ocx doctor'.`;
193
+ case "worker-died":
194
+ return "the history worker exited unexpectedly; this is not a Codex app lock. Run 'ocx doctor'.";
195
+ case "timeout":
196
+ return "the history worker timed out; this is not a Codex app lock. Run 'ocx doctor'.";
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Worker exceptions travel into user-facing CLI output, and a raw filesystem
202
+ * error carries absolute paths — on every platform that includes the account
203
+ * name (`/Users/x`, `/home/x`, `C:\Users\x`). Folding the home directory to
204
+ * `~` keeps the diagnostic value and drops the identifier.
205
+ */
206
+ function redactWorkerMessage(message: string): string {
207
+ const home = homedir();
208
+ if (home.length <= 1) return message;
209
+ // Windows spellings vary in case and separator; an exact match would leave
210
+ // the account name in the message.
211
+ if (process.platform === "win32") {
212
+ const escaped = home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\\/g, "[\\\\/]");
213
+ return message.replace(new RegExp(escaped, "gi"), "~");
214
+ }
215
+ return message.split(home).join("~");
216
+ }
217
+
138
218
  function classifyWorkerResult(result: HistoryWorkerResult): CodexHistoryJobOutcome {
139
219
  if (result.type === "blocked") return { kind: "blocked", reason: result.reason };
140
220
  if (result.type === "error") {
141
221
  return {
142
222
  kind: "failed",
143
223
  reason: "worker-error",
144
- message: result.message,
224
+ message: redactWorkerMessage(result.message),
145
225
  ...(result.reason ? { historyFailureReason: result.reason } : {}),
146
226
  };
147
227
  }
@@ -37,6 +37,7 @@ import {
37
37
  CodexUserIdentityRefusal,
38
38
  resolveCodexHistorySerializationDatabasePath,
39
39
  resolveEffectiveUserIdentity,
40
+ samePathIdentity,
40
41
  } from "./user-identity";
41
42
 
42
43
  /**
@@ -183,7 +184,7 @@ export function withHistoryWriteSerialization<T>(
183
184
  }
184
185
  const opened = lstatSync(databasePath);
185
186
  if (opened.isSymbolicLink() || !opened.isFile()
186
- || realpathSync.native(databasePath) !== databasePath) {
187
+ || !samePathIdentity(realpathSync.native(databasePath), databasePath)) {
187
188
  return { kind: "unavailable", reason: "unsafe-path" };
188
189
  }
189
190
 
@@ -722,16 +722,17 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C
722
722
  }
723
723
  }
724
724
 
725
- export function restoreLegacyOpenaiHistory(stateDbPath = STATE_DB_PATH): { rows: number; files: number; failed?: true } {
725
+ export function restoreLegacyOpenaiHistory(stateDbPath = STATE_DB_PATH): CodexHistorySyncResult {
726
726
  if (!existsSync(stateDbPath)) return { rows: 0, files: 0 };
727
- return withHistoryRetry(() => {
727
+ const retried = withHistoryRetryResult(() => {
728
728
  const db = openStateDb(stateDbPath);
729
729
  try {
730
730
  return ejectRemainingOpencodexHistory(db);
731
731
  } finally {
732
732
  db.close();
733
733
  }
734
- }) ?? { rows: 0, files: 0, failed: true };
734
+ });
735
+ return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason };
735
736
  }
736
737
 
737
738
  /**
@@ -38,9 +38,11 @@ import { withCatalogWriteSerialization } from "./catalog-write-serialization";
38
38
  import { restoreCodexCatalogWithPermit } from "./catalog/sync";
39
39
  import { syncCodexHistoryProvider, type CodexHistoryFailureReason } from "./history-provider";
40
40
  import {
41
+ describeHistoryJobFailure,
41
42
  deriveCodexHistoryOperation,
42
43
  resolveCodexHistoryJobTarget,
43
44
  runCodexHistoryJob,
45
+ type CodexHistoryJobOutcome,
44
46
  } from "./history-job";
45
47
  import {
46
48
  OCX_SECTION_MARKER,
@@ -1034,11 +1036,7 @@ export async function injectCodexConfig(
1034
1036
  config?.syncResumeHistory === false
1035
1037
  ? ` Codex resume history: left unchanged (syncResumeHistory=false).\n`
1036
1038
  : history.failed
1037
- ? legacyMode
1038
- ? ` ⚠️ Codex resume history sync SKIPPED: the history DB is locked (Codex app/IDE open?). Close it and rerun 'ocx start'.\n`
1039
- : // Honest in every caller context: the daemon retries in the background while it runs,
1040
- // and this inject path re-runs the migration on every future start/sync anyway.
1041
- ` ⚠️ Codex resume history migration deferred: the history DB is locked (Codex app/IDE open?). It is retried automatically (while the proxy runs and on every 'ocx start'); to force it now, close the Codex app and run 'ocx sync'.\n`
1039
+ ? formatApplyHistoryFailure(historyOutcome, legacyMode)
1042
1040
  : legacyMode
1043
1041
  ? ` Codex resume history: ${history.rows} thread(s) made visible for opencodex; originals backed up for restore.\n`
1044
1042
  : migratedRows > 0
@@ -1250,7 +1248,7 @@ export interface CodexNativeRestoreResult {
1250
1248
  };
1251
1249
  }
1252
1250
 
1253
- function failedHistoryRestore(reason?: CodexHistoryFailureReason): CodexRestoreHistoryResult {
1251
+ function failedHistoryRestore(reason?: CodexHistoryFailureReason, detail?: string): CodexRestoreHistoryResult {
1254
1252
  return {
1255
1253
  state: "failed",
1256
1254
  changed: false,
@@ -1260,10 +1258,36 @@ function failedHistoryRestore(reason?: CodexHistoryFailureReason): CodexRestoreH
1260
1258
  ejectedRows: 0,
1261
1259
  message: reason === "permission"
1262
1260
  ? "Codex resume history could NOT be restored because permission was denied."
1263
- : "Codex resume history could NOT be restored — the Codex app appears to be holding the history database.",
1261
+ : reason === "busy"
1262
+ ? "Codex resume history could NOT be restored — the Codex app appears to be holding the history database."
1263
+ : detail
1264
+ ? `Codex resume history could NOT be restored: ${detail}`
1265
+ : "Codex resume history could NOT be restored; the reason was not recorded. Run 'ocx doctor'.",
1264
1266
  };
1265
1267
  }
1266
1268
 
1269
+ /**
1270
+ * Restore failure wording for a Worker outcome.
1271
+ *
1272
+ * Only a genuine busy result blames the Codex app. An unsafe-path refusal, an
1273
+ * unavailable coordinator database, a permission denial, or a dead/timed-out
1274
+ * worker is a different problem; the old collapse made every one of those read
1275
+ * as "the Codex app is holding the database" (issue #1191). `busy` and
1276
+ * `permission` keep the restore-specific sentence built by
1277
+ * `failedHistoryRestore`; every other reason reuses the single formatter so
1278
+ * the two modules cannot drift apart.
1279
+ */
1280
+ export function failedHistoryRestoreFromOutcome(
1281
+ outcome: Extract<CodexHistoryJobOutcome, { kind: "blocked" | "failed" }>,
1282
+ ): CodexRestoreHistoryResult {
1283
+ if (outcome.kind === "blocked" && outcome.reason === "busy") return failedHistoryRestore("busy");
1284
+ if (outcome.kind === "failed" && outcome.historyFailureReason === "busy") return failedHistoryRestore("busy");
1285
+ if (outcome.kind === "failed" && outcome.historyFailureReason === "permission") {
1286
+ return failedHistoryRestore("permission");
1287
+ }
1288
+ return failedHistoryRestore(undefined, describeHistoryJobFailure(outcome, "restore"));
1289
+ }
1290
+
1267
1291
  function externalProviderRestoreResult(activeProvider: string): CodexNativeRestoreResult {
1268
1292
  const message = `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.`;
1269
1293
  return {
@@ -1499,11 +1523,9 @@ export async function restoreNativeCodexAsync(
1499
1523
  ? "Codex integration was disabled; history restoration was skipped."
1500
1524
  : "Codex integration was enabled; history restoration was skipped.",
1501
1525
  }
1502
- : outcome.kind === "blocked" && outcome.reason === "busy"
1503
- ? failedHistoryRestore("busy")
1504
- : outcome.kind === "failed"
1505
- ? failedHistoryRestore(outcome.historyFailureReason)
1506
- : failedHistoryRestore();
1526
+ : outcome.kind === "blocked" || outcome.kind === "failed"
1527
+ ? failedHistoryRestoreFromOutcome(outcome)
1528
+ : failedHistoryRestore();
1507
1529
  const base = catalog.removed > 0
1508
1530
  ? `${config.message} Catalog restored to ${catalog.kept} native model(s) (dropped ${catalog.removed} proxy-routed).`
1509
1531
  : config.message;
@@ -1572,3 +1594,25 @@ export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateD
1572
1594
  export function getCodexConfigPath(): string {
1573
1595
  return CODEX_CONFIG_PATH;
1574
1596
  }
1597
+
1598
+ /**
1599
+ * Frame one failed apply history job honestly.
1600
+ *
1601
+ * A genuine lock keeps the established deferred/SKIPPED wording; any other
1602
+ * reason names itself instead of blaming the Codex app/IDE.
1603
+ */
1604
+ export function formatApplyHistoryFailure(outcome: CodexHistoryJobOutcome, legacyMode: boolean): string {
1605
+ // A busy database is a deferral no matter which half observed it: the lock
1606
+ // contended (blocked/busy), or the worker acquired the lock and then found
1607
+ // SQLite busy (failed with a busy history reason). Only those keep the
1608
+ // deferred headline; every other failure is a real "NOT changed".
1609
+ const busy =
1610
+ (outcome.kind === "blocked" && outcome.reason === "busy") ||
1611
+ (outcome.kind === "failed" && outcome.historyFailureReason === "busy");
1612
+ const headline = legacyMode
1613
+ ? "Codex resume history sync SKIPPED"
1614
+ : busy
1615
+ ? "Codex resume history migration deferred"
1616
+ : "Codex resume history NOT changed";
1617
+ return ` ⚠️ ${headline}: ${describeHistoryJobFailure(outcome, "apply", legacyMode)}\n`;
1618
+ }
@@ -42,7 +42,12 @@ export type ProviderModelDiscoveryFailure = ProviderModelDiscoveryStatus extends
42
42
  : never
43
43
  : never;
44
44
 
45
+ /** Whether clearing cache rows also revokes in-flight discovery authority. */
46
+ export type ModelCacheClearReason = "authority" | "eviction";
47
+
45
48
  const cache = new Map<string, CacheEntry>();
49
+ let globalCacheGeneration = 0;
50
+ const providerCacheGenerations = new Map<string, number>();
46
51
  let cacheBytes = 0;
47
52
  let oldestCachedProvider: string | undefined;
48
53
  let oldestCachedAt: number | null = null;
@@ -155,7 +160,29 @@ export function getStaleCached(provider: string): CatalogModel[] | null {
155
160
  return cache.get(provider)?.models ?? null;
156
161
  }
157
162
 
158
- export function setCached(provider: string, models: CatalogModel[], now = Date.now()): void {
163
+ /** Capture the cache generation before an asynchronous provider discovery starts. */
164
+ export function captureModelCacheGeneration(provider: string): string {
165
+ if (!providerCacheGenerations.has(provider)) providerCacheGenerations.set(provider, 0);
166
+ return `${globalCacheGeneration}:${providerCacheGenerations.get(provider)!}`;
167
+ }
168
+
169
+ /** Whether a discovery started under {@link captureModelCacheGeneration} may still write. */
170
+ export function isModelCacheGenerationCurrent(provider: string, generation: string): boolean {
171
+ return generation === captureModelCacheGeneration(provider);
172
+ }
173
+
174
+ /**
175
+ * Store a live result unless the cache was cleared while that asynchronous discovery was running.
176
+ * The optional generation keeps existing direct cache writers unchanged while discovery callers can
177
+ * prevent a previous OAuth account from repopulating the current account's cache.
178
+ */
179
+ export function setCached(
180
+ provider: string,
181
+ models: CatalogModel[],
182
+ now = Date.now(),
183
+ generation?: string,
184
+ ): boolean {
185
+ if (generation !== undefined && !isModelCacheGenerationCurrent(provider, generation)) return false;
159
186
  deleteCachedProvider(provider);
160
187
  const sizeBytes = modelCacheEncoder.encode(provider).byteLength
161
188
  + modelCacheEncoder.encode(JSON.stringify(models)).byteLength;
@@ -166,16 +193,25 @@ export function setCached(provider: string, models: CatalogModel[], now = Date.n
166
193
  oldestCachedAt = now;
167
194
  }
168
195
  enforceAppOwnedMemoryBudget();
196
+ return true;
169
197
  }
170
198
 
171
199
  /** Drop one provider's cache (or all) so the next resolve forces a live re-fetch. */
172
- export function clearModelCache(provider?: string): void {
200
+ export function clearModelCache(
201
+ provider?: string,
202
+ reason: ModelCacheClearReason = "authority",
203
+ ): void {
204
+ const revokesInFlightDiscovery = reason === "authority";
173
205
  if (provider) {
206
+ if (revokesInFlightDiscovery) {
207
+ providerCacheGenerations.set(provider, (providerCacheGenerations.get(provider) ?? 0) + 1);
208
+ }
174
209
  deleteCachedProvider(provider);
175
210
  failureAt.delete(provider);
176
211
  discoveryStatus.delete(provider);
177
212
  liveModelCounts.delete(provider);
178
213
  } else {
214
+ if (revokesInFlightDiscovery) globalCacheGeneration += 1;
179
215
  cache.clear();
180
216
  cacheBytes = 0;
181
217
  oldestCachedProvider = undefined;
@@ -192,16 +228,20 @@ export function reconcileModelCacheProviders(
192
228
  ): number {
193
229
  if (generation <= lastReconciledGeneration) return 0;
194
230
  const removedProviders = new Set<string>();
195
- for (const store of [failureAt, discoveryStatus, liveModelCounts]) {
196
- for (const provider of store.keys()) {
197
- if (validProviders.has(provider)) continue;
198
- store.delete(provider);
199
- removedProviders.add(provider);
200
- }
201
- }
202
- for (const provider of cache.keys()) {
231
+ const trackedProviders = new Set([
232
+ ...providerCacheGenerations.keys(),
233
+ ...failureAt.keys(),
234
+ ...discoveryStatus.keys(),
235
+ ...liveModelCounts.keys(),
236
+ ...cache.keys(),
237
+ ]);
238
+ for (const provider of trackedProviders) {
203
239
  if (validProviders.has(provider)) continue;
240
+ providerCacheGenerations.set(provider, (providerCacheGenerations.get(provider) ?? 0) + 1);
204
241
  deleteCachedProvider(provider);
242
+ failureAt.delete(provider);
243
+ discoveryStatus.delete(provider);
244
+ liveModelCounts.delete(provider);
205
245
  removedProviders.add(provider);
206
246
  }
207
247
  lastReconciledGeneration = generation;
@@ -34,6 +34,7 @@ import {
34
34
  CodexUserIdentityRefusal,
35
35
  resolveCodexCoordinatorDatabasePath,
36
36
  resolveEffectiveUserIdentity,
37
+ samePathIdentity,
37
38
  } from "./user-identity";
38
39
 
39
40
  const COORDINATOR_SCHEMA_VERSION = 1;
@@ -352,6 +353,7 @@ export function openCodexCoordinatorTransaction(finalDatabasePath: string): Code
352
353
  let lastResult: TransitionStateUpdate | undefined;
353
354
  let initialIdentity: string | undefined;
354
355
  let databaseWasAbsent = false;
356
+ let databaseWasEmpty = false;
355
357
 
356
358
  try {
357
359
  try {
@@ -359,6 +361,12 @@ export function openCodexCoordinatorTransaction(finalDatabasePath: string): Code
359
361
  if (before.isSymbolicLink() || !before.isFile()) {
360
362
  throw new CodexUserIdentityRefusal("The coordinator database path is not a real file.");
361
363
  }
364
+ // sqlite3_open_v2(..., SQLITE_OPEN_CREATE) makes the pathname visible
365
+ // before the first schema write. A racing process can therefore observe a
366
+ // real but zero-byte file that carries no coordinator authority yet. Treat
367
+ // that exact state like ENOENT; any non-empty unversioned database remains
368
+ // legacy-ambiguous below.
369
+ databaseWasEmpty = before.size === 0;
362
370
  if (process.platform !== "win32") {
363
371
  const uid = process.getuid?.();
364
372
  // Ownership is decided here; MODE is not.
@@ -415,7 +423,7 @@ export function openCodexCoordinatorTransaction(finalDatabasePath: string): Code
415
423
  initialIdentity = `${opened.dev}:${opened.ino}`;
416
424
  database.exec("PRAGMA busy_timeout = 0; PRAGMA locking_mode = NORMAL; BEGIN IMMEDIATE");
417
425
  transactionOpen = true;
418
- initialize(database, databaseWasAbsent);
426
+ initialize(database, databaseWasAbsent || databaseWasEmpty);
419
427
  } catch (cause) {
420
428
  if (transactionOpen) {
421
429
  try { database?.exec("ROLLBACK"); } catch { /* close releases the transaction */ }
@@ -433,7 +441,7 @@ export function openCodexCoordinatorTransaction(finalDatabasePath: string): Code
433
441
  const entry = lstatSync(finalDatabasePath);
434
442
  if (entry.isSymbolicLink() || !entry.isFile()
435
443
  || `${entry.dev}:${entry.ino}` !== initialIdentity
436
- || realpathSync.native(finalDatabasePath) !== finalDatabasePath) {
444
+ || !samePathIdentity(realpathSync.native(finalDatabasePath), finalDatabasePath)) {
437
445
  throw new CodexUserIdentityRefusal("The coordinator database path was substituted.");
438
446
  }
439
447
  };
@@ -123,7 +123,7 @@ function ensurePrivatePosixDirectory(path: string, uid: number): void {
123
123
  assertPrivatePosixDirectory(path, uid);
124
124
  }
125
125
 
126
- function resolvePosixRuntimeRoot(uid: number): string {
126
+ function resolveTrustedPosixTmp(): string {
127
127
  let realTmp: string;
128
128
  try {
129
129
  realTmp = realpathSync.native(POSIX_TMP_PATH);
@@ -138,12 +138,82 @@ function resolvePosixRuntimeRoot(uid: number): string {
138
138
  if (cause instanceof CodexUserIdentityRefusal) throw cause;
139
139
  refuse("The system temporary directory cannot be trusted.", cause);
140
140
  }
141
+ return realTmp;
142
+ }
141
143
 
144
+ function resolvePosixRuntimeRoot(uid: number): string {
145
+ const realTmp = resolveTrustedPosixTmp();
142
146
  const root = join(realTmp, `opencodex-runtime-v1-${uid}`);
143
147
  ensurePrivatePosixDirectory(root, uid);
144
148
  return root;
145
149
  }
146
150
 
151
+ export type CoordinatorNamespaceProbe =
152
+ | { readonly status: "ok"; readonly root: string }
153
+ | { readonly status: "missing" };
154
+
155
+ /**
156
+ * Read-only namespace probe for diagnostics (`ocx doctor`).
157
+ *
158
+ * Unlike the runtime resolvers, this never creates the root or the lock
159
+ * directories: a doctor run must observe the namespace, not initialize it.
160
+ * A missing namespace is reported as `missing` instead of refused, so a fresh
161
+ * machine does not read as a broken one; an existing but unsafe namespace is
162
+ * refused exactly like the creating path would refuse it.
163
+ */
164
+ export function probeCodexCoordinatorNamespace(identity: UserIdentity): CoordinatorNamespaceProbe {
165
+ if (identity.platform === "posix") {
166
+ const root = join(resolveTrustedPosixTmp(), `opencodex-runtime-v1-${identity.uid}`);
167
+ let entry;
168
+ try {
169
+ entry = lstatSync(root);
170
+ } catch (cause) {
171
+ const code = cause && typeof cause === "object" && "code" in cause
172
+ ? String((cause as { code?: unknown }).code)
173
+ : "";
174
+ if (code === "ENOENT") return { status: "missing" };
175
+ refuse("The Codex coordinator namespace cannot be inspected.", cause);
176
+ }
177
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
178
+ refuse("The Codex coordinator namespace is not a real directory.");
179
+ }
180
+ if (entry.uid !== identity.uid || (entry.mode & 0o777) !== POSIX_PRIVATE_MODE) {
181
+ refuse("The Codex coordinator namespace has unsafe ownership or permissions.");
182
+ }
183
+ return { status: "ok", root };
184
+ }
185
+
186
+ if (!SID_PATTERN.test(identity.sid)) refuse("The coordinator identity contains an invalid SID.");
187
+ const localAppData = powershellValue(
188
+ "[Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)",
189
+ );
190
+ if (!isAbsolute(localAppData)) refuse("Windows LocalAppData resolution returned a relative path.");
191
+ const root = resolve(localAppData, "OpenCodex", "Runtime", "v1", identity.sid.toUpperCase());
192
+ let entry;
193
+ try {
194
+ entry = lstatSync(root);
195
+ } catch (cause) {
196
+ const code = cause && typeof cause === "object" && "code" in cause
197
+ ? String((cause as { code?: unknown }).code)
198
+ : "";
199
+ if (code === "ENOENT") return { status: "missing" };
200
+ refuse("The Windows coordinator namespace cannot be inspected.", cause);
201
+ }
202
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
203
+ refuse("The Windows coordinator namespace is not a real directory.");
204
+ }
205
+ try {
206
+ const real = realpathSync.native(root);
207
+ if (!samePathIdentity(real, root, "win32")) {
208
+ refuse("The Windows coordinator namespace is redirected by a junction or reparse point.");
209
+ }
210
+ return { status: "ok", root: real };
211
+ } catch (cause) {
212
+ if (cause instanceof CodexUserIdentityRefusal) throw cause;
213
+ refuse("The Windows coordinator namespace cannot be resolved.", cause);
214
+ }
215
+ }
216
+
147
217
  function resolveWindowsRuntimeRoot(identity: Extract<UserIdentity, { platform: "win32" }>): string {
148
218
  if (!SID_PATTERN.test(identity.sid)) refuse("The coordinator identity contains an invalid SID.");
149
219
  const localAppData = powershellValue(
@@ -160,7 +230,45 @@ function resolveWindowsRuntimeRoot(identity: Extract<UserIdentity, { platform: "
160
230
  } catch (cause) {
161
231
  refuse("The Windows coordinator namespace cannot be created.", cause);
162
232
  }
163
- return root;
233
+ // Canonicalize before anything is keyed on the path: a junctioned or
234
+ // differently-cased LocalAppData must land on ONE namespace, or two processes
235
+ // that share the real directory would build different lock paths and never
236
+ // contend. The lock modules also compare this path against realpath, so a
237
+ // non-canonical spelling here would read as "unsafe" on every acquisition.
238
+ //
239
+ // Canonicalizing must only ever fold spelling (case, separators). If the
240
+ // realpath lands somewhere else entirely, a component of the namespace is a
241
+ // junction/reparse redirect; accepting the target would convert the old
242
+ // refusal into silently opening the redirected location, so refuse instead.
243
+ try {
244
+ const real = realpathSync.native(root);
245
+ if (!samePathIdentity(real, root, "win32")) {
246
+ refuse("The Windows coordinator namespace is redirected by a junction or reparse point.");
247
+ }
248
+ return real;
249
+ } catch (cause) {
250
+ if (cause instanceof CodexUserIdentityRefusal) throw cause;
251
+ refuse("The Windows coordinator namespace cannot be resolved.", cause);
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Windows path identity is case-insensitive; everywhere else it is exact.
257
+ *
258
+ * The lock modules compare a requested lock path against its own realpath, and
259
+ * byte equality refuses legitimate Windows spellings (drive-letter case, mixed
260
+ * component casing) as "unsafe". This is the same semantics
261
+ * `history-provider.ts` already applies to manifest paths; the platform
262
+ * argument exists so both branches are testable on any host.
263
+ */
264
+ export function samePathIdentity(
265
+ a: string,
266
+ b: string,
267
+ platform: NodeJS.Platform = process.platform,
268
+ ): boolean {
269
+ const left = resolve(a);
270
+ const right = resolve(b);
271
+ return platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
164
272
  }
165
273
 
166
274
  export const resolveCodexCoordinatorDatabasePath: ResolveCodexCoordinatorDatabasePath = (