@wrongstack/acp 0.260.0 → 0.265.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,9 @@
1
1
  import { writeErr, expectDefined } from '@wrongstack/core';
2
2
  import { fileURLToPath } from 'url';
3
+ import * as fsp from 'fs/promises';
4
+ import * as path from 'path';
5
+ import { spawn } from 'child_process';
6
+ import { SubagentBudget } from '@wrongstack/core/coordination';
3
7
 
4
8
  // src/agent/stdio-transport.ts
5
9
  var StdioTransport = class {
@@ -23,9 +27,9 @@ var StdioTransport = class {
23
27
  }
24
28
  send(msg) {
25
29
  if (this.closed) return Promise.resolve();
26
- return new Promise((resolve) => {
30
+ return new Promise((resolve3) => {
27
31
  const line = JSON.stringify(msg) + "\n";
28
- this.stdout.write(line, "utf8", () => resolve());
32
+ this.stdout.write(line, "utf8", () => resolve3());
29
33
  });
30
34
  }
31
35
  sendRaw(chunk) {
@@ -34,8 +38,8 @@ var StdioTransport = class {
34
38
  read() {
35
39
  if (this.messageQueue.length > 0) return Promise.resolve(expectDefined(this.messageQueue.shift()));
36
40
  if (this.closed) return Promise.resolve(null);
37
- return new Promise((resolve) => {
38
- this.resolveRead = resolve;
41
+ return new Promise((resolve3) => {
42
+ this.resolveRead = resolve3;
39
43
  });
40
44
  }
41
45
  onMessage(handler) {
@@ -64,9 +68,9 @@ var StdioTransport = class {
64
68
  }
65
69
  dispatch(msg) {
66
70
  if (this.resolveRead) {
67
- const resolve = this.resolveRead;
71
+ const resolve3 = this.resolveRead;
68
72
  this.resolveRead = null;
69
- resolve(msg);
73
+ resolve3(msg);
70
74
  } else {
71
75
  this.messageQueue.push(msg);
72
76
  }
@@ -106,22 +110,30 @@ var ClientTransport = class {
106
110
  }
107
111
  async start() {
108
112
  if (this.child) return;
109
- const [{ spawn }, { buildChildEnv }] = await Promise.all([
113
+ const [{ spawn: spawn3 }, { buildChildEnv }] = await Promise.all([
110
114
  import('child_process'),
111
115
  import('@wrongstack/core')
112
116
  ]);
113
- return new Promise((resolve, reject) => {
117
+ return new Promise((resolve3, reject) => {
114
118
  const timeout = setTimeout(() => {
115
119
  reject(
116
120
  new Error(`ACP child process failed to start within ${this.opts.handshakeTimeoutMs}ms`)
117
121
  );
118
122
  }, this.opts.handshakeTimeoutMs);
119
123
  try {
120
- this.child = spawn(this.opts.command, this.opts.args ?? [], {
124
+ this.child = spawn3(this.opts.command, this.opts.args ?? [], {
121
125
  env: { ...buildChildEnv(), ...this.opts.env },
122
126
  cwd: this.opts.cwd,
123
127
  stdio: ["pipe", "pipe", "pipe"],
124
- windowsHide: true
128
+ windowsHide: true,
129
+ // On Windows, most ACP-supporting tools (claude, gemini, codex,
130
+ // qwen, copilot) are installed as `.cmd` shims under
131
+ // AppData\Roaming\npm\. Node's spawn won't find them via
132
+ // `shell: false` because the .cmd extension is not in the
133
+ // default PATHEXT lookup. The argv here is always from our
134
+ // own static catalog or from a hardcoded spec, never from
135
+ // user input, so shell-expansion is bounded.
136
+ shell: process.platform === "win32"
125
137
  });
126
138
  } catch (err) {
127
139
  clearTimeout(timeout);
@@ -130,17 +142,24 @@ var ClientTransport = class {
130
142
  }
131
143
  const child = this.child;
132
144
  child.stdout.setEncoding("utf8");
145
+ const onReady = () => {
146
+ child.stdout.on("data", (c) => this.onChildData(c));
147
+ child.stderr.on("data", (c) => this.onChildError(c));
148
+ child.on("close", (code) => this.onChildClose(code));
149
+ clearTimeout(timeout);
150
+ resolve3();
151
+ };
152
+ if (this.opts.skipHandshakeMarker) {
153
+ onReady();
154
+ return;
155
+ }
133
156
  const waitForMarker = (chunk) => {
134
157
  this.buffer += chunk;
135
158
  const idx = this.buffer.indexOf("[wstack-acp]\n");
136
159
  if (idx !== -1) {
137
160
  this.buffer = this.buffer.slice(idx + "[wstack-acp]\n".length);
138
161
  child.stdout.removeListener("data", waitForMarker);
139
- child.stdout.on("data", (c) => this.onChildData(c));
140
- child.stderr.on("data", (c) => this.onChildError(c));
141
- child.on("close", (code) => this.onChildClose(code));
142
- clearTimeout(timeout);
143
- resolve();
162
+ onReady();
144
163
  }
145
164
  };
146
165
  child.stdout.on("data", waitForMarker);
@@ -156,19 +175,19 @@ var ClientTransport = class {
156
175
  }
157
176
  send(msg) {
158
177
  if (!this.child) return Promise.reject(new Error("ClientTransport not started"));
159
- return new Promise((resolve, reject) => {
178
+ return new Promise((resolve3, reject) => {
160
179
  const line = JSON.stringify(msg) + "\n";
161
180
  this.child?.stdin.write(line, "utf8", (err) => {
162
181
  if (err) reject(err);
163
- else resolve();
182
+ else resolve3();
164
183
  });
165
184
  });
166
185
  }
167
186
  read() {
168
187
  if (this.messageQueue.length > 0) return Promise.resolve(expectDefined(this.messageQueue.shift()));
169
188
  if (this.closed) return Promise.resolve(null);
170
- return new Promise((resolve) => {
171
- this.resolveRead = resolve;
189
+ return new Promise((resolve3) => {
190
+ this.resolveRead = resolve3;
172
191
  });
173
192
  }
174
193
  onMessage(handler) {
@@ -210,9 +229,9 @@ var ClientTransport = class {
210
229
  }
211
230
  dispatch(msg) {
212
231
  if (this.resolveRead) {
213
- const resolve = this.resolveRead;
232
+ const resolve3 = this.resolveRead;
214
233
  this.resolveRead = null;
215
- resolve(msg);
234
+ resolve3(msg);
216
235
  } else {
217
236
  this.messageQueue.push(msg);
218
237
  }
@@ -339,182 +358,366 @@ function toolToPriority(tool) {
339
358
  return "low";
340
359
  }
341
360
 
361
+ // src/types/acp-v1.ts
362
+ var ACP_PROTOCOL_VERSION = 1;
363
+
342
364
  // src/agent/protocol-handler.ts
343
- var WRONGSTACK_VERSION = "0.1.0";
344
- var WRONGSTACK_CAPABILITIES = [
345
- "code-generation",
346
- "async-tools",
347
- "streaming",
348
- "progress"
365
+ function toWire(msg) {
366
+ return msg;
367
+ }
368
+ var WRONGSTACK_VERSION = "0.263.0";
369
+ var DEFAULT_MODE_ID = "code";
370
+ var DEFAULT_MODES = [
371
+ {
372
+ id: DEFAULT_MODE_ID,
373
+ name: "Code",
374
+ description: "Default agent mode for code-generation tasks."
375
+ }
349
376
  ];
350
377
  var ACPProtocolHandler = class {
351
- constructor(transport, registry, context) {
352
- this.transport = transport;
353
- this.registry = registry;
354
- this.context = context;
355
- }
356
378
  transport;
357
- registry;
358
- context;
379
+ defaultCwd;
380
+ runTurn;
381
+ onSessionNew;
382
+ modes;
383
+ configOptions;
384
+ agentName;
359
385
  initialized = false;
360
- signal = new AbortController();
361
- pendingCalls = /* @__PURE__ */ new Map();
362
- /** Wire an external abort signal from the ACP client */
363
- wireAbortController(abortController) {
364
- abortController.signal.addEventListener("abort", () => {
365
- for (const id of this.pendingCalls.keys()) {
366
- this.transport.send({ id, method: "cancel", result: { ok: true } }).catch((err) => console.debug(`[protocol-handler] cancel send failed: ${err}`));
367
- }
386
+ sessions = /* @__PURE__ */ new Map();
387
+ nextId = 1;
388
+ constructor(opts) {
389
+ this.transport = opts.transport;
390
+ this.defaultCwd = opts.defaultCwd;
391
+ this.runTurn = opts.runTurn;
392
+ this.onSessionNew = opts.onSessionNew ?? (() => {
368
393
  });
394
+ this.modes = opts.modes ?? DEFAULT_MODES;
395
+ this.configOptions = opts.configOptions ?? [];
396
+ this.agentName = opts.agentName ?? "wrongstack";
369
397
  }
370
- /** Process one inbound message. Returns true if this was a terminal message. */
398
+ /**
399
+ * Process one inbound message. Returns true if this was a terminal
400
+ * message (rare; reserved for future use by the server's own
401
+ * shutdown signal).
402
+ */
371
403
  async handleMessage(msg) {
372
- if (msg.id !== void 0) {
373
- return this.handleRequest(msg);
404
+ if (typeof msg !== "object" || msg === null) return false;
405
+ const m = msg;
406
+ if (m.id !== void 0 && (m.result !== void 0 || m.error !== void 0)) {
407
+ return false;
374
408
  }
375
- return this.handleNotification(msg);
409
+ if (m.id !== void 0 && typeof m.method === "string") {
410
+ return this.handleRequest(m.id, m.method, m.params);
411
+ }
412
+ if (typeof m.method === "string") {
413
+ return this.handleNotification(m.method, m.params);
414
+ }
415
+ return false;
376
416
  }
377
- async handleRequest(req) {
378
- if (req.method !== "initialize" && !this.initialized) {
379
- await this.sendError(req.id ?? null, -32e3, "Not initialized");
417
+ /** Abort all active turns and drop session state. */
418
+ close() {
419
+ for (const [, session] of this.sessions) {
420
+ session.abort.abort();
421
+ }
422
+ this.sessions.clear();
423
+ }
424
+ // ────────────────────────────────────────────────────────────────────
425
+ // Requests
426
+ // ────────────────────────────────────────────────────────────────────
427
+ async handleRequest(id, method, params) {
428
+ if (method !== "initialize" && !this.initialized) {
429
+ await this.sendError(id, -32e3, "Not initialized");
380
430
  return false;
381
431
  }
382
- const id = req.id;
383
- switch (req.method) {
384
- case "initialize":
385
- return this.handleInitialize(req, id);
386
- case "ping":
387
- await this.transport.send({ id, method: "ping", result: { pong: true } });
388
- return false;
389
- case "tools/call":
390
- return this.handleToolCall(req, id);
391
- case "tools/list":
392
- return this.handleToolsList(id);
393
- case "cancel":
394
- return this.handleCancel(id);
395
- case "session/list":
396
- return this.handleSessionList(id);
397
- case "sessionInfoUpdate":
398
- await this.transport.send({ id, method: "sessionInfoUpdate", result: { ok: true } });
399
- return false;
400
- default:
401
- await this.sendError(id, -32601, `Unknown method: ${req.method}`);
402
- return false;
432
+ try {
433
+ switch (method) {
434
+ case "initialize":
435
+ return await this.handleInitialize(id, params);
436
+ case "authenticate":
437
+ return await this.handleAuthenticate(id, params);
438
+ case "session/new":
439
+ return await this.handleSessionNew(id, params);
440
+ case "session/load":
441
+ return await this.handleSessionLoad(id, params);
442
+ case "session/prompt":
443
+ return await this.handleSessionPrompt(id, params);
444
+ case "session/set_mode":
445
+ return await this.handleSetMode(id, params);
446
+ case "session/set_config_option":
447
+ return await this.handleSetConfigOption(id, params);
448
+ case "session/list":
449
+ return await this.handleSessionList(id);
450
+ default:
451
+ await this.sendError(id, -32601, `Unknown method: ${method}`);
452
+ return false;
453
+ }
454
+ } catch (err) {
455
+ const { code, message, data } = errorToJsonRpc(err);
456
+ await this.sendError(id, code, message, data);
457
+ return false;
403
458
  }
404
459
  }
405
- async handleNotification(n) {
406
- if (n.method === "cancel") {
407
- this.handleCancelNotification(n);
460
+ async handleInitialize(id, params) {
461
+ const p = params ?? {};
462
+ const requested = typeof p.protocolVersion === "number" ? p.protocolVersion : 1;
463
+ if (requested !== ACP_PROTOCOL_VERSION) {
464
+ await this.sendError(
465
+ id,
466
+ -32e3,
467
+ `server speaks protocolVersion=${ACP_PROTOCOL_VERSION}, client requested ${requested}`
468
+ );
469
+ return false;
408
470
  }
409
- return false;
410
- }
411
- async handleInitialize(req, id) {
412
471
  this.initialized = true;
413
- const result = {
414
- capabilities: WRONGSTACK_CAPABILITIES,
415
- agentName: "WrongStack",
416
- agentVersion: WRONGSTACK_VERSION,
417
- protocolVersion: req.params?.protocolVersion ?? "2024-11",
418
- ...this.registry.buildToolList()
419
- };
420
- await this.transport.send({ id, method: "initialize", result });
472
+ await this.transport.send(toWire({
473
+ jsonrpc: "2.0",
474
+ id,
475
+ result: {
476
+ protocolVersion: ACP_PROTOCOL_VERSION,
477
+ agentCapabilities: {
478
+ loadSession: true,
479
+ promptCapabilities: {
480
+ image: false,
481
+ audio: false,
482
+ embeddedContext: true
483
+ }
484
+ },
485
+ agentInfo: {
486
+ name: this.agentName,
487
+ title: "WrongStack",
488
+ version: WRONGSTACK_VERSION
489
+ },
490
+ // Static options advertised at handshake. They are also
491
+ // re-sent on every `current_mode_update` / `config_option_update`
492
+ // notification so late-joining clients see them.
493
+ authMethods: [],
494
+ modes: this.modes,
495
+ configOptions: this.configOptions
496
+ }
497
+ }));
421
498
  return false;
422
499
  }
423
- async handleToolsList(id) {
424
- await this.transport.send({
500
+ async handleAuthenticate(id, _params) {
501
+ await this.transport.send(toWire({
502
+ jsonrpc: "2.0",
425
503
  id,
426
- method: "tools/list",
427
- result: this.registry.buildToolList()
504
+ result: { outcome: "unauthenticated" }
505
+ }));
506
+ return false;
507
+ }
508
+ async handleSessionNew(id, params) {
509
+ const p = params ?? {};
510
+ const cwd = typeof p.cwd === "string" ? p.cwd : this.defaultCwd;
511
+ const sessionId = `sess_${this.allocId()}`;
512
+ const now = (/* @__PURE__ */ new Date()).toISOString();
513
+ const state = {
514
+ id: sessionId,
515
+ cwd,
516
+ abort: new AbortController(),
517
+ modeId: DEFAULT_MODE_ID,
518
+ createdAt: now,
519
+ updatedAt: now
520
+ };
521
+ this.sessions.set(sessionId, state);
522
+ this.onSessionNew(state);
523
+ await this.sendNotification({
524
+ sessionId,
525
+ update: {
526
+ sessionUpdate: "current_mode_update",
527
+ modeId: this.modes[0]?.id ?? DEFAULT_MODE_ID
528
+ }
428
529
  });
530
+ if (this.configOptions.length > 0) {
531
+ await this.sendNotification({
532
+ sessionId,
533
+ update: {
534
+ sessionUpdate: "config_option_update",
535
+ configOptions: [...this.configOptions]
536
+ }
537
+ });
538
+ }
539
+ await this.transport.send(toWire({
540
+ jsonrpc: "2.0",
541
+ id,
542
+ result: {
543
+ sessionId,
544
+ modes: this.modes,
545
+ configOptions: this.configOptions
546
+ }
547
+ }));
429
548
  return false;
430
549
  }
431
- async handleToolCall(req, id) {
432
- const { name, arguments: args } = req.params;
433
- const runPromise = (async () => {
434
- if (!this.registry.has(name)) {
435
- return {
436
- content: [{ type: "text", text: `Tool not found: ${name}` }],
437
- isError: true
438
- };
439
- }
440
- const result = await this.registry.execute(
441
- name,
442
- args,
443
- this.context,
444
- this.signal.signal
445
- );
446
- return result ?? { content: [{ type: "text", text: "Tool returned null" }], isError: false };
447
- })();
448
- this.pendingCalls.set(id, runPromise);
550
+ async handleSessionLoad(id, params) {
551
+ return this.handleSessionNew(id, params);
552
+ }
553
+ async handleSessionPrompt(id, params) {
554
+ const p = params ?? {};
555
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
556
+ if (!sessionId || !this.sessions.has(sessionId)) {
557
+ await this.sendError(id, -32e3, "unknown or missing sessionId");
558
+ return false;
559
+ }
560
+ if (!Array.isArray(p.prompt)) {
561
+ await this.sendError(id, -32602, "prompt must be an array of content blocks");
562
+ return false;
563
+ }
564
+ const session = this.sessions.get(sessionId);
565
+ if (session.abort.signal.aborted) {
566
+ session.abort = new AbortController();
567
+ }
568
+ const turnSignal = new AbortController();
569
+ const onCancel = () => turnSignal.abort();
570
+ session.abort.signal.addEventListener("abort", onCancel, { once: true });
571
+ let result;
449
572
  try {
450
- const toolResult = await runPromise;
451
- this.pendingCalls.delete(id);
452
- const response = { method: "tools/call", id, result: toolResult };
453
- await this.transport.send(response);
573
+ result = await this.runTurn(
574
+ { sessionId, prompt: p.prompt, signal: turnSignal.signal },
575
+ (update) => this.sendNotification({ sessionId, update })
576
+ );
454
577
  } catch (err) {
455
- this.pendingCalls.delete(id);
456
- const msg = err instanceof Error ? err.message : String(err);
457
- await this.transport.send({
458
- id,
459
- method: "tools/call",
460
- result: { content: [{ type: "text", text: msg }], isError: true }
461
- });
578
+ session.abort.signal.removeEventListener("abort", onCancel);
579
+ const { code, message, data } = errorToJsonRpc(err);
580
+ await this.sendError(id, code, message, data);
581
+ return false;
462
582
  }
583
+ session.abort.signal.removeEventListener("abort", onCancel);
584
+ session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
585
+ await this.transport.send(toWire({
586
+ jsonrpc: "2.0",
587
+ id,
588
+ result: { stopReason: result.stopReason }
589
+ }));
463
590
  return false;
464
591
  }
465
- async handleCancel(id) {
466
- this.pendingCalls.delete(id);
467
- await this.transport.send({ id, method: "cancel", result: { ok: true } });
592
+ async handleSetMode(id, params) {
593
+ const p = params ?? {};
594
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
595
+ const modeId = typeof p.modeId === "string" ? p.modeId : null;
596
+ const session = sessionId ? this.sessions.get(sessionId) : void 0;
597
+ if (!session || !modeId || !this.modes.some((m) => m.id === modeId)) {
598
+ await this.sendError(id, -32602, "invalid sessionId or modeId");
599
+ return false;
600
+ }
601
+ session.modeId = modeId;
602
+ session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
603
+ await this.sendNotification({
604
+ sessionId,
605
+ update: { sessionUpdate: "current_mode_update", modeId }
606
+ });
607
+ await this.transport.send(toWire({ jsonrpc: "2.0", id, result: {} }));
468
608
  return false;
469
609
  }
470
- handleCancelNotification(_n) {
610
+ async handleSetConfigOption(id, params) {
611
+ const p = params ?? {};
612
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
613
+ const optionId = typeof p.configOptionId === "string" ? p.configOptionId : null;
614
+ const value = typeof p.value === "string" ? p.value : null;
615
+ const session = sessionId ? this.sessions.get(sessionId) : void 0;
616
+ const option = optionId ? this.configOptions.find((o) => o.id === optionId) : void 0;
617
+ if (!session || !option || value === null || !option.options.some((o) => o.value === value)) {
618
+ await this.sendError(id, -32602, "invalid sessionId, configOptionId, or value");
619
+ return false;
620
+ }
621
+ option.currentValue = value;
622
+ session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
623
+ await this.sendNotification({
624
+ sessionId,
625
+ update: {
626
+ sessionUpdate: "config_option_update",
627
+ configOptions: [...this.configOptions]
628
+ }
629
+ });
630
+ await this.transport.send(toWire({ jsonrpc: "2.0", id, result: {} }));
631
+ return false;
471
632
  }
472
633
  async handleSessionList(id) {
473
- await this.transport.send({
474
- id,
475
- method: "session/list",
476
- result: { sessions: [] }
634
+ const sessions = Array.from(this.sessions.values()).map((s) => {
635
+ const out = {
636
+ sessionId: s.id,
637
+ cwd: s.cwd,
638
+ updatedAt: s.updatedAt
639
+ };
640
+ if (s.title !== void 0) out.title = s.title;
641
+ return out;
477
642
  });
643
+ await this.transport.send(toWire({
644
+ jsonrpc: "2.0",
645
+ id,
646
+ result: { sessions }
647
+ }));
478
648
  return false;
479
649
  }
480
- async sendError(id, code, message) {
481
- if (id === null) return;
482
- await this.transport.send({ id, method: "", error: { code, message } });
650
+ // ────────────────────────────────────────────────────────────────────
651
+ // Notifications
652
+ // ────────────────────────────────────────────────────────────────────
653
+ async handleNotification(method, params) {
654
+ switch (method) {
655
+ case "session/cancel": {
656
+ const p = params ?? {};
657
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
658
+ const session = sessionId ? this.sessions.get(sessionId) : void 0;
659
+ if (session) {
660
+ session.abort.abort();
661
+ }
662
+ return false;
663
+ }
664
+ case "exit":
665
+ this.close();
666
+ return true;
667
+ default:
668
+ return false;
669
+ }
670
+ }
671
+ // ────────────────────────────────────────────────────────────────────
672
+ // Wire helpers
673
+ // ────────────────────────────────────────────────────────────────────
674
+ async sendNotification(params) {
675
+ await this.transport.send(toWire({ jsonrpc: "2.0", method: "session/update", params }));
676
+ }
677
+ async sendError(id, code, message, data) {
678
+ const error = { code, message };
679
+ if (data !== void 0) error.data = data;
680
+ await this.transport.send(toWire({ jsonrpc: "2.0", id, error }));
681
+ }
682
+ allocId() {
683
+ return this.nextId++;
483
684
  }
484
685
  };
686
+ function errorToJsonRpc(err) {
687
+ if (err && typeof err === "object") {
688
+ const e = err;
689
+ if (typeof e.code === "number" && typeof e.message === "string") {
690
+ const result = {
691
+ code: e.code,
692
+ message: e.message
693
+ };
694
+ if (e.data !== void 0) result.data = e.data;
695
+ return result;
696
+ }
697
+ }
698
+ const message = err instanceof Error ? err.message : String(err);
699
+ return { code: -32603, message };
700
+ }
485
701
  var WrongStackACPServer = class {
486
702
  transport;
487
- registry;
488
703
  handler;
489
704
  running = false;
490
- constructor(opts) {
705
+ constructor(opts = {}) {
491
706
  this.transport = new StdioTransport();
492
- this.registry = new ACPToolsRegistry(opts.owner);
493
- this.registry.register(opts.tools);
494
- this.handler = new ACPProtocolHandler(
495
- this.transport,
496
- this.registry,
497
- // Future: WrongStack session/memory context for tool execution.
498
- // When wired, this would carry session state, memory entries, and
499
- // project metadata so ACP tools can self-contextualise.
500
- // Tracked in docs/notes/refactor-2026-06-05.md §5.1.
501
- {}
502
- );
707
+ const runTurn = opts.runTurn ?? defaultEchoRunTurn;
708
+ this.handler = new ACPProtocolHandler({
709
+ transport: this.transport,
710
+ defaultCwd: opts.defaultCwd ?? process.cwd(),
711
+ runTurn,
712
+ agentName: opts.agentName
713
+ });
503
714
  }
504
715
  /**
505
716
  * Start the server. Blocks until the client disconnects.
506
717
  *
507
- * 1. Send the startup marker `[wstack-acp]` so the client
508
- * knows which stdout line is the protocol boundary.
509
- * 2. Loop: read messages, dispatch to handler, until EOF or error.
510
- *
511
- * Single dispatch path: every inbound message is read exactly once
512
- * from the transport and passed to the protocol handler exactly once.
513
- * An earlier version combined a `transport.onMessage` callback with
514
- * this read loop, which caused every message to be processed twice
515
- * (once by the callback, once by the loop) — duplicate tool calls
516
- * and duplicate responses to the client. See the ACP double-dispatch
517
- * fix in the security audit (P1-001).
718
+ * 1. Print the legacy `[wstack-acp]\n` marker so the client knows the
719
+ * process is the ACP server (the old `StdioTransport` handshake).
720
+ * 2. Loop: read messages, dispatch to the handler, until EOF / error.
518
721
  */
519
722
  async start() {
520
723
  this.transport.sendStartupMarker();
@@ -533,8 +736,11 @@ var WrongStackACPServer = class {
533
736
  this.transport.close();
534
737
  }
535
738
  };
739
+ var defaultEchoRunTurn = async (_input, _emit) => {
740
+ return { stopReason: "end_turn" };
741
+ };
536
742
  async function main() {
537
- const server = new WrongStackACPServer({ tools: [] });
743
+ const server = new WrongStackACPServer();
538
744
  await server.start();
539
745
  }
540
746
  var isEntrypoint = process.argv[1] !== void 0 && fileURLToPath(import.meta.url) === process.argv[1];
@@ -550,32 +756,6 @@ var DEFAULT_OPTIONS = {
550
756
  pollIntervalMs: 500,
551
757
  totalTimeoutMs: 12e4
552
758
  };
553
- function extractTextFromContent(blocks) {
554
- const parts = [];
555
- for (const b of blocks) {
556
- if (b.type === "text") parts.push(b.text);
557
- else if (b.type === "resource") parts.push(`[resource: ${b.resource.uri}]`);
558
- else if (b.type === "image") parts.push(`[image: ${b.data.slice(0, 20)}...]`);
559
- else if (b.type === "progress") {
560
- if (b.messages?.length) parts.push(b.messages.join("\n"));
561
- }
562
- }
563
- return parts.join("\n");
564
- }
565
- function parseToolResponse(taskId, subagentId, response) {
566
- const blocks = response.result.content;
567
- const text = extractTextFromContent(blocks);
568
- const isError = response.result.isError || text.toLowerCase().includes("error") || text.toLowerCase().includes("failed");
569
- return {
570
- taskId,
571
- subagentId,
572
- status: isError ? "failed" : "success",
573
- result: text,
574
- iterations: 1,
575
- toolCalls: 1,
576
- durationMs: 0
577
- };
578
- }
579
759
  var ToolTranslator = class {
580
760
  opts;
581
761
  pending = /* @__PURE__ */ new Map();
@@ -617,12 +797,12 @@ var ToolTranslator = class {
617
797
  id: callId,
618
798
  params: { name, arguments: args }
619
799
  });
620
- return new Promise((resolve, reject) => {
800
+ return new Promise((resolve3, reject) => {
621
801
  const timeout = setTimeout(() => {
622
802
  this.pending.delete(callId);
623
803
  reject(new Error(`Tool call ${name} timed out after ${this.opts.totalTimeoutMs}ms`));
624
804
  }, this.opts.totalTimeoutMs);
625
- this.pending.set(callId, { resolve, reject, timeout });
805
+ this.pending.set(callId, { resolve: resolve3, reject, timeout });
626
806
  });
627
807
  }
628
808
  cancelAll() {
@@ -632,6 +812,771 @@ var ToolTranslator = class {
632
812
  this.pending.clear();
633
813
  }
634
814
  };
815
+ var FsError = class extends Error {
816
+ code;
817
+ path;
818
+ constructor(code, path3, message) {
819
+ super(message);
820
+ this.name = "FsError";
821
+ this.code = code;
822
+ this.path = path3;
823
+ }
824
+ };
825
+ var FileServer = class {
826
+ root;
827
+ timeoutMs;
828
+ constructor(opts) {
829
+ this.root = path.resolve(opts.projectRoot);
830
+ this.timeoutMs = opts.timeoutMs ?? 3e4;
831
+ }
832
+ /** Read a text file. Returns the content as a string. */
833
+ async readTextFile(params) {
834
+ const safe = this.resolveInside(params.path);
835
+ const controller = new AbortController();
836
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
837
+ try {
838
+ const content = await fsp.readFile(safe, {
839
+ encoding: "utf8",
840
+ signal: controller.signal
841
+ });
842
+ return { content };
843
+ } catch (err) {
844
+ if (controller.signal.aborted) {
845
+ throw new FsError("TIMEOUT", safe, `readTextFile timed out after ${this.timeoutMs}ms`);
846
+ }
847
+ throw mapFsError(err, safe);
848
+ } finally {
849
+ clearTimeout(timer);
850
+ }
851
+ }
852
+ /** Write a text file. Atomic via write-then-rename. */
853
+ async writeTextFile(params) {
854
+ const safe = this.resolveInside(params.path);
855
+ const controller = new AbortController();
856
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
857
+ const tmp = `${safe}.${randomHex(4)}.tmp`;
858
+ try {
859
+ await fsp.writeFile(tmp, params.content, {
860
+ encoding: "utf8",
861
+ signal: controller.signal
862
+ });
863
+ await fsp.rename(tmp, safe);
864
+ } catch (err) {
865
+ try {
866
+ await fsp.unlink(tmp);
867
+ } catch {
868
+ }
869
+ if (controller.signal.aborted) {
870
+ throw new FsError("TIMEOUT", safe, `writeTextFile timed out after ${this.timeoutMs}ms`);
871
+ }
872
+ throw mapFsError(err, safe);
873
+ } finally {
874
+ clearTimeout(timer);
875
+ }
876
+ }
877
+ /**
878
+ * Resolve a path; throw `FsError('OUTSIDE_ROOT')` if the result is
879
+ * not under the project root. Symlinks are not followed here — we
880
+ * operate on the textual path. A future hardening pass can
881
+ * `fs.realpath` each access to catch symlink escapes.
882
+ */
883
+ resolveInside(p) {
884
+ if (typeof p !== "string" || p.length === 0) {
885
+ throw new FsError("INVALID_PATH", p, "path is empty or not a string");
886
+ }
887
+ if (!path.isAbsolute(p)) {
888
+ throw new FsError("INVALID_PATH", p, "path must be absolute (ACP requirement)");
889
+ }
890
+ const resolved = path.resolve(p);
891
+ const rootWithSep = this.root.endsWith(path.sep) ? this.root : this.root + path.sep;
892
+ if (resolved !== this.root && !resolved.startsWith(rootWithSep)) {
893
+ throw new FsError("OUTSIDE_ROOT", resolved, "path is outside the project root");
894
+ }
895
+ return resolved;
896
+ }
897
+ };
898
+ function mapFsError(err, p) {
899
+ const code = err?.code;
900
+ if (code === "ENOENT") return new FsError("ENOENT", p, `no such file: ${p}`);
901
+ if (code === "EACCES" || code === "EPERM") {
902
+ return new FsError("EACCES", p, `permission denied: ${p}`);
903
+ }
904
+ const msg = err instanceof Error ? err.message : String(err);
905
+ return new FsError("INVALID_PATH", p, msg);
906
+ }
907
+ function randomHex(bytes) {
908
+ let out = "";
909
+ for (let i = 0; i < bytes * 2; i++) {
910
+ out += Math.floor(Math.random() * 16).toString(16);
911
+ }
912
+ return out;
913
+ }
914
+
915
+ // src/client/permission.ts
916
+ var defaultPermissionPolicy = async (req) => {
917
+ if (req.signal.aborted) return { outcome: "cancelled" };
918
+ const ranked = [...req.options].sort((a, b) => {
919
+ const score = (k) => {
920
+ if (k === "allow_always") return 0;
921
+ if (k === "allow_once") return 1;
922
+ if (k === "reject_once") return 2;
923
+ return 3;
924
+ };
925
+ return score(a.kind) - score(b.kind);
926
+ });
927
+ const chosen = ranked[0];
928
+ if (!chosen || chosen.kind === "reject_once" || chosen.kind === "reject_always") {
929
+ return { outcome: "cancelled" };
930
+ }
931
+ return { outcome: "selected", optionId: chosen.optionId };
932
+ };
933
+ var TerminalServer = class {
934
+ terminals = /* @__PURE__ */ new Map();
935
+ projectRoot;
936
+ commandTimeoutMs;
937
+ outputByteLimit;
938
+ nextId = 1;
939
+ constructor(opts) {
940
+ this.projectRoot = path.resolve(opts.projectRoot);
941
+ this.commandTimeoutMs = opts.commandTimeoutMs ?? 5 * 6e4;
942
+ this.outputByteLimit = opts.outputByteLimit ?? 1024 * 1024;
943
+ if (opts.signal) {
944
+ opts.signal.addEventListener("abort", () => this.releaseAll());
945
+ }
946
+ }
947
+ /** Spawn a new terminal. Returns the agent-facing id. */
948
+ create(params) {
949
+ const id = `term_${this.nextId++}`;
950
+ const cwd = this.resolveCwd(params.cwd);
951
+ const proc = spawn(params.command, params.args ?? [], {
952
+ cwd,
953
+ env: this.buildEnv(params.env),
954
+ stdio: ["ignore", "pipe", "pipe"],
955
+ windowsHide: true
956
+ // shell: false on purpose. The terminal server is invoked with
957
+ // the agent's explicit argv; turning on shell-mode would make
958
+ // the command a single shell-parsed string, which breaks
959
+ // Windows cmd quoting for the common case of running node with
960
+ // `-e "<script>"`. If a future feature needs shell features
961
+ // (pipes, redirects), it should be opt-in per-call, not the
962
+ // default.
963
+ });
964
+ const state = {
965
+ proc,
966
+ cwd,
967
+ command: params.command,
968
+ args: params.args ?? [],
969
+ output: "",
970
+ retainedBytes: 0,
971
+ truncated: false,
972
+ exitStatus: void 0,
973
+ timeoutHandle: null,
974
+ exitPromise: new Promise((resolve3) => {
975
+ proc.on("close", (code, signalName) => {
976
+ if (state.timeoutHandle) {
977
+ clearTimeout(state.timeoutHandle);
978
+ state.timeoutHandle = null;
979
+ }
980
+ const exitStatus = {
981
+ exitCode: typeof code === "number" ? code : null,
982
+ signal: typeof signalName === "string" ? signalName : null
983
+ };
984
+ state.exitStatus = exitStatus;
985
+ resolve3(exitStatus);
986
+ });
987
+ proc.on("error", (err) => {
988
+ if (state.timeoutHandle) {
989
+ clearTimeout(state.timeoutHandle);
990
+ state.timeoutHandle = null;
991
+ }
992
+ const exitStatus = { exitCode: 127, signal: null };
993
+ state.exitStatus = exitStatus;
994
+ state.output += `[spawn error] ${err.message}
995
+ `;
996
+ state.retainedBytes += Buffer.byteLength(state.output, "utf8");
997
+ resolve3(exitStatus);
998
+ });
999
+ })
1000
+ };
1001
+ const perCallByteLimit = params.outputByteLimit ?? this.outputByteLimit;
1002
+ proc.stdout?.setEncoding("utf8");
1003
+ proc.stderr?.setEncoding("utf8");
1004
+ const onData = (chunk) => {
1005
+ state.output += chunk;
1006
+ state.retainedBytes = Buffer.byteLength(state.output, "utf8");
1007
+ while (state.retainedBytes > perCallByteLimit) {
1008
+ const trimmed = state.output.slice(1);
1009
+ state.output = trimmed;
1010
+ const newBytes = Buffer.byteLength(state.output, "utf8");
1011
+ if (newBytes >= state.retainedBytes) {
1012
+ break;
1013
+ }
1014
+ state.retainedBytes = newBytes;
1015
+ state.truncated = true;
1016
+ }
1017
+ };
1018
+ proc.stdout?.on("data", onData);
1019
+ proc.stderr?.on("data", onData);
1020
+ state.timeoutHandle = setTimeout(() => {
1021
+ try {
1022
+ proc.kill("SIGTERM");
1023
+ } catch {
1024
+ }
1025
+ }, this.commandTimeoutMs);
1026
+ this.terminals.set(id, state);
1027
+ return { terminalId: id };
1028
+ }
1029
+ /** Return captured output and (if available) the exit status. */
1030
+ output(terminalId) {
1031
+ const state = this.terminals.get(terminalId);
1032
+ if (!state) throw new Error(`unknown terminal: ${terminalId}`);
1033
+ return {
1034
+ output: state.output,
1035
+ truncated: state.truncated,
1036
+ ...state.exitStatus ? { exitStatus: state.exitStatus } : {}
1037
+ };
1038
+ }
1039
+ /** Block until the process exits. Resolves with the exit status. */
1040
+ async waitForExit(terminalId) {
1041
+ const state = this.terminals.get(terminalId);
1042
+ if (!state) throw new Error(`unknown terminal: ${terminalId}`);
1043
+ return state.exitPromise;
1044
+ }
1045
+ /** Kill the process but keep the terminal record (agent can still read output). */
1046
+ kill(terminalId) {
1047
+ const state = this.terminals.get(terminalId);
1048
+ if (!state) throw new Error(`unknown terminal: ${terminalId}`);
1049
+ try {
1050
+ state.proc.kill("SIGTERM");
1051
+ } catch {
1052
+ }
1053
+ }
1054
+ /** Kill the process if alive and remove the record. */
1055
+ release(terminalId) {
1056
+ const state = this.terminals.get(terminalId);
1057
+ if (!state) return;
1058
+ if (state.timeoutHandle) {
1059
+ clearTimeout(state.timeoutHandle);
1060
+ state.timeoutHandle = null;
1061
+ }
1062
+ try {
1063
+ state.proc.kill("SIGKILL");
1064
+ } catch {
1065
+ }
1066
+ this.terminals.delete(terminalId);
1067
+ }
1068
+ /** Kill all active terminals. Used on session close. */
1069
+ releaseAll() {
1070
+ for (const id of [...this.terminals.keys()]) {
1071
+ this.release(id);
1072
+ }
1073
+ }
1074
+ resolveCwd(cwd) {
1075
+ if (!cwd) return this.projectRoot;
1076
+ const resolved = path.resolve(cwd);
1077
+ const rootWithSep = this.projectRoot.endsWith(path.sep) ? this.projectRoot : this.projectRoot + path.sep;
1078
+ if (resolved !== this.projectRoot && !resolved.startsWith(rootWithSep)) {
1079
+ return this.projectRoot;
1080
+ }
1081
+ return resolved;
1082
+ }
1083
+ buildEnv(agentEnv) {
1084
+ const env = { ...process.env };
1085
+ if (process.platform === "win32") {
1086
+ if (env.Path !== void 0 && env.PATH === void 0) env.PATH = env.Path;
1087
+ if (env.PATHEXT !== void 0 && env.PATHEXT_CASE === void 0) {
1088
+ env.PATHEXT_CASE = env.PATHEXT;
1089
+ }
1090
+ }
1091
+ if (agentEnv) {
1092
+ for (const { name, value } of agentEnv) {
1093
+ env[name] = value;
1094
+ }
1095
+ }
1096
+ return env;
1097
+ }
1098
+ };
1099
+
1100
+ // src/client/acp-session.ts
1101
+ var ACPSessionError = class extends Error {
1102
+ kind;
1103
+ cause;
1104
+ constructor(kind, message, cause) {
1105
+ super(message);
1106
+ this.name = "ACPSessionError";
1107
+ this.kind = kind;
1108
+ this.cause = cause;
1109
+ }
1110
+ };
1111
+ function isJsonRpcError(v) {
1112
+ return typeof v === "object" && v !== null && typeof v.code === "number" && typeof v.message === "string";
1113
+ }
1114
+ var ACPSession = class _ACPSession {
1115
+ transport;
1116
+ fileServer;
1117
+ terminalServer;
1118
+ permissionPolicy;
1119
+ timeoutMs;
1120
+ opts;
1121
+ state = "init";
1122
+ sessionId = null;
1123
+ /** Pending outbound requests (initialize, session/new, session/prompt, etc). */
1124
+ pending = /* @__PURE__ */ new Map();
1125
+ nextId = 1;
1126
+ /** True after close() has been called. */
1127
+ closed = false;
1128
+ constructor(opts, transport) {
1129
+ this.opts = opts;
1130
+ this.transport = transport;
1131
+ this.timeoutMs = opts.timeoutMs ?? 5 * 6e4;
1132
+ const fsOpts = {
1133
+ projectRoot: opts.projectRoot
1134
+ };
1135
+ if (opts.fsTimeoutMs !== void 0) fsOpts.timeoutMs = opts.fsTimeoutMs;
1136
+ this.fileServer = new FileServer(fsOpts);
1137
+ const termOpts = {
1138
+ projectRoot: opts.projectRoot
1139
+ };
1140
+ if (opts.terminalTimeoutMs !== void 0) {
1141
+ termOpts.commandTimeoutMs = opts.terminalTimeoutMs;
1142
+ }
1143
+ if (opts.terminalOutputByteLimit !== void 0) {
1144
+ termOpts.outputByteLimit = opts.terminalOutputByteLimit;
1145
+ }
1146
+ this.terminalServer = new TerminalServer(termOpts);
1147
+ this.permissionPolicy = opts.permissionPolicy ?? defaultPermissionPolicy;
1148
+ }
1149
+ /**
1150
+ * Spawn the child, run the initialize handshake, install the
1151
+ * message dispatch, and return a ready session.
1152
+ */
1153
+ static async start(opts) {
1154
+ const transportOpts = {
1155
+ command: opts.command,
1156
+ args: opts.args ? [...opts.args] : [],
1157
+ handshakeTimeoutMs: 3e4,
1158
+ // ACPSession is the v1 CLIENT side: it speaks to external agents
1159
+ // (Claude Code, Gemini CLI, …) that do NOT emit a `[wstack-acp]\n`
1160
+ // startup marker. The transport should treat the child as ready
1161
+ // as soon as the process is spawned and stdout is flowing.
1162
+ skipHandshakeMarker: true
1163
+ };
1164
+ if (opts.env !== void 0) transportOpts.env = opts.env;
1165
+ if (opts.cwd !== void 0) transportOpts.cwd = opts.cwd;
1166
+ const transport = new ClientTransport(transportOpts);
1167
+ try {
1168
+ await transport.start();
1169
+ } catch (err) {
1170
+ const msg = err instanceof Error ? err.message : String(err);
1171
+ throw new ACPSessionError("spawn_failed", `failed to spawn ${opts.command}: ${msg}`, err);
1172
+ }
1173
+ const session = new _ACPSession(opts, transport);
1174
+ transport.onMessage((msg) => session.handleMessage(msg));
1175
+ try {
1176
+ await session.initialize();
1177
+ } catch (err) {
1178
+ try {
1179
+ transport.stop();
1180
+ } catch {
1181
+ }
1182
+ throw err;
1183
+ }
1184
+ return session;
1185
+ }
1186
+ async initialize() {
1187
+ const id = this.allocId();
1188
+ const result = await this.sendRequest(id, "initialize", {
1189
+ protocolVersion: ACP_PROTOCOL_VERSION,
1190
+ clientCapabilities: {
1191
+ fs: { readTextFile: true, writeTextFile: true },
1192
+ terminal: true,
1193
+ promptCapabilities: { image: false, audio: false, embeddedContext: true }
1194
+ },
1195
+ clientInfo: { name: "wrongstack", title: "WrongStack", version: "0.263.0" }
1196
+ });
1197
+ if (isJsonRpcError(result)) {
1198
+ throw new ACPSessionError("init_failed", `initialize failed: ${result.message}`, result);
1199
+ }
1200
+ if (typeof result !== "object" || result === null || typeof result.protocolVersion !== "number") {
1201
+ throw new ACPSessionError("protocol_error", "initialize returned no protocolVersion");
1202
+ }
1203
+ const r = result;
1204
+ if (r.protocolVersion !== ACP_PROTOCOL_VERSION) {
1205
+ throw new ACPSessionError(
1206
+ "unsupported_capability",
1207
+ `agent speaks protocolVersion=${r.protocolVersion}, client speaks ${ACP_PROTOCOL_VERSION}`
1208
+ );
1209
+ }
1210
+ this.state = "ready";
1211
+ }
1212
+ /**
1213
+ * Run one prompt turn. Creates a session if needed, sends the
1214
+ * prompt, streams session/update notifications, and resolves with
1215
+ * the agent's response.
1216
+ *
1217
+ * Cancellation: if `signal` aborts mid-prompt, we send
1218
+ * `session/cancel` (a notification per spec) and keep accepting
1219
+ * updates until the agent returns with `stopReason: 'cancelled'`.
1220
+ * The result is the same shape as a normal turn, with
1221
+ * `stopReason === 'cancelled'`.
1222
+ */
1223
+ async prompt(text, signal) {
1224
+ if (this.closed) {
1225
+ throw new ACPSessionError("closed", "session is closed");
1226
+ }
1227
+ if (this.state !== "ready" && this.state !== "done") {
1228
+ throw new ACPSessionError("protocol_error", `prompt called in state=${this.state}`);
1229
+ }
1230
+ if (signal.aborted) {
1231
+ return { text: "", stopReason: "cancelled", hasText: false };
1232
+ }
1233
+ if (!this.sessionId) {
1234
+ await this.createSession();
1235
+ }
1236
+ this.resetScratch();
1237
+ const promptId = this.allocId();
1238
+ const turnPromise = this.sendRequest(
1239
+ promptId,
1240
+ "session/prompt",
1241
+ {
1242
+ sessionId: this.sessionId,
1243
+ prompt: [textContent(text)]
1244
+ },
1245
+ this.timeoutMs
1246
+ );
1247
+ let cancelled = false;
1248
+ const onAbort = () => {
1249
+ cancelled = true;
1250
+ this.transport.send({ method: "session/cancel", params: { sessionId: this.sessionId } }).catch(() => {
1251
+ });
1252
+ };
1253
+ signal.addEventListener("abort", onAbort, { once: true });
1254
+ this.state = "prompting";
1255
+ let response;
1256
+ try {
1257
+ response = await turnPromise;
1258
+ } catch (err) {
1259
+ this.state = "done";
1260
+ signal.removeEventListener("abort", onAbort);
1261
+ if (cancelled || signal.aborted) {
1262
+ throw new ACPSessionError("aborted", "prompt was aborted by the parent");
1263
+ }
1264
+ const msg = err instanceof Error ? err.message : String(err);
1265
+ throw new ACPSessionError("prompt_failed", `session/prompt failed: ${msg}`, err);
1266
+ } finally {
1267
+ signal.removeEventListener("abort", onAbort);
1268
+ }
1269
+ this.state = "done";
1270
+ if (isJsonRpcError(response)) {
1271
+ throw new ACPSessionError("prompt_failed", `agent error: ${response.message}`, response);
1272
+ }
1273
+ const stopReason = response.stopReason ?? "end_turn";
1274
+ const finalText = this.scratch.text;
1275
+ return {
1276
+ text: finalText,
1277
+ stopReason,
1278
+ hasText: finalText.length > 0,
1279
+ usage: this.scratch.usage,
1280
+ plan: this.scratch.plan
1281
+ };
1282
+ }
1283
+ async createSession() {
1284
+ const id = this.allocId();
1285
+ const result = await this.sendRequest(id, "session/new", {
1286
+ cwd: this.opts.cwd ?? this.opts.projectRoot,
1287
+ mcpServers: []
1288
+ });
1289
+ if (isJsonRpcError(result)) {
1290
+ throw new ACPSessionError(
1291
+ "session_create_failed",
1292
+ `session/new failed: ${result.message}`,
1293
+ result
1294
+ );
1295
+ }
1296
+ const sessionId = result.sessionId;
1297
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
1298
+ throw new ACPSessionError(
1299
+ "protocol_error",
1300
+ "session/new returned no sessionId",
1301
+ result
1302
+ );
1303
+ }
1304
+ this.sessionId = sessionId;
1305
+ }
1306
+ /** Tear down the session and kill the child process. */
1307
+ async close() {
1308
+ if (this.closed) return;
1309
+ this.closed = true;
1310
+ this.state = "closed";
1311
+ this.terminalServer.releaseAll();
1312
+ for (const [, p] of this.pending) {
1313
+ clearTimeout(p.timeoutHandle);
1314
+ p.reject(new ACPSessionError("closed", "session was closed"));
1315
+ }
1316
+ this.pending.clear();
1317
+ try {
1318
+ this.transport.stop();
1319
+ } catch {
1320
+ }
1321
+ }
1322
+ // ────────────────────────────────────────────────────────────────────
1323
+ // Wire layer
1324
+ // ────────────────────────────────────────────────────────────────────
1325
+ allocId() {
1326
+ return this.nextId++;
1327
+ }
1328
+ async sendRequest(id, method, params, timeoutMs) {
1329
+ return new Promise((resolve3, reject) => {
1330
+ const effectiveTimeout = timeoutMs ?? this.timeoutMs;
1331
+ const handle = setTimeout(() => {
1332
+ this.pending.delete(id);
1333
+ reject(
1334
+ new ACPSessionError(
1335
+ "protocol_error",
1336
+ `${method} timed out after ${effectiveTimeout}ms`
1337
+ )
1338
+ );
1339
+ }, effectiveTimeout);
1340
+ this.pending.set(id, {
1341
+ method,
1342
+ resolve: resolve3,
1343
+ reject,
1344
+ timeoutMs: effectiveTimeout,
1345
+ timeoutHandle: handle
1346
+ });
1347
+ this.transport.send({ jsonrpc: "2.0", id, method, params }).catch((err) => {
1348
+ clearTimeout(handle);
1349
+ this.pending.delete(id);
1350
+ const msg = err instanceof Error ? err.message : String(err);
1351
+ reject(new ACPSessionError("protocol_error", `send ${method} failed: ${msg}`, err));
1352
+ });
1353
+ });
1354
+ }
1355
+ handleMessage(msg) {
1356
+ if (msg.id !== void 0 && (msg.result !== void 0 || msg.error !== void 0)) {
1357
+ const pending = this.pending.get(msg.id);
1358
+ if (!pending) return;
1359
+ clearTimeout(pending.timeoutHandle);
1360
+ this.pending.delete(msg.id);
1361
+ if (msg.error !== void 0) {
1362
+ pending.reject(new Error(msg.error.message ?? "unknown JSON-RPC error"));
1363
+ } else {
1364
+ pending.resolve(msg.result);
1365
+ }
1366
+ return;
1367
+ }
1368
+ if (msg.method === "session/update") {
1369
+ this.handleUpdate(msg);
1370
+ return;
1371
+ }
1372
+ if (msg.method === "session/request_permission") {
1373
+ void this.handlePermissionRequest(msg);
1374
+ return;
1375
+ }
1376
+ if (msg.method === "fs/read_text_file" || msg.method === "fs/write_text_file") {
1377
+ void this.handleFsRequest(msg);
1378
+ return;
1379
+ }
1380
+ if (msg.method && msg.method.startsWith("terminal/")) {
1381
+ void this.handleTerminalRequest(msg);
1382
+ return;
1383
+ }
1384
+ if (msg.method) {
1385
+ console.warn(`[acp-session] unhandled method: ${msg.method}`);
1386
+ }
1387
+ }
1388
+ handleUpdate(msg) {
1389
+ const update = msg.params?.update;
1390
+ if (typeof update !== "object" || update === null) return;
1391
+ const u = update;
1392
+ switch (u.sessionUpdate) {
1393
+ case "agent_message_chunk": {
1394
+ const text = extractText(u.content);
1395
+ if (text) this.accumulatedText(text);
1396
+ return;
1397
+ }
1398
+ case "thought_chunk":
1399
+ return;
1400
+ case "tool_call":
1401
+ case "tool_call_update":
1402
+ return;
1403
+ case "plan":
1404
+ if (Array.isArray(u.entries)) {
1405
+ this.accumulatedPlan(u.entries);
1406
+ }
1407
+ return;
1408
+ case "usage_update":
1409
+ if (typeof u.used === "number" && typeof u.size === "number") {
1410
+ this.accumulatedUsage({
1411
+ used: u.used,
1412
+ size: u.size,
1413
+ ...typeof u.cost === "object" && u.cost !== null ? {
1414
+ cost: u.cost
1415
+ } : {}
1416
+ });
1417
+ }
1418
+ return;
1419
+ case "available_commands_update":
1420
+ case "current_mode_update":
1421
+ case "config_option_update":
1422
+ case "session_info_update":
1423
+ case "user_message_chunk":
1424
+ return;
1425
+ default:
1426
+ console.warn(`[acp-session] unhandled sessionUpdate: ${u.sessionUpdate}`);
1427
+ return;
1428
+ }
1429
+ }
1430
+ // Per-prompt scratch state. Reset at the start of each prompt() and
1431
+ // read at the end to assemble the ACPSessionRunResult. The stream
1432
+ // pump writes to it via the three `accumulated*` helpers below.
1433
+ scratch = { text: "" };
1434
+ accumulatedText(chunk) {
1435
+ this.scratch.text += chunk;
1436
+ }
1437
+ accumulatedPlan(entries) {
1438
+ this.scratch.plan = entries;
1439
+ }
1440
+ accumulatedUsage(u) {
1441
+ this.scratch.usage = u;
1442
+ }
1443
+ resetScratch() {
1444
+ this.scratch = { text: "" };
1445
+ }
1446
+ async handlePermissionRequest(msg) {
1447
+ const id = msg.id;
1448
+ if (id === void 0) return;
1449
+ const params = msg.params;
1450
+ const toolCall = params?.toolCall;
1451
+ const options = Array.isArray(params?.options) ? params.options : [];
1452
+ if (!toolCall) {
1453
+ await this.transport.send({
1454
+ id,
1455
+ method: "session/request_permission",
1456
+ error: { code: -32602, message: "toolCall is required" }
1457
+ });
1458
+ return;
1459
+ }
1460
+ const policyAbort = new AbortController();
1461
+ const outcome = await this.permissionPolicy({
1462
+ toolCall,
1463
+ options,
1464
+ signal: policyAbort.signal
1465
+ });
1466
+ await this.transport.send({
1467
+ id,
1468
+ method: "session/request_permission",
1469
+ result: { outcome }
1470
+ });
1471
+ }
1472
+ async handleFsRequest(msg) {
1473
+ const id = msg.id;
1474
+ if (id === void 0) return;
1475
+ const params = msg.params;
1476
+ if (!params?.path) {
1477
+ await this.transport.send({
1478
+ id,
1479
+ method: msg.method,
1480
+ error: { code: -32602, message: "path is required" }
1481
+ });
1482
+ return;
1483
+ }
1484
+ try {
1485
+ if (msg.method === "fs/read_text_file") {
1486
+ const result = await this.fileServer.readTextFile({
1487
+ sessionId: params.sessionId ?? "",
1488
+ path: params.path
1489
+ });
1490
+ await this.transport.send({ id, method: msg.method, result });
1491
+ } else {
1492
+ await this.fileServer.writeTextFile({
1493
+ sessionId: params.sessionId ?? "",
1494
+ path: params.path,
1495
+ content: params.content ?? ""
1496
+ });
1497
+ await this.transport.send({ id, method: msg.method, result: {} });
1498
+ }
1499
+ } catch (err) {
1500
+ const code = err instanceof FsError ? -32602 : -32603;
1501
+ const message = err instanceof Error ? err.message : String(err);
1502
+ await this.transport.send({ id, method: msg.method, error: { code, message } });
1503
+ }
1504
+ }
1505
+ async handleTerminalRequest(msg) {
1506
+ const id = msg.id;
1507
+ if (id === void 0) return;
1508
+ const params = msg.params ?? {};
1509
+ try {
1510
+ switch (msg.method) {
1511
+ case "terminal/create": {
1512
+ const createOpts = {
1513
+ sessionId: String(params.sessionId ?? ""),
1514
+ command: String(params.command ?? ""),
1515
+ args: Array.isArray(params.args) ? params.args : []
1516
+ };
1517
+ if (Array.isArray(params.env)) {
1518
+ createOpts.env = params.env;
1519
+ }
1520
+ if (typeof params.cwd === "string") {
1521
+ createOpts.cwd = params.cwd;
1522
+ }
1523
+ if (typeof params.outputByteLimit === "number") {
1524
+ createOpts.outputByteLimit = params.outputByteLimit;
1525
+ }
1526
+ const result = this.terminalServer.create(createOpts);
1527
+ await this.transport.send({ id, method: msg.method, result });
1528
+ return;
1529
+ }
1530
+ case "terminal/output": {
1531
+ const terminalId = String(params.terminalId ?? "");
1532
+ const out = this.terminalServer.output(terminalId);
1533
+ await this.transport.send({ id, method: msg.method, result: out });
1534
+ return;
1535
+ }
1536
+ case "terminal/wait_for_exit": {
1537
+ const terminalId = String(params.terminalId ?? "");
1538
+ const exit = await this.terminalServer.waitForExit(terminalId);
1539
+ await this.transport.send({ id, method: msg.method, result: exit });
1540
+ return;
1541
+ }
1542
+ case "terminal/kill": {
1543
+ const terminalId = String(params.terminalId ?? "");
1544
+ this.terminalServer.kill(terminalId);
1545
+ await this.transport.send({ id, method: msg.method, result: {} });
1546
+ return;
1547
+ }
1548
+ case "terminal/release": {
1549
+ const terminalId = String(params.terminalId ?? "");
1550
+ this.terminalServer.release(terminalId);
1551
+ await this.transport.send({ id, method: msg.method, result: {} });
1552
+ return;
1553
+ }
1554
+ default:
1555
+ await this.transport.send({
1556
+ id,
1557
+ method: msg.method,
1558
+ error: { code: -32601, message: `unknown method: ${msg.method}` }
1559
+ });
1560
+ }
1561
+ } catch (err) {
1562
+ const message = err instanceof Error ? err.message : String(err);
1563
+ await this.transport.send({
1564
+ id,
1565
+ method: msg.method,
1566
+ error: { code: -32603, message }
1567
+ });
1568
+ }
1569
+ }
1570
+ };
1571
+ function textContent(text) {
1572
+ return { type: "text", text };
1573
+ }
1574
+ function extractText(block) {
1575
+ if (typeof block !== "object" || block === null) return "";
1576
+ const b = block;
1577
+ if (b.type === "text" && typeof b.text === "string") return b.text;
1578
+ return "";
1579
+ }
635
1580
 
636
1581
  // src/integration/acp-subagent-runner.ts
637
1582
  var ACP_AGENT_COMMANDS = {
@@ -659,182 +1604,644 @@ var ACP_AGENT_COMMANDS = {
659
1604
  }
660
1605
  };
661
1606
  async function makeACPSubagentRunner(options) {
662
- const transport = new ClientTransport(clientTransportOptions(options));
663
- const translator = new ToolTranslator(options.toolTranslatorOpts);
664
- const activeAbort = new AbortController();
665
- let sessionStarted = false;
666
- const startSession = async () => {
667
- if (sessionStarted) return;
668
- await transport.start();
669
- await transport.send({
670
- method: "initialize",
671
- id: "1",
672
- params: {
673
- capabilities: ["code-generation", "async-tools", "streaming", "progress"],
674
- protocolVersion: "2024-11",
675
- sessionId: options.role ?? "wrongstack-subagent"
676
- }
677
- });
678
- const initResp = await transport.read();
679
- if (!initResp || initResp.error) {
680
- throw new Error(`ACP initialize failed: ${initResp?.error?.message ?? "no response"}`);
1607
+ const { runner, stop } = await makeACPSubagentRunnerWithStop(options);
1608
+ const wrappedRunner = async (task, ctx) => {
1609
+ try {
1610
+ return await runner(task, ctx);
1611
+ } finally {
1612
+ stop();
681
1613
  }
682
- translator.attachToTransport({
683
- onMessage: (h) => transport.onMessage(h),
684
- send: (m) => transport.send(m)
685
- });
686
- sessionStarted = true;
687
1614
  };
1615
+ return wrappedRunner;
1616
+ }
1617
+ async function makeACPSubagentRunnerWithStop(options) {
1618
+ const projectRoot = options.projectRoot ?? options.cwd ?? process.cwd();
1619
+ const timeoutMs = options.timeoutMs ?? 5 * 6e4;
688
1620
  const runner = async (task, ctx) => {
689
- ctx.signal.addEventListener("abort", () => {
690
- activeAbort.abort();
691
- transport.stop();
692
- });
693
- await startSession();
694
- const callId = crypto.randomUUID();
695
- let toolResult = null;
696
- const resultPromise = new Promise((resolve, reject) => {
697
- const budgetMs = ctx.budget.limits.timeoutMs ?? 3e5;
698
- const timeout = setTimeout(() => {
699
- reject(new Error(`ACP task timed out for subagent ${ctx.subagentId} (${budgetMs}ms budget)`));
700
- }, budgetMs);
701
- transport.onMessage((msg) => {
702
- if (msg.method === "tools/call" && msg.id !== void 0) {
703
- clearTimeout(timeout);
704
- resolve(msg);
705
- }
706
- });
707
- ctx.signal.addEventListener("abort", () => {
708
- clearTimeout(timeout);
709
- reject(new Error("Task aborted by parent"));
710
- });
711
- });
1621
+ let session = null;
712
1622
  try {
713
- await transport.send({
714
- method: "agent/run",
715
- id: callId,
716
- params: {
717
- task: task.description,
718
- sessionId: ctx.subagentId
719
- }
1623
+ session = await ACPSession.start({
1624
+ command: options.command,
1625
+ ...options.args !== void 0 ? { args: options.args } : {},
1626
+ ...options.env !== void 0 ? { env: options.env } : {},
1627
+ ...options.cwd !== void 0 ? { cwd: options.cwd } : {},
1628
+ projectRoot,
1629
+ timeoutMs,
1630
+ role: options.role
720
1631
  });
721
- toolResult = await resultPromise;
722
1632
  } catch (err) {
723
- const msg = err instanceof Error ? err.message : String(err);
1633
+ throw acpErrorToSubagentError(err, options.role ?? "acp-subagent");
1634
+ }
1635
+ try {
1636
+ const result = await session.prompt(task.description, ctx.signal);
724
1637
  return {
725
- result: `ACP subagent error: ${msg}`,
726
- iterations: 0,
1638
+ result: result.text,
1639
+ iterations: 1,
727
1640
  toolCalls: 0
728
1641
  };
1642
+ } catch (err) {
1643
+ if (err instanceof ACPSessionError && err.kind === "aborted") {
1644
+ throw acpErrorToSubagentError(err, options.role ?? "acp-subagent");
1645
+ }
1646
+ throw acpErrorToSubagentError(err, options.role ?? "acp-subagent");
1647
+ } finally {
1648
+ try {
1649
+ await session.close();
1650
+ } catch {
1651
+ }
729
1652
  }
730
- if (!toolResult) {
731
- return { result: "ACP subagent returned no result", iterations: 1, toolCalls: 1 };
732
- }
733
- const parsed = parseToolResponse(task.id, ctx.subagentId, toolResult);
1653
+ };
1654
+ const stop = () => {
1655
+ };
1656
+ return { runner, stop };
1657
+ }
1658
+ function acpErrorToSubagentError(err, subagentId) {
1659
+ if (err instanceof ACPSessionError) {
1660
+ const kind = mapACPKind(err.kind);
734
1661
  return {
735
- result: parsed.result ?? parsed.error,
736
- iterations: parsed.iterations,
737
- toolCalls: parsed.toolCalls
1662
+ kind,
1663
+ message: `${subagentId}: ${err.message}`,
1664
+ retryable: isRetryable(kind),
1665
+ cause: {
1666
+ name: err.name,
1667
+ message: err.message,
1668
+ ...err.stack !== void 0 ? { stack: err.stack } : {}
1669
+ }
738
1670
  };
1671
+ }
1672
+ const message = err instanceof Error ? err.message : String(err);
1673
+ return {
1674
+ kind: "bridge_failed",
1675
+ message: `${subagentId}: ${message}`,
1676
+ retryable: false,
1677
+ cause: {
1678
+ name: err instanceof Error ? err.name : "Error",
1679
+ message,
1680
+ ...err instanceof Error && err.stack !== void 0 ? { stack: err.stack } : {}
1681
+ }
739
1682
  };
740
- return runner;
741
1683
  }
742
- async function makeACPSubagentRunnerWithStop(options) {
743
- const transport = new ClientTransport(clientTransportOptions(options));
744
- const translator = new ToolTranslator(options.toolTranslatorOpts);
745
- const activeAbort = new AbortController();
746
- let sessionStarted = false;
747
- const startSession = async () => {
748
- if (sessionStarted) return;
749
- await transport.start();
750
- await transport.send({
751
- method: "initialize",
752
- id: "1",
753
- params: {
754
- capabilities: ["code-generation", "async-tools", "streaming", "progress"],
755
- protocolVersion: "2024-11",
756
- sessionId: options.role ?? "wrongstack-subagent"
1684
+ function mapACPKind(acpKind) {
1685
+ switch (acpKind) {
1686
+ case "spawn_failed":
1687
+ case "init_failed":
1688
+ case "session_create_failed":
1689
+ case "agent_died":
1690
+ case "protocol_error":
1691
+ return "bridge_failed";
1692
+ case "prompt_failed":
1693
+ return "tool_failed";
1694
+ case "aborted":
1695
+ return "aborted_by_parent";
1696
+ case "closed":
1697
+ case "unsupported_capability":
1698
+ return "unknown";
1699
+ }
1700
+ }
1701
+ function isRetryable(kind) {
1702
+ switch (kind) {
1703
+ case "provider_5xx":
1704
+ case "provider_rate_limit":
1705
+ case "provider_timeout":
1706
+ case "tool_threw":
1707
+ case "budget_timeout":
1708
+ return true;
1709
+ default:
1710
+ return false;
1711
+ }
1712
+ }
1713
+
1714
+ // src/registry/agents.catalog.ts
1715
+ var AGENTS_CATALOG = [
1716
+ // ── Anthropic ────────────────────────────────────────────────────────
1717
+ {
1718
+ id: "claude-code",
1719
+ displayName: "Claude Code",
1720
+ vendor: "anthropic",
1721
+ probe: { command: "claude", args: ["--version"] },
1722
+ // Native ACP entry is gated behind the SDK adapter in early releases;
1723
+ // see https://agentclientprotocol.com/get-started/agents
1724
+ acp: { command: "claude", args: [] },
1725
+ supports: {
1726
+ loadSession: true,
1727
+ promptImages: true,
1728
+ terminal: true,
1729
+ fs: true
1730
+ },
1731
+ integration: "adapter",
1732
+ docs: "https://docs.anthropic.com/en/docs/claude-code"
1733
+ },
1734
+ // ── Google ───────────────────────────────────────────────────────────
1735
+ {
1736
+ id: "gemini-cli",
1737
+ displayName: "Gemini CLI",
1738
+ vendor: "google",
1739
+ probe: { command: "gemini", args: ["--version"] },
1740
+ acp: { command: "gemini", args: [] },
1741
+ supports: {
1742
+ loadSession: true,
1743
+ promptImages: true,
1744
+ terminal: true,
1745
+ fs: true
1746
+ },
1747
+ integration: "native",
1748
+ docs: "https://github.com/google-gemini/gemini-cli"
1749
+ },
1750
+ // ── OpenAI ───────────────────────────────────────────────────────────
1751
+ {
1752
+ id: "codex-cli",
1753
+ displayName: "Codex CLI",
1754
+ vendor: "openai",
1755
+ probe: { command: "codex", args: ["--version"] },
1756
+ acp: { command: "codex", args: [] },
1757
+ supports: {
1758
+ loadSession: false,
1759
+ promptImages: false,
1760
+ terminal: true,
1761
+ fs: true
1762
+ },
1763
+ integration: "adapter",
1764
+ docs: "https://github.com/openai/codex"
1765
+ },
1766
+ // ── GitHub ───────────────────────────────────────────────────────────
1767
+ {
1768
+ id: "copilot",
1769
+ displayName: "GitHub Copilot CLI",
1770
+ vendor: "github",
1771
+ probe: { command: "gh", args: ["copilot", "--help"] },
1772
+ acp: { command: "gh", args: ["copilot"] },
1773
+ supports: {
1774
+ loadSession: false,
1775
+ promptImages: false,
1776
+ terminal: true,
1777
+ fs: false
1778
+ },
1779
+ integration: "experimental",
1780
+ docs: "https://github.com/features/copilot/cli"
1781
+ },
1782
+ // ── Community / wrappers ─────────────────────────────────────────────
1783
+ {
1784
+ id: "cline",
1785
+ displayName: "Cline",
1786
+ vendor: "community",
1787
+ probe: { command: "npx", args: ["--version"] },
1788
+ acp: {
1789
+ command: "npx",
1790
+ args: ["-y", "@agentify/cline"]
1791
+ },
1792
+ supports: {
1793
+ loadSession: true,
1794
+ promptImages: true,
1795
+ terminal: true,
1796
+ fs: true
1797
+ },
1798
+ integration: "community",
1799
+ docs: "https://github.com/cline/cline"
1800
+ },
1801
+ {
1802
+ id: "goose",
1803
+ displayName: "Goose",
1804
+ vendor: "community",
1805
+ probe: { command: "goose", args: ["--version"] },
1806
+ acp: { command: "goose", args: [] },
1807
+ supports: {
1808
+ loadSession: true,
1809
+ promptImages: true,
1810
+ terminal: true,
1811
+ fs: true
1812
+ },
1813
+ integration: "experimental",
1814
+ docs: "https://github.com/block/goose"
1815
+ },
1816
+ {
1817
+ id: "openhands",
1818
+ displayName: "OpenHands",
1819
+ vendor: "community",
1820
+ probe: { command: "openhands", args: ["--version"] },
1821
+ acp: { command: "openhands", args: [] },
1822
+ supports: {
1823
+ loadSession: false,
1824
+ promptImages: true,
1825
+ terminal: true,
1826
+ fs: true
1827
+ },
1828
+ integration: "experimental",
1829
+ docs: "https://github.com/All-Hands-AI/OpenHands"
1830
+ },
1831
+ // ── Vendor CLIs (native binaries) ───────────────────────────────────
1832
+ {
1833
+ id: "qwen-code",
1834
+ displayName: "Qwen Code",
1835
+ vendor: "community",
1836
+ probe: { command: "qwen", args: ["--version"] },
1837
+ acp: { command: "qwen", args: [] },
1838
+ supports: {
1839
+ loadSession: false,
1840
+ promptImages: false,
1841
+ terminal: true,
1842
+ fs: false
1843
+ },
1844
+ integration: "experimental",
1845
+ docs: "https://github.com/QwenLM/Qwen3-Coder"
1846
+ },
1847
+ {
1848
+ id: "kiro-cli",
1849
+ displayName: "Kiro CLI",
1850
+ vendor: "community",
1851
+ probe: { command: "kiro", args: ["--version"] },
1852
+ acp: { command: "kiro", args: [] },
1853
+ supports: {
1854
+ loadSession: false,
1855
+ promptImages: false,
1856
+ terminal: true,
1857
+ fs: true
1858
+ },
1859
+ integration: "experimental",
1860
+ docs: "https://kiro.dev"
1861
+ },
1862
+ {
1863
+ id: "opencode",
1864
+ displayName: "OpenCode",
1865
+ vendor: "community",
1866
+ probe: { command: "opencode", args: ["--version"] },
1867
+ acp: { command: "opencode", args: [] },
1868
+ supports: {
1869
+ loadSession: true,
1870
+ promptImages: true,
1871
+ terminal: true,
1872
+ fs: true
1873
+ },
1874
+ integration: "native",
1875
+ docs: "https://github.com/sst/opencode"
1876
+ },
1877
+ {
1878
+ id: "mistral-vibe",
1879
+ displayName: "Mistral Vibe",
1880
+ vendor: "community",
1881
+ probe: { command: "vibe", args: ["--version"] },
1882
+ acp: { command: "vibe", args: [] },
1883
+ supports: {
1884
+ loadSession: false,
1885
+ promptImages: false,
1886
+ terminal: true,
1887
+ fs: false
1888
+ },
1889
+ integration: "experimental",
1890
+ docs: "https://github.com/mistralai/mistral-vibe"
1891
+ },
1892
+ {
1893
+ id: "cursor",
1894
+ displayName: "Cursor",
1895
+ vendor: "community",
1896
+ probe: { command: "cursor", args: ["--version"] },
1897
+ acp: { command: "cursor", args: [] },
1898
+ supports: {
1899
+ loadSession: true,
1900
+ promptImages: true,
1901
+ terminal: true,
1902
+ fs: true
1903
+ },
1904
+ integration: "experimental",
1905
+ docs: "https://cursor.com"
1906
+ }
1907
+ ];
1908
+ function findAgentDescriptor(id) {
1909
+ return AGENTS_CATALOG.find((a) => a.id === id);
1910
+ }
1911
+ var PROBE_TIMEOUT_MS = 5e3;
1912
+ var PROBE_CACHE_MS = 5e3;
1913
+ async function defaultProbe(desc, timeoutMs) {
1914
+ const start = Date.now();
1915
+ return new Promise((resolve3) => {
1916
+ let settled = false;
1917
+ let stdout = "";
1918
+ let stderr = "";
1919
+ const finish = (result) => {
1920
+ if (settled) return;
1921
+ settled = true;
1922
+ try {
1923
+ child.kill();
1924
+ } catch {
757
1925
  }
758
- });
759
- const initResp = await transport.read();
760
- if (!initResp || initResp.error) {
761
- throw new Error(`ACP initialize failed: ${initResp?.error?.message ?? "no response"}`);
1926
+ resolve3(result);
1927
+ };
1928
+ let child;
1929
+ try {
1930
+ child = spawn(desc.probe.command, [...desc.probe.args ?? []], {
1931
+ stdio: ["ignore", "pipe", "pipe"],
1932
+ windowsHide: true,
1933
+ // On Windows, `claude`, `gemini`, `npx`, etc. are typically
1934
+ // installed as `.cmd` shims under AppData\Roaming\npm\. Node's
1935
+ // spawn() will not find them without shell-mode unless the
1936
+ // extension is present. `shell: true` resolves this for the
1937
+ // common case. The probe argv is always from our static
1938
+ // catalog, never user input, so shell-expansion is bounded.
1939
+ shell: process.platform === "win32"
1940
+ });
1941
+ } catch (err) {
1942
+ const msg = err instanceof Error ? err.message : String(err);
1943
+ finish({ ok: false, reason: `spawn failed: ${msg}`, durationMs: 0 });
1944
+ return;
762
1945
  }
763
- translator.attachToTransport({
764
- onMessage: (h) => transport.onMessage(h),
765
- send: (m) => transport.send(m)
1946
+ const timer = setTimeout(() => {
1947
+ finish({ ok: false, reason: "probe timed out", durationMs: Date.now() - start });
1948
+ }, timeoutMs);
1949
+ child.stdout?.setEncoding("utf8");
1950
+ child.stdout?.on("data", (chunk) => {
1951
+ stdout += chunk;
766
1952
  });
767
- sessionStarted = true;
768
- };
769
- const stop = () => {
770
- activeAbort.abort();
771
- transport.stop();
772
- };
773
- const runner = async (task, ctx) => {
774
- ctx.signal.addEventListener("abort", () => {
775
- activeAbort.abort();
776
- transport.stop();
1953
+ child.stderr?.setEncoding("utf8");
1954
+ child.stderr?.on("data", (chunk) => {
1955
+ stderr += chunk;
777
1956
  });
778
- await startSession();
779
- const callId = crypto.randomUUID();
780
- let toolResult = null;
781
- const resultPromise = new Promise((resolve, reject) => {
782
- const budgetMs = ctx.budget.limits.timeoutMs ?? 3e5;
783
- const timeout = setTimeout(() => {
784
- reject(new Error(`ACP task timed out for subagent ${ctx.subagentId} (${budgetMs}ms budget)`));
785
- }, budgetMs);
786
- transport.onMessage((msg) => {
787
- if (msg.method === "tools/call" && msg.id !== void 0) {
788
- clearTimeout(timeout);
789
- resolve(msg);
790
- }
1957
+ child.on("error", (err) => {
1958
+ clearTimeout(timer);
1959
+ finish({
1960
+ ok: false,
1961
+ reason: `binary not found: ${err.message}`,
1962
+ durationMs: Date.now() - start
791
1963
  });
792
- ctx.signal.addEventListener("abort", () => {
793
- clearTimeout(timeout);
794
- reject(new Error("Task aborted by parent"));
1964
+ });
1965
+ child.on("close", (code) => {
1966
+ clearTimeout(timer);
1967
+ const durationMs = Date.now() - start;
1968
+ const out = (stdout + stderr).trim();
1969
+ const isWindowsShellMiss = process.platform === "win32" && out.toLowerCase().includes("is not recognized");
1970
+ if (isWindowsShellMiss) {
1971
+ finish({
1972
+ ok: false,
1973
+ reason: "binary not found",
1974
+ durationMs
1975
+ });
1976
+ return;
1977
+ }
1978
+ if (out.length > 0) {
1979
+ finish({
1980
+ ok: true,
1981
+ version: out.split("\n")[0]?.trim() ?? "",
1982
+ path: desc.probe.command,
1983
+ durationMs
1984
+ });
1985
+ return;
1986
+ }
1987
+ finish({
1988
+ ok: false,
1989
+ reason: `exit code ${code ?? "null"}; no output`,
1990
+ durationMs
795
1991
  });
796
1992
  });
1993
+ });
1994
+ }
1995
+ var EnsembleRegistry = class {
1996
+ catalog;
1997
+ timeoutMs;
1998
+ probe;
1999
+ cache = null;
2000
+ constructor(options = {}) {
2001
+ this.catalog = options.catalog ?? AGENTS_CATALOG;
2002
+ this.timeoutMs = options.probeTimeoutMs ?? PROBE_TIMEOUT_MS;
2003
+ this.probe = options.probeFn ?? ((d) => defaultProbe(d, this.timeoutMs));
2004
+ }
2005
+ /** Return the full catalog (no probe), in catalog order. */
2006
+ listAll() {
2007
+ return this.catalog;
2008
+ }
2009
+ /**
2010
+ * Probe every catalog entry in parallel and return the detection
2011
+ * results. Results are cached for `PROBE_CACHE_MS`.
2012
+ */
2013
+ async list() {
2014
+ if (this.cache && Date.now() - this.cache.at < PROBE_CACHE_MS) {
2015
+ return this.cache.result;
2016
+ }
2017
+ const result = await Promise.all(
2018
+ this.catalog.map((d) => this.detect(d))
2019
+ );
2020
+ this.cache = { at: Date.now(), result };
2021
+ return result;
2022
+ }
2023
+ /** Probe a single descriptor. Always returns a `DetectedAgent`. */
2024
+ async detect(desc) {
2025
+ const result = await this.probe(desc);
2026
+ if (result.ok) {
2027
+ const detected = {
2028
+ ...desc,
2029
+ installed: true,
2030
+ version: result.version
2031
+ };
2032
+ if (result.path !== void 0) detected.path = result.path;
2033
+ return detected;
2034
+ }
2035
+ return { ...desc, installed: false, reason: result.reason };
2036
+ }
2037
+ /** Invalidate the per-process cache. */
2038
+ invalidate() {
2039
+ this.cache = null;
2040
+ }
2041
+ /** Convenience: just the installed agents. */
2042
+ async listInstalled() {
2043
+ const all = await this.list();
2044
+ return all.filter((a) => a.installed);
2045
+ }
2046
+ };
2047
+ var defaultEnsembleCmdResolver = (id) => {
2048
+ const fromMap = ACP_AGENT_COMMANDS[id];
2049
+ if (fromMap) return fromMap;
2050
+ const desc = findAgentDescriptor(id);
2051
+ if (!desc) return null;
2052
+ const out = {
2053
+ command: desc.acp.command,
2054
+ args: [...desc.acp.args ?? []],
2055
+ role: id
2056
+ };
2057
+ if (desc.acp.env) out.env = desc.acp.env;
2058
+ return out;
2059
+ };
2060
+ function setResult(results, agentId, patch) {
2061
+ const i = results.findIndex((r) => r.agentId === agentId);
2062
+ if (i < 0) return;
2063
+ const current = results[i];
2064
+ results[i] = { ...current, ...patch };
2065
+ }
2066
+ async function runOne(agentId, cmd, task, timeoutMs, signal) {
2067
+ const startedAt = Date.now();
2068
+ try {
2069
+ const { runner, stop } = await makeACPSubagentRunnerWithStop({
2070
+ ...cmd,
2071
+ timeoutMs
2072
+ });
797
2073
  try {
798
- await transport.send({
799
- method: "agent/run",
800
- id: callId,
801
- params: {
802
- task: task.description,
803
- sessionId: ctx.subagentId
804
- }
2074
+ const budget = new SubagentBudget({
2075
+ timeoutMs,
2076
+ maxIterations: 2e3,
2077
+ maxToolCalls: 5e3
805
2078
  });
806
- toolResult = await resultPromise;
807
- } catch (err) {
808
- const msg = err instanceof Error ? err.message : String(err);
2079
+ const result = await runner(
2080
+ { id: `ensemble-${agentId}`, description: task },
2081
+ {
2082
+ subagentId: agentId,
2083
+ config: {
2084
+ id: agentId,
2085
+ name: agentId,
2086
+ role: agentId,
2087
+ provider: "acp",
2088
+ prompt: ""
2089
+ },
2090
+ budget,
2091
+ signal: signal ?? new AbortController().signal,
2092
+ bridge: null
2093
+ }
2094
+ );
809
2095
  return {
810
- result: `ACP subagent error: ${msg}`,
811
- iterations: 0,
812
- toolCalls: 0
2096
+ status: "success",
2097
+ result: result.result == null ? "" : String(result.result),
2098
+ durationMs: Date.now() - startedAt,
2099
+ iterations: result.iterations,
2100
+ toolCalls: result.toolCalls
813
2101
  };
2102
+ } finally {
2103
+ try {
2104
+ stop();
2105
+ } catch {
2106
+ }
814
2107
  }
815
- if (!toolResult) {
816
- return { result: "ACP subagent returned no result", iterations: 1, toolCalls: 1 };
817
- }
818
- const parsed = parseToolResponse(task.id, ctx.subagentId, toolResult);
2108
+ } catch (err) {
2109
+ const e = err;
2110
+ const isAbort = e?.name === "AbortError" || e?.kind === "aborted" || e?.kind === "aborted_by_parent" || e?.message?.toLowerCase().includes("aborted");
819
2111
  return {
820
- result: parsed.result ?? parsed.error,
821
- iterations: parsed.iterations,
822
- toolCalls: parsed.toolCalls
2112
+ status: isAbort ? "cancelled" : "failed",
2113
+ error: {
2114
+ kind: e?.kind ?? (isAbort ? "aborted" : "unknown"),
2115
+ message: e?.message ?? (err instanceof Error ? err.message : String(err))
2116
+ },
2117
+ durationMs: Date.now() - startedAt,
2118
+ iterations: 0,
2119
+ toolCalls: 0
823
2120
  };
824
- };
825
- return { runner, stop };
2121
+ }
826
2122
  }
827
- function clientTransportOptions(options) {
828
- const out = {
829
- command: options.command,
830
- handshakeTimeoutMs: 3e4
2123
+ async function runEnsemble(opts) {
2124
+ const timeoutMs = opts.timeoutMs ?? 5 * 6e4;
2125
+ const registry = opts.registry ?? new EnsembleRegistry();
2126
+ const resolveCmd = opts.resolveCmd ?? defaultEnsembleCmdResolver;
2127
+ const seen = /* @__PURE__ */ new Set();
2128
+ const requested = [];
2129
+ for (const raw of opts.agentIds.split(",")) {
2130
+ const id = raw.trim();
2131
+ if (!id || seen.has(id)) continue;
2132
+ seen.add(id);
2133
+ requested.push(id);
2134
+ }
2135
+ const results = requested.map((agentId) => ({
2136
+ agentId,
2137
+ status: "skipped",
2138
+ durationMs: 0,
2139
+ iterations: 0,
2140
+ toolCalls: 0
2141
+ }));
2142
+ const startMs = Date.now();
2143
+ if (requested.length === 0) {
2144
+ return {
2145
+ task: opts.task,
2146
+ requested,
2147
+ results,
2148
+ summary: { succeeded: 0, failed: 0, skipped: 0, cancelled: 0 },
2149
+ totalDurationMs: 0
2150
+ };
2151
+ }
2152
+ const detected = await registry.list();
2153
+ const detectedById = new Map(detected.map((a) => [a.id, a]));
2154
+ const runnable = [];
2155
+ for (const id of requested) {
2156
+ const det = detectedById.get(id);
2157
+ if (!det || !det.installed) {
2158
+ setResult(results, id, {
2159
+ status: "skipped",
2160
+ reason: det?.reason ?? "not in catalog"
2161
+ });
2162
+ continue;
2163
+ }
2164
+ const cmd = resolveCmd(id);
2165
+ if (!cmd) {
2166
+ setResult(results, id, {
2167
+ status: "failed",
2168
+ error: { kind: "unknown_agent", message: `Unknown ACP agent: ${id}` },
2169
+ durationMs: 0
2170
+ });
2171
+ continue;
2172
+ }
2173
+ runnable.push({ id, cmd });
2174
+ }
2175
+ await Promise.allSettled(
2176
+ runnable.map(async ({ id, cmd }) => {
2177
+ if (opts.signal?.aborted) {
2178
+ setResult(results, id, {
2179
+ status: "cancelled",
2180
+ error: { kind: "aborted", message: "aborted by parent" },
2181
+ durationMs: 0
2182
+ });
2183
+ return;
2184
+ }
2185
+ const outcome = await runOne(id, cmd, opts.task, timeoutMs, opts.signal);
2186
+ setResult(results, id, outcome);
2187
+ })
2188
+ );
2189
+ const summary = { succeeded: 0, failed: 0, skipped: 0, cancelled: 0 };
2190
+ for (const r of results) {
2191
+ if (r.status === "success") summary.succeeded++;
2192
+ else if (r.status === "failed") summary.failed++;
2193
+ else if (r.status === "cancelled") summary.cancelled++;
2194
+ else summary.skipped++;
2195
+ }
2196
+ return {
2197
+ task: opts.task,
2198
+ requested,
2199
+ results,
2200
+ summary,
2201
+ totalDurationMs: Date.now() - startMs
831
2202
  };
832
- if (options.args !== void 0) out.args = options.args;
833
- if (options.env !== void 0) out.env = options.env;
834
- if (options.cwd !== void 0) out.cwd = options.cwd;
835
- return out;
2203
+ }
2204
+ function renderEnsembleText(result) {
2205
+ const lines = [];
2206
+ if (result.requested.length === 0) {
2207
+ lines.push("No agent ids provided.");
2208
+ return lines.join("\n");
2209
+ }
2210
+ for (const r of result.results) {
2211
+ lines.push(`
2212
+ === ${r.agentId} ===`);
2213
+ switch (r.status) {
2214
+ case "success":
2215
+ lines.push(r.result && r.result.length > 0 ? r.result : "(no result)");
2216
+ lines.push(
2217
+ `[${r.agentId}] success ${r.durationMs}ms iterations=${r.iterations} toolCalls=${r.toolCalls}`
2218
+ );
2219
+ break;
2220
+ case "failed":
2221
+ lines.push(
2222
+ `[${r.error?.kind ?? "unknown"}] ${r.error?.message ?? "failed"}`
2223
+ );
2224
+ lines.push(`[${r.agentId}] failed ${r.durationMs}ms`);
2225
+ break;
2226
+ case "cancelled":
2227
+ lines.push(
2228
+ `[${r.error?.kind ?? "aborted"}] ${r.error?.message ?? "cancelled"}`
2229
+ );
2230
+ lines.push(`[${r.agentId}] cancelled ${r.durationMs}ms`);
2231
+ break;
2232
+ case "skipped":
2233
+ lines.push(`(skipped \u2014 ${r.reason ?? "not installed"})`);
2234
+ break;
2235
+ }
2236
+ }
2237
+ const { succeeded, failed, skipped, cancelled } = result.summary;
2238
+ lines.push(
2239
+ `
2240
+ Ensemble summary: ${succeeded} succeeded, ${failed} failed, ${cancelled} cancelled, ${skipped} skipped. (${result.totalDurationMs}ms total)`
2241
+ );
2242
+ return lines.join("\n");
836
2243
  }
837
2244
 
838
- export { ACPProtocolHandler, ACPToolsRegistry, ACP_AGENT_COMMANDS, ClientTransport, StdioTransport, ToolTranslator, WrongStackACPServer, makeACPSubagentRunner, makeACPSubagentRunnerWithStop };
2245
+ export { ACPProtocolHandler, ACPSession, ACPSessionError, ACPToolsRegistry, ACP_AGENT_COMMANDS, AGENTS_CATALOG, ClientTransport, EnsembleRegistry, FileServer, FsError, StdioTransport, TerminalServer, ToolTranslator, WrongStackACPServer, defaultEnsembleCmdResolver, defaultPermissionPolicy, findAgentDescriptor, makeACPSubagentRunner, makeACPSubagentRunnerWithStop, renderEnsembleText, runEnsemble };
839
2246
  //# sourceMappingURL=index.js.map
840
2247
  //# sourceMappingURL=index.js.map