@wrongstack/acp 0.293.0 → 0.295.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.
Files changed (63) 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 +15 -189
  5. package/dist/agent/protocol-handler.d.ts.map +1 -1
  6. package/dist/agent/server-agent-turn.d.ts +89 -1
  7. package/dist/agent/server-agent-turn.d.ts.map +1 -1
  8. package/dist/agent/session-store.d.ts.map +1 -1
  9. package/dist/agent/stdio-transport.d.ts +30 -1
  10. package/dist/agent/stdio-transport.d.ts.map +1 -1
  11. package/dist/agent/tools-registry.d.ts +1 -1
  12. package/dist/agent/tools-registry.d.ts.map +1 -1
  13. package/dist/agent/wrongstack-acp-agent.d.ts +37 -0
  14. package/dist/agent/wrongstack-acp-agent.d.ts.map +1 -1
  15. package/dist/agent.js +189 -32
  16. package/dist/agent.js.map +4 -4
  17. package/dist/client/acp-session.d.ts +13 -1
  18. package/dist/client/acp-session.d.ts.map +1 -1
  19. package/dist/client/file-server.d.ts +19 -0
  20. package/dist/client/file-server.d.ts.map +1 -1
  21. package/dist/client/index.d.ts +11 -9
  22. package/dist/client/index.d.ts.map +1 -1
  23. package/dist/client/terminal-server.d.ts +5 -0
  24. package/dist/client/terminal-server.d.ts.map +1 -1
  25. package/dist/client/tool-translator.d.ts +1 -1
  26. package/dist/client/tool-translator.d.ts.map +1 -1
  27. package/dist/client/trust-boundary-permission.d.ts +31 -0
  28. package/dist/client/trust-boundary-permission.d.ts.map +1 -0
  29. package/dist/client/websocket-transport.d.ts +6 -0
  30. package/dist/client/websocket-transport.d.ts.map +1 -1
  31. package/dist/client.js +459 -243
  32. package/dist/client.js.map +4 -4
  33. package/dist/index.d.ts +29 -29
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2116 -1802
  36. package/dist/index.js.map +4 -4
  37. package/dist/integration/acp-bench.d.ts +32 -0
  38. package/dist/integration/acp-bench.d.ts.map +1 -1
  39. package/dist/integration/acp-subagent-runner.d.ts +3 -3
  40. package/dist/integration/acp-subagent-runner.d.ts.map +1 -1
  41. package/dist/integration/ensemble-runner.d.ts +22 -1
  42. package/dist/integration/ensemble-runner.d.ts.map +1 -1
  43. package/dist/legacy.d.ts +8 -0
  44. package/dist/legacy.d.ts.map +1 -0
  45. package/dist/legacy.js +6 -0
  46. package/dist/legacy.js.map +7 -0
  47. package/dist/registry/acp-registry-fetch.d.ts +1 -1
  48. package/dist/registry/acp-registry-fetch.d.ts.map +1 -1
  49. package/dist/registry/ensemble-registry.d.ts +22 -0
  50. package/dist/registry/ensemble-registry.d.ts.map +1 -1
  51. package/dist/sdk.d.ts +10 -8
  52. package/dist/sdk.d.ts.map +1 -1
  53. package/dist/sdk.js +22 -0
  54. package/dist/sdk.js.map +3 -3
  55. package/dist/v1.d.ts +3 -0
  56. package/dist/v1.d.ts.map +1 -0
  57. package/dist/v1.js +12 -0
  58. package/dist/v1.js.map +7 -0
  59. package/dist/version.d.ts +10 -0
  60. package/dist/version.d.ts.map +1 -0
  61. package/dist/wrongstack-acp-agent.js +121 -24
  62. package/dist/wrongstack-acp-agent.js.map +4 -4
  63. package/package.json +10 -2
package/dist/client.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/agent/stdio-transport.ts
2
- import { expectDefined, writeErr } from "@wrongstack/core";
2
+ import { expectDefined, writeErr } from "@wrongstack/core/utils";
3
+ import { treeKill } from "@wrongstack/core/utils/tree-kill";
3
4
 
4
5
  // src/win32-cmd.ts
