feat: improve Hermes provider support

This commit is contained in:
Simos Mikelatos
2026-06-30 20:59:40 +00:00
parent f188648a2a
commit 5c14e08493
13 changed files with 212 additions and 78 deletions

View File

@@ -48,15 +48,23 @@ function readStopReason(result) {
return result.stopReason || result.stop_reason || result.reason || null;
}
function buildPromptParams(sessionId, command, model) {
const params = {
function buildPromptParams(sessionId, command) {
return {
sessionId,
prompt: [{ type: 'text', text: command }],
};
if (model) {
params.modelId = model;
}
return params;
}
function buildSessionSetupParams(sessionId, workingDir) {
return {
...(sessionId ? { sessionId } : {}),
cwd: workingDir,
mcpServers: [],
};
}
function canLoadSession(connection) {
return connection?.initializeResult?.agentCapabilities?.loadSession === true;
}
function findPermissionOption(options, kinds, fallbackOptionIds = []) {
@@ -233,7 +241,7 @@ async function spawnHermes(command, options = {}, ws) {
};
try {
const resolvedModel = await providerModelsService.resolveResumeModel(PROVIDER, sessionId, requestedModel);
await providerModelsService.resolveResumeModel(PROVIDER, sessionId, requestedModel);
const connection = await hermesConnectionManager.getConnection(workingDir);
activeHermesSessions.set(activeKey, {
connection,
@@ -278,20 +286,21 @@ async function spawnHermes(command, options = {}, ws) {
try {
let sessionResult;
if (sessionId) {
if (sessionId && canLoadSession(connection)) {
try {
sessionResult = await connection.request('session/load', { sessionId, cwd: workingDir });
sessionResult = await connection.request('session/load', buildSessionSetupParams(sessionId, workingDir));
} catch {
sessionResult = { sessionId };
}
} else {
sessionResult = await connection.request('session/new', {
cwd: workingDir,
});
sessionResult = await connection.request('session/new', buildSessionSetupParams(null, workingDir));
}
registerSession(readSessionId(sessionResult) || sessionId, connection);
const promptResult = await connection.request('session/prompt', buildPromptParams(capturedSessionId, command, resolvedModel));
if (!capturedSessionId) {
throw new Error('Hermes ACP did not return a session id.');
}
const promptResult = await connection.request('session/prompt', buildPromptParams(capturedSessionId, command));
const finalSessionId = capturedSessionId || readSessionId(promptResult) || sessionId || activeKey;
const stopReason = readStopReason(promptResult) || 'completed';
const active = activeHermesSessions.get(finalSessionId) || activeHermesSessions.get(activeKey);

View File

@@ -19,6 +19,7 @@ class AcpClient extends EventEmitter {
this.buffer = '';
this.requestHandlers = new Map();
this.initialized = false;
this.initializeResult = null;
}
start() {
@@ -54,19 +55,20 @@ class AcpClient extends EventEmitter {
}
this.start();
await this.request('initialize', {
this.initializeResult = await this.request('initialize', {
protocolVersion: 1,
clientCapabilities: {
fs: {
readTextFile: false,
writeTextFile: false,
},
terminal: false,
},
clientInfo: {
name: 'CloudCLI',
title: 'CloudCLI',
version: '1.0.0',
},
capabilities: {
fs: false,
terminal: false,
session: {
requestPermission: true,
},
},
});
this.initialized = true;
this.notify('initialized', {});
@@ -95,7 +97,7 @@ class AcpClient extends EventEmitter {
const payload = { jsonrpc: '2.0', id, method, params };
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.pending.set(id, { resolve, reject, method, params });
this.writeMessage(payload);
});
}
@@ -171,7 +173,13 @@ class AcpClient extends EventEmitter {
}
this.pending.delete(message.id);
if (message.error) {
pending.reject(new Error(message.error.message || JSON.stringify(message.error)));
const messageText = message.error.message || JSON.stringify(message.error);
const error = new Error(`ACP ${pending.method} failed: ${messageText}`);
error.code = message.error.code;
error.data = message.error.data;
error.method = pending.method;
error.params = pending.params;
pending.reject(error);
} else {
pending.resolve(message.result);
}

View File

@@ -9,11 +9,7 @@ import type {
ProviderModelsDefinition,
ProviderSessionActiveModelChange,
} from '@/shared/types.js';
import {
buildDefaultProviderCurrentActiveModel,
readOptionalString,
writeProviderSessionActiveModelChange,
} from '@/shared/utils.js';
import { readOptionalString } from '@/shared/utils.js';
export const HERMES_CONFIGURED_MODEL = '__hermes_configured_model__';
@@ -105,24 +101,21 @@ export class HermesProviderModels implements IProviderModels {
return HERMES_FALLBACK_MODELS;
}
const options = [
{ value: activeModel, label: activeModel },
...HERMES_FALLBACK_MODELS.OPTIONS,
];
return {
OPTIONS: options,
DEFAULT: activeModel,
OPTIONS: [
{
value: HERMES_CONFIGURED_MODEL,
label: 'Configured in Hermes',
description: `Current Hermes model: ${activeModel}`,
},
],
DEFAULT: HERMES_CONFIGURED_MODEL,
};
}
async getCurrentActiveModel(): Promise<ProviderCurrentActiveModel> {
const configured = await this.readConfiguredModel();
if (configured) {
return { model: configured };
}
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
return { model: configured ?? HERMES_CONFIGURED_MODEL };
}
async changeActiveModel(input: ProviderChangeActiveModelInput): Promise<ProviderSessionActiveModelChange> {
@@ -136,7 +129,13 @@ export class HermesProviderModels implements IProviderModels {
};
}
return writeProviderSessionActiveModelChange('hermes', input);
return {
provider: 'hermes',
sessionId: input.sessionId,
supported: false,
changed: false,
model: null,
};
}
private async readConfiguredModel(): Promise<string | null> {

View File

@@ -60,6 +60,44 @@ function readEventSessionId(raw: AnyRecord, sessionId: string | null): string |
return readOptionalString(raw.sessionId) ?? readOptionalString(raw.session_id) ?? sessionId;
}
function readTextContent(value: unknown): string | null {
const direct = readOptionalString(value);
if (direct !== undefined) {
return direct;
}
if (Array.isArray(value)) {
const parts = value
.map((entry) => readTextContent(entry))
.filter((entry): entry is string => Boolean(entry?.trim()));
return parts.length > 0 ? parts.join('') : null;
}
const record = readObjectRecord(value);
if (!record) {
return null;
}
const nestedContent = record.content;
const nestedText = nestedContent === value ? null : readTextContent(nestedContent);
return readOptionalString(record.text)
?? readOptionalString(record.content)
?? nestedText
?? readOptionalString(record.delta)
?? readOptionalString(record.rawOutput)
?? readOptionalString(record.raw_output)
?? readOptionalString(record.output)
?? null;
}
function readToolPayload(raw: AnyRecord): AnyRecord {
return readObjectRecord(raw.toolCall)
?? readObjectRecord(raw.tool_call)
?? readObjectRecord(raw.tool)
?? raw;
}
function normalizeHermesEvent(rawMessage: unknown, sessionId: string | null, history = false): NormalizedMessage[] {
const envelope = readObjectRecord(rawMessage);
if (!envelope) {
@@ -75,10 +113,10 @@ function normalizeHermesEvent(rawMessage: unknown, sessionId: string | null, his
const baseId = readOptionalString(raw.id) ?? readOptionalString(raw.messageId) ?? readOptionalString(raw.message_id) ?? generateMessageId(PROVIDER);
if (['agent_message_chunk', 'assistant_message_chunk', 'message_delta', 'text_delta', 'text'].includes(type)) {
const content = readOptionalString(raw.content)
const content = readTextContent(raw.content)
?? readOptionalString(raw.text)
?? readOptionalString(raw.delta)
?? readOptionalString(readObjectRecord(raw.message)?.content)
?? readTextContent(readObjectRecord(raw.message)?.content)
?? '';
if (!content.trim()) {
return [];
@@ -96,9 +134,9 @@ function normalizeHermesEvent(rawMessage: unknown, sessionId: string | null, his
if (['agent_message', 'assistant_message', 'message'].includes(type)) {
const role = readOptionalString(raw.role) === 'user' ? 'user' : 'assistant';
const content = readOptionalString(raw.content)
const content = readTextContent(raw.content)
?? readOptionalString(raw.text)
?? readOptionalString(readObjectRecord(raw.message)?.content)
?? readTextContent(readObjectRecord(raw.message)?.content)
?? '';
if (!content.trim()) {
return [];
@@ -115,7 +153,7 @@ function normalizeHermesEvent(rawMessage: unknown, sessionId: string | null, his
}
if (['agent_thought_chunk', 'thought_delta', 'thinking', 'reasoning'].includes(type)) {
const content = readOptionalString(raw.content) ?? readOptionalString(raw.text) ?? readOptionalString(raw.delta) ?? '';
const content = readTextContent(raw.content) ?? readOptionalString(raw.text) ?? readOptionalString(raw.delta) ?? '';
if (!content.trim()) {
return [];
}
@@ -130,8 +168,15 @@ function normalizeHermesEvent(rawMessage: unknown, sessionId: string | null, his
}
if (['tool_call', 'tool_use', 'tool_call_start'].includes(type)) {
const tool = readObjectRecord(raw.tool);
const toolId = readOptionalString(raw.toolCallId) ?? readOptionalString(raw.tool_call_id) ?? readOptionalString(raw.toolId) ?? baseId;
const tool = readToolPayload(raw);
const toolId = readOptionalString(raw.toolCallId)
?? readOptionalString(raw.tool_call_id)
?? readOptionalString(raw.toolId)
?? readOptionalString(tool.toolCallId)
?? readOptionalString(tool.tool_call_id)
?? readOptionalString(tool.toolId)
?? readOptionalString(tool.id)
?? baseId;
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
@@ -143,22 +188,51 @@ function normalizeHermesEvent(rawMessage: unknown, sessionId: string | null, his
?? readOptionalString(raw.title)
?? readOptionalString(raw.name)
?? readOptionalString(tool?.name)
?? readOptionalString(tool?.title)
?? 'Tool',
toolInput: raw.rawInput ?? raw.raw_input ?? raw.input ?? raw.arguments ?? raw.params ?? tool?.input ?? {},
toolInput: raw.rawInput
?? raw.raw_input
?? raw.input
?? raw.arguments
?? raw.params
?? tool?.rawInput
?? tool?.raw_input
?? tool?.input
?? tool?.arguments
?? {},
toolId,
})];
}
if (['tool_call_update', 'tool_result', 'tool_call_result', 'tool_call_done'].includes(type)) {
const tool = readToolPayload(raw);
const content = readTextContent(raw.content)
?? readTextContent(raw.rawOutput)
?? readTextContent(raw.raw_output)
?? readTextContent(raw.output)
?? readTextContent(raw.result)
?? readTextContent(tool.rawOutput)
?? readTextContent(tool.raw_output)
?? readTextContent(tool.output)
?? readTextContent(tool.result)
?? '';
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'tool_result',
toolId: readOptionalString(raw.toolCallId) ?? readOptionalString(raw.tool_call_id) ?? readOptionalString(raw.toolId) ?? '',
content: formatContent(raw.output ?? raw.result ?? raw.content ?? raw.delta ?? ''),
isError: Boolean(raw.error) || raw.status === 'error',
toolId: readOptionalString(raw.toolCallId)
?? readOptionalString(raw.tool_call_id)
?? readOptionalString(raw.toolId)
?? readOptionalString(tool.toolCallId)
?? readOptionalString(tool.tool_call_id)
?? readOptionalString(tool.toolId)
?? readOptionalString(tool.id)
?? '',
content: content || formatContent(raw.delta ?? ''),
isError: Boolean(raw.error) || raw.status === 'error' || raw.status === 'failed',
toolUseResult: raw.result ?? raw.output ?? raw.rawOutput ?? raw.raw_output ?? tool.result ?? tool.output ?? tool.rawOutput ?? tool.raw_output,
})];
}

View File

@@ -109,6 +109,12 @@ function resolveResumeSessionId(
return resolvedSessionId;
}
function getHermesShellCommand(): string {
return (process.env.HERMES_COMMAND_PATH || process.env.HERMES_CLI_PATH || 'hermes')
.trim()
.split(/\s+/)[0] || 'hermes';
}
/**
* Resolves provider command line for plain shell and agent-backed shell modes.
*/
@@ -161,6 +167,14 @@ function buildShellCommand(
return initialCommand || 'opencode';
}
if (provider === 'hermes') {
const command = initialCommand || getHermesShellCommand();
if (resumeSessionId) {
return `${command} --resume "${resumeSessionId}"`;
}
return command;
}
const command = initialCommand || 'claude';
if (resumeSessionId) {
if (os.platform() === 'win32') {
@@ -481,6 +495,8 @@ export function handleShellConnection(
? 'Gemini'
: provider === 'opencode'
? 'OpenCode'
: provider === 'hermes'
? 'Hermes'
: 'Claude';
welcomeMsg = hasSession && resumeSessionId
? `\x1b[36mResuming ${providerName} session ${resumeSessionId} in: ${projectPath}\x1b[0m\r\n`

View File

@@ -946,7 +946,6 @@ router.post('/', validateExternalApiKey, async (req, res) => {
const codexModels = (await providerModelsService.getProviderModels('codex')).models;
const geminiModels = (await providerModelsService.getProviderModels('gemini')).models;
const opencodeModels = (await providerModelsService.getProviderModels('opencode')).models;
const hermesModels = (await providerModelsService.getProviderModels('hermes')).models;
// Start the appropriate session
if (provider === 'claude') {
@@ -1006,7 +1005,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
projectPath: finalProjectPath,
cwd: finalProjectPath,
sessionId: sessionId || null,
model: model || (hermesModels.DEFAULT === HERMES_CONFIGURED_MODEL ? undefined : hermesModels.DEFAULT)
model: model === HERMES_CONFIGURED_MODEL ? undefined : model
}, writer);
}

View File

@@ -174,7 +174,6 @@ export function useChatComposerState({
codexModel,
geminiModel,
opencodeModel,
hermesModel,
isLoading,
canAbortSession,
tokenBudget,
@@ -339,7 +338,7 @@ export function useChatComposerState({
: provider === 'opencode'
? opencodeModel
: provider === 'hermes'
? (hermesModel === '__hermes_configured_model__' ? undefined : hermesModel)
? undefined
: claudeModel,
tokenUsage: tokenBudget,
};
@@ -395,7 +394,6 @@ export function useChatComposerState({
cursorModel,
geminiModel,
opencodeModel,
hermesModel,
handleBuiltInCommand,
handleCustomCommand,
input,
@@ -737,7 +735,7 @@ export function useChatComposerState({
: provider === 'opencode'
? opencodeModel
: provider === 'hermes'
? (hermesModel === '__hermes_configured_model__' ? undefined : hermesModel)
? undefined
: claudeModel;
// One message shape for every provider. The backend resolves the
@@ -783,7 +781,6 @@ export function useChatComposerState({
executeCommand,
geminiModel,
opencodeModel,
hermesModel,
isLoading,
onSessionProcessing,
onSessionEstablished,

View File

@@ -417,6 +417,15 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
model: string,
sessionId?: string | null,
) => {
if (targetProvider === 'hermes') {
setStoredProviderModel(targetProvider, model);
return {
scope: 'default' as const,
changed: false,
model,
};
}
const normalizedSessionId = typeof sessionId === 'string' ? sessionId.trim() : '';
if (!normalizedSessionId) {
setStoredProviderModel(targetProvider, model);

View File

@@ -289,15 +289,11 @@ export default function ProviderSelectionEmptyState({
>
<div className="min-w-0 flex-1">
<div className="truncate">{model.label}</div>
{/*
// * Temporarly commented out because the description of models from claude
// * was a bit inconsistent. Will return it back when it becomes more consistent.
*/}
{/* {model.description && (
{model.description && (
<div className="truncate text-xs text-muted-foreground">
{model.description}
</div>
)} */}
)}
</div>
{isSelected && (
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
@@ -332,7 +328,7 @@ export default function ProviderSelectionEmptyState({
defaultValue: "Ready with OpenCode {{model}}",
}),
hermes: t("providerSelection.readyPrompt.hermes", {
model: hermesModel,
model: provider === "hermes" ? currentModelLabel : hermesModel,
defaultValue: "Ready with Hermes {{model}}",
}),
}[provider]

View File

@@ -12,6 +12,7 @@ import type {
} from '../types';
type ProviderAuthStatusPayload = {
installed?: boolean;
authenticated?: boolean;
email?: string | null;
method?: string | null;
@@ -34,6 +35,7 @@ const toProviderAuthStatus = (
payload: ProviderAuthStatusPayload,
fallbackError: string | null = null,
): ProviderAuthStatus => ({
installed: Boolean(payload.installed),
authenticated: Boolean(payload.authenticated),
email: payload.email ?? null,
method: payload.method ?? null,
@@ -78,6 +80,7 @@ export function useProviderAuthStatus(
if (!response.ok) {
const status: ProviderAuthStatus = {
installed: false,
authenticated: false,
email: null,
method: null,
@@ -95,6 +98,7 @@ export function useProviderAuthStatus(
} catch (caughtError) {
console.error(`Error checking ${provider} auth status:`, caughtError);
const status: ProviderAuthStatus = {
installed: false,
authenticated: false,
email: null,
method: null,

View File

@@ -1,6 +1,7 @@
import type { LLMProvider } from '../../types/app';
export type ProviderAuthStatus = {
installed: boolean;
authenticated: boolean;
email: string | null;
method: string | null;
@@ -22,10 +23,10 @@ export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
};
export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({
claude: { authenticated: false, email: null, method: null, error: null, loading },
cursor: { authenticated: false, email: null, method: null, error: null, loading },
codex: { authenticated: false, email: null, method: null, error: null, loading },
gemini: { authenticated: false, email: null, method: null, error: null, loading },
opencode: { authenticated: false, email: null, method: null, error: null, loading },
hermes: { authenticated: false, email: null, method: null, error: null, loading },
claude: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
cursor: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
codex: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
gemini: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
opencode: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
hermes: { installed: false, authenticated: false, email: null, method: null, error: null, loading },
});

View File

@@ -72,6 +72,7 @@ export default function AgentListItem({
}: AgentListItemProps) {
const config = agentConfig[agentId];
const colors = colorClasses[config.color];
const isReady = agentId === 'hermes' ? authStatus.installed : authStatus.authenticated;
if (isMobile) {
return (
@@ -87,7 +88,7 @@ export default function AgentListItem({
<div className="flex items-center justify-center gap-1.5">
<SessionProviderLogo provider={agentId} className="h-4 w-4 flex-shrink-0" />
<span className="truncate text-xs font-medium">{config.name}</span>
{authStatus.authenticated && (
{isReady && (
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${colors.dot}`} />
)}
</div>
@@ -107,10 +108,10 @@ export default function AgentListItem({
>
<SessionProviderLogo provider={agentId} className="h-4 w-4 flex-shrink-0" />
<span>{config.name}</span>
{authStatus.authenticated ? (
{isReady ? (
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${colors.dot}`} />
) : authStatus.loading ? (
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-muted-foreground/30 animate-pulse" />
<span className="h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-muted-foreground/30" />
) : null}
</button>
);

View File

@@ -125,6 +125,8 @@ const hermesActionGroups: HermesActionGroup[] = [
export default function AccountContent({ agent, authStatus, onLogin }: AccountContentProps) {
const { t } = useTranslation('settings');
const config = agentConfig[agent];
const isHermes = agent === 'hermes';
const hermesReady = authStatus.installed;
return (
<div className="space-y-6">
@@ -145,11 +147,17 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
<div className="flex items-center gap-3">
<div className="flex-1">
<div className={`font-medium ${config.textClass}`}>
{t('agents.connectionStatus')}
{isHermes
? t('agents.hermes.setupStatus.title', { defaultValue: 'Setup status' })
: t('agents.connectionStatus')}
</div>
<div className={`text-sm ${config.subtextClass}`}>
{authStatus.loading ? (
t('agents.authStatus.checkingAuth')
) : isHermes ? (
hermesReady
? t('agents.hermes.setupStatus.readyDescription', { defaultValue: 'Hermes ACP is installed. Credentials and models are managed by Hermes.' })
: t('agents.hermes.setupStatus.needsSetupDescription', { defaultValue: 'Install Hermes or run the ACP check to validate the adapter.' })
) : authStatus.authenticated ? (
t('agents.authStatus.loggedInAs', {
email: authStatus.email || t('agents.authStatus.authenticatedUser'),
@@ -164,6 +172,19 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
<Badge variant="secondary" className="bg-muted">
{t('agents.authStatus.checking')}
</Badge>
) : isHermes ? (
<Badge
variant="secondary"
className={
hermesReady
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'
}
>
{hermesReady
? t('agents.hermes.setupStatus.ready', { defaultValue: 'ACP ready' })
: t('agents.hermes.setupStatus.needsSetup', { defaultValue: 'Needs setup' })}
</Badge>
) : authStatus.authenticated ? (
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
{t('agents.authStatus.connected')}
@@ -176,7 +197,7 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
</div>
</div>
{authStatus.method !== 'api_key' && (
{!isHermes && authStatus.method !== 'api_key' && (
<div className="border-t border-border/50 pt-4">
<div className="flex items-center justify-between">
<div>
@@ -201,7 +222,7 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
</div>
)}
{agent === 'hermes' && (
{isHermes && (
<div className="border-t border-border/50 pt-4">
<div className={`mb-3 font-medium ${config.textClass}`}>
{t('agents.hermes.actions.title', { defaultValue: 'Hermes tools' })}