@golba98/codexa 1.0.4 → 1.0.6

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 (78) hide show
  1. package/README.md +6 -10
  2. package/package.json +1 -1
  3. package/src/app.tsx +188 -121
  4. package/src/commands/handler.ts +11 -20
  5. package/src/config/appVersion.ts +65 -0
  6. package/src/config/buildInfo.ts +2 -2
  7. package/src/config/settings.ts +5 -1
  8. package/src/config/updateCheckCache.ts +18 -8
  9. package/src/core/models/codexModelCapabilities.ts +57 -4
  10. package/src/core/models/codexModelsCacheSeed.ts +153 -0
  11. package/src/core/models/providerModelCache.ts +90 -0
  12. package/src/core/process/CommandRunner.ts +12 -1
  13. package/src/core/providerLauncher/registry.ts +37 -7
  14. package/src/core/providerLauncher/types.ts +2 -1
  15. package/src/core/providerRuntime/anthropic.ts +3 -5
  16. package/src/core/providerRuntime/antigravity.ts +257 -90
  17. package/src/core/providerRuntime/claudeCodeDiscovery.ts +724 -692
  18. package/src/core/providerRuntime/mistralVibe.ts +663 -0
  19. package/src/core/providerRuntime/models.ts +18 -4
  20. package/src/core/providerRuntime/reasoning.ts +9 -6
  21. package/src/core/providerRuntime/registry.ts +93 -44
  22. package/src/core/providerRuntime/types.ts +1 -0
  23. package/src/core/version/packageManager.ts +119 -0
  24. package/src/core/version/updateCheck.ts +15 -9
  25. package/src/index.tsx +23 -9
  26. package/src/ui/{ActivityBars.tsx → chrome/ActivityBars.tsx} +1 -1
  27. package/src/ui/{ActivityIndicator.tsx → chrome/ActivityIndicator.tsx} +3 -3
  28. package/src/ui/{AnimatedStatusText.tsx → chrome/AnimatedStatusText.tsx} +3 -3
  29. package/src/ui/{AppShell.tsx → chrome/AppShell.tsx} +11 -11
  30. package/src/ui/{BottomComposer.tsx → chrome/BottomComposer.tsx} +15 -15
  31. package/src/ui/{DashCard.tsx → chrome/DashCard.tsx} +2 -2
  32. package/src/ui/{RunFooter.tsx → chrome/RunFooter.tsx} +3 -3
  33. package/src/ui/{RuntimeStatusBar.tsx → chrome/RuntimeStatusBar.tsx} +3 -3
  34. package/src/ui/{Spinner.tsx → chrome/Spinner.tsx} +1 -1
  35. package/src/ui/{TopHeader.tsx → chrome/TopHeader.tsx} +15 -13
  36. package/src/ui/{UpdateAvailableCard.tsx → chrome/UpdateAvailableCard.tsx} +6 -5
  37. package/src/ui/{commandNormalize.ts → input/commandNormalize.ts} +1 -1
  38. package/src/ui/{focus.ts → input/focus.ts} +1 -1
  39. package/src/ui/{inputBuffer.ts → input/inputBuffer.ts} +3 -3
  40. package/src/ui/{slashCommands.ts → input/slashCommands.ts} +1 -0
  41. package/src/ui/{AttachmentImportPanel.tsx → panels/AttachmentImportPanel.tsx} +1 -1
  42. package/src/ui/{AuthPanel.tsx → panels/AuthPanel.tsx} +5 -5
  43. package/src/ui/{BackendPicker.tsx → panels/BackendPicker.tsx} +2 -2
  44. package/src/ui/{ModePicker.tsx → panels/ModePicker.tsx} +2 -2
  45. package/src/ui/{ModelPicker.tsx → panels/ModelPicker.tsx} +2 -2
  46. package/src/ui/{ModelPickerScreen.tsx → panels/ModelPickerScreen.tsx} +11 -9
  47. package/src/ui/{ModelReasoningPicker.tsx → panels/ModelReasoningPicker.tsx} +5 -5
  48. package/src/ui/{Panel.tsx → panels/Panel.tsx} +1 -1
  49. package/src/ui/{PermissionsPanel.tsx → panels/PermissionsPanel.tsx} +3 -3
  50. package/src/ui/{PlanActionPicker.tsx → panels/PlanActionPicker.tsx} +2 -2
  51. package/src/ui/{PlanReviewPanel.tsx → panels/PlanReviewPanel.tsx} +5 -5
  52. package/src/ui/{ProviderPicker.tsx → panels/ProviderPicker.tsx} +9 -7
  53. package/src/ui/{ReasoningPicker.tsx → panels/ReasoningPicker.tsx} +3 -3
  54. package/src/ui/{SelectionPanel.tsx → panels/SelectionPanel.tsx} +1 -1
  55. package/src/ui/{SettingsPanel.tsx → panels/SettingsPanel.tsx} +3 -3
  56. package/src/ui/{TextEntryPanel.tsx → panels/TextEntryPanel.tsx} +3 -3
  57. package/src/ui/{ThemePicker.tsx → panels/ThemePicker.tsx} +2 -2
  58. package/src/ui/{UpdatePromptPanel.tsx → panels/UpdatePromptPanel.tsx} +54 -44
  59. package/src/ui/{Markdown.tsx → render/Markdown.tsx} +2 -2
  60. package/src/ui/{diffRenderer.ts → render/diffRenderer.ts} +1 -1
  61. package/src/ui/{modeDisplay.ts → render/modeDisplay.ts} +2 -2
  62. package/src/ui/{outputPipeline.ts → render/outputPipeline.ts} +2 -2
  63. package/src/ui/{runtimeDisplay.ts → render/runtimeDisplay.ts} +9 -8
  64. package/src/ui/{ActionRequiredBlock.tsx → timeline/ActionRequiredBlock.tsx} +3 -3
  65. package/src/ui/{AgentBlock.tsx → timeline/AgentBlock.tsx} +10 -10
  66. package/src/ui/{StaticIntroItem.tsx → timeline/StaticIntroItem.tsx} +2 -2
  67. package/src/ui/{ThinkingBlock.tsx → timeline/ThinkingBlock.tsx} +4 -4
  68. package/src/ui/{Timeline.tsx → timeline/Timeline.tsx} +8 -8
  69. package/src/ui/{TranscriptShell.tsx → timeline/TranscriptShell.tsx} +6 -6
  70. package/src/ui/{TurnGroup.tsx → timeline/TurnGroup.tsx} +15 -15
  71. package/src/ui/{progressEntries.ts → timeline/progressEntries.ts} +3 -3
  72. package/src/ui/{runActivityView.ts → timeline/runActivityView.ts} +1 -1
  73. package/src/ui/{timelineMeasure.ts → timeline/timelineMeasure.ts} +14 -14
  74. /package/src/ui/{busyStatusAnimation.ts → chrome/busyStatusAnimation.ts} +0 -0
  75. /package/src/ui/{logoVariants.ts → render/logoVariants.ts} +0 -0
  76. /package/src/ui/{terminalAnswerFormat.ts → render/terminalAnswerFormat.ts} +0 -0
  77. /package/src/ui/{textLayout.ts → render/textLayout.ts} +0 -0
  78. /package/src/ui/{layoutListWindow.ts → timeline/layoutListWindow.ts} +0 -0
