diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index 64ace35d..9dd1943f 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -161,11 +161,15 @@ export class ClaudeUsageService { const workingDirectory = this.isWindows ? process.env.USERPROFILE || os.homedir() || 'C:\\' - : process.env.HOME || os.homedir() || '/tmp'; + : os.tmpdir(); // Use platform-appropriate shell and command const shell = this.isWindows ? 'cmd.exe' : '/bin/sh'; - const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage']; + // Use --add-dir to whitelist the current directory and bypass the trust prompt + // We don't pass /usage here, we'll type it into the REPL + const args = this.isWindows + ? ['/c', 'claude', '--add-dir', workingDirectory] + : ['-c', `claude --add-dir "${workingDirectory}"`]; let ptyProcess: any = null; @@ -181,8 +185,7 @@ export class ClaudeUsageService { } as Record, }); } catch (spawnError) { - // pty.spawn() can throw synchronously if the native module fails to load - // or if PTY is not available in the current environment (e.g., containers without /dev/pts) + // ... (error handling omitted for brevity in replace block, keep existing) const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError); logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage); @@ -205,16 +208,52 @@ export class ClaudeUsageService { if (output.includes('Current session')) { resolve(output); } else { - reject(new Error('Command timed out')); + reject( + new Error( + 'The Claude CLI took too long to respond. This can happen if the CLI is waiting for a trust prompt or is otherwise busy.' + ) + ); } } - }, this.timeout); + }, 45000); // 45 second timeout + + let hasSentCommand = false; + let hasApprovedTrust = false; ptyProcess.onData((data: string) => { output += data; - // Check if we've seen the usage data (look for "Current session") - if (!hasSeenUsageData && output.includes('Current session')) { + // Strip ANSI codes for easier matching + // eslint-disable-next-line no-control-regex + const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ''); + + // Check for specific authentication/permission errors + if ( + cleanOutput.includes('OAuth token does not meet scope requirement') || + cleanOutput.includes('permission_error') || + cleanOutput.includes('token_expired') || + cleanOutput.includes('authentication_error') + ) { + if (!settled) { + settled = true; + if (ptyProcess && !ptyProcess.killed) { + ptyProcess.kill(); + } + reject( + new Error( + "Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions." + ) + ); + } + return; + } + + // Check if we've seen the usage data (look for "Current session" or the TUI Usage header) + if ( + !hasSeenUsageData && + (cleanOutput.includes('Current session') || + (cleanOutput.includes('Usage') && cleanOutput.includes('% left'))) + ) { hasSeenUsageData = true; // Wait for full output, then send escape to exit setTimeout(() => { @@ -228,16 +267,54 @@ export class ClaudeUsageService { } }, 2000); } - }, 2000); + }, 3000); + } + + // Handle Trust Dialog: "Do you want to work in this folder?" + // Since we are running in os.tmpdir(), it is safe to approve. + if (!hasApprovedTrust && cleanOutput.includes('Do you want to work in this folder?')) { + hasApprovedTrust = true; + // Wait a tiny bit to ensure prompt is ready, then send Enter + setTimeout(() => { + if (!settled && ptyProcess && !ptyProcess.killed) { + ptyProcess.write('\r'); + } + }, 1000); + } + + // Detect REPL prompt and send /usage command + if ( + !hasSentCommand && + (cleanOutput.includes('❯') || cleanOutput.includes('? for shortcuts')) + ) { + hasSentCommand = true; + // Wait for REPL to fully settle + setTimeout(() => { + if (!settled && ptyProcess && !ptyProcess.killed) { + // Send command with carriage return + ptyProcess.write('/usage\r'); + + // Send another enter after 1 second to confirm selection if autocomplete menu appeared + setTimeout(() => { + if (!settled && ptyProcess && !ptyProcess.killed) { + ptyProcess.write('\r'); + } + }, 1200); + } + }, 1500); } // Fallback: if we see "Esc to cancel" but haven't seen usage data yet - if (!hasSeenUsageData && output.includes('Esc to cancel')) { + if ( + !hasSeenUsageData && + cleanOutput.includes('Esc to cancel') && + !cleanOutput.includes('Do you want to work in this folder?') + ) { setTimeout(() => { if (!settled && ptyProcess && !ptyProcess.killed) { ptyProcess.write('\x1b'); // Send escape key } - }, 3000); + }, 5000); } }); @@ -246,8 +323,11 @@ export class ClaudeUsageService { if (settled) return; settled = true; - // Check for authentication errors in output - if (output.includes('token_expired') || output.includes('authentication_error')) { + if ( + output.includes('token_expired') || + output.includes('authentication_error') || + output.includes('permission_error') + ) { reject(new Error("Authentication required - please run 'claude login'")); return; } diff --git a/apps/ui/src/app.tsx b/apps/ui/src/app.tsx index 31a71e85..c27cd5e7 100644 --- a/apps/ui/src/app.tsx +++ b/apps/ui/src/app.tsx @@ -5,6 +5,7 @@ import { router } from './utils/router'; import { SplashScreen } from './components/splash-screen'; import { useSettingsSync } from './hooks/use-settings-sync'; import { useCursorStatusInit } from './hooks/use-cursor-status-init'; +import { useProviderAuthInit } from './hooks/use-provider-auth-init'; import './styles/global.css'; import './styles/theme-imports'; @@ -24,8 +25,11 @@ export default function App() { useEffect(() => { if (import.meta.env.DEV) { const clearPerfEntries = () => { - performance.clearMarks(); - performance.clearMeasures(); + // Check if window.performance is available before calling its methods + if (window.performance) { + window.performance.clearMarks(); + window.performance.clearMeasures(); + } }; const interval = setInterval(clearPerfEntries, 5000); return () => clearInterval(interval); @@ -45,6 +49,9 @@ export default function App() { // Initialize Cursor CLI status at startup useCursorStatusInit(); + // Initialize Provider auth status at startup (for Claude/Codex usage display) + useProviderAuthInit(); + const handleSplashComplete = useCallback(() => { sessionStorage.setItem('automaker-splash-shown', 'true'); setShowSplash(false); diff --git a/apps/ui/src/components/usage-popover.tsx b/apps/ui/src/components/usage-popover.tsx index e772d48b..b34765bd 100644 --- a/apps/ui/src/components/usage-popover.tsx +++ b/apps/ui/src/components/usage-popover.tsx @@ -72,8 +72,7 @@ export function UsagePopover() { const [codexError, setCodexError] = useState(null); // Check authentication status - const isClaudeCliVerified = - claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated'; + const isClaudeCliVerified = !!claudeAuthStatus?.authenticated; const isCodexAuthenticated = codexAuthStatus?.authenticated; // Determine which tab to show by default 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 9ad795d3..4fbbb678 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -69,7 +69,6 @@ export function BoardHeader({ const [showAutoModeSettings, setShowAutoModeSettings] = useState(false); const [showWorktreeSettings, setShowWorktreeSettings] = useState(false); const [showPlanSettings, setShowPlanSettings] = useState(false); - const apiKeys = useAppStore((state) => state.apiKeys); const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode); const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode); @@ -108,15 +107,8 @@ export function BoardHeader({ [projectPath, setWorktreePanelVisible] ); - // Claude usage tracking visibility logic - // Hide when using API key (only show for Claude Code CLI users) - // Also hide on Windows for now (CLI usage command not supported) - const isWindows = - typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win'); - const hasClaudeApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey; - const isClaudeCliVerified = - claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated'; - const showClaudeUsage = !hasClaudeApiKey && !isWindows && isClaudeCliVerified; + const isClaudeCliVerified = !!claudeAuthStatus?.authenticated; + const showClaudeUsage = isClaudeCliVerified; // Codex usage tracking visibility logic // Show if Codex is authenticated (CLI or API key) @@ -143,8 +135,8 @@ export function BoardHeader({ />
- {/* Usage Popover - show if either provider is authenticated */} - {isMounted && (showClaudeUsage || showCodexUsage) && } + {/* Usage Popover - show if either provider is authenticated, only on desktop */} + {isMounted && !isMobile && (showClaudeUsage || showCodexUsage) && } {/* Mobile view: show hamburger menu with all controls */} {isMounted && isMobile && ( @@ -158,6 +150,8 @@ export function BoardHeader({ onAutoModeToggle={onAutoModeToggle} onOpenAutoModeSettings={() => setShowAutoModeSettings(true)} onOpenPlanDialog={onOpenPlanDialog} + showClaudeUsage={showClaudeUsage} + showCodexUsage={showCodexUsage} /> )} 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 fb63daac..e36b95f5 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 @@ -11,6 +11,7 @@ import { } from '@/components/ui/dropdown-menu'; import { Menu, Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { MobileUsageBar } from './mobile-usage-bar'; interface HeaderMobileMenuProps { // Worktree panel visibility @@ -26,6 +27,9 @@ interface HeaderMobileMenuProps { onOpenAutoModeSettings: () => void; // Plan button onOpenPlanDialog: () => void; + // Usage bar visibility + showClaudeUsage: boolean; + showCodexUsage: boolean; } export function HeaderMobileMenu({ @@ -38,6 +42,8 @@ export function HeaderMobileMenu({ onAutoModeToggle, onOpenAutoModeSettings, onOpenPlanDialog, + showClaudeUsage, + showCodexUsage, }: HeaderMobileMenuProps) { return ( @@ -52,6 +58,17 @@ export function HeaderMobileMenu({ + {/* Usage Bar - show if either provider is authenticated */} + {(showClaudeUsage || showCodexUsage) && ( + <> + + Usage + + + + + )} + Controls 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 new file mode 100644 index 00000000..5ff62eeb --- /dev/null +++ b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx @@ -0,0 +1,217 @@ +import { useEffect, useCallback } from 'react'; +import { RefreshCw, AlertTriangle } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { getElectronAPI } from '@/lib/electron'; +import { useAppStore, type ClaudeUsage, type CodexUsage } from '@/store/app-store'; +import { useSetupStore } from '@/store/setup-store'; +import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon'; + +interface MobileUsageBarProps { + showClaudeUsage: boolean; + showCodexUsage: boolean; +} + +// Helper to get progress bar color based on percentage +function getProgressBarColor(percentage: number): string { + if (percentage >= 80) return 'bg-red-500'; + if (percentage >= 50) return 'bg-yellow-500'; + return 'bg-green-500'; +} + +// Individual usage bar component +function UsageBar({ + label, + percentage, + isStale, +}: { + label: string; + percentage: number; + isStale: boolean; +}) { + return ( +
+
+ + {label} + + = 80 + ? 'text-red-500' + : percentage >= 50 + ? 'text-yellow-500' + : 'text-green-500' + )} + > + {Math.round(percentage)}% + +
+
+
+
+
+ ); +} + +// Container for a provider's usage info +function UsageItem({ + icon: Icon, + label, + isLoading, + onRefresh, + children, +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; + isLoading: boolean; + onRefresh: () => void; + children: React.ReactNode; +}) { + return ( +
+
+
+ + {label} +
+ +
+
{children}
+
+ ); +} + +export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageBarProps) { + const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore(); + const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore(); + + // 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 fetchClaudeUsage = useCallback(async () => { + try { + const api = getElectronAPI(); + if (!api.claude) return; + const data = await api.claude.getUsage(); + if (!('error' in data)) { + setClaudeUsage(data); + } + } catch { + // Silently fail - usage display is optional + } + }, [setClaudeUsage]); + + const fetchCodexUsage = useCallback(async () => { + try { + const api = getElectronAPI(); + if (!api.codex) return; + const data = await api.codex.getUsage(); + if (!('error' in data)) { + setCodexUsage(data); + } + } catch { + // Silently fail - usage display is optional + } + }, [setCodexUsage]); + + const getCodexWindowLabel = (durationMins: number) => { + if (durationMins < 60) return `${durationMins}m Window`; + if (durationMins < 1440) return `${Math.round(durationMins / 60)}h Window`; + return `${Math.round(durationMins / 1440)}d Window`; + }; + + // Auto-fetch on mount if data is stale + useEffect(() => { + if (showClaudeUsage && isClaudeStale) { + fetchClaudeUsage(); + } + }, [showClaudeUsage, isClaudeStale, fetchClaudeUsage]); + + useEffect(() => { + if (showCodexUsage && isCodexStale) { + fetchCodexUsage(); + } + }, [showCodexUsage, isCodexStale, fetchCodexUsage]); + + // Don't render if there's nothing to show + if (!showClaudeUsage && !showCodexUsage) { + return null; + } + + return ( +
+ {showClaudeUsage && ( + + {claudeUsage ? ( + <> + + + + ) : ( +

Loading usage data...

+ )} +
+ )} + + {showCodexUsage && ( + + {codexUsage?.rateLimits ? ( + <> + {codexUsage.rateLimits.primary && ( + + )} + {codexUsage.rateLimits.secondary && ( + + )} + + ) : ( +

Loading usage data...

+ )} +
+ )} +
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx b/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx index 8807f1ed..998f89bc 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx +++ b/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx @@ -1,6 +1,150 @@ +import { useCallback, useEffect, useState } from 'react'; import { cn } from '@/lib/utils'; +import { getElectronAPI } from '@/lib/electron'; +import { useSetupStore } from '@/store/setup-store'; +import { useAppStore } from '@/store/app-store'; +import { Button } from '@/components/ui/button'; +import { RefreshCw, AlertCircle } from 'lucide-react'; + +const ERROR_NO_API = 'Claude usage API not available'; +const CLAUDE_USAGE_TITLE = 'Claude Usage'; +const CLAUDE_USAGE_SUBTITLE = 'Shows usage limits reported by the Claude CLI.'; +const CLAUDE_AUTH_WARNING = 'Authenticate Claude CLI to view usage limits.'; +const CLAUDE_LOGIN_COMMAND = 'claude login'; +const CLAUDE_NO_USAGE_MESSAGE = + 'Usage limits are not available yet. Try refreshing if this persists.'; +const UPDATED_LABEL = 'Updated'; +const CLAUDE_FETCH_ERROR = 'Failed to fetch usage'; +const CLAUDE_REFRESH_LABEL = 'Refresh Claude usage'; +const WARNING_THRESHOLD = 75; +const CAUTION_THRESHOLD = 50; +const MAX_PERCENTAGE = 100; +const REFRESH_INTERVAL_MS = 60_000; +const STALE_THRESHOLD_MS = 2 * 60_000; +// Using purple/indigo for Claude branding +const USAGE_COLOR_CRITICAL = 'bg-red-500'; +const USAGE_COLOR_WARNING = 'bg-amber-500'; +const USAGE_COLOR_OK = 'bg-indigo-500'; export function ClaudeUsageSection() { + const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); + const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore(); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const canFetchUsage = !!claudeAuthStatus?.authenticated; + // If we have usage data, we can show it even if auth status is unsure + const hasUsage = !!claudeUsage; + + const lastUpdatedLabel = claudeUsageLastUpdated + ? new Date(claudeUsageLastUpdated).toLocaleString() + : null; + + const showAuthWarning = + (!canFetchUsage && !hasUsage && !isLoading) || + (error && error.includes('Authentication required')); + + const isStale = + !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > STALE_THRESHOLD_MS; + + const fetchUsage = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const api = getElectronAPI(); + if (!api.claude) { + setError(ERROR_NO_API); + return; + } + const result = await api.claude.getUsage(); + + if ('error' in result) { + // Check for auth errors specifically + if ( + result.message?.includes('Authentication required') || + result.error?.includes('Authentication required') + ) { + // We'll show the auth warning UI instead of a generic error + } else { + setError(result.message || result.error); + } + return; + } + + setClaudeUsage(result); + } catch (fetchError) { + const message = fetchError instanceof Error ? fetchError.message : CLAUDE_FETCH_ERROR; + setError(message); + } finally { + setIsLoading(false); + } + }, [setClaudeUsage]); + + useEffect(() => { + // Initial fetch if authenticated and stale + if (canFetchUsage && isStale) { + void fetchUsage(); + } + }, [fetchUsage, canFetchUsage, isStale]); + + useEffect(() => { + if (!canFetchUsage) return undefined; + + const intervalId = setInterval(() => { + void fetchUsage(); + }, REFRESH_INTERVAL_MS); + + return () => clearInterval(intervalId); + }, [fetchUsage, canFetchUsage]); + + const getUsageColor = (percentage: number) => { + if (percentage >= WARNING_THRESHOLD) { + return USAGE_COLOR_CRITICAL; + } + if (percentage >= CAUTION_THRESHOLD) { + return USAGE_COLOR_WARNING; + } + return USAGE_COLOR_OK; + }; + + const UsageCard = ({ + title, + subtitle, + percentage, + resetText, + }: { + title: string; + subtitle: string; + percentage: number; + resetText?: string; + }) => { + const safePercentage = Math.min(Math.max(percentage, 0), MAX_PERCENTAGE); + + return ( +
+
+
+

{title}

+

{subtitle}

+
+ + {Math.round(safePercentage)}% + +
+
+
+
+ {resetText &&

{resetText}

} +
+ ); + }; + return (
-
-
+
+

- Claude Usage Tracking + {CLAUDE_USAGE_TITLE}

+
-

- Track your Claude Code usage limits. Uses the Claude CLI for data. -

+

{CLAUDE_USAGE_SUBTITLE}

-
- {/* Info about CLI requirement */} -
-

Usage tracking requires Claude Code CLI to be installed and authenticated:

-
    -
  1. Install Claude Code CLI if not already installed
  2. -
  3. - Run claude login to - authenticate -
  4. -
  5. Usage data will be fetched automatically every ~minute
  6. -
-
+ +
+ {showAuthWarning && ( +
+ +
+ {CLAUDE_AUTH_WARNING} Run {CLAUDE_LOGIN_COMMAND}. +
+
+ )} + + {error && !showAuthWarning && ( +
+ +
{error}
+
+ )} + + {hasUsage && ( +
+ + + +
+ )} + + {!hasUsage && !error && !showAuthWarning && !isLoading && ( +
+ {CLAUDE_NO_USAGE_MESSAGE} +
+ )} + + {lastUpdatedLabel && ( +
+ {UPDATED_LABEL} {lastUpdatedLabel} +
+ )}
); diff --git a/apps/ui/src/hooks/use-provider-auth-init.ts b/apps/ui/src/hooks/use-provider-auth-init.ts new file mode 100644 index 00000000..ae95d121 --- /dev/null +++ b/apps/ui/src/hooks/use-provider-auth-init.ts @@ -0,0 +1,102 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { useSetupStore, type ClaudeAuthMethod, type CodexAuthMethod } 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. + * 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 initialized = useRef(false); + + const refreshStatuses = useCallback(async () => { + const api = getHttpApiClient(); + + // 1. Claude Auth Status + try { + const result = await api.setup.getClaudeStatus(); + if (result.success && result.auth) { + // Cast to extended type that includes server-added fields + const auth = result.auth as typeof result.auth & { + oauthTokenValid?: boolean; + apiKeyValid?: boolean; + }; + + const validMethods: ClaudeAuthMethod[] = [ + 'oauth_token_env', + 'oauth_token', + 'api_key', + 'api_key_env', + 'credentials_file', + 'cli_authenticated', + 'none', + ]; + + const method = validMethods.includes(auth.method as ClaudeAuthMethod) + ? (auth.method as ClaudeAuthMethod) + : ((auth.authenticated ? 'api_key' : 'none') as ClaudeAuthMethod); + + setClaudeAuthStatus({ + authenticated: auth.authenticated, + method, + hasCredentialsFile: auth.hasCredentialsFile ?? false, + oauthTokenValid: !!( + auth.oauthTokenValid || + auth.hasStoredOAuthToken || + auth.hasEnvOAuthToken + ), + apiKeyValid: !!(auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey), + hasEnvOAuthToken: !!auth.hasEnvOAuthToken, + hasEnvApiKey: !!auth.hasEnvApiKey, + }); + } + } catch (error) { + logger.error('Failed to init Claude auth status:', error); + } + + // 2. Codex Auth Status + try { + const result = await api.setup.getCodexStatus(); + if (result.success && result.auth) { + const auth = result.auth; + + const validMethods: CodexAuthMethod[] = [ + 'api_key_env', + 'api_key', + 'cli_authenticated', + 'none', + ]; + + const method = validMethods.includes(auth.method as CodexAuthMethod) + ? (auth.method as CodexAuthMethod) + : ((auth.authenticated ? 'api_key' : 'none') as CodexAuthMethod); + + setCodexAuthStatus({ + authenticated: auth.authenticated, + method, + hasAuthFile: auth.hasAuthFile ?? false, + hasApiKey: auth.hasApiKey ?? false, + hasEnvApiKey: auth.hasEnvApiKey ?? false, + }); + } + } catch (error) { + logger.error('Failed to init Codex auth status:', error); + } + }, [setClaudeAuthStatus, setCodexAuthStatus]); + + useEffect(() => { + // Only initialize once per session if not already set + if (initialized.current || (claudeAuthStatus !== null && codexAuthStatus !== null)) { + return; + } + initialized.current = true; + + void refreshStatuses(); + }, [refreshStatuses, claudeAuthStatus, codexAuthStatus]); +}