@bitkyc08/opencodex 2.7.43 → 2.8.2-preview.20260731

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 (82) hide show
  1. package/bin/ocx.mjs +34 -8
  2. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  3. package/gui/dist/assets/index-GC0Vlu1Z.js +67 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +42 -7
  7. package/src/adapters/cursor/discovery.ts +4 -1
  8. package/src/adapters/cursor/effort-map.ts +3 -0
  9. package/src/adapters/kiro.ts +15 -1
  10. package/src/adapters/openai-chat.ts +55 -4
  11. package/src/claude/alias.ts +94 -14
  12. package/src/claude/outbound.ts +6 -3
  13. package/src/cli/catalog-prewarm.ts +24 -0
  14. package/src/cli/claude-desktop.ts +2 -2
  15. package/src/cli/claude.ts +32 -7
  16. package/src/cli/doctor.ts +48 -1
  17. package/src/cli/index.ts +5 -0
  18. package/src/cli/init.ts +129 -102
  19. package/src/cli/interactive-confirm.ts +5 -1
  20. package/src/cli/star-prompt.ts +26 -4
  21. package/src/cli/v2.ts +10 -1
  22. package/src/codex/account-store.ts +2 -0
  23. package/src/codex/catalog/bundled.ts +9 -2
  24. package/src/codex/catalog/metadata.ts +6 -0
  25. package/src/codex/catalog/parsing.ts +26 -1
  26. package/src/codex/catalog/provider-fetch.ts +240 -82
  27. package/src/codex/catalog/sync.ts +27 -5
  28. package/src/codex/catalog.ts +3 -3
  29. package/src/codex/features.ts +524 -5
  30. package/src/codex/quota.ts +77 -2
  31. package/src/codex/runtime.ts +10 -1
  32. package/src/config.ts +8 -0
  33. package/src/generated/jawcode-model-metadata.ts +12 -12
  34. package/src/github/star-state.ts +191 -0
  35. package/src/lib/bun-binary-validator.d.mts +3 -0
  36. package/src/lib/bun-binary-validator.mjs +18 -0
  37. package/src/lib/bun-runtime.ts +6 -20
  38. package/src/lib/destination-policy.ts +21 -3
  39. package/src/lib/provider-outbound.ts +8 -2
  40. package/src/lib/shadow-call.ts +30 -0
  41. package/src/lib/test-home-guard.ts +90 -0
  42. package/src/lib/win-exec.ts +12 -2
  43. package/src/lib/winsw.ts +6 -0
  44. package/src/oauth/index.ts +29 -5
  45. package/src/oauth/key-providers.ts +21 -2
  46. package/src/oauth/kiro-credentials.ts +129 -9
  47. package/src/oauth/kiro.ts +15 -3
  48. package/src/oauth/login-cli.ts +1 -1
  49. package/src/oauth/store.ts +2 -0
  50. package/src/providers/derive.ts +2 -2
  51. package/src/providers/free-directory.ts +4 -1
  52. package/src/providers/model-discovery.ts +356 -0
  53. package/src/providers/registry.ts +114 -0
  54. package/src/router.ts +5 -3
  55. package/src/server/auth-cors.ts +4 -2
  56. package/src/server/index.ts +3 -3
  57. package/src/server/live.ts +75 -25
  58. package/src/server/management/agent-settings-routes.ts +82 -8
  59. package/src/server/management/config-routes.ts +24 -7
  60. package/src/server/management/context.ts +11 -1
  61. package/src/server/management/model-routes.ts +61 -14
  62. package/src/server/management/provider-routes.ts +44 -9
  63. package/src/server/management/shared.ts +18 -5
  64. package/src/server/management/sidebar-routes.ts +39 -0
  65. package/src/server/management-api.ts +3 -1
  66. package/src/server/proxy-liveness.ts +9 -2
  67. package/src/server/responses/core.ts +31 -20
  68. package/src/server/responses/upstream-error.ts +48 -0
  69. package/src/server/startup-action-control.ts +30 -14
  70. package/src/service.ts +395 -31
  71. package/src/storage/policy-job.ts +26 -5
  72. package/src/storage/restore-job.ts +16 -5
  73. package/src/storage/worker-lifecycle.ts +81 -0
  74. package/src/tray/windows.ts +86 -13
  75. package/src/types.ts +16 -0
  76. package/src/update/badge.ts +72 -0
  77. package/src/update/job.ts +8 -4
  78. package/src/usage/expected-prices.ts +6 -5
  79. package/src/usage/log.ts +8 -0
  80. package/src/web-search/loop.ts +57 -16
  81. package/gui/dist/assets/index-Czw-jpTU.css +0 -1
  82. package/gui/dist/assets/index-cmds12BG.js +0 -67
