@bitkyc08/opencodex 2.59.0 → 2.60.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 (194) hide show
  1. package/gui/dist/assets/index-BTuCbqQd.css +1 -0
  2. package/gui/dist/assets/index-DoBVdPHP.js +134 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +4 -1
  5. package/src/adapters/anthropic-image-codec.ts +16 -2
  6. package/src/adapters/anthropic-image-normalize.ts +49 -2
  7. package/src/adapters/anthropic.ts +4 -1
  8. package/src/adapters/base.ts +23 -0
  9. package/src/adapters/coding-agent/turn.ts +22 -2
  10. package/src/adapters/command-code.ts +50 -3
  11. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  12. package/src/adapters/cursor/discovery.ts +11 -8
  13. package/src/adapters/cursor/live-transport.ts +26 -9
  14. package/src/adapters/cursor/request-builder.ts +7 -1
  15. package/src/adapters/cursor/transport.ts +19 -0
  16. package/src/adapters/cursor.ts +3 -1
  17. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  18. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
  19. package/src/adapters/devin.ts +50 -13
  20. package/src/adapters/google-antigravity-replay.ts +1 -1
  21. package/src/adapters/google-antigravity-wire.ts +29 -5
  22. package/src/adapters/google-http.ts +49 -10
  23. package/src/adapters/google-tool-schema.ts +595 -31
  24. package/src/adapters/google-wire-compiler.ts +93 -10
  25. package/src/adapters/google-wire-shape.ts +461 -0
  26. package/src/adapters/google.ts +37 -6
  27. package/src/adapters/openai-chat-images.ts +3 -1
  28. package/src/adapters/openai-chat.ts +5 -1
  29. package/src/adapters/openai-responses/image-gen.ts +8 -6
  30. package/src/adapters/openai-responses/passthrough.ts +15 -3
  31. package/src/adapters/openai-responses/reasoning.ts +7 -0
  32. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  33. package/src/bridge/sse.ts +3 -151
  34. package/src/cli/account-extended.ts +4 -4
  35. package/src/cli/dispatch.ts +3 -3
  36. package/src/cli/doctor.ts +28 -9
  37. package/src/cli/hub.ts +3 -2
  38. package/src/cli/index.ts +7 -2
  39. package/src/cli/opencode.ts +2 -2
  40. package/src/cli/provider.ts +13 -1
  41. package/src/client/machine-api.ts +2 -2
  42. package/src/client/machine-listener.ts +2 -2
  43. package/src/client/runtime.ts +26 -2
  44. package/src/codex/account-store.ts +65 -0
  45. package/src/codex/auth-api/account-list.ts +19 -11
  46. package/src/codex/auth-api/pool-quota-probe.ts +30 -7
  47. package/src/codex/catalog/gather-capture.ts +21 -2
  48. package/src/codex/catalog/model-hints.ts +29 -28
  49. package/src/codex/catalog/parsing.ts +7 -0
  50. package/src/codex/catalog/provider-models.ts +19 -2
  51. package/src/codex/catalog/retained-sync.ts +22 -26
  52. package/src/codex/catalog/routed-gather.ts +19 -0
  53. package/src/codex/context-compat.ts +5 -2
  54. package/src/codex/desired-state.ts +4 -1
  55. package/src/codex/history-job.ts +6 -6
  56. package/src/codex/history-provider.ts +20 -166
  57. package/src/codex/history-rollout-read.ts +174 -0
  58. package/src/codex/internal/catalog-writer.ts +33 -1
  59. package/src/codex/model-cache.ts +47 -0
  60. package/src/codex/model-entitlements.ts +29 -10
  61. package/src/codex/observed-model-denials.ts +101 -8
  62. package/src/codex/prompt-text-probe.ts +9 -6
  63. package/src/codex/routing.ts +7 -1
  64. package/src/codex/shim.ts +1 -1
  65. package/src/codex/subagent-model-fallback.ts +22 -4
  66. package/src/combos/failover.ts +3 -0
  67. package/src/config/admitted-identity.ts +222 -0
  68. package/src/config/diagnostics.ts +22 -1
  69. package/src/config/feature-flags.ts +5 -0
  70. package/src/config/load-degrade.ts +18 -0
  71. package/src/config/proxy-env.ts +8 -2
  72. package/src/config/schema/compaction-triggers.ts +11 -0
  73. package/src/config/schema/config-schema.ts +4 -0
  74. package/src/config/schema/leaf-validators.ts +12 -0
  75. package/src/config.ts +2 -2
  76. package/src/generated/compatibility-version.json +249 -169
  77. package/src/grok/reset-coupons.ts +38 -19
  78. package/src/images/loop.ts +6 -1
  79. package/src/integrations/aside-profile-context.ts +37 -3
  80. package/src/integrations/aside-profile-journal.ts +68 -3
  81. package/src/integrations/aside-profiles.ts +128 -3
  82. package/src/integrations/mutation-plan.ts +815 -0
  83. package/src/integrations/writer.ts +85 -99
  84. package/src/lab/live/transport.ts +4 -0
  85. package/src/lab/live/types.ts +5 -0
  86. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  87. package/src/lib/admin-secrets.ts +9 -1
  88. package/src/lib/debug-log-buffer.ts +6 -1
  89. package/src/lib/debug.ts +23 -0
  90. package/src/lib/errors.ts +79 -0
  91. package/src/lib/http-response-semantics.ts +57 -0
  92. package/src/lib/lab-live-pinned-sender.ts +26 -12
  93. package/src/lib/pinned-http.ts +142 -2
  94. package/src/lib/plain-data.ts +103 -0
  95. package/src/lib/process-control.ts +13 -5
  96. package/src/lib/provider-outbound.ts +50 -2
  97. package/src/lib/socks5-fetch.ts +136 -26
  98. package/src/lib/spend-ledger-owner.ts +364 -0
  99. package/src/lib/spend-reservation-ledger.ts +218 -27
  100. package/src/lib/windows-system-proxy.ts +16 -11
  101. package/src/oauth/callback-server.ts +4 -3
  102. package/src/oauth/generic-account-failover.ts +1 -0
  103. package/src/oauth/health.ts +12 -1
  104. package/src/oauth/index.ts +3 -107
  105. package/src/oauth/login-flow-state.ts +127 -0
  106. package/src/providers/derive.ts +34 -17
  107. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  108. package/src/providers/key-failover.ts +66 -18
  109. package/src/providers/model-rename-migration.ts +55 -1
  110. package/src/providers/model-rename-startup.ts +7 -5
  111. package/src/providers/openai-virtual-models.ts +42 -2
  112. package/src/providers/quota/antigravity.ts +22 -2
  113. package/src/providers/quota/vendor-probes-key.ts +1 -1
  114. package/src/providers/registry/entries-core.ts +39 -17
  115. package/src/providers/registry/entries-extended.ts +35 -1
  116. package/src/providers/registry/model-ids.ts +168 -0
  117. package/src/providers/registry/model-seeds.ts +9 -0
  118. package/src/providers/registry/types.ts +2 -0
  119. package/src/providers/resolved-model-policy-merge.ts +167 -0
  120. package/src/providers/resolved-model-policy.ts +406 -0
  121. package/src/providers/stale-vision-classification-migration.ts +137 -0
  122. package/src/responses/apply-patch-envelope.ts +0 -12
  123. package/src/responses/freeform-wrapper-scan.ts +279 -0
  124. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  125. package/src/responses/progressive-freeform-input.ts +130 -0
  126. package/src/responses/reasoning-envelope.ts +30 -0
  127. package/src/responses/state.ts +5 -12
  128. package/src/responses/tool-name-aliases.ts +15 -1
  129. package/src/router.ts +91 -115
  130. package/src/routing/compatibility/behavior.ts +9 -0
  131. package/src/routing/compatibility/subject.ts +16 -1
  132. package/src/server/adapter-resolve.ts +9 -0
  133. package/src/server/auth-cors.ts +3 -0
  134. package/src/server/chat-completions.ts +5 -2
  135. package/src/server/claude-messages.ts +6 -3
  136. package/src/server/effort-row.ts +11 -3
  137. package/src/server/grok-responses-control-frame.ts +160 -1
  138. package/src/server/index/serve-options.ts +86 -29
  139. package/src/server/index/spend-ledger-lifecycle.ts +66 -0
  140. package/src/server/index/websocket-handler.ts +6 -1
  141. package/src/server/index.ts +16 -13
  142. package/src/server/management/aside-profile-routes.ts +266 -7
  143. package/src/server/management/config-routes.ts +18 -2
  144. package/src/server/management/context.ts +3 -0
  145. package/src/server/management/integration-routes.ts +287 -5
  146. package/src/server/management/metrics-routes.ts +20 -0
  147. package/src/server/management/model-rows.ts +224 -12
  148. package/src/server/management/route-registry.ts +14 -0
  149. package/src/server/management/shared.ts +10 -3
  150. package/src/server/management/system-restart.ts +7 -2
  151. package/src/server/management/system-routes.ts +2 -0
  152. package/src/server/management/usage-aggregate-cache.ts +4 -0
  153. package/src/server/management-api.ts +2 -0
  154. package/src/server/management-auth.ts +15 -1
  155. package/src/server/readiness.ts +29 -10
  156. package/src/server/relay-eager.ts +24 -2
  157. package/src/server/relay.ts +119 -10
  158. package/src/server/request-log.ts +55 -2
  159. package/src/server/request-metrics.ts +236 -0
  160. package/src/server/responses/adapter-continuation.ts +3 -3
  161. package/src/server/responses/adapter-dispatch.ts +11 -6
  162. package/src/server/responses/compact.ts +32 -10
  163. package/src/server/responses/compaction-routing.ts +111 -0
  164. package/src/server/responses/core-codex-account.ts +8 -3
  165. package/src/server/responses/core-combo.ts +7 -7
  166. package/src/server/responses/core-normalize.ts +6 -12
  167. package/src/server/responses/core-opaque-recovery.ts +1 -0
  168. package/src/server/responses/core-options.ts +4 -0
  169. package/src/server/responses/encrypted-payload.ts +20 -2
  170. package/src/server/responses/passthrough-delivery.ts +31 -14
  171. package/src/server/responses/passthrough-dispatch.ts +36 -9
  172. package/src/server/responses/policy-fallback.ts +5 -13
  173. package/src/server/responses/request-prepare.ts +67 -17
  174. package/src/server/responses/request-send-budget.ts +5 -1
  175. package/src/server/responses/request-sidecar-auth.ts +1 -1
  176. package/src/server/responses/request-transport.ts +2 -2
  177. package/src/server/responses/run-turn-execution.ts +25 -3
  178. package/src/server/responses/sidecar-execution.ts +17 -2
  179. package/src/server/responses/ws-upstream.ts +14 -27
  180. package/src/server/responses-custom-tool-repair.ts +27 -54
  181. package/src/server/responses-undeclared-tool-guard.ts +31 -1
  182. package/src/server/sse-payload-rewrite.ts +1 -1
  183. package/src/tray/windows-tray.ps1 +155 -3
  184. package/src/types/config.ts +11 -3
  185. package/src/types/provider.ts +18 -0
  186. package/src/types/request.ts +2 -0
  187. package/src/types/tools.ts +14 -0
  188. package/src/types.ts +1 -0
  189. package/src/vision/eligibility.ts +88 -9
  190. package/src/vision/plan.ts +34 -10
  191. package/src/web-search/executor.ts +41 -2
  192. package/src/web-search/loop.ts +6 -1
  193. package/gui/dist/assets/index-C5IebErG.js +0 -136
  194. package/gui/dist/assets/index-OESInAjC.css +0 -1
