165 lines
4.7 KiB
JavaScript
165 lines
4.7 KiB
JavaScript
/**
|
|||
|
|
* 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();
|
||
|
|
}
|
||
|
|
}
|