Skip to content

Commit ae35695

Browse files
authored
feat: add ACP transport for cross-tool consultations (#6)
* feat: add ACP transport for cross-tool consultations Add ACP (Agent Client Protocol) as an additional transport alongside existing CLI subprocess invocation. ACP provides standardized JSON-RPC 2.0 communication supported by all major AI coding tools. New files: - acp/client.js: AcpClient class handling spawn, initialize, session, prompt, streaming chunk collection, and graceful shutdown - acp/providers.js: Provider registry mapping tool names to ACP adapter configs (claude, gemini, codex, copilot, kiro, opencode) - acp/run.js: CLI entry point that encapsulates full ACP lifecycle into a single Bash-invocable command with --detect mode Updated: - SKILL.md: ACP Transport section with provider table, command templates, detection, and transport field in session schema - consult.md: ACP detection in Phase 2b, kiro in tool allow-list - consult-agent.md: node/npx/kiro-cli tool permissions * test: add ACP transport assertions and client unit tests - test-command-templates.js: 17 new assertions for ACP provider table, command templates, transport selection docs, kiro in tool lists - test-acp-client.js: 94 tests covering AcpClient constructor, provider registry, run.js argument validation, output sanitization patterns, module exports, and file existence - package.json: wire test:acp and test:templates scripts * fix: address pre-review findings - Remove excessive inline comments from client.js and run.js - Fix consult-agent.md: add Kiro to non-continuable list - Fix test-acp-client.js: replace setTimeout race with proper async/await, move IIFE tests into sequential async runner - Add mock ACP subprocess test (connect, initialize, session, prompt, chunk events, close) - Add missing edge case tests (timeout=0, NaN timeout, leading-dash session-id, detectAllAcpSupport, isCommandAvailable positive path) - Test count: 94 -> 121 * fix: address Phase 9 review findings Security: - Sanitize error messages in writeError() (run.js) - Add question-file path containment check (reject paths outside cwd) Performance: - Replace O(N^2) string concat with array+join for chunk accumulation - Close readline interface on unexpected subprocess exit (resource leak) Correctness: - Accept --effort arg in run.js (was hardcoded to 'medium') - Fix model fallback (was using agentInfo.name as model ID) - Add .catch handler to test runner (prevent silent crash) - Add path-containment test case Test count: 121 -> 122
1 parent 80aba5a commit ae35695

File tree

9 files changed

+1325
-15
lines changed

9 files changed

+1325
-15
lines changed

acp/client.js

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
#!/usr/bin/env node
2+
/**
3+
* ACP (Agent Client Protocol) Client
4+
*
5+
* JSON-RPC 2.0 client for communicating with ACP agents over stdio.
6+
* Uses only Node.js builtins (child_process, readline, events).
7+
*
8+
* Protocol spec: https://agentclientprotocol.com
9+
* Protocol version: 1 (integer)
10+
*
11+
* @license MIT
12+
*/
13+
14+
'use strict';
15+
16+
const { spawn } = require('child_process');
17+
const { createInterface } = require('readline');
18+
const { EventEmitter } = require('events');
19+
20+
const PROTOCOL_VERSION = 1;
21+
22+
class AcpClient extends EventEmitter {
23+
#proc = null;
24+
#rl = null;
25+
#nextId = 0;
26+
#pending = new Map(); // id -> { resolve, reject, timer }
27+
#sessionId = null;
28+
#responseChunks = [];
29+
#closed = false;
30+
#timeout;
31+
32+
/**
33+
* @param {Object} options
34+
* @param {string} options.command - Binary to spawn (e.g., 'gemini', 'npx')
35+
* @param {string[]} options.args - Arguments (e.g., ['-y', '@zed-industries/codex-acp'])
36+
* @param {Object} [options.env] - Extra env vars to merge with process.env
37+
* @param {string} [options.cwd] - Working directory for subprocess
38+
* @param {number} [options.timeout=120000] - Request timeout in ms
39+
*/
40+
constructor(options) {
41+
super();
42+
if (!options || !options.command) {
43+
throw new Error('AcpClient requires options.command');
44+
}
45+
this.command = options.command;
46+
this.args = options.args || [];
47+
this.env = options.env || {};
48+
this.cwd = options.cwd || process.cwd();
49+
this.#timeout = options.timeout || 120000;
50+
}
51+
52+
/** Spawn the ACP agent subprocess and wire up stdio streams. */
53+
async connect() {
54+
if (this.#proc) throw new Error('Already connected');
55+
56+
const env = { ...process.env };
57+
for (const [key, val] of Object.entries(this.env)) {
58+
if (val === undefined) delete env[key];
59+
else env[key] = val;
60+
}
61+
62+
this.#proc = spawn(this.command, this.args, {
63+
cwd: this.cwd,
64+
env,
65+
stdio: ['pipe', 'pipe', 'pipe'],
66+
windowsHide: true,
67+
});
68+
69+
await new Promise((resolve, reject) => {
70+
this.#proc.once('spawn', resolve);
71+
this.#proc.once('error', (err) => {
72+
this.#proc = null;
73+
reject(new Error(`Failed to spawn ${this.command}: ${err.message}`));
74+
});
75+
});
76+
77+
this.#rl = createInterface({ input: this.#proc.stdout });
78+
this.#rl.on('line', (line) => {
79+
const trimmed = line.trim();
80+
if (!trimmed) return;
81+
try {
82+
this.#dispatch(JSON.parse(trimmed));
83+
} catch {
84+
// Ignore non-JSON lines (agent diagnostics)
85+
}
86+
});
87+
88+
this.#proc.on('close', () => {
89+
this.#closed = true;
90+
if (this.#rl) { this.#rl.close(); this.#rl = null; }
91+
for (const [id, pending] of this.#pending) {
92+
clearTimeout(pending.timer);
93+
pending.reject(new Error('ACP agent process exited'));
94+
}
95+
this.#pending.clear();
96+
this.emit('close');
97+
});
98+
99+
this.#proc.stderr.on('data', (chunk) => {
100+
this.emit('stderr', chunk.toString());
101+
});
102+
}
103+
104+
/** Send initialize handshake. Returns agent capabilities. */
105+
async initialize(clientInfo) {
106+
const result = await this.#request('initialize', {
107+
protocolVersion: PROTOCOL_VERSION,
108+
clientCapabilities: {
109+
fs: { readTextFile: false, writeTextFile: false },
110+
terminal: false,
111+
},
112+
clientInfo: clientInfo || { name: 'agentsys-consult', version: '1.0.0' },
113+
});
114+
return result;
115+
}
116+
117+
/** Create a new session. Returns { sessionId, modes, models, configOptions }. */
118+
async newSession(cwd, mcpServers) {
119+
const result = await this.#request('session/new', {
120+
cwd: cwd || this.cwd,
121+
mcpServers: mcpServers || [],
122+
});
123+
this.#sessionId = result.sessionId;
124+
return result;
125+
}
126+
127+
/**
128+
* Send a prompt and collect the full response.
129+
* Blocks until the agent finishes the turn.
130+
*
131+
* @param {string} text - Prompt text
132+
* @param {string} [sessionId] - Override session ID
133+
* @returns {Promise<{text: string, stopReason: string, usage: Object|null}>}
134+
*/
135+
async prompt(text, sessionId) {
136+
this.#responseChunks = [];
137+
const sid = sessionId || this.#sessionId;
138+
if (!sid) throw new Error('No session ID - call newSession() first');
139+
140+
const result = await this.#request('session/prompt', {
141+
sessionId: sid,
142+
prompt: [{ type: 'text', text }],
143+
});
144+
145+
return {
146+
text: this.#responseChunks.join(''),
147+
stopReason: result.stopReason || 'end_turn',
148+
usage: result.usage || null,
149+
};
150+
}
151+
152+
/** Cancel an in-progress prompt (notification, no response). */
153+
cancel(sessionId) {
154+
this.#send({
155+
jsonrpc: '2.0',
156+
method: 'session/cancel',
157+
params: { sessionId: sessionId || this.#sessionId },
158+
});
159+
}
160+
161+
/** Get the current session ID. */
162+
get sessionId() {
163+
return this.#sessionId;
164+
}
165+
166+
/** Gracefully shut down the subprocess. */
167+
async close() {
168+
if (!this.#proc || this.#closed) return;
169+
170+
this.#proc.kill('SIGTERM');
171+
172+
await new Promise((resolve) => {
173+
const killTimer = setTimeout(() => {
174+
if (this.#proc && !this.#closed) {
175+
this.#proc.kill('SIGKILL');
176+
}
177+
}, 5000);
178+
179+
const onClose = () => {
180+
clearTimeout(killTimer);
181+
resolve();
182+
};
183+
184+
if (this.#closed) {
185+
clearTimeout(killTimer);
186+
resolve();
187+
} else {
188+
this.#proc.once('close', onClose);
189+
}
190+
});
191+
192+
if (this.#rl) {
193+
this.#rl.close();
194+
this.#rl = null;
195+
}
196+
this.#proc = null;
197+
}
198+
199+
#send(msg) {
200+
if (this.#closed || !this.#proc) return;
201+
const line = JSON.stringify(msg) + '\n';
202+
try {
203+
this.#proc.stdin.write(line);
204+
} catch {
205+
// Subprocess stdin may be closed
206+
}
207+
}
208+
209+
#request(method, params) {
210+
return new Promise((resolve, reject) => {
211+
if (this.#closed) {
212+
return reject(new Error('ACP client is closed'));
213+
}
214+
const id = this.#nextId++;
215+
const timer = setTimeout(() => {
216+
this.#pending.delete(id);
217+
reject(new Error(`ACP request '${method}' timed out after ${this.#timeout}ms`));
218+
}, this.#timeout);
219+
220+
this.#pending.set(id, { resolve, reject, timer });
221+
this.#send({ jsonrpc: '2.0', id, method, params });
222+
});
223+
}
224+
225+
#dispatch(msg) {
226+
if ('id' in msg && !('method' in msg)) {
227+
const pending = this.#pending.get(msg.id);
228+
if (!pending) return;
229+
this.#pending.delete(msg.id);
230+
clearTimeout(pending.timer);
231+
232+
if ('error' in msg) {
233+
const err = msg.error;
234+
pending.reject(new Error(`ACP error ${err.code}: ${err.message}`));
235+
} else {
236+
pending.resolve(msg.result);
237+
}
238+
return;
239+
}
240+
241+
if ('id' in msg && 'method' in msg) {
242+
this.#handleAgentRequest(msg);
243+
return;
244+
}
245+
246+
if ('method' in msg && !('id' in msg)) {
247+
this.#handleNotification(msg);
248+
}
249+
}
250+
251+
#handleAgentRequest(msg) {
252+
const { id, method, params } = msg;
253+
254+
if (method === 'session/request_permission') {
255+
// Auto-approve read operations, reject writes
256+
const toolCall = params && params.toolCall;
257+
const kind = toolCall && toolCall.kind;
258+
const isReadOnly = kind === 'read' || kind === 'search' || kind === 'think';
259+
260+
if (isReadOnly) {
261+
const allowOption = (params.options || []).find(o =>
262+
o.kind === 'allow_once'
263+
);
264+
this.#send({
265+
jsonrpc: '2.0',
266+
id,
267+
result: {
268+
outcome: {
269+
outcome: 'selected',
270+
optionId: allowOption ? allowOption.optionId : 'allow',
271+
},
272+
},
273+
});
274+
} else {
275+
// Reject non-read operations for safety
276+
this.#send({
277+
jsonrpc: '2.0',
278+
id,
279+
result: { outcome: { outcome: 'cancelled' } },
280+
});
281+
}
282+
return;
283+
}
284+
285+
// Unknown agent request - return method not found
286+
this.#send({
287+
jsonrpc: '2.0',
288+
id,
289+
error: { code: -32601, message: `Method not supported: ${method}` },
290+
});
291+
}
292+
293+
#handleNotification(msg) {
294+
if (msg.method === 'session/update' && msg.params) {
295+
const update = msg.params.update;
296+
if (!update) return;
297+
298+
if (update.sessionUpdate === 'agent_message_chunk') {
299+
const content = update.content;
300+
if (content && content.type === 'text' && content.text) {
301+
this.#responseChunks.push(content.text);
302+
this.emit('chunk', content.text);
303+
}
304+
} else if (update.sessionUpdate === 'tool_call') {
305+
this.emit('tool_call', update);
306+
} else if (update.sessionUpdate === 'tool_call_update') {
307+
this.emit('tool_call_update', update);
308+
}
309+
}
310+
}
311+
}
312+
313+
module.exports = { AcpClient, PROTOCOL_VERSION };

0 commit comments

Comments
 (0)