From 7765a12868a4a859f8ec912d7f54d8bca0e43761 Mon Sep 17 00:00:00 2001 From: eclipxe Date: Tue, 20 Jan 2026 14:34:15 -0800 Subject: [PATCH 1/5] Feat: Add z.ai usage tracking --- apps/server/src/index.ts | 4 + apps/server/src/routes/zai/index.ts | 179 +++++++++ apps/server/src/services/settings-service.ts | 1 + apps/server/src/services/zai-usage-service.ts | 375 ++++++++++++++++++ apps/ui/src/components/ui/provider-icon.tsx | 8 +- apps/ui/src/components/usage-popover.tsx | 254 ++++++++++-- .../views/board-view/board-header.tsx | 11 +- .../views/board-view/header-mobile-menu.tsx | 12 +- .../views/board-view/mobile-usage-bar.tsx | 119 +++++- .../api-keys/hooks/use-api-key-management.ts | 100 ++++- apps/ui/src/config/api-providers.ts | 38 +- apps/ui/src/hooks/queries/index.ts | 2 +- apps/ui/src/hooks/queries/use-usage.ts | 37 +- apps/ui/src/hooks/use-provider-auth-init.ts | 50 ++- apps/ui/src/lib/electron.ts | 56 ++- apps/ui/src/lib/http-api-client.ts | 61 +++ apps/ui/src/lib/query-keys.ts | 2 + apps/ui/src/store/app-store.ts | 14 + apps/ui/src/store/setup-store.ts | 26 ++ apps/ui/src/store/types/settings-types.ts | 1 + apps/ui/src/store/types/state-types.ts | 9 +- apps/ui/src/store/types/usage-types.ts | 24 ++ libs/types/src/settings.ts | 3 + 23 files changed, 1331 insertions(+), 55 deletions(-) create mode 100644 apps/server/src/routes/zai/index.ts create mode 100644 apps/server/src/services/zai-usage-service.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 85ff01458..acff315e4 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -66,6 +66,8 @@ 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 { createZaiRoutes } from './routes/zai/index.js'; +import { ZaiUsageService } from './services/zai-usage-service.js'; import { createGitHubRoutes } from './routes/github/index.js'; import { createContextRoutes } from './routes/context/index.js'; import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js'; @@ -326,6 +328,7 @@ const claudeUsageService = new ClaudeUsageService(); const codexAppServerService = new CodexAppServerService(); const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService); const codexUsageService = new CodexUsageService(codexAppServerService); +const zaiUsageService = new ZaiUsageService(); const mcpTestService = new MCPTestService(settingsService); const ideationService = new IdeationService(events, settingsService, featureLoader); @@ -434,6 +437,7 @@ app.use('/api/terminal', createTerminalRoutes()); app.use('/api/settings', createSettingsRoutes(settingsService)); app.use('/api/claude', createClaudeRoutes(claudeUsageService)); app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService)); +app.use('/api/zai', createZaiRoutes(zaiUsageService, settingsService)); 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/routes/zai/index.ts b/apps/server/src/routes/zai/index.ts new file mode 100644 index 000000000..baf84e192 --- /dev/null +++ b/apps/server/src/routes/zai/index.ts @@ -0,0 +1,179 @@ +import { Router, Request, Response } from 'express'; +import { ZaiUsageService } from '../../services/zai-usage-service.js'; +import type { SettingsService } from '../../services/settings-service.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('Zai'); + +export function createZaiRoutes( + usageService: ZaiUsageService, + settingsService: SettingsService +): Router { + const router = Router(); + + // Initialize z.ai API token from credentials on startup + (async () => { + try { + const credentials = await settingsService.getCredentials(); + if (credentials.apiKeys?.zai) { + usageService.setApiToken(credentials.apiKeys.zai); + logger.info('[init] Loaded z.ai API key from credentials'); + } + } catch (error) { + logger.error('[init] Failed to load z.ai API key from credentials:', error); + } + })(); + + // Get current usage (fetches from z.ai API) + router.get('/usage', async (_req: Request, res: Response) => { + try { + // Check if z.ai API is configured + const isAvailable = usageService.isAvailable(); + if (!isAvailable) { + // Use a 200 + error payload so the UI doesn't interpret it as session auth error + res.status(200).json({ + error: 'z.ai API not configured', + message: 'Set Z_AI_API_KEY environment variable to enable z.ai usage tracking', + }); + return; + } + + const usage = await usageService.fetchUsageData(); + res.json(usage); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + + if (message.includes('not configured') || message.includes('API token')) { + res.status(200).json({ + error: 'API token required', + message: 'Set Z_AI_API_KEY environment variable to enable z.ai usage tracking', + }); + } else if (message.includes('failed') || message.includes('request')) { + res.status(200).json({ + error: 'API request failed', + message: message, + }); + } else { + logger.error('Error fetching z.ai usage:', error); + res.status(500).json({ error: message }); + } + } + }); + + // Configure API token (for settings page) + router.post('/configure', async (req: Request, res: Response) => { + try { + const { apiToken, apiHost } = req.body; + + if (apiToken !== undefined) { + // Set in-memory token + usageService.setApiToken(apiToken || ''); + + // Persist to credentials (deep merge happens in updateCredentials) + try { + await settingsService.updateCredentials({ + apiKeys: { zai: apiToken || '' }, + } as Parameters[0]); + logger.info('[configure] Saved z.ai API key to credentials'); + } catch (persistError) { + logger.error('[configure] Failed to persist z.ai API key:', persistError); + } + } + + if (apiHost) { + usageService.setApiHost(apiHost); + } + + res.json({ + success: true, + message: 'z.ai configuration updated', + isAvailable: usageService.isAvailable(), + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error configuring z.ai:', error); + res.status(500).json({ error: message }); + } + }); + + // Verify API key without storing it (for testing in settings) + router.post('/verify', async (req: Request, res: Response) => { + try { + const { apiKey } = req.body; + + if (!apiKey || typeof apiKey !== 'string' || apiKey.trim().length === 0) { + res.json({ + success: false, + authenticated: false, + error: 'Please provide an API key to test.', + }); + return; + } + + // Test the key by making a request to z.ai API + const quotaUrl = + process.env.Z_AI_QUOTA_URL || + `${process.env.Z_AI_API_HOST ? `https://${process.env.Z_AI_API_HOST}` : 'https://api.z.ai'}/api/monitor/usage/quota/limit`; + + logger.info(`[verify] Testing API key against: ${quotaUrl}`); + + const response = await fetch(quotaUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey.trim()}`, + Accept: 'application/json', + }, + }); + + if (response.ok) { + res.json({ + success: true, + authenticated: true, + message: 'Connection successful! z.ai API responded.', + }); + } else if (response.status === 401 || response.status === 403) { + res.json({ + success: false, + authenticated: false, + error: 'Invalid API key. Please check your key and try again.', + }); + } else { + res.json({ + success: false, + authenticated: false, + error: `API request failed: ${response.status} ${response.statusText}`, + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error verifying z.ai API key:', error); + res.json({ + success: false, + authenticated: false, + error: `Network error: ${message}`, + }); + } + }); + + // Check if z.ai is available + router.get('/status', async (_req: Request, res: Response) => { + try { + const isAvailable = usageService.isAvailable(); + const hasEnvApiKey = Boolean(process.env.Z_AI_API_KEY); + const hasApiKey = usageService.getApiToken() !== null; + + res.json({ + success: true, + available: isAvailable, + hasApiKey, + hasEnvApiKey, + message: isAvailable ? 'z.ai API is configured' : 'z.ai API token not configured', + }); + } catch (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/settings-service.ts b/apps/server/src/services/settings-service.ts index 6ffdd4882..80e8987fc 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -1018,6 +1018,7 @@ export class SettingsService { anthropic: apiKeys.anthropic || '', google: apiKeys.google || '', openai: apiKeys.openai || '', + zai: '', }, }); migratedCredentials = true; diff --git a/apps/server/src/services/zai-usage-service.ts b/apps/server/src/services/zai-usage-service.ts new file mode 100644 index 000000000..c19cf6387 --- /dev/null +++ b/apps/server/src/services/zai-usage-service.ts @@ -0,0 +1,375 @@ +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('ZaiUsage'); + +/** + * z.ai quota limit entry from the API + */ +export interface ZaiQuotaLimit { + limitType: 'TOKENS_LIMIT' | 'TIME_LIMIT' | string; + limit: number; + used: number; + remaining: number; + usedPercent: number; + nextResetTime: number; // epoch milliseconds +} + +/** + * z.ai usage details by model (for MCP tracking) + */ +export interface ZaiUsageDetail { + modelId: string; + used: number; + limit: number; +} + +/** + * z.ai plan types + */ +export type ZaiPlanType = 'free' | 'basic' | 'standard' | 'professional' | 'enterprise' | 'unknown'; + +/** + * z.ai usage data structure + */ +export interface ZaiUsageData { + quotaLimits: { + tokens?: ZaiQuotaLimit; + mcp?: ZaiQuotaLimit; + planType: ZaiPlanType; + } | null; + usageDetails?: ZaiUsageDetail[]; + lastUpdated: string; +} + +/** + * z.ai API limit entry - supports multiple field naming conventions + */ +interface ZaiApiLimit { + // Type field (z.ai uses 'type', others might use 'limitType') + type?: string; + limitType?: string; + // Limit value (z.ai uses 'usage' for total limit, others might use 'limit') + usage?: number; + limit?: number; + // Used value (z.ai uses 'currentValue', others might use 'used') + currentValue?: number; + used?: number; + // Remaining + remaining?: number; + // Percentage (z.ai uses 'percentage', others might use 'usedPercent') + percentage?: number; + usedPercent?: number; + // Reset time + nextResetTime?: number; + // Additional z.ai fields + unit?: number; + number?: number; + usageDetails?: Array<{ modelCode: string; usage: number }>; +} + +/** + * z.ai API response structure + * Flexible to handle various possible response formats + */ +interface ZaiApiResponse { + code?: number; + success?: boolean; + data?: { + limits?: ZaiApiLimit[]; + // Alternative: limits might be an object instead of array + tokensLimit?: { + limit: number; + used: number; + remaining?: number; + usedPercent?: number; + nextResetTime?: number; + }; + timeLimit?: { + limit: number; + used: number; + remaining?: number; + usedPercent?: number; + nextResetTime?: number; + }; + // Quota-style fields + quota?: number; + quotaUsed?: number; + quotaRemaining?: number; + planName?: string; + plan?: string; + plan_type?: string; + packageName?: string; + usageDetails?: Array<{ + modelId: string; + used: number; + limit: number; + }>; + }; + // Root-level alternatives + limits?: ZaiApiLimit[]; + quota?: number; + quotaUsed?: number; + message?: string; +} + +/** + * z.ai Usage Service + * + * Fetches usage quota data from the z.ai API. + * Uses API token authentication stored via environment variable or settings. + */ +export class ZaiUsageService { + private apiToken: string | null = null; + private apiHost: string = 'https://api.z.ai'; + + /** + * Set the API token for authentication + */ + setApiToken(token: string): void { + this.apiToken = token; + logger.info('[setApiToken] API token configured'); + } + + /** + * Get the current API token + */ + getApiToken(): string | null { + // Priority: 1. Instance token, 2. Environment variable + return this.apiToken || process.env.Z_AI_API_KEY || null; + } + + /** + * Set the API host (for BigModel CN region support) + */ + setApiHost(host: string): void { + this.apiHost = host.startsWith('http') ? host : `https://${host}`; + logger.info(`[setApiHost] API host set to: ${this.apiHost}`); + } + + /** + * Get the API host + */ + getApiHost(): string { + // Priority: 1. Instance host, 2. Z_AI_API_HOST env, 3. Default + return process.env.Z_AI_API_HOST ? `https://${process.env.Z_AI_API_HOST}` : this.apiHost; + } + + /** + * Check if z.ai API is available (has token configured) + */ + isAvailable(): boolean { + const token = this.getApiToken(); + return Boolean(token && token.length > 0); + } + + /** + * Fetch usage data from z.ai API + */ + async fetchUsageData(): Promise { + logger.info('[fetchUsageData] Starting...'); + + const token = this.getApiToken(); + if (!token) { + logger.error('[fetchUsageData] No API token configured'); + throw new Error('z.ai API token not configured. Set Z_AI_API_KEY environment variable.'); + } + + const quotaUrl = + process.env.Z_AI_QUOTA_URL || `${this.getApiHost()}/api/monitor/usage/quota/limit`; + + logger.info(`[fetchUsageData] Fetching from: ${quotaUrl}`); + + try { + const response = await fetch(quotaUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + }); + + if (!response.ok) { + logger.error(`[fetchUsageData] HTTP ${response.status}: ${response.statusText}`); + throw new Error(`z.ai API request failed: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as unknown as ZaiApiResponse; + logger.info('[fetchUsageData] Response received:', JSON.stringify(data, null, 2)); + + return this.parseApiResponse(data); + } catch (error) { + if (error instanceof Error && error.message.includes('z.ai API')) { + throw error; + } + logger.error('[fetchUsageData] Failed to fetch:', error); + throw new Error( + `Failed to fetch z.ai usage data: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + /** + * Parse the z.ai API response into our data structure + * Handles multiple possible response formats from z.ai API + */ + private parseApiResponse(response: ZaiApiResponse): ZaiUsageData { + const result: ZaiUsageData = { + quotaLimits: { + planType: 'unknown', + }, + lastUpdated: new Date().toISOString(), + }; + + logger.info('[parseApiResponse] Raw response:', JSON.stringify(response, null, 2)); + + // Try to find data - could be in response.data or at root level + let data = response.data; + + // Check for root-level limits array + if (!data && response.limits) { + logger.info('[parseApiResponse] Found limits at root level'); + data = { limits: response.limits }; + } + + // Check for root-level quota fields + if (!data && (response.quota !== undefined || response.quotaUsed !== undefined)) { + logger.info('[parseApiResponse] Found quota fields at root level'); + data = { quota: response.quota, quotaUsed: response.quotaUsed }; + } + + if (!data) { + logger.warn('[parseApiResponse] No data found in response'); + return result; + } + + logger.info('[parseApiResponse] Data keys:', Object.keys(data)); + + // Parse plan type from various possible field names + const planName = data.planName || data.plan || data.plan_type || data.packageName; + + if (planName) { + const normalizedPlan = String(planName).toLowerCase(); + if (['free', 'basic', 'standard', 'professional', 'enterprise'].includes(normalizedPlan)) { + result.quotaLimits!.planType = normalizedPlan as ZaiPlanType; + } + logger.info(`[parseApiResponse] Plan type: ${result.quotaLimits!.planType}`); + } + + // Parse quota limits from array format + if (data.limits && Array.isArray(data.limits)) { + logger.info('[parseApiResponse] Parsing limits array with', data.limits.length, 'entries'); + for (const limit of data.limits) { + logger.info('[parseApiResponse] Processing limit:', JSON.stringify(limit)); + + // Handle different field naming conventions from z.ai API: + // - 'usage' is the total limit, 'currentValue' is the used amount + // - OR 'limit' is the total limit, 'used' is the used amount + const limitVal = limit.usage ?? limit.limit ?? 0; + const usedVal = limit.currentValue ?? limit.used ?? 0; + + // Get percentage from 'percentage' or 'usedPercent' field, or calculate it + const apiPercent = limit.percentage ?? limit.usedPercent; + const calculatedPercent = limitVal > 0 ? (usedVal / limitVal) * 100 : 0; + const usedPercent = + apiPercent !== undefined && apiPercent > 0 ? apiPercent : calculatedPercent; + + // Get limit type from 'type' or 'limitType' field + const rawLimitType = limit.type ?? limit.limitType ?? ''; + + const quotaLimit: ZaiQuotaLimit = { + limitType: rawLimitType || 'TOKENS_LIMIT', + limit: limitVal, + used: usedVal, + remaining: limit.remaining ?? limitVal - usedVal, + usedPercent, + nextResetTime: limit.nextResetTime ?? 0, + }; + + // Match various possible limitType values + const limitType = String(rawLimitType).toUpperCase(); + if (limitType.includes('TOKEN') || limitType === 'TOKENS_LIMIT') { + result.quotaLimits!.tokens = quotaLimit; + logger.info( + `[parseApiResponse] Tokens: ${quotaLimit.used}/${quotaLimit.limit} (${quotaLimit.usedPercent.toFixed(1)}%)` + ); + } else if (limitType.includes('TIME') || limitType === 'TIME_LIMIT') { + result.quotaLimits!.mcp = quotaLimit; + logger.info( + `[parseApiResponse] MCP: ${quotaLimit.used}/${quotaLimit.limit} (${quotaLimit.usedPercent.toFixed(1)}%)` + ); + } else { + // If limitType is unknown, use as tokens by default (first one) + if (!result.quotaLimits!.tokens) { + quotaLimit.limitType = 'TOKENS_LIMIT'; + result.quotaLimits!.tokens = quotaLimit; + logger.info(`[parseApiResponse] Unknown limit type '${rawLimitType}', using as tokens`); + } + } + } + } + + // Parse alternative object-style limits + if (data.tokensLimit) { + const t = data.tokensLimit; + const limitVal = t.limit ?? 0; + const usedVal = t.used ?? 0; + const calculatedPercent = limitVal > 0 ? (usedVal / limitVal) * 100 : 0; + result.quotaLimits!.tokens = { + limitType: 'TOKENS_LIMIT', + limit: limitVal, + used: usedVal, + remaining: t.remaining ?? limitVal - usedVal, + usedPercent: + t.usedPercent !== undefined && t.usedPercent > 0 ? t.usedPercent : calculatedPercent, + nextResetTime: t.nextResetTime ?? 0, + }; + logger.info('[parseApiResponse] Parsed tokensLimit object'); + } + + if (data.timeLimit) { + const t = data.timeLimit; + const limitVal = t.limit ?? 0; + const usedVal = t.used ?? 0; + const calculatedPercent = limitVal > 0 ? (usedVal / limitVal) * 100 : 0; + result.quotaLimits!.mcp = { + limitType: 'TIME_LIMIT', + limit: limitVal, + used: usedVal, + remaining: t.remaining ?? limitVal - usedVal, + usedPercent: + t.usedPercent !== undefined && t.usedPercent > 0 ? t.usedPercent : calculatedPercent, + nextResetTime: t.nextResetTime ?? 0, + }; + logger.info('[parseApiResponse] Parsed timeLimit object'); + } + + // Parse simple quota/quotaUsed format as tokens + if (data.quota !== undefined && data.quotaUsed !== undefined && !result.quotaLimits!.tokens) { + const limitVal = Number(data.quota) || 0; + const usedVal = Number(data.quotaUsed) || 0; + result.quotaLimits!.tokens = { + limitType: 'TOKENS_LIMIT', + limit: limitVal, + used: usedVal, + remaining: + data.quotaRemaining !== undefined ? Number(data.quotaRemaining) : limitVal - usedVal, + usedPercent: limitVal > 0 ? (usedVal / limitVal) * 100 : 0, + nextResetTime: 0, + }; + logger.info('[parseApiResponse] Parsed simple quota format'); + } + + // Parse usage details (MCP tracking) + if (data.usageDetails && Array.isArray(data.usageDetails)) { + result.usageDetails = data.usageDetails.map((detail) => ({ + modelId: detail.modelId, + used: detail.used, + limit: detail.limit, + })); + logger.info(`[parseApiResponse] Usage details for ${result.usageDetails.length} models`); + } + + logger.info('[parseApiResponse] Final result:', JSON.stringify(result, null, 2)); + return result; + } +} diff --git a/apps/ui/src/components/ui/provider-icon.tsx b/apps/ui/src/components/ui/provider-icon.tsx index 415872cea..637fd812e 100644 --- a/apps/ui/src/components/ui/provider-icon.tsx +++ b/apps/ui/src/components/ui/provider-icon.tsx @@ -105,8 +105,9 @@ const PROVIDER_ICON_DEFINITIONS: Record }, glm: { viewBox: '0 0 24 24', - // Official Z.ai logo from lobehub/lobe-icons (GLM provider) + // Official Z.ai/GLM logo from lobehub/lobe-icons (GLM/Zhipu provider) path: 'M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z', + fill: '#3B82F6', // z.ai brand blue }, bigpickle: { viewBox: '0 0 24 24', @@ -391,12 +392,15 @@ export function GlmIcon({ className, title, ...props }: { className?: string; ti {title && {title}} ); } +// Z.ai icon is the same as GLM (Zhipu AI) +export const ZaiIcon = GlmIcon; + export function BigPickleIcon({ className, title, diff --git a/apps/ui/src/components/usage-popover.tsx b/apps/ui/src/components/usage-popover.tsx index 5d8acb0ba..31bb6d5af 100644 --- a/apps/ui/src/components/usage-popover.tsx +++ b/apps/ui/src/components/usage-popover.tsx @@ -6,8 +6,8 @@ import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } f import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { useSetupStore } from '@/store/setup-store'; -import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon'; -import { useClaudeUsage, useCodexUsage } from '@/hooks/queries'; +import { AnthropicIcon, OpenAIIcon, ZaiIcon } from '@/components/ui/provider-icon'; +import { useClaudeUsage, useCodexUsage, useZaiUsage } from '@/hooks/queries'; // Error codes for distinguishing failure modes const ERROR_CODES = { @@ -27,9 +27,9 @@ type UsageError = { const CLAUDE_SESSION_WINDOW_HOURS = 5; -// Helper to format reset time for Codex -function formatCodexResetTime(unixTimestamp: number): string { - const date = new Date(unixTimestamp * 1000); +// Helper to format reset time for Codex/z.ai (unix timestamp in seconds or milliseconds) +function formatResetTime(unixTimestamp: number, isMilliseconds = false): string { + const date = new Date(isMilliseconds ? unixTimestamp : unixTimestamp * 1000); const now = new Date(); const diff = date.getTime() - now.getTime(); @@ -45,6 +45,11 @@ function formatCodexResetTime(unixTimestamp: number): string { return `Resets ${date.toLocaleDateString()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; } +// Legacy alias for Codex +function formatCodexResetTime(unixTimestamp: number): string { + return formatResetTime(unixTimestamp, false); +} + // Helper to format window duration for Codex function getCodexWindowLabel(durationMins: number): { title: string; subtitle: string } { if (durationMins < 60) { @@ -58,16 +63,32 @@ function getCodexWindowLabel(durationMins: number): { title: string; subtitle: s return { title: `${days}d Window`, subtitle: 'Rate limit' }; } +// Helper to format large numbers with K/M suffixes +function formatNumber(num: number): string { + if (num >= 1_000_000_000) { + return `${(num / 1_000_000_000).toFixed(1)}B`; + } + if (num >= 1_000_000) { + return `${(num / 1_000_000).toFixed(1)}M`; + } + if (num >= 1_000) { + return `${(num / 1_000).toFixed(1)}K`; + } + return num.toLocaleString(); +} + export function UsagePopover() { const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); + const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus); const [open, setOpen] = useState(false); - const [activeTab, setActiveTab] = useState<'claude' | 'codex'>('claude'); + const [activeTab, setActiveTab] = useState<'claude' | 'codex' | 'zai'>('claude'); // Check authentication status const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated; const isCodexAuthenticated = codexAuthStatus?.authenticated; + const isZaiAuthenticated = zaiAuthStatus?.authenticated; // Use React Query hooks for usage data // Only enable polling when popover is open AND the tab is active @@ -87,6 +108,14 @@ export function UsagePopover() { refetch: refetchCodex, } = useCodexUsage(open && activeTab === 'codex' && isCodexAuthenticated); + const { + data: zaiUsage, + isLoading: zaiLoading, + error: zaiQueryError, + dataUpdatedAt: zaiUsageLastUpdated, + refetch: refetchZai, + } = useZaiUsage(open && activeTab === 'zai' && isZaiAuthenticated); + // Parse errors into structured format const claudeError = useMemo((): UsageError | null => { if (!claudeQueryError) return null; @@ -116,14 +145,28 @@ export function UsagePopover() { return { code: ERROR_CODES.AUTH_ERROR, message }; }, [codexQueryError]); + const zaiError = useMemo((): UsageError | null => { + if (!zaiQueryError) return null; + const message = zaiQueryError instanceof Error ? zaiQueryError.message : String(zaiQueryError); + if (message.includes('not configured') || message.includes('API token')) { + return { code: ERROR_CODES.NOT_AVAILABLE, message }; + } + if (message.includes('API bridge')) { + return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message }; + } + return { code: ERROR_CODES.AUTH_ERROR, message }; + }, [zaiQueryError]); + // Determine which tab to show by default useEffect(() => { if (isClaudeAuthenticated) { setActiveTab('claude'); } else if (isCodexAuthenticated) { setActiveTab('codex'); + } else if (isZaiAuthenticated) { + setActiveTab('zai'); } - }, [isClaudeAuthenticated, isCodexAuthenticated]); + }, [isClaudeAuthenticated, isCodexAuthenticated, isZaiAuthenticated]); // Check if data is stale (older than 2 minutes) const isClaudeStale = useMemo(() => { @@ -134,9 +177,14 @@ export function UsagePopover() { return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000; }, [codexUsageLastUpdated]); + const isZaiStale = useMemo(() => { + return !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000; + }, [zaiUsageLastUpdated]); + // Refetch functions for manual refresh const fetchClaudeUsage = () => refetchClaude(); const fetchCodexUsage = () => refetchCodex(); + const fetchZaiUsage = () => refetchZai(); // Derived status color/icon helper const getStatusInfo = (percentage: number) => { @@ -251,26 +299,33 @@ export function UsagePopover() { const indicatorInfo = activeTab === 'claude' ? { - icon: AnthropicIcon, - percentage: claudeSessionPercentage, - isStale: isClaudeStale, - title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, - } - : { - icon: OpenAIIcon, - percentage: codexWindowUsage ?? 0, - isStale: isCodexStale, - title: `Usage (${codexWindowLabel})`, - }; + icon: AnthropicIcon, + percentage: claudeSessionPercentage, + isStale: isClaudeStale, + title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, + } + : activeTab === 'codex' ? { + icon: OpenAIIcon, + percentage: codexWindowUsage ?? 0, + isStale: isCodexStale, + title: `Usage (${codexWindowLabel})`, + } : activeTab === 'zai' ? { + icon: ZaiIcon, + percentage: zaiMaxPercentage, + isStale: isZaiStale, + title: `Usage (z.ai)`, + } : null; const statusColor = getStatusInfo(indicatorInfo.percentage).color; const ProviderIcon = indicatorInfo.icon; const trigger = ( + )} + + + {/* Content */} +
+ {zaiError ? ( +
+ +
+

+ {zaiError.code === ERROR_CODES.NOT_AVAILABLE + ? 'z.ai not configured' + : zaiError.message} +

+

+ {zaiError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? ( + 'Ensure the Electron bridge is running or restart the app' + ) : zaiError.code === ERROR_CODES.NOT_AVAILABLE ? ( + <> + Set Z_AI_API_KEY{' '} + environment variable to enable z.ai usage tracking + + ) : ( + <>Check your z.ai API key configuration + )} +

+
+
+ ) : !zaiUsage ? ( +
+ +

Loading usage data...

+
+ ) : zaiUsage.quotaLimits && + (zaiUsage.quotaLimits.tokens || zaiUsage.quotaLimits.mcp) ? ( + <> + {zaiUsage.quotaLimits.tokens && ( + + )} + + {zaiUsage.quotaLimits.mcp && ( + + )} + + {zaiUsage.quotaLimits.planType && zaiUsage.quotaLimits.planType !== 'unknown' && ( +
+

+ Plan:{' '} + + {zaiUsage.quotaLimits.planType.charAt(0).toUpperCase() + + zaiUsage.quotaLimits.planType.slice(1)} + +

+
+ )} + + ) : ( +
+ +

No usage data available

+
+ )} +
+ + {/* Footer */} +
+ + z.ai + + Updates every minute +
+ diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index 0db3dd48a..05303b85d 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -81,6 +81,7 @@ export function BoardHeader({ (state) => state.setAddFeatureUseSelectedWorktreeBranch ); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); + const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus); // Worktree panel visibility (per-project) const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject); @@ -112,6 +113,9 @@ export function BoardHeader({ // Show if Codex is authenticated (CLI or API key) const showCodexUsage = !!codexAuthStatus?.authenticated; + // z.ai usage tracking visibility logic + const showZaiUsage = !!zaiAuthStatus?.authenticated; + // State for mobile actions panel const [showActionsPanel, setShowActionsPanel] = useState(false); const [isRefreshingBoard, setIsRefreshingBoard] = useState(false); @@ -158,8 +162,10 @@ export function BoardHeader({ Refresh board state from server )} - {/* Usage Popover - show if either provider is authenticated, only on desktop */} - {isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && } + {/* Usage Popover - show if any provider is authenticated, only on desktop */} + {isMounted && !isTablet && (showClaudeUsage || showCodexUsage || showZaiUsage) && ( + + )} {/* Tablet/Mobile view: show hamburger menu with all controls */} {isMounted && isTablet && ( @@ -178,6 +184,7 @@ export function BoardHeader({ onOpenPlanDialog={onOpenPlanDialog} showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} + showZaiUsage={showZaiUsage} /> )} diff --git a/apps/ui/src/components/views/board-view/header-mobile-menu.tsx b/apps/ui/src/components/views/board-view/header-mobile-menu.tsx index f3c2c19d1..184e436a3 100644 --- a/apps/ui/src/components/views/board-view/header-mobile-menu.tsx +++ b/apps/ui/src/components/views/board-view/header-mobile-menu.tsx @@ -30,6 +30,7 @@ interface HeaderMobileMenuProps { // Usage bar visibility showClaudeUsage: boolean; showCodexUsage: boolean; + showZaiUsage?: boolean; } export function HeaderMobileMenu({ @@ -47,18 +48,23 @@ export function HeaderMobileMenu({ onOpenPlanDialog, showClaudeUsage, showCodexUsage, + showZaiUsage = false, }: HeaderMobileMenuProps) { return ( <> - {/* Usage Bar - show if either provider is authenticated */} - {(showClaudeUsage || showCodexUsage) && ( + {/* Usage Bar - show if any provider is authenticated */} + {(showClaudeUsage || showCodexUsage || showZaiUsage) && (
Usage - +
)} diff --git a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx index 918988e91..28225b507 100644 --- a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx +++ b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx @@ -4,11 +4,12 @@ import { cn } from '@/lib/utils'; import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; -import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon'; +import { AnthropicIcon, OpenAIIcon, ZaiIcon } from '@/components/ui/provider-icon'; interface MobileUsageBarProps { showClaudeUsage: boolean; showCodexUsage: boolean; + showZaiUsage?: boolean; } // Helper to get progress bar color based on percentage @@ -18,15 +19,51 @@ function getProgressBarColor(percentage: number): string { return 'bg-green-500'; } +// Helper to format large numbers with K/M suffixes +function formatNumber(num: number): string { + if (num >= 1_000_000_000) { + return `${(num / 1_000_000_000).toFixed(1)}B`; + } + if (num >= 1_000_000) { + return `${(num / 1_000_000).toFixed(1)}M`; + } + if (num >= 1_000) { + return `${(num / 1_000).toFixed(1)}K`; + } + return num.toLocaleString(); +} + +// Helper to format reset time +function formatResetTime(unixTimestamp: number, isMilliseconds = false): string { + const date = new Date(isMilliseconds ? unixTimestamp : unixTimestamp * 1000); + const now = new Date(); + const diff = date.getTime() - now.getTime(); + + if (diff < 3600000) { + const mins = Math.ceil(diff / 60000); + return `Resets in ${mins}m`; + } + if (diff < 86400000) { + const hours = Math.floor(diff / 3600000); + const mins = Math.ceil((diff % 3600000) / 60000); + return `Resets in ${hours}h${mins > 0 ? ` ${mins}m` : ''}`; + } + return `Resets ${date.toLocaleDateString()}`; +} + // Individual usage bar component function UsageBar({ label, percentage, isStale, + details, + resetText, }: { label: string; percentage: number; isStale: boolean; + details?: string; + resetText?: string; }) { return (
@@ -58,6 +95,14 @@ function UsageBar({ style={{ width: `${Math.min(percentage, 100)}%` }} />
+ {(details || resetText) && ( +
+ {details && {details}} + {resetText && ( + {resetText} + )} +
+ )} ); } @@ -103,16 +148,23 @@ function UsageItem({ ); } -export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageBarProps) { +export function MobileUsageBar({ + showClaudeUsage, + showCodexUsage, + showZaiUsage = false, +}: MobileUsageBarProps) { const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore(); const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore(); + const { zaiUsage, zaiUsageLastUpdated, setZaiUsage } = useAppStore(); const [isClaudeLoading, setIsClaudeLoading] = useState(false); const [isCodexLoading, setIsCodexLoading] = useState(false); + const [isZaiLoading, setIsZaiLoading] = useState(false); // Check if data is stale (older than 2 minutes) const isClaudeStale = !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000; const isCodexStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000; + const isZaiStale = !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000; const fetchClaudeUsage = useCallback(async () => { setIsClaudeLoading(true); @@ -146,6 +198,22 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB } }, [setCodexUsage]); + const fetchZaiUsage = useCallback(async () => { + setIsZaiLoading(true); + try { + const api = getElectronAPI(); + if (!api.zai) return; + const data = await api.zai.getUsage(); + if (!('error' in data)) { + setZaiUsage(data); + } + } catch { + // Silently fail - usage display is optional + } finally { + setIsZaiLoading(false); + } + }, [setZaiUsage]); + const getCodexWindowLabel = (durationMins: number) => { if (durationMins < 60) return `${durationMins}m Window`; if (durationMins < 1440) return `${Math.round(durationMins / 60)}h Window`; @@ -165,8 +233,14 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB } }, [showCodexUsage, isCodexStale, fetchCodexUsage]); + useEffect(() => { + if (showZaiUsage && isZaiStale) { + fetchZaiUsage(); + } + }, [showZaiUsage, isZaiStale, fetchZaiUsage]); + // Don't render if there's nothing to show - if (!showClaudeUsage && !showCodexUsage) { + if (!showClaudeUsage && !showCodexUsage && !showZaiUsage) { return null; } @@ -227,6 +301,45 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB )} )} + + {showZaiUsage && ( + + {zaiUsage?.quotaLimits && (zaiUsage.quotaLimits.tokens || zaiUsage.quotaLimits.mcp) ? ( + <> + {zaiUsage.quotaLimits.tokens && ( + + )} + {zaiUsage.quotaLimits.mcp && ( + + )} + + ) : zaiUsage ? ( +

No usage data from z.ai API

+ ) : ( +

Loading usage data...

+ )} +
+ )} ); } diff --git a/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts b/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts index 0290ec9e2..1b6738ece 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts +++ b/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts @@ -1,7 +1,11 @@ // @ts-nocheck - API key management state with validation and persistence import { useState, useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { createLogger } from '@automaker/utils/logger'; import { useAppStore } from '@/store/app-store'; +import { useSetupStore, type ZaiAuthMethod } from '@/store/setup-store'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { queryKeys } from '@/lib/query-keys'; const logger = createLogger('ApiKeyManagement'); import { getElectronAPI } from '@/lib/electron'; @@ -16,6 +20,7 @@ interface ApiKeyStatus { hasAnthropicKey: boolean; hasGoogleKey: boolean; hasOpenaiKey: boolean; + hasZaiKey: boolean; } /** @@ -24,16 +29,20 @@ interface ApiKeyStatus { */ export function useApiKeyManagement() { const { apiKeys, setApiKeys } = useAppStore(); + const { setZaiAuthStatus } = useSetupStore(); + const queryClient = useQueryClient(); // API key values const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic); const [googleKey, setGoogleKey] = useState(apiKeys.google); const [openaiKey, setOpenaiKey] = useState(apiKeys.openai); + const [zaiKey, setZaiKey] = useState(apiKeys.zai); // Visibility toggles const [showAnthropicKey, setShowAnthropicKey] = useState(false); const [showGoogleKey, setShowGoogleKey] = useState(false); const [showOpenaiKey, setShowOpenaiKey] = useState(false); + const [showZaiKey, setShowZaiKey] = useState(false); // Test connection states const [testingConnection, setTestingConnection] = useState(false); @@ -42,6 +51,8 @@ export function useApiKeyManagement() { const [geminiTestResult, setGeminiTestResult] = useState(null); const [testingOpenaiConnection, setTestingOpenaiConnection] = useState(false); const [openaiTestResult, setOpenaiTestResult] = useState(null); + const [testingZaiConnection, setTestingZaiConnection] = useState(false); + const [zaiTestResult, setZaiTestResult] = useState(null); // API key status from environment const [apiKeyStatus, setApiKeyStatus] = useState(null); @@ -54,6 +65,7 @@ export function useApiKeyManagement() { setAnthropicKey(apiKeys.anthropic); setGoogleKey(apiKeys.google); setOpenaiKey(apiKeys.openai); + setZaiKey(apiKeys.zai); }, [apiKeys]); // Check API key status from environment on mount @@ -68,6 +80,7 @@ export function useApiKeyManagement() { hasAnthropicKey: status.hasAnthropicKey, hasGoogleKey: status.hasGoogleKey, hasOpenaiKey: status.hasOpenaiKey, + hasZaiKey: status.hasZaiKey || false, }); } } catch (error) { @@ -173,13 +186,89 @@ export function useApiKeyManagement() { } }; + // Test z.ai connection + const handleTestZaiConnection = async () => { + setTestingZaiConnection(true); + setZaiTestResult(null); + + // Validate input first + if (!zaiKey || zaiKey.trim().length === 0) { + setZaiTestResult({ + success: false, + message: 'Please enter an API key to test.', + }); + setTestingZaiConnection(false); + return; + } + + try { + const api = getElectronAPI(); + // Use the verify endpoint to test the key without storing it + const response = await api.zai?.verify(zaiKey); + + if (response?.success && response?.authenticated) { + setZaiTestResult({ + success: true, + message: response.message || 'Connection successful! z.ai API responded.', + }); + } else { + setZaiTestResult({ + success: false, + message: response?.error || 'Failed to connect to z.ai API.', + }); + } + } catch { + setZaiTestResult({ + success: false, + message: 'Network error. Please check your connection.', + }); + } finally { + setTestingZaiConnection(false); + } + }; + // Save API keys - const handleSave = () => { + const handleSave = async () => { setApiKeys({ anthropic: anthropicKey, google: googleKey, openai: openaiKey, + zai: zaiKey, }); + + // Configure z.ai service on the server with the new key + if (zaiKey && zaiKey.trim().length > 0) { + try { + const api = getHttpApiClient(); + const result = await api.zai.configure(zaiKey.trim()); + + if (result.success || result.isAvailable) { + // Update z.ai auth status in the store + setZaiAuthStatus({ + authenticated: true, + method: 'api_key' as ZaiAuthMethod, + hasApiKey: true, + hasEnvApiKey: false, + }); + // Invalidate the z.ai usage query so it refetches with the new key + await queryClient.invalidateQueries({ queryKey: queryKeys.usage.zai() }); + logger.info('z.ai API key configured successfully'); + } + } catch (error) { + logger.error('Failed to configure z.ai API key:', error); + } + } else { + // Clear z.ai auth status if key is removed + setZaiAuthStatus({ + authenticated: false, + method: 'none' as ZaiAuthMethod, + hasApiKey: false, + hasEnvApiKey: false, + }); + // Invalidate the query to clear any cached data + await queryClient.invalidateQueries({ queryKey: queryKeys.usage.zai() }); + } + setSaved(true); setTimeout(() => setSaved(false), 2000); }; @@ -214,6 +303,15 @@ export function useApiKeyManagement() { onTest: handleTestOpenaiConnection, result: openaiTestResult, }, + zai: { + value: zaiKey, + setValue: setZaiKey, + show: showZaiKey, + setShow: setShowZaiKey, + testing: testingZaiConnection, + onTest: handleTestZaiConnection, + result: zaiTestResult, + }, }; return { diff --git a/apps/ui/src/config/api-providers.ts b/apps/ui/src/config/api-providers.ts index e3cc2a51b..140d0c24d 100644 --- a/apps/ui/src/config/api-providers.ts +++ b/apps/ui/src/config/api-providers.ts @@ -1,7 +1,7 @@ import type { Dispatch, SetStateAction } from 'react'; import type { ApiKeys } from '@/store/app-store'; -export type ProviderKey = 'anthropic' | 'google' | 'openai'; +export type ProviderKey = 'anthropic' | 'google' | 'openai' | 'zai'; export interface ProviderConfig { key: ProviderKey; @@ -59,12 +59,22 @@ export interface ProviderConfigParams { onTest: () => Promise; result: { success: boolean; message: string } | null; }; + zai: { + value: string; + setValue: Dispatch>; + show: boolean; + setShow: Dispatch>; + testing: boolean; + onTest: () => Promise; + result: { success: boolean; message: string } | null; + }; } export const buildProviderConfigs = ({ apiKeys, anthropic, openai, + zai, }: ProviderConfigParams): ProviderConfig[] => [ { key: 'anthropic', @@ -118,6 +128,32 @@ export const buildProviderConfigs = ({ descriptionLinkText: 'platform.openai.com', descriptionSuffix: '.', }, + { + key: 'zai', + label: 'z.ai API Key', + inputId: 'zai-key', + placeholder: 'Enter your z.ai API key', + value: zai.value, + setValue: zai.setValue, + showValue: zai.show, + setShowValue: zai.setShow, + hasStoredKey: apiKeys.zai, + inputTestId: 'zai-api-key-input', + toggleTestId: 'toggle-zai-visibility', + testButton: { + onClick: zai.onTest, + disabled: !zai.value || zai.testing, + loading: zai.testing, + testId: 'test-zai-connection', + }, + result: zai.result, + resultTestId: 'zai-test-connection-result', + resultMessageTestId: 'zai-test-connection-message', + descriptionPrefix: 'Used for z.ai usage tracking and GLM models. Get your key at', + descriptionLinkHref: 'https://z.ai', + descriptionLinkText: 'z.ai', + descriptionSuffix: '.', + }, // { // key: "google", // label: "Google API Key (Gemini)", diff --git a/apps/ui/src/hooks/queries/index.ts b/apps/ui/src/hooks/queries/index.ts index 8cfdf745f..186b5b4e7 100644 --- a/apps/ui/src/hooks/queries/index.ts +++ b/apps/ui/src/hooks/queries/index.ts @@ -23,7 +23,7 @@ export { } from './use-github'; // Usage -export { useClaudeUsage, useCodexUsage } from './use-usage'; +export { useClaudeUsage, useCodexUsage, useZaiUsage } from './use-usage'; // Running Agents export { useRunningAgents, useRunningAgentsCount } from './use-running-agents'; diff --git a/apps/ui/src/hooks/queries/use-usage.ts b/apps/ui/src/hooks/queries/use-usage.ts index 523c53f19..c159ac068 100644 --- a/apps/ui/src/hooks/queries/use-usage.ts +++ b/apps/ui/src/hooks/queries/use-usage.ts @@ -1,7 +1,7 @@ /** * Usage Query Hooks * - * React Query hooks for fetching Claude and Codex API usage data. + * React Query hooks for fetching Claude, Codex, and z.ai API usage data. * These hooks include automatic polling for real-time usage updates. */ @@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query'; import { getElectronAPI } from '@/lib/electron'; import { queryKeys } from '@/lib/query-keys'; import { STALE_TIMES } from '@/lib/query-client'; -import type { ClaudeUsage, CodexUsage } from '@/store/app-store'; +import type { ClaudeUsage, CodexUsage, ZaiUsage } from '@/store/app-store'; /** Polling interval for usage data (60 seconds) */ const USAGE_POLLING_INTERVAL = 60 * 1000; @@ -87,3 +87,36 @@ export function useCodexUsage(enabled = true) { refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, }); } + +/** + * Fetch z.ai API usage data + * + * @param enabled - Whether the query should run (default: true) + * @returns Query result with z.ai usage data + * + * @example + * ```tsx + * const { data: usage, isLoading } = useZaiUsage(isPopoverOpen); + * ``` + */ +export function useZaiUsage(enabled = true) { + return useQuery({ + queryKey: queryKeys.usage.zai(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.zai.getUsage(); + // Check if result is an error response + if ('error' in result) { + throw new Error(result.message || result.error); + } + return result; + }, + enabled, + staleTime: STALE_TIMES.USAGE, + refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false, + // Keep previous data while refetching + placeholderData: (previousData) => previousData, + refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS, + refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, + }); +} diff --git a/apps/ui/src/hooks/use-provider-auth-init.ts b/apps/ui/src/hooks/use-provider-auth-init.ts index ae95d1212..c784e7bd4 100644 --- a/apps/ui/src/hooks/use-provider-auth-init.ts +++ b/apps/ui/src/hooks/use-provider-auth-init.ts @@ -1,18 +1,29 @@ import { useEffect, useRef, useCallback } from 'react'; -import { useSetupStore, type ClaudeAuthMethod, type CodexAuthMethod } from '@/store/setup-store'; +import { + useSetupStore, + type ClaudeAuthMethod, + type CodexAuthMethod, + type ZaiAuthMethod, +} from '@/store/setup-store'; import { getHttpApiClient } from '@/lib/http-api-client'; import { createLogger } from '@automaker/utils/logger'; const logger = createLogger('ProviderAuthInit'); /** - * Hook to initialize Claude and Codex authentication statuses on app startup. + * Hook to initialize Claude, Codex, and z.ai authentication statuses on app startup. * This ensures that usage tracking information is available in the board header * without needing to visit the settings page first. */ export function useProviderAuthInit() { - const { setClaudeAuthStatus, setCodexAuthStatus, claudeAuthStatus, codexAuthStatus } = - useSetupStore(); + const { + setClaudeAuthStatus, + setCodexAuthStatus, + setZaiAuthStatus, + claudeAuthStatus, + codexAuthStatus, + zaiAuthStatus, + } = useSetupStore(); const initialized = useRef(false); const refreshStatuses = useCallback(async () => { @@ -88,15 +99,40 @@ export function useProviderAuthInit() { } catch (error) { logger.error('Failed to init Codex auth status:', error); } - }, [setClaudeAuthStatus, setCodexAuthStatus]); + + // 3. z.ai Auth Status + try { + const result = await api.zai.getStatus(); + if (result.success || result.available !== undefined) { + let method: ZaiAuthMethod = 'none'; + if (result.hasEnvApiKey) { + method = 'api_key_env'; + } else if (result.hasApiKey || result.available) { + method = 'api_key'; + } + + setZaiAuthStatus({ + authenticated: result.available, + method, + hasApiKey: result.hasApiKey ?? result.available, + hasEnvApiKey: result.hasEnvApiKey ?? false, + }); + } + } catch (error) { + logger.error('Failed to init z.ai auth status:', error); + } + }, [setClaudeAuthStatus, setCodexAuthStatus, setZaiAuthStatus]); useEffect(() => { // Only initialize once per session if not already set - if (initialized.current || (claudeAuthStatus !== null && codexAuthStatus !== null)) { + if ( + initialized.current || + (claudeAuthStatus !== null && codexAuthStatus !== null && zaiAuthStatus !== null) + ) { return; } initialized.current = true; void refreshStatuses(); - }, [refreshStatuses, claudeAuthStatus, codexAuthStatus]); + }, [refreshStatuses, claudeAuthStatus, codexAuthStatus, zaiAuthStatus]); } diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 22079822c..145684531 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1,6 +1,6 @@ // Type definitions for Electron IPC API import type { SessionListItem, Message } from '@/types/electron'; -import type { ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store'; +import type { ClaudeUsageResponse, CodexUsageResponse, ZaiUsageResponse } from '@/store/app-store'; import type { IssueValidationVerdict, IssueValidationConfidence, @@ -865,6 +865,15 @@ export interface ElectronAPI { error?: string; }>; }; + zai?: { + getUsage: () => Promise; + verify: (apiKey: string) => Promise<{ + success: boolean; + authenticated: boolean; + message?: string; + error?: string; + }>; + }; settings?: { getStatus: () => Promise<{ success: boolean; @@ -1364,6 +1373,51 @@ const _getMockElectronAPI = (): ElectronAPI => { }; }, }, + + // Mock z.ai API + zai: { + getUsage: async () => { + console.log('[Mock] Getting z.ai usage'); + return { + quotaLimits: { + tokens: { + limitType: 'TOKENS_LIMIT', + limit: 1000000, + used: 250000, + remaining: 750000, + usedPercent: 25, + nextResetTime: Date.now() + 86400000, + }, + time: { + limitType: 'TIME_LIMIT', + limit: 3600, + used: 900, + remaining: 2700, + usedPercent: 25, + nextResetTime: Date.now() + 3600000, + }, + planType: 'standard', + }, + lastUpdated: new Date().toISOString(), + }; + }, + verify: async (apiKey: string) => { + console.log('[Mock] Verifying z.ai API key'); + // Mock successful verification if key is provided + if (apiKey && apiKey.trim().length > 0) { + return { + success: true, + authenticated: true, + message: 'Connection successful! z.ai API responded.', + }; + } + return { + success: false, + authenticated: false, + error: 'Please provide an API key to test.', + }; + }, + }, }; }; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index acd75d22a..b65ab872e 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1737,6 +1737,67 @@ export class HttpApiClient implements ElectronAPI { }, }; + // z.ai API + zai = { + getStatus: (): Promise<{ + success: boolean; + available: boolean; + message?: string; + hasApiKey?: boolean; + hasEnvApiKey?: boolean; + error?: string; + }> => this.get('/api/zai/status'), + + getUsage: (): Promise<{ + quotaLimits?: { + tokens?: { + limitType: string; + limit: number; + used: number; + remaining: number; + usedPercent: number; + nextResetTime: number; + }; + time?: { + limitType: string; + limit: number; + used: number; + remaining: number; + usedPercent: number; + nextResetTime: number; + }; + planType: string; + } | null; + usageDetails?: Array<{ + modelId: string; + used: number; + limit: number; + }>; + lastUpdated: string; + error?: string; + message?: string; + }> => this.get('/api/zai/usage'), + + configure: ( + apiToken?: string, + apiHost?: string + ): Promise<{ + success: boolean; + message?: string; + isAvailable?: boolean; + error?: string; + }> => this.post('/api/zai/configure', { apiToken, apiHost }), + + verify: ( + apiKey: string + ): Promise<{ + success: boolean; + authenticated: boolean; + message?: string; + error?: string; + }> => this.post('/api/zai/verify', { apiKey }), + }; + // Features API features: FeaturesAPI & { bulkUpdate: ( diff --git a/apps/ui/src/lib/query-keys.ts b/apps/ui/src/lib/query-keys.ts index afe4b5b09..aad0208d9 100644 --- a/apps/ui/src/lib/query-keys.ts +++ b/apps/ui/src/lib/query-keys.ts @@ -99,6 +99,8 @@ export const queryKeys = { claude: () => ['usage', 'claude'] as const, /** Codex API usage */ codex: () => ['usage', 'codex'] as const, + /** z.ai API usage */ + zai: () => ['usage', 'zai'] as const, }, // ============================================ diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index c07353554..4d4868b63 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -94,6 +94,10 @@ import { type CodexRateLimitWindow, type CodexUsage, type CodexUsageResponse, + type ZaiPlanType, + type ZaiQuotaLimit, + type ZaiUsage, + type ZaiUsageResponse, } from './types'; // Import utility functions from modular utils files @@ -173,6 +177,10 @@ export type { CodexRateLimitWindow, CodexUsage, CodexUsageResponse, + ZaiPlanType, + ZaiQuotaLimit, + ZaiUsage, + ZaiUsageResponse, }; // Re-export values from ./types for backward compatibility @@ -234,6 +242,7 @@ const initialState: AppState = { anthropic: '', google: '', openai: '', + zai: '', }, chatSessions: [], currentChatSession: null, @@ -314,6 +323,8 @@ const initialState: AppState = { claudeUsageLastUpdated: null, codexUsage: null, codexUsageLastUpdated: null, + zaiUsage: null, + zaiUsageLastUpdated: null, codexModels: [], codexModelsLoading: false, codexModelsError: null, @@ -2400,6 +2411,9 @@ export const useAppStore = create()((set, get) => ({ // Codex Usage Tracking actions setCodexUsage: (usage) => set({ codexUsage: usage, codexUsageLastUpdated: Date.now() }), + // z.ai Usage Tracking actions + setZaiUsage: (usage) => set({ zaiUsage: usage, zaiUsageLastUpdated: usage ? Date.now() : null }), + // Codex Models actions fetchCodexModels: async (forceRefresh = false) => { const state = get(); diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index f354e5b1e..27a9bdac8 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -112,6 +112,21 @@ export interface CodexAuthStatus { error?: string; } +// z.ai Auth Method +export type ZaiAuthMethod = + | 'api_key_env' // Z_AI_API_KEY environment variable + | 'api_key' // Manually stored API key + | 'none'; + +// z.ai Auth Status +export interface ZaiAuthStatus { + authenticated: boolean; + method: ZaiAuthMethod; + hasApiKey?: boolean; + hasEnvApiKey?: boolean; + error?: string; +} + // Claude Auth Method - all possible authentication sources export type ClaudeAuthMethod = | 'oauth_token_env' @@ -189,6 +204,9 @@ export interface SetupState { // Copilot SDK state copilotCliStatus: CopilotCliStatus | null; + // z.ai API state + zaiAuthStatus: ZaiAuthStatus | null; + // Setup preferences skipClaudeSetup: boolean; } @@ -229,6 +247,9 @@ export interface SetupActions { // Copilot SDK setCopilotCliStatus: (status: CopilotCliStatus | null) => void; + // z.ai API + setZaiAuthStatus: (status: ZaiAuthStatus | null) => void; + // Preferences setSkipClaudeSetup: (skip: boolean) => void; } @@ -266,6 +287,8 @@ const initialState: SetupState = { copilotCliStatus: null, + zaiAuthStatus: null, + skipClaudeSetup: shouldSkipSetup, }; @@ -344,6 +367,9 @@ export const useSetupStore = create()((set, get) => ( // Copilot SDK setCopilotCliStatus: (status) => set({ copilotCliStatus: status }), + // z.ai API + setZaiAuthStatus: (status) => set({ zaiAuthStatus: status }), + // Preferences setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }), })); diff --git a/apps/ui/src/store/types/settings-types.ts b/apps/ui/src/store/types/settings-types.ts index 6adb80973..bf371fd0b 100644 --- a/apps/ui/src/store/types/settings-types.ts +++ b/apps/ui/src/store/types/settings-types.ts @@ -2,4 +2,5 @@ export interface ApiKeys { anthropic: string; google: string; openai: string; + zai: string; } diff --git a/apps/ui/src/store/types/state-types.ts b/apps/ui/src/store/types/state-types.ts index 4febb1caa..7bf019687 100644 --- a/apps/ui/src/store/types/state-types.ts +++ b/apps/ui/src/store/types/state-types.ts @@ -36,7 +36,7 @@ import type { ApiKeys } from './settings-types'; import type { ChatMessage, ChatSession, FeatureImage } from './chat-types'; import type { TerminalState, TerminalPanelContent, PersistedTerminalState } from './terminal-types'; import type { Feature, ProjectAnalysis } from './project-types'; -import type { ClaudeUsage, CodexUsage } from './usage-types'; +import type { ClaudeUsage, CodexUsage, ZaiUsage } from './usage-types'; /** State for worktree init script execution */ export interface InitScriptState { @@ -297,6 +297,10 @@ export interface AppState { codexUsage: CodexUsage | null; codexUsageLastUpdated: number | null; + // z.ai Usage Tracking + zaiUsage: ZaiUsage | null; + zaiUsageLastUpdated: number | null; + // Codex Models (dynamically fetched) codexModels: Array<{ id: string; @@ -764,6 +768,9 @@ export interface AppActions { // Codex Usage Tracking actions setCodexUsage: (usage: CodexUsage | null) => void; + // z.ai Usage Tracking actions + setZaiUsage: (usage: ZaiUsage | null) => void; + // Codex Models actions fetchCodexModels: (forceRefresh?: boolean) => Promise; setCodexModels: ( diff --git a/apps/ui/src/store/types/usage-types.ts b/apps/ui/src/store/types/usage-types.ts index e097526c2..e7c47a5d2 100644 --- a/apps/ui/src/store/types/usage-types.ts +++ b/apps/ui/src/store/types/usage-types.ts @@ -58,3 +58,27 @@ export interface CodexUsage { // Response type for Codex usage API (can be success or error) export type CodexUsageResponse = CodexUsage | { error: string; message?: string }; + +// z.ai Usage types +export type ZaiPlanType = 'free' | 'basic' | 'standard' | 'professional' | 'enterprise' | 'unknown'; + +export interface ZaiQuotaLimit { + limitType: 'TOKENS_LIMIT' | 'TIME_LIMIT' | string; + limit: number; + used: number; + remaining: number; + usedPercent: number; // Percentage used (0-100) + nextResetTime: number; // Epoch milliseconds +} + +export interface ZaiUsage { + quotaLimits: { + tokens?: ZaiQuotaLimit; + mcp?: ZaiQuotaLimit; + planType: ZaiPlanType; + } | null; + lastUpdated: string; +} + +// Response type for z.ai usage API (can be success or error) +export type ZaiUsageResponse = ZaiUsage | { error: string; message?: string }; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 06743faa4..a71cde89a 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -1287,6 +1287,8 @@ export interface Credentials { google: string; /** OpenAI API key (for compatibility or alternative providers) */ openai: string; + /** z.ai API key (for GLM models and usage tracking) */ + zai: string; }; } @@ -1615,6 +1617,7 @@ export const DEFAULT_CREDENTIALS: Credentials = { anthropic: '', google: '', openai: '', + zai: '', }, }; From 7d5bc722fa04e4a0efb9a53e8f2731eb34134283 Mon Sep 17 00:00:00 2001 From: eclipxe Date: Sun, 25 Jan 2026 09:44:03 -0800 Subject: [PATCH 2/5] Feat: Show Gemini Usage in usage dropdown and mobile sidebar --- apps/server/src/index.ts | 2 + apps/server/src/routes/gemini/index.ts | 60 ++ .../src/services/gemini-usage-service.ts | 761 ++++++++++++++++++ apps/ui/src/components/usage-popover.tsx | 259 +++++- .../views/board-view/board-header.tsx | 13 +- .../views/board-view/header-mobile-menu.tsx | 5 +- .../views/board-view/mobile-usage-bar.tsx | 87 +- apps/ui/src/hooks/queries/index.ts | 2 +- apps/ui/src/hooks/queries/use-usage.ts | 45 +- apps/ui/src/hooks/use-provider-auth-init.ts | 68 +- apps/ui/src/lib/electron.ts | 24 +- apps/ui/src/lib/http-api-client.ts | 11 +- apps/ui/src/lib/query-keys.ts | 2 + apps/ui/src/store/app-store.ts | 13 +- apps/ui/src/store/setup-store.ts | 20 + apps/ui/src/store/types/usage-types.ts | 52 ++ package-lock.json | 11 - 17 files changed, 1374 insertions(+), 61 deletions(-) create mode 100644 apps/server/src/routes/gemini/index.ts create mode 100644 apps/server/src/services/gemini-usage-service.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index acff315e4..c27bac185 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -68,6 +68,7 @@ import { CodexAppServerService } from './services/codex-app-server-service.js'; import { CodexModelCacheService } from './services/codex-model-cache-service.js'; import { createZaiRoutes } from './routes/zai/index.js'; import { ZaiUsageService } from './services/zai-usage-service.js'; +import { createGeminiRoutes } from './routes/gemini/index.js'; import { createGitHubRoutes } from './routes/github/index.js'; import { createContextRoutes } from './routes/context/index.js'; import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js'; @@ -438,6 +439,7 @@ app.use('/api/settings', createSettingsRoutes(settingsService)); app.use('/api/claude', createClaudeRoutes(claudeUsageService)); app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService)); app.use('/api/zai', createZaiRoutes(zaiUsageService, settingsService)); +app.use('/api/gemini', createGeminiRoutes()); 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/routes/gemini/index.ts b/apps/server/src/routes/gemini/index.ts new file mode 100644 index 000000000..c543d827b --- /dev/null +++ b/apps/server/src/routes/gemini/index.ts @@ -0,0 +1,60 @@ +import { Router, Request, Response } from 'express'; +import { GeminiProvider } from '../../providers/gemini-provider.js'; +import { getGeminiUsageService } from '../../services/gemini-usage-service.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('Gemini'); + +export function createGeminiRoutes(): Router { + const router = Router(); + + // Get current usage/quota data from Google Cloud API + router.get('/usage', async (_req: Request, res: Response) => { + try { + const usageService = getGeminiUsageService(); + const usageData = await usageService.fetchUsageData(); + + res.json(usageData); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error fetching Gemini usage:', error); + + // Return error in a format the UI expects + res.status(200).json({ + authenticated: false, + authMethod: 'none', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: `Failed to fetch Gemini usage: ${message}`, + }); + } + }); + + // Check if Gemini is available + router.get('/status', async (_req: Request, res: Response) => { + try { + const provider = new GeminiProvider(); + const status = await provider.detectInstallation(); + + const authMethod = + (status as any).authMethod || + (status.authenticated ? (status.hasApiKey ? 'api_key' : 'cli_login') : 'none'); + + res.json({ + success: true, + installed: status.installed, + version: status.version || null, + path: status.path || null, + authenticated: status.authenticated || false, + authMethod, + hasCredentialsFile: (status as any).hasCredentialsFile || false, + }); + } catch (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/gemini-usage-service.ts b/apps/server/src/services/gemini-usage-service.ts new file mode 100644 index 000000000..966d09a49 --- /dev/null +++ b/apps/server/src/services/gemini-usage-service.ts @@ -0,0 +1,761 @@ +/** + * Gemini Usage Service + * + * Service for tracking Gemini CLI usage and quota. + * Uses the internal Google Cloud quota API (same as CodexBar). + * See: https://github.com/steipete/CodexBar/blob/main/docs/gemini.md + * + * OAuth credentials are extracted from the Gemini CLI installation, + * not hardcoded, to ensure compatibility with CLI updates. + */ + +import { createLogger } from '@automaker/utils'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execSync } from 'child_process'; + +const logger = createLogger('GeminiUsage'); + +// Quota API endpoint (internal Google Cloud API) +const QUOTA_API_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota'; + +// Code Assist endpoint for getting project ID and tier info +const CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist'; + +// Google OAuth endpoints for token refresh +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; + +export interface GeminiQuotaBucket { + /** Model ID this quota applies to */ + modelId: string; + /** Remaining fraction (0-1) */ + remainingFraction: number; + /** ISO-8601 reset time */ + resetTime: string; +} + +/** Simplified quota info for a model tier (Flash or Pro) */ +export interface GeminiTierQuota { + /** Used percentage (0-100) */ + usedPercent: number; + /** Remaining percentage (0-100) */ + remainingPercent: number; + /** Reset time as human-readable string */ + resetText?: string; + /** ISO-8601 reset time */ + resetTime?: string; +} + +export interface GeminiUsageData { + /** Whether authenticated via CLI */ + authenticated: boolean; + /** Authentication method */ + authMethod: 'cli_login' | 'api_key' | 'none'; + /** Usage percentage (100 - remainingFraction * 100) - overall most constrained */ + usedPercent: number; + /** Remaining percentage - overall most constrained */ + remainingPercent: number; + /** Reset time as human-readable string */ + resetText?: string; + /** ISO-8601 reset time */ + resetTime?: string; + /** Model ID with lowest remaining quota */ + constrainedModel?: string; + /** Flash tier quota (aggregated from all flash models) */ + flashQuota?: GeminiTierQuota; + /** Pro tier quota (aggregated from all pro models) */ + proQuota?: GeminiTierQuota; + /** Raw quota buckets for detailed view */ + quotaBuckets?: GeminiQuotaBucket[]; + /** When this data was last fetched */ + lastUpdated: string; + /** Optional error message */ + error?: string; +} + +interface OAuthCredentials { + access_token?: string; + id_token?: string; + refresh_token?: string; + token_type?: string; + expiry_date?: number; + client_id?: string; + client_secret?: string; +} + +interface OAuthClientCredentials { + clientId: string; + clientSecret: string; +} + +interface QuotaResponse { + // The actual API returns 'buckets', not 'quotaBuckets' + buckets?: Array<{ + modelId?: string; + remainingFraction?: number; + resetTime?: string; + tokenType?: string; + }>; + // Legacy field name (in case API changes) + quotaBuckets?: Array<{ + modelId?: string; + remainingFraction?: number; + resetTime?: string; + tokenType?: string; + }>; +} + +/** + * Gemini Usage Service + * + * Provides real usage/quota data for Gemini CLI users. + * Extracts OAuth credentials from the Gemini CLI installation. + */ +export class GeminiUsageService { + private cachedCredentials: OAuthCredentials | null = null; + private cachedClientCredentials: OAuthClientCredentials | null = null; + private credentialsPath: string; + + constructor() { + // Default credentials path for Gemini CLI + this.credentialsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json'); + } + + /** + * Check if Gemini CLI is authenticated + */ + async isAvailable(): Promise { + const creds = await this.loadCredentials(); + return Boolean(creds?.access_token || creds?.refresh_token); + } + + /** + * Fetch quota/usage data from Google Cloud API + */ + async fetchUsageData(): Promise { + logger.info('[fetchUsageData] Starting...'); + + const creds = await this.loadCredentials(); + + if (!creds || (!creds.access_token && !creds.refresh_token)) { + logger.info('[fetchUsageData] No credentials found'); + return { + authenticated: false, + authMethod: 'none', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: 'Not authenticated. Run "gemini auth login" to authenticate.', + }; + } + + try { + // Get a valid access token (refresh if needed) + const accessToken = await this.getValidAccessToken(creds); + + if (!accessToken) { + return { + authenticated: false, + authMethod: 'none', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: 'Failed to obtain access token. Try running "gemini auth login" again.', + }; + } + + // First, get the project ID from loadCodeAssist endpoint + // This is required to get accurate quota data + let projectId: string | undefined; + try { + const codeAssistResponse = await fetch(CODE_ASSIST_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + + if (codeAssistResponse.ok) { + const codeAssistData = (await codeAssistResponse.json()) as { + cloudaicompanionProject?: string; + currentTier?: { id?: string; name?: string }; + }; + projectId = codeAssistData.cloudaicompanionProject; + logger.debug('[fetchUsageData] Got project ID:', projectId); + } + } catch (e) { + logger.debug('[fetchUsageData] Failed to get project ID:', e); + } + + // Fetch quota from Google Cloud API + // Pass project ID to get accurate quota (without it, returns default 100%) + const response = await fetch(QUOTA_API_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(projectId ? { project: projectId } : {}), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + logger.error('[fetchUsageData] Quota API error:', response.status, errorText); + + // Still authenticated, but quota API failed + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: `Quota API unavailable (${response.status})`, + }; + } + + const data = (await response.json()) as QuotaResponse; + + // API returns 'buckets', with fallback to 'quotaBuckets' for compatibility + const apiBuckets = data.buckets || data.quotaBuckets; + + logger.debug('[fetchUsageData] Raw buckets:', JSON.stringify(apiBuckets)); + + if (!apiBuckets || apiBuckets.length === 0) { + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + }; + } + + // Group buckets into Flash and Pro tiers + // Flash: any model with "flash" in the name + // Pro: any model with "pro" in the name + let flashLowestRemaining = 1.0; + let flashResetTime: string | undefined; + let hasFlashModels = false; + let proLowestRemaining = 1.0; + let proResetTime: string | undefined; + let hasProModels = false; + let overallLowestRemaining = 1.0; + let constrainedModel: string | undefined; + let overallResetTime: string | undefined; + + const quotaBuckets: GeminiQuotaBucket[] = apiBuckets.map((bucket) => { + const remaining = bucket.remainingFraction ?? 1.0; + const modelId = bucket.modelId?.toLowerCase() || ''; + + // Track overall lowest + if (remaining < overallLowestRemaining) { + overallLowestRemaining = remaining; + constrainedModel = bucket.modelId; + overallResetTime = bucket.resetTime; + } + + // Group into Flash or Pro tier + if (modelId.includes('flash')) { + hasFlashModels = true; + if (remaining < flashLowestRemaining) { + flashLowestRemaining = remaining; + flashResetTime = bucket.resetTime; + } + // Also track reset time even if at 100% + if (!flashResetTime && bucket.resetTime) { + flashResetTime = bucket.resetTime; + } + } else if (modelId.includes('pro')) { + hasProModels = true; + if (remaining < proLowestRemaining) { + proLowestRemaining = remaining; + proResetTime = bucket.resetTime; + } + // Also track reset time even if at 100% + if (!proResetTime && bucket.resetTime) { + proResetTime = bucket.resetTime; + } + } + + return { + modelId: bucket.modelId || 'unknown', + remainingFraction: remaining, + resetTime: bucket.resetTime || '', + }; + }); + + const usedPercent = Math.round((1 - overallLowestRemaining) * 100); + const remainingPercent = Math.round(overallLowestRemaining * 100); + + // Build tier quotas (only include if we found models for that tier) + const flashQuota: GeminiTierQuota | undefined = hasFlashModels + ? { + usedPercent: Math.round((1 - flashLowestRemaining) * 100), + remainingPercent: Math.round(flashLowestRemaining * 100), + resetText: flashResetTime ? this.formatResetTime(flashResetTime) : undefined, + resetTime: flashResetTime, + } + : undefined; + + const proQuota: GeminiTierQuota | undefined = hasProModels + ? { + usedPercent: Math.round((1 - proLowestRemaining) * 100), + remainingPercent: Math.round(proLowestRemaining * 100), + resetText: proResetTime ? this.formatResetTime(proResetTime) : undefined, + resetTime: proResetTime, + } + : undefined; + + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent, + remainingPercent, + resetText: overallResetTime ? this.formatResetTime(overallResetTime) : undefined, + resetTime: overallResetTime, + constrainedModel, + flashQuota, + proQuota, + quotaBuckets, + lastUpdated: new Date().toISOString(), + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[fetchUsageData] Error:', errorMsg); + + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: `Failed to fetch quota: ${errorMsg}`, + }; + } + } + + /** + * Load OAuth credentials from file + */ + private async loadCredentials(): Promise { + if (this.cachedCredentials) { + return this.cachedCredentials; + } + + // Check multiple possible paths + const possiblePaths = [ + this.credentialsPath, + path.join(os.homedir(), '.gemini', 'oauth_creds.json'), + path.join(os.homedir(), '.config', 'gemini', 'oauth_creds.json'), + ]; + + for (const credPath of possiblePaths) { + try { + if (fs.existsSync(credPath)) { + const content = fs.readFileSync(credPath, 'utf8'); + const creds = JSON.parse(content); + + // Handle different credential formats + if (creds.access_token || creds.refresh_token) { + this.cachedCredentials = creds; + logger.info('[loadCredentials] Loaded from:', credPath); + return creds; + } + + // Some formats nest credentials under 'web' or 'installed' + if (creds.web?.client_id || creds.installed?.client_id) { + const clientCreds = creds.web || creds.installed; + this.cachedCredentials = { + client_id: clientCreds.client_id, + client_secret: clientCreds.client_secret, + }; + return this.cachedCredentials; + } + } + } catch (error) { + logger.debug('[loadCredentials] Failed to load from', credPath, error); + } + } + + return null; + } + + /** + * Find the Gemini CLI binary path + */ + private findGeminiBinaryPath(): string | null { + try { + // Try 'which' on Unix-like systems + const whichResult = execSync('which gemini 2>/dev/null', { encoding: 'utf8' }).trim(); + if (whichResult && fs.existsSync(whichResult)) { + return whichResult; + } + } catch { + // Ignore errors from 'which' + } + + // Check common installation paths + const possiblePaths = [ + // npm global installs + path.join(os.homedir(), '.npm-global', 'bin', 'gemini'), + '/usr/local/bin/gemini', + '/usr/bin/gemini', + // Homebrew + '/opt/homebrew/bin/gemini', + '/usr/local/opt/gemini/bin/gemini', + // nvm/fnm node installs + path.join(os.homedir(), '.nvm', 'versions', 'node'), + path.join(os.homedir(), '.fnm', 'node-versions'), + // Windows + path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini.cmd'), + path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini'), + ]; + + for (const p of possiblePaths) { + if (fs.existsSync(p)) { + return p; + } + } + + return null; + } + + /** + * Extract OAuth client credentials from Gemini CLI installation + * This mimics CodexBar's approach of finding oauth2.js in the CLI + */ + private extractOAuthClientCredentials(): OAuthClientCredentials | null { + if (this.cachedClientCredentials) { + return this.cachedClientCredentials; + } + + const geminiBinary = this.findGeminiBinaryPath(); + if (!geminiBinary) { + logger.debug('[extractOAuthClientCredentials] Gemini binary not found'); + return null; + } + + // Resolve symlinks to find actual location + let resolvedPath = geminiBinary; + try { + resolvedPath = fs.realpathSync(geminiBinary); + } catch { + // Use original path if realpath fails + } + + const baseDir = path.dirname(resolvedPath); + logger.debug('[extractOAuthClientCredentials] Base dir:', baseDir); + + // Possible locations for oauth2.js relative to the binary + // Based on CodexBar's search patterns + const possibleOAuth2Paths = [ + // npm global install structure + path.join( + baseDir, + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + path.join( + baseDir, + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli-core', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + // Homebrew/libexec structure + path.join( + baseDir, + '..', + 'libexec', + 'lib', + 'node_modules', + '@google', + 'gemini-cli', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + path.join( + baseDir, + '..', + 'libexec', + 'lib', + 'node_modules', + '@google', + 'gemini-cli-core', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + // Direct sibling + path.join(baseDir, '..', 'gemini-cli-core', 'dist', 'src', 'code_assist', 'oauth2.js'), + path.join(baseDir, '..', 'gemini-cli', 'dist', 'src', 'code_assist', 'oauth2.js'), + // Alternative node_modules structures + path.join( + baseDir, + '..', + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + path.join( + baseDir, + '..', + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli-core', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + ]; + + for (const oauth2Path of possibleOAuth2Paths) { + try { + const normalizedPath = path.normalize(oauth2Path); + if (fs.existsSync(normalizedPath)) { + logger.debug('[extractOAuthClientCredentials] Found oauth2.js at:', normalizedPath); + const content = fs.readFileSync(normalizedPath, 'utf8'); + const creds = this.parseOAuthCredentialsFromSource(content); + if (creds) { + this.cachedClientCredentials = creds; + logger.info('[extractOAuthClientCredentials] Extracted credentials from CLI'); + return creds; + } + } + } catch (error) { + logger.debug('[extractOAuthClientCredentials] Failed to read', oauth2Path, error); + } + } + + // Try finding oauth2.js by searching in node_modules + try { + const searchResult = execSync( + `find ${baseDir}/.. -name "oauth2.js" -path "*gemini*" -path "*code_assist*" 2>/dev/null | head -1`, + { encoding: 'utf8', timeout: 5000 } + ).trim(); + + if (searchResult && fs.existsSync(searchResult)) { + logger.debug('[extractOAuthClientCredentials] Found via search:', searchResult); + const content = fs.readFileSync(searchResult, 'utf8'); + const creds = this.parseOAuthCredentialsFromSource(content); + if (creds) { + this.cachedClientCredentials = creds; + logger.info( + '[extractOAuthClientCredentials] Extracted credentials from CLI (via search)' + ); + return creds; + } + } + } catch { + // Ignore search errors + } + + logger.warn('[extractOAuthClientCredentials] Could not extract credentials from CLI'); + return null; + } + + /** + * Parse OAuth client credentials from oauth2.js source code + */ + private parseOAuthCredentialsFromSource(content: string): OAuthClientCredentials | null { + // Patterns based on CodexBar's regex extraction + // Look for: OAUTH_CLIENT_ID = "..." or const clientId = "..." + const clientIdPatterns = [ + /OAUTH_CLIENT_ID\s*=\s*["']([^"']+)["']/, + /clientId\s*[:=]\s*["']([^"']+)["']/, + /client_id\s*[:=]\s*["']([^"']+)["']/, + /"clientId"\s*:\s*["']([^"']+)["']/, + ]; + + const clientSecretPatterns = [ + /OAUTH_CLIENT_SECRET\s*=\s*["']([^"']+)["']/, + /clientSecret\s*[:=]\s*["']([^"']+)["']/, + /client_secret\s*[:=]\s*["']([^"']+)["']/, + /"clientSecret"\s*:\s*["']([^"']+)["']/, + ]; + + let clientId: string | null = null; + let clientSecret: string | null = null; + + for (const pattern of clientIdPatterns) { + const match = content.match(pattern); + if (match && match[1]) { + clientId = match[1]; + break; + } + } + + for (const pattern of clientSecretPatterns) { + const match = content.match(pattern); + if (match && match[1]) { + clientSecret = match[1]; + break; + } + } + + if (clientId && clientSecret) { + logger.debug('[parseOAuthCredentialsFromSource] Found client credentials'); + return { clientId, clientSecret }; + } + + return null; + } + + /** + * Get a valid access token, refreshing if necessary + */ + private async getValidAccessToken(creds: OAuthCredentials): Promise { + // Check if current token is still valid (with 5 min buffer) + if (creds.access_token && creds.expiry_date) { + const now = Date.now(); + if (creds.expiry_date > now + 5 * 60 * 1000) { + logger.debug('[getValidAccessToken] Using existing token (not expired)'); + return creds.access_token; + } + } + + // If we have a refresh token, try to refresh + if (creds.refresh_token) { + // Try to extract credentials from CLI first + const extractedCreds = this.extractOAuthClientCredentials(); + + // Use extracted credentials, then fall back to credentials in file + const clientId = extractedCreds?.clientId || creds.client_id; + const clientSecret = extractedCreds?.clientSecret || creds.client_secret; + + if (!clientId || !clientSecret) { + logger.error('[getValidAccessToken] No client credentials available for token refresh'); + // Return existing token even if expired - it might still work + return creds.access_token || null; + } + + try { + logger.debug('[getValidAccessToken] Refreshing token...'); + const response = await fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: creds.refresh_token, + grant_type: 'refresh_token', + }), + }); + + if (response.ok) { + const data = (await response.json()) as { access_token?: string; expires_in?: number }; + const newAccessToken = data.access_token; + const expiresIn = data.expires_in || 3600; + + if (newAccessToken) { + logger.info('[getValidAccessToken] Token refreshed successfully'); + + // Update cached credentials + this.cachedCredentials = { + ...creds, + access_token: newAccessToken, + expiry_date: Date.now() + expiresIn * 1000, + }; + + // Save back to file + try { + fs.writeFileSync( + this.credentialsPath, + JSON.stringify(this.cachedCredentials, null, 2) + ); + } catch (e) { + logger.debug('[getValidAccessToken] Could not save refreshed token:', e); + } + + return newAccessToken; + } + } else { + const errorText = await response.text().catch(() => ''); + logger.error('[getValidAccessToken] Token refresh failed:', response.status, errorText); + } + } catch (error) { + logger.error('[getValidAccessToken] Token refresh error:', error); + } + } + + // Return current access token even if it might be expired + return creds.access_token || null; + } + + /** + * Format reset time as human-readable string + */ + private formatResetTime(isoTime: string): string { + try { + const resetDate = new Date(isoTime); + const now = new Date(); + const diff = resetDate.getTime() - now.getTime(); + + if (diff < 0) { + return 'Resetting soon'; + } + + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + const remainingMins = minutes % 60; + return remainingMins > 0 ? `Resets in ${hours}h ${remainingMins}m` : `Resets in ${hours}h`; + } + + return `Resets in ${minutes}m`; + } catch { + return ''; + } + } + + /** + * Clear cached credentials (useful after logout) + */ + clearCache(): void { + this.cachedCredentials = null; + this.cachedClientCredentials = null; + } +} + +// Singleton instance +let usageServiceInstance: GeminiUsageService | null = null; + +/** + * Get the singleton instance of GeminiUsageService + */ +export function getGeminiUsageService(): GeminiUsageService { + if (!usageServiceInstance) { + usageServiceInstance = new GeminiUsageService(); + } + return usageServiceInstance; +} diff --git a/apps/ui/src/components/usage-popover.tsx b/apps/ui/src/components/usage-popover.tsx index 31bb6d5af..58c6fd274 100644 --- a/apps/ui/src/components/usage-popover.tsx +++ b/apps/ui/src/components/usage-popover.tsx @@ -6,8 +6,8 @@ import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } f import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { useSetupStore } from '@/store/setup-store'; -import { AnthropicIcon, OpenAIIcon, ZaiIcon } from '@/components/ui/provider-icon'; -import { useClaudeUsage, useCodexUsage, useZaiUsage } from '@/hooks/queries'; +import { AnthropicIcon, OpenAIIcon, ZaiIcon, GeminiIcon } from '@/components/ui/provider-icon'; +import { useClaudeUsage, useCodexUsage, useZaiUsage, useGeminiUsage } from '@/hooks/queries'; // Error codes for distinguishing failure modes const ERROR_CODES = { @@ -81,14 +81,16 @@ export function UsagePopover() { const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus); + const geminiAuthStatus = useSetupStore((state) => state.geminiAuthStatus); const [open, setOpen] = useState(false); - const [activeTab, setActiveTab] = useState<'claude' | 'codex' | 'zai'>('claude'); + const [activeTab, setActiveTab] = useState<'claude' | 'codex' | 'zai' | 'gemini'>('claude'); // Check authentication status const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated; const isCodexAuthenticated = codexAuthStatus?.authenticated; const isZaiAuthenticated = zaiAuthStatus?.authenticated; + const isGeminiAuthenticated = geminiAuthStatus?.authenticated; // Use React Query hooks for usage data // Only enable polling when popover is open AND the tab is active @@ -116,6 +118,14 @@ export function UsagePopover() { refetch: refetchZai, } = useZaiUsage(open && activeTab === 'zai' && isZaiAuthenticated); + const { + data: geminiUsage, + isLoading: geminiLoading, + error: geminiQueryError, + dataUpdatedAt: geminiUsageLastUpdated, + refetch: refetchGemini, + } = useGeminiUsage(open && activeTab === 'gemini' && isGeminiAuthenticated); + // Parse errors into structured format const claudeError = useMemo((): UsageError | null => { if (!claudeQueryError) return null; @@ -157,6 +167,19 @@ export function UsagePopover() { return { code: ERROR_CODES.AUTH_ERROR, message }; }, [zaiQueryError]); + const geminiError = useMemo((): UsageError | null => { + if (!geminiQueryError) return null; + const message = + geminiQueryError instanceof Error ? geminiQueryError.message : String(geminiQueryError); + if (message.includes('not configured') || message.includes('not authenticated')) { + return { code: ERROR_CODES.NOT_AVAILABLE, message }; + } + if (message.includes('API bridge')) { + return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message }; + } + return { code: ERROR_CODES.AUTH_ERROR, message }; + }, [geminiQueryError]); + // Determine which tab to show by default useEffect(() => { if (isClaudeAuthenticated) { @@ -165,8 +188,10 @@ export function UsagePopover() { setActiveTab('codex'); } else if (isZaiAuthenticated) { setActiveTab('zai'); + } else if (isGeminiAuthenticated) { + setActiveTab('gemini'); } - }, [isClaudeAuthenticated, isCodexAuthenticated, isZaiAuthenticated]); + }, [isClaudeAuthenticated, isCodexAuthenticated, isZaiAuthenticated, isGeminiAuthenticated]); // Check if data is stale (older than 2 minutes) const isClaudeStale = useMemo(() => { @@ -181,10 +206,15 @@ export function UsagePopover() { return !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000; }, [zaiUsageLastUpdated]); + const isGeminiStale = useMemo(() => { + return !geminiUsageLastUpdated || Date.now() - geminiUsageLastUpdated > 2 * 60 * 1000; + }, [geminiUsageLastUpdated]); + // Refetch functions for manual refresh const fetchClaudeUsage = () => refetchClaude(); const fetchCodexUsage = () => refetchCodex(); const fetchZaiUsage = () => refetchZai(); + const fetchGeminiUsage = () => refetchGemini(); // Derived status color/icon helper const getStatusInfo = (percentage: number) => { @@ -275,6 +305,23 @@ export function UsagePopover() { // Calculate max percentage for header button const claudeSessionPercentage = claudeUsage?.sessionPercentage || 0; + const codexMaxPercentage = codexUsage?.rateLimits + ? Math.max( + codexUsage.rateLimits.primary?.usedPercent || 0, + codexUsage.rateLimits.secondary?.usedPercent || 0 + ) + : 0; + + const zaiMaxPercentage = zaiUsage?.quotaLimits + ? Math.max( + zaiUsage.quotaLimits.tokens?.usedPercent || 0, + zaiUsage.quotaLimits.mcp?.usedPercent || 0 + ) + : 0; + + // Gemini quota from Google Cloud API (if available) + const geminiMaxPercentage = geminiUsage?.usedPercent ?? (geminiUsage?.authenticated ? 0 : 100); + const getProgressBarColor = (percentage: number) => { if (percentage >= 80) return 'bg-red-500'; if (percentage >= 50) return 'bg-yellow-500'; @@ -299,33 +346,43 @@ export function UsagePopover() { const indicatorInfo = activeTab === 'claude' ? { - icon: AnthropicIcon, - percentage: claudeSessionPercentage, - isStale: isClaudeStale, - title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, - } - : activeTab === 'codex' ? { - icon: OpenAIIcon, - percentage: codexWindowUsage ?? 0, - isStale: isCodexStale, - title: `Usage (${codexWindowLabel})`, - } : activeTab === 'zai' ? { - icon: ZaiIcon, - percentage: zaiMaxPercentage, - isStale: isZaiStale, - title: `Usage (z.ai)`, - } : null; + icon: AnthropicIcon, + percentage: claudeSessionPercentage, + isStale: isClaudeStale, + title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, + } + : activeTab === 'codex' + ? { + icon: OpenAIIcon, + percentage: codexWindowUsage ?? 0, + isStale: isCodexStale, + } + : activeTab === 'zai' + ? { + icon: ZaiIcon, + percentage: zaiMaxPercentage, + isStale: isZaiStale, + title: `Usage (z.ai)`, + } + : activeTab === 'gemini' + ? { + icon: GeminiIcon, + percentage: geminiMaxPercentage, + isStale: isGeminiStale, + title: `Usage (Gemini)`, + } + : null; const statusColor = getStatusInfo(indicatorInfo.percentage).color; const ProviderIcon = indicatorInfo.icon; const trigger = ( + )} + + + {/* Content */} +
+ {geminiError ? ( +
+ +
+

+ {geminiError.code === ERROR_CODES.NOT_AVAILABLE + ? 'Gemini not configured' + : geminiError.message} +

+

+ {geminiError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? ( + 'Ensure the Electron bridge is running or restart the app' + ) : geminiError.code === ERROR_CODES.NOT_AVAILABLE ? ( + <> + Run{' '} + gemini auth login{' '} + to authenticate with your Google account + + ) : ( + <>Check your Gemini CLI configuration + )} +

+
+
+ ) : !geminiUsage ? ( +
+ +

Loading usage data...

+
+ ) : geminiUsage.authenticated ? ( + <> + {/* Show Flash and Pro quota tiers */} + {geminiUsage.flashQuota || geminiUsage.proQuota ? ( +
+ {geminiUsage.flashQuota && ( + + )} + {geminiUsage.proQuota && ( + + )} +
+ ) : ( + <> + {/* No quota data available - show connected status */} +
+
+ +
+
+

Connected

+

+ Authenticated via{' '} + + {geminiUsage.authMethod === 'cli_login' + ? 'CLI Login' + : geminiUsage.authMethod === 'api_key_env' + ? 'API Key (Environment)' + : geminiUsage.authMethod === 'api_key' + ? 'API Key' + : 'Unknown'} + +

+
+
+ +
+

+ {geminiUsage.error ? ( + <>Quota API: {geminiUsage.error} + ) : ( + <>No usage yet or quota data unavailable + )} +

+
+ + )} + + ) : ( +
+ +

Not authenticated

+

+ Run gemini auth login{' '} + to authenticate +

+
+ )} +
+ + {/* Footer */} +
+ + Google AI + + Updates every minute +
+ diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index 05303b85d..8e3654e36 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -82,6 +82,7 @@ export function BoardHeader({ ); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus); + const geminiAuthStatus = useSetupStore((state) => state.geminiAuthStatus); // Worktree panel visibility (per-project) const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject); @@ -116,6 +117,9 @@ export function BoardHeader({ // z.ai usage tracking visibility logic const showZaiUsage = !!zaiAuthStatus?.authenticated; + // Gemini usage tracking visibility logic + const showGeminiUsage = !!geminiAuthStatus?.authenticated; + // State for mobile actions panel const [showActionsPanel, setShowActionsPanel] = useState(false); const [isRefreshingBoard, setIsRefreshingBoard] = useState(false); @@ -163,9 +167,11 @@ export function BoardHeader({ )} {/* Usage Popover - show if any provider is authenticated, only on desktop */} - {isMounted && !isTablet && (showClaudeUsage || showCodexUsage || showZaiUsage) && ( - - )} + {isMounted && + !isTablet && + (showClaudeUsage || showCodexUsage || showZaiUsage || showGeminiUsage) && ( + + )} {/* Tablet/Mobile view: show hamburger menu with all controls */} {isMounted && isTablet && ( @@ -185,6 +191,7 @@ export function BoardHeader({ showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} showZaiUsage={showZaiUsage} + showGeminiUsage={showGeminiUsage} /> )} diff --git a/apps/ui/src/components/views/board-view/header-mobile-menu.tsx b/apps/ui/src/components/views/board-view/header-mobile-menu.tsx index 184e436a3..3eed7c0e7 100644 --- a/apps/ui/src/components/views/board-view/header-mobile-menu.tsx +++ b/apps/ui/src/components/views/board-view/header-mobile-menu.tsx @@ -31,6 +31,7 @@ interface HeaderMobileMenuProps { showClaudeUsage: boolean; showCodexUsage: boolean; showZaiUsage?: boolean; + showGeminiUsage?: boolean; } export function HeaderMobileMenu({ @@ -49,13 +50,14 @@ export function HeaderMobileMenu({ showClaudeUsage, showCodexUsage, showZaiUsage = false, + showGeminiUsage = false, }: HeaderMobileMenuProps) { return ( <> {/* Usage Bar - show if any provider is authenticated */} - {(showClaudeUsage || showCodexUsage || showZaiUsage) && ( + {(showClaudeUsage || showCodexUsage || showZaiUsage || showGeminiUsage) && (
Usage @@ -64,6 +66,7 @@ export function HeaderMobileMenu({ showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} showZaiUsage={showZaiUsage} + showGeminiUsage={showGeminiUsage} />
)} diff --git a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx index 28225b507..4755dfbb6 100644 --- a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx +++ b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx @@ -4,12 +4,14 @@ import { cn } from '@/lib/utils'; import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; -import { AnthropicIcon, OpenAIIcon, ZaiIcon } from '@/components/ui/provider-icon'; +import { AnthropicIcon, OpenAIIcon, ZaiIcon, GeminiIcon } from '@/components/ui/provider-icon'; +import type { GeminiUsage } from '@/store/app-store'; interface MobileUsageBarProps { showClaudeUsage: boolean; showCodexUsage: boolean; showZaiUsage?: boolean; + showGeminiUsage?: boolean; } // Helper to get progress bar color based on percentage @@ -152,6 +154,7 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage, showZaiUsage = false, + showGeminiUsage = false, }: MobileUsageBarProps) { const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore(); const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore(); @@ -159,12 +162,17 @@ export function MobileUsageBar({ const [isClaudeLoading, setIsClaudeLoading] = useState(false); const [isCodexLoading, setIsCodexLoading] = useState(false); const [isZaiLoading, setIsZaiLoading] = useState(false); + const [isGeminiLoading, setIsGeminiLoading] = useState(false); + const [geminiUsage, setGeminiUsage] = useState(null); + const [geminiUsageLastUpdated, setGeminiUsageLastUpdated] = useState(null); // Check if data is stale (older than 2 minutes) const isClaudeStale = !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000; const isCodexStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000; const isZaiStale = !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000; + const isGeminiStale = + !geminiUsageLastUpdated || Date.now() - geminiUsageLastUpdated > 2 * 60 * 1000; const fetchClaudeUsage = useCallback(async () => { setIsClaudeLoading(true); @@ -214,6 +222,23 @@ export function MobileUsageBar({ } }, [setZaiUsage]); + const fetchGeminiUsage = useCallback(async () => { + setIsGeminiLoading(true); + try { + const api = getElectronAPI(); + if (!api.gemini) return; + const data = await api.gemini.getUsage(); + if (!('error' in data)) { + setGeminiUsage(data); + setGeminiUsageLastUpdated(Date.now()); + } + } catch { + // Silently fail - usage display is optional + } finally { + setIsGeminiLoading(false); + } + }, []); + const getCodexWindowLabel = (durationMins: number) => { if (durationMins < 60) return `${durationMins}m Window`; if (durationMins < 1440) return `${Math.round(durationMins / 60)}h Window`; @@ -239,8 +264,14 @@ export function MobileUsageBar({ } }, [showZaiUsage, isZaiStale, fetchZaiUsage]); + useEffect(() => { + if (showGeminiUsage && isGeminiStale) { + fetchGeminiUsage(); + } + }, [showGeminiUsage, isGeminiStale, fetchGeminiUsage]); + // Don't render if there's nothing to show - if (!showClaudeUsage && !showCodexUsage && !showZaiUsage) { + if (!showClaudeUsage && !showCodexUsage && !showZaiUsage && !showGeminiUsage) { return null; } @@ -340,6 +371,58 @@ export function MobileUsageBar({ )} )} + + {showGeminiUsage && ( + + {geminiUsage ? ( + geminiUsage.authenticated ? ( + geminiUsage.flashQuota || geminiUsage.proQuota ? ( + <> + {geminiUsage.flashQuota && ( + + )} + {geminiUsage.proQuota && ( + + )} + + ) : ( +
+

