@bitkyc08/opencodex 2.11.1 → 2.12.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 (175) hide show
  1. package/README.md +1 -1
  2. package/bin/ocx.mjs +4 -0
  3. package/gui/dist/assets/index-BNVYzdn0.css +1 -0
  4. package/gui/dist/assets/index-Cw1_mxO8.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/command-code.ts +94 -5
  8. package/src/adapters/cursor/live-transport.ts +14 -1
  9. package/src/adapters/google-tool-schema.ts +80 -15
  10. package/src/adapters/google.ts +24 -7
  11. package/src/adapters/openai-chat.ts +233 -305
  12. package/src/adapters/openai-responses.ts +62 -0
  13. package/src/adapters/tool-catalog-nudge.ts +2 -11
  14. package/src/claude/outbound.ts +42 -14
  15. package/src/cli/claude.ts +61 -9
  16. package/src/cli/codex-shim-autorestore.ts +2 -0
  17. package/src/cli/codex-shim-readiness.ts +8 -1
  18. package/src/cli/config-command.ts +4 -1
  19. package/src/cli/doctor.ts +10 -0
  20. package/src/cli/help.ts +15 -0
  21. package/src/cli/index.ts +27 -11
  22. package/src/cli/lab.ts +367 -0
  23. package/src/cli/provider.ts +9 -1
  24. package/src/clients/config-export.ts +100 -3
  25. package/src/codex/app-server-processes.ts +11 -8
  26. package/src/codex/auth-api.ts +92 -28
  27. package/src/codex/auth-collision.ts +5 -3
  28. package/src/codex/catalog/metadata.ts +7 -3
  29. package/src/codex/catalog/provider-fetch.ts +191 -47
  30. package/src/codex/catalog.ts +2 -2
  31. package/src/codex/history-job.ts +83 -18
  32. package/src/codex/history-migration-guardian.ts +9 -16
  33. package/src/codex/history-provider.ts +178 -1
  34. package/src/codex/history-transition.ts +5 -5
  35. package/src/codex/history-worker.ts +22 -2
  36. package/src/codex/native-profile-store.ts +1 -1
  37. package/src/codex/plan.ts +15 -0
  38. package/src/codex/quota.ts +6 -6
  39. package/src/codex/routing.ts +3 -3
  40. package/src/codex/subagent-model-fallback.ts +246 -22
  41. package/src/codex/user-identity.ts +63 -12
  42. package/src/config.ts +265 -32
  43. package/src/images/loop.ts +3 -1
  44. package/src/images/plan.ts +23 -13
  45. package/src/integrations/omp-yaml-source.ts +225 -0
  46. package/src/integrations/ownership.ts +5 -5
  47. package/src/integrations/state.ts +45 -6
  48. package/src/integrations/writer.ts +38 -3
  49. package/src/lab/artifacts/sanitize.ts +586 -0
  50. package/src/lab/artifacts/secure-fs.ts +475 -0
  51. package/src/lab/artifacts/store.ts +310 -0
  52. package/src/lab/conformance/assertion.ts +325 -0
  53. package/src/lab/conformance/digest.ts +22 -0
  54. package/src/lab/conformance/executor.ts +741 -0
  55. package/src/lab/conformance/fixture-provider.ts +27 -0
  56. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  57. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  58. package/src/lab/conformance/harness-budget.ts +47 -0
  59. package/src/lab/conformance/index.ts +5 -0
  60. package/src/lab/conformance/jcs.ts +24 -0
  61. package/src/lab/conformance/json-pointer.ts +39 -0
  62. package/src/lab/conformance/manifest.ts +180 -0
  63. package/src/lab/conformance/mcp-stub.ts +179 -0
  64. package/src/lab/conformance/negative-controls.ts +160 -0
  65. package/src/lab/conformance/observation.ts +355 -0
  66. package/src/lab/conformance/runner.ts +57 -0
  67. package/src/lab/conformance/sse-normalize.ts +59 -0
  68. package/src/lab/conformance/suite-manifest.ts +78 -0
  69. package/src/lab/conformance/types.ts +191 -0
  70. package/src/lab/constants.ts +126 -0
  71. package/src/lab/digest.ts +64 -0
  72. package/src/lab/events/errors.ts +9 -0
  73. package/src/lab/events/limits.ts +117 -0
  74. package/src/lab/events/types.ts +228 -0
  75. package/src/lab/events/validate.ts +780 -0
  76. package/src/lab/index.ts +36 -0
  77. package/src/lab/ledger/artifact-refs.ts +127 -0
  78. package/src/lab/ledger/invalidation.ts +136 -0
  79. package/src/lab/ledger/purge.ts +241 -0
  80. package/src/lab/ledger/store.ts +263 -0
  81. package/src/lab/live/credential-lease.ts +53 -0
  82. package/src/lab/live/destination.ts +155 -0
  83. package/src/lab/live/executor.ts +311 -0
  84. package/src/lab/live/inert-tools.ts +56 -0
  85. package/src/lab/live/manifest.ts +85 -0
  86. package/src/lab/live/mcp-loopback.ts +57 -0
  87. package/src/lab/live/runner.ts +19 -0
  88. package/src/lab/live/sandbox.ts +61 -0
  89. package/src/lab/live/suite-manifest.ts +41 -0
  90. package/src/lab/live/transport.ts +116 -0
  91. package/src/lab/live/types.ts +197 -0
  92. package/src/lab/observe/from-conformance.ts +344 -0
  93. package/src/lab/observe/from-live.ts +103 -0
  94. package/src/lab/paths.ts +65 -0
  95. package/src/lab/projection/rebuild.ts +477 -0
  96. package/src/lab/projection/schema.ts +122 -0
  97. package/src/lab/projection/verdicts.ts +438 -0
  98. package/src/lab/projection/verification.ts +332 -0
  99. package/src/lab/query/catalog.ts +72 -0
  100. package/src/lab/query/connection.ts +107 -0
  101. package/src/lab/query/constants.ts +4 -0
  102. package/src/lab/query/cursor.ts +132 -0
  103. package/src/lab/query/dto-map.ts +277 -0
  104. package/src/lab/query/errors.ts +22 -0
  105. package/src/lab/query/index.ts +34 -0
  106. package/src/lab/query/queries.ts +444 -0
  107. package/src/lab/query/types.ts +266 -0
  108. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  109. package/src/lab/subject/installation-salt.ts +81 -0
  110. package/src/lab/subject/route-subject.ts +73 -0
  111. package/src/lib/bounded-body.ts +104 -0
  112. package/src/lib/lab-live-execution-authority.ts +13 -0
  113. package/src/lib/lab-live-host.ts +30 -0
  114. package/src/lib/lab-live-pinned-sender.ts +45 -0
  115. package/src/lib/pinned-http.ts +36 -7
  116. package/src/oauth/index.ts +20 -1
  117. package/src/oauth/local-token-detect.ts +11 -2
  118. package/src/oauth/login-cli.ts +38 -4
  119. package/src/providers/alibaba-region-migration.ts +1 -1
  120. package/src/providers/codex-capacity.ts +7 -4
  121. package/src/providers/command-code-efforts.ts +5 -2
  122. package/src/providers/derive.ts +105 -0
  123. package/src/providers/label.ts +1 -1
  124. package/src/providers/openai-tiers.ts +59 -3
  125. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  126. package/src/providers/quota.ts +67 -21
  127. package/src/providers/registry.ts +118 -5
  128. package/src/router.ts +15 -1
  129. package/src/routing/analytics.ts +8 -7
  130. package/src/routing/evaluator.ts +51 -5
  131. package/src/routing/profile.ts +10 -1
  132. package/src/routing/trace.ts +42 -6
  133. package/src/server/auth-cors.ts +15 -0
  134. package/src/server/background-lifecycle.ts +169 -0
  135. package/src/server/chat-completions.ts +18 -3
  136. package/src/server/claude-messages.ts +1 -0
  137. package/src/server/images.ts +78 -11
  138. package/src/server/index.ts +72 -18
  139. package/src/server/lifecycle.ts +6 -0
  140. package/src/server/live.ts +5 -1
  141. package/src/server/management/agent-settings-routes.ts +41 -24
  142. package/src/server/management/body.ts +6 -0
  143. package/src/server/management/config-routes.ts +51 -8
  144. package/src/server/management/lab-routes.ts +388 -0
  145. package/src/server/management/logs-usage-routes.ts +19 -1
  146. package/src/server/management/model-rows.ts +25 -11
  147. package/src/server/management/native-integration-routes.ts +3 -3
  148. package/src/server/management/provider-routes.ts +5 -0
  149. package/src/server/management/shared.ts +8 -1
  150. package/src/server/management/usage-summary-cache.ts +2 -0
  151. package/src/server/management/vision-sidecar-options.ts +125 -0
  152. package/src/server/management-api.ts +2 -0
  153. package/src/server/request-decompress.ts +106 -5
  154. package/src/server/responses/collaboration.ts +4 -1
  155. package/src/server/responses/core.ts +117 -31
  156. package/src/server/responses-json-events.ts +50 -12
  157. package/src/server/search.ts +23 -7
  158. package/src/server/ws-bridge.ts +3 -1
  159. package/src/service.ts +40 -20
  160. package/src/types.ts +74 -0
  161. package/src/update/job.ts +10 -2
  162. package/src/usage/cost.ts +0 -0
  163. package/src/usage/expected-prices.ts +9 -0
  164. package/src/usage/log.ts +9 -9
  165. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  166. package/src/usage/user-cost-overlays.ts +314 -0
  167. package/src/vision/describe.ts +2 -2
  168. package/src/vision/eligibility.ts +229 -0
  169. package/src/vision/index.ts +20 -2
  170. package/src/web-search/executor.ts +2 -2
  171. package/src/web-search/index.ts +4 -2
  172. package/src/web-search/loop.ts +3 -1
  173. package/src/web-search/parse.ts +72 -3
  174. package/gui/dist/assets/index-BA1dgo4q.css +0 -1
  175. package/gui/dist/assets/index-DDZpgzKk.js +0 -70
