@bitkyc08/opencodex 2.36.0 → 2.38.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 (70) hide show
  1. package/bin/ocx.mjs +69 -10
  2. package/gui/dist/assets/index-C14iCj_Q.js +112 -0
  3. package/gui/dist/assets/index-D7PIz7_g.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/aside.svg +3 -0
  6. package/gui/dist/provider-icons/deepseek-harness.svg +3 -0
  7. package/gui/dist/provider-icons/oh-my-pi.svg +11 -0
  8. package/gui/dist/provider-icons/openclaw.svg +54 -0
  9. package/gui/dist/provider-icons/prime-agent.svg +21 -0
  10. package/gui/dist/provider-icons/zcode.svg +219 -0
  11. package/package.json +1 -1
  12. package/src/adapters/cursor/protobuf-request.ts +4 -1
  13. package/src/adapters/cursor/tool-definitions.ts +36 -4
  14. package/src/adapters/kiro-constants.ts +36 -2
  15. package/src/adapters/kiro.ts +13 -2
  16. package/src/cli/capabilities.ts +14 -0
  17. package/src/cli/claude.ts +12 -0
  18. package/src/cli/codex-cli-update.ts +96 -0
  19. package/src/cli/codex-shim-autorestore.ts +3 -0
  20. package/src/cli/export-command.ts +18 -17
  21. package/src/cli/help.ts +2 -2
  22. package/src/cli/index.ts +3 -2
  23. package/src/cli/launcher-context.ts +53 -2
  24. package/src/cli/opencode.ts +126 -33
  25. package/src/cli/registry.ts +16 -10
  26. package/src/cli/system-command.ts +6 -1
  27. package/src/clients/config-export.ts +293 -28
  28. package/src/codex/account-store.ts +10 -4
  29. package/src/codex/autostart-health.ts +3 -3
  30. package/src/codex/catalog/provider-fetch.ts +20 -1
  31. package/src/codex/catalog/sync.ts +4 -3
  32. package/src/codex/cli-install-provenance.ts +795 -0
  33. package/src/codex/convergence.ts +4 -3
  34. package/src/codex/credential-mutation-epoch.ts +11 -0
  35. package/src/codex/main-account.ts +2 -0
  36. package/src/codex/model-entitlements.ts +489 -30
  37. package/src/codex/native-profile-manager.ts +4 -0
  38. package/src/codex/reset-credit-operation-ledger.ts +1411 -0
  39. package/src/codex/reset-credit-recovery.ts +20 -2
  40. package/src/codex/shim.ts +204 -18
  41. package/src/codex/user-identity.ts +2 -1
  42. package/src/config/paths.ts +18 -3
  43. package/src/config.ts +23 -0
  44. package/src/generated/compatibility-version.json +84 -48
  45. package/src/integrations/registry.ts +112 -0
  46. package/src/integrations/state.ts +67 -5
  47. package/src/integrations/writer.ts +25 -9
  48. package/src/lib/bounded-subprocess.ts +36 -0
  49. package/src/lib/strict-semver.ts +47 -0
  50. package/src/lib/windows-elevation.ts +32 -1
  51. package/src/lib/windows-secret-acl.ts +47 -25
  52. package/src/lib/windows-service-mutation-lock.ts +133 -0
  53. package/src/lib/windows-user-principal.ts +15 -17
  54. package/src/responses/spill-store.ts +334 -29
  55. package/src/responses/state.ts +488 -7
  56. package/src/server/index.ts +4 -3
  57. package/src/server/lifecycle.ts +5 -1
  58. package/src/server/management/model-rows.ts +11 -2
  59. package/src/server/management/provider-routes.ts +4 -0
  60. package/src/server/management/system-restart.ts +5 -5
  61. package/src/server/management-api.ts +7 -2
  62. package/src/server/startup-action-control.ts +3 -2
  63. package/src/service.ts +594 -33
  64. package/src/sidecar/candidates.ts +1 -1
  65. package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
  66. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  67. package/src/update/index.ts +3 -2
  68. package/src/update/job.ts +10 -11
  69. package/gui/dist/assets/index-Cy7Z_pl0.css +0 -1
  70. package/gui/dist/assets/index-DO8liQVL.js +0 -112
@@ -3,16 +3,23 @@ import { uptime } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { atomicWriteFileAsync, getConfigDir, resolveWriteTarget } from "../config";
5
5
  import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory";
6
+ import { windowsSecretAclApplies } from "../lib/windows-secret-acl";
6
7
  import type { OcxProviderContinuationState } from "../types";
7
8
  import {
9
+ cleanupSupersededResponseSpillPublication,
10
+ createResponseSpillPublicationControl,
8
11
  deleteResponseSpill,
12
+ MAX_RESPONSE_SPILL_PAYLOAD_BYTES,
9
13
  noteStubSwapForTest,
10
14
  readResponseSpill,
11
15
  recoverOrphanedResponseSpills,
12
16
  responseSpillDirectory,
13
17
  responseSpillPayloadCap,
18
+ markResponseSpillPublicationSuperseded,
19
+ type ResponseSpillPublicationControl,
14
20
  type ResponseSpillRef,
15
21
  writeResponseSpillDurably,
22
+ writeResponseSpillDurablyAsync,
16
23
  } from "./spill-store";
