@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.
- package/README.md +6 -10
- package/package.json +1 -1
- package/src/app.tsx +188 -121
- package/src/commands/handler.ts +11 -20
- package/src/config/appVersion.ts +65 -0
- package/src/config/buildInfo.ts +2 -2
- package/src/config/settings.ts +5 -1
- package/src/config/updateCheckCache.ts +18 -8
- package/src/core/models/codexModelCapabilities.ts +57 -4
- package/src/core/models/codexModelsCacheSeed.ts +153 -0
- package/src/core/models/providerModelCache.ts +90 -0
- package/src/core/process/CommandRunner.ts +12 -1
- package/src/core/providerLauncher/registry.ts +37 -7
- package/src/core/providerLauncher/types.ts +2 -1
- package/src/core/providerRuntime/anthropic.ts +3 -5
- package/src/core/providerRuntime/antigravity.ts +257 -90
- package/src/core/providerRuntime/claudeCodeDiscovery.ts +724 -692
- package/src/core/providerRuntime/mistralVibe.ts +663 -0
- package/src/core/providerRuntime/models.ts +18 -4
- package/src/core/providerRuntime/reasoning.ts +9 -6
- package/src/core/providerRuntime/registry.ts +93 -44
- package/src/core/providerRuntime/types.ts +1 -0
- package/src/core/version/packageManager.ts +119 -0
- package/src/core/version/updateCheck.ts +15 -9
- package/src/index.tsx +23 -9
- package/src/ui/{ActivityBars.tsx → chrome/ActivityBars.tsx} +1 -1
- package/src/ui/{ActivityIndicator.tsx → chrome/ActivityIndicator.tsx} +3 -3
- package/src/ui/{AnimatedStatusText.tsx → chrome/AnimatedStatusText.tsx} +3 -3
- package/src/ui/{AppShell.tsx → chrome/AppShell.tsx} +11 -11
- package/src/ui/{BottomComposer.tsx → chrome/BottomComposer.tsx} +15 -15
- package/src/ui/{DashCard.tsx → chrome/DashCard.tsx} +2 -2
- package/src/ui/{RunFooter.tsx → chrome/RunFooter.tsx} +3 -3
- package/src/ui/{RuntimeStatusBar.tsx → chrome/RuntimeStatusBar.tsx} +3 -3
- package/src/ui/{Spinner.tsx → chrome/Spinner.tsx} +1 -1
- package/src/ui/{TopHeader.tsx → chrome/TopHeader.tsx} +15 -13
- package/src/ui/{UpdateAvailableCard.tsx → chrome/UpdateAvailableCard.tsx} +6 -5
- package/src/ui/{commandNormalize.ts → input/commandNormalize.ts} +1 -1
- package/src/ui/{focus.ts → input/focus.ts} +1 -1
- package/src/ui/{inputBuffer.ts → input/inputBuffer.ts} +3 -3
- package/src/ui/{slashCommands.ts → input/slashCommands.ts} +1 -0
- package/src/ui/{AttachmentImportPanel.tsx → panels/AttachmentImportPanel.tsx} +1 -1
- package/src/ui/{AuthPanel.tsx → panels/AuthPanel.tsx} +5 -5
- package/src/ui/{BackendPicker.tsx → panels/BackendPicker.tsx} +2 -2
- package/src/ui/{ModePicker.tsx → panels/ModePicker.tsx} +2 -2
- package/src/ui/{ModelPicker.tsx → panels/ModelPicker.tsx} +2 -2
- package/src/ui/{ModelPickerScreen.tsx → panels/ModelPickerScreen.tsx} +11 -9
- package/src/ui/{ModelReasoningPicker.tsx → panels/ModelReasoningPicker.tsx} +5 -5
- package/src/ui/{Panel.tsx → panels/Panel.tsx} +1 -1
- package/src/ui/{PermissionsPanel.tsx → panels/PermissionsPanel.tsx} +3 -3
- package/src/ui/{PlanActionPicker.tsx → panels/PlanActionPicker.tsx} +2 -2
- package/src/ui/{PlanReviewPanel.tsx → panels/PlanReviewPanel.tsx} +5 -5
- package/src/ui/{ProviderPicker.tsx → panels/ProviderPicker.tsx} +9 -7
- package/src/ui/{ReasoningPicker.tsx → panels/ReasoningPicker.tsx} +3 -3
- package/src/ui/{SelectionPanel.tsx → panels/SelectionPanel.tsx} +1 -1
- package/src/ui/{SettingsPanel.tsx → panels/SettingsPanel.tsx} +3 -3
- package/src/ui/{TextEntryPanel.tsx → panels/TextEntryPanel.tsx} +3 -3
- package/src/ui/{ThemePicker.tsx → panels/ThemePicker.tsx} +2 -2
- package/src/ui/{UpdatePromptPanel.tsx → panels/UpdatePromptPanel.tsx} +54 -44
- package/src/ui/{Markdown.tsx → render/Markdown.tsx} +2 -2
- package/src/ui/{diffRenderer.ts → render/diffRenderer.ts} +1 -1
- package/src/ui/{modeDisplay.ts → render/modeDisplay.ts} +2 -2
- package/src/ui/{outputPipeline.ts → render/outputPipeline.ts} +2 -2
- package/src/ui/{runtimeDisplay.ts → render/runtimeDisplay.ts} +9 -8
- package/src/ui/{ActionRequiredBlock.tsx → timeline/ActionRequiredBlock.tsx} +3 -3
- package/src/ui/{AgentBlock.tsx → timeline/AgentBlock.tsx} +10 -10
- package/src/ui/{StaticIntroItem.tsx → timeline/StaticIntroItem.tsx} +2 -2
- package/src/ui/{ThinkingBlock.tsx → timeline/ThinkingBlock.tsx} +4 -4
- package/src/ui/{Timeline.tsx → timeline/Timeline.tsx} +8 -8
- package/src/ui/{TranscriptShell.tsx → timeline/TranscriptShell.tsx} +6 -6
- package/src/ui/{TurnGroup.tsx → timeline/TurnGroup.tsx} +15 -15
- package/src/ui/{progressEntries.ts → timeline/progressEntries.ts} +3 -3
- package/src/ui/{runActivityView.ts → timeline/runActivityView.ts} +1 -1
- package/src/ui/{timelineMeasure.ts → timeline/timelineMeasure.ts} +14 -14
- /package/src/ui/{busyStatusAnimation.ts → chrome/busyStatusAnimation.ts} +0 -0
- /package/src/ui/{logoVariants.ts → render/logoVariants.ts} +0 -0
- /package/src/ui/{terminalAnswerFormat.ts → render/terminalAnswerFormat.ts} +0 -0
- /package/src/ui/{textLayout.ts → render/textLayout.ts} +0 -0
- /package/src/ui/{layoutListWindow.ts → timeline/layoutListWindow.ts} +0 -0
package/src/commands/handler.ts
CHANGED
|
@@ -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 [
|
|
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: "
|
|
532
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/config/buildInfo.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Generated by scripts/gen-build-info.mjs — do not edit manually.
|
|
2
|
-
export const BUILD_COMMIT = "
|
|
3
|
-
export const APP_VERSION = "1.0.
|
|
2
|
+
export const BUILD_COMMIT = "8a275f0a19434d0cf59ff59cebe1aae734559bde" as const;
|
|
3
|
+
export const APP_VERSION = "1.0.6" as const;
|
package/src/config/settings.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
34
|
-
const 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,
|
|
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 (
|
|
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
|
-
//
|
|
443
|
-
//
|
|
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
|
-
}).
|
|
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 (
|
|
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);
|