|
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
|
} |