From 048c671b13bf405a82ce2971b7ab085016539d4d Mon Sep 17 00:00:00 2001 From: Simos Mikelatos Date: Tue, 30 Jun 2026 09:51:18 +0000 Subject: [PATCH] feat: add Hermes provider --- .env.example | 4 +- public/api-docs.html | 3 +- server/claude-sdk.js | 49 +-- server/hermes-cli.js | 398 ++++++++++++++++++ server/hermes/acp-client.js | 279 ++++++++++++ server/index.js | 6 + .../list/hermes/hermes-auth.provider.ts | 135 ++++++ .../list/hermes/hermes-mcp.provider.ts | 296 +++++++++++++ .../list/hermes/hermes-models.provider.ts | 152 +++++++ .../hermes-session-synchronizer.provider.ts | 110 +++++ .../list/hermes/hermes-sessions.provider.ts | 307 ++++++++++++++ .../list/hermes/hermes-skills.provider.ts | 181 ++++++++ .../providers/list/hermes/hermes.provider.ts | 27 ++ server/modules/providers/provider.registry.ts | 2 + server/modules/providers/provider.routes.ts | 114 +++++ .../services/provider-capabilities.service.ts | 9 + .../services/session-synchronizer.service.ts | 1 + .../services/sessions-watcher.service.ts | 8 + .../providers/services/skills.service.ts | 65 ++- server/modules/providers/tests/mcp.test.ts | 8 +- server/routes/agent.js | 20 +- server/routes/commands.js | 3 +- server/shared/interfaces.ts | 19 + server/shared/tool-approval-registry.js | 83 ++++ server/shared/types.ts | 28 +- .../chat/hooks/useChatComposerState.ts | 10 + .../chat/hooks/useChatProviderState.ts | 34 +- src/components/chat/view/ChatInterface.tsx | 13 +- .../view/subcomponents/ChatMessagesPane.tsx | 6 + .../view/subcomponents/CommandResultModal.tsx | 1 + .../view/subcomponents/MessageComponent.tsx | 3 +- .../ProviderSelectionEmptyState.tsx | 18 +- .../llm-logo-provider/HermesLogo.tsx | 16 + .../llm-logo-provider/SessionProviderLogo.tsx | 5 + src/components/mcp/constants.ts | 5 + src/components/provider-auth/types.ts | 4 +- .../provider-auth/view/ProviderLoginModal.tsx | 9 +- .../settings/constants/constants.ts | 2 +- .../settings/hooks/useSettingsController.ts | 8 +- src/components/settings/view/Settings.tsx | 4 + .../tabs/agents-settings/AgentListItem.tsx | 9 +- .../agents-settings/AgentsSettingsTab.tsx | 17 +- .../sections/AgentSelectorSection.tsx | 4 +- .../sections/content/AccountContent.tsx | 100 ++++- .../view/tabs/agents-settings/types.ts | 4 +- .../skills/hooks/useProviderSkills.ts | 138 +++++- src/components/skills/types.ts | 22 + src/components/skills/view/ProviderSkills.tsx | 151 +++++++ src/types/app.ts | 2 +- 49 files changed, 2816 insertions(+), 76 deletions(-) create mode 100644 server/hermes-cli.js create mode 100644 server/hermes/acp-client.js create mode 100644 server/modules/providers/list/hermes/hermes-auth.provider.ts create mode 100644 server/modules/providers/list/hermes/hermes-mcp.provider.ts create mode 100644 server/modules/providers/list/hermes/hermes-models.provider.ts create mode 100644 server/modules/providers/list/hermes/hermes-session-synchronizer.provider.ts create mode 100644 server/modules/providers/list/hermes/hermes-sessions.provider.ts create mode 100644 server/modules/providers/list/hermes/hermes-skills.provider.ts create mode 100644 server/modules/providers/list/hermes/hermes.provider.ts create mode 100644 server/shared/tool-approval-registry.js create mode 100644 src/components/llm-logo-provider/HermesLogo.tsx diff --git a/.env.example b/.env.example index 7e1d124c..c717e049 100755 --- a/.env.example +++ b/.env.example @@ -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 - diff --git a/public/api-docs.html b/public/api-docs.html index 03dbb9b8..997cdbda 100644 --- a/public/api-docs.html +++ b/public/api-docs.html @@ -524,7 +524,7 @@ provider string Optional - claude, cursor, or codex (default: claude) + claude, cursor, codex, gemini, opencode, or hermes (default: claude) stream @@ -834,6 +834,7 @@ data: {"type":"done"} { id: 'gemini', name: 'Google' }, { id: 'cursor', name: 'Cursor' }, { id: 'opencode', name: 'OpenCode' }, + { id: 'hermes', name: 'Nous Research' }, ]; async function populateModels() { diff --git a/server/claude-sdk.js b/server/claude-sdk.js index a0a795c6..489882e8 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -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. diff --git a/server/hermes-cli.js b/server/hermes-cli.js new file mode 100644 index 00000000..1c6f2730 --- /dev/null +++ b/server/hermes-cli.js @@ -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, +}; diff --git a/server/hermes/acp-client.js b/server/hermes/acp-client.js new file mode 100644 index 00000000..2e536f92 --- /dev/null +++ b/server/hermes/acp-client.js @@ -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, +}; diff --git a/server/index.js b/server/index.js index 679645a9..927a2073 100755 --- a/server/index.js +++ b/server/index.js @@ -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, diff --git a/server/modules/providers/list/hermes/hermes-auth.provider.ts b/server/modules/providers/list/hermes/hermes-auth.provider.ts new file mode 100644 index 00000000..ed3b1ed3 --- /dev/null +++ b/server/modules/providers/list/hermes/hermes-auth.provider.ts @@ -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 { + 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 { + const parsed: Record = {}; + 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): 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())); + } +} diff --git a/server/modules/providers/list/hermes/hermes-mcp.provider.ts b/server/modules/providers/list/hermes/hermes-mcp.provider.ts new file mode 100644 index 00000000..dbaed52f --- /dev/null +++ b/server/modules/providers/list/hermes/hermes-mcp.provider.ts @@ -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; nextIndex: number } => { + const value: Record = {}; + 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 => { + 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> => { + 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, 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 => { + 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): Promise => { + 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> { + 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, + ): Promise { + 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 { + 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; + } +} diff --git a/server/modules/providers/list/hermes/hermes-models.provider.ts b/server/modules/providers/list/hermes/hermes-models.provider.ts new file mode 100644 index 00000000..89b21c45 --- /dev/null +++ b/server/modules/providers/list/hermes/hermes-models.provider.ts @@ -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 { + 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 { + const configured = await this.readConfiguredModel(); + if (configured) { + return { model: configured }; + } + + return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels()); + } + + async changeActiveModel(input: ProviderChangeActiveModelInput): Promise { + 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 { + try { + const content = await readFile(HERMES_CONFIG_PATH, 'utf8'); + return readOptionalString(readYamlPath(content, ['model', 'default'])) + ?? readOptionalString(readYamlPath(content, ['model'])) + ?? null; + } catch { + return null; + } + } +} diff --git a/server/modules/providers/list/hermes/hermes-session-synchronizer.provider.ts b/server/modules/providers/list/hermes/hermes-session-synchronizer.provider.ts new file mode 100644 index 00000000..d3d52315 --- /dev/null +++ b/server/modules/providers/list/hermes/hermes-session-synchronizer.provider.ts @@ -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 { + 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 { + 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; + } +} diff --git a/server/modules/providers/list/hermes/hermes-sessions.provider.ts b/server/modules/providers/list/hermes/hermes-sessions.provider.ts new file mode 100644 index 00000000..9d4cd0cc --- /dev/null +++ b/server/modules/providers/list/hermes/hermes-sessions.provider.ts @@ -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 { + 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, + }; + } +} diff --git a/server/modules/providers/list/hermes/hermes-skills.provider.ts b/server/modules/providers/list/hermes/hermes-skills.provider.ts new file mode 100644 index 00000000..9fe14fd6 --- /dev/null +++ b/server/modules/providers/list/hermes/hermes-skills.provider.ts @@ -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 { + 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 { + 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 { + 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 { + return this.runHermes(['skills', 'check']); + } + + async updateRegistrySkills(): Promise { + return this.runHermes(['skills', 'update']); + } + + async auditRegistrySkills(): Promise { + return this.runHermes(['skills', 'audit']); + } + + protected async getSkillSources(workspacePath: string): Promise { + const sources: ProviderSkillSource[] = []; + const seenRootDirs = new Set(); + + 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 { + return { + scope: 'user', + rootDir: path.join(os.homedir(), '.hermes', 'skills'), + commandPrefix: '/', + recursive: true, + }; + } + + private async runHermes(args: string[]): Promise { + 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, + }, + }); + } + } +} diff --git a/server/modules/providers/list/hermes/hermes.provider.ts b/server/modules/providers/list/hermes/hermes.provider.ts new file mode 100644 index 00000000..83224012 --- /dev/null +++ b/server/modules/providers/list/hermes/hermes.provider.ts @@ -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'); + } +} diff --git a/server/modules/providers/provider.registry.ts b/server/modules/providers/provider.registry.ts index a9f0d26b..9943e1eb 100644 --- a/server/modules/providers/provider.registry.ts +++ b/server/modules/providers/provider.registry.ts @@ -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 = { cursor: new CursorProvider(), gemini: new GeminiProvider(), opencode: new OpenCodeProvider(), + hermes: new HermesProvider(), }; /** diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index 7fa7947e..5a60b303 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -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; + 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', diff --git a/server/modules/providers/services/provider-capabilities.service.ts b/server/modules/providers/services/provider-capabilities.service.ts index 1b7cbbb3..ef89d915 100644 --- a/server/modules/providers/services/provider-capabilities.service.ts +++ b/server/modules/providers/services/provider-capabilities.service.ts @@ -75,6 +75,15 @@ const PROVIDER_CAPABILITIES: Record = { supportsPermissionRequests: false, supportsTokenUsage: true, }, + hermes: { + provider: 'hermes', + permissionModes: ['default'], + defaultPermissionMode: 'default', + supportsImages: false, + supportsAbort: true, + supportsPermissionRequests: true, + supportsTokenUsage: false, + }, }; /** diff --git a/server/modules/providers/services/session-synchronizer.service.ts b/server/modules/providers/services/session-synchronizer.service.ts index 55b41f9e..928fe095 100644 --- a/server/modules/providers/services/session-synchronizer.service.ts +++ b/server/modules/providers/services/session-synchronizer.service.ts @@ -23,6 +23,7 @@ export const sessionSynchronizerService = { cursor: 0, gemini: 0, opencode: 0, + hermes: 0, }; const failures: string[] = []; diff --git a/server/modules/providers/services/sessions-watcher.service.ts b/server/modules/providers/services/sessions-watcher.service.ts index cfbdb887..f0e8ea2f 100644 --- a/server/modules/providers/services/sessions-watcher.service.ts +++ b/server/modules/providers/services/sessions-watcher.service.ts @@ -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'); } diff --git a/server/modules/providers/services/skills.service.ts b/server/modules/providers/services/skills.service.ts index bbb2c7da..8c41a573 100644 --- a/server/modules/providers/services/skills.service.ts +++ b/server/modules/providers/services/skills.service.ts @@ -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 = >( + providerName: string, + methodName: TMethod, +): NonNullable[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[TMethod]>; +}; export const providerSkillsService = { /** @@ -14,8 +36,7 @@ export const providerSkillsService = { providerName: string, options?: ProviderSkillListOptions, ): Promise { - 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 { - const provider = providerRegistry.resolveProvider(providerName); - return provider.skills.addSkills(input); + return getProviderSkills(providerName).addSkills(input); + }, + + async searchSkillRegistry( + providerName: string, + query: string, + options?: ProviderSkillRegistrySearchOptions, + ): Promise { + const searchRegistry = requireSkillRegistryMethod(providerName, 'searchRegistry'); + return searchRegistry.call(getProviderSkills(providerName), query, options); + }, + + async installRegistrySkill( + providerName: string, + input: ProviderSkillRegistryInstallInput, + ): Promise { + const installRegistrySkill = requireSkillRegistryMethod(providerName, 'installRegistrySkill'); + return installRegistrySkill.call(getProviderSkills(providerName), input); + }, + + async uninstallRegistrySkill(providerName: string, name: string): Promise { + const uninstallRegistrySkill = requireSkillRegistryMethod(providerName, 'uninstallRegistrySkill'); + return uninstallRegistrySkill.call(getProviderSkills(providerName), name); + }, + + async checkRegistryUpdates(providerName: string): Promise { + const checkRegistryUpdates = requireSkillRegistryMethod(providerName, 'checkRegistryUpdates'); + return checkRegistryUpdates.call(getProviderSkills(providerName)); + }, + + async updateRegistrySkills(providerName: string): Promise { + const updateRegistrySkills = requireSkillRegistryMethod(providerName, 'updateRegistrySkills'); + return updateRegistrySkills.call(getProviderSkills(providerName)); + }, + + async auditRegistrySkills(providerName: string): Promise { + const auditRegistrySkills = requireSkillRegistryMethod(providerName, 'auditRegistrySkills'); + return auditRegistrySkills.call(getProviderSkills(providerName)); }, async removeProviderSkill( diff --git a/server/modules/providers/tests/mcp.test.ts b/server/modules/providers/tests/mcp.test.ts index f10b1354..cff6c02d 100644 --- a/server/modules/providers/tests/mcp.test.ts +++ b/server/modules/providers/tests/mcp.test.ts @@ -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)['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)['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 }); } }); - diff --git a/server/routes/agent.js b/server/routes/agent.js index f2273181..af09bc47 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -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 diff --git a/server/routes/commands.js b/server/routes/commands.js index ea223f12..c24e24da 100644 --- a/server/routes/commands.js +++ b/server/routes/commands.js @@ -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) => { diff --git a/server/shared/interfaces.ts b/server/shared/interfaces.ts index 86f36844..8692a619 100644 --- a/server/shared/interfaces.ts +++ b/server/shared/interfaces.ts @@ -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; + + installRegistrySkill?(input: ProviderSkillRegistryInstallInput): Promise; + + uninstallRegistrySkill?(name: string): Promise; + + checkRegistryUpdates?(): Promise; + + updateRegistrySkills?(): Promise; + + auditRegistrySkills?(): Promise; } // --------------------------- diff --git a/server/shared/tool-approval-registry.js b/server/shared/tool-approval-registry.js new file mode 100644 index 00000000..0b7433f6 --- /dev/null +++ b/server/shared/tool-approval-registry.js @@ -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, +}; diff --git a/server/shared/types.ts b/server/shared/types.ts index 5d411efe..8d95df55 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -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. * diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 9817b6d4..74466e83 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -39,6 +39,7 @@ interface UseChatComposerStateArgs { codexModel: string; geminiModel: string; opencodeModel: string; + hermesModel: string; isLoading: boolean; canAbortSession: boolean; tokenBudget: Record | 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, diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index ea49d841..9233a5a1 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -15,6 +15,7 @@ const FALLBACK_DEFAULT_MODEL: Record = { 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 = { 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(() => { return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode; }); + const [hermesModel, setHermesModel] = useState(() => { + 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, diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index b466c6ba..b01e056a 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -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 (
@@ -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} diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index bb61096a..341970cd 100644 --- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx +++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx @@ -39,6 +39,8 @@ interface ChatMessagesPaneProps { setGeminiModel: (model: string) => void; opencodeModel: string; setOpenCodeModel: (model: string) => void; + hermesModel: string; + setHermesModel: (model: string) => void; providerModelCatalog: Partial>; 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} diff --git a/src/components/chat/view/subcomponents/CommandResultModal.tsx b/src/components/chat/view/subcomponents/CommandResultModal.tsx index d80a97d6..4a629d79 100644 --- a/src/components/chat/view/subcomponents/CommandResultModal.tsx +++ b/src/components/chat/view/subcomponents/CommandResultModal.tsx @@ -63,6 +63,7 @@ const PROVIDER_LABELS: Record = { codex: 'Codex', gemini: 'Gemini', opencode: 'OpenCode', + hermes: 'Hermes', }; const FALLBACK_COMMANDS: CommandEntry[] = [ diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx index e9615a85..aadb992f 100644 --- a/src/components/chat/view/subcomponents/MessageComponent.tsx +++ b/src/components/chat/view/subcomponents/MessageComponent.tsx @@ -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'))}
@@ -430,4 +432,3 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a }); export default MessageComponent; - diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx index 6d97ca88..3325b0cf 100644 --- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx +++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx @@ -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>; 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] }

diff --git a/src/components/llm-logo-provider/HermesLogo.tsx b/src/components/llm-logo-provider/HermesLogo.tsx new file mode 100644 index 00000000..49c7598f --- /dev/null +++ b/src/components/llm-logo-provider/HermesLogo.tsx @@ -0,0 +1,16 @@ +type HermesLogoProps = { + className?: string; +}; + +export default function HermesLogo({ className = 'w-5 h-5' }: HermesLogoProps) { + return ( + + + + + + ); +} diff --git a/src/components/llm-logo-provider/SessionProviderLogo.tsx b/src/components/llm-logo-provider/SessionProviderLogo.tsx index e29ecd6d..348eaca4 100644 --- a/src/components/llm-logo-provider/SessionProviderLogo.tsx +++ b/src/components/llm-logo-provider/SessionProviderLogo.tsx @@ -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 ; } + if (provider === 'hermes') { + return ; + } + return ; } diff --git a/src/components/mcp/constants.ts b/src/components/mcp/constants.ts index ab6396ff..c2ff79c0 100644 --- a/src/components/mcp/constants.ts +++ b/src/components/mcp/constants.ts @@ -6,6 +6,7 @@ export const MCP_PROVIDER_NAMES: Record = { codex: 'Codex', gemini: 'Gemini', opencode: 'OpenCode', + hermes: 'Hermes', }; export const MCP_SUPPORTED_SCOPES: Record = { @@ -14,6 +15,7 @@ export const MCP_SUPPORTED_SCOPES: Record = { codex: ['user', 'project'], gemini: ['user', 'project'], opencode: ['user', 'project'], + hermes: ['user', 'project'], }; export const MCP_SUPPORTED_TRANSPORTS: Record = { @@ -22,6 +24,7 @@ export const MCP_SUPPORTED_TRANSPORTS: Record = { 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 = { 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 = { @@ -42,6 +46,7 @@ export const MCP_SUPPORTS_WORKING_DIRECTORY: Record = { codex: true, gemini: true, opencode: false, + hermes: false, }; export const DEFAULT_MCP_FORM: McpFormState = { diff --git a/src/components/provider-auth/types.ts b/src/components/provider-auth/types.ts index afa08094..178efdcf 100644 --- a/src/components/provider-auth/types.ts +++ b/src/components/provider-auth/types.ts @@ -10,7 +10,7 @@ export type ProviderAuthStatus = { export type ProviderAuthStatusMap = Record; -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 = { claude: '/api/providers/claude/auth/status', @@ -18,6 +18,7 @@ export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record = { 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 }, }); diff --git a/src/components/provider-auth/view/ProviderLoginModal.tsx b/src/components/provider-auth/view/ProviderLoginModal.tsx index 9de1d227..4127f188 100644 --- a/src/components/provider-auth/view/ProviderLoginModal.tsx +++ b/src/components/provider-auth/view/ProviderLoginModal.tsx @@ -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); diff --git a/src/components/settings/constants/constants.ts b/src/components/settings/constants/constants.ts index 8e5bfebe..a073374f 100644 --- a/src/components/settings/constants/constants.ts +++ b/src/components/settings/constants/constants.ts @@ -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'; diff --git a/src/components/settings/hooks/useSettingsController.ts b/src/components/settings/hooks/useSettingsController.ts index abf62cec..fa3b9f14 100644 --- a/src/components/settings/hooks/useSettingsController.ts +++ b/src/components/settings/hooks/useSettingsController.ts @@ -164,6 +164,8 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl const [showLoginModal, setShowLoginModal] = useState(false); const [loginProvider, setLoginProvider] = useState(''); + const [loginCommand, setLoginCommand] = useState(undefined); + const [loginTitle, setLoginTitle] = useState(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, }; } diff --git a/src/components/settings/view/Settings.tsx b/src/components/settings/view/Settings.tsx index 96eaa0c6..0e5a7635 100644 --- a/src/components/settings/view/Settings.tsx +++ b/src/components/settings/view/Settings.tsx @@ -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} /> diff --git a/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx b/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx index b23784ee..fd524498 100644 --- a/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx +++ b/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx @@ -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 = { @@ -36,6 +36,10 @@ const agentConfig: Record = { 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({ diff --git a/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx b/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx index a5221f83..19ce5181 100644 --- a/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx +++ b/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx @@ -29,29 +29,33 @@ export default function AgentsSettingsTab({ ), [selectedAgent]); const visibleAgents = useMemo(() => { - return ['claude', 'cursor', 'codex', 'gemini', 'opencode']; + return ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes']; }, []); const agentContextById = useMemo>(() => ({ 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(() => { diff --git a/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx b/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx index a6d017fa..356d66f6 100644 --- a/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx +++ b/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx @@ -9,6 +9,7 @@ const AGENT_NAMES: Record = { 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 ( void; + onLogin: (customCommand?: string, customTitle?: string) => void; }; type AgentVisualConfig = { @@ -63,8 +69,59 @@ const agentConfig: Record = { 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 + ); + })} + + + ))} + + + )} + {authStatus.error && (
diff --git a/src/components/settings/view/tabs/agents-settings/types.ts b/src/components/settings/view/tabs/agents-settings/types.ts index 731ce8d3..a63a841f 100644 --- a/src/components/settings/view/tabs/agents-settings/types.ts +++ b/src/components/settings/view/tabs/agents-settings/types.ts @@ -11,7 +11,7 @@ import type { export type AgentContext = { authStatus: AuthStatus; - onLogin: () => void; + onLogin: (customCommand?: string, customTitle?: string) => void; }; export type AgentContextByProvider = Record; @@ -19,7 +19,7 @@ export type ProviderAuthStatusByProvider = Record; 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; diff --git a/src/components/skills/hooks/useProviderSkills.ts b/src/components/skills/hooks/useProviderSkills.ts index 4655acd5..8db2a0d9 100644 --- a/src/components/skills/hooks/useProviderSkills.ts +++ b/src/components/skills/hooks/useProviderSkills.ts @@ -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 => { + const params = new URLSearchParams({ query, limit: String(limit) }); + const response = await authenticatedFetch(`/api/providers/${provider}/skills/registry/search?${params.toString()}`); + const data = await toResponseJson>(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, +): Promise => { + const response = await authenticatedFetch(`/api/providers/${provider}/skills/registry/${action}`, { + method: 'POST', + body: payload ? JSON.stringify(payload) : undefined, + }); + const data = await toResponseJson>(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 => { + const response = await authenticatedFetch(`/api/providers/${provider}/skills/registry/${encodeURIComponent(name)}`, { + method: 'DELETE', + }); + const data = await toResponseJson>(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(null); const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null); + const [registryResults, setRegistryResults] = useState([]); + const [registryError, setRegistryError] = useState(null); + const [registryStatus, setRegistryStatus] = useState(null); + const [registryBusyKey, setRegistryBusyKey] = useState(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, }; } diff --git a/src/components/skills/types.ts b/src/components/skills/types.ts index cbebe582..6a92fcab 100644 --- a/src/components/skills/types.ts +++ b/src/components/skills/types.ts @@ -43,6 +43,28 @@ export type ProviderSkillsResponse = { skills: Array>; }; +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 = { success: true; data: T; diff --git a/src/components/skills/view/ProviderSkills.tsx b/src/components/skills/view/ProviderSkills.tsx index 186b6d35..05ad4628 100644 --- a/src/components/skills/view/ProviderSkills.tsx +++ b/src/components/skills/view/ProviderSkills.tsx @@ -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 = { cursor: 'Cursor', gemini: 'Gemini', opencode: 'OpenCode', + hermes: 'Hermes', }; const PROVIDER_SKILL_PATHS: Record, string> = { @@ -69,8 +71,30 @@ const PROVIDER_SKILL_PATHS: Record, string> codex: '~/.agents/skills//SKILL.md', cursor: '~/.cursor/skills//SKILL.md', gemini: '~/.gemini/skills//SKILL.md', + hermes: '~/.hermes/skills//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 = { 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([]); const [submitError, setSubmitError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const [searchQuery, setSearchQuery] = useState(''); + const [registryQuery, setRegistryQuery] = useState(''); const fileInputRef = useRef(null); const folderInputRef = useRef(null); @@ -388,6 +420,125 @@ export default function ProviderSkills({ selectedProvider, currentProjects }: Pr
+ {selectedProvider === 'hermes' && ( +
+
+
+
+ + Hermes Skills Hub +
+
+ Search the Hermes registry, install skills, and keep installed hub skills current. +
+
+
+
+
+ + 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" + /> +
+ +
+ +
+ {HERMES_SKILL_ACTIONS.map((action) => { + const Icon = action.icon; + return ( + + ); + })} +
+ + {(registryError || registryStatus) && ( +
+ {registryError || registryStatus} +
+ )} + + {registryResults.length > 0 && ( +
+ {registryResults.map((result) => ( +
+
+
+ {result.name} + {result.source && ( + {result.source} + )} + {result.trustLevel && ( + {result.trustLevel} + )} +
+
{result.identifier}
+ {result.description && ( +
{result.description}
+ )} +
+ +
+ ))} +
+ )} +
+ )} +
diff --git a/src/types/app.ts b/src/types/app.ts index f81c3e26..1d629ddc 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -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;