@wrongstack/acp 0.260.0 → 0.264.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/dist/client.js CHANGED
@@ -1,4 +1,7 @@
1
1
  import { expectDefined, writeErr } from '@wrongstack/core';
2
+ import * as fsp from 'fs/promises';
3
+ import * as path from 'path';
4
+ import { spawn } from 'child_process';
2
5
 
3
6
  // src/agent/stdio-transport.ts
4
7
  var ClientTransport = class {
@@ -17,22 +20,30 @@ var ClientTransport = class {
17
20
  }
18
21
  async start() {
19
22
  if (this.child) return;
20
- const [{ spawn }, { buildChildEnv }] = await Promise.all([
23
+ const [{ spawn: spawn2 }, { buildChildEnv }] = await Promise.all([
21
24
  import('child_process'),
22
25
  import('@wrongstack/core')
23
26
  ]);
24
- return new Promise((resolve, reject) => {
27
+ return new Promise((resolve3, reject) => {
25
28
  const timeout = setTimeout(() => {
26
29
  reject(
27
30
  new Error(`ACP child process failed to start within ${this.opts.handshakeTimeoutMs}ms`)
28
31
  );
29
32
  }, this.opts.handshakeTimeoutMs);
30
33
  try {
31
- this.child = spawn(this.opts.command, this.opts.args ?? [], {
34
+ this.child = spawn2(this.opts.command, this.opts.args ?? [], {
32
35
  env: { ...buildChildEnv(), ...this.opts.env },
33
36
  cwd: this.opts.cwd,
34
37
  stdio: ["pipe", "pipe", "pipe"],
35
- windowsHide: true
38
+ windowsHide: true,
39
+ // On Windows, most ACP-supporting tools (claude, gemini, codex,
40
+ // qwen, copilot) are installed as `.cmd` shims under
41
+ // AppData\Roaming\npm\. Node's spawn won't find them via
42
+ // `shell: false` because the .cmd extension is not in the
43
+ // default PATHEXT lookup. The argv here is always from our
44
+ // own static catalog or from a hardcoded spec, never from
45
+ // user input, so shell-expansion is bounded.
46
+ shell: process.platform === "win32"
36
47
  });
37
48
  } catch (err) {
38
49
  clearTimeout(timeout);
@@ -41,17 +52,24 @@ var ClientTransport = class {
41
52
  }
42
53
  const child = this.child;
43
54
  child.stdout.setEncoding("utf8");
55
+ const onReady = () => {
56
+ child.stdout.on("data", (c) => this.onChildData(c));
57
+ child.stderr.on("data", (c) => this.onChildError(c));
58
+ child.on("close", (code) => this.onChildClose(code));
59
+ clearTimeout(timeout);
60
+ resolve3();
61
+ };
62
+ if (this.opts.skipHandshakeMarker) {
63
+ onReady();
64
+ return;
65
+ }
44
66
  const waitForMarker = (chunk) => {
45
67
  this.buffer += chunk;
46
68
  const idx = this.buffer.indexOf("[wstack-acp]\n");
47
69
  if (idx !== -1) {
48
70
  this.buffer = this.buffer.slice(idx + "[wstack-acp]\n".length);
49
71
  child.stdout.removeListener("data", waitForMarker);
50
- child.stdout.on("data", (c) => this.onChildData(c));
51
- child.stderr.on("data", (c) => this.onChildError(c));
52
- child.on("close", (code) => this.onChildClose(code));
53
- clearTimeout(timeout);
54
- resolve();
72
+ onReady();
55
73
  }
56
74
  };
57
75
  child.stdout.on("data", waitForMarker);
@@ -67,19 +85,19 @@ var ClientTransport = class {
67
85
  }
68
86
  send(msg) {
69
87
  if (!this.child) return Promise.reject(new Error("ClientTransport not started"));
70
- return new Promise((resolve, reject) => {
88
+ return new Promise((resolve3, reject) => {
71
89
  const line = JSON.stringify(msg) + "\n";
72
90
  this.child?.stdin.write(line, "utf8", (err) => {
73
91
  if (err) reject(err);
74
- else resolve();
92
+ else resolve3();
75
93
  });
76
94
  });
77
95
  }
78
96
  read() {
79
97
  if (this.messageQueue.length > 0) return Promise.resolve(expectDefined(this.messageQueue.shift()));
80
98
  if (this.closed) return Promise.resolve(null);
81
- return new Promise((resolve) => {
82
- this.resolveRead = resolve;
99
+ return new Promise((resolve3) => {
100
+ this.resolveRead = resolve3;
83
101
  });
84
102
  }
85
103
  onMessage(handler) {
@@ -121,9 +139,9 @@ var ClientTransport = class {
121
139
  }
122
140
  dispatch(msg) {
123
141
  if (this.resolveRead) {
124
- const resolve = this.resolveRead;
142
+ const resolve3 = this.resolveRead;
125
143
  this.resolveRead = null;
126
- resolve(msg);
144
+ resolve3(msg);
127
145
  } else {
128
146
  this.messageQueue.push(msg);
129
147
  }
@@ -140,32 +158,6 @@ var DEFAULT_OPTIONS = {
140
158
  pollIntervalMs: 500,
141
159
  totalTimeoutMs: 12e4
142
160
  };
143
- function extractTextFromContent(blocks) {
144
- const parts = [];
145
- for (const b of blocks) {
146
- if (b.type === "text") parts.push(b.text);
147
- else if (b.type === "resource") parts.push(`[resource: ${b.resource.uri}]`);
148
- else if (b.type === "image") parts.push(`[image: ${b.data.slice(0, 20)}...]`);
149
- else if (b.type === "progress") {
150
- if (b.messages?.length) parts.push(b.messages.join("\n"));
151
- }
152
- }
153
- return parts.join("\n");
154
- }
155
- function parseToolResponse(taskId, subagentId, response) {
156
- const blocks = response.result.content;
157
- const text = extractTextFromContent(blocks);
158
- const isError = response.result.isError || text.toLowerCase().includes("error") || text.toLowerCase().includes("failed");
159
- return {
160
- taskId,
161
- subagentId,
162
- status: isError ? "failed" : "success",
163
- result: text,
164
- iterations: 1,
165
- toolCalls: 1,
166
- durationMs: 0
167
- };
168
- }
169
161
  var ToolTranslator = class {
170
162
  opts;
171
163
  pending = /* @__PURE__ */ new Map();
@@ -207,12 +199,12 @@ var ToolTranslator = class {
207
199
  id: callId,
208
200
  params: { name, arguments: args }
209
201
  });
210
- return new Promise((resolve, reject) => {
202
+ return new Promise((resolve3, reject) => {
211
203
  const timeout = setTimeout(() => {
212
204
  this.pending.delete(callId);
213
205
  reject(new Error(`Tool call ${name} timed out after ${this.opts.totalTimeoutMs}ms`));
214
206
  }, this.opts.totalTimeoutMs);
215
- this.pending.set(callId, { resolve, reject, timeout });
207
+ this.pending.set(callId, { resolve: resolve3, reject, timeout });
216
208
  });
217
209
  }
218
210
  cancelAll() {
@@ -223,97 +215,881 @@ var ToolTranslator = class {
223
215
  }
224
216
  };
225
217
 
226
- // src/integration/acp-subagent-runner.ts
227
- async function makeACPSubagentRunner(options) {
228
- const transport = new ClientTransport(clientTransportOptions(options));
229
- const translator = new ToolTranslator(options.toolTranslatorOpts);
230
- const activeAbort = new AbortController();
231
- let sessionStarted = false;
232
- const startSession = async () => {
233
- if (sessionStarted) return;
234
- await transport.start();
235
- await transport.send({
236
- method: "initialize",
237
- id: "1",
238
- params: {
239
- capabilities: ["code-generation", "async-tools", "streaming", "progress"],
240
- protocolVersion: "2024-11",
241
- sessionId: options.role ?? "wrongstack-subagent"
218
+ // src/types/acp-v1.ts
219
+ var ACP_PROTOCOL_VERSION = 1;
220
+ var FsError = class extends Error {
221
+ code;
222
+ path;
223
+ constructor(code, path3, message) {
224
+ super(message);
225
+ this.name = "FsError";
226
+ this.code = code;
227
+ this.path = path3;
228
+ }
229
+ };
230
+ var FileServer = class {
231
+ root;
232
+ timeoutMs;
233
+ constructor(opts) {
234
+ this.root = path.resolve(opts.projectRoot);
235
+ this.timeoutMs = opts.timeoutMs ?? 3e4;
236
+ }
237
+ /** Read a text file. Returns the content as a string. */
238
+ async readTextFile(params) {
239
+ const safe = this.resolveInside(params.path);
240
+ const controller = new AbortController();
241
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
242
+ try {
243
+ const content = await fsp.readFile(safe, {
244
+ encoding: "utf8",
245
+ signal: controller.signal
246
+ });
247
+ return { content };
248
+ } catch (err) {
249
+ if (controller.signal.aborted) {
250
+ throw new FsError("TIMEOUT", safe, `readTextFile timed out after ${this.timeoutMs}ms`);
251
+ }
252
+ throw mapFsError(err, safe);
253
+ } finally {
254
+ clearTimeout(timer);
255
+ }
256
+ }
257
+ /** Write a text file. Atomic via write-then-rename. */
258
+ async writeTextFile(params) {
259
+ const safe = this.resolveInside(params.path);
260
+ const controller = new AbortController();
261
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
262
+ const tmp = `${safe}.${randomHex(4)}.tmp`;
263
+ try {
264
+ await fsp.writeFile(tmp, params.content, {
265
+ encoding: "utf8",
266
+ signal: controller.signal
267
+ });
268
+ await fsp.rename(tmp, safe);
269
+ } catch (err) {
270
+ try {
271
+ await fsp.unlink(tmp);
272
+ } catch {
273
+ }
274
+ if (controller.signal.aborted) {
275
+ throw new FsError("TIMEOUT", safe, `writeTextFile timed out after ${this.timeoutMs}ms`);
242
276
  }
277
+ throw mapFsError(err, safe);
278
+ } finally {
279
+ clearTimeout(timer);
280
+ }
281
+ }
282
+ /**
283
+ * Resolve a path; throw `FsError('OUTSIDE_ROOT')` if the result is
284
+ * not under the project root. Symlinks are not followed here — we
285
+ * operate on the textual path. A future hardening pass can
286
+ * `fs.realpath` each access to catch symlink escapes.
287
+ */
288
+ resolveInside(p) {
289
+ if (typeof p !== "string" || p.length === 0) {
290
+ throw new FsError("INVALID_PATH", p, "path is empty or not a string");
291
+ }
292
+ if (!path.isAbsolute(p)) {
293
+ throw new FsError("INVALID_PATH", p, "path must be absolute (ACP requirement)");
294
+ }
295
+ const resolved = path.resolve(p);
296
+ const rootWithSep = this.root.endsWith(path.sep) ? this.root : this.root + path.sep;
297
+ if (resolved !== this.root && !resolved.startsWith(rootWithSep)) {
298
+ throw new FsError("OUTSIDE_ROOT", resolved, "path is outside the project root");
299
+ }
300
+ return resolved;
301
+ }
302
+ };
303
+ function mapFsError(err, p) {
304
+ const code = err?.code;
305
+ if (code === "ENOENT") return new FsError("ENOENT", p, `no such file: ${p}`);
306
+ if (code === "EACCES" || code === "EPERM") {
307
+ return new FsError("EACCES", p, `permission denied: ${p}`);
308
+ }
309
+ const msg = err instanceof Error ? err.message : String(err);
310
+ return new FsError("INVALID_PATH", p, msg);
311
+ }
312
+ function randomHex(bytes) {
313
+ let out = "";
314
+ for (let i = 0; i < bytes * 2; i++) {
315
+ out += Math.floor(Math.random() * 16).toString(16);
316
+ }
317
+ return out;
318
+ }
319
+
320
+ // src/client/permission.ts
321
+ var defaultPermissionPolicy = async (req) => {
322
+ if (req.signal.aborted) return { outcome: "cancelled" };
323
+ const ranked = [...req.options].sort((a, b) => {
324
+ const score = (k) => {
325
+ if (k === "allow_always") return 0;
326
+ if (k === "allow_once") return 1;
327
+ if (k === "reject_once") return 2;
328
+ return 3;
329
+ };
330
+ return score(a.kind) - score(b.kind);
331
+ });
332
+ const chosen = ranked[0];
333
+ if (!chosen || chosen.kind === "reject_once" || chosen.kind === "reject_always") {
334
+ return { outcome: "cancelled" };
335
+ }
336
+ return { outcome: "selected", optionId: chosen.optionId };
337
+ };
338
+ var TerminalServer = class {
339
+ terminals = /* @__PURE__ */ new Map();
340
+ projectRoot;
341
+ commandTimeoutMs;
342
+ outputByteLimit;
343
+ nextId = 1;
344
+ constructor(opts) {
345
+ this.projectRoot = path.resolve(opts.projectRoot);
346
+ this.commandTimeoutMs = opts.commandTimeoutMs ?? 5 * 6e4;
347
+ this.outputByteLimit = opts.outputByteLimit ?? 1024 * 1024;
348
+ if (opts.signal) {
349
+ opts.signal.addEventListener("abort", () => this.releaseAll());
350
+ }
351
+ }
352
+ /** Spawn a new terminal. Returns the agent-facing id. */
353
+ create(params) {
354
+ const id = `term_${this.nextId++}`;
355
+ const cwd = this.resolveCwd(params.cwd);
356
+ const proc = spawn(params.command, params.args ?? [], {
357
+ cwd,
358
+ env: this.buildEnv(params.env),
359
+ stdio: ["ignore", "pipe", "pipe"],
360
+ windowsHide: true
361
+ // shell: false on purpose. The terminal server is invoked with
362
+ // the agent's explicit argv; turning on shell-mode would make
363
+ // the command a single shell-parsed string, which breaks
364
+ // Windows cmd quoting for the common case of running node with
365
+ // `-e "<script>"`. If a future feature needs shell features
366
+ // (pipes, redirects), it should be opt-in per-call, not the
367
+ // default.
243
368
  });
244
- const initResp = await transport.read();
245
- if (!initResp || initResp.error) {
246
- throw new Error(`ACP initialize failed: ${initResp?.error?.message ?? "no response"}`);
369
+ const state = {
370
+ proc,
371
+ cwd,
372
+ command: params.command,
373
+ args: params.args ?? [],
374
+ output: "",
375
+ retainedBytes: 0,
376
+ truncated: false,
377
+ exitStatus: void 0,
378
+ timeoutHandle: null,
379
+ exitPromise: new Promise((resolve3) => {
380
+ proc.on("close", (code, signalName) => {
381
+ if (state.timeoutHandle) {
382
+ clearTimeout(state.timeoutHandle);
383
+ state.timeoutHandle = null;
384
+ }
385
+ const exitStatus = {
386
+ exitCode: typeof code === "number" ? code : null,
387
+ signal: typeof signalName === "string" ? signalName : null
388
+ };
389
+ state.exitStatus = exitStatus;
390
+ resolve3(exitStatus);
391
+ });
392
+ proc.on("error", (err) => {
393
+ if (state.timeoutHandle) {
394
+ clearTimeout(state.timeoutHandle);
395
+ state.timeoutHandle = null;
396
+ }
397
+ const exitStatus = { exitCode: 127, signal: null };
398
+ state.exitStatus = exitStatus;
399
+ state.output += `[spawn error] ${err.message}
400
+ `;
401
+ state.retainedBytes += Buffer.byteLength(state.output, "utf8");
402
+ resolve3(exitStatus);
403
+ });
404
+ })
405
+ };
406
+ const perCallByteLimit = params.outputByteLimit ?? this.outputByteLimit;
407
+ proc.stdout?.setEncoding("utf8");
408
+ proc.stderr?.setEncoding("utf8");
409
+ const onData = (chunk) => {
410
+ state.output += chunk;
411
+ state.retainedBytes = Buffer.byteLength(state.output, "utf8");
412
+ while (state.retainedBytes > perCallByteLimit) {
413
+ const trimmed = state.output.slice(1);
414
+ state.output = trimmed;
415
+ const newBytes = Buffer.byteLength(state.output, "utf8");
416
+ if (newBytes >= state.retainedBytes) {
417
+ break;
418
+ }
419
+ state.retainedBytes = newBytes;
420
+ state.truncated = true;
421
+ }
422
+ };
423
+ proc.stdout?.on("data", onData);
424
+ proc.stderr?.on("data", onData);
425
+ state.timeoutHandle = setTimeout(() => {
426
+ try {
427
+ proc.kill("SIGTERM");
428
+ } catch {
429
+ }
430
+ }, this.commandTimeoutMs);
431
+ this.terminals.set(id, state);
432
+ return { terminalId: id };
433
+ }
434
+ /** Return captured output and (if available) the exit status. */
435
+ output(terminalId) {
436
+ const state = this.terminals.get(terminalId);
437
+ if (!state) throw new Error(`unknown terminal: ${terminalId}`);
438
+ return {
439
+ output: state.output,
440
+ truncated: state.truncated,
441
+ ...state.exitStatus ? { exitStatus: state.exitStatus } : {}
442
+ };
443
+ }
444
+ /** Block until the process exits. Resolves with the exit status. */
445
+ async waitForExit(terminalId) {
446
+ const state = this.terminals.get(terminalId);
447
+ if (!state) throw new Error(`unknown terminal: ${terminalId}`);
448
+ return state.exitPromise;
449
+ }
450
+ /** Kill the process but keep the terminal record (agent can still read output). */
451
+ kill(terminalId) {
452
+ const state = this.terminals.get(terminalId);
453
+ if (!state) throw new Error(`unknown terminal: ${terminalId}`);
454
+ try {
455
+ state.proc.kill("SIGTERM");
456
+ } catch {
247
457
  }
248
- translator.attachToTransport({
249
- onMessage: (h) => transport.onMessage(h),
250
- send: (m) => transport.send(m)
458
+ }
459
+ /** Kill the process if alive and remove the record. */
460
+ release(terminalId) {
461
+ const state = this.terminals.get(terminalId);
462
+ if (!state) return;
463
+ if (state.timeoutHandle) {
464
+ clearTimeout(state.timeoutHandle);
465
+ state.timeoutHandle = null;
466
+ }
467
+ try {
468
+ state.proc.kill("SIGKILL");
469
+ } catch {
470
+ }
471
+ this.terminals.delete(terminalId);
472
+ }
473
+ /** Kill all active terminals. Used on session close. */
474
+ releaseAll() {
475
+ for (const id of [...this.terminals.keys()]) {
476
+ this.release(id);
477
+ }
478
+ }
479
+ resolveCwd(cwd) {
480
+ if (!cwd) return this.projectRoot;
481
+ const resolved = path.resolve(cwd);
482
+ const rootWithSep = this.projectRoot.endsWith(path.sep) ? this.projectRoot : this.projectRoot + path.sep;
483
+ if (resolved !== this.projectRoot && !resolved.startsWith(rootWithSep)) {
484
+ return this.projectRoot;
485
+ }
486
+ return resolved;
487
+ }
488
+ buildEnv(agentEnv) {
489
+ const env = { ...process.env };
490
+ if (process.platform === "win32") {
491
+ if (env.Path !== void 0 && env.PATH === void 0) env.PATH = env.Path;
492
+ if (env.PATHEXT !== void 0 && env.PATHEXT_CASE === void 0) {
493
+ env.PATHEXT_CASE = env.PATHEXT;
494
+ }
495
+ }
496
+ if (agentEnv) {
497
+ for (const { name, value } of agentEnv) {
498
+ env[name] = value;
499
+ }
500
+ }
501
+ return env;
502
+ }
503
+ };
504
+
505
+ // src/client/acp-session.ts
506
+ var ACPSessionError = class extends Error {
507
+ kind;
508
+ cause;
509
+ constructor(kind, message, cause) {
510
+ super(message);
511
+ this.name = "ACPSessionError";
512
+ this.kind = kind;
513
+ this.cause = cause;
514
+ }
515
+ };
516
+ function isJsonRpcError(v) {
517
+ return typeof v === "object" && v !== null && typeof v.code === "number" && typeof v.message === "string";
518
+ }
519
+ var ACPSession = class _ACPSession {
520
+ transport;
521
+ fileServer;
522
+ terminalServer;
523
+ permissionPolicy;
524
+ timeoutMs;
525
+ opts;
526
+ state = "init";
527
+ sessionId = null;
528
+ /** Pending outbound requests (initialize, session/new, session/prompt, etc). */
529
+ pending = /* @__PURE__ */ new Map();
530
+ nextId = 1;
531
+ /** True after close() has been called. */
532
+ closed = false;
533
+ constructor(opts, transport) {
534
+ this.opts = opts;
535
+ this.transport = transport;
536
+ this.timeoutMs = opts.timeoutMs ?? 5 * 6e4;
537
+ const fsOpts = {
538
+ projectRoot: opts.projectRoot
539
+ };
540
+ if (opts.fsTimeoutMs !== void 0) fsOpts.timeoutMs = opts.fsTimeoutMs;
541
+ this.fileServer = new FileServer(fsOpts);
542
+ const termOpts = {
543
+ projectRoot: opts.projectRoot
544
+ };
545
+ if (opts.terminalTimeoutMs !== void 0) {
546
+ termOpts.commandTimeoutMs = opts.terminalTimeoutMs;
547
+ }
548
+ if (opts.terminalOutputByteLimit !== void 0) {
549
+ termOpts.outputByteLimit = opts.terminalOutputByteLimit;
550
+ }
551
+ this.terminalServer = new TerminalServer(termOpts);
552
+ this.permissionPolicy = opts.permissionPolicy ?? defaultPermissionPolicy;
553
+ }
554
+ /**
555
+ * Spawn the child, run the initialize handshake, install the
556
+ * message dispatch, and return a ready session.
557
+ */
558
+ static async start(opts) {
559
+ const transportOpts = {
560
+ command: opts.command,
561
+ args: opts.args ? [...opts.args] : [],
562
+ handshakeTimeoutMs: 3e4,
563
+ // ACPSession is the v1 CLIENT side: it speaks to external agents
564
+ // (Claude Code, Gemini CLI, …) that do NOT emit a `[wstack-acp]\n`
565
+ // startup marker. The transport should treat the child as ready
566
+ // as soon as the process is spawned and stdout is flowing.
567
+ skipHandshakeMarker: true
568
+ };
569
+ if (opts.env !== void 0) transportOpts.env = opts.env;
570
+ if (opts.cwd !== void 0) transportOpts.cwd = opts.cwd;
571
+ const transport = new ClientTransport(transportOpts);
572
+ try {
573
+ await transport.start();
574
+ } catch (err) {
575
+ const msg = err instanceof Error ? err.message : String(err);
576
+ throw new ACPSessionError("spawn_failed", `failed to spawn ${opts.command}: ${msg}`, err);
577
+ }
578
+ const session = new _ACPSession(opts, transport);
579
+ transport.onMessage((msg) => session.handleMessage(msg));
580
+ try {
581
+ await session.initialize();
582
+ } catch (err) {
583
+ try {
584
+ transport.stop();
585
+ } catch {
586
+ }
587
+ throw err;
588
+ }
589
+ return session;
590
+ }
591
+ async initialize() {
592
+ const id = this.allocId();
593
+ const result = await this.sendRequest(id, "initialize", {
594
+ protocolVersion: ACP_PROTOCOL_VERSION,
595
+ clientCapabilities: {
596
+ fs: { readTextFile: true, writeTextFile: true },
597
+ terminal: true,
598
+ promptCapabilities: { image: false, audio: false, embeddedContext: true }
599
+ },
600
+ clientInfo: { name: "wrongstack", title: "WrongStack", version: "0.263.0" }
251
601
  });
252
- sessionStarted = true;
253
- };
254
- const runner = async (task, ctx) => {
255
- ctx.signal.addEventListener("abort", () => {
256
- activeAbort.abort();
257
- transport.stop();
602
+ if (isJsonRpcError(result)) {
603
+ throw new ACPSessionError("init_failed", `initialize failed: ${result.message}`, result);
604
+ }
605
+ if (typeof result !== "object" || result === null || typeof result.protocolVersion !== "number") {
606
+ throw new ACPSessionError("protocol_error", "initialize returned no protocolVersion");
607
+ }
608
+ const r = result;
609
+ if (r.protocolVersion !== ACP_PROTOCOL_VERSION) {
610
+ throw new ACPSessionError(
611
+ "unsupported_capability",
612
+ `agent speaks protocolVersion=${r.protocolVersion}, client speaks ${ACP_PROTOCOL_VERSION}`
613
+ );
614
+ }
615
+ this.state = "ready";
616
+ }
617
+ /**
618
+ * Run one prompt turn. Creates a session if needed, sends the
619
+ * prompt, streams session/update notifications, and resolves with
620
+ * the agent's response.
621
+ *
622
+ * Cancellation: if `signal` aborts mid-prompt, we send
623
+ * `session/cancel` (a notification per spec) and keep accepting
624
+ * updates until the agent returns with `stopReason: 'cancelled'`.
625
+ * The result is the same shape as a normal turn, with
626
+ * `stopReason === 'cancelled'`.
627
+ */
628
+ async prompt(text, signal) {
629
+ if (this.closed) {
630
+ throw new ACPSessionError("closed", "session is closed");
631
+ }
632
+ if (this.state !== "ready" && this.state !== "done") {
633
+ throw new ACPSessionError("protocol_error", `prompt called in state=${this.state}`);
634
+ }
635
+ if (signal.aborted) {
636
+ return { text: "", stopReason: "cancelled", hasText: false };
637
+ }
638
+ if (!this.sessionId) {
639
+ await this.createSession();
640
+ }
641
+ this.resetScratch();
642
+ const promptId = this.allocId();
643
+ const turnPromise = this.sendRequest(
644
+ promptId,
645
+ "session/prompt",
646
+ {
647
+ sessionId: this.sessionId,
648
+ prompt: [textContent(text)]
649
+ },
650
+ this.timeoutMs
651
+ );
652
+ let cancelled = false;
653
+ const onAbort = () => {
654
+ cancelled = true;
655
+ this.transport.send({ method: "session/cancel", params: { sessionId: this.sessionId } }).catch(() => {
656
+ });
657
+ };
658
+ signal.addEventListener("abort", onAbort, { once: true });
659
+ this.state = "prompting";
660
+ let response;
661
+ try {
662
+ response = await turnPromise;
663
+ } catch (err) {
664
+ this.state = "done";
665
+ signal.removeEventListener("abort", onAbort);
666
+ if (cancelled || signal.aborted) {
667
+ throw new ACPSessionError("aborted", "prompt was aborted by the parent");
668
+ }
669
+ const msg = err instanceof Error ? err.message : String(err);
670
+ throw new ACPSessionError("prompt_failed", `session/prompt failed: ${msg}`, err);
671
+ } finally {
672
+ signal.removeEventListener("abort", onAbort);
673
+ }
674
+ this.state = "done";
675
+ if (isJsonRpcError(response)) {
676
+ throw new ACPSessionError("prompt_failed", `agent error: ${response.message}`, response);
677
+ }
678
+ const stopReason = response.stopReason ?? "end_turn";
679
+ const finalText = this.scratch.text;
680
+ return {
681
+ text: finalText,
682
+ stopReason,
683
+ hasText: finalText.length > 0,
684
+ usage: this.scratch.usage,
685
+ plan: this.scratch.plan
686
+ };
687
+ }
688
+ async createSession() {
689
+ const id = this.allocId();
690
+ const result = await this.sendRequest(id, "session/new", {
691
+ cwd: this.opts.cwd ?? this.opts.projectRoot,
692
+ mcpServers: []
258
693
  });
259
- await startSession();
260
- const callId = crypto.randomUUID();
261
- let toolResult = null;
262
- const resultPromise = new Promise((resolve, reject) => {
263
- const budgetMs = ctx.budget.limits.timeoutMs ?? 3e5;
264
- const timeout = setTimeout(() => {
265
- reject(new Error(`ACP task timed out for subagent ${ctx.subagentId} (${budgetMs}ms budget)`));
266
- }, budgetMs);
267
- transport.onMessage((msg) => {
268
- if (msg.method === "tools/call" && msg.id !== void 0) {
269
- clearTimeout(timeout);
270
- resolve(msg);
271
- }
694
+ if (isJsonRpcError(result)) {
695
+ throw new ACPSessionError(
696
+ "session_create_failed",
697
+ `session/new failed: ${result.message}`,
698
+ result
699
+ );
700
+ }
701
+ const sessionId = result.sessionId;
702
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
703
+ throw new ACPSessionError(
704
+ "protocol_error",
705
+ "session/new returned no sessionId",
706
+ result
707
+ );
708
+ }
709
+ this.sessionId = sessionId;
710
+ }
711
+ /** Tear down the session and kill the child process. */
712
+ async close() {
713
+ if (this.closed) return;
714
+ this.closed = true;
715
+ this.state = "closed";
716
+ this.terminalServer.releaseAll();
717
+ for (const [, p] of this.pending) {
718
+ clearTimeout(p.timeoutHandle);
719
+ p.reject(new ACPSessionError("closed", "session was closed"));
720
+ }
721
+ this.pending.clear();
722
+ try {
723
+ this.transport.stop();
724
+ } catch {
725
+ }
726
+ }
727
+ // ────────────────────────────────────────────────────────────────────
728
+ // Wire layer
729
+ // ────────────────────────────────────────────────────────────────────
730
+ allocId() {
731
+ return this.nextId++;
732
+ }
733
+ async sendRequest(id, method, params, timeoutMs) {
734
+ return new Promise((resolve3, reject) => {
735
+ const effectiveTimeout = timeoutMs ?? this.timeoutMs;
736
+ const handle = setTimeout(() => {
737
+ this.pending.delete(id);
738
+ reject(
739
+ new ACPSessionError(
740
+ "protocol_error",
741
+ `${method} timed out after ${effectiveTimeout}ms`
742
+ )
743
+ );
744
+ }, effectiveTimeout);
745
+ this.pending.set(id, {
746
+ method,
747
+ resolve: resolve3,
748
+ reject,
749
+ timeoutMs: effectiveTimeout,
750
+ timeoutHandle: handle
272
751
  });
273
- ctx.signal.addEventListener("abort", () => {
274
- clearTimeout(timeout);
275
- reject(new Error("Task aborted by parent"));
752
+ this.transport.send({ jsonrpc: "2.0", id, method, params }).catch((err) => {
753
+ clearTimeout(handle);
754
+ this.pending.delete(id);
755
+ const msg = err instanceof Error ? err.message : String(err);
756
+ reject(new ACPSessionError("protocol_error", `send ${method} failed: ${msg}`, err));
757
+ });
758
+ });
759
+ }
760
+ handleMessage(msg) {
761
+ if (msg.id !== void 0 && (msg.result !== void 0 || msg.error !== void 0)) {
762
+ const pending = this.pending.get(msg.id);
763
+ if (!pending) return;
764
+ clearTimeout(pending.timeoutHandle);
765
+ this.pending.delete(msg.id);
766
+ if (msg.error !== void 0) {
767
+ pending.reject(new Error(msg.error.message ?? "unknown JSON-RPC error"));
768
+ } else {
769
+ pending.resolve(msg.result);
770
+ }
771
+ return;
772
+ }
773
+ if (msg.method === "session/update") {
774
+ this.handleUpdate(msg);
775
+ return;
776
+ }
777
+ if (msg.method === "session/request_permission") {
778
+ void this.handlePermissionRequest(msg);
779
+ return;
780
+ }
781
+ if (msg.method === "fs/read_text_file" || msg.method === "fs/write_text_file") {
782
+ void this.handleFsRequest(msg);
783
+ return;
784
+ }
785
+ if (msg.method && msg.method.startsWith("terminal/")) {
786
+ void this.handleTerminalRequest(msg);
787
+ return;
788
+ }
789
+ if (msg.method) {
790
+ console.warn(`[acp-session] unhandled method: ${msg.method}`);
791
+ }
792
+ }
793
+ handleUpdate(msg) {
794
+ const update = msg.params?.update;
795
+ if (typeof update !== "object" || update === null) return;
796
+ const u = update;
797
+ switch (u.sessionUpdate) {
798
+ case "agent_message_chunk": {
799
+ const text = extractText(u.content);
800
+ if (text) this.accumulatedText(text);
801
+ return;
802
+ }
803
+ case "thought_chunk":
804
+ return;
805
+ case "tool_call":
806
+ case "tool_call_update":
807
+ return;
808
+ case "plan":
809
+ if (Array.isArray(u.entries)) {
810
+ this.accumulatedPlan(u.entries);
811
+ }
812
+ return;
813
+ case "usage_update":
814
+ if (typeof u.used === "number" && typeof u.size === "number") {
815
+ this.accumulatedUsage({
816
+ used: u.used,
817
+ size: u.size,
818
+ ...typeof u.cost === "object" && u.cost !== null ? {
819
+ cost: u.cost
820
+ } : {}
821
+ });
822
+ }
823
+ return;
824
+ case "available_commands_update":
825
+ case "current_mode_update":
826
+ case "config_option_update":
827
+ case "session_info_update":
828
+ case "user_message_chunk":
829
+ return;
830
+ default:
831
+ console.warn(`[acp-session] unhandled sessionUpdate: ${u.sessionUpdate}`);
832
+ return;
833
+ }
834
+ }
835
+ // Per-prompt scratch state. Reset at the start of each prompt() and
836
+ // read at the end to assemble the ACPSessionRunResult. The stream
837
+ // pump writes to it via the three `accumulated*` helpers below.
838
+ scratch = { text: "" };
839
+ accumulatedText(chunk) {
840
+ this.scratch.text += chunk;
841
+ }
842
+ accumulatedPlan(entries) {
843
+ this.scratch.plan = entries;
844
+ }
845
+ accumulatedUsage(u) {
846
+ this.scratch.usage = u;
847
+ }
848
+ resetScratch() {
849
+ this.scratch = { text: "" };
850
+ }
851
+ async handlePermissionRequest(msg) {
852
+ const id = msg.id;
853
+ if (id === void 0) return;
854
+ const params = msg.params;
855
+ const toolCall = params?.toolCall;
856
+ const options = Array.isArray(params?.options) ? params.options : [];
857
+ if (!toolCall) {
858
+ await this.transport.send({
859
+ id,
860
+ method: "session/request_permission",
861
+ error: { code: -32602, message: "toolCall is required" }
276
862
  });
863
+ return;
864
+ }
865
+ const policyAbort = new AbortController();
866
+ const outcome = await this.permissionPolicy({
867
+ toolCall,
868
+ options,
869
+ signal: policyAbort.signal
277
870
  });
871
+ await this.transport.send({
872
+ id,
873
+ method: "session/request_permission",
874
+ result: { outcome }
875
+ });
876
+ }
877
+ async handleFsRequest(msg) {
878
+ const id = msg.id;
879
+ if (id === void 0) return;
880
+ const params = msg.params;
881
+ if (!params?.path) {
882
+ await this.transport.send({
883
+ id,
884
+ method: msg.method,
885
+ error: { code: -32602, message: "path is required" }
886
+ });
887
+ return;
888
+ }
889
+ try {
890
+ if (msg.method === "fs/read_text_file") {
891
+ const result = await this.fileServer.readTextFile({
892
+ sessionId: params.sessionId ?? "",
893
+ path: params.path
894
+ });
895
+ await this.transport.send({ id, method: msg.method, result });
896
+ } else {
897
+ await this.fileServer.writeTextFile({
898
+ sessionId: params.sessionId ?? "",
899
+ path: params.path,
900
+ content: params.content ?? ""
901
+ });
902
+ await this.transport.send({ id, method: msg.method, result: {} });
903
+ }
904
+ } catch (err) {
905
+ const code = err instanceof FsError ? -32602 : -32603;
906
+ const message = err instanceof Error ? err.message : String(err);
907
+ await this.transport.send({ id, method: msg.method, error: { code, message } });
908
+ }
909
+ }
910
+ async handleTerminalRequest(msg) {
911
+ const id = msg.id;
912
+ if (id === void 0) return;
913
+ const params = msg.params ?? {};
278
914
  try {
279
- await transport.send({
280
- method: "agent/run",
281
- id: callId,
282
- params: {
283
- task: task.description,
284
- sessionId: ctx.subagentId
915
+ switch (msg.method) {
916
+ case "terminal/create": {
917
+ const createOpts = {
918
+ sessionId: String(params.sessionId ?? ""),
919
+ command: String(params.command ?? ""),
920
+ args: Array.isArray(params.args) ? params.args : []
921
+ };
922
+ if (Array.isArray(params.env)) {
923
+ createOpts.env = params.env;
924
+ }
925
+ if (typeof params.cwd === "string") {
926
+ createOpts.cwd = params.cwd;
927
+ }
928
+ if (typeof params.outputByteLimit === "number") {
929
+ createOpts.outputByteLimit = params.outputByteLimit;
930
+ }
931
+ const result = this.terminalServer.create(createOpts);
932
+ await this.transport.send({ id, method: msg.method, result });
933
+ return;
285
934
  }
935
+ case "terminal/output": {
936
+ const terminalId = String(params.terminalId ?? "");
937
+ const out = this.terminalServer.output(terminalId);
938
+ await this.transport.send({ id, method: msg.method, result: out });
939
+ return;
940
+ }
941
+ case "terminal/wait_for_exit": {
942
+ const terminalId = String(params.terminalId ?? "");
943
+ const exit = await this.terminalServer.waitForExit(terminalId);
944
+ await this.transport.send({ id, method: msg.method, result: exit });
945
+ return;
946
+ }
947
+ case "terminal/kill": {
948
+ const terminalId = String(params.terminalId ?? "");
949
+ this.terminalServer.kill(terminalId);
950
+ await this.transport.send({ id, method: msg.method, result: {} });
951
+ return;
952
+ }
953
+ case "terminal/release": {
954
+ const terminalId = String(params.terminalId ?? "");
955
+ this.terminalServer.release(terminalId);
956
+ await this.transport.send({ id, method: msg.method, result: {} });
957
+ return;
958
+ }
959
+ default:
960
+ await this.transport.send({
961
+ id,
962
+ method: msg.method,
963
+ error: { code: -32601, message: `unknown method: ${msg.method}` }
964
+ });
965
+ }
966
+ } catch (err) {
967
+ const message = err instanceof Error ? err.message : String(err);
968
+ await this.transport.send({
969
+ id,
970
+ method: msg.method,
971
+ error: { code: -32603, message }
972
+ });
973
+ }
974
+ }
975
+ };
976
+ function textContent(text) {
977
+ return { type: "text", text };
978
+ }
979
+ function extractText(block) {
980
+ if (typeof block !== "object" || block === null) return "";
981
+ const b = block;
982
+ if (b.type === "text" && typeof b.text === "string") return b.text;
983
+ return "";
984
+ }
985
+
986
+ // src/integration/acp-subagent-runner.ts
987
+ async function makeACPSubagentRunner(options) {
988
+ const { runner, stop } = await makeACPSubagentRunnerWithStop(options);
989
+ const wrappedRunner = async (task, ctx) => {
990
+ try {
991
+ return await runner(task, ctx);
992
+ } finally {
993
+ stop();
994
+ }
995
+ };
996
+ return wrappedRunner;
997
+ }
998
+ async function makeACPSubagentRunnerWithStop(options) {
999
+ const projectRoot = options.projectRoot ?? options.cwd ?? process.cwd();
1000
+ const timeoutMs = options.timeoutMs ?? 5 * 6e4;
1001
+ const runner = async (task, ctx) => {
1002
+ let session = null;
1003
+ try {
1004
+ session = await ACPSession.start({
1005
+ command: options.command,
1006
+ ...options.args !== void 0 ? { args: options.args } : {},
1007
+ ...options.env !== void 0 ? { env: options.env } : {},
1008
+ ...options.cwd !== void 0 ? { cwd: options.cwd } : {},
1009
+ projectRoot,
1010
+ timeoutMs,
1011
+ role: options.role
286
1012
  });
287
- toolResult = await resultPromise;
288
1013
  } catch (err) {
289
- const msg = err instanceof Error ? err.message : String(err);
1014
+ throw acpErrorToSubagentError(err, options.role ?? "acp-subagent");
1015
+ }
1016
+ try {
1017
+ const result = await session.prompt(task.description, ctx.signal);
290
1018
  return {
291
- result: `ACP subagent error: ${msg}`,
292
- iterations: 0,
1019
+ result: result.text,
1020
+ iterations: 1,
293
1021
  toolCalls: 0
294
1022
  };
1023
+ } catch (err) {
1024
+ if (err instanceof ACPSessionError && err.kind === "aborted") {
1025
+ throw acpErrorToSubagentError(err, options.role ?? "acp-subagent");
1026
+ }
1027
+ throw acpErrorToSubagentError(err, options.role ?? "acp-subagent");
1028
+ } finally {
1029
+ try {
1030
+ await session.close();
1031
+ } catch {
1032
+ }
295
1033
  }
296
- if (!toolResult) {
297
- return { result: "ACP subagent returned no result", iterations: 1, toolCalls: 1 };
298
- }
299
- const parsed = parseToolResponse(task.id, ctx.subagentId, toolResult);
1034
+ };
1035
+ const stop = () => {
1036
+ };
1037
+ return { runner, stop };
1038
+ }
1039
+ function acpErrorToSubagentError(err, subagentId) {
1040
+ if (err instanceof ACPSessionError) {
1041
+ const kind = mapACPKind(err.kind);
300
1042
  return {
301
- result: parsed.result ?? parsed.error,
302
- iterations: parsed.iterations,
303
- toolCalls: parsed.toolCalls
1043
+ kind,
1044
+ message: `${subagentId}: ${err.message}`,
1045
+ retryable: isRetryable(kind),
1046
+ cause: {
1047
+ name: err.name,
1048
+ message: err.message,
1049
+ ...err.stack !== void 0 ? { stack: err.stack } : {}
1050
+ }
304
1051
  };
1052
+ }
1053
+ const message = err instanceof Error ? err.message : String(err);
1054
+ return {
1055
+ kind: "bridge_failed",
1056
+ message: `${subagentId}: ${message}`,
1057
+ retryable: false,
1058
+ cause: {
1059
+ name: err instanceof Error ? err.name : "Error",
1060
+ message,
1061
+ ...err instanceof Error && err.stack !== void 0 ? { stack: err.stack } : {}
1062
+ }
305
1063
  };
306
- return runner;
307
1064
  }
308
- function clientTransportOptions(options) {
309
- const out = {
310
- command: options.command,
311
- handshakeTimeoutMs: 3e4
312
- };
313
- if (options.args !== void 0) out.args = options.args;
314
- if (options.env !== void 0) out.env = options.env;
315
- if (options.cwd !== void 0) out.cwd = options.cwd;
316
- return out;
1065
+ function mapACPKind(acpKind) {
1066
+ switch (acpKind) {
1067
+ case "spawn_failed":
1068
+ case "init_failed":
1069
+ case "session_create_failed":
1070
+ case "agent_died":
1071
+ case "protocol_error":
1072
+ return "bridge_failed";
1073
+ case "prompt_failed":
1074
+ return "tool_failed";
1075
+ case "aborted":
1076
+ return "aborted_by_parent";
1077
+ case "closed":
1078
+ case "unsupported_capability":
1079
+ return "unknown";
1080
+ }
1081
+ }
1082
+ function isRetryable(kind) {
1083
+ switch (kind) {
1084
+ case "provider_5xx":
1085
+ case "provider_rate_limit":
1086
+ case "provider_timeout":
1087
+ case "tool_threw":
1088
+ case "budget_timeout":
1089
+ return true;
1090
+ default:
1091
+ return false;
1092
+ }
317
1093
  }
318
1094
 
319
1095
  export { ClientTransport, ToolTranslator, makeACPSubagentRunner };