@bitkyc08/opencodex 2.7.43-preview.20260728 → 2.8.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 (173) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +47 -22
  3. package/gui/dist/assets/index-BDjpkcRN.js +67 -0
  4. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/discovery.ts +4 -1
  10. package/src/adapters/cursor/effort-map.ts +3 -0
  11. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  12. package/src/adapters/cursor/protobuf-events.ts +24 -2
  13. package/src/adapters/cursor/protobuf-request.ts +1 -2
  14. package/src/adapters/cursor/tool-definitions.ts +68 -29
  15. package/src/adapters/google-wire-compiler.ts +4 -0
  16. package/src/adapters/google.ts +128 -2
  17. package/src/adapters/identity.ts +12 -2
  18. package/src/adapters/kiro.ts +64 -7
  19. package/src/adapters/mimo-free.ts +2 -0
  20. package/src/adapters/openai-responses.ts +246 -59
  21. package/src/claude/agents-inject.ts +5 -0
  22. package/src/claude/alias.ts +94 -14
  23. package/src/claude/inbound.ts +26 -9
  24. package/src/claude/outbound.ts +6 -3
  25. package/src/cli/account-auth.ts +1 -1
  26. package/src/cli/agent-driven.ts +37 -0
  27. package/src/cli/catalog-prewarm.ts +24 -0
  28. package/src/cli/claude.ts +35 -10
  29. package/src/cli/doctor.ts +71 -19
  30. package/src/cli/help.ts +42 -6
  31. package/src/cli/index.ts +93 -19
  32. package/src/cli/interactive-confirm.ts +133 -0
  33. package/src/cli/opencode.ts +701 -0
  34. package/src/cli/provider-runtime.ts +3 -0
  35. package/src/cli/provider.ts +31 -10
  36. package/src/cli/star-prompt.ts +79 -18
  37. package/src/cli/status.ts +47 -13
  38. package/src/cli/v2.ts +10 -1
  39. package/src/codex/account-id.ts +34 -0
  40. package/src/codex/account-lifecycle.ts +4 -1
  41. package/src/codex/account-namespace-match.ts +63 -0
  42. package/src/codex/account-namespaces.ts +149 -0
  43. package/src/codex/account-pause.ts +20 -0
  44. package/src/codex/account-store.ts +2 -0
  45. package/src/codex/account-usability.ts +6 -1
  46. package/src/codex/app-server-processes.ts +511 -0
  47. package/src/codex/auth-api.ts +293 -34
  48. package/src/codex/auth-collision.ts +2 -1
  49. package/src/codex/auth-context.ts +60 -17
  50. package/src/codex/catalog/bundled.ts +9 -2
  51. package/src/codex/catalog/parsing.ts +42 -2
  52. package/src/codex/catalog/provider-fetch.ts +264 -70
  53. package/src/codex/catalog/sync.ts +45 -8
  54. package/src/codex/catalog.ts +2 -2
  55. package/src/codex/features.ts +524 -5
  56. package/src/codex/history-provider.ts +145 -1
  57. package/src/codex/inject.ts +114 -14
  58. package/src/codex/main-account.ts +2 -8
  59. package/src/codex/pool-rotation.ts +186 -0
  60. package/src/codex/quota.ts +92 -2
  61. package/src/codex/routing.ts +695 -106
  62. package/src/codex/runtime.ts +10 -1
  63. package/src/codex/shim.ts +4 -1
  64. package/src/codex/subagent-defaults.ts +550 -0
  65. package/src/codex/subagent-model-fallback.ts +2 -0
  66. package/src/codex/sync.ts +3 -0
  67. package/src/config.ts +574 -25
  68. package/src/generated/jawcode-model-metadata.ts +12 -12
  69. package/src/github/star-state.ts +191 -0
  70. package/src/images/artifacts.ts +516 -0
  71. package/src/images/fulfill-video.ts +163 -0
  72. package/src/images/fulfill.ts +111 -0
  73. package/src/images/index.ts +4 -0
  74. package/src/images/loop.ts +789 -0
  75. package/src/images/plan.ts +133 -0
  76. package/src/images/synthetic-tool.ts +133 -0
  77. package/src/images/types.ts +41 -0
  78. package/src/images/xai-client.ts +141 -0
  79. package/src/images/xai-video-client.ts +163 -0
  80. package/src/lib/admin-secrets.ts +25 -0
  81. package/src/lib/bun-binary-validator.d.mts +3 -0
  82. package/src/lib/bun-binary-validator.mjs +18 -0
  83. package/src/lib/bun-runtime.ts +6 -20
  84. package/src/lib/config-ownership.ts +327 -0
  85. package/src/lib/crash-guard.ts +2 -0
  86. package/src/lib/destination-policy.ts +132 -7
  87. package/src/lib/pinned-http.ts +151 -0
  88. package/src/lib/process-control.ts +2 -2
  89. package/src/lib/provider-outbound.ts +167 -0
  90. package/src/lib/provider-url.ts +14 -0
  91. package/src/lib/proxy-env.ts +18 -0
  92. package/src/lib/shadow-call.ts +30 -0
  93. package/src/lib/test-home-guard.ts +90 -0
  94. package/src/lib/win-exec.ts +12 -2
  95. package/src/lib/windows-elevation.ts +81 -3
  96. package/src/lib/windows-secret-acl.ts +189 -12
  97. package/src/lib/winsw.ts +2 -0
  98. package/src/oauth/anthropic-routing.ts +570 -0
  99. package/src/oauth/health.ts +6 -0
  100. package/src/oauth/index.ts +310 -75
  101. package/src/oauth/key-providers.ts +38 -8
  102. package/src/oauth/kimi.ts +2 -0
  103. package/src/oauth/kiro-credentials.ts +373 -12
  104. package/src/oauth/kiro.ts +424 -43
  105. package/src/oauth/login-cli.ts +33 -6
  106. package/src/oauth/store.ts +56 -4
  107. package/src/oauth/types.ts +11 -0
  108. package/src/providers/alibaba-region-migration.ts +16 -3
  109. package/src/providers/antigravity-models.ts +3 -0
  110. package/src/providers/api-keys.ts +13 -6
  111. package/src/providers/derive.ts +8 -2
  112. package/src/providers/key-failover.ts +24 -4
  113. package/src/providers/model-discovery.ts +356 -0
  114. package/src/providers/quota.ts +233 -29
  115. package/src/providers/registry.ts +125 -3
  116. package/src/responses/parser.ts +11 -0
  117. package/src/responses/state.ts +22 -8
  118. package/src/responses/tool-groups.ts +19 -0
  119. package/src/router.ts +19 -7
  120. package/src/server/auth-cors.ts +114 -24
  121. package/src/server/claude-messages.ts +8 -1
  122. package/src/server/gui-static.ts +30 -6
  123. package/src/server/images.ts +303 -9
  124. package/src/server/index.ts +77 -9
  125. package/src/server/lifecycle.ts +25 -1
  126. package/src/server/live.ts +75 -25
  127. package/src/server/management/agent-settings-routes.ts +106 -8
  128. package/src/server/management/combo-routes.ts +7 -0
  129. package/src/server/management/config-routes.ts +22 -7
  130. package/src/server/management/context.ts +11 -1
  131. package/src/server/management/logs-usage-routes.ts +167 -3
  132. package/src/server/management/model-routes.ts +46 -13
  133. package/src/server/management/oauth-account-routes.ts +163 -17
  134. package/src/server/management/provider-routes.ts +73 -10
  135. package/src/server/management/shared.ts +2 -2
  136. package/src/server/management/sidebar-routes.ts +39 -0
  137. package/src/server/management/system-restart.ts +172 -0
  138. package/src/server/management/system-routes.ts +33 -10
  139. package/src/server/management-api.ts +5 -3
  140. package/src/server/management-auth.ts +216 -0
  141. package/src/server/proxy-liveness.ts +14 -3
  142. package/src/server/responses/compact.ts +21 -13
  143. package/src/server/responses/core.ts +614 -172
  144. package/src/server/responses/upstream-error.ts +48 -0
  145. package/src/server/responses-image-gen-repair.ts +118 -0
  146. package/src/server/responses-item-id-repair.ts +10 -85
  147. package/src/server/sse-payload-rewrite.ts +116 -0
  148. package/src/server/startup-action-control.ts +30 -14
  149. package/src/server/system-env.ts +28 -10
  150. package/src/service.ts +284 -19
  151. package/src/storage/cleanup-job.ts +57 -0
  152. package/src/storage/cleanup.ts +1504 -28
  153. package/src/storage/policy-job.ts +387 -0
  154. package/src/storage/policy-scheduler.ts +40 -0
  155. package/src/storage/policy-worker.ts +53 -0
  156. package/src/storage/policy.ts +522 -0
  157. package/src/storage/restore-job.ts +253 -0
  158. package/src/storage/restore-worker.ts +52 -0
  159. package/src/storage/storage-mutation-coordinator.ts +109 -0
  160. package/src/storage/worker-lifecycle.ts +81 -0
  161. package/src/tray/windows.ts +34 -4
  162. package/src/types.ts +107 -1
  163. package/src/update/badge.ts +72 -0
  164. package/src/update/index.ts +36 -18
  165. package/src/update/job.ts +111 -16
  166. package/src/update/npm-invocation.d.mts +23 -0
  167. package/src/update/npm-invocation.mjs +94 -0
  168. package/src/usage/debug.ts +2 -0
  169. package/src/usage/expected-prices.ts +6 -5
  170. package/src/usage/log.ts +12 -0
  171. package/src/web-search/loop.ts +57 -16
  172. package/gui/dist/assets/index-CjKFJHSC.js +0 -65
  173. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Single-flight controller for trash restore runs.
