feat: add Hermes provider

This commit is contained in:
Simos Mikelatos
2026-06-30 09:51:18 +00:00
parent 2ebe64f218
commit 048c671b13
49 changed files with 2816 additions and 76 deletions

View File

@@ -28,6 +28,9 @@ HOST=0.0.0.0
# Uncomment the following line if you have a custom claude cli path other than the default "claude"
# CLAUDE_CLI_PATH=claude
# Uncomment the following line if you want a custom Hermes ACP launcher
# HERMES_CLI_PATH=hermes acp
# =============================================================================
# DATABASE CONFIGURATION
# =============================================================================
@@ -42,4 +45,3 @@ HOST=0.0.0.0
VITE_CONTEXT_WINDOW=160000
CONTEXT_WINDOW=160000

View File

@@ -524,7 +524,7 @@
<td><code>provider</code></td>
<td>string</td>
<td><span class="badge badge-optional">Optional</span></td>
<td><code>claude</code>, <code>cursor</code>, or <code>codex</code> (default: <code>claude</code>)</td>
<td><code>claude</code>, <code>cursor</code>, <code>codex</code>, <code>gemini</code>, <code>opencode</code>, or <code>hermes</code> (default: <code>claude</code>)</td>
</tr>
<tr>
<td><code>stream</code></td>
@@ -834,6 +834,7 @@ data: {"type":"done"}</code></pre>
{ id: 'gemini', name: 'Google' },
{ id: 'cursor', name: 'Cursor' },
{ id: 'opencode', name: 'OpenCode' },
{ id: 'hermes', name: 'Nous Research' },
];
async function populateModels() {

View File

@@ -29,9 +29,14 @@ import {
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
import {
getPendingApprovalsForSession,
registerApproval,
resolveToolApproval,
unregisterApproval,
} from './shared/tool-approval-registry.js';
const activeSessions = new Map();
const pendingToolApprovals = new Map();
// Sessions cancelled via abort-session. The abort handler already sent the
// terminal `complete` (aborted: true) to the client, so the run loop must not
// emit a second one when its generator winds down.
@@ -64,7 +69,7 @@ function waitForToolApproval(requestId, options = {}) {
let timeout;
const cleanup = () => {
pendingToolApprovals.delete(requestId);
unregisterApproval(requestId);
if (timeout) clearTimeout(timeout);
if (signal && abortHandler) {
signal.removeEventListener('abort', abortHandler);
@@ -96,21 +101,15 @@ function waitForToolApproval(requestId, options = {}) {
const resolver = (decision) => {
finalize(decision);
};
// Attach metadata for getPendingApprovalsForSession lookup
if (metadata) {
Object.assign(resolver, metadata);
}
pendingToolApprovals.set(requestId, resolver);
registerApproval(requestId, {
resolver,
sessionId: metadata?._sessionId ?? null,
provider: 'claude',
meta: metadata ?? {},
});
});
}
function resolveToolApproval(requestId, decision) {
const resolver = pendingToolApprovals.get(requestId);
if (resolver) {
resolver(decision);
}
}
// Match stored permission entries against a tool + input combo.
// This only supports exact tool names and the Bash(command:*) shorthand
// used by the UI; it intentionally does not implement full glob semantics,
@@ -846,28 +845,6 @@ function getActiveClaudeSDKSessions() {
return getAllSessions();
}
/**
* Get pending tool approvals for a specific session.
* @param {string} sessionId - The session ID
* @returns {Array} Array of pending permission request objects
*/
function getPendingApprovalsForSession(sessionId) {
const pending = [];
for (const [requestId, resolver] of pendingToolApprovals.entries()) {
if (resolver._sessionId === sessionId) {
pending.push({
requestId,
toolName: resolver._toolName || 'UnknownTool',
input: resolver._input,
context: resolver._context,
sessionId,
receivedAt: resolver._receivedAt || new Date(),
});
}
}
return pending;
}
/**
* Reconnect a session's WebSocketWriter to a new raw WebSocket.
* Called when client reconnects (e.g. page refresh) while SDK is still running.

398
server/hermes-cli.js Normal file
View File

@@ -0,0 +1,398 @@
import crypto from 'node:crypto';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
import {
clearApprovalsForSession,
getPendingApprovalsForSession,
registerApproval,
resolveToolApproval,
unregisterApproval,
} from './shared/tool-approval-registry.js';
import { hermesConnectionManager } from './hermes/acp-client.js';
const PROVIDER = 'hermes';
const HERMES_CONFIGURED_MODEL = '__hermes_configured_model__';
const activeHermesSessions = new Map();
// Session ids whose run was aborted; the terminal `complete` is emitted by
// handleChatAbort, so the runtime must not also emit a "completed" one.
const abortedSessionIds = new Set();
function createRequestId() {
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return crypto.randomBytes(16).toString('hex');
}
function readSessionId(result) {
if (!result || typeof result !== 'object') {
return null;
}
return result.sessionId
|| result.session_id
|| result.id
|| result.session?.id
|| result.session?.sessionId
|| result.session?.session_id
|| null;
}
function readStopReason(result) {
if (!result || typeof result !== 'object') {
return null;
}
return result.stopReason || result.stop_reason || result.reason || null;
}
function buildPromptParams(sessionId, command, model) {
const params = {
sessionId,
prompt: [{ type: 'text', text: command }],
};
if (model) {
params.modelId = model;
}
return params;
}
function findPermissionOption(options, kinds, fallbackOptionIds = []) {
if (!Array.isArray(options)) {
return null;
}
for (const kind of kinds) {
const match = options.find((option) => option?.kind === kind);
if (match?.optionId) {
return match.optionId;
}
}
for (const optionId of fallbackOptionIds) {
const match = options.find((option) => option?.optionId === optionId);
if (match?.optionId) {
return match.optionId;
}
}
return null;
}
function createPermissionDecision(decision, options = []) {
if (!decision) {
return { outcome: { outcome: 'cancelled' } };
}
if (decision.cancelled) {
return { outcome: { outcome: 'cancelled' } };
}
if (decision.allow) {
const optionId = decision.rememberEntry
? findPermissionOption(options, ['allow_always', 'allow_session'], ['allow_always', 'allow_session'])
: findPermissionOption(options, ['allow_once'], ['allow_once']);
if (!optionId) {
return { outcome: { outcome: 'cancelled' } };
}
return {
outcome: {
outcome: 'selected',
optionId,
},
};
}
const denyOptionId = findPermissionOption(options, ['reject_once', 'deny', 'reject_always'], ['deny', 'reject_once', 'reject_always']);
if (denyOptionId) {
return {
outcome: {
outcome: 'selected',
optionId: denyOptionId,
},
};
}
return {
outcome: { outcome: 'cancelled' },
};
}
async function waitForPermission(ws, params, capturedSessionId, sessionSummary) {
const requestId = createRequestId();
const toolCall = params?.toolCall || params?.tool_call || {};
const toolName = params?.toolName
|| params?.tool_name
|| params?.name
|| params?.tool?.name
|| toolCall.title
|| 'HermesTool';
const input = params?.input
?? params?.arguments
?? params?.toolInput
?? params?.tool_input
?? toolCall.rawInput
?? toolCall.raw_input
?? toolCall;
ws.send(createNormalizedMessage({
kind: 'permission_request',
requestId,
toolName,
input,
sessionId: capturedSessionId,
provider: PROVIDER,
}));
return new Promise((resolve) => {
registerApproval(requestId, {
sessionId: capturedSessionId,
provider: PROVIDER,
meta: {
toolName,
input,
context: params,
sessionName: sessionSummary,
receivedAt: new Date(),
},
resolver: (decision) => {
unregisterApproval(requestId);
resolve(createPermissionDecision(decision, params?.options));
},
});
});
}
async function spawnHermes(command, options = {}, ws) {
const { sessionId, projectPath, cwd, model, sessionSummary } = options;
const workingDir = cwd || projectPath || process.cwd();
const requestedModel = model === HERMES_CONFIGURED_MODEL ? undefined : model;
let capturedSessionId = sessionId || null;
let sessionCreatedSent = false;
let completeSent = false;
let activeKey = capturedSessionId || `pending-${createRequestId()}`;
const notifyTerminalState = ({ error = null, stopReason = 'completed' } = {}) => {
const finalSessionId = capturedSessionId || sessionId || activeKey;
if (!error) {
notifyRunStopped({
userId: ws?.userId || null,
provider: PROVIDER,
sessionId: finalSessionId,
sessionName: sessionSummary,
stopReason,
});
return;
}
notifyRunFailed({
userId: ws?.userId || null,
provider: PROVIDER,
sessionId: finalSessionId,
sessionName: sessionSummary,
error,
});
};
const registerSession = (nextSessionId, connection) => {
if (!nextSessionId || capturedSessionId === nextSessionId) {
return;
}
if (activeHermesSessions.has(activeKey)) {
activeHermesSessions.delete(activeKey);
}
activeKey = nextSessionId;
capturedSessionId = nextSessionId;
activeHermesSessions.set(activeKey, {
connection,
sessionId: capturedSessionId,
status: 'active',
aborted: false,
ws,
sessionSummary,
});
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
ws.setSessionId(capturedSessionId);
}
if (!sessionId && !sessionCreatedSent) {
sessionCreatedSent = true;
ws.send(createNormalizedMessage({
kind: 'session_created',
newSessionId: capturedSessionId,
sessionId: capturedSessionId,
provider: PROVIDER,
}));
}
};
try {
const resolvedModel = await providerModelsService.resolveResumeModel(PROVIDER, sessionId, requestedModel);
const connection = await hermesConnectionManager.getConnection(workingDir);
activeHermesSessions.set(activeKey, {
connection,
sessionId: capturedSessionId,
status: 'active',
aborted: false,
ws,
sessionSummary,
});
const unregisterPermissionHandler = connection.registerRequestHandler('session/request_permission', (params) => {
const permissionSessionId = params?.sessionId || params?.session_id || null;
const active = permissionSessionId
? activeHermesSessions.get(permissionSessionId)
: activeHermesSessions.get(activeKey);
if (!active) {
return { outcome: { outcome: 'cancelled' } };
}
return waitForPermission(
active.ws,
params,
active.sessionId || permissionSessionId || capturedSessionId,
active.sessionSummary || sessionSummary,
);
});
const updateHandler = (params) => {
const updateSessionId = params?.sessionId || params?.session_id || null;
if (capturedSessionId && updateSessionId && updateSessionId !== capturedSessionId) {
return;
}
registerSession(updateSessionId, connection);
const normalized = sessionsService.normalizeMessage(PROVIDER, params, capturedSessionId || updateSessionId || null);
for (const msg of normalized) {
ws.send(msg);
}
};
connection.on('session/update', updateHandler);
try {
let sessionResult;
if (sessionId) {
try {
sessionResult = await connection.request('session/load', { sessionId, cwd: workingDir });
} catch {
sessionResult = { sessionId };
}
} else {
sessionResult = await connection.request('session/new', {
cwd: workingDir,
});
}
registerSession(readSessionId(sessionResult) || sessionId, connection);
const promptResult = await connection.request('session/prompt', buildPromptParams(capturedSessionId, command, resolvedModel));
const finalSessionId = capturedSessionId || readSessionId(promptResult) || sessionId || activeKey;
const stopReason = readStopReason(promptResult) || 'completed';
const active = activeHermesSessions.get(finalSessionId) || activeHermesSessions.get(activeKey);
if (promptResult?.usage || promptResult?.tokenUsage || promptResult?.token_usage) {
ws.send(createNormalizedMessage({
kind: 'status',
text: 'token_budget',
tokenBudget: promptResult.usage || promptResult.tokenUsage || promptResult.token_usage,
sessionId: finalSessionId,
provider: PROVIDER,
}));
}
const abortedById = abortedSessionIds.delete(finalSessionId);
const abortedByKey = abortedSessionIds.delete(activeKey);
const wasAborted = Boolean(active?.aborted || abortedById || abortedByKey);
if (!completeSent && !wasAborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: PROVIDER, sessionId: finalSessionId, exitCode: 0 }));
}
activeHermesSessions.delete(finalSessionId);
activeHermesSessions.delete(activeKey);
clearApprovalsForSession(finalSessionId);
notifyTerminalState({ stopReason: wasAborted ? 'aborted' : stopReason });
} finally {
connection.off('session/update', updateHandler);
unregisterPermissionHandler();
}
} catch (error) {
const finalSessionId = capturedSessionId || sessionId || activeKey;
const abortedById = abortedSessionIds.delete(finalSessionId);
const abortedByKey = abortedSessionIds.delete(activeKey);
activeHermesSessions.delete(finalSessionId);
activeHermesSessions.delete(activeKey);
clearApprovalsForSession(finalSessionId);
// A cancelled session/prompt rejects here; its aborted terminal `complete`
// is sent by handleChatAbort, so don't surface the cancellation as an error.
if (abortedById || abortedByKey) {
return;
}
const installed = await providerAuthService.isProviderInstalled(PROVIDER);
const errorContent = !installed
? 'Hermes ACP is not installed. Install Hermes and ensure hermes-acp is on PATH.'
: error instanceof Error ? error.message : String(error);
ws.send(createNormalizedMessage({
kind: 'error',
content: errorContent,
sessionId: finalSessionId,
provider: PROVIDER,
}));
if (!completeSent) {
completeSent = true;
ws.send(createCompleteMessage({ provider: PROVIDER, sessionId: finalSessionId, exitCode: 1 }));
}
notifyTerminalState({ error });
throw error;
}
}
async function abortHermesSession(providerSessionId) {
const active = activeHermesSessions.get(providerSessionId);
if (!active) {
return false;
}
active.aborted = true;
active.status = 'aborted';
abortedSessionIds.add(providerSessionId);
if (active.sessionId) {
abortedSessionIds.add(active.sessionId);
}
for (const approval of getPendingApprovalsForSession(active.sessionId || providerSessionId)) {
resolveToolApproval(approval.requestId, { cancelled: true });
}
try {
active.connection.notify('session/cancel', { sessionId: active.sessionId || providerSessionId });
} catch {
// If Hermes already finished, the caller still sees the run as aborted.
}
activeHermesSessions.delete(providerSessionId);
return true;
}
function isHermesSessionActive(sessionId) {
return activeHermesSessions.has(sessionId);
}
function getActiveHermesSessions() {
return Array.from(activeHermesSessions.keys());
}
export {
spawnHermes,
abortHermesSession,
isHermesSessionActive,
getActiveHermesSessions,
createPermissionDecision,
};

279
server/hermes/acp-client.js Normal file
View File

@@ -0,0 +1,279 @@
import { EventEmitter } from 'node:events';
import { spawn } from 'node:child_process';
import crossSpawn from 'cross-spawn';
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
class AcpClient extends EventEmitter {
constructor({ command = process.env.HERMES_CLI_PATH || 'hermes acp', cwd = process.cwd(), env = process.env } = {}) {
super();
const commandParts = command.trim().split(/\s+/);
this.command = commandParts.shift() || 'hermes';
this.args = commandParts;
this.cwd = cwd;
this.env = env;
this.process = null;
this.nextId = 1;
this.pending = new Map();
this.buffer = '';
this.requestHandlers = new Map();
this.initialized = false;
}
start() {
if (this.process) {
return;
}
this.process = spawnFunction(this.command, this.args, {
cwd: this.cwd,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...this.env },
});
this.process.stdout.on('data', (chunk) => this.handleData(chunk));
this.process.stderr.on('data', (chunk) => {
const text = chunk.toString();
if (text.trim()) {
this.emit('stderr', text);
}
});
this.process.on('error', (error) => this.rejectAll(error));
this.process.on('close', (code, signal) => {
this.rejectAll(new Error(`hermes-acp exited with code ${code ?? 'null'}${signal ? ` signal ${signal}` : ''}`));
this.emit('close', { code, signal });
this.process = null;
this.initialized = false;
});
}
async initialize() {
if (this.initialized) {
return;
}
this.start();
await this.request('initialize', {
protocolVersion: 1,
clientInfo: {
name: 'CloudCLI',
version: '1.0.0',
},
capabilities: {
fs: false,
terminal: false,
session: {
requestPermission: true,
},
},
});
this.initialized = true;
this.notify('initialized', {});
}
onRequest(method, handler) {
this.requestHandlers.set(method, handler);
}
registerRequestHandler(method, handler) {
const handlers = this.requestHandlers.get(method) || new Set();
handlers.add(handler);
this.requestHandlers.set(method, handlers);
return () => {
handlers.delete(handler);
if (handlers.size === 0) {
this.requestHandlers.delete(method);
}
};
}
request(method, params) {
this.start();
const id = this.nextId;
this.nextId += 1;
const payload = { jsonrpc: '2.0', id, method, params };
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.writeMessage(payload);
});
}
notify(method, params) {
this.start();
this.writeMessage({ jsonrpc: '2.0', method, params });
}
writeMessage(payload) {
if (!this.process || !this.process.stdin || this.process.stdin.destroyed) {
throw new Error('hermes-acp process is not running');
}
const line = `${JSON.stringify(payload)}\n`;
this.process.stdin.write(line);
}
handleData(chunk) {
this.buffer += chunk.toString();
while (this.buffer.length > 0) {
if (this.buffer.startsWith('Content-Length:')) {
const headerEnd = this.buffer.indexOf('\r\n\r\n');
if (headerEnd === -1) {
return;
}
const header = this.buffer.slice(0, headerEnd);
const match = header.match(/Content-Length:\s*(\d+)/i);
if (!match) {
this.buffer = this.buffer.slice(headerEnd + 4);
continue;
}
const length = Number(match[1]);
const messageStart = headerEnd + 4;
if (this.buffer.length < messageStart + length) {
return;
}
const raw = this.buffer.slice(messageStart, messageStart + length);
this.buffer = this.buffer.slice(messageStart + length);
this.dispatchRaw(raw);
continue;
}
const newlineIndex = this.buffer.indexOf('\n');
if (newlineIndex === -1) {
return;
}
const raw = this.buffer.slice(0, newlineIndex).trim();
this.buffer = this.buffer.slice(newlineIndex + 1);
if (raw) {
this.dispatchRaw(raw);
}
}
}
dispatchRaw(raw) {
let message;
try {
message = JSON.parse(raw);
} catch (error) {
this.emit('error', error);
return;
}
void this.dispatchMessage(message);
}
async dispatchMessage(message) {
if (Object.prototype.hasOwnProperty.call(message, 'id') && (message.result !== undefined || message.error !== undefined)) {
const pending = this.pending.get(message.id);
if (!pending) {
return;
}
this.pending.delete(message.id);
if (message.error) {
pending.reject(new Error(message.error.message || JSON.stringify(message.error)));
} else {
pending.resolve(message.result);
}
return;
}
if (Object.prototype.hasOwnProperty.call(message, 'id') && message.method) {
const handler = this.requestHandlers.get(message.method);
if (!handler) {
this.writeMessage({
jsonrpc: '2.0',
id: message.id,
error: { code: -32601, message: `No handler for ${message.method}` },
});
return;
}
try {
const result = handler instanceof Set
? await this.dispatchRequestHandlers(handler, message.params)
: await handler(message.params);
this.writeMessage({ jsonrpc: '2.0', id: message.id, result });
} catch (error) {
this.writeMessage({
jsonrpc: '2.0',
id: message.id,
error: { code: -32000, message: error instanceof Error ? error.message : String(error) },
});
}
return;
}
if (message.method) {
this.emit(message.method, message.params);
this.emit('notification', { method: message.method, params: message.params });
}
}
rejectAll(error) {
for (const pending of this.pending.values()) {
pending.reject(error);
}
this.pending.clear();
}
async dispatchRequestHandlers(handlers, params) {
let fallbackResult = null;
let sawHandler = false;
for (const handler of Array.from(handlers).reverse()) {
sawHandler = true;
const result = await handler(params);
const outcome = result?.outcome?.outcome;
if (outcome !== 'cancelled') {
return result;
}
fallbackResult = result;
}
if (sawHandler && fallbackResult) {
return fallbackResult;
}
return { outcome: { outcome: 'cancelled' } };
}
close() {
if (!this.process) {
return;
}
this.process.kill('SIGTERM');
}
}
class HermesConnectionManager {
constructor() {
this.connections = new Map();
}
async getConnection(cwd) {
const key = cwd || process.cwd();
let connection = this.connections.get(key);
if (!connection) {
connection = new AcpClient({ cwd: key });
connection.on('close', () => {
this.connections.delete(key);
});
this.connections.set(key, connection);
}
await connection.initialize();
return connection;
}
closeAll() {
for (const connection of this.connections.values()) {
connection.close();
}
this.connections.clear();
}
}
const hermesConnectionManager = new HermesConnectionManager();
export {
AcpClient,
HermesConnectionManager,
hermesConnectionManager,
};

View File

@@ -41,6 +41,10 @@ import {
spawnOpenCode,
abortOpenCodeSession,
} from './opencode-cli.js';
import {
spawnHermes,
abortHermesSession,
} from './hermes-cli.js';
import sessionManager from './sessionManager.js';
import {
stripAnsiSequences,
@@ -118,6 +122,7 @@ const wss = createWebSocketServer(server, {
codex: queryCodex,
gemini: spawnGemini,
opencode: spawnOpenCode,
hermes: spawnHermes,
},
abortFns: {
claude: abortClaudeSDKSession,
@@ -125,6 +130,7 @@ const wss = createWebSocketServer(server, {
codex: abortCodexSession,
gemini: abortGeminiSession,
opencode: abortOpenCodeSession,
hermes: abortHermesSession,
},
resolveToolApproval,
getPendingApprovalsForSession,

View File

@@ -0,0 +1,135 @@
import { readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import spawn from 'cross-spawn';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
export class HermesProviderAuth implements IProviderAuth {
private checkInstalled(): boolean {
const cliPath = process.env.HERMES_CLI_PATH || 'hermes acp';
const [command, ...args] = cliPath.trim().split(/\s+/);
try {
const result = spawn.sync(command || 'hermes', [...args, '--version'], { stdio: 'ignore', timeout: 5000 });
return result.error ? false : result.status === 0 || result.status === null;
} catch {
return false;
}
}
async getStatus(): Promise<ProviderAuthStatus> {
const installed = this.checkInstalled();
if (!installed) {
return {
provider: 'hermes',
installed: false,
authenticated: false,
email: null,
method: null,
error: 'Hermes ACP is not installed',
};
}
const credentials = await this.checkCredentials();
return {
provider: 'hermes',
installed,
authenticated: credentials.authenticated,
email: credentials.email,
method: credentials.method,
error: credentials.authenticated ? undefined : 'Hermes credentials were not found',
};
}
private async checkCredentials(): Promise<{ authenticated: boolean; email: string | null; method: string | null }> {
if (this.hasKnownProviderEnv(process.env)) {
return { authenticated: true, email: 'API Key Auth', method: 'env' };
}
const hermesHome = path.join(os.homedir(), '.hermes');
try {
const authJson = readObjectRecord(JSON.parse(await readFile(path.join(hermesHome, 'auth.json'), 'utf8')));
if (
readOptionalString(authJson?.apiKey)
|| readOptionalString(authJson?.api_key)
|| readOptionalString(authJson?.token)
|| readOptionalString(authJson?.access_token)
|| readOptionalString(authJson?.refresh_token)
) {
return {
authenticated: true,
email: readOptionalString(authJson?.email) ?? 'Hermes Auth',
method: 'credentials_file',
};
}
} catch {
// Fall through to dotenv check.
}
try {
const envContent = await readFile(path.join(hermesHome, '.env'), 'utf8');
if (this.hasKnownProviderEnv(this.parseEnvFile(envContent))) {
return { authenticated: true, email: 'API Key Auth', method: 'env_file' };
}
} catch {
// Fall through.
}
try {
const configContent = await readFile(path.join(hermesHome, 'config.yaml'), 'utf8');
if (/^\s*api_key\s*:\s*["']?[^"'#\s]+/m.test(configContent)) {
return { authenticated: true, email: 'Hermes Config', method: 'config_file' };
}
} catch {
// Fall through.
}
return { authenticated: false, email: null, method: null };
}
private parseEnvFile(content: string): Record<string, string> {
const parsed: Record<string, string> = {};
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const separatorIndex = line.indexOf('=');
if (separatorIndex <= 0) {
continue;
}
const key = line.slice(0, separatorIndex).trim();
const value = line.slice(separatorIndex + 1).trim().replace(/^['"]|['"]$/g, '');
if (key && value) {
parsed[key] = value;
}
}
return parsed;
}
private hasKnownProviderEnv(env: Record<string, string | undefined>): boolean {
const keys = [
'HERMES_API_KEY',
'NOUS_API_KEY',
'OPENROUTER_API_KEY',
'OPENAI_API_KEY',
'ANTHROPIC_API_KEY',
'GOOGLE_API_KEY',
'GEMINI_API_KEY',
'GLM_API_KEY',
'KIMI_API_KEY',
'MINIMAX_API_KEY',
'MINIMAX_CN_API_KEY',
'HF_TOKEN',
'NVIDIA_API_KEY',
'ARCEEAI_API_KEY',
'OLLAMA_API_KEY',
'KILOCODE_API_KEY',
'GITHUB_TOKEN',
];
return keys.some((key) => Boolean(env[key]?.trim()));
}
}

View File

@@ -0,0 +1,296 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import {
AppError,
readObjectRecord,
readOptionalString,
readStringArray,
readStringRecord,
} from '@/shared/utils.js';
const yamlScalar = (value: unknown): string => {
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
if (value === null) {
return 'null';
}
return JSON.stringify(String(value));
};
const parseYamlScalar = (value: string): unknown => {
const trimmed = value.trim();
if (!trimmed) {
return '';
}
if (trimmed === 'null') {
return null;
}
if (trimmed === 'true') {
return true;
}
if (trimmed === 'false') {
return false;
}
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith('\'') && trimmed.endsWith('\''))) {
try {
return JSON.parse(trimmed);
} catch {
return trimmed.slice(1, -1);
}
}
if (
(trimmed.startsWith('[') && trimmed.endsWith(']'))
|| (trimmed.startsWith('{') && trimmed.endsWith('}'))
) {
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
return trimmed.replace(/\s+#.*$/, '').trim();
};
const getIndent = (line: string): number => line.match(/^\s*/)?.[0].length ?? 0;
const parseYamlArray = (
lines: string[],
startIndex: number,
indent: number,
): { value: unknown[]; nextIndex: number } => {
const value: unknown[] = [];
let index = startIndex;
while (index < lines.length) {
const line = lines[index];
if (!line.trim()) {
index += 1;
continue;
}
if (getIndent(line) !== indent || !line.trimStart().startsWith('- ')) {
break;
}
value.push(parseYamlScalar(line.trimStart().slice(2)));
index += 1;
}
return { value, nextIndex: index };
};
const parseYamlMap = (
lines: string[],
startIndex: number,
indent: number,
): { value: Record<string, unknown>; nextIndex: number } => {
const value: Record<string, unknown> = {};
let index = startIndex;
while (index < lines.length) {
const line = lines[index];
if (!line.trim()) {
index += 1;
continue;
}
const currentIndent = getIndent(line);
if (currentIndent < indent) {
break;
}
if (currentIndent > indent) {
index += 1;
continue;
}
const match = line.slice(indent).match(/^([^:#]+):(?:\s*(.*))?$/);
if (!match) {
index += 1;
continue;
}
const key = match[1].trim();
const raw = match[2]?.trim() ?? '';
if (raw) {
value[key] = parseYamlScalar(raw);
index += 1;
continue;
}
const nextLine = lines[index + 1];
if (nextLine && getIndent(nextLine) > indent && nextLine.trimStart().startsWith('- ')) {
const parsed = parseYamlArray(lines, index + 1, getIndent(nextLine));
value[key] = parsed.value;
index = parsed.nextIndex;
continue;
}
const parsed = parseYamlMap(lines, index + 1, indent + 2);
value[key] = parsed.value;
index = parsed.nextIndex;
}
return { value, nextIndex: index };
};
const readYamlConfig = async (filePath: string): Promise<string> => {
try {
return await readFile(filePath, 'utf8');
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return '';
}
throw error;
}
};
const readMcpServers = async (filePath: string): Promise<Record<string, unknown>> => {
const content = await readYamlConfig(filePath);
const lines = content.split(/\r?\n/);
const sectionIndex = lines.findIndex((line) => /^mcp_servers\s*:\s*$/.test(line));
if (sectionIndex === -1) {
return {};
}
const parsed = parseYamlMap(lines, sectionIndex + 1, 2);
return readObjectRecord(parsed.value) ?? {};
};
const serializeYamlMap = (value: Record<string, unknown>, indent = 0): string[] => {
const lines: string[] = [];
for (const [key, rawValue] of Object.entries(value)) {
if (rawValue === undefined) {
continue;
}
const prefix = `${' '.repeat(indent)}${key}:`;
if (Array.isArray(rawValue)) {
lines.push(prefix);
for (const item of rawValue) {
lines.push(`${' '.repeat(indent + 2)}- ${yamlScalar(item)}`);
}
continue;
}
const nested = readObjectRecord(rawValue);
if (nested) {
lines.push(prefix);
lines.push(...serializeYamlMap(nested, indent + 2));
continue;
}
lines.push(`${prefix} ${yamlScalar(rawValue)}`);
}
return lines;
};
const replaceMcpServersSection = (content: string, servers: Record<string, unknown>): string => {
const lines = content.split(/\r?\n/);
const sectionIndex = lines.findIndex((line) => /^mcp_servers\s*:\s*$/.test(line));
const serialized = ['mcp_servers:', ...serializeYamlMap(servers, 2)];
if (sectionIndex === -1) {
const prefix = content.trimEnd();
return `${prefix ? `${prefix}\n\n` : ''}${serialized.join('\n')}\n`;
}
let endIndex = sectionIndex + 1;
while (endIndex < lines.length) {
const line = lines[endIndex];
if (line.trim() && getIndent(line) === 0) {
break;
}
endIndex += 1;
}
lines.splice(sectionIndex, endIndex - sectionIndex, ...serialized);
return `${lines.join('\n').trimEnd()}\n`;
};
const writeMcpServers = async (filePath: string, servers: Record<string, unknown>): Promise<void> => {
const content = await readYamlConfig(filePath);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, replaceMcpServersSection(content, servers), 'utf8');
};
export class HermesMcpProvider extends McpProvider {
constructor() {
super('hermes', ['user', 'project'], ['stdio', 'http']);
}
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.hermes', 'config.yaml')
: path.join(workspacePath, '.hermes', 'config.yaml');
return readMcpServers(filePath);
}
protected async writeScopedServers(
scope: McpScope,
workspacePath: string,
servers: Record<string, unknown>,
): Promise<void> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.hermes', 'config.yaml')
: path.join(workspacePath, '.hermes', 'config.yaml');
await writeMcpServers(filePath, servers);
}
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
if (input.transport === 'stdio') {
if (!input.command?.trim()) {
throw new AppError('command is required for stdio MCP servers.', {
code: 'MCP_COMMAND_REQUIRED',
statusCode: 400,
});
}
return {
command: input.command,
args: input.args ?? [],
env: input.env ?? {},
cwd: input.cwd,
};
}
if (!input.url?.trim()) {
throw new AppError('url is required for http/sse MCP servers.', {
code: 'MCP_URL_REQUIRED',
statusCode: 400,
});
}
return {
type: input.transport,
url: input.url,
headers: input.headers ?? {},
};
}
protected normalizeServerConfig(scope: McpScope, name: string, rawConfig: unknown): ProviderMcpServer | null {
const config = readObjectRecord(rawConfig);
if (!config) {
return null;
}
if (typeof config.command === 'string') {
return {
provider: 'hermes',
name,
scope,
transport: 'stdio',
command: config.command,
args: readStringArray(config.args),
env: readStringRecord(config.env),
cwd: readOptionalString(config.cwd),
};
}
if (typeof config.url === 'string') {
return {
provider: 'hermes',
name,
scope,
transport: 'http',
url: config.url,
headers: readStringRecord(config.headers),
};
}
return null;
}
}

