@wrongstack/acp 0.292.1 → 0.295.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.
Files changed (55) hide show
  1. package/README.md +22 -2
  2. package/dist/agent/protocol-contract.d.ts +210 -0
  3. package/dist/agent/protocol-contract.d.ts.map +1 -0
  4. package/dist/agent/protocol-handler.d.ts +6 -189
  5. package/dist/agent/protocol-handler.d.ts.map +1 -1
  6. package/dist/agent/server-agent-turn.d.ts +7 -1
  7. package/dist/agent/server-agent-turn.d.ts.map +1 -1
  8. package/dist/agent/stdio-transport.d.ts +12 -1
  9. package/dist/agent/stdio-transport.d.ts.map +1 -1
  10. package/dist/agent/tools-registry.d.ts +1 -1
  11. package/dist/agent/tools-registry.d.ts.map +1 -1
  12. package/dist/agent/wrongstack-acp-agent.d.ts +2 -0
  13. package/dist/agent/wrongstack-acp-agent.d.ts.map +1 -1
  14. package/dist/agent.js +172 -22
  15. package/dist/agent.js.map +4 -4
  16. package/dist/client/acp-session.d.ts +19 -6
  17. package/dist/client/acp-session.d.ts.map +1 -1
  18. package/dist/client/index.d.ts +11 -9
  19. package/dist/client/index.d.ts.map +1 -1
  20. package/dist/client/permission.d.ts +7 -3
  21. package/dist/client/permission.d.ts.map +1 -1
  22. package/dist/client/terminal-server.d.ts +5 -0
  23. package/dist/client/terminal-server.d.ts.map +1 -1
  24. package/dist/client/tool-translator.d.ts +1 -1
  25. package/dist/client/tool-translator.d.ts.map +1 -1
  26. package/dist/client/trust-boundary-permission.d.ts +16 -0
  27. package/dist/client/trust-boundary-permission.d.ts.map +1 -0
  28. package/dist/client/websocket-transport.d.ts +6 -0
  29. package/dist/client/websocket-transport.d.ts.map +1 -1
  30. package/dist/client.js +440 -225
  31. package/dist/client.js.map +4 -4
  32. package/dist/index.d.ts +29 -29
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2433 -2121
  35. package/dist/index.js.map +4 -4
  36. package/dist/integration/acp-subagent-runner.d.ts +3 -3
  37. package/dist/integration/acp-subagent-runner.d.ts.map +1 -1
  38. package/dist/integration/ensemble-runner.d.ts.map +1 -1
  39. package/dist/legacy.d.ts +8 -0
  40. package/dist/legacy.d.ts.map +1 -0
  41. package/dist/legacy.js +6 -0
  42. package/dist/legacy.js.map +7 -0
  43. package/dist/sdk.d.ts +10 -8
  44. package/dist/sdk.d.ts.map +1 -1
  45. package/dist/sdk.js +22 -0
  46. package/dist/sdk.js.map +3 -3
  47. package/dist/v1.d.ts +3 -0
  48. package/dist/v1.d.ts.map +1 -0
  49. package/dist/v1.js +12 -0
  50. package/dist/v1.js.map +7 -0
  51. package/dist/version.d.ts +2 -0
  52. package/dist/version.d.ts.map +1 -0
  53. package/dist/wrongstack-acp-agent.js +107 -16
  54. package/dist/wrongstack-acp-agent.js.map +4 -4
  55. package/package.json +10 -2
package/dist/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/agent/stdio-transport.ts
2
- import { expectDefined, writeErr } from "@wrongstack/core";
2
+ import { expectDefined, writeErr } from "@wrongstack/core/utils";
3
3
 
4
4
  // src/win32-cmd.ts
5
5
  var WIN32_CMD_META = /[&|<>"\r\n\0]/;
@@ -26,6 +26,11 @@ function quoteWin32CmdArg(arg) {
26
26
  }
27
27
 
28
28
  // src/agent/stdio-transport.ts
29
+ var DEFAULT_MAX_FRAME_CHARS = 20 * 1024 * 1024;
30
+ var DEFAULT_MAX_QUEUED_MESSAGES = 1e3;
31
+ function positiveLimit(value, fallback) {
32
+ return Number.isFinite(value) && (value ?? 0) > 0 ? Math.floor(value) : fallback;
33
+ }
29
34
  var ClientTransport = class {
30
35
  child = null;
31
36
  buffer = "";
@@ -34,17 +39,24 @@ var ClientTransport = class {
34
39
  resolveRead = null;
35
40
  messageQueue = [];
36
41
  opts;
42
+ maxFrameChars;
43
+ maxQueuedMessages;
37
44
  constructor(options) {
38
45
  this.opts = {
39
46
  handshakeTimeoutMs: 3e4,
40
47
  ...options
41
48
  };
49
+ this.maxFrameChars = positiveLimit(options.maxFrameChars, DEFAULT_MAX_FRAME_CHARS);
50
+ this.maxQueuedMessages = positiveLimit(
51
+ options.maxQueuedMessages,
52
+ DEFAULT_MAX_QUEUED_MESSAGES
53
+ );
42
54
  }
43
55
  async start() {
44
56
  if (this.child) return;
45
57
  const [{ spawn: spawn2 }, { buildChildEnv: buildChildEnv2 }, os] = await Promise.all([
46
58
  import("node:child_process"),
47
- import("@wrongstack/core"),
59
+ import("@wrongstack/core/utils"),
48
60
  import("node:os")
49
61
  ]);
50
62
  return new Promise((resolve3, reject) => {
@@ -139,10 +151,16 @@ var ClientTransport = class {
139
151
  return () => this.handlers.delete(handler);
140
152
  }
141
153
  stop() {
142
- if (!this.child) return;
143
154
  this.closed = true;
155
+ this.resolveRead?.(null);
156
+ this.resolveRead = null;
157
+ this.buffer = "";
158
+ this.messageQueue.length = 0;
159
+ this.handlers.clear();
160
+ const child = this.child;
161
+ if (!child) return;
144
162
  try {
145
- this.child.kill();
163
+ child.kill();
146
164
  } catch {
147
165
  }
148
166
  this.child = null;
@@ -151,8 +169,20 @@ var ClientTransport = class {
151
169
  this.buffer += chunk;
152
170
  const lines = this.buffer.split("\n");
153
171
  this.buffer = lines.pop() ?? "";
172
+ if (this.buffer.length > this.maxFrameChars) {
173
+ writeErr(`[acp-child pending frame exceeds ${this.maxFrameChars} characters]
174
+ `);
175
+ this.stop();
176
+ return;
177
+ }
154
178
  for (const raw of lines) {
155
179
  if (!raw.trim()) continue;
180
+ if (raw.length > this.maxFrameChars) {
181
+ writeErr(`[acp-child frame exceeds ${this.maxFrameChars} characters]
182
+ `);
183
+ this.stop();
184
+ return;
185
+ }
156
186
  try {
157
187
  this.dispatch(JSON.parse(raw));
158
188
  } catch {
@@ -166,6 +196,9 @@ var ClientTransport = class {
166
196
  this.closed = true;
167
197
  this.resolveRead?.(null);
168
198
  this.resolveRead = null;
199
+ this.buffer = "";
200
+ this.messageQueue.length = 0;
201
+ this.handlers.clear();
169
202
  if (code !== 0 && code !== null) {
170
203
  writeErr(`[acp-child exited with code ${code}]
171
204
  `);
@@ -176,7 +209,13 @@ var ClientTransport = class {
176
209
  const resolve3 = this.resolveRead;
177
210
  this.resolveRead = null;
178
211
  resolve3(msg);
179
- } else {
212
+ } else if (this.handlers.size === 0) {
213
+ if (this.messageQueue.length >= this.maxQueuedMessages) {
214
+ writeErr(`[acp-child message queue exceeds ${this.maxQueuedMessages} entries]
215
+ `);
216
+ this.stop();
217
+ return;
218
+ }
180
219
  this.messageQueue.push(msg);
181
220
  }
182
221
  for (const handler of this.handlers) {
@@ -188,180 +227,6 @@ var ClientTransport = class {
188
227
  }
189
228
  };
190
229
 
191
- // src/client/websocket-transport.ts
192
- var WebSocketClientTransport = class {
193
- ws = null;
194
- handlers = /* @__PURE__ */ new Set();
195
- closed = false;
196
- opts;
197
- constructor(opts) {
198
- this.opts = opts;
199
- }
200
- start() {
201
- const WS = globalThis.WebSocket;
202
- if (!WS) {
203
- return Promise.reject(
204
- new Error(
205
- "global WebSocket is not available \u2014 Node \u2265 22 is required for the remote ACP transport"
206
- )
207
- );
208
- }
209
- const timeoutMs = this.opts.handshakeTimeoutMs ?? 3e4;
210
- return new Promise((resolve3, reject) => {
211
- let settled = false;
212
- const ws = new WS(this.opts.url, this.opts.protocols);
213
- this.ws = ws;
214
- const timer = setTimeout(() => {
215
- if (settled) return;
216
- settled = true;
217
- try {
218
- ws.close();
219
- } catch {
220
- }
221
- reject(new Error(`WebSocket failed to open within ${timeoutMs}ms`));
222
- }, timeoutMs);
223
- ws.addEventListener("open", () => {
224
- if (settled) return;
225
- settled = true;
226
- clearTimeout(timer);
227
- resolve3();
228
- });
229
- ws.addEventListener("error", (ev) => {
230
- if (settled) {
231
- this.closed = true;
232
- return;
233
- }
234
- settled = true;
235
- clearTimeout(timer);
236
- const message = ev && typeof ev === "object" && "message" in ev ? String(ev.message) : "WebSocket error";
237
- reject(new Error(message));
238
- });
239
- ws.addEventListener("close", () => {
240
- this.closed = true;
241
- });
242
- ws.addEventListener("message", (ev) => {
243
- this.onData(ev.data);
244
- });
245
- });
246
- }
247
- send(msg) {
248
- if (this.closed || !this.ws) {
249
- return Promise.reject(new Error("WebSocket transport is not open"));
250
- }
251
- try {
252
- this.ws.send(JSON.stringify(msg));
253
- return Promise.resolve();
254
- } catch (err) {
255
- return Promise.reject(err instanceof Error ? err : new Error(String(err)));
256
- }
257
- }
258
- onMessage(handler) {
259
- this.handlers.add(handler);
260
- return () => this.handlers.delete(handler);
261
- }
262
- stop() {
263
- this.closed = true;
264
- if (this.ws) {
265
- try {
266
- this.ws.close();
267
- } catch {
268
- }
269
- this.ws = null;
270
- }
271
- }
272
- onData(data) {
273
- const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : Buffer.isBuffer(data) ? data.toString("utf8") : String(data);
274
- if (!text.trim()) return;
275
- let msg;
276
- try {
277
- msg = JSON.parse(text);
278
- } catch {
279
- for (const line of text.split("\n")) {
280
- if (!line.trim()) continue;
281
- try {
282
- this.dispatch(JSON.parse(line));
283
- } catch {
284
- }
285
- }
286
- return;
287
- }
288
- this.dispatch(msg);
289
- }
290
- dispatch(msg) {
291
- for (const handler of [...this.handlers]) {
292
- try {
293
- handler(msg);
294
- } catch {
295
- }
296
- }
297
- }
298
- };
299
-
300
- // src/client/tool-translator.ts
301
- import { expectDefined as expectDefined2 } from "@wrongstack/core";
302
- var DEFAULT_OPTIONS = {
303
- asyncTools: true,
304
- pollIntervalMs: 500,
305
- totalTimeoutMs: 12e4
306
- };
307
- var ToolTranslator = class {
308
- opts;
309
- pending = /* @__PURE__ */ new Map();
310
- constructor(opts = {}) {
311
- this.opts = { ...DEFAULT_OPTIONS, ...opts };
312
- }
313
- /**
314
- * Start listening to a transport for tool responses and cancellations.
315
- * Call this once after constructing the translator and before sending tasks.
316
- */
317
- attachToTransport(transport) {
318
- transport.onMessage((msg) => {
319
- if (msg.method === "tools/call" && msg.id !== void 0) {
320
- const pending = this.pending.get(msg.id);
321
- if (pending) {
322
- clearTimeout(pending.timeout);
323
- this.pending.delete(expectDefined2(msg.id));
324
- pending.resolve(msg);
325
- }
326
- }
327
- if (msg.method === "cancel" && msg.id !== void 0) {
328
- const pending = this.pending.get(msg.id);
329
- if (pending) {
330
- clearTimeout(pending.timeout);
331
- this.pending.delete(expectDefined2(msg.id));
332
- pending.reject(new Error("Call cancelled by client"));
333
- }
334
- }
335
- });
336
- }
337
- /**
338
- * Send a tool call over the transport and wait for a response.
339
- * If asyncTools is true, polls for progress and resolves when the final
340
- * response arrives.
341
- */
342
- async callTool(transport, name, args, callId = crypto.randomUUID()) {
343
- await transport.send({
344
- jsonrpc: "2.0",
345
- method: "tools/call",
346
- id: callId,
347
- params: { name, arguments: args }
348
- });
349
- return new Promise((resolve3, reject) => {
350
- const timeout = setTimeout(() => {
351
- this.pending.delete(callId);
352
- reject(new Error(`Tool call ${name} timed out after ${this.opts.totalTimeoutMs}ms`));
353
- }, this.opts.totalTimeoutMs);
354
- this.pending.set(callId, { resolve: resolve3, reject, timeout });
355
- });
356
- }
357
- cancelAll() {
358
- for (const [, p] of this.pending) {
359
- clearTimeout(p.timeout);
360
- }
361
- this.pending.clear();
362
- }
363
- };
364
-
365
230
  // src/types/acp-v1.ts
366
231
  var ACP_PROTOCOL_VERSION = 1;
367
232
 
@@ -584,26 +449,42 @@ import { spawn } from "node:child_process";
584
449
  import { realpathSync as realpathSync2 } from "node:fs";
585
450
  import * as path2 from "node:path";
586
451
  import { buildChildEnv } from "@wrongstack/core/utils";
452
+ var EMPTY_BUFFER = Buffer.alloc(0);
587
453
  var TerminalServer = class {
588
454
  terminals = /* @__PURE__ */ new Map();
589
455
  projectRoot;
590
456
  commandTimeoutMs;
591
457
  outputByteLimit;
592
458
  maxOutputByteLimit;
459
+ maxTerminals;
460
+ abortSignal;
461
+ abortHandler = () => this.releaseAll();
593
462
  nextId = 1;
594
463
  constructor(opts) {
595
464
  this.projectRoot = path2.resolve(opts.projectRoot);
596
465
  this.commandTimeoutMs = opts.commandTimeoutMs ?? 5 * 6e4;
597
466
  this.outputByteLimit = opts.outputByteLimit ?? 1024 * 1024;
598
467
  this.maxOutputByteLimit = opts.maxOutputByteLimit ?? 16 * 1024 * 1024;
468
+ this.maxTerminals = this.clampFiniteInt(opts.maxTerminals, 32);
469
+ if (this.maxTerminals < 1) throw new RangeError("maxTerminals must be at least 1");
470
+ this.abortSignal = opts.signal;
599
471
  if (opts.signal) {
600
- opts.signal.addEventListener("abort", () => this.releaseAll());
472
+ opts.signal.addEventListener("abort", this.abortHandler, { once: true });
601
473
  }
602
474
  }
603
475
  /** Spawn a new terminal. Returns the agent-facing id. */
604
476
  create(params) {
477
+ if (this.terminals.size >= this.maxTerminals) {
478
+ throw new Error(
479
+ `terminal limit reached (${this.maxTerminals}); release an existing terminal before creating another`
480
+ );
481
+ }
605
482
  const id = `term_${this.nextId++}`;
606
483
  const cwd = this.resolveCwd(params.cwd);
484
+ const perCallByteLimit = Math.min(
485
+ Math.max(1, this.clampFiniteInt(params.outputByteLimit, this.outputByteLimit)),
486
+ this.maxOutputByteLimit
487
+ );
607
488
  const proc = spawn(params.command, params.args ?? [], {
608
489
  cwd,
609
490
  env: this.buildEnv(params.env),
@@ -622,7 +503,8 @@ var TerminalServer = class {
622
503
  cwd,
623
504
  command: params.command,
624
505
  args: params.args ?? [],
625
- output: "",
506
+ outputChunks: [],
507
+ outputHead: 0,
626
508
  retainedBytes: 0,
627
509
  truncated: false,
628
510
  exitStatus: void 0,
@@ -647,31 +529,44 @@ var TerminalServer = class {
647
529
  }
648
530
  const exitStatus = { exitCode: 127, signal: null };
649
531
  state.exitStatus = exitStatus;
650
- state.output += `[spawn error] ${err.message}
651
- `;
652
- state.retainedBytes = Buffer.byteLength(state.output, "utf8");
532
+ let errorOutput = Buffer.from(`[spawn error] ${err.message}
533
+ `, "utf8");
534
+ if (errorOutput.length > perCallByteLimit) {
535
+ let start = errorOutput.length - perCallByteLimit;
536
+ while (start < errorOutput.length && (errorOutput[start] & 192) === 128) start++;
537
+ errorOutput = errorOutput.subarray(start);
538
+ state.truncated = true;
539
+ }
540
+ state.outputChunks.push(errorOutput);
541
+ state.retainedBytes = errorOutput.length;
653
542
  resolve3(exitStatus);
654
543
  });
655
544
  })
656
545
  };
657
- const perCallByteLimit = Math.min(
658
- Math.max(1, this.clampFiniteInt(params.outputByteLimit, this.outputByteLimit)),
659
- this.maxOutputByteLimit
660
- );
661
546
  proc.stdout?.setEncoding("utf8");
662
547
  proc.stderr?.setEncoding("utf8");
663
548
  const onData = (chunk) => {
664
- state.output += chunk;
665
- state.retainedBytes = Buffer.byteLength(state.output, "utf8");
666
- while (state.retainedBytes > perCallByteLimit) {
667
- const trimmed = state.output.slice(1);
668
- state.output = trimmed;
669
- const newBytes = Buffer.byteLength(state.output, "utf8");
670
- if (newBytes >= state.retainedBytes) {
671
- break;
549
+ const outputChunk = Buffer.from(chunk, "utf8");
550
+ state.outputChunks.push(outputChunk);
551
+ state.retainedBytes += outputChunk.length;
552
+ if (state.retainedBytes > perCallByteLimit) state.truncated = true;
553
+ while (state.retainedBytes > perCallByteLimit && state.outputHead < state.outputChunks.length) {
554
+ const first = state.outputChunks[state.outputHead];
555
+ const overflow = state.retainedBytes - perCallByteLimit;
556
+ if (first.length <= overflow) {
557
+ state.outputChunks[state.outputHead] = EMPTY_BUFFER;
558
+ state.outputHead++;
559
+ state.retainedBytes -= first.length;
560
+ continue;
672
561
  }
673
- state.retainedBytes = newBytes;
674
- state.truncated = true;
562
+ let start = overflow;
563
+ while (start < first.length && (first[start] & 192) === 128) start++;
564
+ state.outputChunks[state.outputHead] = first.subarray(start);
565
+ state.retainedBytes -= start;
566
+ }
567
+ if (state.outputHead >= 256 && state.outputHead * 2 >= state.outputChunks.length) {
568
+ state.outputChunks = state.outputChunks.slice(state.outputHead);
569
+ state.outputHead = 0;
675
570
  }
676
571
  };
677
572
  proc.stdout?.on("data", onData);
@@ -690,7 +585,10 @@ var TerminalServer = class {
690
585
  const state = this.terminals.get(terminalId);
691
586
  if (!state) throw new Error(`unknown terminal: ${terminalId}`);
692
587
  return {
693
- output: state.output,
588
+ output: Buffer.concat(
589
+ state.outputChunks.slice(state.outputHead),
590
+ state.retainedBytes
591
+ ).toString("utf8"),
694
592
  truncated: state.truncated,
695
593
  ...state.exitStatus ? { exitStatus: state.exitStatus } : {}
696
594
  };
@@ -726,6 +624,7 @@ var TerminalServer = class {
726
624
  }
727
625
  /** Kill all active terminals. Used on session close. */
728
626
  releaseAll() {
627
+ this.abortSignal?.removeEventListener("abort", this.abortHandler);
729
628
  for (const id of [...this.terminals.keys()]) {
730
629
  this.release(id);
731
630
  }
@@ -789,6 +688,213 @@ var DENIED_AGENT_ENV_KEYS = /* @__PURE__ */ new Set([
789
688
  "RUBYLIB"
790
689
  ]);
791
690
 
691
+ // src/client/trust-boundary-permission.ts
692
+ function pickOption(options, allowed) {
693
+ const kinds = allowed ? ["allow_once", "allow_always"] : ["reject_once", "reject_always"];
694
+ for (const kind of kinds) {
695
+ const option = options.find((candidate) => candidate.kind === kind);
696
+ if (option) return { outcome: "selected", optionId: option.optionId };
697
+ }
698
+ return { outcome: "cancelled" };
699
+ }
700
+ function riskFor(kind) {
701
+ if (kind === "read" || kind === "search" || kind === "fetch" || kind === "think") return "low";
702
+ if (kind === "edit" || kind === "move") return "elevated";
703
+ if (kind === "delete" || kind === "execute") return "high";
704
+ return "elevated";
705
+ }
706
+ function capabilityFor(request) {
707
+ const raw = request.toolCall.rawInput;
708
+ if (typeof raw?.path === "string") {
709
+ return request.toolCall.kind === "read" || request.toolCall.kind === "search" ? "filesystem.read" : "filesystem.write";
710
+ }
711
+ if (typeof raw?.command === "string" || request.toolCall.kind === "execute")
712
+ return "process.spawn";
713
+ if (request.toolCall.kind === "fetch") return "network.fetch";
714
+ return `tool.${request.toolCall.kind ?? "unknown"}`;
715
+ }
716
+ function subjectFor(request) {
717
+ const raw = request.toolCall.rawInput;
718
+ const title = request.toolCall.title ?? `ACP tool call ${String(request.toolCall.toolCallId)}`;
719
+ if (typeof raw?.path === "string") {
720
+ return { kind: "path", id: raw.path, attributes: { toolKind: request.toolCall.kind ?? null } };
721
+ }
722
+ if (typeof raw?.command === "string") {
723
+ return {
724
+ kind: "command",
725
+ id: raw.command,
726
+ attributes: { toolKind: request.toolCall.kind ?? null }
727
+ };
728
+ }
729
+ return {
730
+ kind: "resource",
731
+ id: title,
732
+ attributes: { toolKind: request.toolCall.kind ?? null }
733
+ };
734
+ }
735
+ function isAllowed(decision) {
736
+ return decision.kind === "allow" || decision.kind === "scoped-token";
737
+ }
738
+ function toTrustBoundaryRequest(request, options) {
739
+ const rawSessionId = request.toolCall.rawInput?.sessionId;
740
+ const sessionId = typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : options.actor?.sessionId;
741
+ return {
742
+ version: 1,
743
+ requestId: String(request.toolCall.toolCallId),
744
+ actor: {
745
+ ...options.actor ?? { kind: "agent" },
746
+ ...sessionId ? { sessionId } : {}
747
+ },
748
+ surface: "acp",
749
+ capability: capabilityFor(request),
750
+ subject: subjectFor(request),
751
+ risk: riskFor(request.toolCall.kind),
752
+ scope: {
753
+ ...options.scope ?? {},
754
+ ...sessionId ? { sessionId } : {}
755
+ },
756
+ ...options.authContext ? { authContext: options.authContext } : {},
757
+ metadata: {
758
+ ...request.toolCall.title ? { title: request.toolCall.title } : {},
759
+ toolKind: request.toolCall.kind ?? null
760
+ }
761
+ };
762
+ }
763
+ function makeTrustBoundaryPermissionPolicy(options) {
764
+ return async (request) => {
765
+ if (request.signal.aborted) return { outcome: "cancelled" };
766
+ const decision = await options.boundary.evaluate(toTrustBoundaryRequest(request, options));
767
+ if (request.signal.aborted) return { outcome: "cancelled" };
768
+ return pickOption(request.options, isAllowed(decision));
769
+ };
770
+ }
771
+
772
+ // src/client/websocket-transport.ts
773
+ var WebSocketClientTransport = class {
774
+ ws = null;
775
+ handlers = /* @__PURE__ */ new Set();
776
+ closed = false;
777
+ opts;
778
+ maxBufferedBytes;
779
+ maxMessageChars;
780
+ constructor(opts) {
781
+ this.opts = opts;
782
+ this.maxBufferedBytes = finitePositiveLimit(opts.maxBufferedBytes, 32 * 1024 * 1024);
783
+ this.maxMessageChars = finitePositiveLimit(opts.maxMessageChars, 20 * 1024 * 1024);
784
+ }
785
+ start() {
786
+ const WS = globalThis.WebSocket;
787
+ if (!WS) {
788
+ return Promise.reject(
789
+ new Error(
790
+ "global WebSocket is not available \u2014 Node \u2265 22 is required for the remote ACP transport"
791
+ )
792
+ );
793
+ }
794
+ const timeoutMs = this.opts.handshakeTimeoutMs ?? 3e4;
795
+ return new Promise((resolve3, reject) => {
796
+ let settled = false;
797
+ const ws = new WS(this.opts.url, this.opts.protocols);
798
+ this.ws = ws;
799
+ const timer = setTimeout(() => {
800
+ if (settled) return;
801
+ settled = true;
802
+ try {
803
+ ws.close();
804
+ } catch {
805
+ }
806
+ reject(new Error(`WebSocket failed to open within ${timeoutMs}ms`));
807
+ }, timeoutMs);
808
+ ws.addEventListener("open", () => {
809
+ if (settled) return;
810
+ settled = true;
811
+ clearTimeout(timer);
812
+ resolve3();
813
+ });
814
+ ws.addEventListener("error", (ev) => {
815
+ if (settled) {
816
+ this.closed = true;
817
+ return;
818
+ }
819
+ settled = true;
820
+ clearTimeout(timer);
821
+ const message = ev && typeof ev === "object" && "message" in ev ? String(ev.message) : "WebSocket error";
822
+ reject(new Error(message));
823
+ });
824
+ ws.addEventListener("close", () => {
825
+ this.closed = true;
826
+ });
827
+ ws.addEventListener("message", (ev) => {
828
+ this.onData(ev.data);
829
+ });
830
+ });
831
+ }
832
+ send(msg) {
833
+ if (this.closed || !this.ws) {
834
+ return Promise.reject(new Error("WebSocket transport is not open"));
835
+ }
836
+ try {
837
+ const serialized = JSON.stringify(msg);
838
+ const buffered = Number.isFinite(this.ws.bufferedAmount) ? this.ws.bufferedAmount ?? 0 : 0;
839
+ if (buffered + Buffer.byteLength(serialized, "utf8") > this.maxBufferedBytes) {
840
+ this.stop();
841
+ return Promise.reject(new Error("WebSocket transport send buffer limit exceeded"));
842
+ }
843
+ this.ws.send(serialized);
844
+ return Promise.resolve();
845
+ } catch (err) {
846
+ return Promise.reject(err instanceof Error ? err : new Error(String(err)));
847
+ }
848
+ }
849
+ onMessage(handler) {
850
+ this.handlers.add(handler);
851
+ return () => this.handlers.delete(handler);
852
+ }
853
+ stop() {
854
+ this.closed = true;
855
+ if (this.ws) {
856
+ try {
857
+ this.ws.close();
858
+ } catch {
859
+ }
860
+ this.ws = null;
861
+ }
862
+ }
863
+ onData(data) {
864
+ const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : Buffer.isBuffer(data) ? data.toString("utf8") : String(data);
865
+ if (text.length > this.maxMessageChars) {
866
+ this.stop();
867
+ return;
868
+ }
869
+ if (!text.trim()) return;
870
+ let msg;
871
+ try {
872
+ msg = JSON.parse(text);
873
+ } catch {
874
+ for (const line of text.split("\n")) {
875
+ if (!line.trim()) continue;
876
+ try {
877
+ this.dispatch(JSON.parse(line));
878
+ } catch {
879
+ }
880
+ }
881
+ return;
882
+ }
883
+ this.dispatch(msg);
884
+ }
885
+ dispatch(msg) {
886
+ for (const handler of [...this.handlers]) {
887
+ try {
888
+ handler(msg);
889
+ } catch {
890
+ }
891
+ }
892
+ }
893
+ };
894
+ function finitePositiveLimit(value, fallback) {
895
+ return Number.isFinite(value) && (value ?? 0) > 0 ? Math.floor(value) : fallback;
896
+ }
897
+
792
898
  // src/client/acp-session.ts
793
899
  var ACPSessionError = class extends Error {
794
900
  kind;
@@ -810,6 +916,7 @@ var ACPSession = class _ACPSession {
810
916
  permissionPolicy;
811
917
  timeoutMs;
812
918
  opts;
919
+ transportOff = null;
813
920
  state = "init";
814
921
  sessionId = null;
815
922
  /** Pending outbound requests (initialize, session/new, session/prompt, etc). */
@@ -841,8 +948,19 @@ var ACPSession = class _ACPSession {
841
948
  if (opts.terminalOutputByteLimit !== void 0) {
842
949
  termOpts.outputByteLimit = opts.terminalOutputByteLimit;
843
950
  }
951
+ if (opts.terminalMaxCount !== void 0) {
952
+ termOpts.maxTerminals = opts.terminalMaxCount;
953
+ }
844
954
  this.terminalServer = new TerminalServer(termOpts);
845
- this.permissionPolicy = opts.permissionPolicy ?? defaultPermissionPolicy;
955
+ if (opts.permissionPolicy && opts.trustBoundary) {
956
+ throw new TypeError("permissionPolicy and trustBoundary are mutually exclusive");
957
+ }
958
+ this.permissionPolicy = opts.trustBoundary ? makeTrustBoundaryPermissionPolicy({
959
+ boundary: opts.trustBoundary,
960
+ ...opts.trustActor ? { actor: opts.trustActor } : {},
961
+ scope: opts.trustScope ?? { cwd: opts.projectRoot },
962
+ ...opts.trustAuthContext ? { authContext: opts.trustAuthContext } : {}
963
+ }) : opts.permissionPolicy ?? readOnlyPermissionPolicy;
846
964
  }
847
965
  // ──────────────────────────────────────────────────────────────────────
848
966
  // Public accessors
@@ -916,10 +1034,12 @@ var ACPSession = class _ACPSession {
916
1034
  throw new ACPSessionError("spawn_failed", `${spawnErrLabel}: ${msg}`, err);
917
1035
  }
918
1036
  const session = new _ACPSession(opts, transport);
919
- transport.onMessage((msg) => session.handleMessage(msg));
1037
+ session.transportOff = transport.onMessage((msg) => session.handleMessage(msg));
920
1038
  try {
921
1039
  await session.initialize();
922
1040
  } catch (err) {
1041
+ session.transportOff?.();
1042
+ session.transportOff = null;
923
1043
  try {
924
1044
  transport.stop();
925
1045
  } catch {
@@ -1083,7 +1203,11 @@ var ACPSession = class _ACPSession {
1083
1203
  mcpServers: servers
1084
1204
  });
1085
1205
  if (isJsonRpcError(result)) {
1086
- throw new ACPSessionError("prompt_failed", `session/resume failed: ${result.message}`, result);
1206
+ throw new ACPSessionError(
1207
+ "prompt_failed",
1208
+ `session/resume failed: ${result.message}`,
1209
+ result
1210
+ );
1087
1211
  }
1088
1212
  this.sessionId = sessionId;
1089
1213
  }
@@ -1134,7 +1258,11 @@ var ACPSession = class _ACPSession {
1134
1258
  const id = this.allocId();
1135
1259
  const result = await this.sendRequest(id, "session/delete", { sessionId });
1136
1260
  if (isJsonRpcError(result)) {
1137
- throw new ACPSessionError("prompt_failed", `session/delete failed: ${result.message}`, result);
1261
+ throw new ACPSessionError(
1262
+ "prompt_failed",
1263
+ `session/delete failed: ${result.message}`,
1264
+ result
1265
+ );
1138
1266
  }
1139
1267
  if (this.sessionId === sessionId) {
1140
1268
  this.sessionId = null;
@@ -1169,7 +1297,11 @@ var ACPSession = class _ACPSession {
1169
1297
  const id = this.allocId();
1170
1298
  const result = await this.sendRequest(id, "session/set_mode", { sessionId, modeId });
1171
1299
  if (isJsonRpcError(result)) {
1172
- throw new ACPSessionError("prompt_failed", `session/set_mode failed: ${result.message}`, result);
1300
+ throw new ACPSessionError(
1301
+ "prompt_failed",
1302
+ `session/set_mode failed: ${result.message}`,
1303
+ result
1304
+ );
1173
1305
  }
1174
1306
  }
1175
1307
  /**
@@ -1184,7 +1316,11 @@ var ACPSession = class _ACPSession {
1184
1316
  value
1185
1317
  });
1186
1318
  if (isJsonRpcError(result)) {
1187
- throw new ACPSessionError("prompt_failed", `session/set_config_option failed: ${result.message}`, result);
1319
+ throw new ACPSessionError(
1320
+ "prompt_failed",
1321
+ `session/set_config_option failed: ${result.message}`,
1322
+ result
1323
+ );
1188
1324
  }
1189
1325
  }
1190
1326
  /**
@@ -1195,7 +1331,11 @@ var ACPSession = class _ACPSession {
1195
1331
  const id = this.allocId();
1196
1332
  const result = await this.sendRequest(id, "providers/list", {});
1197
1333
  if (isJsonRpcError(result)) {
1198
- throw new ACPSessionError("prompt_failed", `providers/list failed: ${result.message}`, result);
1334
+ throw new ACPSessionError(
1335
+ "prompt_failed",
1336
+ `providers/list failed: ${result.message}`,
1337
+ result
1338
+ );
1199
1339
  }
1200
1340
  const r = result;
1201
1341
  return { providers: r.providers ?? [], currentProviderId: r.currentProviderId ?? null };
@@ -1231,7 +1371,11 @@ var ACPSession = class _ACPSession {
1231
1371
  const id = this.allocId();
1232
1372
  const result = await this.sendRequest(id, "providers/disable", {});
1233
1373
  if (isJsonRpcError(result)) {
1234
- throw new ACPSessionError("prompt_failed", `providers/disable failed: ${result.message}`, result);
1374
+ throw new ACPSessionError(
1375
+ "prompt_failed",
1376
+ `providers/disable failed: ${result.message}`,
1377
+ result
1378
+ );
1235
1379
  }
1236
1380
  }
1237
1381
  // ──────────────────────────────────────────────────────────────────────
@@ -1338,11 +1482,7 @@ var ACPSession = class _ACPSession {
1338
1482
  }
1339
1483
  const sessionId = result.sessionId;
1340
1484
  if (typeof sessionId !== "string" || sessionId.length === 0) {
1341
- throw new ACPSessionError(
1342
- "protocol_error",
1343
- "session/new returned no sessionId",
1344
- result
1345
- );
1485
+ throw new ACPSessionError("protocol_error", "session/new returned no sessionId", result);
1346
1486
  }
1347
1487
  this.sessionId = sessionId;
1348
1488
  }
@@ -1385,6 +1525,8 @@ var ACPSession = class _ACPSession {
1385
1525
  p.reject(new ACPSessionError("closed", "session was closed"));
1386
1526
  }
1387
1527
  this.pending.clear();
1528
+ this.transportOff?.();
1529
+ this.transportOff = null;
1388
1530
  try {
1389
1531
  this.transport.stop();
1390
1532
  } catch {
@@ -1420,10 +1562,7 @@ var ACPSession = class _ACPSession {
1420
1562
  const handle = setTimeout(() => {
1421
1563
  this.pending.delete(id);
1422
1564
  reject(
1423
- new ACPSessionError(
1424
- "protocol_error",
1425
- `${method} timed out after ${effectiveTimeout}ms`
1426
- )
1565
+ new ACPSessionError("protocol_error", `${method} timed out after ${effectiveTimeout}ms`)
1427
1566
  );
1428
1567
  }, effectiveTimeout);
1429
1568
  this.pending.set(id, {
@@ -1657,11 +1796,12 @@ var ACPSession = class _ACPSession {
1657
1796
  * closing the gap where the agent simply skips the voluntary permission
1658
1797
  * request and sends the privileged callback directly.
1659
1798
  *
1660
- * Uses the session's permission policy. The default policy
1661
- * (`defaultPermissionPolicy`) auto-approves everything this is correct
1662
- * for trusted local agents (CLI `acp spawn`, Director fan-out). For
1663
- * untrusted/remote agents, the host should inject
1664
- * `readOnlyPermissionPolicy` or an interactive policy.
1799
+ * Uses the session's permission policy. The default
1800
+ * (`readOnlyPermissionPolicy`) auto-approves only side-effect-free tool
1801
+ * calls (read/search/fetch/think) and rejects everything else this is
1802
+ * the safe-by-default posture. For trusted local agents (CLI `acp spawn`,
1803
+ * Director fan-out), inject `defaultPermissionPolicy` to grant
1804
+ * write/execute access.
1665
1805
  *
1666
1806
  * Returns true if the callback is authorized, false if denied.
1667
1807
  */
@@ -1673,7 +1813,8 @@ var ACPSession = class _ACPSession {
1673
1813
  toolCallId: partial.toolCallId,
1674
1814
  title: partial.title,
1675
1815
  kind: partial.kind,
1676
- status: "pending"
1816
+ status: "pending",
1817
+ ...partial.rawInput ? { rawInput: partial.rawInput } : {}
1677
1818
  },
1678
1819
  options: [
1679
1820
  { optionId: "allow", name: "Allow", kind: "allow_once" },
@@ -1698,7 +1839,8 @@ var ACPSession = class _ACPSession {
1698
1839
  const allowed = await this.authorizeCallback({
1699
1840
  toolCallId: `acp-fs-write-${id}`,
1700
1841
  title: `Write file: ${params.path}`,
1701
- kind: "edit"
1842
+ kind: "edit",
1843
+ rawInput: { path: params.path, sessionId: params.sessionId }
1702
1844
  });
1703
1845
  if (!allowed) {
1704
1846
  await this.sendErrorResponse(id, -32602, "filesystem write denied by permission policy");
@@ -1736,7 +1878,13 @@ var ACPSession = class _ACPSession {
1736
1878
  const allowed = await this.authorizeCallback({
1737
1879
  toolCallId: `acp-terminal-create-${id}`,
1738
1880
  title: `Run command: ${String(params.command ?? "")} ${(Array.isArray(params.args) ? params.args : []).join(" ")}`.trim(),
1739
- kind: "execute"
1881
+ kind: "execute",
1882
+ rawInput: {
1883
+ command: params.command,
1884
+ args: params.args,
1885
+ cwd: params.cwd,
1886
+ sessionId: params.sessionId
1887
+ }
1740
1888
  });
1741
1889
  if (!allowed) {
1742
1890
  await this.sendErrorResponse(id, -32602, "terminal create denied by permission policy");
@@ -1963,6 +2111,71 @@ function isRetryable(kind) {
1963
2111
  return false;
1964
2112
  }
1965
2113
  }
2114
+
2115
+ // src/client/tool-translator.ts
2116
+ import { expectDefined as expectDefined2 } from "@wrongstack/core/utils";
2117
+ var DEFAULT_OPTIONS = {
2118
+ asyncTools: true,
2119
+ pollIntervalMs: 500,
2120
+ totalTimeoutMs: 12e4
2121
+ };
2122
+ var ToolTranslator = class {
2123
+ opts;
2124
+ pending = /* @__PURE__ */ new Map();
2125
+ constructor(opts = {}) {
2126
+ this.opts = { ...DEFAULT_OPTIONS, ...opts };
2127
+ }
2128
+ /**
2129
+ * Start listening to a transport for tool responses and cancellations.
2130
+ * Call this once after constructing the translator and before sending tasks.
2131
+ */
2132
+ attachToTransport(transport) {
2133
+ transport.onMessage((msg) => {
2134
+ if (msg.method === "tools/call" && msg.id !== void 0) {
2135
+ const pending = this.pending.get(msg.id);
2136
+ if (pending) {
2137
+ clearTimeout(pending.timeout);
2138
+ this.pending.delete(expectDefined2(msg.id));
2139
+ pending.resolve(msg);
2140
+ }
2141
+ }
2142
+ if (msg.method === "cancel" && msg.id !== void 0) {
2143
+ const pending = this.pending.get(msg.id);
2144
+ if (pending) {
2145
+ clearTimeout(pending.timeout);
2146
+ this.pending.delete(expectDefined2(msg.id));
2147
+ pending.reject(new Error("Call cancelled by client"));
2148
+ }
2149
+ }
2150
+ });
2151
+ }
2152
+ /**
2153
+ * Send a tool call over the transport and wait for a response.
2154
+ * If asyncTools is true, polls for progress and resolves when the final
2155
+ * response arrives.
2156
+ */
2157
+ async callTool(transport, name, args, callId = crypto.randomUUID()) {
2158
+ await transport.send({
2159
+ jsonrpc: "2.0",
2160
+ method: "tools/call",
2161
+ id: callId,
2162
+ params: { name, arguments: args }
2163
+ });
2164
+ return new Promise((resolve3, reject) => {
2165
+ const timeout = setTimeout(() => {
2166
+ this.pending.delete(callId);
2167
+ reject(new Error(`Tool call ${name} timed out after ${this.opts.totalTimeoutMs}ms`));
2168
+ }, this.opts.totalTimeoutMs);
2169
+ this.pending.set(callId, { resolve: resolve3, reject, timeout });
2170
+ });
2171
+ }
2172
+ cancelAll() {
2173
+ for (const [, p] of this.pending) {
2174
+ clearTimeout(p.timeout);
2175
+ }
2176
+ this.pending.clear();
2177
+ }
2178
+ };
1966
2179
  export {
1967
2180
  ACPSession,
1968
2181
  ACPSessionError,
@@ -1974,7 +2187,9 @@ export {
1974
2187
  imageContent,
1975
2188
  makeACPSubagentRunner,
1976
2189
  makePermissionPolicy,
2190
+ makeTrustBoundaryPermissionPolicy,
1977
2191
  readOnlyPermissionPolicy,
1978
- textContent
2192
+ textContent,
2193
+ toTrustBoundaryRequest
1979
2194
  };
1980
2195
  //# sourceMappingURL=client.js.map