diff --git a/.gitignore b/.gitignore index 7d02e8ba..91571307 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,9 @@ blob-report/ !.env.example !.env.local.example +# Codex config (contains API keys) +.codex/config.toml + # TypeScript *.tsbuildinfo diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 755569de..caa4dd6a 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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)); diff --git a/apps/server/src/lib/codex-auth.ts b/apps/server/src/lib/codex-auth.ts index 965885bc..94fadc8c 100644 --- a/apps/server/src/lib/codex-auth.ts +++ b/apps/server/src/lib/codex-auth.ts @@ -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 { - 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' }; } diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index 54e13989..2e3962a0 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -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 { - 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 { - 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' }; } diff --git a/apps/server/src/routes/codex/index.ts b/apps/server/src/routes/codex/index.ts index 4a2db951..005a81bc 100644 --- a/apps/server/src/routes/codex/index.ts +++ b/apps/server/src/routes/codex/index.ts @@ -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; } diff --git a/apps/server/src/services/codex-app-server-service.ts b/apps/server/src/services/codex-app-server-service.ts new file mode 100644 index 00000000..ecfb99da --- /dev/null +++ b/apps/server/src/services/codex-app-server-service.ts @@ -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 { + this.cachedCliPath = await findCodexCliPath(); + return Boolean(this.cachedCliPath); + } + + /** + * Fetch available models from app-server + */ + async getModels(): Promise { + const result = await this.executeJsonRpc((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 { + return this.executeJsonRpc((sendRequest) => { + return sendRequest('account/read', { refreshToken: false }); + }); + } + + /** + * Fetch rate limits from app-server + */ + async getRateLimits(): Promise { + return this.executeJsonRpc((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( + requestFn: (sendRequest: (method: string, params?: unknown) => Promise) => Promise + ): Promise { + 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 = (method: string, params?: unknown): Promise => { + 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'); + } + } + } +} diff --git a/apps/server/src/services/codex-model-cache-service.ts b/apps/server/src/services/codex-model-cache-service.ts new file mode 100644 index 00000000..7e171428 --- /dev/null +++ b/apps/server/src/services/codex-model-cache-service.ts @@ -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 | 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 { + // 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 { + // 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 { + 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 { + 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 { + 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 { + 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 + } + } + } +} diff --git a/apps/server/src/services/codex-usage-service.ts b/apps/server/src/services/codex-usage-service.ts index bf8aff99..e18d508e 100644 --- a/apps/server/src/services/codex-usage-service.ts +++ b/apps/server/src/services/codex-usage-service.ts @@ -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 { + 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 { + 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 { 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 { - 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 { - 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 { + 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 | 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 { diff --git a/apps/ui/src/components/views/board-view/shared/model-selector.tsx b/apps/ui/src/components/views/board-view/shared/model-selector.tsx index 65d29dca..323190c8 100644 --- a/apps/ui/src/components/views/board-view/shared/model-selector.tsx +++ b/apps/ui/src/components/views/board-view/shared/model-selector.tsx @@ -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 -
- {CODEX_MODELS.map((option) => { - const isSelected = selectedModel === option.id; - return ( + + {/* Loading state */} + {codexModelsLoading && dynamicCodexModels.length === 0 && ( +
+ + Loading models... +
+ )} + + {/* Error state */} + {codexModelsError && !codexModelsLoading && ( +
+ +
+
Failed to load Codex models
- ); - })} -
+
+
+ )} + + {/* Model list */} + {!codexModelsLoading && !codexModelsError && dynamicCodexModels.length === 0 && ( +
+ No Codex models available +
+ )} + + {!codexModelsLoading && dynamicCodexModels.length > 0 && ( +
+ {dynamicCodexModels.map((option) => { + const isSelected = selectedModel === option.id; + return ( + + ); + })} +
+ )} )} diff --git a/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx b/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx index 1e927777..b879df4a 100644 --- a/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx +++ b/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx @@ -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() { })} )} - {(planType || credits) && ( + {planType && (
- {planType && ( -
- {PLAN_LABEL}:{' '} - {formatCodexPlanType(planType)} -
- )} - {credits && ( -
- {CREDITS_LABEL}:{' '} - {formatCodexCredits(credits)} -
- )} +
+ {PLAN_LABEL}: {formatCodexPlanType(planType)} +
)} {!hasMetrics && !error && canFetchUsage && !isLoading && ( diff --git a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx index c64b5bd4..28d2ed1d 100644 --- a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx +++ b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx @@ -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(null); - const [expandedClaudeModel, setExpandedClaudeModel] = React.useState(null); - const [expandedCodexModel, setExpandedCodexModel] = React.useState(null); - const commandListRef = React.useRef(null); - const expandedTriggerRef = React.useRef(null); - const expandedClaudeTriggerRef = React.useRef(null); - const expandedCodexTriggerRef = React.useRef(null); - const { enabledCursorModels, favoriteModels, toggleFavoriteModel } = useAppStore(); + const [open, setOpen] = useState(false); + const [expandedGroup, setExpandedGroup] = useState(null); + const [expandedClaudeModel, setExpandedClaudeModel] = useState(null); + const [expandedCodexModel, setExpandedCodexModel] = useState(null); + const commandListRef = useRef(null); + const expandedTriggerRef = useRef(null); + const expandedClaudeTriggerRef = useRef(null); + const expandedCodexTriggerRef = useRef(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(); @@ -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') { diff --git a/apps/ui/src/lib/codex-usage-format.ts b/apps/ui/src/lib/codex-usage-format.ts index 288898b2..25114a57 100644 --- a/apps/ui/src/lib/codex-usage-format.ts +++ b/apps/ui/src/lib/codex-usage-format.ts @@ -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; -} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 84e0d945..9a746a6a 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -727,6 +727,20 @@ export interface ElectronAPI { ideation?: IdeationAPI; codex?: { getUsage: () => Promise; + 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<{ diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 89983479..3e1e602c 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -2053,6 +2053,25 @@ export class HttpApiClient implements ElectronAPI { // Codex API codex = { getUsage: (): Promise => 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 diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 1e940dbf..6a883071 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -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; diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 588d5e7c..54bf18eb 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -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; @@ -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; + 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()((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({ diff --git a/libs/types/src/codex-app-server.ts b/libs/types/src/codex-app-server.ts new file mode 100644 index 00000000..454a77b2 --- /dev/null +++ b/libs/types/src/codex-app-server.ts @@ -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 { + id: number; + result?: T; + error?: { + code: number; + message: string; + data?: unknown; + }; +} diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index a2d361a7..d2e49cbf 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -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, diff --git a/package-lock.json b/package-lock.json index 6481a7fb..2079b66d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }