@bitkyc08/opencodex 2.8.0 → 2.9.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 +24 -0
  2. package/bin/ocx.mjs +32 -4
  3. package/gui/dist/assets/index-CHwf3tTD.css +1 -0
  4. package/gui/dist/assets/index-u5eFOv2y.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-image-normalize.ts +114 -20
  8. package/src/adapters/anthropic.ts +126 -10
  9. package/src/adapters/azure.ts +3 -3
  10. package/src/adapters/base.ts +7 -3
  11. package/src/adapters/cursor/discovery.ts +14 -4
  12. package/src/adapters/cursor/effort-map.ts +18 -6
  13. package/src/adapters/cursor/framing.ts +102 -27
  14. package/src/adapters/cursor/kv-store.ts +30 -3
  15. package/src/adapters/cursor/live-models.ts +22 -2
  16. package/src/adapters/cursor/live-transport.ts +245 -49
  17. package/src/adapters/cursor/mcp-manager.ts +105 -8
  18. package/src/adapters/cursor/native-exec-mcp.ts +5 -3
  19. package/src/adapters/cursor/native-exec-shell.ts +296 -14
  20. package/src/adapters/cursor/native-exec.ts +381 -33
  21. package/src/adapters/cursor/protobuf-events.ts +28 -1
  22. package/src/adapters/cursor/protobuf-request.ts +71 -39
  23. package/src/adapters/cursor/request-builder.ts +2 -2
  24. package/src/adapters/cursor/transport.ts +2 -0
  25. package/src/adapters/cursor.ts +13 -2
  26. package/src/adapters/google-antigravity-replay.ts +184 -17
  27. package/src/adapters/google.ts +58 -8
  28. package/src/adapters/kiro-thinking.ts +23 -9
  29. package/src/adapters/kiro-tools.ts +49 -18
  30. package/src/adapters/kiro.ts +377 -133
  31. package/src/adapters/mimo-free.ts +36 -4
  32. package/src/adapters/openai-chat.ts +143 -17
  33. package/src/adapters/openai-responses.ts +130 -14
  34. package/src/adapters/run-turn-queue.ts +7 -1
  35. package/src/bridge.ts +466 -69
  36. package/src/chat/outbound.ts +144 -38
  37. package/src/claude/inbound-debug.ts +53 -8
  38. package/src/claude/outbound.ts +224 -38
  39. package/src/cli/agent-driven.ts +34 -1
  40. package/src/cli/catalog-prewarm.ts +5 -2
  41. package/src/cli/claude-desktop.ts +2 -2
  42. package/src/cli/doctor.ts +12 -0
  43. package/src/cli/export-command.ts +187 -0
  44. package/src/cli/help.ts +11 -0
  45. package/src/cli/index.ts +13 -3
  46. package/src/cli/init.ts +129 -102
  47. package/src/cli/opencode.ts +36 -151
  48. package/src/cli/star-prompt.ts +13 -4
  49. package/src/cli/status-oauth.ts +12 -2
  50. package/src/clients/config-export.ts +377 -0
  51. package/src/codex/account-runtime-state.ts +19 -1
  52. package/src/codex/account-store.ts +162 -82
  53. package/src/codex/auth-api.ts +467 -159
  54. package/src/codex/auth-context.ts +15 -2
  55. package/src/codex/catalog/aggregation.ts +15 -0
  56. package/src/codex/catalog/effort.ts +16 -6
  57. package/src/codex/catalog/metadata.ts +6 -0
  58. package/src/codex/catalog/parsing.ts +3 -1
  59. package/src/codex/catalog/provider-fetch.ts +29 -0
  60. package/src/codex/catalog/sync.ts +64 -7
  61. package/src/codex/catalog.ts +2 -2
  62. package/src/codex/inject.ts +5 -5
  63. package/src/codex/main-account-cache.ts +8 -1
  64. package/src/codex/model-cache.ts +81 -2
  65. package/src/codex/pool-rotation.ts +39 -0
  66. package/src/codex/project-config-warnings.ts +12 -1
  67. package/src/codex/quota.ts +35 -3
  68. package/src/codex/routing.ts +46 -1
  69. package/src/codex/shim.ts +10 -4
  70. package/src/codex/subagent-model-fallback.ts +12 -0
  71. package/src/codex/websocket-registry.ts +27 -0
  72. package/src/combos/failover.ts +31 -1
  73. package/src/combos/request.ts +9 -0
  74. package/src/combos/resolve.ts +60 -4
  75. package/src/combos/types.ts +12 -0
  76. package/src/config.ts +510 -55
  77. package/src/github/star-state.ts +13 -1
  78. package/src/images/fulfill.ts +39 -1
  79. package/src/images/loop.ts +52 -12
  80. package/src/lib/admission.ts +83 -0
  81. package/src/lib/app-owned-memory-stores.ts +173 -0
  82. package/src/lib/app-owned-memory.ts +265 -0
  83. package/src/lib/bun-stream-caps.ts +31 -7
  84. package/src/lib/config-ownership.ts +33 -0
  85. package/src/lib/crash-guard.ts +65 -5
  86. package/src/lib/debug-log-buffer.ts +47 -6
  87. package/src/lib/destination-policy.ts +12 -1
  88. package/src/lib/errors.ts +3 -0
  89. package/src/lib/gcp-adc.ts +40 -2
  90. package/src/lib/injection-debug-log.ts +26 -2
  91. package/src/lib/provider-outbound.ts +3 -0
  92. package/src/lib/sidecar-tracker.ts +5 -2
  93. package/src/lib/sse-decoder.ts +257 -37
  94. package/src/lib/state-store-registrations.ts +109 -0
  95. package/src/lib/state-store-sweeper.ts +184 -0
  96. package/src/lib/translator-budget.ts +356 -0
  97. package/src/lib/windows-secret-acl.ts +33 -12
  98. package/src/lib/winsw.ts +14 -1
  99. package/src/oauth/anthropic-routing.ts +31 -7
  100. package/src/oauth/google-antigravity.ts +2 -1
  101. package/src/oauth/health.ts +30 -12
  102. package/src/oauth/index.ts +127 -23
  103. package/src/oauth/kiro-credentials.ts +72 -1
  104. package/src/oauth/kiro.ts +23 -4
  105. package/src/oauth/store.ts +165 -18
  106. package/src/oauth/token-guardian.ts +43 -4
  107. package/src/oauth/types.ts +2 -1
  108. package/src/providers/base-url-choices.ts +10 -0
  109. package/src/providers/derive.ts +12 -0
  110. package/src/providers/free-directory.ts +4 -1
  111. package/src/providers/key-failover.ts +12 -0
  112. package/src/providers/openai-sidecar.ts +4 -1
  113. package/src/providers/quota.ts +68 -7
  114. package/src/providers/registry.ts +279 -3
  115. package/src/responses/parser.ts +5 -1
  116. package/src/responses/spill-store.ts +394 -0
  117. package/src/responses/state.ts +520 -102
  118. package/src/router.ts +18 -1
  119. package/src/server/adapter-resolve.ts +20 -3
  120. package/src/server/auth-cors.ts +121 -28
  121. package/src/server/chat-completions.ts +57 -12
  122. package/src/server/claude-messages.ts +85 -13
  123. package/src/server/index.ts +242 -100
  124. package/src/server/lifecycle.ts +155 -25
  125. package/src/server/management/agent-settings-routes.ts +79 -36
  126. package/src/server/management/api-key-usage.ts +167 -0
  127. package/src/server/management/body.ts +35 -0
  128. package/src/server/management/combo-routes.ts +5 -1
  129. package/src/server/management/config-routes.ts +42 -12
  130. package/src/server/management/logs-usage-routes.ts +41 -21
  131. package/src/server/management/model-routes.ts +188 -54
  132. package/src/server/management/oauth-account-routes.ts +115 -26
  133. package/src/server/management/provider-routes.ts +56 -6
  134. package/src/server/management/shared.ts +16 -3
  135. package/src/server/management/sidebar-routes.ts +50 -1
  136. package/src/server/management/system-restart.ts +13 -6
  137. package/src/server/management/system-routes.ts +15 -3
  138. package/src/server/management/usage-summary-cache.ts +86 -0
  139. package/src/server/management-api.ts +39 -5
  140. package/src/server/management-auth.ts +65 -14
  141. package/src/server/port-reclaim.ts +58 -12
  142. package/src/server/ports.ts +2 -0
  143. package/src/server/proxy-liveness.ts +60 -14
  144. package/src/server/relay-eager.ts +20 -4
  145. package/src/server/relay.ts +548 -154
  146. package/src/server/request-decompress.ts +51 -4
  147. package/src/server/request-log.ts +134 -15
  148. package/src/server/responses/collaboration.ts +15 -4
  149. package/src/server/responses/compact.ts +3 -0
  150. package/src/server/responses/core.ts +241 -66
  151. package/src/server/responses-image-gen-repair.ts +19 -5
  152. package/src/server/responses-item-id-repair.ts +23 -5
  153. package/src/server/sse-payload-rewrite.ts +71 -12
  154. package/src/server/startup-health-cache.ts +14 -1
  155. package/src/server/system-env.ts +8 -1
  156. package/src/server/windows-tcp-drop.ts +15 -5
  157. package/src/server/ws-bridge.ts +25 -0
  158. package/src/service.ts +179 -13
  159. package/src/storage/policy-job.ts +93 -23
  160. package/src/storage/policy-worker.ts +6 -0
  161. package/src/storage/restore-job.ts +62 -16
  162. package/src/storage/restore-worker.ts +6 -0
  163. package/src/storage/storage-mutation-coordinator.ts +36 -6
  164. package/src/storage/worker-lifecycle.ts +181 -47
  165. package/src/tray/windows.ts +97 -25
  166. package/src/types.ts +39 -6
  167. package/src/update/index.ts +24 -5
  168. package/src/update/job.ts +598 -73
  169. package/src/usage/log.ts +115 -17
  170. package/src/usage/summary.ts +67 -2
  171. package/src/vision/index.ts +112 -22
  172. package/src/web-search/loop.ts +38 -6
  173. package/src/web-search/progress-stream.ts +14 -3
  174. package/gui/dist/assets/index-BDjpkcRN.js +0 -67
  175. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
