@wrongstack/acp 0.296.4 → 0.298.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 (56) hide show
  1. package/dist/agent/protocol-handler.d.ts +39 -46
  2. package/dist/agent/session-store.d.ts +55 -0
  3. package/dist/agent/stdio-transport.d.ts +74 -1
  4. package/dist/agent.js +472 -210
  5. package/dist/client/terminal-server.d.ts +5 -1
  6. package/dist/client/websocket-transport.d.ts +2 -0
  7. package/dist/client.js +122 -56
  8. package/dist/index.js +484 -264
  9. package/dist/win32-cmd.d.ts +9 -6
  10. package/dist/wrongstack-acp-agent.js +312 -157
  11. package/package.json +4 -3
  12. package/dist/agent/index.d.ts.map +0 -1
  13. package/dist/agent/protocol-contract.d.ts.map +0 -1
  14. package/dist/agent/protocol-handler.d.ts.map +0 -1
  15. package/dist/agent/server-agent-turn.d.ts.map +0 -1
  16. package/dist/agent/session-store.d.ts.map +0 -1
  17. package/dist/agent/stdio-transport.d.ts.map +0 -1
  18. package/dist/agent/tools-registry.d.ts.map +0 -1
  19. package/dist/agent/wrongstack-acp-agent.d.ts.map +0 -1
  20. package/dist/agent/ws-bridge-transport.d.ts.map +0 -1
  21. package/dist/agent.js.map +0 -7
  22. package/dist/client/acp-message-routing.d.ts.map +0 -1
  23. package/dist/client/acp-session-callbacks.d.ts.map +0 -1
  24. package/dist/client/acp-session-content.d.ts.map +0 -1
  25. package/dist/client/acp-session-errors.d.ts.map +0 -1
  26. package/dist/client/acp-session-types.d.ts.map +0 -1
  27. package/dist/client/acp-session-updates.d.ts.map +0 -1
  28. package/dist/client/acp-session.d.ts.map +0 -1
  29. package/dist/client/file-server.d.ts.map +0 -1
  30. package/dist/client/index.d.ts.map +0 -1
  31. package/dist/client/permission.d.ts.map +0 -1
  32. package/dist/client/terminal-server.d.ts.map +0 -1
  33. package/dist/client/tool-translator.d.ts.map +0 -1
  34. package/dist/client/trust-boundary-permission.d.ts.map +0 -1
  35. package/dist/client/websocket-transport.d.ts.map +0 -1
  36. package/dist/client.js.map +0 -7
  37. package/dist/index.d.ts.map +0 -1
  38. package/dist/index.js.map +0 -7
  39. package/dist/integration/acp-bench.d.ts.map +0 -1
  40. package/dist/integration/acp-subagent-runner.d.ts.map +0 -1
  41. package/dist/integration/ensemble-runner.d.ts.map +0 -1
  42. package/dist/integration/run-one-acp-task.d.ts.map +0 -1
  43. package/dist/legacy.d.ts.map +0 -1
  44. package/dist/legacy.js.map +0 -7
  45. package/dist/registry/acp-registry-fetch.d.ts.map +0 -1
  46. package/dist/registry/agents.catalog.d.ts.map +0 -1
  47. package/dist/registry/ensemble-registry.d.ts.map +0 -1
  48. package/dist/sdk.d.ts.map +0 -1
  49. package/dist/sdk.js.map +0 -7
  50. package/dist/types/acp-messages.d.ts.map +0 -1
  51. package/dist/types/acp-v1.d.ts.map +0 -1
  52. package/dist/v1.d.ts.map +0 -1
  53. package/dist/v1.js.map +0 -7
  54. package/dist/version.d.ts.map +0 -1
  55. package/dist/win32-cmd.d.ts.map +0 -1
  56. package/dist/wrongstack-acp-agent.js.map +0 -7
@@ -55,7 +55,11 @@ export declare class TerminalServer {
55
55
  exitCode: number | null;
56
56
  signal: string | null;
57
57
  }>;
