Raw
1 import { ChildProcessWithoutNullStreams, spawn } from "child_process";
2 import { EventEmitter } from "events";
3 import { Connection } from "./connection";
4
5 /**
6 * AcpClient — spawns an Agent Client Protocol agent over stdio and drives the
7 * ACP handshake and prompt turns.
8 *
9 * Protocol reference: https://agentclientprotocol.com
10 */
11
12 export interface AgentSpawnConfig {
13 command: string;
14 args?: string[];
15 cwd?: string;
16 env?: Record<string, string>;
17 }
18
19 export interface PermissionRequest {
20 sessionId: string;
21 toolCall?: unknown;
22 options?: Array<{ optionId: string; name: string; kind?: string }>;
23 [key: string]: unknown;
24 }
25
26 export interface PermissionResponse {
27 outcome: { outcome: "selected"; optionId: string } | { outcome: "cancelled" };
28 }
29
30 /** Callbacks the host injects so the agent can act on the workspace. */
31 export interface AcpCallbacks {
32 requestPermission: (request: PermissionRequest) => Promise<PermissionResponse>;
33 readTextFile: (params: { path: string; line?: number; limit?: number }) => Promise<string>;
34 writeTextFile: (params: { path: string; content: string }) => Promise<void>;
35 }
36
37 export interface SessionUpdate {
38 sessionId: string;
39 update: {
40 sessionUpdate: string;
41 [key: string]: unknown;
42 };
43 }
44
45 const PROTOCOL_VERSION = 1;
46
47 export class AcpClient extends EventEmitter {
48 private child: ChildProcessWithoutNullStreams | undefined;
49 private connection: Connection | undefined;
50 private callbacks: AcpCallbacks;
51 private sessionId: string | undefined;
52 private disposed = false;
53
54 constructor(callbacks: AcpCallbacks) {
55 super();
56 this.callbacks = callbacks;
57 }
58
59 get currentSessionId(): string | undefined {
60 return this.sessionId;
61 }
62
63 /** Spawn the agent process and wire up the JSON-RPC connection. */
64 spawn(config: AgentSpawnConfig): void {
65 const env = { ...process.env, ...(config.env ?? {}) };
66 const child = spawn(config.command, config.args ?? [], {
67 cwd: config.cwd,
68 env,
69 stdio: ["pipe", "pipe", "pipe"]
70 });
71
72 child.on("error", (err) => this.emit("error", err));
73 child.on("exit", (code, signal) => this.emit("exit", code, signal));
74 child.stderr.on("data", (chunk: Buffer) => this.emit("stderr", chunk.toString()));
75
76 const connection = new Connection(child.stdout, child.stdin);
77 connection.on("error", (err) => this.emit("error", err));
78
79 connection.onNotification("session/update", (params) => {
80 this.emit("update", params as SessionUpdate);
81 });
82
83 connection.onRequest("session/request_permission", async (params) => {
84 return this.callbacks.requestPermission(params as PermissionRequest);
85 });
86
87 connection.onRequest("fs/read_text_file", async (params) => {
88 const p = params as { path: string; line?: number; limit?: number };
89 const content = await this.callbacks.readTextFile(p);
90 return { content };
91 });
92
93 connection.onRequest("fs/write_text_file", async (params) => {
94 const p = params as { path: string; content: string };
95 await this.callbacks.writeTextFile(p);
96 return null;
97 });
98
99 this.child = child;
100 this.connection = connection;
101 }
102
103 /** Run the ACP handshake: initialize → session/new. Returns the session id. */
104 async initialize(cwd: string): Promise<string> {
105 const connection = this.requireConnection();
106
107 await connection.request("initialize", {
108 protocolVersion: PROTOCOL_VERSION,
109 clientCapabilities: {
110 fs: {
111 readTextFile: true,
112 writeTextFile: true
113 }
114 }
115 });
116
117 const session = await connection.request<{ sessionId: string }>("session/new", {
118 cwd,
119 mcpServers: []
120 });
121
122 this.sessionId = session.sessionId;
123 return session.sessionId;
124 }
125
126 /** Send a text prompt and resolve with the stop reason for the turn. */
127 async prompt(text: string): Promise<string> {
128 const connection = this.requireConnection();
129 if (!this.sessionId) {
130 throw new Error("No active session; call initialize() first");
131 }
132 const result = await connection.request<{ stopReason: string }>("session/prompt", {
133 sessionId: this.sessionId,
134 prompt: [{ type: "text", text }]
135 });
136 return result.stopReason;
137 }
138
139 /** Ask the agent to cancel the current turn. */
140 cancel(): void {
141 if (!this.connection || !this.sessionId) {
142 return;
143 }
144 this.connection.notify("session/cancel", { sessionId: this.sessionId });
145 }
146
147 dispose(): void {
148 if (this.disposed) {
149 return;
150 }
151 this.disposed = true;
152 this.connection?.dispose();
153 this.connection = undefined;
154 if (this.child) {
155 try {
156 this.child.kill();
157 } catch {
158 // ignore
159 }
160 this.child = undefined;
161 }
162 this.sessionId = undefined;
163 this.removeAllListeners();
164 }
165
166 private requireConnection(): Connection {
167 if (!this.connection) {
168 throw new Error("Agent not spawned; call spawn() first");
169 }
170 return this.connection;
171 }
172 }