@bitkyc08/opencodex 2.7.35 → 2.7.36

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 (80) hide show
  1. package/README.ja.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +4 -2
  4. package/README.ru.md +1 -1
  5. package/README.zh-CN.md +1 -1
  6. package/bin/ocx.mjs +52 -0
  7. package/gui/dist/assets/index-BpX-hoSd.css +1 -0
  8. package/gui/dist/assets/index-ZmFopEYw.js +52 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -1
  11. package/src/adapters/cursor/cursor-errors.ts +38 -1
  12. package/src/adapters/cursor/discovery.ts +1 -0
  13. package/src/adapters/cursor/effort-map.ts +1 -0
  14. package/src/adapters/cursor/live-models.ts +22 -5
  15. package/src/adapters/cursor/live-transport.ts +82 -7
  16. package/src/adapters/cursor/transport.ts +2 -0
  17. package/src/adapters/cursor.ts +5 -2
  18. package/src/adapters/openai-responses.ts +64 -1
  19. package/src/cli/doctor.ts +10 -0
  20. package/src/cli/help.ts +10 -0
  21. package/src/cli/index.ts +88 -9
  22. package/src/cli/internal-dispatch.ts +20 -0
  23. package/src/cli/status.ts +15 -4
  24. package/src/cli/tray-proxy.ts +52 -0
  25. package/src/codex/auth-api.ts +46 -5
  26. package/src/codex/autostart-health.ts +149 -0
  27. package/src/codex/catalog/aggregation.ts +268 -0
  28. package/src/codex/catalog/bundled.ts +188 -0
  29. package/src/codex/catalog/effort.ts +263 -0
  30. package/src/codex/catalog/metadata.ts +176 -0
  31. package/src/codex/catalog/parsing.ts +399 -0
  32. package/src/codex/catalog/provider-fetch.ts +609 -0
  33. package/src/codex/catalog/sync.ts +540 -0
  34. package/src/codex/catalog.ts +11 -2426
  35. package/src/codex/inject.ts +165 -3
  36. package/src/codex/shim.ts +141 -8
  37. package/src/codex/sync.ts +17 -2
  38. package/src/config.ts +23 -0
  39. package/src/lib/errors.ts +11 -0
  40. package/src/providers/antigravity-models.ts +33 -0
  41. package/src/providers/kiro-models.ts +2 -0
  42. package/src/providers/registry.ts +2 -2
  43. package/src/responses/state.ts +69 -6
  44. package/src/server/auth-cors.ts +3 -0
  45. package/src/server/management/agent-settings-routes.ts +536 -0
  46. package/src/server/management/combo-routes.ts +210 -0
  47. package/src/server/management/config-routes.ts +302 -0
  48. package/src/server/management/context.ts +21 -0
  49. package/src/server/management/logs-usage-routes.ts +176 -0
  50. package/src/server/management/model-routes.ts +253 -0
  51. package/src/server/management/oauth-account-routes.ts +301 -0
  52. package/src/server/management/provider-routes.ts +408 -0
  53. package/src/server/management/shared.ts +186 -0
  54. package/src/server/management-api.ts +23 -1806
  55. package/src/server/responses/collaboration.ts +300 -0
  56. package/src/server/responses/compact.ts +342 -0
  57. package/src/server/responses/core.ts +1498 -0
  58. package/src/server/responses/encrypted-payload.ts +231 -0
  59. package/src/server/responses/fetch-helpers.ts +157 -0
  60. package/src/server/responses.ts +9 -2172
  61. package/src/server/startup-action-control.ts +41 -0
  62. package/src/server/startup-health-cache.ts +100 -0
  63. package/src/server/windows-tray-control.ts +41 -0
  64. package/src/service.ts +171 -19
  65. package/src/tray/assets/opencodex-tray-offline.ico +0 -0
  66. package/src/tray/assets/opencodex-tray-online.ico +0 -0
  67. package/src/tray/assets/opencodex-tray-warning.ico +0 -0
  68. package/src/tray/assets/opencodex-tray.png +0 -0
  69. package/src/tray/windows-tray.ps1 +290 -0
  70. package/src/tray/windows.ts +628 -0
  71. package/src/types.ts +5 -0
  72. package/src/update/index.ts +43 -0
  73. package/src/update/job.ts +46 -0
  74. package/src/update/tray-update-plan.d.mts +18 -0
  75. package/src/update/tray-update-plan.mjs +38 -0
  76. package/src/usage/cost.ts +0 -0
  77. package/src/usage/expected-prices.ts +9 -2
  78. package/src/usage/summary.ts +42 -7
  79. package/gui/dist/assets/index-BunUANVE.js +0 -52
  80. package/gui/dist/assets/index-Sg-7L_oZ.css +0 -1