17
24
 
18
25
  const MAX_STORED_RESPONSES = 1_000;
@@ -51,6 +58,10 @@ const PERIODIC_TEMP_MAX_CLEANUPS = 64;
51
58
  const PERIODIC_TEMP_SCAN_DEADLINE_MS = 25;
52
59
  const RESPONSE_STATE_TEMP_NAME = /^responses-state\.json\.ocx\.(\d+)\.(\d+)\.tmp$/;
53
60
  const MAX_SNAPSHOT_REWRITE_ATTEMPTS = 4;
61
+ const RESPONSE_SPILL_SHUTDOWN_BUDGET_MS = 5_000;
62
+ const RESPONSE_SPILL_SHUTDOWN_FALLBACK_RESERVE_MS = 4_000;
63
+ const RESPONSE_SPILL_ASYNC_ACL_ATTEMPT_BUDGET_MS = 30_000;
64
+ const RESPONSE_SPILL_SHUTDOWN_TERMINALIZATION_MAX_PASSES = MAX_STORED_RESPONSES + 1;
54
65
 
55
66
  interface ResidentResponseState {
56
67
  kind: "resident";
@@ -160,6 +171,424 @@ const pendingSpillUnlinks: ResponseSpillRef[] = [];
160
171
  // structured 400 — bounded-loss, never silent corruption or unbounded disk.
161
172
  const PENDING_SPILL_UNLINKS_MAX = 128;
162
173
 
174
+ /**
175
+ * Windows keeps the candidate replayable while required ACL hardening runs off the event loop.
176
+ * Pending bytes are pinned, not evictable; cap them below the process-owned 512 MiB ceiling so an
177
+ * icacls outage cannot turn the serialized queue into an unbounded resident backlog.
178
+ */
179
+ const MAX_PENDING_RESPONSE_SPILL_BYTES = MAX_RESPONSE_SPILL_PAYLOAD_BYTES;
180
+
181
+ interface PendingResponseSpill {
182
+ id: string;
183
+ candidate: ResidentResponseState | null;
184
+ supersededSpill?: ResponseSpillRef;
185
+ directAdmission: boolean;
186
+ running: boolean;
187
+ cancelled: boolean;
188
+ released: boolean;
189
+ sizeBytes: number;
190
+ publicationControl: ResponseSpillPublicationControl;
191
+ }
192
+
193
+ const pendingResponseSpills = new Set<PendingResponseSpill>();
194
+ const pendingResponseSpillById = new Map<string, PendingResponseSpill>();
195
+ let pendingResponseSpillBytes = 0;
196
+ let responseSpillPublicationTail: Promise<void> = Promise.resolve();
197
+ let responseSpillShutdownBudgetOverride: { totalMs: number; fallbackReserveMs: number } | null = null;
198
+ let responseSpillShutdownTerminalizationPassLimitOverride: number | null = null;
199
+ let responseSpillAsyncAclAttemptBudgetOverride: number | null = null;
200
+
201
+ function deferSupersededSpill(ref: ResponseSpillRef | undefined): void {
202
+ if (!ref) return;
203
+ pendingSpillUnlinks.push(ref);
204
+ while (pendingSpillUnlinks.length > PENDING_SPILL_UNLINKS_MAX) {
205
+ deleteResponseSpill(pendingSpillUnlinks.shift()!);
206
+ }
207
+ }
208
+
209
+ function releasePendingResponseSpill(job: PendingResponseSpill): void {
210
+ if (job.released) return;
211
+ job.released = true;
212
+ pendingResponseSpillBytes = Math.max(0, pendingResponseSpillBytes - job.sizeBytes);
213
+ pendingResponseSpills.delete(job);
214
+ if (pendingResponseSpillById.get(job.id) === job) pendingResponseSpillById.delete(job.id);
215
+ job.candidate = null;
216
+ }
217
+
218
+ function cancelPendingResponseSpill(id: string): ResponseSpillRef | undefined {
219
+ const job = pendingResponseSpillById.get(id);
220
+ if (!job) return undefined;
221
+ pendingResponseSpillById.delete(id);
222
+ job.cancelled = true;
223
+ markResponseSpillPublicationSuperseded(job.publicationControl);
224
+ const superseded = job.supersededSpill;
225
+ // A queued job has not captured the candidate in an async frame yet, so release it now.
226
+ // A running job retains its accounting until settlement and will discard its stale file.
227
+ if (!job.running) releasePendingResponseSpill(job);
228
+ return superseded;
229
+ }
230
+
231
+ function isAclTimeout(error: unknown): boolean {
232
+ return !!error && typeof error === "object" && "code" in error
233
+ && String((error as { code?: unknown }).code) === "ETIMEDOUT";
234
+ }
235
+
236
+ function spillPayloadForResident(candidate: ResidentResponseState): Parameters<typeof writeResponseSpillDurably>[1] {
237
+ return {
238
+ createdAt: candidate.createdAt,
239
+ ...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}),
240
+ items: candidate.items,
241
+ ...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}),
242
+ ...(candidate.providers ? { providers: candidate.providers } : {}),
243
+ };
244
+ }
245
+
246
+ async function runPendingResponseSpill(job: PendingResponseSpill): Promise<void> {
247
+ if (job.cancelled || !job.candidate) return;
248
+ job.running = true;
249
+ const candidate = job.candidate;
250
+ let ref: ResponseSpillRef | null = null;
251
+ try {
252
+ const state = spillPayloadForResident(candidate);
253
+ try {
254
+ ref = await writeResponseSpillDurablyAsync(job.id, state, {
255
+ aclBudgetMs: responseSpillAsyncAclAttemptBudgetMs(),
256
+ publicationControl: job.publicationControl,
257
+ });
258
+ } catch (error) {
259
+ if (!isAclTimeout(error)) throw error;
260
+ // The ACL helper permits exactly one caller-owned recovery budget. The resident generation
261
+ // remains replayable during both attempts, so a transient timeout never becomes a tombstone.
262
+ ref = await writeResponseSpillDurablyAsync(job.id, state, {
263
+ aclBudgetMs: responseSpillAsyncAclAttemptBudgetMs(),
264
+ retryTimedOutOnce: true,
265
+ publicationControl: job.publicationControl,
266
+ });
267
+ }
268
+ if (ref.payloadBytes > responseSpillPayloadCap()) {
269
+ deleteResponseSpill(ref);
270
+ ref = null;
271
+ if (job.directAdmission) admissionCounters.oversizedDrops += 1;
272
+ throw Object.assign(new Error("Response spill payload exceeds replay ceiling"), { code: "EFBIG" });
273
+ }
274
+ if (states.get(job.id) !== candidate || job.cancelled) {
275
+ deleteResponseSpill(ref);
276
+ ref = null;
277
+ return;
278
+ }
279
+ if (swapResidentForSpill(job.id, candidate, ref)) {
280
+ ref = null;
281
+ spillCounters.writes += 1;
282
+ if (job.directAdmission) admissionCounters.directSpills += 1;
283
+ deferSupersededSpill(job.supersededSpill);
284
+ }
285
+ } catch {
286
+ if (ref) deleteResponseSpill(ref);
287
+ if (states.get(job.id) === candidate && !job.cancelled) {
288
+ spillCounters.writeFailures += 1;
289
+ replaceWithSpillFailure(job.id, candidate);
290
+ deferSupersededSpill(job.supersededSpill);
291
+ }
292
+ } finally {
293
+ const cancelled = job.cancelled;
294
+ releasePendingResponseSpill(job);
295
+ recomputeOldestResident();
296
+ if (!cancelled) {
297
+ schedulePersist();
298
+ pruneResponses();
299
+ enforceAppOwnedMemoryBudget();
300
+ }
301
+ }
302
+ }
303
+
304
+ function queuePendingResponseSpill(
305
+ id: string,
306
+ candidate: ResidentResponseState,
307
+ options: { supersededSpill?: ResponseSpillRef; directAdmission?: boolean } = {},
308
+ ): void {
309
+ const inheritedSpill = cancelPendingResponseSpill(id) ?? options.supersededSpill;
310
+ if (pendingResponseSpillBytes + candidate.sizeBytes > MAX_PENDING_RESPONSE_SPILL_BYTES) {
311
+ spillCounters.writeFailures += 1;
312
+ replaceWithSpillFailure(id, candidate);
313
+ deferSupersededSpill(inheritedSpill);
314
+ return;
315
+ }
316
+ const job: PendingResponseSpill = {
317
+ id,
318
+ candidate,
319
+ ...(inheritedSpill ? { supersededSpill: inheritedSpill } : {}),
320
+ directAdmission: options.directAdmission === true,
321
+ running: false,
322
+ cancelled: false,
323
+ released: false,
324
+ sizeBytes: candidate.sizeBytes,
325
+ publicationControl: createResponseSpillPublicationControl(),
326
+ };
327
+ pendingResponseSpills.add(job);
328
+ pendingResponseSpillById.set(id, job);
329
+ pendingResponseSpillBytes += job.sizeBytes;
330
+ recomputeOldestResident();
331
+ responseSpillPublicationTail = responseSpillPublicationTail
332
+ .then(() => runPendingResponseSpill(job), () => runPendingResponseSpill(job));
333
+ }
334
+
335
+ function replaceWithPendingResponseSpill(
336
+ id: string,
337
+ candidate: ResidentResponseState,
338
+ expected: StoredResponseState | undefined,
339
+ options: { directAdmission?: boolean } = {},
340
+ ): boolean {
341
+ const inheritedSpill = pendingResponseSpillById.get(id)?.supersededSpill
342
+ ?? (expected?.kind === "spill" ? expected.spill : undefined);
343
+ if (!replaceMapEntry(id, candidate, expected)) return false;
344
+ queuePendingResponseSpill(id, candidate, {
345
+ ...(inheritedSpill ? { supersededSpill: inheritedSpill } : {}),
346
+ directAdmission: options.directAdmission === true,
347
+ });
348
+ return true;
349
+ }
350
+
351
+ /** Test-only: settle every serialized Windows spill publication. */
352
+ export async function flushPendingResponseSpillsForTests(): Promise<void> {
353
+ await drainResponseSpillPublications();
354
+ }
355
+
356
+ /** Test-only: observe ordinary queue settlement without invoking shutdown fallback. */
357
+ export async function awaitResponseSpillPublicationTailForTests(): Promise<void> {
358
+ await responseSpillPublicationTail;
359
+ }
360
+
361
+ /** Test-only: observe the bounded queue without exposing payloads. */
362
+ export function pendingResponseSpillMetricsForTests(): { count: number; bytes: number } {
363
+ return { count: pendingResponseSpills.size, bytes: pendingResponseSpillBytes };
364
+ }
365
+
366
+ /** Test-only: shorten the shutdown drain/fallback budget (null restores production values). */
367
+ export function setResponseSpillShutdownBudgetForTests(
368
+ budget: { totalMs: number; fallbackReserveMs: number } | null,
369
+ ): void {
370
+ responseSpillShutdownBudgetOverride = budget;
371
+ }
372
+
373
+ /** Test-only: shorten the ordinary async whole-attempt ACL budget. */
374
+ export function setResponseSpillAsyncAclAttemptBudgetForTests(budgetMs: number | null): void {
375
+ responseSpillAsyncAclAttemptBudgetOverride = budgetMs;
376
+ }
377
+
378
+ function responseSpillAsyncAclAttemptBudgetMs(): number {
379
+ return responseSpillAsyncAclAttemptBudgetOverride ?? RESPONSE_SPILL_ASYNC_ACL_ATTEMPT_BUDGET_MS;
380
+ }
381
+
382
+ /** Test-only: lower the hard terminalization pass guard (null restores production). */
383
+ export function setResponseSpillShutdownTerminalizationPassLimitForTests(limit: number | null): void {
384
+ responseSpillShutdownTerminalizationPassLimitOverride = limit;
385
+ }
386
+
387
+ function responseSpillShutdownTerminalizationPassLimit(): number {
388
+ return responseSpillShutdownTerminalizationPassLimitOverride
389
+ ?? RESPONSE_SPILL_SHUTDOWN_TERMINALIZATION_MAX_PASSES;
390
+ }
391
+
392
+ function responseSpillShutdownBudget(): { totalMs: number; fallbackReserveMs: number } {
393
+ return responseSpillShutdownBudgetOverride ?? {
394
+ totalMs: RESPONSE_SPILL_SHUTDOWN_BUDGET_MS,
395
+ fallbackReserveMs: RESPONSE_SPILL_SHUTDOWN_FALLBACK_RESERVE_MS,
396
+ };
397
+ }
398
+
399
+ function awaitResponseSpillTailUntil(observed: Promise<void>, deadline: number): Promise<boolean> {
400
+ const remaining = deadline - Date.now();
401
+ if (remaining <= 0) return Promise.resolve(false);
402
+ return new Promise(resolve => {
403
+ let finished = false;
404
+ const finish = (settled: boolean): void => {
405
+ if (finished) return;
406
+ finished = true;
407
+ clearTimeout(timer);
408
+ resolve(settled);
409
+ };
410
+ const timer = setTimeout(() => finish(false), remaining);
411
+ observed.then(() => finish(true), () => finish(true));
412
+ });
413
+ }
414
+
415
+ function installShutdownFallbackSpill(
416
+ job: PendingResponseSpill,
417
+ candidate: ResidentResponseState,
418
+ aclBudgetMs: number,
419
+ ): void {
420
+ let ref: ResponseSpillRef | null = null;
421
+ try {
422
+ ref = writeResponseSpillDurably(job.id, spillPayloadForResident(candidate), { aclBudgetMs });
423
+ if (ref.payloadBytes > responseSpillPayloadCap()) {
424
+ deleteResponseSpill(ref);
425
+ ref = null;
426
+ if (job.directAdmission) admissionCounters.oversizedDrops += 1;
427
+ throw Object.assign(new Error("Response spill payload exceeds replay ceiling"), { code: "EFBIG" });
428
+ }
429
+ if (states.get(job.id) !== candidate) {
430
+ deleteResponseSpill(ref);
431
+ ref = null;
432
+ return;
433
+ }
434
+ if (swapResidentForSpill(job.id, candidate, ref)) {
435
+ ref = null;
436
+ spillCounters.writes += 1;
437
+ if (job.directAdmission) admissionCounters.directSpills += 1;
438
+ deferSupersededSpill(job.supersededSpill);
439
+ }
440
+ } catch (error) {
441
+ if (ref) deleteResponseSpill(ref);
442
+ if (states.get(job.id) === candidate) {
443
+ spillCounters.writeFailures += 1;
444
+ replaceWithSpillFailure(job.id, candidate);
445
+ deferSupersededSpill(job.supersededSpill);
446
+ }
447
+ throw error;
448
+ }
449
+ }
450
+
451
+ function terminalizeShutdownFallbackCandidate(
452
+ job: PendingResponseSpill,
453
+ candidate: ResidentResponseState,
454
+ ): void {
455
+ if (states.get(job.id) !== candidate) return;
456
+ spillCounters.writeFailures += 1;
457
+ replaceWithSpillFailure(job.id, candidate);
458
+ deferSupersededSpill(job.supersededSpill);
459
+ }
460
+
461
+ function pendingShutdownFallbackCandidates(): Array<{
462
+ job: PendingResponseSpill;
463
+ candidate: ResidentResponseState;
464
+ }> {
465
+ return [...pendingResponseSpills]
466
+ .map(job => ({ job, candidate: job.candidate }))
467
+ .filter((entry): entry is { job: PendingResponseSpill; candidate: ResidentResponseState } => !!entry.candidate);
468
+ }
469
+
470
+ function supersedeShutdownFallbackBatch(
471
+ pending: Array<{ job: PendingResponseSpill; candidate: ResidentResponseState }>,
472
+ failures: Error[],
473
+ ): void {
474
+ for (const { job } of pending) {
475
+ job.cancelled = true;
476
+ markResponseSpillPublicationSuperseded(job.publicationControl);
477
+ }
478
+ for (const { job } of pending) {
479
+ const cleanupFailure = cleanupSupersededResponseSpillPublication(job.publicationControl);
480
+ if (cleanupFailure) failures.push(cleanupFailure);
481
+ releasePendingResponseSpill(job);
482
+ }
483
+ }
484
+
485
+ function stopAtShutdownTerminalizationPassLimit(
486
+ pending: Array<{ job: PendingResponseSpill; candidate: ResidentResponseState }>,
487
+ failures: Error[],
488
+ ): void {
489
+ failures.push(Object.assign(new Error("Response spill shutdown terminalization pass limit exceeded"), { code: "ELOOP" }));
490
+ supersedeShutdownFallbackBatch(pending, failures);
491
+ for (const { job, candidate } of pending) {
492
+ terminalizeShutdownFallbackCandidate(job, candidate);
493
+ }
494
+ for (const [id, state] of [...states]) {
495
+ if (state.kind !== "resident") continue;
496
+ spillCounters.writeFailures += 1;
497
+ replaceWithSpillFailure(id, state);
498
+ }
499
+ recomputeOldestResident();
500
+ pruneResponses();
501
+ enforceAppOwnedMemoryBudget();
502
+ }
503
+
504
+ function terminalizeExhaustedShutdownFallback(
505
+ initial: Array<{ job: PendingResponseSpill; candidate: ResidentResponseState }>,
506
+ failures: Error[],
507
+ ): void {
508
+ let pending = initial;
509
+ let passes = 0;
510
+ const passLimit = responseSpillShutdownTerminalizationPassLimit();
511
+ // Every pass replaces each captured resident with a tombstone. Pruning may expose
512
+ // another finite batch, but resident count strictly decreases until none can requeue.
513
+ while (pending.length > 0) {
514
+ if (passes >= passLimit) {
515
+ stopAtShutdownTerminalizationPassLimit(pending, failures);
516
+ return;
517
+ }
518
+ passes += 1;
519
+ supersedeShutdownFallbackBatch(pending, failures);
520
+ for (const { job, candidate } of pending) {
521
+ failures.push(Object.assign(new Error("Response spill shutdown fallback budget exhausted"), { code: "ETIMEDOUT" }));
522
+ terminalizeShutdownFallbackCandidate(job, candidate);
523
+ }
524
+ recomputeOldestResident();
525
+ pruneResponses();
526
+ enforceAppOwnedMemoryBudget();
527
+ pending = pendingShutdownFallbackCandidates();
528
+ }
529
+ }
530
+
531
+ function fallbackPendingResponseSpills(reserveMs: number): Error[] {
532
+ const deadline = Date.now() + reserveMs;
533
+ const failures: Error[] = [];
534
+ for (;;) {
535
+ const pending = pendingShutdownFallbackCandidates();
536
+ if (pending.length === 0) return failures;
537
+ if (Date.now() >= deadline) {
538
+ terminalizeExhaustedShutdownFallback(pending, failures);
539
+ return failures;
540
+ }
541
+
542
+ supersedeShutdownFallbackBatch(pending, failures);
543
+ let reserveExhausted = false;
544
+ for (let index = 0; index < pending.length; index += 1) {
545
+ const { job, candidate } = pending[index]!;
546
+ if (states.get(job.id) !== candidate) continue;
547
+ const remaining = deadline - Date.now();
548
+ if (remaining <= 0) {
549
+ reserveExhausted = true;
550
+ for (const exhausted of pending.slice(index)) {
551
+ failures.push(Object.assign(new Error("Response spill shutdown fallback budget exhausted"), { code: "ETIMEDOUT" }));
552
+ terminalizeShutdownFallbackCandidate(exhausted.job, exhausted.candidate);
553
+ }
554
+ break;
555
+ }
556
+ try {
557
+ installShutdownFallbackSpill(job, candidate, remaining);
558
+ } catch (error) {
559
+ failures.push(error instanceof Error ? error : new Error("Response spill shutdown fallback failed"));
560
+ }
561
+ }
562
+ recomputeOldestResident();
563
+ pruneResponses();
564
+ enforceAppOwnedMemoryBudget();
565
+ if (reserveExhausted || Date.now() >= deadline) {
566
+ terminalizeExhaustedShutdownFallback(pendingShutdownFallbackCandidates(), failures);
567
+ return failures;
568
+ }
569
+ }
570
+ }
571
+
572
+ async function drainResponseSpillPublications(): Promise<void> {
573
+ const budget = responseSpillShutdownBudget();
574
+ const fallbackReserveMs = Math.min(budget.totalMs, Math.max(1, budget.fallbackReserveMs));
575
+ const drainDeadline = Date.now() + Math.max(0, budget.totalMs - fallbackReserveMs);
576
+
577
+ for (;;) {
578
+ if (pendingResponseSpills.size === 0) return;
579
+ const observed = responseSpillPublicationTail;
580
+ const settled = await awaitResponseSpillTailUntil(observed, drainDeadline);
581
+ if (!settled) {
582
+ const failures = fallbackPendingResponseSpills(fallbackReserveMs);
583
+ if (failures.length > 0) {
584
+ throw new AggregateError(failures, "Response spill shutdown fallback incomplete");
585
+ }
586
+ return;
587
+ }
588
+ if (observed === responseSpillPublicationTail) return;
589
+ }
590
+ }
591
+
163
592
  function byteCap(): number {
164
593
  return byteCapOverride ?? MAX_STORED_RESPONSE_BYTES;
165
594
  }