View File

@@ -0,0 +1,152 @@
import { readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { IProviderModels } from '@/shared/interfaces.js';
import type {
ProviderChangeActiveModelInput,
ProviderCurrentActiveModel,
ProviderModelsDefinition,
ProviderSessionActiveModelChange,
} from '@/shared/types.js';
import {
buildDefaultProviderCurrentActiveModel,
readOptionalString,
writeProviderSessionActiveModelChange,
} from '@/shared/utils.js';
export const HERMES_CONFIGURED_MODEL = '__hermes_configured_model__';
export const HERMES_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [
{
value: HERMES_CONFIGURED_MODEL,
label: 'Configured in Hermes',
description: 'Uses the provider and model selected with `hermes model`.',
},
],
DEFAULT: HERMES_CONFIGURED_MODEL,
};
const HERMES_CONFIG_PATH = path.join(os.homedir(), '.hermes', 'config.yaml');
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function stripScalar(raw: string): string | null {
let value = raw.trim();
// Drop an unquoted trailing comment.
if (!value.startsWith('"') && !value.startsWith("'")) {
const comment = value.search(/\s#/);
if (comment >= 0) {
value = value.slice(0, comment).trim();
}
}
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
return value.trim() || null;
}
const indentOf = (line: string): number => line.length - line.replace(/^\s+/, '').length;
// Minimal, indentation-aware reader for the flat `key: value` and one-level
// nested (`section:`\n` key: value`) shapes used by ~/.hermes/config.yaml.
// Avoids the fragile single-regex lookahead that could terminate a section
// early and silently miss the configured model.
export function readYamlPath(content: string, pathParts: string[]): string | null {
const lines = content.split(/\r?\n/);
if (pathParts.length === 1) {
const re = new RegExp(`^\\s*${escapeRegex(pathParts[0])}\\s*:\\s*(.*)$`);
for (const line of lines) {
if (!line.trim() || line.trim().startsWith('#')) continue;
const match = line.match(re);
if (match) return stripScalar(match[1]);
}
return null;
}
const [section, key] = pathParts;
const sectionRe = new RegExp(`^(\\s*)${escapeRegex(section)}\\s*:\\s*$`);
const keyRe = new RegExp(`^\\s*${escapeRegex(key)}\\s*:\\s*(.*)$`);
let sectionIndent: number | null = null;
for (const line of lines) {
if (!line.trim() || line.trim().startsWith('#')) continue;
if (sectionIndent === null) {
const match = line.match(sectionRe);
if (match) sectionIndent = match[1].length;
continue;
}
// Left the nested block once indentation returns to the section level or less.
if (indentOf(line) <= sectionIndent) {
sectionIndent = line.match(sectionRe)?.[1].length ?? null;
continue;
}
const match = line.match(keyRe);
if (match) return stripScalar(match[1]);
}
return null;
}
export class HermesProviderModels implements IProviderModels {
async getSupportedModels(): Promise<ProviderModelsDefinition> {
const activeModel = await this.readConfiguredModel();
if (!activeModel) {
return HERMES_FALLBACK_MODELS;
}
const options = [
{ value: activeModel, label: activeModel },
...HERMES_FALLBACK_MODELS.OPTIONS,
];
return {
OPTIONS: options,
DEFAULT: activeModel,
};
}
async getCurrentActiveModel(): Promise<ProviderCurrentActiveModel> {
const configured = await this.readConfiguredModel();
if (configured) {
return { model: configured };
}
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
}
async changeActiveModel(input: ProviderChangeActiveModelInput): Promise<ProviderSessionActiveModelChange> {
if (input.model === HERMES_CONFIGURED_MODEL) {
return {
provider: 'hermes',
sessionId: input.sessionId,
supported: true,
changed: false,
model: null,
};
}
return writeProviderSessionActiveModelChange('hermes', input);
}
private async readConfiguredModel(): Promise<string | null> {
try {
const content = await readFile(HERMES_CONFIG_PATH, 'utf8');
return readOptionalString(readYamlPath(content, ['model', 'default']))
?? readOptionalString(readYamlPath(content, ['model']))
?? null;
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,110 @@
import fsSync from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
import { sessionsDb } from '@/modules/database/index.js';
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
import { normalizeSessionName } from '@/shared/utils.js';
type HermesSessionRow = {
id: string;
cwd: string | null;
title: string | null;
started_at: number | null;
ended_at: number | null;
message_count: number | null;
};
const HERMES_DB_PATH = path.join(os.homedir(), '.hermes', 'state.db');
function unixSecondsToIso(value: number | null | undefined): string {
if (!value || !Number.isFinite(value)) {
return new Date().toISOString();
}
return new Date(value * 1000).toISOString();
}
function openHermesDatabase(): Database.Database | null {
if (!fsSync.existsSync(HERMES_DB_PATH)) {
return null;
}
return new Database(HERMES_DB_PATH, { readonly: true, fileMustExist: true });
}
export class HermesSessionSynchronizer implements IProviderSessionSynchronizer {
private readonly provider = 'hermes' as const;
async synchronize(since?: Date): Promise<number> {
const db = openHermesDatabase();
if (!db) {
return 0;
}
try {
const rows = since
? db.prepare(`
SELECT id, cwd, title, started_at, ended_at, message_count
FROM sessions
WHERE COALESCE(ended_at, started_at) >= ?
ORDER BY COALESCE(ended_at, started_at) ASC
`).all(Math.floor(since.getTime() / 1000)) as HermesSessionRow[]
: db.prepare(`
SELECT id, cwd, title, started_at, ended_at, message_count
FROM sessions
ORDER BY COALESCE(ended_at, started_at) ASC
`).all() as HermesSessionRow[];
let processed = 0;
for (const row of rows) {
if (this.upsertRow(row)) {
processed += 1;
}
}
return processed;
} finally {
db.close();
}
}
async synchronizeFile(filePath: string): Promise<string | null> {
if (path.resolve(filePath) !== HERMES_DB_PATH) {
return null;
}
const db = openHermesDatabase();
if (!db) {
return null;
}
try {
const row = db.prepare(`
SELECT id, cwd, title, started_at, ended_at, message_count
FROM sessions
ORDER BY COALESCE(ended_at, started_at) DESC
LIMIT 1
`).get() as HermesSessionRow | undefined;
return row && this.upsertRow(row) ? row.id : null;
} finally {
db.close();
}
}
private upsertRow(row: HermesSessionRow): boolean {
if (!row.id || !row.cwd) {
return false;
}
sessionsDb.createSession(
row.id,
this.provider,
row.cwd,
normalizeSessionName(row.title ?? undefined, 'Untitled Hermes Session'),
unixSecondsToIso(row.started_at),
unixSecondsToIso(row.ended_at ?? row.started_at),
HERMES_DB_PATH,
);
return true;
}
}

View File

@@ -0,0 +1,307 @@
import fsSync from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
import { sessionsDb } from '@/modules/database/index.js';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import {
createNormalizedMessage,
generateMessageId,
normalizeProviderTimestamp,
readObjectRecord,
readOptionalString,
sliceTailPage,
} from '@/shared/utils.js';
const PROVIDER = 'hermes';
const HERMES_DB_PATH = path.join(os.homedir(), '.hermes', 'state.db');
type HermesMessageRow = {
id: number;
role: string;
content: string | null;
tool_call_id: string | null;
tool_calls: string | null;
tool_name: string | null;
timestamp: number;
reasoning: string | null;
reasoning_content: string | null;
finish_reason: string | null;
};
function formatContent(value: unknown): string {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'string') {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function readUpdateType(raw: AnyRecord): string {
return readOptionalString(raw.type)
?? readOptionalString(raw.kind)
?? readOptionalString(raw.sessionUpdate)
?? readOptionalString(raw.session_update)
?? readOptionalString(raw.update)
?? readOptionalString(raw.event)
?? '';
}
function readEventSessionId(raw: AnyRecord, sessionId: string | null): string | null {
return readOptionalString(raw.sessionId) ?? readOptionalString(raw.session_id) ?? sessionId;
}
function normalizeHermesEvent(rawMessage: unknown, sessionId: string | null, history = false): NormalizedMessage[] {
const envelope = readObjectRecord(rawMessage);
if (!envelope) {
return [];
}
const nestedUpdate = readObjectRecord(envelope.update);
const raw = nestedUpdate ? { ...nestedUpdate, sessionId: envelope.sessionId ?? envelope.session_id ?? sessionId } : envelope;
const type = readUpdateType(raw);
const eventSessionId = readEventSessionId(raw, sessionId);
const timestamp = normalizeProviderTimestamp(raw.timestamp ?? raw.time ?? raw.createdAt ?? raw.created_at);
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)
?? readOptionalString(raw.text)
?? readOptionalString(raw.delta)
?? readOptionalString(readObjectRecord(raw.message)?.content)
?? '';
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: history ? 'text' : 'stream_delta',
role: history ? 'assistant' : undefined,
content,
})];
}
if (['agent_message', 'assistant_message', 'message'].includes(type)) {
const role = readOptionalString(raw.role) === 'user' ? 'user' : 'assistant';
const content = readOptionalString(raw.content)
?? readOptionalString(raw.text)
?? readOptionalString(readObjectRecord(raw.message)?.content)
?? '';
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: history ? 'text' : role === 'assistant' ? 'stream_delta' : 'text',
role: history || role === 'user' ? role : undefined,
content,
})];
}
if (['agent_thought_chunk', 'thought_delta', 'thinking', 'reasoning'].includes(type)) {
const content = readOptionalString(raw.content) ?? readOptionalString(raw.text) ?? readOptionalString(raw.delta) ?? '';
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'thinking',
content,
})];
}
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;
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'tool_use',
toolName: readOptionalString(raw.toolName)
?? readOptionalString(raw.tool_name)
?? readOptionalString(raw.title)
?? readOptionalString(raw.name)
?? readOptionalString(tool?.name)
?? 'Tool',
toolInput: raw.rawInput ?? raw.raw_input ?? raw.input ?? raw.arguments ?? raw.params ?? tool?.input ?? {},
toolId,
})];
}
if (['tool_call_update', 'tool_result', 'tool_call_result', 'tool_call_done'].includes(type)) {
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',
})];
}
if (type === 'plan') {
const content = readOptionalString(raw.content) ?? readOptionalString(raw.text) ?? formatContent(raw.plan);
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'status',
text: 'plan',
summary: content,
})];
}
if (type === 'error') {
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'error',
content: readOptionalString(raw.error) ?? readOptionalString(raw.message) ?? 'Unknown Hermes error',
})];
}
return [];
}
function parseJsonArray(value: string | null): unknown[] {
if (!value) {
return [];
}
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function readHermesHistoryFromDatabase(sessionId: string): NormalizedMessage[] {
const normalized: NormalizedMessage[] = [];
if (!fsSync.existsSync(HERMES_DB_PATH)) {
return normalized;
}
const db = new Database(HERMES_DB_PATH, { readonly: true, fileMustExist: true });
try {
const rows = db.prepare(`
SELECT id, role, content, tool_call_id, tool_calls, tool_name, timestamp, reasoning, reasoning_content, finish_reason
FROM messages
WHERE session_id = ? AND active = 1
ORDER BY timestamp ASC, id ASC
`).all(sessionId) as HermesMessageRow[];
for (const row of rows) {
const timestamp = new Date(row.timestamp * 1000).toISOString();
const baseId = `hermes-${sessionId}-${row.id}`;
const reasoning = row.reasoning_content || row.reasoning;
if (reasoning?.trim()) {
normalized.push(createNormalizedMessage({
id: `${baseId}-thinking`,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'thinking',
content: reasoning,
}));
}
for (const toolCall of parseJsonArray(row.tool_calls)) {
const call = readObjectRecord(toolCall);
const fn = readObjectRecord(call?.function);
normalized.push(createNormalizedMessage({
id: `${baseId}-tool-${readOptionalString(call?.id) ?? normalized.length}`,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'tool_use',
toolName: readOptionalString(fn?.name) ?? readOptionalString(call?.name) ?? 'Tool',
toolInput: fn?.arguments ?? call?.arguments ?? {},
toolId: readOptionalString(call?.id) ?? `${baseId}-tool`,
}));
}
if (row.role === 'tool') {
normalized.push(createNormalizedMessage({
id: `${baseId}-result`,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'tool_result',
toolId: row.tool_call_id ?? '',
content: row.content ?? '',
isError: row.finish_reason === 'error',
}));
continue;
}
if (row.content?.trim()) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'text',
role: row.role === 'user' ? 'user' : 'assistant',
content: row.content,
}));
}
}
} finally {
db.close();
}
return normalized;
}
export class HermesSessionsProvider implements IProviderSessions {
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
return normalizeHermesEvent(rawMessage, sessionId);
}
async fetchHistory(sessionId: string, options: FetchHistoryOptions = {}): Promise<FetchHistoryResult> {
const { limit = null, offset = 0 } = options;
const row = sessionsDb.getSessionById(sessionId) ?? sessionsDb.getSessionByProviderSessionId(sessionId);
const messages = readHermesHistoryFromDatabase(row?.provider_session_id ?? sessionId);
const start = Math.max(0, offset);
const pageLimit = limit === null ? null : Math.max(0, limit);
const page = sliceTailPage(messages, pageLimit, start);
return {
messages: page.page,
total: messages.length,
hasMore: page.hasMore,
offset: start,
limit: pageLimit,
};
}
}

