@engineeros/connector 0.9.2 → 0.10.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/package.json +1 -1
- package/src/acp-client.mjs +381 -373
- package/src/agent-harness.mjs +196 -183
- package/src/codex-app-server.mjs +139 -0
- package/src/runner.mjs +1368 -1324
package/src/acp-client.mjs
CHANGED
|
@@ -1,373 +1,381 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { Readable, Writable } from "node:stream";
|
|
3
|
-
import * as acp from "@agentclientprotocol/sdk";
|
|
4
|
-
|
|
5
|
-
const RUNTIME_IDLE_MS = 30 * 60 * 1_000;
|
|
6
|
-
const runtimes = new Map();
|
|
7
|
-
|
|
8
|
-
export function permissionOutcome(options = [], allow = false) {
|
|
9
|
-
if (!allow) return { outcome: { outcome: "cancelled" } };
|
|
10
|
-
const selected =
|
|
11
|
-
options.find((option) => option.kind === "allow_once") ??
|
|
12
|
-
options.find((option) => option.kind === "allow_always");
|
|
13
|
-
return selected
|
|
14
|
-
? { outcome: { outcome: "selected", optionId: selected.optionId } }
|
|
15
|
-
: { outcome: { outcome: "cancelled" } };
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function launchAcpAgent(
|
|
19
|
-
workspace,
|
|
20
|
-
prompt,
|
|
21
|
-
config,
|
|
22
|
-
callbacks = {},
|
|
23
|
-
options = {},
|
|
24
|
-
) {
|
|
25
|
-
if (!config.agent_command) {
|
|
26
|
-
throw new Error(
|
|
27
|
-
"No ACP coding agent is configured. Pair again with --agent or --agent-command and optional --agent-args JSON.",
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
const persistent = options.persistent === true;
|
|
31
|
-
const runtimeKey = persistent ? acpRuntimeKey(workspace, config) : null;
|
|
32
|
-
let runtime = runtimeKey ? runtimes.get(runtimeKey) : null;
|
|
33
|
-
if (!runtime?.isRunning()) {
|
|
34
|
-
runtime = new AcpRuntime(workspace, config, () => {
|
|
35
|
-
if (runtimeKey && runtimes.get(runtimeKey) === runtime) {
|
|
36
|
-
runtimes.delete(runtimeKey);
|
|
37
|
-
}
|
|
38
|
-
});
|
|
39
|
-
if (runtimeKey) runtimes.set(runtimeKey, runtime);
|
|
40
|
-
}
|
|
41
|
-
const sessionKey = options.sessionKey || `isolated-${crypto.randomUUID()}`;
|
|
42
|
-
const completed = runtime
|
|
43
|
-
.prompt({
|
|
44
|
-
sessionKey,
|
|
45
|
-
prompt,
|
|
46
|
-
previousSessionId: options.previousSessionId,
|
|
47
|
-
sandbox: options.sandbox,
|
|
48
|
-
profile: options.profile,
|
|
49
|
-
callbacks,
|
|
50
|
-
})
|
|
51
|
-
.finally(async () => {
|
|
52
|
-
if (!persistent) await runtime.dispose();
|
|
53
|
-
});
|
|
54
|
-
return {
|
|
55
|
-
child: runtime.child,
|
|
56
|
-
completed,
|
|
57
|
-
cancel: () => runtime.cancel(sessionKey),
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export async function disposeAcpRuntimes() {
|
|
62
|
-
const active = [...runtimes.values()];
|
|
63
|
-
runtimes.clear();
|
|
64
|
-
await Promise.all(active.map((runtime) => runtime.dispose()));
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function activeAcpRuntimeCount() {
|
|
68
|
-
return runtimes.size;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
class AcpRuntime {
|
|
72
|
-
constructor(workspace, config, onClose) {
|
|
73
|
-
this.workspace = workspace;
|
|
74
|
-
this.config = config;
|
|
75
|
-
this.onClose = onClose;
|
|
76
|
-
this.sessions = new Map();
|
|
77
|
-
this.turns = new Map();
|
|
78
|
-
this.stderr = "";
|
|
79
|
-
this.disposed = false;
|
|
80
|
-
this.idleTimer = null;
|
|
81
|
-
this.child = spawn(
|
|
82
|
-
config.agent_command,
|
|
83
|
-
Array.isArray(config.agent_args) ? config.agent_args : [],
|
|
84
|
-
{
|
|
85
|
-
cwd: workspace,
|
|
86
|
-
env: { ...process.env, ...(config.agent_env || {}) },
|
|
87
|
-
shell:
|
|
88
|
-
process.platform === "win32" &&
|
|
89
|
-
/\.(cmd|bat)$/i.test(config.agent_command),
|
|
90
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
91
|
-
},
|
|
92
|
-
);
|
|
93
|
-
this.child.stderr.setEncoding("utf8");
|
|
94
|
-
this.child.stderr.on("data", (chunk) => {
|
|
95
|
-
this.stderr = `${this.stderr}${chunk}`.slice(-4_000);
|
|
96
|
-
for (const turn of this.turns.values()) {
|
|
97
|
-
turn.callbacks.onEvent?.({
|
|
98
|
-
type: "agent.stderr",
|
|
99
|
-
message: String(chunk).trim().slice(0, 500),
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
});
|
|
103
|
-
this.child.once("error", (error) => {
|
|
104
|
-
this.spawnError = error;
|
|
105
|
-
});
|
|
106
|
-
const stream = acp.ndJsonStream(
|
|
107
|
-
Writable.toWeb(this.child.stdin),
|
|
108
|
-
Readable.toWeb(this.child.stdout),
|
|
109
|
-
);
|
|
110
|
-
this.connection = acp
|
|
111
|
-
.client({ name: "EngineerOS" })
|
|
112
|
-
.onRequest(acp.methods.client.session.requestPermission, (ctx) =>
|
|
113
|
-
this.permissionOutcome(ctx.params),
|
|
114
|
-
)
|
|
115
|
-
.onNotification(acp.methods.client.session.update, (ctx) =>
|
|
116
|
-
this.handleUpdate(ctx.params),
|
|
117
|
-
)
|
|
118
|
-
.connect(stream);
|
|
119
|
-
this.context = this.connection.agent;
|
|
120
|
-
this.ready = this.initialize();
|
|
121
|
-
void this.ready.catch(() => {
|
|
122
|
-
void this.dispose();
|
|
123
|
-
});
|
|
124
|
-
this.child.once("exit", () => this.closed());
|
|
125
|
-
void this.connection.closed.then(
|
|
126
|
-
() => this.closed(),
|
|
127
|
-
() => this.closed(),
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async initialize() {
|
|
132
|
-
try {
|
|
133
|
-
const initialized = await this.context.request(
|
|
134
|
-
acp.methods.agent.initialize,
|
|
135
|
-
{
|
|
136
|
-
protocolVersion: acp.PROTOCOL_VERSION,
|
|
137
|
-
clientCapabilities: {
|
|
138
|
-
session: { configOptions: { boolean: {} } },
|
|
139
|
-
},
|
|
140
|
-
},
|
|
141
|
-
);
|
|
142
|
-
this.capabilities = initialized.agentCapabilities ?? {};
|
|
143
|
-
return initialized;
|
|
144
|
-
} catch (error) {
|
|
145
|
-
throw this.failure("could not initialize", error);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async prompt({
|
|
150
|
-
sessionKey,
|
|
151
|
-
prompt,
|
|
152
|
-
previousSessionId,
|
|
153
|
-
sandbox = "read-only",
|
|
154
|
-
profile = {},
|
|
155
|
-
callbacks,
|
|
156
|
-
}) {
|
|
157
|
-
this.clearIdleTimer();
|
|
158
|
-
const initialized = await this.ready;
|
|
159
|
-
let session = this.sessions.get(sessionKey);
|
|
160
|
-
if (!session) {
|
|
161
|
-
session = await this.openSession(
|
|
162
|
-
sessionKey,
|
|
163
|
-
previousSessionId,
|
|
164
|
-
sandbox,
|
|
165
|
-
profile,
|
|
166
|
-
);
|
|
167
|
-
callbacks.onEvent?.({
|
|
168
|
-
type: "agent.connected",
|
|
169
|
-
message: `Agent connected with protocol ${initialized.protocolVersion}`,
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
if (this.turns.has(session.sessionId)) {
|
|
173
|
-
throw new Error(
|
|
174
|
-
"The agent is already answering another prompt in this session.",
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
const turn = { callbacks, finalMessage: "", sandbox };
|
|
178
|
-
this.turns.set(session.sessionId, turn);
|
|
179
|
-
try {
|
|
180
|
-
const response = await this.context.request(
|
|
181
|
-
acp.methods.agent.session.prompt,
|
|
182
|
-
{
|
|
183
|
-
sessionId: session.sessionId,
|
|
184
|
-
prompt: [{ type: "text", text: prompt }],
|
|
185
|
-
},
|
|
186
|
-
);
|
|
187
|
-
if (response.stopReason === "error") {
|
|
188
|
-
throw new Error("The ACP agent ended the task with an error.");
|
|
189
|
-
}
|
|
190
|
-
return {
|
|
191
|
-
finalMessage: turn.finalMessage,
|
|
192
|
-
model: profile.model || this.config.agent_name || "acp-agent",
|
|
193
|
-
sessionId: session.sessionId,
|
|
194
|
-
};
|
|
195
|
-
} catch (error) {
|
|
196
|
-
throw this.failure("failed", error);
|
|
197
|
-
} finally {
|
|
198
|
-
this.turns.delete(session.sessionId);
|
|
199
|
-
this.scheduleIdleDisposal();
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
async openSession(sessionKey, previousSessionId, sandbox, profile) {
|
|
204
|
-
let response;
|
|
205
|
-
let sessionId;
|
|
206
|
-
if (previousSessionId && this.capabilities?.loadSession === true) {
|
|
207
|
-
response = await this.context.request(acp.methods.agent.session.load, {
|
|
208
|
-
sessionId: previousSessionId,
|
|
209
|
-
cwd: this.workspace,
|
|
210
|
-
mcpServers: [],
|
|
211
|
-
});
|
|
212
|
-
sessionId = previousSessionId;
|
|
213
|
-
} else {
|
|
214
|
-
response = await this.context.request(acp.methods.agent.session.new, {
|
|
215
|
-
cwd: this.workspace,
|
|
216
|
-
mcpServers: [],
|
|
217
|
-
});
|
|
218
|
-
sessionId = response.sessionId;
|
|
219
|
-
}
|
|
220
|
-
await this.applyMode(sessionId, response.modes, sandbox);
|
|
221
|
-
await this.applyProfile(sessionId, response.configOptions, profile);
|
|
222
|
-
const session = { sessionId };
|
|
223
|
-
this.sessions.set(sessionKey, session);
|
|
224
|
-
return session;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
async applyMode(sessionId, modes, sandbox) {
|
|
228
|
-
const modeId = this.config.agent_modes?.[sandbox];
|
|
229
|
-
if (!modeId) return;
|
|
230
|
-
if (!modes?.availableModes?.some((mode) => mode.id === modeId)) {
|
|
231
|
-
throw new Error(
|
|
232
|
-
`${this.config.agent_name || "This ACP agent"} does not advertise the required '${modeId}' mode for ${sandbox} prompts.`,
|
|
233
|
-
);
|
|
234
|
-
}
|
|
235
|
-
await this.context.request(acp.methods.agent.session.setMode, {
|
|
236
|
-
sessionId,
|
|
237
|
-
modeId,
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
async applyProfile(sessionId, configOptions = [], profile = {}) {
|
|
242
|
-
const requested = [
|
|
243
|
-
["model", profile.model],
|
|
244
|
-
["thought_level", profile.reasoning_effort],
|
|
245
|
-
];
|
|
246
|
-
for (const [category, value] of requested) {
|
|
247
|
-
if (!value) continue;
|
|
248
|
-
const option = configOptions?.find(
|
|
249
|
-
(candidate) => candidate.category === category,
|
|
250
|
-
);
|
|
251
|
-
if (!option || option.type !== "select") {
|
|
252
|
-
throw new Error(
|
|
253
|
-
`This ACP agent does not advertise ${category.replace("_", " ")} selection.`,
|
|
254
|
-
);
|
|
255
|
-
}
|
|
256
|
-
const available = option.options.flatMap((candidate) =>
|
|
257
|
-
Array.isArray(candidate.options) ? candidate.options : [candidate],
|
|
258
|
-
);
|
|
259
|
-
const selected = available.find(
|
|
260
|
-
(candidate) =>
|
|
261
|
-
candidate.value === value ||
|
|
262
|
-
candidate.name?.toLowerCase() === String(value).toLowerCase(),
|
|
263
|
-
);
|
|
264
|
-
if (!selected) {
|
|
265
|
-
throw new Error(
|
|
266
|
-
`${option.name} does not offer '${value}'. Available values: ${available
|
|
267
|
-
.map((candidate) => candidate.value)
|
|
268
|
-
.join(", ")}.`,
|
|
269
|
-
);
|
|
270
|
-
}
|
|
271
|
-
await this.context.request(acp.methods.agent.session.setConfigOption, {
|
|
272
|
-
sessionId,
|
|
273
|
-
configId: option.id,
|
|
274
|
-
value: selected.value,
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
async handleUpdate(notification) {
|
|
280
|
-
const turn = this.turns.get(notification.sessionId);
|
|
281
|
-
if (!turn) return;
|
|
282
|
-
const update = notification.update;
|
|
283
|
-
if (
|
|
284
|
-
update.sessionUpdate === "agent_message_chunk" &&
|
|
285
|
-
update.content?.type === "text"
|
|
286
|
-
) {
|
|
287
|
-
turn.finalMessage += update.content.text;
|
|
288
|
-
}
|
|
289
|
-
turn.callbacks.onEvent?.({
|
|
290
|
-
type: `acp.${update.sessionUpdate}`,
|
|
291
|
-
update,
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
permissionOutcome(request) {
|
|
296
|
-
const turn = this.turns.get(request.sessionId);
|
|
297
|
-
|
|
298
|
-
request.options,
|
|
299
|
-
turn?.sandbox === "workspace-write",
|
|
300
|
-
);
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
this.
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
this.
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
this.disposed
|
|
349
|
-
this.
|
|
350
|
-
this.
|
|
351
|
-
this.
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
this.child.
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { Readable, Writable } from "node:stream";
|
|
3
|
+
import * as acp from "@agentclientprotocol/sdk";
|
|
4
|
+
|
|
5
|
+
const RUNTIME_IDLE_MS = 30 * 60 * 1_000;
|
|
6
|
+
const runtimes = new Map();
|
|
7
|
+
|
|
8
|
+
export function permissionOutcome(options = [], allow = false) {
|
|
9
|
+
if (!allow) return { outcome: { outcome: "cancelled" } };
|
|
10
|
+
const selected =
|
|
11
|
+
options.find((option) => option.kind === "allow_once") ??
|
|
12
|
+
options.find((option) => option.kind === "allow_always");
|
|
13
|
+
return selected
|
|
14
|
+
? { outcome: { outcome: "selected", optionId: selected.optionId } }
|
|
15
|
+
: { outcome: { outcome: "cancelled" } };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function launchAcpAgent(
|
|
19
|
+
workspace,
|
|
20
|
+
prompt,
|
|
21
|
+
config,
|
|
22
|
+
callbacks = {},
|
|
23
|
+
options = {},
|
|
24
|
+
) {
|
|
25
|
+
if (!config.agent_command) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
"No ACP coding agent is configured. Pair again with --agent or --agent-command and optional --agent-args JSON.",
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const persistent = options.persistent === true;
|
|
31
|
+
const runtimeKey = persistent ? acpRuntimeKey(workspace, config) : null;
|
|
32
|
+
let runtime = runtimeKey ? runtimes.get(runtimeKey) : null;
|
|
33
|
+
if (!runtime?.isRunning()) {
|
|
34
|
+
runtime = new AcpRuntime(workspace, config, () => {
|
|
35
|
+
if (runtimeKey && runtimes.get(runtimeKey) === runtime) {
|
|
36
|
+
runtimes.delete(runtimeKey);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
if (runtimeKey) runtimes.set(runtimeKey, runtime);
|
|
40
|
+
}
|
|
41
|
+
const sessionKey = options.sessionKey || `isolated-${crypto.randomUUID()}`;
|
|
42
|
+
const completed = runtime
|
|
43
|
+
.prompt({
|
|
44
|
+
sessionKey,
|
|
45
|
+
prompt,
|
|
46
|
+
previousSessionId: options.previousSessionId,
|
|
47
|
+
sandbox: options.sandbox,
|
|
48
|
+
profile: options.profile,
|
|
49
|
+
callbacks,
|
|
50
|
+
})
|
|
51
|
+
.finally(async () => {
|
|
52
|
+
if (!persistent) await runtime.dispose();
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
child: runtime.child,
|
|
56
|
+
completed,
|
|
57
|
+
cancel: () => runtime.cancel(sessionKey),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function disposeAcpRuntimes() {
|
|
62
|
+
const active = [...runtimes.values()];
|
|
63
|
+
runtimes.clear();
|
|
64
|
+
await Promise.all(active.map((runtime) => runtime.dispose()));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function activeAcpRuntimeCount() {
|
|
68
|
+
return runtimes.size;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
class AcpRuntime {
|
|
72
|
+
constructor(workspace, config, onClose) {
|
|
73
|
+
this.workspace = workspace;
|
|
74
|
+
this.config = config;
|
|
75
|
+
this.onClose = onClose;
|
|
76
|
+
this.sessions = new Map();
|
|
77
|
+
this.turns = new Map();
|
|
78
|
+
this.stderr = "";
|
|
79
|
+
this.disposed = false;
|
|
80
|
+
this.idleTimer = null;
|
|
81
|
+
this.child = spawn(
|
|
82
|
+
config.agent_command,
|
|
83
|
+
Array.isArray(config.agent_args) ? config.agent_args : [],
|
|
84
|
+
{
|
|
85
|
+
cwd: workspace,
|
|
86
|
+
env: { ...process.env, ...(config.agent_env || {}) },
|
|
87
|
+
shell:
|
|
88
|
+
process.platform === "win32" &&
|
|
89
|
+
/\.(cmd|bat)$/i.test(config.agent_command),
|
|
90
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
this.child.stderr.setEncoding("utf8");
|
|
94
|
+
this.child.stderr.on("data", (chunk) => {
|
|
95
|
+
this.stderr = `${this.stderr}${chunk}`.slice(-4_000);
|
|
96
|
+
for (const turn of this.turns.values()) {
|
|
97
|
+
turn.callbacks.onEvent?.({
|
|
98
|
+
type: "agent.stderr",
|
|
99
|
+
message: String(chunk).trim().slice(0, 500),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
this.child.once("error", (error) => {
|
|
104
|
+
this.spawnError = error;
|
|
105
|
+
});
|
|
106
|
+
const stream = acp.ndJsonStream(
|
|
107
|
+
Writable.toWeb(this.child.stdin),
|
|
108
|
+
Readable.toWeb(this.child.stdout),
|
|
109
|
+
);
|
|
110
|
+
this.connection = acp
|
|
111
|
+
.client({ name: "EngineerOS" })
|
|
112
|
+
.onRequest(acp.methods.client.session.requestPermission, (ctx) =>
|
|
113
|
+
this.permissionOutcome(ctx.params),
|
|
114
|
+
)
|
|
115
|
+
.onNotification(acp.methods.client.session.update, (ctx) =>
|
|
116
|
+
this.handleUpdate(ctx.params),
|
|
117
|
+
)
|
|
118
|
+
.connect(stream);
|
|
119
|
+
this.context = this.connection.agent;
|
|
120
|
+
this.ready = this.initialize();
|
|
121
|
+
void this.ready.catch(() => {
|
|
122
|
+
void this.dispose();
|
|
123
|
+
});
|
|
124
|
+
this.child.once("exit", () => this.closed());
|
|
125
|
+
void this.connection.closed.then(
|
|
126
|
+
() => this.closed(),
|
|
127
|
+
() => this.closed(),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async initialize() {
|
|
132
|
+
try {
|
|
133
|
+
const initialized = await this.context.request(
|
|
134
|
+
acp.methods.agent.initialize,
|
|
135
|
+
{
|
|
136
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
137
|
+
clientCapabilities: {
|
|
138
|
+
session: { configOptions: { boolean: {} } },
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
this.capabilities = initialized.agentCapabilities ?? {};
|
|
143
|
+
return initialized;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
throw this.failure("could not initialize", error);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async prompt({
|
|
150
|
+
sessionKey,
|
|
151
|
+
prompt,
|
|
152
|
+
previousSessionId,
|
|
153
|
+
sandbox = "read-only",
|
|
154
|
+
profile = {},
|
|
155
|
+
callbacks,
|
|
156
|
+
}) {
|
|
157
|
+
this.clearIdleTimer();
|
|
158
|
+
const initialized = await this.ready;
|
|
159
|
+
let session = this.sessions.get(sessionKey);
|
|
160
|
+
if (!session) {
|
|
161
|
+
session = await this.openSession(
|
|
162
|
+
sessionKey,
|
|
163
|
+
previousSessionId,
|
|
164
|
+
sandbox,
|
|
165
|
+
profile,
|
|
166
|
+
);
|
|
167
|
+
callbacks.onEvent?.({
|
|
168
|
+
type: "agent.connected",
|
|
169
|
+
message: `Agent connected with protocol ${initialized.protocolVersion}`,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
if (this.turns.has(session.sessionId)) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
"The agent is already answering another prompt in this session.",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const turn = { callbacks, finalMessage: "", sandbox };
|
|
178
|
+
this.turns.set(session.sessionId, turn);
|
|
179
|
+
try {
|
|
180
|
+
const response = await this.context.request(
|
|
181
|
+
acp.methods.agent.session.prompt,
|
|
182
|
+
{
|
|
183
|
+
sessionId: session.sessionId,
|
|
184
|
+
prompt: [{ type: "text", text: prompt }],
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
if (response.stopReason === "error") {
|
|
188
|
+
throw new Error("The ACP agent ended the task with an error.");
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
finalMessage: turn.finalMessage,
|
|
192
|
+
model: profile.model || this.config.agent_name || "acp-agent",
|
|
193
|
+
sessionId: session.sessionId,
|
|
194
|
+
};
|
|
195
|
+
} catch (error) {
|
|
196
|
+
throw this.failure("failed", error);
|
|
197
|
+
} finally {
|
|
198
|
+
this.turns.delete(session.sessionId);
|
|
199
|
+
this.scheduleIdleDisposal();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async openSession(sessionKey, previousSessionId, sandbox, profile) {
|
|
204
|
+
let response;
|
|
205
|
+
let sessionId;
|
|
206
|
+
if (previousSessionId && this.capabilities?.loadSession === true) {
|
|
207
|
+
response = await this.context.request(acp.methods.agent.session.load, {
|
|
208
|
+
sessionId: previousSessionId,
|
|
209
|
+
cwd: this.workspace,
|
|
210
|
+
mcpServers: [],
|
|
211
|
+
});
|
|
212
|
+
sessionId = previousSessionId;
|
|
213
|
+
} else {
|
|
214
|
+
response = await this.context.request(acp.methods.agent.session.new, {
|
|
215
|
+
cwd: this.workspace,
|
|
216
|
+
mcpServers: [],
|
|
217
|
+
});
|
|
218
|
+
sessionId = response.sessionId;
|
|
219
|
+
}
|
|
220
|
+
await this.applyMode(sessionId, response.modes, sandbox);
|
|
221
|
+
await this.applyProfile(sessionId, response.configOptions, profile);
|
|
222
|
+
const session = { sessionId };
|
|
223
|
+
this.sessions.set(sessionKey, session);
|
|
224
|
+
return session;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async applyMode(sessionId, modes, sandbox) {
|
|
228
|
+
const modeId = this.config.agent_modes?.[sandbox];
|
|
229
|
+
if (!modeId) return;
|
|
230
|
+
if (!modes?.availableModes?.some((mode) => mode.id === modeId)) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`${this.config.agent_name || "This ACP agent"} does not advertise the required '${modeId}' mode for ${sandbox} prompts.`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
await this.context.request(acp.methods.agent.session.setMode, {
|
|
236
|
+
sessionId,
|
|
237
|
+
modeId,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async applyProfile(sessionId, configOptions = [], profile = {}) {
|
|
242
|
+
const requested = [
|
|
243
|
+
["model", profile.model],
|
|
244
|
+
["thought_level", profile.reasoning_effort],
|
|
245
|
+
];
|
|
246
|
+
for (const [category, value] of requested) {
|
|
247
|
+
if (!value) continue;
|
|
248
|
+
const option = configOptions?.find(
|
|
249
|
+
(candidate) => candidate.category === category,
|
|
250
|
+
);
|
|
251
|
+
if (!option || option.type !== "select") {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`This ACP agent does not advertise ${category.replace("_", " ")} selection.`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
const available = option.options.flatMap((candidate) =>
|
|
257
|
+
Array.isArray(candidate.options) ? candidate.options : [candidate],
|
|
258
|
+
);
|
|
259
|
+
const selected = available.find(
|
|
260
|
+
(candidate) =>
|
|
261
|
+
candidate.value === value ||
|
|
262
|
+
candidate.name?.toLowerCase() === String(value).toLowerCase(),
|
|
263
|
+
);
|
|
264
|
+
if (!selected) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`${option.name} does not offer '${value}'. Available values: ${available
|
|
267
|
+
.map((candidate) => candidate.value)
|
|
268
|
+
.join(", ")}.`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
await this.context.request(acp.methods.agent.session.setConfigOption, {
|
|
272
|
+
sessionId,
|
|
273
|
+
configId: option.id,
|
|
274
|
+
value: selected.value,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async handleUpdate(notification) {
|
|
280
|
+
const turn = this.turns.get(notification.sessionId);
|
|
281
|
+
if (!turn) return;
|
|
282
|
+
const update = notification.update;
|
|
283
|
+
if (
|
|
284
|
+
update.sessionUpdate === "agent_message_chunk" &&
|
|
285
|
+
update.content?.type === "text"
|
|
286
|
+
) {
|
|
287
|
+
turn.finalMessage += update.content.text;
|
|
288
|
+
}
|
|
289
|
+
turn.callbacks.onEvent?.({
|
|
290
|
+
type: `acp.${update.sessionUpdate}`,
|
|
291
|
+
update,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
permissionOutcome(request) {
|
|
296
|
+
const turn = this.turns.get(request.sessionId);
|
|
297
|
+
const outcome = permissionOutcome(
|
|
298
|
+
request.options,
|
|
299
|
+
turn?.sandbox === "workspace-write",
|
|
300
|
+
);
|
|
301
|
+
turn?.callbacks.onEvent?.({
|
|
302
|
+
type: "acp.permission",
|
|
303
|
+
update: {
|
|
304
|
+
title: "Agent requested workspace permission",
|
|
305
|
+
status: turn?.sandbox === "workspace-write" ? "approved" : "denied",
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
return outcome;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async cancel(sessionKey) {
|
|
312
|
+
const session = this.sessions.get(sessionKey);
|
|
313
|
+
if (!session || !this.isRunning()) return;
|
|
314
|
+
await this.context.notify(acp.methods.agent.session.cancel, {
|
|
315
|
+
sessionId: session.sessionId,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
isRunning() {
|
|
320
|
+
return (
|
|
321
|
+
!this.disposed &&
|
|
322
|
+
this.child.exitCode === null &&
|
|
323
|
+
!this.connection.signal.aborted
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
scheduleIdleDisposal() {
|
|
328
|
+
if (this.turns.size || this.disposed) return;
|
|
329
|
+
this.clearIdleTimer();
|
|
330
|
+
this.idleTimer = setTimeout(() => void this.dispose(), RUNTIME_IDLE_MS);
|
|
331
|
+
this.idleTimer.unref?.();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
clearIdleTimer() {
|
|
335
|
+
clearTimeout(this.idleTimer);
|
|
336
|
+
this.idleTimer = null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
failure(action, error) {
|
|
340
|
+
const detail = this.stderr.trim().slice(-1_000);
|
|
341
|
+
const cause = this.spawnError || error;
|
|
342
|
+
return new Error(
|
|
343
|
+
`ACP agent ${action}: ${cause instanceof Error ? cause.message : String(cause)}${detail ? ` ${detail}` : ""}`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
closed() {
|
|
348
|
+
if (this.disposed) return;
|
|
349
|
+
this.disposed = true;
|
|
350
|
+
this.clearIdleTimer();
|
|
351
|
+
this.onClose?.();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async dispose() {
|
|
355
|
+
if (this.disposed) return;
|
|
356
|
+
this.disposed = true;
|
|
357
|
+
this.clearIdleTimer();
|
|
358
|
+
this.onClose?.();
|
|
359
|
+
this.connection.close();
|
|
360
|
+
if (this.child.exitCode === null) {
|
|
361
|
+
this.child.kill("SIGTERM");
|
|
362
|
+
await Promise.race([
|
|
363
|
+
new Promise((resolve) => this.child.once("exit", resolve)),
|
|
364
|
+
new Promise((resolve) => setTimeout(resolve, 500)),
|
|
365
|
+
]);
|
|
366
|
+
}
|
|
367
|
+
if (this.child.exitCode === null) this.child.kill("SIGKILL");
|
|
368
|
+
this.child.stdin.destroy();
|
|
369
|
+
this.child.stdout.destroy();
|
|
370
|
+
this.child.stderr.destroy();
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function acpRuntimeKey(workspace, config) {
|
|
375
|
+
return JSON.stringify([
|
|
376
|
+
workspace,
|
|
377
|
+
config.agent_command,
|
|
378
|
+
config.agent_args || [],
|
|
379
|
+
config.agent_env || {},
|
|
380
|
+
]);
|
|
381
|
+
}
|