@@ -113,6 +113,15 @@ export interface GatherRoutedModelsOptions {
113
113
  providerModelOutcomes?: CatalogGatherProviderModelOutcome[];
114
114
  /** Internal convergence sink for the immutable policy that produced the returned rows. */
115
115
  discoveryPolicySnapshots?: CatalogProviderDiscoveryPolicySnapshot[];
116
+ /**
117
+ * Each provider's cache content revision as of the moment its rows were chosen.
118
+ *
119
+ * A caller that derives something from these rows and wants to know later whether the rows are
120
+ * still current must use this, not a revision sampled after the gather returns: another flight
121
+ * can publish between the choice and the sample, and the derived value would then carry that
122
+ * flight's identity while holding these rows.
123
+ */
124
+ providerContentRevisions?: Map<string, string>;
116
125
  }
117
126
 
118
127
  interface GatherFlightResult {
@@ -121,6 +130,7 @@ interface GatherFlightResult {
121
130
  providerAuthOutcomes: readonly CatalogGatherProviderAuthOutcome[];
122
131
  providerModelOutcomes: readonly CatalogGatherProviderModelOutcome[];
123
132
  discoveryPolicySnapshots: readonly CatalogProviderDiscoveryPolicySnapshot[];
133
+ providerContentRevisions: ReadonlyMap<string, string>;
124
134
  }
125
135
  interface GatherInflightEntry {
126
136
  readonly discoveryPolicyIdentity: string;
@@ -256,6 +266,7 @@ async function gatherRoutedModelsWithAuth(
256
266
  providerAuthOutcomes,
257
267
  providerModelOutcomes,
258
268
  discoveryPolicySnapshots,
269
+ providerContentRevisions,
259
270
  } = await entry.promise;
260
271
  if (options?.comboOmissions) {
261
272
  options.comboOmissions.length = 0;
@@ -269,6 +280,12 @@ async function gatherRoutedModelsWithAuth(
269
280
  options.providerModelOutcomes.length = 0;
270
281
  options.providerModelOutcomes.push(...providerModelOutcomes);
271
282
  }
283
+ if (options?.providerContentRevisions) {
284
+ options.providerContentRevisions.clear();
285
+ for (const [provider, revision] of providerContentRevisions) {
286
+ options.providerContentRevisions.set(provider, revision);
287
+ }
288
+ }
272
289
  if (options?.discoveryPolicySnapshots) {
273
290
  options.discoveryPolicySnapshots.length = 0;
274
291
  options.discoveryPolicySnapshots.push(...discoveryPolicySnapshots);
@@ -763,6 +780,8 @@ async function gatherRoutedModelsUncached(
763
780
  providerAuthOutcomes: localProviderAuthOutcomes,
764
781
  providerModelOutcomes,
765
782
  discoveryPolicySnapshots: capture.discoveryPolicySnapshots,
783
+ // Stamped by each provider at the moment it chose its rows, not sampled here.
784
+ providerContentRevisions: new Map(providerResults.map(result => [result.outcome.provider, result.contentRevision])),
766
785
  };
767
786
  }
768
787
 
@@ -7,14 +7,17 @@ export const CONTEXT_BACKEND_PREFIX = "/backend-api/codex";
7
7
 
8
8
  /** The single definition of the opt-in, shared by injection and the runtime gate. */
9
9
  export function contextExperimentalEnabled(configContent: string): boolean {
10
- let parsed: { features?: { context_management?: { experimental_mode?: boolean } } };
10
+ let parsed: { features?: { context_management?: boolean | { experimental_mode?: boolean } } };
11
11
  try {
12
12
  parsed = Bun.TOML.parse(configContent) as typeof parsed;
13
13
  } catch {
14
14
  // Injection tolerates incomplete user config; malformed TOML is not an opt-in.
15
15
  return false;
16
16
  }
17
- return parsed.features?.context_management?.experimental_mode === true;
17
+ // Codex FeatureToml accepts either a boolean or the experimental_mode table.
18
+ // Keep strict true checks: malformed values must not activate a credentialed relay.
19
+ const setting = parsed.features?.context_management;
20
+ return setting === true || (typeof setting === "object" && setting?.experimental_mode === true);
18
21
  }
19
22
 
20
23
  let activation: { key: string; active: boolean } | undefined;
@@ -260,7 +260,10 @@ export async function syncCodexOnStartIfEnabled(
260
260
  // The `.catch` is deliberate and stays: a failure to APPLY must not stop the
261
261
  // proxy from coming up. A failed sync simply reports no writes. The readiness
262
262
  // gate observes the real outcome so /readyz reflects the sync state exactly as
263
- // the PR contract defines (ready only on ok=true with no warning).
263
+ // the contract defines: ready on ok=true once the sync has settled. A nonempty
264
+ // `warning` is a local-Codex-artifact degradation the sync chose to continue
265
+ // past, and #5181 is what it cost to treat it as terminal — a healthy
266
+ // multi-provider proxy reported itself permanently unready.
264
267
  const outcome = readinessGate
265
268
  ? await runStartupReadinessSync(readinessGate, async () => (await sync(port)) ?? null)
266
269
  : await sync(port).catch(() => undefined);
@@ -23,7 +23,7 @@ import type {
23
23
  CodexHistoryWorkerOperation,
24
24
  HistoryWorkerResult,
25
25
  } from "./history-worker";
26
- import { currentHistoryDbBusyTimeoutMs, historyBackupPathFor } from "./history-provider";
26
+ import { currentHistoryDbBusyTimeoutMs, resolveExistingHistoryBackupPath } from "./history-provider";
27
27
  import type { CodexHistoryFailureReason, CodexHistoryVerifiedNoopProof } from "./history-provider";
28
28
  import { getCodexHome, resolveCodexStateDbPath } from "./paths";
29
29
 
@@ -32,7 +32,7 @@ import { getCodexHome, resolveCodexStateDbPath } from "./paths";
32
32
  *
33
33
  * The SQLite root can differ from CODEX_HOME and both environment/config inputs
34
34
  * can change between invocations. The parent resolves one exact target and hands
35
- * those canonical paths to the Worker rather than asking the Worker to infer a
35
+ * those resolved paths to the Worker rather than asking the Worker to infer a
36
36
  * possibly different environment.
37
37
  */
38
38
  export function resolveCodexHistoryJobTarget(): {
@@ -45,10 +45,10 @@ export function resolveCodexHistoryJobTarget(): {
45
45
  return {
46
46
  canonicalCodexHome: home,
47
47
  canonicalStateDbPath: stateDb,
48
- // Derived by the provider's own rule rather than guessed: the manifest lives
49
- // in the config directory under a hash of the state database, so a
50
- // hand-built path would address a different file entirely.
51
- canonicalBackupPath: historyBackupPathFor(stateDb),
48
+ // Resolve through the provider's canonical-first compatibility rule. Passing
49
+ // only the newly normalized name would hide a pre-#4442 Windows manifest from
50
+ // the Worker and make an upgrade look like an empty backup.
51
+ canonicalBackupPath: resolveExistingHistoryBackupPath(stateDb),
52
52
  };
53
53
  }
54
54
 
@@ -1,7 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
- import { zstdDecompressSync } from "node:zlib";
4
+ import { parseSessionMetaLine, type ParsedSessionMeta } from "./history-rollout-read";
5
+ export { MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES, readThreadFieldsFromRollout, type RolloutThreadFields } from "./history-rollout-read";
5
6
  import { Database } from "bun:sqlite";
6
7
  import { resolveCodexStateDbPath } from "./paths";
7
8
  import { openCodexStateForPreflight } from "./history-state-open";
@@ -16,13 +17,6 @@ import {
16
17
  type CodexHistoryBackupManifest,
17
18
  } from "./history-manifest";
18
19
 
19
- /**
20
- * Cap for decompressing a lone `.jsonl.zst` rollout during quarantine restore.
21
- * Bounds peak memory while reconstructing thread rows; never write the decoded
22
- * JSONL to disk.
23
- */
24
- export const MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES = 64 * 1024 * 1024;
25
-
26
20
  /**
27
21
  * The manifest that shadows one state database.
28
22
  *
@@ -441,18 +435,32 @@ export function preflightCodexHistoryInjection(
441
435
  const paginatedColumn = columns.some(column => column.name === "history_mode");
442
436
  if (paginatedColumn && restoreEntries.length > 0) return HISTORY_RELABEL_STANDS_DOWN;
443
437
  for (const entry of restoreEntries) assertLegacyHistoryWritable(entry.rolloutPath);
444
- const rows = db.query<{ rollout_path: string; history_mode: string | null }, []>(`
445
- SELECT rollout_path, ${paginatedColumn ? "history_mode" : "NULL AS history_mode"}
438
+ const rows = db.query<{ rollout_path: string; history_mode: string | null; model_provider: string }, []>(`
439
+ SELECT rollout_path, ${paginatedColumn ? "history_mode" : "NULL AS history_mode"}, model_provider
446
440
  FROM threads
447
441
  WHERE ${providerTableMode
448
442
  ? resumeHistory ? "model_provider IN ('openai', 'opencodex')" : "0"
449
443
  : "model_provider = 'opencodex'"}
450
444
  `).all();
445
+ // No ORDER BY: a paginated opencodex row can precede a paginated openai one, so the
446
+ // verdict waits for the full scan instead of standing down on the first paginated row.
447
+ let foundPaginatedRow = false;
448
+ let foundPaginatedOpenaiRow = false;
451
449
  for (const row of rows) {
452
- if (paginatedColumn || row.history_mode === "paginated") return HISTORY_RELABEL_STANDS_DOWN;
450
+ if (paginatedColumn || row.history_mode === "paginated") {
451
+ foundPaginatedRow = true;
452
+ // A provider-table transition removes the root openai_base_url, and a row already
453
+ // paginated cannot be relabeled: standing it down would route the openai-tagged
454
+ // thread to Codex's built-in OpenAI endpoint. A still-legacy row only stands down.
455
+ if (providerTableMode && row.history_mode === "paginated" && row.model_provider === "openai") {
456
+ foundPaginatedOpenaiRow = true;
457
+ }
458
+ continue;
459
+ }
453
460
  assertLegacyHistoryWritable(row.rollout_path);
454
461
  }
455
- return null;
462
+ if (foundPaginatedOpenaiRow) return "history_paginated_openai_requires_native_writer";
463
+ return foundPaginatedRow ? HISTORY_RELABEL_STANDS_DOWN : null;
456
464
  } catch (error) {
457
465
  return error instanceof CodexHistoryIntegrityError
458
466
  ? error.message
@@ -1021,24 +1029,6 @@ function assertRestoreReadback(
1021
1029
  }
1022
1030
  }
1023
1031
 
1024
- interface ParsedSessionMeta {
1025
- record: { type?: unknown; timestamp?: unknown; payload: { model_provider?: unknown; source?: unknown } & Record<string, unknown> };
1026
- }
1027
-
1028
- /** Parse one JSONL line into a `session_meta` record, or null if it isn't one. */
1029
- function parseSessionMetaLine(line: string): ParsedSessionMeta | null {
1030
- let parsed: unknown;
1031
- try {
1032
- parsed = JSON.parse(line);
1033
- } catch {
1034
- return null;
1035
- }
1036
- if (!parsed || typeof parsed !== "object") return null;
1037
- const record = parsed as ParsedSessionMeta["record"];
1038
- if (record.type !== "session_meta" || !record.payload || typeof record.payload !== "object") return null;
1039
- return { record };
1040
- }
1041
-
1042
1032
  /**
1043
1033
  * Find the LAST `session_meta` line in a rollout, mirroring the app's last-writer-wins fold
1044
1034
  * (codex-rs `apply_session_meta_from_item`). We base our patch on the most recent metadata so we
@@ -1107,142 +1097,6 @@ function compensateConcurrentSessionMetaAppend(
1107
1097
  }
1108
1098
  }
1109
1099
 
1110
- /**
1111
- * Fields needed to re-insert a production-shaped `threads` row from a rollout JSONL when a
1112
- * Phase-2 quarantine predates full `satellite-backup.json` thread snapshots.
1113
- *
1114
- * Uses the same last-writer-wins `session_meta` fold as {@link readLatestSessionMeta}, plus the
1115
- * first user-message preview (codex-rs `list.rs` / `EventMsg::UserMessage` path).
1116
- */
1117
- export interface RolloutThreadFields {
1118
- id: string;
1119
- modelProvider: string;
1120
- source: string;
1121
- firstUserMessage: string;
1122
- hasUserEvent: number;
1123
- cwd?: string;
1124
- historyMode?: string;
1125
- cliVersion?: string;
1126
- }
1127
-
1128
- function textFromContentParts(content: unknown): string | null {
1129
- if (typeof content === "string" && content.trim()) return content.trim();
1130
- if (!Array.isArray(content)) return null;
1131
- const parts: string[] = [];
1132
- for (const part of content) {
1133
- if (!part || typeof part !== "object") continue;
1134
- const p = part as Record<string, unknown>;
1135
- if (typeof p.text === "string" && p.text.trim()) parts.push(p.text.trim());
1136
- else if (typeof p.input_text === "string" && p.input_text.trim()) parts.push(p.input_text.trim());
1137
- }
1138
- const joined = parts.join("\n").trim();
1139
- return joined || null;
1140
- }
1141
-
1142
- /** Extract the first user-message preview from a rollout line, or null. */
1143
- function extractUserMessagePreview(line: string): string | null {
1144
- let parsed: unknown;
1145
- try {
1146
- parsed = JSON.parse(line);
1147
- } catch {
1148
- return null;
1149
- }
1150
- if (!parsed || typeof parsed !== "object") return null;
1151
- const record = parsed as { type?: unknown; payload?: unknown };
1152
- const payload = record.payload;
1153
- if (!payload || typeof payload !== "object") return null;
1154
- const p = payload as Record<string, unknown>;
1155
-
1156
- if (record.type === "event_msg") {
1157
- // codex-rs EventMsg::UserMessage — payload.type is "user_message" (or omitted in fixtures).
1158
- if (p.type === "user_message" || typeof p.message === "string") {
1159
- if (typeof p.message === "string" && p.message.trim()) return p.message.trim();
1160
- const fromContent = textFromContentParts(p.content);
1161
- if (fromContent) return fromContent;
1162
- }
1163
- return null;
1164
- }
1165
-
1166
- if (record.type === "response_item") {
1167
- if (p.type === "message" && p.role === "user") {
1168
- return textFromContentParts(p.content);
1169
- }
1170
- }
1171
- return null;
1172
- }
1173
-
1174
- /**
1175
- * Reconstruct thread identity + listing fields from a staged/restored rollout JSONL.
1176
- * Returns null when the file is missing or has no parseable `session_meta`.
1177
- *
1178
- * Accepts plain `.jsonl` or a lone `.jsonl.zst` (legacy Phase-2 quarantine). Compressed
1179
- * rollouts are decompressed in memory with {@link MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES};
1180
- * no decompressed copy is written to disk.
1181
- */
1182
- export function readThreadFieldsFromRollout(path: string): RolloutThreadFields | null {
1183
- if (!path || !existsSync(path)) return null;
1184
- let raw: string;
1185
- try {
1186
- raw = path.endsWith(".zst")
1187
- ? decompressRolloutZstUtf8(path)
1188
- : readFileSync(path, "utf8");
1189
- } catch {
1190
- return null;
1191
- }
1192
- return parseThreadFieldsFromRolloutText(raw);
1193
- }
1194
-
1195
- function decompressRolloutZstUtf8(
1196
- path: string,
1197
- maxBytes: number = MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES,
1198
- ): string {
1199
- const compressed = readFileSync(path);
1200
- const decoded = zstdDecompressSync(compressed as Uint8Array<ArrayBuffer>, {
1201
- maxOutputLength: maxBytes,
1202
- });
1203
- if (decoded.byteLength > maxBytes) {
1204
- throw new Error("rollout_zst_too_large");
1205
- }
1206
- return new TextDecoder().decode(decoded);
1207
- }
1208
-
1209
- function parseThreadFieldsFromRolloutText(raw: string): RolloutThreadFields | null {
1210
- const lines = raw.split("\n");
1211
- let latest: ParsedSessionMeta | null = null;
1212
- let firstUserMessage = "";
1213
- for (const line of lines) {
1214
- if (!line) continue;
1215
- if (line.includes("\"session_meta\"")) {
1216
- const meta = parseSessionMetaLine(line);
1217
- if (meta) latest = meta;
1218
- }
1219
- if (!firstUserMessage) {
1220
- const preview = extractUserMessagePreview(line);
1221
- if (preview) firstUserMessage = preview;
1222
- }
1223
- }
1224
- if (!latest) return null;
1225
- const payload = latest.record.payload;
1226
- const id = typeof payload.id === "string" ? payload.id : "";
1227
- if (!id) return null;
1228
- const modelProvider = typeof payload.model_provider === "string" && payload.model_provider
1229
- ? payload.model_provider
1230
- : "openai";
1231
- const source = typeof payload.source === "string" && payload.source
1232
- ? payload.source
1233
- : "cli";
1234
- return {
1235
- id,
1236
- modelProvider,
1237
- source,
1238
- firstUserMessage,
1239
- hasUserEvent: firstUserMessage.trim() ? 1 : 0,
1240
- ...(typeof payload.cwd === "string" ? { cwd: payload.cwd } : {}),
1241
- ...(typeof payload.history_mode === "string" ? { historyMode: payload.history_mode } : {}),
1242
- ...(typeof payload.cli_version === "string" ? { cliVersion: payload.cli_version } : {}),
1243
- };
1244
- }
1245
-
1246
1100
  /**
1247
1101
  * Make a thread's rollout reflect a provider/source change by APPENDING a new `session_meta` line,
1248
1102
  * rather than rewriting line 1. The appended line clones the latest metadata payload (so no field
@@ -0,0 +1,174 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { zstdDecompressSync } from "node:zlib";
3
+
4
+ /**
5
+ * Reading side of Codex rollout JSONL files: the `session_meta` fold and the thread fields a
6
+ * quarantine restore reconstructs from a rollout.
7
+ *
8
+ * Split out of `history-provider.ts` when that file crossed the repository file-size ratchet at
9
+ * 2009 lines. The cut follows a real seam rather than a line count: everything here reads a
10
+ * rollout file and returns plain data, while the module it left keeps the database and manifest
11
+ * writes. Nothing here opens a database or touches a backup manifest.
12
+ *
13
+ * `history-provider.ts` re-exports the public names, so existing importers are unaffected.
14
+ */
15
+ /**
16
+ * Cap for decompressing a lone `.jsonl.zst` rollout during quarantine restore.
17
+ * Bounds peak memory while reconstructing thread rows; never write the decoded
18
+ * JSONL to disk.
19
+ */
20
+ export const MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES = 64 * 1024 * 1024;
21
+
22
+ export interface ParsedSessionMeta {
23
+ record: { type?: unknown; timestamp?: unknown; payload: { model_provider?: unknown; source?: unknown } & Record<string, unknown> };
24
+ }
25
+
26
+ /** Parse one JSONL line into a `session_meta` record, or null if it isn't one. */
27
+ export function parseSessionMetaLine(line: string): ParsedSessionMeta | null {
28
+ let parsed: unknown;
29
+ try {
30
+ parsed = JSON.parse(line);
31
+ } catch {
32
+ return null;
33
+ }
34
+ if (!parsed || typeof parsed !== "object") return null;
35
+ const record = parsed as ParsedSessionMeta["record"];
36
+ if (record.type !== "session_meta" || !record.payload || typeof record.payload !== "object") return null;
37
+ return { record };
38
+ }
39
+
40
+ /**
41
+ * Fields needed to re-insert a production-shaped `threads` row from a rollout JSONL when a
42
+ * Phase-2 quarantine predates full `satellite-backup.json` thread snapshots.
43
+ *
44
+ * Uses the same last-writer-wins `session_meta` fold as {@link readLatestSessionMeta}, plus the
45
+ * first user-message preview (codex-rs `list.rs` / `EventMsg::UserMessage` path).
46
+ */
47
+ export interface RolloutThreadFields {
48
+ id: string;
49
+ modelProvider: string;
50
+ source: string;
51
+ firstUserMessage: string;
52
+ hasUserEvent: number;
53
+ cwd?: string;
54
+ historyMode?: string;
55
+ cliVersion?: string;
56
+ }
57
+
58
+ function textFromContentParts(content: unknown): string | null {
59
+ if (typeof content === "string" && content.trim()) return content.trim();
60
+ if (!Array.isArray(content)) return null;
61
+ const parts: string[] = [];
62
+ for (const part of content) {
63
+ if (!part || typeof part !== "object") continue;
64
+ const p = part as Record<string, unknown>;
65
+ if (typeof p.text === "string" && p.text.trim()) parts.push(p.text.trim());
66
+ else if (typeof p.input_text === "string" && p.input_text.trim()) parts.push(p.input_text.trim());
67
+ }
68
+ const joined = parts.join("\n").trim();
69
+ return joined || null;
70
+ }
71
+
72
+ /** Extract the first user-message preview from a rollout line, or null. */
73
+ function extractUserMessagePreview(line: string): string | null {
74
+ let parsed: unknown;
75
+ try {
76
+ parsed = JSON.parse(line);
77
+ } catch {
78
+ return null;
79
+ }
80
+ if (!parsed || typeof parsed !== "object") return null;
81
+ const record = parsed as { type?: unknown; payload?: unknown };
82
+ const payload = record.payload;
83
+ if (!payload || typeof payload !== "object") return null;
84
+ const p = payload as Record<string, unknown>;
85
+
86
+ if (record.type === "event_msg") {
87
+ // codex-rs EventMsg::UserMessage — payload.type is "user_message" (or omitted in fixtures).
88
+ if (p.type === "user_message" || typeof p.message === "string") {
89
+ if (typeof p.message === "string" && p.message.trim()) return p.message.trim();
90
+ const fromContent = textFromContentParts(p.content);
91
+ if (fromContent) return fromContent;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ if (record.type === "response_item") {
97
+ if (p.type === "message" && p.role === "user") {
98
+ return textFromContentParts(p.content);
99
+ }
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /**
105
+ * Reconstruct thread identity + listing fields from a staged/restored rollout JSONL.
106
+ * Returns null when the file is missing or has no parseable `session_meta`.
107
+ *
108
+ * Accepts plain `.jsonl` or a lone `.jsonl.zst` (legacy Phase-2 quarantine). Compressed
109
+ * rollouts are decompressed in memory with {@link MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES};
110
+ * no decompressed copy is written to disk.
111
+ */
112
+ export function readThreadFieldsFromRollout(path: string): RolloutThreadFields | null {
113
+ if (!path || !existsSync(path)) return null;
114
+ let raw: string;
115
+ try {
116
+ raw = path.endsWith(".zst")
117
+ ? decompressRolloutZstUtf8(path)
118
+ : readFileSync(path, "utf8");
119
+ } catch {
120
+ return null;
121
+ }
122
+ return parseThreadFieldsFromRolloutText(raw);
123
+ }
124
+
125
+ function decompressRolloutZstUtf8(
126
+ path: string,
127
+ maxBytes: number = MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES,
128
+ ): string {
129
+ const compressed = readFileSync(path);
130
+ const decoded = zstdDecompressSync(compressed as Uint8Array<ArrayBuffer>, {
131
+ maxOutputLength: maxBytes,
132
+ });
133
+ if (decoded.byteLength > maxBytes) {
134
+ throw new Error("rollout_zst_too_large");
135
+ }
136
+ return new TextDecoder().decode(decoded);
137
+ }
138
+
139
+ function parseThreadFieldsFromRolloutText(raw: string): RolloutThreadFields | null {
140
+ const lines = raw.split("\n");
141
+ let latest: ParsedSessionMeta | null = null;
142
+ let firstUserMessage = "";
143
+ for (const line of lines) {
144
+ if (!line) continue;
145
+ if (line.includes("\"session_meta\"")) {
146
+ const meta = parseSessionMetaLine(line);
147
+ if (meta) latest = meta;
148
+ }
149
+ if (!firstUserMessage) {
150
+ const preview = extractUserMessagePreview(line);
151
+ if (preview) firstUserMessage = preview;
152
+ }
153
+ }
154
+ if (!latest) return null;
155
+ const payload = latest.record.payload;
156
+ const id = typeof payload.id === "string" ? payload.id : "";
157
+ if (!id) return null;
158
+ const modelProvider = typeof payload.model_provider === "string" && payload.model_provider
159
+ ? payload.model_provider
160
+ : "openai";
161
+ const source = typeof payload.source === "string" && payload.source
162
+ ? payload.source
163
+ : "cli";
164
+ return {
165
+ id,
166
+ modelProvider,
167
+ source,
168
+ firstUserMessage,
169
+ hasUserEvent: firstUserMessage.trim() ? 1 : 0,
170
+ ...(typeof payload.cwd === "string" ? { cwd: payload.cwd } : {}),
171
+ ...(typeof payload.history_mode === "string" ? { historyMode: payload.history_mode } : {}),
172
+ ...(typeof payload.cli_version === "string" ? { cliVersion: payload.cli_version } : {}),
173
+ };
174
+ }
@@ -1,4 +1,4 @@
1
- import { chmodSync, linkSync, mkdirSync, truncateSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, linkSync, mkdirSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
 
4
4
  import {
@@ -37,6 +37,38 @@ export interface CatalogBackupWriteIO {
37
37
 
38
38
  let backupTempSequence = 0;
39
39
 
40
+ /**
41
+ * Do the prepared bytes differ from what is already on disk at `prepared.path`?
42
+ *
43
+ * The single no-op rule for both files the catalog layer owns — the active catalog
44
+ * and Codex's models cache — so the two writers cannot drift apart. Every
45
+ * mtime-keyed reader has to treat a rewrite as a change: the app-server staleness
46
+ * classifier (#857) compares a file's mtime against each running Codex's start
47
+ * time, so rewriting identical bytes marks every already-running Codex as stale
48
+ * even though nothing changed. #1459 established this rule for the catalog; the
49
+ * cache is the second writer that has to apply it, because
50
+ * `refreshCodexModelCatalog` reports `cacheSynced` straight into the startup
51
+ * warning in `handleStart`.
52
+ *
53
+ * Deliberately a Buffer rather than a decoded string: `readFileSync(path, "utf8")`
54
+ * substitutes U+FFFD for every invalid byte, so a file holding a raw 0x80 decodes
55
+ * equal to prepared content holding a legitimately encoded U+FFFD. Comparing
56
+ * decoded strings would then classify a malformed file as identical, skip the
57
+ * atomic repair write, and leave the corruption on disk.
58
+ *
59
+ * An unreadable or absent file reports "differs", so the caller performs the real
60
+ * write; that also converges a file that does not exist yet.
61
+ */
62
+ export function preparedBytesDifferFromDisk(prepared: PreparedCatalogFileWrite): boolean {
63
+ let onDisk: Buffer;
64
+ try {
65
+ onDisk = readFileSync(prepared.path);
66
+ } catch {
67
+ return true;
68
+ }
69
+ return !onDisk.equals(Buffer.from(prepared.content, "utf8"));
70
+ }
71
+
40
72
  function isMissingPathError(error: unknown): boolean {
41
73
  return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
42
74
  }
@@ -71,6 +71,8 @@ function deleteCachedProvider(provider: string): number {
71
71
  cache.delete(provider);
72
72
  cacheBytes = Math.max(0, cacheBytes - entry.sizeBytes);
73
73
  if (oldestCachedProvider === provider) recomputeOldestCachedProvider();
74
+ // A removal changes this provider's content as surely as a publication does.
75
+ bumpProviderCacheRevision(provider);
74
76
  return entry.sizeBytes;
75
77
  }
76
78
 
@@ -177,6 +179,34 @@ export function isModelCacheGenerationCurrent(provider: string, generation: stri
177
179
  return generation === captureModelCacheGeneration(provider);
178
180
  }
179
181
 
182
+ /**
183
+ * How many times this provider's cached content has actually changed.
184
+ *
185
+ * Deliberately separate from the generation. A generation revokes an in-flight discovery's right
186
+ * to publish, so it advances on an authority clear and must not be repurposed: moving it on a
187
+ * successful publication would cancel writes that are still legitimate. This counts accepted
188
+ * publications and removals instead, which is what a reader holding a derived roster needs to
189
+ * know, and it advances on exactly the event a generation does not: a discovery that succeeded
190
+ * and changed the rows.
191
+ *
192
+ * Reading is passive. An unseen provider reads as `0` rather than seeding an entry, so observing
193
+ * one cannot alter what a later capture or publication sees.
194
+ */
195
+ const providerCacheRevisions = new Map<string, number>();
196
+ let globalContentRevision = 0;
197
+
198
+ function bumpProviderCacheRevision(provider: string): void {
199
+ providerCacheRevisions.set(provider, (providerCacheRevisions.get(provider) ?? 0) + 1);
200
+ }
201
+
202
+ export function observeModelCacheRevision(provider: string): string {
203
+ // The global term covers a clear that empties the map wholesale, which per-provider counters
204
+ // cannot express: without it, wiping every entry and republishing identical-looking rows would
205
+ // read as unchanged. It also survives pruning, so a retired provider cannot come back with a
206
+ // counter that matches a roster built before it left.
207
+ return `${globalContentRevision}:${providerCacheRevisions.get(provider) ?? 0}`;
208
+ }
209
+
180
210
  /**
181
211
  * Store a live result unless the cache was cleared while that asynchronous discovery was running.
182
212
  * The optional generation keeps existing direct cache writers unchanged while discovery callers can
@@ -200,6 +230,8 @@ export function setCached(
200
230
  oldestCachedAt = now;
201
231
  }
202
232
  enforceAppOwnedMemoryBudget();
233
+ // Published and accepted, so anything derived from this provider's rows is now out of date.
234
+ bumpProviderCacheRevision(provider);
203
235
  return true;
204
236
  }
205
237
 
@@ -220,6 +252,11 @@ export function clearModelCache(
220
252
  } else {
221
253
  if (revokesInFlightDiscovery) globalCacheGeneration += 1;
222
254
  cache.clear();
255
+ // A wholesale clear changes every provider's content at once, and clearing the map means no
256
+ // per-provider counter can record it. Advancing the global term retires every derived roster
257
+ // and lets the per-provider entries be dropped without an ABA on the way back.
258
+ globalContentRevision += 1;
259
+ providerCacheRevisions.clear();
223
260
  cacheBytes = 0;
224
261
  oldestCachedProvider = undefined;
225
262
  oldestCachedAt = null;
@@ -237,6 +274,7 @@ export function reconcileModelCacheProviders(
237
274
  const removedProviders = new Set<string>();
238
275
  const trackedProviders = new Set([
239
276
  ...providerCacheGenerations.keys(),
277
+ ...providerCacheRevisions.keys(),
240
278
  ...failureAt.keys(),
241
279
  ...discoveryStatus.keys(),
242
280
  ...liveModelCounts.keys(),
@@ -247,6 +285,10 @@ export function reconcileModelCacheProviders(
247
285
  if (validProviders.has(provider)) continue;
248
286
  if (!revokedRemovedProviderAuthority) {
249
287
  globalCacheGeneration += 1;
288
+ // Advanced BEFORE any revision entry is dropped, so a provider that comes back cannot
289
+ // present the counter a roster was built against. Without this the delete below is an ABA:
290
+ // the entry returns at zero and an old stamp matches again.
291
+ globalContentRevision += 1;
250
292
  revokedRemovedProviderAuthority = true;
251
293
  }
252
294
  providerCacheGenerations.set(provider, (providerCacheGenerations.get(provider) ?? 0) + 1);
@@ -255,6 +297,11 @@ export function reconcileModelCacheProviders(
255
297
  failureAt.delete(provider);
256
298
  discoveryStatus.delete(provider);
257
299
  liveModelCounts.delete(provider);
300
+ // AFTER the cache deletion, which bumps this provider's revision and would otherwise recreate
301
+ // the entry we just removed. Dropped rather than left behind: a provider the configuration no
302
+ // longer has must not keep an entry alive for the life of the process merely because nothing
303
+ // cleared the whole cache. The global epoch advanced above, so the removal is not an ABA.
304
+ providerCacheRevisions.delete(provider);
258
305
  removedProviders.add(provider);
259
306
  }
260
307
  lastReconciledGeneration = generation;