+ Connected via{' '} + {geminiUsage.authMethod === 'cli_login' + ? 'CLI Login' + : geminiUsage.authMethod === 'api_key' + ? 'API Key' + : geminiUsage.authMethod} +

+

+ {geminiUsage.error || 'No usage yet'} +

+
+ ) + ) : ( +

Not authenticated

+ ) + ) : ( +

Loading usage data...

+ )} +
+ )} ); } diff --git a/apps/ui/src/hooks/queries/index.ts b/apps/ui/src/hooks/queries/index.ts index 186b5b4e7..5a5730ac4 100644 --- a/apps/ui/src/hooks/queries/index.ts +++ b/apps/ui/src/hooks/queries/index.ts @@ -23,7 +23,7 @@ export { } from './use-github'; // Usage -export { useClaudeUsage, useCodexUsage, useZaiUsage } from './use-usage'; +export { useClaudeUsage, useCodexUsage, useZaiUsage, useGeminiUsage } from './use-usage'; // Running Agents export { useRunningAgents, useRunningAgentsCount } from './use-running-agents'; diff --git a/apps/ui/src/hooks/queries/use-usage.ts b/apps/ui/src/hooks/queries/use-usage.ts index c159ac068..18fedfa77 100644 --- a/apps/ui/src/hooks/queries/use-usage.ts +++ b/apps/ui/src/hooks/queries/use-usage.ts @@ -1,7 +1,7 @@ /** * Usage Query Hooks * - * React Query hooks for fetching Claude, Codex, and z.ai API usage data. + * React Query hooks for fetching Claude, Codex, z.ai, and Gemini API usage data. * These hooks include automatic polling for real-time usage updates. */ @@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query'; import { getElectronAPI } from '@/lib/electron'; import { queryKeys } from '@/lib/query-keys'; import { STALE_TIMES } from '@/lib/query-client'; -import type { ClaudeUsage, CodexUsage, ZaiUsage } from '@/store/app-store'; +import type { ClaudeUsage, CodexUsage, ZaiUsage, GeminiUsage } from '@/store/app-store'; /** Polling interval for usage data (60 seconds) */ const USAGE_POLLING_INTERVAL = 60 * 1000; @@ -33,7 +33,7 @@ export function useClaudeUsage(enabled = true) { queryFn: async (): Promise => { const api = getElectronAPI(); if (!api.claude) { - throw new Error('Claude API not available'); + throw new Error('Claude API bridge unavailable'); } const result = await api.claude.getUsage(); // Check if result is an error response @@ -69,7 +69,7 @@ export function useCodexUsage(enabled = true) { queryFn: async (): Promise => { const api = getElectronAPI(); if (!api.codex) { - throw new Error('Codex API not available'); + throw new Error('Codex API bridge unavailable'); } const result = await api.codex.getUsage(); // Check if result is an error response @@ -104,6 +104,9 @@ export function useZaiUsage(enabled = true) { queryKey: queryKeys.usage.zai(), queryFn: async (): Promise => { const api = getElectronAPI(); + if (!api.zai) { + throw new Error('z.ai API bridge unavailable'); + } const result = await api.zai.getUsage(); // Check if result is an error response if ('error' in result) { @@ -120,3 +123,37 @@ export function useZaiUsage(enabled = true) { refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, }); } + +/** + * Fetch Gemini API usage/status data + * + * @param enabled - Whether the query should run (default: true) + * @returns Query result with Gemini usage data + * + * @example + * ```tsx + * const { data: usage, isLoading } = useGeminiUsage(isPopoverOpen); + * ``` + */ +export function useGeminiUsage(enabled = true) { + return useQuery({ + queryKey: queryKeys.usage.gemini(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + if (!api.gemini) { + throw new Error('Gemini API bridge unavailable'); + } + const result = await api.gemini.getUsage(); + // Server always returns a response with 'authenticated' field, even on error + // So we can safely cast to GeminiUsage + return result as GeminiUsage; + }, + enabled, + staleTime: STALE_TIMES.USAGE, + refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false, + // Keep previous data while refetching + placeholderData: (previousData) => previousData, + refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS, + refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, + }); +} diff --git a/apps/ui/src/hooks/use-provider-auth-init.ts b/apps/ui/src/hooks/use-provider-auth-init.ts index c784e7bd4..f8919b1e0 100644 --- a/apps/ui/src/hooks/use-provider-auth-init.ts +++ b/apps/ui/src/hooks/use-provider-auth-init.ts @@ -4,6 +4,7 @@ import { type ClaudeAuthMethod, type CodexAuthMethod, type ZaiAuthMethod, + type GeminiAuthMethod, } from '@/store/setup-store'; import { getHttpApiClient } from '@/lib/http-api-client'; import { createLogger } from '@automaker/utils/logger'; @@ -11,7 +12,7 @@ import { createLogger } from '@automaker/utils/logger'; const logger = createLogger('ProviderAuthInit'); /** - * Hook to initialize Claude, Codex, and z.ai authentication statuses on app startup. + * Hook to initialize Claude, Codex, z.ai, and Gemini authentication statuses on app startup. * This ensures that usage tracking information is available in the board header * without needing to visit the settings page first. */ @@ -20,9 +21,12 @@ export function useProviderAuthInit() { setClaudeAuthStatus, setCodexAuthStatus, setZaiAuthStatus, + setGeminiCliStatus, + setGeminiAuthStatus, claudeAuthStatus, codexAuthStatus, zaiAuthStatus, + geminiAuthStatus, } = useSetupStore(); const initialized = useRef(false); @@ -121,18 +125,74 @@ export function useProviderAuthInit() { } catch (error) { logger.error('Failed to init z.ai auth status:', error); } - }, [setClaudeAuthStatus, setCodexAuthStatus, setZaiAuthStatus]); + + // 4. Gemini Auth Status + try { + const result = await api.setup.getGeminiStatus(); + if (result.success) { + // Set CLI status + setGeminiCliStatus({ + installed: result.installed ?? false, + version: result.version, + path: result.status, + }); + + // Set Auth status - always set a status to mark initialization as complete + if (result.auth) { + const auth = result.auth; + const validMethods: GeminiAuthMethod[] = ['cli_login', 'api_key_env', 'api_key', 'none']; + + const method = validMethods.includes(auth.method as GeminiAuthMethod) + ? (auth.method as GeminiAuthMethod) + : ((auth.authenticated ? 'cli_login' : 'none') as GeminiAuthMethod); + + setGeminiAuthStatus({ + authenticated: auth.authenticated, + method, + hasApiKey: auth.hasApiKey ?? false, + hasEnvApiKey: auth.hasEnvApiKey ?? false, + }); + } else { + // No auth info available, set default unauthenticated status + setGeminiAuthStatus({ + authenticated: false, + method: 'none', + hasApiKey: false, + hasEnvApiKey: false, + }); + } + } + } catch (error) { + logger.error('Failed to init Gemini auth status:', error); + // Set default status on error to prevent infinite retries + setGeminiAuthStatus({ + authenticated: false, + method: 'none', + hasApiKey: false, + hasEnvApiKey: false, + }); + } + }, [ + setClaudeAuthStatus, + setCodexAuthStatus, + setZaiAuthStatus, + setGeminiCliStatus, + setGeminiAuthStatus, + ]); useEffect(() => { // Only initialize once per session if not already set if ( initialized.current || - (claudeAuthStatus !== null && codexAuthStatus !== null && zaiAuthStatus !== null) + (claudeAuthStatus !== null && + codexAuthStatus !== null && + zaiAuthStatus !== null && + geminiAuthStatus !== null) ) { return; } initialized.current = true; void refreshStatuses(); - }, [refreshStatuses, claudeAuthStatus, codexAuthStatus, zaiAuthStatus]); + }, [refreshStatuses, claudeAuthStatus, codexAuthStatus, zaiAuthStatus, geminiAuthStatus]); } diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 145684531..54ea0c45e 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1,6 +1,11 @@ // Type definitions for Electron IPC API import type { SessionListItem, Message } from '@/types/electron'; -import type { ClaudeUsageResponse, CodexUsageResponse, ZaiUsageResponse } from '@/store/app-store'; +import type { + ClaudeUsageResponse, + CodexUsageResponse, + ZaiUsageResponse, + GeminiUsageResponse, +} from '@/store/app-store'; import type { IssueValidationVerdict, IssueValidationConfidence, @@ -874,6 +879,9 @@ export interface ElectronAPI { error?: string; }>; }; + gemini?: { + getUsage: () => Promise; + }; settings?: { getStatus: () => Promise<{ success: boolean; @@ -1418,6 +1426,20 @@ const _getMockElectronAPI = (): ElectronAPI => { }; }, }, + + // Mock Gemini API + gemini: { + getUsage: async () => { + console.log('[Mock] Getting Gemini usage'); + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + }; + }, + }, }; }; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index b65ab872e..37b6b4166 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -41,7 +41,11 @@ import type { Notification, } from '@automaker/types'; import type { Message, SessionListItem } from '@/types/electron'; -import type { ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store'; +import type { + ClaudeUsageResponse, + CodexUsageResponse, + GeminiUsage, +} from '@/store/app-store'; import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron'; import type { ModelId, ThinkingLevel, ReasoningEffort, Feature } from '@automaker/types'; import { getGlobalFileBrowser } from '@/contexts/file-browser-context'; @@ -2688,6 +2692,11 @@ export class HttpApiClient implements ElectronAPI { }, }; + // Gemini API + gemini = { + getUsage: (): Promise => this.get('/api/gemini/usage'), + }; + // Context API context = { describeImage: ( diff --git a/apps/ui/src/lib/query-keys.ts b/apps/ui/src/lib/query-keys.ts index aad0208d9..70c2679a1 100644 --- a/apps/ui/src/lib/query-keys.ts +++ b/apps/ui/src/lib/query-keys.ts @@ -101,6 +101,8 @@ export const queryKeys = { codex: () => ['usage', 'codex'] as const, /** z.ai API usage */ zai: () => ['usage', 'zai'] as const, + /** Gemini API usage */ + gemini: () => ['usage', 'gemini'] as const, }, // ============================================ diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 4d4868b63..cc5fd64bc 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -98,6 +98,10 @@ import { type ZaiQuotaLimit, type ZaiUsage, type ZaiUsageResponse, + type GeminiQuotaBucket, + type GeminiTierQuota, + type GeminiUsage, + type GeminiUsageResponse, } from './types'; // Import utility functions from modular utils files @@ -181,6 +185,10 @@ export type { ZaiQuotaLimit, ZaiUsage, ZaiUsageResponse, + GeminiQuotaBucket, + GeminiTierQuota, + GeminiUsage, + GeminiUsageResponse, }; // Re-export values from ./types for backward compatibility @@ -210,7 +218,7 @@ export { defaultBackgroundSettings, defaultTerminalState, MAX_INIT_OUTPUT_LINES // - Terminal types (./types/terminal-types.ts) // - ClaudeModel, Feature, FileTreeNode, ProjectAnalysis (./types/project-types.ts) // - InitScriptState, AutoModeActivity, AppState, AppActions (./types/state-types.ts) -// - Claude/Codex usage types (./types/usage-types.ts) +// - Claude/Codex/Zai/Gemini usage types (./types/usage-types.ts) // The following utility functions have been moved to ./utils/: // - Theme utilities: THEME_STORAGE_KEY, getStoredTheme, getStoredFontSans, getStoredFontMono, etc. (./utils/theme-utils.ts) // - Shortcut utilities: parseShortcut, formatShortcut, DEFAULT_KEYBOARD_SHORTCUTS (./utils/shortcut-utils.ts) @@ -220,6 +228,9 @@ export { defaultBackgroundSettings, defaultTerminalState, MAX_INIT_OUTPUT_LINES // - defaultBackgroundSettings (./defaults/background-settings.ts) // - defaultTerminalState (./defaults/terminal-defaults.ts) +// Type definitions are imported from ./types/state-types.ts +// AppActions interface is defined in ./types/state-types.ts + const initialState: AppState = { projects: [], currentProject: null, diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index 27a9bdac8..aae357ea0 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -127,6 +127,22 @@ export interface ZaiAuthStatus { error?: string; } +// Gemini Auth Method +export type GeminiAuthMethod = + | 'cli_login' // Gemini CLI is installed and authenticated + | 'api_key_env' // GOOGLE_API_KEY or GEMINI_API_KEY environment variable + | 'api_key' // Manually stored API key + | 'none'; + +// Gemini Auth Status +export interface GeminiAuthStatus { + authenticated: boolean; + method: GeminiAuthMethod; + hasApiKey?: boolean; + hasEnvApiKey?: boolean; + error?: string; +} + // Claude Auth Method - all possible authentication sources export type ClaudeAuthMethod = | 'oauth_token_env' @@ -200,6 +216,7 @@ export interface SetupState { // Gemini CLI state geminiCliStatus: GeminiCliStatus | null; + geminiAuthStatus: GeminiAuthStatus | null; // Copilot SDK state copilotCliStatus: CopilotCliStatus | null; @@ -243,6 +260,7 @@ export interface SetupActions { // Gemini CLI setGeminiCliStatus: (status: GeminiCliStatus | null) => void; + setGeminiAuthStatus: (status: GeminiAuthStatus | null) => void; // Copilot SDK setCopilotCliStatus: (status: CopilotCliStatus | null) => void; @@ -284,6 +302,7 @@ const initialState: SetupState = { opencodeCliStatus: null, geminiCliStatus: null, + geminiAuthStatus: null, copilotCliStatus: null, @@ -363,6 +382,7 @@ export const useSetupStore = create()((set, get) => ( // Gemini CLI setGeminiCliStatus: (status) => set({ geminiCliStatus: status }), + setGeminiAuthStatus: (status) => set({ geminiAuthStatus: status }), // Copilot SDK setCopilotCliStatus: (status) => set({ copilotCliStatus: status }), diff --git a/apps/ui/src/store/types/usage-types.ts b/apps/ui/src/store/types/usage-types.ts index e7c47a5d2..0b6536f3a 100644 --- a/apps/ui/src/store/types/usage-types.ts +++ b/apps/ui/src/store/types/usage-types.ts @@ -82,3 +82,55 @@ export interface ZaiUsage { // Response type for z.ai usage API (can be success or error) export type ZaiUsageResponse = ZaiUsage | { error: string; message?: string }; + +// Gemini Usage types - uses internal Google Cloud quota API +export interface GeminiQuotaBucket { + /** Model ID this quota applies to */ + modelId: string; + /** Remaining fraction (0-1) */ + remainingFraction: number; + /** ISO-8601 reset time */ + resetTime: string; +} + +/** Simplified quota info for a model tier (Flash or Pro) */ +export interface GeminiTierQuota { + /** Used percentage (0-100) */ + usedPercent: number; + /** Remaining percentage (0-100) */ + remainingPercent: number; + /** Reset time as human-readable string */ + resetText?: string; + /** ISO-8601 reset time */ + resetTime?: string; +} + +export interface GeminiUsage { + /** Whether the user is authenticated (via CLI or API key) */ + authenticated: boolean; + /** Authentication method: 'cli_login' | 'api_key' | 'api_key_env' | 'none' */ + authMethod: string; + /** Usage percentage (100 - remainingFraction * 100) - overall most constrained */ + usedPercent: number; + /** Remaining percentage - overall most constrained */ + remainingPercent: number; + /** Reset time as human-readable string */ + resetText?: string; + /** ISO-8601 reset time */ + resetTime?: string; + /** Model ID with lowest remaining quota */ + constrainedModel?: string; + /** Flash tier quota (aggregated from all flash models) */ + flashQuota?: GeminiTierQuota; + /** Pro tier quota (aggregated from all pro models) */ + proQuota?: GeminiTierQuota; + /** Raw quota buckets for detailed view */ + quotaBuckets?: GeminiQuotaBucket[]; + /** When this data was last fetched */ + lastUpdated: string; + /** Optional error message */ + error?: string; +} + +// Response type for Gemini usage API (can be success or error) +export type GeminiUsageResponse = GeminiUsage | { error: string; message?: string }; diff --git a/package-lock.json b/package-lock.json index 8804b479c..96c4ff7fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11475,7 +11475,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11497,7 +11496,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11519,7 +11517,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11541,7 +11538,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11563,7 +11559,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11585,7 +11580,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11607,7 +11601,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11629,7 +11622,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11651,7 +11643,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11673,7 +11664,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11695,7 +11685,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, From ac2e8cfa882679c3c1cab845f7a6fd9afb4a9f40 Mon Sep 17 00:00:00 2001 From: eclipxe Date: Tue, 20 Jan 2026 14:34:15 -0800 Subject: [PATCH 3/5] Feat: Add z.ai usage tracking --- apps/ui/src/components/usage-popover.tsx | 50 ++++++++++++------------ 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/apps/ui/src/components/usage-popover.tsx b/apps/ui/src/components/usage-popover.tsx index 58c6fd274..bb3b35675 100644 --- a/apps/ui/src/components/usage-popover.tsx +++ b/apps/ui/src/components/usage-popover.tsx @@ -307,16 +307,16 @@ export function UsagePopover() { const codexMaxPercentage = codexUsage?.rateLimits ? Math.max( - codexUsage.rateLimits.primary?.usedPercent || 0, - codexUsage.rateLimits.secondary?.usedPercent || 0 - ) + codexUsage.rateLimits.primary?.usedPercent || 0, + codexUsage.rateLimits.secondary?.usedPercent || 0 + ) : 0; const zaiMaxPercentage = zaiUsage?.quotaLimits ? Math.max( - zaiUsage.quotaLimits.tokens?.usedPercent || 0, - zaiUsage.quotaLimits.mcp?.usedPercent || 0 - ) + zaiUsage.quotaLimits.tokens?.usedPercent || 0, + zaiUsage.quotaLimits.mcp?.usedPercent || 0 + ) : 0; // Gemini quota from Google Cloud API (if available) @@ -346,31 +346,31 @@ export function UsagePopover() { const indicatorInfo = activeTab === 'claude' ? { - icon: AnthropicIcon, - percentage: claudeSessionPercentage, - isStale: isClaudeStale, - title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, - } + icon: AnthropicIcon, + percentage: claudeSessionPercentage, + isStale: isClaudeStale, + title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, + } : activeTab === 'codex' ? { - icon: OpenAIIcon, - percentage: codexWindowUsage ?? 0, - isStale: isCodexStale, - } + icon: OpenAIIcon, + percentage: codexWindowUsage ?? 0, + isStale: isCodexStale, + } : activeTab === 'zai' ? { - icon: ZaiIcon, - percentage: zaiMaxPercentage, - isStale: isZaiStale, - title: `Usage (z.ai)`, - } + icon: ZaiIcon, + percentage: zaiMaxPercentage, + isStale: isZaiStale, + title: `Usage (z.ai)`, + } : activeTab === 'gemini' ? { - icon: GeminiIcon, - percentage: geminiMaxPercentage, - isStale: isGeminiStale, - title: `Usage (Gemini)`, - } + icon: GeminiIcon, + percentage: geminiMaxPercentage, + isStale: isGeminiStale, + title: `Usage (Gemini)`, + } : null; const statusColor = getStatusInfo(indicatorInfo.percentage).color; From bea26a6b6125d359c13956511c7b4431ff0bf723 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 15 Feb 2026 22:50:01 -0800 Subject: [PATCH 4/5] style: Fix inconsistent indentation in components and imports --- apps/ui/src/components/usage-popover.tsx | 50 ++++++++++++------------ apps/ui/src/lib/http-api-client.ts | 6 +-- 2 files changed, 26 insertions(+), 30 deletions(-) diff --git a/apps/ui/src/components/usage-popover.tsx b/apps/ui/src/components/usage-popover.tsx index bb3b35675..58c6fd274 100644 --- a/apps/ui/src/components/usage-popover.tsx +++ b/apps/ui/src/components/usage-popover.tsx @@ -307,16 +307,16 @@ export function UsagePopover() { const codexMaxPercentage = codexUsage?.rateLimits ? Math.max( - codexUsage.rateLimits.primary?.usedPercent || 0, - codexUsage.rateLimits.secondary?.usedPercent || 0 - ) + codexUsage.rateLimits.primary?.usedPercent || 0, + codexUsage.rateLimits.secondary?.usedPercent || 0 + ) : 0; const zaiMaxPercentage = zaiUsage?.quotaLimits ? Math.max( - zaiUsage.quotaLimits.tokens?.usedPercent || 0, - zaiUsage.quotaLimits.mcp?.usedPercent || 0 - ) + zaiUsage.quotaLimits.tokens?.usedPercent || 0, + zaiUsage.quotaLimits.mcp?.usedPercent || 0 + ) : 0; // Gemini quota from Google Cloud API (if available) @@ -346,31 +346,31 @@ export function UsagePopover() { const indicatorInfo = activeTab === 'claude' ? { - icon: AnthropicIcon, - percentage: claudeSessionPercentage, - isStale: isClaudeStale, - title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, - } + icon: AnthropicIcon, + percentage: claudeSessionPercentage, + isStale: isClaudeStale, + title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, + } : activeTab === 'codex' ? { - icon: OpenAIIcon, - percentage: codexWindowUsage ?? 0, - isStale: isCodexStale, - } + icon: OpenAIIcon, + percentage: codexWindowUsage ?? 0, + isStale: isCodexStale, + } : activeTab === 'zai' ? { - icon: ZaiIcon, - percentage: zaiMaxPercentage, - isStale: isZaiStale, - title: `Usage (z.ai)`, - } + icon: ZaiIcon, + percentage: zaiMaxPercentage, + isStale: isZaiStale, + title: `Usage (z.ai)`, + } : activeTab === 'gemini' ? { - icon: GeminiIcon, - percentage: geminiMaxPercentage, - isStale: isGeminiStale, - title: `Usage (Gemini)`, - } + icon: GeminiIcon, + percentage: geminiMaxPercentage, + isStale: isGeminiStale, + title: `Usage (Gemini)`, + } : null; const statusColor = getStatusInfo(indicatorInfo.percentage).color; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 37b6b4166..4238f58ed 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -41,11 +41,7 @@ import type { Notification, } from '@automaker/types'; import type { Message, SessionListItem } from '@/types/electron'; -import type { - ClaudeUsageResponse, - CodexUsageResponse, - GeminiUsage, -} from '@/store/app-store'; +import type { ClaudeUsageResponse, CodexUsageResponse, GeminiUsage } from '@/store/app-store'; import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron'; import type { ModelId, ThinkingLevel, ReasoningEffort, Feature } from '@automaker/types'; import { getGlobalFileBrowser } from '@/contexts/file-browser-context'; From de021f96bf8683b2be6edb5a011076e556d4416c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 17 Feb 2026 13:18:40 -0800 Subject: [PATCH 5/5] fix: Remove unused vars and improve type safety. Improve task recovery --- apps/server/src/index.ts | 24 +- apps/server/src/lib/cli-detection.ts | 5 +- apps/server/src/lib/error-handler.ts | 21 +- apps/server/src/lib/permission-enforcer.ts | 15 +- apps/server/src/lib/worktree-metadata.ts | 2 +- apps/server/src/providers/claude-provider.ts | 41 +-- apps/server/src/providers/codex-provider.ts | 23 +- apps/server/src/providers/copilot-provider.ts | 4 - apps/server/src/providers/cursor-provider.ts | 3 +- apps/server/src/providers/gemini-provider.ts | 1 - .../src/providers/simple-query-service.ts | 2 - apps/server/src/routes/agent/routes/start.ts | 2 +- apps/server/src/routes/app-spec/common.ts | 2 +- .../app-spec/generate-features-from-spec.ts | 2 +- apps/server/src/routes/app-spec/sync-spec.ts | 1 - .../src/routes/backlog-plan/generate-plan.ts | 2 +- .../src/routes/backlog-plan/routes/apply.ts | 2 +- .../src/routes/features/routes/export.ts | 2 +- .../routes/features/routes/generate-title.ts | 2 +- .../src/routes/features/routes/import.ts | 2 +- apps/server/src/routes/fs/routes/mkdir.ts | 8 +- .../src/routes/fs/routes/resolve-directory.ts | 6 +- .../routes/fs/routes/save-board-background.ts | 3 +- .../server/src/routes/fs/routes/save-image.ts | 3 +- .../src/routes/fs/routes/validate-path.ts | 2 +- apps/server/src/routes/gemini/index.ts | 11 +- .../github/routes/validation-endpoints.ts | 2 - .../src/routes/models/routes/providers.ts | 2 +- .../routes/settings/routes/update-global.ts | 12 +- .../src/routes/setup/routes/auth-claude.ts | 4 - .../src/routes/setup/routes/auth-opencode.ts | 4 - .../src/routes/setup/routes/copilot-models.ts | 3 - .../routes/setup/routes/opencode-models.ts | 3 - .../routes/setup/routes/verify-claude-auth.ts | 8 +- apps/server/src/routes/terminal/common.ts | 1 - .../server/src/routes/terminal/routes/auth.ts | 1 - .../routes/worktree/routes/branch-tracking.ts | 4 +- .../src/routes/worktree/routes/create-pr.ts | 6 +- .../src/routes/worktree/routes/delete.ts | 2 +- apps/server/src/routes/zai/index.ts | 73 +----- apps/server/src/services/agent-executor.ts | 6 - apps/server/src/services/agent-service.ts | 2 - .../src/services/auto-loop-coordinator.ts | 3 - .../src/services/auto-mode/global-service.ts | 1 - .../src/services/claude-usage-service.ts | 3 - .../server/src/services/dev-server-service.ts | 2 +- .../src/services/event-history-service.ts | 7 +- apps/server/src/services/execution-service.ts | 1 - .../src/services/feature-export-service.ts | 1 - apps/server/src/services/feature-loader.ts | 3 +- .../src/services/gemini-usage-service.ts | 128 +++++++--- apps/server/src/services/ideation-service.ts | 38 ++- apps/server/src/services/recovery-service.ts | 43 +++- apps/server/src/services/zai-usage-service.ts | 236 ++++++++++++++++-- apps/server/src/tests/cli-integration.test.ts | 4 +- .../unit/services/recovery-service.test.ts | 120 +++++++++ apps/ui/src/components/usage-popover.tsx | 12 +- .../components/kanban-card/card-actions.tsx | 201 +++++++++------ .../components/kanban-card/kanban-card.tsx | 12 +- .../components/list-view/list-row.tsx | 12 +- .../components/list-view/row-actions.tsx | 202 +++++++++------ .../views/board-view/mobile-usage-bar.tsx | 14 +- .../api-keys/hooks/use-api-key-management.ts | 104 ++++++-- apps/ui/src/hooks/use-auto-mode.ts | 5 +- apps/ui/src/hooks/use-provider-auth-init.ts | 31 ++- apps/ui/src/lib/http-api-client.ts | 37 +-- apps/ui/src/store/app-store.ts | 9 + apps/ui/src/store/types/state-types.ts | 9 +- 68 files changed, 1028 insertions(+), 534 deletions(-) diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index ce8651bd3..eda71d445 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -303,7 +303,7 @@ app.use( callback(null, origin); return; } - } catch (err) { + } catch { // Ignore URL parsing errors } @@ -376,7 +376,7 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur let globalSettings: Awaited> | null = null; try { globalSettings = await settingsService.getGlobalSettings(); - } catch (err) { + } catch { logger.warn('Failed to load global settings, using defaults'); } @@ -394,7 +394,7 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur const enableRequestLog = globalSettings.enableRequestLogging ?? true; setRequestLoggingEnabled(enableRequestLog); logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`); - } catch (err) { + } catch { logger.warn('Failed to apply logging settings, using defaults'); } } @@ -421,6 +421,22 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur } else { logger.info('[STARTUP] Feature state reconciliation complete - no stale states found'); } + + // Resume interrupted features in the background after reconciliation. + // This uses the saved execution state to identify features that were running + // before the restart (their statuses have been reset to ready/backlog by + // reconciliation above). Running in background so it doesn't block startup. + if (totalReconciled > 0) { + for (const project of globalSettings.projects) { + autoModeService.resumeInterruptedFeatures(project.path).catch((err) => { + logger.warn( + `[STARTUP] Failed to resume interrupted features for ${project.path}:`, + err + ); + }); + } + logger.info('[STARTUP] Initiated background resume of interrupted features'); + } } } catch (err) { logger.warn('[STARTUP] Failed to reconcile feature states:', err); @@ -581,7 +597,7 @@ wss.on('connection', (ws: WebSocket) => { logger.info('Sending event to client:', { type, messageLength: message.length, - sessionId: (payload as any)?.sessionId, + sessionId: (payload as Record)?.sessionId, }); ws.send(message); } else { diff --git a/apps/server/src/lib/cli-detection.ts b/apps/server/src/lib/cli-detection.ts index eba4c68a7..a7b5b14db 100644 --- a/apps/server/src/lib/cli-detection.ts +++ b/apps/server/src/lib/cli-detection.ts @@ -8,9 +8,6 @@ import { spawn, execSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { createLogger } from '@automaker/utils'; - -const logger = createLogger('CliDetection'); export interface CliInfo { name: string; @@ -86,7 +83,7 @@ export async function detectCli( options: CliDetectionOptions = {} ): Promise { const config = CLI_CONFIGS[provider]; - const { timeout = 5000, includeWsl = false, wslDistribution } = options; + const { timeout = 5000 } = options; const issues: string[] = []; const cliInfo: CliInfo = { diff --git a/apps/server/src/lib/error-handler.ts b/apps/server/src/lib/error-handler.ts index 770f26a23..d67200984 100644 --- a/apps/server/src/lib/error-handler.ts +++ b/apps/server/src/lib/error-handler.ts @@ -40,7 +40,7 @@ export interface ErrorClassification { suggestedAction?: string; retryable: boolean; provider?: string; - context?: Record; + context?: Record; } export interface ErrorPattern { @@ -180,7 +180,7 @@ const ERROR_PATTERNS: ErrorPattern[] = [ export function classifyError( error: unknown, provider?: string, - context?: Record + context?: Record ): ErrorClassification { const errorText = getErrorText(error); @@ -281,18 +281,19 @@ function getErrorText(error: unknown): string { if (typeof error === 'object' && error !== null) { // Handle structured error objects - const errorObj = error as any; + const errorObj = error as Record; - if (errorObj.message) { + if (typeof errorObj.message === 'string') { return errorObj.message; } - if (errorObj.error?.message) { - return errorObj.error.message; + const nestedError = errorObj.error; + if (typeof nestedError === 'object' && nestedError !== null && 'message' in nestedError) { + return String((nestedError as Record).message); } - if (errorObj.error) { - return typeof errorObj.error === 'string' ? errorObj.error : JSON.stringify(errorObj.error); + if (nestedError) { + return typeof nestedError === 'string' ? nestedError : JSON.stringify(nestedError); } return JSON.stringify(error); @@ -307,7 +308,7 @@ function getErrorText(error: unknown): string { export function createErrorResponse( error: unknown, provider?: string, - context?: Record + context?: Record ): { success: false; error: string; @@ -335,7 +336,7 @@ export function logError( error: unknown, provider?: string, operation?: string, - additionalContext?: Record + additionalContext?: Record ): void { const classification = classifyError(error, provider, { operation, diff --git a/apps/server/src/lib/permission-enforcer.ts b/apps/server/src/lib/permission-enforcer.ts index 003608ee4..714f7d40a 100644 --- a/apps/server/src/lib/permission-enforcer.ts +++ b/apps/server/src/lib/permission-enforcer.ts @@ -12,11 +12,18 @@ export interface PermissionCheckResult { reason?: string; } +/** Minimal shape of a Cursor tool call used for permission checking */ +interface CursorToolCall { + shellToolCall?: { args?: { command: string } }; + readToolCall?: { args?: { path: string } }; + writeToolCall?: { args?: { path: string } }; +} + /** * Check if a tool call is allowed based on permissions */ export function checkToolCallPermission( - toolCall: any, + toolCall: CursorToolCall, permissions: CursorCliConfigFile | null ): PermissionCheckResult { if (!permissions || !permissions.permissions) { @@ -152,7 +159,11 @@ function matchesRule(toolName: string, rule: string): boolean { /** * Log permission violations */ -export function logPermissionViolation(toolCall: any, reason: string, sessionId?: string): void { +export function logPermissionViolation( + toolCall: CursorToolCall, + reason: string, + sessionId?: string +): void { const sessionIdStr = sessionId ? ` [${sessionId}]` : ''; if (toolCall.shellToolCall?.args?.command) { diff --git a/apps/server/src/lib/worktree-metadata.ts b/apps/server/src/lib/worktree-metadata.ts index 4742a5b08..aa6e24870 100644 --- a/apps/server/src/lib/worktree-metadata.ts +++ b/apps/server/src/lib/worktree-metadata.ts @@ -78,7 +78,7 @@ export async function readWorktreeMetadata( const metadataPath = getWorktreeMetadataPath(projectPath, branch); const content = (await secureFs.readFile(metadataPath, 'utf-8')) as string; return JSON.parse(content) as WorktreeMetadata; - } catch (error) { + } catch (_error) { // File doesn't exist or can't be read return null; } diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 3d4f88cde..fa3eb9231 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -5,7 +5,7 @@ * with the provider architecture. */ -import { query, type Options } from '@anthropic-ai/claude-agent-sdk'; +import { query, type Options, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; import { BaseProvider } from './base-provider.js'; import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils'; @@ -32,31 +32,6 @@ import type { ModelDefinition, } from './types.js'; -// Explicit allowlist of environment variables to pass to the SDK. -// Only these vars are passed - nothing else from process.env leaks through. -const ALLOWED_ENV_VARS = [ - // Authentication - 'ANTHROPIC_API_KEY', - 'ANTHROPIC_AUTH_TOKEN', - // Endpoint configuration - 'ANTHROPIC_BASE_URL', - 'API_TIMEOUT_MS', - // Model mappings - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - // Traffic control - 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', - // System vars (always from process.env) - 'PATH', - 'HOME', - 'SHELL', - 'TERM', - 'USER', - 'LANG', - 'LC_ALL', -]; - // System vars are always passed from process.env regardless of profile const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL']; @@ -258,7 +233,7 @@ export class ClaudeProvider extends BaseProvider { }; // Build prompt payload - let promptPayload: string | AsyncIterable; + let promptPayload: string | AsyncIterable; if (Array.isArray(prompt)) { // Multi-part prompt (with images) @@ -317,12 +292,16 @@ export class ClaudeProvider extends BaseProvider { ? `${userMessage}\n\nTip: If you're running multiple features in auto-mode, consider reducing concurrency (maxConcurrency setting) to avoid hitting rate limits.` : userMessage; - const enhancedError = new Error(message); - (enhancedError as any).originalError = error; - (enhancedError as any).type = errorInfo.type; + const enhancedError = new Error(message) as Error & { + originalError: unknown; + type: string; + retryAfter?: number; + }; + enhancedError.originalError = error; + enhancedError.type = errorInfo.type; if (errorInfo.isRateLimit) { - (enhancedError as any).retryAfter = errorInfo.retryAfter; + enhancedError.retryAfter = errorInfo.retryAfter; } throw enhancedError; diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index 5c200ea54..f499db41c 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -30,7 +30,6 @@ import type { ModelDefinition, } from './types.js'; import { - CODEX_MODEL_MAP, supportsReasoningEffort, validateBareModelId, calculateReasoningTimeout, @@ -56,15 +55,9 @@ const CODEX_EXEC_SUBCOMMAND = 'exec'; const CODEX_JSON_FLAG = '--json'; const CODEX_MODEL_FLAG = '--model'; const CODEX_VERSION_FLAG = '--version'; -const CODEX_SANDBOX_FLAG = '--sandbox'; -const CODEX_APPROVAL_FLAG = '--ask-for-approval'; -const CODEX_SEARCH_FLAG = '--search'; -const CODEX_OUTPUT_SCHEMA_FLAG = '--output-schema'; const CODEX_CONFIG_FLAG = '--config'; -const CODEX_IMAGE_FLAG = '--image'; const CODEX_ADD_DIR_FLAG = '--add-dir'; const CODEX_SKIP_GIT_REPO_CHECK_FLAG = '--skip-git-repo-check'; -const CODEX_RESUME_FLAG = 'resume'; const CODEX_REASONING_EFFORT_KEY = 'reasoning_effort'; const CODEX_YOLO_FLAG = '--dangerously-bypass-approvals-and-sandbox'; const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY'; @@ -106,9 +99,6 @@ const TEXT_ENCODING = 'utf-8'; */ const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS; const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation -const CONTEXT_WINDOW_256K = 256000; -const MAX_OUTPUT_32K = 32000; -const MAX_OUTPUT_16K = 16000; const SYSTEM_PROMPT_SEPARATOR = '\n\n'; const CODEX_INSTRUCTIONS_DIR = '.codex'; const CODEX_INSTRUCTIONS_SECTION = 'Codex Project Instructions'; @@ -758,17 +748,14 @@ export class CodexProvider extends BaseProvider { options.cwd, codexSettings.sandboxMode !== 'danger-full-access' ); - const resolvedSandboxMode = sandboxCheck.enabled - ? codexSettings.sandboxMode - : 'danger-full-access'; if (!sandboxCheck.enabled && sandboxCheck.message) { console.warn(`[CodexProvider] ${sandboxCheck.message}`); } const searchEnabled = codexSettings.enableWebSearch || resolveSearchEnabled(resolvedAllowedTools, restrictTools); - const outputSchemaPath = await writeOutputSchemaFile(options.cwd, options.outputFormat); + await writeOutputSchemaFile(options.cwd, options.outputFormat); const imageBlocks = codexSettings.enableImages ? extractImageBlocks(options.prompt) : []; - const imagePaths = await writeImageFiles(options.cwd, imageBlocks); + await writeImageFiles(options.cwd, imageBlocks); const approvalPolicy = hasMcpServers && options.mcpAutoApproveTools !== undefined ? options.mcpAutoApproveTools @@ -801,7 +788,7 @@ export class CodexProvider extends BaseProvider { overrides.push({ key: 'features.web_search_request', value: true }); } - const configOverrides = buildConfigOverrides(overrides); + buildConfigOverrides(overrides); const preExecArgs: string[] = []; // Add additional directories with write access @@ -1033,7 +1020,7 @@ export class CodexProvider extends BaseProvider { async detectInstallation(): Promise { const cliPath = await findCodexCliPath(); const hasApiKey = Boolean(await resolveOpenAiApiKey()); - const authIndicators = await getCodexAuthIndicators(); + await getCodexAuthIndicators(); const installed = !!cliPath; let version = ''; @@ -1045,7 +1032,7 @@ export class CodexProvider extends BaseProvider { cwd: process.cwd(), }); version = result.stdout.trim(); - } catch (error) { + } catch { version = ''; } } diff --git a/apps/server/src/providers/copilot-provider.ts b/apps/server/src/providers/copilot-provider.ts index 64423047b..ad6e155e4 100644 --- a/apps/server/src/providers/copilot-provider.ts +++ b/apps/server/src/providers/copilot-provider.ts @@ -85,10 +85,6 @@ interface SdkToolExecutionEndEvent extends SdkEvent { }; } -interface SdkSessionIdleEvent extends SdkEvent { - type: 'session.idle'; -} - interface SdkSessionErrorEvent extends SdkEvent { type: 'session.error'; data: { diff --git a/apps/server/src/providers/cursor-provider.ts b/apps/server/src/providers/cursor-provider.ts index a2e813c0c..7d965db43 100644 --- a/apps/server/src/providers/cursor-provider.ts +++ b/apps/server/src/providers/cursor-provider.ts @@ -69,6 +69,7 @@ interface CursorToolHandler { * Registry of Cursor tool handlers * Each handler knows how to normalize its specific tool call type */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- handler registry stores heterogeneous tool type parameters const CURSOR_TOOL_HANDLERS: Record> = { readToolCall: { name: 'Read', @@ -878,7 +879,7 @@ export class CursorProvider extends CliProvider { logger.debug(`CursorProvider.executeQuery called with model: "${options.model}"`); // Get effective permissions for this project - const effectivePermissions = await getEffectivePermissions(options.cwd || process.cwd()); + await getEffectivePermissions(options.cwd || process.cwd()); // Debug: log raw events when AUTOMAKER_DEBUG_RAW_OUTPUT is enabled const debugRawEvents = diff --git a/apps/server/src/providers/gemini-provider.ts b/apps/server/src/providers/gemini-provider.ts index 09f16c16e..764c57eba 100644 --- a/apps/server/src/providers/gemini-provider.ts +++ b/apps/server/src/providers/gemini-provider.ts @@ -20,7 +20,6 @@ import type { ProviderMessage, InstallationStatus, ModelDefinition, - ContentBlock, } from './types.js'; import { validateBareModelId } from '@automaker/types'; import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types'; diff --git a/apps/server/src/providers/simple-query-service.ts b/apps/server/src/providers/simple-query-service.ts index 85c252351..1250e6f8b 100644 --- a/apps/server/src/providers/simple-query-service.ts +++ b/apps/server/src/providers/simple-query-service.ts @@ -16,8 +16,6 @@ import { ProviderFactory } from './provider-factory.js'; import type { - ProviderMessage, - ContentBlock, ThinkingLevel, ReasoningEffort, ClaudeApiProfile, diff --git a/apps/server/src/routes/agent/routes/start.ts b/apps/server/src/routes/agent/routes/start.ts index 1023fa389..dd9b7e419 100644 --- a/apps/server/src/routes/agent/routes/start.ts +++ b/apps/server/src/routes/agent/routes/start.ts @@ -6,7 +6,7 @@ import type { Request, Response } from 'express'; import { AgentService } from '../../../services/agent-service.js'; import { createLogger } from '@automaker/utils'; import { getErrorMessage, logError } from '../common.js'; -const logger = createLogger('Agent'); +const _logger = createLogger('Agent'); export function createStartHandler(agentService: AgentService) { return async (req: Request, res: Response): Promise => { diff --git a/apps/server/src/routes/app-spec/common.ts b/apps/server/src/routes/app-spec/common.ts index 1a48fc6a8..0731a7ddd 100644 --- a/apps/server/src/routes/app-spec/common.ts +++ b/apps/server/src/routes/app-spec/common.ts @@ -128,7 +128,7 @@ export function logAuthStatus(context: string): void { */ export function logError(error: unknown, context: string): void { logger.error(`❌ ${context}:`); - logger.error('Error name:', (error as any)?.name); + logger.error('Error name:', (error as Error)?.name); logger.error('Error message:', (error as Error)?.message); logger.error('Error stack:', (error as Error)?.stack); logger.error('Full error object:', JSON.stringify(error, Object.getOwnPropertyNames(error), 2)); diff --git a/apps/server/src/routes/app-spec/generate-features-from-spec.ts b/apps/server/src/routes/app-spec/generate-features-from-spec.ts index 6558256b9..93daeb8ef 100644 --- a/apps/server/src/routes/app-spec/generate-features-from-spec.ts +++ b/apps/server/src/routes/app-spec/generate-features-from-spec.ts @@ -30,7 +30,7 @@ const DEFAULT_MAX_FEATURES = 50; * Timeout for Codex models when generating features (5 minutes). * Codex models are slower and need more time to generate 50+ features. */ -const CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes +const _CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes /** * Type for extracted features JSON response diff --git a/apps/server/src/routes/app-spec/sync-spec.ts b/apps/server/src/routes/app-spec/sync-spec.ts index d1ba139d5..53bdc91a3 100644 --- a/apps/server/src/routes/app-spec/sync-spec.ts +++ b/apps/server/src/routes/app-spec/sync-spec.ts @@ -29,7 +29,6 @@ import { updateTechnologyStack, updateRoadmapPhaseStatus, type ImplementedFeature, - type RoadmapPhase, } from '../../lib/xml-extractor.js'; import { getNotificationService } from '../../services/notification-service.js'; diff --git a/apps/server/src/routes/backlog-plan/generate-plan.ts b/apps/server/src/routes/backlog-plan/generate-plan.ts index 2bd3a6a75..c2548f24c 100644 --- a/apps/server/src/routes/backlog-plan/generate-plan.ts +++ b/apps/server/src/routes/backlog-plan/generate-plan.ts @@ -6,7 +6,7 @@ */ import type { EventEmitter } from '../../lib/events.js'; -import type { Feature, BacklogPlanResult, BacklogChange, DependencyUpdate } from '@automaker/types'; +import type { Feature, BacklogPlanResult } from '@automaker/types'; import { DEFAULT_PHASE_MODELS, isCursorModel, diff --git a/apps/server/src/routes/backlog-plan/routes/apply.ts b/apps/server/src/routes/backlog-plan/routes/apply.ts index 1a238d17f..dacd156a2 100644 --- a/apps/server/src/routes/backlog-plan/routes/apply.ts +++ b/apps/server/src/routes/backlog-plan/routes/apply.ts @@ -3,7 +3,7 @@ */ import type { Request, Response } from 'express'; -import type { BacklogPlanResult, BacklogChange, Feature } from '@automaker/types'; +import type { BacklogPlanResult } from '@automaker/types'; import { FeatureLoader } from '../../../services/feature-loader.js'; import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js'; diff --git a/apps/server/src/routes/features/routes/export.ts b/apps/server/src/routes/features/routes/export.ts index c767dda47..28a048b4b 100644 --- a/apps/server/src/routes/features/routes/export.ts +++ b/apps/server/src/routes/features/routes/export.ts @@ -36,7 +36,7 @@ interface ExportRequest { }; } -export function createExportHandler(featureLoader: FeatureLoader) { +export function createExportHandler(_featureLoader: FeatureLoader) { const exportService = getFeatureExportService(); return async (req: Request, res: Response): Promise => { diff --git a/apps/server/src/routes/features/routes/generate-title.ts b/apps/server/src/routes/features/routes/generate-title.ts index 4e5e0dcbe..a84680b0c 100644 --- a/apps/server/src/routes/features/routes/generate-title.ts +++ b/apps/server/src/routes/features/routes/generate-title.ts @@ -34,7 +34,7 @@ export function createGenerateTitleHandler( ): (req: Request, res: Response) => Promise { return async (req: Request, res: Response): Promise => { try { - const { description, projectPath } = req.body as GenerateTitleRequestBody; + const { description } = req.body as GenerateTitleRequestBody; if (!description || typeof description !== 'string') { const response: GenerateTitleErrorResponse = { diff --git a/apps/server/src/routes/features/routes/import.ts b/apps/server/src/routes/features/routes/import.ts index 85fb6d9be..aa8cfce14 100644 --- a/apps/server/src/routes/features/routes/import.ts +++ b/apps/server/src/routes/features/routes/import.ts @@ -33,7 +33,7 @@ interface ConflictInfo { hasConflict: boolean; } -export function createImportHandler(featureLoader: FeatureLoader) { +export function createImportHandler(_featureLoader: FeatureLoader) { const exportService = getFeatureExportService(); return async (req: Request, res: Response): Promise => { diff --git a/apps/server/src/routes/fs/routes/mkdir.ts b/apps/server/src/routes/fs/routes/mkdir.ts index 04d0a8362..f813abcd6 100644 --- a/apps/server/src/routes/fs/routes/mkdir.ts +++ b/apps/server/src/routes/fs/routes/mkdir.ts @@ -35,9 +35,9 @@ export function createMkdirHandler() { error: 'Path exists and is not a directory', }); return; - } catch (statError: any) { + } catch (statError: unknown) { // ENOENT means path doesn't exist - we should create it - if (statError.code !== 'ENOENT') { + if ((statError as NodeJS.ErrnoException).code !== 'ENOENT') { // Some other error (could be ELOOP in parent path) throw statError; } @@ -47,7 +47,7 @@ export function createMkdirHandler() { await secureFs.mkdir(resolvedPath, { recursive: true }); res.json({ success: true }); - } catch (error: any) { + } catch (error: unknown) { // Path not allowed - return 403 Forbidden if (error instanceof PathNotAllowedError) { res.status(403).json({ success: false, error: getErrorMessage(error) }); @@ -55,7 +55,7 @@ export function createMkdirHandler() { } // Handle ELOOP specifically - if (error.code === 'ELOOP') { + if ((error as NodeJS.ErrnoException).code === 'ELOOP') { logError(error, 'Create directory failed - symlink loop detected'); res.status(400).json({ success: false, diff --git a/apps/server/src/routes/fs/routes/resolve-directory.ts b/apps/server/src/routes/fs/routes/resolve-directory.ts index 5e4147db9..be5a5b0d2 100644 --- a/apps/server/src/routes/fs/routes/resolve-directory.ts +++ b/apps/server/src/routes/fs/routes/resolve-directory.ts @@ -10,7 +10,11 @@ import { getErrorMessage, logError } from '../common.js'; export function createResolveDirectoryHandler() { return async (req: Request, res: Response): Promise => { try { - const { directoryName, sampleFiles, fileCount } = req.body as { + const { + directoryName, + sampleFiles, + fileCount: _fileCount, + } = req.body as { directoryName: string; sampleFiles?: string[]; fileCount?: number; diff --git a/apps/server/src/routes/fs/routes/save-board-background.ts b/apps/server/src/routes/fs/routes/save-board-background.ts index a0c2164a8..e8b82169a 100644 --- a/apps/server/src/routes/fs/routes/save-board-background.ts +++ b/apps/server/src/routes/fs/routes/save-board-background.ts @@ -11,10 +11,9 @@ import { getBoardDir } from '@automaker/platform'; export function createSaveBoardBackgroundHandler() { return async (req: Request, res: Response): Promise => { try { - const { data, filename, mimeType, projectPath } = req.body as { + const { data, filename, projectPath } = req.body as { data: string; filename: string; - mimeType: string; projectPath: string; }; diff --git a/apps/server/src/routes/fs/routes/save-image.ts b/apps/server/src/routes/fs/routes/save-image.ts index 695a8dedf..4d48661cf 100644 --- a/apps/server/src/routes/fs/routes/save-image.ts +++ b/apps/server/src/routes/fs/routes/save-image.ts @@ -12,10 +12,9 @@ import { sanitizeFilename } from '@automaker/utils'; export function createSaveImageHandler() { return async (req: Request, res: Response): Promise => { try { - const { data, filename, mimeType, projectPath } = req.body as { + const { data, filename, projectPath } = req.body as { data: string; filename: string; - mimeType: string; projectPath: string; }; diff --git a/apps/server/src/routes/fs/routes/validate-path.ts b/apps/server/src/routes/fs/routes/validate-path.ts index 8659eb5ac..9405e0c1e 100644 --- a/apps/server/src/routes/fs/routes/validate-path.ts +++ b/apps/server/src/routes/fs/routes/validate-path.ts @@ -5,7 +5,7 @@ import type { Request, Response } from 'express'; import * as secureFs from '../../../lib/secure-fs.js'; import path from 'path'; -import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform'; +import { isPathAllowed, getAllowedRootDirectory } from '@automaker/platform'; import { getErrorMessage, logError } from '../common.js'; export function createValidatePathHandler() { diff --git a/apps/server/src/routes/gemini/index.ts b/apps/server/src/routes/gemini/index.ts index c543d827b..b3d70cdb9 100644 --- a/apps/server/src/routes/gemini/index.ts +++ b/apps/server/src/routes/gemini/index.ts @@ -37,9 +37,12 @@ export function createGeminiRoutes(): Router { const provider = new GeminiProvider(); const status = await provider.detectInstallation(); - const authMethod = - (status as any).authMethod || - (status.authenticated ? (status.hasApiKey ? 'api_key' : 'cli_login') : 'none'); + // Derive authMethod from typed InstallationStatus fields + const authMethod = status.authenticated + ? status.hasApiKey + ? 'api_key' + : 'cli_login' + : 'none'; res.json({ success: true, @@ -48,7 +51,7 @@ export function createGeminiRoutes(): Router { path: status.path || null, authenticated: status.authenticated || false, authMethod, - hasCredentialsFile: (status as any).hasCredentialsFile || false, + hasCredentialsFile: false, }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; diff --git a/apps/server/src/routes/github/routes/validation-endpoints.ts b/apps/server/src/routes/github/routes/validation-endpoints.ts index 218597371..1f3c23161 100644 --- a/apps/server/src/routes/github/routes/validation-endpoints.ts +++ b/apps/server/src/routes/github/routes/validation-endpoints.ts @@ -6,7 +6,6 @@ import type { Request, Response } from 'express'; import type { EventEmitter } from '../../../lib/events.js'; import type { IssueValidationEvent } from '@automaker/types'; import { - isValidationRunning, getValidationStatus, getRunningValidations, abortValidation, @@ -15,7 +14,6 @@ import { logger, } from './validation-common.js'; import { - readValidation, getAllValidations, getValidationWithFreshness, deleteValidation, diff --git a/apps/server/src/routes/models/routes/providers.ts b/apps/server/src/routes/models/routes/providers.ts index 174a1faca..fa4d2828d 100644 --- a/apps/server/src/routes/models/routes/providers.ts +++ b/apps/server/src/routes/models/routes/providers.ts @@ -12,7 +12,7 @@ export function createProvidersHandler() { // Get installation status from all providers const statuses = await ProviderFactory.checkAllProviders(); - const providers: Record = { + const providers: Record> = { anthropic: { available: statuses.claude?.installed || false, hasApiKey: !!process.env.ANTHROPIC_API_KEY, diff --git a/apps/server/src/routes/settings/routes/update-global.ts b/apps/server/src/routes/settings/routes/update-global.ts index 817b5c1da..2bc1c2fa2 100644 --- a/apps/server/src/routes/settings/routes/update-global.ts +++ b/apps/server/src/routes/settings/routes/update-global.ts @@ -46,16 +46,14 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) { } // Minimal debug logging to help diagnose accidental wipes. - const projectsLen = Array.isArray((updates as any).projects) - ? (updates as any).projects.length - : undefined; - const trashedLen = Array.isArray((updates as any).trashedProjects) - ? (updates as any).trashedProjects.length + const projectsLen = Array.isArray(updates.projects) ? updates.projects.length : undefined; + const trashedLen = Array.isArray(updates.trashedProjects) + ? updates.trashedProjects.length : undefined; logger.info( `[SERVER_SETTINGS_UPDATE] Request received: projects=${projectsLen ?? 'n/a'}, trashedProjects=${trashedLen ?? 'n/a'}, theme=${ - (updates as any).theme ?? 'n/a' - }, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}` + updates.theme ?? 'n/a' + }, localStorageMigrated=${updates.localStorageMigrated ?? 'n/a'}` ); // Get old settings to detect theme changes diff --git a/apps/server/src/routes/setup/routes/auth-claude.ts b/apps/server/src/routes/setup/routes/auth-claude.ts index 97a170f48..9eac09895 100644 --- a/apps/server/src/routes/setup/routes/auth-claude.ts +++ b/apps/server/src/routes/setup/routes/auth-claude.ts @@ -4,13 +4,9 @@ import type { Request, Response } from 'express'; import { getErrorMessage, logError } from '../common.js'; -import { exec } from 'child_process'; -import { promisify } from 'util'; import * as fs from 'fs'; import * as path from 'path'; -const execAsync = promisify(exec); - export function createAuthClaudeHandler() { return async (_req: Request, res: Response): Promise => { try { diff --git a/apps/server/src/routes/setup/routes/auth-opencode.ts b/apps/server/src/routes/setup/routes/auth-opencode.ts index 7d7f35e25..dce314bf3 100644 --- a/apps/server/src/routes/setup/routes/auth-opencode.ts +++ b/apps/server/src/routes/setup/routes/auth-opencode.ts @@ -4,13 +4,9 @@ import type { Request, Response } from 'express'; import { logError, getErrorMessage } from '../common.js'; -import { exec } from 'child_process'; -import { promisify } from 'util'; import * as fs from 'fs'; import * as path from 'path'; -const execAsync = promisify(exec); - export function createAuthOpencodeHandler() { return async (_req: Request, res: Response): Promise => { try { diff --git a/apps/server/src/routes/setup/routes/copilot-models.ts b/apps/server/src/routes/setup/routes/copilot-models.ts index 5a3da128c..08b9eda90 100644 --- a/apps/server/src/routes/setup/routes/copilot-models.ts +++ b/apps/server/src/routes/setup/routes/copilot-models.ts @@ -10,9 +10,6 @@ import type { Request, Response } from 'express'; import { CopilotProvider } from '../../../providers/copilot-provider.js'; import { getErrorMessage, logError } from '../common.js'; import type { ModelDefinition } from '@automaker/types'; -import { createLogger } from '@automaker/utils'; - -const logger = createLogger('CopilotModelsRoute'); // Singleton provider instance for caching let providerInstance: CopilotProvider | null = null; diff --git a/apps/server/src/routes/setup/routes/opencode-models.ts b/apps/server/src/routes/setup/routes/opencode-models.ts index a3b2b7bee..e7909bf99 100644 --- a/apps/server/src/routes/setup/routes/opencode-models.ts +++ b/apps/server/src/routes/setup/routes/opencode-models.ts @@ -14,9 +14,6 @@ import { } from '../../../providers/opencode-provider.js'; import { getErrorMessage, logError } from '../common.js'; import type { ModelDefinition } from '@automaker/types'; -import { createLogger } from '@automaker/utils'; - -const logger = createLogger('OpenCodeModelsRoute'); // Singleton provider instance for caching let providerInstance: OpencodeProvider | null = null; diff --git a/apps/server/src/routes/setup/routes/verify-claude-auth.ts b/apps/server/src/routes/setup/routes/verify-claude-auth.ts index 7df27c3dc..6e934dab9 100644 --- a/apps/server/src/routes/setup/routes/verify-claude-auth.ts +++ b/apps/server/src/routes/setup/routes/verify-claude-auth.ts @@ -151,7 +151,7 @@ export function createVerifyClaudeAuthHandler() { AuthSessionManager.createSession(sessionId, authMethod || 'api_key', apiKey, 'anthropic'); // Create temporary environment override for SDK call - const cleanupEnv = createTempEnvOverride(authEnv); + const _cleanupEnv = createTempEnvOverride(authEnv); // Run a minimal query to verify authentication const stream = query({ @@ -194,8 +194,10 @@ export function createVerifyClaudeAuthHandler() { } // Check specifically for assistant messages with text content - if (msg.type === 'assistant' && (msg as any).message?.content) { - const content = (msg as any).message.content; + const msgRecord = msg as Record; + const msgMessage = msgRecord.message as Record | undefined; + if (msg.type === 'assistant' && msgMessage?.content) { + const content = msgMessage.content; if (Array.isArray(content)) { for (const block of content) { if (block.type === 'text' && block.text) { diff --git a/apps/server/src/routes/terminal/common.ts b/apps/server/src/routes/terminal/common.ts index 6121e3453..5e8b6b329 100644 --- a/apps/server/src/routes/terminal/common.ts +++ b/apps/server/src/routes/terminal/common.ts @@ -5,7 +5,6 @@ import { randomBytes } from 'crypto'; import { createLogger } from '@automaker/utils'; import type { Request, Response, NextFunction } from 'express'; -import { getTerminalService } from '../../services/terminal-service.js'; const logger = createLogger('Terminal'); diff --git a/apps/server/src/routes/terminal/routes/auth.ts b/apps/server/src/routes/terminal/routes/auth.ts index 1d6156bd1..0aa29b345 100644 --- a/apps/server/src/routes/terminal/routes/auth.ts +++ b/apps/server/src/routes/terminal/routes/auth.ts @@ -9,7 +9,6 @@ import { generateToken, addToken, getTokenExpiryMs, - getErrorMessage, } from '../common.js'; export function createAuthHandler() { diff --git a/apps/server/src/routes/worktree/routes/branch-tracking.ts b/apps/server/src/routes/worktree/routes/branch-tracking.ts index 1c9f069a3..4144b94a3 100644 --- a/apps/server/src/routes/worktree/routes/branch-tracking.ts +++ b/apps/server/src/routes/worktree/routes/branch-tracking.ts @@ -31,8 +31,8 @@ export async function getTrackedBranches(projectPath: string): Promise { try { const { apiToken, apiHost } = req.body; - - if (apiToken !== undefined) { - // Set in-memory token - usageService.setApiToken(apiToken || ''); - - // Persist to credentials (deep merge happens in updateCredentials) - try { - await settingsService.updateCredentials({ - apiKeys: { zai: apiToken || '' }, - } as Parameters[0]); - logger.info('[configure] Saved z.ai API key to credentials'); - } catch (persistError) { - logger.error('[configure] Failed to persist z.ai API key:', persistError); - } - } - - if (apiHost) { - usageService.setApiHost(apiHost); - } - - res.json({ - success: true, - message: 'z.ai configuration updated', - isAvailable: usageService.isAvailable(), - }); + const result = await usageService.configure({ apiToken, apiHost }, settingsService); + res.json(result); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; logger.error('Error configuring z.ai:', error); @@ -100,50 +77,8 @@ export function createZaiRoutes( router.post('/verify', async (req: Request, res: Response) => { try { const { apiKey } = req.body; - - if (!apiKey || typeof apiKey !== 'string' || apiKey.trim().length === 0) { - res.json({ - success: false, - authenticated: false, - error: 'Please provide an API key to test.', - }); - return; - } - - // Test the key by making a request to z.ai API - const quotaUrl = - process.env.Z_AI_QUOTA_URL || - `${process.env.Z_AI_API_HOST ? `https://${process.env.Z_AI_API_HOST}` : 'https://api.z.ai'}/api/monitor/usage/quota/limit`; - - logger.info(`[verify] Testing API key against: ${quotaUrl}`); - - const response = await fetch(quotaUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${apiKey.trim()}`, - Accept: 'application/json', - }, - }); - - if (response.ok) { - res.json({ - success: true, - authenticated: true, - message: 'Connection successful! z.ai API responded.', - }); - } else if (response.status === 401 || response.status === 403) { - res.json({ - success: false, - authenticated: false, - error: 'Invalid API key. Please check your key and try again.', - }); - } else { - res.json({ - success: false, - authenticated: false, - error: `API request failed: ${response.status} ${response.statusText}`, - }); - } + const result = await usageService.verifyApiKey(apiKey); + res.json(result); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; logger.error('Error verifying z.ai API key:', error); diff --git a/apps/server/src/services/agent-executor.ts b/apps/server/src/services/agent-executor.ts index 5d0498046..e005b3391 100644 --- a/apps/server/src/services/agent-executor.ts +++ b/apps/server/src/services/agent-executor.ts @@ -444,17 +444,11 @@ export class AgentExecutor { callbacks: AgentExecutorCallbacks ): Promise<{ responseText: string; tasksCompleted: number }> { const { - workDir, featureId, projectPath, - abortController, branchName = null, planningMode = 'skip', provider, - effectiveBareModel, - credentials, - claudeCompatibleProvider, - mcpServers, sdkOptions, } = options; let responseText = initialResponseText, diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index 0ecec44e7..663a1383e 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -15,11 +15,9 @@ import { loadContextFiles, createLogger, classifyError, - getUserFriendlyErrorMessage, } from '@automaker/utils'; import { ProviderFactory } from '../providers/provider-factory.js'; import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; -import { PathNotAllowedError } from '@automaker/platform'; import type { SettingsService } from './settings-service.js'; import { getAutoLoadClaudeMdSetting, diff --git a/apps/server/src/services/auto-loop-coordinator.ts b/apps/server/src/services/auto-loop-coordinator.ts index ddc666d50..3310b2d6f 100644 --- a/apps/server/src/services/auto-loop-coordinator.ts +++ b/apps/server/src/services/auto-loop-coordinator.ts @@ -158,10 +158,7 @@ export class AutoLoopCoordinator { const projectState = this.autoLoopsByProject.get(worktreeKey); if (!projectState) return; const { projectPath, branchName } = projectState.config; - let iterationCount = 0; - while (projectState.isRunning && !projectState.abortController.signal.aborted) { - iterationCount++; try { const runningCount = await this.getRunningCountForWorktree(projectPath, branchName); if (runningCount >= projectState.config.maxConcurrency) { diff --git a/apps/server/src/services/auto-mode/global-service.ts b/apps/server/src/services/auto-mode/global-service.ts index 90576f8c2..459562ebc 100644 --- a/apps/server/src/services/auto-mode/global-service.ts +++ b/apps/server/src/services/auto-mode/global-service.ts @@ -10,7 +10,6 @@ */ import path from 'path'; -import type { Feature } from '@automaker/types'; import { createLogger } from '@automaker/utils'; import type { EventEmitter } from '../../lib/events.js'; import { TypedEventBus } from '../typed-event-bus.js'; diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index 40cffd7f7..ffb076319 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -295,7 +295,6 @@ export class ClaudeUsageService { } // Don't fail if we have data - return it instead // Check cleaned output since raw output has ANSI codes between words - // eslint-disable-next-line no-control-regex const cleanedForCheck = output .replace(/\x1B\[(\d+)C/g, (_m: string, n: string) => ' '.repeat(parseInt(n, 10))) .replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, ''); @@ -332,7 +331,6 @@ export class ClaudeUsageService { // Convert cursor forward (ESC[nC) to spaces first to preserve word boundaries, // then strip remaining ANSI sequences. Without this, the Claude CLI TUI output // like "Current week (all models)" becomes "Currentweek(allmodels)". - // eslint-disable-next-line no-control-regex const cleanOutput = output .replace(/\x1B\[(\d+)C/g, (_match: string, n: string) => ' '.repeat(parseInt(n, 10))) .replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, ''); @@ -492,7 +490,6 @@ export class ClaudeUsageService { // First, convert cursor movement sequences to whitespace to preserve word boundaries. // The Claude CLI TUI uses ESC[nC (cursor forward) instead of actual spaces between words. // Without this, "Current week (all models)" becomes "Currentweek(allmodels)" after stripping. - // eslint-disable-next-line no-control-regex let clean = text // Cursor forward (CSI n C): replace with n spaces to preserve word separation .replace(/\x1B\[(\d+)C/g, (_match, n) => ' '.repeat(parseInt(n, 10))) diff --git a/apps/server/src/services/dev-server-service.ts b/apps/server/src/services/dev-server-service.ts index 76cf31748..13281dc1b 100644 --- a/apps/server/src/services/dev-server-service.ts +++ b/apps/server/src/services/dev-server-service.ts @@ -246,7 +246,7 @@ class DevServerService { // No process found on port, which is fine } } - } catch (error) { + } catch { // Ignore errors - port might not have any process logger.debug(`No process to kill on port ${port}`); } diff --git a/apps/server/src/services/event-history-service.ts b/apps/server/src/services/event-history-service.ts index b983af09b..a70917251 100644 --- a/apps/server/src/services/event-history-service.ts +++ b/apps/server/src/services/event-history-service.ts @@ -13,12 +13,7 @@ import { createLogger } from '@automaker/utils'; import * as secureFs from '../lib/secure-fs.js'; -import { - getEventHistoryDir, - getEventHistoryIndexPath, - getEventPath, - ensureEventHistoryDir, -} from '@automaker/platform'; +import { getEventHistoryIndexPath, getEventPath, ensureEventHistoryDir } from '@automaker/platform'; import type { StoredEvent, StoredEventIndex, diff --git a/apps/server/src/services/execution-service.ts b/apps/server/src/services/execution-service.ts index f7a51ace9..3ebce443d 100644 --- a/apps/server/src/services/execution-service.ts +++ b/apps/server/src/services/execution-service.ts @@ -20,7 +20,6 @@ import type { TypedEventBus } from './typed-event-bus.js'; import type { ConcurrencyManager, RunningFeature } from './concurrency-manager.js'; import type { WorktreeResolver } from './worktree-resolver.js'; import type { SettingsService } from './settings-service.js'; -import type { PipelineContext } from './pipeline-orchestrator.js'; import { pipelineService } from './pipeline-service.js'; // Re-export callback types from execution-types.ts for backward compatibility diff --git a/apps/server/src/services/feature-export-service.ts b/apps/server/src/services/feature-export-service.ts index a58b65276..bd741dc22 100644 --- a/apps/server/src/services/feature-export-service.ts +++ b/apps/server/src/services/feature-export-service.ts @@ -205,7 +205,6 @@ export class FeatureExportService { importData: FeatureImport ): Promise { const warnings: string[] = []; - const errors: string[] = []; try { // Extract feature from data (handle both raw Feature and wrapped FeatureExport) diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index b40a85f07..941194b71 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -195,9 +195,10 @@ export class FeatureLoader { } // Read all feature directories + // secureFs.readdir returns Dirent[] but typed as generic; cast to access isDirectory() const entries = (await secureFs.readdir(featuresDir, { withFileTypes: true, - })) as any[]; + })) as import('fs').Dirent[]; const featureDirs = entries.filter((entry) => entry.isDirectory()); // Load all features concurrently with automatic recovery from backups diff --git a/apps/server/src/services/gemini-usage-service.ts b/apps/server/src/services/gemini-usage-service.ts index 966d09a49..fba8bda34 100644 --- a/apps/server/src/services/gemini-usage-service.ts +++ b/apps/server/src/services/gemini-usage-service.ts @@ -13,7 +13,7 @@ import { createLogger } from '@automaker/utils'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; const logger = createLogger('GeminiUsage'); @@ -26,6 +26,12 @@ const CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCode // Google OAuth endpoints for token refresh const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; +/** Default timeout for fetch requests in milliseconds */ +const FETCH_TIMEOUT_MS = 10_000; + +/** TTL for cached credentials in milliseconds (5 minutes) */ +const CREDENTIALS_CACHE_TTL_MS = 5 * 60 * 1000; + export interface GeminiQuotaBucket { /** Model ID this quota applies to */ modelId: string; @@ -114,8 +120,11 @@ interface QuotaResponse { */ export class GeminiUsageService { private cachedCredentials: OAuthCredentials | null = null; + private cachedCredentialsAt: number | null = null; private cachedClientCredentials: OAuthClientCredentials | null = null; private credentialsPath: string; + /** The actual path from which credentials were loaded (for write-back) */ + private loadedCredentialsPath: string | null = null; constructor() { // Default credentials path for Gemini CLI @@ -176,6 +185,7 @@ export class GeminiUsageService { 'Content-Type': 'application/json', }, body: JSON.stringify({}), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (codeAssistResponse.ok) { @@ -199,6 +209,7 @@ export class GeminiUsageService { 'Content-Type': 'application/json', }, body: JSON.stringify(projectId ? { project: projectId } : {}), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!response.ok) { @@ -338,19 +349,46 @@ export class GeminiUsageService { } /** - * Load OAuth credentials from file + * Load OAuth credentials from file. + * Implements TTL-based cache invalidation and file mtime checks. */ private async loadCredentials(): Promise { - if (this.cachedCredentials) { - return this.cachedCredentials; + // Check if cached credentials are still valid + if (this.cachedCredentials && this.cachedCredentialsAt) { + const now = Date.now(); + const cacheAge = now - this.cachedCredentialsAt; + + if (cacheAge < CREDENTIALS_CACHE_TTL_MS) { + // Cache is within TTL - also check file mtime + const sourcePath = this.loadedCredentialsPath || this.credentialsPath; + try { + const stat = fs.statSync(sourcePath); + if (stat.mtimeMs <= this.cachedCredentialsAt) { + // File hasn't been modified since we cached - use cache + return this.cachedCredentials; + } + // File has been modified, fall through to re-read + logger.debug('[loadCredentials] File modified since cache, re-reading'); + } catch { + // File doesn't exist or can't stat - use cache + return this.cachedCredentials; + } + } else { + // Cache TTL expired, discard + logger.debug('[loadCredentials] Cache TTL expired, re-reading'); + } + + // Invalidate cached credentials + this.cachedCredentials = null; + this.cachedCredentialsAt = null; } - // Check multiple possible paths - const possiblePaths = [ + // Build unique possible paths (deduplicate) + const rawPaths = [ this.credentialsPath, - path.join(os.homedir(), '.gemini', 'oauth_creds.json'), path.join(os.homedir(), '.config', 'gemini', 'oauth_creds.json'), ]; + const possiblePaths = [...new Set(rawPaths)]; for (const credPath of possiblePaths) { try { @@ -361,6 +399,8 @@ export class GeminiUsageService { // Handle different credential formats if (creds.access_token || creds.refresh_token) { this.cachedCredentials = creds; + this.cachedCredentialsAt = Date.now(); + this.loadedCredentialsPath = credPath; logger.info('[loadCredentials] Loaded from:', credPath); return creds; } @@ -372,6 +412,8 @@ export class GeminiUsageService { client_id: clientCreds.client_id, client_secret: clientCreds.client_secret, }; + this.cachedCredentialsAt = Date.now(); + this.loadedCredentialsPath = credPath; return this.cachedCredentials; } } @@ -387,14 +429,21 @@ export class GeminiUsageService { * Find the Gemini CLI binary path */ private findGeminiBinaryPath(): string | null { + // Try 'which' on Unix-like systems, 'where' on Windows + const whichCmd = process.platform === 'win32' ? 'where' : 'which'; try { - // Try 'which' on Unix-like systems - const whichResult = execSync('which gemini 2>/dev/null', { encoding: 'utf8' }).trim(); - if (whichResult && fs.existsSync(whichResult)) { - return whichResult; + const whichResult = execFileSync(whichCmd, ['gemini'], { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + // 'where' on Windows may return multiple lines; take the first + const firstLine = whichResult.split('\n')[0]?.trim(); + if (firstLine && fs.existsSync(firstLine)) { + return firstLine; } } catch { - // Ignore errors from 'which' + // Ignore errors from 'which'/'where' } // Check common installation paths @@ -554,27 +603,33 @@ export class GeminiUsageService { } } - // Try finding oauth2.js by searching in node_modules - try { - const searchResult = execSync( - `find ${baseDir}/.. -name "oauth2.js" -path "*gemini*" -path "*code_assist*" 2>/dev/null | head -1`, - { encoding: 'utf8', timeout: 5000 } - ).trim(); - - if (searchResult && fs.existsSync(searchResult)) { - logger.debug('[extractOAuthClientCredentials] Found via search:', searchResult); - const content = fs.readFileSync(searchResult, 'utf8'); - const creds = this.parseOAuthCredentialsFromSource(content); - if (creds) { - this.cachedClientCredentials = creds; - logger.info( - '[extractOAuthClientCredentials] Extracted credentials from CLI (via search)' - ); - return creds; + // Try finding oauth2.js by searching in node_modules (POSIX only) + if (process.platform !== 'win32') { + try { + const searchBase = path.resolve(baseDir, '..'); + const searchResult = execFileSync( + 'find', + [searchBase, '-name', 'oauth2.js', '-path', '*gemini*', '-path', '*code_assist*'], + { encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] } + ) + .trim() + .split('\n')[0]; // Take first result + + if (searchResult && fs.existsSync(searchResult)) { + logger.debug('[extractOAuthClientCredentials] Found via search:', searchResult); + const content = fs.readFileSync(searchResult, 'utf8'); + const creds = this.parseOAuthCredentialsFromSource(content); + if (creds) { + this.cachedClientCredentials = creds; + logger.info( + '[extractOAuthClientCredentials] Extracted credentials from CLI (via search)' + ); + return creds; + } } + } catch { + // Ignore search errors } - } catch { - // Ignore search errors } logger.warn('[extractOAuthClientCredentials] Could not extract credentials from CLI'); @@ -669,6 +724,7 @@ export class GeminiUsageService { refresh_token: creds.refresh_token, grant_type: 'refresh_token', }), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (response.ok) { @@ -685,13 +741,12 @@ export class GeminiUsageService { access_token: newAccessToken, expiry_date: Date.now() + expiresIn * 1000, }; + this.cachedCredentialsAt = Date.now(); - // Save back to file + // Save back to the file the credentials were loaded from + const writePath = this.loadedCredentialsPath || this.credentialsPath; try { - fs.writeFileSync( - this.credentialsPath, - JSON.stringify(this.cachedCredentials, null, 2) - ); + fs.writeFileSync(writePath, JSON.stringify(this.cachedCredentials, null, 2)); } catch (e) { logger.debug('[getValidAccessToken] Could not save refreshed token:', e); } @@ -743,6 +798,7 @@ export class GeminiUsageService { */ clearCache(): void { this.cachedCredentials = null; + this.cachedCredentialsAt = null; this.cachedClientCredentials = null; } } diff --git a/apps/server/src/services/ideation-service.ts b/apps/server/src/services/ideation-service.ts index 0d43252fb..9bbea03b7 100644 --- a/apps/server/src/services/ideation-service.ts +++ b/apps/server/src/services/ideation-service.ts @@ -27,7 +27,6 @@ import type { } from '@automaker/types'; import { DEFAULT_IDEATION_CONTEXT_SOURCES } from '@automaker/types'; import { - getIdeationDir, getIdeasDir, getIdeaDir, getIdeaPath, @@ -407,7 +406,9 @@ export class IdeationService { return []; } - const entries = (await secureFs.readdir(ideasDir, { withFileTypes: true })) as any[]; + const entries = (await secureFs.readdir(ideasDir, { + withFileTypes: true, + })) as import('fs').Dirent[]; const ideaDirs = entries.filter((entry) => entry.isDirectory()); const ideas: Idea[] = []; @@ -855,15 +856,26 @@ ${contextSection}${existingWorkSection}`; } return parsed - .map((item: any, index: number) => ({ - id: this.generateId('sug'), - category, - title: item.title || `Suggestion ${index + 1}`, - description: item.description || '', - rationale: item.rationale || '', - priority: item.priority || 'medium', - relatedFiles: item.relatedFiles || [], - })) + .map( + ( + item: { + title?: string; + description?: string; + rationale?: string; + priority?: 'low' | 'medium' | 'high'; + relatedFiles?: string[]; + }, + index: number + ) => ({ + id: this.generateId('sug'), + category, + title: item.title || `Suggestion ${index + 1}`, + description: item.description || '', + rationale: item.rationale || '', + priority: item.priority || ('medium' as const), + relatedFiles: item.relatedFiles || [], + }) + ) .slice(0, count); } catch (error) { logger.warn('Failed to parse JSON response:', error); @@ -1705,7 +1717,9 @@ ${contextSection}${existingWorkSection}`; const results: AnalysisFileInfo[] = []; try { - const entries = (await secureFs.readdir(dirPath, { withFileTypes: true })) as any[]; + const entries = (await secureFs.readdir(dirPath, { + withFileTypes: true, + })) as import('fs').Dirent[]; for (const entry of entries) { if (entry.isDirectory()) { diff --git a/apps/server/src/services/recovery-service.ts b/apps/server/src/services/recovery-service.ts index d575f1da6..d08f5a8e5 100644 --- a/apps/server/src/services/recovery-service.ts +++ b/apps/server/src/services/recovery-service.ts @@ -250,6 +250,14 @@ export class RecoveryService { async resumeInterruptedFeatures(projectPath: string): Promise { const featuresDir = getFeaturesDir(projectPath); try { + // Load execution state to find features that were running before restart. + // This is critical because reconcileAllFeatureStates() runs at server startup + // and resets in_progress/interrupted/pipeline_* features to ready/backlog + // BEFORE the UI connects and calls this method. Without checking execution state, + // we would find no features to resume since their statuses have already been reset. + const executionState = await this.loadExecutionState(projectPath); + const previouslyRunningIds = new Set(executionState.runningFeatureIds ?? []); + const entries = await secureFs.readdir(featuresDir, { withFileTypes: true }); const featuresWithContext: Feature[] = []; const featuresWithoutContext: Feature[] = []; @@ -263,18 +271,37 @@ export class RecoveryService { logRecoveryWarning(result, `Feature ${entry.name}`, logger); const feature = result.data; if (!feature) continue; - if ( + + // Check if the feature should be resumed: + // 1. Features still in active states (in_progress, pipeline_*) - not yet reconciled + // 2. Features in interrupted state - explicitly marked for resume + // 3. Features that were previously running (from execution state) and are now + // in ready/backlog due to reconciliation resetting their status + const isActiveState = feature.status === 'in_progress' || - (feature.status && feature.status.startsWith('pipeline_')) - ) { - (await this.contextExists(projectPath, feature.id)) - ? featuresWithContext.push(feature) - : featuresWithoutContext.push(feature); + feature.status === 'interrupted' || + (feature.status && feature.status.startsWith('pipeline_')); + const wasReconciledFromRunning = + previouslyRunningIds.has(feature.id) && + (feature.status === 'ready' || feature.status === 'backlog'); + + if (isActiveState || wasReconciledFromRunning) { + if (await this.contextExists(projectPath, feature.id)) { + featuresWithContext.push(feature); + } else { + featuresWithoutContext.push(feature); + } } } } const allInterruptedFeatures = [...featuresWithContext, ...featuresWithoutContext]; if (allInterruptedFeatures.length === 0) return; + + logger.info( + `[resumeInterruptedFeatures] Found ${allInterruptedFeatures.length} feature(s) to resume ` + + `(${previouslyRunningIds.size} from execution state, statuses: ${allInterruptedFeatures.map((f) => `${f.id}=${f.status}`).join(', ')})` + ); + this.eventBus.emitAutoModeEvent('auto_mode_resuming_features', { message: `Resuming ${allInterruptedFeatures.length} interrupted feature(s)`, projectPath, @@ -295,6 +322,10 @@ export class RecoveryService { /* continue */ } } + + // Clear execution state after successful resume to prevent + // re-resuming the same features on subsequent calls + await this.clearExecutionState(projectPath); } catch { /* ignore */ } diff --git a/apps/server/src/services/zai-usage-service.ts b/apps/server/src/services/zai-usage-service.ts index c19cf6387..e779c5c34 100644 --- a/apps/server/src/services/zai-usage-service.ts +++ b/apps/server/src/services/zai-usage-service.ts @@ -1,7 +1,12 @@ import { createLogger } from '@automaker/utils'; +import { createEventEmitter } from '../lib/events.js'; +import type { SettingsService } from './settings-service.js'; const logger = createLogger('ZaiUsage'); +/** Default timeout for fetch requests in milliseconds */ +const FETCH_TIMEOUT_MS = 10_000; + /** * z.ai quota limit entry from the API */ @@ -112,6 +117,21 @@ interface ZaiApiResponse { message?: string; } +/** Result from configure method */ +interface ConfigureResult { + success: boolean; + message: string; + isAvailable: boolean; +} + +/** Result from verifyApiKey method */ +interface VerifyResult { + success: boolean; + authenticated: boolean; + message?: string; + error?: string; +} + /** * z.ai Usage Service * @@ -162,16 +182,163 @@ export class ZaiUsageService { return Boolean(token && token.length > 0); } + /** + * Configure z.ai API token and host. + * Persists the token via settingsService and updates in-memory state. + */ + async configure( + options: { apiToken?: string; apiHost?: string }, + settingsService: SettingsService + ): Promise { + const emitter = createEventEmitter(); + + if (options.apiToken !== undefined) { + // Set in-memory token + this.setApiToken(options.apiToken || ''); + + // Persist to credentials + try { + await settingsService.updateCredentials({ + apiKeys: { zai: options.apiToken || '' }, + } as Parameters[0]); + logger.info('[configure] Saved z.ai API key to credentials'); + } catch (persistError) { + logger.error('[configure] Failed to persist z.ai API key:', persistError); + } + } + + if (options.apiHost) { + this.setApiHost(options.apiHost); + } + + const result: ConfigureResult = { + success: true, + message: 'z.ai configuration updated', + isAvailable: this.isAvailable(), + }; + + emitter.emit('notification:created', { + type: 'zai.configured', + success: result.success, + isAvailable: result.isAvailable, + }); + + return result; + } + + /** + * Verify an API key without storing it. + * Makes a test request to the z.ai quota URL with the given key. + */ + async verifyApiKey(apiKey: string | undefined): Promise { + const emitter = createEventEmitter(); + + if (!apiKey || typeof apiKey !== 'string' || apiKey.trim().length === 0) { + return { + success: false, + authenticated: false, + error: 'Please provide an API key to test.', + }; + } + + const quotaUrl = + process.env.Z_AI_QUOTA_URL || + `${process.env.Z_AI_API_HOST ? `https://${process.env.Z_AI_API_HOST}` : 'https://api.z.ai'}/api/monitor/usage/quota/limit`; + + logger.info(`[verify] Testing API key against: ${quotaUrl}`); + + try { + const response = await fetch(quotaUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey.trim()}`, + Accept: 'application/json', + }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + let result: VerifyResult; + + if (response.ok) { + result = { + success: true, + authenticated: true, + message: 'Connection successful! z.ai API responded.', + }; + } else if (response.status === 401 || response.status === 403) { + result = { + success: false, + authenticated: false, + error: 'Invalid API key. Please check your key and try again.', + }; + } else { + result = { + success: false, + authenticated: false, + error: `API request failed: ${response.status} ${response.statusText}`, + }; + } + + emitter.emit('notification:created', { + type: 'zai.verify.result', + success: result.success, + authenticated: result.authenticated, + }); + + return result; + } catch (error) { + // Handle abort/timeout errors specifically + if (error instanceof Error && error.name === 'AbortError') { + const result: VerifyResult = { + success: false, + authenticated: false, + error: 'Request timed out. The z.ai API did not respond in time.', + }; + emitter.emit('notification:created', { + type: 'zai.verify.result', + success: false, + error: 'timeout', + }); + return result; + } + + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error verifying z.ai API key:', error); + + emitter.emit('notification:created', { + type: 'zai.verify.result', + success: false, + error: message, + }); + + return { + success: false, + authenticated: false, + error: `Network error: ${message}`, + }; + } + } + /** * Fetch usage data from z.ai API */ async fetchUsageData(): Promise { logger.info('[fetchUsageData] Starting...'); + const emitter = createEventEmitter(); + + emitter.emit('notification:created', { type: 'zai.usage.start' }); const token = this.getApiToken(); if (!token) { logger.error('[fetchUsageData] No API token configured'); - throw new Error('z.ai API token not configured. Set Z_AI_API_KEY environment variable.'); + const error = new Error( + 'z.ai API token not configured. Set Z_AI_API_KEY environment variable.' + ); + emitter.emit('notification:created', { + type: 'zai.usage.error', + error: error.message, + }); + throw error; } const quotaUrl = @@ -180,31 +347,68 @@ export class ZaiUsageService { logger.info(`[fetchUsageData] Fetching from: ${quotaUrl}`); try { - const response = await fetch(quotaUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/json', - }, - }); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const response = await fetch(quotaUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + logger.error(`[fetchUsageData] HTTP ${response.status}: ${response.statusText}`); + throw new Error(`z.ai API request failed: ${response.status} ${response.statusText}`); + } - if (!response.ok) { - logger.error(`[fetchUsageData] HTTP ${response.status}: ${response.statusText}`); - throw new Error(`z.ai API request failed: ${response.status} ${response.statusText}`); - } + const data = (await response.json()) as unknown as ZaiApiResponse; + logger.info('[fetchUsageData] Response received:', JSON.stringify(data, null, 2)); - const data = (await response.json()) as unknown as ZaiApiResponse; - logger.info('[fetchUsageData] Response received:', JSON.stringify(data, null, 2)); + const result = this.parseApiResponse(data); - return this.parseApiResponse(data); + emitter.emit('notification:created', { + type: 'zai.usage.success', + data: result, + }); + + return result; + } finally { + clearTimeout(timeoutId); + } } catch (error) { + // Handle abort/timeout errors + if (error instanceof Error && error.name === 'AbortError') { + const timeoutError = new Error(`z.ai API request timed out after ${FETCH_TIMEOUT_MS}ms`); + emitter.emit('notification:created', { + type: 'zai.usage.error', + error: timeoutError.message, + }); + throw timeoutError; + } + if (error instanceof Error && error.message.includes('z.ai API')) { + emitter.emit('notification:created', { + type: 'zai.usage.error', + error: error.message, + }); throw error; } + logger.error('[fetchUsageData] Failed to fetch:', error); - throw new Error( + const fetchError = new Error( `Failed to fetch z.ai usage data: ${error instanceof Error ? error.message : String(error)}` ); + emitter.emit('notification:created', { + type: 'zai.usage.error', + error: fetchError.message, + }); + throw fetchError; } } diff --git a/apps/server/src/tests/cli-integration.test.ts b/apps/server/src/tests/cli-integration.test.ts index 7e84eb54b..87269ac0a 100644 --- a/apps/server/src/tests/cli-integration.test.ts +++ b/apps/server/src/tests/cli-integration.test.ts @@ -5,7 +5,7 @@ * across all providers (Claude, Codex, Cursor) */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { detectCli, detectAllCLis, @@ -270,7 +270,7 @@ describe('Error Recovery Tests', () => { expect(results).toHaveProperty('cursor'); // Should provide error information for failures - Object.entries(results).forEach(([provider, result]) => { + Object.entries(results).forEach(([_provider, result]) => { if (!result.detected && result.issues.length > 0) { expect(result.issues.length).toBeGreaterThan(0); expect(result.issues[0]).toBeTruthy(); diff --git a/apps/server/tests/unit/services/recovery-service.test.ts b/apps/server/tests/unit/services/recovery-service.test.ts index 90be3eb21..cd99fc088 100644 --- a/apps/server/tests/unit/services/recovery-service.test.ts +++ b/apps/server/tests/unit/services/recovery-service.test.ts @@ -491,6 +491,32 @@ describe('recovery-service.ts', () => { ); }); + it('finds features with interrupted status', async () => { + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + vi.mocked(utils.readJsonWithRecovery).mockResolvedValueOnce({ + data: { id: 'feature-1', title: 'Feature 1', status: 'interrupted' }, + wasRecovered: false, + }); + + mockLoadFeature.mockResolvedValue({ + id: 'feature-1', + title: 'Feature 1', + status: 'interrupted', + description: 'Test', + }); + + await service.resumeInterruptedFeatures('/test/project'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_resuming_features', + expect.objectContaining({ + featureIds: ['feature-1'], + }) + ); + }); + it('finds features with pipeline_* status', async () => { vi.mocked(secureFs.readdir).mockResolvedValueOnce([ { name: 'feature-1', isDirectory: () => true } as any, @@ -519,6 +545,100 @@ describe('recovery-service.ts', () => { ); }); + it('finds reconciled features using execution state (ready/backlog from previously running)', async () => { + // Simulate execution state with previously running feature IDs + const executionState = { + version: 1, + autoLoopWasRunning: true, + maxConcurrency: 2, + projectPath: '/test/project', + branchName: null, + runningFeatureIds: ['feature-1', 'feature-2'], + savedAt: '2026-01-27T12:00:00Z', + }; + vi.mocked(secureFs.readFile).mockResolvedValueOnce(JSON.stringify(executionState)); + + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-1', isDirectory: () => true } as any, + { name: 'feature-2', isDirectory: () => true } as any, + { name: 'feature-3', isDirectory: () => true } as any, + ]); + // feature-1 was reconciled from in_progress to ready + // feature-2 was reconciled from in_progress to backlog + // feature-3 is in backlog but was NOT previously running + vi.mocked(utils.readJsonWithRecovery) + .mockResolvedValueOnce({ + data: { id: 'feature-1', title: 'Feature 1', status: 'ready' }, + wasRecovered: false, + }) + .mockResolvedValueOnce({ + data: { id: 'feature-2', title: 'Feature 2', status: 'backlog' }, + wasRecovered: false, + }) + .mockResolvedValueOnce({ + data: { id: 'feature-3', title: 'Feature 3', status: 'backlog' }, + wasRecovered: false, + }); + + mockLoadFeature + .mockResolvedValueOnce({ + id: 'feature-1', + title: 'Feature 1', + status: 'ready', + description: 'Test', + }) + .mockResolvedValueOnce({ + id: 'feature-2', + title: 'Feature 2', + status: 'backlog', + description: 'Test', + }); + + await service.resumeInterruptedFeatures('/test/project'); + + // Should resume feature-1 and feature-2 (from execution state) but NOT feature-3 + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_resuming_features', + expect.objectContaining({ + featureIds: ['feature-1', 'feature-2'], + }) + ); + }); + + it('clears execution state after successful resume', async () => { + // Simulate execution state + const executionState = { + version: 1, + autoLoopWasRunning: true, + maxConcurrency: 1, + projectPath: '/test/project', + branchName: null, + runningFeatureIds: ['feature-1'], + savedAt: '2026-01-27T12:00:00Z', + }; + vi.mocked(secureFs.readFile).mockResolvedValueOnce(JSON.stringify(executionState)); + + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + vi.mocked(utils.readJsonWithRecovery).mockResolvedValueOnce({ + data: { id: 'feature-1', title: 'Feature 1', status: 'ready' }, + wasRecovered: false, + }); + + mockLoadFeature.mockResolvedValue({ + id: 'feature-1', + title: 'Feature 1', + status: 'ready', + description: 'Test', + }); + + await service.resumeInterruptedFeatures('/test/project'); + + // Should clear execution state after resuming + expect(secureFs.unlink).toHaveBeenCalledWith('/test/project/.automaker/execution-state.json'); + }); + it('distinguishes features with/without context', async () => { vi.mocked(secureFs.readdir).mockResolvedValueOnce([ { name: 'feature-with', isDirectory: () => true } as any, diff --git a/apps/ui/src/components/usage-popover.tsx b/apps/ui/src/components/usage-popover.tsx index 5c961f83a..6122f24c5 100644 --- a/apps/ui/src/components/usage-popover.tsx +++ b/apps/ui/src/components/usage-popover.tsx @@ -340,7 +340,7 @@ export function UsagePopover() { // Calculate max percentage for header button const claudeSessionPercentage = claudeUsage?.sessionPercentage || 0; - const codexMaxPercentage = codexUsage?.rateLimits + const _codexMaxPercentage = codexUsage?.rateLimits ? Math.max( codexUsage.rateLimits.primary?.usedPercent || 0, codexUsage.rateLimits.secondary?.usedPercent || 0 @@ -369,7 +369,7 @@ export function UsagePopover() { codexSecondaryWindowMinutes && codexPrimaryWindowMinutes ? Math.min(codexPrimaryWindowMinutes, codexSecondaryWindowMinutes) : (codexSecondaryWindowMinutes ?? codexPrimaryWindowMinutes); - const codexWindowLabel = codexWindowMinutes + const _codexWindowLabel = codexWindowMinutes ? getCodexWindowLabel(codexWindowMinutes).title : 'Window'; const codexWindowUsage = @@ -408,16 +408,16 @@ export function UsagePopover() { } : null; - const statusColor = getStatusInfo(indicatorInfo.percentage).color; - const ProviderIcon = indicatorInfo.icon; + const statusColor = indicatorInfo ? getStatusInfo(indicatorInfo.percentage).color : ''; + const ProviderIcon = indicatorInfo?.icon; const trigger = ( - )} - {feature.skipTests && onManualVerify ? ( - - ) : onResume ? ( - - ) : onVerify ? ( - - ) : null} - {onViewOutput && !feature.skipTests && ( - + {/* When feature is in_progress with no error and onForceStop is available, + it means the agent is starting/running but hasn't been added to runningAutoTasks yet. + Show Stop button instead of Verify/Resume to avoid confusing UI during this race window. */} + {!feature.error && onForceStop ? ( + <> + {onViewOutput && ( + + )} + + + ) : ( + <> + {/* Approve Plan button - shows when plan is generated and waiting for approval */} + {feature.planSpec?.status === 'generated' && onApprovePlan && ( + + )} + {feature.skipTests && onManualVerify ? ( + + ) : onResume ? ( + + ) : onVerify ? ( + + ) : null} + {onViewOutput && !feature.skipTests && ( + + )} + )} )} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx index ab109d5f1..b54c5fcc2 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx @@ -112,9 +112,15 @@ export const KanbanCard = memo(function KanbanCard({ currentProject: state.currentProject, })) ); - // A card in waiting_approval should not display as "actively running" even if - // it's still in the runningAutoTasks list. The waiting_approval UI takes precedence. - const isActivelyRunning = !!isCurrentAutoTask && feature.status !== 'waiting_approval'; + // A card should only display as "actively running" if it's both in the + // runningAutoTasks list AND in an execution-compatible status. Cards in resting + // states (backlog, ready, waiting_approval, verified, completed) should never + // show running controls, even if they appear in runningAutoTasks due to stale + // state (e.g., after a server restart that reconciled features back to backlog). + const isInExecutionState = + feature.status === 'in_progress' || + (typeof feature.status === 'string' && feature.status.startsWith('pipeline_')); + const isActivelyRunning = !!isCurrentAutoTask && isInExecutionState; const [isLifted, setIsLifted] = useState(false); useLayoutEffect(() => { diff --git a/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx b/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx index 6d14c269d..8fcd0bb9d 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx @@ -209,9 +209,15 @@ export const ListRow = memo(function ListRow({ blockingDependencies = [], className, }: ListRowProps) { - // A card in waiting_approval should not display as "actively running" even if - // it's still in the runningAutoTasks list. The waiting_approval UI takes precedence. - const isActivelyRunning = isCurrentAutoTask && feature.status !== 'waiting_approval'; + // A row should only display as "actively running" if it's both in the + // runningAutoTasks list AND in an execution-compatible status. Features in resting + // states (backlog, ready, waiting_approval, verified, completed) should never + // show running controls, even if they appear in runningAutoTasks due to stale + // state (e.g., after a server restart that reconciled features back to backlog). + const isInExecutionState = + feature.status === 'in_progress' || + (typeof feature.status === 'string' && feature.status.startsWith('pipeline_')); + const isActivelyRunning = isCurrentAutoTask && isInExecutionState; const handleRowClick = useCallback( (e: React.MouseEvent) => { diff --git a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx index 60158d0fd..894625638 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx @@ -143,6 +143,17 @@ function getPrimaryAction( }; } + // In progress with no error - agent is starting/running but not yet in runningAutoTasks. + // Show Stop button immediately instead of Verify/Resume during this race window. + if (feature.status === 'in_progress' && !feature.error && handlers.onForceStop) { + return { + icon: StopCircle, + label: 'Stop', + onClick: handlers.onForceStop, + variant: 'destructive', + }; + } + // In progress with plan approval pending if ( feature.status === 'in_progress' && @@ -446,81 +457,126 @@ export const RowActions = memo(function RowActions({ )} - {/* In Progress actions */} - {!isCurrentAutoTask && feature.status === 'in_progress' && ( - <> - {handlers.onViewOutput && ( - - )} - {feature.planSpec?.status === 'generated' && handlers.onApprovePlan && ( - - )} - {feature.skipTests && handlers.onManualVerify ? ( - - ) : handlers.onResume ? ( - - ) : null} - - - {handlers.onSpawnTask && ( - - )} - {handlers.onDuplicate && ( - -
- - - Duplicate - + {/* In Progress actions - starting/running (no error, force stop available) - mirrors running task actions */} + {!isCurrentAutoTask && + feature.status === 'in_progress' && + !feature.error && + handlers.onForceStop && ( + <> + {handlers.onViewOutput && ( + + )} + {feature.planSpec?.status === 'generated' && handlers.onApprovePlan && ( + + )} + + {handlers.onSpawnTask && ( + + )} + {handlers.onForceStop && ( + <> + + + + )} + + )} + + {/* In Progress actions - interrupted/error state */} + {!isCurrentAutoTask && + feature.status === 'in_progress' && + !(!feature.error && handlers.onForceStop) && ( + <> + {handlers.onViewOutput && ( + + )} + {feature.planSpec?.status === 'generated' && handlers.onApprovePlan && ( + + )} + {feature.skipTests && handlers.onManualVerify ? ( + + ) : handlers.onResume ? ( + + ) : null} + + + {handlers.onSpawnTask && ( + + )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
{handlers.onDuplicateAsChild && ( - + + + )} -
- {handlers.onDuplicateAsChild && ( - - - - )} -
- )} - - - )} + + )} + + + )} {/* Waiting Approval actions */} {!isCurrentAutoTask && feature.status === 'waiting_approval' && ( diff --git a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx index f6a89cf14..d78ce2241 100644 --- a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx +++ b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx @@ -5,7 +5,6 @@ import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; import { AnthropicIcon, OpenAIIcon, ZaiIcon, GeminiIcon } from '@/components/ui/provider-icon'; -import type { GeminiUsage } from '@/store/app-store'; import { getExpectedWeeklyPacePercentage, getPaceStatusLabel } from '@/store/utils/usage-utils'; interface MobileUsageBarProps { @@ -42,6 +41,11 @@ function formatResetTime(unixTimestamp: number, isMilliseconds = false): string const now = new Date(); const diff = date.getTime() - now.getTime(); + // Handle past timestamps (negative diff) + if (diff <= 0) { + return 'Resetting soon'; + } + if (diff < 3600000) { const mins = Math.ceil(diff / 60000); return `Resets in ${mins}m`; @@ -184,12 +188,11 @@ export function MobileUsageBar({ const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore(); const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore(); const { zaiUsage, zaiUsageLastUpdated, setZaiUsage } = useAppStore(); + const { geminiUsage, geminiUsageLastUpdated, setGeminiUsage } = useAppStore(); const [isClaudeLoading, setIsClaudeLoading] = useState(false); const [isCodexLoading, setIsCodexLoading] = useState(false); const [isZaiLoading, setIsZaiLoading] = useState(false); const [isGeminiLoading, setIsGeminiLoading] = useState(false); - const [geminiUsage, setGeminiUsage] = useState(null); - const [geminiUsageLastUpdated, setGeminiUsageLastUpdated] = useState(null); // Check if data is stale (older than 2 minutes) const isClaudeStale = @@ -254,15 +257,14 @@ export function MobileUsageBar({ if (!api.gemini) return; const data = await api.gemini.getUsage(); if (!('error' in data)) { - setGeminiUsage(data); - setGeminiUsageLastUpdated(Date.now()); + setGeminiUsage(data, Date.now()); } } catch { // Silently fail - usage display is optional } finally { setIsGeminiLoading(false); } - }, []); + }, [setGeminiUsage]); const getCodexWindowLabel = (durationMins: number) => { if (durationMins < 60) return `${durationMins}m Window`; diff --git a/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts b/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts index 1b6738ece..23ccd1924 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts +++ b/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts @@ -1,4 +1,4 @@ -// @ts-nocheck - API key management state with validation and persistence +// API key management state with validation and persistence import { useState, useEffect } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { createLogger } from '@automaker/utils/logger'; @@ -23,20 +23,44 @@ interface ApiKeyStatus { hasZaiKey: boolean; } +/** Shape of the configure API response */ +interface ConfigureResponse { + success?: boolean; + isAvailable?: boolean; + error?: string; +} + +/** Shape of a verify API response */ +interface VerifyResponse { + success?: boolean; + authenticated?: boolean; + message?: string; + error?: string; +} + +/** Shape of an API key status response from the env check */ +interface ApiKeyStatusResponse { + success: boolean; + hasAnthropicKey: boolean; + hasGoogleKey: boolean; + hasOpenaiKey: boolean; + hasZaiKey?: boolean; +} + /** * Custom hook for managing API key state and operations * Handles input values, visibility toggles, connection testing, and saving */ export function useApiKeyManagement() { const { apiKeys, setApiKeys } = useAppStore(); - const { setZaiAuthStatus } = useSetupStore(); + const { setZaiAuthStatus, zaiAuthStatus } = useSetupStore(); const queryClient = useQueryClient(); // API key values - const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic); - const [googleKey, setGoogleKey] = useState(apiKeys.google); - const [openaiKey, setOpenaiKey] = useState(apiKeys.openai); - const [zaiKey, setZaiKey] = useState(apiKeys.zai); + const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic); + const [googleKey, setGoogleKey] = useState(apiKeys.google); + const [openaiKey, setOpenaiKey] = useState(apiKeys.openai); + const [zaiKey, setZaiKey] = useState(apiKeys.zai); // Visibility toggles const [showAnthropicKey, setShowAnthropicKey] = useState(false); @@ -74,7 +98,7 @@ export function useApiKeyManagement() { const api = getElectronAPI(); if (api?.setup?.getApiKeys) { try { - const status = await api.setup.getApiKeys(); + const status: ApiKeyStatusResponse = await api.setup.getApiKeys(); if (status.success) { setApiKeyStatus({ hasAnthropicKey: status.hasAnthropicKey, @@ -92,7 +116,7 @@ export function useApiKeyManagement() { }, []); // Test Anthropic/Claude connection - const handleTestAnthropicConnection = async () => { + const handleTestAnthropicConnection = async (): Promise => { // Validate input first if (!anthropicKey || anthropicKey.trim().length === 0) { setTestResult({ @@ -106,7 +130,7 @@ export function useApiKeyManagement() { setTestResult(null); try { - const api = getElectronAPI(); + const api = getHttpApiClient(); // Pass the current input value to test unsaved keys const data = await api.setup.verifyClaudeAuth('api_key', anthropicKey); @@ -133,7 +157,7 @@ export function useApiKeyManagement() { // Test Google/Gemini connection // TODO: Add backend endpoint for Gemini API key verification - const handleTestGeminiConnection = async () => { + const handleTestGeminiConnection = async (): Promise => { setTestingGeminiConnection(true); setGeminiTestResult(null); @@ -157,12 +181,12 @@ export function useApiKeyManagement() { }; // Test OpenAI/Codex connection - const handleTestOpenaiConnection = async () => { + const handleTestOpenaiConnection = async (): Promise => { setTestingOpenaiConnection(true); setOpenaiTestResult(null); try { - const api = getElectronAPI(); + const api = getHttpApiClient(); const data = await api.setup.verifyCodexAuth('api_key', openaiKey); if (data.success && data.authenticated) { @@ -187,7 +211,7 @@ export function useApiKeyManagement() { }; // Test z.ai connection - const handleTestZaiConnection = async () => { + const handleTestZaiConnection = async (): Promise => { setTestingZaiConnection(true); setZaiTestResult(null); @@ -204,7 +228,7 @@ export function useApiKeyManagement() { try { const api = getElectronAPI(); // Use the verify endpoint to test the key without storing it - const response = await api.zai?.verify(zaiKey); + const response: VerifyResponse | undefined = await api.zai?.verify(zaiKey); if (response?.success && response?.authenticated) { setZaiTestResult({ @@ -228,42 +252,70 @@ export function useApiKeyManagement() { }; // Save API keys - const handleSave = async () => { - setApiKeys({ - anthropic: anthropicKey, - google: googleKey, - openai: openaiKey, - zai: zaiKey, - }); - + const handleSave = async (): Promise => { // Configure z.ai service on the server with the new key if (zaiKey && zaiKey.trim().length > 0) { try { const api = getHttpApiClient(); - const result = await api.zai.configure(zaiKey.trim()); + const result: ConfigureResponse = await api.zai.configure(zaiKey.trim()); + + if (result.success) { + // Only persist to local store after server confirms success + setApiKeys({ + anthropic: anthropicKey, + google: googleKey, + openai: openaiKey, + zai: zaiKey, + }); + + // Preserve the existing hasEnvApiKey flag from current auth status + const currentHasEnvApiKey = zaiAuthStatus?.hasEnvApiKey ?? false; - if (result.success || result.isAvailable) { // Update z.ai auth status in the store setZaiAuthStatus({ authenticated: true, method: 'api_key' as ZaiAuthMethod, hasApiKey: true, - hasEnvApiKey: false, + hasEnvApiKey: currentHasEnvApiKey, }); // Invalidate the z.ai usage query so it refetches with the new key await queryClient.invalidateQueries({ queryKey: queryKeys.usage.zai() }); logger.info('z.ai API key configured successfully'); + } else { + // Server config failed - still save other keys but log the issue + logger.error('z.ai API key configuration failed on server'); + setApiKeys({ + anthropic: anthropicKey, + google: googleKey, + openai: openaiKey, + zai: zaiKey, + }); } } catch (error) { logger.error('Failed to configure z.ai API key:', error); + // Still save other keys even if z.ai config fails + setApiKeys({ + anthropic: anthropicKey, + google: googleKey, + openai: openaiKey, + zai: zaiKey, + }); } } else { + // Save keys (z.ai key is empty/removed) + setApiKeys({ + anthropic: anthropicKey, + google: googleKey, + openai: openaiKey, + zai: zaiKey, + }); + // Clear z.ai auth status if key is removed setZaiAuthStatus({ authenticated: false, method: 'none' as ZaiAuthMethod, hasApiKey: false, - hasEnvApiKey: false, + hasEnvApiKey: zaiAuthStatus?.hasEnvApiKey ?? false, }); // Invalidate the query to clear any cached data await queryClient.invalidateQueries({ queryKey: queryKeys.usage.zai() }); diff --git a/apps/ui/src/hooks/use-auto-mode.ts b/apps/ui/src/hooks/use-auto-mode.ts index c6dba5b3a..70da21eeb 100644 --- a/apps/ui/src/hooks/use-auto-mode.ts +++ b/apps/ui/src/hooks/use-auto-mode.ts @@ -172,7 +172,10 @@ export function useAutoMode(worktree?: WorktreeInfo) { (backendIsRunning && Array.isArray(backendRunningFeatures) && backendRunningFeatures.length > 0 && - !arraysEqual(backendRunningFeatures, runningAutoTasks)); + !arraysEqual(backendRunningFeatures, runningAutoTasks)) || + // Also sync when UI has stale running tasks but backend has none + // (handles server restart where features were reconciled to backlog/ready) + (!backendIsRunning && runningAutoTasks.length > 0 && backendRunningFeatures.length === 0); if (needsSync) { const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; diff --git a/apps/ui/src/hooks/use-provider-auth-init.ts b/apps/ui/src/hooks/use-provider-auth-init.ts index f8919b1e0..02ae801ef 100644 --- a/apps/ui/src/hooks/use-provider-auth-init.ts +++ b/apps/ui/src/hooks/use-provider-auth-init.ts @@ -108,22 +108,41 @@ export function useProviderAuthInit() { try { const result = await api.zai.getStatus(); if (result.success || result.available !== undefined) { + const available = !!result.available; + const hasApiKey = !!(result.hasApiKey ?? result.available); + const hasEnvApiKey = !!(result.hasEnvApiKey ?? false); + let method: ZaiAuthMethod = 'none'; - if (result.hasEnvApiKey) { + if (hasEnvApiKey) { method = 'api_key_env'; - } else if (result.hasApiKey || result.available) { + } else if (hasApiKey || available) { method = 'api_key'; } setZaiAuthStatus({ - authenticated: result.available, + authenticated: available, method, - hasApiKey: result.hasApiKey ?? result.available, - hasEnvApiKey: result.hasEnvApiKey ?? false, + hasApiKey, + hasEnvApiKey, + }); + } else { + // Non-success path - set default unauthenticated status + setZaiAuthStatus({ + authenticated: false, + method: 'none', + hasApiKey: false, + hasEnvApiKey: false, }); } } catch (error) { logger.error('Failed to init z.ai auth status:', error); + // Set default status on error to prevent stale state + setZaiAuthStatus({ + authenticated: false, + method: 'none', + hasApiKey: false, + hasEnvApiKey: false, + }); } // 4. Gemini Auth Status @@ -134,7 +153,7 @@ export function useProviderAuthInit() { setGeminiCliStatus({ installed: result.installed ?? false, version: result.version, - path: result.status, + path: result.path, }); // Set Auth status - always set a status to mark initialization as complete diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 4238f58ed..71fb68f71 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -41,7 +41,12 @@ import type { Notification, } from '@automaker/types'; import type { Message, SessionListItem } from '@/types/electron'; -import type { ClaudeUsageResponse, CodexUsageResponse, GeminiUsage } from '@/store/app-store'; +import type { + ClaudeUsageResponse, + CodexUsageResponse, + GeminiUsage, + ZaiUsageResponse, +} from '@/store/app-store'; import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron'; import type { ModelId, ThinkingLevel, ReasoningEffort, Feature } from '@automaker/types'; import { getGlobalFileBrowser } from '@/contexts/file-browser-context'; @@ -1748,35 +1753,7 @@ export class HttpApiClient implements ElectronAPI { error?: string; }> => this.get('/api/zai/status'), - getUsage: (): Promise<{ - quotaLimits?: { - tokens?: { - limitType: string; - limit: number; - used: number; - remaining: number; - usedPercent: number; - nextResetTime: number; - }; - time?: { - limitType: string; - limit: number; - used: number; - remaining: number; - usedPercent: number; - nextResetTime: number; - }; - planType: string; - } | null; - usageDetails?: Array<{ - modelId: string; - used: number; - limit: number; - }>; - lastUpdated: string; - error?: string; - message?: string; - }> => this.get('/api/zai/usage'), + getUsage: (): Promise => this.get('/api/zai/usage'), configure: ( apiToken?: string, diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 034b1aa59..235fd9f67 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -321,6 +321,8 @@ const initialState: AppState = { codexUsageLastUpdated: null, zaiUsage: null, zaiUsageLastUpdated: null, + geminiUsage: null, + geminiUsageLastUpdated: null, codexModels: [], codexModelsLoading: false, codexModelsError: null, @@ -2410,6 +2412,13 @@ export const useAppStore = create()((set, get) => ({ // z.ai Usage Tracking actions setZaiUsage: (usage) => set({ zaiUsage: usage, zaiUsageLastUpdated: usage ? Date.now() : null }), + // Gemini Usage Tracking actions + setGeminiUsage: (usage, lastUpdated) => + set({ + geminiUsage: usage, + geminiUsageLastUpdated: lastUpdated ?? (usage ? Date.now() : null), + }), + // Codex Models actions fetchCodexModels: async (forceRefresh = false) => { const state = get(); diff --git a/apps/ui/src/store/types/state-types.ts b/apps/ui/src/store/types/state-types.ts index bff4d474f..25bc3dfa6 100644 --- a/apps/ui/src/store/types/state-types.ts +++ b/apps/ui/src/store/types/state-types.ts @@ -34,7 +34,7 @@ import type { ApiKeys } from './settings-types'; import type { ChatMessage, ChatSession } from './chat-types'; import type { TerminalState, TerminalPanelContent, PersistedTerminalState } from './terminal-types'; import type { Feature, ProjectAnalysis } from './project-types'; -import type { ClaudeUsage, CodexUsage, ZaiUsage } from './usage-types'; +import type { ClaudeUsage, CodexUsage, ZaiUsage, GeminiUsage } from './usage-types'; /** State for worktree init script execution */ export interface InitScriptState { @@ -299,6 +299,10 @@ export interface AppState { zaiUsage: ZaiUsage | null; zaiUsageLastUpdated: number | null; + // Gemini Usage Tracking + geminiUsage: GeminiUsage | null; + geminiUsageLastUpdated: number | null; + // Codex Models (dynamically fetched) codexModels: Array<{ id: string; @@ -769,6 +773,9 @@ export interface AppActions { // z.ai Usage Tracking actions setZaiUsage: (usage: ZaiUsage | null) => void; + // Gemini Usage Tracking actions + setGeminiUsage: (usage: GeminiUsage | null, lastUpdated?: number) => void; + // Codex Models actions fetchCodexModels: (forceRefresh?: boolean) => Promise; setCodexModels: (