@bitkyc08/opencodex 2.26.0 → 2.28.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 (83) hide show
  1. package/gui/dist/assets/{index-RL6b1bTV.js → index-D2sP-biU.js} +14 -14
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic.ts +60 -1
  5. package/src/adapters/base.ts +16 -2
  6. package/src/adapters/command-code.ts +4 -3
  7. package/src/adapters/cursor/cursor-errors.ts +15 -0
  8. package/src/adapters/cursor/live-transport.ts +14 -1
  9. package/src/adapters/google-antigravity-replay.ts +16 -8
  10. package/src/adapters/google.ts +22 -5
  11. package/src/adapters/openai-chat.ts +189 -60
  12. package/src/adapters/openai-responses.ts +37 -0
  13. package/src/adapters/tool-catalog-nudge.ts +1 -1
  14. package/src/bridge.ts +11 -5
  15. package/src/cli/doctor.ts +76 -0
  16. package/src/cli/help.ts +2 -0
  17. package/src/cli/index.ts +19 -6
  18. package/src/cli/models.ts +13 -6
  19. package/src/codex/account-usability.ts +3 -0
  20. package/src/codex/app-server-processes.ts +269 -37
  21. package/src/codex/auth-api.ts +22 -5
  22. package/src/codex/auth-context.ts +108 -3
  23. package/src/codex/catalog/aggregation.ts +3 -0
  24. package/src/codex/catalog/metadata.ts +17 -3
  25. package/src/codex/catalog/native-models.ts +22 -14
  26. package/src/codex/catalog/parsing.ts +20 -3
  27. package/src/codex/catalog/provider-fetch.ts +8 -0
  28. package/src/codex/catalog/sync.ts +63 -15
  29. package/src/codex/convergence.ts +61 -13
  30. package/src/codex/log-guard/path-safety.ts +52 -3
  31. package/src/codex/model-entitlements.ts +353 -0
  32. package/src/codex/native-profile-startup.ts +100 -2
  33. package/src/codex/quota.ts +28 -3
  34. package/src/codex/routing.ts +14 -8
  35. package/src/codex/user-identity.ts +21 -1
  36. package/src/config/provider-name.ts +24 -0
  37. package/src/config.ts +11 -24
  38. package/src/generated/compatibility-version.json +110 -70
  39. package/src/images/loop.ts +11 -4
  40. package/src/lib/destination-policy.ts +47 -0
  41. package/src/lib/shadow-call.ts +15 -0
  42. package/src/lib/state-store-registrations.ts +8 -2
  43. package/src/oauth/index.ts +33 -5
  44. package/src/oauth/store.ts +11 -5
  45. package/src/providers/antigravity-models.ts +70 -5
  46. package/src/providers/derive.ts +12 -2
  47. package/src/providers/fastwire.ts +39 -8
  48. package/src/providers/quota.ts +9 -2
  49. package/src/providers/registry.ts +120 -6
  50. package/src/providers/service-tier.ts +50 -15
  51. package/src/responses/parser.ts +59 -11
  52. package/src/responses/state.ts +162 -5
  53. package/src/responses/tool-search-compat.ts +301 -0
  54. package/src/router.ts +17 -3
  55. package/src/routing/capability.ts +26 -9
  56. package/src/routing/compatibility/behavior.ts +44 -6
  57. package/src/routing/profile.ts +1 -1
  58. package/src/server/chat-native.ts +11 -2
  59. package/src/server/index.ts +59 -8
  60. package/src/server/management/agent-settings-routes.ts +16 -2
  61. package/src/server/management/shared.ts +3 -1
  62. package/src/server/request-log.ts +31 -0
  63. package/src/server/responses/collaboration.ts +34 -9
  64. package/src/server/responses/compact.ts +54 -7
  65. package/src/server/responses/core.ts +259 -43
  66. package/src/server/responses/input-admission.ts +7 -2
  67. package/src/server/responses/responses-field-backfill.ts +88 -6
  68. package/src/server/responses/terminal-guard.ts +10 -0
  69. package/src/server/responses-tool-search-repair.ts +217 -0
  70. package/src/server/system-env.ts +74 -5
  71. package/src/service-manager-probe.ts +99 -0
  72. package/src/service.ts +86 -6
  73. package/src/tray/windows.ts +25 -5
  74. package/src/types/accounts.ts +37 -0
  75. package/src/types/config.ts +818 -0
  76. package/src/types/provider.ts +521 -0
  77. package/src/types/request.ts +358 -0
  78. package/src/types/tools.ts +131 -0
  79. package/src/types/wire.ts +80 -0
  80. package/src/types.ts +103 -1883
  81. package/src/usage/cost.ts +37 -1
  82. package/src/usage/log.ts +4 -0
  83. package/src/web-search/loop.ts +11 -4
