@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
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { resolve } from "node:path";
9
9
  import { resolveCodexHomeDir } from "../codex/home";
10
+ import { createAdmissionGate, type AdmissionLease, type AdmissionMetrics } from "../lib/admission";
10
11
 
11
12
  export type StorageMutationKind = "cleanup" | "restore" | "policy";
12
13
 
@@ -20,9 +21,13 @@ export interface StorageMutationCoordinatorTestHooks {
20
21
  interface ActiveSlot {
21
22
  kind: StorageMutationKind;
22
23
  startedAt: number;
24
+ lease: AdmissionLease;
23
25
  }
24
26
 
27
+ export const MAX_ACTIVE_STORAGE_HOME_SLOTS = 32;
25
28
  const slots = new Map<string, ActiveSlot>();
29
+ const slotGate = createAdmissionGate("storage_home_slots", MAX_ACTIVE_STORAGE_HOME_SLOTS);
30
+ let releaseMisses = 0;
26
31
  let testHooks: StorageMutationCoordinatorTestHooks | null = null;
27
32
 
28
33
  function slotKey(codexHome?: string): string {
@@ -37,9 +42,15 @@ export function setStorageMutationCoordinatorTestHooks(
37
42
 
38
43
  export function resetStorageMutationCoordinatorForTests(): void {
39
44
  testHooks = null;
45
+ for (const slot of slots.values()) slot.lease.release();
40
46
  slots.clear();
41
47
  }
42
48
 
49
+ export function storageMutationAdmissionMetrics(): AdmissionMetrics {
50
+ const metrics = slotGate.metrics();
51
+ return { ...metrics, releaseMisses: metrics.releaseMisses + releaseMisses };
52
+ }
53
+
43
54
  export function getActiveStorageMutation(
44
55
  codexHome?: string,
45
56
  ): { kind: StorageMutationKind; startedAt: number } | null {
@@ -50,17 +61,36 @@ export function getActiveStorageMutation(
50
61
  export function tryBeginStorageMutation(
51
62
  kind: StorageMutationKind,
52
63
  codexHome?: string,
53
- ): { acquired: true } | { acquired: false; error: StorageMutationBusyError } {
64
+ ): { acquired: true; lease: AdmissionLease } | { acquired: false; error: StorageMutationBusyError } {
54
65
  const key = slotKey(codexHome);
55
66
  if (slots.has(key)) {
56
67
  return { acquired: false, error: "storage_mutation_busy" };
57
68
  }
58
- slots.set(key, { kind, startedAt: Date.now() });
59
- return { acquired: true };
69
+ const lease = slotGate.tryAcquire();
70
+ if (!lease) return { acquired: false, error: "storage_mutation_busy" };
71
+ let active = true;
72
+ const ownerLease: AdmissionLease = {
73
+ release() {
74
+ if (!active) return;
75
+ active = false;
76
+ const owner = slots.get(key);
77
+ if (owner?.lease === ownerLease) slots.delete(key);
78
+ lease.release();
79
+ },
80
+ };
81
+ slots.set(key, { kind, startedAt: Date.now(), lease: ownerLease });
82
+ return { acquired: true, lease: ownerLease };
60
83
  }
61
84
 
62
85
  export function endStorageMutation(codexHome?: string): void {
63
- slots.delete(slotKey(codexHome));
86
+ const key = slotKey(codexHome);
87
+ const slot = slots.get(key);
88
+ if (!slot) {
89
+ releaseMisses += 1;
90
+ return;
91
+ }
92
+ slots.delete(key);
93
+ slot.lease.release();
64
94
  }
65
95
 
66
96
  async function applyCoordinatorBlock(): Promise<void> {
@@ -87,7 +117,7 @@ export async function runPolicyStorageMutation<T>(
87
117
  await applyCoordinatorBlock();
88
118
  return await work();
89
119
  } finally {
90
- endStorageMutation(codexHome);
120
+ gate.lease.release();
91
121
  }
92
122
  }
93
123
 
@@ -104,6 +134,6 @@ export async function withStorageMutationSlot<T>(
104
134
  await applyCoordinatorBlock();
105
135
  return await work();
106
136
  } finally {
107
- endStorageMutation(codexHome);
137
+ gate.lease.release();
108
138
  }
109
139
  }
@@ -2,77 +2,211 @@
2
2
  * Deterministic teardown for the storage Bun Workers.
3
3
  *
4
4
  * `Worker.terminate()` returns void and does NOT wait for the thread to be
5
- * reclaimed. Every caller here used to fire it and move on, which is fine for
6
- * the proxy but not for `bun test --isolate`: the harness tears a test file's
7
- * realm down at the file boundary, and on Windows a worker that has not
8
- * finished exiting by then trips a Bun-internal assertion and kills the whole
9
- * run.
5
+ * reclaimed. Callers that fire-and-forget leave a window where
6
+ * `bun test --isolate` reclaims the file realm while a Windows worker thread
7
+ * is still exiting Bun then panics with
8
+ * `workers_spawned(N) workers_terminated(N-1)` / Internal assertion failure.
10
9
  *
11
- * The crash header names the shape exactly `workers_spawned(9)
12
- * workers_terminated(8)`, one worker still alive and it lands right at the
13
- * `api-storage-policy` `api-storage` file boundary, the only suite that
14
- * spawns policy workers. See `devlog/_plan/260730_remote_issue_merge_round/150`,
15
- * which reproduced the panic twice against an unchanged tree and left this
16
- * defence as the follow-up if it ever recurred. It recurred.
17
- *
18
- * So: keep a registry of live workers, and give shutdown/reset paths something
19
- * they can actually await. `close` is Bun's post-exit event for a worker
20
- * thread, so awaiting it is the real "the thread is gone" signal rather than a
21
- * timer we hope is long enough. The timeout only exists so a wedged worker
22
- * cannot hang a test teardown forever.
10
+ * Invariant: every registered worker has a `close` listener attached at spawn
11
+ * time (so we cannot miss `self.close()` / early exit), stays in `liveWorkers`
12
+ * until that close settles, and `drainStorageWorkers()` joins every in-flight
13
+ * terminate. Spawns are serialized through `withStorageWorkerSpawnGate` so the
14
+ * next Worker cannot be created until prior threads have exited. On Windows and
15
+ * macOS, a post-close settle covers the OS join gap Bun does not expose
16
+ * (Windows unbalanced join panic; macOS Silicon balanced-count segfault under
17
+ * `bun test --isolate`).
18
+ */
19
+
20
+ import { createAdmissionGate, type AdmissionMetrics, type AdmissionReservation } from "../lib/admission";
21
+
22
+ type TrackedWorker = {
23
+ worker: Worker;
24
+ closed: Promise<void>;
25
+ resolveClosed: () => void;
26
+ terminatePromise?: Promise<void>;
27
+ reservation?: AdmissionReservation<Worker>;
28
+ };
29
+
30
+ export const MAX_RESERVED_STORAGE_WORKER_SPAWNS = 16;
31
+ export class StorageWorkerAdmissionBusyError extends Error {
32
+ readonly code = "storage_mutation_busy";
33
+
34
+ constructor() {
35
+ super("storage worker spawn queue is busy");
36
+ this.name = "StorageWorkerAdmissionBusyError";
37
+ }
38
+ }
39
+
40
+ const liveWorkers = new Map<Worker, TrackedWorker>();
41
+ const workerGate = createAdmissionGate("storage_worker_reservations", MAX_RESERVED_STORAGE_WORKER_SPAWNS);
42
+
43
+ /** Serialize spawns so a new Worker never overlaps a still-exiting predecessor. */
44
+ let spawnGate: Promise<void> = Promise.resolve();
45
+
46
+ /**
47
+ * Bumped by teardown so a spawn still queued on `spawnGate` (not yet in
48
+ * `liveWorkers`) cannot create a Worker after reset/shutdown reported idle.
49
+ */
50
+ let spawnCancelEpoch = 0;
51
+
52
+ /**
53
+ * OS-join gap after the `close` event on platforms where Bun's Worker reclaim
54
+ * races the isolate/file boundary (not a CI job-timeout bump).
55
+ * Windows GHA at 250ms and 750ms still left `workers_spawned(N)
56
+ * workers_terminated(N-1)` panics under isolate (seen mid
57
+ * `storage-mutation-race` with 11/10). 1500ms covers deferred reclaim under
58
+ * stacked policy/restore workers. Darwin uses 250ms.
23
59
  */
60
+ const WORKER_OS_JOIN_MS = process.platform === "win32" ? 1_500 : 250;
24
61
 
25
- const liveWorkers = new Set<Worker>();
62
+ function needsWorkerOsJoinSettle(): boolean {
63
+ return process.platform === "win32" || process.platform === "darwin";
64
+ }
65
+
66
+ /** Invalidate spawn callbacks still waiting on the gate (reset / server drain). */
67
+ export function cancelQueuedStorageWorkerSpawns(): void {
68
+ spawnCancelEpoch += 1;
69
+ }
26
70
 
27
71
  /** Track a freshly spawned worker so teardown can wait for it later. */
28
72
  export function registerStorageWorker(worker: Worker): void {
29
- liveWorkers.add(worker);
73
+ if (liveWorkers.has(worker)) return;
74
+
75
+ let resolveClosed!: () => void;
76
+ const closed = new Promise<void>(resolve => {
77
+ resolveClosed = resolve;
78
+ });
79
+
80
+ const tracked: TrackedWorker = { worker, closed, resolveClosed };
81
+ liveWorkers.set(worker, tracked);
82
+
83
+ try {
84
+ worker.addEventListener("close", () => {
85
+ resolveClosed();
86
+ }, { once: true });
87
+ } catch {
88
+ // No close event — terminate path will still resolve via timeout/settle.
89
+ }
90
+ }
91
+
92
+ export function tryReserveStorageWorker(): AdmissionReservation<Worker> | null {
93
+ const gateLease = workerGate.tryAcquire();
94
+ if (!gateLease) return null;
95
+ let active = true;
96
+ let bound: Worker | undefined;
97
+ const reservation: AdmissionReservation<Worker> = {
98
+ bind(worker) {
99
+ if (!active) return;
100
+ bound = worker;
101
+ registerStorageWorker(worker);
102
+ const tracked = liveWorkers.get(worker);
103
+ if (tracked) tracked.reservation = reservation;
104
+ },
105
+ release() {
106
+ if (!active) return;
107
+ active = false;
108
+ if (bound) {
109
+ const tracked = liveWorkers.get(bound);
110
+ if (tracked?.reservation === reservation) tracked.reservation = undefined;
111
+ }
112
+ bound = undefined;
113
+ gateLease.release();
114
+ },
115
+ };
116
+ return reservation;
117
+ }
118
+
119
+ export function storageWorkerAdmissionMetrics(): AdmissionMetrics {
120
+ return workerGate.metrics();
121
+ }
122
+
123
+ /**
124
+ * Run `fn` only after every previously gated spawn has finished tearing down.
125
+ * Used around `new Worker(...)` so tests cannot overlap Windows thread exit.
126
+ */
127
+ export function withStorageWorkerSpawnGate<T>(fn: () => Promise<T>): Promise<T> {
128
+ const epochAtEnqueue = spawnCancelEpoch;
129
+ const run = spawnGate.then(async () => {
130
+ if (epochAtEnqueue !== spawnCancelEpoch) {
131
+ throw new Error("storage_worker_spawn_cancelled");
132
+ }
133
+ await drainStorageWorkers();
134
+ if (epochAtEnqueue !== spawnCancelEpoch) {
135
+ throw new Error("storage_worker_spawn_cancelled");
136
+ }
137
+ return fn();
138
+ });
139
+ spawnGate = run.then(
140
+ () => undefined,
141
+ () => undefined,
142
+ );
143
+ return run;
144
+ }
145
+
146
+ export function queuedStorageWorkerSpawnCount(): number {
147
+ return Math.max(0, workerGate.metrics().active - liveWorkers.size);
30
148
  }
31
149
 
32
150
  /**
33
151
  * Terminate a worker and resolve once its thread has actually exited.
34
152
  *
35
- * Safe to call twice: the second call finds the worker already deregistered and
36
- * resolves immediately.
153
+ * Safe to call twice: the second call joins the in-flight terminate promise.
37
154
  */
38
155
  export function terminateStorageWorker(worker: Worker, timeoutMs = 5_000): Promise<void> {
39
- if (!liveWorkers.has(worker)) {
156
+ const tracked = liveWorkers.get(worker);
157
+ if (!tracked) {
40
158
  try { worker.terminate(); } catch { /* already gone */ }
41
159
  return Promise.resolve();
42
160
  }
43
- liveWorkers.delete(worker);
161
+ if (tracked.terminatePromise) return tracked.terminatePromise;
162
+
163
+ tracked.terminatePromise = (async () => {
164
+ let timedOut = false;
165
+ const timer = setTimeout(() => {
166
+ timedOut = true;
167
+ tracked.resolveClosed();
168
+ }, timeoutMs);
44
169
 
45
- return new Promise<void>(resolve => {
46
- let done = false;
47
- const settle = (): void => {
48
- if (done) return;
49
- done = true;
50
- clearTimeout(timer);
51
- resolve();
52
- };
53
- // A worker that refuses to exit must not wedge teardown; the proxy path
54
- // never awaits this, and a test teardown would rather continue than hang.
55
- const timer = setTimeout(settle, timeoutMs);
56
- try {
57
- worker.addEventListener("close", settle, { once: true });
58
- } catch {
59
- // No close event available — fall back to the timeout above.
60
- }
61
170
  try {
62
- worker.terminate();
63
- } catch {
64
- settle();
171
+ try {
172
+ worker.terminate();
173
+ } catch {
174
+ tracked.resolveClosed();
175
+ }
176
+ await tracked.closed;
177
+ // Disarm before the OS-join settle: a late timer firing during the sleep
178
+ // would set timedOut after close already won and throw a false timeout.
179
+ clearTimeout(timer);
180
+ // Always run the OS-join settle before throwing on timeout: the timer
181
+ // only forces `closed`, it does not prove the OS thread has exited.
182
+ // Callers that catch and continue (e.g. drainAndShutdown) still need
183
+ // that gap before the next isolate reclaim or server.stop.
184
+ if (needsWorkerOsJoinSettle()) {
185
+ await Bun.sleep(0);
186
+ await Bun.sleep(WORKER_OS_JOIN_MS);
187
+ }
188
+ if (timedOut) {
189
+ throw new Error(`storage worker did not exit within ${timeoutMs}ms`);
190
+ }
191
+ } finally {
192
+ clearTimeout(timer);
193
+ liveWorkers.delete(worker);
194
+ tracked.reservation?.release();
195
+ tracked.reservation = undefined;
65
196
  }
66
- });
197
+ })();
198
+
199
+ return tracked.terminatePromise;
67
200
  }
68
201
 
69
202
  /**
70
- * Await every worker this module still tracks. Used by test resets so no
71
- * storage worker outlives the file that spawned it.
203
+ * Await every worker this module still tracks (including terminations already
204
+ * in flight). Used by test resets so no storage worker outlives the file that
205
+ * spawned it.
72
206
  */
73
207
  export async function drainStorageWorkers(timeoutMs = 5_000): Promise<void> {
74
- const pending = [...liveWorkers];
75
- await Promise.all(pending.map(worker => terminateStorageWorker(worker, timeoutMs)));
208
+ const pending = [...liveWorkers.keys()].map(worker => terminateStorageWorker(worker, timeoutMs));
209
+ await Promise.all(pending);
76
210
  }
77
211
 
78
212
  /** Live worker count — exported so a regression test can assert the invariant. */
@@ -5,7 +5,7 @@ import { homedir } from "node:os";
5
5
  import { join, resolve } from "node:path";
6
6
  import { expandUserPath, getConfigDir } from "../config";
7
7
  import { durableBunPath } from "../lib/bun-runtime";
8
- import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
8
+ import { forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
9
9
  import { recordOwnedConfigPath } from "../lib/config-ownership";
10
10
 
11
11
  const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
@@ -150,8 +150,16 @@ function quoteRunValue(value: string): string {
150
150
  return `\"${value}\"`;
151
151
  }
152
152
 
153
- /** Command persisted under HKCU Run. Every value is an owned absolute package/home path. */
154
- export function buildWindowsTrayRunCommand(entry: WindowsTrayEntry, powershell = windowsPowerShellPath()): string {
153
+ function installedTrayLauncherPath(): string {
154
+ return join(getConfigDir(), "opencodex-tray.vbs");
155
+ }
156
+
157
+ function quoteVbsPath(value: string): string {
158
+ return value.replace(/"/g, '""');
159
+ }
160
+
161
+ /** Full PowerShell invocation used by the owned VBS launcher (not written to HKCU Run). */
162
+ export function buildWindowsTrayPowerShellCommand(entry: WindowsTrayEntry, powershell = windowsPowerShellPath()): string {
155
163
  return [
156
164
  quoteRunValue(powershell),
157
165
  "-NoLogo",
@@ -169,6 +177,27 @@ export function buildWindowsTrayRunCommand(entry: WindowsTrayEntry, powershell =
169
177
  ].join(" ");
170
178
  }
171
179
 
180
+ /** Short HKCU Run command (must stay ≤260 chars under long Windows user/npm paths). */
181
+ export function buildWindowsTrayRunCommand(entry: WindowsTrayEntry & { launcherPath: string }): string {
182
+ const wscript = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "wscript.exe");
183
+ return `${quoteRunValue(wscript)} //B //NoLogo ${quoteRunValue(entry.launcherPath)}`;
184
+ }
185
+
186
+ export function buildWindowsTrayLauncherScript(entry: WindowsTrayEntry, powershell = windowsPowerShellPath()): string {
187
+ const command = buildWindowsTrayPowerShellCommand(entry, powershell);
188
+ // VBS CreateObject("WScript.Shell").Run command, 0, False — hidden, non-blocking.
189
+ return [
190
+ "' OpenCodex owned tray launcher — do not edit by hand.",
191
+ `CreateObject("WScript.Shell").Run "${quoteVbsPath(command)}", 0, False`,
192
+ "",
193
+ ].join("\r\n");
194
+ }
195
+
196
+ /** @deprecated Prefer buildWindowsTrayPowerShellCommand; kept for callers that still expect the long form. */
197
+ export function buildWindowsTrayLegacyRunCommand(entry: WindowsTrayEntry, powershell = windowsPowerShellPath()): string {
198
+ return buildWindowsTrayPowerShellCommand(entry, powershell);
199
+ }
200
+
172
201
  function readState(): WindowsTrayState | null {
173
202
  try {
174
203
  const state = JSON.parse(readFileSync(trayStatePath(), "utf8")) as Partial<WindowsTrayState>;
@@ -189,24 +218,55 @@ function readState(): WindowsTrayState | null {
189
218
  }
190
219
  }
191
220
 
192
- function replaceOwnedFile(path: string, contents: string | Buffer): void {
221
+ export interface WindowsTrayOwnedFileIO {
222
+ write(path: string, contents: string | Buffer): void;
223
+ harden(path: string): void;
224
+ rename(source: string, destination: string): void;
225
+ unlink(path: string): void;
226
+ }
227
+
228
+ export function replaceWindowsTrayOwnedFile(
229
+ path: string,
230
+ contents: string | Buffer,
231
+ io: WindowsTrayOwnedFileIO = {
232
+ write: (target, value) => writeFileSync(target, value, { mode: 0o600 }),
233
+ harden: target => {
234
+ try { chmodSync(target, 0o600); } catch { /* best-effort */ }
235
+ if (process.platform !== "win32") return;
236
+ const hardened = hardenSecretPath(target, { required: true });
237
+ if (!hardened.ok) throw new Error("Windows tray ACL hardening did not complete; refusing to persist executable state.");
238
+ },
239
+ rename: renameSync,
240
+ unlink: unlinkSync,
241
+ },
242
+ ): void {
193
243
  const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
194
- writeFileSync(temporary, contents, { mode: 0o600 });
244
+ io.write(temporary, contents);
245
+ let renamed = false;
195
246
  try {
196
- try { chmodSync(temporary, 0o600); } catch { /* best-effort */ }
197
- if (process.platform === "win32") {
198
- const hardened = hardenSecretPath(temporary, { required: true });
199
- if (!hardened.ok) throw new Error("Windows tray ACL hardening did not complete; refusing to persist executable state.");
200
- }
201
- renameSync(temporary, path);
247
+ io.harden(temporary);
248
+ io.rename(temporary, path);
249
+ renamed = true;
250
+ forgetHardenedSecretPath(temporary);
202
251
  } finally {
203
- try { if (existsSync(temporary)) unlinkSync(temporary); } catch { /* best-effort */ }
252
+ if (!renamed) {
253
+ try {
254
+ io.unlink(temporary);
255
+ forgetHardenedSecretPath(temporary);
256
+ } catch (error) {
257
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") forgetHardenedSecretPath(temporary);
258
+ }
259
+ }
204
260
  }
205
261
  }
206
262
 
207
- function writeState(entry: WindowsTrayEntry, runValue: string, runCommand: string): void {
263
+ function writeState(
264
+ entry: WindowsTrayEntry & { launcherPath: string },
265
+ runValue: string,
266
+ runCommand: string,
267
+ ): void {
208
268
  const path = trayStatePath();
209
- replaceOwnedFile(path, JSON.stringify({ version: TRAY_STATE_VERSION, ...entry, runValue, runCommand }, null, 2) + "\n");
269
+ replaceWindowsTrayOwnedFile(path, JSON.stringify({ version: TRAY_STATE_VERSION, ...entry, runValue, runCommand }, null, 2) + "\n");
210
270
  }
211
271
 
212
272
  export function parseWindowsTrayRunValue(output: string, runValue: string): string | null {
@@ -376,7 +436,8 @@ function trayStatusFrom(registered: string | null): WindowsTrayStatus {
376
436
  const running = heartbeatProcessAlive(heartbeat);
377
437
  const registrationOwned = state !== null
378
438
  && registered === state.runCommand
379
- && [state.bun, state.cli, state.script, ...installedTrayIconPaths()].every(path => existsSync(path));
439
+ && [state.bun, state.cli, state.script, ...(state.launcherPath ? [state.launcherPath] : []), ...installedTrayIconPaths()]
440
+ .every(path => existsSync(path));
380
441
  const stale = windowsTrayRegistrationIsStale({
381
442
  registered: registered !== null,
382
443
  registrationOwned,
@@ -507,7 +568,12 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus {
507
568
  }
508
569
  recordOwnedConfigPath(getConfigDir(), trayStatePath());
509
570
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
510
- const runCommand = buildWindowsTrayRunCommand(entry);
571
+ const launcherPath = installedTrayLauncherPath();
572
+ const entryWithLauncher = { ...entry, launcherPath };
573
+ const runCommand = buildWindowsTrayRunCommand(entryWithLauncher);
574
+ if (runCommand.length > 260) {
575
+ throw new Error(`Tray Run command exceeds the Windows 260-character limit (${runCommand.length} chars).`);
576
+ }
511
577
  const runValue = windowsTrayRunValue(entry.opencodexHome);
512
578
  const existing = readOwnedRunValue(runValue);
513
579
  const state = readState();
@@ -517,6 +583,9 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus {
517
583
  if (existsSync(entry.script) && (!state || resolve(state.script) !== resolve(entry.script))) {
518
584
  throw new Error(`Refusing to overwrite an unowned tray script at ${entry.script}.`);
519
585
  }
586
+ if (existsSync(launcherPath) && (!state?.launcherPath || resolve(state.launcherPath) !== resolve(launcherPath))) {
587
+ throw new Error(`Refusing to overwrite an unowned tray launcher at ${launcherPath}.`);
588
+ }
520
589
  if (!state && iconPairs.some(pair => existsSync(pair.installed))) {
521
590
  throw new Error("Refusing to overwrite unowned Windows tray icon assets.");
522
591
  }
@@ -531,23 +600,28 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus {
531
600
 
532
601
  const previousStateBytes = existsSync(trayStatePath()) ? readFileSync(trayStatePath()) : null;
533
602
  const previousScriptBytes = existsSync(entry.script) ? readFileSync(entry.script) : null;
603
+ const previousLauncherBytes = existsSync(launcherPath) ? readFileSync(launcherPath) : null;
534
604
  const previousIconBytes = new Map(iconPairs.map(pair => [
535
605
  pair.installed,
536
606
  existsSync(pair.installed) ? readFileSync(pair.installed) : null,
537
607
  ]));
538
608
  const restorePreviousInstall = () => {
539
609
  try {
540
- if (previousScriptBytes) replaceOwnedFile(entry.script, previousScriptBytes);
610
+ if (previousScriptBytes) replaceWindowsTrayOwnedFile(entry.script, previousScriptBytes);
541
611
  else if (existsSync(entry.script)) unlinkSync(entry.script);
542
612
  } catch { /* rollback best-effort */ }
613
+ try {
614
+ if (previousLauncherBytes) replaceWindowsTrayOwnedFile(launcherPath, previousLauncherBytes);
615
+ else if (existsSync(launcherPath)) unlinkSync(launcherPath);
616
+ } catch { /* rollback best-effort */ }
543
617
  for (const [path, contents] of previousIconBytes) {
544
618
  try {
545
- if (contents) replaceOwnedFile(path, contents);
619
+ if (contents) replaceWindowsTrayOwnedFile(path, contents);
546
620
  else if (existsSync(path)) unlinkSync(path);
547
621
  } catch { /* rollback best-effort */ }
548
622
  }
549
623
  try {
550
- if (previousStateBytes) replaceOwnedFile(trayStatePath(), previousStateBytes);
624
+ if (previousStateBytes) replaceWindowsTrayOwnedFile(trayStatePath(), previousStateBytes);
551
625
  else if (existsSync(trayStatePath())) unlinkSync(trayStatePath());
552
626
  } catch { /* rollback best-effort */ }
553
627
  try {
@@ -565,10 +639,11 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus {
565
639
  try {
566
640
  const hardenedDir = hardenSecretDir(getConfigDir(), { required: true });
567
641
  if (!hardenedDir.ok) throw new Error("Windows tray directory ACL hardening did not complete; refusing to install persistence.");
568
- replaceOwnedFile(entry.script, readFileSync(sourceScript));
569
- for (const pair of iconPairs) replaceOwnedFile(pair.installed, readFileSync(pair.source));
642
+ replaceWindowsTrayOwnedFile(entry.script, readFileSync(sourceScript));
643
+ for (const pair of iconPairs) replaceWindowsTrayOwnedFile(pair.installed, readFileSync(pair.source));
644
+ replaceWindowsTrayOwnedFile(launcherPath, Buffer.from("\uFEFF" + buildWindowsTrayLauncherScript(entry), "utf16le"));
570
645
  runRegistry(["add", RUN_KEY, "/v", runValue, "/t", "REG_SZ", "/d", runCommand, "/f", "/reg:64"]);
571
- writeState(entry, runValue, runCommand);
646
+ writeState(entryWithLauncher, runValue, runCommand);
572
647
  } catch (error) {
573
648
  restorePreviousInstall();
574
649
  throw error;
@@ -578,9 +653,6 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus {
578
653
  restorePreviousInstall();
579
654
  throw new Error("The tray startup registration was installed, but the tray process did not become healthy.");
580
655
  }
581
- if (state?.launcherPath && existsSync(state.launcherPath)) {
582
- try { unlinkSync(state.launcherPath); } catch { /* old owned VBS is inert after a committed Run replacement */ }
583
- }
584
656
  return getWindowsTrayStatus();
585
657
  }
586
658
 
package/src/types.ts CHANGED
@@ -455,6 +455,11 @@ export interface OcxClaudeCodeConfig {
455
455
  desktopProfile?: OcxClaudeDesktopProfile;
456
456
  /** Auto-reconcile Desktop 3P config when provider catalog changes. Default: enabled. */
457
457
  desktopAutoApply?: boolean;
458
+ /**
459
+ * When false, omit `native/*` rows from Claude Desktop show/export/apply. Default: enabled.
460
+ * Routing-sidecar alias decoding is unchanged — only the Desktop model list writer.
461
+ */
462
+ desktopNativeModels?: boolean;
458
463
  }
459
464
 
460
465
  export type OcxClaudeDesktopFamily = "opus" | "fable" | "sonnet" | "haiku";
@@ -511,8 +516,22 @@ export interface OcxCustomModel {
511
516
  addedAt?: string;
512
517
  }
513
518
 
519
+ /**
520
+ * A generated `ocx_` data-plane key. `key` is the secret itself and never leaves
521
+ * the server except in the one-time POST /api/keys response; every other surface
522
+ * sees only the masked prefix.
523
+ */
524
+ export interface OcxApiKeyEntry {
525
+ id: string;
526
+ name: string;
527
+ key: string;
528
+ createdAt: string;
529
+ }
530
+
514
531
  export interface OcxConfig {
515
532
  port: number;
533
+ /** Maximum usage-log bytes read for one management snapshot. */
534
+ managementUsageMaxReadBytes?: number;
516
535
  providers: Record<string, OcxProviderConfig>;
517
536
  defaultProvider: string;
518
537
  /** OpenAI provider-contract migration marker (v2 = single `openai` provider with account mode). */
@@ -574,11 +593,12 @@ export interface OcxConfig {
574
593
  */
575
594
  fastMode?: boolean;
576
595
  /**
577
- * Windows SSE passthrough stream shape (#314 mitigation).
578
- * "auto" (default): eager bounded relay only on runtimes proven to carry the
579
- * Bun#32111 fix (none today legacy tee). "eager-relay": force the new relay
580
- * (accepts #32111 crash risk on Bun 1.3.14). "legacy-tee": pin the tee path.
581
- * Persisted in config.json because Windows services do not inherit shell env.
596
+ * Windows/macOS SSE passthrough stream shape (#314 mitigation).
597
+ * On Windows, "auto" (default) selects eager relay only on a runtime proven
598
+ * to carry the Bun#32111 fix. On macOS, "auto" always stays on legacy tee and
599
+ * eager relay is explicit-only. "eager-relay" opts into the new relay (and
600
+ * accepts #32111 crash risk on Bun 1.3.14); "legacy-tee" pins the tee path.
601
+ * Persisted in config.json so service users can select the stream shape.
582
602
  * See src/lib/bun-stream-caps.ts.
583
603
  */
584
604
  streamMode?: "auto" | "legacy-tee" | "eager-relay";
@@ -670,7 +690,7 @@ export interface OcxConfig {
670
690
  */
671
691
  storageCleanupPolicy?: StorageCleanupPolicy;
672
692
  /** Generated API keys for external access to the proxy's /v1/responses endpoint. */
673
- apiKeys?: Array<{ id: string; name: string; key: string; createdAt: string }>;
693
+ apiKeys?: OcxApiKeyEntry[];
674
694
  /** Auto-start/sync the proxy from the Codex shim before launching Codex. Default true. */
675
695
  codexAutoStart?: boolean;
676
696
  /** Restore an installed shim after a stable external Codex update replaces it. Default true. */
@@ -684,6 +704,8 @@ export interface OcxConfig {
684
704
  syncResumeHistory?: boolean;
685
705
  /** Freshness window (ms) for the per-provider live `/models` cache. Defaults to 5 min. */
686
706
  modelCacheTtlMs?: number;
707
+ /** Evictable retained app-state budget in MiB. Default 256; valid 64..4096. */
708
+ appOwnedMemoryBudgetMb?: number;
687
709
  /** Anthropic prompt-cache retention: "short" = 5-min ephemeral (default), "long" = 1-hour extended, "none" = disabled. */
688
710
  cacheRetention?: "none" | "short" | "long";
689
711
  /** Web-search sidecar: route web_search for non-OpenAI models through a gpt-mini via ChatGPT passthrough. */
@@ -885,6 +907,10 @@ export interface ResponsesItemIdRepairConfig {
885
907
 
886
908
  export interface OcxProviderConfig {
887
909
  adapter: string;
910
+ /** Cursor MCP compatibility bounds; positive integers when configured. */
911
+ mcpMaxTools?: number;
912
+ mcpMaxSchemaBytes?: number;
913
+ mcpMaxResultBytes?: number;
888
914
  /**
889
915
  * Per-model wire override, keyed by the upstream native model id (after namespace
890
916
  * and combo resolution). A single gateway can front models that speak different
@@ -903,6 +929,13 @@ export interface OcxProviderConfig {
903
929
  * the legacy `/v1/responses` construction.
904
930
  */
905
931
  responsesPath?: string;
932
+ /**
933
+ * Responses upstream that stores nothing server-side (DeepSeek documents "the API
934
+ * is stateless"). Stateful request parameters are dropped, `store` is pinned false,
935
+ * and orphaned tool results left by a replay miss are repaired rather than
936
+ * forwarded to an upstream that cannot resolve their pair.
937
+ */
938
+ statelessResponses?: boolean;
906
939
  /**
907
940
  * Explicit opt-in for non-registry private-network destinations such as localhost, RFC1918,
908
941
  * link-local, or unique-local upstreams. Metadata endpoints remain blocked.