Files
cursor-agent-api-proxy/src/acp-client.mjs
T

165 lines
4.7 KiB
JavaScript
Raw Normal View History

/**
* 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<number, {resolve: Function, reject: Function}>} */
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<object>}
*/
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<object>}
*/
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();
}
}