@bitkyc08/opencodex 2.38.0 → 2.39.0

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 (81) hide show
  1. package/bin/ocx.mjs +58 -6
  2. package/gui/dist/assets/index-D-lchsPw.js +112 -0
  3. package/gui/dist/assets/index-uvENYLin.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/baseten.svg +13 -0
  6. package/gui/dist/provider-icons/bizrouter.svg +41 -0
  7. package/gui/dist/provider-icons/cerebras.svg +26 -0
  8. package/gui/dist/provider-icons/deepinfra.svg +75 -0
  9. package/gui/dist/provider-icons/digitalocean.svg +10 -0
  10. package/gui/dist/provider-icons/featherless.svg +4 -0
  11. package/gui/dist/provider-icons/gajae-code.svg +410 -0
  12. package/gui/dist/provider-icons/hermes-agent.svg +207 -0
  13. package/gui/dist/provider-icons/hyperbolic.svg +18 -0
  14. package/gui/dist/provider-icons/kilo.svg +13 -0
  15. package/gui/dist/provider-icons/litellm.svg +1 -0
  16. package/gui/dist/provider-icons/minimax.svg +1 -0
  17. package/gui/dist/provider-icons/nanogpt.svg +74 -0
  18. package/gui/dist/provider-icons/nebius.svg +1 -0
  19. package/gui/dist/provider-icons/neuralwatt.svg +27 -0
  20. package/gui/dist/provider-icons/nous.svg +149 -0
  21. package/gui/dist/provider-icons/novita.svg +32 -0
  22. package/gui/dist/provider-icons/orcarouter.svg +175 -0
  23. package/gui/dist/provider-icons/parallel.svg +13 -0
  24. package/gui/dist/provider-icons/sambanova.svg +276 -0
  25. package/gui/dist/provider-icons/scaleway.svg +11 -0
  26. package/gui/dist/provider-icons/siliconflow.svg +18 -0
  27. package/gui/dist/provider-icons/synthetic.svg +12 -0
  28. package/gui/dist/provider-icons/together.svg +18 -0
  29. package/gui/dist/provider-icons/umans.svg +30 -0
  30. package/gui/dist/provider-icons/venice.svg +165 -0
  31. package/gui/dist/provider-icons/vultr.svg +15 -0
  32. package/gui/dist/provider-icons/zai.svg +218 -0
  33. package/gui/dist/provider-icons/zenmux.svg +1 -0
  34. package/package.json +1 -1
  35. package/src/adapters/cursor/live-models.ts +1 -0
  36. package/src/adapters/openai-responses.ts +72 -10
  37. package/src/bridge.ts +15 -15
  38. package/src/cli/dispatch.ts +54 -18
  39. package/src/cli/index.ts +420 -30
  40. package/src/cli/integrations.ts +24 -2
  41. package/src/cli/uninstall-plan.ts +86 -0
  42. package/src/codex/account-store.ts +121 -8
  43. package/src/codex/auth-api.ts +202 -33
  44. package/src/codex/catalog/provider-fetch.ts +76 -9
  45. package/src/codex/history-job.ts +10 -0
  46. package/src/codex/history-manifest.ts +35 -2
  47. package/src/codex/history-provider.ts +196 -19
  48. package/src/codex/history-worker.ts +3 -0
  49. package/src/codex/quota-401-recovery.ts +190 -0
  50. package/src/codex/quota-recovery-timing.ts +28 -0
  51. package/src/codex/quota.ts +6 -0
  52. package/src/codex/routing.ts +68 -16
  53. package/src/codex/subagent-model-fallback.ts +4 -1
  54. package/src/config/pending-teardown-names.d.mts +8 -0
  55. package/src/config/pending-teardown-names.mjs +69 -0
  56. package/src/config/pending-teardown.ts +286 -0
  57. package/src/generated/compatibility-version.json +84 -32
  58. package/src/integrations/journal.ts +12 -1
  59. package/src/integrations/writer.ts +82 -7
  60. package/src/lib/process-control.ts +30 -5
  61. package/src/lib/state-store-registrations.ts +8 -0
  62. package/src/oauth/index.ts +220 -13
  63. package/src/oauth/store.ts +220 -18
  64. package/src/responses/spill-store.ts +20 -0
  65. package/src/responses/state.ts +288 -2
  66. package/src/server/management/integration-routes.ts +36 -5
  67. package/src/server/management-api.ts +66 -12
  68. package/src/server/responses/encrypted-payload.ts +18 -1
  69. package/src/server/stop-teardown.ts +84 -0
  70. package/src/service.ts +155 -20
  71. package/src/update/index.ts +36 -4
  72. package/src/update/proxy-liveness-probe.d.mts +6 -0
  73. package/src/update/proxy-liveness-probe.mjs +84 -0
  74. package/src/update/stop-contract.d.mts +2 -0
  75. package/src/update/stop-contract.mjs +15 -0
  76. package/src/update/stop-decision.d.mts +10 -0
  77. package/src/update/stop-decision.mjs +34 -0
  78. package/src/vision/eligibility.ts +19 -1
  79. package/src/vision/index.ts +4 -18
  80. package/gui/dist/assets/index-C14iCj_Q.js +0 -112
  81. package/gui/dist/assets/index-D7PIz7_g.css +0 -1
