UNPKG

@mrtkrcm/acp-claude-code

Version:

ACP (Agent Client Protocol) bridge for Claude Code

356 lines 13.9 kB
import { z } from 'zod'; import { EOL } from 'node:os'; import { promises as fs } from 'node:fs'; import * as path from 'node:path'; import * as schema from './schema.js'; // Enhanced error handling with specific error codes export class RequestError extends Error { code; data; constructor(code, message, details) { super(message); this.code = code; this.name = 'RequestError'; if (details) { this.data = { details }; } } static parseError(details) { return new RequestError(-32700, 'Parse error', details); } static invalidRequest(details) { return new RequestError(-32600, 'Invalid request', details); } static methodNotFound(method) { return new RequestError(-32601, `Method not found: ${method || 'unknown'}`, method); } static invalidParams(details) { return new RequestError(-32602, 'Invalid params', details); } static internalError(details) { return new RequestError(-32603, 'Internal error', details); } static authRequired(details) { return new RequestError(-32000, 'Authentication required', details); } static sessionNotFound(sessionId) { return new RequestError(-32001, `Session not found: ${sessionId || 'unknown'}`, sessionId); } static sessionBusy(sessionId) { return new RequestError(-32002, `Session busy: ${sessionId || 'unknown'}`, sessionId); } static resourceExhausted(details) { return new RequestError(-32003, 'Resource exhausted', details); } toResult() { return { error: { code: this.code, message: this.message, data: this.data, }, }; } } // Robust connection class with proper message queuing and error handling class Connection { #pendingResponses = new Map(); #nextRequestId = 0; #handler; #peerInput; #writeQueue = Promise.resolve(); #textEncoder; #isDestroyed = false; constructor(handler, peerInput, peerOutput) { this.#handler = handler; this.#peerInput = peerInput; this.#textEncoder = new TextEncoder(); this.#receive(peerOutput); } async #receive(output) { let content = ''; const decoder = new TextDecoder(); try { for await (const chunk of output) { if (this.#isDestroyed) break; content += decoder.decode(chunk, { stream: true }); const lines = content.split(EOL); content = lines.pop() || ''; for (const line of lines) { const trimmedLine = line.trim(); if (trimmedLine) { try { const message = JSON.parse(trimmedLine); await this.#processMessage(message); } catch (error) { // Send parse error response await this.#sendParseError(error); } } } } } catch (error) { console.error('ACP connection receive error:', error); } } async #processMessage(message) { try { if ('method' in message && 'id' in message) { // It's a request - handle with validation const response = await this.#tryCallHandler(message.method, message.params); await this.#sendMessage({ jsonrpc: '2.0', id: message.id, ...response, }); } else if ('method' in message) { // It's a notification - no response needed await this.#tryCallHandler(message.method, message.params); } else if ('id' in message) { // It's a response - resolve pending request this.#handleResponse(message); } } catch (error) { console.error('ACP message processing error:', error); } } async #tryCallHandler(method, params) { try { const result = await this.#handler(method, params); return { result: result ?? null }; } catch (error) { if (error instanceof RequestError) { return error.toResult(); } if (error instanceof z.ZodError) { return RequestError.invalidParams(JSON.stringify(error.format(), undefined, 2)).toResult(); } let details; if (error instanceof Error) { details = error.message; } else if (typeof error === 'object' && error != null && 'message' in error && typeof error.message === 'string') { details = error.message; } return RequestError.internalError(details).toResult(); } } #handleResponse(response) { const pendingResponse = this.#pendingResponses.get(response.id); if (pendingResponse) { if ('result' in response) { pendingResponse.resolve(response.result); } else if ('error' in response) { pendingResponse.reject(response.error); } this.#pendingResponses.delete(response.id); } } async #sendParseError(error) { const errorMessage = error instanceof Error ? error.message : String(error); await this.#sendMessage({ jsonrpc: '2.0', id: "parse-error", error: { code: -32700, message: 'Parse error', data: errorMessage, }, }); } async sendRequest(method, params) { if (this.#isDestroyed) { throw new Error('Connection is destroyed'); } const id = this.#nextRequestId++; const responsePromise = new Promise((resolve, reject) => { this.#pendingResponses.set(id, { resolve, reject }); }); await this.#sendMessage({ jsonrpc: '2.0', id, method, params }); return responsePromise; } async sendNotification(method, params) { if (this.#isDestroyed) { return; } await this.#sendMessage({ jsonrpc: '2.0', method, params }); } async #sendMessage(json) { const content = JSON.stringify(json) + '\n'; this.#writeQueue = this.#writeQueue .then(async () => { if (this.#isDestroyed) return; const writer = this.#peerInput.getWriter(); try { await writer.write(this.#textEncoder.encode(content)); } finally { writer.releaseLock(); } }) .catch((error) => { console.error('ACP write error:', error); // Don't rethrow - continue processing }); return this.#writeQueue; } destroy() { this.#isDestroyed = true; // Reject all pending responses for (const [id, pending] of this.#pendingResponses) { pending.reject({ code: -32003, message: 'Connection destroyed', data: { requestId: id }, }); } this.#pendingResponses.clear(); } } // Enhanced Agent Side Connection with file system operations export class AgentSideConnection { #connection; #fileSystemEnabled; constructor(toAgent, input, output, options = {}) { this.#fileSystemEnabled = options.fileSystemEnabled ?? false; const agent = toAgent(this); const handler = async (method, params) => { switch (method) { case schema.AGENT_METHODS.initialize: { const validatedParams = schema.initializeRequestSchema.parse(params); return agent.initialize(validatedParams); } case schema.AGENT_METHODS.session_new: { const validatedParams = schema.newSessionRequestSchema.parse(params); return agent.newSession(validatedParams); } case schema.AGENT_METHODS.session_load: { if (!agent.loadSession) { throw RequestError.methodNotFound(method); } const validatedParams = schema.loadSessionRequestSchema.parse(params); return agent.loadSession(validatedParams); } case schema.AGENT_METHODS.authenticate: { const validatedParams = schema.authenticateRequestSchema.parse(params); return agent.authenticate(validatedParams); } case schema.AGENT_METHODS.session_prompt: { const validatedParams = schema.promptRequestSchema.parse(params); return agent.prompt(validatedParams); } case schema.AGENT_METHODS.session_cancel: { const validatedParams = schema.cancelNotificationSchema.parse(params); return agent.cancel(validatedParams); } // Handle file system operations if enabled case schema.CLIENT_METHODS.fs_read_text_file: { if (!this.#fileSystemEnabled) { throw RequestError.methodNotFound(method); } const validatedParams = schema.readTextFileRequestSchema.parse(params); return await this.#handleReadTextFile(validatedParams); } case schema.CLIENT_METHODS.fs_write_text_file: { if (!this.#fileSystemEnabled) { throw RequestError.methodNotFound(method); } const validatedParams = schema.writeTextFileRequestSchema.parse(params); return await this.#handleWriteTextFile(validatedParams); } default: throw RequestError.methodNotFound(method); } }; this.#connection = new Connection(handler, input, output); } destroy() { this.#connection.destroy(); } // Client interface methods async sessionUpdate(params) { return await this.#connection.sendNotification(schema.CLIENT_METHODS.session_update, params); } async requestPermission(params) { return await this.#connection.sendRequest(schema.CLIENT_METHODS.session_request_permission, params); } async readTextFile(params) { return await this.#connection.sendRequest(schema.CLIENT_METHODS.fs_read_text_file, params); } async writeTextFile(params) { return await this.#connection.sendRequest(schema.CLIENT_METHODS.fs_write_text_file, params); } // File system operation handlers async #handleReadTextFile(params) { try { // Security: Ensure path is within allowed boundaries const resolvedPath = path.resolve(params.path); const cwd = process.cwd(); // Prevent reading files outside the current working directory if (!resolvedPath.startsWith(cwd)) { throw RequestError.internalError('Access denied: file outside workspace'); } const content = await fs.readFile(resolvedPath, 'utf-8'); // Apply limit if specified let processedContent = content; if (params.limit !== null && params.limit !== undefined && params.limit > 0) { const lines = content.split('\n'); processedContent = lines.slice(0, params.limit).join('\n'); if (lines.length > params.limit) { processedContent += '\n... (truncated)'; } } // Apply line offset if specified if (params.line !== null && params.line !== undefined && params.line > 0) { const lines = processedContent.split('\n'); const startLine = Math.max(0, params.line - 1); // Convert to 0-based index processedContent = lines.slice(startLine).join('\n'); } return { content: processedContent }; } catch (error) { if (error instanceof RequestError) { throw error; } if (error.code === 'ENOENT') { throw RequestError.internalError(`File not found: ${params.path}`); } throw RequestError.internalError(`Failed to read file: ${error.message}`); } } async #handleWriteTextFile(params) { try { // Security: Ensure path is within allowed boundaries const resolvedPath = path.resolve(params.path); const cwd = process.cwd(); // Prevent writing files outside the current working directory if (!resolvedPath.startsWith(cwd)) { throw RequestError.internalError('Access denied: file outside workspace'); } // Ensure directory exists const dir = path.dirname(resolvedPath); await fs.mkdir(dir, { recursive: true }); await fs.writeFile(resolvedPath, params.content, 'utf-8'); return null; // Success response } catch (error) { if (error instanceof RequestError) { throw error; } throw RequestError.internalError(`Failed to write file: ${error.message}`); } } } //# sourceMappingURL=protocol.js.map