package/src/service.ts CHANGED
@@ -6,10 +6,11 @@
6
6
  * restore it via the command.
7
7
  */
8
8
  import { execFileSync, execSync } from "node:child_process";
9
+ import { findLiveProxy, SERVICE_STOP_LIVENESS } from "./server/proxy-liveness";
9
10
  import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
10
11
  import { homedir } from "node:os";
11
12
  import { dirname, join, resolve } from "node:path";
12
- import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort } from "./config";
13
+ import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort, verifyPidIdentity } from "./config";
13
14
  import { loadConfig } from "./config";
14
15
  import { restoreNativeCodex } from "./codex/inject";
15
16
  import { stripGrokConfig } from "./grok/inject";
@@ -17,7 +18,6 @@ import { isWslRuntime } from "./codex/home";
17
18
  import { durableBunPath, durableBunRuntime } from "./lib/bun-runtime";
18
19
  import { isProcessAlive, stopProxy } from "./lib/process-control";
19
20
  import { serviceApiTokenFilePath } from "./lib/service-secrets";
20
- import { findLiveProxy } from "./server/proxy-liveness";
21
21
  import { randomUUID } from "node:crypto";
22
22
  import {
23
23
  ELEVATION_REQUEST_TIMEOUT_MS,
@@ -1428,7 +1428,38 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise<void>
1428
1428
  * scheduler backend first; on failure the machine is left with NO service (explicitly
1429
1429
  * reported) — never a silent fallback to the scheduler.
1430
1430
  */
1431
+ /** Refuse WinSW when the interactive user is a Microsoft account (SCM cannot authenticate it). */
1432
+ export function assertWindowsNativeServiceAccountSupported(): void {
1433
+ if (process.platform !== "win32") return;
1434
+ const source = readWindowsPrincipalSource();
1435
+ if (source?.toLowerCase() === "microsoftaccount") {
1436
+ throw new Error(
1437
+ "The native (WinSW) service backend cannot run under a Microsoft-account Windows login. "
1438
+ + "Keep the Task Scheduler backend (`ocx service install`) or sign in with a local/domain account before `ocx service install --native`.",
1439
+ );
1440
+ }
1441
+ }
1442
+
1443
+ function readWindowsPrincipalSource(): string | null {
1444
+ if (process.platform !== "win32") return null;
1445
+ const ps = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
1446
+ if (!existsSync(ps)) return null;
1447
+ try {
1448
+ const out = execFileSync(ps, [
1449
+ "-NoLogo",
1450
+ "-NoProfile",
1451
+ "-NonInteractive",
1452
+ "-Command",
1453
+ "(Get-LocalUser -Name $env:USERNAME -ErrorAction SilentlyContinue).PrincipalSource",
1454
+ ], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true }).trim();
1455
+ return out || null;
1456
+ } catch {
1457
+ return null;
1458
+ }
1459
+ }
1460
+
1431
1461
  async function installWindowsNative(): Promise<void> {
1462
+ assertWindowsNativeServiceAccountSupported();
1432
1463
  recordOwnedConfigPath(getConfigDir(), serviceStatePath());
1433
1464
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
1434
1465
  writeServiceApiTokenFile();
@@ -1463,11 +1494,49 @@ async function installWindowsNative(): Promise<void> {
1463
1494
  writeServiceInstallState("native");
1464
1495
  }
1465
1496
  function startWindows(): void { schtasks(["/run", "/tn", TASK]); }
1466
- function stopWindows(): void { try { schtasks(["/end", "/tn", TASK]); } catch { /* not running */ } }
1497
+
1498
+ export function isWindowsSchedulerEndBenign(error: unknown): boolean {
1499
+ const detail = schtasksErrorDetail(error).toLowerCase();
1500
+ return detail.includes("no running instance")
1501
+ || detail.includes("not currently running")
1502
+ || detail.includes("0x41330");
1503
+ }
1504
+
1505
+ /**
1506
+ * End the scheduler task. "Already stopped" is success; other `/end` failures are
1507
+ * swallowed so callers can still run tracked-proxy + live-proxy cleanup.
1508
+ *
1509
+ * Do not key a restart-window wait on `/end` failure: the #764 case is an `/end`
1510
+ * that *succeeds* while the wrapper survives and respawns. That verification lives
1511
+ * on the stop-verification path (poll across the restart window), not here.
1512
+ */
1513
+ export function stopWindows(): void {
1514
+ try {
1515
+ schtasks(["/end", "/tn", TASK]);
1516
+ } catch (error) {
1517
+ if (isWindowsSchedulerEndBenign(error)) return;
1518
+ }
1519
+ }
1467
1520
  function statusWindows(): string { try { return schtasks(["/query", "/tn", TASK]); } catch { return ""; } }
1468
1521
  function statusWindowsXml(): string { try { return schtasks(["/query", "/tn", TASK, "/xml"]); } catch { return ""; } }
1469
1522
  function uninstallWindows(): void {
1470
- try { schtasks(["/delete", "/tn", TASK, "/f"]); } catch { /* absent */ }
1523
+ const probe = probeWindowsSchedulerTask(TASK);
1524
+ if (probe.status === "present") {
1525
+ try {
1526
+ schtasks(["/delete", "/tn", TASK, "/f"]);
1527
+ } catch (error) {
1528
+ throw new Error(`Failed to delete Task Scheduler task ${TASK}: ${error instanceof Error ? error.message : String(error)}`);
1529
+ }
1530
+ const afterDelete = probeWindowsSchedulerTask(TASK);
1531
+ if (afterDelete.status === "present") {
1532
+ throw new Error(`Task Scheduler task ${TASK} is still present after delete — refusing to remove service assets. Retry from an elevated shell.`);
1533
+ }
1534
+ if (afterDelete.status === "unknown") {
1535
+ throw new Error(`Task Scheduler task ${TASK} presence could not be verified after delete — refusing to remove service assets.`);
1536
+ }
1537
+ } else if (probe.status === "unknown") {
1538
+ throw new Error(`Task Scheduler task ${TASK} presence could not be verified — refusing to remove service assets.`);
1539
+ }
1471
1540
  if (existsSync(windowsServiceScriptPath())) unlinkSync(windowsServiceScriptPath());
1472
1541
  if (existsSync(windowsLauncherVbsPath())) unlinkSync(windowsLauncherVbsPath());
1473
1542
  if (existsSync(windowsTaskXmlPath())) unlinkSync(windowsTaskXmlPath());
@@ -1626,18 +1695,88 @@ function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
1626
1695
 
1627
1696
  type TrackedProxyCleanupResult = "none" | "stale" | "stopped";
1628
1697
 
1698
+ function verifiedKillTarget(pid: number | null | undefined): number | null {
1699
+ if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) return null;
1700
+ const verified = verifyPidIdentity(pid);
1701
+ return verified === pid ? verified : null;
1702
+ }
1703
+
1704
+ /**
1705
+ * Whether a proxy is still answering after the service manager claimed to stop it.
1706
+ *
1707
+ * `ops.stop()` reports the outcome of the STOP COMMAND, not of the process. A Windows scheduler
1708
+ * task whose wrapper survives `schtasks /end` respawns its child a few seconds later, so a stop
1709
+ * that returned success can still leave a live proxy — and `ocx service stop` then restored
1710
+ * native Codex on top of a running one (#764). The tracked-pid cleanup does not catch it either:
1711
+ * the respawned child writes a different pid, or none this process knows about.
1712
+ *
1713
+ * Probed rather than assumed, and bounded. The respawn risk is specific to a supervisor that can
1714
+ * restart its child — the Windows scheduler wrapper — so only that case pays the restart window.
1715
+ * Everywhere else a single probe answers the question, because nothing is going to bring the
1716
+ * proxy back after `launchctl unload` or `systemctl stop`. Making every platform wait 7s on a
1717
+ * stop that already succeeded would trade one bug for a worse everyday one.
1718
+ */
1719
+ export async function proxyStillLiveAfterStop(deps: {
1720
+ findProxy?: () => Promise<{ port: number } | null>;
1721
+ sleep?: (ms: number) => Promise<void>;
1722
+ now?: () => number;
1723
+ /** Whether the stopped supervisor can respawn its child; only then is polling worth the wait. */
1724
+ canRespawn?: boolean;
1725
+ } = {}): Promise<{ port: number } | null> {
1726
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
1727
+ const now = deps.now ?? Date.now;
1728
+ const canRespawn = deps.canRespawn ?? process.platform === "win32";
1729
+ const deadline = now() + (canRespawn ? 7000 : 0);
1730
+ // Single-shot (non-respawn) still needs one full SERVICE_STOP_LIVENESS budget; respawn
1731
+ // polling shares the outer deadline so multi-candidate discovery cannot overrun it.
1732
+ const findProxy = deps.findProxy ?? (() => {
1733
+ const probeDeadline = canRespawn
1734
+ ? deadline
1735
+ : now() + (SERVICE_STOP_LIVENESS.timeoutMs! * SERVICE_STOP_LIVENESS.attempts! + 250);
1736
+ return findLiveProxy({ ...SERVICE_STOP_LIVENESS, deadlineAt: probeDeadline, nowFn: now });
1737
+ });
1738
+ for (;;) {
1739
+ try {
1740
+ const live = await findProxy();
1741
+ if (live) return live;
1742
+ } catch {
1743
+ // A probe failure is not proof the proxy is gone; keep polling until the deadline.
1744
+ }
1745
+ if (now() >= deadline) return null;
1746
+ await sleep(1000);
1747
+ }
1748
+ }
1749
+
1629
1750
  async function stopTrackedProxyIfRunning(): Promise<TrackedProxyCleanupResult> {
1751
+ let stopped = false;
1630
1752
  const pid = readPid();
1631
- if (!pid) return "none";
1632
- if (!isProcessAlive(pid)) {
1753
+ const trackedKillPid = verifiedKillTarget(pid);
1754
+ if (trackedKillPid !== null && isProcessAlive(trackedKillPid)) {
1755
+ await stopProxy(trackedKillPid);
1756
+ removePid(trackedKillPid);
1757
+ removeRuntimePort(trackedKillPid);
1758
+ stopped = true;
1759
+ } else if (pid) {
1633
1760
  removePid(pid);
1634
1761
  removeRuntimePort(pid);
1635
- return "stale";
1636
1762
  }
1637
- await stopProxy(pid);
1638
- removePid(pid);
1639
- removeRuntimePort(pid);
1640
- return "stopped";
1763
+ // Orphan recovery: the pid file can be missing/stale while the service wrapper keeps
1764
+ // a live proxy running — mirror `ocx stop`'s identity-checked findLiveProxy fallback.
1765
+ // Cap multi-candidate discovery so stop cleanup cannot hang for three full retry budgets.
1766
+ const live = await findLiveProxy({
1767
+ ...SERVICE_STOP_LIVENESS,
1768
+ deadlineAt: Date.now() + 7000,
1769
+ });
1770
+ const liveKillPid = verifiedKillTarget(live?.pid);
1771
+ if (liveKillPid !== null) {
1772
+ await stopProxy(liveKillPid);
1773
+ removePid(liveKillPid);
1774
+ removeRuntimePort(liveKillPid);
1775
+ stopped = true;
1776
+ }
1777
+ if (stopped) return "stopped";
1778
+ if (pid) return "stale";
1779
+ return "none";
1641
1780
  }
1642
1781
 
1643
1782
  async function stopTrackedProxyForServiceCommand(): Promise<TrackedProxyCleanupResult> {
@@ -1723,6 +1862,15 @@ export function isServiceInstalled(): boolean {
1723
1862
  return diagnoseService().installed;
1724
1863
  }
1725
1864
 
1865
+ /**
1866
+ * True when an installed background service can actually supervise the proxy.
1867
+ * Presence alone is not enough: stale/missing assets, conflicts, and disabled
1868
+ * registrations report `installed` but will not bring the proxy back after exit.
1869
+ */
1870
+ export function isServiceViable(): boolean {
1871
+ return diagnoseService().viable;
1872
+ }
1873
+
1726
1874
  export interface ServiceDiagnostic {
1727
1875
  supported: boolean;
1728
1876
  installed: boolean;
@@ -1945,13 +2093,29 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
1945
2093
  ops.start();
1946
2094
  console.log("✅ service started.");
1947
2095
  break;
1948
- case "stop":
2096
+ case "stop": {
1949
2097
  assertServiceEnvironmentMatchesInstall();
1950
2098
  // Only stop what is actually installed. The unguarded call ran a real `launchctl unload`
1951
2099
  // (and its Windows/Linux twins) even with nothing installed.
1952
- if (ops.status() !== null || isServiceInstalled()) ops.stop();
2100
+ if (ops.status() !== null || isServiceInstalled()) {
2101
+ ops.stop();
2102
+ }
1953
2103
  await stopTrackedProxyForServiceCommand();
1954
2104
  {
2105
+ // Verify rather than trust the stop command: a surviving wrapper respawns its child
2106
+ // seconds later, and restoring native Codex on top of a live proxy is the failure #764
2107
+ // reports as "stop reports success without stopping the proxy".
2108
+ const survivor = await proxyStillLiveAfterStop();
2109
+ if (survivor) {
2110
+ console.error(
2111
+ `❌ service stop did not take effect: a proxy is still listening on port ${survivor.port}.`
2112
+ + "\nNative Codex was NOT restored, because doing so while the proxy is running leaves"
2113
+ + " both pointing at each other. Check for a second service backend (`ocx service status`)"
2114
+ + " or a manually started proxy, then re-run `ocx service stop`.",
2115
+ );
2116
+ process.exitCode = 1;
2117
+ break;
2118
+ }
1955
2119
  const restore = restoreNativeCodex();
1956
2120
  if (restore.success) console.log("✅ service stopped + native Codex restored.");
1957
2121
  else console.error(`⚠️ service stopped, but native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` (or check $CODEX_HOME/config.toml) before using native Codex.`);
@@ -1962,6 +2126,7 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
1962
2126
  else if (!grok.ok) console.error(`⚠️ ${grok.message}`);
1963
2127
  }
1964
2128
  break;
2129
+ }
1965
2130
  case "status": {
1966
2131
  if (process.platform === "win32" && backend === "scheduler") {
1967
2132
  console.log(await inspectWindowsSchedulerServiceStatus());
@@ -2007,3 +2172,4 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
2007
2172
  process.exit(1);
2008
2173
  }
2009
2174
  }
2175
+
@@ -8,9 +8,9 @@
8
8
  import type { CleanupMode, CleanupResult } from "./cleanup";
9
9
  import { resolveCodexHomeDir } from "../codex/home";
10
10
  import {
11
- endStorageMutation,
12
11
  tryBeginStorageMutation,
13
12
  } from "./storage-mutation-coordinator";
13
+ import type { AdmissionLease } from "../lib/admission";
14
14
  import {
15
15
  isPolicyDue,
16
16
  readStorageCleanupPolicyFromConfig,
@@ -19,9 +19,12 @@ import {
19
19
  type PolicySkipReason,
20
20
  } from "./policy";
21
21
  import {
22
+ cancelQueuedStorageWorkerSpawns,
22
23
  drainStorageWorkers,
23
- registerStorageWorker,
24
+ StorageWorkerAdmissionBusyError,
24
25
  terminateStorageWorker,
26
+ tryReserveStorageWorker,
27
+ withStorageWorkerSpawnGate,
25
28
  } from "./worker-lifecycle";
26
29
 
27
30
  export type PolicyJobStatus = "idle" | "running";
@@ -82,12 +85,11 @@ let cancelActiveRun: (() => void) | null = null;
82
85
  /** Bumped on abort/reset so a late worker completion cannot clobber newer job state. */
83
86
  let runGeneration = 0;
84
87
  /** CODEX_HOME whose mutation slot this job holds (parent thread only). */
85
- let heldMutationHome: string | undefined;
88
+ let heldMutationLease: AdmissionLease | undefined;
86
89
 
87
90
  function releaseHeldMutationSlot(): void {
88
- if (heldMutationHome === undefined) return;
89
- endStorageMutation(heldMutationHome);
90
- heldMutationHome = undefined;
91
+ heldMutationLease?.release();
92
+ heldMutationLease = undefined;
91
93
  }
92
94
 
93
95
  export function setStorageCleanupPolicyJobLiveApply(
@@ -129,8 +131,16 @@ function disownActiveRun(): void {
129
131
  cancel?.();
130
132
  }
131
133
 
134
+ /**
135
+ * Fire-and-forget reset. Prefer {@link resetStorageCleanupPolicyJobForTestsAsync}
136
+ * from test beforeEach/afterEach — sync terminate races Windows
137
+ * `bun test --isolate` reclaim when the next case spawns immediately.
138
+ */
132
139
  export function resetStorageCleanupPolicyJobForTests(): void {
133
140
  disownActiveRun();
141
+ // Invalidate spawnGate callbacks that have not created a Worker yet — otherwise
142
+ // they can still spawn after this sync path releases the mutation slot.
143
+ cancelQueuedStorageWorkerSpawns();
134
144
  if (activeWorker) {
135
145
  void terminateStorageWorker(activeWorker);
136
146
  activeWorker = null;
@@ -148,19 +158,39 @@ export function resetStorageCleanupPolicyJobForTests(): void {
148
158
  * worker still exiting at that moment trips a Bun-internal assertion on Windows
149
159
  * and takes the whole run down, so a suite that spawns workers must be able to
150
160
  * wait for them rather than fire-and-forget.
161
+ *
162
+ * Do not route through the sync reset's `void terminate(...)`: that used to
163
+ * deregister the worker before the thread exited, so a follow-up drain saw an
164
+ * empty registry and returned while Windows was still reclaiming the thread.
151
165
  */
152
166
  export async function resetStorageCleanupPolicyJobForTestsAsync(): Promise<void> {
153
- resetStorageCleanupPolicyJobForTests();
154
- await drainStorageWorkers();
167
+ disownActiveRun();
168
+ cancelQueuedStorageWorkerSpawns();
169
+ const worker = activeWorker;
170
+ activeWorker = null;
171
+ inflight = null;
172
+ testHooks = null;
173
+ state = { status: "idle" };
174
+ // Join the worker before releasing the mutation slot so a concurrent run
175
+ // cannot acquire CODEX_HOME while the aborted thread is still mutating.
176
+ try {
177
+ if (worker) await terminateStorageWorker(worker);
178
+ await drainStorageWorkers();
179
+ } finally {
180
+ releaseHeldMutationSlot();
181
+ }
155
182
  }
156
183
 
157
184
  /** Terminate an in-flight worker during process shutdown. */
158
185
  export function abortStorageCleanupPolicyJob(): void {
159
186
  disownActiveRun();
187
+ cancelQueuedStorageWorkerSpawns();
160
188
  if (activeWorker) {
161
189
  void terminateStorageWorker(activeWorker);
162
190
  activeWorker = null;
163
191
  }
192
+ // Sync path cannot join; prefer abortStorageCleanupPolicyJobAsync when the
193
+ // caller can await so the mutation slot stays held until the thread exits.
164
194
  releaseHeldMutationSlot();
165
195
  if (state.status === "running") {
166
196
  state = {
@@ -177,6 +207,37 @@ export function abortStorageCleanupPolicyJob(): void {
177
207
  inflight = null;
178
208
  }
179
209
 
210
+ /**
211
+ * Shutdown path that joins worker threads. Prefer this over the sync abort when
212
+ * the caller can await (server drain, test teardown) so Windows isolate reclaim
213
+ * does not race a still-exiting storage worker.
214
+ */
215
+ export async function abortStorageCleanupPolicyJobAsync(): Promise<void> {
216
+ disownActiveRun();
217
+ cancelQueuedStorageWorkerSpawns();
218
+ const worker = activeWorker;
219
+ activeWorker = null;
220
+ if (state.status === "running") {
221
+ state = {
222
+ ...state,
223
+ status: "idle",
224
+ finishedAt: Date.now(),
225
+ lastError: "aborted",
226
+ lastOutcome: {
227
+ ok: false,
228
+ error: "evaluation_failed",
229
+ },
230
+ };
231
+ }
232
+ inflight = null;
233
+ try {
234
+ if (worker) await terminateStorageWorker(worker);
235
+ await drainStorageWorkers();
236
+ } finally {
237
+ releaseHeldMutationSlot();
238
+ }
239
+ }
240
+
180
241
  function outcomeFromResult(result: PolicyRunResult): PolicyJobOutcome {
181
242
  return {
182
243
  ok: result.ok,
@@ -232,22 +293,22 @@ function applyMutationBusy(): void {
232
293
  }
233
294
 
234
295
  function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Promise<PolicyRunResult> {
235
- return new Promise((resolve, reject) => {
296
+ const reservation = tryReserveStorageWorker();
297
+ if (!reservation) return Promise.reject(new StorageWorkerAdmissionBusyError());
298
+ return withStorageWorkerSpawnGate(() => new Promise<PolicyRunResult>((resolve, reject) => {
236
299
  const requestId = crypto.randomUUID();
237
300
  let settled = false;
238
- const worker = new Worker(new URL("./policy-worker.ts", import.meta.url).href);
239
- registerStorageWorker(worker);
301
+ let worker: Worker;
302
+ try {
303
+ worker = new Worker(new URL("./policy-worker.ts", import.meta.url).href);
304
+ reservation.bind(worker);
305
+ } catch (error) {
306
+ reservation.release();
307
+ reject(error);
308
+ return;
309
+ }
240
310
  activeWorker = worker;
241
311
 
242
- const timer = setTimeout(() => {
243
- if (settled) return;
244
- settled = true;
245
- cancelActiveRun = null;
246
- void terminateStorageWorker(worker);
247
- if (activeWorker === worker) activeWorker = null;
248
- reject(new Error("storage_cleanup_worker_timeout"));
249
- }, WORKER_TIMEOUT_MS);
250
-
251
312
  const finish = (fn: () => void) => {
252
313
  if (settled) return;
253
314
  settled = true;
@@ -256,10 +317,15 @@ function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Prom
256
317
  if (activeWorker === worker) activeWorker = null;
257
318
  // Settle the caller only after the thread is actually gone, so a suite
258
319
  // that awaits its request cannot reach the next test file with a worker
259
- // still exiting behind it.
320
+ // still exiting behind it. Also keeps executeJob's finally from releasing
321
+ // the mutation slot while the thread may still mutate CODEX_HOME.
260
322
  void terminateStorageWorker(worker).then(fn, fn);
261
323
  };
262
324
 
325
+ const timer = setTimeout(() => {
326
+ finish(() => reject(new Error("storage_cleanup_worker_timeout")));
327
+ }, WORKER_TIMEOUT_MS);
328
+
263
329
  cancelActiveRun = () => {
264
330
  finish(() => reject(new Error("aborted")));
265
331
  };
@@ -296,6 +362,9 @@ function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Prom
296
362
  ...(process.env.OPENCODEX_HOME ? { OPENCODEX_HOME: process.env.OPENCODEX_HOME } : {}),
297
363
  },
298
364
  });
365
+ })).catch(error => {
366
+ reservation.release();
367
+ throw error;
299
368
  });
300
369
  }
301
370
 
@@ -309,7 +378,7 @@ async function executeJob(opts: RequestPolicyRunOptions): Promise<void> {
309
378
  }
310
379
  return;
311
380
  }
312
- heldMutationHome = codexHome;
381
+ heldMutationLease = gate.lease;
313
382
  try {
314
383
  const blockMs = testHooks?.blockMs;
315
384
  let result: PolicyRunResult;
@@ -336,7 +405,8 @@ async function executeJob(opts: RequestPolicyRunOptions): Promise<void> {
336
405
  applyFinished(result);
337
406
  } catch (err) {
338
407
  if (generation !== runGeneration) return;
339
- applyFailed(err instanceof Error ? err.message : "worker_failed");
408
+ if (err instanceof StorageWorkerAdmissionBusyError) applyMutationBusy();
409
+ else applyFailed(err instanceof Error ? err.message : "worker_failed");
340
410
  } finally {
341
411
  releaseHeldMutationSlot();
342
412
  }
@@ -49,5 +49,11 @@ self.onmessage = (event: MessageEvent<unknown>) => {
49
49
  requestId,
50
50
  message: err instanceof Error ? err.message : "worker_failed",
51
51
  });
52
+ } finally {
53
+ // Close from inside the worker so the thread begins exiting before the
54
+ // parent’s terminate() races isolate realm reclaim on Windows.
55
+ try {
56
+ (self as unknown as { close?: () => void }).close?.();
57
+ } catch { /* already closing */ }
52
58
  }
53
59
  };
@@ -17,9 +17,12 @@ import {
17
17
  type StorageMutationCoordinatorTestHooks,
18
18
  } from "./storage-mutation-coordinator";
19
19
  import {
20
+ cancelQueuedStorageWorkerSpawns,
20
21
  drainStorageWorkers,
21
- registerStorageWorker,
22
+ StorageWorkerAdmissionBusyError,
22
23
  terminateStorageWorker,
24
+ tryReserveStorageWorker,
25
+ withStorageWorkerSpawnGate,
23
26
  } from "./worker-lifecycle";
24
27
 
25
28
  export interface RestoreJobTestHooks extends StorageMutationCoordinatorTestHooks {
@@ -72,7 +75,12 @@ export function setRestoreTrashJobTestHooks(hooks: RestoreJobTestHooks | null):
72
75
  setStorageMutationCoordinatorTestHooks(hooks);
73
76
  }
74
77
 
78
+ /**
79
+ * Fire-and-forget reset. Prefer {@link resetRestoreTrashJobForTestsAsync} from
80
+ * test beforeEach/afterEach under `bun test --isolate` on Windows.
81
+ */
75
82
  export function resetRestoreTrashJobForTests(): void {
83
+ cancelQueuedStorageWorkerSpawns();
76
84
  if (activeWorker) {
77
85
  void terminateStorageWorker(activeWorker);
78
86
  activeWorker = null;
@@ -83,14 +91,31 @@ export function resetRestoreTrashJobForTests(): void {
83
91
  resetStorageMutationCoordinatorForTests();
84
92
  }
85
93
 
86
- /** Await-able reset for test teardown; see policy-job's equivalent for why. */
94
+ /**
95
+ * Await-able reset for test teardown; see policy-job's equivalent for why.
96
+ * Awaits the active worker directly so drain cannot race a fire-and-forget
97
+ * deregister from the sync reset.
98
+ */
87
99
  export async function resetRestoreTrashJobForTestsAsync(): Promise<void> {
88
- resetRestoreTrashJobForTests();
89
- await drainStorageWorkers();
100
+ cancelQueuedStorageWorkerSpawns();
101
+ const worker = activeWorker;
102
+ activeWorker = null;
103
+ cancelActiveRun?.();
104
+ cancelActiveRun = null;
105
+ testHooks = null;
106
+ // Join the worker before clearing the mutation coordinator so a concurrent
107
+ // run cannot acquire CODEX_HOME while the aborted thread is still mutating.
108
+ try {
109
+ if (worker) await terminateStorageWorker(worker);
110
+ await drainStorageWorkers();
111
+ } finally {
112
+ resetStorageMutationCoordinatorForTests();
113
+ }
90
114
  }
91
115
 
92
116
  /** Terminate an in-flight worker during process shutdown. */
93
117
  export function abortRestoreTrashJob(): void {
118
+ cancelQueuedStorageWorkerSpawns();
94
119
  if (activeWorker) {
95
120
  void terminateStorageWorker(activeWorker);
96
121
  activeWorker = null;
@@ -99,6 +124,17 @@ export function abortRestoreTrashJob(): void {
99
124
  cancelActiveRun = null;
100
125
  }
101
126
 
127
+ /** Await-able shutdown sibling; joins the restore worker thread before returning. */
128
+ export async function abortRestoreTrashJobAsync(): Promise<void> {
129
+ cancelQueuedStorageWorkerSpawns();
130
+ const worker = activeWorker;
131
+ activeWorker = null;
132
+ cancelActiveRun?.();
133
+ cancelActiveRun = null;
134
+ if (worker) await terminateStorageWorker(worker);
135
+ await drainStorageWorkers();
136
+ }
137
+
102
138
  /** Test-only SSE/text stream served from the proxy while a worker is blocked. */
103
139
  export function getRestoreTrashTestStreamResponse(): Response | null {
104
140
  if (!testHooks?.enableTestStream) return null;
@@ -124,31 +160,37 @@ function runInWorker(opts: {
124
160
  blockMs?: number;
125
161
  restoreTest?: RestoreTestHooks;
126
162
  }): Promise<RestoreResult> {
127
- return new Promise((resolve, reject) => {
163
+ const reservation = tryReserveStorageWorker();
164
+ if (!reservation) return Promise.reject(new StorageWorkerAdmissionBusyError());
165
+ return withStorageWorkerSpawnGate(() => new Promise<RestoreResult>((resolve, reject) => {
128
166
  const requestId = crypto.randomUUID();
129
167
  let settled = false;
130
- const worker = new Worker(new URL("./restore-worker.ts", import.meta.url).href);
131
- registerStorageWorker(worker);
168
+ let worker: Worker;
169
+ try {
170
+ worker = new Worker(new URL("./restore-worker.ts", import.meta.url).href);
171
+ reservation.bind(worker);
172
+ } catch (error) {
173
+ reservation.release();
174
+ reject(error);
175
+ return;
176
+ }
132
177
  activeWorker = worker;
133
178
 
134
- const timer = setTimeout(() => {
135
- if (settled) return;
136
- settled = true;
137
- cancelActiveRun = null;
138
- void terminateStorageWorker(worker);
139
- if (activeWorker === worker) activeWorker = null;
140
- reject(new Error("restore_worker_timeout"));
141
- }, WORKER_TIMEOUT_MS);
142
-
143
179
  const finish = (fn: () => void) => {
144
180
  if (settled) return;
145
181
  settled = true;
146
182
  cancelActiveRun = null;
147
183
  clearTimeout(timer);
148
184
  if (activeWorker === worker) activeWorker = null;
185
+ // Join before settle so mutation coordination inside the restore worker
186
+ // cannot overlap a follow-on run after a watchdog timeout.
149
187
  void terminateStorageWorker(worker).then(fn, fn);
150
188
  };
151
189
 
190
+ const timer = setTimeout(() => {
191
+ finish(() => reject(new Error("restore_worker_timeout")));
192
+ }, WORKER_TIMEOUT_MS);
193
+
152
194
  cancelActiveRun = () => {
153
195
  finish(() => reject(new Error("aborted")));
154
196
  };
@@ -185,6 +227,9 @@ function runInWorker(opts: {
185
227
  ...(process.env.OPENCODEX_HOME ? { OPENCODEX_HOME: process.env.OPENCODEX_HOME } : {}),
186
228
  },
187
229
  });
230
+ })).catch(error => {
231
+ reservation.release();
232
+ throw error;
188
233
  });
189
234
  }
190
235
 
@@ -215,6 +260,7 @@ async function executeRestore(opts: {
215
260
  ...(restoreTest ? { restoreTest } : {}),
216
261
  });
217
262
  } catch (err) {
263
+ if (err instanceof StorageWorkerAdmissionBusyError) return busyRestoreResult();
218
264
  return restoreResultFromWorkerRejection(err, opts.trashId);
219
265
  }
220
266
  }
@@ -46,6 +46,12 @@ self.onmessage = async (event: MessageEvent<unknown>) => {
46
46
  requestId,
47
47
  message: err instanceof Error ? err.message : "worker_failed",
48
48
  });
49
+ } finally {
50
+ // Close from inside the worker so the thread begins exiting before the
51
+ // parent’s terminate() races isolate realm reclaim on Windows.
52
+ try {
53
+ (self as unknown as { close?: () => void }).close?.();
54
+ } catch { /* already closing */ }
49
55
  }
50
56
  };
51
57