3
+ *
4
+ * Heavy work (file moves, SQLite reconcile) runs in a Bun Worker so the proxy
5
+ * event loop stays responsive while the management API awaits the outcome.
6
+ *
7
+ * Restore shares the CODEX_HOME storage-mutation coordinator with manual cleanup
8
+ * and (Phase 3) policy-driven cleanup. Concurrent callers receive
9
+ * `storage_mutation_busy` (409) instead of queueing.
10
+ */
11
+ import { resolveCodexHomeDir } from "../codex/home";
12
+ import { restoreTrashEntry, type RestoreResult, type RestoreTestHooks } from "./cleanup";
13
+ import {
14
+ resetStorageMutationCoordinatorForTests,
15
+ setStorageMutationCoordinatorTestHooks,
16
+ withStorageMutationSlot,
17
+ type StorageMutationCoordinatorTestHooks,
18
+ } from "./storage-mutation-coordinator";
19
+ import {
20
+ drainStorageWorkers,
21
+ registerStorageWorker,
22
+ terminateStorageWorker,
23
+ } from "./worker-lifecycle";
24
+
25
+ export interface RestoreJobTestHooks extends StorageMutationCoordinatorTestHooks {
26
+ /**
27
+ * When true, run on the main thread via dynamic import + optional sleep.
28
+ * Responsiveness tests must leave this unset so work stays in a Worker.
29
+ */
30
+ runInProcess?: boolean;
31
+ /** Expose GET /api/storage/trash/restore/test-stream for responsiveness tests. */
32
+ enableTestStream?: boolean;
33
+ /** Forwarded to restoreTrashEntry _test hooks. */
34
+ restoreTest?: RestoreTestHooks;
35
+ }
36
+
37
+ const WORKER_TIMEOUT_MS = 10 * 60 * 1000;
38
+
39
+ const WORKER_REJECTION_CODES: Record<string, RestoreResult["error"]> = {
40
+ restore_worker_timeout: "restore_worker_timeout",
41
+ aborted: "restore_worker_aborted",
42
+ worker_failed: "restore_worker_failed",
43
+ };
44
+
45
+ /** Map a rejected worker promise to a precise restore outcome (exported for tests). */
46
+ export function restoreResultFromWorkerRejection(err: unknown, trashId: string): RestoreResult {
47
+ const raw = err instanceof Error ? err.message : String(err);
48
+ const error = WORKER_REJECTION_CODES[raw] ?? "restore_worker_failed";
49
+ const detail =
50
+ error === "restore_worker_failed" && raw !== "worker_failed" && raw.trim().length > 0
51
+ ? raw
52
+ : undefined;
53
+ console.error(
54
+ `[storage] trash restore worker failed (${error}) trashId=${trashId}${detail ? `: ${detail}` : ""}`,
55
+ );
56
+ return {
57
+ ok: false,
58
+ count: 0,
59
+ bytes: 0,
60
+ restoredPaths: [],
61
+ error,
62
+ ...(detail ? { message: detail } : {}),
63
+ };
64
+ }
65
+
66
+ let activeWorker: Worker | null = null;
67
+ let testHooks: RestoreJobTestHooks | null = null;
68
+ let cancelActiveRun: (() => void) | null = null;
69
+
70
+ export function setRestoreTrashJobTestHooks(hooks: RestoreJobTestHooks | null): void {
71
+ testHooks = hooks;
72
+ setStorageMutationCoordinatorTestHooks(hooks);
73
+ }
74
+
75
+ export function resetRestoreTrashJobForTests(): void {
76
+ if (activeWorker) {
77
+ void terminateStorageWorker(activeWorker);
78
+ activeWorker = null;
79
+ }
80
+ cancelActiveRun?.();
81
+ cancelActiveRun = null;
82
+ testHooks = null;
83
+ resetStorageMutationCoordinatorForTests();
84
+ }
85
+
86
+ /** Await-able reset for test teardown; see policy-job's equivalent for why. */
87
+ export async function resetRestoreTrashJobForTestsAsync(): Promise<void> {
88
+ resetRestoreTrashJobForTests();
89
+ await drainStorageWorkers();
90
+ }
91
+
92
+ /** Terminate an in-flight worker during process shutdown. */
93
+ export function abortRestoreTrashJob(): void {
94
+ if (activeWorker) {
95
+ void terminateStorageWorker(activeWorker);
96
+ activeWorker = null;
97
+ }
98
+ cancelActiveRun?.();
99
+ cancelActiveRun = null;
100
+ }
101
+
102
+ /** Test-only SSE/text stream served from the proxy while a worker is blocked. */
103
+ export function getRestoreTrashTestStreamResponse(): Response | null {
104
+ if (!testHooks?.enableTestStream) return null;
105
+ const encoder = new TextEncoder();
106
+ return new Response(
107
+ new ReadableStream({
108
+ async start(controller) {
109
+ for (let i = 0; i < 8; i++) {
110
+ controller.enqueue(encoder.encode(`chunk-${i}\n`));
111
+ await Bun.sleep(50);
112
+ }
113
+ controller.close();
114
+ },
115
+ }),
116
+ { headers: { "content-type": "text/plain; charset=utf-8" } },
117
+ );
118
+ }
119
+
120
+ function runInWorker(opts: {
121
+ trashId: string;
122
+ codexHome?: string;
123
+ busyTimeoutMs?: number;
124
+ blockMs?: number;
125
+ restoreTest?: RestoreTestHooks;
126
+ }): Promise<RestoreResult> {
127
+ return new Promise((resolve, reject) => {
128
+ const requestId = crypto.randomUUID();
129
+ let settled = false;
130
+ const worker = new Worker(new URL("./restore-worker.ts", import.meta.url).href);
131
+ registerStorageWorker(worker);
132
+ activeWorker = worker;
133
+
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
+ const finish = (fn: () => void) => {
144
+ if (settled) return;
145
+ settled = true;
146
+ cancelActiveRun = null;
147
+ clearTimeout(timer);
148
+ if (activeWorker === worker) activeWorker = null;
149
+ void terminateStorageWorker(worker).then(fn, fn);
150
+ };
151
+
152
+ cancelActiveRun = () => {
153
+ finish(() => reject(new Error("aborted")));
154
+ };
155
+
156
+ worker.onmessage = (event: MessageEvent<unknown>) => {
157
+ const data = event.data;
158
+ if (!data || typeof data !== "object") return;
159
+ const msg = data as Record<string, unknown>;
160
+ if (msg.requestId !== requestId) return;
161
+ if (msg.type === "done" && msg.result && typeof msg.result === "object") {
162
+ finish(() => resolve(msg.result as RestoreResult));
163
+ return;
164
+ }
165
+ if (msg.type === "error") {
166
+ const message = typeof msg.message === "string" ? msg.message : "worker_failed";
167
+ finish(() => reject(new Error(message)));
168
+ }
169
+ };
170
+
171
+ worker.onerror = (err: ErrorEvent) => {
172
+ finish(() => reject(err.error instanceof Error ? err.error : new Error(err.message || "worker_failed")));
173
+ };
174
+
175
+ worker.postMessage({
176
+ type: "run",
177
+ requestId,
178
+ trashId: opts.trashId,
179
+ ...(opts.codexHome ? { codexHome: opts.codexHome } : {}),
180
+ ...(opts.busyTimeoutMs !== undefined ? { busyTimeoutMs: opts.busyTimeoutMs } : {}),
181
+ ...(opts.blockMs !== undefined ? { blockMs: opts.blockMs } : {}),
182
+ ...(opts.restoreTest ? { restoreTest: opts.restoreTest } : {}),
183
+ env: {
184
+ ...(process.env.CODEX_HOME ? { CODEX_HOME: process.env.CODEX_HOME } : {}),
185
+ ...(process.env.OPENCODEX_HOME ? { OPENCODEX_HOME: process.env.OPENCODEX_HOME } : {}),
186
+ },
187
+ });
188
+ });
189
+ }
190
+
191
+ async function executeRestore(opts: {
192
+ trashId: string;
193
+ codexHome?: string;
194
+ busyTimeoutMs?: number;
195
+ _test?: RestoreTestHooks;
196
+ }): Promise<RestoreResult> {
197
+ const blockMs = testHooks?.blockMs;
198
+ const restoreTest = opts._test ?? testHooks?.restoreTest;
199
+
200
+ if (testHooks?.runInProcess) {
201
+ if (typeof blockMs === "number" && blockMs > 0) await Bun.sleep(blockMs);
202
+ return restoreTrashEntry(opts.trashId, {
203
+ ...(opts.codexHome ? { codexHome: opts.codexHome } : {}),
204
+ ...(opts.busyTimeoutMs !== undefined ? { busyTimeoutMs: opts.busyTimeoutMs } : {}),
205
+ ...(restoreTest ? { _test: restoreTest } : {}),
206
+ });
207
+ }
208
+
209
+ try {
210
+ return await runInWorker({
211
+ trashId: opts.trashId,
212
+ ...(opts.codexHome ? { codexHome: opts.codexHome } : {}),
213
+ ...(opts.busyTimeoutMs !== undefined ? { busyTimeoutMs: opts.busyTimeoutMs } : {}),
214
+ ...(typeof blockMs === "number" && blockMs > 0 ? { blockMs } : {}),
215
+ ...(restoreTest ? { restoreTest } : {}),
216
+ });
217
+ } catch (err) {
218
+ return restoreResultFromWorkerRejection(err, opts.trashId);
219
+ }
220
+ }
221
+
222
+ function busyRestoreResult(): RestoreResult {
223
+ return {
224
+ ok: false,
225
+ count: 0,
226
+ bytes: 0,
227
+ restoredPaths: [],
228
+ error: "storage_mutation_busy",
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Run trash restore off the event loop under the shared storage-mutation gate.
234
+ * Returns `storage_mutation_busy` when cleanup or another restore is in flight.
235
+ */
236
+ export async function runRestoreTrashEntryJob(
237
+ trashId: string,
238
+ options?: {
239
+ codexHome?: string;
240
+ busyTimeoutMs?: number;
241
+ _test?: RestoreTestHooks;
242
+ },
243
+ ): Promise<RestoreResult> {
244
+ const codexHome = options?.codexHome ?? resolveCodexHomeDir();
245
+ const result = await withStorageMutationSlot("restore", codexHome, () =>
246
+ executeRestore({ trashId, ...options }),
247
+ );
248
+ if (result && typeof result === "object" && "ok" in result && result.ok === false
249
+ && "error" in result && result.error === "storage_mutation_busy") {
250
+ return busyRestoreResult();
251
+ }
252
+ return result as RestoreResult;
253
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Worker-thread entry for trash restore runs.
3
+ * Keeps file moves and SQLite reconcile off the proxy event loop.
4
+ */
5
+ import { restoreTrashEntry, type RestoreResult, type RestoreTestHooks } from "./cleanup";
6
+
7
+ interface RunMessage {
8
+ type: "run";
9
+ requestId: string;
10
+ trashId: string;
11
+ codexHome?: string;
12
+ busyTimeoutMs?: number;
13
+ /** Test-only: block before restore so responsiveness tests can probe /healthz. */
14
+ blockMs?: number;
15
+ restoreTest?: RestoreTestHooks;
16
+ /** Env snapshot — Workers may not see parent mutations on all platforms. */
17
+ env?: { CODEX_HOME?: string; OPENCODEX_HOME?: string };
18
+ }
19
+
20
+ function isRunMessage(data: unknown): data is RunMessage {
21
+ if (!data || typeof data !== "object" || Array.isArray(data)) return false;
22
+ const o = data as Record<string, unknown>;
23
+ return o.type === "run" && typeof o.requestId === "string" && typeof o.trashId === "string";
24
+ }
25
+
26
+ declare const self: Worker;
27
+
28
+ self.onmessage = async (event: MessageEvent<unknown>) => {
29
+ if (!isRunMessage(event.data)) return;
30
+ const { requestId, trashId, codexHome, busyTimeoutMs, blockMs, restoreTest, env } = event.data;
31
+ try {
32
+ if (env?.CODEX_HOME) process.env.CODEX_HOME = env.CODEX_HOME;
33
+ if (env?.OPENCODEX_HOME) process.env.OPENCODEX_HOME = env.OPENCODEX_HOME;
34
+ if (typeof blockMs === "number" && Number.isFinite(blockMs) && blockMs > 0) {
35
+ await Bun.sleep(Math.floor(blockMs));
36
+ }
37
+ const result = restoreTrashEntry(trashId, {
38
+ ...(codexHome ? { codexHome } : {}),
39
+ ...(busyTimeoutMs !== undefined ? { busyTimeoutMs } : {}),
40
+ ...(restoreTest ? { _test: restoreTest } : {}),
41
+ });
42
+ self.postMessage({ type: "done", requestId, result });
43
+ } catch (err) {
44
+ self.postMessage({
45
+ type: "error",
46
+ requestId,
47
+ message: err instanceof Error ? err.message : "worker_failed",
48
+ });
49
+ }
50
+ };
51
+
52
+ export type { RestoreResult };
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Single-flight gate for CODEX_HOME storage mutations (cleanup, restore, policy).
3
+ *
4
+ * Manual cleanup, trash restore, and (Phase 3) policy-driven cleanup share one
5
+ * in-flight slot per resolved CODEX_HOME. A second caller receives
6
+ * `storage_mutation_busy` immediately — no per-operation queues.
7
+ */
8
+ import { resolve } from "node:path";
9
+ import { resolveCodexHomeDir } from "../codex/home";
10
+
11
+ export type StorageMutationKind = "cleanup" | "restore" | "policy";
12
+
13
+ export type StorageMutationBusyError = "storage_mutation_busy";
14
+
15
+ export interface StorageMutationCoordinatorTestHooks {
16
+ /** Block after acquiring the slot, before mutation work (race tests). */
17
+ blockMs?: number;
18
+ }
19
+
20
+ interface ActiveSlot {
21
+ kind: StorageMutationKind;
22
+ startedAt: number;
23
+ }
24
+
25
+ const slots = new Map<string, ActiveSlot>();
26
+ let testHooks: StorageMutationCoordinatorTestHooks | null = null;
27
+
28
+ function slotKey(codexHome?: string): string {
29
+ return resolve(codexHome ?? resolveCodexHomeDir());
30
+ }
31
+
32
+ export function setStorageMutationCoordinatorTestHooks(
33
+ hooks: StorageMutationCoordinatorTestHooks | null,
34
+ ): void {
35
+ testHooks = hooks;
36
+ }
37
+
38
+ export function resetStorageMutationCoordinatorForTests(): void {
39
+ testHooks = null;
40
+ slots.clear();
41
+ }
42
+
43
+ export function getActiveStorageMutation(
44
+ codexHome?: string,
45
+ ): { kind: StorageMutationKind; startedAt: number } | null {
46
+ const active = slots.get(slotKey(codexHome));
47
+ return active ? { kind: active.kind, startedAt: active.startedAt } : null;
48
+ }
49
+
50
+ export function tryBeginStorageMutation(
51
+ kind: StorageMutationKind,
52
+ codexHome?: string,
53
+ ): { acquired: true } | { acquired: false; error: StorageMutationBusyError } {
54
+ const key = slotKey(codexHome);
55
+ if (slots.has(key)) {
56
+ return { acquired: false, error: "storage_mutation_busy" };
57
+ }
58
+ slots.set(key, { kind, startedAt: Date.now() });
59
+ return { acquired: true };
60
+ }
61
+
62
+ export function endStorageMutation(codexHome?: string): void {
63
+ slots.delete(slotKey(codexHome));
64
+ }
65
+
66
+ async function applyCoordinatorBlock(): Promise<void> {
67
+ const blockMs = testHooks?.blockMs;
68
+ if (typeof blockMs === "number" && Number.isFinite(blockMs) && blockMs > 0) {
69
+ await Bun.sleep(Math.floor(blockMs));
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Phase 3 policy worker integration — wrap policy-driven cleanup FS/DB work.
75
+ * Returns `{ ok: false, error: 'storage_mutation_busy' }` when another mutation
76
+ * holds the CODEX_HOME slot.
77
+ */
78
+ export async function runPolicyStorageMutation<T>(
79
+ codexHome: string | undefined,
80
+ work: () => T | Promise<T>,
81
+ ): Promise<T | { ok: false; error: StorageMutationBusyError }> {
82
+ const gate = tryBeginStorageMutation("policy", codexHome);
83
+ if (!gate.acquired) {
84
+ return { ok: false, error: "storage_mutation_busy" };
85
+ }
86
+ try {
87
+ await applyCoordinatorBlock();
88
+ return await work();
89
+ } finally {
90
+ endStorageMutation(codexHome);
91
+ }
92
+ }
93
+
94
+ export async function withStorageMutationSlot<T>(
95
+ kind: StorageMutationKind,
96
+ codexHome: string | undefined,
97
+ work: () => T | Promise<T>,
98
+ ): Promise<T | { ok: false; error: StorageMutationBusyError }> {
99
+ const gate = tryBeginStorageMutation(kind, codexHome);
100
+ if (!gate.acquired) {
101
+ return { ok: false, error: "storage_mutation_busy" };
102
+ }
103
+ try {
104
+ await applyCoordinatorBlock();
105
+ return await work();
106
+ } finally {
107
+ endStorageMutation(codexHome);
108
+ }
109
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Deterministic teardown for the storage Bun Workers.
3
+ *
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.
10
+ *
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.
23
+ */
24
+
25
+ const liveWorkers = new Set<Worker>();
26
+
27
+ /** Track a freshly spawned worker so teardown can wait for it later. */
28
+ export function registerStorageWorker(worker: Worker): void {
29
+ liveWorkers.add(worker);
30
+ }
31
+
32
+ /**
33
+ * Terminate a worker and resolve once its thread has actually exited.
34
+ *
35
+ * Safe to call twice: the second call finds the worker already deregistered and
36
+ * resolves immediately.
37
+ */
38
+ export function terminateStorageWorker(worker: Worker, timeoutMs = 5_000): Promise<void> {
39
+ if (!liveWorkers.has(worker)) {
40
+ try { worker.terminate(); } catch { /* already gone */ }
41
+ return Promise.resolve();
42
+ }
43
+ liveWorkers.delete(worker);
44
+
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
+ try {
62
+ worker.terminate();
63
+ } catch {
64
+ settle();
65
+ }
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Await every worker this module still tracks. Used by test resets so no
71
+ * storage worker outlives the file that spawned it.
72
+ */
73
+ export async function drainStorageWorkers(timeoutMs = 5_000): Promise<void> {
74
+ const pending = [...liveWorkers];
75
+ await Promise.all(pending.map(worker => terminateStorageWorker(worker, timeoutMs)));
76
+ }
77
+
78
+ /** Live worker count — exported so a regression test can assert the invariant. */
79
+ export function liveStorageWorkerCount(): number {
80
+ return liveWorkers.size;
81
+ }
@@ -6,6 +6,7 @@ import { join, resolve } from "node:path";
6
6
  import { expandUserPath, getConfigDir } from "../config";
7
7
  import { durableBunPath } from "../lib/bun-runtime";
8
8
  import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
9
+ import { recordOwnedConfigPath } from "../lib/config-ownership";
9
10
 
10
11
  const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
11
12
  const RUN_PARENT_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion";
@@ -413,17 +414,43 @@ function assertWindows(): void {
413
414
  if (process.platform !== "win32") throw new Error(`The opencodex tray is Windows-only (current platform: ${process.platform}).`);
414
415
  }
415
416
 
416
- function spawnTray(state: WindowsTrayEntry): void {
417
- const child = spawn(state.bun, [state.cli, "__tray-host"], {
418
- detached: true,
417
+ const DETACHED_TRAY_HOST_LAUNCHER = [
418
+ "$startInfo = New-Object System.Diagnostics.ProcessStartInfo",
419
+ "$startInfo.FileName = $env:OCX_TRAY_HOST_BUN",
420
+ "$startInfo.Arguments = $env:OCX_TRAY_HOST_ARGS",
421
+ "$startInfo.UseShellExecute = $true",
422
+ "$startInfo.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden",
423
+ "$child = [System.Diagnostics.Process]::Start($startInfo)",
424
+ "if ($null -eq $child) { throw 'Windows tray host did not start.' }",
425
+ "$child.Dispose()",
426
+ ].join("; ");
427
+
428
+ const DETACHED_TRAY_HOST_LAUNCHER_B64 = Buffer.from(DETACHED_TRAY_HOST_LAUNCHER, "utf16le").toString("base64");
429
+
430
+ export function launchWindowsTrayHost(state: WindowsTrayEntry): void {
431
+ const bun = safePath(state.bun);
432
+ const cli = safePath(state.cli);
433
+ execFileSync(windowsPowerShellPath(), [
434
+ "-NoLogo",
435
+ "-NoProfile",
436
+ "-NonInteractive",
437
+ "-EncodedCommand",
438
+ DETACHED_TRAY_HOST_LAUNCHER_B64,
439
+ ], {
419
440
  stdio: "ignore",
420
441
  windowsHide: true,
442
+ timeout: 15_000,
421
443
  env: {
422
444
  ...process.env,
445
+ OCX_TRAY_HOST_BUN: bun,
446
+ OCX_TRAY_HOST_ARGS: `${quoteRunValue(cli)} __tray-host`,
423
447
  OCX_TRAY_ENTRY_B64: Buffer.from(JSON.stringify(state), "utf8").toString("base64"),
424
448
  },
425
449
  });
426
- child.unref();
450
+ }
451
+
452
+ function spawnTray(state: WindowsTrayEntry): void {
453
+ launchWindowsTrayHost(state);
427
454
  }
428
455
 
429
456
  function parseTrayHostEntry(): WindowsTrayEntry {
@@ -442,6 +469,8 @@ export async function runWindowsTrayHost(): Promise<void> {
442
469
  assertWindows();
443
470
  const entry = parseTrayHostEntry();
444
471
  delete process.env.OCX_TRAY_ENTRY_B64;
472
+ delete process.env.OCX_TRAY_HOST_BUN;
473
+ delete process.env.OCX_TRAY_HOST_ARGS;
445
474
  const child = spawn(windowsPowerShellPath(), windowsTrayProcessArgs(entry, "Run", process.pid), {
446
475
  stdio: "ignore",
447
476
  windowsHide: true,
@@ -476,6 +505,7 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus {
476
505
  for (const path of [entry.bun, entry.cli, sourceScript, ...iconPairs.map(pair => pair.source)]) {
477
506
  if (!existsSync(path)) throw new Error(`Cannot install the tray because a required file is missing: ${path}`);
478
507
  }
508
+ recordOwnedConfigPath(getConfigDir(), trayStatePath());
479
509
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
480
510
  const runCommand = buildWindowsTrayRunCommand(entry);
481
511
  const runValue = windowsTrayRunValue(entry.opencodexHome);