package/src/cli/index.ts CHANGED
@@ -46,9 +46,8 @@ import { loadServiceTokenFromFile } from "../lib/service-secrets";
46
46
  import { diagnoseService, isServiceOwnershipError, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service";
47
47
  import { startupHealthSummary } from "../codex/autostart-health";
48
48
  import { drainAndShutdown, isRecyclingForExit, startServer } from "../server";
49
- import { injectSystemEnv, revertSystemEnv } from "../server/system-env";
49
+ import { injectSystemEnv, reconcileShellHook, revertSystemEnv, uninstallShellHook } from "../server/system-env";
50
50
  import { buildDesktop3pRegistry } from "../claude/desktop-3p";
51
- import { installShellHook, uninstallShellHook } from "../server/system-env";
52
51
  import { startTokenGuardian } from "../oauth/token-guardian";
53
52
  import { startHistoryMigrationGuardian } from "../codex/history-migration-guardian";
54
53
  import { maybeShowStarPrompt } from "./star-prompt";
@@ -57,6 +56,18 @@ import { maybeShowUpdatePrompt } from "../update/notify";
57
56
  import { syncModelsToCodex } from "../codex/sync";
58
57
  import { setIntegrationEnabled, shouldSyncCodexOnStart, shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state";
59
58
 
59
+ /**
60
+ * A failed shell-hook reconcile is not cosmetic: a stale hook keeps sourcing
61
+ * `claude-env.sh` from every new interactive shell, pointing at a proxy or a CLI that may no
62
+ * longer exist. `reconcileShellHook` already reports `state: "failed"`, but both call sites
63
+ * discarded it, so the one outcome the user has to act on was the one they never saw.
64
+ */
65
+ function reportShellHookFailure(result: { state: "installed" | "absent" | "failed"; reason?: string }): void {
66
+ if (result.state !== "failed") return;
67
+ console.warn(` Claude shell hook not reconciled${result.reason ? `: ${result.reason}` : ""}`);
68
+ console.warn(" Check ~/.zshrc for the '# opencodex claude-env hook' block.");
69
+ }
70
+
60
71
 
61
72
  import { removeOwnedConfigState } from "../lib/config-ownership";
62
73
  import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
@@ -366,9 +377,10 @@ async function handleStart(options: { block?: boolean } = {}) {
366
377
 
367
378
  // System-wide env injection AFTER signal handlers are registered (crash safety:
368
379
  // syncCleanup reverts even if injection itself or subsequent startup steps fail).
369
- await injectSystemEnv(port, config).catch(() => {});
370
- // Auto-install .zshrc hook (idempotent skips if already present).
371
- installShellHook();
380
+ const systemEnv = await injectSystemEnv(port, config).catch(() => ({ injected: false }));
381
+ // The hook is useful only for an installed Claude Code CLI. Reconcile instead of
382
+ // appending unconditionally so stale OpenCodex-owned hooks are removed as well.
383
+ reportShellHookFailure(reconcileShellHook(systemEnv.injected));
372
384
 
373
385
  await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start
374
386
  // Post-startup sync drives the readiness gate AND the #1046 stale app-server
@@ -455,7 +467,8 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom
455
467
  });
456
468
  if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native.");
457
469
  // Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature).
458
- await injectSystemEnv(live.port, config).catch(() => {});
470
+ const systemEnv = await injectSystemEnv(live.port, config).catch(() => ({ injected: false }));
471
+ reportShellHookFailure(reconcileShellHook(systemEnv.injected));
459
472
  // Refresh the Grok Build fence too (same contract as start). live.hostname is the
460
473
  // hostname the running proxy actually bound — config.hostname may have drifted.
461
474
  try {
package/src/cli/models.ts CHANGED
@@ -5,11 +5,11 @@ import { randomUUID } from "node:crypto";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { syncModelsToCodex } from "../codex/sync";
7
7
  import { hasOwnProvider, isValidProviderName, loadConfig, saveConfig } from "../config";
8
- import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort } from "../reasoning-effort";
8
+ import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort, modelRecordValue } from "../reasoning-effort";
9
9
  import { encodedModelIdCollides, routedSlug, slugEquals } from "../providers/slug-codec";
10
10
  import { knownModelIdsForProvider } from "../router";
11
11
  import { findLiveProxy } from "../server/proxy-liveness";
12
- import type { OcxConfig, OcxCustomModel } from "../types";
12
+ import { modelInList, type OcxConfig, type OcxCustomModel } from "../types";
13
13
 
14
14
  const ADD_USAGE = "Usage: ocx models add <provider> <modelId> [--display-name <name>] [--context-window <tokens>] [--modalities text,image,audio] [--reasoning-efforts <none,minimal,low,medium,high,xhigh,max,ultra>] [--default-reasoning-effort <level>]";
15
15
  const REMOVE_USAGE = "Usage: ocx models remove <customId|provider/modelId> [--yes]";
@@ -98,15 +98,22 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[]
98
98
  if (seen.has(model)) return;
