@actant/acp 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +281 -0
- package/dist/index.js +1089 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1089 @@
|
|
|
1
|
+
// src/connection.ts
|
|
2
|
+
import { spawn as spawn2 } from "child_process";
|
|
3
|
+
import { Writable, Readable } from "stream";
|
|
4
|
+
import {
|
|
5
|
+
ClientSideConnection,
|
|
6
|
+
ndJsonStream
|
|
7
|
+
} from "@agentclientprotocol/sdk";
|
|
8
|
+
import { createLogger as createLogger2 } from "@actant/shared";
|
|
9
|
+
import { PermissionPolicyEnforcer, PermissionAuditLogger } from "@actant/core";
|
|
10
|
+
|
|
11
|
+
// src/terminal-manager.ts
|
|
12
|
+
import { spawn } from "child_process";
|
|
13
|
+
import { createLogger } from "@actant/shared";
|
|
14
|
+
var logger = createLogger("acp-terminal");
|
|
15
|
+
var LocalTerminalManager = class {
|
|
16
|
+
terminals = /* @__PURE__ */ new Map();
|
|
17
|
+
counter = 0;
|
|
18
|
+
async createTerminal(params) {
|
|
19
|
+
const id = `term_${++this.counter}_${Date.now()}`;
|
|
20
|
+
const envEntries = { ...process.env };
|
|
21
|
+
if (params.env) {
|
|
22
|
+
for (const entry of params.env) {
|
|
23
|
+
envEntries[entry.name] = entry.value;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const proc = spawn(params.command, params.args ?? [], {
|
|
27
|
+
cwd: params.cwd ?? void 0,
|
|
28
|
+
env: envEntries,
|
|
29
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
30
|
+
shell: true
|
|
31
|
+
});
|
|
32
|
+
const limit = params.outputByteLimit ?? 1024 * 1024;
|
|
33
|
+
const output = [];
|
|
34
|
+
let totalBytes = 0;
|
|
35
|
+
const exitPromise = new Promise((resolve) => {
|
|
36
|
+
proc.on("exit", (code, signal) => {
|
|
37
|
+
resolve({ exitCode: code ?? null, signal: signal ?? null });
|
|
38
|
+
});
|
|
39
|
+
proc.on("error", (err) => {
|
|
40
|
+
logger.error({ terminalId: id, error: err }, "Terminal process error");
|
|
41
|
+
resolve({ exitCode: 1, signal: null });
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
const appendOutput = (chunk) => {
|
|
45
|
+
output.push(chunk);
|
|
46
|
+
totalBytes += chunk.length;
|
|
47
|
+
while (totalBytes > limit && output.length > 1) {
|
|
48
|
+
const removed = output.shift();
|
|
49
|
+
if (removed) totalBytes -= removed.length;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
proc.stdout?.on("data", appendOutput);
|
|
53
|
+
proc.stderr?.on("data", appendOutput);
|
|
54
|
+
const terminal = {
|
|
55
|
+
id,
|
|
56
|
+
process: proc,
|
|
57
|
+
output,
|
|
58
|
+
totalBytes,
|
|
59
|
+
outputByteLimit: limit,
|
|
60
|
+
exitStatus: null,
|
|
61
|
+
exitPromise,
|
|
62
|
+
disposed: false
|
|
63
|
+
};
|
|
64
|
+
exitPromise.then((status) => {
|
|
65
|
+
terminal.exitStatus = status;
|
|
66
|
+
});
|
|
67
|
+
this.terminals.set(id, terminal);
|
|
68
|
+
logger.info({ terminalId: id, command: params.command, cwd: params.cwd }, "Terminal created");
|
|
69
|
+
return { terminalId: id };
|
|
70
|
+
}
|
|
71
|
+
async terminalOutput(params) {
|
|
72
|
+
const term = this.getTerminal(params.terminalId);
|
|
73
|
+
const outputStr = Buffer.concat(term.output).toString("utf-8");
|
|
74
|
+
const truncated = term.totalBytes > term.outputByteLimit;
|
|
75
|
+
return {
|
|
76
|
+
output: outputStr,
|
|
77
|
+
truncated,
|
|
78
|
+
...term.exitStatus != null ? { exitStatus: term.exitStatus } : {}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
async waitForExit(params) {
|
|
82
|
+
const term = this.getTerminal(params.terminalId);
|
|
83
|
+
const status = await term.exitPromise;
|
|
84
|
+
return { exitCode: status.exitCode, signal: status.signal };
|
|
85
|
+
}
|
|
86
|
+
async killTerminal(params) {
|
|
87
|
+
const term = this.getTerminal(params.terminalId);
|
|
88
|
+
if (!term.process.killed && term.exitStatus == null) {
|
|
89
|
+
term.process.kill("SIGTERM");
|
|
90
|
+
setTimeout(() => {
|
|
91
|
+
if (term.exitStatus == null && !term.process.killed) {
|
|
92
|
+
term.process.kill("SIGKILL");
|
|
93
|
+
}
|
|
94
|
+
}, 3e3);
|
|
95
|
+
}
|
|
96
|
+
return {};
|
|
97
|
+
}
|
|
98
|
+
async releaseTerminal(params) {
|
|
99
|
+
const term = this.terminals.get(params.terminalId);
|
|
100
|
+
if (!term) return {};
|
|
101
|
+
if (!term.process.killed && term.exitStatus == null) {
|
|
102
|
+
term.process.kill("SIGKILL");
|
|
103
|
+
}
|
|
104
|
+
term.disposed = true;
|
|
105
|
+
this.terminals.delete(params.terminalId);
|
|
106
|
+
logger.debug({ terminalId: params.terminalId }, "Terminal released");
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
disposeAll() {
|
|
110
|
+
for (const [id, term] of this.terminals) {
|
|
111
|
+
if (!term.process.killed && term.exitStatus == null) {
|
|
112
|
+
term.process.kill("SIGKILL");
|
|
113
|
+
}
|
|
114
|
+
term.disposed = true;
|
|
115
|
+
this.terminals.delete(id);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
getTerminal(terminalId) {
|
|
119
|
+
const term = this.terminals.get(terminalId);
|
|
120
|
+
if (!term) {
|
|
121
|
+
throw new Error(`Terminal "${terminalId}" not found or already released`);
|
|
122
|
+
}
|
|
123
|
+
return term;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// src/connection.ts
|
|
128
|
+
var logger2 = createLogger2("acp-connection");
|
|
129
|
+
var AcpConnection = class {
|
|
130
|
+
child = null;
|
|
131
|
+
conn = null;
|
|
132
|
+
initResponse = null;
|
|
133
|
+
sessions = /* @__PURE__ */ new Map();
|
|
134
|
+
updateListeners = /* @__PURE__ */ new Map();
|
|
135
|
+
options;
|
|
136
|
+
terminalManager;
|
|
137
|
+
enforcer = null;
|
|
138
|
+
auditLogger;
|
|
139
|
+
constructor(options) {
|
|
140
|
+
this.options = options ?? {};
|
|
141
|
+
this.terminalManager = new LocalTerminalManager();
|
|
142
|
+
this.auditLogger = new PermissionAuditLogger(options?.instanceName);
|
|
143
|
+
if (options?.permissionPolicy) {
|
|
144
|
+
this.enforcer = new PermissionPolicyEnforcer(options.permissionPolicy);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Update the permission policy at runtime (hot-reload). */
|
|
148
|
+
updatePermissionPolicy(config) {
|
|
149
|
+
if (this.enforcer) {
|
|
150
|
+
this.enforcer.updateConfig(config);
|
|
151
|
+
} else {
|
|
152
|
+
this.enforcer = new PermissionPolicyEnforcer(config);
|
|
153
|
+
}
|
|
154
|
+
this.auditLogger.logUpdated("runtime");
|
|
155
|
+
}
|
|
156
|
+
get isConnected() {
|
|
157
|
+
return this.conn != null && !this.conn.signal.aborted;
|
|
158
|
+
}
|
|
159
|
+
get agentCapabilities() {
|
|
160
|
+
return this.initResponse;
|
|
161
|
+
}
|
|
162
|
+
get rawConnection() {
|
|
163
|
+
return this.conn;
|
|
164
|
+
}
|
|
165
|
+
/* ---------------------------------------------------------------- */
|
|
166
|
+
/* Lifecycle */
|
|
167
|
+
/* ---------------------------------------------------------------- */
|
|
168
|
+
async spawn(command, args, cwd) {
|
|
169
|
+
if (this.child) throw new Error("AcpConnection already spawned");
|
|
170
|
+
const env = { ...process.env, ...this.options.env };
|
|
171
|
+
logger2.info({ command, args, cwd }, "Spawning ACP agent subprocess");
|
|
172
|
+
this.child = spawn2(command, args, {
|
|
173
|
+
cwd,
|
|
174
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
175
|
+
env
|
|
176
|
+
});
|
|
177
|
+
if (!this.child.stdout || !this.child.stdin) {
|
|
178
|
+
throw new Error("Failed to create stdio pipes for ACP agent");
|
|
179
|
+
}
|
|
180
|
+
this.child.stderr?.on("data", (chunk) => {
|
|
181
|
+
logger2.debug({ stderr: chunk.toString().trim() }, "ACP agent stderr");
|
|
182
|
+
});
|
|
183
|
+
this.child.on("error", (err) => {
|
|
184
|
+
logger2.error({ error: err }, "ACP agent process error");
|
|
185
|
+
});
|
|
186
|
+
const webWritable = Writable.toWeb(this.child.stdin);
|
|
187
|
+
const webReadable = Readable.toWeb(this.child.stdout);
|
|
188
|
+
const stream = ndJsonStream(webWritable, webReadable);
|
|
189
|
+
this.conn = new ClientSideConnection(
|
|
190
|
+
(_agent) => this.buildClient(),
|
|
191
|
+
stream
|
|
192
|
+
);
|
|
193
|
+
this.conn.signal.addEventListener("abort", () => {
|
|
194
|
+
logger2.info("ACP connection closed");
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
async initialize() {
|
|
198
|
+
if (!this.conn) throw new Error("AcpConnection not spawned");
|
|
199
|
+
this.initResponse = await this.conn.initialize({
|
|
200
|
+
protocolVersion: 1,
|
|
201
|
+
clientCapabilities: {
|
|
202
|
+
fs: { readTextFile: true, writeTextFile: true },
|
|
203
|
+
terminal: true
|
|
204
|
+
},
|
|
205
|
+
clientInfo: {
|
|
206
|
+
name: "actant",
|
|
207
|
+
title: "Actant Daemon",
|
|
208
|
+
version: "0.1.0"
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
logger2.info({
|
|
212
|
+
agentName: this.initResponse.agentInfo?.name,
|
|
213
|
+
protocolVersion: this.initResponse.protocolVersion,
|
|
214
|
+
loadSession: this.initResponse.agentCapabilities?.loadSession
|
|
215
|
+
}, "ACP initialized");
|
|
216
|
+
return this.initResponse;
|
|
217
|
+
}
|
|
218
|
+
/* ---------------------------------------------------------------- */
|
|
219
|
+
/* Session management */
|
|
220
|
+
/* ---------------------------------------------------------------- */
|
|
221
|
+
async newSession(cwd, mcpServers = []) {
|
|
222
|
+
if (!this.conn) throw new Error("AcpConnection not initialized");
|
|
223
|
+
const response = await this.conn.newSession({
|
|
224
|
+
cwd,
|
|
225
|
+
mcpServers
|
|
226
|
+
});
|
|
227
|
+
const info = {
|
|
228
|
+
sessionId: response.sessionId,
|
|
229
|
+
modes: response.modes,
|
|
230
|
+
configOptions: response.configOptions
|
|
231
|
+
};
|
|
232
|
+
this.sessions.set(response.sessionId, info);
|
|
233
|
+
logger2.info({ sessionId: response.sessionId, cwd }, "ACP session created");
|
|
234
|
+
return info;
|
|
235
|
+
}
|
|
236
|
+
async loadSession(sessionId, cwd) {
|
|
237
|
+
if (!this.conn) throw new Error("AcpConnection not initialized");
|
|
238
|
+
if (!this.initResponse?.agentCapabilities?.loadSession) {
|
|
239
|
+
throw new Error("Agent does not support loadSession capability");
|
|
240
|
+
}
|
|
241
|
+
await this.conn.loadSession({ sessionId, cwd, mcpServers: [] });
|
|
242
|
+
logger2.info({ sessionId }, "ACP session loaded");
|
|
243
|
+
}
|
|
244
|
+
async setSessionMode(sessionId, modeId) {
|
|
245
|
+
if (!this.conn) throw new Error("AcpConnection not initialized");
|
|
246
|
+
await this.conn.setSessionMode({ sessionId, modeId });
|
|
247
|
+
logger2.info({ sessionId, modeId }, "Session mode set");
|
|
248
|
+
}
|
|
249
|
+
async setSessionConfigOption(sessionId, configId, value) {
|
|
250
|
+
if (!this.conn) throw new Error("AcpConnection not initialized");
|
|
251
|
+
const result = await this.conn.setSessionConfigOption({ sessionId, configId, value });
|
|
252
|
+
logger2.info({ sessionId, configId, value }, "Session config option set");
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
async authenticate(methodId) {
|
|
256
|
+
if (!this.conn) throw new Error("AcpConnection not initialized");
|
|
257
|
+
await this.conn.authenticate({ methodId });
|
|
258
|
+
logger2.info({ methodId }, "Authenticated");
|
|
259
|
+
}
|
|
260
|
+
/* ---------------------------------------------------------------- */
|
|
261
|
+
/* Prompt */
|
|
262
|
+
/* ---------------------------------------------------------------- */
|
|
263
|
+
/**
|
|
264
|
+
* Send a prompt with arbitrary content blocks.
|
|
265
|
+
* For text-only convenience, use the string overload.
|
|
266
|
+
*/
|
|
267
|
+
async prompt(sessionId, content) {
|
|
268
|
+
if (!this.conn) throw new Error("AcpConnection not initialized");
|
|
269
|
+
const promptBlocks = typeof content === "string" ? [{ type: "text", text: content }] : content;
|
|
270
|
+
let collectedText = "";
|
|
271
|
+
const listener = (notification) => {
|
|
272
|
+
const update = notification.update;
|
|
273
|
+
if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") {
|
|
274
|
+
collectedText += update.content.text;
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
this.addUpdateListener(sessionId, listener);
|
|
278
|
+
try {
|
|
279
|
+
const response = await this.conn.prompt({
|
|
280
|
+
sessionId,
|
|
281
|
+
prompt: promptBlocks
|
|
282
|
+
});
|
|
283
|
+
return { stopReason: response.stopReason, text: collectedText };
|
|
284
|
+
} finally {
|
|
285
|
+
this.removeUpdateListener(sessionId, listener);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Stream prompt — yields every SessionNotification as it arrives.
|
|
290
|
+
*/
|
|
291
|
+
async *streamPrompt(sessionId, content) {
|
|
292
|
+
if (!this.conn) throw new Error("AcpConnection not initialized");
|
|
293
|
+
const promptBlocks = typeof content === "string" ? [{ type: "text", text: content }] : content;
|
|
294
|
+
const queue = [];
|
|
295
|
+
let resolve = null;
|
|
296
|
+
let done = false;
|
|
297
|
+
const listener = (notification) => {
|
|
298
|
+
queue.push(notification);
|
|
299
|
+
resolve?.();
|
|
300
|
+
};
|
|
301
|
+
this.addUpdateListener(sessionId, listener);
|
|
302
|
+
const promptPromise = this.conn.prompt({
|
|
303
|
+
sessionId,
|
|
304
|
+
prompt: promptBlocks
|
|
305
|
+
}).then(() => {
|
|
306
|
+
done = true;
|
|
307
|
+
resolve?.();
|
|
308
|
+
}).catch((err) => {
|
|
309
|
+
done = true;
|
|
310
|
+
resolve?.();
|
|
311
|
+
throw err;
|
|
312
|
+
});
|
|
313
|
+
try {
|
|
314
|
+
while (!done || queue.length > 0) {
|
|
315
|
+
if (queue.length === 0 && !done) {
|
|
316
|
+
await new Promise((r) => {
|
|
317
|
+
resolve = r;
|
|
318
|
+
});
|
|
319
|
+
resolve = null;
|
|
320
|
+
}
|
|
321
|
+
while (queue.length > 0) {
|
|
322
|
+
const item = queue.shift();
|
|
323
|
+
if (item) yield item;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
await promptPromise;
|
|
327
|
+
} finally {
|
|
328
|
+
this.removeUpdateListener(sessionId, listener);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
async cancel(sessionId) {
|
|
332
|
+
if (!this.conn) return;
|
|
333
|
+
await this.conn.cancel({ sessionId });
|
|
334
|
+
}
|
|
335
|
+
/* ---------------------------------------------------------------- */
|
|
336
|
+
/* Session accessors */
|
|
337
|
+
/* ---------------------------------------------------------------- */
|
|
338
|
+
getSession(sessionId) {
|
|
339
|
+
return this.sessions.get(sessionId);
|
|
340
|
+
}
|
|
341
|
+
listSessions() {
|
|
342
|
+
return Array.from(this.sessions.keys());
|
|
343
|
+
}
|
|
344
|
+
/* ---------------------------------------------------------------- */
|
|
345
|
+
/* Close */
|
|
346
|
+
/* ---------------------------------------------------------------- */
|
|
347
|
+
async close() {
|
|
348
|
+
this.terminalManager.disposeAll();
|
|
349
|
+
if (this.child) {
|
|
350
|
+
const child = this.child;
|
|
351
|
+
this.child = null;
|
|
352
|
+
if (child.stdin && !child.stdin.destroyed) {
|
|
353
|
+
child.stdin.end();
|
|
354
|
+
}
|
|
355
|
+
await new Promise((resolve) => {
|
|
356
|
+
const timer = setTimeout(() => {
|
|
357
|
+
child.kill("SIGKILL");
|
|
358
|
+
resolve();
|
|
359
|
+
}, 5e3);
|
|
360
|
+
child.once("exit", () => {
|
|
361
|
+
clearTimeout(timer);
|
|
362
|
+
resolve();
|
|
363
|
+
});
|
|
364
|
+
child.kill("SIGTERM");
|
|
365
|
+
});
|
|
366
|
+
logger2.info("ACP agent subprocess terminated");
|
|
367
|
+
}
|
|
368
|
+
this.conn = null;
|
|
369
|
+
this.initResponse = null;
|
|
370
|
+
this.sessions.clear();
|
|
371
|
+
this.updateListeners.clear();
|
|
372
|
+
}
|
|
373
|
+
/* ---------------------------------------------------------------- */
|
|
374
|
+
/* Build the Client callback implementation */
|
|
375
|
+
/* ---------------------------------------------------------------- */
|
|
376
|
+
buildClient() {
|
|
377
|
+
const handler = this.options.callbackHandler;
|
|
378
|
+
if (handler) {
|
|
379
|
+
return {
|
|
380
|
+
requestPermission: (p) => handler.requestPermission(p),
|
|
381
|
+
sessionUpdate: (p) => handler.sessionUpdate(p),
|
|
382
|
+
readTextFile: (p) => handler.readTextFile(p),
|
|
383
|
+
writeTextFile: (p) => handler.writeTextFile(p),
|
|
384
|
+
createTerminal: handler.createTerminal?.bind(handler),
|
|
385
|
+
terminalOutput: handler.terminalOutput?.bind(handler),
|
|
386
|
+
waitForTerminalExit: handler.waitForTerminalExit?.bind(handler),
|
|
387
|
+
killTerminal: handler.killTerminal?.bind(handler),
|
|
388
|
+
releaseTerminal: handler.releaseTerminal?.bind(handler)
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
requestPermission: (p) => this.localRequestPermission(p),
|
|
393
|
+
sessionUpdate: (p) => this.localSessionUpdate(p),
|
|
394
|
+
readTextFile: (p) => this.localReadTextFile(p),
|
|
395
|
+
writeTextFile: (p) => this.localWriteTextFile(p),
|
|
396
|
+
createTerminal: (p) => this.terminalManager.createTerminal(p),
|
|
397
|
+
terminalOutput: (p) => this.terminalManager.terminalOutput(p),
|
|
398
|
+
waitForTerminalExit: (p) => this.terminalManager.waitForExit(p),
|
|
399
|
+
killTerminal: (p) => this.terminalManager.killTerminal(p),
|
|
400
|
+
releaseTerminal: (p) => this.terminalManager.releaseTerminal(p)
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/* ---------------------------------------------------------------- */
|
|
404
|
+
/* Local callback implementations */
|
|
405
|
+
/* ---------------------------------------------------------------- */
|
|
406
|
+
async localRequestPermission(params) {
|
|
407
|
+
if (this.enforcer && params.options.length > 0) {
|
|
408
|
+
const toolInfo = {
|
|
409
|
+
kind: params.toolCall?.kind ?? void 0,
|
|
410
|
+
title: params.toolCall?.title ?? void 0,
|
|
411
|
+
toolCallId: params.toolCall?.toolCallId ?? "unknown"
|
|
412
|
+
};
|
|
413
|
+
const decision = this.enforcer.evaluate(toolInfo);
|
|
414
|
+
this.auditLogger.logEvaluation(toolInfo, decision);
|
|
415
|
+
if (decision.action === "allow" || decision.action === "deny") {
|
|
416
|
+
const outcome = this.enforcer.buildOutcome(decision, params.options);
|
|
417
|
+
return { outcome };
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (this.options.autoApprove && params.options.length > 0) {
|
|
421
|
+
const allowOption = params.options.find(
|
|
422
|
+
(o) => o.kind === "allow_once" || o.kind === "allow_always"
|
|
423
|
+
) ?? params.options[0];
|
|
424
|
+
if (!allowOption) return { outcome: { outcome: "cancelled" } };
|
|
425
|
+
return { outcome: { outcome: "selected", optionId: allowOption.optionId } };
|
|
426
|
+
}
|
|
427
|
+
logger2.warn({ sessionId: params.sessionId }, "Permission request denied (no handler)");
|
|
428
|
+
return { outcome: { outcome: "cancelled" } };
|
|
429
|
+
}
|
|
430
|
+
async localSessionUpdate(params) {
|
|
431
|
+
this.options.onSessionUpdate?.(params);
|
|
432
|
+
const listeners = this.updateListeners.get(params.sessionId);
|
|
433
|
+
if (listeners) {
|
|
434
|
+
for (const listener of listeners) {
|
|
435
|
+
listener(params);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
async localReadTextFile(params) {
|
|
440
|
+
const { readFile } = await import("fs/promises");
|
|
441
|
+
try {
|
|
442
|
+
const raw = await readFile(params.path, "utf-8");
|
|
443
|
+
if (params.line != null || params.limit != null) {
|
|
444
|
+
const lines = raw.split("\n");
|
|
445
|
+
const start = Math.max(0, (params.line ?? 1) - 1);
|
|
446
|
+
const end = params.limit != null ? start + params.limit : lines.length;
|
|
447
|
+
return { content: lines.slice(start, end).join("\n") };
|
|
448
|
+
}
|
|
449
|
+
return { content: raw };
|
|
450
|
+
} catch {
|
|
451
|
+
throw new Error(`Cannot read file: ${params.path}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
async localWriteTextFile(params) {
|
|
455
|
+
const { writeFile, mkdir } = await import("fs/promises");
|
|
456
|
+
const { dirname } = await import("path");
|
|
457
|
+
try {
|
|
458
|
+
await mkdir(dirname(params.path), { recursive: true });
|
|
459
|
+
await writeFile(params.path, params.content, "utf-8");
|
|
460
|
+
return {};
|
|
461
|
+
} catch {
|
|
462
|
+
throw new Error(`Cannot write file: ${params.path}`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/* ---------------------------------------------------------------- */
|
|
466
|
+
/* Update listener management */
|
|
467
|
+
/* ---------------------------------------------------------------- */
|
|
468
|
+
addUpdateListener(sessionId, listener) {
|
|
469
|
+
const existing = this.updateListeners.get(sessionId) ?? [];
|
|
470
|
+
existing.push(listener);
|
|
471
|
+
this.updateListeners.set(sessionId, existing);
|
|
472
|
+
}
|
|
473
|
+
removeUpdateListener(sessionId, listener) {
|
|
474
|
+
const existing = this.updateListeners.get(sessionId);
|
|
475
|
+
if (existing) {
|
|
476
|
+
const idx = existing.indexOf(listener);
|
|
477
|
+
if (idx >= 0) existing.splice(idx, 1);
|
|
478
|
+
if (existing.length === 0) this.updateListeners.delete(sessionId);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
// src/connection-manager.ts
|
|
484
|
+
import { createLogger as createLogger5 } from "@actant/shared";
|
|
485
|
+
import { PermissionPolicyEnforcer as PermissionPolicyEnforcer2, PermissionAuditLogger as PermissionAuditLogger2 } from "@actant/core";
|
|
486
|
+
|
|
487
|
+
// src/callback-router.ts
|
|
488
|
+
import { createLogger as createLogger3 } from "@actant/shared";
|
|
489
|
+
var logger3 = createLogger3("acp-callback-router");
|
|
490
|
+
var ClientCallbackRouter = class {
|
|
491
|
+
constructor(local) {
|
|
492
|
+
this.local = local;
|
|
493
|
+
}
|
|
494
|
+
upstream = null;
|
|
495
|
+
ideCapabilities = null;
|
|
496
|
+
enforcer = null;
|
|
497
|
+
/** Attach a PermissionPolicyEnforcer for pre-filtering in lease mode. */
|
|
498
|
+
setEnforcer(enforcer) {
|
|
499
|
+
this.enforcer = enforcer;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Activate lease-forwarding mode.
|
|
503
|
+
* Callbacks will be routed to the IDE for supported capabilities.
|
|
504
|
+
*/
|
|
505
|
+
attachUpstream(handler, capabilities) {
|
|
506
|
+
this.upstream = handler;
|
|
507
|
+
this.ideCapabilities = capabilities;
|
|
508
|
+
logger3.info({
|
|
509
|
+
terminal: !!capabilities.terminal,
|
|
510
|
+
fsRead: !!capabilities.fs?.readTextFile,
|
|
511
|
+
fsWrite: !!capabilities.fs?.writeTextFile
|
|
512
|
+
}, "Upstream IDE attached \u2014 lease forwarding active");
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Deactivate lease-forwarding. All callbacks revert to local handlers.
|
|
516
|
+
*/
|
|
517
|
+
detachUpstream() {
|
|
518
|
+
this.upstream = null;
|
|
519
|
+
this.ideCapabilities = null;
|
|
520
|
+
logger3.info("Upstream IDE detached \u2014 local mode");
|
|
521
|
+
}
|
|
522
|
+
get isLeaseActive() {
|
|
523
|
+
return this.upstream != null;
|
|
524
|
+
}
|
|
525
|
+
/* ---------------------------------------------------------------- */
|
|
526
|
+
/* ClientCallbackHandler implementation */
|
|
527
|
+
/* ---------------------------------------------------------------- */
|
|
528
|
+
async requestPermission(params) {
|
|
529
|
+
if (this.enforcer && params.options.length > 0) {
|
|
530
|
+
const toolInfo = {
|
|
531
|
+
kind: params.toolCall?.kind ?? void 0,
|
|
532
|
+
title: params.toolCall?.title ?? void 0,
|
|
533
|
+
toolCallId: params.toolCall?.toolCallId ?? "unknown"
|
|
534
|
+
};
|
|
535
|
+
const decision = this.enforcer.evaluate(toolInfo);
|
|
536
|
+
if (decision.action === "deny") {
|
|
537
|
+
const outcome = this.enforcer.buildOutcome(decision, params.options);
|
|
538
|
+
return { outcome };
|
|
539
|
+
}
|
|
540
|
+
if (decision.action === "allow") {
|
|
541
|
+
const outcome = this.enforcer.buildOutcome(decision, params.options);
|
|
542
|
+
return { outcome };
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (this.upstream) {
|
|
546
|
+
try {
|
|
547
|
+
return await this.upstream.requestPermission(params);
|
|
548
|
+
} catch (err) {
|
|
549
|
+
logger3.warn({ error: err }, "Failed to forward requestPermission to IDE, falling back");
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return this.local.requestPermission(params);
|
|
553
|
+
}
|
|
554
|
+
async sessionUpdate(params) {
|
|
555
|
+
await this.local.sessionUpdate(params);
|
|
556
|
+
if (this.upstream) {
|
|
557
|
+
try {
|
|
558
|
+
await this.upstream.sessionUpdate(params);
|
|
559
|
+
} catch {
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
async readTextFile(params) {
|
|
564
|
+
if (this.upstream && this.ideCapabilities?.fs?.readTextFile) {
|
|
565
|
+
try {
|
|
566
|
+
return await this.upstream.readTextFile(params);
|
|
567
|
+
} catch (err) {
|
|
568
|
+
logger3.warn({ path: params.path, error: err }, "IDE readTextFile failed, falling back");
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return this.local.readTextFile(params);
|
|
572
|
+
}
|
|
573
|
+
async writeTextFile(params) {
|
|
574
|
+
if (this.upstream && this.ideCapabilities?.fs?.writeTextFile) {
|
|
575
|
+
try {
|
|
576
|
+
return await this.upstream.writeTextFile(params);
|
|
577
|
+
} catch (err) {
|
|
578
|
+
logger3.warn({ path: params.path, error: err }, "IDE writeTextFile failed, falling back");
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return this.local.writeTextFile(params);
|
|
582
|
+
}
|
|
583
|
+
async createTerminal(params) {
|
|
584
|
+
if (this.upstream && this.ideCapabilities?.terminal) {
|
|
585
|
+
try {
|
|
586
|
+
return await this.upstream.createTerminal(params);
|
|
587
|
+
} catch (err) {
|
|
588
|
+
logger3.warn({ error: err }, "IDE createTerminal failed, falling back");
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (this.local.createTerminal) return this.local.createTerminal(params);
|
|
592
|
+
throw new Error("Terminal not supported");
|
|
593
|
+
}
|
|
594
|
+
async terminalOutput(params) {
|
|
595
|
+
if (this.upstream && this.ideCapabilities?.terminal) {
|
|
596
|
+
try {
|
|
597
|
+
return await this.upstream.terminalOutput(params);
|
|
598
|
+
} catch (err) {
|
|
599
|
+
logger3.warn({ error: err }, "IDE terminalOutput failed, falling back");
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (this.local.terminalOutput) return this.local.terminalOutput(params);
|
|
603
|
+
throw new Error("Terminal not supported");
|
|
604
|
+
}
|
|
605
|
+
async waitForTerminalExit(params) {
|
|
606
|
+
if (this.upstream && this.ideCapabilities?.terminal) {
|
|
607
|
+
try {
|
|
608
|
+
return await this.upstream.waitForTerminalExit(params);
|
|
609
|
+
} catch (err) {
|
|
610
|
+
logger3.warn({ error: err }, "IDE waitForTerminalExit failed, falling back");
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
if (this.local.waitForTerminalExit) return this.local.waitForTerminalExit(params);
|
|
614
|
+
throw new Error("Terminal not supported");
|
|
615
|
+
}
|
|
616
|
+
async killTerminal(params) {
|
|
617
|
+
if (this.upstream && this.ideCapabilities?.terminal) {
|
|
618
|
+
try {
|
|
619
|
+
return await this.upstream.killTerminal(params);
|
|
620
|
+
} catch (err) {
|
|
621
|
+
logger3.warn({ error: err }, "IDE killTerminal failed, falling back");
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
if (this.local.killTerminal) return this.local.killTerminal(params);
|
|
625
|
+
throw new Error("Terminal not supported");
|
|
626
|
+
}
|
|
627
|
+
async releaseTerminal(params) {
|
|
628
|
+
if (this.upstream && this.ideCapabilities?.terminal) {
|
|
629
|
+
try {
|
|
630
|
+
return await this.upstream.releaseTerminal(params);
|
|
631
|
+
} catch (err) {
|
|
632
|
+
logger3.warn({ error: err }, "IDE releaseTerminal failed, falling back");
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
if (this.local.releaseTerminal) return this.local.releaseTerminal(params);
|
|
636
|
+
throw new Error("Terminal not supported");
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
// src/gateway.ts
|
|
641
|
+
import { Duplex } from "stream";
|
|
642
|
+
import {
|
|
643
|
+
AgentSideConnection,
|
|
644
|
+
ndJsonStream as ndJsonStream2
|
|
645
|
+
} from "@agentclientprotocol/sdk";
|
|
646
|
+
import { createLogger as createLogger4 } from "@actant/shared";
|
|
647
|
+
var logger4 = createLogger4("acp-gateway");
|
|
648
|
+
var AcpGateway = class {
|
|
649
|
+
upstream = null;
|
|
650
|
+
downstream;
|
|
651
|
+
callbackRouter;
|
|
652
|
+
ideCapabilities = null;
|
|
653
|
+
/**
|
|
654
|
+
* WORKAROUND for SDK API limitation (see #95):
|
|
655
|
+
* AgentSideConnection exposes flat methods for fs (readTextFile, writeTextFile)
|
|
656
|
+
* but wraps terminal ops behind TerminalHandle. Ideally the Gateway should be
|
|
657
|
+
* stateless — the IDE (Client) manages its own terminal state keyed by terminalId.
|
|
658
|
+
* We maintain this map only because the SDK doesn't expose flat terminalOutput(),
|
|
659
|
+
* waitForTerminalExit(), killTerminal(), releaseTerminal() on AgentSideConnection.
|
|
660
|
+
* Remove this once the SDK adds flat terminal methods.
|
|
661
|
+
*/
|
|
662
|
+
terminalHandles = /* @__PURE__ */ new Map();
|
|
663
|
+
constructor(options) {
|
|
664
|
+
this.downstream = options.downstream;
|
|
665
|
+
this.callbackRouter = options.callbackRouter;
|
|
666
|
+
}
|
|
667
|
+
get isUpstreamConnected() {
|
|
668
|
+
return this.upstream != null && !this.upstream.signal.aborted;
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Accept an IDE connection on a Unix/named-pipe socket.
|
|
672
|
+
* Creates an AgentSideConnection that bridges to the downstream Agent.
|
|
673
|
+
*/
|
|
674
|
+
acceptSocket(socket) {
|
|
675
|
+
if (this.upstream && !this.upstream.signal.aborted) {
|
|
676
|
+
throw new Error("Gateway already has an active upstream connection");
|
|
677
|
+
}
|
|
678
|
+
const { readable, writable } = Duplex.toWeb(socket);
|
|
679
|
+
const stream = ndJsonStream2(
|
|
680
|
+
writable,
|
|
681
|
+
readable
|
|
682
|
+
);
|
|
683
|
+
this.upstream = new AgentSideConnection(
|
|
684
|
+
(conn) => this.buildAgentHandler(conn),
|
|
685
|
+
stream
|
|
686
|
+
);
|
|
687
|
+
this.upstream.signal.addEventListener("abort", () => {
|
|
688
|
+
logger4.info("Upstream IDE disconnected from Gateway");
|
|
689
|
+
for (const handle of this.terminalHandles.values()) {
|
|
690
|
+
handle.release().catch(() => {
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
this.terminalHandles.clear();
|
|
694
|
+
this.callbackRouter.detachUpstream();
|
|
695
|
+
this.ideCapabilities = null;
|
|
696
|
+
});
|
|
697
|
+
logger4.info("Upstream IDE connected to Gateway");
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Disconnect the upstream IDE.
|
|
701
|
+
*/
|
|
702
|
+
disconnectUpstream() {
|
|
703
|
+
for (const handle of this.terminalHandles.values()) {
|
|
704
|
+
handle.release().catch(() => {
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
this.terminalHandles.clear();
|
|
708
|
+
this.callbackRouter.detachUpstream();
|
|
709
|
+
this.upstream = null;
|
|
710
|
+
this.ideCapabilities = null;
|
|
711
|
+
}
|
|
712
|
+
/* ---------------------------------------------------------------- */
|
|
713
|
+
/* Agent handler (facing IDE) */
|
|
714
|
+
/* ---------------------------------------------------------------- */
|
|
715
|
+
buildAgentHandler(conn) {
|
|
716
|
+
const cachedInit = this.downstream.agentCapabilities;
|
|
717
|
+
const upstreamHandler = {
|
|
718
|
+
requestPermission: (p) => conn.requestPermission(p),
|
|
719
|
+
sessionUpdate: (p) => conn.sessionUpdate(p),
|
|
720
|
+
readTextFile: (p) => conn.readTextFile(p),
|
|
721
|
+
writeTextFile: (p) => conn.writeTextFile(p),
|
|
722
|
+
// Terminal forwarding via TerminalHandle map.
|
|
723
|
+
// SDK limitation: AgentSideConnection doesn't expose flat terminalOutput() etc.
|
|
724
|
+
// so we store handles from createTerminal and delegate through them.
|
|
725
|
+
// The IDE (Client) owns the real terminal state; this map is purely an SDK
|
|
726
|
+
// adapter and should be removed once the SDK exposes flat terminal methods.
|
|
727
|
+
createTerminal: async (p) => {
|
|
728
|
+
const handle = await conn.createTerminal(p);
|
|
729
|
+
this.terminalHandles.set(handle.id, handle);
|
|
730
|
+
return { terminalId: handle.id };
|
|
731
|
+
},
|
|
732
|
+
terminalOutput: async (p) => {
|
|
733
|
+
const handle = this.terminalHandles.get(p.terminalId);
|
|
734
|
+
if (!handle) throw new Error(`Terminal "${p.terminalId}" not found in Gateway handle map`);
|
|
735
|
+
return handle.currentOutput();
|
|
736
|
+
},
|
|
737
|
+
waitForTerminalExit: async (p) => {
|
|
738
|
+
const handle = this.terminalHandles.get(p.terminalId);
|
|
739
|
+
if (!handle) throw new Error(`Terminal "${p.terminalId}" not found in Gateway handle map`);
|
|
740
|
+
return handle.waitForExit();
|
|
741
|
+
},
|
|
742
|
+
killTerminal: async (p) => {
|
|
743
|
+
const handle = this.terminalHandles.get(p.terminalId);
|
|
744
|
+
if (!handle) throw new Error(`Terminal "${p.terminalId}" not found in Gateway handle map`);
|
|
745
|
+
return handle.kill();
|
|
746
|
+
},
|
|
747
|
+
releaseTerminal: async (p) => {
|
|
748
|
+
const handle = this.terminalHandles.get(p.terminalId);
|
|
749
|
+
if (!handle) return {};
|
|
750
|
+
const result = await handle.release();
|
|
751
|
+
this.terminalHandles.delete(p.terminalId);
|
|
752
|
+
return result ?? {};
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
return {
|
|
756
|
+
initialize: async (params) => {
|
|
757
|
+
this.ideCapabilities = params.clientCapabilities ?? {};
|
|
758
|
+
this.callbackRouter.attachUpstream(upstreamHandler, this.ideCapabilities);
|
|
759
|
+
if (cachedInit) {
|
|
760
|
+
return {
|
|
761
|
+
protocolVersion: cachedInit.protocolVersion,
|
|
762
|
+
agentCapabilities: cachedInit.agentCapabilities,
|
|
763
|
+
agentInfo: cachedInit.agentInfo,
|
|
764
|
+
authMethods: cachedInit.authMethods
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
return {
|
|
768
|
+
protocolVersion: 1,
|
|
769
|
+
agentCapabilities: {},
|
|
770
|
+
agentInfo: { name: "actant-gateway", version: "0.1.0" }
|
|
771
|
+
};
|
|
772
|
+
},
|
|
773
|
+
authenticate: async (params) => {
|
|
774
|
+
await this.downstream.authenticate(params.methodId);
|
|
775
|
+
return {};
|
|
776
|
+
},
|
|
777
|
+
newSession: async (params) => {
|
|
778
|
+
const info = await this.downstream.newSession(
|
|
779
|
+
params.cwd,
|
|
780
|
+
params.mcpServers
|
|
781
|
+
);
|
|
782
|
+
return {
|
|
783
|
+
sessionId: info.sessionId,
|
|
784
|
+
modes: info.modes,
|
|
785
|
+
configOptions: info.configOptions
|
|
786
|
+
};
|
|
787
|
+
},
|
|
788
|
+
loadSession: async (params) => {
|
|
789
|
+
await this.downstream.loadSession(params.sessionId, params.cwd);
|
|
790
|
+
return {};
|
|
791
|
+
},
|
|
792
|
+
prompt: async (params) => {
|
|
793
|
+
const conn2 = this.downstream.rawConnection;
|
|
794
|
+
if (!conn2) throw new Error("Downstream not connected");
|
|
795
|
+
return conn2.prompt(params);
|
|
796
|
+
},
|
|
797
|
+
cancel: async (params) => {
|
|
798
|
+
await this.downstream.cancel(params.sessionId);
|
|
799
|
+
},
|
|
800
|
+
setSessionMode: async (params) => {
|
|
801
|
+
await this.downstream.setSessionMode(params.sessionId, params.modeId);
|
|
802
|
+
},
|
|
803
|
+
setSessionConfigOption: async (params) => {
|
|
804
|
+
return await this.downstream.setSessionConfigOption(
|
|
805
|
+
params.sessionId,
|
|
806
|
+
params.configId,
|
|
807
|
+
params.value
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
// src/connection-manager.ts
|
|
815
|
+
var logger5 = createLogger5("acp-connection-manager");
|
|
816
|
+
var AcpConnectionManager = class {
|
|
817
|
+
connections = /* @__PURE__ */ new Map();
|
|
818
|
+
primarySessions = /* @__PURE__ */ new Map();
|
|
819
|
+
routers = /* @__PURE__ */ new Map();
|
|
820
|
+
gateways = /* @__PURE__ */ new Map();
|
|
821
|
+
enforcers = /* @__PURE__ */ new Map();
|
|
822
|
+
/**
|
|
823
|
+
* Spawn an ACP agent process, initialize, and create a default session.
|
|
824
|
+
* Uses ClientCallbackRouter so Gateway can later attach an IDE upstream.
|
|
825
|
+
* When connectionOptions.permissionPolicy is set, creates a PermissionPolicyEnforcer
|
|
826
|
+
* for Layer 2 ACP Client allowlist enforcement.
|
|
827
|
+
*/
|
|
828
|
+
async connect(name, options) {
|
|
829
|
+
if (this.connections.has(name)) {
|
|
830
|
+
throw new Error(`ACP connection for "${name}" already exists`);
|
|
831
|
+
}
|
|
832
|
+
let enforcer;
|
|
833
|
+
let auditLogger;
|
|
834
|
+
if (options.connectionOptions?.permissionPolicy) {
|
|
835
|
+
enforcer = new PermissionPolicyEnforcer2(options.connectionOptions.permissionPolicy);
|
|
836
|
+
auditLogger = new PermissionAuditLogger2(name);
|
|
837
|
+
this.enforcers.set(name, enforcer);
|
|
838
|
+
}
|
|
839
|
+
const localConn = new AcpConnection(options.connectionOptions);
|
|
840
|
+
const localHandler = buildLocalHandler(localConn, options.connectionOptions, enforcer, auditLogger);
|
|
841
|
+
const router = new ClientCallbackRouter(localHandler);
|
|
842
|
+
const connWithRouter = new AcpConnection({
|
|
843
|
+
...options.connectionOptions,
|
|
844
|
+
callbackHandler: router
|
|
845
|
+
});
|
|
846
|
+
this.connections.set(name, connWithRouter);
|
|
847
|
+
this.routers.set(name, router);
|
|
848
|
+
try {
|
|
849
|
+
await connWithRouter.spawn(options.command, options.args, options.cwd);
|
|
850
|
+
await connWithRouter.initialize();
|
|
851
|
+
const session = await connWithRouter.newSession(options.cwd);
|
|
852
|
+
this.primarySessions.set(name, session.sessionId);
|
|
853
|
+
const gateway = new AcpGateway({
|
|
854
|
+
downstream: connWithRouter,
|
|
855
|
+
callbackRouter: router
|
|
856
|
+
});
|
|
857
|
+
this.gateways.set(name, gateway);
|
|
858
|
+
logger5.info({ name, sessionId: session.sessionId }, "ACP agent connected (gateway-ready)");
|
|
859
|
+
return session;
|
|
860
|
+
} catch (err) {
|
|
861
|
+
await connWithRouter.close().catch(() => {
|
|
862
|
+
});
|
|
863
|
+
this.connections.delete(name);
|
|
864
|
+
this.primarySessions.delete(name);
|
|
865
|
+
this.routers.delete(name);
|
|
866
|
+
throw err;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Accept an IDE connection on the Gateway for a named agent.
|
|
871
|
+
* The IDE socket carries ACP protocol messages.
|
|
872
|
+
*/
|
|
873
|
+
acceptLeaseSocket(name, socket) {
|
|
874
|
+
const gateway = this.gateways.get(name);
|
|
875
|
+
if (!gateway) {
|
|
876
|
+
throw new Error(`No gateway for agent "${name}". Is the agent connected via ACP?`);
|
|
877
|
+
}
|
|
878
|
+
gateway.acceptSocket(socket);
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Disconnect IDE from the Gateway.
|
|
882
|
+
*/
|
|
883
|
+
disconnectLease(name) {
|
|
884
|
+
this.gateways.get(name)?.disconnectUpstream();
|
|
885
|
+
}
|
|
886
|
+
getConnection(name) {
|
|
887
|
+
return this.connections.get(name);
|
|
888
|
+
}
|
|
889
|
+
getGateway(name) {
|
|
890
|
+
return this.gateways.get(name);
|
|
891
|
+
}
|
|
892
|
+
getRouter(name) {
|
|
893
|
+
return this.routers.get(name);
|
|
894
|
+
}
|
|
895
|
+
getPrimarySessionId(name) {
|
|
896
|
+
return this.primarySessions.get(name);
|
|
897
|
+
}
|
|
898
|
+
has(name) {
|
|
899
|
+
const conn = this.connections.get(name);
|
|
900
|
+
return conn != null && conn.isConnected;
|
|
901
|
+
}
|
|
902
|
+
async disconnect(name) {
|
|
903
|
+
this.gateways.get(name)?.disconnectUpstream();
|
|
904
|
+
this.gateways.delete(name);
|
|
905
|
+
this.routers.delete(name);
|
|
906
|
+
this.enforcers.delete(name);
|
|
907
|
+
const conn = this.connections.get(name);
|
|
908
|
+
if (!conn) return;
|
|
909
|
+
await conn.close();
|
|
910
|
+
this.connections.delete(name);
|
|
911
|
+
this.primarySessions.delete(name);
|
|
912
|
+
logger5.info({ name }, "ACP agent disconnected");
|
|
913
|
+
}
|
|
914
|
+
async disposeAll() {
|
|
915
|
+
const names = Array.from(this.connections.keys());
|
|
916
|
+
await Promise.allSettled(names.map((n) => this.disconnect(n)));
|
|
917
|
+
logger5.info({ count: names.length }, "All ACP connections disposed");
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Update the permission policy for a named connection at runtime.
|
|
921
|
+
* Propagates to both the AcpConnection and the local handler enforcer.
|
|
922
|
+
*/
|
|
923
|
+
updatePermissionPolicy(name, config) {
|
|
924
|
+
const conn = this.connections.get(name);
|
|
925
|
+
if (conn) {
|
|
926
|
+
conn.updatePermissionPolicy(config);
|
|
927
|
+
}
|
|
928
|
+
const enforcer = this.enforcers.get(name);
|
|
929
|
+
if (enforcer) {
|
|
930
|
+
enforcer.updateConfig(config);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
};
|
|
934
|
+
function buildLocalHandler(_conn, options, enforcer, auditLogger) {
|
|
935
|
+
const terminalManager = new LocalTerminalManager();
|
|
936
|
+
return {
|
|
937
|
+
requestPermission: async (params) => {
|
|
938
|
+
if (enforcer && params.options.length > 0) {
|
|
939
|
+
const toolInfo = {
|
|
940
|
+
kind: params.toolCall?.kind ?? void 0,
|
|
941
|
+
title: params.toolCall?.title ?? void 0,
|
|
942
|
+
toolCallId: params.toolCall?.toolCallId ?? "unknown"
|
|
943
|
+
};
|
|
944
|
+
const decision = enforcer.evaluate(toolInfo);
|
|
945
|
+
auditLogger?.logEvaluation(toolInfo, decision);
|
|
946
|
+
if (decision.action === "allow" || decision.action === "deny") {
|
|
947
|
+
const outcome = enforcer.buildOutcome(decision, params.options);
|
|
948
|
+
return { outcome };
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
if (options?.autoApprove && params.options.length > 0) {
|
|
952
|
+
const opt = params.options.find(
|
|
953
|
+
(o) => o.kind === "allow_once" || o.kind === "allow_always"
|
|
954
|
+
) ?? params.options[0];
|
|
955
|
+
if (!opt) return { outcome: { outcome: "cancelled" } };
|
|
956
|
+
return { outcome: { outcome: "selected", optionId: opt.optionId } };
|
|
957
|
+
}
|
|
958
|
+
return { outcome: { outcome: "cancelled" } };
|
|
959
|
+
},
|
|
960
|
+
sessionUpdate: async (params) => {
|
|
961
|
+
options?.onSessionUpdate?.(params);
|
|
962
|
+
},
|
|
963
|
+
readTextFile: async (params) => {
|
|
964
|
+
const { readFile } = await import("fs/promises");
|
|
965
|
+
const raw = await readFile(params.path, "utf-8");
|
|
966
|
+
if (params.line != null || params.limit != null) {
|
|
967
|
+
const lines = raw.split("\n");
|
|
968
|
+
const start = Math.max(0, (params.line ?? 1) - 1);
|
|
969
|
+
const end = params.limit != null ? start + params.limit : lines.length;
|
|
970
|
+
return { content: lines.slice(start, end).join("\n") };
|
|
971
|
+
}
|
|
972
|
+
return { content: raw };
|
|
973
|
+
},
|
|
974
|
+
writeTextFile: async (params) => {
|
|
975
|
+
const { writeFile, mkdir } = await import("fs/promises");
|
|
976
|
+
const { dirname } = await import("path");
|
|
977
|
+
await mkdir(dirname(params.path), { recursive: true });
|
|
978
|
+
await writeFile(params.path, params.content, "utf-8");
|
|
979
|
+
return {};
|
|
980
|
+
},
|
|
981
|
+
createTerminal: (p) => terminalManager.createTerminal(p),
|
|
982
|
+
terminalOutput: (p) => terminalManager.terminalOutput(p),
|
|
983
|
+
waitForTerminalExit: (p) => terminalManager.waitForExit(p),
|
|
984
|
+
killTerminal: (p) => terminalManager.killTerminal(p),
|
|
985
|
+
releaseTerminal: (p) => terminalManager.releaseTerminal(p)
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// src/communicator.ts
|
|
990
|
+
import { createLogger as createLogger6 } from "@actant/shared";
|
|
991
|
+
var logger6 = createLogger6("acp-communicator");
|
|
992
|
+
var AcpCommunicator = class {
|
|
993
|
+
constructor(connection, sessionId) {
|
|
994
|
+
this.connection = connection;
|
|
995
|
+
this.sessionId = sessionId;
|
|
996
|
+
}
|
|
997
|
+
async runPrompt(_workspaceDir, prompt, _options) {
|
|
998
|
+
logger6.debug({ sessionId: this.sessionId, promptLength: prompt.length }, "Sending ACP prompt");
|
|
999
|
+
const result = await this.connection.prompt(this.sessionId, prompt);
|
|
1000
|
+
return { text: result.text, sessionId: this.sessionId };
|
|
1001
|
+
}
|
|
1002
|
+
async *streamPrompt(_workspaceDir, prompt, _options) {
|
|
1003
|
+
logger6.debug({ sessionId: this.sessionId, promptLength: prompt.length }, "Streaming ACP prompt");
|
|
1004
|
+
for await (const notification of this.connection.streamPrompt(this.sessionId, prompt)) {
|
|
1005
|
+
const chunks = mapNotificationToChunks(notification);
|
|
1006
|
+
for (const chunk of chunks) {
|
|
1007
|
+
yield chunk;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
};
|
|
1012
|
+
function mapNotificationToChunks(notification) {
|
|
1013
|
+
const update = notification.update;
|
|
1014
|
+
switch (update.sessionUpdate) {
|
|
1015
|
+
case "agent_message_chunk":
|
|
1016
|
+
return [mapContentToChunk(update.content)].filter(Boolean);
|
|
1017
|
+
case "agent_thought_chunk":
|
|
1018
|
+
if (update.content.type === "text") {
|
|
1019
|
+
return [{ type: "text", content: `[Thought] ${update.content.text}` }];
|
|
1020
|
+
}
|
|
1021
|
+
return [];
|
|
1022
|
+
case "user_message_chunk":
|
|
1023
|
+
return [];
|
|
1024
|
+
case "tool_call":
|
|
1025
|
+
return [{
|
|
1026
|
+
type: "tool_use",
|
|
1027
|
+
content: `[Tool: ${update.title ?? "unknown"}] ${update.toolCallId} (${update.kind ?? "other"}) [${update.status ?? "pending"}]`
|
|
1028
|
+
}];
|
|
1029
|
+
case "tool_call_update": {
|
|
1030
|
+
const chunks = [];
|
|
1031
|
+
if (update.content) {
|
|
1032
|
+
for (const item of update.content) {
|
|
1033
|
+
if (item.type === "content" && item.content.type === "text") {
|
|
1034
|
+
chunks.push({ type: "text", content: item.content.text });
|
|
1035
|
+
} else if (item.type === "diff") {
|
|
1036
|
+
chunks.push({
|
|
1037
|
+
type: "text",
|
|
1038
|
+
content: `[Diff: ${item.path ?? ""}]`
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
return chunks;
|
|
1044
|
+
}
|
|
1045
|
+
case "plan": {
|
|
1046
|
+
const entries = update.entries;
|
|
1047
|
+
return [{
|
|
1048
|
+
type: "text",
|
|
1049
|
+
content: entries?.map((e) => `[Plan ${e.status}] ${e.content}`).join("\n") ?? "[Plan updated]"
|
|
1050
|
+
}];
|
|
1051
|
+
}
|
|
1052
|
+
case "available_commands_update":
|
|
1053
|
+
return [];
|
|
1054
|
+
case "current_mode_update":
|
|
1055
|
+
return [{
|
|
1056
|
+
type: "text",
|
|
1057
|
+
content: `[Mode changed: ${update.modeId ?? "unknown"}]`
|
|
1058
|
+
}];
|
|
1059
|
+
case "config_option_update":
|
|
1060
|
+
return [];
|
|
1061
|
+
default:
|
|
1062
|
+
return [];
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
function mapContentToChunk(content) {
|
|
1066
|
+
switch (content.type) {
|
|
1067
|
+
case "text":
|
|
1068
|
+
return { type: "text", content: content.text };
|
|
1069
|
+
case "image":
|
|
1070
|
+
return { type: "text", content: "[Image content]" };
|
|
1071
|
+
case "audio":
|
|
1072
|
+
return { type: "text", content: "[Audio content]" };
|
|
1073
|
+
case "resource":
|
|
1074
|
+
return { type: "text", content: `[Resource: ${content.resource?.uri ?? "unknown"}]` };
|
|
1075
|
+
case "resource_link":
|
|
1076
|
+
return { type: "text", content: `[ResourceLink: ${content.uri ?? "unknown"}]` };
|
|
1077
|
+
default:
|
|
1078
|
+
return null;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
export {
|
|
1082
|
+
AcpCommunicator,
|
|
1083
|
+
AcpConnection,
|
|
1084
|
+
AcpConnectionManager,
|
|
1085
|
+
AcpGateway,
|
|
1086
|
+
ClientCallbackRouter,
|
|
1087
|
+
LocalTerminalManager
|
|
1088
|
+
};
|
|
1089
|
+
//# sourceMappingURL=index.js.map
|