View File

@@ -0,0 +1,181 @@
import os from 'node:os';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
import type {
ProviderSkillRegistryActionResult,
ProviderSkillRegistryInstallInput,
ProviderSkillRegistrySearchOptions,
ProviderSkillRegistrySearchResult,
ProviderSkillSource,
} from '@/shared/types.js';
import { AppError, addUniqueProviderSkillSource, readObjectRecord, readOptionalString } from '@/shared/utils.js';
const execFileAsync = promisify(execFile);
const HERMES_COMMAND =
(process.env.HERMES_COMMAND_PATH || process.env.HERMES_CLI_PATH || 'hermes').trim().split(/\s+/)[0] || 'hermes';
const HERMES_SKILLS_TIMEOUT_MS = 45_000;
const HERMES_SKILLS_MAX_BUFFER = 1024 * 1024 * 8;
function normalizeSearchResult(value: unknown): ProviderSkillRegistrySearchResult | null {
const record = readObjectRecord(value);
if (!record) {
return null;
}
const name = readOptionalString(record.name);
const identifier = readOptionalString(record.identifier);
if (!name || !identifier) {
return null;
}
return {
name,
identifier,
source: readOptionalString(record.source) ?? undefined,
trustLevel: readOptionalString(record.trust_level) ?? readOptionalString(record.trustLevel) ?? undefined,
description: readOptionalString(record.description) ?? undefined,
};
}
export class HermesSkillsProvider extends SkillsProvider {
constructor() {
super('hermes');
}
async searchRegistry(
query: string,
options: ProviderSkillRegistrySearchOptions = {},
): Promise<ProviderSkillRegistrySearchResult[]> {
const normalizedQuery = query.trim();
if (!normalizedQuery) {
return [];
}
const args = ['skills', 'search', normalizedQuery, '--json'];
const source = options.source?.trim();
if (source) {
args.push('--source', source);
}
if (options.limit && Number.isFinite(options.limit)) {
args.push('--limit', String(Math.max(1, Math.min(Math.floor(options.limit), 50))));
}
const result = await this.runHermes(args);
try {
const parsed = JSON.parse(result.stdout);
return Array.isArray(parsed)
? parsed.map(normalizeSearchResult).filter((entry): entry is ProviderSkillRegistrySearchResult => Boolean(entry))
: [];
} catch (error) {
throw new AppError('Hermes returned invalid skill search JSON.', {
code: 'HERMES_SKILL_SEARCH_PARSE_FAILED',
statusCode: 502,
details: error instanceof Error ? error.message : String(error),
});
}
}
async installRegistrySkill(input: ProviderSkillRegistryInstallInput): Promise<ProviderSkillRegistryActionResult> {
const identifier = input.identifier.trim();
if (!identifier) {
throw new AppError('identifier is required.', {
code: 'HERMES_SKILL_IDENTIFIER_REQUIRED',
statusCode: 400,
});
}
const args = ['skills', 'install', identifier, '--yes'];
if (input.category?.trim()) {
args.push('--category', input.category.trim());
}
if (input.name?.trim()) {
args.push('--name', input.name.trim());
}
if (input.force) {
args.push('--force');
}
return this.runHermes(args);
}
async uninstallRegistrySkill(name: string): Promise<ProviderSkillRegistryActionResult> {
const normalizedName = name.trim();
if (!normalizedName) {
throw new AppError('name is required.', {
code: 'HERMES_SKILL_NAME_REQUIRED',
statusCode: 400,
});
}
return this.runHermes(['skills', 'uninstall', normalizedName]);
}
async checkRegistryUpdates(): Promise<ProviderSkillRegistryActionResult> {
return this.runHermes(['skills', 'check']);
}
async updateRegistrySkills(): Promise<ProviderSkillRegistryActionResult> {
return this.runHermes(['skills', 'update']);
}
async auditRegistrySkills(): Promise<ProviderSkillRegistryActionResult> {
return this.runHermes(['skills', 'audit']);
}
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
const sources: ProviderSkillSource[] = [];
const seenRootDirs = new Set<string>();
addUniqueProviderSkillSource(sources, seenRootDirs, {
scope: 'repo',
rootDir: path.join(workspacePath, '.hermes', 'skills'),
commandPrefix: '/',
recursive: true,
});
addUniqueProviderSkillSource(sources, seenRootDirs, {
scope: 'user',
rootDir: path.join(os.homedir(), '.hermes', 'skills'),
commandPrefix: '/',
recursive: true,
});
return sources;
}
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
return {
scope: 'user',
rootDir: path.join(os.homedir(), '.hermes', 'skills'),
commandPrefix: '/',
recursive: true,
};
}
private async runHermes(args: string[]): Promise<ProviderSkillRegistryActionResult> {
try {
const { stdout, stderr } = await execFileAsync(HERMES_COMMAND, args, {
timeout: HERMES_SKILLS_TIMEOUT_MS,
maxBuffer: HERMES_SKILLS_MAX_BUFFER,
env: process.env,
});
return { ok: true, stdout, stderr };
} catch (error) {
const maybeError = error as Error & {
stdout?: string;
stderr?: string;
code?: number | string;
};
throw new AppError(maybeError.stderr || maybeError.message || 'Hermes skill command failed.', {
code: 'HERMES_SKILL_COMMAND_FAILED',
statusCode: 502,
details: {
exitCode: maybeError.code,
stdout: maybeError.stdout,
stderr: maybeError.stderr,
},
});
}
}
}