99
99
  seen.add(model);
100
100
 
101
- const noVision = prov.noVisionModels?.includes(model);
102
- const modalities = inputModalities[model] ?? (noVision ? ["text"] : null);
103
- const efforts = reasoningEfforts[model] ?? prov.reasoningEfforts ?? null;
101
+ // Resolve exactly as the runtime does, or this command reports capabilities the
102
+ // proxy will not honour: `isModelTextOnly` matches noVisionModels with modelInList
103
+ // and reads modelInputModalities with modelRecordValue, so a `gpt-oss` entry covers
104
+ // `gpt-oss:120b`. A bare lookup reported that model as unclassified on every field.
105
+ // noVisionModels is checked first because `isModelTextOnly` returns true on that
106
+ // match before it ever reads modelInputModalities: a `gpt-oss` noVision entry beats
107
+ // an exact `gpt-oss:120b` entry that lists "image", and the proxy rejects the image.
108
+ const noVision = modelInList(prov.noVisionModels, model);
109
+ const modalities = noVision ? ["text"] : (modelRecordValue(inputModalities, model) ?? null);
110
+ const efforts = modelRecordValue(reasoningEfforts, model) ?? prov.reasoningEfforts ?? null;
104
111
 
105
112
  entries.push({
106
113
  provider: provName,
107
114
  model,
108
115
  isDefault,
109
- contextWindow: contextWindows[model] ?? globalContext,
116
+ contextWindow: modelRecordValue(contextWindows, model) ?? globalContext,
110
117
  inputModalities: modalities,
111
118
  reasoningEfforts: efforts,
112
119
  });
@@ -10,6 +10,8 @@ export interface CodexAccountUsabilityOptions {
10
10
  nativeMainSelectionOnly?: boolean;
11
11
  /** Test seam for proving whether routing attempted a physical native-token read. */
12
12
  isMainAccountTokenLive?: typeof isMainAccountTokenLive;
13
+ /** Confirmed account ids for an account-gated model; omitted for ordinary native models. */
14
+ modelEligibleAccountIds?: ReadonlySet<string>;
13
15
  }
14
16
 
