Files
claudecodeui/server/hermes/acp-client.js
2026-06-30 09:51:18 +00:00

280 lines
7.3 KiB
JavaScript

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