@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
@@ -16,6 +16,7 @@ import {
16
16
  responseSpillDirectory,
17
17
  responseSpillPayloadCap,
18
18
  markResponseSpillPublicationSuperseded,
19
+ prospectiveResponseSpillBytes,
19
20
  type ResponseSpillPublicationControl,
20
21
  type ResponseSpillRef,
21
22
  writeResponseSpillDurably,
@@ -35,6 +36,30 @@ const SNAPSHOT_DEBOUNCE_MAX_MS = 30_000;
35
36
  * continuation chains) stores the full expanded input each turn — ~quadratic bytes per chain —
36
37
  * so a count cap alone cannot bound memory. Oldest-first eviction applies past this mark. */
37
38
  export const MAX_STORED_RESPONSE_BYTES = 64 * 1024 * 1024;
39
+ /**
40
+ * Aggregate ceiling for the durable spill directory: the disk-side counterpart to
41
+ * the RAM ceiling above. Without it the spilled set is bounded only per-file
42
+ * (MAX_RESPONSE_SPILL_PAYLOAD_BYTES, 256 MiB) and per-entry (MAX_STORED_RESPONSES,
43
+ * 1000), whose product is 250 GiB — larger than the disk of any host this runs on.
44
+ * The only effective bound was therefore RESPONSE_TTL_MS, which makes disk use a
45
+ * function of client request rate rather than of anything this process controls.
46
+ *
47
+ * Measured on one macOS host, 2026-08-30: a client spilling ~150 MB payloads at
48
+ * ~1.4/min held 6.8 GB after 44 minutes, still climbing toward the ~12 GB an
49
+ * hour-long window implies, and filled the volume. Retention itself was correct
50
+ * throughout — the TTL evicted that whole cohort an hour later — so what was
51
+ * missing is a budget, not a sweep.
52
+ *
53
+ * 1 GiB comes from the same sample (n=31), whose spilled sizes are strongly
54
+ * bimodal: median 1.1 MiB against a p90 of 198.7 MiB, near the per-file ceiling.
55
+ * At that median the count cap and this ceiling bind within 8% of each other
56
+ * (1000 x 1.1 MiB = 1.07 GiB), so ordinary traffic sees no eviction it would not
57
+ * already have seen and only the large tail is cut. Erring small is the safe
58
+ * direction: too low costs a replay miss, an already-handled path surfaced as
59
+ * previous_response_not_found, while too high costs the host's disk and every
60
+ * unrelated process on it.
61
+ */
62
+ export const MAX_SPILLED_RESPONSE_BYTES = 1024 * 1024 * 1024;
38
63
  /** Legacy snapshot selection only. Spill demotion is governed solely by the RAM cap above. */
39
64
  const SNAPSHOT_ENTRY_MAX_BYTES = 2 * 1024 * 1024;
40
65
  const SNAPSHOT_TOTAL_MAX_BYTES = 24 * 1024 * 1024;
@@ -187,12 +212,79 @@ interface PendingResponseSpill {
187
212
  cancelled: boolean;
188
213
  released: boolean;
189
214
  sizeBytes: number;
215
+ /** Peak on-disk bytes reserved for this publication; released exactly once on settle. */
216
+ reservedBytes: number;
190
217
  publicationControl: ResponseSpillPublicationControl;
191
218
  }
192
219
 
193
220
  const pendingResponseSpills = new Set<PendingResponseSpill>();
194
221
  const pendingResponseSpillById = new Map<string, PendingResponseSpill>();
195
222
  let pendingResponseSpillBytes = 0;
