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..e2df3de9 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -967,21 +967,11 @@ export class CodexProvider extends BaseProvider { } async detectInstallation(): 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 +981,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 +998,6 @@ export class CodexProvider extends BaseProvider { hasApiKey, authenticated, }; - console.log('[CodexProvider.detectInstallation] Final result:', JSON.stringify(result)); - return result; } getAvailableModels(): ModelDefinition[] { @@ -1025,36 +1009,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 +1036,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); + // Silent fail } } - 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..c9491a13 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) => { 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 = await modelCacheService.getModels(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: Date.now(), + }); + } 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..ec65720f --- /dev/null +++ b/apps/server/src/services/codex-model-cache-service.ts @@ -0,0 +1,240 @@ +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(); + } + + /** + * 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 fc3490b6..e9420266 100644 --- a/apps/server/src/services/codex-usage-service.ts +++ b/apps/server/src/services/codex-usage-service.ts @@ -1,5 +1,3 @@ -import { spawn, type ChildProcess } from 'child_process'; -import readline from 'readline'; import { findCodexCliPath, getCodexAuthPath, @@ -7,6 +5,7 @@ import { systemPathReadFile, } from '@automaker/platform'; import { createLogger } from '@automaker/utils'; +import type { CodexAppServerService } from './codex-app-server-service.js'; const logger = createLogger('CodexUsage'); @@ -37,35 +36,6 @@ export interface CodexUsageData { lastUpdated: string; } -/** - * JSON-RPC response types from Codex app-server - */ -interface AppServerAccountResponse { - account: { - type: 'apiKey' | 'chatgpt'; - email?: string; - planType?: string; - } | null; - requiresOpenaiAuth: boolean; -} - -interface AppServerRateLimitsResponse { - rateLimits: { - primary: { - usedPercent: number; - windowDurationMins: number; - resetsAt: number; - } | null; - secondary: { - usedPercent: number; - windowDurationMins: number; - resetsAt: number; - } | null; - credits?: unknown; - planType?: string; // This is the most accurate/current plan type - }; -} - /** * Codex Usage Service * @@ -74,6 +44,7 @@ interface AppServerRateLimitsResponse { */ export class CodexUsageService { private cachedCliPath: string | null = null; + private appServerService: CodexAppServerService | null = null; private accountPlanTypeArray: CodexPlanType[] = [ 'free', 'plus', @@ -82,6 +53,11 @@ export class CodexUsageService { 'enterprise', 'edu', ]; + + constructor(appServerService?: CodexAppServerService) { + this.appServerService = appServerService || null; + } + /** * Check if Codex CLI is available on the system */ @@ -109,12 +85,9 @@ export class CodexUsageService { logger.info(`[fetchUsageData] Using CLI path: ${cliPath}`); // Try to get usage from Codex app-server (most reliable method) - const appServerUsage = await this.fetchFromAppServer(cliPath); + const appServerUsage = await this.fetchFromAppServer(); if (appServerUsage) { - logger.info( - '[fetchUsageData] Got data from app-server:', - JSON.stringify(appServerUsage, null, 2) - ); + logger.info('[fetchUsageData] ✓ Fetched usage from app-server'); return appServerUsage; } @@ -123,7 +96,7 @@ export class CodexUsageService { // Fallback: try to parse usage from auth file const authUsage = await this.fetchFromAuthFile(); if (authUsage) { - logger.info('[fetchUsageData] Got data from auth file:', JSON.stringify(authUsage, null, 2)); + logger.info('[fetchUsageData] ✓ Fetched usage from auth file'); return authUsage; } @@ -145,139 +118,23 @@ export class CodexUsageService { * 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(cliPath: string): Promise { - let childProcess: ChildProcess | null = null; - + private async fetchFromAppServer(): Promise { try { - // 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'); + // Use CodexAppServerService if available + if (!this.appServerService) { + return null; } - // Setup readline for reading JSONL responses - const rl = readline.createInterface({ - input: childProcess.stdout, - crlfDelay: Infinity, - }); + // Fetch account and rate limits in parallel + const [accountResult, rateLimitsResult] = await Promise.all([ + this.appServerService.getAccount(), + this.appServerService.getRateLimits(), + ]); - // 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 = params ? { method, id, params } : { method, id }; - - // Set timeout for request - 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 - logger.info('[fetchFromAppServer] Sending initialize request...'); - const initResult = await sendRequest('initialize', { - clientInfo: { - name: 'automaker', - title: 'AutoMaker', - version: '1.0.0', - }, - }); - logger.info('[fetchFromAppServer] Initialize result:', JSON.stringify(initResult, null, 2)); - - // 2. Send initialized notification - sendNotification('initialized'); - logger.info('[fetchFromAppServer] Sent initialized notification'); - - // 3. Get account info (includes plan type) - logger.info('[fetchFromAppServer] Requesting account/read...'); - const accountResult = await sendRequest('account/read', { - refreshToken: false, - }); - logger.info('[fetchFromAppServer] Account result:', JSON.stringify(accountResult, null, 2)); - - // 4. Get rate limits - let rateLimitsResult: AppServerRateLimitsResponse | null = null; - try { - logger.info('[fetchFromAppServer] Requesting account/rateLimits/read...'); - rateLimitsResult = - await sendRequest('account/rateLimits/read'); - logger.info( - '[fetchFromAppServer] Rate limits result:', - JSON.stringify(rateLimitsResult, null, 2) - ); - } catch (rateLimitError) { - // Rate limits may not be available for API key auth - logger.info('[fetchFromAppServer] Rate limits not available:', rateLimitError); + if (!accountResult) { + return null; } - // Clean up - rl.close(); - childProcess.kill('SIGTERM'); - // Build response // Prefer planType from rateLimits (more accurate/current) over account (can be stale) let planType: CodexPlanType = 'unknown'; @@ -286,9 +143,6 @@ export class CodexUsageService { const rateLimitsPlanType = rateLimitsResult?.rateLimits?.planType; if (rateLimitsPlanType) { const normalizedType = rateLimitsPlanType.toLowerCase() as CodexPlanType; - logger.info( - `[fetchFromAppServer] Rate limits planType: "${rateLimitsPlanType}", normalized: "${normalizedType}"` - ); if (this.accountPlanTypeArray.includes(normalizedType)) { planType = normalizedType; } @@ -297,20 +151,11 @@ export class CodexUsageService { // 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; - logger.info( - `[fetchFromAppServer] Fallback to account planType: "${accountResult.account.planType}", normalized: "${normalizedType}"` - ); if (this.accountPlanTypeArray.includes(normalizedType)) { planType = normalizedType; } } - if (planType === 'unknown') { - logger.info('[fetchFromAppServer] No planType found in either response'); - } else { - logger.info(`[fetchFromAppServer] Final planType: ${planType}`); - } - const result: CodexUsageData = { rateLimits: { planType, @@ -325,10 +170,6 @@ export class CodexUsageService { // Add rate limit info if available if (rateLimitsResult?.rateLimits?.primary) { const primary = rateLimitsResult.rateLimits.primary; - logger.info( - '[fetchFromAppServer] Adding primary rate limit:', - JSON.stringify(primary, null, 2) - ); result.rateLimits!.primary = { limit: 100, // Not provided by API, using placeholder used: primary.usedPercent, @@ -337,17 +178,11 @@ export class CodexUsageService { windowDurationMins: primary.windowDurationMins, resetsAt: primary.resetsAt, }; - } else { - logger.info('[fetchFromAppServer] No primary rate limit in result'); } // Add secondary rate limit if available if (rateLimitsResult?.rateLimits?.secondary) { const secondary = rateLimitsResult.rateLimits.secondary; - logger.info( - '[fetchFromAppServer] Adding secondary rate limit:', - JSON.stringify(secondary, null, 2) - ); result.rateLimits!.secondary = { limit: 100, used: secondary.usedPercent, @@ -358,17 +193,13 @@ export class CodexUsageService { }; } - logger.info('[fetchFromAppServer] Final result:', JSON.stringify(result, null, 2)); + logger.info( + `[fetchFromAppServer] ✓ Plan: ${planType}, Primary: ${result.rateLimits?.primary?.usedPercent || 'N/A'}%, Secondary: ${result.rateLimits?.secondary?.usedPercent || 'N/A'}%` + ); return result; } catch (error) { - // App-server method failed, will fall back to other methods - logger.error('Failed to fetch from app-server:', error); + logger.error('[fetchFromAppServer] Failed:', error); return null; - } finally { - // Ensure process is killed - if (childProcess && !childProcess.killed) { - childProcess.kill('SIGTERM'); - } } } @@ -383,7 +214,7 @@ export class CodexUsageService { const exists = systemPathExists(authFilePath); if (!exists) { - logger.info('[getPlanTypeFromAuthFile] Auth file does not exist'); + logger.warn('[getPlanTypeFromAuthFile] Auth file does not exist'); return 'unknown'; } 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/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/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 691dcfec..effe8207 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -2056,6 +2056,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 78d6e65c..b6de532b 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 { @@ -652,6 +653,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; @@ -1093,6 +1108,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; } @@ -1233,6 +1262,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, @@ -3016,6 +3049,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..f255eac9 --- /dev/null +++ b/libs/types/src/codex-app-server.ts @@ -0,0 +1,94 @@ +/** + * 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; + credits?: AppServerCredits; + planType?: string; +} + +export interface AppServerRateLimitWindow { + usedPercent: number; + windowDurationMins: number; + resetsAt: number; +} + +export interface AppServerCredits { + hasCredits: boolean; + unlimited: boolean; + balance: string; +} + +/** + * 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 259e251d..a28cfc69 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -30,6 +30,21 @@ 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, + AppServerCredits, + 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" }