@@ -0,0 +1,628 @@
1
+ import { execFile, execFileSync, spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { expandUserPath, getConfigDir } from "../config";
7
+ import { durableBunPath } from "../lib/bun-runtime";
8
+ import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
9
+
10
+ const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
11
+ const RUN_PARENT_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion";
12
+ const TRAY_STATE_VERSION = 1;
13
+ const FOREIGN_RUN_VALUE = "<foreign-or-unreadable-registry-value>";
14
+ const TRAY_ICON_FILES = [
15
+ "opencodex-tray-online.ico",
16
+ "opencodex-tray-warning.ico",
17
+ "opencodex-tray-offline.ico",
18
+ ] as const;
19
+
20
+ export interface WindowsTrayEntry {
21
+ bun: string;
22
+ cli: string;
23
+ script: string;
24
+ codexHome: string;
25
+ opencodexHome: string;
26
+ }
27
+
28
+ interface WindowsTrayState extends WindowsTrayEntry {
29
+ version: 1;
30
+ launcherPath?: string;
31
+ runValue: string;
32
+ runCommand: string;
33
+ }
34
+
35
+ export interface WindowsTrayStatus {
36
+ supported: boolean;
37
+ installed: boolean;
38
+ running: boolean;
39
+ stale: boolean;
40
+ summary: string;
41
+ }
42
+
43
+ function trayStatePath(): string {
44
+ return join(getConfigDir(), "tray-state.json");
45
+ }
46
+
47
+ function trayHeartbeatPath(): string {
48
+ return join(getConfigDir(), "tray-heartbeat.json");
49
+ }
50
+
51
+ function installedTrayScriptPath(): string {
52
+ return join(getConfigDir(), "opencodex-tray.ps1");
53
+ }
54
+
55
+ function installedTrayIconPaths(): string[] {
56
+ return TRAY_ICON_FILES.map(name => join(getConfigDir(), name));
57
+ }
58
+
59
+ export function windowsTrayStatePathsOwned(
60
+ state: Pick<WindowsTrayEntry, "script" | "opencodexHome"> & { launcherPath?: string },
61
+ configDir = getConfigDir(),
62
+ ): boolean {
63
+ if (resolve(state.opencodexHome) !== resolve(configDir)) return false;
64
+ if (resolve(state.script) !== resolve(join(configDir, "opencodex-tray.ps1"))) return false;
65
+ return state.launcherPath === undefined
66
+ || resolve(state.launcherPath) === resolve(join(configDir, "opencodex-tray.vbs"));
67
+ }
68
+
69
+ function sourceTrayScriptPath(): string {
70
+ return join(import.meta.dir, "windows-tray.ps1");
71
+ }
72
+
73
+ function sourceTrayIconPaths(): string[] {
74
+ return TRAY_ICON_FILES.map(name => join(import.meta.dir, "assets", name));
75
+ }
76
+
77
+ function currentCodexHome(): string {
78
+ const raw = process.env.CODEX_HOME?.trim();
79
+ return raw ? resolve(expandUserPath(raw)) : join(homedir(), ".codex");
80
+ }
81
+
82
+ function currentEntry(): WindowsTrayEntry {
83
+ return {
84
+ bun: durableBunPath(),
85
+ cli: join(import.meta.dir, "..", "cli", "index.ts"),
86
+ script: installedTrayScriptPath(),
87
+ codexHome: currentCodexHome(),
88
+ opencodexHome: getConfigDir(),
89
+ };
90
+ }
91
+
92
+ export function windowsTrayRunValue(opencodexHome: string): string {
93
+ const normalized = resolve(opencodexHome).replace(/[\\/](?:\.)?[\\/]*$/, "").toLowerCase();
94
+ return `OpenCodexTray-${createHash("sha256").update(normalized).digest("hex").slice(0, 12)}`;
95
+ }
96
+
97
+ export function windowsPowerShellPath(systemRoot = process.env.SystemRoot): string {
98
+ const candidate = join(
99
+ systemRoot ?? "C:\\Windows",
100
+ "System32",
101
+ "WindowsPowerShell",
102
+ "v1.0",
103
+ "powershell.exe",
104
+ );
105
+ return existsSync(candidate) ? candidate : "powershell.exe";
106
+ }
107
+
108
+ function registryExe(): string {
109
+ const candidate = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "reg.exe");
110
+ return existsSync(candidate) ? candidate : "reg.exe";
111
+ }
112
+
113
+ function runRegistry(args: string[]): string {
114
+ return execFileSync(registryExe(), args, {
115
+ encoding: "utf8",
116
+ stdio: ["ignore", "pipe", "pipe"],
117
+ windowsHide: true,
118
+ }).trim();
119
+ }
120
+
121
+ function safePath(value: string): string {
122
+ if (/[\u0000-\u001f\"]/.test(value)) {
123
+ throw new Error("Windows tray paths cannot contain quotes or control characters.");
124
+ }
125
+ return value;
126
+ }
127
+
128
+ export function windowsTrayProcessArgs(entry: WindowsTrayEntry, mode: "Run" | "Stop" = "Run", hostPid?: number): string[] {
129
+ const args = [
130
+ "-NoLogo",
131
+ "-NoProfile",
132
+ "-NonInteractive",
133
+ "-STA",
134
+ "-ExecutionPolicy", "Bypass",
135
+ "-WindowStyle", "Hidden",
136
+ "-File", safePath(entry.script),
137
+ "-BunPath", safePath(entry.bun),
138
+ "-CliPath", safePath(entry.cli),
139
+ "-CodexHome", safePath(entry.codexHome),
140
+ "-OpenCodexHome", safePath(entry.opencodexHome),
141
+ "-Mode", mode,
142
+ ];
143
+ if (Number.isSafeInteger(hostPid) && (hostPid ?? 0) > 0) args.push("-HostPid", String(hostPid));
144
+ return args;
145
+ }
146
+
147
+ function quoteRunValue(value: string): string {
148
+ safePath(value);
149
+ return `\"${value}\"`;
150
+ }
151
+
152
+ /** Command persisted under HKCU Run. Every value is an owned absolute package/home path. */
153
+ export function buildWindowsTrayRunCommand(entry: WindowsTrayEntry, powershell = windowsPowerShellPath()): string {
154
+ return [
155
+ quoteRunValue(powershell),
156
+ "-NoLogo",
157
+ "-NoProfile",
158
+ "-NonInteractive",
159
+ "-STA",
160
+ "-ExecutionPolicy", "Bypass",
161
+ "-WindowStyle", "Hidden",
162
+ "-File", quoteRunValue(entry.script),
163
+ "-BunPath", quoteRunValue(entry.bun),
164
+ "-CliPath", quoteRunValue(entry.cli),
165
+ "-CodexHome", quoteRunValue(entry.codexHome),
166
+ "-OpenCodexHome", quoteRunValue(entry.opencodexHome),
167
+ "-Mode", "Run",
168
+ ].join(" ");
169
+ }
170
+
171
+ function readState(): WindowsTrayState | null {
172
+ try {
173
+ const state = JSON.parse(readFileSync(trayStatePath(), "utf8")) as Partial<WindowsTrayState>;
174
+ if (state.version !== TRAY_STATE_VERSION) return null;
175
+ for (const key of ["bun", "cli", "script", "codexHome", "opencodexHome", "runValue", "runCommand"] as const) {
176
+ if (typeof state[key] !== "string" || state[key].length === 0) return null;
177
+ }
178
+ if (state.launcherPath !== undefined && typeof state.launcherPath !== "string") return null;
179
+ const valid = state as WindowsTrayState;
180
+ for (const value of [valid.bun, valid.cli, valid.script, valid.codexHome, valid.opencodexHome]) safePath(value);
181
+ // State is advisory, not an authority for executable or deletion paths. In
182
+ // particular, never let a forged state file redirect PowerShell -File.
183
+ if (!windowsTrayStatePathsOwned(valid)) return null;
184
+ if (valid.runValue !== windowsTrayRunValue(valid.opencodexHome)) return null;
185
+ return valid;
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+
191
+ function replaceOwnedFile(path: string, contents: string | Buffer): void {
192
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
193
+ writeFileSync(temporary, contents, { mode: 0o600 });
194
+ try {
195
+ try { chmodSync(temporary, 0o600); } catch { /* best-effort */ }
196
+ if (process.platform === "win32") {
197
+ const hardened = hardenSecretPath(temporary, { required: true });
198
+ if (!hardened.ok) throw new Error("Windows tray ACL hardening did not complete; refusing to persist executable state.");
199
+ }
200
+ renameSync(temporary, path);
201
+ } finally {
202
+ try { if (existsSync(temporary)) unlinkSync(temporary); } catch { /* best-effort */ }
203
+ }
204
+ }
205
+
206
+ function writeState(entry: WindowsTrayEntry, runValue: string, runCommand: string): void {
207
+ const path = trayStatePath();
208
+ replaceOwnedFile(path, JSON.stringify({ version: TRAY_STATE_VERSION, ...entry, runValue, runCommand }, null, 2) + "\n");
209
+ }
210
+
211
+ export function parseWindowsTrayRunValue(output: string, runValue: string): string | null {
212
+ const escaped = runValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
213
+ const match = new RegExp(`^\\s*${escaped}\\s+(\\S+)\\s*(.*)$`, "mi").exec(output);
214
+ if (!match || match[1] !== "REG_SZ" || !match[2]?.trim()) return FOREIGN_RUN_VALUE;
215
+ return match[2].trim();
216
+ }
217
+
218
+ export function windowsRegistryParentShowsRunKey(output: string): boolean {
219
+ const expected = RUN_KEY.toLowerCase();
220
+ return output.split(/\r?\n/).some(line => line.trim().toLowerCase()
221
+ .replace(/^hkey_current_user\\/, "hkcu\\") === expected);
222
+ }
223
+
224
+ export type WindowsRegistryRunner = (args: string[]) => string;
225
+ export type WindowsRegistryAsyncRunner = (args: string[]) => Promise<string>;
226
+
227
+ function registryExitCode(error: unknown): number | null {
228
+ const value = error as { status?: unknown; code?: unknown };
229
+ const code = Number(value.status ?? value.code);
230
+ return Number.isFinite(code) ? code : null;
231
+ }
232
+
233
+ function syncRegistryAbsenceIsProven(run: WindowsRegistryRunner): boolean {
234
+ try {
235
+ run(["query", RUN_KEY, "/reg:64"]);
236
+ return true;
237
+ } catch (runError) {
238
+ if (registryExitCode(runError) !== 1) return false;
239
+ try {
240
+ const parent = run(["query", RUN_PARENT_KEY, "/reg:64"]);
241
+ return !windowsRegistryParentShowsRunKey(parent);
242
+ } catch {
243
+ return false;
244
+ }
245
+ }
246
+ }
247
+
248
+ export function readWindowsTrayRunValueWithRunner(runValue: string, run: WindowsRegistryRunner): string | null {
249
+ try {
250
+ const output = run(["query", RUN_KEY, "/v", runValue, "/reg:64"]);
251
+ return parseWindowsTrayRunValue(output, runValue);
252
+ } catch (error) {
253
+ if (registryExitCode(error) === 1) {
254
+ // reg.exe also uses exit 1 for access/query failures. Only treat the
255
+ // value as absent after proving Run is readable or does not exist under
256
+ // a readable CurrentVersion parent.
257
+ if (syncRegistryAbsenceIsProven(run)) return null;
258
+ }
259
+ throw new Error("Unable to verify the owned Windows tray registry value; refusing to change persistence.");
260
+ }
261
+ }
262
+
263
+ function readOwnedRunValue(runValue = windowsTrayRunValue(getConfigDir())): string | null {
264
+ return readWindowsTrayRunValueWithRunner(runValue, runRegistry);
265
+ }
266
+
267
+ function runRegistryAsync(args: string[]): Promise<string> {
268
+ return new Promise((resolvePromise, rejectPromise) => {
269
+ execFile(registryExe(), args, {
270
+ encoding: "utf8",
271
+ timeout: 2_000,
272
+ windowsHide: true,
273
+ maxBuffer: 64 * 1024,
274
+ }, (error, stdout) => {
275
+ if (error) rejectPromise(error);
276
+ else resolvePromise(stdout.trim());
277
+ });
278
+ });
279
+ }
280
+
281
+ async function asyncRegistryAbsenceIsProven(run: WindowsRegistryAsyncRunner): Promise<boolean> {
282
+ try {
283
+ await run(["query", RUN_KEY, "/reg:64"]);
284
+ return true;
285
+ } catch (runError) {
286
+ if (registryExitCode(runError) !== 1) return false;
287
+ try {
288
+ const parent = await run(["query", RUN_PARENT_KEY, "/reg:64"]);
289
+ return !windowsRegistryParentShowsRunKey(parent);
290
+ } catch {
291
+ return false;
292
+ }
293
+ }
294
+ }
295
+
296
+ export async function readWindowsTrayRunValueWithAsyncRunner(
297
+ runValue: string,
298
+ run: WindowsRegistryAsyncRunner,
299
+ ): Promise<string | null> {
300
+ try {
301
+ const output = await run(["query", RUN_KEY, "/v", runValue, "/reg:64"]);
302
+ return parseWindowsTrayRunValue(output, runValue);
303
+ } catch (error) {
304
+ if (registryExitCode(error) === 1 && await asyncRegistryAbsenceIsProven(run)) {
305
+ return null;
306
+ }
307
+ throw new Error("Unable to verify Windows tray registry status.");
308
+ }
309
+ }
310
+
311
+ async function readOwnedRunValueAsync(runValue = windowsTrayRunValue(getConfigDir())): Promise<string | null> {
312
+ return readWindowsTrayRunValueWithAsyncRunner(runValue, runRegistryAsync);
313
+ }
314
+
315
+ function readHeartbeat(): { pid: number; hostPid?: number; timestamp: number } | null {
316
+ try {
317
+ const heartbeat = JSON.parse(readFileSync(trayHeartbeatPath(), "utf8").replace(/^\uFEFF/, "")) as { pid?: unknown; hostPid?: unknown; timestamp?: unknown };
318
+ if (!Number.isSafeInteger(heartbeat.pid) || (heartbeat.pid as number) <= 0 || typeof heartbeat.timestamp !== "number") return null;
319
+ const hostPid = Number.isSafeInteger(heartbeat.hostPid) && (heartbeat.hostPid as number) > 0 ? heartbeat.hostPid as number : undefined;
320
+ return { pid: heartbeat.pid as number, hostPid, timestamp: heartbeat.timestamp };
321
+ } catch {
322
+ return null;
323
+ }
324
+ }
325
+
326
+ function processAlive(pid: number): boolean {
327
+ try { process.kill(pid, 0); return true; } catch { return false; }
328
+ }
329
+
330
+ function heartbeatProcessAlive(heartbeat = readHeartbeat()): boolean {
331
+ return Boolean(heartbeat && processAlive(heartbeat.pid));
332
+ }
333
+
334
+ function heartbeatRunning(): boolean {
335
+ const heartbeat = readHeartbeat();
336
+ return Boolean(heartbeat && Date.now() - heartbeat.timestamp <= 15_000 && heartbeatProcessAlive(heartbeat));
337
+ }
338
+
339
+ function waitForHeartbeat(expected: boolean, timeoutMs = 8_000): boolean {
340
+ const deadline = Date.now() + timeoutMs;
341
+ while (Date.now() < deadline) {
342
+ if (heartbeatRunning() === expected) return true;
343
+ Bun.sleepSync(100);
344
+ }
345
+ return heartbeatRunning() === expected;
346
+ }
347
+
348
+ function waitForTrayExit(previous: ReturnType<typeof readHeartbeat>, timeoutMs = 15_000): boolean {
349
+ const deadline = Date.now() + timeoutMs;
350
+ while (Date.now() < deadline) {
351
+ const powershellExited = !previous || !processAlive(previous.pid);
352
+ const hostExited = !previous?.hostPid || !processAlive(previous.hostPid);
353
+ if (!heartbeatProcessAlive() && powershellExited && hostExited) return true;
354
+ Bun.sleepSync(100);
355
+ }
356
+ const powershellExited = !previous || !processAlive(previous.pid);
357
+ const hostExited = !previous?.hostPid || !processAlive(previous.hostPid);
358
+ return !heartbeatProcessAlive() && powershellExited && hostExited;
359
+ }
360
+
361
+ export function windowsTrayRegistrationIsStale(inputs: {
362
+ registered: boolean;
363
+ registrationOwned: boolean;
364
+ running: boolean;
365
+ heartbeatFresh: boolean;
366
+ }): boolean {
367
+ if (!inputs.registered && inputs.running) return true;
368
+ if (inputs.registered && !inputs.registrationOwned) return true;
369
+ return inputs.running && !inputs.heartbeatFresh;
370
+ }
371
+
372
+ function trayStatusFrom(registered: string | null): WindowsTrayStatus {
373
+ const state = readState();
374
+ const heartbeat = readHeartbeat();
375
+ const running = heartbeatProcessAlive(heartbeat);
376
+ const registrationOwned = state !== null
377
+ && registered === state.runCommand
378
+ && [state.bun, state.cli, state.script, ...installedTrayIconPaths()].every(path => existsSync(path));
379
+ const stale = windowsTrayRegistrationIsStale({
380
+ registered: registered !== null,
381
+ registrationOwned,
382
+ running,
383
+ heartbeatFresh: Boolean(heartbeat && Date.now() - heartbeat.timestamp <= 15_000),
384
+ });
385
+ const installed = registered !== null && state !== null && registered === state.runCommand && !stale;
386
+ const summary = registered === null
387
+ ? running ? "unregistered tray process is still running" : "not installed"
388
+ : stale
389
+ ? "startup registration is foreign, stale, or points to missing package files"
390
+ : running
391
+ ? "installed and running"
392
+ : "installed, not currently running";
393
+ return { supported: true, installed, running, stale, summary };
394
+ }
395
+
396
+ export function getWindowsTrayStatus(): WindowsTrayStatus {
397
+ if (process.platform !== "win32") {
398
+ return { supported: false, installed: false, running: false, stale: false, summary: `unsupported on ${process.platform}` };
399
+ }
400
+ return trayStatusFrom(readOwnedRunValue());
401
+ }
402
+
403
+ export async function getWindowsTrayStatusAsync(): Promise<WindowsTrayStatus> {
404
+ if (process.platform !== "win32") {
405
+ return { supported: false, installed: false, running: false, stale: false, summary: `unsupported on ${process.platform}` };
406
+ }
407
+ const runValue = windowsTrayRunValue(getConfigDir());
408
+ const registered = await readOwnedRunValueAsync(runValue);
409
+ return trayStatusFrom(registered);
410
+ }
411
+
412
+ function assertWindows(): void {
413
+ if (process.platform !== "win32") throw new Error(`The opencodex tray is Windows-only (current platform: ${process.platform}).`);
414
+ }
415
+
416
+ function spawnTray(state: WindowsTrayEntry): void {
417
+ const child = spawn(state.bun, [state.cli, "__tray-host"], {
418
+ detached: true,
419
+ stdio: "ignore",
420
+ windowsHide: true,
421
+ env: {
422
+ ...process.env,
423
+ OCX_TRAY_ENTRY_B64: Buffer.from(JSON.stringify(state), "utf8").toString("base64"),
424
+ },
425
+ });
426
+ child.unref();
427
+ }
428
+
429
+ function parseTrayHostEntry(): WindowsTrayEntry {
430
+ const encoded = process.env.OCX_TRAY_ENTRY_B64;
431
+ if (!encoded) throw new Error("Missing tray host entry.");
432
+ const value = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")) as Partial<WindowsTrayEntry>;
433
+ for (const key of ["bun", "cli", "script", "codexHome", "opencodexHome"] as const) {
434
+ if (typeof value[key] !== "string") throw new Error(`Invalid tray host field: ${key}`);
435
+ safePath(value[key]);
436
+ }
437
+ return value as WindowsTrayEntry;
438
+ }
439
+
440
+ /** Detached Bun host keeps the attached WinForms PowerShell process alive. */
441
+ export async function runWindowsTrayHost(): Promise<void> {
442
+ assertWindows();
443
+ const entry = parseTrayHostEntry();
444
+ delete process.env.OCX_TRAY_ENTRY_B64;
445
+ const child = spawn(windowsPowerShellPath(), windowsTrayProcessArgs(entry, "Run", process.pid), {
446
+ stdio: "ignore",
447
+ windowsHide: true,
448
+ env: process.env,
449
+ });
450
+ await new Promise<void>((resolvePromise, rejectPromise) => {
451
+ child.once("error", rejectPromise);
452
+ child.once("exit", code => {
453
+ if (code && code !== 0) rejectPromise(new Error(`Windows tray host exited with code ${code}.`));
454
+ else resolvePromise();
455
+ });
456
+ });
457
+ }
458
+
459
+ function signalTrayStop(): ReturnType<typeof readHeartbeat> {
460
+ const previous = readHeartbeat();
461
+ // The stop event name depends only on the current home. Never execute paths
462
+ // recovered from tray-state.json while attempting cleanup or repair.
463
+ execFileSync(windowsPowerShellPath(), windowsTrayProcessArgs(currentEntry(), "Stop"), {
464
+ stdio: "ignore",
465
+ windowsHide: true,
466
+ timeout: 15_000,
467
+ });
468
+ return previous;
469
+ }
470
+
471
+ export function installWindowsTray(startNow = true): WindowsTrayStatus {
472
+ assertWindows();
473
+ const entry = currentEntry();
474
+ const sourceScript = sourceTrayScriptPath();
475
+ const iconPairs = sourceTrayIconPaths().map((source, index) => ({ source, installed: installedTrayIconPaths()[index] }));
476
+ for (const path of [entry.bun, entry.cli, sourceScript, ...iconPairs.map(pair => pair.source)]) {
477
+ if (!existsSync(path)) throw new Error(`Cannot install the tray because a required file is missing: ${path}`);
478
+ }
479
+ if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
480
+ const runCommand = buildWindowsTrayRunCommand(entry);
481
+ const runValue = windowsTrayRunValue(entry.opencodexHome);
482
+ const existing = readOwnedRunValue(runValue);
483
+ const state = readState();
484
+ if (existing && (!state || existing !== state.runCommand)) {
485
+ throw new Error(`Refusing to replace a foreign or unowned HKCU Run value named ${runValue}.`);
486
+ }
487
+ if (existsSync(entry.script) && (!state || resolve(state.script) !== resolve(entry.script))) {
488
+ throw new Error(`Refusing to overwrite an unowned tray script at ${entry.script}.`);
489
+ }
490
+ if (!state && iconPairs.some(pair => existsSync(pair.installed))) {
491
+ throw new Error("Refusing to overwrite unowned Windows tray icon assets.");
492
+ }
493
+ const wasRunning = heartbeatProcessAlive();
494
+ if (wasRunning && !state) {
495
+ throw new Error("Refusing to replace an unowned running tray process. Exit it before installing.");
496
+ }
497
+ if (wasRunning && state) {
498
+ const previous = signalTrayStop();
499
+ if (!waitForTrayExit(previous)) throw new Error("The old tray did not exit; refusing to replace its persistent script.");
500
+ }
501
+
502
+ const previousStateBytes = existsSync(trayStatePath()) ? readFileSync(trayStatePath()) : null;
503
+ const previousScriptBytes = existsSync(entry.script) ? readFileSync(entry.script) : null;
504
+ const previousIconBytes = new Map(iconPairs.map(pair => [
505
+ pair.installed,
506
+ existsSync(pair.installed) ? readFileSync(pair.installed) : null,
507
+ ]));
508
+ const restorePreviousInstall = () => {
509
+ try {
510
+ if (previousScriptBytes) replaceOwnedFile(entry.script, previousScriptBytes);
511
+ else if (existsSync(entry.script)) unlinkSync(entry.script);
512
+ } catch { /* rollback best-effort */ }
513
+ for (const [path, contents] of previousIconBytes) {
514
+ try {
515
+ if (contents) replaceOwnedFile(path, contents);
516
+ else if (existsSync(path)) unlinkSync(path);
517
+ } catch { /* rollback best-effort */ }
518
+ }
519
+ try {
520
+ if (previousStateBytes) replaceOwnedFile(trayStatePath(), previousStateBytes);
521
+ else if (existsSync(trayStatePath())) unlinkSync(trayStatePath());
522
+ } catch { /* rollback best-effort */ }
523
+ try {
524
+ if (existing !== null) runRegistry(["add", RUN_KEY, "/v", runValue, "/t", "REG_SZ", "/d", existing, "/f", "/reg:64"]);
525
+ else runRegistry(["delete", RUN_KEY, "/v", runValue, "/f", "/reg:64"]);
526
+ } catch { /* rollback best-effort */ }
527
+ if (wasRunning && state && !heartbeatRunning()) {
528
+ try {
529
+ spawnTray(currentEntry());
530
+ waitForHeartbeat(true);
531
+ } catch { /* retain the primary installation failure */ }
532
+ }
533
+ };
534
+
535
+ try {
536
+ const hardenedDir = hardenSecretDir(getConfigDir(), { required: true });
537
+ if (!hardenedDir.ok) throw new Error("Windows tray directory ACL hardening did not complete; refusing to install persistence.");
538
+ replaceOwnedFile(entry.script, readFileSync(sourceScript));
539
+ for (const pair of iconPairs) replaceOwnedFile(pair.installed, readFileSync(pair.source));
540
+ runRegistry(["add", RUN_KEY, "/v", runValue, "/t", "REG_SZ", "/d", runCommand, "/f", "/reg:64"]);
541
+ writeState(entry, runValue, runCommand);
542
+ } catch (error) {
543
+ restorePreviousInstall();
544
+ throw error;
545
+ }
546
+ if (startNow && !heartbeatRunning()) spawnTray(entry);
547
+ if (startNow && !waitForHeartbeat(true)) {
548
+ restorePreviousInstall();
549
+ throw new Error("The tray startup registration was installed, but the tray process did not become healthy.");
550
+ }
551
+ if (state?.launcherPath && existsSync(state.launcherPath)) {
552
+ try { unlinkSync(state.launcherPath); } catch { /* old owned VBS is inert after a committed Run replacement */ }
553
+ }
554
+ return getWindowsTrayStatus();
555
+ }
556
+
557
+ export function startWindowsTray(): WindowsTrayStatus {
558
+ assertWindows();
559
+ const state = readState();
560
+ if (!state || readOwnedRunValue(state.runValue) !== state.runCommand) throw new Error("The tray is not installed. Install it first.");
561
+ // Persisted state proves registration ownership but never selects an
562
+ // executable. Resolve every launch path from the running installation.
563
+ if (!heartbeatRunning()) spawnTray(currentEntry());
564
+ if (!waitForHeartbeat(true)) throw new Error("The tray process did not become healthy after launch.");
565
+ return getWindowsTrayStatus();
566
+ }
567
+
568
+ export function stopWindowsTray(): WindowsTrayStatus {
569
+ assertWindows();
570
+ let previous = readHeartbeat();
571
+ if (previous) {
572
+ previous = signalTrayStop();
573
+ }
574
+ if (!waitForTrayExit(previous)) throw new Error("The tray did not exit after the stop signal. Its login registration was preserved.");
575
+ return getWindowsTrayStatus();
576
+ }
577
+
578
+ export function uninstallWindowsTray(): WindowsTrayStatus {
579
+ assertWindows();
580
+ const state = readState();
581
+ const existing = state ? readOwnedRunValue(state.runValue) : readOwnedRunValue();
582
+ if (existing && (!state || existing !== state.runCommand)) {
583
+ throw new Error(`Refusing to remove a foreign or unowned HKCU Run value named ${state?.runValue ?? windowsTrayRunValue(getConfigDir())}.`);
584
+ }
585
+ let previous = readHeartbeat();
586
+ if (previous) {
587
+ previous = signalTrayStop();
588
+ }
589
+ if (!waitForTrayExit(previous)) throw new Error("The tray did not exit; refusing to remove its owned registration or state.");
590
+ if (existing) runRegistry(["delete", RUN_KEY, "/v", state?.runValue ?? windowsTrayRunValue(getConfigDir()), "/f", "/reg:64"]);
591
+ const ownedPaths = [trayStatePath(), trayHeartbeatPath(), ...(state?.launcherPath ? [state.launcherPath] : [])];
592
+ if (state?.script && resolve(state.script) === resolve(installedTrayScriptPath())) ownedPaths.push(state.script);
593
+ if (state) ownedPaths.push(...installedTrayIconPaths());
594
+ for (const path of ownedPaths) {
595
+ try { if (existsSync(path)) unlinkSync(path); } catch { /* best-effort */ }
596
+ }
597
+ return getWindowsTrayStatus();
598
+ }
599
+
600
+ /** Update hook: refresh trusted paths and relaunch only when the tray was already installed. */
601
+ export function repairWindowsTrayIfInstalled(startNow = true): WindowsTrayStatus | null {
602
+ if (process.platform !== "win32" || !readState()) return null;
603
+ return installWindowsTray(startNow);
604
+ }
605
+
606
+ export async function windowsTrayCommand(args: string[]): Promise<void> {
607
+ const wantsJson = args.includes("--json");
608
+ const startNow = !args.includes("--no-start");
609
+ const values = args.filter(value => value !== "--json" && value !== "--no-start");
610
+ const sub = values[0] ?? "status";
611
+ if (args.includes("--no-start") && sub !== "install" || values.length > 1 || !["install", "start", "stop", "status", "uninstall", "remove"].includes(sub)) {
612
+ console.error("Usage: ocx tray <install|start|stop|status|uninstall|remove> [--json] [--no-start]");
613
+ process.exitCode = 1;
614
+ return;
615
+ }
616
+ try {
617
+ const status = sub === "install" ? installWindowsTray(startNow)
618
+ : sub === "start" ? startWindowsTray()
619
+ : sub === "stop" ? stopWindowsTray()
620
+ : sub === "uninstall" || sub === "remove" ? uninstallWindowsTray()
621
+ : getWindowsTrayStatus();
622
+ console.log(wantsJson ? JSON.stringify(status) : `Windows tray: ${status.summary}`);
623
+ } catch (error) {
624
+ if (wantsJson) console.log(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }));
625
+ else console.error(`Windows tray error: ${error instanceof Error ? error.message : String(error)}`);
626
+ process.exitCode = 1;
627
+ }
628
+ }
package/src/types.ts CHANGED
@@ -792,6 +792,11 @@ export interface OcxProviderConfig {
792
792
  modelReasoningEfforts?: Record<string, string[]>;
793
793
  /** Model-specific default Codex reasoning tier; must also be present in the visible tier list. */
794
794
  modelDefaultReasoningEfforts?: Record<string, string>;
795
+ /**
796
+ * Model-specific Codex reasoning-summary capability. Set false when an OpenAI-compatible
797
+ * Responses backend rejects Codex summary-delivery fields for that model.
798
+ */
799
+ modelSupportsReasoningSummaries?: Record<string, boolean>;
795
800
  /** Provider-wide mapping from Codex effort labels to upstream `reasoning_effort` values. */
796
801
  reasoningEffortMap?: Record<string, string>;
797
802
  /** Model-specific mapping from Codex effort labels to upstream `reasoning_effort` values. */