223
+ /**
224
+ * On-disk bytes a queued publication is about to occupy but has not yet installed into
225
+ * `states`.
226
+ *
227
+ * `spilledResponseBytes()` walks installed spills and deferred unlinks — files that
228
+ * already exist. It cannot see one that `writeResponseSpillDurablyAsync` is in the
229
+ * middle of creating, and on Windows that middle can last as long as `icacls` takes.
230
+ * Without a reservation the cap holds only when writes are fast, which is not a cap.
231
+ *
232
+ * The reserved figure is the PEAK footprint, not the payload: publication can fall back
233
+ * from hard-linking to an exclusive copy, and during that fallback the destination copy
234
+ * and the temp file exist simultaneously. Reserving one envelope would leave the overshoot
235
+ * intact at half its magnitude.
236
+ *
237
+ * Ownership is single: a job holds its reservation from queue until
238
+ * `releasePendingResponseSpill`, which every exit from the publication path reaches
239
+ * through the `finally` in `runPendingResponseSpill` and through cancellation of a
240
+ * not-yet-running job. A leaked reservation is monotonic — it would ratchet the usable
241
+ * cap toward zero — so the release must stay on the settlement path rather than in a
242
+ * parallel bookkeeping pass.
243
+ */
244
+ let reservedResponseSpillBytes = 0;
245
+ /**
246
+ * Paths a failed cleanup left on the volume, with the bytes each one occupies.
247
+ *
248
+ * A failed unlink leaves a real file behind, so the cap has to keep seeing it. But a
249
+ * never-decremented total would be phantom debt: a Windows lock that clears a moment
250
+ * later, or the async writer's own retry, can remove the file while the charge stays
251
+ * forever — and with 256 MiB payloads two conservative charges consume the whole default
252
+ * cap, after which nothing can spill for the life of the process.
253
+ *
254
+ * So the debt is per PATH, priced at what that path actually holds, and settled the
255
+ * moment the path is gone. `reconcileUnreclaimableSpillPaths` re-checks on every read of
256
+ * the accounted total, which is the same tick that would otherwise refuse an admission.
257
+ */
258
+ const unreclaimableSpillPaths = new Map<string, number>();
259
+
260
+ function chargeUnreclaimableSpillPath(path: string | null | undefined, bytes: number): void {
261
+ if (!path || bytes <= 0) return;
262
+ unreclaimableSpillPaths.set(path, bytes);
263
+ }
264
+
265
+ /** Drop charges for paths that have since disappeared; returns the surviving total. */
266
+ function reconcileUnreclaimableSpillPaths(): number {
267
+ let total = 0;
268
+ for (const [path, bytes] of [...unreclaimableSpillPaths]) {
269
+ if (existsSync(path)) total += bytes;
270
+ else unreclaimableSpillPaths.delete(path);
271
+ }
272
+ return total;
273
+ }
274
+
275
+ /**
276
+ * Peak on-disk footprint of publishing this candidate: temp plus destination copy.
277
+ *
278
+ * Measured from the production serializer rather than from `candidate.sizeBytes`. The
279
+ * resident measurement omits the `version` field the published envelope carries, so
280
+ * pricing an admission by it undercounts and lets a request sitting exactly at the cap
281
+ * still exceed it. Falls back to the resident figure only when serialization fails, which
282
+ * is the same condition that will fail the publication itself.
283
+ */
284
+ function publicationFootprintBytes(id: string, candidate: ResidentResponseState): number {
285
+ const exact = prospectiveResponseSpillBytes(id, spillPayloadForResident(candidate));
286
+ return (exact ?? candidate.sizeBytes) * 2;
287
+ }
196
288
  let responseSpillPublicationTail: Promise<void> = Promise.resolve();
197
289
  let responseSpillShutdownBudgetOverride: { totalMs: number; fallbackReserveMs: number } | null = null;
198
290
  let responseSpillShutdownTerminalizationPassLimitOverride: number | null = null;