5
6
  var WIN32_CMD_META = /[&|<>"\r\n\0]/;
@@ -26,6 +27,11 @@ function quoteWin32CmdArg(arg) {
26
27
  }
27
28
 
28
29
  // src/agent/stdio-transport.ts
30
+ var DEFAULT_MAX_FRAME_CHARS = 20 * 1024 * 1024;
31
+ var DEFAULT_MAX_QUEUED_MESSAGES = 1e3;
32
+ function positiveLimit(value, fallback) {
33
+ return value !== void 0 && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
34
+ }
29
35
  var ClientTransport = class {
30
36
  child = null;
31
37
  buffer = "";
@@ -34,17 +40,21 @@ var ClientTransport = class {
34
40
  resolveRead = null;
35
41
  messageQueue = [];
36
42
  opts;
43
+ maxFrameChars;
44
+ maxQueuedMessages;
37
45
  constructor(options) {
38
46
  this.opts = {
39
47
  handshakeTimeoutMs: 3e4,
40
48
  ...options
41
49
  };
50
+ this.maxFrameChars = positiveLimit(options.maxFrameChars, DEFAULT_MAX_FRAME_CHARS);
51
+ this.maxQueuedMessages = positiveLimit(options.maxQueuedMessages, DEFAULT_MAX_QUEUED_MESSAGES);
42
52
  }
43
53
  async start() {
44
54
  if (this.child) return;
45
55
  const [{ spawn: spawn2 }, { buildChildEnv: buildChildEnv2 }, os] = await Promise.all([
46
56
  import("node:child_process"),
47
- import("@wrongstack/core"),
57
+ import("@wrongstack/core/utils"),
48
58
  import("node:os")
49
59
  ]);
50
60
  return new Promise((resolve3, reject) => {
@@ -57,13 +67,13 @@ var ClientTransport = class {
57
67
  const spawnCwd = isPkgLauncher ? os.homedir() : this.opts.cwd;
58
68
  try {
59
69
  const childArgs = this.opts.args ?? [];
60
- const shim = process.platform === "win32" ? buildWin32CmdShimInvocation(this.opts.command, childArgs) : null;
61
- this.child = spawn2(shim?.command ?? this.opts.command, shim?.args ?? childArgs, {
70
+ const invocation = spawnInvocation(this.opts.command, childArgs, process.platform);
71
+ this.child = spawn2(invocation.command, invocation.args, {
62
72
  env: { ...buildChildEnv2(), ...this.opts.env },
63
73
  cwd: spawnCwd,
64
74
  stdio: ["pipe", "pipe", "pipe"],
65
75
  windowsHide: true,
66
- ...shim ? { windowsVerbatimArguments: shim.windowsVerbatimArguments } : {}
76
+ ...verbatimOptions(invocation)
67
77
  });
68
78
  } catch (err) {
69
79
  clearTimeout(timeout);
@@ -128,7 +138,8 @@ var ClientTransport = class {
128
138
  });
129
139
  }
130
140
  read() {
131
- if (this.messageQueue.length > 0) return Promise.resolve(expectDefined(this.messageQueue.shift()));
141
+ if (this.messageQueue.length > 0)
142
+ return Promise.resolve(expectDefined(this.messageQueue.shift()));
132
143
  if (this.closed) return Promise.resolve(null);
133
144
  return new Promise((resolve3) => {
134
145
  this.resolveRead = resolve3;
@@ -139,20 +150,35 @@ var ClientTransport = class {
139
150
  return () => this.handlers.delete(handler);
140
151
  }
141
152
  stop() {
142
- if (!this.child) return;
143
153
  this.closed = true;
144
- try {
145
- this.child.kill();
146
- } catch {
147
- }
154
+ this.resolveRead?.(null);
155
+ this.resolveRead = null;
156
+ this.buffer = "";
157
+ this.messageQueue.length = 0;
158
+ this.handlers.clear();
159
+ const child = this.child;
160
+ if (!child) return;
161
+ treeKill(child);
148
162
  this.child = null;
149
163
  }
150
164
  onChildData(chunk) {
151
165
  this.buffer += chunk;
152
166
  const lines = this.buffer.split("\n");
153
167
  this.buffer = lines.pop() ?? "";
168
+ if (this.buffer.length > this.maxFrameChars) {
169
+ writeErr(`[acp-child pending frame exceeds ${this.maxFrameChars} characters]
170
+ `);
171
+ this.stop();
172
+ return;
173
+ }
154
174
  for (const raw of lines) {
155
175
  if (!raw.trim()) continue;
176
+ if (raw.length > this.maxFrameChars) {
177
+ writeErr(`[acp-child frame exceeds ${this.maxFrameChars} characters]
178
+ `);
179
+ this.stop();
180
+ return;
181
+ }
156
182
  try {
157
183
  this.dispatch(JSON.parse(raw));
158
184
  } catch {
@@ -166,6 +192,9 @@ var ClientTransport = class {
166
192
  this.closed = true;
167
193
  this.resolveRead?.(null);
168
194
  this.resolveRead = null;
195
+ this.buffer = "";
196
+ this.messageQueue.length = 0;
197
+ this.handlers.clear();
169
198
  if (code !== 0 && code !== null) {
170
199
  writeErr(`[acp-child exited with code ${code}]
171
200
  `);
@@ -176,7 +205,13 @@ var ClientTransport = class {
176
205
  const resolve3 = this.resolveRead;
177
206
  this.resolveRead = null;
178
207
  resolve3(msg);
179
- } else {
208
+ } else if (this.handlers.size === 0) {
209
+ if (this.messageQueue.length >= this.maxQueuedMessages) {
210
+ writeErr(`[acp-child message queue exceeds ${this.maxQueuedMessages} entries]
211
+ `);
212
+ this.stop();
213
+ return;
214
+ }
180
215
  this.messageQueue.push(msg);
181
216
  }
182
217
  for (const handler of this.handlers) {
@@ -187,180 +222,13 @@ var ClientTransport = class {
187
222
  }
188
223
  }
189
224
  };
190
-
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
- };
225
+ function spawnInvocation(command, args, platform) {
226
+ if (platform !== "win32") return { command, args };
227
+ return buildWin32CmdShimInvocation(command, args);
228
+ }
229
+ function verbatimOptions(invocation) {
230
+ return invocation.windowsVerbatimArguments ? { windowsVerbatimArguments: invocation.windowsVerbatimArguments } : {};
231
+ }
364
232
 
365
233
  // src/types/acp-v1.ts
366
234
  var ACP_PROTOCOL_VERSION = 1;
@@ -370,6 +238,14 @@ import { randomBytes } from "node:crypto";
370
238
  import { realpathSync } from "node:fs";
371
239
  import * as fsp from "node:fs/promises";
372
240
  import * as path from "node:path";
241
+ var DEFAULT_FILE_OPERATIONS = {
242
+ stat: fsp.stat,
243
+ readFile: fsp.readFile,
244
+ writeFile: fsp.writeFile,
245
+ realpath: fsp.realpath,
246
+ rename: fsp.rename,
247
+ unlink: fsp.unlink
248
+ };
373
249
  var DEFAULT_MAX_READ_BYTES = 5 * 1024 * 1024;
374
250
  var DEFAULT_MAX_WRITE_BYTES = 5 * 1024 * 1024;
375
251
  var FsError = class extends Error {
@@ -388,12 +264,14 @@ var FileServer = class {
388
264
  timeoutMs;
389
265
  maxReadBytes;
390
266
  maxWriteBytes;
267
+ operations;
391
268
  constructor(opts) {
392
269
  this.root = path.resolve(opts.projectRoot);
393
270
  this.realRoot = safeRealpathSync(this.root);
394
271
  this.timeoutMs = opts.timeoutMs ?? 3e4;
395
272
  this.maxReadBytes = opts.maxReadBytes ?? DEFAULT_MAX_READ_BYTES;
396
273
  this.maxWriteBytes = opts.maxWriteBytes ?? DEFAULT_MAX_WRITE_BYTES;
274
+ this.operations = opts.operations ?? DEFAULT_FILE_OPERATIONS;
397
275
  }
398
276
  /** Read a text file. Returns the content as a string. */
399
277
  async readTextFile(params) {
@@ -401,7 +279,7 @@ var FileServer = class {
401
279
  const controller = new AbortController();
402
280
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
403
281
  try {
404
- const stat2 = await fsp.stat(safe).catch((err) => {
282
+ const stat2 = await this.operations.stat(safe).catch((err) => {
405
283
  throw mapFsError(err, safe);
406
284
  });
407
285
  if (stat2.size > this.maxReadBytes) {
@@ -411,7 +289,7 @@ var FileServer = class {
411
289
  `file is ${stat2.size} bytes, max read is ${this.maxReadBytes} bytes`
412
290
  );
413
291
  }
414
- const content = await fsp.readFile(safe, {
292
+ const content = await this.operations.readFile(safe, {
415
293
  encoding: "utf8",
416
294
  signal: controller.signal
417
295
  });
@@ -441,20 +319,20 @@ var FileServer = class {
441
319
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
442
320
  const tmp = `${safe}.${randomBytes(6).toString("hex")}.tmp`;
443
321
  try {
444
- await fsp.writeFile(tmp, params.content, {
322
+ await this.operations.writeFile(tmp, params.content, {
445
323
  encoding: "utf8",
446
324
  signal: controller.signal
447
325
  });
448
326
  await this.assertRealInside(tmp);
449
327
  await this.assertRealInside(path.dirname(safe));
450
- await fsp.rename(tmp, safe);
328
+ await this.operations.rename(tmp, safe);
451
329
  } catch (err) {
452
330
  if (err instanceof FsError) {
453
- await fsp.unlink(tmp).catch(() => void 0);
331
+ await this.operations.unlink(tmp).catch(() => void 0);
454
332
  throw err;
455
333
  }
456
334
  try {
457
- await fsp.unlink(tmp);
335
+ await this.operations.unlink(tmp);
458
336
  } catch {
459
337
  }
460
338
  if (controller.signal.aborted) {
@@ -498,12 +376,14 @@ var FileServer = class {
498
376
  for (; ; ) {
499
377
  let real;
500
378
  try {
501
- real = await fsp.realpath(probe);
379
+ real = await this.operations.realpath(probe);
502
380
  } catch (err) {
503
381
  const code = err.code;
504
382
  if (code === "ENOENT") {
505
383
  const parent = path.dirname(probe);
506
- if (parent === probe) return;
384
+ if (parent === probe) {
385
+ throw new FsError("ENOENT", resolvedPath, `no existing ancestor: ${resolvedPath}`);
386
+ }
507
387
  probe = parent;
508
388
  continue;
509
389
  }
@@ -584,26 +464,41 @@ import { spawn } from "node:child_process";
584
464
  import { realpathSync as realpathSync2 } from "node:fs";
585
465
  import * as path2 from "node:path";
586
466
  import { buildChildEnv } from "@wrongstack/core/utils";
467
+ var EMPTY_BUFFER = Buffer.alloc(0);
587
468
  var TerminalServer = class {
588
469
  terminals = /* @__PURE__ */ new Map();
589
470
  projectRoot;
590
471
  commandTimeoutMs;
591
472
  outputByteLimit;
592
473
  maxOutputByteLimit;
474
+ maxTerminals;
475
+ abortSignal;
476
+ abortHandler = () => this.releaseAll();
593
477
  nextId = 1;
594
478
  constructor(opts) {
595
479
  this.projectRoot = path2.resolve(opts.projectRoot);
596
480
  this.commandTimeoutMs = opts.commandTimeoutMs ?? 5 * 6e4;
597
481
  this.outputByteLimit = opts.outputByteLimit ?? 1024 * 1024;
598
482
  this.maxOutputByteLimit = opts.maxOutputByteLimit ?? 16 * 1024 * 1024;
483
+ this.maxTerminals = this.clampFiniteInt(opts.maxTerminals, 32);
484
+ this.abortSignal = opts.signal;
599
485
  if (opts.signal) {
600
- opts.signal.addEventListener("abort", () => this.releaseAll());
486
+ opts.signal.addEventListener("abort", this.abortHandler, { once: true });
601
487
  }
602
488
  }
603
489
  /** Spawn a new terminal. Returns the agent-facing id. */
604
490
  create(params) {
491
+ if (this.terminals.size >= this.maxTerminals) {
492
+ throw new Error(
493
+ `terminal limit reached (${this.maxTerminals}); release an existing terminal before creating another`
494
+ );
495
+ }
605
496
  const id = `term_${this.nextId++}`;
606
497
  const cwd = this.resolveCwd(params.cwd);
498
+ const perCallByteLimit = Math.min(
499
+ Math.max(1, this.clampFiniteInt(params.outputByteLimit, this.outputByteLimit)),
500
+ this.maxOutputByteLimit
501
+ );
607
502
  const proc = spawn(params.command, params.args ?? [], {
608
503
  cwd,
609
504
  env: this.buildEnv(params.env),
@@ -622,7 +517,8 @@ var TerminalServer = class {
622
517
  cwd,
623
518
  command: params.command,
624
519
  args: params.args ?? [],
625
- output: "",
520
+ outputChunks: [],
521
+ outputHead: 0,
626
522
  retainedBytes: 0,
627
523
  truncated: false,
628
524
  exitStatus: void 0,
@@ -647,31 +543,44 @@ var TerminalServer = class {
647
543
  }
648
544
  const exitStatus = { exitCode: 127, signal: null };
649
545
  state.exitStatus = exitStatus;
650
- state.output += `[spawn error] ${err.message}
651
- `;
652
- state.retainedBytes = Buffer.byteLength(state.output, "utf8");
546
+ let errorOutput = Buffer.from(`[spawn error] ${err.message}
547
+ `, "utf8");
548
+ if (errorOutput.length > perCallByteLimit) {
549
+ let start = errorOutput.length - perCallByteLimit;
550
+ while (start < errorOutput.length && (errorOutput[start] & 192) === 128) start++;
551
+ errorOutput = errorOutput.subarray(start);
552
+ state.truncated = true;
553
+ }
554
+ state.outputChunks.push(errorOutput);
555
+ state.retainedBytes = errorOutput.length;
653
556
  resolve3(exitStatus);
654
557
  });
655
558
  })
656
559
  };
657
- const perCallByteLimit = Math.min(
658
- Math.max(1, this.clampFiniteInt(params.outputByteLimit, this.outputByteLimit)),
659
- this.maxOutputByteLimit
660
- );
661
560
  proc.stdout?.setEncoding("utf8");
662
561
  proc.stderr?.setEncoding("utf8");
663
562
  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;
563
+ const outputChunk = Buffer.from(chunk, "utf8");
564
+ state.outputChunks.push(outputChunk);
565
+ state.retainedBytes += outputChunk.length;
566
+ if (state.retainedBytes > perCallByteLimit) state.truncated = true;
567
+ while (state.retainedBytes > perCallByteLimit && state.outputHead < state.outputChunks.length) {
568
+ const first = state.outputChunks[state.outputHead];
569
+ const overflow = state.retainedBytes - perCallByteLimit;
570
+ if (first.length <= overflow) {
571
+ state.outputChunks[state.outputHead] = EMPTY_BUFFER;
572
+ state.outputHead++;
573
+ state.retainedBytes -= first.length;
574
+ continue;
672
575
  }
673
- state.retainedBytes = newBytes;
674
- state.truncated = true;
576
+ let start = overflow;
577
+ while (start < first.length && (first[start] & 192) === 128) start++;
578
+ state.outputChunks[state.outputHead] = first.subarray(start);
579
+ state.retainedBytes -= start;
580
+ }
581
+ if (state.outputHead >= 256 && state.outputHead * 2 >= state.outputChunks.length) {
582
+ state.outputChunks = state.outputChunks.slice(state.outputHead);
583
+ state.outputHead = 0;
675
584
  }
676
585
  };
677
586
  proc.stdout?.on("data", onData);
@@ -690,7 +599,10 @@ var TerminalServer = class {
690
599
  const state = this.terminals.get(terminalId);
691
600
  if (!state) throw new Error(`unknown terminal: ${terminalId}`);
692
601
  return {
693
- output: state.output,
602
+ output: Buffer.concat(
603
+ state.outputChunks.slice(state.outputHead),
604
+ state.retainedBytes
605
+ ).toString("utf8"),
694
606
  truncated: state.truncated,
695
607
  ...state.exitStatus ? { exitStatus: state.exitStatus } : {}
696
608
  };
@@ -726,6 +638,7 @@ var TerminalServer = class {
726
638
  }
727
639
  /** Kill all active terminals. Used on session close. */
728
640
  releaseAll() {
641
+ this.abortSignal?.removeEventListener("abort", this.abortHandler);
729
642
  for (const id of [...this.terminals.keys()]) {
730
643
  this.release(id);
731
644
  }
@@ -789,6 +702,212 @@ var DENIED_AGENT_ENV_KEYS = /* @__PURE__ */ new Set([
789
702
  "RUBYLIB"
790
703
  ]);
791
704
 
705
+ // src/client/trust-boundary-permission.ts
706
+ function pickOption(options, allowed) {
707
+ const kinds = allowed ? ["allow_once", "allow_always"] : ["reject_once", "reject_always"];
708
+ for (const kind of kinds) {
709
+ const option = options.find((candidate) => candidate.kind === kind);
710
+ if (option) return { outcome: "selected", optionId: option.optionId };
711
+ }
712
+ return { outcome: "cancelled" };
713
+ }
714
+ function riskFor(kind) {
715
+ if (kind === "read" || kind === "search" || kind === "fetch" || kind === "think") return "low";
716
+ if (kind === "edit" || kind === "move") return "elevated";
717
+ if (kind === "delete" || kind === "execute") return "high";
718
+ return "elevated";
719
+ }
720
+ function capabilityFor(request) {
721
+ const raw = request.toolCall.rawInput;
722
+ if (typeof raw?.path === "string") {
723
+ return request.toolCall.kind === "read" || request.toolCall.kind === "search" ? "filesystem.read" : "filesystem.write";
724
+ }
725
+ if (typeof raw?.command === "string" || request.toolCall.kind === "execute")
726
+ return "process.spawn";
727
+ if (request.toolCall.kind === "fetch") return "network.fetch";
728
+ return `tool.${request.toolCall.kind ?? "unknown"}`;
729
+ }
730
+ function subjectFor(request) {
731
+ const raw = request.toolCall.rawInput;
732
+ const title = request.toolCall.title ?? `ACP tool call ${String(request.toolCall.toolCallId)}`;
733
+ if (typeof raw?.path === "string") {
734
+ return { kind: "path", id: raw.path, attributes: { toolKind: request.toolCall.kind ?? null } };
735
+ }
736
+ if (typeof raw?.command === "string") {
737
+ return {
738
+ kind: "command",
739
+ id: raw.command,
740
+ attributes: { toolKind: request.toolCall.kind ?? null }
741
+ };
742
+ }
743
+ return {
744
+ kind: "resource",
745
+ id: title,
746
+ attributes: { toolKind: request.toolCall.kind ?? null }
747
+ };
748
+ }
749
+ function isAllowed(decision) {
750
+ return decision.kind === "allow" || decision.kind === "scoped-token";
751
+ }
752
+ function toTrustBoundaryRequest(request, options) {
753
+ const rawSessionId = request.toolCall.rawInput?.sessionId;
754
+ const sessionId = typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : options.actor?.sessionId;
755
+ return {
756
+ version: 1,
757
+ requestId: String(request.toolCall.toolCallId),
758
+ actor: {
759
+ ...options.actor ?? { kind: "agent" },
760
+ ...sessionId ? { sessionId } : {}
761
+ },
762
+ surface: "acp",
763
+ capability: capabilityFor(request),
764
+ subject: subjectFor(request),
765
+ risk: riskFor(request.toolCall.kind),
766
+ scope: {
767
+ ...options.scope ?? {},
768
+ ...sessionId ? { sessionId } : {}
769
+ },
770
+ ...options.authContext ? { authContext: options.authContext } : {},
771
+ metadata: {
772
+ ...request.toolCall.title ? { title: request.toolCall.title } : {},
773
+ toolKind: request.toolCall.kind ?? null
774
+ }
775
+ };
776
+ }
777
+ function makeTrustBoundaryPermissionPolicy(options) {
778
+ return async (request) => {
779
+ if (request.signal.aborted) return { outcome: "cancelled" };
780
+ const decision = await options.boundary.evaluate(toTrustBoundaryRequest(request, options));
781
+ if (request.signal.aborted) return { outcome: "cancelled" };
782
+ return pickOption(request.options, isAllowed(decision));
783
+ };
784
+ }
785
+
786
+ // src/client/websocket-transport.ts
787
+ var WebSocketClientTransport = class {
788
+ ws = null;
789
+ handlers = /* @__PURE__ */ new Set();
790
+ closed = false;
791
+ opts;
792
+ maxBufferedBytes;
793
+ maxMessageChars;
794
+ constructor(opts) {
795
+ this.opts = opts;
796
+ this.maxBufferedBytes = finitePositiveLimit(opts.maxBufferedBytes, 32 * 1024 * 1024);
797
+ this.maxMessageChars = finitePositiveLimit(opts.maxMessageChars, 20 * 1024 * 1024);
798
+ }
799
+ start() {
800
+ const WS = globalThis.WebSocket;
801
+ if (!WS) {
802
+ return Promise.reject(
803
+ new Error(
804
+ "global WebSocket is not available \u2014 Node \u2265 22 is required for the remote ACP transport"
805
+ )
806
+ );
807
+ }
808
+ const timeoutMs = this.opts.handshakeTimeoutMs ?? 3e4;
809
+ return new Promise((resolve3, reject) => {
810
+ let settled = false;
811
+ const ws = new WS(this.opts.url, this.opts.protocols);
812
+ this.ws = ws;
813
+ const timer = setTimeout(() => {
814
+ settled = true;
815
+ try {
816
+ ws.close();
817
+ } catch {
818
+ }
819
+ reject(new Error(`WebSocket failed to open within ${timeoutMs}ms`));
820
+ }, timeoutMs);
821
+ ws.addEventListener("open", () => {
822
+ if (settled) return;
823
+ settled = true;
824
+ clearTimeout(timer);
825
+ resolve3();
826
+ });
827
+ ws.addEventListener("error", (ev) => {
828
+ if (settled) {
829
+ this.closed = true;
830
+ return;
831
+ }
832
+ settled = true;
833
+ clearTimeout(timer);
834
+ const message = ev && typeof ev === "object" && "message" in ev ? String(ev.message) : "WebSocket error";
835
+ reject(new Error(message));
836
+ });
837
+ ws.addEventListener("close", () => {
838
+ this.closed = true;
839
+ });
840
+ ws.addEventListener("message", (ev) => {
841
+ this.onData(ev.data);
842
+ });
843
+ });
844
+ }
845
+ send(msg) {
846
+ if (this.closed || !this.ws) {
847
+ return Promise.reject(new Error("WebSocket transport is not open"));
848
+ }
849
+ try {
850
+ const serialized = JSON.stringify(msg);
851
+ const buffered = Number.isFinite(this.ws.bufferedAmount) ? this.ws.bufferedAmount : 0;
852
+ if (buffered + Buffer.byteLength(serialized, "utf8") > this.maxBufferedBytes) {
853
+ this.stop();
854
+ return Promise.reject(new Error("WebSocket transport send buffer limit exceeded"));
855
+ }
856
+ this.ws.send(serialized);
857
+ return Promise.resolve();
858
+ } catch (err) {
859
+ return Promise.reject(err instanceof Error ? err : new Error(String(err)));
860
+ }
861
+ }
862
+ onMessage(handler) {
863
+ this.handlers.add(handler);
864
+ return () => this.handlers.delete(handler);
865
+ }
866
+ stop() {
867
+ this.closed = true;
868
+ if (this.ws) {
869
+ try {
870
+ this.ws.close();
871
+ } catch {
872
+ }
873
+ this.ws = null;
874
+ }
875
+ }
876
+ onData(data) {
877
+ const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : Buffer.isBuffer(data) ? data.toString("utf8") : String(data);
878
+ if (text.length > this.maxMessageChars) {
879
+ this.stop();
880
+ return;
881
+ }
882
+ if (!text.trim()) return;
883
+ let msg;
884
+ try {
885
+ msg = JSON.parse(text);
886
+ } catch {
887
+ for (const line of text.split("\n")) {
888
+ if (!line.trim()) continue;
889
+ try {
890
+ this.dispatch(JSON.parse(line));
891
+ } catch {
892
+ }
893
+ }
894
+ return;
895
+ }
896
+ this.dispatch(msg);
897
+ }
898
+ dispatch(msg) {
899
+ for (const handler of [...this.handlers]) {
900
+ try {
901
+ handler(msg);
902
+ } catch {
903
+ }
904
+ }
905
+ }
906
+ };
907
+ function finitePositiveLimit(value, fallback) {
908
+ return value !== void 0 && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
909
+ }
910
+
792
911
  // src/client/acp-session.ts
793
912
  var ACPSessionError = class extends Error {
794
913
  kind;
@@ -810,6 +929,7 @@ var ACPSession = class _ACPSession {
810
929
  permissionPolicy;
811
930
  timeoutMs;
812
931
  opts;
932
+ transportOff = null;
813
933
  state = "init";
814
934
  sessionId = null;
815
935
  /** Pending outbound requests (initialize, session/new, session/prompt, etc). */
@@ -841,8 +961,19 @@ var ACPSession = class _ACPSession {
841
961
  if (opts.terminalOutputByteLimit !== void 0) {
842
962
  termOpts.outputByteLimit = opts.terminalOutputByteLimit;
843
963
  }
964
+ if (opts.terminalMaxCount !== void 0) {
965
+ termOpts.maxTerminals = opts.terminalMaxCount;
966
+ }
844
967
  this.terminalServer = new TerminalServer(termOpts);
845
- this.permissionPolicy = opts.permissionPolicy ?? readOnlyPermissionPolicy;
968
+ if (opts.permissionPolicy && opts.trustBoundary) {
969
+ throw new TypeError("permissionPolicy and trustBoundary are mutually exclusive");
970
+ }
971
+ this.permissionPolicy = opts.trustBoundary ? makeTrustBoundaryPermissionPolicy({
972
+ boundary: opts.trustBoundary,
973
+ ...opts.trustActor ? { actor: opts.trustActor } : {},
974
+ scope: opts.trustScope ?? { cwd: opts.projectRoot },
975
+ ...opts.trustAuthContext ? { authContext: opts.trustAuthContext } : {}
976
+ }) : opts.permissionPolicy ?? readOnlyPermissionPolicy;
846
977
  }
847
978
  // ──────────────────────────────────────────────────────────────────────
848
979
  // Public accessors
@@ -916,10 +1047,12 @@ var ACPSession = class _ACPSession {
916
1047
  throw new ACPSessionError("spawn_failed", `${spawnErrLabel}: ${msg}`, err);
917
1048
  }
918
1049
  const session = new _ACPSession(opts, transport);
919
- transport.onMessage((msg) => session.handleMessage(msg));
1050
+ session.transportOff = transport.onMessage((msg) => session.handleMessage(msg));
920
1051
  try {
921
1052
  await session.initialize();
922
1053
  } catch (err) {
1054
+ session.transportOff?.();
1055
+ session.transportOff = null;
923
1056
  try {
924
1057
  transport.stop();
925
1058
  } catch {
@@ -1083,7 +1216,11 @@ var ACPSession = class _ACPSession {
1083
1216
  mcpServers: servers
1084
1217
  });
1085
1218
  if (isJsonRpcError(result)) {
1086
- throw new ACPSessionError("prompt_failed", `session/resume failed: ${result.message}`, result);
1219
+ throw new ACPSessionError(
1220
+ "prompt_failed",
1221
+ `session/resume failed: ${result.message}`,
1222
+ result
1223
+ );
1087
1224
  }
1088
1225
  this.sessionId = sessionId;
1089
1226
  }
@@ -1134,7 +1271,11 @@ var ACPSession = class _ACPSession {
1134
1271
  const id = this.allocId();
1135
1272
  const result = await this.sendRequest(id, "session/delete", { sessionId });
1136
1273
  if (isJsonRpcError(result)) {
1137
- throw new ACPSessionError("prompt_failed", `session/delete failed: ${result.message}`, result);
1274
+ throw new ACPSessionError(
1275
+ "prompt_failed",
1276
+ `session/delete failed: ${result.message}`,
1277
+ result
1278
+ );
1138
1279
  }
1139
1280
  if (this.sessionId === sessionId) {
1140
1281
  this.sessionId = null;
@@ -1169,7 +1310,11 @@ var ACPSession = class _ACPSession {
1169
1310
  const id = this.allocId();
1170
1311
  const result = await this.sendRequest(id, "session/set_mode", { sessionId, modeId });
1171
1312
  if (isJsonRpcError(result)) {
1172
- throw new ACPSessionError("prompt_failed", `session/set_mode failed: ${result.message}`, result);
1313
+ throw new ACPSessionError(
1314
+ "prompt_failed",
1315
+ `session/set_mode failed: ${result.message}`,
1316
+ result
1317
+ );
1173
1318
  }
1174
1319
  }
1175
1320
  /**
@@ -1184,7 +1329,11 @@ var ACPSession = class _ACPSession {
1184
1329
  value
1185
1330
  });
1186
1331
  if (isJsonRpcError(result)) {
1187
- throw new ACPSessionError("prompt_failed", `session/set_config_option failed: ${result.message}`, result);
1332
+ throw new ACPSessionError(
1333
+ "prompt_failed",
1334
+ `session/set_config_option failed: ${result.message}`,
1335
+ result
1336
+ );
1188
1337
  }
1189
1338
  }
1190
1339
  /**
@@ -1195,7 +1344,11 @@ var ACPSession = class _ACPSession {
1195
1344
  const id = this.allocId();
1196
1345
  const result = await this.sendRequest(id, "providers/list", {});
1197
1346
  if (isJsonRpcError(result)) {
1198
- throw new ACPSessionError("prompt_failed", `providers/list failed: ${result.message}`, result);
1347
+ throw new ACPSessionError(
1348
+ "prompt_failed",
1349
+ `providers/list failed: ${result.message}`,
1350
+ result
1351
+ );
1199
1352
  }
1200
1353
  const r = result;
1201
1354
  return { providers: r.providers ?? [], currentProviderId: r.currentProviderId ?? null };
@@ -1231,7 +1384,11 @@ var ACPSession = class _ACPSession {
1231
1384
  const id = this.allocId();
1232
1385
  const result = await this.sendRequest(id, "providers/disable", {});
1233
1386
  if (isJsonRpcError(result)) {
1234
- throw new ACPSessionError("prompt_failed", `providers/disable failed: ${result.message}`, result);
1387
+ throw new ACPSessionError(
1388
+ "prompt_failed",
1389
+ `providers/disable failed: ${result.message}`,
1390
+ result
1391
+ );
1235
1392
  }
1236
1393
  }
1237
1394
  // ──────────────────────────────────────────────────────────────────────
@@ -1338,11 +1495,7 @@ var ACPSession = class _ACPSession {
1338
1495
  }
1339
1496
  const sessionId = result.sessionId;
1340
1497
  if (typeof sessionId !== "string" || sessionId.length === 0) {
1341
- throw new ACPSessionError(
1342
- "protocol_error",
1343
- "session/new returned no sessionId",
1344
- result
1345
- );
1498
+ throw new ACPSessionError("protocol_error", "session/new returned no sessionId", result);
1346
1499
  }
1347
1500
  this.sessionId = sessionId;
1348
1501
  }
@@ -1385,6 +1538,8 @@ var ACPSession = class _ACPSession {
1385
1538
  p.reject(new ACPSessionError("closed", "session was closed"));
1386
1539
  }
1387
1540
  this.pending.clear();
1541
+ this.transportOff?.();
1542
+ this.transportOff = null;
1388
1543
  try {
1389
1544
  this.transport.stop();
1390
1545
  } catch {
@@ -1420,10 +1575,7 @@ var ACPSession = class _ACPSession {
1420
1575
  const handle = setTimeout(() => {
1421
1576
  this.pending.delete(id);
1422
1577
  reject(
1423
- new ACPSessionError(
1424
- "protocol_error",
1425
- `${method} timed out after ${effectiveTimeout}ms`
1426
- )
1578
+ new ACPSessionError("protocol_error", `${method} timed out after ${effectiveTimeout}ms`)
1427
1579
  );
1428
1580
  }, effectiveTimeout);
1429
1581
  this.pending.set(id, {
@@ -1740,7 +1892,12 @@ var ACPSession = class _ACPSession {
1740
1892
  toolCallId: `acp-terminal-create-${id}`,
1741
1893
  title: `Run command: ${String(params.command ?? "")} ${(Array.isArray(params.args) ? params.args : []).join(" ")}`.trim(),
1742
1894
  kind: "execute",
1743
- rawInput: { command: params.command, args: params.args, cwd: params.cwd, sessionId: params.sessionId }
1895
+ rawInput: {
1896
+ command: params.command,
1897
+ args: params.args,
1898
+ cwd: params.cwd,
1899
+ sessionId: params.sessionId
1900
+ }
1744
1901
  });
1745
1902
  if (!allowed) {
1746
1903
  await this.sendErrorResponse(id, -32602, "terminal create denied by permission policy");
@@ -1956,17 +2113,74 @@ function mapACPKind(acpKind) {
1956
2113
  }
1957
2114
  }
1958
2115
  function isRetryable(kind) {
1959
- switch (kind) {
1960
- case "provider_5xx":
1961
- case "provider_rate_limit":
1962
- case "provider_timeout":
1963
- case "tool_threw":
1964
- case "budget_timeout":
1965
- return true;
1966
- default:
1967
- return false;
1968
- }
2116
+ void kind;
2117
+ return false;
1969
2118
  }
2119
+
2120
+ // src/client/tool-translator.ts
2121
+ import { expectDefined as expectDefined2 } from "@wrongstack/core/utils";
2122
+ var DEFAULT_OPTIONS = {
2123
+ asyncTools: true,
2124
+ pollIntervalMs: 500,
2125
+ totalTimeoutMs: 12e4
2126
+ };
2127
+ var ToolTranslator = class {
2128
+ opts;
2129
+ pending = /* @__PURE__ */ new Map();
2130
+ constructor(opts = {}) {
2131
+ this.opts = { ...DEFAULT_OPTIONS, ...opts };
2132
+ }
2133
+ /**
2134
+ * Start listening to a transport for tool responses and cancellations.
2135
+ * Call this once after constructing the translator and before sending tasks.
2136
+ */
2137
+ attachToTransport(transport) {
2138
+ transport.onMessage((msg) => {
2139
+ if (msg.method === "tools/call" && msg.id !== void 0) {
2140
+ const pending = this.pending.get(msg.id);
2141
+ if (pending) {
2142
+ clearTimeout(pending.timeout);
2143
+ this.pending.delete(expectDefined2(msg.id));
2144
+ pending.resolve(msg);
2145
+ }
2146
+ }
2147
+ if (msg.method === "cancel" && msg.id !== void 0) {
2148
+ const pending = this.pending.get(msg.id);
2149
+ if (pending) {
2150
+ clearTimeout(pending.timeout);
2151
+ this.pending.delete(expectDefined2(msg.id));
2152
+ pending.reject(new Error("Call cancelled by client"));
2153
+ }
2154
+ }
2155
+ });
2156
+ }
2157
+ /**
2158
+ * Send a tool call over the transport and wait for a response.
2159
+ * If asyncTools is true, polls for progress and resolves when the final
2160
+ * response arrives.
2161
+ */
2162
+ async callTool(transport, name, args, callId = crypto.randomUUID()) {
2163
+ await transport.send({
2164
+ jsonrpc: "2.0",
2165
+ method: "tools/call",
2166
+ id: callId,
2167
+ params: { name, arguments: args }
2168
+ });
2169
+ return new Promise((resolve3, reject) => {
2170
+ const timeout = setTimeout(() => {
2171
+ this.pending.delete(callId);
2172
+ reject(new Error(`Tool call ${name} timed out after ${this.opts.totalTimeoutMs}ms`));
2173
+ }, this.opts.totalTimeoutMs);
2174
+ this.pending.set(callId, { resolve: resolve3, reject, timeout });
2175
+ });
2176
+ }
2177
+ cancelAll() {
2178
+ for (const [, p] of this.pending) {
2179
+ clearTimeout(p.timeout);
2180
+ }
2181
+ this.pending.clear();
2182
+ }
2183
+ };
1970
2184
  export {
1971
2185
  ACPSession,
1972
2186
  ACPSessionError,
@@ -1978,7 +2192,9 @@ export {
1978
2192
  imageContent,
1979
2193
  makeACPSubagentRunner,
1980
2194
  makePermissionPolicy,
2195
+ makeTrustBoundaryPermissionPolicy,
1981
2196
  readOnlyPermissionPolicy,
1982
- textContent
2197
+ textContent,
2198
+ toTrustBoundaryRequest
1983
2199
  };
1984
2200
  //# sourceMappingURL=client.js.map