58
- /** Kill the process but keep the terminal record (agent can still read output). */
58
+ /**
59
+ * Kill the process but keep the terminal record (agent can still read output).
60
+ * On POSIX this signals only the direct child; descendants may survive because
61
+ * terminal processes are not spawned as process-group leaders.
62
+ */
59
63
  kill(terminalId: string): void;
60
64
  /** Kill the process if alive and remove the record. */
61
65
  release(terminalId: string): void;
@@ -36,6 +36,8 @@ export declare class WebSocketClientTransport implements ACPClientTransport {
36
36
  private readonly maxBufferedBytes;
37
37
  private readonly maxMessageChars;
38
38
  constructor(opts: WebSocketClientTransportOptions);
39
+ /** Pending start() promise resolve/reject — settled in stop() to avoid leaking. */
40
+ private pendingStart;
39
41
  start(): Promise<void>;
40
42
  send(msg: ACPMessage): Promise<void>;
41
43
  onMessage(handler: (msg: ACPMessage) => void): () => void;
package/dist/client.js CHANGED
@@ -3,32 +3,14 @@ import { expectDefined, writeErr } from "@wrongstack/core/utils";
3
3
  import { treeKill } from "@wrongstack/core/utils/tree-kill";
4
4
 
5
5
  // src/win32-cmd.ts
6
- var WIN32_CMD_META = /[&|<>"\r\n\0]/;
7
- function buildWin32CmdShimInvocation(command, args = []) {
8
- assertSafeWin32CmdArgs([command, ...args]);
9
- const line = ["call", quoteWin32CmdArg(command), ...args.map(quoteWin32CmdArg)].join(" ");
10
- return {
11
- command: process.env["COMSPEC"] ?? "cmd.exe",
12
- args: ["/d", "/c", line],
13
- windowsVerbatimArguments: true
14
- };
15
- }
16
- function assertSafeWin32CmdArgs(args) {
17
- for (const arg of args) {
18
- if (typeof arg === "string" && WIN32_CMD_META.test(arg)) {
19
- throw new Error(
20
- 'win32 cmd shim spawn: argument contains a shell metacharacter (one of & | < > ", or a newline) that could enable command injection through the .cmd/.bat wrapper - refusing to run. Offending argument: ' + JSON.stringify(arg)
21
- );
22
- }
23
- }
24
- }
25
- function quoteWin32CmdArg(arg) {
26
- return `"${arg}"`;
27
- }
6
+ import {
7
+ buildWin32CmdShimInvocation
8
+ } from "@wrongstack/core/utils";
28
9
 
29
10
  // src/agent/stdio-transport.ts
30
11
  var DEFAULT_MAX_FRAME_CHARS = 20 * 1024 * 1024;
31
12
  var DEFAULT_MAX_QUEUED_MESSAGES = 1e3;
13
+ var DEFAULT_MAX_QUEUED_CHARS = 32 * 1024 * 1024;
32
14
  function positiveLimit(value, fallback) {
33
15
  return value !== void 0 && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
34
16
  }
@@ -36,12 +18,15 @@ var ClientTransport = class {
36
18
  child = null;
37
19
  buffer = "";
38
20
  handlers = /* @__PURE__ */ new Set();
21
+ claimHandlers = /* @__PURE__ */ new Set();
39
22
  closed = false;
40
23
  resolveRead = null;
41
24
  messageQueue = [];
25
+ queuedChars = 0;
42
26
  opts;
43
27
  maxFrameChars;
44
28
  maxQueuedMessages;
29
+ maxQueuedChars;
45
30
  constructor(options) {
46
31
  this.opts = {
47
32
  handshakeTimeoutMs: 3e4,
@@ -49,6 +34,7 @@ var ClientTransport = class {
49
34
  };
50
35
  this.maxFrameChars = positiveLimit(options.maxFrameChars, DEFAULT_MAX_FRAME_CHARS);
51
36
  this.maxQueuedMessages = positiveLimit(options.maxQueuedMessages, DEFAULT_MAX_QUEUED_MESSAGES);
37
+ this.maxQueuedChars = positiveLimit(options.maxQueuedChars, DEFAULT_MAX_QUEUED_CHARS);
52
38
  }
53
39
  async start() {
54
40
  if (this.child) return;
@@ -59,6 +45,14 @@ var ClientTransport = class {
59
45
  ]);
60
46
  return new Promise((resolve3, reject) => {
61
47
  const timeout = setTimeout(() => {
48
+ const child2 = this.child;
49
+ this.child = null;
50
+ if (child2) {
51
+ try {
52
+ treeKill(child2);
53
+ } catch {
54
+ }
55
+ }
62
56
  reject(
63
57
  new Error(`ACP child process failed to start within ${this.opts.handshakeTimeoutMs}ms`)
64
58
  );
@@ -141,8 +135,11 @@ var ClientTransport = class {
141
135
  });
142
136
  }
143
137
  read() {
144
- if (this.messageQueue.length > 0)
145
- return Promise.resolve(expectDefined(this.messageQueue.shift()));
138
+ if (this.messageQueue.length > 0) {
139
+ const queued = expectDefined(this.messageQueue.shift());
140
+ this.queuedChars = Math.max(0, this.queuedChars - queued.chars);
141
+ return Promise.resolve(queued.message);
142
+ }
146
143
  if (this.closed) return Promise.resolve(null);
147
144
  return new Promise((resolve3) => {
148
145
  this.resolveRead = resolve3;
@@ -152,13 +149,29 @@ var ClientTransport = class {
152
149
  this.handlers.add(handler);
153
150
  return () => this.handlers.delete(handler);
154
151
  }
152
+ /**
153
+ * Register a handler that MAY claim a message by returning `true`.
154
+ * Claimed messages are NOT enqueued for the read() loop — they are
155
+ * considered fully consumed by the handler. This is how the ACP
156
+ * server transport prevents the correlation handler (which always
157
+ * fires on every message but only actually correlates responses)
158
+ * from starving the read() loop of pipelined requests, notifications,
159
+ * and any message the handler chose to ignore. See dispatch() for
160
+ * the gating logic.
161
+ */
162
+ onMessageClaim(handler) {
163
+ this.claimHandlers.add(handler);
164
+ return () => this.claimHandlers.delete(handler);
165
+ }
155
166
  stop() {
156
167
  this.closed = true;
157
168
  this.resolveRead?.(null);
158
169
  this.resolveRead = null;
159
170
  this.buffer = "";
160
171
  this.messageQueue.length = 0;
172
+ this.queuedChars = 0;
161
173
  this.handlers.clear();
174
+ this.claimHandlers.clear();
162
175
  const child = this.child;
163
176
  if (!child) return;
164
177
  treeKill(child);
@@ -183,7 +196,7 @@ var ClientTransport = class {
183
196
  return;
184
197
  }
185
198
  try {
186
- this.dispatch(JSON.parse(raw));
199
+ this.dispatch(JSON.parse(raw), raw.length);
187
200
  } catch {
188
201
  }
189
202
  }
@@ -193,29 +206,34 @@ var ClientTransport = class {
193
206
  }
194
207
  onChildClose(code) {
195
208
  this.closed = true;
209
+ this.child = null;
196
210
  this.resolveRead?.(null);
197
211
  this.resolveRead = null;
198
212
  this.buffer = "";
199
213
  this.messageQueue.length = 0;
214
+ this.queuedChars = 0;
200
215
  this.handlers.clear();
201
216
  if (code !== 0 && code !== null) {
202
217
  writeErr(`[acp-child exited with code ${code}]
203
218
  `);
204
219
  }
205
220
  }
206
- dispatch(msg) {
221
+ dispatch(msg, chars = JSON.stringify(msg).length) {
207
222
  if (this.resolveRead) {
208
223
  const resolve3 = this.resolveRead;
209
224
  this.resolveRead = null;
210
225
  resolve3(msg);
211
226
  } else if (this.handlers.size === 0) {
212
- if (this.messageQueue.length >= this.maxQueuedMessages) {
213
- writeErr(`[acp-child message queue exceeds ${this.maxQueuedMessages} entries]
214
- `);
227
+ if (this.messageQueue.length >= this.maxQueuedMessages || this.queuedChars + chars > this.maxQueuedChars) {
228
+ writeErr(
229
+ `[acp-child message queue exceeds ${this.maxQueuedMessages} entries or ${this.maxQueuedChars} characters]
230
+ `
231
+ );
215
232
  this.stop();
216
233
  return;
217
234
  }
218
- this.messageQueue.push(msg);
235
+ this.messageQueue.push({ message: msg, chars });
236
+ this.queuedChars += chars;
219
237
  }
220
238
  for (const handler of this.handlers) {
221
239
  try {
@@ -500,6 +518,7 @@ import { spawn } from "node:child_process";
500
518
  import { realpathSync as realpathSync2 } from "node:fs";
501
519
  import * as path2 from "node:path";
502
520
  import { buildChildEnv } from "@wrongstack/core/utils";
521
+ import { treeKill as treeKill2 } from "@wrongstack/core/utils/tree-kill";
503
522
  var EMPTY_BUFFER = Buffer.alloc(0);
504
523
  var TerminalServer = class {
505
524
  terminals = /* @__PURE__ */ new Map();
@@ -619,13 +638,11 @@ var TerminalServer = class {
619
638
  state.outputHead = 0;
620
639
  }
621
640
  };
641
+ state.onData = onData;
622
642
  proc.stdout?.on("data", onData);
623
643
  proc.stderr?.on("data", onData);
624
644
  state.timeoutHandle = setTimeout(() => {
625
- try {
626
- proc.kill("SIGTERM");
627
- } catch {
628
- }
645
+ treeKill2(proc);
629
646
  }, this.commandTimeoutMs);
630
647
  this.terminals.set(id, state);
631
648
  return { terminalId: id };
@@ -649,14 +666,15 @@ var TerminalServer = class {
649
666
  if (!state) throw new Error(`unknown terminal: ${terminalId}`);
650
667
  return state.exitPromise;
651
668
  }
652
- /** Kill the process but keep the terminal record (agent can still read output). */
669
+ /**
670
+ * Kill the process but keep the terminal record (agent can still read output).
671
+ * On POSIX this signals only the direct child; descendants may survive because
672
+ * terminal processes are not spawned as process-group leaders.
673
+ */
653
674
  kill(terminalId) {
654
675
  const state = this.terminals.get(terminalId);
655
676
  if (!state) throw new Error(`unknown terminal: ${terminalId}`);
656
- try {
657
- state.proc.kill("SIGTERM");
658
- } catch {
659
- }
677
+ treeKill2(state.proc);
660
678
  }
661
679
  /** Kill the process if alive and remove the record. */
662
680
  release(terminalId) {
@@ -666,10 +684,17 @@ var TerminalServer = class {
666
684
  clearTimeout(state.timeoutHandle);
667
685
  state.timeoutHandle = null;
668
686
  }
669
- try {
670
- state.proc.kill("SIGKILL");
671
- } catch {
672
- }
687
+ if (state.onData) {
688
+ state.proc.stdout?.off("data", state.onData);
689
+ state.proc.stderr?.off("data", state.onData);
690
+ state.onData = void 0;
691
+ }
692
+ state.proc.stdout?.destroy?.();
693
+ state.proc.stderr?.destroy?.();
694
+ state.outputChunks.length = 0;
695
+ state.outputHead = 0;
696
+ state.retainedBytes = 0;
697
+ treeKill2(state.proc, { force: true });
673
698
  this.terminals.delete(terminalId);
674
699
  }
675
700
  /** Kill all active terminals. Used on session close. */
@@ -832,7 +857,12 @@ var WebSocketClientTransport = class {
832
857
  this.maxBufferedBytes = finitePositiveLimit(opts.maxBufferedBytes, 32 * 1024 * 1024);
833
858
  this.maxMessageChars = finitePositiveLimit(opts.maxMessageChars, 20 * 1024 * 1024);
834
859
  }
860
+ /** Pending start() promise resolve/reject — settled in stop() to avoid leaking. */
861
+ pendingStart = null;
835
862
  start() {
863
+ if (this.closed || this.ws !== null || this.pendingStart !== null) {
864
+ return Promise.reject(new Error("WebSocket transport has already been started or stopped"));
865
+ }
836
866
  const WS = globalThis.WebSocket;
837
867
  if (!WS) {
838
868
  return Promise.reject(
@@ -843,35 +873,53 @@ var WebSocketClientTransport = class {
843
873
  }
844
874
  const timeoutMs = this.opts.handshakeTimeoutMs ?? 3e4;
845
875
  return new Promise((resolve3, reject) => {
846
- let settled = false;
847
876
  const ws = new WS(this.opts.url, this.opts.protocols);
848
877
  this.ws = ws;
849
878
  const timer = setTimeout(() => {
850
- settled = true;
879
+ const pending = this.pendingStart;
880
+ if (pending === null) return;
881
+ this.pendingStart = null;
882
+ this.closed = true;
883
+ if (this.ws === ws) this.ws = null;
884
+ this.handlers.clear();
851
885
  try {
852
886
  ws.close();
853
887
  } catch {
854
888
  }
855
- reject(new Error(`WebSocket failed to open within ${timeoutMs}ms`));
889
+ pending.reject(new Error(`WebSocket failed to open within ${timeoutMs}ms`));
856
890
  }, timeoutMs);
891
+ this.pendingStart = { resolve: resolve3, reject, timer };
857
892
  ws.addEventListener("open", () => {
858
- if (settled) return;
859
- settled = true;
860
- clearTimeout(timer);
861
- resolve3();
893
+ const pending = this.pendingStart;
894
+ if (pending === null) return;
895
+ this.pendingStart = null;
896
+ clearTimeout(pending.timer);
897
+ pending.resolve();
862
898
  });
863
899
  ws.addEventListener("error", (ev) => {
864
- if (settled) {
865
- this.closed = true;
900
+ const pending = this.pendingStart;
901
+ if (pending === null) {
902
+ this.stop();
866
903
  return;
867
904
  }
868
- settled = true;
869
- clearTimeout(timer);
905
+ this.pendingStart = null;
906
+ this.closed = true;
907
+ if (this.ws === ws) this.ws = null;
908
+ this.handlers.clear();
909
+ clearTimeout(pending.timer);
870
910
  const message = ev && typeof ev === "object" && "message" in ev ? String(ev.message) : "WebSocket error";
871
- reject(new Error(message));
911
+ pending.reject(new Error(message));
872
912
  });
873
913
  ws.addEventListener("close", () => {
874
914
  this.closed = true;
915
+ if (this.ws === ws) this.ws = null;
916
+ this.handlers.clear();
917
+ const pending = this.pendingStart;
918
+ if (pending !== null) {
919
+ this.pendingStart = null;
920
+ clearTimeout(pending.timer);
921
+ pending.reject(new Error("WebSocket closed before the connection opened"));
922
+ }
875
923
  });
876
924
  ws.addEventListener("message", (ev) => {
877
925
  this.onData(ev.data);
@@ -901,6 +949,16 @@ var WebSocketClientTransport = class {
901
949
  }
902
950
  stop() {
903
951
  this.closed = true;
952
+ this.handlers.clear();
953
+ if (this.pendingStart !== null) {
954
+ const pending = this.pendingStart;
955
+ this.pendingStart = null;
956
+ clearTimeout(pending.timer);
957
+ try {
958
+ pending.reject(new Error("WebSocket transport stopped while connecting"));
959
+ } catch {
960
+ }
961
+ }
904
962
  if (this.ws) {
905
963
  try {
906
964
  this.ws.close();
@@ -1313,7 +1371,15 @@ var ACPSession = class _ACPSession {
1313
1371
  if (opts.env !== void 0) transportOpts.env = opts.env;
1314
1372
  if (opts.cwd !== void 0) transportOpts.cwd = opts.cwd;
1315
1373
  const transport = new ClientTransport(transportOpts);
1316
- return _ACPSession.attach(opts, transport, `failed to spawn ${opts.command}`);
1374
+ try {
1375
+ return await _ACPSession.attach(opts, transport, `failed to spawn ${opts.command}`);
1376
+ } catch (err) {
1377
+ try {
1378
+ transport.stop();
1379
+ } catch {
1380
+ }
1381
+ throw err;
1382
+ }
1317
1383
  }
1318
1384
  /**
1319
1385
  * Connect to a REMOTE ACP agent over a WebSocket instead of spawning a