@@ -210,6 +302,7 @@ function releasePendingResponseSpill(job: PendingResponseSpill): void {
210
302
  if (job.released) return;
211
303
  job.released = true;
212
304
  pendingResponseSpillBytes = Math.max(0, pendingResponseSpillBytes - job.sizeBytes);
305
+ reservedResponseSpillBytes = Math.max(0, reservedResponseSpillBytes - job.reservedBytes);
213
306
  pendingResponseSpills.delete(job);
214
307
  if (pendingResponseSpillById.get(job.id) === job) pendingResponseSpillById.delete(job.id);
215
308
  job.candidate = null;
@@ -222,6 +315,11 @@ function cancelPendingResponseSpill(id: string): ResponseSpillRef | undefined {
222
315
  job.cancelled = true;
223
316
  markResponseSpillPublicationSuperseded(job.publicationControl);
224
317
  const superseded = job.supersededSpill;
318
+ // Ownership TRANSFERS to the caller. Leaving the ref on the cancelled job would let the
319
+ // accounting walk count the same physical file twice — once here and once on the
320
+ // replacement — and an overcount evicts live continuations to make room for bytes that
321
+ // are not there.
322
+ delete job.supersededSpill;
225
323
  // A queued job has not captured the candidate in an async frame yet, so release it now.
226
324
  // A running job retains its accounting until settlement and will discard its stale file.
227
325
  if (!job.running) releasePendingResponseSpill(job);
@@ -313,6 +411,25 @@ function queuePendingResponseSpill(
313
411
  deferSupersededSpill(inheritedSpill);
314
412
  return;
315
413
  }
414
+ // Enforce the disk cap BEFORE the temp or destination file is created. Deleting the
415
+ // overflow afterwards is not equivalent: on Windows the file can outlive the decision
416
+ // by as long as ACL hardening takes, which is the window the measured 6.8 GiB
417
+ // accumulated in. Reclaim first, and only refuse if the peak footprint still does not
418
+ // fit — an eviction pass can free a live continuation's worth of room.
419
+ const footprint = publicationFootprintBytes(id, candidate);
420
+ // The superseded generation this job is about to own is already off `states` and not
421
+ // yet on the job, so it is invisible to the walk. Price it here or admission decides
422
+ // against a total that is short by a whole envelope.
423
+ const inheritedBytes = inheritedSpill?.payloadBytes ?? 0;
424
+ if (accountedResponseSpillBytes() + footprint + inheritedBytes > spillByteCap()) {
425
+ enforceSpilledResponseBudget();
426
+ if (accountedResponseSpillBytes() + footprint + inheritedBytes > spillByteCap()) {
427
+ spillCounters.writeFailures += 1;
428
+ replaceWithSpillFailure(id, candidate);
429
+ deferSupersededSpill(inheritedSpill);
430
+ return;
431
+ }
432
+ }
316
433
  const job: PendingResponseSpill = {
317
434
  id,
318
435
  candidate,
@@ -322,11 +439,13 @@ function queuePendingResponseSpill(
322
439
  cancelled: false,
323
440
  released: false,
324
441
  sizeBytes: candidate.sizeBytes,
442
+ reservedBytes: footprint,
325
443
  publicationControl: createResponseSpillPublicationControl(),
326
444
  };
327
445
  pendingResponseSpills.add(job);
328
446
  pendingResponseSpillById.set(id, job);
329
447
  pendingResponseSpillBytes += job.sizeBytes;
448
+ reservedResponseSpillBytes += job.reservedBytes;
330
449
  recomputeOldestResident();
331
450
  responseSpillPublicationTail = responseSpillPublicationTail
332
451
  .then(() => runPendingResponseSpill(job), () => runPendingResponseSpill(job));
@@ -418,7 +537,36 @@ function installShutdownFallbackSpill(
418
537
  aclBudgetMs: number,
419
538
  ): void {
420
539
  let ref: ResponseSpillRef | null = null;
540
+ // Supersession released this job's reservation, but the synchronous write below is the
541
+ // largest publication of the shutdown path and has its own link-then-copy fallback
542
+ // holding a temp and a destination at once. Re-reserve for its duration so the cap is
543
+ // not blind exactly where the drain does its heaviest work, and settle in `finally` so
544
+ // every return, throw and mismatch releases it.
545
+ const footprint = publicationFootprintBytes(job.id, candidate);
546
+ reservedResponseSpillBytes += footprint;
421
547
  try {
548
+ // Supersession released this job, so its superseded generation is no longer visible
549
+ // to the accounting walk — but the file is still on the volume until
550
+ // `deferSupersededSpill` or a delete takes it. Price it here or the fallback decides
551
+ // against a total short by that whole envelope, which is exactly the gap that lets
552
+ // `debt + footprint <= cap < old + debt + footprint` publish over budget.
553
+ const supersededBytes = job.supersededSpill?.payloadBytes ?? 0;
554
+ // The drain must not publish over the cap either. Reclaim first; if the footprint
555
+ // still does not fit — which is what unreclaimable cleanup debt looks like — the
556
+ // honest close-out is a tombstone, not another file on a volume that is already
557
+ // over budget. `replaceWithSpillFailure` is the same fail-closed ending the budget
558
+ // exhaustion path uses, so replay reports `spill_failed` and the client resends.
559
+ if (accountedResponseSpillBytes() + supersededBytes > spillByteCap()) {
560
+ enforceSpilledResponseBudget();
561
+ if (accountedResponseSpillBytes() + supersededBytes > spillByteCap()) {
562
+ if (states.get(job.id) === candidate) {
563
+ spillCounters.writeFailures += 1;
564
+ replaceWithSpillFailure(job.id, candidate);
565
+ deferSupersededSpill(job.supersededSpill);
566
+ }
567
+ throw Object.assign(new Error("Response spill shutdown fallback exceeds the durable disk cap"), { code: "ENOSPC" });
568
+ }
569
+ }
422
570
  ref = writeResponseSpillDurably(job.id, spillPayloadForResident(candidate), { aclBudgetMs });
423
571
  if (ref.payloadBytes > responseSpillPayloadCap()) {
424
572
  deleteResponseSpill(ref);
@@ -445,6 +593,8 @@ function installShutdownFallbackSpill(
445
593
  deferSupersededSpill(job.supersededSpill);
446
594
  }
447
595
  throw error;
596
+ } finally {
597
+ reservedResponseSpillBytes = Math.max(0, reservedResponseSpillBytes - footprint);
448
598
  }
449
599
  }
450
600
 
@@ -477,7 +627,20 @@ function supersedeShutdownFallbackBatch(
477
627
  }
478
628
  for (const { job } of pending) {
479
629
  const cleanupFailure = cleanupSupersededResponseSpillPublication(job.publicationControl);
480
- if (cleanupFailure) failures.push(cleanupFailure);
630
+ if (cleanupFailure) {
631
+ failures.push(cleanupFailure);
632
+ // Cleanup failed, so an async temp or destination is STILL on the volume. Releasing
633
+ // the reservation would un-account a file that exists, and the fallback write that
634
+ // follows reserves only its own footprint — three envelopes on disk priced as two.
635
+ //
636
+ // Charge the surviving PATHS rather than a flat two envelopes: `clearOwnedPath`
637
+ // nulls whichever it managed to remove, so one failure is one file, not two. The
638
+ // charge is settled automatically once the path disappears, which a retried unlink
639
+ // or a released Windows lock can still do.
640
+ const perPath = Math.max(1, Math.floor(job.reservedBytes / 2));
641
+ chargeUnreclaimableSpillPath(job.publicationControl.tempPath, perPath);
642
+ chargeUnreclaimableSpillPath(job.publicationControl.destinationPath, perPath);
643
+ }
481
644
  releasePendingResponseSpill(job);
482
645
  }
483
646
  }
@@ -603,6 +766,69 @@ export function getStoredResponseBytesForTests(): number {
603
766
  return storedResponseBytes;
604
767
  }
605
768
 
769
+ let spillByteCapOverride: number | null = null;
770
+
771
+ function spillByteCap(): number {
772
+ return spillByteCapOverride ?? MAX_SPILLED_RESPONSE_BYTES;
773
+ }
774
+
775
+ /**
776
+ * Live total of durable spill payloads. Recomputed per call rather than carried as
777
+ * a running counter: spilled entries reach `states` through several insertion paths
778
+ * (demotion swap, direct oversized admission, snapshot reload), and one missed
779
+ * increment there would silently disable the cap, where an O(MAX_STORED_RESPONSES)
780
+ * walk cannot drift.
781
+ */
782
+ function spilledResponseBytes(): number {
783
+ let total = 0;
784
+ for (const entry of states.values()) {
785
+ if (entry.kind === "spill") total += entry.spill.payloadBytes;
786
+ }
787
+ // Superseded generations awaiting a durable snapshot are still files on disk.
788
+ // Counting only `states` would let PENDING_SPILL_UNLINKS_MAX of them sit outside
789
+ // the budget while it reports itself satisfied.
790
+ for (const ref of pendingSpillUnlinks) total += ref.payloadBytes;
791
+ return total;
792
+ }
793
+
794
+ /**
795
+ * Accounted on-disk bytes: files that exist, plus the peak footprint of publications
796
+ * already in flight.
797
+ *
798
+ * The cap is enforced against this rather than against `spilledResponseBytes()` alone,
799
+ * because a publication that has not finished is still consuming the volume. On Windows
800
+ * the gap between "queued" and "installed" is however long `icacls` takes, and the
801
+ * measured incident this cap answers accumulated 6.8 GiB in 44 minutes.
802
+ */
803
+ function accountedResponseSpillBytes(): number {
804
+ // Superseded generations a pending job still owns are files on disk too. A same-id
805
+ // replacement removes the old spill from `states` and hands its ref to the job, so
806
+ // counting only `states` plus `pendingSpillUnlinks` loses it for the whole publication
807
+ // — during a copy fallback that is old generation + new temp + new destination, three
808
+ // envelopes priced as two.
809
+ let ownedBySpillJobs = 0;
810
+ for (const job of pendingResponseSpills) {
811
+ if (job.supersededSpill) ownedBySpillJobs += job.supersededSpill.payloadBytes;
812
+ }
813
+ return spilledResponseBytes() + reservedResponseSpillBytes + ownedBySpillJobs
814
+ + reconcileUnreclaimableSpillPaths();
815
+ }
816
+
817
+ /** Test-only: lower/restore the durable spill cap (null restores the default). */
818
+ export function setSpilledResponseByteCapForTests(bytes: number | null): void {
819
+ spillByteCapOverride = bytes;
820
+ }
821
+
822
+ /** Test-only: current durable spill accounting (proves evictions unlink their files). */
823
+ export function getSpilledResponseBytesForTests(): number {
824
+ return spilledResponseBytes();
825
+ }
826
+
827
+ /** Test-only: on-disk bytes plus in-flight publication reservations. */
828
+ export function getAccountedResponseSpillBytesForTests(): number {
829
+ return accountedResponseSpillBytes();
830
+ }
831
+
606
832
  function serializedBytes(value: unknown): number | null {
607
833
  try {
608
834
  const serialized = JSON.stringify(value);
@@ -1495,6 +1721,60 @@ export function replayOverlapSkipsForTests(): number {
1495
1721
  return replayOverlapSkips;
1496
1722
  }
1497
1723
 
1724
+ /**
1725
+ * Bring the durable spill set inside MAX_SPILLED_RESPONSE_BYTES, and report the
1726
+ * bytes released.
1727
+ *
1728
+ * One owner, three callers: mutation pruning, the lazy load that follows a
1729
+ * restart, and the periodic sweep. The periodic caller is not redundant — the
1730
+ * mutation path only runs when traffic arrives, and a process can come up over
1731
+ * budget from a snapshot written under a larger ceiling and then sit idle. That
1732
+ * was observed in production at 1.8 GiB against a 1 GiB cap, held until the first
1733
+ * request.
1734
+ *
1735
+ * NOT covered here: spill files orphaned by a crash. They are absent from
1736
+ * `states`, so this function can neither see nor price them, and they stay with
1737
+ * recoverOrphanedResponseSpills and its RESPONSE_SPILL_ORPHAN_GRACE_MS window.
1738
+ * This ceiling therefore bounds what the store owns, which is every file it can
1739
+ * account for, and not the directory as a whole.
1740
+ */
1741
+ function enforceSpilledResponseBudget(): number {
1742
+ // Price in-flight publications too: a file being created by
1743
+ // `writeResponseSpillDurablyAsync` occupies the volume before it reaches `states`.
1744
+ let spilledBytes = accountedResponseSpillBytes();
1745
+ if (spilledBytes <= spillByteCap()) return 0;
1746
+ const before = spilledBytes;
1747
+ // Deferred generations go first. They are already superseded, so releasing one
1748
+ // costs only the crash window the queue exists to cover — the same trade
1749
+ // PENDING_SPILL_UNLINKS_MAX already makes against unbounded disk. Evicting a
1750
+ // live continuation to make room for a dead file would be the wrong order.
1751
+ while (spilledBytes > spillByteCap() && pendingSpillUnlinks.length > 0) {
1752
+ const ref = pendingSpillUnlinks.shift()!;
1753
+ spilledBytes -= ref.payloadBytes;
1754
+ deleteResponseSpill(ref);
1755
+ }
1756
+ // Ordered by createdAt, not by map order. `states` is not an age index:
1757
+ // demotion and spill replacement delete and reinsert entries, and
1758
+ // writeBoundedSnapshot serializes the map reversed, so map order can put a
1759
+ // newer continuation first — and evicting that one spends a resume the older
1760
+ // entry would not have cost. Sorting is O(k log k) over the spilled subset and
1761
+ // runs only on a tick already over budget.
1762
+ const spilled = [...states]
1763
+ .filter((pair): pair is [string, SpilledResponseState] => pair[1].kind === "spill")
1764
+ // createdAt is millisecond-resolution, so ties are ordinary under load. A
1765
+ // stable sort would then fall back to insertion order — the very order this
1766
+ // is avoiding — so break ties on the response id. Not localeCompare: the
1767
+ // order must not depend on the host locale.
1768
+ .sort((a, b) => a[1].createdAt - b[1].createdAt
1769
+ || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
1770
+ for (const [id, entry] of spilled) {
1771
+ if (spilledBytes <= spillByteCap()) break;
1772
+ spilledBytes -= entry.spill.payloadBytes;
1773
+ deleteEntry(id);
1774
+ }
1775
+ return before - spilledBytes;
1776
+ }
1777
+
1498
1778
  function pruneResponses(at = now()): void {
1499
1779
  for (const [id, state] of states) {
1500
1780
  if (at - state.createdAt > RESPONSE_TTL_MS) deleteEntry(id);
@@ -1537,6 +1817,7 @@ function pruneResponses(at = now()): void {
1537
1817
  replaceWithSpillFailure(oldestId, entry);
1538
1818
  }
1539
1819
  }
1820
+ enforceSpilledResponseBudget();
1540
1821
  }
1541
1822
 
1542
1823
  /** Periodic TTL-only sweep; count/byte eviction remains owned by mutation paths. */
@@ -1547,7 +1828,10 @@ export function sweepExpiredResponseStates(at = now()): number {
1547
1828
  deleteEntry(id);
1548
1829
  removed += 1;
1549
1830
  }
1550
- if (removed > 0) schedulePersist();
1831
+ // The disk ceiling needs a caller that does not depend on traffic. The return
1832
+ // value stays the TTL count so this function's existing contract is unchanged.
1833
+ const reclaimed = enforceSpilledResponseBudget();
1834
+ if (removed > 0 || reclaimed > 0) schedulePersist();
1551
1835
  return removed;
1552
1836
  }
1553
1837
 
@@ -1995,6 +2279,8 @@ export function clearResponseStateMemoryForTests(): void {
1995
2279
  export function clearResponseStateForTests(): void {
1996
2280
  for (const entry of states.values()) deleteOwnedSpills(entry);
1997
2281
  clearResponseStateMemoryForTests();
2282
+ reservedResponseSpillBytes = 0;
2283
+ unreclaimableSpillPaths.clear();
1998
2284
  try {
1999
2285
  unlinkSync(snapshotPath());
2000
2286
  } catch {
@@ -21,6 +21,7 @@ import { createIntegrationStateStore, type IntegrationStateStore } from "../../i
21
21
  import {
22
22
  applyIntegrationCoordinated,
23
23
  disableIntegrationCoordinated,
24
+ overwriteIntegrationCoordinated,
24
25
  restoreIntegrationCoordinated,
25
26
  type IntegrationRestoreInput,
26
27
  type IntegrationWriteInput,
@@ -70,7 +71,7 @@ export interface IntegrationJournalEnvelope {
70
71
  export interface IntegrationJournalRow {
71
72
  opId: string;
72
73
  clientId: IntegrationClientId;
73
- kind: "apply" | "disable" | "refresh" | "restore";
74
+ kind: "apply" | "disable" | "refresh" | "restore" | "overwrite";
74
75
  at: string;
75
76
  configPath: string;
76
77
  snapshot: "none" | "stored" | "expired";
@@ -79,6 +80,14 @@ export interface IntegrationJournalRow {
79
80
 
80
81
  export interface IntegrationToggleBody {
81
82
  enabled: boolean;
83
+ /**
84
+ * Opt in to replacing a conflicted block with the one opencodex would write.
85
+ *
86
+ * Absent and `false` behave identically and are the only states a caller
87
+ * reaches by accident, which is the point: the conflict refusal protects work
88
+ * we did not author, so it can only be waived by asking for it by name.
89
+ */
90
+ overwriteConflict?: boolean;
82
91
  }
83
92
 
84
93
  export interface IntegrationRestoreBody {
@@ -472,16 +481,38 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
472
481
  code: "invalid_enabled",
473
482
  }, 400, req, ctx.config);
474
483
  }
484
+ if (parsed.overwriteConflict !== undefined && typeof parsed.overwriteConflict !== "boolean") {
485
+ return jsonResponse({
486
+ error: "overwriteConflict must be a boolean",
487
+ code: "invalid_overwrite_conflict",
488
+ }, 400, req, ctx.config);
489
+ }
490
+ /*
491
+ * Rejected rather than ignored. Disabling a block we do not own is precisely
492
+ * the deletion this subsystem exists to prevent, so a caller sending this
493
+ * combination has misunderstood the field, and silently dropping it would
494
+ * answer 200 for a request whose intent we refused.
495
+ */
496
+ if (parsed.overwriteConflict === true && parsed.enabled === false) {
497
+ return jsonResponse({
498
+ error: "overwriteConflict applies only to enabling an integration",
499
+ code: "invalid_overwrite_conflict",
500
+ }, 400, req, ctx.config);
501
+ }
475
502
 
476
503
  try {
477
504
  const input = await buildIntegrationWriteInput(requestedClient, ctx, integrationStore());
478
505
  const result = await runIntegrationMutationFlight(
479
506
  requestedClient,
480
- parsed.enabled ? "apply" : "disable",
507
+ parsed.enabled ? (parsed.overwriteConflict === true ? "overwrite" : "apply") : "disable",
481
508
  input.io?.now ?? Date.now,
482
- () => parsed.enabled
483
- ? applyIntegrationCoordinated(input, { lockSeams: integrationMutationTestHooks?.lockSeams })
484
- : disableIntegrationCoordinated(input, { lockSeams: integrationMutationTestHooks?.lockSeams }),
509
+ () => {
510
+ const options = { lockSeams: integrationMutationTestHooks?.lockSeams };
511
+ if (!parsed.enabled) return disableIntegrationCoordinated(input, options);
512
+ return parsed.overwriteConflict === true
513
+ ? overwriteIntegrationCoordinated(input, options)
514
+ : applyIntegrationCoordinated(input, options);
515
+ },
485
516
  );
486
517
  if (!result.ok) return writerFailureResponse(requestedClient, result, ctx);
487
518
  return jsonResponse(result satisfies IntegrationToggleEnvelope, 200, req, ctx.config);
@@ -256,10 +256,46 @@ export async function handleManagementAPI(
256
256
  if (routed) return routed;
257
257
 
258
258
  if (url.pathname === "/api/stop" && req.method === "POST") {
259
- const { restoreNativeCodexAsync } = await import("../codex/inject");
260
- const { stopServiceIfInstalled, isServiceOwnershipError } = await import("../service");
259
+ const { installedServiceRespawnRisk, stopServiceIfInstalledDetailed, isServiceOwnershipError } = await import("../service");
260
+ // `ocx stop` performs its own shared teardown AFTER verifying the scheduler did not
261
+ // respawn the proxy (#3008). Without this the child restores native Codex and strips
262
+ // the Grok fence here, so a survivor found moments later has already had the shared
263
+ // config pulled out from under it — and the parent's `ownershipBlocked` guard can
264
+ // only prevent a second, redundant teardown. A direct caller sends nothing and keeps
265
+ // the self-contained behaviour.
266
+ //
267
+ // The query flag alone is not enough to hand over the obligation: any authenticated
268
+ // caller could set it and simply exit, leaving client config pointed at a proxy that
269
+ // no longer exists. Honour the deferral only when the caller left a pending-teardown
270
+ // receipt on disk, which a later stop/update can find and finish.
271
+ // Decide BEFORE touching the manager. Stopping the Task Scheduler task and then
272
+ // refusing left the proxy running with its manager stopped — worse than either
273
+ // outcome. This process cannot verify its own post-exit respawn window; only the
274
+ // receipt-backed parent `ocx stop` can, which is what the deferral exists for.
275
+ const { deferralMatchesReceipt } = await import("../config/pending-teardown");
276
+ const { deferralHonored, performStopTeardown } = await import("./stop-teardown");
277
+ const holdsReceipt = deferralHonored(url, deferralMatchesReceipt);
278
+ const respawnRisk = holdsReceipt ? "none" : installedServiceRespawnRisk();
279
+ if (respawnRisk === "respawnable") {
280
+ return jsonResponse({
281
+ success: false,
282
+ code: "respawnable_service",
283
+ message: "This proxy is managed by a Task Scheduler wrapper that can respawn it, so the stop must be run by `ocx stop`, which verifies the respawn window. Nothing was changed.",
284
+ }, 409, req, config);
285
+ }
286
+ if (respawnRisk === "unknown") {
287
+ // Do NOT send them to `ocx stop`: it maps the same unanswerable probe to a stop
288
+ // failure, so that advice would be a loop. The scheduler query itself is what needs
289
+ // fixing (#3008).
290
+ return jsonResponse({
291
+ success: false,
292
+ code: "service_state_unknown",
293
+ message: "The Windows Task Scheduler state could not be read, so this proxy cannot tell whether a wrapper would respawn it. Nothing was changed. Run `ocx service status` to see the query error, repair Task Scheduler access, then retry.",
294
+ }, 409, req, config);
295
+ }
296
+ let serviceStop: import("../service").ServiceStopOutcome;
261
297
  try {
262
- stopServiceIfInstalled();
298
+ serviceStop = stopServiceIfInstalledDetailed();
263
299
  } catch (err) {
264
300
  if (isServiceOwnershipError(err)) {
265
301
  // The installed service belongs to another CODEX_HOME/OPENCODEX_HOME: it would respawn
@@ -269,12 +305,30 @@ export async function handleManagementAPI(
269
305
  }
270
306
  throw err;
271
307
  }
272
- const restore = await restoreNativeCodexAsync();
308
+ // The boolean helper collapses "failed" into the same false as "no service installed",
309
+ // so this route used to tear down shared config and exit while a manager that refused
310
+ // to stop was still there to respawn the proxy (#3008).
311
+ if (serviceStop === "failed") {
312
+ return jsonResponse({
313
+ success: false,
314
+ message: "The installed service manager did not stop; it may respawn the proxy. Shared client config was left alone. Run `ocx stop` from the home that owns the service.",
315
+ }, 409, req, config);
316
+ }
317
+ if (serviceStop === "state-unknown") {
318
+ // Same case, same remedy as the pre-check: the query is what needs fixing.
319
+ return jsonResponse({
320
+ success: false,
321
+ code: "service_state_unknown",
322
+ message: "The Windows Task Scheduler state could not be read, so this proxy cannot tell whether a wrapper would respawn it. Shared client config was left alone. Run `ocx service status` to see the query error, repair Task Scheduler access, then retry.",
323
+ }, 409, req, config);
324
+ }
325
+ // The pre-check above already refused the respawnable case without a receipt, so
326
+ // reaching here with one means the parent owns the verification.
273
327
  // Both managed configs come down together on an explicit teardown. The daemon's own
274
328
  // syncCleanup skips this when OCX_SERVICE is set (so a crash/respawn keeps the fence),
275
- // which is exactly why an intentional stop has to do it here.
276
- const { stripGrokConfig } = await import("../grok/inject");
277
- const grok = stripGrokConfig();
329
+ // which is exactly why an intentional stop has to do it here — unless the caller is
330
+ // `ocx stop`, which does it itself once the proxy is proven down.
331
+ const teardown = await performStopTeardown(url, { ownsReceipt: deferralMatchesReceipt });
278
332
  setTimeout(async () => {
279
333
  let shutdownSucceeded = false;
280
334
  try {
@@ -282,12 +336,12 @@ export async function handleManagementAPI(
282
336
  } catch {
283
337
  console.warn("[opencodex] shutdown drain failed");
284
338
  }
285
- process.exit(shutdownSucceeded ? 0 : 1);
339
+ // A drained proxy whose shared teardown failed did not finish the job. Exiting 0
340
+ // told a supervisor the stop was clean while native Codex or the Grok fence was
341
+ // still pointed at this process (#3008).
342
+ process.exit(shutdownSucceeded && teardown.success ? 0 : 1);
286
343
  }, 200);
287
- const grokNote = grok.ok ? "" : ` Grok config cleanup failed: ${grok.message}`;
288
- return jsonResponse(restore.success
289
- ? { success: true, message: `Proxy stopping, native Codex restored.${grokNote}` }
290
- : { success: false, message: `Proxy stopping, but native Codex restore failed: ${restore.message}. Run \`ocx restore\`.${grokNote}` });
344
+ return jsonResponse(teardown);
291
345
  }
292
346
 
293
347
  if (url.pathname.startsWith("/api/native-main-profiles")) {
@@ -175,7 +175,24 @@ function textWithoutFernetRuns(payload: string, runs: readonly FernetTokenRun[])
175
175
  return `${text}${payload.slice(last)}`;
176
176
  }
177
177
 
178
- export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*NEW_TASK[^\n]*\nTask name\s*:[^\n]*\nSender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)/gi;
178
+ /**
179
+ * The routing header codex-rs writes above a delegated agent payload.
180
+ *
181
+ * `MESSAGE` is matched as well as `NEW_TASK`, and only for the unreadability CHECK --
182
+ * recovery stays NEW_TASK-only. #3021 reported a subagent `MESSAGE` arriving in the
183
+ * parent conversation as raw `gAAAA...` ciphertext after an `adapter_eof`. The detector
184
+ * decides "unreadable" by stripping the envelope and asking whether any plaintext
185
+ * survives, so an envelope shape it does not recognise counts as surviving text: a
186
+ * `MESSAGE` whose entire body is one Fernet token measured as READABLE and was forwarded
187
+ * verbatim.
188
+ *
189
+ * Widening the strip is not the same as widening recovery. Recovery decrypts, and
190
+ * decrypting a `MESSAGE` on the parent's behalf would build a plaintext oracle out of a
191
+ * payload the parent's session may have no right to read. This only lets the proxy
192
+ * NOTICE that what it is about to forward is unreadable ciphertext, which is what the
193
+ * report asks for: fail closed with a structured error rather than paste the token.
194
+ */
195
+ export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*(?:NEW_TASK|MESSAGE)[^\n]*\nTask name\s*:[^\n]*\nSender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)/gi;
179
196
 
180
197
  // CXC is the compatibility-hook control namespace. Strip only the tagged paragraph:
181
198
  // later untagged paragraphs may be genuine task text. Repeated CXC paragraphs are