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 },