Merge pull request #400 from AutoMaker-Org/feat/codex-usage

feat: improve codex plan and usage detection
This commit is contained in:
Shirone
2026-01-10 15:29:33 +00:00
committed by GitHub
19 changed files with 1134 additions and 463 deletions

3
.gitignore vendored
View File

@@ -73,6 +73,9 @@ blob-report/
!.env.example
!.env.local.example
# Codex config (contains API keys)
.codex/config.toml
# TypeScript
*.tsbuildinfo

View File

@@ -55,6 +55,8 @@ import { createClaudeRoutes } from './routes/claude/index.js';
import { ClaudeUsageService } from './services/claude-usage-service.js';
import { createCodexRoutes } from './routes/codex/index.js';
import { CodexUsageService } from './services/codex-usage-service.js';
import { CodexAppServerService } from './services/codex-app-server-service.js';
import { CodexModelCacheService } from './services/codex-model-cache-service.js';
import { createGitHubRoutes } from './routes/github/index.js';
import { createContextRoutes } from './routes/context/index.js';
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
@@ -168,7 +170,9 @@ const agentService = new AgentService(DATA_DIR, events, settingsService);
const featureLoader = new FeatureLoader();
const autoModeService = new AutoModeService(events, settingsService);
const claudeUsageService = new ClaudeUsageService();
const codexUsageService = new CodexUsageService();
const codexAppServerService = new CodexAppServerService();
const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService);
const codexUsageService = new CodexUsageService(codexAppServerService);
const mcpTestService = new MCPTestService(settingsService);
const ideationService = new IdeationService(events, settingsService, featureLoader);
@@ -176,6 +180,11 @@ const ideationService = new IdeationService(events, settingsService, featureLoad
(async () => {
await agentService.initialize();
logger.info('Agent service initialized');
// Bootstrap Codex model cache in background (don't block server startup)
void codexModelCacheService.getModels().catch((err) => {
logger.error('Failed to bootstrap Codex model cache:', err);
});
})();
// Run stale validation cleanup every hour to prevent memory leaks from crashed validations
@@ -219,7 +228,7 @@ app.use('/api/templates', createTemplatesRoutes());
app.use('/api/terminal', createTerminalRoutes());
app.use('/api/settings', createSettingsRoutes(settingsService));
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
app.use('/api/codex', createCodexRoutes(codexUsageService));
app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService));
app.use('/api/github', createGitHubRoutes(events, settingsService));
app.use('/api/context', createContextRoutes(settingsService));
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));

View File

