@engineeros/connector 0.8.8 → 0.8.9

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.
@@ -2,7 +2,11 @@ import { spawn } from "node:child_process";
2
2
  import { Readable, Writable } from "node:stream";
3
3
  import * as acp from "@agentclientprotocol/sdk";
4
4
 
5
- function permissionOutcome(options = []) {
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" } };
6
10
  const selected =
7
11
  options.find((option) => option.kind === "allow_once") ??
8
12
  options.find((option) => option.kind === "allow_always");
@@ -11,99 +15,359 @@ function permissionOutcome(options = []) {
11
15
  : { outcome: { outcome: "cancelled" } };
12
16
  }
13
17
 
14
- export function launchAcpAgent(workspace, prompt, config, callbacks = {}) {
18
+ export function launchAcpAgent(
19
+ workspace,
20
+ prompt,
21
+ config,
22
+ callbacks = {},
23
+ options = {},
24
+ ) {
15
25
  if (!config.agent_command) {
16
26
  throw new Error(
17
- "No ACP coding agent is configured. Pair again with --agent-command and optional --agent-args JSON.",
27
+ "No ACP coding agent is configured. Pair again with --agent or --agent-command and optional --agent-args JSON.",
18
28
  );
19
29
  }
20
- const child = spawn(
21
- config.agent_command,
22
- Array.isArray(config.agent_args) ? config.agent_args : [],
23
- {
24
- cwd: workspace,
25
- env: process.env,
26
- shell: false,
27
- stdio: ["pipe", "pipe", "pipe"],
28
- },
29
- );
30
- let stderr = "";
31
- child.stderr.setEncoding("utf8");
32
- child.stderr.on("data", (chunk) => {
33
- stderr += chunk;
34
- callbacks.onEvent?.({
35
- type: "agent.stderr",
36
- message: String(chunk).trim().slice(0, 500),
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
+ }
37
38
  });
38
- });
39
-
40
- const stream = acp.ndJsonStream(
41
- Writable.toWeb(child.stdin),
42
- Readable.toWeb(child.stdout),
43
- );
44
- let finalMessage = "";
45
- const completed = acp
46
- .client({ name: "EngineerOS" })
47
- .onRequest(acp.methods.client.session.requestPermission, (ctx) =>
48
- permissionOutcome(ctx.params.options),
49
- )
50
- .connectWith(stream, async (ctx) => {
51
- const initialized = await ctx.request(acp.methods.agent.initialize, {
52
- protocolVersion: acp.PROTOCOL_VERSION,
53
- clientCapabilities: {},
54
- });
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
+ );
55
167
  callbacks.onEvent?.({
56
168
  type: "agent.connected",
57
- message: `ACP agent connected with protocol ${initialized.protocolVersion}`,
169
+ message: `Agent connected with protocol ${initialized.protocolVersion}`,
58
170
  });
59
- return ctx.buildSession(workspace).withSession(async (session) => {
60
- child.engineerOsCancel = () =>
61
- ctx.notify(acp.methods.agent.session.cancel, {
62
- sessionId: session.sessionId,
63
- });
64
- void session.prompt(prompt);
65
- for (;;) {
66
- const message = await session.nextUpdate();
67
- if (message.kind === "stop") {
68
- if (message.stopReason === "error") {
69
- throw new Error("The ACP agent ended the task with an error.");
70
- }
71
- return {
72
- finalMessage,
73
- model: "acp-agent",
74
- sessionId: session.sessionId,
75
- };
76
- }
77
- const update = message.update;
78
- if (
79
- update.sessionUpdate === "agent_message_chunk" &&
80
- update.content?.type === "text"
81
- ) {
82
- finalMessage += update.content.text;
83
- }
84
- callbacks.onEvent?.({
85
- type: `acp.${update.sessionUpdate}`,
86
- message:
87
- update.title ??
88
- update.content?.text ??
89
- update.status ??
90
- update.sessionUpdate,
91
- });
92
- }
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: [],
93
211
  });
94
- })
95
- .catch((error) => {
96
- const detail = stderr.trim().slice(-1_000);
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)) {
97
231
  throw new Error(
98
- `ACP agent failed: ${error instanceof Error ? error.message : String(error)}${detail ? ` ${detail}` : ""}`,
232
+ `${this.config.agent_name || "This ACP agent"} does not advertise the required '${modeId}' mode for ${sandbox} prompts.`,
99
233
  );
100
- })
101
- .finally(() => {
102
- child.kill("SIGKILL");
103
- child.stdin.destroy();
104
- child.stdout.destroy();
105
- child.stderr.destroy();
106
- child.unref();
234
+ }
235
+ await this.context.request(acp.methods.agent.session.setMode, {
236
+ sessionId,
237
+ modeId,
107
238
  });
108
- return { child, completed };
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
+ return permissionOutcome(
298
+ request.options,
299
+ turn?.sandbox === "workspace-write",
300
+ );
301
+ }
302
+
303
+ async cancel(sessionKey) {
304
+ const session = this.sessions.get(sessionKey);
305
+ if (!session || !this.isRunning()) return;
306
+ await this.context.notify(acp.methods.agent.session.cancel, {
307
+ sessionId: session.sessionId,
308
+ });
309
+ }
310
+
311
+ isRunning() {
312
+ return (
313
+ !this.disposed &&
314
+ this.child.exitCode === null &&
315
+ !this.connection.signal.aborted
316
+ );
317
+ }
318
+
319
+ scheduleIdleDisposal() {
320
+ if (this.turns.size || this.disposed) return;
321
+ this.clearIdleTimer();
322
+ this.idleTimer = setTimeout(() => void this.dispose(), RUNTIME_IDLE_MS);
323
+ this.idleTimer.unref?.();
324
+ }
325
+
326
+ clearIdleTimer() {
327
+ clearTimeout(this.idleTimer);
328
+ this.idleTimer = null;
329
+ }
330
+
331
+ failure(action, error) {
332
+ const detail = this.stderr.trim().slice(-1_000);
333
+ const cause = this.spawnError || error;
334
+ return new Error(
335
+ `ACP agent ${action}: ${cause instanceof Error ? cause.message : String(cause)}${detail ? ` ${detail}` : ""}`,
336
+ );
337
+ }
338
+
339
+ closed() {
340
+ if (this.disposed) return;
341
+ this.disposed = true;
342
+ this.clearIdleTimer();
343
+ this.onClose?.();
344
+ }
345
+
346
+ async dispose() {
347
+ if (this.disposed) return;
348
+ this.disposed = true;
349
+ this.clearIdleTimer();
350
+ this.onClose?.();
351
+ this.connection.close();
352
+ if (this.child.exitCode === null) {
353
+ this.child.kill("SIGTERM");
354
+ await Promise.race([
355
+ new Promise((resolve) => this.child.once("exit", resolve)),
356
+ new Promise((resolve) => setTimeout(resolve, 500)),
357
+ ]);
358
+ }
359
+ if (this.child.exitCode === null) this.child.kill("SIGKILL");
360
+ this.child.stdin.destroy();
361
+ this.child.stdout.destroy();
362
+ this.child.stderr.destroy();
363
+ }
364
+ }
365
+
366
+ function acpRuntimeKey(workspace, config) {
367
+ return JSON.stringify([
368
+ workspace,
369
+ config.agent_command,
370
+ config.agent_args || [],
371
+ config.agent_env || {},
372
+ ]);
109
373
  }