15
17
  export function isCodexAccountUsable(
@@ -17,6 +19,7 @@ export function isCodexAccountUsable(
17
19
  accountId: string,
18
20
  options: CodexAccountUsabilityOptions = {},
19
21
  ): boolean {
22
+ if (options.modelEligibleAccountIds && !options.modelEligibleAccountIds.has(accountId)) return false;
20
23
  if (accountId === MAIN_CODEX_ACCOUNT_ID) {
21
24
  // Startup recovery owns the physical auth/vault boundary. Never parse or select
22
25
  // native __main__ while an encrypted switch journal is pending or inconclusive.
@@ -7,7 +7,7 @@
7
7
  * Never match broad `*codex*` patterns that hit unrelated tools such as
8
8
  * `hermes-codex-bridge-mcp`.
9
9
  */
10
- import { execFileSync } from "node:child_process";
10
+ import { execFile, execFileSync, type ExecFileException } from "node:child_process";
11
11
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
12
12
  import { isProcessAlive, waitForExit } from "../lib/process-control";
13
13
  import {
@@ -98,9 +98,30 @@ export interface CodexAppServerProcessIo {
98
98
  waitExit?: (pid: number, timeoutMs: number) => boolean;
99
99
  now?: () => number;
100
100
  readStartMs?: (pid: number) => number | null;
101
+ /** Async process-list seam used by the request-path Windows collector. */
102
+ listSnapshotsAsync?: () => Promise<ProcessSnapshot[]>;
103
+ /** Async batch start-time seam used by the request-path Windows collector. */
104
+ readStartMsBatchAsync?: (pids: readonly number[]) => Promise<Map<number, number | null>>;
101
105
  catalogMtimeMs?: () => number | null;
102
106
  }
103
107
 
108
+ function execFileTextAsync(
109
+ file: string,
110
+ args: readonly string[],
111
+ timeoutMs: number,
112
+ ): Promise<string> {
113
+ return new Promise((resolve, reject) => {
114
+ execFile(file, [...args], {
115
+ encoding: "utf-8",
116
+ timeout: timeoutMs,
117
+ windowsHide: true,
118
+ }, (error: ExecFileException | null, stdout: string) => {
119
+ if (error) reject(error);
120
+ else resolve(stdout);
121
+ });
122
+ });
123
+ }
124
+
104
125
  /** Split a process command line into argv-like tokens (handles simple quotes). */
105
126
  export function tokenizeCommandLine(commandLine: string): string[] {
106
127
  const tokens: string[] = [];
@@ -375,7 +396,7 @@ export function parseWindowsSnapshotOutput(output: string): ProcessSnapshot[] {
375
396
  return out;
376
397
  }
377
398
 
378
- export function listWindowsSnapshots(runPowerShell?: (psCommand: string) => string): ProcessSnapshot[] {
399
+ function windowsSnapshotPowerShellCommand(): string {
379
400
  // Newlines keep -Command as a real script (space-joined statements need ';').
380
401
  // Double-quoted format string so `t expands to a real tab.
381
402
  // Codex candidates only: basename token codex / codex.exe / codex.cmd /
@@ -384,7 +405,7 @@ export function listWindowsSnapshots(runPowerShell?: (psCommand: string) => stri
384
405
  // path with "opencodex".
385
406
  const basenameMatch = powerShellSingleQuotedIgnoreCaseMatch(WINDOWS_CODEX_BASENAME_CANDIDATE_RE.source);
386
407
  const codeModeMatch = powerShellSingleQuotedIgnoreCaseMatch(WINDOWS_CODEX_CODE_MODE_HOST_CANDIDATE_RE.source);
387
- const psCommand = [
408
+ return [
388
409
  "$ErrorActionPreference='SilentlyContinue'",
389
410
  "$me=[System.Security.Principal.WindowsIdentity]::GetCurrent().Name",
390
411
  // -ErrorAction Stop plus the outer try is what makes a TOP-LEVEL query failure
@@ -412,9 +433,13 @@ export function listWindowsSnapshots(runPowerShell?: (psCommand: string) => stri
412
433
  "}",
413
434
  "} catch { \"__OCX_ENUM_INCOMPLETE__\" }",
414
435
  ].join("\n");
436
+ }
437
+
438
+ export function listWindowsSnapshots(runPowerShell?: (psCommand: string) => string): ProcessSnapshot[] {
415
439
  // Top-level exec failure propagates (see listDarwinSnapshots note). The
416
440
  // executable resolves from the trusted System32 directory (never PATH), and
417
441
  // windowsHide keeps the enumeration console-less on desktop sessions (#1278).
442
+ const psCommand = windowsSnapshotPowerShellCommand();
418
443
  const output = runPowerShell
419
444
  ? runPowerShell(psCommand)
420
445
  : execFileSync(resolveTrustedWindowsPowerShellExe(), [
@@ -425,6 +450,15 @@ export function listWindowsSnapshots(runPowerShell?: (psCommand: string) => stri
425
450
  return parseWindowsSnapshotOutput(output);
426
451
  }
427
452
 
453
+ async function listWindowsSnapshotsAsync(): Promise<ProcessSnapshot[]> {
454
+ const output = await execFileTextAsync(resolveTrustedWindowsPowerShellExe(), [
455
+ "-NoProfile", "-NoLogo", "-NonInteractive",
456
+ "-Command",
457
+ windowsSnapshotPowerShellCommand(),
458
+ ], 8_000);
459
+ return parseWindowsSnapshotOutput(output);
460
+ }
461
+
428
462
  function defaultListSnapshots(platform: NodeJS.Platform, getuid: () => number | undefined): ProcessSnapshot[] {
429
463
  if (platform === "win32") return listWindowsSnapshots();
430
464
  if (platform === "darwin") return listDarwinSnapshots(getuid());
@@ -533,6 +567,41 @@ export function readProcessStartMs(pid: number, platform: NodeJS.Platform = proc
533
567
  return readLinuxProcStartMs(pid);
534
568
  }
535
569
 
570
+ function windowsProcessStartPowerShellCommand(pids: readonly number[]): string {
571
+ const filter = pids.map(pid => `ProcessId=${pid}`).join(" OR ");
572
+ return `Get-CimInstance Win32_Process -Filter "${filter}" | ForEach-Object { "$($_.ProcessId)\t$($_.CreationDate.ToUniversalTime().ToString("o"))" }`;
573
+ }
574
+
575
+ function parseWindowsProcessStartTimes(
576
+ stdout: string,
577
+ pids: readonly number[],
578
+ ): Map<number, number | null> {
579
+ const byPid = new Map<number, number>();
580
+ for (const line of stdout.split(/\r?\n/)) {
581
+ const tab = line.indexOf("\t");
582
+ if (tab <= 0) continue;
583
+ const pid = Number(line.slice(0, tab));
584
+ const parsed = Date.parse(line.slice(tab + 1).trim());
585
+ if (Number.isSafeInteger(pid) && Number.isFinite(parsed)) byPid.set(pid, parsed);
586
+ }
587
+ return new Map(pids.map(pid => [pid, byPid.get(pid) ?? null]));
588
+ }
589
+
590
+ async function readWindowsProcessStartMsBatchAsync(
591
+ pids: readonly number[],
592
+ ): Promise<Map<number, number | null>> {
593
+ try {
594
+ const stdout = await execFileTextAsync(resolveTrustedWindowsPowerShellExe(), [
595
+ "-NoProfile", "-NoLogo", "-NonInteractive",
596
+ "-Command",
597
+ windowsProcessStartPowerShellCommand(pids),
598
+ ], 5_000);
599
+ return parseWindowsProcessStartTimes(stdout, pids);
600
+ } catch {
601
+ return new Map(pids.map(pid => [pid, null]));
602
+ }
603
+ }
604
+
536
605
  /**
537
606
  * Start times for many pids in ONE platform call where possible, so the
538
607
  * staleness check does not serialize per-process ps/PowerShell invocations
@@ -568,22 +637,12 @@ export function readProcessStartMsBatch(
568
637
  }
569
638
  if (platform === "win32") {
570
639
  try {
571
- const filter = pids.map(pid => `ProcessId=${pid}`).join(" OR ");
572
640
  const stdout = execFileSync(resolveTrustedWindowsPowerShellExe(), [
573
641
  "-NoProfile", "-NoLogo", "-NonInteractive",
574
642
  "-Command",
575
- `Get-CimInstance Win32_Process -Filter "${filter}" | ForEach-Object { "$($_.ProcessId)\t$($_.CreationDate.ToUniversalTime().ToString("o"))" }`,
643
+ windowsProcessStartPowerShellCommand(pids),
576
644
  ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, windowsHide: true });
577
- const byPid = new Map<number, number>();
578
- for (const line of stdout.split(/\r?\n/)) {
579
- const tab = line.indexOf("\t");
580
- if (tab <= 0) continue;
581
- const pid = Number(line.slice(0, tab));
582
- const parsed = Date.parse(line.slice(tab + 1).trim());
583
- if (Number.isSafeInteger(pid) && Number.isFinite(parsed)) byPid.set(pid, parsed);
584
- }
585
- for (const pid of pids) out.set(pid, byPid.get(pid) ?? null);
586
- return out;
645
+ return parseWindowsProcessStartTimes(stdout, pids);
587
646
  } catch {
588
647
  for (const pid of pids) out.set(pid, null);
589
648
  return out;
@@ -610,9 +669,65 @@ function defaultCatalogMtimeMs(): number | null {
610
669
  }
611
670
  }
612
671
 
672
+ function codexAppServerProcessesFromSnapshots(
673
+ snapshots: readonly ProcessSnapshot[],
674
+ ): CodexAppServerProcess[] {
675
+ const processes: CodexAppServerProcess[] = [];
676
+ const seen = new Set<number>();
677
+ for (const snapshot of snapshots) {
678
+ if (seen.has(snapshot.pid)) continue;
679
+ if (!isCodexAppServerCommandLine(snapshot.commandLine, snapshot.executable)) continue;
680
+ seen.add(snapshot.pid);
681
+ processes.push({ pid: snapshot.pid, commandLine: snapshot.commandLine });
682
+ }
683
+ return processes;
684
+ }
685
+
686
+ function catalogStatusFromProcesses(
687
+ processes: readonly CodexAppServerProcess[],
688
+ catalogMtimeMs: number | null,
689
+ starts: ReadonlyMap<number, number | null>,
690
+ ): CodexAppServerCatalogStatus {
691
+ const withStarts = processes.map(proc => ({
692
+ pid: proc.pid,
693
+ startedAtMs: starts.get(proc.pid) ?? null,
694
+ }));
695
+ if (catalogMtimeMs === null || withStarts.some(proc => proc.startedAtMs === null)) {
696
+ return { state: "unknown", processes: withStarts, catalogMtimeMs };
697
+ }
698
+ // `<=` is deliberate: coarse clocks (ps lstart is second-granularity) can
699
+ // report equal values when the catalog actually changed after startup.
700
+ const stale = withStarts.some(proc => proc.startedAtMs! <= catalogMtimeMs);
701
+ return { state: stale ? "stale" : "fresh", processes: withStarts, catalogMtimeMs };
702
+ }
703
+
613
704
  // Short TTL: process listing + stat run once per window even under per-turn
614
705
  // guidance calls (#857).
615
706
  let catalogStateCache: { atMs: number; status: CodexAppServerCatalogStatus } | null = null;
707
+ interface RequestCatalogStateIdentity {
708
+ platform: NodeJS.Platform;
709
+ listSnapshots?: CodexAppServerProcessIo["listSnapshots"];
710
+ listSnapshotsAsync?: CodexAppServerProcessIo["listSnapshotsAsync"];
711
+ readStartMs?: CodexAppServerProcessIo["readStartMs"];
712
+ readStartMsBatchAsync?: CodexAppServerProcessIo["readStartMsBatchAsync"];
713
+ catalogMtimeMs?: CodexAppServerProcessIo["catalogMtimeMs"];
714
+ now?: CodexAppServerProcessIo["now"];
715
+ }
716
+
717
+ interface RequestCatalogStateFlight {
718
+ generation: number;
719
+ identity: RequestCatalogStateIdentity;
720
+ promise: Promise<CodexAppServerCatalogStatus>;
721
+ }
722
+
723
+ let requestCatalogStateGeneration = 0;
724
+ let requestCatalogStateCache: {
725
+ generation: number;
726
+ identity: RequestCatalogStateIdentity;
727
+ atMs: number;
728
+ status: CodexAppServerCatalogStatus;
729
+ } | null = null;
730
+ let requestCatalogStateFlight: RequestCatalogStateFlight | null = null;
616
731
  const CATALOG_STATE_TTL_MS = 5_000;
617
732
  /**
618
733
  * `unknown` is a failure to observe, not an observation, so it gets a much shorter
@@ -627,6 +742,19 @@ export function catalogStateTtlMs(state: CodexAppServerCatalogState): number {
627
742
  return state === "unknown" ? CATALOG_STATE_UNKNOWN_TTL_MS : CATALOG_STATE_TTL_MS;
628
743
  }
629
744
 
745
+ function sameRequestCatalogStateIdentity(
746
+ left: RequestCatalogStateIdentity,
747
+ right: RequestCatalogStateIdentity,
748
+ ): boolean {
749
+ return left.platform === right.platform
750
+ && left.listSnapshots === right.listSnapshots
751
+ && left.listSnapshotsAsync === right.listSnapshotsAsync
752
+ && left.readStartMs === right.readStartMs
753
+ && left.readStartMsBatchAsync === right.readStartMsBatchAsync
754
+ && left.catalogMtimeMs === right.catalogMtimeMs
755
+ && left.now === right.now;
756
+ }
757
+
630
758
  /**
631
759
  * Compare the on-disk catalog mtime against the start time of running Codex
632
760
  * app-servers (#857): a server that started before the catalog changed keeps
@@ -676,33 +804,17 @@ export function collectCodexAppServerCatalogState(
676
804
  snapshots = [];
677
805
  enumerationFailed = true;
678
806
  }
679
- const processes: CodexAppServerProcess[] = [];
680
- const seen = new Set<number>();
681
- for (const snapshot of snapshots) {
682
- if (seen.has(snapshot.pid)) continue;
683
- if (!isCodexAppServerCommandLine(snapshot.commandLine, snapshot.executable)) continue;
684
- seen.add(snapshot.pid);
685
- processes.push({ pid: snapshot.pid, commandLine: snapshot.commandLine });
686
- }
807
+ const processes = codexAppServerProcessesFromSnapshots(snapshots);
687
808
  if (processes.length === 0) {
688
809
  return enumerationFailed
689
810
  ? { state: "unknown", processes: [], catalogMtimeMs: null }
690
811
  : { state: "not_running", processes: [], catalogMtimeMs: null };
691
812
  }
692
813
  const catalogMtimeMs = (io.catalogMtimeMs ?? defaultCatalogMtimeMs)();
693
- const withStarts = io.readStartMs
694
- ? processes.map(proc => ({ pid: proc.pid, startedAtMs: io.readStartMs!(proc.pid) }))
695
- : (() => {
696
- const batch = readProcessStartMsBatch(processes.map(proc => proc.pid), platform);
697
- return processes.map(proc => ({ pid: proc.pid, startedAtMs: batch.get(proc.pid) ?? null }));
698
- })();
699
- if (catalogMtimeMs === null || withStarts.some(proc => proc.startedAtMs === null)) {
700
- return { state: "unknown", processes: withStarts, catalogMtimeMs };
701
- }
702
- // `<=` is deliberate: coarse clocks (ps lstart is second-granularity) can
703
- // report equal values when the catalog actually changed after startup.
704
- const stale = withStarts.some(proc => proc.startedAtMs! <= catalogMtimeMs);
705
- return { state: stale ? "stale" : "fresh", processes: withStarts, catalogMtimeMs };
814
+ const starts = io.readStartMs
815
+ ? new Map(processes.map(proc => [proc.pid, io.readStartMs!(proc.pid)] as const))
816
+ : readProcessStartMsBatch(processes.map(proc => proc.pid), platform);
817
+ return catalogStatusFromProcesses(processes, catalogMtimeMs, starts);
706
818
  };
707
819
  const status = compute();
708
820
  if (fullyDefault) {
@@ -711,9 +823,129 @@ export function collectCodexAppServerCatalogState(
711
823
  return status;
712
824
  }
713
825
 
714
- /** Test hook: drop the memoized catalog state. */
826
+ /**
827
+ * Request-path catalog state collector.
828
+ *
829
+ * [Decision Log]
830
+ * - 목적과 의도: keep Windows CIM discovery from blocking Bun's event loop while v2 guidance is built.
831
+ * - 기존 구현 및 제약 조건: CLI/service operations still need the synchronous, fail-closed collector; the request path needs only advisory state.
832
+ * - 검토한 주요 대안: remove stale-catalog guidance, move all process work to workers, or add a Windows-only async boundary.
833
+ * - 선택한 방식: retain the synchronous API and use async PowerShell plus an identity-scoped in-flight refresh, short cache, and invalidation generation only for Windows requests.
834
+ * - 다른 대안 대신 이 방식을 선택한 이유: it fixes unrelated `/healthz` starvation without widening the process-matching or restart contract.
835
+ * - 장점, 단점 및 영향: concurrent turns share one CIM walk, invalidated pre-write results cannot repopulate the cache, and the event loop stays responsive; a cold v2 turn can still await the bounded advisory probe.
836
+ */
837
+ export async function collectCodexAppServerCatalogStateForRequest(
838
+ io: CodexAppServerProcessIo = {},
839
+ ): Promise<CodexAppServerCatalogStatus> {
840
+ const platform = io.platform ?? process.platform;
841
+ if (platform !== "win32") return collectCodexAppServerCatalogState(io);
842
+
843
+ const now = (io.now ?? Date.now)();
844
+ const generation = requestCatalogStateGeneration;
845
+ const identity: RequestCatalogStateIdentity = {
846
+ platform,
847
+ listSnapshots: io.listSnapshots,
848
+ listSnapshotsAsync: io.listSnapshotsAsync,
849
+ readStartMs: io.readStartMs,
850
+ readStartMsBatchAsync: io.readStartMsBatchAsync,
851
+ catalogMtimeMs: io.catalogMtimeMs,
852
+ now: io.now,
853
+ };
854
+ if (requestCatalogStateCache
855
+ && requestCatalogStateCache.generation === generation
856
+ && sameRequestCatalogStateIdentity(requestCatalogStateCache.identity, identity)
857
+ && now - requestCatalogStateCache.atMs < catalogStateTtlMs(requestCatalogStateCache.status.state)) {
858
+ return requestCatalogStateCache.status;
859
+ }
860
+ if (requestCatalogStateFlight
861
+ && requestCatalogStateFlight.generation === generation
862
+ && sameRequestCatalogStateIdentity(requestCatalogStateFlight.identity, identity)) {
863
+ return requestCatalogStateFlight.promise;
864
+ }
865
+
866
+ const refresh = async (): Promise<CodexAppServerCatalogStatus> => {
867
+ let snapshots: ProcessSnapshot[];
868
+ try {
869
+ snapshots = io.listSnapshotsAsync
870
+ ? await io.listSnapshotsAsync()
871
+ : io.listSnapshots
872
+ ? io.listSnapshots()
873
+ : await listWindowsSnapshotsAsync();
874
+ } catch {
875
+ return { state: "unknown", processes: [], catalogMtimeMs: null };
876
+ }
877
+ const processes = codexAppServerProcessesFromSnapshots(snapshots);
878
+ if (processes.length === 0) {
879
+ return { state: "not_running", processes: [], catalogMtimeMs: null };
880
+ }
881
+ let catalogMtimeMs: number | null;
882
+ try {
883
+ catalogMtimeMs = (io.catalogMtimeMs ?? defaultCatalogMtimeMs)();
884
+ } catch {
885
+ catalogMtimeMs = null;
886
+ }
887
+ const pids = processes.map(proc => proc.pid);
888
+ const starts = io.readStartMsBatchAsync
889
+ ? await io.readStartMsBatchAsync(pids)
890
+ : io.readStartMs
891
+ ? new Map(pids.map(pid => [pid, io.readStartMs!(pid)] as const))
892
+ : await readWindowsProcessStartMsBatchAsync(pids);
893
+ return catalogStatusFromProcesses(processes, catalogMtimeMs, starts);
894
+ };
895
+
896
+ const pending = refresh().catch(() => ({
897
+ state: "unknown" as const,
898
+ processes: [],
899
+ catalogMtimeMs: null,
900
+ }));
901
+ let flight: RequestCatalogStateFlight;
902
+ const promise = pending.then(status => {
903
+ // A catalog write can invalidate while slow CIM is still running. The result
904
+ // describes the PRE-write world, so it must neither repopulate the post-write
905
+ // cache nor reach the caller.
906
+ //
907
+ // Suppressing only the cache write is not enough. The awaiting request still
908
+ // received `fresh`, and `fresh` is the one state that authorizes positive
909
+ // guidance (`src/server/responses/collaboration.ts:279-280` returns null for
910
+ // `stale`/`unknown` but describes the catalog for `fresh`). So the request
911
+ // would advertise the newly written disk catalog to an app-server whose
912
+ // in-memory copy the write just made stale — a wrong answer, which is worse
913
+ // than the slow answer this whole change exists to fix.
914
+ //
915
+ // Degrade to `unknown` instead: it is the honest description of what an
916
+ // invalidated observation knows, and the guidance path already treats it as
917
+ // "say nothing positive".
918
+ if (requestCatalogStateGeneration !== generation) {
919
+ return { state: "unknown" as const, processes: [], catalogMtimeMs: null };
920
+ }
921
+ if (requestCatalogStateFlight === flight) {
922
+ requestCatalogStateCache = {
923
+ generation,
924
+ identity,
925
+ atMs: (io.now ?? Date.now)(),
926
+ status,
927
+ };
928
+ }
929
+ return status;
930
+ }).finally(() => {
931
+ if (requestCatalogStateFlight === flight) requestCatalogStateFlight = null;
932
+ });
933
+ flight = { generation, identity, promise };
934
+ requestCatalogStateFlight = flight;
935
+ return flight.promise;
936
+ }
937
+
938
+ /**
939
+ * Drop memoized catalog state after a relevant catalog/cache write and before
940
+ * the post-write state read. Advancing the generation prevents an older
941
+ * in-flight Windows CIM refresh from publishing its pre-write result after the
942
+ * write has completed.
943
+ */
715
944
  export function resetCodexAppServerCatalogStateCache(): void {
716
945
  catalogStateCache = null;
946
+ requestCatalogStateGeneration += 1;
947
+ requestCatalogStateCache = null;
948
+ requestCatalogStateFlight = null;
717
949
  }
718
950
 
719
951
  export interface RestartCodexAppServersResult {
@@ -644,12 +644,13 @@ async function retryMainAccountInfoIfIdentityChanged(
644
644
  requestAccountId: string | null,
645
645
  retriesRemaining: number,
646
646
  nativeMainLease: AdmissionLease,
647
+ explicitRefresh: boolean,
647
648
  ): Promise<MainAccountInfoFetchResult | null> {
648
649
  const currentAccountId = getMainChatgptAccountId();
649
650
  if (currentAccountId === null || currentAccountId === requestAccountId) return null;
650
651
  reconcileMainCodexAccountRuntimeState();
651
652
  return retriesRemaining > 0
652
- ? fetchMainAccountInfoWhileOwned(true, retriesRemaining - 1, nativeMainLease)
653
+ ? fetchMainAccountInfoWhileOwned(true, retriesRemaining - 1, nativeMainLease, explicitRefresh)
653
654
  : { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
654
655
  }
655
656
 
@@ -696,6 +697,13 @@ async function fetchMainAccountInfoWhileOwned(
696
697
  forceRefresh: boolean,
697
698
  retriesRemaining: number,
698
699
  nativeMainLease: AdmissionLease,
700
+ /**
701
+ * Whether the *caller* asked for this refresh. `forceRefresh` also means "bypass the
702
+ * cache", and `retryMainAccountInfoIfIdentityChanged` re-enters with it set purely to
703
+ * re-read after the identity changed. Keeping the two apart stops that retry from
704
+ * promoting a background poll into operator intent below.
705
+ */
706
+ explicitRefresh: boolean = forceRefresh,
699
707
  ): Promise<MainAccountInfoFetchResult> {
700
708
  const writerGeneration = captureConfigGeneration();
701
709
  reconcileMainCodexAccountRuntimeState();
@@ -724,7 +732,7 @@ async function fetchMainAccountInfoWhileOwned(
724
732
  });
725
733
  if (!resp.ok) {
726
734
  const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive());
727
- const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease);
735
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
728
736
  if (retried) return retried;
729
737
  if (terminalAuthFailure) {
730
738
  clearMainAccountInfoCache();
@@ -733,7 +741,7 @@ async function fetchMainAccountInfoWhileOwned(
733
741
  return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
734
742
  }
735
743
  const data = (await resp.json()) as WhamUsageResponse;
736
- const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease);
744
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
737
745
  if (retried) return retried;
738
746
  const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan());
739
747
  const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) });
@@ -745,7 +753,16 @@ async function fetchMainAccountInfoWhileOwned(
745
753
  ts: Date.now(),
746
754
  };
747
755
  setMainAccountInfoCache(result);
748
- clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
756
+ // Only an explicit refresh may retract a reauth quarantine. A 200 from
757
+ // /wham/usage proves the token authenticates to the usage endpoint; it does not
758
+ // prove the account can serve Responses traffic, which is a different backend path
759
+ // and still answers 403 for a workspace the token may no longer select (#327).
760
+ // Letting the background poll clear the flag put such an account straight back into
761
+ // rotation: the next request failed the same way and re-marked it, so needsReauth
762
+ // never settled and the dashboard kept showing nothing — the symptom #327 reported.
763
+ // An explicit refresh is an operator asking to re-evaluate, normally right after
764
+ // signing in again, so it stays authoritative.
765
+ if (explicitRefresh) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
749
766
  // Mirror main quota + plan into the shared stores so the rotation engine can
750
767
  // score and auto-switch the main account exactly like a pool account (Option A).
751
768
  setMainAccountPlan(result.plan);
@@ -760,7 +777,7 @@ async function fetchMainAccountInfoWhileOwned(
760
777
  ...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
761
778
  };
762
779
  } catch {
763
- const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease);
780
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
764
781
  return retried ?? { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
765
782
  }
766
783
  }