@@ -5,9 +5,11 @@
* Never assumes authenticated - only returns true if CLI confirms.
*/
import { spawnProcess, getCodexAuthPath } from '@automaker/platform';
import { spawnProcess } from '@automaker/platform';
import { findCodexCliPath } from '@automaker/platform';
import * as fs from 'fs';
import { createLogger } from '@automaker/utils';
const logger = createLogger('CodexAuth');
const CODEX_COMMAND = 'codex';
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
@@ -26,36 +28,16 @@ export interface CodexAuthCheckResult {
export async function checkCodexAuthentication(
cliPath?: string | null
): Promise<CodexAuthCheckResult> {
console.log('[CodexAuth] checkCodexAuthentication called with cliPath:', cliPath);
const resolvedCliPath = cliPath || (await findCodexCliPath());
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
console.log('[CodexAuth] resolvedCliPath:', resolvedCliPath);
console.log('[CodexAuth] hasApiKey:', hasApiKey);
// Debug: Check auth file
const authFilePath = getCodexAuthPath();
console.log('[CodexAuth] Auth file path:', authFilePath);
try {
const authFileExists = fs.existsSync(authFilePath);
console.log('[CodexAuth] Auth file exists:', authFileExists);
if (authFileExists) {
const authContent = fs.readFileSync(authFilePath, 'utf-8');
console.log('[CodexAuth] Auth file content:', authContent.substring(0, 500)); // First 500 chars
}
} catch (error) {
console.log('[CodexAuth] Error reading auth file:', error);
}
// If CLI is not installed, cannot be authenticated
if (!resolvedCliPath) {
console.log('[CodexAuth] No CLI path found, returning not authenticated');
logger.info('CLI not found');
return { authenticated: false, method: 'none' };
}
try {
console.log('[CodexAuth] Running: ' + resolvedCliPath + ' login status');
const result = await spawnProcess({
command: resolvedCliPath || CODEX_COMMAND,
args: ['login', 'status'],
@@ -66,33 +48,21 @@ export async function checkCodexAuthentication(
},
});
console.log('[CodexAuth] Command result:');
console.log('[CodexAuth] exitCode:', result.exitCode);
console.log('[CodexAuth] stdout:', JSON.stringify(result.stdout));
console.log('[CodexAuth] stderr:', JSON.stringify(result.stderr));
// Check both stdout and stderr for "logged in" - Codex CLI outputs to stderr
const combinedOutput = (result.stdout + result.stderr).toLowerCase();
const isLoggedIn = combinedOutput.includes('logged in');
console.log('[CodexAuth] isLoggedIn (contains "logged in" in stdout or stderr):', isLoggedIn);
if (result.exitCode === 0 && isLoggedIn) {
// Determine auth method based on what we know
const method = hasApiKey ? 'api_key_env' : 'cli_authenticated';
console.log('[CodexAuth] Authenticated! method:', method);
logger.info(`✓ Authenticated (${method})`);
return { authenticated: true, method };
}
console.log(
'[CodexAuth] Not authenticated. exitCode:',
result.exitCode,
'isLoggedIn:',
isLoggedIn
);
logger.info('Not authenticated');
return { authenticated: false, method: 'none' };
} catch (error) {
console.log('[CodexAuth] Error running command:', error);
logger.error('Failed to check authentication:', error);
return { authenticated: false, method: 'none' };
}
console.log('[CodexAuth] Returning not authenticated');
return { authenticated: false, method: 'none' };
}

View File

@@ -21,6 +21,7 @@ import {
extractTextFromContent,
classifyError,
getUserFriendlyErrorMessage,
createLogger,
} from '@automaker/utils';
import type {
ExecuteOptions,
@@ -658,6 +659,8 @@ async function loadCodexInstructions(cwd: string, enabled: boolean): Promise<str
.join('\n\n');
}
const logger = createLogger('CodexProvider');
export class CodexProvider extends BaseProvider {
getName(): string {
return 'codex';
@@ -967,21 +970,11 @@ export class CodexProvider extends BaseProvider {
}
async detectInstallation(): Promise<InstallationStatus> {
console.log('[CodexProvider.detectInstallation] Starting...');
const cliPath = await findCodexCliPath();
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
const authIndicators = await getCodexAuthIndicators();
const installed = !!cliPath;
console.log('[CodexProvider.detectInstallation] cliPath:', cliPath);
console.log('[CodexProvider.detectInstallation] hasApiKey:', hasApiKey);
console.log(
'[CodexProvider.detectInstallation] authIndicators:',
JSON.stringify(authIndicators)
);
console.log('[CodexProvider.detectInstallation] installed:', installed);
let version = '';
if (installed) {
try {
@@ -991,20 +984,16 @@ export class CodexProvider extends BaseProvider {
cwd: process.cwd(),
});
version = result.stdout.trim();
console.log('[CodexProvider.detectInstallation] version:', version);
} catch (error) {
console.log('[CodexProvider.detectInstallation] Error getting version:', error);
version = '';
}
}
// Determine auth status - always verify with CLI, never assume authenticated
console.log('[CodexProvider.detectInstallation] Calling checkCodexAuthentication...');
const authCheck = await checkCodexAuthentication(cliPath);
console.log('[CodexProvider.detectInstallation] authCheck result:', JSON.stringify(authCheck));
const authenticated = authCheck.authenticated;
const result = {
return {
installed,
path: cliPath || undefined,
version: version || undefined,
@@ -1012,8 +1001,6 @@ export class CodexProvider extends BaseProvider {
hasApiKey,
authenticated,
};
console.log('[CodexProvider.detectInstallation] Final result:', JSON.stringify(result));
return result;
}
getAvailableModels(): ModelDefinition[] {
@@ -1025,36 +1012,24 @@ export class CodexProvider extends BaseProvider {
* Check authentication status for Codex CLI
*/
async checkAuth(): Promise<CodexAuthStatus> {
console.log('[CodexProvider.checkAuth] Starting auth check...');
const cliPath = await findCodexCliPath();
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
const authIndicators = await getCodexAuthIndicators();
console.log('[CodexProvider.checkAuth] cliPath:', cliPath);
console.log('[CodexProvider.checkAuth] hasApiKey:', hasApiKey);
console.log('[CodexProvider.checkAuth] authIndicators:', JSON.stringify(authIndicators));
// Check for API key in environment
if (hasApiKey) {
console.log('[CodexProvider.checkAuth] Has API key, returning authenticated');
return { authenticated: true, method: 'api_key' };
}
// Check for OAuth/token from Codex CLI
if (authIndicators.hasOAuthToken || authIndicators.hasApiKey) {
console.log(
'[CodexProvider.checkAuth] Has OAuth token or API key in auth file, returning authenticated'
);
return { authenticated: true, method: 'oauth' };
}
// CLI is installed but not authenticated via indicators - try CLI command
console.log('[CodexProvider.checkAuth] No indicators found, trying CLI command...');
if (cliPath) {
try {
// Try 'codex login status' first (same as checkCodexAuthentication)
console.log('[CodexProvider.checkAuth] Running: ' + cliPath + ' login status');
const result = await spawnProcess({
command: cliPath || CODEX_COMMAND,
args: ['login', 'status'],
@@ -1064,26 +1039,19 @@ export class CodexProvider extends BaseProvider {
TERM: 'dumb',
},
});
console.log('[CodexProvider.checkAuth] login status result:');
console.log('[CodexProvider.checkAuth] exitCode:', result.exitCode);
console.log('[CodexProvider.checkAuth] stdout:', JSON.stringify(result.stdout));
console.log('[CodexProvider.checkAuth] stderr:', JSON.stringify(result.stderr));
// Check both stdout and stderr - Codex CLI outputs to stderr
const combinedOutput = (result.stdout + result.stderr).toLowerCase();
const isLoggedIn = combinedOutput.includes('logged in');
console.log('[CodexProvider.checkAuth] isLoggedIn:', isLoggedIn);
if (result.exitCode === 0 && isLoggedIn) {
console.log('[CodexProvider.checkAuth] CLI says logged in, returning authenticated');
return { authenticated: true, method: 'oauth' };
}
} catch (error) {
console.log('[CodexProvider.checkAuth] Error running login status:', error);
logger.warn('Error running login status command during auth check:', error);
}
}
console.log('[CodexProvider.checkAuth] Not authenticated');
return { authenticated: false, method: 'none' };
}

View File

@@ -1,17 +1,21 @@
import { Router, Request, Response } from 'express';
import { CodexUsageService } from '../../services/codex-usage-service.js';
import { CodexModelCacheService } from '../../services/codex-model-cache-service.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('Codex');
export function createCodexRoutes(service: CodexUsageService): Router {
export function createCodexRoutes(
usageService: CodexUsageService,
modelCacheService: CodexModelCacheService
): Router {
const router = Router();
// Get current usage (attempts to fetch from Codex CLI)
router.get('/usage', async (req: Request, res: Response) => {
router.get('/usage', async (_req: Request, res: Response) => {
try {
// Check if Codex CLI is available first
const isAvailable = await service.isAvailable();
const isAvailable = await usageService.isAvailable();
if (!isAvailable) {
// IMPORTANT: This endpoint is behind Automaker session auth already.
// Use a 200 + error payload for Codex CLI issues so the UI doesn't
@@ -23,7 +27,7 @@ export function createCodexRoutes(service: CodexUsageService): Router {
return;
}
const usage = await service.fetchUsageData();
const usage = await usageService.fetchUsageData();
res.json(usage);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
@@ -52,5 +56,35 @@ export function createCodexRoutes(service: CodexUsageService): Router {
}
});
// Get available Codex models (cached)
router.get('/models', async (req: Request, res: Response) => {
try {
const forceRefresh = req.query.refresh === 'true';
const { models, cachedAt } = await modelCacheService.getModelsWithMetadata(forceRefresh);
if (models.length === 0) {
res.status(503).json({
success: false,
error: 'Codex CLI not available or not authenticated',
message: "Please install Codex CLI and run 'codex login' to authenticate",
});
return;
}
res.json({
success: true,
models,
cachedAt,
});
} catch (error) {
logger.error('Error fetching models:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({
success: false,
error: message,
});
}
});
return router;
}

View File

@@ -0,0 +1,212 @@
import { spawn, type ChildProcess } from 'child_process';
import readline from 'readline';
import { findCodexCliPath } from '@automaker/platform';
import { createLogger } from '@automaker/utils';
import type {
AppServerModelResponse,
AppServerAccountResponse,
AppServerRateLimitsResponse,
JsonRpcRequest,
} from '@automaker/types';
const logger = createLogger('CodexAppServer');
/**
* CodexAppServerService
*
* Centralized service for communicating with Codex CLI's app-server via JSON-RPC protocol.
* Handles process spawning, JSON-RPC messaging, and cleanup.
*
* Connection strategy: Spawn on-demand (new process for each method call)
*/
export class CodexAppServerService {
private cachedCliPath: string | null = null;
/**
* Check if Codex CLI is available on the system
*/
async isAvailable(): Promise<boolean> {
this.cachedCliPath = await findCodexCliPath();
return Boolean(this.cachedCliPath);
}
/**
* Fetch available models from app-server
*/
async getModels(): Promise<AppServerModelResponse | null> {
const result = await this.executeJsonRpc<AppServerModelResponse>((sendRequest) => {
return sendRequest('model/list', {});
});
if (result) {
logger.info(`[getModels] ✓ Fetched ${result.data.length} models`);
}
return result;
}
/**
* Fetch account information from app-server
*/
async getAccount(): Promise<AppServerAccountResponse | null> {
return this.executeJsonRpc<AppServerAccountResponse>((sendRequest) => {
return sendRequest('account/read', { refreshToken: false });
});
}
/**
* Fetch rate limits from app-server
*/
async getRateLimits(): Promise<AppServerRateLimitsResponse | null> {
return this.executeJsonRpc<AppServerRateLimitsResponse>((sendRequest) => {
return sendRequest('account/rateLimits/read', {});
});
}
/**
* Execute JSON-RPC requests via Codex app-server
*
* This method:
* 1. Spawns a new `codex app-server` process
* 2. Handles JSON-RPC initialization handshake
* 3. Executes user-provided requests
* 4. Cleans up the process
*
* @param requestFn - Function that receives sendRequest helper and returns a promise
* @returns Result of the JSON-RPC request or null on failure
*/
private async executeJsonRpc<T>(
requestFn: (sendRequest: <R>(method: string, params?: unknown) => Promise<R>) => Promise<T>
): Promise<T | null> {
let childProcess: ChildProcess | null = null;
try {
const cliPath = this.cachedCliPath || (await findCodexCliPath());
if (!cliPath) {
return null;
}
// On Windows, .cmd files must be run through shell
const needsShell = process.platform === 'win32' && cliPath.toLowerCase().endsWith('.cmd');
childProcess = spawn(cliPath, ['app-server'], {
cwd: process.cwd(),
env: {
...process.env,
TERM: 'dumb',
},
stdio: ['pipe', 'pipe', 'pipe'],
shell: needsShell,
});
if (!childProcess.stdin || !childProcess.stdout) {
throw new Error('Failed to create stdio pipes');
}
// Setup readline for reading JSONL responses
const rl = readline.createInterface({
input: childProcess.stdout,
crlfDelay: Infinity,
});
// Message ID counter for JSON-RPC
let messageId = 0;
const pendingRequests = new Map<
number,
{
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}
>();
// Process incoming messages
rl.on('line', (line) => {
if (!line.trim()) return;
try {
const message = JSON.parse(line);
// Handle response to our request
if ('id' in message && message.id !== undefined) {
const pending = pendingRequests.get(message.id);
if (pending) {
clearTimeout(pending.timeout);
pendingRequests.delete(message.id);
if (message.error) {
pending.reject(new Error(message.error.message || 'Unknown error'));
} else {
pending.resolve(message.result);
}
}
}
// Ignore notifications (no id field)
} catch {
// Ignore parse errors for non-JSON lines
}
});
// Helper to send JSON-RPC request and wait for response
const sendRequest = <R>(method: string, params?: unknown): Promise<R> => {
return new Promise((resolve, reject) => {
const id = ++messageId;
const request: JsonRpcRequest = {
method,
id,
params: params ?? {},
};
// Set timeout for request (10 seconds)
const timeout = setTimeout(() => {
pendingRequests.delete(id);
reject(new Error(`Request timeout: ${method}`));
}, 10000);
pendingRequests.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
timeout,
});
childProcess!.stdin!.write(JSON.stringify(request) + '\n');
});
};
// Helper to send notification (no response expected)
const sendNotification = (method: string, params?: unknown): void => {
const notification = params ? { method, params } : { method };
childProcess!.stdin!.write(JSON.stringify(notification) + '\n');
};
// 1. Initialize the app-server
await sendRequest('initialize', {
clientInfo: {
name: 'automaker',
title: 'AutoMaker',
version: '1.0.0',
},
});
// 2. Send initialized notification
sendNotification('initialized');
// 3. Execute user-provided requests
const result = await requestFn(sendRequest);
// Clean up
rl.close();
childProcess.kill('SIGTERM');
return result;
} catch (error) {
logger.error('[executeJsonRpc] Failed:', error);
return null;
} finally {
// Ensure process is killed
if (childProcess && !childProcess.killed) {
childProcess.kill('SIGTERM');
}
}
}
}

View File

@@ -0,0 +1,258 @@
import path from 'path';
import { secureFs } from '@automaker/platform';
import { createLogger } from '@automaker/utils';
import type { AppServerModel } from '@automaker/types';
import type { CodexAppServerService } from './codex-app-server-service.js';
const logger = createLogger('CodexModelCache');
/**
* Codex model with UI-compatible format
*/
export interface CodexModel {
id: string;
label: string;
description: string;
hasThinking: boolean;
supportsVision: boolean;
tier: 'premium' | 'standard' | 'basic';
isDefault: boolean;
}
/**
* Cache structure stored on disk
*/
interface CodexModelCache {
models: CodexModel[];
cachedAt: number;
ttl: number;
}
/**
* CodexModelCacheService
*
* Caches Codex models fetched from app-server with TTL-based invalidation and disk persistence.
*
* Features:
* - 1-hour TTL (configurable)
* - Atomic file writes (temp file + rename)
* - Thread-safe (deduplicates concurrent refresh requests)
* - Auto-bootstrap on service creation
* - Graceful fallback (returns empty array on errors)
*/
export class CodexModelCacheService {
private cacheFilePath: string;
private ttl: number;
private appServerService: CodexAppServerService;
private inFlightRefresh: Promise<CodexModel[]> | null = null;
constructor(
dataDir: string,
appServerService: CodexAppServerService,
ttl: number = 3600000 // 1 hour default
) {
this.cacheFilePath = path.join(dataDir, 'codex-models-cache.json');
this.ttl = ttl;
this.appServerService = appServerService;
}
/**
* Get models from cache or fetch if stale
*
* @param forceRefresh - If true, bypass cache and fetch fresh data
* @returns Array of Codex models (empty array if unavailable)
*/
async getModels(forceRefresh = false): Promise<CodexModel[]> {
// If force refresh, skip cache
if (forceRefresh) {
return this.refreshModels();
}
// Try to load from cache
const cached = await this.loadFromCache();
if (cached) {
const age = Date.now() - cached.cachedAt;
const isStale = age > cached.ttl;
if (!isStale) {
logger.info(
`[getModels] ✓ Using cached models (${cached.models.length} models, age: ${Math.round(age / 60000)}min)`
);
return cached.models;
}
}
// Cache is stale or missing, refresh
return this.refreshModels();
}
/**
* Get models with cache metadata
*
* @param forceRefresh - If true, bypass cache and fetch fresh data
* @returns Object containing models and cache timestamp
*/
async getModelsWithMetadata(
forceRefresh = false
): Promise<{ models: CodexModel[]; cachedAt: number }> {
const models = await this.getModels(forceRefresh);
// Try to get the actual cache timestamp
const cached = await this.loadFromCache();
const cachedAt = cached?.cachedAt ?? Date.now();
return { models, cachedAt };
}
/**
* Refresh models from app-server and update cache
*
* Thread-safe: Deduplicates concurrent refresh requests
*/
async refreshModels(): Promise<CodexModel[]> {
// Deduplicate concurrent refresh requests
if (this.inFlightRefresh) {
return this.inFlightRefresh;
}
// Start new refresh
this.inFlightRefresh = this.doRefresh();
try {
const models = await this.inFlightRefresh;
return models;
} finally {
this.inFlightRefresh = null;
}
}
/**
* Clear the cache file
*/
async clearCache(): Promise<void> {
logger.info('[clearCache] Clearing cache...');
try {
await secureFs.unlink(this.cacheFilePath);
logger.info('[clearCache] Cache cleared');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error('[clearCache] Failed to clear cache:', error);
}
}
}
/**
* Internal method to perform the actual refresh
*/
private async doRefresh(): Promise<CodexModel[]> {
try {
// Check if app-server is available
const isAvailable = await this.appServerService.isAvailable();
if (!isAvailable) {
return [];
}
// Fetch models from app-server
const response = await this.appServerService.getModels();
if (!response || !response.data) {
return [];
}
// Transform models to UI format
const models = response.data.map((model) => this.transformModel(model));
// Save to cache
await this.saveToCache(models);
logger.info(`[refreshModels] ✓ Fetched fresh models (${models.length} models)`);
return models;
} catch (error) {
logger.error('[doRefresh] Refresh failed:', error);
return [];
}
}
/**
* Transform app-server model to UI-compatible format
*/
private transformModel(appServerModel: AppServerModel): CodexModel {
return {
id: `codex-${appServerModel.id}`, // Add 'codex-' prefix for compatibility
label: appServerModel.displayName,
description: appServerModel.description,
hasThinking: appServerModel.supportedReasoningEfforts.length > 0,
supportsVision: true, // All Codex models support vision
tier: this.inferTier(appServerModel.id),
isDefault: appServerModel.isDefault,
};
}
/**
* Infer tier from model ID
*/
private inferTier(modelId: string): 'premium' | 'standard' | 'basic' {
if (modelId.includes('max') || modelId.includes('gpt-5.2-codex')) {
return 'premium';
}
if (modelId.includes('mini')) {
return 'basic';
}
return 'standard';
}
/**
* Load cache from disk
*/
private async loadFromCache(): Promise<CodexModelCache | null> {
try {
const content = await secureFs.readFile(this.cacheFilePath, 'utf-8');
const cache = JSON.parse(content.toString()) as CodexModelCache;
// Validate cache structure
if (!Array.isArray(cache.models) || typeof cache.cachedAt !== 'number') {
logger.warn('[loadFromCache] Invalid cache structure, ignoring');
return null;
}
return cache;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.warn('[loadFromCache] Failed to read cache:', error);
}
return null;
}
}
/**
* Save cache to disk (atomic write)
*/
private async saveToCache(models: CodexModel[]): Promise<void> {
const cache: CodexModelCache = {
models,
cachedAt: Date.now(),
ttl: this.ttl,
};
const tempPath = `${this.cacheFilePath}.tmp.${Date.now()}`;
try {
// Write to temp file
const content = JSON.stringify(cache, null, 2);
await secureFs.writeFile(tempPath, content, 'utf-8');
// Atomic rename
await secureFs.rename(tempPath, this.cacheFilePath);
} catch (error) {
logger.error('[saveToCache] Failed to save cache:', error);
// Clean up temp file
try {
await secureFs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
}
}
}

View File

@@ -1,11 +1,11 @@
import {
findCodexCliPath,
spawnProcess,
getCodexAuthPath,
systemPathExists,
systemPathReadFile,
} from '@automaker/platform';
import { createLogger } from '@automaker/utils';
import type { CodexAppServerService } from './codex-app-server-service.js';
const logger = createLogger('CodexUsage');
@@ -18,19 +18,12 @@ export interface CodexRateLimitWindow {
resetsAt: number;
}
export interface CodexCreditsSnapshot {
balance?: string;
unlimited?: boolean;
hasCredits?: boolean;
}
export type CodexPlanType = 'free' | 'plus' | 'pro' | 'team' | 'enterprise' | 'edu' | 'unknown';
export interface CodexUsageData {
rateLimits: {
primary?: CodexRateLimitWindow;
secondary?: CodexRateLimitWindow;
credits?: CodexCreditsSnapshot;
planType?: CodexPlanType;
} | null;
lastUpdated: string;
@@ -39,13 +32,24 @@ export interface CodexUsageData {
/**
* Codex Usage Service
*
* Attempts to fetch usage data from Codex CLI and OpenAI API.
* Codex CLI doesn't provide a direct usage command, but we can:
* 1. Parse usage info from error responses (rate limit errors contain plan info)
* 2. Check for OpenAI API usage if API key is available
* Fetches usage data from Codex CLI using the app-server JSON-RPC API.
* Falls back to auth file parsing if app-server is unavailable.
*/
export class CodexUsageService {
private cachedCliPath: string | null = null;
private appServerService: CodexAppServerService | null = null;
private accountPlanTypeArray: CodexPlanType[] = [
'free',
'plus',
'pro',
'team',
'enterprise',
'edu',
];
constructor(appServerService?: CodexAppServerService) {
this.appServerService = appServerService || null;
}
/**
* Check if Codex CLI is available on the system
@@ -58,60 +62,131 @@ export class CodexUsageService {
/**
* Attempt to fetch usage data
*
* Tries multiple approaches:
* 1. Always try to get plan type from auth file first (authoritative source)
* 2. Check for OpenAI API key in environment for API usage
* 3. Make a test request to capture rate limit headers from CLI
* 4. Combine results from auth file and CLI
* Priority order:
* 1. Codex app-server JSON-RPC API (most reliable, provides real-time data)
* 2. Auth file JWT parsing (fallback for plan type)
*/
async fetchUsageData(): Promise<CodexUsageData> {
logger.info('[fetchUsageData] Starting...');
const cliPath = this.cachedCliPath || (await findCodexCliPath());
if (!cliPath) {
logger.error('[fetchUsageData] Codex CLI not found');
throw new Error('Codex CLI not found. Please install it with: npm install -g @openai/codex');
}
// Always try to get plan type from auth file first - this is the authoritative source
const authPlanType = await this.getPlanTypeFromAuthFile();
logger.info(`[fetchUsageData] Using CLI path: ${cliPath}`);
// Check if user has an API key that we can use
const hasApiKey = !!process.env.OPENAI_API_KEY;
if (hasApiKey) {
// Try to get usage from OpenAI API
const openaiUsage = await this.fetchOpenAIUsage();
if (openaiUsage) {
// Merge with auth file plan type if available
if (authPlanType && openaiUsage.rateLimits) {
openaiUsage.rateLimits.planType = authPlanType;
}
return openaiUsage;
}
// Try to get usage from Codex app-server (most reliable method)
const appServerUsage = await this.fetchFromAppServer();
if (appServerUsage) {
logger.info('[fetchUsageData] ✓ Fetched usage from app-server');
return appServerUsage;
}
// Try to get usage from Codex CLI by making a simple request
const codexUsage = await this.fetchCodexUsage(cliPath, authPlanType);
if (codexUsage) {
return codexUsage;
}
logger.info('[fetchUsageData] App-server failed, trying auth file fallback...');
// Fallback: try to parse full usage from auth file
// Fallback: try to parse usage from auth file
const authUsage = await this.fetchFromAuthFile();
if (authUsage) {
logger.info('[fetchUsageData] ✓ Fetched usage from auth file');
return authUsage;
}
// If all else fails, return a message with helpful information
throw new Error(
'Codex usage statistics require additional configuration. ' +
'To enable usage tracking:\n\n' +
'1. Set your OpenAI API key in the environment:\n' +
' export OPENAI_API_KEY=sk-...\n\n' +
'2. Or check your usage at:\n' +
' https://platform.openai.com/usage\n\n' +
'Note: If using Codex CLI with ChatGPT OAuth authentication, ' +
'usage data must be queried through your OpenAI account.'
);
logger.info('[fetchUsageData] All methods failed, returning unknown');
// If all else fails, return unknown
return {
rateLimits: {
planType: 'unknown',
},
lastUpdated: new Date().toISOString(),
};
}
/**
* Fetch usage data from Codex app-server using JSON-RPC API
* This is the most reliable method as it gets real-time data from OpenAI
*/
private async fetchFromAppServer(): Promise<CodexUsageData | null> {
try {
// Use CodexAppServerService if available
if (!this.appServerService) {
return null;
}
// Fetch account and rate limits in parallel
const [accountResult, rateLimitsResult] = await Promise.all([
this.appServerService.getAccount(),
this.appServerService.getRateLimits(),
]);
if (!accountResult) {
return null;
}
// Build response
// Prefer planType from rateLimits (more accurate/current) over account (can be stale)
let planType: CodexPlanType = 'unknown';
// First try rate limits planType (most accurate)
const rateLimitsPlanType = rateLimitsResult?.rateLimits?.planType;
if (rateLimitsPlanType) {
const normalizedType = rateLimitsPlanType.toLowerCase() as CodexPlanType;
if (this.accountPlanTypeArray.includes(normalizedType)) {
planType = normalizedType;
}
}
// Fall back to account planType if rate limits didn't have it
if (planType === 'unknown' && accountResult.account?.planType) {
const normalizedType = accountResult.account.planType.toLowerCase() as CodexPlanType;
if (this.accountPlanTypeArray.includes(normalizedType)) {
planType = normalizedType;
}
}
const result: CodexUsageData = {
rateLimits: {
planType,
},
lastUpdated: new Date().toISOString(),
};
// Add rate limit info if available
if (rateLimitsResult?.rateLimits?.primary) {
const primary = rateLimitsResult.rateLimits.primary;
result.rateLimits!.primary = {
limit: -1, // Not provided by API
used: -1, // Not provided by API
remaining: -1, // Not provided by API
usedPercent: primary.usedPercent,
windowDurationMins: primary.windowDurationMins,
resetsAt: primary.resetsAt,
};
}
// Add secondary rate limit if available
if (rateLimitsResult?.rateLimits?.secondary) {
const secondary = rateLimitsResult.rateLimits.secondary;
result.rateLimits!.secondary = {
limit: -1, // Not provided by API
used: -1, // Not provided by API
remaining: -1, // Not provided by API
usedPercent: secondary.usedPercent,
windowDurationMins: secondary.windowDurationMins,
resetsAt: secondary.resetsAt,
};
}
logger.info(
`[fetchFromAppServer] ✓ Plan: ${planType}, Primary: ${result.rateLimits?.primary?.usedPercent || 'N/A'}%, Secondary: ${result.rateLimits?.secondary?.usedPercent || 'N/A'}%`
);
return result;
} catch (error) {
logger.error('[fetchFromAppServer] Failed:', error);
return null;
}
}
/**
@@ -121,9 +196,11 @@ export class CodexUsageService {
private async getPlanTypeFromAuthFile(): Promise<CodexPlanType> {
try {
const authFilePath = getCodexAuthPath();
const exists = await systemPathExists(authFilePath);
logger.info(`[getPlanTypeFromAuthFile] Auth file path: ${authFilePath}`);
const exists = systemPathExists(authFilePath);
if (!exists) {
logger.warn('[getPlanTypeFromAuthFile] Auth file does not exist');
return 'unknown';
}
@@ -131,16 +208,24 @@ export class CodexUsageService {
const authData = JSON.parse(authContent);
if (!authData.tokens?.id_token) {
logger.info('[getPlanTypeFromAuthFile] No id_token in auth file');
return 'unknown';
}
const claims = this.parseJwt(authData.tokens.id_token);
if (!claims) {
logger.info('[getPlanTypeFromAuthFile] Failed to parse JWT');
return 'unknown';
}
logger.info('[getPlanTypeFromAuthFile] JWT claims keys:', Object.keys(claims));
// Extract plan type from nested OpenAI auth object with type validation
const openaiAuthClaim = claims['https://api.openai.com/auth'];
logger.info(
'[getPlanTypeFromAuthFile] OpenAI auth claim:',
JSON.stringify(openaiAuthClaim, null, 2)
);
let accountType: string | undefined;
let isSubscriptionExpired = false;
@@ -188,154 +273,23 @@ export class CodexUsageService {
}
if (accountType) {
const normalizedType = accountType.toLowerCase();
if (['free', 'plus', 'pro', 'team', 'enterprise', 'edu'].includes(normalizedType)) {
return normalizedType as CodexPlanType;
}
}
} catch (error) {
logger.error('Failed to get plan type from auth file:', error);
}
return 'unknown';
}
/**
* Try to fetch usage from OpenAI API using the API key
*/
private async fetchOpenAIUsage(): Promise<CodexUsageData | null> {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
return null;
}
try {
const endTime = Math.floor(Date.now() / 1000);
const startTime = endTime - 7 * 24 * 60 * 60; // Last 7 days
const response = await fetch(
`https://api.openai.com/v1/organization/usage/completions?start_time=${startTime}&end_time=${endTime}&limit=1`,
{
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
}
);
if (response.ok) {
const data = await response.json();
return this.parseOpenAIUsage(data);
}
} catch (error) {
logger.error('Failed to fetch from OpenAI API:', error);
}
return null;
}
/**
* Parse OpenAI usage API response
*/
private parseOpenAIUsage(data: any): CodexUsageData {
let totalInputTokens = 0;
let totalOutputTokens = 0;
if (data.data && Array.isArray(data.data)) {
for (const bucket of data.data) {
if (bucket.results && Array.isArray(bucket.results)) {
for (const result of bucket.results) {
totalInputTokens += result.input_tokens || 0;
totalOutputTokens += result.output_tokens || 0;
}
}
}
}
return {
rateLimits: {
planType: 'unknown',
credits: {
hasCredits: true,
},
},
lastUpdated: new Date().toISOString(),
};
}
/**
* Try to fetch usage by making a test request to Codex CLI
* and parsing rate limit information from the response
*/
private async fetchCodexUsage(
cliPath: string,
authPlanType: CodexPlanType
): Promise<CodexUsageData | null> {
try {
// Make a simple request to trigger rate limit info if at limit
const result = await spawnProcess({
command: cliPath,
args: ['exec', '--', 'echo', 'test'],
cwd: process.cwd(),
env: {
...process.env,
TERM: 'dumb',
},
timeout: 10000,
});
// Parse the output for rate limit information
const combinedOutput = (result.stdout + result.stderr).toLowerCase();
// Check if we got a rate limit error
const rateLimitMatch = combinedOutput.match(
/usage_limit_reached.*?"plan_type":"([^"]+)".*?"resets_at":(\d+).*?"resets_in_seconds":(\d+)/
);
if (rateLimitMatch) {
// Rate limit error contains the plan type - use that as it's the most authoritative
const planType = rateLimitMatch[1] as CodexPlanType;
const resetsAt = parseInt(rateLimitMatch[2], 10);
const resetsInSeconds = parseInt(rateLimitMatch[3], 10);
const normalizedType = accountType.toLowerCase() as CodexPlanType;
logger.info(
`Rate limit hit - plan: ${planType}, resets in ${Math.ceil(resetsInSeconds / 60)} mins`
`[getPlanTypeFromAuthFile] Account type: "${accountType}", normalized: "${normalizedType}"`
);
return {
rateLimits: {
planType,
primary: {
limit: 0,
used: 0,
remaining: 0,
usedPercent: 100,
windowDurationMins: Math.ceil(resetsInSeconds / 60),
resetsAt,
},
},
lastUpdated: new Date().toISOString(),
};
if (this.accountPlanTypeArray.includes(normalizedType)) {
logger.info(`[getPlanTypeFromAuthFile] Returning plan type: ${normalizedType}`);
return normalizedType;
}
} else {
logger.info('[getPlanTypeFromAuthFile] No account type found in claims');
}
// No rate limit error - use the plan type from auth file
const isFreePlan = authPlanType === 'free';
return {
rateLimits: {
planType: authPlanType,
credits: {
hasCredits: true,
unlimited: !isFreePlan && authPlanType !== 'unknown',
},
},
lastUpdated: new Date().toISOString(),
};
} catch (error) {
logger.error('Failed to fetch from Codex CLI:', error);
logger.error('[getPlanTypeFromAuthFile] Failed to get plan type from auth file:', error);
}
return null;
logger.info('[getPlanTypeFromAuthFile] Returning unknown');
return 'unknown';
}
/**
@@ -343,27 +297,27 @@ export class CodexUsageService {
* Reuses getPlanTypeFromAuthFile to avoid code duplication
*/
private async fetchFromAuthFile(): Promise<CodexUsageData | null> {
logger.info('[fetchFromAuthFile] Starting...');
try {
const planType = await this.getPlanTypeFromAuthFile();
logger.info(`[fetchFromAuthFile] Got plan type: ${planType}`);
if (planType === 'unknown') {
logger.info('[fetchFromAuthFile] Plan type unknown, returning null');
return null;
}
const isFreePlan = planType === 'free';
return {
const result: CodexUsageData = {
rateLimits: {
planType,
credits: {
hasCredits: true,
unlimited: !isFreePlan,
},
},
lastUpdated: new Date().toISOString(),
};
logger.info('[fetchFromAuthFile] Returning result:', JSON.stringify(result, null, 2));
return result;
} catch (error) {
logger.error('Failed to parse auth file:', error);
logger.error('[fetchFromAuthFile] Failed to parse auth file:', error);
}
return null;
@@ -372,7 +326,7 @@ export class CodexUsageService {
/**
* Parse JWT token to extract claims
*/
private parseJwt(token: string): any {
private parseJwt(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.');
@@ -383,18 +337,8 @@ export class CodexUsageService {
const base64Url = parts[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
// Use Buffer for Node.js environment instead of atob
let jsonPayload: string;
if (typeof Buffer !== 'undefined') {
jsonPayload = Buffer.from(base64, 'base64').toString('utf-8');
} else {
jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
}
// Use Buffer for Node.js environment
const jsonPayload = Buffer.from(base64, 'base64').toString('utf-8');
return JSON.parse(jsonPayload);
} catch {

View File

@@ -9,7 +9,9 @@ import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@automaker/types';
import type { ModelProvider } from '@automaker/types';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, ModelOption } from './model-constants';
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
import { useEffect } from 'react';
import { RefreshCw } from 'lucide-react';
interface ModelSelectorProps {
selectedModel: string; // Can be ModelAlias or "cursor-{id}"
@@ -22,7 +24,14 @@ export function ModelSelector({
onModelSelect,
testIdPrefix = 'model-select',
}: ModelSelectorProps) {
const { enabledCursorModels, cursorDefaultModel } = useAppStore();
const {
enabledCursorModels,
cursorDefaultModel,
codexModels,
codexModelsLoading,
codexModelsError,
fetchCodexModels,
} = useAppStore();
const { cursorCliStatus, codexCliStatus } = useSetupStore();
const selectedProvider = getModelProvider(selectedModel);
@@ -33,6 +42,31 @@ export function ModelSelector({
// Check if Codex CLI is available
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated;
// Fetch Codex models on mount
useEffect(() => {
if (isCodexAvailable && codexModels.length === 0 && !codexModelsLoading) {
fetchCodexModels();
}
}, [isCodexAvailable, codexModels.length, codexModelsLoading, fetchCodexModels]);
// Transform codex models from store to ModelOption format
const dynamicCodexModels: ModelOption[] = codexModels.map((model) => {
// Infer badge based on tier
let badge: string | undefined;
if (model.tier === 'premium') badge = 'Premium';
else if (model.tier === 'basic') badge = 'Speed';
else if (model.tier === 'standard') badge = 'Balanced';
return {
id: model.id,
label: model.label,
description: model.description,
badge,
provider: 'codex' as ModelProvider,
hasThinking: model.hasThinking,
};
});
// Filter Cursor models based on enabled models from global settings
const filteredCursorModels = CURSOR_MODELS.filter((model) => {
// Extract the cursor model ID from the prefixed ID (e.g., "cursor-auto" -> "auto")
@@ -45,8 +79,10 @@ export function ModelSelector({
// Switch to Cursor's default model (from global settings)
onModelSelect(`${PROVIDER_PREFIXES.cursor}${cursorDefaultModel}`);
} else if (provider === 'codex' && selectedProvider !== 'codex') {
// Switch to Codex's default model (codex-gpt-5.2-codex)
onModelSelect('codex-gpt-5.2-codex');
// Switch to Codex's default model (use isDefault flag from dynamic models)
const defaultModel = codexModels.find((m) => m.isDefault);
const defaultModelId = defaultModel?.id || codexModels[0]?.id || 'codex-gpt-5.2-codex';
onModelSelect(defaultModelId);
} else if (provider === 'claude' && selectedProvider !== 'claude') {
// Switch to Claude's default model
onModelSelect('sonnet');
@@ -234,41 +270,91 @@ export function ModelSelector({
CLI
</span>
</div>
<div className="flex flex-col gap-2">
{CODEX_MODELS.map((option) => {
const isSelected = selectedModel === option.id;
return (
{/* Loading state */}
{codexModelsLoading && dynamicCodexModels.length === 0 && (
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
<RefreshCw className="w-4 h-4 animate-spin" />
Loading models...
</div>
)}
{/* Error state */}
{codexModelsError && !codexModelsLoading && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
<AlertTriangle className="w-4 h-4 text-red-400 mt-0.5 shrink-0" />
<div className="space-y-1">
<div className="text-sm text-red-400">Failed to load Codex models</div>
<button
key={option.id}
type="button"
onClick={() => onModelSelect(option.id)}
title={option.description}
className={cn(
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
isSelected
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`${testIdPrefix}-${option.id}`}
onClick={() => fetchCodexModels(true)}
className="text-xs text-red-400 underline hover:no-underline"
>
<span>{option.label}</span>
{option.badge && (
<Badge
variant="outline"
className={cn(
'text-xs',
isSelected
? 'border-primary-foreground/50 text-primary-foreground'
: 'border-muted-foreground/50 text-muted-foreground'
)}
>
{option.badge}
</Badge>
)}
Retry
</button>
);
})}
</div>
</div>
</div>
)}
{/* Model list */}
{!codexModelsLoading && !codexModelsError && dynamicCodexModels.length === 0 && (
<div className="text-sm text-muted-foreground p-3 border border-dashed rounded-md text-center">
No Codex models available
</div>
)}
{!codexModelsLoading && dynamicCodexModels.length > 0 && (
<div className="flex flex-col gap-2">
{dynamicCodexModels.map((option) => {
const isSelected = selectedModel === option.id;
return (
<button
key={option.id}
type="button"
onClick={() => onModelSelect(option.id)}
title={option.description}
className={cn(
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
isSelected
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`${testIdPrefix}-${option.id}`}
>
<span>{option.label}</span>
<div className="flex gap-1">
{option.hasThinking && (
<Badge
variant="outline"
className={cn(
'text-xs',
isSelected
? 'border-primary-foreground/50 text-primary-foreground'
: 'border-emerald-500/50 text-emerald-600 dark:text-emerald-400'
)}
>
Thinking
</Badge>
)}
{option.badge && (
<Badge
variant="outline"
className={cn(
'text-xs',
isSelected
? 'border-primary-foreground/50 text-primary-foreground'
: 'border-muted-foreground/50 text-muted-foreground'
)}
>
{option.badge}
</Badge>
)}
</div>
</button>
);
})}
</div>
)}
</div>
)}
</div>

View File

@@ -6,7 +6,6 @@ import { OpenAIIcon } from '@/components/ui/provider-icon';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import {
formatCodexCredits,
formatCodexPlanType,
formatCodexResetTime,
getCodexWindowLabel,
@@ -25,7 +24,6 @@ const UPDATED_LABEL = 'Updated';
const CODEX_FETCH_ERROR = 'Failed to fetch usage';
const CODEX_REFRESH_LABEL = 'Refresh Codex usage';
const PLAN_LABEL = 'Plan';
const CREDITS_LABEL = 'Credits';
const WARNING_THRESHOLD = 75;
const CAUTION_THRESHOLD = 50;
const MAX_PERCENTAGE = 100;
@@ -49,7 +47,6 @@ export function CodexUsageSection() {
const rateLimits = codexUsage?.rateLimits ?? null;
const primary = rateLimits?.primary ?? null;
const secondary = rateLimits?.secondary ?? null;
const credits = rateLimits?.credits ?? null;
const planType = rateLimits?.planType ?? null;
const rateLimitWindows = [primary, secondary].filter(isRateLimitWindow);
const hasMetrics = rateLimitWindows.length > 0;
@@ -206,20 +203,11 @@ export function CodexUsageSection() {
})}
</div>
)}
{(planType || credits) && (
{planType && (
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
{planType && (
<div>
{PLAN_LABEL}:{' '}
<span className="text-foreground">{formatCodexPlanType(planType)}</span>
</div>
)}
{credits && (
<div>
{CREDITS_LABEL}:{' '}
<span className="text-foreground">{formatCodexCredits(credits)}</span>
</div>
)}
<div>
{PLAN_LABEL}: <span className="text-foreground">{formatCodexPlanType(planType)}</span>
</div>
</div>
)}
{!hasMetrics && !error && canFetchUsage && !isLoading && (

View File

@@ -1,4 +1,4 @@
import * as React from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import type {
@@ -8,8 +8,6 @@ import type {
OpencodeModelId,
GroupedModel,
PhaseModelEntry,
ThinkingLevel,
ReasoningEffort,
} from '@automaker/types';
import {
stripProviderPrefix,
@@ -17,13 +15,11 @@ import {
getModelGroup,
isGroupSelected,
getSelectedVariant,
isCursorModel,
codexModelHasThinking,
} from '@automaker/types';
import {
CLAUDE_MODELS,
CURSOR_MODELS,
CODEX_MODELS,
OPENCODE_MODELS,
THINKING_LEVELS,
THINKING_LEVEL_LABELS,
@@ -73,23 +69,39 @@ export function PhaseModelSelector({
align = 'end',
disabled = false,
}: PhaseModelSelectorProps) {
const [open, setOpen] = React.useState(false);
const [expandedGroup, setExpandedGroup] = React.useState<string | null>(null);
const [expandedClaudeModel, setExpandedClaudeModel] = React.useState<ModelAlias | null>(null);
const [expandedCodexModel, setExpandedCodexModel] = React.useState<CodexModelId | null>(null);
const commandListRef = React.useRef<HTMLDivElement>(null);
const expandedTriggerRef = React.useRef<HTMLDivElement>(null);
const expandedClaudeTriggerRef = React.useRef<HTMLDivElement>(null);
const expandedCodexTriggerRef = React.useRef<HTMLDivElement>(null);
const { enabledCursorModels, favoriteModels, toggleFavoriteModel } = useAppStore();
const [open, setOpen] = useState(false);
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
const [expandedClaudeModel, setExpandedClaudeModel] = useState<ModelAlias | null>(null);
const [expandedCodexModel, setExpandedCodexModel] = useState<CodexModelId | null>(null);
const commandListRef = useRef<HTMLDivElement>(null);
const expandedTriggerRef = useRef<HTMLDivElement>(null);
const expandedClaudeTriggerRef = useRef<HTMLDivElement>(null);
const expandedCodexTriggerRef = useRef<HTMLDivElement>(null);
const {
enabledCursorModels,
favoriteModels,
toggleFavoriteModel,
codexModels,
codexModelsLoading,
fetchCodexModels,
} = useAppStore();
// Extract model and thinking/reasoning levels from value
const selectedModel = value.model;
const selectedThinkingLevel = value.thinkingLevel || 'none';
const selectedReasoningEffort = value.reasoningEffort || 'none';
// Fetch Codex models on mount
useEffect(() => {
if (codexModels.length === 0 && !codexModelsLoading) {
fetchCodexModels().catch(() => {
// Silently fail - user will see empty Codex section
});
}
}, [codexModels.length, codexModelsLoading, fetchCodexModels]);
// Close expanded group when trigger scrolls out of view
React.useEffect(() => {
useEffect(() => {
const triggerElement = expandedTriggerRef.current;
const listElement = commandListRef.current;
if (!triggerElement || !listElement || !expandedGroup) return;
@@ -112,7 +124,7 @@ export function PhaseModelSelector({
}, [expandedGroup]);
// Close expanded Claude model popover when trigger scrolls out of view
React.useEffect(() => {
useEffect(() => {
const triggerElement = expandedClaudeTriggerRef.current;
const listElement = commandListRef.current;
if (!triggerElement || !listElement || !expandedClaudeModel) return;
@@ -135,7 +147,7 @@ export function PhaseModelSelector({
}, [expandedClaudeModel]);
// Close expanded Codex model popover when trigger scrolls out of view
React.useEffect(() => {
useEffect(() => {
const triggerElement = expandedCodexTriggerRef.current;
const listElement = commandListRef.current;
if (!triggerElement || !listElement || !expandedCodexModel) return;
@@ -157,6 +169,17 @@ export function PhaseModelSelector({
return () => observer.disconnect();
}, [expandedCodexModel]);
// Transform dynamic Codex models from store to component format
const transformedCodexModels = useMemo(() => {
return codexModels.map((model) => ({
id: model.id,
label: model.label,
description: model.description,
provider: 'codex' as const,
badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Speed' : undefined,
}));
}, [codexModels]);
// Filter Cursor models to only show enabled ones
const availableCursorModels = CURSOR_MODELS.filter((model) => {
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
@@ -164,7 +187,7 @@ export function PhaseModelSelector({
});
// Helper to find current selected model details
const currentModel = React.useMemo(() => {
const currentModel = useMemo(() => {
const claudeModel = CLAUDE_MODELS.find((m) => m.id === selectedModel);
if (claudeModel) {
// Add thinking level to label if not 'none'
@@ -198,7 +221,7 @@ export function PhaseModelSelector({
}
// Check Codex models
const codexModel = CODEX_MODELS.find((m) => m.id === selectedModel);
const codexModel = transformedCodexModels.find((m) => m.id === selectedModel);
if (codexModel) return { ...codexModel, icon: OpenAIIcon };
// Check OpenCode models
@@ -206,10 +229,10 @@ export function PhaseModelSelector({
if (opencodeModel) return { ...opencodeModel, icon: OpenCodeIcon };
return null;
}, [selectedModel, selectedThinkingLevel, availableCursorModels]);
}, [selectedModel, selectedThinkingLevel, availableCursorModels, transformedCodexModels]);
// Compute grouped vs standalone Cursor models
const { groupedModels, standaloneCursorModels } = React.useMemo(() => {
const { groupedModels, standaloneCursorModels } = useMemo(() => {
const grouped: GroupedModel[] = [];
const standalone: typeof CURSOR_MODELS = [];
const seenGroups = new Set<string>();
@@ -242,11 +265,11 @@ export function PhaseModelSelector({
}, [availableCursorModels, enabledCursorModels]);
// Group models
const { favorites, claude, cursor, codex, opencode } = React.useMemo(() => {
const { favorites, claude, cursor, codex, opencode } = useMemo(() => {
const favs: typeof CLAUDE_MODELS = [];
const cModels: typeof CLAUDE_MODELS = [];
const curModels: typeof CURSOR_MODELS = [];
const codModels: typeof CODEX_MODELS = [];
const codModels: typeof transformedCodexModels = [];
const ocModels: typeof OPENCODE_MODELS = [];
// Process Claude Models
@@ -268,7 +291,7 @@ export function PhaseModelSelector({
});
// Process Codex Models
CODEX_MODELS.forEach((model) => {
transformedCodexModels.forEach((model) => {
if (favoriteModels.includes(model.id)) {
favs.push(model);
} else {
@@ -292,10 +315,10 @@ export function PhaseModelSelector({
codex: codModels,
opencode: ocModels,
};
}, [favoriteModels, availableCursorModels]);
}, [favoriteModels, availableCursorModels, transformedCodexModels]);
// Render Codex model item with secondary popover for reasoning effort (only for models that support it)
const renderCodexModelItem = (model: (typeof CODEX_MODELS)[0]) => {
const renderCodexModelItem = (model: (typeof transformedCodexModels)[0]) => {
const isSelected = selectedModel === model.id;
const isFavorite = favoriteModels.includes(model.id);
const hasReasoning = codexModelHasThinking(model.id as CodexModelId);
@@ -919,7 +942,7 @@ export function PhaseModelSelector({
}
// Codex model
if (model.provider === 'codex') {
return renderCodexModelItem(model);
return renderCodexModelItem(model as (typeof transformedCodexModels)[0]);
}
// OpenCode model
if (model.provider === 'opencode') {

View File

@@ -1,12 +1,8 @@
import { type CodexCreditsSnapshot, type CodexPlanType } from '@/store/app-store';
import { type CodexPlanType } from '@/store/app-store';
const WINDOW_DEFAULT_LABEL = 'Usage window';
const RESET_LABEL = 'Resets';
const UNKNOWN_LABEL = 'Unknown';
const UNAVAILABLE_LABEL = 'Unavailable';
const UNLIMITED_LABEL = 'Unlimited';
const AVAILABLE_LABEL = 'Available';
const NONE_LABEL = 'None';
const DAY_UNIT = 'day';
const HOUR_UNIT = 'hour';
const MINUTE_UNIT = 'min';
@@ -77,10 +73,3 @@ export function formatCodexPlanType(plan: CodexPlanType | null): string {
if (!plan) return UNKNOWN_LABEL;
return PLAN_TYPE_LABELS[plan] ?? plan;
}
export function formatCodexCredits(snapshot: CodexCreditsSnapshot | null): string {
if (!snapshot) return UNAVAILABLE_LABEL;
if (snapshot.unlimited) return UNLIMITED_LABEL;
if (snapshot.balance) return snapshot.balance;
return snapshot.hasCredits ? AVAILABLE_LABEL : NONE_LABEL;
}

View File

@@ -727,6 +727,20 @@ export interface ElectronAPI {
ideation?: IdeationAPI;
codex?: {
getUsage: () => Promise<CodexUsageResponse>;
getModels: (refresh?: boolean) => Promise<{
success: boolean;
models?: Array<{
id: string;
label: string;
description: string;
hasThinking: boolean;
supportsVision: boolean;
tier: 'premium' | 'standard' | 'basic';
isDefault: boolean;
}>;
cachedAt?: number;
error?: string;
}>;
};
settings?: {
getStatus: () => Promise<{

View File

@@ -2053,6 +2053,25 @@ export class HttpApiClient implements ElectronAPI {
// Codex API
codex = {
getUsage: (): Promise<CodexUsageResponse> => this.get('/api/codex/usage'),
getModels: (
refresh = false
): Promise<{
success: boolean;
models?: Array<{
id: string;
label: string;
description: string;
hasThinking: boolean;
supportsVision: boolean;
tier: 'premium' | 'standard' | 'basic';
isDefault: boolean;
}>;
cachedAt?: number;
error?: string;
}> => {
const url = `/api/codex/models${refresh ? '?refresh=true' : ''}`;
return this.get(url);
},
};
// Context API

View File

@@ -68,8 +68,9 @@ function RootLayoutContent() {
getEffectiveTheme,
skipSandboxWarning,
setSkipSandboxWarning,
fetchCodexModels,
} = useAppStore();
const { setupComplete } = useSetupStore();
const { setupComplete, codexCliStatus } = useSetupStore();
const navigate = useNavigate();
const [isMounted, setIsMounted] = useState(false);
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
@@ -431,6 +432,20 @@ function RootLayoutContent() {
}
}, [isMounted, currentProject, location.pathname, navigate]);
// Bootstrap Codex models on app startup (after auth completes)
useEffect(() => {
// Only fetch if authenticated and Codex CLI is available
if (!authChecked || !isAuthenticated) return;
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated;
if (!isCodexAvailable) return;
// Fetch models in the background
fetchCodexModels().catch((error) => {
logger.warn('Failed to bootstrap Codex models:', error);
});
}, [authChecked, isAuthenticated, codexCliStatus, fetchCodexModels]);
// Apply theme class to document - use deferred value to avoid blocking UI
useEffect(() => {
const root = document.documentElement;

View File

@@ -1,6 +1,7 @@
import { create } from 'zustand';
// Note: persist middleware removed - settings now sync via API (use-settings-sync.ts)
import type { Project, TrashedProject } from '@/lib/electron';
import { getElectronAPI } from '@/lib/electron';
import { createLogger } from '@automaker/utils/logger';
import { setItem, getItem } from '@/lib/storage';
import type {
@@ -638,6 +639,20 @@ export interface AppState {
codexUsage: CodexUsage | null;
codexUsageLastUpdated: number | null;
// Codex Models (dynamically fetched)
codexModels: Array<{
id: string;
label: string;
description: string;
hasThinking: boolean;
supportsVision: boolean;
tier: 'premium' | 'standard' | 'basic';
isDefault: boolean;
}>;
codexModelsLoading: boolean;
codexModelsError: string | null;
codexModelsLastFetched: number | null;
// Pipeline Configuration (per-project, keyed by project path)
pipelineConfigByProject: Record<string, PipelineConfig>;
@@ -690,12 +705,6 @@ export type CodexPlanType =
| 'edu'
| 'unknown';
export interface CodexCreditsSnapshot {
balance?: string;
unlimited?: boolean;
hasCredits?: boolean;
}
export interface CodexRateLimitWindow {
limit: number;
used: number;
@@ -709,7 +718,6 @@ export interface CodexUsage {
rateLimits: {
primary?: CodexRateLimitWindow;
secondary?: CodexRateLimitWindow;
credits?: CodexCreditsSnapshot;
planType?: CodexPlanType;
} | null;
lastUpdated: string;
@@ -1068,6 +1076,20 @@ export interface AppActions {
// Codex Usage Tracking actions
setCodexUsage: (usage: CodexUsage | null) => void;
// Codex Models actions
fetchCodexModels: (forceRefresh?: boolean) => Promise<void>;
setCodexModels: (
models: Array<{
id: string;
label: string;
description: string;
hasThinking: boolean;
supportsVision: boolean;
tier: 'premium' | 'standard' | 'basic';
isDefault: boolean;
}>
) => void;
// Reset
reset: () => void;
}
@@ -1159,6 +1181,10 @@ const initialState: AppState = {
claudeUsageLastUpdated: null,
codexUsage: null,
codexUsageLastUpdated: null,
codexModels: [],
codexModelsLoading: false,
codexModelsError: null,
codexModelsLastFetched: null,
pipelineConfigByProject: {},
// UI State (previously in localStorage, now synced via API)
worktreePanelCollapsed: false,
@@ -2898,6 +2924,53 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
codexUsageLastUpdated: usage ? Date.now() : null,
}),
// Codex Models actions
fetchCodexModels: async (forceRefresh = false) => {
const { codexModelsLastFetched, codexModelsLoading } = get();
// Skip if already loading
if (codexModelsLoading) return;
// Skip if recently fetched (< 5 minutes ago) and not forcing refresh
if (!forceRefresh && codexModelsLastFetched && Date.now() - codexModelsLastFetched < 300000) {
return;
}
set({ codexModelsLoading: true, codexModelsError: null });
try {
const api = getElectronAPI();
if (!api.codex) {
throw new Error('Codex API not available');
}
const result = await api.codex.getModels(forceRefresh);
if (!result.success) {
throw new Error(result.error || 'Failed to fetch Codex models');
}
set({
codexModels: result.models || [],
codexModelsLastFetched: Date.now(),
codexModelsLoading: false,
codexModelsError: null,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
set({
codexModelsError: errorMessage,
codexModelsLoading: false,
});
}
},
setCodexModels: (models) =>
set({
codexModels: models,
codexModelsLastFetched: Date.now(),
}),
// Pipeline actions
setPipelineConfig: (projectPath, config) => {
set({

View File

@@ -0,0 +1,87 @@
/**
* Codex App-Server JSON-RPC Types
*
* Type definitions for communicating with Codex CLI's app-server via JSON-RPC protocol.
* These types match the response structures from the `codex app-server` command.
*/
/**
* Response from model/list JSON-RPC method
* Returns list of available Codex models for the authenticated user
*/
export interface AppServerModelResponse {
data: AppServerModel[];
nextCursor: string | null;
}
export interface AppServerModel {
id: string;
model: string;
displayName: string;
description: string;
supportedReasoningEfforts: AppServerReasoningEffort[];
defaultReasoningEffort: string;
isDefault: boolean;
}
export interface AppServerReasoningEffort {
reasoningEffort: string;
description: string;
}
/**
* Response from account/read JSON-RPC method
* Returns current authentication state and account information
*/
export interface AppServerAccountResponse {
account: AppServerAccount | null;
requiresOpenaiAuth: boolean;
}
export interface AppServerAccount {
type: 'apiKey' | 'chatgpt';
email?: string;
planType?: string;
}
/**
* Response from account/rateLimits/read JSON-RPC method
* Returns rate limit information for the current user
*/
export interface AppServerRateLimitsResponse {
rateLimits: AppServerRateLimits;
}
export interface AppServerRateLimits {
primary: AppServerRateLimitWindow | null;
secondary: AppServerRateLimitWindow | null;
planType?: string;
}
export interface AppServerRateLimitWindow {
usedPercent: number;
windowDurationMins: number;
resetsAt: number;
}
/**
* Generic JSON-RPC request structure
*/
export interface JsonRpcRequest {
method: string;
id: number;
params?: unknown;
}
/**
* Generic JSON-RPC response structure
*/
export interface JsonRpcResponse<T = unknown> {
id: number;
result?: T;
error?: {
code: number;
message: string;
data?: unknown;
};
}

View File

@@ -30,6 +30,20 @@ export type {
} from './codex.js';
export * from './codex-models.js';
// Codex App-Server JSON-RPC types
export type {
AppServerModelResponse,
AppServerModel,
AppServerReasoningEffort,
AppServerAccountResponse,
AppServerAccount,
AppServerRateLimitsResponse,
AppServerRateLimits,
AppServerRateLimitWindow,
JsonRpcRequest,
JsonRpcResponse,
} from './codex-app-server.js';
// Feature types
export type {
Feature,

103
package-lock.json generated
View File

@@ -29,7 +29,7 @@
},
"apps/server": {
"name": "@automaker/server",
"version": "0.8.0",
"version": "0.9.0",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.1.76",
@@ -80,7 +80,7 @@
},
"apps/ui": {
"name": "@automaker/ui",
"version": "0.8.0",
"version": "0.9.0",
"hasInstallScript": true,
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
@@ -677,6 +677,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1260,6 +1261,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz",
"integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
@@ -1302,6 +1304,7 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -2122,7 +2125,6 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@@ -2144,7 +2146,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -2161,7 +2162,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -2176,7 +2176,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -2944,7 +2943,6 @@
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3069,7 +3067,6 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -3086,7 +3083,6 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -3103,7 +3099,6 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -3212,7 +3207,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -3235,7 +3229,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -3258,7 +3251,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -3344,7 +3336,6 @@
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
@@ -3367,7 +3358,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -3387,7 +3377,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -3787,8 +3776,7 @@
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz",
"integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.0.10",
@@ -3802,7 +3790,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3819,7 +3806,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3836,7 +3822,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3853,7 +3838,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3870,7 +3854,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3887,7 +3870,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3904,7 +3886,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3921,7 +3902,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -4021,6 +4001,7 @@
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright": "1.57.0"
},
@@ -5461,7 +5442,6 @@
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
@@ -5795,6 +5775,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz",
"integrity": "sha512-qWFxi2D6eGc1L03RzUuhyEOplZ7Q6q62YOl7Of9Y0q4YjwQwxRm4zxwDVtvUIoy4RLVCpqp5UoE+Nxv2PY9trg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/history": "1.141.0",
"@tanstack/react-store": "^0.8.0",
@@ -6221,6 +6202,7 @@
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@@ -6363,6 +6345,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -6373,6 +6356,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -6478,6 +6462,7 @@
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0",
@@ -6971,7 +6956,8 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@xyflow/react": {
"version": "12.10.0",
@@ -7069,6 +7055,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7129,6 +7116,7 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -7727,6 +7715,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -8258,8 +8247,7 @@
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/cliui": {
"version": "8.0.1",
@@ -8564,8 +8552,7 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"optional": true
},
"node_modules/cross-env": {
"version": "10.1.0",
@@ -8662,6 +8649,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -8963,6 +8951,7 @@
"integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.0.12",
"builder-util": "26.0.11",
@@ -9289,7 +9278,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@@ -9310,7 +9298,6 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@@ -9561,6 +9548,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -9875,6 +9863,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -11542,7 +11531,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11564,7 +11552,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11586,7 +11573,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11608,7 +11594,6 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11630,7 +11615,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11652,7 +11636,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11674,7 +11657,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11696,7 +11678,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11718,7 +11699,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11740,7 +11720,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11762,7 +11741,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -14050,7 +14028,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
@@ -14067,7 +14044,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@@ -14085,7 +14061,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@@ -14274,6 +14249,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14283,6 +14259,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -14641,7 +14618,6 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@@ -14830,6 +14806,7 @@
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz",
"integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
}
@@ -14878,7 +14855,6 @@
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
@@ -14929,7 +14905,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -14952,7 +14927,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -14975,7 +14949,6 @@
"os": [
"darwin"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -14992,7 +14965,6 @@
"os": [
"darwin"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -15009,7 +14981,6 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -15026,7 +14997,6 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -15043,7 +15013,6 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -15060,7 +15029,6 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -15077,7 +15045,6 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -15094,7 +15061,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -15117,7 +15083,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -15140,7 +15105,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -15163,7 +15127,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -15186,7 +15149,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -15209,7 +15171,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -15678,7 +15639,6 @@
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
"license": "MIT",
"peer": true,
"dependencies": {
"client-only": "0.0.1"
},
@@ -15848,7 +15808,6 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@@ -15912,7 +15871,6 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@@ -16010,6 +15968,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16214,6 +16173,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16585,6 +16545,7 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -16674,7 +16635,8 @@
"resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/vite/node_modules/fdir": {
"version": "6.5.0",
@@ -16700,6 +16662,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16742,6 +16705,7 @@
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.16",
"@vitest/mocker": "4.0.16",
@@ -17067,6 +17031,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz",
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}