Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import type { FC } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { formatRelativeTime } from '@/entrypoints/app/agents/agent-display.helpers'
import type { HarnessAgent } from '@/entrypoints/app/agents/agent-harness-types'
import type {
HarnessAgent,
HarnessAgentAdapter,
} from '@/entrypoints/app/agents/agent-harness-types'
import { AgentSummaryChips } from '@/entrypoints/app/agents/agent-row/AgentSummaryChips'
import { formatTokens } from '@/entrypoints/app/agents/agent-row/agent-row.helpers'
import type { AgentAdapterHealth } from '@/entrypoints/app/agents/agent-row/agent-row.types'
Expand All @@ -14,7 +17,7 @@ import { cn } from '@/lib/utils'
interface ConversationHeaderProps {
agent: HarnessAgent | null
fallbackName: string
fallbackAdapter: 'claude' | 'codex' | 'openclaw' | 'unknown'
fallbackAdapter: HarnessAgentAdapter | 'unknown'
adapterHealth: AgentAdapterHealth | null
backLabel: string
backTarget: 'home' | 'page'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,13 @@ interface AdapterIconProps {
export const AdapterIcon: FC<AdapterIconProps> = ({ adapter, className }) => {
switch (adapter) {
case 'claude':
// Claude Code — text-based agent, sparkles to evoke the "AI assistant" feel.
return <Sparkles className={className} aria-label="Claude Code" />
case 'codex':
// Codex — code-leaning, CPU mark.
return <Cpu className={className} aria-label="Codex" />
case 'openclaw':
// OpenClaw — bot/automation framing.
return <Bot className={className} aria-label="OpenClaw" />
case 'hermes':
return <Bot className={className} aria-label="Hermes" />
default:
return <Bot className={className} aria-label="Agent" />
}
Expand All @@ -36,6 +35,8 @@ export function adapterLabel(adapter: HarnessAgentAdapter | 'unknown'): string {
return 'Codex'
case 'openclaw':
return 'OpenClaw'
case 'hermes':
return 'Hermes'
default:
return 'Agent'
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ function inferAdapterFromLabel(label: string): HarnessAgentAdapter | 'unknown' {
if (lower === 'claude code') return 'claude'
if (lower === 'codex') return 'codex'
if (lower === 'openclaw') return 'openclaw'
if (lower === 'hermes') return 'hermes'
return 'unknown'
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
HarnessAgentAdapter,
} from './agent-harness-types'
import type { CreateAgentRuntime, ProviderOption } from './agents-page-types'
import { getHermesCliInstallPrompt } from './hermes-install-prompt'
import { ProviderSelector } from './OpenClawControls'
import {
type OpenClawCliProvider,
Expand Down Expand Up @@ -89,6 +90,17 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
}) => {
const selectedHarnessAdapter =
adapters.find((adapter) => adapter.id === harnessAdapterId) ?? adapters[0]
const selectedCreateAdapter =
createRuntime === 'openclaw'
? selectedHarnessAdapter
: (adapters.find((adapter) => adapter.id === createRuntime) ??
selectedHarnessAdapter)
const hermesInstallPrompt = getHermesCliInstallPrompt({
createRuntime,
selectedAdapter: selectedCreateAdapter,
})
const harnessModels = selectedHarnessAdapter?.models ?? []
const harnessReasoningEfforts = selectedHarnessAdapter?.reasoningEfforts ?? []
const isHarnessRuntime = createRuntime !== 'openclaw'
const openClawBlocked = createRuntime === 'openclaw' && !canManageOpenClaw
const cliBlocked =
Expand All @@ -100,6 +112,7 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
!creating &&
!openClawBlocked &&
!cliBlocked &&
!hermesInstallPrompt &&
(createRuntime === 'openclaw'
? providers.length > 0
: Boolean(selectedHarnessAdapter))
Expand Down Expand Up @@ -143,7 +156,8 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({
if (
value === 'openclaw' ||
value === 'claude' ||
value === 'codex'
value === 'codex' ||
value === 'hermes'
) {
onRuntimeChange(value)
if (value !== 'openclaw') onHarnessAdapterChange(value)
Expand Down Expand Up @@ -198,45 +212,70 @@ export const NewAgentDialog: FC<NewAgentDialogProps> = ({

{isHarnessRuntime ? (
<>
<div className="grid gap-2">
<Label htmlFor="harness-model">Model</Label>
<Select
value={harnessModelId}
onValueChange={onHarnessModelChange}
>
<SelectTrigger id="harness-model">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(selectedHarnessAdapter?.models ?? []).map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{hermesInstallPrompt ? (
<Alert>
<AlertCircle className="size-4" />
<AlertTitle>{hermesInstallPrompt.title}</AlertTitle>
<AlertDescription>
<div className="grid gap-2">
<p>{hermesInstallPrompt.description}</p>
<code className="rounded bg-muted px-2 py-1 font-mono text-xs">
{hermesInstallPrompt.installCommand}
</code>
<a
href={hermesInstallPrompt.docsUrl}
target="_blank"
rel="noreferrer"
className="text-primary text-xs underline underline-offset-4"
>
Hermes ACP setup
</a>
</div>
</AlertDescription>
</Alert>
) : null}

<div className="grid gap-2">
<Label htmlFor="harness-effort">Reasoning</Label>
<Select
value={harnessReasoningEffort}
onValueChange={onHarnessReasoningChange}
>
<SelectTrigger id="harness-effort">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(selectedHarnessAdapter?.reasoningEfforts ?? []).map(
(effort) => (
{harnessModels.length > 0 ? (
<div className="grid gap-2">
<Label htmlFor="harness-model">Model</Label>
<Select
value={harnessModelId}
onValueChange={onHarnessModelChange}
>
<SelectTrigger id="harness-model">
<SelectValue />
</SelectTrigger>
<SelectContent>
{harnessModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}

{harnessReasoningEfforts.length > 1 ? (
<div className="grid gap-2">
<Label htmlFor="harness-effort">Reasoning</Label>
<Select
value={harnessReasoningEffort}
onValueChange={onHarnessReasoningChange}
>
<SelectTrigger id="harness-effort">
<SelectValue />
</SelectTrigger>
<SelectContent>
{harnessReasoningEfforts.map((effort) => (
<SelectItem key={effort.id} value={effort.id}>
{effort.label}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
))}
</SelectContent>
</Select>
</div>
) : null}
</>
) : null}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export function canRename(_agent: AgentListItem): boolean {
*/
export function workspaceLabel(agent: AgentListItem): string | null {
if (!agent.detail) return null
if (/^(claude|codex|openclaw):main$/.test(agent.detail)) return null
if (/^(claude|codex|openclaw|hermes):main$/.test(agent.detail)) return null
return agent.detail
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { AgentEntry } from './useOpenClaw'

export type HarnessAgentAdapter = 'claude' | 'codex' | 'openclaw'
export type HarnessAgentAdapter = 'claude' | 'codex' | 'openclaw' | 'hermes'

export type AgentHarnessStreamEvent =
| {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export function createAgentPageActions(input: AgentPageActionInput) {
openclaw: handleOpenClawCreate,
claude: handleHarnessCreate,
codex: handleHarnessCreate,
hermes: handleHarnessCreate,
}
void createByRuntime[input.createRuntime]()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,16 @@ export function getRecoveryDetail(status: OpenClawStatus): string | null {
}

export function formatHarnessAdapter(adapter: HarnessAgentAdapter): string {
return adapter === 'claude' ? 'Claude Code' : 'Codex'
switch (adapter) {
case 'claude':
return 'Claude Code'
case 'codex':
return 'Codex'
case 'openclaw':
return 'OpenClaw'
case 'hermes':
return 'Hermes'
}
}

export function toProviderOptions(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'bun:test'
import {
getHermesCliInstallPrompt,
HERMES_ACP_DOCS_URL,
} from './hermes-install-prompt'

describe('getHermesCliInstallPrompt', () => {
it('prompts for Hermes install when the Hermes adapter health is unhealthy', () => {
expect(
getHermesCliInstallPrompt({
createRuntime: 'hermes',
selectedAdapter: {
id: 'hermes',
name: 'Hermes',
defaultModelId: 'default',
defaultReasoningEffort: 'default',
modelControl: 'best-effort',
models: [],
reasoningEfforts: [],
health: {
healthy: false,
reason: 'hermes acp --help failed: command not found',
checkedAt: 1000,
},
},
}),
).toEqual({
title: 'Hermes CLI not installed',
description:
'hermes acp --help failed: command not found. Install Hermes normally, then add the ACP extra and make sure hermes is on PATH.',
docsUrl: HERMES_ACP_DOCS_URL,
installCommand: "pip install -e '.[acp]'",
})
})

it('does not prompt for healthy or non-Hermes adapter selections', () => {
const hermesDescriptor = {
id: 'hermes' as const,
name: 'Hermes',
defaultModelId: 'default',
defaultReasoningEffort: 'default',
modelControl: 'best-effort' as const,
models: [],
reasoningEfforts: [],
health: { healthy: true, checkedAt: 1000 },
}

expect(
getHermesCliInstallPrompt({
createRuntime: 'hermes',
selectedAdapter: hermesDescriptor,
}),
).toBeNull()
expect(
getHermesCliInstallPrompt({
createRuntime: 'codex',
selectedAdapter: { ...hermesDescriptor, id: 'codex', name: 'Codex' },
}),
).toBeNull()
expect(
getHermesCliInstallPrompt({
createRuntime: 'hermes',
selectedAdapter: null,
}),
).toBeNull()
expect(
getHermesCliInstallPrompt({
createRuntime: 'hermes',
selectedAdapter: {
...hermesDescriptor,
id: 'codex' as const,
name: 'Codex',
health: { healthy: false, checkedAt: 1000 },
},
}),
).toBeNull()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { HarnessAdapterDescriptor } from './agent-harness-types'
import type { CreateAgentRuntime } from './agents-page-types'

export const HERMES_ACP_DOCS_URL =
'https://hermes-agent.nousresearch.com/docs/user-guide/features/acp'

export interface HermesCliInstallPrompt {
title: string
description: string
docsUrl: string
installCommand: string
}

/**
* Returns the blocking install prompt for host-local Hermes ACP.
* BrowserOS launches `hermes acp`, so a missing host CLI has to be
* resolved before we create the persisted agent record.
*/
export function getHermesCliInstallPrompt(input: {
createRuntime: CreateAgentRuntime
selectedAdapter: HarnessAdapterDescriptor | null | undefined
}): HermesCliInstallPrompt | null {
const health = input.selectedAdapter?.health
if (
input.createRuntime !== 'hermes' ||
input.selectedAdapter?.id !== 'hermes' ||
health?.healthy !== false
) {
return null
}

const reason = health.reason?.trim()
return {
title: 'Hermes CLI not installed',
description: `${
reason ? `${reason}. ` : ''
}Install Hermes normally, then add the ACP extra and make sure hermes is on PATH.`,
docsUrl: HERMES_ACP_DOCS_URL,
installCommand: "pip install -e '.[acp]'",
}
}
Loading
Loading