View File

@@ -0,0 +1,27 @@
import { HermesProviderAuth } from '@/modules/providers/list/hermes/hermes-auth.provider.js';
import { HermesMcpProvider } from '@/modules/providers/list/hermes/hermes-mcp.provider.js';
import { HermesProviderModels } from '@/modules/providers/list/hermes/hermes-models.provider.js';
import { HermesSessionSynchronizer } from '@/modules/providers/list/hermes/hermes-session-synchronizer.provider.js';
import { HermesSessionsProvider } from '@/modules/providers/list/hermes/hermes-sessions.provider.js';
import { HermesSkillsProvider } from '@/modules/providers/list/hermes/hermes-skills.provider.js';
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import type {
IProviderAuth,
IProviderModels,
IProviderSessionSynchronizer,
IProviderSkills,
IProviderSessions,
} from '@/shared/interfaces.js';
export class HermesProvider extends AbstractProvider {
readonly models: IProviderModels = new HermesProviderModels();
readonly mcp = new HermesMcpProvider();
readonly auth: IProviderAuth = new HermesProviderAuth();
readonly skills: IProviderSkills = new HermesSkillsProvider();
readonly sessions: IProviderSessions = new HermesSessionsProvider();
readonly sessionSynchronizer: IProviderSessionSynchronizer = new HermesSessionSynchronizer();
constructor() {
super('hermes');
}
}

View File