@@ -25,7 +25,7 @@ import type {
25
25
  HistoryWorkerResult,
26
26
  } from "./history-worker";
27
27
  import { historyBackupPathFor } from "./history-provider";
28
- import type { CodexHistoryFailureReason } from "./history-provider";
28
+ import type { CodexHistoryFailureReason, CodexHistoryVerifiedNoopProof } from "./history-provider";
29
29
  import { getCodexHome } from "./paths";
30
30
 
31
31
  /** Where Codex keeps its resume history, and the manifest that shadows it. */
@@ -59,6 +59,47 @@ export function resolveCodexHistoryJobTarget(): {
59
59
 
60
60
  /** How long a history unit may run before the parent stops waiting on it. */
61
61
  const WORKER_TIMEOUT_MS = 30_000;
62
+ /** A terminated Worker must close before its caller can cross the file boundary. */
63
+ const WORKER_CLOSE_TIMEOUT_MS = 5_000;
64
+
65
+ function historyWorkerOsJoinSettleMs(platform = process.platform): number {
66
+ if (platform === "win32") return 1_500;
67
+ if (platform === "darwin" || platform === "linux") return 250;
68
+ return 0;
69
+ }
70
+
71
+ async function terminateAndJoinHistoryWorker(
72
+ worker: Worker,
73
+ closed: Promise<void>,
74
+ ): Promise<boolean> {
75
+ let timedOut = false;
76
+ let timeout: ReturnType<typeof setTimeout> | undefined;
77
+ const closeTimeout = new Promise<void>(resolve => {
78
+ timeout = setTimeout(() => {
79
+ timedOut = true;
80
+ resolve();
81
+ }, WORKER_CLOSE_TIMEOUT_MS);
82
+ });
83
+
84
+ try {
85
+ worker.terminate();
86
+ } catch {
87
+ // A Worker that already closed still resolves through the close listener.
88
+ }
89
+
90
+ try {
91
+ await Promise.race([closed, closeTimeout]);
92
+ } finally {
93
+ if (timeout) clearTimeout(timeout);
94
+ }
95
+
96
+ const settleMs = historyWorkerOsJoinSettleMs();
97
+ if (settleMs > 0) {
98
+ await Bun.sleep(0);
99
+ await Bun.sleep(settleMs);
100
+ }
101
+ return !timedOut;
102
+ }
62
103
 
63
104
  export interface CodexHistoryJobRequest {
64
105
  readonly canonicalCodexHome: string;
@@ -69,7 +110,7 @@ export interface CodexHistoryJobRequest {
69
110
  }
70
111
 
71
112
  export type CodexHistoryJobOutcome =
72
- | { readonly kind: "converged"; readonly rows: number; readonly files: number }
113
+ | { readonly kind: "converged"; readonly rows: number; readonly files: number; readonly proof?: CodexHistoryVerifiedNoopProof }
73
114
  | { readonly kind: "skipped" }
74
115
  | { readonly kind: "blocked"; readonly reason: "busy" | "database" | "unsafe-path" | "desired_disabled" | "desired_enabled" }
75
116
  | { readonly kind: "failed"; readonly reason: "worker-error" | "worker-died" | "timeout";
@@ -92,8 +133,9 @@ export function isPlausibleWorkerResultForTests(
92
133
  message: Record<string, unknown>,
93
134
  requestId: string,
94
135
  jobId: string,
136
+ target?: Pick<CodexHistoryJobRequest, "canonicalStateDbPath" | "canonicalBackupPath" | "operation">,
95
137
  ): boolean {
96
- return isPlausibleWorkerResult(message, requestId, jobId);
138
+ return isPlausibleWorkerResult(message, requestId, jobId, target);
97
139
  }
98
140
 
99
141
  /**
@@ -108,13 +150,30 @@ function isPlausibleWorkerResult(
108
150
  message: Record<string, unknown>,
109
151
  requestId: string,
110
152
  jobId: string,
153
+ target?: Pick<CodexHistoryJobRequest, "canonicalStateDbPath" | "canonicalBackupPath" | "operation">,
111
154
  ): boolean {
112
155
  if (message.requestId !== requestId || message.jobId !== jobId) return false;
113
156
  switch (message.type) {
114
157
  case "done":
115
- return (message.outcome === "converged" || message.outcome === "skipped")
158
+ if (!((message.outcome === "converged" || message.outcome === "skipped")
116
159
  && typeof message.rows === "number"
117
- && typeof message.files === "number";
160
+ && typeof message.files === "number")) return false;
161
+ if (message.proof === undefined) return true;
162
+ if (!target || !message.proof || typeof message.proof !== "object" || Array.isArray(message.proof)) return false;
163
+ {
164
+ const proof = message.proof as Record<string, unknown>;
165
+ return target.operation === "migrate-openai"
166
+ && message.outcome === "converged"
167
+ && message.rows === 0
168
+ && message.files === 0
169
+ && proof.kind === "verified-noop"
170
+ && proof.pendingRows === 0
171
+ && proof.backupEntries === 0
172
+ && proof.canonicalStateDbPath === target.canonicalStateDbPath
173
+ && proof.stateDbPresent === true
174
+ && proof.canonicalBackupPath === target.canonicalBackupPath
175
+ && typeof proof.backupPresent === "boolean";
176
+ }
118
177
  case "blocked":
119
178
  return message.reason === "busy" || message.reason === "database" || message.reason === "unsafe-path"
120
179
  || message.reason === "desired_disabled" || message.reason === "desired_enabled";
@@ -227,7 +286,7 @@ function classifyWorkerResult(result: HistoryWorkerResult): CodexHistoryJobOutco
227
286
  }
228
287
  return result.outcome === "skipped"
229
288
  ? { kind: "skipped" }
230
- : { kind: "converged", rows: result.rows, files: result.files };
289
+ : { kind: "converged", rows: result.rows, files: result.files, ...(result.proof ? { proof: result.proof } : {}) };
231
290
  }
232
291
 
233
292
  /**
@@ -263,20 +322,23 @@ export async function runCodexHistoryJob(
263
322
 
264
323
  return new Promise<CodexHistoryJobOutcome>(resolve => {
265
324
  let settled = false;
325
+ let resolveWorkerClosed!: () => void;
326
+ const workerClosed = new Promise<void>(closed => {
327
+ resolveWorkerClosed = closed;
328
+ });
266
329
  const finish = (outcome: CodexHistoryJobOutcome) => {
267
330
  if (settled) return;
268
331
  settled = true;
269
332
  clearTimeout(timer);
270
- // Terminate and join before settling: see the note above.
271
- const done = () => resolve(outcome);
272
- try {
273
- const terminated = worker.terminate() as unknown;
274
- if (terminated && typeof (terminated as Promise<unknown>).then === "function") {
275
- void (terminated as Promise<unknown>).then(done, done);
276
- return;
277
- }
278
- } catch { /* already gone */ }
279
- done();
333
+ // Bun 1.3.14 returns void from terminate(); resolving on that return lets
334
+ // the Worker outlive this job and, under `bun test --isolate`, its file.
335
+ void terminateAndJoinHistoryWorker(worker, workerClosed).then(joined => {
336
+ resolve(joined
337
+ ? outcome
338
+ : { kind: "failed", reason: "worker-died", message: "history_worker_close_timeout" });
339
+ }, () => {
340
+ resolve({ kind: "failed", reason: "worker-died", message: "history_worker_join_failed" });
341
+ });
280
342
  };
281
343
 
282
344
  const timer = setTimeout(() => {
@@ -303,7 +365,7 @@ export async function runCodexHistoryJob(
303
365
  died("history_worker_unknown_message_type");
304
366
  return;
305
367
  }
306
- if (!isPlausibleWorkerResult(message, requestId, jobId)) {
368
+ if (!isPlausibleWorkerResult(message, requestId, jobId, request)) {
307
369
  // A recognized type with a missing payload read as `converged` with
308
370
  // undefined fields once — success for work that may not have happened.
309
371
  died("history_worker_malformed_payload");
@@ -319,7 +381,10 @@ export async function runCodexHistoryJob(
319
381
  * and the Worker always closes after posting its result.
320
382
  */
321
383
  worker.addEventListener("messageerror", () => died("history_worker_unserializable_message"));
322
- worker.addEventListener("close", () => died("history_worker_closed_early"));
384
+ worker.addEventListener("close", () => {
385
+ resolveWorkerClosed();
386
+ died("history_worker_closed_early");
387
+ }, { once: true });
323
388
 
324
389
  worker.onerror = (event: ErrorEvent) => {
325
390
  finish({
@@ -1,4 +1,4 @@
1
- import { countPendingOpencodexHistory, migrateHistoryToOpenai } from "./history-provider";
1
+ import { migrateHistoryToOpenai } from "./history-provider";
2
2
  import { resolveCodexHistoryJobTarget, runCodexHistoryJob } from "./history-job";
3
3
 
4
4
  /**
@@ -23,7 +23,6 @@ export interface HistoryMigrationGuardianHandle {
23
23
  }
24
24
 
25
25
  export interface HistoryMigrationGuardianDeps {
26
- countFn?: typeof countPendingOpencodexHistory;
27
26
  migrateFn?: () => ReturnType<typeof migrateHistoryToOpenai>
28
27
  | Promise<ReturnType<typeof migrateHistoryToOpenai>>;
29
28
  log?: Pick<Console, "log">;
@@ -43,7 +42,6 @@ function defaultSchedule(fn: () => void, ms: number): { cancel(): void } {
43
42
  }
44
43
 
45
44
  export function startHistoryMigrationGuardian(deps: HistoryMigrationGuardianDeps = {}): HistoryMigrationGuardianHandle {
46
- const countFn = deps.countFn ?? countPendingOpencodexHistory;
47
45
  // The default migration goes through the history job, so the guardian's timer
48
46
  // thread never performs the transition itself. A background repair that races
49
47
  // an apply or a restore is exactly what H exists to order.
@@ -53,7 +51,7 @@ export function startHistoryMigrationGuardian(deps: HistoryMigrationGuardianDeps
53
51
  operation: "migrate-openai",
54
52
  });
55
53
  return outcome.kind === "converged"
56
- ? { rows: outcome.rows, files: outcome.files }
54
+ ? { rows: outcome.rows, files: outcome.files, verifiedNoop: outcome.proof?.kind === "verified-noop" }
57
55
  : { rows: 0, files: 0, failed: true as const };
58
56
  });
59
57
  const log = deps.log ?? console;
@@ -73,23 +71,18 @@ export function startHistoryMigrationGuardian(deps: HistoryMigrationGuardianDeps
73
71
  if (stopped) return;
74
72
  ticks++;
75
73
  try {
76
- const count = countFn();
77
- if (!count.failed && count.pendingRows === 0 && count.backupEntries === 0) {
78
- stopped = true; // nothing left to migrate — normal steady state, no log noise
79
- return;
80
- }
81
- // Locked probe or pending work: attempt one migration pass.
74
+ // The worker re-derives state under H on every pass; no pre-lock probe is
75
+ // allowed to stop or suppress this attempt.
82
76
  const result = await migrateFn();
83
77
  if (!result.failed) {
84
78
  const moved = result.rows + ((result as { ejectedRows?: number }).ejectedRows ?? 0);
85
79
  if (moved > 0) {
86
80
  log.log(`🩹 history-migration: ${moved} legacy opencodex thread(s) migrated back to openai.`);
87
81
  }
88
- // A "successful" zero-row migration can also mean the DB does not exist YET while a
89
- // backup manifest still holds restore work (fresh reinstall race). Only stop when a
90
- // re-count proves nothing is pending; otherwise keep ticking within the budget.
91
- const after = countFn();
92
- if (moved > 0 || (!after.failed && after.pendingRows === 0 && after.backupEntries === 0)) {
82
+ // Zero mutations are authoritative only when the worker verified the
83
+ // exact DB/manifest state while H was held.
84
+ const verifiedNoop = (result as { verifiedNoop?: boolean }).verifiedNoop === true;
85
+ if (moved > 0 || verifiedNoop) {
93
86
  stopped = true;
94
87
  return;
95
88
  }
@@ -99,7 +92,7 @@ export function startHistoryMigrationGuardian(deps: HistoryMigrationGuardianDeps
99
92
  }
100
93
  if (ticks >= maxTicks) {
101
94
  stopped = true;
102
- log.log("⚠️ history-migration: Codex history DB stayed locked; legacy threads not yet migrated. Close the Codex app and run 'ocx sync' (or check 'ocx doctor').");
95
+ log.log("⚠️ history-migration: Could not verify that legacy threads were migrated; the history database may be busy, unavailable, or not yet ready. Run 'ocx sync' (or check 'ocx doctor').");
103
96
  return;
104
97
  }
105
98
  schedule();
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, unlinkSync, writeSync } from "node:fs";
2
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { zstdDecompressSync } from "node:zlib";
5
5
  import { Database } from "bun:sqlite";
@@ -200,6 +200,49 @@ interface BackupManifest {
200
200
  entries: Record<string, BackupEntry>;
201
201
  }
202
202
 
203
+ export interface CodexHistoryVerifiedNoopProof {
204
+ readonly kind: "verified-noop";
205
+ readonly pendingRows: 0;
206
+ readonly backupEntries: 0;
207
+ readonly canonicalStateDbPath: string;
208
+ readonly stateDbPresent: true;
209
+ readonly canonicalBackupPath: string;
210
+ readonly backupPresent: boolean;
211
+ }
212
+
213
+ export type CodexHistoryNoopSnapshot =
214
+ | CodexHistoryVerifiedNoopProof
215
+ | {
216
+ readonly kind: "work-pending";
217
+ readonly pendingRows: number;
218
+ readonly backupEntries: number;
219
+ readonly canonicalStateDbPath: string;
220
+ readonly stateDbPresent: boolean;
221
+ readonly canonicalBackupPath: string;
222
+ readonly backupPresent: boolean;
223
+ }
224
+ | {
225
+ readonly kind: "unknown";
226
+ readonly pendingRows: null;
227
+ readonly backupEntries: null;
228
+ readonly canonicalStateDbPath: string;
229
+ readonly stateDbPresent: boolean;
230
+ readonly canonicalBackupPath: string;
231
+ readonly backupPresent: boolean;
232
+ readonly reason: "backup-path" | "database-absent" | "manifest-read" | "manifest-schema" | "manifest-foreign" | "database-query" | "snapshot-race";
233
+ };
234
+
235
+ type StrictBackupInspection =
236
+ | { readonly kind: "known"; readonly present: boolean; readonly entries: number; readonly fingerprint: string }
237
+ | { readonly kind: "unknown"; readonly present: boolean; readonly reason: "manifest-read" | "manifest-schema" | "manifest-foreign" };
238
+
239
+ let afterNoopPendingCountForTests: (() => void) | undefined;
240
+
241
+ /** Test seam: runs after the pending count and before stability validation. */
242
+ export function setAfterNoopPendingCountForTests(hook: (() => void) | undefined): void {
243
+ afterNoopPendingCountForTests = hook;
244
+ }
245
+
203
246
  interface NativeRestoreTarget {
204
247
  modelProvider: string;
205
248
  source: string;
@@ -212,6 +255,65 @@ function samePath(a: string, b: string): boolean {
212
255
  return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
213
256
  }
214
257
 
258
+ function inspectBackupForNoop(path: string, stateDbPath: string): StrictBackupInspection {
259
+ if (!existsSync(path)) return { kind: "known", present: false, entries: 0, fingerprint: "absent" };
260
+ let parsed: unknown;
261
+ let raw: string;
262
+ try {
263
+ raw = readFileSync(path, "utf8");
264
+ parsed = JSON.parse(raw);
265
+ } catch {
266
+ return { kind: "unknown", present: true, reason: "manifest-read" };
267
+ }
268
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
269
+ return { kind: "unknown", present: true, reason: "manifest-schema" };
270
+ }
271
+ const manifest = parsed as Partial<BackupManifest>;
272
+ if (manifest.version !== 1 || typeof manifest.stateDbPath !== "string") {
273
+ return { kind: "unknown", present: true, reason: "manifest-schema" };
274
+ }
275
+ if (!samePath(manifest.stateDbPath, stateDbPath)) {
276
+ return { kind: "unknown", present: true, reason: "manifest-foreign" };
277
+ }
278
+ if (!manifest.entries || typeof manifest.entries !== "object" || Array.isArray(manifest.entries)) {
279
+ return { kind: "unknown", present: true, reason: "manifest-schema" };
280
+ }
281
+ for (const [id, value] of Object.entries(manifest.entries)) {
282
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
283
+ return { kind: "unknown", present: true, reason: "manifest-schema" };
284
+ }
285
+ const entry = value as Partial<BackupEntry>;
286
+ if (entry.id !== id
287
+ || typeof entry.rolloutPath !== "string"
288
+ || typeof entry.modelProvider !== "string"
289
+ || typeof entry.source !== "string"
290
+ || typeof entry.hasUserEvent !== "number") {
291
+ return { kind: "unknown", present: true, reason: "manifest-schema" };
292
+ }
293
+ }
294
+ return {
295
+ kind: "known",
296
+ present: true,
297
+ entries: Object.keys(manifest.entries).length,
298
+ fingerprint: createHash("sha256").update(raw).digest("hex"),
299
+ };
300
+ }
301
+
302
+ function historyFileIdentity(path: string): string | null {
303
+ try {
304
+ const stat = statSync(path);
305
+ return [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(":");
306
+ } catch {
307
+ return null;
308
+ }
309
+ }
310
+
311
+ function readHistoryDataVersion(db: Database): number | null {
312
+ const row = db.query<{ data_version: number }, []>("PRAGMA data_version").get();
313
+ const value = row?.data_version;
314
+ return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
315
+ }
316
+
215
317
  function readBackup(path: string, stateDbPath?: string): BackupManifest {
216
318
  if (!existsSync(path)) return { version: 1, stateDbPath, entries: {} };
217
319
  try {
@@ -756,6 +858,81 @@ export function migrateHistoryToOpenai(
756
858
  return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason };
757
859
  }
758
860
 
861
+ /**
862
+ * Captures no-op evidence while the caller holds the history serialization
863
+ * lock H. This function does not acquire H itself. Unknown or foreign backup
864
+ * state is never collapsed into an empty manifest.
865
+ */
866
+ export function snapshotCodexHistoryNoop(
867
+ stateDbPath: string,
868
+ backupPath: string,
869
+ ): CodexHistoryNoopSnapshot {
870
+ const canonicalStateDbPath = resolve(stateDbPath);
871
+ const canonicalBackupPath = resolve(backupPath);
872
+ const stateDbPresent = existsSync(stateDbPath);
873
+ const backupPresent = existsSync(backupPath);
874
+ const base = { canonicalStateDbPath, stateDbPresent, canonicalBackupPath, backupPresent };
875
+ if (!samePath(backupPath, historyBackupPathFor(stateDbPath))) {
876
+ return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "backup-path" };
877
+ }
878
+ const backup = inspectBackupForNoop(backupPath, stateDbPath);
879
+ if (backup.kind === "unknown") {
880
+ return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: backup.reason };
881
+ }
882
+ if (!stateDbPresent) {
883
+ return backup.entries > 0
884
+ ? { kind: "work-pending", pendingRows: 0, backupEntries: backup.entries, ...base }
885
+ : { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-absent" };
886
+ }
887
+ const stateDbIdentity = historyFileIdentity(stateDbPath);
888
+ if (stateDbIdentity === null) {
889
+ return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "snapshot-race" };
890
+ }
891
+ let monitor: Database | undefined;
892
+ try {
893
+ monitor = new Database(stateDbPath, { readonly: true });
894
+ monitor.exec("PRAGMA busy_timeout = 100");
895
+ const dataVersionBefore = readHistoryDataVersion(monitor);
896
+ if (dataVersionBefore === null) {
897
+ return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-query" };
898
+ }
899
+ const pending = countPendingOpencodexHistory(stateDbPath, backupPath);
900
+ if (pending.failed) {
901
+ return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-query" };
902
+ }
903
+ afterNoopPendingCountForTests?.();
904
+ const backupAfter = inspectBackupForNoop(backupPath, stateDbPath);
905
+ if (backupAfter.kind === "unknown") {
906
+ return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: backupAfter.reason };
907
+ }
908
+ const dataVersionAfter = readHistoryDataVersion(monitor);
909
+ if (dataVersionAfter === null) {
910
+ return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-query" };
911
+ }
912
+ if (dataVersionAfter !== dataVersionBefore
913
+ || pending.backupEntries !== backup.entries
914
+ || backupAfter.entries !== backup.entries
915
+ || backupAfter.present !== backup.present
916
+ || backupAfter.fingerprint !== backup.fingerprint
917
+ || historyFileIdentity(stateDbPath) !== stateDbIdentity
918
+ || existsSync(stateDbPath) !== stateDbPresent
919
+ || existsSync(backupPath) !== backupPresent) {
920
+ return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "snapshot-race" };
921
+ }
922
+ return pending.pendingRows === 0 && backup.entries === 0
923
+ ? { kind: "verified-noop", pendingRows: 0, backupEntries: 0, ...base, stateDbPresent: true }
924
+ : { kind: "work-pending", pendingRows: pending.pendingRows, backupEntries: backup.entries, ...base };
925
+ } catch {
926
+ return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-query" };
927
+ } finally {
928
+ try {
929
+ monitor?.close();
930
+ } catch {
931
+ // Read-only monitor cleanup cannot make an uncertain snapshot authoritative.
932
+ }
933
+ }
934
+ }
935
+
759
936
  export interface PendingHistoryCount {
760
937
  /** Threads still tagged opencodex that the eject path WOULD move (mirrors its WHERE). */
761
938
  pendingRows: number;
@@ -33,11 +33,11 @@ function classify(outcome: CodexHistoryJobOutcome, txId: string | null): CodexHi
33
33
  attempts: 1,
34
34
  nextRetryAt: null,
35
35
  txId,
36
- // Mutation counts, not probe counts. The durable counts come from the
37
- // final probe, which this path does not run null rather than a
38
- // manufactured zero.
39
- pendingRows: null,
40
- backupEntries: null,
36
+ // A zero is durable only when the worker proved the exact DB/manifest
37
+ // state while H was held. Without proof, keep the counts unknown. Keep
38
+ // `??`: `||` would discard a verified zero.
39
+ pendingRows: outcome.proof?.pendingRows ?? null,
40
+ backupEntries: outcome.proof?.backupEntries ?? null,
41
41
  };
42
42
  case "skipped":
43
43
  // The user opting out is a completed decision, not a failure — converged
@@ -31,7 +31,11 @@ import {
31
31
  writeLegacyOpenaiHistoryRecovery,
32
32
  type HistoryWriteTarget,
33
33
  } from "./internal/history-writer";
34
- import type { CodexHistoryFailureReason } from "./history-provider";
34
+ import {
35
+ snapshotCodexHistoryNoop,
36
+ type CodexHistoryFailureReason,
37
+ type CodexHistoryVerifiedNoopProof,
38
+ } from "./history-provider";
35
39
 
36
40
  /**
37
41
  * The durable operation, mirrored into the request for diagnostics only.
@@ -65,7 +69,8 @@ export interface HistoryWorkerRunMessage {
65
69
  export type HistoryWorkerResult =
66
70
  | { readonly type: "done"; readonly requestId: string; readonly jobId: string;
67
71
  readonly outcome: "converged" | "skipped";
68
- readonly rows: number; readonly files: number }
72
+ readonly rows: number; readonly files: number;
73
+ readonly proof?: CodexHistoryVerifiedNoopProof }
69
74
  | { readonly type: "blocked"; readonly requestId: string; readonly jobId: string;
70
75
  readonly reason: "busy" | "database" | "unsafe-path" | "desired_disabled" | "desired_enabled" }
71
76
  | { readonly type: "error"; readonly requestId: string; readonly jobId: string;
@@ -133,6 +138,10 @@ export function runHistoryUnitUnderLock(
133
138
  if (operation === "recover-legacy-openai") {
134
139
  return writeLegacyOpenaiHistoryRecovery(permit, target);
135
140
  }
141
+ if (operation === "migrate-openai") {
142
+ const proof = snapshotCodexHistoryNoop(message.canonicalStateDbPath, message.canonicalBackupPath);
143
+ if (proof.kind === "verified-noop") return { verifiedNoop: proof } as const;
144
+ }
136
145
  // apply-opencodex routes history to opencodex; migrate/restore return it to
137
146
  // native. The provider is derived from the operation, never from a caller.
138
147
  const provider = operation === "apply-opencodex" ? "opencodex" : "openai";
@@ -152,6 +161,17 @@ export function runHistoryUnitUnderLock(
152
161
  reason: message.expectedDesiredEnabled ? "desired_disabled" : "desired_enabled",
153
162
  };
154
163
  }
164
+ if ("verifiedNoop" in result) {
165
+ return {
166
+ type: "done",
167
+ requestId,
168
+ jobId,
169
+ outcome: "converged",
170
+ rows: 0,
171
+ files: 0,
172
+ proof: result.verifiedNoop,
173
+ };
174
+ }
155
175
  if (result.failed === true) {
156
176
  return {
157
177
  type: "error",
@@ -383,7 +383,7 @@ function readBounded(path: string, limit: number, testSeam?: BoundedReadTestSeam
383
383
  testSeam?.beforeOpen?.(path);
384
384
  const flags = process.platform === "win32"
385
385
  ? fsConstants.O_RDONLY
386
- : fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW;
386
+ : fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK;
387
387
  fd = openSync(path, flags);
388
388
  const opened = fstatSync(fd, { bigint: true });
389
389
  if (!opened.isFile() || opened.size > BigInt(limit)) throw new Error("invalid bounded file");
@@ -0,0 +1,15 @@
1
+ /** Preserve user/provider plan labels only when they are usable strings. */
2
+ export function codexPlanValue(value: unknown): string | undefined {
3
+ if (typeof value !== "string") return undefined;
4
+ return value.trim() ? value : undefined;
5
+ }
6
+
7
+ /** Case-insensitive key used by quota, capacity, and collision policy. */
8
+ export function codexPlanKey(value: unknown): string | undefined {
9
+ return codexPlanValue(value)?.trim().toLowerCase();
10
+ }
11
+
12
+ export function isThirtyDayOnlyCodexPlan(value: unknown): boolean {
13
+ const key = codexPlanKey(value);
14
+ return key === "go" || key === "free";
15
+ }
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, unlinkSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { atomicWriteFile, getConfigDir } from "../config";
4
4
  import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
5
+ import { isThirtyDayOnlyCodexPlan } from "./plan";
5
6
 
6
7
  export type StoredAccountQuota = {
7
8
  weeklyPercent?: number;
@@ -36,7 +37,7 @@ let persistTimer: ReturnType<typeof setTimeout> | null = null;
36
37
 
37
38
  export type WhamUsageResponse = {
38
39
  email?: string | null;
39
- plan_type?: string | null;
40
+ plan_type?: unknown;
40
41
  rate_limit?: {
41
42
  // Live WHAM payloads send explicit nulls for absent windows (issue #315 repro).
42
43
  primary_window?: WhamUsageWindow | null;
@@ -72,7 +73,7 @@ export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
72
73
 
73
74
  export function isCodexQuotaExhausted(
74
75
  quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent"> | null,
75
- plan?: string | null,
76
+ plan?: unknown,
76
77
  ): boolean {
77
78
  if (!quota) return false;
78
79
  const values = codexQuotaWindowForPlan(plan) === "monthly"
@@ -98,14 +99,13 @@ export function isCodexQuotaExhausted(
98
99
  * everything else (including an absent plan) reports weekly. Recovery reads the
99
100
  * window the parser actually wrote rather than second-guessing it.
100
101
  */
101
- export function codexQuotaWindowForPlan(plan?: string | null): "monthly" | "weekly" {
102
- const normalized = plan?.trim().toLowerCase();
103
- return normalized === "go" || normalized === "free" ? "monthly" : "weekly";
102
+ export function codexQuotaWindowForPlan(plan?: unknown): "monthly" | "weekly" {
103
+ return isThirtyDayOnlyCodexPlan(plan) ? "monthly" : "weekly";
104
104
  }
105
105
 
106
106
  export function isCompleteCodexQuotaRecoverySnapshot(
107
107
  quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "monthlyIsPrimaryWindow"> | null,
108
- plan?: string | null,
108
+ plan?: unknown,
109
109
  ): boolean {
110
110
  if (!quota || isCodexQuotaExhausted(quota, plan)) return false;
111
111
  // Recovery still fails closed on MISSING EVIDENCE — a credits-only or windowless payload
@@ -18,6 +18,7 @@ import {
18
18
  selectPriorityTier,
19
19
  } from "./pool-rotation";
20
20
  import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
21
+ import { isThirtyDayOnlyCodexPlan } from "./plan";
21
22
  import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "./main-account";
22
23
  import { isSelectableCodexPoolAccount } from "./account-id";
23
24
  import type { OcxConfig } from "../types";
@@ -314,10 +315,9 @@ function deleteScopedHealth(accountId: string, scope: CodexQuotaScope): void {
314
315
  export function computeCodexUsageScore(quota: {
315
316
  weeklyPercent?: number;
316
317
  monthlyPercent?: number;
317
- } | null, plan?: string | null): number {
318
+ } | null, plan?: unknown): number {
318
319
  if (!quota) return CODEX_UNKNOWN_USAGE_SCORE;
319
- const normalizedPlan = plan?.trim().toLowerCase();
320
- if (normalizedPlan === "go" || normalizedPlan === "free") {
320
+ if (isThirtyDayOnlyCodexPlan(plan)) {
321
321
  return typeof quota.monthlyPercent === "number" && Number.isFinite(quota.monthlyPercent)
322
322
  ? quota.monthlyPercent
323
323
  : CODEX_UNKNOWN_USAGE_SCORE;