@@ -200,6 +629,7 @@ function recomputeOldestResident(): void {
200
629
  oldestResidentAt = null;
201
630
  for (const [id, state] of states) {
202
631
  if (state.kind !== "resident") continue;
632
+ if (pendingResponseSpillById.get(id)?.candidate === state) continue;
203
633
  if (oldestResidentAt !== null && state.createdAt >= oldestResidentAt) continue;
204
634
  oldestResidentId = id;
205
635
  oldestResidentAt = state.createdAt;
@@ -248,6 +678,7 @@ function deleteOwnedSpills(entry: StoredResponseState): void {
248
678
  function deleteEntry(id: string, options: { deleteSpill?: boolean } = {}): void {
249
679
  const existing = states.get(id);
250
680
  if (!existing) return;
681
+ const supersededSpill = cancelPendingResponseSpill(id);
251
682
  storedResponseBytes -= existing.sizeBytes;
252
683
  if (existing.kind === "resident") {
253
684
  residentResponseBytes -= existing.sizeBytes;
@@ -258,6 +689,7 @@ function deleteEntry(id: string, options: { deleteSpill?: boolean } = {}): void
258
689
  if (oldestResidentId === id) recomputeOldestResident();
259
690
  stateRevision += 1;
260
691
  if (options.deleteSpill !== false) deleteOwnedSpills(existing);
692
+ if (options.deleteSpill !== false && supersededSpill) deleteResponseSpill(supersededSpill);
261
693
  }
262
694
 
263
695
  function replaceWithSpillFailure(
@@ -362,11 +794,18 @@ function setResidentEntry(id: string, entry: ResidentInput): void {
362
794
  pruneResponses();
363
795
  return;
364
796
  }
797
+ const pending = pendingResponseSpillById.get(id);
798
+ if (windowsSecretAclApplies() && (expected?.kind === "spill" || pending?.supersededSpill)) {
799
+ replaceWithPendingResponseSpill(id, candidate, expected);
800
+ pruneResponses();
801
+ return;
802
+ }
365
803
  if (expected?.kind === "spill") {
366
804
  replaceSpillEntryAtomically(id, expected, candidate);
367
805
  pruneResponses();
368
806
  return;
369
807
  }
808
+ if (windowsSecretAclApplies()) cancelPendingResponseSpill(id);
370
809
  if (!replaceMapEntry(id, candidate, expected)) return;
371
810
  pruneResponses();
372
811
  }
@@ -389,6 +828,10 @@ function admitOversizedCandidate(
389
828
  replaceWithSpillFailure(id, expected, { deferSpillUnlink: true });
390
829
  return;
391
830
  }
831
+ if (windowsSecretAclApplies()) {
832
+ replaceWithPendingResponseSpill(id, candidate, expected, { directAdmission: true });
833
+ return;
834
+ }
392
835
  try {
393
836
  const ref = writeResponseSpillDurably(id, {
394
837
  createdAt: candidate.createdAt,
@@ -927,8 +1370,7 @@ function schedulePersist(): void {
927
1370
  schedulePersistAt(snapshotPath());
928
1371
  }
929
1372
 
930
- /** Flush any pending debounced snapshot write (graceful shutdown / deterministic tests). */
931
- export async function flushResponseState(): Promise<void> {
1373
+ async function flushResponseSnapshot(): Promise<void> {
932
1374
  if (persistTimer) {
933
1375
  await persistNow(pendingPersistPath ?? snapshotPath(), true);
934
1376
  return;
@@ -941,6 +1383,23 @@ export async function flushResponseState(): Promise<void> {
941
1383
  if (persistTimer) await persistNow(pendingPersistPath ?? snapshotPath(), true);
942
1384
  }
943
1385
 
1386
+ /** Flush publications and snapshot state; report drain failure only after persistence completes. */
1387
+ export async function flushResponseState(): Promise<void> {
1388
+ const failures: unknown[] = [];
1389
+ try {
1390
+ await drainResponseSpillPublications();
1391
+ } catch (error) {
1392
+ failures.push(error);
1393
+ }
1394
+ try {
1395
+ await flushResponseSnapshot();
1396
+ } catch (error) {
1397
+ failures.push(error);
1398
+ }
1399
+ if (failures.length === 1) throw failures[0];
1400
+ if (failures.length > 1) throw new AggregateError(failures, "Response state shutdown flush incomplete");
1401
+ }
1402
+
944
1403
  function inputItems(input: unknown): unknown[] {
945
1404
  if (input === undefined) return [];
946
1405
  if (Array.isArray(input)) return input;
@@ -1048,7 +1507,11 @@ function pruneResponses(at = now()): void {
1048
1507
  // Unconditional RAM cap. Resident payloads demote durably; stubs/tombstones are
1049
1508
  // deleted only when even their bounded metadata cannot fit the override.
1050
1509
  while (storedResponseBytes > byteCap() && states.size > 0) {
1051
- const oldestResident = [...states].find(([, entry]) => entry.kind === "resident");
1510
+ const oldestResident = [...states].find(([id, entry]) => entry.kind === "resident"
1511
+ && pendingResponseSpillById.get(id)?.candidate !== entry);
1512
+ const hasPendingResident = !oldestResident && [...states].some(([id, entry]) => entry.kind === "resident"
1513
+ && pendingResponseSpillById.get(id)?.candidate === entry);
1514
+ if (hasPendingResident) break;
1052
1515
  const oldestId = oldestResident?.[0] ?? states.keys().next().value as string | undefined;
1053
1516
  if (!oldestId) break;
1054
1517
  const entry = states.get(oldestId)!;
@@ -1056,6 +1519,10 @@ function pruneResponses(at = now()): void {
1056
1519
  deleteEntry(oldestId);
1057
1520
  continue;
1058
1521
  }
1522
+ if (windowsSecretAclApplies()) {
1523
+ queuePendingResponseSpill(oldestId, entry);
1524
+ continue;
1525
+ }
1059
1526
  try {
1060
1527
  const ref = writeResponseSpillDurably(oldestId, {
1061
1528
  createdAt: entry.createdAt,
@@ -1143,11 +1610,18 @@ export function sweepAbandonedResponseStateTemps(): number {
1143
1610
  }
1144
1611
 
1145
1612
  export function responseContinuationRetainedStoreSnapshot(): RetainedStoreSnapshot {
1613
+ let currentPendingBytes = 0;
1614
+ for (const job of pendingResponseSpills) {
1615
+ if (job.candidate && states.get(job.id) === job.candidate) currentPendingBytes += job.sizeBytes;
1616
+ }
1617
+ const detachedPendingBytes = Math.max(0, pendingResponseSpillBytes - currentPendingBytes);
1618
+ const bytes = storedResponseBytes + detachedPendingBytes;
1619
+ const evictableBytes = Math.max(0, residentResponseBytes - currentPendingBytes);
1146
1620
  return {
1147
1621
  count: states.size,
1148
- bytes: storedResponseBytes,
1149
- evictableBytes: residentResponseBytes,
1150
- pinnedBytes: Math.max(0, storedResponseBytes - residentResponseBytes),
1622
+ bytes,
1623
+ evictableBytes,
1624
+ pinnedBytes: Math.max(0, bytes - evictableBytes),
1151
1625
  oldestAt: oldestResidentAt,
1152
1626
  };
1153
1627
  }
@@ -1157,6 +1631,11 @@ export function evictOldestResponseContinuationForBudget(): number {
1157
1631
  const id = oldestResidentId;
1158
1632
  const entry = states.get(id);
1159
1633
  if (!entry || entry.kind !== "resident") return 0;
1634
+ if (windowsSecretAclApplies()) {
1635
+ queuePendingResponseSpill(id, entry);
1636
+ schedulePersist();
1637
+ return 0;
1638
+ }
1160
1639
  try {
1161
1640
  const ref = writeResponseSpillDurably(id, {
1162
1641
  createdAt: entry.createdAt,
@@ -1380,7 +1859,7 @@ export function responseStateMetrics(): ResponseStateMetrics {
1380
1859
  residentCount,
1381
1860
  spillStubCount,
1382
1861
  tombstoneCount,
1383
- totalBytes: storedResponseBytes,
1862
+ totalBytes: responseContinuationRetainedStoreSnapshot().bytes,
1384
1863
  spillPayloadBytes,
1385
1864
  largestBytes,
1386
1865
  oldestAgeMs: states.size > 0 ? at - oldestCreatedAt : 0,
@@ -1492,6 +1971,8 @@ export function clearResponseStateMemoryForTests(): void {
1492
1971
  persistTimer = null;
1493
1972
  }
1494
1973
  pendingPersistPath = null;
1974
+ for (const id of [...pendingResponseSpillById.keys()]) cancelPendingResponseSpill(id);
1975
+ pendingResponseSpillById.clear();
1495
1976
  states.clear();
1496
1977
  storedResponseBytes = 0;
1497
1978
  residentResponseBytes = 0;
@@ -66,6 +66,7 @@ import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../codex
66
66
  import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
67
67
  import {
68
68
  availableAccountGatedNativeModels,
69
+ codexModelEntitlementStateForAccount,
69
70
  resolveCodexModelEntitlements,
70
71
  } from "../codex/model-entitlements";
71
72
  export {
@@ -1211,10 +1212,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1211
1212
  ? new Map([...accountBoundNativeOpenAiSlugsBySelector(config)].map(([selector, slugs]) => {
1212
1213
  const target = accountTargets.get(selector);
1213
1214
  const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target;
1214
- const entitled = accountId ? modelEntitlements.modelsByAccount.get(accountId) : undefined;
1215
- const confirmed = accountId ? modelEntitlements.confirmedAccountIds.has(accountId) : false;
1216
1215
  return [selector, slugs.filter(slug => (
1217
- !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || (confirmed && entitled?.has(slug) === true)
1216
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)
1217
+ || (accountId !== undefined
1218
+ && codexModelEntitlementStateForAccount(modelEntitlements, accountId, slug) === "granted")
1218
1219
  ))] as const;
1219
1220
  }))
1220
1221
  : new Map<string, readonly string[]>();
@@ -458,8 +458,9 @@ export function trackStreamLifetime(
458
458
  export async function drainAndShutdown(
459
459
  server: ReturnType<typeof Bun.serve> | undefined,
460
460
  timeoutMs: number,
461
- ): Promise<void> {
461
+ ): Promise<boolean> {
462
462
  const s = server ?? _serverRef;
463
+ let shutdownSucceeded = true;
463
464
  // One absolute budget covers both a pre-existing scoped profile drain and
464
465
  // ordinary in-flight turns. A stuck scoped owner must not pin shutdown forever.
465
466
  const deadline = Date.now() + Math.max(0, timeoutMs);
@@ -491,9 +492,11 @@ export async function drainAndShutdown(
491
492
  // shutdown is usually part of.
492
493
  const stateFlush = await Promise.allSettled([flushResponseState(), flushAntigravityReplay()]);
493
494
  if (stateFlush[0]?.status === "rejected") {
495
+ shutdownSucceeded = false;
494
496
  console.warn("[responses] state flush during shutdown failed");
495
497
  }
496
498
  if (stateFlush[1]?.status === "rejected") {
499
+ shutdownSucceeded = false;
497
500
  console.warn("[antigravity] replay flush during shutdown failed");
498
501
  }
499
502
 
@@ -546,4 +549,5 @@ export async function drainAndShutdown(
546
549
  // never resume admission merely because shutdown cleanup returned.
547
550
  }
548
551
  }
552
+ return shutdownSucceeded;
549
553
  }
@@ -25,6 +25,7 @@ import { providerContextCap } from "../../providers/context-cap";
25
25
  import { isVisionReasoningEffort } from "../../reasoning-effort";
26
26
  import { routedSlug, slugEquals } from "../../providers/slug-codec";
27
27
  import type { OcxConfig } from "../../types";
28
+ import { ensureCodexEntitlementFreshness } from "../../codex/model-entitlements";
28
29
  import { fetchAllModels } from "./shared";
29
30
 
30
31
  /**
@@ -47,8 +48,16 @@ export type ManagementModelRow = Partial<CatalogModel> & {
47
48
  * models the GUI's Models tab shows — including this function's `disabled` computation,
48
49
  * which the export core (src/clients/config-export.ts) deliberately does not perform.
49
50
  */
50
- export async function listManagementModelRows(config: OcxConfig): Promise<ManagementModelRow[]> {
51
- const models = await fetchAllModels(config);
51
+ export async function listManagementModelRows(
52
+ config: OcxConfig,
53
+ options: { entitlementWaitMs?: number } = {},
54
+ ): Promise<ManagementModelRow[]> {
55
+ const [models] = await Promise.all([
56
+ fetchAllModels(config),
57
+ ensureCodexEntitlementFreshness(config, {
58
+ waitMs: options.entitlementWaitMs ?? 3_000,
59
+ }),
60
+ ]);
52
61
  const disabled = new Set(config.disabledModels ?? []);
53
62
  // Native GPT passthrough rows lead (provider "openai", bare-slug namespaced ids): sourced
54
63
  // from the static supported set so a disabled model stays listed and re-enableable.