@@ -30,6 +30,7 @@ import {
30
30
  import type { OcxConfig, OcxProviderConfig } from "../../types";
31
31
  import { modelInList } from "../../types";
32
32
  import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
33
+ import { isModelVisionSidecarConsumer } from "../../vision/eligibility";
33
34
  import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata";
34
35
  import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
35
36
  import {
@@ -670,11 +671,14 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig,
670
671
  const configuredMaxInput = configuredMaxInputTokens(prov, model.id);
671
672
  const configuredAutoCompact = configuredAutoCompactTokenLimit(prov, model.id);
672
673
  let inputModalities = configuredInputModalities(prov, model.id);
673
- // Vision-sidecar coverage: `noVisionModels` marks models whose images the PROXY describes
674
- // (src/vision/index.ts). The catalog must still advertise image input for them — the Codex app
674
+ // The shared vision-sidecar consumer predicate keeps catalog advertisement and request-time
675
+ // planning aligned. The catalog must still advertise image input — the Codex app
675
676
  // gates attachments client-side on input_modalities, and a text-only entry would block images
676
- // before the sidecar ever runs ("This model does not support image inputs").
677
- if (modelInList(prov.noVisionModels, model.id)) {
677
+ // before the sidecar ever runs ("This model does not support image inputs"). Discovery-derived
678
+ // text-only rows stay untouched: the runtime predicate only reads these two config sources, so
679
+ // it would not convert those.
680
+ const sidecarCovered = isModelVisionSidecarConsumer(prov, model.id);
681
+ if (sidecarCovered) {
678
682
  const base = inputModalities ?? model.inputModalities ?? ["text"];
679
683
  inputModalities = base.includes("image") ? [...base] : [...base, "image"];
680
684
  }
@@ -936,9 +940,70 @@ export function resolveComboCatalogMember(
936
940
  };
937
941
  }
938
942
 
943
+ const DATED_VARIANT_YYYYMMDD = /^(\d{4})(\d{2})(\d{2})$/;
944
+ const DATED_VARIANT_YYMMDD = /^(2\d)(\d{2})(\d{2})$/;
945
+ const DATED_VARIANT_MMDD_OR_YYMM = /^(\d{2})(\d{2})$/;
946
+
947
+ /** Whether a Gregorian year contains February 29th. */
948
+ function isLeapYear(year: number): boolean {
949
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
950
+ }
951
+
952
+ /**
953
+ * Whether a month/day pair exists in the given year. Without a year, February 29th is
954
+ * accepted because it occurs in at least one calendar year.
955
+ */
956
+ function isValidCalendarDate(year: number | undefined, month: number, day: number): boolean {
957
+ if (year !== undefined && (year < 1 || year > 9999)) return false;
958
+ if (month < 1 || month > 12 || day < 1) return false;
959
+ const daysInMonth = [
960
+ 31, year === undefined || isLeapYear(year) ? 29 : 28, 31, 30, 31, 30,
961
+ 31, 31, 30, 31, 30, 31,
962
+ ];
963
+ return day <= daysInMonth[month - 1]!;
964
+ }
965
+
966
+ /**
967
+ * Release-date suffixes providers actually publish: `YYYYMMDD` (`-20251001`), `YYMMDD`
968
+ * (`-260806`), `MMDD` (`-0813`) and `YYMM` (`-2512`). A `\d{8}`-only rule matched none of
969
+ * the dated ids on a real multi-provider install, so DeepSeek, Kimi, Mistral, Qwen and
970
+ * Solar aliases all fell through to `droppedConfiguredIds` (#3024).
971
+ *
972
+ * Calendar validation rejects impossible month-end and leap-day values as well as ordinary
973
+ * numeric suffixes such as `-2048`, `-4096` and `-8192`. `-1024` is the one irreducible
974
+ * collision — it is a valid `MMDD` (October 24th) — so it reads as dated. That is a known,
975
+ * accepted cost; the test table pins it so it cannot become a surprise later.
976
+ *
977
+ * Hyphenated ISO suffixes (`-2024-08-06`, `-05-06`) are deliberately out of scope: a
978
+ * hyphenated suffix is ambiguous against ordinary name segments and needs its own call.
979
+ */
980
+ function isDatedVariantSuffix(suffix: string): boolean {
981
+ const yyyyMmDd = DATED_VARIANT_YYYYMMDD.exec(suffix);
982
+ if (yyyyMmDd) {
983
+ return isValidCalendarDate(
984
+ Number(yyyyMmDd[1]), Number(yyyyMmDd[2]), Number(yyyyMmDd[3]),
985
+ );
986
+ }
987
+
988
+ const yyMmDd = DATED_VARIANT_YYMMDD.exec(suffix);
989
+ if (yyMmDd) {
990
+ return isValidCalendarDate(
991
+ 2000 + Number(yyMmDd[1]), Number(yyMmDd[2]), Number(yyMmDd[3]),
992
+ );
993
+ }
994
+
995
+ const mmDdOrYyMm = DATED_VARIANT_MMDD_OR_YYMM.exec(suffix);
996
+ if (!mmDdOrYyMm) return false;
997
+ const first = Number(mmDdOrYyMm[1]);
998
+ const second = Number(mmDdOrYyMm[2]);
999
+ return isValidCalendarDate(undefined, first, second)
1000
+ || (first >= 20 && first <= 29 && second >= 1 && second <= 12);
1001
+ }
1002
+
1003
+ /** Whether `liveId` is a supported dated release of the configured base id. */
939
1004
  export function isDatedVariantId(liveId: string, configuredId: string): boolean {
940
1005
  if (!liveId.startsWith(`${configuredId}-`)) return false;
941
- return /^\d{8}$/.test(liveId.slice(configuredId.length + 1));
1006
+ return isDatedVariantSuffix(liveId.slice(configuredId.length + 1));
942
1007
  }
943
1008
 
944
1009
  export const lastDropWarnSignature = new Map<string, string>();
@@ -2113,9 +2178,10 @@ async function gatherRoutedModelsUncached(
2113
2178
  ...(base.codexToolMode === undefined && replaced.codexToolMode !== undefined ? { codexToolMode: replaced.codexToolMode } : {}),
2114
2179
  ...(base.capabilities === undefined && replaced.capabilities !== undefined ? { capabilities: replaced.capabilities } : {}),
2115
2180
  } : base;
2116
- // Vision-sidecar coverage ONLY: if the custom model is in the enriched provider's
2117
- // noVisionModels, advertise image input so the Codex app lets images reach the sidecar
2118
- // (#349/#344). Deliberately NOT the full applyProviderConfigHints pass — custom rows are a
2181
+ // Vision-sidecar coverage only: when the enriched provider's shared predicate matches
2182
+ // noVisionModels or text-without-image modelInputModalities, advertise image input so the
2183
+ // Codex app lets images reach the sidecar (#349/#344). Deliberately NOT the full
2184
+ // applyProviderConfigHints pass — custom rows are a
2119
2185
  // user override, so their explicit contextWindow / inputModalities / reasoning fields must be
2120
2186
  // preserved verbatim (the hint pass would cap context and overwrite modalities from registry).
2121
2187
  const mergedContext = typeof merged.contextWindow === "number" && merged.contextWindow > 0
@@ -2141,7 +2207,8 @@ async function gatherRoutedModelsUncached(
2141
2207
  }
2142
2208
  : mergedWithHardBounds;
2143
2209
  const enrichedProvider = enrichedByName.get(cm.provider) ?? rawProvider;
2144
- if (enrichedProvider && modelInList(enrichedProvider.noVisionModels, mergedWithAutoCompact.id)) {
2210
+ // Reuse the request-time consumer predicate so custom rows cannot drift from catalog hints.
2211
+ if (enrichedProvider && isModelVisionSidecarConsumer(enrichedProvider, mergedWithAutoCompact.id)) {
2145
2212
  const current = mergedWithAutoCompact.inputModalities ?? ["text"];
2146
2213
  if (!current.includes("image")) {
2147
2214
  return { ...mergedWithAutoCompact, inputModalities: [...current, "image"] };
@@ -110,6 +110,8 @@ export type CodexHistoryJobOutcome =
110
110
  | { readonly kind: "blocked"; readonly reason: "busy" | "database" | "unsafe-path" | "desired_disabled" | "desired_enabled" }
111
111
  | { readonly kind: "failed"; readonly reason: "worker-error" | "worker-died" | "timeout";
112
112
  readonly message: string; readonly historyFailureReason?: CodexHistoryFailureReason;
113
+ /** Specific integrity condition when `historyFailureReason` is `"integrity"`. */
114
+ readonly historyIntegrityCode?: string;
113
115
  readonly rows?: number; readonly files?: number };
114
116
 
115
117
  /**
@@ -258,6 +260,13 @@ export function describeHistoryJobFailure(
258
260
  return "permission was denied while writing Codex history; this is not a Codex app lock. Run 'ocx doctor'.";
259
261
  }
260
262
  if (outcome.historyFailureReason === "integrity") {
263
+ // Not every integrity stop is a retry. An ambiguous reroute means two histories
264
+ // produced the same row and no durable fact separates them, so retrying reaches the
265
+ // same refusal - the manifest needs a person, and saying "run doctor" sends them the
266
+ // wrong way.
267
+ if (outcome.historyIntegrityCode === "history_apply_ambiguous_reroute") {
268
+ return "a Codex history entry could not be re-routed because its manifest cannot prove whether an earlier relabel was undone; nothing was changed and the manifest was kept. Resolve it manually rather than retrying.";
269
+ }
261
270
  return partiallyChanged
262
271
  ? "the history backup or its restore target changed after a partial restore; the manifest was retained for review and safe retry. Run 'ocx doctor'."
263
272
  : "the history backup or its restore target failed integrity checks; no unverified provider metadata was applied. Run 'ocx doctor'.";
@@ -298,6 +307,7 @@ function classifyWorkerResult(result: HistoryWorkerResult): CodexHistoryJobOutco
298
307
  reason: "worker-error",
299
308
  message: redactWorkerMessage(result.message),
300
309
  ...(result.reason ? { historyFailureReason: result.reason } : {}),
310
+ ...(result.integrityCode ? { historyIntegrityCode: result.integrityCode } : {}),
301
311
  ...(result.rows !== undefined && result.files !== undefined
302
312
  ? { rows: result.rows, files: result.files }
303
313
  : {}),
@@ -10,10 +10,37 @@ export interface CodexHistoryBackupEntry {
10
10
  modelProvider: string;
11
11
  source: string;
12
12
  hasUserEvent: 0 | 1;
13
+ /**
14
+ * Whether the row had a non-empty `first_user_message` when the snapshot was taken.
15
+ *
16
+ * Routing derives the post-image `has_user_event` from the message AT SNAPSHOT TIME
17
+ * (`history-provider.ts` `routeOpenai`), so a restore that recomputes it from the
18
+ * message as it is NOW will mistake the user's first message for OpenCodex's own write
19
+ * and erase it. Only the emptiness is recorded, never the text: this manifest is a file
20
+ * on disk and the message is user content.
21
+ *
22
+ * Optional because manifests written before this field exists cannot be given one. An
23
+ * entry without it falls back to the current-row reading, which is exactly as imprecise
24
+ * as the behaviour it replaces and no worse.
25
+ */
26
+ hadFirstUserMessage?: boolean;
27
+ /**
28
+ * Whether OpenCodex's routing relabel is known to have landed for this entry.
29
+ *
30
+ * `pending` is written before the routing write and resolved after it, so a crash
31
+ * between the two leaves an honest "unknown" rather than a confident wrong answer. The
32
+ * observed row resolves it: the recorded original means the write did not land, the
33
+ * expected post-image means it did.
34
+ *
35
+ * Absent on entries written before the field existed. Those refuse only in the one
36
+ * genuinely undecidable case — original tuple with `has_user_event` moved 0 to 1 —
37
+ * which `dev` already refuses today.
38
+ */
39
+ relabel?: "pending" | "committed" | "none";
13
40
  }
14
41
 
15
42
  export interface CodexHistoryBackupManifest {
16
- version: 1;
43
+ version: 1 | 2;
17
44
  stateDbPath: string;
18
45
  entries: Record<string, CodexHistoryBackupEntry>;
19
46
  }
@@ -76,7 +103,7 @@ export function validateCodexHistoryBackupManifest(
76
103
  expectedStateDbPath: string,
77
104
  ): CodexHistoryManifestValidation {
78
105
  if (!isRecord(raw)
79
- || raw.version !== 1
106
+ || (raw.version !== 1 && raw.version !== 2)
80
107
  || typeof raw.stateDbPath !== "string"
81
108
  || !raw.stateDbPath.trim()
82
109
  || !isAbsolute(raw.stateDbPath)
@@ -103,6 +130,12 @@ export function validateCodexHistoryBackupManifest(
103
130
  || typeof value.hasUserEvent !== "number"
104
131
  || !Number.isSafeInteger(value.hasUserEvent)
105
132
  || (value.hasUserEvent !== 0 && value.hasUserEvent !== 1)
133
+ // Optional, but not unvalidated: a truthy `hadFirstUserMessage: "false"` would select
134
+ // the wrong restore verdict, and an unrecognized `relabel` would be read as a state
135
+ // the classifier does not have.
136
+ || (value.hadFirstUserMessage !== undefined && typeof value.hadFirstUserMessage !== "boolean")
137
+ || (value.relabel !== undefined
138
+ && value.relabel !== "pending" && value.relabel !== "committed" && value.relabel !== "none")
106
139
  || !hasAllowedProvenance(value)) {
107
140
  return { ok: false, reason: "schema", scope: "entry-provenance" };
108
141
  }
@@ -227,6 +227,11 @@ function integrityFailureResult(error: CodexHistoryIntegrityError): CodexHistory
227
227
  files: error.progress.files,
228
228
  failed: true,
229
229
  failureReason: "integrity",
230
+ // The specific code, so an operator sees WHICH integrity condition stopped the
231
+ // transition rather than a generic "run doctor". `history_apply_ambiguous_reroute` in
232
+ // particular needs manual resolution: the manifest is intact and the safe move is to
233
+ // inspect it, not to retry.
234
+ integrityCode: error.message,
230
235
  };
231
236
  }
232
237
 
@@ -239,6 +244,15 @@ export interface CodexHistorySyncResult {
239
244
  failed?: true;
240
245
  /** Why the retry budget was exhausted when `failed` is set. */
241
246
  failureReason?: CodexHistoryFailureReason;
247
+ /**
248
+ * The specific integrity condition, when `failureReason` is `"integrity"`.
249
+ *
250
+ * `failureReason` alone tells an operator only that something was inconsistent, which
251
+ * reads as "retry or run doctor". Some of these are not retryable —
252
+ * `history_apply_ambiguous_reroute` means two histories produced the same row and the
253
+ * manifest needs a human — so the code travels with the result.
254
+ */
255
+ integrityCode?: string;
242
256
  }
243
257
 
244
258
  interface ThreadRow {
@@ -361,7 +375,10 @@ function readBackupStrict(path: string, stateDbPath: string): StrictBackupRead {
361
375
  return {
362
376
  kind: "known",
363
377
  present: false,
364
- manifest: { version: 1, stateDbPath, entries: {} },
378
+ // New manifests carry the snapshot and relabel fields, so they are v2. v1 stays
379
+ // readable: an entry written before those fields existed falls back to the
380
+ // current-row reading, which is the behaviour it was written under.
381
+ manifest: { version: 2, stateDbPath, entries: {} },
365
382
  fingerprint: "absent",
366
383
  };
367
384
  }
@@ -462,14 +479,80 @@ function writeBackup(path: string, manifest: CodexHistoryBackupManifest, stateDb
462
479
  atomicWriteFile(path, JSON.stringify({ ...manifest, stateDbPath: manifest.stateDbPath ?? stateDbPath }, null, 2) + "\n");
463
480
  }
464
481
 
465
- function rememberOriginal(manifest: CodexHistoryBackupManifest, row: ThreadRow): void {
466
- if (manifest.entries[row.id]) return;
482
+ function rememberOriginal(manifest: CodexHistoryBackupManifest, row: ApplyRowSnapshot): void {
483
+ const existing = manifest.entries[row.id];
484
+ if (existing) {
485
+ // A surviving entry means a previous route/restore cycle did not consume its manifest.
486
+ // Its `relabel` describes THAT attempt, and this one has not written yet, so a stale
487
+ // `committed` would let a later restore treat the marker as proof that OpenCodex
488
+ // authored an event flag the user had since set.
489
+ //
490
+ // The provenance tuple stays — it is the ORIGINAL, and a routed row must never
491
+ // overwrite it. But `hadFirstUserMessage` is not provenance: it describes the input to
492
+ // one routing write, and this attempt has its own. Leaving the previous attempt's value
493
+ // makes the new routed row match the expected post-image and erases activity that
494
+ // arrived in between. Re-record it, and promote the manifest so the field is covered by
495
+ // the schema that declares it.
496
+ const previousRelabel = existing.relabel;
497
+ const previousHadFirstUserMessage = existing.hadFirstUserMessage;
498
+ existing.relabel = "pending";
499
+ existing.hadFirstUserMessage = hasFirstUserMessage(row.first_user_message);
500
+ // `hasUserEvent` is the value restore returns to, and the previous attempt's can be two
501
+ // events stale — a restore that already landed, plus whatever the user did afterwards.
502
+ // Refreshing it needs proof that the previous relabel was UNDONE, because the original
503
+ // tuple alone is not proof: route-then-legacy-recovery lands on that same tuple, so
504
+ // refreshing there would adopt OpenCodex's own write as the user's baseline.
505
+ //
506
+ const atOriginalTuple = row.model_provider === existing.modelProvider
507
+ && row.source === existing.source;
508
+ const observedEvent: 0 | 1 = Number(row.has_user_event) === 1 ? 1 : 0;
509
+ if (observedEvent !== existing.hasUserEvent) {
510
+ // The row's event disagrees with the recorded baseline. Whether that is decidable is
511
+ // a function of DIRECTION and ORIGIN, not of one flag:
512
+ //
513
+ // - `1 -> 0` is always foreign. Nothing in this system clears the flag: routing only
514
+ // ever sets it, the user only ever sets it, and legacy recovery sets it to 1. A
515
+ // baseline that moved down is a decision this manifest does not own.
516
+ // - `0 -> 1` on an exec-origin entry is the user's. `routeExec` moves `source` to
517
+ // `cli` and legacy recovery does not move it back, so an exec-origin row wearing
518
+ // its original tuple was never routed away and back.
519
+ // - `0 -> 1` with `relabel: "none"` is the user's: a restore landed and undid the
520
+ // previous relabel, so the observed row is the honest pre-route state.
521
+ // - `0 -> 1` where the previous route would have written 0 is the user's, because
522
+ // OpenCodex could not have authored a 1 it never writes.
523
+ // - `0 -> 1` where the previous route WOULD have written 1, or where a legacy entry
524
+ // records nothing about it, is undecidable: routing-never-landed-plus-activity and
525
+ // routing-landed-then-legacy-recovery produce the same row. Refuse rather than pick.
526
+ const reverseDrift = observedEvent === 0;
527
+ const execOrigin = existing.modelProvider !== "openai";
528
+ const priorRouteWroteZero = previousHadFirstUserMessage === false;
529
+ const decidable = !reverseDrift
530
+ && atOriginalTuple
531
+ && (execOrigin || previousRelabel === "none" || priorRouteWroteZero);
532
+ if (!decidable) {
533
+ throw new CodexHistoryIntegrityError("history_apply_ambiguous_reroute");
534
+ }
535
+ existing.hasUserEvent = observedEvent;
536
+ }
537
+ manifest.version = 2;
538
+ return;
539
+ }
540
+ manifest.version = 2;
467
541
  manifest.entries[row.id] = {
468
542
  id: row.id,
469
543
  rolloutPath: row.rollout_path,
470
544
  modelProvider: row.model_provider,
471
545
  source: row.source,
472
546
  hasUserEvent: Number(row.has_user_event) === 1 ? 1 : 0,
547
+ // Emptiness only, never the text: the manifest is a file on disk and the message is
548
+ // user content. Routing derives the post-image event flag from the message as it was
549
+ // HERE, so a restore that re-reads the current message would mistake later user
550
+ // activity for OpenCodex's own write.
551
+ hadFirstUserMessage: hasFirstUserMessage(row.first_user_message),
552
+ // The routing write has not happened yet. Resolved to "committed" after it lands, or
553
+ // left pending if the process dies between the two - in which case the observed row
554
+ // is what decides.
555
+ relabel: "pending",
473
556
  };
474
557
  }
475
558
 
@@ -486,7 +569,13 @@ function rowMatchesRestoreTuple(
486
569
 
487
570
  function rowMatchesExpectedPostImage(row: RestoreRowSnapshot, entry: CodexHistoryBackupEntry): boolean {
488
571
  if (entry.modelProvider === "openai") {
489
- const postHasUserEvent = hasFirstUserMessage(row.first_user_message) ? 1 : entry.hasUserEvent;
572
+ // Routing derived this from the message AT SNAPSHOT TIME (`routeOpenai`), so read the
573
+ // recorded flag when the manifest has one. Recomputing from the row's CURRENT message
574
+ // mistakes a first message the user sent after routing for OpenCodex's own write, and
575
+ // restore then erases it. Manifests written before the flag existed fall back to the
576
+ // current reading, which is exactly the behaviour this replaces and no worse.
577
+ const hadMessage = entry.hadFirstUserMessage ?? hasFirstUserMessage(row.first_user_message);
578
+ const postHasUserEvent = hadMessage ? 1 : entry.hasUserEvent;
490
579
  return rowMatchesRestoreTuple(row, "opencodex", entry.source, postHasUserEvent);
491
580
  }
492
581
  return hasFirstUserMessage(row.first_user_message)
@@ -499,6 +588,60 @@ function rowMatchesExpectedPostImage(row: RestoreRowSnapshot, entry: CodexHistor
499
588
  );
500
589
  }
501
590
 
591
+ /**
592
+ * What `has_user_event` should read after restore, or `null` when the row is not one this
593
+ * manifest owns.
594
+ *
595
+ * The field has two writers, so a final state cannot establish authorship on its own. Four
596
+ * shapes cover every row reachable in practice, and the tuple the row wears says which:
597
+ *
598
+ * - **A** exactly the recorded original: untouched, or already restored.
599
+ * - **B** the expected post-image: OpenCodex wrote it, so the recorded value is authoritative.
600
+ * - **C** the original tuple with the flag moved 0 to 1: either Codex-side user activity, or
601
+ * OpenCodex routing that legacy recovery has since pulled back to the original provider.
602
+ * - **D** the post-image tuple with the flag moved 0 to 1: a routed row the user then touched.
603
+ * No provenance needed - a row wearing the routed tuple was written by OpenCodex, so drift
604
+ * on top of it can only be what followed.
605
+ *
606
+ * Only C is ambiguous, and only when the route's own expected event was 1: then "routing
607
+ * never landed and the user typed" and "routing landed and legacy recovery pulled it back"
608
+ * produce an identical row, and nothing durable separates them. That one cell refuses. A
609
+ * guess there either erases real activity or fabricates it.
610
+ */
611
+ export function restoredUserEventFor(row: RestoreRowSnapshot, entry: CodexHistoryBackupEntry): 0 | 1 | null {
612
+ if (rowMatchesRestoreTuple(row, entry.modelProvider, entry.source, entry.hasUserEvent)) {
613
+ return entry.hasUserEvent; // A
614
+ }
615
+ if (rowMatchesExpectedPostImage(row, entry)) return entry.hasUserEvent; // B
616
+
617
+ const drifted = Number(row.has_user_event) === 1 && entry.hasUserEvent === 0;
618
+ if (!drifted) return null;
619
+
620
+ const routeExpectedEvent = entry.hadFirstUserMessage ?? hasFirstUserMessage(row.first_user_message) ? 1 : 0;
621
+
622
+ // D: wearing the routed tuple, so the 1 arrived after OpenCodex wrote the row. The tuple
623
+ // is the one routing actually produces — `routeOpenai` keeps the source, `routeExec`
624
+ // moves exec to cli — so D and C cannot both match rather than merely being ordered.
625
+ const routedSource = entry.modelProvider === "openai" ? entry.source : "cli";
626
+ if (rowMatchesRestoreTuple(row, "opencodex", routedSource, 1)) return 1;
627
+
628
+ // C: wearing the original tuple.
629
+ if (rowMatchesRestoreTuple(row, entry.modelProvider, entry.source, 1)) {
630
+ // An exec-origin entry cannot reach here by legacy recovery: routeExec moves source to
631
+ // cli and recovery does not move it back, so the original tuple is unreachable that way.
632
+ if (entry.modelProvider !== "openai") return 1;
633
+ if (entry.relabel === "none") return 1;
634
+ if (entry.relabel === "committed") {
635
+ // OpenCodex authored the 1 only if its own routing write would have produced one.
636
+ return routeExpectedEvent === 1 ? 0 : 1;
637
+ }
638
+ if (entry.relabel === undefined) return null; // legacy manifest: the pre-existing refusal
639
+ // pending: two histories reach this exact row and nothing durable tells them apart.
640
+ return routeExpectedEvent === 1 ? null : 1;
641
+ }
642
+ return null;
643
+ }
644
+
502
645
  interface RestoreRolloutSnapshot {
503
646
  readonly identity: string;
504
647
  readonly latestProvider: string;
@@ -549,7 +692,7 @@ function snapshotRolloutForRestore(entry: CodexHistoryBackupEntry): RestoreRollo
549
692
  if (identityBefore === null) {
550
693
  throw new CodexHistoryIntegrityError("history_backup_rollout_unrestorable");
551
694
  }
552
- const latest = readLatestSessionMeta(entry.rolloutPath);
695
+ const latest = readLatestSessionMetaForId(entry.rolloutPath, entry.id);
553
696
  if (!latest
554
697
  || (!rolloutMatchesRestoreTuple(latest, entry, entry.modelProvider, entry.source)
555
698
  && !rolloutMatchesExpectedPostImage(latest, entry))) {
@@ -608,8 +751,7 @@ function preflightRestoreRows(
608
751
  if (!row || typeof row.rollout_path !== "string" || !sameCodexHistoryPath(row.rollout_path, entry.rolloutPath)) {
609
752
  throw new CodexHistoryIntegrityError("history_backup_target_mismatch");
610
753
  }
611
- if (!rowMatchesRestoreTuple(row, entry.modelProvider, entry.source, entry.hasUserEvent)
612
- && !rowMatchesExpectedPostImage(row, entry)) {
754
+ if (restoredUserEventFor(row, entry) === null) {
613
755
  throw new CodexHistoryIntegrityError("history_backup_postimage_mismatch");
614
756
  }
615
757
  snapshots.set(entry.id, row);
@@ -625,10 +767,11 @@ function assertRestoreReadback(
625
767
  const row = getCurrent(entry.id);
626
768
  if (!row
627
769
  || !sameCodexHistoryPath(row.rollout_path, entry.rolloutPath)
628
- || !rowMatchesRestoreTuple(row, entry.modelProvider, entry.source, entry.hasUserEvent)) {
770
+ || !rowMatchesRestoreTuple(row, entry.modelProvider, entry.source, Number(row.has_user_event) === 1 ? 1 : 0)
771
+ || restoredUserEventFor(row, entry) === null) {
629
772
  throw new CodexHistoryIntegrityError("history_backup_database_readback_mismatch");
630
773
  }
631
- const latest = readLatestSessionMeta(entry.rolloutPath);
774
+ const latest = readLatestSessionMetaForId(entry.rolloutPath, entry.id);
632
775
  if (inspectFirstLineProvider(entry.rolloutPath, entry.id, entry.modelProvider) !== "current"
633
776
  || !latest
634
777
  || !rolloutMatchesRestoreTuple(latest, entry, entry.modelProvider, entry.source)) {
@@ -665,6 +808,19 @@ export function readLatestSessionMeta(path: string): ParsedSessionMeta | null {
665
808
  return readLatestSessionMetaFromText(raw);
666
809
  }
667
810
 
811
+ /**
812
+ * Same fold as {@link readLatestSessionMeta}, restricted to this thread's own metadata.
813
+ *
814
+ * A forked/branched rollout appends the SOURCE thread's `session_meta` after its own, and the
815
+ * app discards any record whose payload id is not the canonical thread id (codex-rs
816
+ * `apply_session_meta_from_item`). Reading the last line regardless of id therefore answers
817
+ * with a foreign thread's provider, which is neither what the app honors nor what we may patch.
818
+ */
819
+ function readLatestSessionMetaForId(path: string, expectedId: string): ParsedSessionMeta | null {
820
+ const raw = readFileSync(path, "utf8");
821
+ return readLatestSessionMetaForIdFromText(raw, expectedId);
822
+ }
823
+
668
824
  function readLatestSessionMetaFromText(raw: string): ParsedSessionMeta | null {
669
825
  const lines = raw.split("\n");
670
826
  for (let i = lines.length - 1; i >= 0; i--) {
@@ -875,18 +1031,15 @@ function updateSessionMeta(
875
1031
  return { changed: false, durableProvider: false, conflict: true };
876
1032
  }
877
1033
 
878
- const latest = readLatestSessionMeta(path);
1034
+ // Resolve by id. The app ignores `session_meta` lines whose payload id != the canonical
1035
+ // thread id (codex-rs `apply_session_meta_from_item`), and a forked rollout trails the source
1036
+ // session's metadata, so the last line is not necessarily this thread's. Patching that record
1037
+ // would clone the wrong thread's meta into a line the app discards; skipping the file entirely
1038
+ // left forked threads unroutable and, once routed, unrestorable.
1039
+ const latest = readLatestSessionMetaForId(path, expectedId);
879
1040
  if (!latest) return { changed: false, durableProvider: false };
880
1041
  const record = latest.record;
881
1042
 
882
- // The app ignores `session_meta` lines whose payload id != the canonical thread id
883
- // (codex-rs `apply_session_meta_from_item`). Forked rollouts can embed a source session's
884
- // metadata, so an id-mismatched latest line means we'd be cloning the wrong thread's meta and
885
- // appending a line the app would discard. Skip rather than write a no-op/misleading line.
886
- const payloadId = record.payload.id;
887
- if (typeof payloadId !== "string" || payloadId !== expectedId) {
888
- return { changed: false, durableProvider: false };
889
- }
890
1043
  const latestProvider = typeof record.payload.model_provider === "string" && record.payload.model_provider
891
1044
  ? record.payload.model_provider
892
1045
  : "openai";
@@ -1236,6 +1389,15 @@ function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbP
1236
1389
  throw error;
1237
1390
  }
1238
1391
 
1392
+ // The routing writes landed. Resolve every pending marker and rewrite the manifest, so a
1393
+ // later restore knows the relabel is OpenCodex's rather than having to infer it. A crash
1394
+ // before this point leaves `pending`, which the observed row resolves at restore time.
1395
+ for (const row of [...openaiRows, ...execRows]) {
1396
+ const entry = manifest.entries[row.id];
1397
+ if (entry?.relabel === "pending") entry.relabel = "committed";
1398
+ }
1399
+ writeBackup(backupPath, manifest, stateDbPath);
1400
+
1239
1401
  return { rows: openaiRows.length + execRows.length, files };
1240
1402
  } finally {
1241
1403
  db.close();
@@ -1277,10 +1439,13 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C
1277
1439
  for (const entry of entries) {
1278
1440
  const before = snapshots.get(entry.id);
1279
1441
  if (!before) throw new CodexHistoryIntegrityError("history_backup_snapshot_missing");
1442
+ // Codex-side activity that arrived after OpenCodex wrote the row is the user's, and
1443
+ // restoring the manifest's snapshot over it would erase it.
1444
+ const restoredEvent = restoredUserEventFor(before, entry) ?? entry.hasUserEvent;
1280
1445
  const result = update.run(
1281
1446
  entry.modelProvider,
1282
1447
  entry.source,
1283
- entry.hasUserEvent,
1448
+ restoredEvent,
1284
1449
  entry.id,
1285
1450
  before.rollout_path,
1286
1451
  before.model_provider,
@@ -1345,6 +1510,18 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C
1345
1510
  if (error instanceof CodexHistoryIntegrityError) {
1346
1511
  throw new CodexHistoryIntegrityError(error.message, { rows: entries.length, files });
1347
1512
  }
1513
+ // The restore landed and its readback passed; only finalization failed, so the
1514
+ // manifest survives on disk. Record that its relabel is undone, or a later routing
1515
+ // attempt cannot tell this entry from one still mid-route and has to keep a baseline
1516
+ // that is now stale. Best-effort: a failure here leaves exactly the prior state.
1517
+ try {
1518
+ for (const entry of entries) {
1519
+ const stored = manifest.entries[entry.id];
1520
+ if (stored) stored.relabel = "none";
1521
+ }
1522
+ manifest.version = 2;
1523
+ writeBackup(backupPath, manifest, stateDbPath);
1524
+ } catch { /* the surviving manifest keeps its previous marker */ }
1348
1525
  const failureReason = classifyRecoverableHistoryError(error);
1349
1526
  if (failureReason) {
1350
1527
  return {
@@ -75,6 +75,8 @@ export type HistoryWorkerResult =
75
75
  readonly reason: "busy" | "database" | "unsafe-path" | "desired_disabled" | "desired_enabled" }
76
76
  | { readonly type: "error"; readonly requestId: string; readonly jobId: string;
77
77
  readonly message: string; readonly reason?: CodexHistoryFailureReason;
78
+ /** Specific integrity condition, so a non-retryable one can be named as such. */
79
+ readonly integrityCode?: string;
78
80
  readonly rows?: number; readonly files?: number };
79
81
 
80
82
  const OPERATIONS: ReadonlySet<string> = new Set<CodexHistoryWorkerOperation>([
@@ -181,6 +183,7 @@ export function runHistoryUnitUnderLock(
181
183
  jobId,
182
184
  message: "history_transition_failed",
183
185
  ...(result.failureReason ? { reason: result.failureReason } : {}),
186
+ ...(result.integrityCode ? { integrityCode: result.integrityCode } : {}),
184
187
  ...(result.rows > 0 || result.files > 0 ? { rows: result.rows, files: result.files } : {}),
185
188
  };
186
189
  }