diff --git a/messages/ar.json b/messages/ar.json index 97c596bfe..aa4a2814c 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -307,7 +307,17 @@ "gatewayRegistered": "البوابة: Mission Control مسجل", "gatewayRegistrationPending": "تسجيل البوابة معلق", "skipSetup": "تخطي الإعداد", - "getStarted": "ابدأ الآن" + "getStarted": "ابدأ الآن", + "runtimesReady": "{installed} of {total} runtimes ready", + "noRuntimesDetected": "No agent runtimes detected", + "installAtLeastOne": "Install at least one to get started.", + "goToSettings": "Go to Settings", + "runtimeAuthenticated": "Authenticated", + "runtimeConfigured": "Configured", + "runtimeNotAuthenticated": "Not authenticated", + "runtimeNotInstalled": "Not installed", + "runtimesLoading": "Detecting runtimes...", + "yourFleetIsReady": "Your agent fleet is ready." }, "interfaceMode": { "title": "اختر تخطيط محطتك", diff --git a/messages/de.json b/messages/de.json index d0890b580..5a5eda5ef 100644 --- a/messages/de.json +++ b/messages/de.json @@ -307,7 +307,17 @@ "gatewayRegistered": "Gateway: Mission Control registriert", "gatewayRegistrationPending": "Gateway-Registrierung ausstehend", "skipSetup": "Einrichtung überspringen", - "getStarted": "Loslegen" + "getStarted": "Loslegen", + "runtimesReady": "{installed} of {total} runtimes ready", + "noRuntimesDetected": "No agent runtimes detected", + "installAtLeastOne": "Install at least one to get started.", + "goToSettings": "Go to Settings", + "runtimeAuthenticated": "Authenticated", + "runtimeConfigured": "Configured", + "runtimeNotAuthenticated": "Not authenticated", + "runtimeNotInstalled": "Not installed", + "runtimesLoading": "Detecting runtimes...", + "yourFleetIsReady": "Your agent fleet is ready." }, "interfaceMode": { "title": "Wählen Sie Ihr Stationslayout", diff --git a/messages/en.json b/messages/en.json index 3f18bdb89..c55dfeeda 100644 --- a/messages/en.json +++ b/messages/en.json @@ -307,7 +307,17 @@ "gatewayRegistered": "Gateway: Mission Control registered", "gatewayRegistrationPending": "Gateway registration pending", "skipSetup": "Skip setup", - "getStarted": "Get started" + "getStarted": "Get started", + "runtimesReady": "{installed} of {total} runtimes ready", + "noRuntimesDetected": "No agent runtimes detected", + "installAtLeastOne": "Install at least one to get started.", + "goToSettings": "Go to Settings", + "runtimeAuthenticated": "Authenticated", + "runtimeConfigured": "Configured", + "runtimeNotAuthenticated": "Not authenticated", + "runtimeNotInstalled": "Not installed", + "runtimesLoading": "Detecting runtimes...", + "yourFleetIsReady": "Your agent fleet is ready." }, "interfaceMode": { "title": "Choose Your Station Layout", diff --git a/messages/es.json b/messages/es.json index d302ce1c0..b33cee0f4 100644 --- a/messages/es.json +++ b/messages/es.json @@ -307,7 +307,17 @@ "gatewayRegistered": "Gateway: Mission Control registrado", "gatewayRegistrationPending": "Registro del gateway pendiente", "skipSetup": "Omitir configuración", - "getStarted": "Comenzar" + "getStarted": "Comenzar", + "runtimesReady": "{installed} of {total} runtimes ready", + "noRuntimesDetected": "No agent runtimes detected", + "installAtLeastOne": "Install at least one to get started.", + "goToSettings": "Go to Settings", + "runtimeAuthenticated": "Authenticated", + "runtimeConfigured": "Configured", + "runtimeNotAuthenticated": "Not authenticated", + "runtimeNotInstalled": "Not installed", + "runtimesLoading": "Detecting runtimes...", + "yourFleetIsReady": "Your agent fleet is ready." }, "interfaceMode": { "title": "Elige el diseño de tu estación", diff --git a/messages/fr.json b/messages/fr.json index 545f8e230..e89cc111d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -307,7 +307,17 @@ "gatewayRegistered": "Passerelle : Mission Control enregistré", "gatewayRegistrationPending": "Enregistrement de la passerelle en attente", "skipSetup": "Passer la configuration", - "getStarted": "Commencer" + "getStarted": "Commencer", + "runtimesReady": "{installed} of {total} runtimes ready", + "noRuntimesDetected": "No agent runtimes detected", + "installAtLeastOne": "Install at least one to get started.", + "goToSettings": "Go to Settings", + "runtimeAuthenticated": "Authenticated", + "runtimeConfigured": "Configured", + "runtimeNotAuthenticated": "Not authenticated", + "runtimeNotInstalled": "Not installed", + "runtimesLoading": "Detecting runtimes...", + "yourFleetIsReady": "Your agent fleet is ready." }, "interfaceMode": { "title": "Choisissez la disposition de votre station", diff --git a/messages/ja.json b/messages/ja.json index f200d977a..c11ac7319 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -307,7 +307,17 @@ "gatewayRegistered": "ゲートウェイ:Mission Control 登録済み", "gatewayRegistrationPending": "ゲートウェイ登録保留中", "skipSetup": "セットアップをスキップ", - "getStarted": "始めましょう" + "getStarted": "始めましょう", + "runtimesReady": "{installed} of {total} runtimes ready", + "noRuntimesDetected": "No agent runtimes detected", + "installAtLeastOne": "Install at least one to get started.", + "goToSettings": "Go to Settings", + "runtimeAuthenticated": "Authenticated", + "runtimeConfigured": "Configured", + "runtimeNotAuthenticated": "Not authenticated", + "runtimeNotInstalled": "Not installed", + "runtimesLoading": "Detecting runtimes...", + "yourFleetIsReady": "Your agent fleet is ready." }, "interfaceMode": { "title": "ステーションレイアウトを選択", diff --git a/messages/ko.json b/messages/ko.json index ee0c5a87b..9d6ae817f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -307,7 +307,17 @@ "gatewayRegistered": "게이트웨이: Mission Control 등록됨", "gatewayRegistrationPending": "게이트웨이 등록 대기 중", "skipSetup": "설정 건너뛰기", - "getStarted": "시작하기" + "getStarted": "시작하기", + "runtimesReady": "{installed} of {total} runtimes ready", + "noRuntimesDetected": "No agent runtimes detected", + "installAtLeastOne": "Install at least one to get started.", + "goToSettings": "Go to Settings", + "runtimeAuthenticated": "Authenticated", + "runtimeConfigured": "Configured", + "runtimeNotAuthenticated": "Not authenticated", + "runtimeNotInstalled": "Not installed", + "runtimesLoading": "Detecting runtimes...", + "yourFleetIsReady": "Your agent fleet is ready." }, "interfaceMode": { "title": "스테이션 레이아웃 선택", diff --git a/messages/pt.json b/messages/pt.json index cb4d0ce9a..d0653bb03 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -307,7 +307,17 @@ "gatewayRegistered": "Gateway: Mission Control registrado", "gatewayRegistrationPending": "Registro do gateway pendente", "skipSetup": "Pular configuração", - "getStarted": "Começar" + "getStarted": "Começar", + "runtimesReady": "{installed} of {total} runtimes ready", + "noRuntimesDetected": "No agent runtimes detected", + "installAtLeastOne": "Install at least one to get started.", + "goToSettings": "Go to Settings", + "runtimeAuthenticated": "Authenticated", + "runtimeConfigured": "Configured", + "runtimeNotAuthenticated": "Not authenticated", + "runtimeNotInstalled": "Not installed", + "runtimesLoading": "Detecting runtimes...", + "yourFleetIsReady": "Your agent fleet is ready." }, "interfaceMode": { "title": "Escolha o layout da sua estação", diff --git a/messages/ru.json b/messages/ru.json index f268028fa..b197af207 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -307,7 +307,17 @@ "gatewayRegistered": "Шлюз: Mission Control зарегистрирован", "gatewayRegistrationPending": "Регистрация шлюза ожидается", "skipSetup": "Пропустить настройку", - "getStarted": "Начать" + "getStarted": "Начать", + "runtimesReady": "{installed} of {total} runtimes ready", + "noRuntimesDetected": "No agent runtimes detected", + "installAtLeastOne": "Install at least one to get started.", + "goToSettings": "Go to Settings", + "runtimeAuthenticated": "Authenticated", + "runtimeConfigured": "Configured", + "runtimeNotAuthenticated": "Not authenticated", + "runtimeNotInstalled": "Not installed", + "runtimesLoading": "Detecting runtimes...", + "yourFleetIsReady": "Your agent fleet is ready." }, "interfaceMode": { "title": "Выберите раскладку станции", diff --git a/messages/zh.json b/messages/zh.json index 794a7a9a5..066865ce4 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -307,7 +307,17 @@ "gatewayRegistered": "网关:Mission Control 已注册", "gatewayRegistrationPending": "网关注册待处理", "skipSetup": "跳过设置", - "getStarted": "开始使用" + "getStarted": "开始使用", + "runtimesReady": "{installed} of {total} runtimes ready", + "noRuntimesDetected": "No agent runtimes detected", + "installAtLeastOne": "Install at least one to get started.", + "goToSettings": "Go to Settings", + "runtimeAuthenticated": "Authenticated", + "runtimeConfigured": "Configured", + "runtimeNotAuthenticated": "Not authenticated", + "runtimeNotInstalled": "Not installed", + "runtimesLoading": "Detecting runtimes...", + "yourFleetIsReady": "Your agent fleet is ready." }, "interfaceMode": { "title": "选择您的站点布局", diff --git a/src/app/api/onboarding/route.ts b/src/app/api/onboarding/route.ts index 7f028c962..18aac13e7 100644 --- a/src/app/api/onboarding/route.ts +++ b/src/app/api/onboarding/route.ts @@ -3,14 +3,9 @@ import { requireRole } from '@/lib/auth' import { getDatabase } from '@/lib/db' import { logger } from '@/lib/logger' import { nextIncompleteStepIndex, parseCompletedSteps, shouldShowOnboarding, markStepCompleted } from '@/lib/onboarding-state' +import { ALL_KNOWN_STEPS } from '@/lib/onboarding-flow' -const ONBOARDING_STEPS = [ - { id: 'welcome', title: 'Welcome' }, - { id: 'interface-mode', title: 'Interface' }, - { id: 'gateway-link', title: 'Gateway' }, - { id: 'agent-runtimes', title: 'Runtimes' }, - { id: 'credentials', title: 'Credentials' }, -] as const +const ONBOARDING_STEPS = ALL_KNOWN_STEPS const ONBOARDING_SETTING_KEYS = { completed: 'onboarding.completed', diff --git a/src/components/dashboard/empty-state-launchpad.tsx b/src/components/dashboard/empty-state-launchpad.tsx index 757fa7ffb..f6427aa13 100644 --- a/src/components/dashboard/empty-state-launchpad.tsx +++ b/src/components/dashboard/empty-state-launchpad.tsx @@ -7,6 +7,10 @@ interface RuntimeStatus { id: string name: string installed: boolean + version?: string | null + authRequired?: boolean + authHint?: string + authenticated?: boolean } interface Props { @@ -74,12 +78,22 @@ export function EmptyStateLaunchpad({ agentCount, taskCount, onNavigate }: Props doneContent={
{installed.map(r => ( -
- - {r.name} +
+
+ + {r.name} + {r.version && v{r.version}} +
+ {r.authRequired && !r.authenticated && ( + {r.authHint || 'Not authenticated'} + )}
))} -

Installed and ready

+ {installed.length < runtimes.length && ( + + )}
} pendingContent={ diff --git a/src/components/onboarding/onboarding-wizard.tsx b/src/components/onboarding/onboarding-wizard.tsx index 00708b198..aacf2c883 100644 --- a/src/components/onboarding/onboarding-wizard.tsx +++ b/src/components/onboarding/onboarding-wizard.tsx @@ -10,7 +10,7 @@ import { useMissionControl } from '@/store' import { useNavigateToPanel } from '@/lib/navigation' import { clampWizardStep, getWizardSteps, stepIdAt } from '@/lib/onboarding-flow' import { SecurityScanCard } from '@/components/onboarding/security-scan-card' -import { StepAgentRuntimes } from '@/components/onboarding/step-agent-runtimes' +// StepAgentRuntimes removed — runtime management moved to Settings page import { clearOnboardingReplayFromStart, markOnboardingDismissedThisSession, readOnboardingReplayFromStart } from '@/lib/onboarding-session' interface StepInfo { @@ -36,6 +36,17 @@ interface DashboardRegistration { alreadySet: boolean } +interface RuntimeStatusInfo { + id: string + name: string + installed: boolean + version: string | null + running: boolean + authRequired: boolean + authHint: string + authenticated: boolean +} + interface SystemCapabilities { claudeSessions: number agentCount: number @@ -63,6 +74,8 @@ export function OnboardingWizard() { const [credentialStatus, setCredentialStatus] = useState<{ authOk: boolean; apiKeyOk: boolean } | null>(null) const [closing, setClosing] = useState(false) const [completionMessage, setCompletionMessage] = useState(false) + const [runtimeStatuses, setRuntimeStatuses] = useState([]) + const [runtimesLoading, setRuntimesLoading] = useState(true) const [capabilities, setCapabilities] = useState({ claudeSessions: 0, agentCount: 0, @@ -98,13 +111,15 @@ export function OnboardingWizard() { }) .catch(() => {}) - // Fetch system capabilities in parallel + // Fetch system capabilities and runtime status in parallel Promise.allSettled([ fetch('/api/status?action=capabilities').then(r => r.ok ? r.json() : null), fetch('/api/agents?limit=1').then(r => r.ok ? r.json() : null), - ]).then(([statusResult, agentsResult]) => { + fetch('/api/agent-runtimes').then(r => r.ok ? r.json() : null), + ]).then(([statusResult, agentsResult, runtimesResult]) => { const statusData = statusResult.status === 'fulfilled' ? statusResult.value : null const agentsData = agentsResult.status === 'fulfilled' ? agentsResult.value : null + const runtimesData = runtimesResult.status === 'fulfilled' ? runtimesResult.value : null setCapabilities({ claudeSessions: statusData?.claudeSessions ?? 0, gatewayConnected: statusData?.gateway ?? false, @@ -112,6 +127,10 @@ export function OnboardingWizard() { hasSkills: false, dashboardRegistration: statusData?.dashboardRegistration ?? null, }) + if (runtimesData?.runtimes) { + setRuntimeStatuses(runtimesData.runtimes) + } + setRuntimesLoading(false) }) return () => { @@ -223,7 +242,7 @@ export function OnboardingWizard() { role="dialog" aria-modal="true" aria-label="Mission Control onboarding" - className={`relative z-10 my-auto w-full ${step === 2 ? 'max-w-6xl' : 'max-w-3xl'} bg-background border border-border/50 rounded-lg sm:rounded-xl shadow-2xl overflow-hidden flex max-h-[calc(100dvh-1rem)] sm:max-h-[85vh] flex-col`} + className="relative z-10 my-auto w-full max-w-3xl bg-background border border-border/50 rounded-lg sm:rounded-xl shadow-2xl overflow-hidden flex max-h-[calc(100dvh-1rem)] sm:max-h-[85vh] flex-col" > {/* Progress bar */}
@@ -265,7 +284,7 @@ export function OnboardingWizard() {
)} {STEPS[step]?.id === 'welcome' && ( - + { skip(); navigateToPanel('settings') }} /> )} {STEPS[step]?.id === 'interface-mode' && ( @@ -273,9 +292,7 @@ export function OnboardingWizard() { {STEPS[step]?.id === 'gateway-link' && ( )} - {STEPS[step]?.id === 'agent-runtimes' && ( - - )} + {/* agent-runtimes step removed — runtime management via Settings */} {STEPS[step]?.id === 'credentials' && ( )} @@ -286,15 +303,20 @@ export function OnboardingWizard() { ) } -function StepWelcome({ isGateway, capabilities, onNext, onSkip }: { +function StepWelcome({ isGateway, capabilities, runtimeStatuses, runtimesLoading, onNext, onSkip, onNavigateToSettings }: { isGateway: boolean capabilities: SystemCapabilities + runtimeStatuses: RuntimeStatusInfo[] + runtimesLoading: boolean onNext: () => void onSkip: () => void + onNavigateToSettings: () => void }) { const mc = modeColors(isGateway) const t = useTranslations('onboarding.welcome') - const tc = useTranslations('common') + + const installedCount = runtimeStatuses.filter(r => r.installed).length + const totalCount = runtimeStatuses.length return ( <> @@ -315,6 +337,70 @@ function StepWelcome({ isGateway, capabilities, onNext, onSkip }: {

+ {/* Runtime status list */} +
+ {runtimesLoading ? ( +
+ + {t('runtimesLoading')} +
+ ) : ( + <> +
+ {runtimeStatuses.map((rt) => ( +
+
+ +
+ + {rt.name} + + {rt.version && ( + v{rt.version} + )} +
+
+ + {!rt.installed + ? t('runtimeNotInstalled') + : rt.authRequired && !rt.authenticated + ? t('runtimeNotAuthenticated') + : t('runtimeAuthenticated')} + +
+ ))} +
+ + {totalCount > 0 && ( +

+ {t('runtimesReady', { installed: installedCount, total: totalCount })} +

+ )} + + {installedCount === 0 && totalCount > 0 && ( +
+

{t('installAtLeastOne')}

+ +
+ )} + + )} +
+ {/* Live status chips */}
- {capabilities.gatewayConnected && capabilities.dashboardRegistration && ( - - )} -
- - {/* Mode cards — both visible, detected mode highlighted */} -
-

{t('availableModes')}

-
- {/* Local mode card */} -
- {!isGateway && ( - - {tc('detected')} - - )} -

- {t('localMode')} -

-
    -
  • {t('monitorClaude')}
  • -
  • {t('taskTracking')}
  • -
  • {t('sessionHistory')}
  • -
- {isGateway && ( -

{t('singlePilot')}

- )} -
- - {/* Gateway mode card */} -
- {isGateway && ( - - {tc('detected')} - - )} -

- {t('gatewayMode')} -

-
    -
  • {t('orchestrateAgents')}
  • -
  • {t('memorySkills')}
  • -
  • {t('webhookIntegrations')}
  • -
- {!isGateway && ( -

{t('requiresGateway')}

- )} -
-
diff --git a/src/components/onboarding/step-agent-runtimes.tsx b/src/components/onboarding/step-agent-runtimes.tsx deleted file mode 100644 index 7cf4e162c..000000000 --- a/src/components/onboarding/step-agent-runtimes.tsx +++ /dev/null @@ -1,592 +0,0 @@ -'use client' - -import { useState, useEffect, useCallback, useRef } from 'react' -import { Button } from '@/components/ui/button' -import { Loader } from '@/components/ui/loader' -import { RuntimeSetupModal } from './runtime-setup-modal' - -const HERMES_PROVIDERS = [ - { id: 'anthropic', label: 'Anthropic', hermesId: 'anthropic', models: ['claude-sonnet-4-6', 'claude-opus-4-6', 'claude-haiku-4-5'], env: 'ANTHROPIC_API_KEY' }, - { id: 'openai', label: 'OpenAI', hermesId: 'openai-codex', oauthHermesId: 'openai-codex', supportsDeviceCode: true, models: ['gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano', 'o3', 'o4-mini', 'codex-mini-latest', 'gpt-5.3-codex'], env: 'OPENAI_API_KEY' }, - { id: 'openrouter', label: 'OpenRouter', hermesId: 'openrouter', models: ['anthropic/claude-sonnet-4-6', 'openai/gpt-4.1'], env: 'OPENROUTER_API_KEY' }, - { id: 'google', label: 'Google AI', hermesId: 'google', models: ['gemini-2.5-pro', 'gemini-2.5-flash'], env: 'GOOGLE_API_KEY' }, - { id: 'nous', label: 'Nous Portal', hermesId: 'nous', models: ['hermes-3-llama-3.1-70b'], env: 'NOUS_API_KEY' }, - { id: 'xai', label: 'xAI', hermesId: 'xai', models: ['grok-3', 'grok-3-mini'], env: 'XAI_API_KEY' }, -] as const - -interface RuntimeStatus { - id: string - name: string - description: string - installed: boolean - version: string | null - running: boolean - authRequired: boolean - authHint: string - authenticated: boolean -} - -interface InstallJob { - id: string - runtime: string - status: 'pending' | 'running' | 'success' | 'failed' - output: string - error: string | null -} - -interface Props { - isGateway: boolean - onNext: () => void - onBack: () => void -} - -function modeColors(isGateway: boolean) { - return isGateway - ? { text: 'text-void-cyan', border: 'border-void-cyan/30', bgBtn: 'bg-void-cyan/20', hoverBg: 'hover:bg-void-cyan/30' } - : { text: 'text-void-amber', border: 'border-void-amber/30', bgBtn: 'bg-void-amber/20', hoverBg: 'hover:bg-void-amber/30' } -} - -export function StepAgentRuntimes({ isGateway, onNext, onBack }: Props) { - const mc = modeColors(isGateway) - const [runtimes, setRuntimes] = useState([]) - const [isDocker, setIsDocker] = useState(false) - const [loading, setLoading] = useState(true) - const [activeJobs, setActiveJobs] = useState>({}) - const [copiedYaml, setCopiedYaml] = useState(null) - const [setupRuntime, setSetupRuntime] = useState<'openclaw' | 'hermes' | 'claude' | 'codex' | null>(null) - const [setupCompleted, setSetupCompleted] = useState>(new Set()) - const [hermesProvider, setHermesProvider] = useState('anthropic') - const [hermesModel, setHermesModel] = useState('claude-sonnet-4-6') - const [hermesAuthMethod, setHermesAuthMethod] = useState<'api_key' | 'device_code'>('api_key') - const [hermesApiKey, setHermesApiKey] = useState('') - const [hermesConfigSaved, setHermesConfigSaved] = useState(false) - const [hermesConfigBusy, setHermesConfigBusy] = useState(false) - const [hermesOAuthBusy, setHermesOAuthBusy] = useState(false) - const [hermesOAuthOutput, setHermesOAuthOutput] = useState(null) - const [hermesOAuthError, setHermesOAuthError] = useState(null) - const [hermesOAuthUrl, setHermesOAuthUrl] = useState(null) - const [hermesOAuthCode, setHermesOAuthCode] = useState(null) - const [hermesMigrating, setHermesMigrating] = useState(false) - const [hermesMigrateResult, setHermesMigrateResult] = useState(null) - const hermesOauthLogRef = useRef(null) - const hermesOauthStickToBottomRef = useRef(true) - const [showHermesOauthJump, setShowHermesOauthJump] = useState(false) - - const syncHermesOauthScrollState = useCallback(() => { - const el = hermesOauthLogRef.current - if (!el) return - const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight - const atBottom = distanceFromBottom < 12 - hermesOauthStickToBottomRef.current = atBottom - setShowHermesOauthJump(!atBottom) - }, []) - - useEffect(() => { - const el = hermesOauthLogRef.current - if (!el || !hermesOAuthOutput) return - if (hermesOauthStickToBottomRef.current) { - el.scrollTop = el.scrollHeight - setShowHermesOauthJump(false) - return - } - syncHermesOauthScrollState() - }, [hermesOAuthOutput, syncHermesOauthScrollState]) - - const fetchRuntimes = useCallback(async () => { - try { - const res = await fetch('/api/agent-runtimes') - if (!res.ok) return - const data = await res.json() - setRuntimes(data.runtimes || []) - setIsDocker(data.isDocker || false) - } catch { - // ignore - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { fetchRuntimes() }, [fetchRuntimes]) - - // Poll active jobs - useEffect(() => { - const running = Object.values(activeJobs).filter(j => j.status === 'running' || j.status === 'pending') - if (running.length === 0) return - - const interval = setInterval(async () => { - for (const job of running) { - try { - const res = await fetch('/api/agent-runtimes', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'job-status', jobId: job.id }), - }) - if (!res.ok) continue - const data = await res.json() - if (data.job) { - setActiveJobs(prev => ({ ...prev, [data.job.runtime]: data.job })) - if (data.job.status === 'success' || data.job.status === 'failed') { - fetchRuntimes() - } - } - } catch { - // ignore - } - } - }, 1000) - - return () => clearInterval(interval) - }, [activeJobs, fetchRuntimes]) - - const handleInstall = async (runtimeId: string) => { - try { - const res = await fetch('/api/agent-runtimes', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'install', runtime: runtimeId, mode: 'local' }), - }) - if (!res.ok) return - const data = await res.json() - if (data.job) { - setActiveJobs(prev => ({ ...prev, [runtimeId]: data.job })) - } - } catch { - // ignore - } - } - - const handleCopyCompose = async (runtimeId: string) => { - try { - const res = await fetch('/api/agent-runtimes', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'docker-compose', runtime: runtimeId }), - }) - if (!res.ok) return - const data = await res.json() - await navigator.clipboard.writeText(data.yaml) - setCopiedYaml(runtimeId) - setTimeout(() => setCopiedYaml(null), 2000) - } catch { - // ignore - } - } - - if (loading) { - return ( - <> -
- -
-
- - -
- - ) - } - - const selectedHermesProvider = HERMES_PROVIDERS.find(p => p.id === hermesProvider) - const supportsDeviceCode = Boolean(selectedHermesProvider && 'supportsDeviceCode' in selectedHermesProvider && selectedHermesProvider.supportsDeviceCode) - const usesDeviceCode = supportsDeviceCode && hermesAuthMethod === 'device_code' - - return ( - <> -
-

Agent Runtimes

-

- Install agent runtimes to run AI agents. You can skip this and install later from Settings. -

- - {isDocker && ( -
- Running in Docker — install directly or use sidecar services for production. -
- )} - -
- {runtimes.map((rt) => { - const job = activeJobs[rt.id] - const isInstalling = job?.status === 'running' || job?.status === 'pending' - const installFailed = job?.status === 'failed' - const justInstalled = job?.status === 'success' - - return ( -
- {/* Installing shimmer overlay */} - {isInstalling && ( -
-
-
-
-
-
- )} - -
- {/* Status badge */} - {(rt.installed || justInstalled) && !isInstalling && ( - - Detected - - )} - - {isInstalling ? ( - /* Full-card installing state with live output */ -
-
-
-
-
- {rt.name.charAt(0)} -
-
-
-

{rt.name}

-

Installing...

-
-
- {/* Live output tail */} - {job?.output && ( -
-
-                            {job.output.trim().split('\n').slice(-6).join('\n')}
-                          
-
- )} -
- ) : ( - <> -

- {rt.name} -

-

{rt.description}

- - {rt.version && ( -

v{rt.version}

- )} - - {/* Auth status */} - {rt.installed && rt.authRequired && ( -

- {rt.authenticated ? 'Authenticated' : rt.authHint} -

- )} - - {/* Configure button for non-hermes runtimes (hermes has inline config below) */} - {rt.id !== 'hermes' && (rt.installed || justInstalled) && !setupCompleted.has(rt.id) && ( - - )} - - {/* Hermes inline quick config */} - {rt.id === 'hermes' && (rt.installed || justInstalled) && !hermesConfigSaved && ( -
-

Quick Setup

- - {/* Provider + Model dropdowns */} -
- - -
- - {/* Authorization method */} - {supportsDeviceCode && ( -
- - -
- )} - - {/* API Key or OAuth */} - {usesDeviceCode ? ( -
-

OAuth uses device code flow:

-
- $ - hermes login - -
-

No API key needed. Start auth, open the link, paste the code, then return here while terminal waits for completion.

- {hermesOAuthUrl && ( - - Open device login link - - )} - {hermesOAuthCode && ( -
-

Device code

- {hermesOAuthCode} -
- )} - {hermesOAuthBusy && ( -

Waiting for authentication confirmation...

- )} - {hermesOAuthOutput && ( -
-
-                                    {hermesOAuthOutput}
-                                  
- {showHermesOauthJump && ( - - )} -
- )} - {hermesOAuthError &&

{hermesOAuthError}

} -
- ) : ( - setHermesApiKey(e.target.value)} - placeholder={`${HERMES_PROVIDERS.find(p => p.id === hermesProvider)?.label || ''} API key`} - className="w-full h-7 rounded border border-border/20 bg-card px-2 text-[10px] text-foreground font-mono placeholder:text-muted-foreground/30 focus:outline-none focus:ring-1 focus:ring-primary/30" - /> - )} - - {/* Save button */} - - - {/* OpenClaw migration option */} - {runtimes.find(r => r.id === 'openclaw')?.installed && ( -
- - {hermesMigrateResult && ( -

{hermesMigrateResult}

- )} -
- )} -
- )} - - {hermesConfigSaved && rt.id === 'hermes' && ( -

Provider configured

- )} - - {/* Install actions */} - {!rt.installed && !justInstalled && ( -
- {installFailed ? ( -
-

Install failed: {job?.error || 'Unknown error'}

- -
- ) : ( -
- - {isDocker && ( - - )} -
- )} -
- )} - - )} -
-
- ) - })} -
-
- -
- - -
- - {setupRuntime && ( - setSetupRuntime(null)} - onComplete={() => { - setSetupCompleted(prev => new Set([...prev, setupRuntime])) - setSetupRuntime(null) - fetchRuntimes() - }} - /> - )} - - ) -} diff --git a/src/lib/__tests__/onboarding-flow.test.ts b/src/lib/__tests__/onboarding-flow.test.ts index 57b587838..0597a4ac4 100644 --- a/src/lib/__tests__/onboarding-flow.test.ts +++ b/src/lib/__tests__/onboarding-flow.test.ts @@ -1,18 +1,22 @@ import { describe, expect, it } from 'vitest' -import { BASE_STEPS, GATEWAY_STEPS, clampWizardStep, getWizardSteps, stepIdAt } from '@/lib/onboarding-flow' +import { ALL_KNOWN_STEPS, BASE_STEPS, GATEWAY_STEPS, clampWizardStep, getWizardSteps, stepIdAt } from '@/lib/onboarding-flow' describe('onboarding-flow', () => { it('returns base steps when gateway is unavailable', () => { const steps = getWizardSteps(false) expect(steps).toEqual(BASE_STEPS) - expect(steps.map((step) => step.id)).toEqual(['welcome', 'interface-mode', 'agent-runtimes', 'credentials']) + expect(steps.map((step) => step.id)).toEqual(['welcome', 'interface-mode', 'credentials']) }) it('returns gateway steps when gateway is available', () => { const steps = getWizardSteps(true) expect(steps).toEqual(GATEWAY_STEPS) - expect(steps.map((step) => step.id)).toEqual(['welcome', 'interface-mode', 'gateway-link', 'agent-runtimes', 'credentials']) + expect(steps.map((step) => step.id)).toEqual(['welcome', 'interface-mode', 'gateway-link', 'credentials']) + }) + + it('exports ALL_KNOWN_STEPS with canonical step IDs', () => { + expect(ALL_KNOWN_STEPS.map(s => s.id)).toEqual(['welcome', 'interface-mode', 'gateway-link', 'credentials']) }) it('clamps invalid step indexes', () => { diff --git a/src/lib/agent-runtimes.ts b/src/lib/agent-runtimes.ts index 61a47e8c1..9b5c3b2f4 100644 --- a/src/lib/agent-runtimes.ts +++ b/src/lib/agent-runtimes.ts @@ -1,5 +1,6 @@ import crypto from 'node:crypto' import { existsSync } from 'node:fs' +import { join } from 'node:path' import { config } from './config' import { runCommand, runOpenClaw } from './command' import { isHermesInstalled, isHermesGatewayRunning, clearHermesDetectionCache } from './hermes-sessions' @@ -48,8 +49,8 @@ const RUNTIME_META: Record = { hermes: { name: 'Hermes Agent', description: 'Self-improving AI agent with learning loop, skills, and multi-platform messaging.', - authRequired: false, - authHint: '', + authRequired: true, + authHint: 'Run "hermes setup" or configure via Mission Control.', }, claude: { name: 'Claude Code', @@ -141,22 +142,25 @@ function detectHermes(): RuntimeStatus { if (installed) { try { const path = require('node:path') - const dataDir = path.resolve(config.dataDir || '.data') const homeDir = require('node:os').homedir() + const dataDir = path.resolve(config.dataDir || '.data') const candidates = [ process.env.HERMES_BIN, path.join(dataDir, '.local', 'bin', 'hermes'), - path.join(dataDir, '.hermes', 'hermes-agent', 'venv', 'bin', 'hermes'), path.join(homeDir, '.local', 'bin', 'hermes'), path.join(homeDir, '.hermes', 'hermes-agent', 'venv', 'bin', 'hermes'), 'hermes-agent', 'hermes', ].filter(Boolean) as string[] + // hermes --version exits non-zero but stdout contains the version banner for (const bin of candidates) { try { - const result = require('node:child_process').spawnSync(bin, ['--version'], { stdio: 'pipe', timeout: 1200 }) - if (result.status === 0) { - version = (result.stdout?.toString() || '').trim() || null + if (bin.startsWith('/') && !existsSync(bin)) continue + const result = require('node:child_process').spawnSync(bin, ['--version'], { stdio: 'pipe', timeout: 5000 }) + const out = (result.stdout?.toString() || '') + (result.stderr?.toString() || '') + const match = out.match(/Hermes Agent v([\d.]+)/) + if (match) { + version = match[1] break } } catch { continue } @@ -167,25 +171,62 @@ function detectHermes(): RuntimeStatus { } const running = installed && isHermesGatewayRunning() - return { id: 'hermes', ...meta, installed, version, running, authenticated: true } + + // Check if hermes has a provider/model configured + let authenticated = false + if (installed) { + try { + const homeDir = require('node:os').homedir() + const configPath = join(homeDir, '.hermes', 'config.yaml') + if (existsSync(configPath)) { + const raw = require('node:fs').readFileSync(configPath, 'utf8') + // Has a model configured = considered authenticated/configured + authenticated = /^model:\s*\S+/m.test(raw) + } + } catch { + // ignore + } + } + + return { id: 'hermes', ...meta, installed, version, running, authenticated } } -function detectBinary(bins: string[], versionFlag = '--version'): { installed: boolean; version: string | null } { +function detectBinary(bins: string[], versionFlag = '--version'): { installed: boolean; version: string | null; resolvedBin: string | null } { const { spawnSync } = require('node:child_process') + const homedir = require('node:os').homedir() + const path = require('node:path') + + // Expand bare binary names with common install locations that may not be on PATH + const candidates: string[] = [] for (const bin of bins) { + if (!bin.includes('/')) { + candidates.push( + path.join(homedir, '.local', 'bin', bin), + path.join('/usr', 'local', 'bin', bin), + path.join(homedir, 'Library', 'pnpm', bin), // macOS pnpm global + path.join(homedir, '.npm-global', 'bin', bin), + ) + } + candidates.push(bin) + } + + for (const bin of candidates) { try { const result = spawnSync(bin, [versionFlag], { stdio: 'pipe', timeout: 3000 }) if (result.status === 0) { - return { installed: true, version: (result.stdout?.toString() || '').trim() || null } + // Extract first meaningful line as version (skip wrapper/logging noise like [lacp]) + const rawOutput = (result.stdout?.toString() || '').trim() + const versionLine = rawOutput.split('\n').find((l: string) => l.trim() && !l.trim().startsWith('['))?.trim() || rawOutput.split('\n')[0]?.trim() || null + return { installed: true, version: versionLine, resolvedBin: bin } } } catch { continue } } - return { installed: false, version: null } + return { installed: false, version: null, resolvedBin: null } } function detectClaude(): RuntimeStatus { const meta = RUNTIME_META.claude - const { installed, version } = detectBinary(['claude']) + const { installed, version, resolvedBin } = detectBinary(['claude']) // Detect Claude Code authentication. Claude supports two auth modes: // @@ -232,7 +273,7 @@ function detectClaude(): RuntimeStatus { if (!authenticated) { try { const { spawnSync } = require('node:child_process') - const result = spawnSync('claude', ['auth', 'status', '--json'], { + const result = spawnSync(resolvedBin || 'claude', ['auth', 'status', '--json'], { stdio: 'pipe', timeout: 5000, }) @@ -251,16 +292,18 @@ function detectClaude(): RuntimeStatus { function detectCodex(): RuntimeStatus { const meta = RUNTIME_META.codex - const { installed, version } = detectBinary(['codex']) + const { installed, version } = detectBinary(['codex', 'codex-cli']) - // Check authentication: codex stores config in ~/.codex/ + // Codex CLI authenticates via OPENAI_API_KEY env var or config files let authenticated = false if (installed) { try { const homedir = require('node:os').homedir() const path = require('node:path') - authenticated = existsSync(path.join(homedir, '.codex', 'auth.json')) + authenticated = !!process.env.OPENAI_API_KEY + || existsSync(path.join(homedir, '.codex', 'auth.json')) || existsSync(path.join(homedir, '.codex', 'config.json')) + || existsSync(path.join(homedir, '.config', 'codex', 'config.json')) } catch { // ignore } diff --git a/src/lib/hermes-sessions.ts b/src/lib/hermes-sessions.ts index f714b025b..77b46759f 100644 --- a/src/lib/hermes-sessions.ts +++ b/src/lib/hermes-sessions.ts @@ -79,7 +79,8 @@ function hasHermesCliBinary(): boolean { logger.debug({ bin }, 'hermes candidate not found on disk') return false } - const res = spawnSync(bin, ['--version'], { stdio: 'pipe', timeout: 5000 }) + // hermes CLI doesn't support --version (exits 2). Use --help as probe. + const res = spawnSync(bin, ['--help'], { stdio: 'pipe', timeout: 5000 }) const found = res.status === 0 if (found) { logger.info({ bin, stdout: (res.stdout || '').toString().trim().slice(0, 60) }, 'hermes binary detected') diff --git a/src/lib/onboarding-flow.ts b/src/lib/onboarding-flow.ts index 34709ad20..10b0ea7b5 100644 --- a/src/lib/onboarding-flow.ts +++ b/src/lib/onboarding-flow.ts @@ -6,7 +6,6 @@ export interface OnboardingStepDefinition { export const BASE_STEPS: OnboardingStepDefinition[] = [ { id: 'welcome', title: 'Welcome' }, { id: 'interface-mode', title: 'Interface' }, - { id: 'agent-runtimes', title: 'Runtimes' }, { id: 'credentials', title: 'Credentials' }, ] @@ -14,7 +13,14 @@ export const GATEWAY_STEPS: OnboardingStepDefinition[] = [ { id: 'welcome', title: 'Welcome' }, { id: 'interface-mode', title: 'Interface' }, { id: 'gateway-link', title: 'Gateway' }, - { id: 'agent-runtimes', title: 'Runtimes' }, + { id: 'credentials', title: 'Credentials' }, +] + +/** All canonical step IDs — superset for API validation and persistence. */ +export const ALL_KNOWN_STEPS: OnboardingStepDefinition[] = [ + { id: 'welcome', title: 'Welcome' }, + { id: 'interface-mode', title: 'Interface' }, + { id: 'gateway-link', title: 'Gateway' }, { id: 'credentials', title: 'Credentials' }, ] diff --git a/tests/onboarding-api.spec.ts b/tests/onboarding-api.spec.ts index 136a1bd5d..ca3bf24d5 100644 --- a/tests/onboarding-api.spec.ts +++ b/tests/onboarding-api.spec.ts @@ -41,12 +41,11 @@ test.describe('Onboarding API', () => { test('GET steps array has expected onboarding steps with id/title/completed', async ({ request }) => { const res = await request.get('/api/onboarding', { headers: API_KEY_HEADER }) const body = await res.json() - expect(body.steps).toHaveLength(5) + expect(body.steps).toHaveLength(4) expect(body.steps.map((step: any) => step.id)).toEqual([ 'welcome', 'interface-mode', 'gateway-link', - 'agent-runtimes', 'credentials', ]) for (const step of body.steps) { @@ -185,7 +184,7 @@ test.describe('Onboarding API', () => { expect(initialState.skipped).toBe(false) // Complete all configured steps - for (const stepId of ['welcome', 'interface-mode', 'gateway-link', 'agent-runtimes', 'credentials']) { + for (const stepId of ['welcome', 'interface-mode', 'gateway-link', 'credentials']) { const res = await request.post('/api/onboarding', { headers: API_KEY_HEADER, data: { action: 'complete_step', step: stepId },