@@ -16,6 +16,11 @@ import {
16
16
  withStorageMutationSlot,
17
17
  type StorageMutationCoordinatorTestHooks,
18
18
  } from "./storage-mutation-coordinator";
19
+ import {
20
+ drainStorageWorkers,
21
+ registerStorageWorker,
22
+ terminateStorageWorker,
23
+ } from "./worker-lifecycle";
19
24
 
20
25
  export interface RestoreJobTestHooks extends StorageMutationCoordinatorTestHooks {
21
26
  /**
@@ -69,7 +74,7 @@ export function setRestoreTrashJobTestHooks(hooks: RestoreJobTestHooks | null):
69
74
 
70
75
  export function resetRestoreTrashJobForTests(): void {
71
76
  if (activeWorker) {
72
- try { activeWorker.terminate(); } catch { /* */ }
77
+ void terminateStorageWorker(activeWorker);
73
78
  activeWorker = null;
74
79
  }
75
80
  cancelActiveRun?.();
@@ -78,10 +83,16 @@ export function resetRestoreTrashJobForTests(): void {
78
83
  resetStorageMutationCoordinatorForTests();
79
84
  }
80
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
+
81
92
  /** Terminate an in-flight worker during process shutdown. */
82
93
  export function abortRestoreTrashJob(): void {
83
94
  if (activeWorker) {
84
- try { activeWorker.terminate(); } catch { /* */ }
95
+ void terminateStorageWorker(activeWorker);
85
96
  activeWorker = null;
86
97
  }
87
98
  cancelActiveRun?.();
@@ -117,13 +128,14 @@ function runInWorker(opts: {
117
128
  const requestId = crypto.randomUUID();
118
129
  let settled = false;
119
130
  const worker = new Worker(new URL("./restore-worker.ts", import.meta.url).href);
131
+ registerStorageWorker(worker);
120
132
  activeWorker = worker;
121
133
 
122
134
  const timer = setTimeout(() => {
123
135
  if (settled) return;
124
136
  settled = true;
125
137
  cancelActiveRun = null;
126
- try { worker.terminate(); } catch { /* */ }
138
+ void terminateStorageWorker(worker);
127
139
  if (activeWorker === worker) activeWorker = null;
128
140
  reject(new Error("restore_worker_timeout"));
129
141
  }, WORKER_TIMEOUT_MS);
@@ -134,8 +146,7 @@ function runInWorker(opts: {
134
146
  cancelActiveRun = null;
135
147
  clearTimeout(timer);
136
148
  if (activeWorker === worker) activeWorker = null;
137
- try { worker.terminate(); } catch { /* */ }
138
- fn();
149
+ void terminateStorageWorker(worker).then(fn, fn);
139
150
  };
140
151
 
141
152
  cancelActiveRun = () => {
@@ -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
+ }
@@ -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>;
@@ -204,7 +233,11 @@ function replaceOwnedFile(path: string, contents: string | Buffer): void {
204
233
  }
205
234
  }
206
235
 
207
- function writeState(entry: WindowsTrayEntry, runValue: string, runCommand: string): void {
236
+ function writeState(
237
+ entry: WindowsTrayEntry & { launcherPath: string },
238
+ runValue: string,
239
+ runCommand: string,
240
+ ): void {
208
241
  const path = trayStatePath();
209
242
  replaceOwnedFile(path, JSON.stringify({ version: TRAY_STATE_VERSION, ...entry, runValue, runCommand }, null, 2) + "\n");
210
243
  }
@@ -376,7 +409,8 @@ function trayStatusFrom(registered: string | null): WindowsTrayStatus {
376
409
  const running = heartbeatProcessAlive(heartbeat);
377
410
  const registrationOwned = state !== null
378
411
  && registered === state.runCommand
379
- && [state.bun, state.cli, state.script, ...installedTrayIconPaths()].every(path => existsSync(path));
412
+ && [state.bun, state.cli, state.script, ...(state.launcherPath ? [state.launcherPath] : []), ...installedTrayIconPaths()]
413
+ .every(path => existsSync(path));
380
414
  const stale = windowsTrayRegistrationIsStale({
381
415
  registered: registered !== null,
382
416
  registrationOwned,
@@ -414,17 +448,43 @@ function assertWindows(): void {
414
448
  if (process.platform !== "win32") throw new Error(`The opencodex tray is Windows-only (current platform: ${process.platform}).`);
415
449
  }
416
450
 
417
- function spawnTray(state: WindowsTrayEntry): void {
418
- const child = spawn(state.bun, [state.cli, "__tray-host"], {
419
- detached: true,
451
+ const DETACHED_TRAY_HOST_LAUNCHER = [
452
+ "$startInfo = New-Object System.Diagnostics.ProcessStartInfo",
453
+ "$startInfo.FileName = $env:OCX_TRAY_HOST_BUN",
454
+ "$startInfo.Arguments = $env:OCX_TRAY_HOST_ARGS",
455
+ "$startInfo.UseShellExecute = $true",
456
+ "$startInfo.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden",
457
+ "$child = [System.Diagnostics.Process]::Start($startInfo)",
458
+ "if ($null -eq $child) { throw 'Windows tray host did not start.' }",
459
+ "$child.Dispose()",
460
+ ].join("; ");
461
+
462
+ const DETACHED_TRAY_HOST_LAUNCHER_B64 = Buffer.from(DETACHED_TRAY_HOST_LAUNCHER, "utf16le").toString("base64");
463
+
464
+ export function launchWindowsTrayHost(state: WindowsTrayEntry): void {
465
+ const bun = safePath(state.bun);
466
+ const cli = safePath(state.cli);
467
+ execFileSync(windowsPowerShellPath(), [
468
+ "-NoLogo",
469
+ "-NoProfile",
470
+ "-NonInteractive",
471
+ "-EncodedCommand",
472
+ DETACHED_TRAY_HOST_LAUNCHER_B64,
473
+ ], {
420
474
  stdio: "ignore",
421
475
  windowsHide: true,
476
+ timeout: 15_000,
422
477
  env: {
423
478
  ...process.env,
479
+ OCX_TRAY_HOST_BUN: bun,
480
+ OCX_TRAY_HOST_ARGS: `${quoteRunValue(cli)} __tray-host`,
424
481
  OCX_TRAY_ENTRY_B64: Buffer.from(JSON.stringify(state), "utf8").toString("base64"),
425
482
  },
426
483
  });
427
- child.unref();
484
+ }
485
+
486
+ function spawnTray(state: WindowsTrayEntry): void {
487
+ launchWindowsTrayHost(state);
428
488
  }
429
489
 
430
490
  function parseTrayHostEntry(): WindowsTrayEntry {
@@ -443,6 +503,8 @@ export async function runWindowsTrayHost(): Promise<void> {
443
503
  assertWindows();
444
504
  const entry = parseTrayHostEntry();
445
505
  delete process.env.OCX_TRAY_ENTRY_B64;
506
+ delete process.env.OCX_TRAY_HOST_BUN;
507
+ delete process.env.OCX_TRAY_HOST_ARGS;
446
508
  const child = spawn(windowsPowerShellPath(), windowsTrayProcessArgs(entry, "Run", process.pid), {
447
509
  stdio: "ignore",
448
510
  windowsHide: true,
@@ -479,7 +541,12 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus {
479
541
  }
480
542
  recordOwnedConfigPath(getConfigDir(), trayStatePath());
481
543
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
482
- const runCommand = buildWindowsTrayRunCommand(entry);
544
+ const launcherPath = installedTrayLauncherPath();
545
+ const entryWithLauncher = { ...entry, launcherPath };
546
+ const runCommand = buildWindowsTrayRunCommand(entryWithLauncher);
547
+ if (runCommand.length > 260) {
548
+ throw new Error(`Tray Run command exceeds the Windows 260-character limit (${runCommand.length} chars).`);
549
+ }
483
550
  const runValue = windowsTrayRunValue(entry.opencodexHome);
484
551
  const existing = readOwnedRunValue(runValue);
485
552
  const state = readState();
@@ -489,6 +556,9 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus {
489
556
  if (existsSync(entry.script) && (!state || resolve(state.script) !== resolve(entry.script))) {
490
557
  throw new Error(`Refusing to overwrite an unowned tray script at ${entry.script}.`);
491
558
  }
559
+ if (existsSync(launcherPath) && (!state?.launcherPath || resolve(state.launcherPath) !== resolve(launcherPath))) {
560
+ throw new Error(`Refusing to overwrite an unowned tray launcher at ${launcherPath}.`);
561
+ }
492
562
  if (!state && iconPairs.some(pair => existsSync(pair.installed))) {
493
563
  throw new Error("Refusing to overwrite unowned Windows tray icon assets.");
494
564
  }
@@ -503,6 +573,7 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus {
503
573
 
504
574
  const previousStateBytes = existsSync(trayStatePath()) ? readFileSync(trayStatePath()) : null;
505
575
  const previousScriptBytes = existsSync(entry.script) ? readFileSync(entry.script) : null;
576
+ const previousLauncherBytes = existsSync(launcherPath) ? readFileSync(launcherPath) : null;
506
577
  const previousIconBytes = new Map(iconPairs.map(pair => [
507
578
  pair.installed,
508
579
  existsSync(pair.installed) ? readFileSync(pair.installed) : null,
@@ -512,6 +583,10 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus {
512
583
  if (previousScriptBytes) replaceOwnedFile(entry.script, previousScriptBytes);
513
584
  else if (existsSync(entry.script)) unlinkSync(entry.script);
514
585
  } catch { /* rollback best-effort */ }
586
+ try {
587
+ if (previousLauncherBytes) replaceOwnedFile(launcherPath, previousLauncherBytes);
588
+ else if (existsSync(launcherPath)) unlinkSync(launcherPath);
589
+ } catch { /* rollback best-effort */ }
515
590
  for (const [path, contents] of previousIconBytes) {
516
591
  try {
517
592
  if (contents) replaceOwnedFile(path, contents);
@@ -539,8 +614,9 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus {
539
614
  if (!hardenedDir.ok) throw new Error("Windows tray directory ACL hardening did not complete; refusing to install persistence.");
540
615
  replaceOwnedFile(entry.script, readFileSync(sourceScript));
541
616
  for (const pair of iconPairs) replaceOwnedFile(pair.installed, readFileSync(pair.source));
617
+ replaceOwnedFile(launcherPath, Buffer.from("\uFEFF" + buildWindowsTrayLauncherScript(entry), "utf16le"));
542
618
  runRegistry(["add", RUN_KEY, "/v", runValue, "/t", "REG_SZ", "/d", runCommand, "/f", "/reg:64"]);
543
- writeState(entry, runValue, runCommand);
619
+ writeState(entryWithLauncher, runValue, runCommand);
544
620
  } catch (error) {
545
621
  restorePreviousInstall();
546
622
  throw error;
@@ -550,9 +626,6 @@ export function installWindowsTray(startNow = true): WindowsTrayStatus {
550
626
  restorePreviousInstall();
551
627
  throw new Error("The tray startup registration was installed, but the tray process did not become healthy.");
552
628
  }
553
- if (state?.launcherPath && existsSync(state.launcherPath)) {
554
- try { unlinkSync(state.launcherPath); } catch { /* old owned VBS is inert after a committed Run replacement */ }
555
- }
556
629
  return getWindowsTrayStatus();
557
630
  }
558
631
 
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";
@@ -546,6 +551,17 @@ export interface OcxConfig {
546
551
  * the Codex ladder (src/reasoning-effort.ts CODEX_REASONING_LEVELS) at the API boundary.
547
552
  */
548
553
  injectionEffort?: string;
554
+ /**
555
+ * Explicit sideband websocket base for realtime/live joins, mirroring upstream's
556
+ * `experimental_realtime_ws_base_url`. The value is a ROOT (or a recognized
557
+ * `/realtime`, `/realtime/calls/<id>`, `/live/<id>` endpoint form, which is
558
+ * stripped back to the root); `/v1` is appended during normalization. Intended
559
+ * for local development against a fake realtime server — plaintext `http`/`ws`
560
+ * is accepted only for loopback hosts, and URL userinfo is rejected; both
561
+ * failures close to the canonical `https://api.openai.com/v1`. Configured by
562
+ * editing this file; there is deliberately no management-API or GUI surface.
563
+ */
564
+ experimentalRealtimeWsBaseUrl?: string;
549
565
  /**
550
566
  * Model ids the user has EXCLUDED from the Grok Build managed block. Absent or empty
551
567
  * means "everything visible", which is the historical behaviour — so an existing
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Cached "is an update available?" answer for the GUI sidebar badge.
3
+ *
4
+ * `/api/update/check` spawns `npm view` on every call (~1s, network-bound), so a
5
+ * sidebar that polls it would spawn a process per tick on every page of the GUI.
6
+ * The badge instead READS the 20h version cache the CLI update prompt already
7
+ * maintains (`~/.opencodex/version.json`).
8
+ *
9
+ * This is deliberately read-only: it must never trigger a registry refresh. The GUI
10
+ * polls it, so a refresh-on-read would let repeated polls launch repeated `npm view`
11
+ * helpers with no coalescing. Cache warming stays with `ocx start`
12
+ * (`triggerBackgroundRefreshIfStale` in `src/update/notify.ts`) and with the explicit
13
+ * `/api/update/check` the user reaches by clicking the sidebar update button.
14
+ */
15
+ import { currentVersion, defaultUpdateTag, detectInstall, type Channel } from "./index";
16
+ import { isNewer, isSourceBuildVersion, readVersionCache } from "./notify";
17
+
18
+ export interface UpdateBadge {
19
+ /** True only when a newer version exists on the current channel. */
20
+ updateAvailable: boolean;
21
+ currentVersion: string;
22
+ latestVersion: string | null;
23
+ channel: Channel;
24
+ /** False for source checkouts, where the GUI cannot offer a one-click update. */
25
+ canUpdate: boolean;
26
+ /** True when no cached registry answer exists yet, so "no update" is unproven. */
27
+ unknown: boolean;
28
+ }
29
+
30
+ export interface UpdateBadgeDeps {
31
+ currentVersion: () => string;
32
+ detectInstall: () => ReturnType<typeof detectInstall>;
33
+ readCache: (channel: Channel) => ReturnType<typeof readVersionCache>;
34
+ }
35
+
36
+ const defaultDeps: UpdateBadgeDeps = {
37
+ currentVersion,
38
+ detectInstall,
39
+ readCache: readVersionCache,
40
+ };
41
+
42
+ /**
43
+ * Read-only badge state. Source checkouts and unknown versions report no update
44
+ * rather than a dead badge the user cannot act on.
45
+ */
46
+ export function readUpdateBadge(deps: UpdateBadgeDeps = defaultDeps): UpdateBadge {
47
+ const current = deps.currentVersion();
48
+ const installer = deps.detectInstall();
49
+ const channel = defaultUpdateTag(current);
50
+ const base: UpdateBadge = {
51
+ updateAvailable: false,
52
+ currentVersion: current,
53
+ latestVersion: null,
54
+ channel,
55
+ canUpdate: installer !== "source",
56
+ unknown: true,
57
+ };
58
+ // A source checkout has nothing to compare against, so "unknown" is not useful there.
59
+ if (installer === "source" || current === "?" || isSourceBuildVersion(current)) {
60
+ return { ...base, canUpdate: false, unknown: false };
61
+ }
62
+
63
+ const cache = deps.readCache(channel);
64
+ if (!cache) return base;
65
+
66
+ return {
67
+ ...base,
68
+ latestVersion: cache.latest_version,
69
+ updateAvailable: isNewer(cache.latest_version, current, channel),
70
+ unknown: false,
71
+ };
72
+ }
package/src/update/job.ts CHANGED
@@ -26,7 +26,7 @@ const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/lates
26
26
  const UPDATE_JOB_FILENAME = "update-job.json";
27
27
  const UPDATE_TIMEOUT_MS = 180_000;
28
28
  const RESTART_TIMEOUT_MS = 60_000;
29
- const RESTART_HEALTH_TIMEOUT_MS = 15_000;
29
+ const RESTART_HEALTH_TIMEOUT_MS = 30_000;
30
30
  const RESTART_STABILITY_WINDOW_MS = 15_000;
31
31
  /** Legacy active records did not persist a worker PID, so age is their only safe recovery signal. */
32
32
  export const UPDATE_JOB_LEGACY_STALE_MS = 10 * 60_000;
@@ -533,7 +533,10 @@ async function awaitRestartedProxyHealthy(
533
533
  const hostname = captured.hostname;
534
534
  const startDeadline = now() + RESTART_HEALTH_TIMEOUT_MS;
535
535
 
536
- while (now() < startDeadline) {
536
+ while (true) {
537
+ // Always make one identity-aware probe at or after the boundary. A replacement
538
+ // becoming healthy on the final tick must not be mistaken for a timeout.
539
+ const finalProbe = now() >= startDeadline;
537
540
  if (await probe(port, hostname)) {
538
541
  updateJob(job, {}, `Proxy reported healthy on ${hostname}:${port}; confirming it stays up...`);
539
542
  const stableUntil = now() + RESTART_STABILITY_WINDOW_MS;
@@ -547,7 +550,8 @@ async function awaitRestartedProxyHealthy(
547
550
  updateJob(job, {}, `Proxy stayed healthy for ${Math.trunc(RESTART_STABILITY_WINDOW_MS / 1000)}s after restart.`);
548
551
  return { ok: true };
549
552
  }
550
- await sleep(250);
553
+ if (finalProbe) break;
554
+ await sleep(Math.min(250, Math.max(0, startDeadline - now())));
551
555
  }
552
556
 
553
557
  return { ok: false, reason: "timeout" };
@@ -570,7 +574,7 @@ async function confirmRestartedProxy(
570
574
  - 검토한 주요 대안: (1) 포트 점유만 확인 — 외부 프로세스/죽기 직전 프로세스를 성공으로 오인할 수 있다. (2) 무기한 /healthz 폴링 — UX가 느려지고 worker 종료 시점이 불명확하다. (3) 짧은 healthy 등장 + 안정성 창 확인 — 실제 복귀를 확인하면서도 대기 시간을 제한할 수 있다.
571
575
  - 선택한 방식: identity-aware /healthz probe가 일정 시간 안에 나타나고, 추가 안정성 창 동안 유지되는지 확인한다.
572
576
  - 다른 대안 대신 이 방식을 선택한 이유: GUI는 "업데이트가 설치됐지만 재시작은 실패"를 분리해 알려줘야 하며, 이 방식이 가장 적은 오탐으로 그 경계를 만든다.
573
- - 장점, 단점 및 영향: 장점은 silent restart failure가 update-job 상태로 드러난다는 점이다. 단점은 성공 판정이 최대 30초 늦어질 수 있다는 점이며, 대신 실제 복귀를 더 정확히 반영한다.
577
+ - 장점, 단점 및 영향: 장점은 silent restart failure가 update-job 상태로 드러난다는 점이다. 단점은 설정상 성공 판정 창이 30초 도착 + 15초 안정성으로 늘어나고 경계 probe 지연이 추가될 수 있다는 점이며, 대신 실제 복귀를 더 정확히 반영한다.
574
578
  */
575
579
  const result = await awaitRestartedProxyHealthy(job, captured, io);
576
580
  if (result.ok) return true;
@@ -144,16 +144,17 @@ export function findExpectedPriceOverlay(
144
144
  }
145
145
 
146
146
  /**
147
- * OpenAI service_tier "priority" (Fast) price multipliers by model slug.
148
- * Source: https://platform.openai.com/docs/models (2026-07-24).
149
- * Priority pricing applies uniformly to all token types (input, output, cache).
147
+ * OpenAI Fast mode (`service_tier=priority`) price multipliers by model slug.
148
+ * Source: https://openai.com/api-fast-mode/ (2026-07-31).
149
+ * Fast pricing applies uniformly to all token types (input, output, cache).
150
150
  * Models not listed here fall back to 1× (no multiplier).
151
151
  */
152
152
  export const PRIORITY_MULTIPLIERS: Readonly<Record<string, number>> = {
153
153
  "gpt-5.6-sol": 2,
154
- "gpt-5.6-terra": 2,
155
- "gpt-5.6-luna": 2,
154
+ "gpt-5.6-terra": 1.6,
155
+ "gpt-5.6-luna": 0.4,
156
156
  "gpt-5.5": 2.5,
157
+ "gpt-5.4-mini": 2,
157
158
  "gpt-5.4": 2,
158
159
  };
159
160
 
package/src/usage/log.ts CHANGED
@@ -128,6 +128,13 @@ function normalizeUsageValue(usage: OcxUsage | undefined): OcxUsage | undefined
128
128
  return {
129
129
  inputTokens: usage.inputTokens,
130
130
  outputTokens: usage.outputTokens,
131
+ // Absolute active-context checkpoint (types.ts). Stateful providers such as Kiro report
132
+ // per-attempt usage only, so this field is the ONLY carrier of the cumulative context
133
+ // figure once the log records raw adapter usage instead of re-parsing the bridged wire
134
+ // (usageFromBridge, request-log.ts). Omitting it here silently dropped Kiro's context
135
+ // growth from every persisted row. It is deliberately NOT folded into totalTokens:
136
+ // a checkpoint is not a per-request total and must never be summed across requests.
137
+ ...(typeof usage.contextTotalTokens === "number" ? { contextTotalTokens: usage.contextTotalTokens } : {}),
131
138
  ...(typeof usage.totalTokens === "number" ? { totalTokens: usage.totalTokens } : {}),
132
139
  ...(typeof usage.cachedInputTokens === "number" ? { cachedInputTokens: usage.cachedInputTokens } : {}),
133
140
  ...(typeof usage.cacheReadInputTokens === "number" ? { cacheReadInputTokens: usage.cacheReadInputTokens } : {}),
@@ -162,6 +169,7 @@ function normalizeAttemptUsage(raw: unknown): OcxUsage | null {
162
169
  if (!isNonNegativeFiniteNumber(usage.inputTokens)
163
170
  || !isNonNegativeFiniteNumber(usage.outputTokens)) return null;
164
171
  for (const key of [
172
+ "contextTotalTokens",
165
173
  "totalTokens",
166
174
  "cachedInputTokens",
167
175
  "cacheReadInputTokens",
@@ -103,23 +103,63 @@ async function* replay(events: AdapterEvent[]): AsyncGenerator<AdapterEvent> {
103
103
  * replaying a bare toolCall 400s ("Expected `thinking` or `redacted_thinking`, but found
104
104
  * `tool_use`"). The signature validity gate stays in the anthropic adapter; other adapters
105
105
  * ignore or serialize the part harmlessly.
106
+ *
107
+ * Each signed block keeps its OWN signature and text, mirroring src/images/loop.ts: a signature
108
+ * authenticates the exact block it closed, so flattening two blocks under the last signature
109
+ * 400s on replay just as it does there.
110
+ *
111
+ * Raw reasoning (`reasoning_raw_delta`, what OpenAI-compatible providers emit instead of signed
112
+ * thinking) accumulates into a SEPARATE UNSIGNED part. It must never join a signed block: the
113
+ * anthropic serializer skips signature-less parts, while openai-chat serializes their text as
114
+ * `reasoning_content` — which DeepSeek V4 thinking mode requires back alongside the replayed
115
+ * tool_calls, and whose absence ended the turn as a provider 400 (issue #688).
116
+ *
117
+ * This assumes raw reasoning never interleaves INSIDE an unfinished signed block: Anthropic-family
118
+ * adapters emit thinking_delta/signature and OpenAI-compatible ones emit reasoning_raw_delta, and
119
+ * the two never share a stream. Honoring a genuinely mixed stream would need per-segment state,
120
+ * not another accumulator.
106
121
  */
107
- function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent | null {
122
+ function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent[] {
123
+ const parts: OcxThinkingContent[] = [];
108
124
  let thinking = "";
109
125
  let signature: string | undefined;
110
- const redacted: string[] = [];
126
+ let rawReasoning = "";
127
+
128
+ const flushVisible = () => {
129
+ if (!thinking && !signature) return;
130
+ parts.push({
131
+ type: "thinking",
132
+ thinking,
133
+ ...(signature ? { signature } : {}),
134
+ });
135
+ thinking = "";
136
+ signature = undefined;
137
+ };
138
+ const flushRaw = () => {
139
+ if (!rawReasoning) return;
140
+ parts.push({ type: "thinking", thinking: rawReasoning });
141
+ rawReasoning = "";
142
+ };
143
+
111
144
  for (const e of events) {
112
- if (e.type === "thinking_delta") thinking += e.thinking;
113
- else if (e.type === "thinking_signature") signature = e.signature;
114
- else if (e.type === "redacted_thinking") redacted.push(e.data);
145
+ if (e.type === "thinking_delta") {
146
+ flushRaw();
147
+ thinking += e.thinking;
148
+ } else if (e.type === "reasoning_raw_delta") {
149
+ flushVisible();
150
+ rawReasoning += e.text;
151
+ } else if (e.type === "thinking_signature") {
152
+ signature = e.signature;
153
+ flushVisible();
154
+ } else if (e.type === "redacted_thinking") {
155
+ flushVisible();
156
+ flushRaw();
157
+ parts.push({ type: "thinking", thinking: "", redacted: [e.data] });
158
+ }
115
159
  }
116
- if (!thinking && !signature && redacted.length === 0) return null;
117
- return {
118
- type: "thinking",
119
- thinking,
120
- ...(signature ? { signature } : {}),
121
- ...(redacted.length > 0 ? { redacted } : {}),
122
- };
160
+ flushVisible();
161
+ flushRaw();
162
+ return parts;
123
163
  }
124
164
 
125
165
  /** Normalize a query for failed-query de-duplication (case/whitespace-insensitive). */
@@ -406,7 +446,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
406
446
  // valid, and surface as ONE search cell carrying every attempted query. A real search (one that
407
447
  // hits the sidecar) shows the spinner WHILE the batch runs. Empty/limit/repeat placeholders never
408
448
  // emit a cell (matching the prior single-query behavior).
409
- async function* runSearchCall(call: WebSearchCall, precedingThinking?: OcxThinkingContent | null): AsyncGenerator<AdapterEvent> {
449
+ async function* runSearchCall(call: WebSearchCall, precedingThinking: OcxThinkingContent[] = []): AsyncGenerator<AdapterEvent> {
410
450
  const results: { query: string; outcome: SidecarOutcome }[] = [];
411
451
  let beganCell = false;
412
452
  if (call.queries.length === 0) {
@@ -465,8 +505,9 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
465
505
  messages.push({
466
506
  role: "assistant",
467
507
  content: [
468
- // Signed thinking must precede tool_use on replay (Anthropic extended thinking).
469
- ...(precedingThinking ? [precedingThinking] : []),
508
+ // Signed thinking must precede tool_use on replay (Anthropic extended thinking), and
509
+ // unsigned raw reasoning has to ride along for providers that require it back (#688).
510
+ ...precedingThinking,
470
511
  { type: "toolCall" as const, id: call.id, name: WEB_SEARCH_TOOL_NAME, arguments: callArgs },
471
512
  ],
472
513
  timestamp: now,
@@ -559,7 +600,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
559
600
  // The thinking that led to the search belongs to the FIRST call's assistant replay turn.
560
601
  const iterationThinking = extractIterationThinking(split.passthrough);
561
602
  for (const [callIndex, call] of split.calls.entries()) {
562
- yield* runSearchCall(call, callIndex === 0 ? iterationThinking : null);
603
+ yield* runSearchCall(call, callIndex === 0 ? iterationThinking : []);
563
604
  }
564
605
  } catch (e) {
565
606
  yield { type: "error", message: e instanceof LoopError ? e.message : (e instanceof Error ? e.message : String(e)) };