@@ -18,7 +18,6 @@ import {
18
18
  AUTH_PREFERENCES,
19
19
  AVAILABLE_BACKENDS,
20
20
  AVAILABLE_MODELS,
21
- AVAILABLE_REASONING_LEVELS,
22
21
  AVAILABLE_THEMES,
23
22
  BUSY_LOADER_SETTING_VALUES,
24
23
  WORKSPACE_DISPLAY_MODES,
@@ -150,10 +149,6 @@ function expandReasoningAliases(arg: string): string {
150
149
  return normalized === "extra high" ? "xhigh" : normalized;
151
150
  }
152
151
 
153
- function isKnownFallbackReasoning(value: string): boolean {
154
- return AVAILABLE_REASONING_LEVELS.some((item) => item.id === value);
155
- }
156
-
157
152
  function simplePolicySetter<T extends string>(
158
153
  rest: string,
159
154
  normalizedRest: string,
@@ -396,12 +391,12 @@ function buildHelpMessage(context: CommandContext): string {
396
391
  ` Current reasoning: ${formatReasoningLabel(context.runtime.reasoningLevel)}`,
397
392
  ` Current plan mode: ${context.runtime.planMode ? "Enabled" : "Disabled"}`,
398
393
  " /copy Copy last response to clipboard",
399
- " /update [check] Show update status and instructions for updating Codexa",
394
+ " /update [status] Check for updates and install the latest Codexa (status: cached result only)",
400
395
  " /help Show this help",
401
- "",
402
- "Local development:",
403
- " npm run install:dev-bin Install the codexa-dev command",
404
- " codexa-dev Run this repo without replacing codexa",
396
+ "",
397
+ "Local development:",
398
+ " npm run install:dev-bin Install the codexa-dev command",
399
+ " codexa-dev Run this repo without replacing codexa",
405
400
  "",
406
401
  "Shortcuts:",
407
402
  " Ctrl+B Open backend picker",
@@ -520,16 +515,10 @@ export function handleCommand(text: string, context: CommandContext): CommandRes
520
515
  message: `Unknown reasoning level for ${context.runtime.model}: ${arg}. Valid: ${valid}`,
521
516
  };
522
517
  }
523
- if (isKnownFallbackReasoning(normalized)) {
524
- return {
525
- action: "reasoning",
526
- value: normalized,
527
- message: `Reasoning level switched to ${formatReasoningLabel(normalized)} (runtime metadata unavailable)`,
528
- };
529
- }
530
518
  return {
531
- action: "unknown",
532
- message: `Unknown reasoning level: ${arg}. Runtime reasoning metadata is unavailable for ${context.runtime.model}.`,
519
+ action: "reasoning",
520
+ value: normalized,
521
+ message: `Reasoning level switched to ${formatReasoningLabel(normalized)} (runtime metadata unavailable; unverified until runtime)`,
533
522
  };
534
523
  }