@@ -2,6 +2,7 @@ import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.
import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js';
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js';
import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js';
import { HermesProvider } from '@/modules/providers/list/hermes/hermes.provider.js';
import { OpenCodeProvider } from '@/modules/providers/list/opencode/opencode.provider.js';
import type { IProvider } from '@/shared/interfaces.js';
import type { LLMProvider } from '@/shared/types.js';
@@ -13,6 +14,7 @@ const providers: Record<LLMProvider, IProvider> = {
cursor: new CursorProvider(),
gemini: new GeminiProvider(),
opencode: new OpenCodeProvider(),
hermes: new HermesProvider(),
};
/**

View File

@@ -279,6 +279,48 @@ const parseProviderSkillCreatePayload = (payload: unknown): ProviderSkillCreateI
return { entries };
};
const parseSkillRegistryLimit = (value: unknown): number => {
const raw = readOptionalQueryString(value);
if (!raw) {
return 10;
}
const parsed = Number.parseInt(raw, 10);
if (Number.isNaN(parsed)) {
throw new AppError('limit must be a valid integer.', {
code: 'INVALID_QUERY_PARAMETER',
statusCode: 400,
});
}
return Math.max(1, Math.min(parsed, 50));
};
const parseSkillRegistryInstallPayload = (payload: unknown) => {
if (!payload || typeof payload !== 'object') {
throw new AppError('Request body must be an object.', {
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
});
}
const body = payload as Record<string, unknown>;
const identifier = readOptionalQueryString(body.identifier);
if (!identifier) {
throw new AppError('identifier is required.', {
code: 'SKILL_IDENTIFIER_REQUIRED',
statusCode: 400,
});
}
return {
identifier,
category: readOptionalQueryString(body.category),
name: readOptionalQueryString(body.name),
force: body.force === true,
};
};
const parseProvider = (value: unknown): LLMProvider => {
const normalized = normalizeProviderParam(value);
if (
@@ -287,6 +329,7 @@ const parseProvider = (value: unknown): LLMProvider => {
|| normalized === 'cursor'
|| normalized === 'gemini'
|| normalized === 'opencode'
|| normalized === 'hermes'
) {
return normalized;
}
@@ -441,6 +484,77 @@ router.delete(
}),
);
router.get(
'/:provider/skills/registry/search',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const query = readOptionalQueryString(req.query.query);
if (!query) {
throw new AppError('query is required.', {
code: 'SKILL_SEARCH_QUERY_REQUIRED',
statusCode: 400,
});
}
const results = await providerSkillsService.searchSkillRegistry(provider, query, {
source: readOptionalQueryString(req.query.source),
limit: parseSkillRegistryLimit(req.query.limit),
});
res.json(createApiSuccessResponse({ provider, results }));
}),
);
router.post(
'/:provider/skills/registry/install',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const result = await providerSkillsService.installRegistrySkill(
provider,
parseSkillRegistryInstallPayload(req.body),
);
res.status(201).json(createApiSuccessResponse({ provider, result }));
}),
);
router.post(
'/:provider/skills/registry/check',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const result = await providerSkillsService.checkRegistryUpdates(provider);
res.json(createApiSuccessResponse({ provider, result }));
}),
);
router.post(
'/:provider/skills/registry/update',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const result = await providerSkillsService.updateRegistrySkills(provider);
res.json(createApiSuccessResponse({ provider, result }));
}),
);
router.post(
'/:provider/skills/registry/audit',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const result = await providerSkillsService.auditRegistrySkills(provider);
res.json(createApiSuccessResponse({ provider, result }));
}),
);
router.delete(
'/:provider/skills/registry/:name',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const result = await providerSkillsService.uninstallRegistrySkill(
provider,
readPathParam(req.params.name, 'name'),
);
res.json(createApiSuccessResponse({ provider, result }));
}),
);
// ----------------- MCP routes -----------------
router.get(
'/:provider/mcp/servers',

View File

@@ -75,6 +75,15 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
supportsPermissionRequests: false,
supportsTokenUsage: true,
},
hermes: {
provider: 'hermes',
permissionModes: ['default'],
defaultPermissionMode: 'default',
supportsImages: false,
supportsAbort: true,
supportsPermissionRequests: true,
supportsTokenUsage: false,
},
};
/**

View File

@@ -23,6 +23,7 @@ export const sessionSynchronizerService = {
cursor: 0,
gemini: 0,
opencode: 0,
hermes: 0,
};
const failures: string[] = [];

View File

@@ -39,6 +39,10 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> =
provider: 'opencode',
rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'),
},
{
provider: 'hermes',
rootPath: path.join(os.homedir(), '.hermes'),
},
];
const WATCHER_IGNORED_PATTERNS = [
@@ -81,6 +85,10 @@ function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean {
return path.basename(filePath) === 'opencode.db';
}
if (provider === 'hermes') {
return path.basename(filePath) === 'state.db';
}
if (provider === 'gemini') {
return filePath.endsWith('.json') || filePath.endsWith('.jsonl');
}

View File

@@ -4,7 +4,29 @@ import type {
ProviderSkillCreateInput,
ProviderSkillListOptions,
ProviderSkillRemoveInput,
ProviderSkillRegistryActionResult,
ProviderSkillRegistryInstallInput,
ProviderSkillRegistrySearchOptions,
ProviderSkillRegistrySearchResult,
} from '@/shared/types.js';
import { AppError } from '@/shared/utils.js';
const getProviderSkills = (providerName: string) => providerRegistry.resolveProvider(providerName).skills;
const requireSkillRegistryMethod = <TMethod extends keyof ReturnType<typeof getProviderSkills>>(
providerName: string,
methodName: TMethod,
): NonNullable<ReturnType<typeof getProviderSkills>[TMethod]> => {
const skills = getProviderSkills(providerName);
const method = skills[methodName];
if (typeof method !== 'function') {
throw new AppError(`${providerName} does not support skill registry operations.`, {
code: 'PROVIDER_SKILL_REGISTRY_UNSUPPORTED',
statusCode: 400,
});
}
return method as NonNullable<ReturnType<typeof getProviderSkills>[TMethod]>;
};
export const providerSkillsService = {
/**
@@ -14,8 +36,7 @@ export const providerSkillsService = {
providerName: string,
options?: ProviderSkillListOptions,
): Promise<ProviderSkill[]> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.skills.listSkills(options);
return getProviderSkills(providerName).listSkills(options);
},
/**
@@ -25,8 +46,44 @@ export const providerSkillsService = {
providerName: string,
input: ProviderSkillCreateInput,
): Promise<ProviderSkill[]> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.skills.addSkills(input);
return getProviderSkills(providerName).addSkills(input);
},
async searchSkillRegistry(
providerName: string,
query: string,
options?: ProviderSkillRegistrySearchOptions,
): Promise<ProviderSkillRegistrySearchResult[]> {
const searchRegistry = requireSkillRegistryMethod(providerName, 'searchRegistry');
return searchRegistry.call(getProviderSkills(providerName), query, options);
},
async installRegistrySkill(
providerName: string,
input: ProviderSkillRegistryInstallInput,
): Promise<ProviderSkillRegistryActionResult> {
const installRegistrySkill = requireSkillRegistryMethod(providerName, 'installRegistrySkill');
return installRegistrySkill.call(getProviderSkills(providerName), input);
},
async uninstallRegistrySkill(providerName: string, name: string): Promise<ProviderSkillRegistryActionResult> {
const uninstallRegistrySkill = requireSkillRegistryMethod(providerName, 'uninstallRegistrySkill');
return uninstallRegistrySkill.call(getProviderSkills(providerName), name);
},
async checkRegistryUpdates(providerName: string): Promise<ProviderSkillRegistryActionResult> {
const checkRegistryUpdates = requireSkillRegistryMethod(providerName, 'checkRegistryUpdates');
return checkRegistryUpdates.call(getProviderSkills(providerName));
},
async updateRegistrySkills(providerName: string): Promise<ProviderSkillRegistryActionResult> {
const updateRegistrySkills = requireSkillRegistryMethod(providerName, 'updateRegistrySkills');
return updateRegistrySkills.call(getProviderSkills(providerName));
},
async auditRegistrySkills(providerName: string): Promise<ProviderSkillRegistryActionResult> {
const auditRegistrySkills = requireSkillRegistryMethod(providerName, 'auditRegistrySkills');
return auditRegistrySkills.call(getProviderSkills(providerName));
},
async removeProviderSkill(

View File

@@ -341,7 +341,7 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
workspacePath,
});
assert.equal(globalResult.length, 5);
assert.equal(globalResult.length, 6);
assert.ok(globalResult.every((entry) => entry.created === true));
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
@@ -356,6 +356,11 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
const opencodeProject = await readJson(path.join(workspacePath, 'opencode.json'));
assert.ok((opencodeProject.mcp as Record<string, unknown>)['global-http']);
const hermesProject = await fs.readFile(path.join(workspacePath, '.hermes', 'config.yaml'), 'utf8');
assert.match(hermesProject, /^mcp_servers:\n/m);
assert.match(hermesProject, /^\s+global-http:\n/m);
assert.match(hermesProject, /^\s+url: "https:\/\/global\.example\.com\/mcp"\n/m);
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
@@ -377,4 +382,3 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
await fs.rm(tempRoot, { recursive: true, force: true });
}
});

View File

@@ -10,12 +10,14 @@ import { spawnCursor } from '../cursor-cli.js';
import { queryCodex } from '../openai-codex.js';
import { spawnGemini } from '../gemini-cli.js';
import { spawnOpenCode } from '../opencode-cli.js';
import { spawnHermes } from '../hermes-cli.js';
import { Octokit } from '@octokit/rest';
import { providerModelsService } from '../modules/providers/services/provider-models.service.js';
import { IS_PLATFORM } from '../constants/config.js';
import { normalizeProjectPath } from '../shared/utils.js';
const router = express.Router();
const HERMES_CONFIGURED_MODEL = '__hermes_configured_model__';
/**
* Middleware to authenticate agent API requests.
@@ -636,7 +638,7 @@ class ResponseCollector {
* - Source for auto-generated branch names (if createBranch=true and no branchName)
* - Fallback for PR title if no commits are made
*
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode'
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode' | 'hermes'
* Default: 'claude'
*
* @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
@@ -754,7 +756,7 @@ class ResponseCollector {
* Input Validations (400 Bad Request):
* - Either githubUrl OR projectPath must be provided (not neither)
* - message must be non-empty string
* - provider must be 'claude', 'cursor', 'codex', 'gemini', or 'opencode'
* - provider must be 'claude', 'cursor', 'codex', 'gemini', 'opencode', or 'hermes'
* - createBranch/createPR requires githubUrl OR projectPath (not neither)
* - branchName must pass Git naming rules (if provided)
*
@@ -862,8 +864,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
return res.status(400).json({ error: 'message is required' });
}
if (!['claude', 'cursor', 'codex', 'gemini', 'opencode'].includes(provider)) {
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", "gemini", or "opencode"' });
if (!['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'].includes(provider)) {
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", "gemini", "opencode", or "hermes"' });
}
// Validate GitHub branch/PR creation requirements
@@ -944,6 +946,7 @@ 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') {
@@ -996,6 +999,15 @@ router.post('/', validateExternalApiKey, async (req, res) => {
sessionId: sessionId || null,
model: model || opencodeModels.DEFAULT
}, writer);
} else if (provider === 'hermes') {
console.log('Starting Hermes ACP session');
await spawnHermes(message.trim(), {
projectPath: finalProjectPath,
cwd: finalProjectPath,
sessionId: sessionId || null,
model: model || (hermesModels.DEFAULT === HERMES_CONFIGURED_MODEL ? undefined : hermesModels.DEFAULT)
}, writer);
}
// Handle GitHub branch and PR creation after successful agent completion

View File

@@ -15,7 +15,7 @@ const APP_ROOT = findAppRoot(__dirname);
const router = express.Router();
const MODEL_PROVIDERS = ["claude", "cursor", "codex", "gemini", "opencode"];
const MODEL_PROVIDERS = ["claude", "cursor", "codex", "gemini", "opencode", "hermes"];
const MODEL_PROVIDER_LABELS = {
claude: "Claude",
@@ -23,6 +23,7 @@ const MODEL_PROVIDER_LABELS = {
codex: "Codex",
gemini: "Gemini",
opencode: "OpenCode",
hermes: "Hermes",
};
const readModelProvider = (value) => {

View File

@@ -5,6 +5,10 @@ import type {
McpScope,
NormalizedMessage,
ProviderSkill,
ProviderSkillRegistryActionResult,
ProviderSkillRegistryInstallInput,
ProviderSkillRegistrySearchOptions,
ProviderSkillRegistrySearchResult,
ProviderSkillListOptions,
ProviderAuthStatus,
ProviderChangeActiveModelInput,
@@ -116,6 +120,21 @@ export interface IProviderSkills {
removeSkill(
input: ProviderSkillRemoveInput,
): Promise<{ removed: boolean; provider: LLMProvider; directoryName: string }>;
searchRegistry?(
query: string,
options?: ProviderSkillRegistrySearchOptions,
): Promise<ProviderSkillRegistrySearchResult[]>;
installRegistrySkill?(input: ProviderSkillRegistryInstallInput): Promise<ProviderSkillRegistryActionResult>;
uninstallRegistrySkill?(name: string): Promise<ProviderSkillRegistryActionResult>;
checkRegistryUpdates?(): Promise<ProviderSkillRegistryActionResult>;
updateRegistrySkills?(): Promise<ProviderSkillRegistryActionResult>;
auditRegistrySkills?(): Promise<ProviderSkillRegistryActionResult>;
}
// ---------------------------

View File

@@ -0,0 +1,83 @@
const pendingApprovals = new Map();
const APPROVAL_MAX_AGE_MS = 30 * 60 * 1000;
// Drop approvals whose run died without resolving them (WS disconnect, process
// crash) so their captured payloads/closures don't accumulate unbounded.
function sweepExpiredApprovals(now = Date.now()) {
for (const [requestId, entry] of pendingApprovals) {
const receivedAt = entry.receivedAt instanceof Date ? entry.receivedAt.getTime() : 0;
if (receivedAt && now - receivedAt > APPROVAL_MAX_AGE_MS) {
pendingApprovals.delete(requestId);
}
}
}
function clearApprovalsForSession(sessionId) {
if (!sessionId) {
return;
}
for (const [requestId, entry] of pendingApprovals) {
if (entry.sessionId === sessionId) {
pendingApprovals.delete(requestId);
}
}
}
function registerApproval(requestId, { resolver, sessionId = null, provider = null, meta = {} } = {}) {
if (!requestId || typeof resolver !== 'function') {
return;
}
sweepExpiredApprovals();
pendingApprovals.set(requestId, {
resolver,
sessionId,
provider,
meta,
receivedAt: meta.receivedAt || meta._receivedAt || new Date(),
});
}
function unregisterApproval(requestId) {
pendingApprovals.delete(requestId);
}
function resolveToolApproval(requestId, decision) {
const entry = pendingApprovals.get(requestId);
if (!entry) {
return false;
}
entry.resolver(decision);
return true;
}
function getPendingApprovalsForSession(sessionId) {
const pending = [];
for (const [requestId, entry] of pendingApprovals.entries()) {
if (entry.sessionId !== sessionId) {
continue;
}
pending.push({
requestId,
toolName: entry.meta.toolName || entry.meta._toolName || 'UnknownTool',
input: entry.meta.input ?? entry.meta._input,
context: entry.meta.context ?? entry.meta._context,
sessionId,
provider: entry.provider,
receivedAt: entry.receivedAt,
});
}
return pending;
}
export {
registerApproval,
unregisterApproval,
resolveToolApproval,
getPendingApprovalsForSession,
clearApprovalsForSession,
};

View File

@@ -65,7 +65,7 @@ export type AuthenticatedWebSocketRequest = IncomingMessage & {
* Use this as the source of truth whenever a function or payload needs to identify
* a specific LLM integration.
*/
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode';
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'hermes';
/**
* One selectable model row in a provider model catalog.
@@ -365,6 +365,32 @@ export type ProviderSkillRemoveInput = {
directoryName: string;
};
export type ProviderSkillRegistrySearchOptions = {
source?: string;
limit?: number;
};
export type ProviderSkillRegistrySearchResult = {
name: string;
identifier: string;
source?: string;
trustLevel?: string;
description?: string;
};
export type ProviderSkillRegistryInstallInput = {
identifier: string;
category?: string;
name?: string;
force?: boolean;
};
export type ProviderSkillRegistryActionResult = {
ok: boolean;
stdout: string;
stderr: string;
};
/**
* Normalized skill record returned by provider skill adapters.
*

View File

@@ -39,6 +39,7 @@ interface UseChatComposerStateArgs {
codexModel: string;
geminiModel: string;
opencodeModel: string;
hermesModel: string;
isLoading: boolean;
canAbortSession: boolean;
tokenBudget: Record<string, unknown> | null;
@@ -173,6 +174,7 @@ export function useChatComposerState({
codexModel,
geminiModel,
opencodeModel,
hermesModel,
isLoading,
canAbortSession,
tokenBudget,
@@ -336,6 +338,8 @@ export function useChatComposerState({
? geminiModel
: provider === 'opencode'
? opencodeModel
: provider === 'hermes'
? (hermesModel === '__hermes_configured_model__' ? undefined : hermesModel)
: claudeModel,
tokenUsage: tokenBudget,
};
@@ -391,6 +395,7 @@ export function useChatComposerState({
cursorModel,
geminiModel,
opencodeModel,
hermesModel,
handleBuiltInCommand,
handleCustomCommand,
input,
@@ -703,6 +708,8 @@ export function useChatComposerState({
? 'gemini-settings'
: provider === 'opencode'
? 'opencode-settings'
: provider === 'hermes'
? 'hermes-settings'
: 'claude-settings';
const savedSettings = safeLocalStorage.getItem(settingsKey);
if (savedSettings) {
@@ -729,6 +736,8 @@ export function useChatComposerState({
? geminiModel
: provider === 'opencode'
? opencodeModel
: provider === 'hermes'
? (hermesModel === '__hermes_configured_model__' ? undefined : hermesModel)
: claudeModel;
// One message shape for every provider. The backend resolves the
@@ -774,6 +783,7 @@ export function useChatComposerState({
executeCommand,
geminiModel,
opencodeModel,
hermesModel,
isLoading,
onSessionProcessing,
onSessionEstablished,

View File

@@ -15,6 +15,7 @@ const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
codex: 'gpt-5.4',
gemini: 'gemini-3.1-pro-preview',
opencode: 'anthropic/claude-sonnet-4-5',
hermes: '__hermes_configured_model__',
};
/**
@@ -29,6 +30,7 @@ const FALLBACK_PERMISSION_MODES: Record<LLMProvider, PermissionMode[]> = {
codex: ['default', 'acceptEdits', 'bypassPermissions'],
gemini: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
opencode: ['default'],
hermes: ['default'],
};
type ProviderCapabilities = {
@@ -93,6 +95,9 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const [opencodeModel, setOpenCodeModel] = useState<string>(() => {
return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode;
});
const [hermesModel, setHermesModel] = useState<string>(() => {
return localStorage.getItem('hermes-model') || FALLBACK_DEFAULT_MODEL.hermes;
});
/**
* Backend-owned capability matrix keyed by provider. Drives the permission
@@ -141,12 +146,20 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
return;
}
setOpenCodeModel(model);
localStorage.setItem('opencode-model', model);
if (targetProvider === 'opencode') {
setOpenCodeModel(model);
localStorage.setItem('opencode-model', model);
return;
}
if (targetProvider === 'hermes') {
setHermesModel(model);
localStorage.setItem('hermes-model', model);
}
}, []);
const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => {
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'];
const requestId = providerModelsRequestIdRef.current + 1;
providerModelsRequestIdRef.current = requestId;
const isHardRefresh = options.bypassCache === true;
@@ -324,6 +337,19 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
}
}, [providerModelCatalog.opencode, opencodeModel]);
useEffect(() => {
const hermes = providerModelCatalog.hermes;
if (hermes) {
const next = pickStoredOrCurrent('hermes-model', hermesModel, hermes);
if (next !== hermesModel) {
setHermesModel(next);
}
if (localStorage.getItem('hermes-model') !== next) {
localStorage.setItem('hermes-model', next);
}
}
}, [providerModelCatalog.hermes, hermesModel]);
useEffect(() => {
if (!selectedSession?.id) {
return;
@@ -434,6 +460,8 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
setGeminiModel,
opencodeModel,
setOpenCodeModel,
hermesModel,
setHermesModel,
permissionMode,
setPermissionMode,
pendingPermissionRequests,

View File

@@ -75,6 +75,8 @@ function ChatInterface({
setGeminiModel,
opencodeModel,
setOpenCodeModel,
hermesModel,
setHermesModel,
permissionMode,
pendingPermissionRequests,
setPendingPermissionRequests,
@@ -201,6 +203,7 @@ function ChatInterface({
codexModel,
geminiModel,
opencodeModel,
hermesModel,
isLoading: isProcessing,
canAbortSession,
tokenBudget,
@@ -293,7 +296,9 @@ function ChatInterface({
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: t('messageTypes.claude');
: provider === 'hermes'
? t('messageTypes.hermes', { defaultValue: 'Hermes' })
: t('messageTypes.claude');
return (
<div className="flex h-full items-center justify-center">
@@ -334,6 +339,8 @@ function ChatInterface({
setGeminiModel={setGeminiModel}
opencodeModel={opencodeModel}
setOpenCodeModel={setOpenCodeModel}
hermesModel={hermesModel}
setHermesModel={setHermesModel}
providerModelCatalog={providerModelCatalog}
providerModelsLoading={providerModelsLoading}
tasksEnabled={tasksEnabled}
@@ -425,7 +432,9 @@ function ChatInterface({
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: t('messageTypes.claude'),
: provider === 'hermes'
? t('messageTypes.hermes', { defaultValue: 'Hermes' })
: t('messageTypes.claude'),
})}
isTextareaExpanded={isTextareaExpanded}
sendByCtrlEnter={sendByCtrlEnter}

View File

@@ -39,6 +39,8 @@ interface ChatMessagesPaneProps {
setGeminiModel: (model: string) => void;
opencodeModel: string;
setOpenCodeModel: (model: string) => void;
hermesModel: string;
setHermesModel: (model: string) => void;
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
providerModelsLoading: boolean;
tasksEnabled: boolean;
@@ -89,6 +91,8 @@ function ChatMessagesPane({
setGeminiModel,
opencodeModel,
setOpenCodeModel,
hermesModel,
setHermesModel,
providerModelCatalog,
providerModelsLoading,
tasksEnabled,
@@ -177,6 +181,8 @@ function ChatMessagesPane({
setGeminiModel={setGeminiModel}
opencodeModel={opencodeModel}
setOpenCodeModel={setOpenCodeModel}
hermesModel={hermesModel}
setHermesModel={setHermesModel}
providerModelCatalog={providerModelCatalog}
providerModelsLoading={providerModelsLoading}
tasksEnabled={tasksEnabled}

View File

@@ -63,6 +63,7 @@ const PROVIDER_LABELS: Record<string, string> = {
codex: 'Codex',
gemini: 'Gemini',
opencode: 'OpenCode',
hermes: 'Hermes',
};
const FALLBACK_COMMANDS: CommandEntry[] = [

View File

@@ -183,6 +183,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: provider === 'hermes'
? t('messageTypes.hermes', { defaultValue: 'Hermes' })
: t('messageTypes.claude'))}
</div>
</div>
@@ -430,4 +432,3 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
});
export default MessageComponent;

View File

@@ -29,6 +29,7 @@ const PROVIDER_META: { id: LLMProvider; name: string }[] = [
{ id: "gemini", name: "Google" },
{ id: "cursor", name: "Cursor" },
{ id: "opencode", name: "OpenCode" },
{ id: "hermes", name: "Hermes" },
];
const MOD_KEY =
@@ -50,6 +51,8 @@ type ProviderSelectionEmptyStateProps = {
setGeminiModel: (model: string) => void;
opencodeModel: string;
setOpenCodeModel: (model: string) => void;
hermesModel: string;
setHermesModel: (model: string) => void;
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
providerModelsLoading: boolean;
tasksEnabled: boolean;
@@ -79,11 +82,13 @@ function getCurrentModel(
co: string,
g: string,
o: string,
h: string,
) {
if (p === "claude") return c;
if (p === "codex") return co;
if (p === "gemini") return g;
if (p === "opencode") return o;
if (p === "hermes") return h;
return cu;
}
@@ -92,6 +97,7 @@ function getProviderDisplayName(p: LLMProvider) {
if (p === "cursor") return "Cursor";
if (p === "codex") return "Codex";
if (p === "opencode") return "OpenCode";
if (p === "hermes") return "Hermes";
return "Gemini";
}
@@ -111,6 +117,8 @@ export default function ProviderSelectionEmptyState({
setGeminiModel,
opencodeModel,
setOpenCodeModel,
hermesModel,
setHermesModel,
providerModelCatalog,
providerModelsLoading,
tasksEnabled,
@@ -140,6 +148,7 @@ export default function ProviderSelectionEmptyState({
codexModel,
geminiModel,
opencodeModel,
hermesModel,
);
const currentModelLabel = useMemo(() => {
@@ -164,12 +173,15 @@ export default function ProviderSelectionEmptyState({
} else if (providerId === "opencode") {
setOpenCodeModel(modelValue);
localStorage.setItem("opencode-model", modelValue);
} else if (providerId === "hermes") {
setHermesModel(modelValue);
localStorage.setItem("hermes-model", modelValue);
} else {
setCursorModel(modelValue);
localStorage.setItem("cursor-model", modelValue);
}
},
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, setOpenCodeModel],
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, setOpenCodeModel, setHermesModel],
);
const handleModelSelect = useCallback(
@@ -319,6 +331,10 @@ export default function ProviderSelectionEmptyState({
model: opencodeModel,
defaultValue: "Ready with OpenCode {{model}}",
}),
hermes: t("providerSelection.readyPrompt.hermes", {
model: hermesModel,
defaultValue: "Ready with Hermes {{model}}",
}),
}[provider]
}
</p>

View File

@@ -0,0 +1,16 @@
type HermesLogoProps = {
className?: string;
};
export default function HermesLogo({ className = 'w-5 h-5' }: HermesLogoProps) {
return (
<svg className={className} viewBox="0 0 24 24" role="img" aria-label="Hermes">
<rect width="24" height="24" rx="6" fill="#047857" />
<path
d="M6.2 6.5h2.4v4.3h6.8V6.5h2.4v11h-2.4v-4.6H8.6v4.6H6.2v-11Z"
fill="white"
/>
<path d="M9.3 4.7h5.4l-1.2 1.2h-3L9.3 4.7Z" fill="#A7F3D0" />
</svg>
);
}

View File

@@ -3,6 +3,7 @@ import ClaudeLogo from './ClaudeLogo';
import CodexLogo from './CodexLogo';
import CursorLogo from './CursorLogo';
import GeminiLogo from './GeminiLogo';
import HermesLogo from './HermesLogo';
import OpenCodeLogo from './OpenCodeLogo';
type SessionProviderLogoProps = {
@@ -30,5 +31,9 @@ export default function SessionProviderLogo({
return <OpenCodeLogo className={className} />;
}
if (provider === 'hermes') {
return <HermesLogo className={className} />;
}
return <ClaudeLogo className={className} />;
}

View File

@@ -6,6 +6,7 @@ export const MCP_PROVIDER_NAMES: Record<McpProvider, string> = {
codex: 'Codex',
gemini: 'Gemini',
opencode: 'OpenCode',
hermes: 'Hermes',
};
export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
@@ -14,6 +15,7 @@ export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
codex: ['user', 'project'],
gemini: ['user', 'project'],
opencode: ['user', 'project'],
hermes: ['user', 'project'],
};
export const MCP_SUPPORTED_TRANSPORTS: Record<McpProvider, McpTransport[]> = {
@@ -22,6 +24,7 @@ export const MCP_SUPPORTED_TRANSPORTS: Record<McpProvider, McpTransport[]> = {
codex: ['stdio', 'http'],
gemini: ['stdio', 'http', 'sse'],
opencode: ['stdio', 'http'],
hermes: ['stdio', 'http'],
};
export const MCP_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project'];
@@ -34,6 +37,7 @@ export const MCP_PROVIDER_BUTTON_CLASSES: Record<McpProvider, string> = {
codex: 'bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
gemini: 'bg-blue-600 text-white hover:bg-blue-700',
opencode: 'bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-700 dark:hover:bg-zinc-600',
hermes: 'bg-emerald-700 text-white hover:bg-emerald-800 dark:bg-emerald-600 dark:hover:bg-emerald-700',
};
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
@@ -42,6 +46,7 @@ export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
codex: true,
gemini: true,
opencode: false,
hermes: false,
};
export const DEFAULT_MCP_FORM: McpFormState = {

View File

@@ -10,7 +10,7 @@ export type ProviderAuthStatus = {
export type ProviderAuthStatusMap = Record<LLMProvider, ProviderAuthStatus>;
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'];
export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
claude: '/api/providers/claude/auth/status',
@@ -18,6 +18,7 @@ export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
codex: '/api/providers/codex/auth/status',
gemini: '/api/providers/gemini/auth/status',
opencode: '/api/providers/opencode/auth/status',
hermes: '/api/providers/hermes/auth/status',
};
export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({
@@ -26,4 +27,5 @@ export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuth
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 },
});

View File

@@ -9,6 +9,7 @@ type ProviderLoginModalProps = {
provider?: LLMProvider;
onComplete?: (exitCode: number) => void;
customCommand?: string;
customTitle?: string;
isAuthenticated?: boolean;
};
@@ -41,6 +42,10 @@ const getProviderCommand = ({
return 'opencode auth login';
}
if (provider === 'hermes') {
return 'hermes model';
}
return 'gemini status';
};
@@ -49,6 +54,7 @@ const getProviderTitle = (provider: LLMProvider) => {
if (provider === 'cursor') return 'Cursor CLI Login';
if (provider === 'codex') return 'Codex CLI Login';
if (provider === 'opencode') return 'OpenCode CLI Login';
if (provider === 'hermes') return 'Hermes Agent Setup';
return 'Gemini CLI Configuration';
};
@@ -58,6 +64,7 @@ export default function ProviderLoginModal({
provider = 'claude',
onComplete,
customCommand,
customTitle,
isAuthenticated = false,
}: ProviderLoginModalProps) {
if (!isOpen) {
@@ -65,7 +72,7 @@ export default function ProviderLoginModal({
}
const command = getProviderCommand({ provider, customCommand, isAuthenticated });
const title = getProviderTitle(provider);
const title = customTitle || getProviderTitle(provider);
const handleComplete = (exitCode: number) => {
onComplete?.(exitCode);

View File

@@ -39,7 +39,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTabMeta[] = [
{ id: 'about', label: 'About', keywords: 'about version info', icon: Info },
];
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'];
export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';

View File

@@ -164,6 +164,8 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
const [showLoginModal, setShowLoginModal] = useState(false);
const [loginProvider, setLoginProvider] = useState<ActiveLoginProvider>('');
const [loginCommand, setLoginCommand] = useState<string | undefined>(undefined);
const [loginTitle, setLoginTitle] = useState<string | undefined>(undefined);
const {
providerAuthStatus,
checkProviderAuthStatus,
@@ -231,8 +233,10 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
}
}, []);
const openLoginForProvider = useCallback((provider: AgentProvider) => {
const openLoginForProvider = useCallback((provider: AgentProvider, customCommand?: string, customTitle?: string) => {
setLoginProvider(provider);
setLoginCommand(customCommand);
setLoginTitle(customTitle);
setShowLoginModal(true);
}, []);
@@ -417,6 +421,8 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
showLoginModal,
setShowLoginModal,
loginProvider,
loginCommand,
loginTitle,
handleLoginComplete,
};
}

View File

@@ -58,6 +58,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
showLoginModal,
setShowLoginModal,
loginProvider,
loginCommand,
loginTitle,
handleLoginComplete,
} = useSettingsController({
isOpen,
@@ -232,6 +234,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
onClose={() => setShowLoginModal(false)}
provider={loginProvider || 'claude'}
onComplete={handleLoginComplete}
customCommand={loginCommand}
customTitle={loginTitle}
isAuthenticated={isAuthenticated}
/>

View File

@@ -12,7 +12,7 @@ type AgentListItemProps = {
type AgentConfig = {
name: string;
color: 'blue' | 'purple' | 'gray' | 'indigo' | 'zinc';
color: 'blue' | 'purple' | 'gray' | 'indigo' | 'zinc' | 'emerald';
};
const agentConfig: Record<AgentProvider, AgentConfig> = {
@@ -36,6 +36,10 @@ const agentConfig: Record<AgentProvider, AgentConfig> = {
name: 'OpenCode',
color: 'zinc',
},
hermes: {
name: 'Hermes',
color: 'emerald',
},
};
const colorClasses = {
@@ -54,6 +58,9 @@ const colorClasses = {
zinc: {
dot: 'bg-zinc-500',
},
emerald: {
dot: 'bg-emerald-600',
},
} as const;
export default function AgentListItem({

View File

@@ -29,29 +29,33 @@ export default function AgentsSettingsTab({
), [selectedAgent]);
const visibleAgents = useMemo<AgentProvider[]>(() => {
return ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
return ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'];
}, []);
const agentContextById = useMemo<Record<AgentProvider, AgentContext>>(() => ({
claude: {
authStatus: providerAuthStatus.claude,
onLogin: () => onProviderLogin('claude'),
onLogin: (customCommand, customTitle) => onProviderLogin('claude', customCommand, customTitle),
},
cursor: {
authStatus: providerAuthStatus.cursor,
onLogin: () => onProviderLogin('cursor'),
onLogin: (customCommand, customTitle) => onProviderLogin('cursor', customCommand, customTitle),
},
codex: {
authStatus: providerAuthStatus.codex,
onLogin: () => onProviderLogin('codex'),
onLogin: (customCommand, customTitle) => onProviderLogin('codex', customCommand, customTitle),
},
gemini: {
authStatus: providerAuthStatus.gemini,
onLogin: () => onProviderLogin('gemini'),
onLogin: (customCommand, customTitle) => onProviderLogin('gemini', customCommand, customTitle),
},
opencode: {
authStatus: providerAuthStatus.opencode,
onLogin: () => onProviderLogin('opencode'),
onLogin: (customCommand, customTitle) => onProviderLogin('opencode', customCommand, customTitle),
},
hermes: {
authStatus: providerAuthStatus.hermes,
onLogin: (customCommand, customTitle) => onProviderLogin('hermes', customCommand, customTitle),
},
}), [
onProviderLogin,
@@ -60,6 +64,7 @@ export default function AgentsSettingsTab({
providerAuthStatus.cursor,
providerAuthStatus.gemini,
providerAuthStatus.opencode,
providerAuthStatus.hermes,
]);
useEffect(() => {

View File

@@ -9,6 +9,7 @@ const AGENT_NAMES: Record<AgentProvider, string> = {
codex: 'Codex',
gemini: 'Gemini',
opencode: 'OpenCode',
hermes: 'Hermes',
};
export default function AgentSelectorSection({
@@ -25,7 +26,8 @@ export default function AgentSelectorSection({
agent === 'claude' ? 'bg-blue-500' :
agent === 'cursor' ? 'bg-purple-500' :
agent === 'gemini' ? 'bg-indigo-500' :
agent === 'opencode' ? 'bg-zinc-500' : 'bg-foreground/60';
agent === 'opencode' ? 'bg-zinc-500' :
agent === 'hermes' ? 'bg-emerald-600' : 'bg-foreground/60';
return (
<Pill

View File

@@ -1,5 +1,11 @@
import { LogIn } from 'lucide-react';
import {
CheckCircle2,
KeyRound,
Layers3,
LogIn,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge, Button } from '../../../../../../../shared/view/ui';
import SessionProviderLogo from '../../../../../../llm-logo-provider/SessionProviderLogo';
import type { AgentProvider, AuthStatus } from '../../../../../types/types';
@@ -7,7 +13,7 @@ import type { AgentProvider, AuthStatus } from '../../../../../types/types';
type AccountContentProps = {
agent: AgentProvider;
authStatus: AuthStatus;
onLogin: () => void;
onLogin: (customCommand?: string, customTitle?: string) => void;
};
type AgentVisualConfig = {
@@ -63,8 +69,59 @@ const agentConfig: Record<AgentProvider, AgentVisualConfig> = {
subtextClass: 'text-zinc-700 dark:text-zinc-300',
buttonClass: 'bg-zinc-900 hover:bg-zinc-800 active:bg-zinc-950 dark:bg-zinc-700 dark:hover:bg-zinc-600',
},
hermes: {
name: 'Hermes',
description: 'Nous Research Hermes Agent',
bgClass: 'bg-emerald-50 dark:bg-emerald-900/20',
borderClass: 'border-emerald-200 dark:border-emerald-800',
textClass: 'text-emerald-950 dark:text-emerald-100',
subtextClass: 'text-emerald-700 dark:text-emerald-300',
buttonClass: 'bg-emerald-700 hover:bg-emerald-800 active:bg-emerald-900',
},
};
type HermesAction = {
label: string;
description: string;
command: string;
title: string;
icon: typeof Layers3;
};
type HermesActionGroup = {
title: string;
actions: HermesAction[];
};
const hermesActionGroups: HermesActionGroup[] = [
{
title: 'Setup',
actions: [
{
label: 'Provider setup',
description: 'Configure provider credentials and the active model.',
command: 'hermes model',
title: 'Hermes Provider Setup',
icon: Layers3,
},
{
label: 'Credential pools',
description: 'Manage API keys and OAuth credentials.',
command: 'hermes auth',
title: 'Hermes Credential Pools',
icon: KeyRound,
},
{
label: 'ACP check',
description: 'Validate the Hermes ACP adapter.',
command: 'hermes acp --check',
title: 'Hermes ACP Check',
icon: CheckCircle2,
},
],
},
];
export default function AccountContent({ agent, authStatus, onLogin }: AccountContentProps) {
const { t } = useTranslation('settings');
const config = agentConfig[agent];
@@ -133,7 +190,7 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
</div>
</div>
<Button
onClick={onLogin}
onClick={() => onLogin()}
className={`${config.buttonClass} text-white`}
size="sm"
>
@@ -144,6 +201,43 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
</div>
)}
{agent === 'hermes' && (
<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' })}
</div>
<div className="space-y-4">
{hermesActionGroups.map((group) => (
<div key={group.title}>
<div className={`mb-2 text-xs font-semibold uppercase ${config.subtextClass}`}>
{group.title}
</div>
<div className="grid gap-2 md:grid-cols-2">
{group.actions.map((action) => {
const Icon = action.icon;
return (
<Button
key={action.command}
type="button"
variant="outline"
className="h-auto justify-start gap-3 border-border/70 bg-background/70 px-3 py-2 text-left"
onClick={() => onLogin(action.command, action.title)}
>
<Icon className="h-4 w-4 flex-shrink-0" />
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">{action.label}</span>
<span className="block text-xs text-muted-foreground">{action.description}</span>
</span>
</Button>
);
})}
</div>
</div>
))}
</div>
</div>
)}
{authStatus.error && (
<div className="border-t border-border/50 pt-4">
<div className="text-sm text-red-600 dark:text-red-400">

View File

@@ -11,7 +11,7 @@ import type {
export type AgentContext = {
authStatus: AuthStatus;
onLogin: () => void;
onLogin: (customCommand?: string, customTitle?: string) => void;
};
export type AgentContextByProvider = Record<AgentProvider, AgentContext>;
@@ -19,7 +19,7 @@ export type ProviderAuthStatusByProvider = Record<AgentProvider, AuthStatus>;
export type AgentsSettingsTabProps = {
providerAuthStatus: ProviderAuthStatusByProvider;
onProviderLogin: (provider: AgentProvider) => void;
onProviderLogin: (provider: AgentProvider, customCommand?: string, customTitle?: string) => void;
claudePermissions: ClaudePermissionsState;
onClaudePermissionsChange: (value: ClaudePermissionsState) => void;
cursorPermissions: CursorPermissionsState;

View File

@@ -5,6 +5,9 @@ import type {
ApiResponse,
ProviderSkill,
ProviderSkillCreatePayload,
ProviderSkillRegistryActionResponse,
ProviderSkillRegistryResult,
ProviderSkillRegistrySearchResponse,
ProviderSkillsResponse,
SkillsProject,
SkillsProvider,
@@ -197,6 +200,50 @@ const saveProviderSkills = async (
return (data.data.skills || []).map((skill) => normalizeSkill(provider, skill));
};
const searchProviderSkillRegistry = async (
provider: SkillsProvider,
query: string,
limit = 10,
): Promise<ProviderSkillRegistryResult[]> => {
const params = new URLSearchParams({ query, limit: String(limit) });
const response = await authenticatedFetch(`/api/providers/${provider}/skills/registry/search?${params.toString()}`);
const data = await toResponseJson<ApiResponse<ProviderSkillRegistrySearchResponse>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, 'Failed to search skill registry'));
}
return data.data.results || [];
};
const runProviderSkillRegistryAction = async (
provider: SkillsProvider,
action: 'install' | 'check' | 'update' | 'audit',
payload?: Record<string, unknown>,
): Promise<ProviderSkillRegistryActionResponse['result']> => {
const response = await authenticatedFetch(`/api/providers/${provider}/skills/registry/${action}`, {
method: 'POST',
body: payload ? JSON.stringify(payload) : undefined,
});
const data = await toResponseJson<ApiResponse<ProviderSkillRegistryActionResponse>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, `Failed to run ${action}`));
}
return data.data.result;
};
const uninstallProviderSkillRegistrySkill = async (
provider: SkillsProvider,
name: string,
): Promise<ProviderSkillRegistryActionResponse['result']> => {
const response = await authenticatedFetch(`/api/providers/${provider}/skills/registry/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
const data = await toResponseJson<ApiResponse<ProviderSkillRegistryActionResponse>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, 'Failed to uninstall skill'));
}
return data.data.result;
};
const getCacheKey = (provider: SkillsProvider, projects: ProjectTarget[]): string => {
const projectKey = projects.map((project) => project.path).sort().join('|');
return `${provider}:${projectKey}`;
@@ -221,6 +268,10 @@ export function useProviderSkills({ selectedProvider, currentProjects }: UseProv
const [isLoadingProjectScopes, setIsLoadingProjectScopes] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
const [registryResults, setRegistryResults] = useState<ProviderSkillRegistryResult[]>([]);
const [registryError, setRegistryError] = useState<string | null>(null);
const [registryStatus, setRegistryStatus] = useState<string | null>(null);
const [registryBusyKey, setRegistryBusyKey] = useState<string | null>(null);
const activeLoadIdRef = useRef(0);
const projectTargets = useMemo(() => createProjectTargets(currentProjects), [currentProjects]);
@@ -250,7 +301,10 @@ export function useProviderSkills({ selectedProvider, currentProjects }: UseProv
setIsLoadingProjectScopes(false);
setLoadError(null);
let nextSkills = cachedEntry && !options.force ? cachedEntry.skills : [];
// Build the authoritative list from the fresh fetches only. The cache still
// feeds instant display above, but seeding the merge from it would let
// skills deleted out-of-band survive the union and never get pruned.
let nextSkills: ProviderSkill[] = [];
let firstError: string | null = null;
try {
@@ -319,12 +373,86 @@ export function useProviderSkills({ selectedProvider, currentProjects }: UseProv
}
}, [refreshSkills, selectedProvider]);
const searchRegistry = useCallback(async (query: string) => {
const normalizedQuery = query.trim();
if (!normalizedQuery) {
setRegistryResults([]);
setRegistryError(null);
return;
}
setRegistryBusyKey('search');
setRegistryError(null);
setRegistryStatus(null);
try {
setRegistryResults(await searchProviderSkillRegistry(selectedProvider, normalizedQuery, 12));
} catch (error) {
setRegistryError(error instanceof Error ? error.message : 'Failed to search skill registry');
} finally {
setRegistryBusyKey((current) => (current === 'search' ? null : current));
}
}, [selectedProvider]);
const installRegistrySkill = useCallback(async (identifier: string) => {
setRegistryBusyKey(`install:${identifier}`);
setRegistryError(null);
setRegistryStatus(null);
try {
await runProviderSkillRegistryAction(selectedProvider, 'install', { identifier });
clearProviderSkillCache(selectedProvider);
await refreshSkills({ force: true });
setRegistryStatus('Skill installed.');
} catch (error) {
setRegistryError(error instanceof Error ? error.message : 'Failed to install skill');
} finally {
setRegistryBusyKey((current) => (current === `install:${identifier}` ? null : current));
}
}, [refreshSkills, selectedProvider]);
const uninstallRegistrySkill = useCallback(async (name: string) => {
setRegistryBusyKey(`uninstall:${name}`);
setRegistryError(null);
setRegistryStatus(null);
try {
await uninstallProviderSkillRegistrySkill(selectedProvider, name);
clearProviderSkillCache(selectedProvider);
await refreshSkills({ force: true });
setRegistryStatus('Skill uninstalled.');
} catch (error) {
setRegistryError(error instanceof Error ? error.message : 'Failed to uninstall skill');
} finally {
setRegistryBusyKey((current) => (current === `uninstall:${name}` ? null : current));
}
}, [refreshSkills, selectedProvider]);
const runRegistryMaintenance = useCallback(async (action: 'check' | 'update' | 'audit') => {
setRegistryBusyKey(action);
setRegistryError(null);
setRegistryStatus(null);
try {
const result = await runProviderSkillRegistryAction(selectedProvider, action);
if (action === 'update' || action === 'audit') {
clearProviderSkillCache(selectedProvider);
await refreshSkills({ force: true });
}
setRegistryStatus((result.stdout || result.stderr || `${action} completed.`).trim());
} catch (error) {
setRegistryError(error instanceof Error ? error.message : `Failed to run ${action}`);
} finally {
setRegistryBusyKey((current) => (current === action ? null : current));
}
}, [refreshSkills, selectedProvider]);
useEffect(() => {
void refreshSkills();
}, [refreshSkills]);
useEffect(() => {
setSaveStatus(null);
setRegistryResults([]);
setRegistryError(null);
setRegistryStatus(null);
setRegistryBusyKey(null);
}, [selectedProvider]);
useEffect(() => {
@@ -342,7 +470,15 @@ export function useProviderSkills({ selectedProvider, currentProjects }: UseProv
isLoadingProjectScopes,
loadError,
saveStatus,
registryResults,
registryError,
registryStatus,
registryBusyKey,
addSkills,
refreshSkills,
searchRegistry,
installRegistrySkill,
uninstallRegistrySkill,
runRegistryMaintenance,
};
}

View File

@@ -43,6 +43,28 @@ export type ProviderSkillsResponse = {
skills: Array<Partial<ProviderSkill>>;
};
export type ProviderSkillRegistryResult = {
name: string;
identifier: string;
source?: string;
trustLevel?: string;
description?: string;
};
export type ProviderSkillRegistrySearchResponse = {
provider: SkillsProvider;
results: ProviderSkillRegistryResult[];
};
export type ProviderSkillRegistryActionResponse = {
provider: SkillsProvider;
result: {
ok: boolean;
stdout: string;
stderr: string;
};
};
export type ApiSuccessResponse<T> = {
success: true;
data: T;

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import {
CheckCircle2,
Compass,
FileCode2,
FileText,
FileUp,
@@ -62,6 +63,7 @@ const PROVIDER_NAMES: Record<SkillsProvider, string> = {
cursor: 'Cursor',
gemini: 'Gemini',
opencode: 'OpenCode',
hermes: 'Hermes',
};
const PROVIDER_SKILL_PATHS: Record<Exclude<SkillsProvider, 'opencode'>, string> = {
@@ -69,8 +71,30 @@ const PROVIDER_SKILL_PATHS: Record<Exclude<SkillsProvider, 'opencode'>, string>
codex: '~/.agents/skills/<skill-name>/SKILL.md',
cursor: '~/.cursor/skills/<skill-name>/SKILL.md',
gemini: '~/.gemini/skills/<skill-name>/SKILL.md',
hermes: '~/.hermes/skills/<skill-name>/SKILL.md',
};
const HERMES_SKILL_ACTIONS = [
{
label: 'Check Updates',
description: 'Check installed hub skills.',
action: 'check' as const,
icon: RefreshCw,
},
{
label: 'Update Hub Skills',
description: 'Apply available hub updates.',
action: 'update' as const,
icon: Upload,
},
{
label: 'Audit Installed',
description: 'Re-scan installed hub skills.',
action: 'audit' as const,
icon: CheckCircle2,
},
];
const SCOPE_LABELS: Record<SkillsScope, string> = {
user: 'User',
plugin: 'Plugin',
@@ -209,13 +233,21 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
isLoadingProjectScopes,
loadError,
saveStatus,
registryResults,
registryError,
registryStatus,
registryBusyKey,
addSkills,
refreshSkills,
searchRegistry,
installRegistrySkill,
runRegistryMaintenance,
} = useProviderSkills({ selectedProvider, currentProjects });
const [queuedFiles, setQueuedFiles] = useState<QueuedSkillFile[]>([]);
const [submitError, setSubmitError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [registryQuery, setRegistryQuery] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
@@ -388,6 +420,125 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
</Button>
</div>
{selectedProvider === 'hermes' && (
<div className="rounded-lg border border-border/70 bg-muted/15 p-3">
<div className="mb-3 flex min-w-0 flex-col gap-1">
<div className="min-w-0">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<Compass className="h-4 w-4" />
Hermes Skills Hub
</div>
<div className="text-xs text-muted-foreground">
Search the Hermes registry, install skills, and keep installed hub skills current.
</div>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
value={registryQuery}
onChange={(event) => setRegistryQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
void searchRegistry(registryQuery);
}
}}
placeholder="Search Hermes skills..."
aria-label="Search Hermes skills registry"
className="h-9 w-full pl-9"
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={!registryQuery.trim() || registryBusyKey === 'search'}
onClick={() => void searchRegistry(registryQuery)}
>
{registryBusyKey === 'search' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
Search
</Button>
</div>
<div className="mt-3 grid gap-2 md:grid-cols-3">
{HERMES_SKILL_ACTIONS.map((action) => {
const Icon = action.icon;
return (
<Button
key={action.action}
type="button"
variant="outline"
className="h-auto justify-start gap-3 border-border/70 bg-background/70 px-3 py-2 text-left"
disabled={registryBusyKey === action.action}
onClick={() => void runRegistryMaintenance(action.action)}
>
{registryBusyKey === action.action
? <Loader2 className="h-4 w-4 flex-shrink-0 animate-spin" />
: <Icon className="h-4 w-4 flex-shrink-0" />}
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">{action.label}</span>
<span className="block text-xs text-muted-foreground">{action.description}</span>
</span>
</Button>
);
})}
</div>
{(registryError || registryStatus) && (
<div className={cn(
'mt-3 rounded-lg border px-3 py-2 text-xs',
registryError
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200'
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
)}>
{registryError || registryStatus}
</div>
)}
{registryResults.length > 0 && (
<div className="mt-3 grid gap-2">
{registryResults.map((result) => (
<div
key={result.identifier}
className="flex flex-col gap-3 rounded-lg border border-border/70 bg-background/70 p-3 sm:flex-row sm:items-start sm:justify-between"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-foreground">{result.name}</span>
{result.source && (
<Badge variant="outline" className="rounded-full text-[10px]">{result.source}</Badge>
)}
{result.trustLevel && (
<Badge variant="secondary" className="rounded-full text-[10px]">{result.trustLevel}</Badge>
)}
</div>
<div className="mt-1 break-all font-mono text-xs text-muted-foreground">{result.identifier}</div>
{result.description && (
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">{result.description}</div>
)}
</div>
<Button
type="button"
size="sm"
className="w-full sm:w-auto"
disabled={registryBusyKey === `install:${result.identifier}`}
onClick={() => void installRegistrySkill(result.identifier)}
>
{registryBusyKey === `install:${result.identifier}`
? <Loader2 className="h-4 w-4 animate-spin" />
: <Upload className="h-4 w-4" />}
Install
</Button>
</div>
))}
</div>
)}
</div>
)}
<Card className="min-w-0 overflow-hidden border-border/70 bg-background shadow-sm">
<CardHeader className="space-y-3 border-b border-border/60 bg-muted/20">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">

View File

@@ -1,4 +1,4 @@
export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode';
export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode' | 'hermes';
export type ProviderModelOption = {
value: string;