@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
package/dist/index.js CHANGED
@@ -1,3 +1,8 @@
1
+ // src/agent/protocol-handler.ts
2
+ import { randomUUID } from "node:crypto";
3
+ import * as fsp from "node:fs/promises";
4
+ import * as path from "node:path";
5
+
1
6
  // src/types/acp-v1.ts
2
7
  var ACP_PROTOCOL_VERSION = 1;
3
8
  function assertNeverSessionUpdate(x) {
@@ -92,12 +97,12 @@ var ACPProtocolHandler = class {
92
97
  */
93
98
  request(method, params, timeoutMs = 6e4) {
94
99
  const id = `srv_${this.nextOutId++}`;
95
- return new Promise((resolve3, reject) => {
100
+ return new Promise((resolve4, reject) => {
96
101
  const timer = setTimeout(() => {
97
102
  this.pendingOut.delete(id);
98
103
  reject(new Error(`${method} timed out after ${timeoutMs}ms`));
99
104
  }, timeoutMs);
100
- this.pendingOut.set(id, { resolve: resolve3, reject, timer });
105
+ this.pendingOut.set(id, { resolve: resolve4, reject, timer });
101
106
  this.transport.send(toWire({ jsonrpc: "2.0", id, method, params })).catch((e) => {
102
107
  clearTimeout(timer);
103
108
  this.pendingOut.delete(id);
@@ -214,62 +219,68 @@ var ACPProtocolHandler = class {
214
219
  this.clientCapabilities = p.clientCapabilities;
215
220
  }
216
221
  this.initialized = true;
217
- await this.transport.send(toWire({
218
- jsonrpc: "2.0",
219
- id,
220
- result: {
221
- protocolVersion: ACP_PROTOCOL_VERSION,
222
- agentCapabilities: {
223
- loadSession: true,
224
- promptCapabilities: {
225
- // We route ACP image blocks into the core agent's multimodal
226
- // input (server-agent-turn.promptToAgentInput); whether the
227
- // model can see them is the configured provider's concern.
228
- image: true,
229
- audio: false,
230
- embeddedContext: true
231
- },
232
- mcpCapabilities: {
233
- http: false,
234
- sse: false
222
+ await this.transport.send(
223
+ toWire({
224
+ jsonrpc: "2.0",
225
+ id,
226
+ result: {
227
+ protocolVersion: ACP_PROTOCOL_VERSION,
228
+ agentCapabilities: {
229
+ loadSession: true,
230
+ promptCapabilities: {
231
+ // We route ACP image blocks into the core agent's multimodal
232
+ // input (server-agent-turn.promptToAgentInput); whether the
233
+ // model can see them is the configured provider's concern.
234
+ image: true,
235
+ audio: false,
236
+ embeddedContext: true
237
+ },
238
+ mcpCapabilities: {
239
+ http: false,
240
+ sse: false
241
+ },
242
+ sessionCapabilities: {
243
+ close: {},
244
+ list: {},
245
+ delete: {},
246
+ resume: {},
247
+ fork: {}
248
+ },
249
+ auth: {
250
+ logout: {}
251
+ }
235
252
  },
236
- sessionCapabilities: {
237
- close: {},
238
- list: {},
239
- delete: {},
240
- resume: {},
241
- fork: {}
253
+ agentInfo: {
254
+ name: this.agentName,
255
+ title: "WrongStack",
256
+ version: WRONGSTACK_VERSION
242
257
  },
243
- auth: {
244
- logout: {}
245
- }
246
- },
247
- agentInfo: {
248
- name: this.agentName,
249
- title: "WrongStack",
250
- version: WRONGSTACK_VERSION
251
- },
252
- authMethods: WRONGSTACK_AUTH_METHODS,
253
- modes: this.modes,
254
- configOptions: this.configOptions
255
- }
256
- }));
258
+ authMethods: WRONGSTACK_AUTH_METHODS,
259
+ modes: this.modes,
260
+ configOptions: this.configOptions
261
+ }
262
+ })
263
+ );
257
264
  return false;
258
265
  }
259
266
  async handleAuthenticate(id, _params) {
260
- await this.transport.send(toWire({
261
- jsonrpc: "2.0",
262
- id,
263
- result: { outcome: "unauthenticated" }
264
- }));
267
+ await this.transport.send(
268
+ toWire({
269
+ jsonrpc: "2.0",
270
+ id,
271
+ result: { outcome: "unauthenticated" }
272
+ })
273
+ );
265
274
  return false;
266
275
  }
267
276
  async handleLogout(id, _params) {
268
- await this.transport.send(toWire({
269
- jsonrpc: "2.0",
270
- id,
271
- result: {}
272
- }));
277
+ await this.transport.send(
278
+ toWire({
279
+ jsonrpc: "2.0",
280
+ id,
281
+ result: {}
282
+ })
283
+ );
273
284
  return false;
274
285
  }
275
286
  async handleSessionNew(id, params) {
@@ -278,7 +289,15 @@ var ACPProtocolHandler = class {
278
289
  return false;
279
290
  }
280
291
  const p = params ?? {};
281
- const cwd = typeof p.cwd === "string" ? p.cwd : this.defaultCwd;
292
+ let cwd = this.defaultCwd;
293
+ if (typeof p.cwd === "string") {
294
+ const resolved = await this.resolveSessionCwd(p.cwd);
295
+ if (resolved === null) {
296
+ await this.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
297
+ return false;
298
+ }
299
+ cwd = resolved;
300
+ }
282
301
  const sessionId = `sess_${this.allocId()}`;
283
302
  const now = (/* @__PURE__ */ new Date()).toISOString();
284
303
  const state = {
@@ -308,15 +327,17 @@ var ACPProtocolHandler = class {
308
327
  }
309
328
  });
310
329
  }
311
- await this.transport.send(toWire({
312
- jsonrpc: "2.0",
313
- id,
314
- result: {
315
- sessionId,
316
- modes: this.modes,
317
- configOptions: this.configOptions
318
- }
319
- }));
330
+ await this.transport.send(
331
+ toWire({
332
+ jsonrpc: "2.0",
333
+ id,
334
+ result: {
335
+ sessionId,
336
+ modes: this.modes,
337
+ configOptions: this.configOptions
338
+ }
339
+ })
340
+ );
320
341
  return false;
321
342
  }
322
343
  async handleSessionLoad(id, params) {
@@ -331,9 +352,15 @@ var ACPProtocolHandler = class {
331
352
  await this.sendError(id, -32e3, `active session limit reached (${this.maxSessions})`);
332
353
  return false;
333
354
  }
355
+ if (loadCwd !== void 0 && await this.resolveSessionCwd(loadCwd) === null) {
356
+ await this.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
357
+ return false;
358
+ }
359
+ const candidateCwd = persisted.cwd ?? loadCwd ?? this.defaultCwd;
360
+ const restoredCwd = await this.resolveSessionCwd(candidateCwd) ?? this.defaultCwd;
334
361
  const restored = {
335
362
  id: sessionId,
336
- cwd: persisted.cwd ?? loadCwd ?? this.defaultCwd,
363
+ cwd: restoredCwd,
337
364
  abort: new AbortController(),
338
365
  modeId: persisted.modeId ?? DEFAULT_MODE_ID,
339
366
  createdAt: persisted.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
@@ -349,13 +376,15 @@ var ACPProtocolHandler = class {
349
376
  sessionId,
350
377
  update: { sessionUpdate: "current_mode_update", modeId: restored.modeId }
351
378
  });
352
- await this.transport.send(toWire({
353
- jsonrpc: "2.0",
354
- id,
355
- result: {
356
- initialMode: { currentModeId: restored.modeId, availableModes: this.modes }
357
- }
358
- }));
379
+ await this.transport.send(
380
+ toWire({
381
+ jsonrpc: "2.0",
382
+ id,
383
+ result: {
384
+ initialMode: { currentModeId: restored.modeId, availableModes: this.modes }
385
+ }
386
+ })
387
+ );
359
388
  return false;
360
389
  }
361
390
  }
@@ -381,16 +410,18 @@ var ACPProtocolHandler = class {
381
410
  modeId: existing.modeId
382
411
  }
383
412
  });
384
- await this.transport.send(toWire({
385
- jsonrpc: "2.0",
386
- id,
387
- result: {
388
- initialMode: {
389
- currentModeId: existing.modeId,
390
- availableModes: this.modes
413
+ await this.transport.send(
414
+ toWire({
415
+ jsonrpc: "2.0",
416
+ id,
417
+ result: {
418
+ initialMode: {
419
+ currentModeId: existing.modeId,
420
+ availableModes: this.modes
421
+ }
391
422
  }
392
- }
393
- }));
423
+ })
424
+ );
394
425
  return false;
395
426
  }
396
427
  await this.sendError(id, -32e3, `session not found: ${sessionId}`);
@@ -402,16 +433,18 @@ var ACPProtocolHandler = class {
402
433
  const existing = sessionId ? this.sessions.get(sessionId) : void 0;
403
434
  if (existing) {
404
435
  existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
405
- await this.transport.send(toWire({
406
- jsonrpc: "2.0",
407
- id,
408
- result: {
409
- initialMode: {
410
- currentModeId: existing.modeId,
411
- availableModes: this.modes
436
+ await this.transport.send(
437
+ toWire({
438
+ jsonrpc: "2.0",
439
+ id,
440
+ result: {
441
+ initialMode: {
442
+ currentModeId: existing.modeId,
443
+ availableModes: this.modes
444
+ }
412
445
  }
413
- }
414
- }));
446
+ })
447
+ );
415
448
  return false;
416
449
  }
417
450
  await this.sendError(id, -32e3, `session not found: ${sessionId}`);
@@ -428,11 +461,13 @@ var ACPProtocolHandler = class {
428
461
  session.abort.abort();
429
462
  this.sessions.delete(sessionId);
430
463
  this.disposeSession(sessionId);
431
- await this.transport.send(toWire({
432
- jsonrpc: "2.0",
433
- id,
434
- result: {}
435
- }));
464
+ await this.transport.send(
465
+ toWire({
466
+ jsonrpc: "2.0",
467
+ id,
468
+ result: {}
469
+ })
470
+ );
436
471
  return false;
437
472
  }
438
473
  async handleSessionDelete(id, params) {
@@ -443,18 +478,22 @@ var ACPProtocolHandler = class {
443
478
  return false;
444
479
  }
445
480
  if (!this.sessions.has(sessionId)) {
446
- await this.transport.send(toWire({ jsonrpc: "2.0", id, result: { configOptions: [...this.configOptions] } }));
481
+ await this.transport.send(
482
+ toWire({ jsonrpc: "2.0", id, result: { configOptions: [...this.configOptions] } })
483
+ );
447
484
  return false;
448
485
  }
449
486
  const session = this.sessions.get(sessionId);
450
487
  session.abort.abort();
451
488
  this.sessions.delete(sessionId);
452
489
  this.disposeSession(sessionId);
453
- await this.transport.send(toWire({
454
- jsonrpc: "2.0",
455
- id,
456
- result: {}
457
- }));
490
+ await this.transport.send(
491
+ toWire({
492
+ jsonrpc: "2.0",
493
+ id,
494
+ result: {}
495
+ })
496
+ );
458
497
  return false;
459
498
  }
460
499
  async handleSessionFork(id, params) {
@@ -469,11 +508,20 @@ var ACPProtocolHandler = class {
469
508
  await this.sendError(id, -32e3, `active session limit reached (${this.maxSessions})`);
470
509
  return false;
471
510
  }
511
+ let forkCwd = source.cwd;
512
+ if (typeof p.cwd === "string") {
513
+ const resolved = await this.resolveSessionCwd(p.cwd);
514
+ if (resolved === null) {
515
+ await this.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
516
+ return false;
517
+ }
518
+ forkCwd = resolved;
519
+ }
472
520
  const now = (/* @__PURE__ */ new Date()).toISOString();
473
521
  const sessionId = `sess_${this.allocId()}`;
474
522
  const forked = {
475
523
  id: sessionId,
476
- cwd: typeof p.cwd === "string" ? p.cwd : source.cwd,
524
+ cwd: forkCwd,
477
525
  abort: new AbortController(),
478
526
  modeId: source.modeId,
479
527
  createdAt: now,
@@ -492,38 +540,48 @@ var ACPProtocolHandler = class {
492
540
  sessionId,
493
541
  update: { sessionUpdate: "current_mode_update", modeId: forked.modeId }
494
542
  });
495
- await this.transport.send(toWire({
496
- jsonrpc: "2.0",
497
- id,
498
- result: {
499
- sessionId,
500
- modes: this.modes,
501
- configOptions: this.configOptions
502
- }
503
- }));
543
+ await this.transport.send(
544
+ toWire({
545
+ jsonrpc: "2.0",
546
+ id,
547
+ result: {
548
+ sessionId,
549
+ modes: this.modes,
550
+ configOptions: this.configOptions
551
+ }
552
+ })
553
+ );
504
554
  return false;
505
555
  }
506
556
  async handleProvidersList(id, _params) {
507
- await this.transport.send(toWire({
508
- jsonrpc: "2.0",
509
- id,
510
- result: {
511
- providers: [],
512
- currentProviderId: null
513
- }
514
- }));
557
+ await this.transport.send(
558
+ toWire({
559
+ jsonrpc: "2.0",
560
+ id,
561
+ result: {
562
+ providers: [],
563
+ currentProviderId: null
564
+ }
565
+ })
566
+ );
515
567
  return false;
516
568
  }
517
569
  async handleProvidersSet(id, _params) {
518
- await this.sendError(id, -32e3, "provider configuration not available through ACP; use wstack auth");
570
+ await this.sendError(
571
+ id,
572
+ -32e3,
573
+ "provider configuration not available through ACP; use wstack auth"
574
+ );
519
575
  return false;
520
576
  }
521
577
  async handleProvidersDisable(id, _params) {
522
- await this.transport.send(toWire({
523
- jsonrpc: "2.0",
524
- id,
525
- result: {}
526
- }));
578
+ await this.transport.send(
579
+ toWire({
580
+ jsonrpc: "2.0",
581
+ id,
582
+ result: {}
583
+ })
584
+ );
527
585
  return false;
528
586
  }
529
587
  async handleMcpMessage(id, _params) {
@@ -576,7 +634,10 @@ var ACPProtocolHandler = class {
576
634
  const terminalId = created?.terminalId;
577
635
  if (!terminalId) return { output: "", exitCode: null };
578
636
  try {
579
- const exit = await this.request("terminal/wait_for_exit", { sessionId, terminalId });
637
+ const exit = await this.request("terminal/wait_for_exit", {
638
+ sessionId,
639
+ terminalId
640
+ });
580
641
  const out = await this.request("terminal/output", { sessionId, terminalId });
581
642
  return {
582
643
  output: String(out?.output ?? ""),
@@ -606,11 +667,13 @@ var ACPProtocolHandler = class {
606
667
  session.abort.signal.removeEventListener("abort", onCancel);
607
668
  session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
608
669
  await this.persist(session);
609
- await this.transport.send(toWire({
610
- jsonrpc: "2.0",
611
- id,
612
- result: { stopReason: result.stopReason }
613
- }));
670
+ await this.transport.send(
671
+ toWire({
672
+ jsonrpc: "2.0",
673
+ id,
674
+ result: { stopReason: result.stopReason }
675
+ })
676
+ );
614
677
  return false;
615
678
  }
616
679
  async handleSetMode(id, params) {
@@ -651,7 +714,9 @@ var ACPProtocolHandler = class {
651
714
  configOptions: [...this.configOptions]
652
715
  }
653
716
  });
654
- await this.transport.send(toWire({ jsonrpc: "2.0", id, result: { configOptions: [...this.configOptions] } }));
717
+ await this.transport.send(
718
+ toWire({ jsonrpc: "2.0", id, result: { configOptions: [...this.configOptions] } })
719
+ );
655
720
  return false;
656
721
  }
657
722
  async handleSessionList(id) {
@@ -664,11 +729,13 @@ var ACPProtocolHandler = class {
664
729
  if (s.title !== void 0) out.title = s.title;
665
730
  return out;
666
731
  });
667
- await this.transport.send(toWire({
668
- jsonrpc: "2.0",
669
- id,
670
- result: { sessions }
671
- }));
732
+ await this.transport.send(
733
+ toWire({
734
+ jsonrpc: "2.0",
735
+ id,
736
+ result: { sessions }
737
+ })
738
+ );
672
739
  return false;
673
740
  }
674
741
  // ────────────────────────────────────────────────────────────────────
@@ -714,8 +781,54 @@ var ACPProtocolHandler = class {
714
781
  if (data !== void 0) error.data = data;
715
782
  await this.transport.send(toWire({ jsonrpc: "2.0", id, error }));
716
783
  }
784
+ /**
785
+ * Allocate a session id (WS-015).
786
+ *
787
+ * This was `this.nextId++`, so ids were `sess_1`, `sess_2`, … — and the
788
+ * handler has no per-connection ownership: any caller that names a session
789
+ * id can `session/load`, `session/prompt`, `session/cancel` or
790
+ * `session/delete` it. Over stdio that is academic (one client per process),
791
+ * but the agent also serves over HTTP, where a guessable id is the whole
792
+ * authorization story for any local process or page that reaches the port.
793
+ *
794
+ * Random ids do not create ownership — they remove the trivial enumeration
795
+ * that made its absence exploitable. Real per-connection ownership is the
796
+ * larger fix and is noted in the WS-015 test file.
797
+ *
798
+ * The counter is retained: it keeps ids ordered for debugging and guarantees
799
+ * uniqueness within a process even in the (impossible) event of a UUID
800
+ * collision. The random half is what makes the id unguessable.
801
+ */
802
+ /**
803
+ * Resolve a client-supplied `cwd` for a session, or `null` when it is not
804
+ * usable (WS-015).
805
+ *
806
+ * `session/new`, `session/load` and `session/fork` all took `params.cwd`
807
+ * with a single `typeof === 'string'` check and nothing else. That value is
808
+ * the working directory the agent then reads, writes and executes in.
809
+ *
810
+ * SCOPE, deliberately stated: this does NOT confine the session to a root.
811
+ * In ACP the client IS the editor and legitimately names its own workspace —
812
+ * Zed and JetBrains pass the project root — so a fixed boundary here would
813
+ * break the integration this package exists for. What it enforces is that
814
+ * the directory is absolute and actually exists as a directory: a relative
815
+ * or missing `cwd` is a bug or an attack under either reading, and silently
816
+ * running the agent somewhere other than where the client asked is worse
817
+ * than refusing. Confinement, if wanted, belongs in an operator-set option
818
+ * on top of this, not in place of it.
819
+ */
820
+ async resolveSessionCwd(requested) {
821
+ if (!path.isAbsolute(requested)) return null;
822
+ const resolved = path.resolve(requested);
823
+ try {
824
+ const stat3 = await fsp.stat(resolved);
825
+ return stat3.isDirectory() ? resolved : null;
826
+ } catch {
827
+ return null;
828
+ }
829
+ }
717
830
  allocId() {
718
- return this.nextId++;
831
+ return `${this.nextId++}_${randomUUID().replaceAll("-", "")}`;
719
832
  }
720
833
  };
721
834
  function errorToJsonRpc(err) {
@@ -739,32 +852,14 @@ import { expectDefined, writeErr } from "@wrongstack/core/utils";
739
852
  import { treeKill } from "@wrongstack/core/utils/tree-kill";
740
853
 
741
854
  // src/win32-cmd.ts
742
- var WIN32_CMD_META = /[&|<>"\r\n\0]/;
743
- function buildWin32CmdShimInvocation(command, args = []) {
744
- assertSafeWin32CmdArgs([command, ...args]);
745
- const line = ["call", quoteWin32CmdArg(command), ...args.map(quoteWin32CmdArg)].join(" ");
746
- return {
747
- command: process.env["COMSPEC"] ?? "cmd.exe",
748
- args: ["/d", "/c", line],
749
- windowsVerbatimArguments: true
750
- };
751
- }
752
- function assertSafeWin32CmdArgs(args) {
753
- for (const arg of args) {
754
- if (typeof arg === "string" && WIN32_CMD_META.test(arg)) {
755
- throw new Error(
756
- '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)
757
- );
758
- }
759
- }
760
- }
761
- function quoteWin32CmdArg(arg) {
762
- return `"${arg}"`;
763
- }
855
+ import {
856
+ buildWin32CmdShimInvocation
857
+ } from "@wrongstack/core/utils";
764
858
 
765
859
  // src/agent/stdio-transport.ts
766
860
  var DEFAULT_MAX_FRAME_CHARS = 20 * 1024 * 1024;
767
861
  var DEFAULT_MAX_QUEUED_MESSAGES = 1e3;
862
+ var DEFAULT_MAX_QUEUED_CHARS = 32 * 1024 * 1024;
768
863
  function positiveLimit(value, fallback) {
769
864
  return value !== void 0 && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
770
865
  }
@@ -774,53 +869,82 @@ var StdioTransport = class {
774
869
  stderr = process.stderr;
775
870
  buffer = "";
776
871
  handlers = /* @__PURE__ */ new Set();
872
+ claimHandlers = /* @__PURE__ */ new Set();
777
873
  closed = false;
778
874
  resolveRead = null;
779
875
  messageQueue = [];
876
+ queuedChars = 0;
780
877
  maxFrameChars;
781
878
  maxQueuedMessages;
879
+ maxQueuedChars;
880
+ onStdinData = (chunk) => this.onData(chunk);
881
+ onStdinEnd = () => this.handleClose();
882
+ onStdinError = (err) => this.failAll(err);
782
883
  constructor(opts = {}) {
783
884
  this.maxFrameChars = positiveLimit(opts.maxFrameChars, DEFAULT_MAX_FRAME_CHARS);
784
885
  this.maxQueuedMessages = positiveLimit(opts.maxQueuedMessages, DEFAULT_MAX_QUEUED_MESSAGES);
886
+ this.maxQueuedChars = positiveLimit(opts.maxQueuedChars, DEFAULT_MAX_QUEUED_CHARS);
785
887
  this.stdin.resume();
786
888
  this.stdin.setEncoding("utf8");
787
- this.stdin.on("data", (chunk) => this.onData(chunk));
788
- this.stdin.on("end", () => this.handleClose());
789
- this.stdin.on("error", (err) => this.failAll(err));
889
+ this.stdin.on("data", this.onStdinData);
890
+ this.stdin.on("end", this.onStdinEnd);
891
+ this.stdin.on("error", this.onStdinError);
790
892
  }
791
893
  sendStartupMarker() {
792
894
  this.stdout.write("[wstack-acp]\n", "utf8");
793
895
  }
794
896
  send(msg) {
795
897
  if (this.closed) return Promise.resolve();
796
- return new Promise((resolve3) => {
898
+ return new Promise((resolve4) => {
797
899
  const line = JSON.stringify(msg) + "\n";
798
- this.stdout.write(line, "utf8", () => resolve3());
900
+ this.stdout.write(line, "utf8", () => resolve4());
799
901
  });
800
902
  }
801
903
  sendRaw(chunk) {
802
904
  this.stdout.write(chunk, "utf8");
803
905
  }
804
906
  read() {
805
- if (this.messageQueue.length > 0)
806
- return Promise.resolve(expectDefined(this.messageQueue.shift()));
907
+ if (this.messageQueue.length > 0) {
908
+ const queued = expectDefined(this.messageQueue.shift());
909
+ this.queuedChars = Math.max(0, this.queuedChars - queued.chars);
910
+ return Promise.resolve(queued.message);
911
+ }
807
912
  if (this.closed) return Promise.resolve(null);
808
- return new Promise((resolve3) => {
809
- this.resolveRead = resolve3;
913
+ return new Promise((resolve4) => {
914
+ this.resolveRead = resolve4;
810
915
  });
811
916
  }
812
917
  onMessage(handler) {
813
918
  this.handlers.add(handler);
814
919
  return () => this.handlers.delete(handler);
815
920
  }
921
+ /**
922
+ * Register a handler that MAY claim a message by returning `true`.
923
+ * Claimed messages are NOT enqueued for the read() loop — they are
924
+ * considered fully consumed by the handler. This is how the ACP
925
+ * server transport prevents the correlation handler (which always
926
+ * fires on every message but only actually correlates responses)
927
+ * from starving the read() loop of pipelined requests, notifications,
928
+ * and any message the handler chose to ignore. See dispatch() for
929
+ * the gating logic.
930
+ */
931
+ onMessageClaim(handler) {
932
+ this.claimHandlers.add(handler);
933
+ return () => this.claimHandlers.delete(handler);
934
+ }
816
935
  close() {
817
936
  this.closed = true;
937
+ this.stdin.off("data", this.onStdinData);
938
+ this.stdin.off("end", this.onStdinEnd);
939
+ this.stdin.off("error", this.onStdinError);
818
940
  this.stdin.pause();
819
941
  this.resolveRead?.(null);
820
942
  this.resolveRead = null;
821
943
  this.buffer = "";
822
944
  this.messageQueue.length = 0;
945
+ this.queuedChars = 0;
823
946
  this.handlers.clear();
947
+ this.claimHandlers.clear();
824
948
  }
825
949
  onData(chunk) {
826
950
  this.buffer += chunk;
@@ -847,29 +971,41 @@ var StdioTransport = class {
847
971
  return;
848
972
  }
849
973
  try {
850
- this.dispatch(JSON.parse(raw));
974
+ this.dispatch(JSON.parse(raw), raw.length);
851
975
  } catch (err) {
852
976
  this.stderr.write(`[wstack-acp parse error] ${err}
853
977
  `, "utf8");
854
978
  }
855
979
  }
856
980
  }
857
- dispatch(msg) {
981
+ dispatch(msg, chars = JSON.stringify(msg).length) {
858
982
  if (this.resolveRead) {
859
- const resolve3 = this.resolveRead;
983
+ const resolve4 = this.resolveRead;
860
984
  this.resolveRead = null;
861
- resolve3(msg);
985
+ resolve4(msg);
862
986
  } else {
863
- if (this.messageQueue.length >= this.maxQueuedMessages) {
864
- this.stderr.write(
865
- `[wstack-acp queue error] pending message queue exceeds ${this.maxQueuedMessages} entries
987
+ let claimed = false;
988
+ for (const handler of this.claimHandlers) {
989
+ try {
990
+ if (handler(msg)) claimed = true;
991
+ } catch (err) {
992
+ this.stderr.write(`[wstack-acp handler error] ${err}
993
+ `, "utf8");
994
+ }
995
+ }
996
+ if (!claimed) {
997
+ if (this.messageQueue.length >= this.maxQueuedMessages || this.queuedChars + chars > this.maxQueuedChars) {
998
+ this.stderr.write(
999
+ `[wstack-acp queue error] pending message queue exceeds ${this.maxQueuedMessages} entries or ${this.maxQueuedChars} characters
866
1000
  `,
867
- "utf8"
868
- );
869
- this.close();
870
- return;
1001
+ "utf8"
1002
+ );
1003
+ this.close();
1004
+ return;
1005
+ }
1006
+ this.messageQueue.push({ message: msg, chars });
1007
+ this.queuedChars += chars;
871
1008
  }
872
- this.messageQueue.push(msg);
873
1009
  }
874
1010
  for (const handler of this.handlers) {
875
1011
  try {
@@ -893,12 +1029,15 @@ var ClientTransport = class {
893
1029
  child = null;
894
1030
  buffer = "";
895
1031
  handlers = /* @__PURE__ */ new Set();
1032
+ claimHandlers = /* @__PURE__ */ new Set();
896
1033
  closed = false;
897
1034
  resolveRead = null;
898
1035
  messageQueue = [];
1036
+ queuedChars = 0;
899
1037
  opts;
900
1038
  maxFrameChars;
901
1039
  maxQueuedMessages;
1040
+ maxQueuedChars;
902
1041
  constructor(options) {
903
1042
  this.opts = {
904
1043
  handshakeTimeoutMs: 3e4,
@@ -906,6 +1045,7 @@ var ClientTransport = class {
906
1045
  };
907
1046
  this.maxFrameChars = positiveLimit(options.maxFrameChars, DEFAULT_MAX_FRAME_CHARS);
908
1047
  this.maxQueuedMessages = positiveLimit(options.maxQueuedMessages, DEFAULT_MAX_QUEUED_MESSAGES);
1048
+ this.maxQueuedChars = positiveLimit(options.maxQueuedChars, DEFAULT_MAX_QUEUED_CHARS);
909
1049
  }
910
1050
  async start() {
911
1051
  if (this.child) return;
@@ -914,8 +1054,16 @@ var ClientTransport = class {
914
1054
  import("@wrongstack/core/utils"),
915
1055
  import("node:os")
916
1056
  ]);
917
- return new Promise((resolve3, reject) => {
1057
+ return new Promise((resolve4, reject) => {
918
1058
  const timeout = setTimeout(() => {
1059
+ const child2 = this.child;
1060
+ this.child = null;
1061
+ if (child2) {
1062
+ try {
1063
+ treeKill(child2);
1064
+ } catch {
1065
+ }
1066
+ }
919
1067
  reject(
920
1068
  new Error(`ACP child process failed to start within ${this.opts.handshakeTimeoutMs}ms`)
921
1069
  );
@@ -959,7 +1107,7 @@ var ClientTransport = class {
959
1107
  if (settled) return;
960
1108
  settled = true;
961
1109
  clearTimeout(timeout);
962
- resolve3();
1110
+ resolve4();
963
1111
  });
964
1112
  return;
965
1113
  }
@@ -970,7 +1118,7 @@ var ClientTransport = class {
970
1118
  child.stderr.on("data", (c) => this.onChildError(c));
971
1119
  child.on("close", (code) => this.onChildClose(code));
972
1120
  clearTimeout(timeout);
973
- resolve3();
1121
+ resolve4();
974
1122
  };
975
1123
  const waitForMarker = (chunk) => {
976
1124
  this.buffer += chunk;
@@ -989,33 +1137,52 @@ var ClientTransport = class {
989
1137
  }
990
1138
  send(msg) {
991
1139
  if (!this.child) return Promise.reject(new Error("ClientTransport not started"));
992
- return new Promise((resolve3, reject) => {
1140
+ return new Promise((resolve4, reject) => {
993
1141
  const line = JSON.stringify(msg) + "\n";
994
1142
  this.child?.stdin.write(line, "utf8", (err) => {
995
1143
  if (err) reject(err);
996
- else resolve3();
1144
+ else resolve4();
997
1145
  });
998
1146
  });
999
1147
  }
1000
1148
  read() {
1001
- if (this.messageQueue.length > 0)
1002
- return Promise.resolve(expectDefined(this.messageQueue.shift()));
1149
+ if (this.messageQueue.length > 0) {
1150
+ const queued = expectDefined(this.messageQueue.shift());
1151
+ this.queuedChars = Math.max(0, this.queuedChars - queued.chars);
1152
+ return Promise.resolve(queued.message);
1153
+ }
1003
1154
  if (this.closed) return Promise.resolve(null);
1004
- return new Promise((resolve3) => {
1005
- this.resolveRead = resolve3;
1155
+ return new Promise((resolve4) => {
1156
+ this.resolveRead = resolve4;
1006
1157
  });
1007
1158
  }
1008
1159
  onMessage(handler) {
1009
1160
  this.handlers.add(handler);
1010
1161
  return () => this.handlers.delete(handler);
1011
1162
  }
1163
+ /**
1164
+ * Register a handler that MAY claim a message by returning `true`.
1165
+ * Claimed messages are NOT enqueued for the read() loop — they are
1166
+ * considered fully consumed by the handler. This is how the ACP
1167
+ * server transport prevents the correlation handler (which always
1168
+ * fires on every message but only actually correlates responses)
1169
+ * from starving the read() loop of pipelined requests, notifications,
1170
+ * and any message the handler chose to ignore. See dispatch() for
1171
+ * the gating logic.
1172
+ */
1173
+ onMessageClaim(handler) {
1174
+ this.claimHandlers.add(handler);
1175
+ return () => this.claimHandlers.delete(handler);
1176
+ }
1012
1177
  stop() {
1013
1178
  this.closed = true;
1014
1179
  this.resolveRead?.(null);
1015
1180
  this.resolveRead = null;
1016
1181
  this.buffer = "";
1017
1182
  this.messageQueue.length = 0;
1183
+ this.queuedChars = 0;
1018
1184
  this.handlers.clear();
1185
+ this.claimHandlers.clear();
1019
1186
  const child = this.child;
1020
1187
  if (!child) return;
1021
1188
  treeKill(child);
@@ -1040,7 +1207,7 @@ var ClientTransport = class {
1040
1207
  return;
1041
1208
  }
1042
1209
  try {
1043
- this.dispatch(JSON.parse(raw));
1210
+ this.dispatch(JSON.parse(raw), raw.length);
1044
1211
  } catch {
1045
1212
  }
1046
1213
  }
@@ -1050,29 +1217,34 @@ var ClientTransport = class {
1050
1217
  }
1051
1218
  onChildClose(code) {
1052
1219
  this.closed = true;
1220
+ this.child = null;
1053
1221
  this.resolveRead?.(null);
1054
1222
  this.resolveRead = null;
1055
1223
  this.buffer = "";
1056
1224
  this.messageQueue.length = 0;
1225
+ this.queuedChars = 0;
1057
1226
  this.handlers.clear();
1058
1227
  if (code !== 0 && code !== null) {
1059
1228
  writeErr(`[acp-child exited with code ${code}]
1060
1229
  `);
1061
1230
  }
1062
1231
  }
1063
- dispatch(msg) {
1232
+ dispatch(msg, chars = JSON.stringify(msg).length) {
1064
1233
  if (this.resolveRead) {
1065
- const resolve3 = this.resolveRead;
1234
+ const resolve4 = this.resolveRead;
1066
1235
  this.resolveRead = null;
1067
- resolve3(msg);
1236
+ resolve4(msg);
1068
1237
  } else if (this.handlers.size === 0) {
1069
- if (this.messageQueue.length >= this.maxQueuedMessages) {
1070
- writeErr(`[acp-child message queue exceeds ${this.maxQueuedMessages} entries]
1071
- `);
1238
+ if (this.messageQueue.length >= this.maxQueuedMessages || this.queuedChars + chars > this.maxQueuedChars) {
1239
+ writeErr(
1240
+ `[acp-child message queue exceeds ${this.maxQueuedMessages} entries or ${this.maxQueuedChars} characters]
1241
+ `
1242
+ );
1072
1243
  this.stop();
1073
1244
  return;
1074
1245
  }
1075
- this.messageQueue.push(msg);
1246
+ this.messageQueue.push({ message: msg, chars });
1247
+ this.queuedChars += chars;
1076
1248
  }
1077
1249
  for (const handler of this.handlers) {
1078
1250
  try {
@@ -1366,12 +1538,12 @@ var WrongStackACPServer = class {
1366
1538
  res.end(JSON.stringify({ error: { code: -32603, message: "Internal error" } }));
1367
1539
  }
1368
1540
  });
1369
- return new Promise((resolve3) => {
1541
+ return new Promise((resolve4) => {
1370
1542
  this.httpServer.listen(port, host, () => {
1371
1543
  writeErr2(`[wstack-acp] HTTP server listening on http://${host}:${port}
1372
1544
  `);
1373
1545
  this.running = true;
1374
- resolve3();
1546
+ resolve4();
1375
1547
  });
1376
1548
  });
1377
1549
  }
@@ -1454,26 +1626,26 @@ function emptyRunResult(stopReason) {
1454
1626
  // src/client/file-server.ts
1455
1627
  import { randomBytes } from "node:crypto";
1456
1628
  import { realpathSync } from "node:fs";
1457
- import * as fsp from "node:fs/promises";
1458
- import * as path from "node:path";
1629
+ import * as fsp2 from "node:fs/promises";
1630
+ import * as path2 from "node:path";
1459
1631
  var DEFAULT_FILE_OPERATIONS = {
1460
- stat: fsp.stat,
1461
- readFile: fsp.readFile,
1462
- writeFile: fsp.writeFile,
1463
- realpath: fsp.realpath,
1464
- rename: fsp.rename,
1465
- unlink: fsp.unlink
1632
+ stat: fsp2.stat,
1633
+ readFile: fsp2.readFile,
1634
+ writeFile: fsp2.writeFile,
1635
+ realpath: fsp2.realpath,
1636
+ rename: fsp2.rename,
1637
+ unlink: fsp2.unlink
1466
1638
  };
1467
1639
  var DEFAULT_MAX_READ_BYTES = 5 * 1024 * 1024;
1468
1640
  var DEFAULT_MAX_WRITE_BYTES = 5 * 1024 * 1024;
1469
1641
  var FsError = class extends Error {
1470
1642
  code;
1471
1643
  path;
1472
- constructor(code, path4, message) {
1644
+ constructor(code, path5, message) {
1473
1645
  super(message);
1474
1646
  this.name = "FsError";
1475
1647
  this.code = code;
1476
- this.path = path4;
1648
+ this.path = path5;
1477
1649
  }
1478
1650
  };
1479
1651
  var FileServer = class {
@@ -1484,7 +1656,7 @@ var FileServer = class {
1484
1656
  maxWriteBytes;
1485
1657
  operations;
1486
1658
  constructor(opts) {
1487
- this.root = path.resolve(opts.projectRoot);
1659
+ this.root = path2.resolve(opts.projectRoot);
1488
1660
  this.realRoot = safeRealpathSync(this.root);
1489
1661
  this.timeoutMs = opts.timeoutMs ?? 3e4;
1490
1662
  this.maxReadBytes = opts.maxReadBytes ?? DEFAULT_MAX_READ_BYTES;
@@ -1497,14 +1669,14 @@ var FileServer = class {
1497
1669
  const controller = new AbortController();
1498
1670
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
1499
1671
  try {
1500
- const stat2 = await this.operations.stat(safe).catch((err) => {
1672
+ const stat3 = await this.operations.stat(safe).catch((err) => {
1501
1673
  throw mapFsError(err, safe);
1502
1674
  });
1503
- if (stat2.size > this.maxReadBytes) {
1675
+ if (stat3.size > this.maxReadBytes) {
1504
1676
  throw new FsError(
1505
1677
  "TOO_LARGE",
1506
1678
  safe,
1507
- `file is ${stat2.size} bytes, max read is ${this.maxReadBytes} bytes`
1679
+ `file is ${stat3.size} bytes, max read is ${this.maxReadBytes} bytes`
1508
1680
  );
1509
1681
  }
1510
1682
  const content = await this.operations.readFile(safe, {
@@ -1542,7 +1714,7 @@ var FileServer = class {
1542
1714
  signal: controller.signal
1543
1715
  });
1544
1716
  await this.assertRealInside(tmp);
1545
- await this.assertRealInside(path.dirname(safe));
1717
+ await this.assertRealInside(path2.dirname(safe));
1546
1718
  await this.operations.rename(tmp, safe);
1547
1719
  } catch (err) {
1548
1720
  if (err instanceof FsError) {
@@ -1573,11 +1745,11 @@ var FileServer = class {
1573
1745
  if (typeof p !== "string" || p.length === 0) {
1574
1746
  throw new FsError("INVALID_PATH", p, "path is empty or not a string");
1575
1747
  }
1576
- if (!path.isAbsolute(p)) {
1748
+ if (!path2.isAbsolute(p)) {
1577
1749
  throw new FsError("INVALID_PATH", p, "path must be absolute (ACP requirement)");
1578
1750
  }
1579
- const resolved = path.resolve(p);
1580
- const rootWithSep = this.root.endsWith(path.sep) ? this.root : this.root + path.sep;
1751
+ const resolved = path2.resolve(p);
1752
+ const rootWithSep = this.root.endsWith(path2.sep) ? this.root : this.root + path2.sep;
1581
1753
  if (resolved !== this.root && !resolved.startsWith(rootWithSep)) {
1582
1754
  throw new FsError("OUTSIDE_ROOT", resolved, "path is outside the project root");
1583
1755
  }
@@ -1598,7 +1770,7 @@ var FileServer = class {
1598
1770
  } catch (err) {
1599
1771
  const code = err.code;
1600
1772
  if (code === "ENOENT") {
1601
- const parent = path.dirname(probe);
1773
+ const parent = path2.dirname(probe);
1602
1774
  if (parent === probe) {
1603
1775
  throw new FsError("ENOENT", resolvedPath, `no existing ancestor: ${resolvedPath}`);
1604
1776
  }
@@ -1607,7 +1779,7 @@ var FileServer = class {
1607
1779
  }
1608
1780
  throw mapFsError(err, resolvedPath);
1609
1781
  }
1610
- if (real === this.realRoot || real.startsWith(this.realRoot + path.sep)) return;
1782
+ if (real === this.realRoot || real.startsWith(this.realRoot + path2.sep)) return;
1611
1783
  throw new FsError(
1612
1784
  "OUTSIDE_ROOT",
1613
1785
  resolvedPath,
@@ -1680,8 +1852,9 @@ function makePermissionPolicy(decide) {
1680
1852
  // src/client/terminal-server.ts
1681
1853
  import { spawn } from "node:child_process";
1682
1854
  import { realpathSync as realpathSync2 } from "node:fs";
1683
- import * as path2 from "node:path";
1855
+ import * as path3 from "node:path";
1684
1856
  import { buildChildEnv } from "@wrongstack/core/utils";
1857
+ import { treeKill as treeKill2 } from "@wrongstack/core/utils/tree-kill";
1685
1858
  var EMPTY_BUFFER = Buffer.alloc(0);
1686
1859
  var TerminalServer = class {
1687
1860
  terminals = /* @__PURE__ */ new Map();
@@ -1694,7 +1867,7 @@ var TerminalServer = class {
1694
1867
  abortHandler = () => this.releaseAll();
1695
1868
  nextId = 1;
1696
1869
  constructor(opts) {
1697
- this.projectRoot = path2.resolve(opts.projectRoot);
1870
+ this.projectRoot = path3.resolve(opts.projectRoot);
1698
1871
  this.commandTimeoutMs = opts.commandTimeoutMs ?? 5 * 6e4;
1699
1872
  this.outputByteLimit = opts.outputByteLimit ?? 1024 * 1024;
1700
1873
  this.maxOutputByteLimit = opts.maxOutputByteLimit ?? 16 * 1024 * 1024;
@@ -1741,7 +1914,7 @@ var TerminalServer = class {
1741
1914
  truncated: false,
1742
1915
  exitStatus: void 0,
1743
1916
  timeoutHandle: null,
1744
- exitPromise: new Promise((resolve3) => {
1917
+ exitPromise: new Promise((resolve4) => {
1745
1918
  proc.on("close", (code, signalName) => {
1746
1919
  if (state.timeoutHandle) {
1747
1920
  clearTimeout(state.timeoutHandle);
@@ -1752,7 +1925,7 @@ var TerminalServer = class {
1752
1925
  signal: typeof signalName === "string" ? signalName : null
1753
1926
  };
1754
1927
  state.exitStatus = exitStatus;
1755
- resolve3(exitStatus);
1928
+ resolve4(exitStatus);
1756
1929
  });
1757
1930
  proc.on("error", (err) => {
1758
1931
  if (state.timeoutHandle) {
@@ -1771,7 +1944,7 @@ var TerminalServer = class {
1771
1944
  }
1772
1945
  state.outputChunks.push(errorOutput);
1773
1946
  state.retainedBytes = errorOutput.length;
1774
- resolve3(exitStatus);
1947
+ resolve4(exitStatus);
1775
1948
  });
1776
1949
  })
1777
1950
  };
@@ -1801,13 +1974,11 @@ var TerminalServer = class {
1801
1974
  state.outputHead = 0;
1802
1975
  }
1803
1976
  };
1977
+ state.onData = onData;
1804
1978
  proc.stdout?.on("data", onData);
1805
1979
  proc.stderr?.on("data", onData);
1806
1980
  state.timeoutHandle = setTimeout(() => {
1807
- try {
1808
- proc.kill("SIGTERM");
1809
- } catch {
1810
- }
1981
+ treeKill2(proc);
1811
1982
  }, this.commandTimeoutMs);
1812
1983
  this.terminals.set(id, state);
1813
1984
  return { terminalId: id };
@@ -1831,14 +2002,15 @@ var TerminalServer = class {
1831
2002
  if (!state) throw new Error(`unknown terminal: ${terminalId}`);
1832
2003
  return state.exitPromise;
1833
2004
  }
1834
- /** Kill the process but keep the terminal record (agent can still read output). */
2005
+ /**
2006
+ * Kill the process but keep the terminal record (agent can still read output).
2007
+ * On POSIX this signals only the direct child; descendants may survive because
2008
+ * terminal processes are not spawned as process-group leaders.
2009
+ */
1835
2010
  kill(terminalId) {
1836
2011
  const state = this.terminals.get(terminalId);
1837
2012
  if (!state) throw new Error(`unknown terminal: ${terminalId}`);
1838
- try {
1839
- state.proc.kill("SIGTERM");
1840
- } catch {
1841
- }
2013
+ treeKill2(state.proc);
1842
2014
  }
1843
2015
  /** Kill the process if alive and remove the record. */
1844
2016
  release(terminalId) {
@@ -1848,10 +2020,17 @@ var TerminalServer = class {
1848
2020
  clearTimeout(state.timeoutHandle);
1849
2021
  state.timeoutHandle = null;
1850
2022
  }
1851
- try {
1852
- state.proc.kill("SIGKILL");
1853
- } catch {
1854
- }
2023
+ if (state.onData) {
2024
+ state.proc.stdout?.off("data", state.onData);
2025
+ state.proc.stderr?.off("data", state.onData);
2026
+ state.onData = void 0;
2027
+ }
2028
+ state.proc.stdout?.destroy?.();
2029
+ state.proc.stderr?.destroy?.();
2030
+ state.outputChunks.length = 0;
2031
+ state.outputHead = 0;
2032
+ state.retainedBytes = 0;
2033
+ treeKill2(state.proc, { force: true });
1855
2034
  this.terminals.delete(terminalId);
1856
2035
  }
1857
2036
  /** Kill all active terminals. Used on session close. */
@@ -1863,15 +2042,15 @@ var TerminalServer = class {
1863
2042
  }
1864
2043
  resolveCwd(cwd) {
1865
2044
  if (!cwd) return this.projectRoot;
1866
- const resolved = path2.resolve(cwd);
1867
- const rootWithSep = this.projectRoot.endsWith(path2.sep) ? this.projectRoot : this.projectRoot + path2.sep;
2045
+ const resolved = path3.resolve(cwd);
2046
+ const rootWithSep = this.projectRoot.endsWith(path3.sep) ? this.projectRoot : this.projectRoot + path3.sep;
1868
2047
  if (resolved !== this.projectRoot && !resolved.startsWith(rootWithSep)) {
1869
2048
  return this.projectRoot;
1870
2049
  }
1871
2050
  try {
1872
2051
  const realRoot = realpathSync2(this.projectRoot);
1873
2052
  const realCwd = realpathSync2(resolved);
1874
- const realRootWithSep = realRoot.endsWith(path2.sep) ? realRoot : realRoot + path2.sep;
2053
+ const realRootWithSep = realRoot.endsWith(path3.sep) ? realRoot : realRoot + path3.sep;
1875
2054
  if (realCwd !== realRoot && !realCwd.startsWith(realRootWithSep)) {
1876
2055
  return realRoot;
1877
2056
  }
@@ -2014,7 +2193,12 @@ var WebSocketClientTransport = class {
2014
2193
  this.maxBufferedBytes = finitePositiveLimit(opts.maxBufferedBytes, 32 * 1024 * 1024);
2015
2194
  this.maxMessageChars = finitePositiveLimit(opts.maxMessageChars, 20 * 1024 * 1024);
2016
2195
  }
2196
+ /** Pending start() promise resolve/reject — settled in stop() to avoid leaking. */
2197
+ pendingStart = null;
2017
2198
  start() {
2199
+ if (this.closed || this.ws !== null || this.pendingStart !== null) {
2200
+ return Promise.reject(new Error("WebSocket transport has already been started or stopped"));
2201
+ }
2018
2202
  const WS = globalThis.WebSocket;
2019
2203
  if (!WS) {
2020
2204
  return Promise.reject(
@@ -2024,36 +2208,54 @@ var WebSocketClientTransport = class {
2024
2208
  );
2025
2209
  }
2026
2210
  const timeoutMs = this.opts.handshakeTimeoutMs ?? 3e4;
2027
- return new Promise((resolve3, reject) => {
2028
- let settled = false;
2211
+ return new Promise((resolve4, reject) => {
2029
2212
  const ws = new WS(this.opts.url, this.opts.protocols);
2030
2213
  this.ws = ws;
2031
2214
  const timer = setTimeout(() => {
2032
- settled = true;
2215
+ const pending = this.pendingStart;
2216
+ if (pending === null) return;
2217
+ this.pendingStart = null;
2218
+ this.closed = true;
2219
+ if (this.ws === ws) this.ws = null;
2220
+ this.handlers.clear();
2033
2221
  try {
2034
2222
  ws.close();
2035
2223
  } catch {
2036
2224
  }
2037
- reject(new Error(`WebSocket failed to open within ${timeoutMs}ms`));
2225
+ pending.reject(new Error(`WebSocket failed to open within ${timeoutMs}ms`));
2038
2226
  }, timeoutMs);
2227
+ this.pendingStart = { resolve: resolve4, reject, timer };
2039
2228
  ws.addEventListener("open", () => {
2040
- if (settled) return;
2041
- settled = true;
2042
- clearTimeout(timer);
2043
- resolve3();
2229
+ const pending = this.pendingStart;
2230
+ if (pending === null) return;
2231
+ this.pendingStart = null;
2232
+ clearTimeout(pending.timer);
2233
+ pending.resolve();
2044
2234
  });
2045
2235
  ws.addEventListener("error", (ev) => {
2046
- if (settled) {
2047
- this.closed = true;
2236
+ const pending = this.pendingStart;
2237
+ if (pending === null) {
2238
+ this.stop();
2048
2239
  return;
2049
2240
  }
2050
- settled = true;
2051
- clearTimeout(timer);
2241
+ this.pendingStart = null;
2242
+ this.closed = true;
2243
+ if (this.ws === ws) this.ws = null;
2244
+ this.handlers.clear();
2245
+ clearTimeout(pending.timer);
2052
2246
  const message = ev && typeof ev === "object" && "message" in ev ? String(ev.message) : "WebSocket error";
2053
- reject(new Error(message));
2247
+ pending.reject(new Error(message));
2054
2248
  });
2055
2249
  ws.addEventListener("close", () => {
2056
2250
  this.closed = true;
2251
+ if (this.ws === ws) this.ws = null;
2252
+ this.handlers.clear();
2253
+ const pending = this.pendingStart;
2254
+ if (pending !== null) {
2255
+ this.pendingStart = null;
2256
+ clearTimeout(pending.timer);
2257
+ pending.reject(new Error("WebSocket closed before the connection opened"));
2258
+ }
2057
2259
  });
2058
2260
  ws.addEventListener("message", (ev) => {
2059
2261
  this.onData(ev.data);
@@ -2083,6 +2285,16 @@ var WebSocketClientTransport = class {
2083
2285
  }
2084
2286
  stop() {
2085
2287
  this.closed = true;
2288
+ this.handlers.clear();
2289
+ if (this.pendingStart !== null) {
2290
+ const pending = this.pendingStart;
2291
+ this.pendingStart = null;
2292
+ clearTimeout(pending.timer);
2293
+ try {
2294
+ pending.reject(new Error("WebSocket transport stopped while connecting"));
2295
+ } catch {
2296
+ }
2297
+ }
2086
2298
  if (this.ws) {
2087
2299
  try {
2088
2300
  this.ws.close();
@@ -2495,7 +2707,15 @@ var ACPSession = class _ACPSession {
2495
2707
  if (opts.env !== void 0) transportOpts.env = opts.env;
2496
2708
  if (opts.cwd !== void 0) transportOpts.cwd = opts.cwd;
2497
2709
  const transport = new ClientTransport(transportOpts);
2498
- return _ACPSession.attach(opts, transport, `failed to spawn ${opts.command}`);
2710
+ try {
2711
+ return await _ACPSession.attach(opts, transport, `failed to spawn ${opts.command}`);
2712
+ } catch (err) {
2713
+ try {
2714
+ transport.stop();
2715
+ } catch {
2716
+ }
2717
+ throw err;
2718
+ }
2499
2719
  }
2500
2720
  /**
2501
2721
  * Connect to a REMOTE ACP agent over a WebSocket instead of spawning a
@@ -3046,7 +3266,7 @@ var ACPSession = class _ACPSession {
3046
3266
  return this.nextId++;
3047
3267
  }
3048
3268
  async sendRequest(id, method, params, timeoutMs) {
3049
- return new Promise((resolve3, reject) => {
3269
+ return new Promise((resolve4, reject) => {
3050
3270
  const effectiveTimeout = timeoutMs ?? this.timeoutMs;
3051
3271
  const handle = setTimeout(() => {
3052
3272
  this.pending.delete(id);
@@ -3056,7 +3276,7 @@ var ACPSession = class _ACPSession {
3056
3276
  }, effectiveTimeout);
3057
3277
  this.pending.set(id, {
3058
3278
  method,
3059
- resolve: resolve3,
3279
+ resolve: resolve4,
3060
3280
  reject,
3061
3281
  timeoutMs: effectiveTimeout,
3062
3282
  timeoutHandle: handle
@@ -3222,12 +3442,12 @@ var ToolTranslator = class {
3222
3442
  id: callId,
3223
3443
  params: { name, arguments: args }
3224
3444
  });
3225
- return new Promise((resolve3, reject) => {
3445
+ return new Promise((resolve4, reject) => {
3226
3446
  const timeout = setTimeout(() => {
3227
3447
  this.pending.delete(callId);
3228
3448
  reject(new Error(`Tool call ${name} timed out after ${this.opts.totalTimeoutMs}ms`));
3229
3449
  }, this.opts.totalTimeoutMs);
3230
- this.pending.set(callId, { resolve: resolve3, reject, timeout });
3450
+ this.pending.set(callId, { resolve: resolve4, reject, timeout });
3231
3451
  });
3232
3452
  }
3233
3453
  cancelAll() {
@@ -3239,8 +3459,8 @@ var ToolTranslator = class {
3239
3459
  };
3240
3460
 
3241
3461
  // src/integration/acp-bench.ts
3242
- import * as fsp2 from "node:fs/promises";
3243
- import * as path3 from "node:path";
3462
+ import * as fsp3 from "node:fs/promises";
3463
+ import * as path4 from "node:path";
3244
3464
  function firstLine(s) {
3245
3465
  const line = s.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
3246
3466
  return line.length > 120 ? `${line.slice(0, 117)}\u2026` : line;
@@ -3307,11 +3527,11 @@ async function benchOne(agentId, cmd, opts) {
3307
3527
  if (opts.checkFs) {
3308
3528
  const fileToken = `FILE_${opts.marker}`;
3309
3529
  const fileName = `acp-bench-${opts.marker}.txt`;
3310
- const filePath = path3.join(opts.projectRoot, fileName);
3530
+ const filePath = path4.join(opts.projectRoot, fileName);
3311
3531
  let fsOk = false;
3312
3532
  let fsDetail;
3313
3533
  try {
3314
- await fsp2.writeFile(filePath, fileToken, "utf8");
3534
+ await fsp3.writeFile(filePath, fileToken, "utf8");
3315
3535
  const fsRes = await session.prompt(
3316
3536
  [
3317
3537
  textContent(
@@ -3447,7 +3667,7 @@ function renderAcpBenchText(result) {
3447
3667
  );
3448
3668
  return lines.join("\n");
3449
3669
  }
3450
- async function removeBenchFile(filePath, remove = fsp2.rm) {
3670
+ async function removeBenchFile(filePath, remove = fsp3.rm) {
3451
3671
  await remove(filePath, { force: true }).catch(() => void 0);
3452
3672
  }
3453
3673
 
@@ -3987,7 +4207,7 @@ import { SubagentBudget } from "@wrongstack/core/coordination";
3987
4207
 
3988
4208
  // src/registry/ensemble-registry.ts
3989
4209
  import { spawn as spawn2 } from "node:child_process";
3990
- import { treeKill as treeKill2 } from "@wrongstack/core/utils/tree-kill";
4210
+ import { treeKill as treeKill3 } from "@wrongstack/core/utils/tree-kill";
3991
4211
  var PROBE_TIMEOUT_MS = 5e3;
3992
4212
  var PROBE_CACHE_MS = 5e3;
3993
4213
  var MAX_PARALLEL_PROBES = 4;
@@ -4013,7 +4233,7 @@ async function probeWithBound(items, worker, limit) {
4013
4233
  }
4014
4234
  async function defaultProbe(desc, timeoutMs, spawnProcess = spawn2, platform = process.platform) {
4015
4235
  const start = Date.now();
4016
- return new Promise((resolve3) => {
4236
+ return new Promise((resolve4) => {
4017
4237
  let settled = false;
4018
4238
  let stdout = "";
4019
4239
  let stderr = "";
@@ -4022,11 +4242,11 @@ async function defaultProbe(desc, timeoutMs, spawnProcess = spawn2, platform = p
4022
4242
  settled = true;
4023
4243
  try {
4024
4244
  if (child.exitCode === null && child.signalCode === null) {
4025
- treeKill2(child);
4245
+ treeKill3(child);
4026
4246
  }
4027
4247
  } catch {
4028
4248
  }
4029
- resolve3(result);
4249
+ resolve4(result);
4030
4250
  };
4031
4251
  let child;
4032
4252
  try {