@peanut996/acp-router 0.8.0
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/LICENSE +21 -0
- package/README.md +350 -0
- package/dist/acp-client.d.ts +179 -0
- package/dist/acp-client.d.ts.map +1 -0
- package/dist/acp-client.js +718 -0
- package/dist/acp-client.js.map +1 -0
- package/dist/agents.d.ts +180 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/agents.js +430 -0
- package/dist/agents.js.map +1 -0
- package/dist/bin/acp-router-cli.d.ts +3 -0
- package/dist/bin/acp-router-cli.d.ts.map +1 -0
- package/dist/bin/acp-router-cli.js +252 -0
- package/dist/bin/acp-router-cli.js.map +1 -0
- package/dist/bin/acp-router.d.ts +3 -0
- package/dist/bin/acp-router.d.ts.map +1 -0
- package/dist/bin/acp-router.js +7 -0
- package/dist/bin/acp-router.js.map +1 -0
- package/dist/constants.d.ts +44 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +131 -0
- package/dist/constants.js.map +1 -0
- package/dist/jobs.d.ts +133 -0
- package/dist/jobs.d.ts.map +1 -0
- package/dist/jobs.js +716 -0
- package/dist/jobs.js.map +1 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +180 -0
- package/dist/server.js.map +1 -0
- package/dist/storage.d.ts +166 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +506 -0
- package/dist/storage.js.map +1 -0
- package/dist/utils.d.ts +31 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +78 -0
- package/dist/utils.js.map +1 -0
- package/package.json +55 -0
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { SERVER_NAME, SERVER_VERSION, ACP_MODE_MAP, ACP_STARTUP_DELAY_MS, AGENT_ERROR_PATTERNS, AGENT_ERROR_KEY_PATTERN } from "./constants.js";
|
|
3
|
+
import { safeEnv, sleep, preview, isPlainObject, uniqueStrings, buildAcpProcessClosedEvent } from "./utils.js";
|
|
4
|
+
import { appendJsonl } from "./storage.js";
|
|
5
|
+
import { resolveAcpLaunchTarget, collectWorktreeState } from "./agents.js";
|
|
6
|
+
class AcpStdioClient {
|
|
7
|
+
command;
|
|
8
|
+
args;
|
|
9
|
+
cwd;
|
|
10
|
+
timeoutMs;
|
|
11
|
+
env;
|
|
12
|
+
permissionProfile;
|
|
13
|
+
onEvent;
|
|
14
|
+
onProcessStart;
|
|
15
|
+
nextId;
|
|
16
|
+
pending;
|
|
17
|
+
stdoutBuffer;
|
|
18
|
+
logEvents;
|
|
19
|
+
child;
|
|
20
|
+
startError;
|
|
21
|
+
constructor({ command, args, cwd, timeoutMs, env, permissionProfile, onEvent, onProcessStart }) {
|
|
22
|
+
this.command = command;
|
|
23
|
+
this.args = args;
|
|
24
|
+
this.cwd = cwd;
|
|
25
|
+
this.timeoutMs = timeoutMs;
|
|
26
|
+
this.env = env ?? safeEnv();
|
|
27
|
+
this.permissionProfile = permissionProfile ?? "bypassPermissions";
|
|
28
|
+
this.onEvent = onEvent ?? (() => { });
|
|
29
|
+
this.onProcessStart = onProcessStart;
|
|
30
|
+
this.nextId = 1;
|
|
31
|
+
this.pending = new Map();
|
|
32
|
+
this.stdoutBuffer = "";
|
|
33
|
+
this.logEvents = [];
|
|
34
|
+
this.child = null;
|
|
35
|
+
this.startError = null;
|
|
36
|
+
}
|
|
37
|
+
async start() {
|
|
38
|
+
const currentDepth = Number.parseInt(process.env.ACP_ROUTER_DEPTH ?? "0", 10) || 0;
|
|
39
|
+
const childEnv = {
|
|
40
|
+
...this.env,
|
|
41
|
+
ACP_ROUTER_DEPTH: String(currentDepth + 1)
|
|
42
|
+
};
|
|
43
|
+
this.child = spawn(this.command, this.args, {
|
|
44
|
+
cwd: this.cwd,
|
|
45
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
46
|
+
env: childEnv
|
|
47
|
+
});
|
|
48
|
+
this.child.stdout.setEncoding("utf8");
|
|
49
|
+
this.child.stderr.setEncoding("utf8");
|
|
50
|
+
if (typeof this.onProcessStart === "function") {
|
|
51
|
+
await Promise.resolve(this.onProcessStart(this.child)).catch((error) => {
|
|
52
|
+
this.logEvents.push({
|
|
53
|
+
type: "process_record_error",
|
|
54
|
+
timestamp: new Date().toISOString(),
|
|
55
|
+
message: `Failed to record ACP process pid: ${error.message}`
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
this.child.stdout.on("data", (chunk) => this.handleStdout(chunk));
|
|
60
|
+
this.child.stderr.on("data", (chunk) => this.handleStderr(chunk));
|
|
61
|
+
this.child.on("error", (error) => {
|
|
62
|
+
this.startError = error;
|
|
63
|
+
this.rejectPending(error);
|
|
64
|
+
});
|
|
65
|
+
this.child.on("exit", (code, signal) => this.rejectPending(new Error(`ACP process exited with code=${code} signal=${signal}`)));
|
|
66
|
+
await sleep(ACP_STARTUP_DELAY_MS);
|
|
67
|
+
if (this.startError)
|
|
68
|
+
throw this.startError;
|
|
69
|
+
}
|
|
70
|
+
request(method, params) {
|
|
71
|
+
const id = this.nextId++;
|
|
72
|
+
const payload = { jsonrpc: "2.0", id, method, params };
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
this.pending.delete(id);
|
|
76
|
+
const stderrTail = this.logEvents
|
|
77
|
+
.filter((e) => e.type === "acp_stderr")
|
|
78
|
+
.slice(-5)
|
|
79
|
+
.map((e) => e.message)
|
|
80
|
+
.join("\n");
|
|
81
|
+
const error = new Error(stderrTail
|
|
82
|
+
? `ACP request timed out: ${method}. Recent stderr:\n${stderrTail}`
|
|
83
|
+
: `ACP request timed out: ${method} (no stderr output).`);
|
|
84
|
+
error.code = "timeout";
|
|
85
|
+
reject(error);
|
|
86
|
+
}, this.timeoutMs);
|
|
87
|
+
this.pending.set(id, { method, resolve, reject, timer });
|
|
88
|
+
this.write(payload);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
respond(id, result) {
|
|
92
|
+
this.write({ jsonrpc: "2.0", id, result });
|
|
93
|
+
}
|
|
94
|
+
respondError(id, code, message) {
|
|
95
|
+
this.write({ jsonrpc: "2.0", id, error: { code, message } });
|
|
96
|
+
}
|
|
97
|
+
write(payload) {
|
|
98
|
+
if (!this.child || !this.child.stdin.writable) {
|
|
99
|
+
throw new Error("ACP process is not writable.");
|
|
100
|
+
}
|
|
101
|
+
this.child.stdin.write(`${JSON.stringify(payload)}\n`);
|
|
102
|
+
}
|
|
103
|
+
handleStdout(chunk) {
|
|
104
|
+
this.stdoutBuffer += chunk;
|
|
105
|
+
while (true) {
|
|
106
|
+
const newlineIndex = this.stdoutBuffer.indexOf("\n");
|
|
107
|
+
if (newlineIndex === -1)
|
|
108
|
+
return;
|
|
109
|
+
const line = this.stdoutBuffer.slice(0, newlineIndex).trim();
|
|
110
|
+
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
|
|
111
|
+
if (!line)
|
|
112
|
+
continue;
|
|
113
|
+
this.handleMessageLine(line);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
handleMessageLine(line) {
|
|
117
|
+
let message;
|
|
118
|
+
try {
|
|
119
|
+
message = JSON.parse(line);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
this.logEvents.push({
|
|
123
|
+
type: "acp_stdout_parse_error",
|
|
124
|
+
timestamp: new Date().toISOString(),
|
|
125
|
+
message: preview(line, 300)
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (Object.prototype.hasOwnProperty.call(message, "id") && (message.result || message.error)) {
|
|
130
|
+
const pending = this.pending.get(message.id);
|
|
131
|
+
if (!pending)
|
|
132
|
+
return;
|
|
133
|
+
clearTimeout(pending.timer);
|
|
134
|
+
this.pending.delete(message.id);
|
|
135
|
+
if (message.error) {
|
|
136
|
+
pending.reject(new Error(`${pending.method} failed: ${message.error.message ?? JSON.stringify(message.error)}`));
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
pending.resolve(message.result);
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (message.method && Object.prototype.hasOwnProperty.call(message, "id")) {
|
|
144
|
+
this.handleClientRequest(message);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (message.method) {
|
|
148
|
+
this.handleNotification(message);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
handleClientRequest(message) {
|
|
152
|
+
if (message.method === "session/request_permission") {
|
|
153
|
+
const outcome = this.resolvePermissionOutcome(message.params);
|
|
154
|
+
this.logEvents.push({
|
|
155
|
+
type: outcome === "approved" ? "acp_permission_approved" : "acp_permission_cancelled",
|
|
156
|
+
timestamp: new Date().toISOString(),
|
|
157
|
+
message: outcome === "approved"
|
|
158
|
+
? `Agent Router approved an ACP permission request (${this.permissionProfile}).`
|
|
159
|
+
: "Agent Router cancelled an ACP permission request.",
|
|
160
|
+
params: message.params
|
|
161
|
+
});
|
|
162
|
+
this.respond(message.id, { outcome });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
this.respondError(message.id, -32601, `Unsupported client method: ${message.method}`);
|
|
166
|
+
}
|
|
167
|
+
resolvePermissionOutcome(params) {
|
|
168
|
+
switch (this.permissionProfile) {
|
|
169
|
+
case "bypassPermissions":
|
|
170
|
+
return "approved";
|
|
171
|
+
case "acceptEdits": {
|
|
172
|
+
const perms = params?.permissions ?? [];
|
|
173
|
+
const hasNonFilePermission = perms.some((p) => p.type !== "file_edit" && p.type !== "write");
|
|
174
|
+
if (hasNonFilePermission)
|
|
175
|
+
return "cancelled";
|
|
176
|
+
return "approved";
|
|
177
|
+
}
|
|
178
|
+
case "plan":
|
|
179
|
+
return "cancelled";
|
|
180
|
+
default:
|
|
181
|
+
return "cancelled";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
handleNotification(message) {
|
|
185
|
+
const event = normalizeAcpNotification(message);
|
|
186
|
+
this.onEvent(event);
|
|
187
|
+
}
|
|
188
|
+
handleStderr(chunk) {
|
|
189
|
+
for (const line of chunk.split(/\r?\n/).filter(Boolean)) {
|
|
190
|
+
const event = {
|
|
191
|
+
type: "acp_stderr",
|
|
192
|
+
timestamp: new Date().toISOString(),
|
|
193
|
+
message: preview(line, 500)
|
|
194
|
+
};
|
|
195
|
+
if (typeof this.onEvent === "function") {
|
|
196
|
+
try {
|
|
197
|
+
this.onEvent(event);
|
|
198
|
+
}
|
|
199
|
+
catch { }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
drainLogEvents() {
|
|
204
|
+
const events = this.logEvents;
|
|
205
|
+
this.logEvents = [];
|
|
206
|
+
return events;
|
|
207
|
+
}
|
|
208
|
+
rejectPending(error) {
|
|
209
|
+
for (const pending of this.pending.values()) {
|
|
210
|
+
clearTimeout(pending.timer);
|
|
211
|
+
pending.reject(error);
|
|
212
|
+
}
|
|
213
|
+
this.pending.clear();
|
|
214
|
+
}
|
|
215
|
+
dispose() {
|
|
216
|
+
for (const pending of this.pending.values())
|
|
217
|
+
clearTimeout(pending.timer);
|
|
218
|
+
this.pending.clear();
|
|
219
|
+
if (this.child && !this.child.killed) {
|
|
220
|
+
this.child.kill("SIGTERM");
|
|
221
|
+
const timer = setTimeout(() => {
|
|
222
|
+
if (this.child && !this.child.killed) {
|
|
223
|
+
this.child.kill("SIGKILL");
|
|
224
|
+
}
|
|
225
|
+
}, 1000);
|
|
226
|
+
timer.unref?.();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function normalizeAcpNotification(message) {
|
|
231
|
+
if (message.method === "session/update") {
|
|
232
|
+
const update = message.params?.update ?? {};
|
|
233
|
+
const event = {
|
|
234
|
+
type: `acp_${update.sessionUpdate ?? "session_update"}`,
|
|
235
|
+
timestamp: new Date().toISOString(),
|
|
236
|
+
message: describeSessionUpdate(update),
|
|
237
|
+
params: message.params
|
|
238
|
+
};
|
|
239
|
+
return event;
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
type: `acp_${message.method.replaceAll("/", "_")}`,
|
|
243
|
+
timestamp: new Date().toISOString(),
|
|
244
|
+
message: message.method,
|
|
245
|
+
params: message.params
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function describeSessionUpdate(update) {
|
|
249
|
+
if (update.sessionUpdate === "agent_message_chunk") {
|
|
250
|
+
return preview(update.content?.text ?? "", 300);
|
|
251
|
+
}
|
|
252
|
+
if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") {
|
|
253
|
+
return preview(update.title ?? update.status ?? update.toolCallId ?? "tool call update", 300);
|
|
254
|
+
}
|
|
255
|
+
if (update.sessionUpdate === "plan") {
|
|
256
|
+
return "Agent plan update.";
|
|
257
|
+
}
|
|
258
|
+
if (update.sessionUpdate === "available_commands_update") {
|
|
259
|
+
return `Available commands updated: ${(update.availableCommands ?? []).length}`;
|
|
260
|
+
}
|
|
261
|
+
return update.sessionUpdate ?? "session update";
|
|
262
|
+
}
|
|
263
|
+
function summarizeInitializeResult(result) {
|
|
264
|
+
return {
|
|
265
|
+
protocolVersion: result.protocolVersion,
|
|
266
|
+
agentInfo: result.agentInfo ?? null,
|
|
267
|
+
agentCapabilities: {
|
|
268
|
+
loadSession: Boolean(result.agentCapabilities?.loadSession),
|
|
269
|
+
sessionCapabilities: Object.keys(result.agentCapabilities?.sessionCapabilities ?? {})
|
|
270
|
+
},
|
|
271
|
+
authMethods: (result.authMethods ?? []).map((method) => ({ id: method.id, name: method.name }))
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
function summarizeAcpConfigOptions(configOptions) {
|
|
275
|
+
if (!Array.isArray(configOptions))
|
|
276
|
+
return [];
|
|
277
|
+
return configOptions
|
|
278
|
+
.map((option) => {
|
|
279
|
+
const id = option.id ?? option.configId ?? null;
|
|
280
|
+
const title = option.title ?? option.name ?? option.label ?? id;
|
|
281
|
+
const category = option.category ?? null;
|
|
282
|
+
const description = option.description ? preview(option.description, 300) : null;
|
|
283
|
+
const choices = summarizeConfigChoices(option.options ?? option.values ?? option.choices);
|
|
284
|
+
return {
|
|
285
|
+
id,
|
|
286
|
+
title,
|
|
287
|
+
category,
|
|
288
|
+
type: option.type ?? option.input?.type ?? (choices.length > 0 ? "select" : null),
|
|
289
|
+
description,
|
|
290
|
+
currentValue: summarizeConfigValue(option.value ?? option.currentValue ?? option.defaultValue),
|
|
291
|
+
options: choices
|
|
292
|
+
};
|
|
293
|
+
})
|
|
294
|
+
.filter((option) => option.id || option.category || option.options.length > 0);
|
|
295
|
+
}
|
|
296
|
+
function summarizeConfigChoices(choices) {
|
|
297
|
+
if (!Array.isArray(choices))
|
|
298
|
+
return [];
|
|
299
|
+
return choices.map((choice) => {
|
|
300
|
+
if (typeof choice === "string")
|
|
301
|
+
return { value: choice, label: choice, description: null };
|
|
302
|
+
if (!isPlainObject(choice))
|
|
303
|
+
return null;
|
|
304
|
+
const value = choice.value ?? choice.id ?? choice.name ?? choice.label ?? choice.title;
|
|
305
|
+
const label = choice.label ?? choice.title ?? choice.name ?? choice.value ?? choice.id;
|
|
306
|
+
if (typeof value !== "string" || !value)
|
|
307
|
+
return null;
|
|
308
|
+
return {
|
|
309
|
+
value,
|
|
310
|
+
label: typeof label === "string" && label ? label : value,
|
|
311
|
+
description: choice.description ? preview(choice.description, 300) : null
|
|
312
|
+
};
|
|
313
|
+
}).filter((choice) => choice !== null);
|
|
314
|
+
}
|
|
315
|
+
function summarizeConfigValue(value) {
|
|
316
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
|
|
317
|
+
return value;
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
function extractModelOptions(configOptions) {
|
|
321
|
+
return configOptions
|
|
322
|
+
.filter((option) => (option.category === "model"
|
|
323
|
+
|| /model/i.test(option.id ?? "")
|
|
324
|
+
|| /model/i.test(option.title ?? "")))
|
|
325
|
+
.flatMap((option) => option.options.map((choice) => ({
|
|
326
|
+
configId: option.id,
|
|
327
|
+
value: choice.value,
|
|
328
|
+
label: choice.label,
|
|
329
|
+
description: choice.description
|
|
330
|
+
})));
|
|
331
|
+
}
|
|
332
|
+
function buildDispatchPrompt(prompt) {
|
|
333
|
+
return [
|
|
334
|
+
prompt,
|
|
335
|
+
"",
|
|
336
|
+
"When you finish, report:",
|
|
337
|
+
"- changed files",
|
|
338
|
+
"- validation commands and results",
|
|
339
|
+
"- risks or incomplete work"
|
|
340
|
+
].join("\n");
|
|
341
|
+
}
|
|
342
|
+
function extractAgentText(events) {
|
|
343
|
+
const chunks = events
|
|
344
|
+
.filter((event) => event.type === "acp_agent_message_chunk")
|
|
345
|
+
.map((event) => event.params?.update?.content?.text)
|
|
346
|
+
.filter(Boolean);
|
|
347
|
+
return chunks.join("").trim();
|
|
348
|
+
}
|
|
349
|
+
function extractAgentErrors(events) {
|
|
350
|
+
const candidates = [];
|
|
351
|
+
for (const event of events) {
|
|
352
|
+
candidates.push(event.message);
|
|
353
|
+
candidates.push(event.errorMessage);
|
|
354
|
+
candidates.push(event.params?.update?.content?.text);
|
|
355
|
+
candidates.push(event.params?.update?.error?.message);
|
|
356
|
+
candidates.push(event.params?.update?.message);
|
|
357
|
+
candidates.push(event.params?.error?.message);
|
|
358
|
+
candidates.push(event.result?.error?.message);
|
|
359
|
+
candidates.push(event.payload?.error?.message);
|
|
360
|
+
candidates.push(...collectDiagnosticStrings(event.params));
|
|
361
|
+
candidates.push(...collectDiagnosticStrings(event.payload));
|
|
362
|
+
}
|
|
363
|
+
return uniqueStrings(candidates
|
|
364
|
+
.filter((value) => typeof value === "string")
|
|
365
|
+
.map((value) => preview(value.trim().replace(/\s+/g, " "), 500))
|
|
366
|
+
.filter(Boolean)
|
|
367
|
+
.filter((value) => AGENT_ERROR_PATTERNS.some((pattern) => pattern.test(value)))).slice(0, 10);
|
|
368
|
+
}
|
|
369
|
+
function collectDiagnosticStrings(value, depth = 0) {
|
|
370
|
+
if (depth > 5 || value == null)
|
|
371
|
+
return [];
|
|
372
|
+
if (typeof value === "string")
|
|
373
|
+
return [value];
|
|
374
|
+
if (Array.isArray(value))
|
|
375
|
+
return value.flatMap((item) => collectDiagnosticStrings(item, depth + 1));
|
|
376
|
+
if (typeof value !== "object")
|
|
377
|
+
return [];
|
|
378
|
+
const values = [];
|
|
379
|
+
for (const [key, child] of Object.entries(value)) {
|
|
380
|
+
if (AGENT_ERROR_KEY_PATTERN.test(key)
|
|
381
|
+
&& (typeof child === "string" || typeof child === "number" || typeof child === "boolean")) {
|
|
382
|
+
values.push(`${key}: ${child}`);
|
|
383
|
+
}
|
|
384
|
+
values.push(...collectDiagnosticStrings(child, depth + 1));
|
|
385
|
+
}
|
|
386
|
+
return values;
|
|
387
|
+
}
|
|
388
|
+
function buildFailureReason(adapterLabel, error, agentErrors) {
|
|
389
|
+
if (agentErrors.length > 0) {
|
|
390
|
+
const suffix = error.code === "timeout" ? " (request timed out after agent error)" : "";
|
|
391
|
+
return `${adapterLabel} failed: ${agentErrors.join("; ")}${suffix}`;
|
|
392
|
+
}
|
|
393
|
+
return `${adapterLabel} failed: ${error.message}`;
|
|
394
|
+
}
|
|
395
|
+
function diffChangedFiles(beforeState, afterState) {
|
|
396
|
+
const before = new Set(Array.isArray(beforeState?.preExistingChangedFiles) ? beforeState.preExistingChangedFiles : []);
|
|
397
|
+
const afterFiles = afterState?.preExistingChangedFiles;
|
|
398
|
+
const after = Array.isArray(afterFiles) ? afterFiles : [];
|
|
399
|
+
const introduced = after.filter((file) => !before.has(file));
|
|
400
|
+
return introduced.length > 0 ? introduced : after;
|
|
401
|
+
}
|
|
402
|
+
async function runAcpStdioJob({ args, job, session, selectedAgent, timeoutSec, agentEnv, controller }) {
|
|
403
|
+
const acpSpec = selectedAgent.acp;
|
|
404
|
+
const launchTarget = resolveAcpLaunchTarget(acpSpec, selectedAgent, args.worktree);
|
|
405
|
+
if (!launchTarget)
|
|
406
|
+
throw new Error(`No ACP adapter is available for ${selectedAgent.id}.`);
|
|
407
|
+
const adapterLabel = acpSpec?.label ?? `${selectedAgent.displayName} ACP`;
|
|
408
|
+
const adapterStatus = acpSpec?.adapterStatus ?? `${selectedAgent.id}_acp`;
|
|
409
|
+
const permissionProfile = job.permissionProfile ?? "bypassPermissions";
|
|
410
|
+
const events = [];
|
|
411
|
+
const startedAt = Date.now();
|
|
412
|
+
let providerSessionId = session.providerSessionId ?? null;
|
|
413
|
+
let agentConfigOptions = [];
|
|
414
|
+
let availableModels = [];
|
|
415
|
+
let writeChain = Promise.resolve();
|
|
416
|
+
const streamEvent = (event) => {
|
|
417
|
+
events.push(event);
|
|
418
|
+
writeChain = writeChain.then(() => appendJsonl(job.logPath, [{
|
|
419
|
+
...event,
|
|
420
|
+
jobId: job.jobId,
|
|
421
|
+
sessionId: job.sessionId,
|
|
422
|
+
agentId: selectedAgent.id
|
|
423
|
+
}]).catch(() => { }));
|
|
424
|
+
};
|
|
425
|
+
const client = new AcpStdioClient({
|
|
426
|
+
command: launchTarget.command,
|
|
427
|
+
args: launchTarget.args,
|
|
428
|
+
cwd: args.worktree,
|
|
429
|
+
timeoutMs: timeoutSec * 1000,
|
|
430
|
+
env: agentEnv,
|
|
431
|
+
permissionProfile,
|
|
432
|
+
onEvent: streamEvent,
|
|
433
|
+
onProcessStart: (child) => controller?.recordProcess({
|
|
434
|
+
pid: child.pid,
|
|
435
|
+
kind: "acp_stdio",
|
|
436
|
+
command: launchTarget.processLabel,
|
|
437
|
+
startedAt: new Date().toISOString()
|
|
438
|
+
})
|
|
439
|
+
});
|
|
440
|
+
if (controller) {
|
|
441
|
+
controller.cancelProcess = () => {
|
|
442
|
+
client.dispose();
|
|
443
|
+
return true;
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
await client.start();
|
|
448
|
+
const initialize = await client.request("initialize", {
|
|
449
|
+
protocolVersion: 1,
|
|
450
|
+
clientCapabilities: {},
|
|
451
|
+
clientInfo: {
|
|
452
|
+
name: SERVER_NAME,
|
|
453
|
+
title: "Agent Router",
|
|
454
|
+
version: SERVER_VERSION
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
streamEvent({
|
|
458
|
+
type: "acp_initialize",
|
|
459
|
+
timestamp: new Date().toISOString(),
|
|
460
|
+
message: `${adapterLabel} initialized.`,
|
|
461
|
+
result: summarizeInitializeResult(initialize)
|
|
462
|
+
});
|
|
463
|
+
const sessionResult = providerSessionId
|
|
464
|
+
? await client.request("session/resume", {
|
|
465
|
+
sessionId: providerSessionId,
|
|
466
|
+
cwd: args.worktree,
|
|
467
|
+
mcpServers: []
|
|
468
|
+
})
|
|
469
|
+
: await client.request("session/new", {
|
|
470
|
+
cwd: args.worktree,
|
|
471
|
+
mcpServers: []
|
|
472
|
+
});
|
|
473
|
+
providerSessionId = providerSessionId ?? sessionResult.sessionId;
|
|
474
|
+
agentConfigOptions = summarizeAcpConfigOptions(sessionResult.configOptions);
|
|
475
|
+
availableModels = extractModelOptions(agentConfigOptions);
|
|
476
|
+
streamEvent({
|
|
477
|
+
type: session.providerSessionId ? "acp_session_resumed" : "acp_session_created",
|
|
478
|
+
timestamp: new Date().toISOString(),
|
|
479
|
+
message: `${adapterLabel} session ready: ${providerSessionId}`,
|
|
480
|
+
providerSessionId
|
|
481
|
+
});
|
|
482
|
+
if (agentConfigOptions.length > 0) {
|
|
483
|
+
streamEvent({
|
|
484
|
+
type: "acp_config_options",
|
|
485
|
+
timestamp: new Date().toISOString(),
|
|
486
|
+
message: `${adapterLabel} exposed ${agentConfigOptions.length} config option(s), including ${availableModels.length} model option(s).`,
|
|
487
|
+
configOptions: agentConfigOptions,
|
|
488
|
+
availableModels
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
const modeOption = agentConfigOptions.find((o) => o.category === "mode" || o.id === "mode");
|
|
492
|
+
const targetMode = ACP_MODE_MAP[selectedAgent.id]?.[permissionProfile];
|
|
493
|
+
if (modeOption && targetMode) {
|
|
494
|
+
const modeValueExists = modeOption.options.some((o) => o.value === targetMode);
|
|
495
|
+
if (modeValueExists) {
|
|
496
|
+
const setConfigResult = await client.request("session/set_config_option", {
|
|
497
|
+
sessionId: providerSessionId,
|
|
498
|
+
configId: modeOption.id ?? "mode",
|
|
499
|
+
value: targetMode
|
|
500
|
+
});
|
|
501
|
+
if (Array.isArray(setConfigResult?.configOptions)) {
|
|
502
|
+
agentConfigOptions = summarizeAcpConfigOptions(setConfigResult.configOptions);
|
|
503
|
+
availableModels = extractModelOptions(agentConfigOptions);
|
|
504
|
+
}
|
|
505
|
+
streamEvent({
|
|
506
|
+
type: "acp_mode_set",
|
|
507
|
+
timestamp: new Date().toISOString(),
|
|
508
|
+
message: `Set ${selectedAgent.id} mode to ${targetMode} (permissionProfile=${permissionProfile}).`,
|
|
509
|
+
permissionProfile,
|
|
510
|
+
mode: targetMode
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
streamEvent({
|
|
515
|
+
type: "acp_mode_set_skipped",
|
|
516
|
+
timestamp: new Date().toISOString(),
|
|
517
|
+
message: `${adapterLabel} mode option does not include value "${targetMode}" for permissionProfile=${permissionProfile}; skipping mode setting.`,
|
|
518
|
+
permissionProfile,
|
|
519
|
+
attemptedMode: targetMode,
|
|
520
|
+
availableModeValues: modeOption.options.map((o) => o.value)
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (args.model) {
|
|
525
|
+
const modelOption = agentConfigOptions.find((o) => o.category === "model" || /model/i.test(o.id ?? ""));
|
|
526
|
+
if (modelOption) {
|
|
527
|
+
const modelValueExists = modelOption.options.some((o) => o.value === args.model);
|
|
528
|
+
if (modelValueExists) {
|
|
529
|
+
const setModelResult = await client.request("session/set_config_option", {
|
|
530
|
+
sessionId: providerSessionId,
|
|
531
|
+
configId: modelOption.id ?? "model",
|
|
532
|
+
value: args.model
|
|
533
|
+
});
|
|
534
|
+
if (Array.isArray(setModelResult?.configOptions)) {
|
|
535
|
+
agentConfigOptions = summarizeAcpConfigOptions(setModelResult.configOptions);
|
|
536
|
+
availableModels = extractModelOptions(agentConfigOptions);
|
|
537
|
+
}
|
|
538
|
+
streamEvent({
|
|
539
|
+
type: "acp_model_set",
|
|
540
|
+
timestamp: new Date().toISOString(),
|
|
541
|
+
message: `Set ${selectedAgent.id} model to ${args.model}.`,
|
|
542
|
+
model: args.model
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
streamEvent({
|
|
547
|
+
type: "acp_model_set_skipped",
|
|
548
|
+
timestamp: new Date().toISOString(),
|
|
549
|
+
message: `${adapterLabel} model option does not include value "${args.model}"; skipping model setting.`,
|
|
550
|
+
attemptedModel: args.model,
|
|
551
|
+
availableModelValues: modelOption.options.map((o) => o.value)
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
streamEvent({
|
|
557
|
+
type: "acp_model_set_skipped",
|
|
558
|
+
timestamp: new Date().toISOString(),
|
|
559
|
+
message: `${adapterLabel} does not expose a model config option; skipping model setting.`,
|
|
560
|
+
attemptedModel: args.model
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const promptResult = await client.request("session/prompt", {
|
|
565
|
+
sessionId: providerSessionId,
|
|
566
|
+
prompt: [
|
|
567
|
+
{
|
|
568
|
+
type: "text",
|
|
569
|
+
text: buildDispatchPrompt(args.prompt)
|
|
570
|
+
}
|
|
571
|
+
]
|
|
572
|
+
});
|
|
573
|
+
const completedAt = new Date().toISOString();
|
|
574
|
+
const stopReason = promptResult.stopReason ?? null;
|
|
575
|
+
for (const logEvent of client.drainLogEvents())
|
|
576
|
+
streamEvent(logEvent);
|
|
577
|
+
streamEvent({
|
|
578
|
+
type: "acp_prompt_completed",
|
|
579
|
+
timestamp: completedAt,
|
|
580
|
+
message: `${adapterLabel} prompt completed with stopReason=${stopReason ?? "unknown"}.`,
|
|
581
|
+
stopReason
|
|
582
|
+
});
|
|
583
|
+
streamEvent(buildAcpProcessClosedEvent(startedAt));
|
|
584
|
+
await writeChain;
|
|
585
|
+
const afterState = args.collectDiff === false
|
|
586
|
+
? { skipped: true, reason: "collectDiff disabled" }
|
|
587
|
+
: await collectWorktreeState(args.worktree);
|
|
588
|
+
const changedFiles = diffChangedFiles(job.worktreeState, afterState);
|
|
589
|
+
const agentText = extractAgentText(events);
|
|
590
|
+
const planViolations = (permissionProfile === "plan" && changedFiles.length > 0)
|
|
591
|
+
? [`plan_mode_violation: Agent modified ${changedFiles.length} file(s) despite permissionProfile=plan. The ACP adapter did not enforce read-only mode.`]
|
|
592
|
+
: [];
|
|
593
|
+
const planRisks = planViolations.length > 0
|
|
594
|
+
? planViolations
|
|
595
|
+
: (stopReason && stopReason !== "end_turn" ? [`${adapterLabel} stopped with ${stopReason}.`] : []);
|
|
596
|
+
return {
|
|
597
|
+
events: [...events],
|
|
598
|
+
sessionPatch: {
|
|
599
|
+
providerSessionId,
|
|
600
|
+
agentConfigOptions,
|
|
601
|
+
availableModels,
|
|
602
|
+
status: "idle",
|
|
603
|
+
canContinue: true
|
|
604
|
+
},
|
|
605
|
+
jobPatch: {
|
|
606
|
+
status: "completed",
|
|
607
|
+
endedAt: completedAt,
|
|
608
|
+
adapterStatus,
|
|
609
|
+
providerSessionId,
|
|
610
|
+
stopReason,
|
|
611
|
+
failureReason: null,
|
|
612
|
+
agentErrors: [],
|
|
613
|
+
agentConfigOptions,
|
|
614
|
+
availableModels,
|
|
615
|
+
changedFiles,
|
|
616
|
+
worktreeState: {
|
|
617
|
+
before: job.worktreeState,
|
|
618
|
+
after: afterState
|
|
619
|
+
},
|
|
620
|
+
resultSummary: agentText || `${adapterLabel} completed with stopReason=${stopReason ?? "unknown"}.`,
|
|
621
|
+
validation: [],
|
|
622
|
+
risks: planRisks
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
catch (error) {
|
|
627
|
+
const failedAt = new Date().toISOString();
|
|
628
|
+
for (const logEvent of client.drainLogEvents())
|
|
629
|
+
streamEvent(logEvent);
|
|
630
|
+
const collectedEvents = [...events];
|
|
631
|
+
const agentErrors = extractAgentErrors(collectedEvents);
|
|
632
|
+
const cancelled = controller?.cancelRequested === true;
|
|
633
|
+
const failureReason = cancelled
|
|
634
|
+
? (controller.cancelReason || `${adapterLabel} cancelled by Agent Router caller.`)
|
|
635
|
+
: buildFailureReason(adapterLabel, error, agentErrors);
|
|
636
|
+
streamEvent({
|
|
637
|
+
type: cancelled ? "acp_cancelled" : "acp_error",
|
|
638
|
+
timestamp: failedAt,
|
|
639
|
+
message: failureReason,
|
|
640
|
+
errorMessage: error.message,
|
|
641
|
+
agentErrors
|
|
642
|
+
});
|
|
643
|
+
streamEvent(buildAcpProcessClosedEvent(startedAt));
|
|
644
|
+
await writeChain;
|
|
645
|
+
const afterState = args.collectDiff === false
|
|
646
|
+
? { skipped: true, reason: "collectDiff disabled" }
|
|
647
|
+
: await collectWorktreeState(args.worktree);
|
|
648
|
+
return {
|
|
649
|
+
events: [...events],
|
|
650
|
+
sessionPatch: {
|
|
651
|
+
providerSessionId,
|
|
652
|
+
agentConfigOptions,
|
|
653
|
+
availableModels,
|
|
654
|
+
status: "idle",
|
|
655
|
+
canContinue: Boolean(providerSessionId)
|
|
656
|
+
},
|
|
657
|
+
jobPatch: {
|
|
658
|
+
status: cancelled ? "cancelled" : error.code === "timeout" ? "timed_out" : "failed",
|
|
659
|
+
endedAt: failedAt,
|
|
660
|
+
adapterStatus,
|
|
661
|
+
providerSessionId,
|
|
662
|
+
failureReason,
|
|
663
|
+
agentErrors,
|
|
664
|
+
agentConfigOptions,
|
|
665
|
+
availableModels,
|
|
666
|
+
changedFiles: diffChangedFiles(job.worktreeState, afterState),
|
|
667
|
+
worktreeState: {
|
|
668
|
+
before: job.worktreeState,
|
|
669
|
+
after: afterState
|
|
670
|
+
},
|
|
671
|
+
resultSummary: failureReason,
|
|
672
|
+
validation: [],
|
|
673
|
+
risks: cancelled ? [] : ["Inspect the job log before re-running the agent."]
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
finally {
|
|
678
|
+
client.dispose();
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
async function probeAgentModels({ selectedAgent, worktree, env, timeoutMs }) {
|
|
682
|
+
const cwd = worktree ?? process.cwd();
|
|
683
|
+
const launchTarget = resolveAcpLaunchTarget(selectedAgent.acp, selectedAgent, cwd);
|
|
684
|
+
if (!launchTarget)
|
|
685
|
+
throw new Error(`No ACP adapter is available for ${selectedAgent.id}.`);
|
|
686
|
+
const client = new AcpStdioClient({
|
|
687
|
+
command: launchTarget.command,
|
|
688
|
+
args: launchTarget.args,
|
|
689
|
+
cwd,
|
|
690
|
+
timeoutMs: timeoutMs ?? 10000,
|
|
691
|
+
env,
|
|
692
|
+
onEvent: () => { }
|
|
693
|
+
});
|
|
694
|
+
try {
|
|
695
|
+
await client.start();
|
|
696
|
+
await client.request("initialize", {
|
|
697
|
+
protocolVersion: 1,
|
|
698
|
+
clientCapabilities: {},
|
|
699
|
+
clientInfo: { name: SERVER_NAME, title: "Agent Router", version: SERVER_VERSION }
|
|
700
|
+
});
|
|
701
|
+
const sessionResult = await client.request("session/new", {
|
|
702
|
+
cwd,
|
|
703
|
+
mcpServers: []
|
|
704
|
+
});
|
|
705
|
+
const configOptions = summarizeAcpConfigOptions(sessionResult.configOptions);
|
|
706
|
+
const models = extractModelOptions(configOptions);
|
|
707
|
+
return {
|
|
708
|
+
agentId: selectedAgent.id,
|
|
709
|
+
models,
|
|
710
|
+
configOptions
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
finally {
|
|
714
|
+
client.dispose();
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
export { AcpStdioClient, normalizeAcpNotification, describeSessionUpdate, summarizeInitializeResult, summarizeAcpConfigOptions, summarizeConfigChoices, summarizeConfigValue, extractModelOptions, buildDispatchPrompt, extractAgentText, extractAgentErrors, collectDiagnosticStrings, buildFailureReason, diffChangedFiles, runAcpStdioJob, probeAgentModels };
|
|
718
|
+
//# sourceMappingURL=acp-client.js.map
|