/** * Minimal ACP (Agent Client Protocol) JSON-RPC client over stdio. * Newline-delimited JSON-RPC 2.0 per ACP transport spec. */ export class AcpClient { /** * @param {object} opts * @param {import("node:stream").Writable} opts.stdin * @param {import("node:stream").Readable} opts.stdout * @param {(msg: string) => void} [opts.log] */ constructor({ stdin, stdout, log, requestTimeoutMs }) { this.stdin = stdin; this.stdout = stdout; this.log = log || (() => {}); this.requestTimeoutMs = requestTimeoutMs ?? parseInt(process.env.ACP_REQUEST_TIMEOUT_MS || "300000", 10); this._buf = ""; this._nextId = 1; /** @type {Map} */ this._pending = new Map(); /** @type {(update: object) => void} */ this.onSessionUpdate = null; /** * Handle incoming JSON-RPC requests from the agent (permissions, cursor extensions). * @type {(method: string, params: object) => Promise} */ this.onIncomingRequest = null; this._closed = false; this.agentCapabilities = null; stdout.setEncoding("utf8"); stdout.on("data", (chunk) => this._onData(chunk)); } _send(obj) { if (this._closed || this.stdin.destroyed) return; this.stdin.write(`${JSON.stringify(obj)}\n`); } _onData(chunk) { this._buf += chunk; let nl; while ((nl = this._buf.indexOf("\n")) >= 0) { const line = this._buf.slice(0, nl).trim(); this._buf = this._buf.slice(nl + 1); if (!line) continue; let msg; try { msg = JSON.parse(line); } catch (e) { this.log(`[acp] parse error: ${e.message}`); continue; } this._dispatch(msg).catch((e) => this.log(`[acp] dispatch error: ${e.message}`)); } } async _dispatch(msg) { if (msg.method === "session/update") { this.onSessionUpdate?.(msg.params || {}); return; } if (msg.method && msg.id != null && !Object.prototype.hasOwnProperty.call(msg, "result") && !msg.error) { const handler = this.onIncomingRequest; if (!handler) { this._send({ jsonrpc: "2.0", id: msg.id, error: { code: -32603, message: "no incoming request handler" }, }); return; } try { const result = await handler(msg.method, msg.params || {}); this._send({ jsonrpc: "2.0", id: msg.id, result: result ?? {} }); } catch (e) { this._send({ jsonrpc: "2.0", id: msg.id, error: { code: -32000, message: e.message || "handler failed" }, }); } return; } if (msg.id != null && (msg.result !== undefined || msg.error !== undefined)) { const p = this._pending.get(msg.id); if (!p) return; this._pending.delete(msg.id); if (msg.error) { const err = new Error(msg.error.message || "ACP error"); err.code = msg.error.code; err.data = msg.error.data; p.reject(err); } else { p.resolve(msg.result); } } } /** * @param {string} method * @param {object} [params] * @returns {Promise} */ request(method, params = {}) { const id = this._nextId++; return new Promise((resolve, reject) => { const timer = setTimeout(() => { if (!this._pending.has(id)) return; this._pending.delete(id); reject(new Error(`ACP request timeout after ${this.requestTimeoutMs}ms: ${method}`)); }, this.requestTimeoutMs); this._pending.set(id, { resolve: (value) => { clearTimeout(timer); resolve(value); }, reject: (err) => { clearTimeout(timer); reject(err); }, }); this._send({ jsonrpc: "2.0", id, method, params }); }); } async initialize() { const result = await this.request("initialize", { protocolVersion: 1, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false, }, clientInfo: { name: "cursor-agent-api-proxy", version: "1.6.0" }, }); this.agentCapabilities = result.agentCapabilities || null; if (!result.agentCapabilities?.promptCapabilities?.image) { throw new Error("cursor-agent ACP does not advertise image prompt capability"); } return result; } async authenticateIfNeeded() { try { return await this.request("authenticate", { methodId: "cursor_login" }); } catch (e) { this.log(`[acp] authenticate skipped or failed: ${e.message}`); return null; } } close() { this._closed = true; for (const [, p] of this._pending) { p.reject(new Error("ACP client closed")); } this._pending.clear(); } }