535
524
 
@@ -857,7 +846,9 @@ export function handleCommand(text: string, context: CommandContext): CommandRes
857
846
  return { action: "help", message: buildHelpMessage(context) };
858
847
 
859
848
  case "update":
860
- return { action: "update", value: normalizedArg || "status" };
849
+ // Bare /update forces a fresh registry check; /update status is the
850
+ // explicit no-network path that reuses the cached/in-memory result.
851
+ return { action: "update", value: normalizedArg || "check" };
861
852
 
862
853
  default:
863
854
  return {
@@ -0,0 +1,65 @@
1
+ import { readFileSync } from "fs";
2
+ import { dirname, join } from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { APP_VERSION as BUILD_INFO_VERSION } from "./buildInfo.js";
5
+
6
+ // Leaf module: must not import settings.ts or anything under src/core/version
7
+ // (settings.ts re-exports APP_VERSION from here, so that would be a cycle).
8
+
9
+ const CODEXA_PACKAGE_NAME = "@golba98/codexa";
10
+ const SEMVER_RE = /^\d+\.\d+\.\d+(-[\w.]+)?$/;
11
+
12
+ function readPackageVersion(packageJsonPath: string, requireName?: string): string | null {
13
+ try {
14
+ const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
15
+ name?: unknown;
16
+ version?: unknown;
17
+ };
18
+ if (requireName !== undefined && parsed.name !== requireName) return null;
19
+ if (typeof parsed.version !== "string") return null;
20
+ const version = parsed.version.trim();
21
+ return SEMVER_RE.test(version) ? version : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Resolves the version of the running Codexa install:
29
+ * 1. `CODEXA_PACKAGE_ROOT` (set by bin/codexa.js) → that package.json's version
30
+ * 2. Walk up from this module for a package.json named "@golba98/codexa" (local dev)
31
+ * 3. The committed buildInfo.ts APP_VERSION as the last resort
32
+ *
33
+ * `startDir` overrides the walk-up starting directory (test seam).
34
+ */
35
+ export function resolveAppVersion(
36
+ env: NodeJS.ProcessEnv = process.env,
37
+ startDir?: string,
38
+ ): string {
39
+ const packageRoot = env.CODEXA_PACKAGE_ROOT?.trim();
40
+ if (packageRoot) {
41
+ const version = readPackageVersion(join(packageRoot, "package.json"));
42
+ if (version) return version;
43
+ }
44
+
45
+ let dir = startDir ?? dirname(fileURLToPath(import.meta.url));
46
+ for (;;) {
47
+ const version = readPackageVersion(join(dir, "package.json"), CODEXA_PACKAGE_NAME);
48
+ if (version) return version;
49
+ const parent = dirname(dir);
50
+ if (parent === dir) break;
51
+ dir = parent;
52
+ }
53
+
54
+ return BUILD_INFO_VERSION;
55
+ }
56
+
57
+ let cachedVersion: string | null = null;
58
+
59
+ /** Cached form of resolveAppVersion() — the installed version cannot change mid-process. */
60
+ export function getAppVersion(): string {
61
+ if (cachedVersion === null) {
62
+ cachedVersion = resolveAppVersion();
63
+ }
64
+ return cachedVersion;
65
+ }
@@ -1,3 +1,3 @@
1
1
  // Generated by scripts/gen-build-info.mjs — do not edit manually.
2
- export const BUILD_COMMIT = "b066a15b77696d2a0945ea7c6c3d07630584296b" as const;
3
- export const APP_VERSION = "1.0.4" as const;
2
+ export const BUILD_COMMIT = "8a275f0a19434d0cf59ff59cebe1aae734559bde" as const;
3
+ export const APP_VERSION = "1.0.6" as const;
@@ -9,7 +9,11 @@ function smartJoin(base: string, ...parts: string[]): string {
9
9
  return isWindowsStylePath(base) ? win32.join(base, ...parts) : join(base, ...parts);
10
10
  }
11
11
 
12
- export { APP_VERSION } from "./buildInfo.js";
12
+ import { getAppVersion } from "./appVersion.js";
13
+
14
+ // Authoritative runtime version: resolved from the installed package.json at
15
+ // startup (buildInfo.ts is only the committed fallback and can drift).
16
+ export const APP_VERSION: string = getAppVersion();
13
17
  export const APP_NAME = "Codexa";
14
18
  export const DEFAULT_BACKEND = "codex-subprocess";
15
19
  export const DEFAULT_MODEL = "gpt-5.4";
@@ -9,11 +9,16 @@ export interface UpdateCheckCache {
9
9
  updateAvailable: boolean;
10
10
  }
11
11
 
12
- const CACHE_FILE = join(homedir(), ".codexa-update-check.json");
12
+ // Resolved per call (not at module load) so HOME changes — e.g. test isolation —
13
+ // are honored. See the same pattern in src/core/models/providerModelCache.ts.
14
+ export function getUpdateCheckCacheFilePath(): string {
15
+ const home = process.env.USERPROFILE ?? process.env.HOME ?? homedir();
16
+ return join(home, ".codexa-update-check.json");
17
+ }
13
18
 
14
- export function loadUpdateCheckCache(): UpdateCheckCache | null {
19
+ export function loadUpdateCheckCache(filePath = getUpdateCheckCacheFilePath()): UpdateCheckCache | null {
15
20
  try {
16
- const text = readFileSync(CACHE_FILE, "utf-8");
21
+ const text = readFileSync(filePath, "utf-8");
17
22
  const data = JSON.parse(text) as Record<string, unknown>;
18
23
  if (typeof data.lastChecked !== "number") return null;
19
24
  if (typeof data.currentVersion !== "string") return null;
@@ -28,12 +33,12 @@ export function loadUpdateCheckCache(): UpdateCheckCache | null {
28
33
  }
29
34
  }
30
35
 
31
- export function saveUpdateCheckCache(cache: UpdateCheckCache): void {
36
+ export function saveUpdateCheckCache(cache: UpdateCheckCache, filePath = getUpdateCheckCacheFilePath()): void {
32
37
  try {
33
- mkdirSync(dirname(CACHE_FILE), { recursive: true });
34
- const tmp = `${CACHE_FILE}.tmp`;
38
+ mkdirSync(dirname(filePath), { recursive: true });
39
+ const tmp = `${filePath}.tmp`;
35
40
  writeFileSync(tmp, JSON.stringify(cache, null, 2), "utf-8");
36
- renameSync(tmp, CACHE_FILE);
41
+ renameSync(tmp, filePath);
37
42
  } catch {
38
43
  // Best-effort — never crash on cache write failure.
39
44
  }
@@ -43,6 +48,11 @@ function stripV(v: string): string {
43
48
  return v.startsWith("v") ? v.slice(1) : v;
44
49
  }
45
50
 
51
+ /** Returns true when a cache entry was created by the running Codexa version. */
52
+ export function isCacheForRunningVersion(cache: UpdateCheckCache, runningVersion: string): boolean {
53
+ return stripV(cache.currentVersion) === stripV(runningVersion);
54
+ }
55
+
46
56
  /**
47
57
  * Returns true only when the cache is still usable:
48
58
  * - `runningVersion` matches the version the cache was written for (version-mismatch = stale)
@@ -54,7 +64,7 @@ export function isCacheValid(
54
64
  runningVersion?: string,
55
65
  ): boolean {
56
66
  if (runningVersion !== undefined) {
57
- if (stripV(cache.currentVersion) !== stripV(runningVersion)) {
67
+ if (!isCacheForRunningVersion(cache, runningVersion)) {
58
68
  return false;
59
69
  }
60
70
  }
@@ -1,6 +1,9 @@
1
1
  import { type ChildProcess } from "child_process";
2
2
  import { APP_NAME, APP_VERSION, DEFAULT_MODEL, LEGACY_FALLBACK_MODELS, formatReasoningLabel } from "../../config/settings.js";
3
3
  import { resolveCodexExecutable, spawnCodexProcess } from "../executables/codexExecutable.js";
4
+ import { loadSeededCodexCapabilities } from "./codexModelsCacheSeed.js";
5
+ import { saveCachedProviderModels } from "./providerModelCache.js";
6
+ import type { ProviderModel } from "../providerRuntime/types.js";
4
7
 
5
8
  export type ModelCapabilitySource = "runtime" | "fallback";
6
9
  export type ModelCapabilityStatus = "ready" | "fallback";
@@ -47,6 +50,8 @@ export interface GetCodexModelCapabilitiesOptions extends DiscoverCodexModelCapa
47
50
  ttlMs?: number;
48
51
  resolveExecutable?: typeof resolveCodexExecutable;
49
52
  discover?: typeof discoverCodexModelCapabilities;
53
+ seed?: typeof loadSeededCodexCapabilities;
54
+ persist?: typeof persistCodexModelCapabilities;
50
55
  }
51
56
 
52
57
  interface JsonRpcResponse {
@@ -439,14 +444,36 @@ export async function discoverCodexModelCapabilities(
439
444
  });
440
445
  }
441
446
 
442
- // Cache chain: TTL-based in-memory cache live discovery fallback model list on error.
443
- // Failed discoveries are evicted from the cache so retries are possible.
447
+ // Persist a successful live discovery so the next launch can seed the model
448
+ // picker without spawning the codex app-server. Best-effort by design.
449
+ export function persistCodexModelCapabilities(capabilities: CodexModelCapabilities): void {
450
+ const models: ProviderModel[] = capabilities.models
451
+ .filter((capability) => !capability.hidden)
452
+ .map((capability) => ({
453
+ id: capability.id,
454
+ modelId: capability.model,
455
+ label: capability.label,
456
+ description: capability.description,
457
+ defaultReasoningLevel: capability.defaultReasoningLevel,
458
+ supportedReasoningLevels: capability.supportedReasoningLevels,
459
+ source: "discovered",
460
+ }));
461
+ saveCachedProviderModels("openai", { discoveredAt: capabilities.discoveredAt, models });
462
+ }
463
+
464
+ // Cache chain: TTL-based in-memory cache → live discovery → seeded local caches
465
+ // (codex's own models_cache.json / Codexa's last-good discovery) → static
466
+ // fallback model list. Failed discoveries are evicted from the in-memory cache
467
+ // so retries are possible even when a seed satisfied the request.
444
468
  export async function getCodexModelCapabilities(
445
469
  options: GetCodexModelCapabilitiesOptions = {},
446
470
  ): Promise<CodexModelCapabilities> {
447
471
  const now = options.now?.() ?? Date.now();
448
472
  const ttlMs = options.ttlMs ?? DEFAULT_CACHE_TTL_MS;
473
+ const seed = options.seed ?? loadSeededCodexCapabilities;
474
+ const persist = options.persist ?? persistCodexModelCapabilities;
449
475
  let executable: string | null = null;
476
+ let liveDiscoverySucceeded = false;
450
477
 
451
478
  try {
452
479
  executable = options.executable ?? await (options.resolveExecutable ?? resolveCodexExecutable)();
@@ -462,7 +489,25 @@ export async function getCodexModelCapabilities(
462
489
  includeHidden: options.includeHidden,
463
490
  timeoutMs: options.timeoutMs,
464
491
  now: () => now,
465
- }).catch((error) => createFallbackModelCapabilities(error, { discoveredAt: now, executable }));
492
+ }).then((discovered) => {
493
+ liveDiscoverySucceeded = true;
494
+ try {
495
+ persist(discovered);
496
+ } catch {
497
+ // Persistence must never fail a successful discovery.
498
+ }
499
+ return discovered;
500
+ }).catch((error) => {
501
+ try {
502
+ const seeded = seed();
503
+ if (seeded) {
504
+ return seeded;
505
+ }
506
+ } catch {
507
+ // Seed read failures fall through to the static list.
508
+ }
509
+ return createFallbackModelCapabilities(error, { discoveredAt: now, executable });
510
+ });
466
511
 
467
512
  capabilityCache.set(cacheKey, {
468
513
  expiresAt: now + ttlMs,
@@ -470,12 +515,20 @@ export async function getCodexModelCapabilities(
470
515
  });
471
516
 
472
517
  const result = await promise;
473
- if (result.status === "fallback") {
518
+ if (!liveDiscoverySucceeded) {
474
519
  capabilityCache.delete(cacheKey);
475
520
  }
476
521
 
477
522
  return result;
478
523
  } catch (error) {
524
+ try {
525
+ const seeded = seed();
526
+ if (seeded) {
527
+ return seeded;
528
+ }
529
+ } catch {
530
+ // Seed read failures fall through to the static list.
531
+ }
479
532
  return createFallbackModelCapabilities(error, { discoveredAt: now, executable });
480
533
  }
481
534
  }
@@ -0,0 +1,153 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { formatReasoningLabel } from "../../config/settings.js";
5
+ import type { ProviderModel } from "../providerRuntime/types.js";
6
+ import type {
7
+ CodexModelCapabilities,
8
+ CodexModelCapability,
9
+ ReasoningEffortCapability,
10
+ } from "./codexModelCapabilities.js";
11
+ import { loadCachedProviderModels, type CachedProviderModels } from "./providerModelCache.js";
12
+
13
+ // The codex CLI maintains its own model catalog cache with slugs, labels and
14
+ // reasoning levels. Reading it seeds Codexa's OpenAI model list instantly —
15
+ // no subprocess — and stays current because the codex CLI refreshes the file
16
+ // on its own runs.
17
+ // Resolved per call from env (matching claudeCodeDiscovery) so HOME
18
+ // redirection in tests holds — Bun's homedir() ignores runtime HOME changes.
19
+ export function getCodexModelsCacheFile(): string {
20
+ const home = process.env.USERPROFILE ?? process.env.HOME ?? homedir();
21
+ return join(home, ".codex", "models_cache.json");
22
+ }
23
+
24
+ interface CodexSeed {
25
+ fetchedAt: number;
26
+ models: readonly ProviderModel[];
27
+ }
28
+
29
+ function isRecord(value: unknown): value is Record<string, unknown> {
30
+ return typeof value === "object" && value !== null && !Array.isArray(value);
31
+ }
32
+
33
+ function asString(value: unknown): string | null {
34
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
35
+ }
36
+
37
+ function parseReasoningLevels(raw: unknown): readonly ReasoningEffortCapability[] | null {
38
+ if (!Array.isArray(raw)) {
39
+ return null;
40
+ }
41
+ const levels = raw
42
+ .map((entry): ReasoningEffortCapability | null => {
43
+ if (!isRecord(entry)) {
44
+ return null;
45
+ }
46
+ const id = asString(entry.effort);
47
+ if (!id) {
48
+ return null;
49
+ }
50
+ return { id, label: formatReasoningLabel(id), description: asString(entry.description) };
51
+ })
52
+ .filter((entry): entry is ReasoningEffortCapability => entry !== null);
53
+ return levels.length > 0 ? levels : null;
54
+ }
55
+
56
+ function parseSeedModel(raw: unknown): ProviderModel | null {
57
+ if (!isRecord(raw)) {
58
+ return null;
59
+ }
60
+ // Models with visibility "hide" are internal codex routes (e.g. auto-review).
61
+ if (asString(raw.visibility) === "hide") {
62
+ return null;
63
+ }
64
+ const modelId = asString(raw.slug);
65
+ if (!modelId) {
66
+ return null;
67
+ }
68
+ return {
69
+ id: modelId,
70
+ modelId,
71
+ label: asString(raw.display_name) ?? modelId,
72
+ description: asString(raw.description),
73
+ defaultReasoningLevel: asString(raw.default_reasoning_level),
74
+ supportedReasoningLevels: parseReasoningLevels(raw.supported_reasoning_levels),
75
+ source: "discovered",
76
+ };
77
+ }
78
+
79
+ export function loadCodexSeedModels(cacheFile = getCodexModelsCacheFile()): CodexSeed | null {
80
+ try {
81
+ if (!existsSync(cacheFile)) {
82
+ return null;
83
+ }
84
+ const parsed: unknown = JSON.parse(readFileSync(cacheFile, "utf8"));
85
+ if (!isRecord(parsed) || !Array.isArray(parsed.models)) {
86
+ return null;
87
+ }
88
+ const models = parsed.models
89
+ .map(parseSeedModel)
90
+ .filter((model): model is ProviderModel => model !== null);
91
+ if (models.length === 0) {
92
+ return null;
93
+ }
94
+ const fetchedAtRaw = asString(parsed.fetched_at);
95
+ const fetchedAt = fetchedAtRaw ? Date.parse(fetchedAtRaw) : Number.NaN;
96
+ return { fetchedAt: Number.isFinite(fetchedAt) ? fetchedAt : 0, models };
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ // Freshest locally known OpenAI models without spawning a subprocess:
103
+ // codex's own cache file vs Codexa's persisted last-good discovery.
104
+ export function loadSeededOpenAiModels(options: {
105
+ codexCacheFile?: string;
106
+ providerCacheFile?: string;
107
+ } = {}): CachedProviderModels | null {
108
+ const seed = loadCodexSeedModels(options.codexCacheFile);
109
+ const persisted = options.providerCacheFile === undefined
110
+ ? loadCachedProviderModels("openai")
111
+ : loadCachedProviderModels("openai", options.providerCacheFile);
112
+ if (seed && (!persisted || seed.fetchedAt >= persisted.discoveredAt)) {
113
+ return { discoveredAt: seed.fetchedAt, models: seed.models };
114
+ }
115
+ return persisted;
116
+ }
117
+
118
+ function toCapability(model: ProviderModel, index: number): CodexModelCapability {
119
+ return {
120
+ id: model.id,
121
+ model: model.modelId,
122
+ label: model.label,
123
+ description: model.description,
124
+ available: true,
125
+ hidden: false,
126
+ isDefault: index === 0,
127
+ defaultReasoningLevel: model.defaultReasoningLevel,
128
+ supportedReasoningLevels: model.supportedReasoningLevels,
129
+ reasoningLevelCount: model.supportedReasoningLevels ? model.supportedReasoningLevels.length : null,
130
+ source: "runtime",
131
+ raw: model,
132
+ };
133
+ }
134
+
135
+ // Capabilities for the OpenAI picker sourced purely from local caches.
136
+ // Returns null when nothing is cached yet (first ever launch).
137
+ export function loadSeededCodexCapabilities(options: {
138
+ codexCacheFile?: string;
139
+ providerCacheFile?: string;
140
+ } = {}): CodexModelCapabilities | null {
141
+ const seeded = loadSeededOpenAiModels(options);
142
+ if (!seeded) {
143
+ return null;
144
+ }
145
+ return {
146
+ status: "ready",
147
+ source: "runtime",
148
+ models: seeded.models.map(toCapability),
149
+ discoveredAt: seeded.discoveredAt,
150
+ executable: null,
151
+ error: null,
152
+ };
153
+ }
@@ -0,0 +1,90 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { ProviderId } from "../providerLauncher/types.js";
5
+ import type { ProviderModel } from "../providerRuntime/types.js";
6
+
7
+ // Persistent last-good model discovery results, one entry per provider.
8
+ // Lets pickers open instantly with the previous session's discovered models
9
+ // while a background refresh runs. Corrupt or missing cache degrades to null.
10
+ // Resolved per call from env (matching claudeCodeDiscovery) so HOME
11
+ // redirection in tests holds — Bun's homedir() ignores runtime HOME changes.
12
+ export function getProviderModelCacheFile(): string {
13
+ const home = process.env.USERPROFILE ?? process.env.HOME ?? homedir();
14
+ return join(home, ".codexa-model-cache.json");
15
+ }
16
+
17
+ const CACHE_VERSION = 1;
18
+
19
+ export interface CachedProviderModels {
20
+ discoveredAt: number;
21
+ models: readonly ProviderModel[];
22
+ }
23
+
24
+ interface ProviderModelCacheFile {
25
+ version: number;
26
+ providers: Partial<Record<ProviderId, CachedProviderModels>>;
27
+ }
28
+
29
+ function readCacheFile(cacheFile: string): ProviderModelCacheFile | null {
30
+ try {
31
+ if (!existsSync(cacheFile)) {
32
+ return null;
33
+ }
34
+ const parsed: unknown = JSON.parse(readFileSync(cacheFile, "utf8"));
35
+ if (
36
+ typeof parsed !== "object" || parsed === null || Array.isArray(parsed)
37
+ || (parsed as ProviderModelCacheFile).version !== CACHE_VERSION
38
+ || typeof (parsed as ProviderModelCacheFile).providers !== "object"
39
+ || (parsed as ProviderModelCacheFile).providers === null
40
+ ) {
41
+ return null;
42
+ }
43
+ return parsed as ProviderModelCacheFile;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ function isValidEntry(entry: unknown): entry is CachedProviderModels {
50
+ if (typeof entry !== "object" || entry === null) {
51
+ return false;
52
+ }
53
+ const candidate = entry as CachedProviderModels;
54
+ return typeof candidate.discoveredAt === "number"
55
+ && Array.isArray(candidate.models)
56
+ && candidate.models.every((model) =>
57
+ typeof model === "object" && model !== null
58
+ && typeof model.id === "string"
59
+ && typeof model.modelId === "string"
60
+ && typeof model.label === "string");
61
+ }
62
+
63
+ export function loadCachedProviderModels(
64
+ providerId: ProviderId,
65
+ cacheFile = getProviderModelCacheFile(),
66
+ ): CachedProviderModels | null {
67
+ const cache = readCacheFile(cacheFile);
68
+ const entry = cache?.providers?.[providerId];
69
+ if (!entry || !isValidEntry(entry) || entry.models.length === 0) {
70
+ return null;
71
+ }
72
+ return entry;
73
+ }
74
+
75
+ export function saveCachedProviderModels(
76
+ providerId: ProviderId,
77
+ entry: CachedProviderModels,
78
+ cacheFile = getProviderModelCacheFile(),
79
+ ): void {
80
+ if (entry.models.length === 0) {
81
+ return;
82
+ }
83
+ try {
84
+ const cache = readCacheFile(cacheFile) ?? { version: CACHE_VERSION, providers: {} };
85
+ cache.providers[providerId] = entry;
86
+ writeFileSync(cacheFile, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
87
+ } catch {
88
+ // Persistence is best-effort; discovery still works without it.
89
+ }
90
+ }
@@ -9,6 +9,7 @@ export interface CommandSpec {
9
9
  cwd: string;
10
10
  env?: NodeJS.ProcessEnv;
11
11
  timeoutMs?: number;
12
+ stdinData?: string;
12
13
  }
13
14
 
14
15
  export interface CommandResult {
@@ -177,10 +178,20 @@ function runProcess(
177
178
  cwd: spec.cwd,
178
179
  env: spec.env,
179
180
  shell: false,
180
- stdio: ["ignore", "pipe", "pipe"],
181
+ stdio: [spec.stdinData !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
181
182
  });
182
183
  handlers.onProcessLifecycle?.("spawned");
183
184
 
185
+ if (spec.stdinData !== undefined) {
186
+ try {
187
+ child.stdin?.on("error", () => { /* EPIPE when the process exits before reading stdin */ });
188
+ child.stdin?.write(spec.stdinData);
189
+ child.stdin?.end();
190
+ } catch {
191
+ // stdin already closed; the close/error handlers report the outcome
192
+ }
193
+ }
194
+
184
195
  const result = new Promise<CommandResult>((resolve) => {
185
196
  const finish = (partial: Omit<CommandResult, "stdout" | "stderr" | "startedAt" | "endedAt" | "durationMs" | "userMessage"> & { endedAt?: number }) => {
186
197
  if (timeoutHandle) clearTimeout(timeoutHandle);