@wrongstack/acp 0.297.0 → 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 (55) 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.js +70 -44
  7. package/dist/index.js +433 -253
  8. package/dist/win32-cmd.d.ts +9 -6
  9. package/dist/wrongstack-acp-agent.js +312 -157
  10. package/package.json +4 -3
  11. package/dist/agent/index.d.ts.map +0 -1
  12. package/dist/agent/protocol-contract.d.ts.map +0 -1
  13. package/dist/agent/protocol-handler.d.ts.map +0 -1
  14. package/dist/agent/server-agent-turn.d.ts.map +0 -1
  15. package/dist/agent/session-store.d.ts.map +0 -1
  16. package/dist/agent/stdio-transport.d.ts.map +0 -1
  17. package/dist/agent/tools-registry.d.ts.map +0 -1
  18. package/dist/agent/wrongstack-acp-agent.d.ts.map +0 -1
  19. package/dist/agent/ws-bridge-transport.d.ts.map +0 -1
  20. package/dist/agent.js.map +0 -7
  21. package/dist/client/acp-message-routing.d.ts.map +0 -1
  22. package/dist/client/acp-session-callbacks.d.ts.map +0 -1
  23. package/dist/client/acp-session-content.d.ts.map +0 -1
  24. package/dist/client/acp-session-errors.d.ts.map +0 -1
  25. package/dist/client/acp-session-types.d.ts.map +0 -1
  26. package/dist/client/acp-session-updates.d.ts.map +0 -1
  27. package/dist/client/acp-session.d.ts.map +0 -1
  28. package/dist/client/file-server.d.ts.map +0 -1
  29. package/dist/client/index.d.ts.map +0 -1
  30. package/dist/client/permission.d.ts.map +0 -1
  31. package/dist/client/terminal-server.d.ts.map +0 -1
  32. package/dist/client/tool-translator.d.ts.map +0 -1
  33. package/dist/client/trust-boundary-permission.d.ts.map +0 -1
  34. package/dist/client/websocket-transport.d.ts.map +0 -1
  35. package/dist/client.js.map +0 -7
  36. package/dist/index.d.ts.map +0 -1
  37. package/dist/index.js.map +0 -7
  38. package/dist/integration/acp-bench.d.ts.map +0 -1
  39. package/dist/integration/acp-subagent-runner.d.ts.map +0 -1
  40. package/dist/integration/ensemble-runner.d.ts.map +0 -1
  41. package/dist/integration/run-one-acp-task.d.ts.map +0 -1
  42. package/dist/legacy.d.ts.map +0 -1
  43. package/dist/legacy.js.map +0 -7
  44. package/dist/registry/acp-registry-fetch.d.ts.map +0 -1
  45. package/dist/registry/agents.catalog.d.ts.map +0 -1
  46. package/dist/registry/ensemble-registry.d.ts.map +0 -1
  47. package/dist/sdk.d.ts.map +0 -1
  48. package/dist/sdk.js.map +0 -7
  49. package/dist/types/acp-messages.d.ts.map +0 -1
  50. package/dist/types/acp-v1.d.ts.map +0 -1
  51. package/dist/v1.d.ts.map +0 -1
  52. package/dist/v1.js.map +0 -7
  53. package/dist/version.d.ts.map +0 -1
  54. package/dist/win32-cmd.d.ts.map +0 -1
  55. 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,7 +1054,7 @@ 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(() => {
919
1059
  const child2 = this.child;
920
1060
  this.child = null;
@@ -967,7 +1107,7 @@ var ClientTransport = class {
967
1107
  if (settled) return;
968
1108
  settled = true;
969
1109
  clearTimeout(timeout);
970
- resolve3();
1110
+ resolve4();
971
1111
  });
972
1112
  return;
973
1113
  }
@@ -978,7 +1118,7 @@ var ClientTransport = class {
978
1118
  child.stderr.on("data", (c) => this.onChildError(c));
979
1119
  child.on("close", (code) => this.onChildClose(code));
980
1120
  clearTimeout(timeout);
981
- resolve3();
1121
+ resolve4();
982
1122
  };
983
1123
  const waitForMarker = (chunk) => {
984
1124
  this.buffer += chunk;
@@ -997,33 +1137,52 @@ var ClientTransport = class {
997
1137
  }
998
1138
  send(msg) {
999
1139
  if (!this.child) return Promise.reject(new Error("ClientTransport not started"));
1000
- return new Promise((resolve3, reject) => {
1140
+ return new Promise((resolve4, reject) => {
1001
1141
  const line = JSON.stringify(msg) + "\n";
1002
1142
  this.child?.stdin.write(line, "utf8", (err) => {
1003
1143
  if (err) reject(err);
1004
- else resolve3();
1144
+ else resolve4();
1005
1145
  });
1006
1146
  });
1007
1147
  }
1008
1148
  read() {
1009
- if (this.messageQueue.length > 0)
1010
- 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
+ }
1011
1154
  if (this.closed) return Promise.resolve(null);
1012
- return new Promise((resolve3) => {
1013
- this.resolveRead = resolve3;
1155
+ return new Promise((resolve4) => {
1156
+ this.resolveRead = resolve4;
1014
1157
  });
1015
1158
  }
1016
1159
  onMessage(handler) {
1017
1160
  this.handlers.add(handler);
1018
1161
  return () => this.handlers.delete(handler);
1019
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
+ }
1020
1177
  stop() {
1021
1178
  this.closed = true;
1022
1179
  this.resolveRead?.(null);
1023
1180
  this.resolveRead = null;
1024
1181
  this.buffer = "";
1025
1182
  this.messageQueue.length = 0;
1183
+ this.queuedChars = 0;
1026
1184
  this.handlers.clear();
1185
+ this.claimHandlers.clear();
1027
1186
  const child = this.child;
1028
1187
  if (!child) return;
1029
1188
  treeKill(child);
@@ -1048,7 +1207,7 @@ var ClientTransport = class {
1048
1207
  return;
1049
1208
  }
1050
1209
  try {
1051
- this.dispatch(JSON.parse(raw));
1210
+ this.dispatch(JSON.parse(raw), raw.length);
1052
1211
  } catch {
1053
1212
  }
1054
1213
  }
@@ -1058,29 +1217,34 @@ var ClientTransport = class {
1058
1217
  }
1059
1218
  onChildClose(code) {
1060
1219
  this.closed = true;
1220
+ this.child = null;
1061
1221
  this.resolveRead?.(null);
1062
1222
  this.resolveRead = null;
1063
1223
  this.buffer = "";
1064
1224
  this.messageQueue.length = 0;
1225
+ this.queuedChars = 0;
1065
1226
  this.handlers.clear();
1066
1227
  if (code !== 0 && code !== null) {
1067
1228
  writeErr(`[acp-child exited with code ${code}]
1068
1229
  `);
1069
1230
  }
1070
1231
  }
1071
- dispatch(msg) {
1232
+ dispatch(msg, chars = JSON.stringify(msg).length) {
1072
1233
  if (this.resolveRead) {
1073
- const resolve3 = this.resolveRead;
1234
+ const resolve4 = this.resolveRead;
1074
1235
  this.resolveRead = null;
1075
- resolve3(msg);
1236
+ resolve4(msg);
1076
1237
  } else if (this.handlers.size === 0) {
1077
- if (this.messageQueue.length >= this.maxQueuedMessages) {
1078
- writeErr(`[acp-child message queue exceeds ${this.maxQueuedMessages} entries]
1079
- `);
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
+ );
1080
1243
  this.stop();
1081
1244
  return;
1082
1245
  }
1083
- this.messageQueue.push(msg);
1246
+ this.messageQueue.push({ message: msg, chars });
1247
+ this.queuedChars += chars;
1084
1248
  }
1085
1249
  for (const handler of this.handlers) {
1086
1250
  try {
@@ -1374,12 +1538,12 @@ var WrongStackACPServer = class {
1374
1538
  res.end(JSON.stringify({ error: { code: -32603, message: "Internal error" } }));
1375
1539
  }
1376
1540
  });
1377
- return new Promise((resolve3) => {
1541
+ return new Promise((resolve4) => {
1378
1542
  this.httpServer.listen(port, host, () => {
1379
1543
  writeErr2(`[wstack-acp] HTTP server listening on http://${host}:${port}
1380
1544
  `);
1381
1545
  this.running = true;
1382
- resolve3();
1546
+ resolve4();
1383
1547
  });
1384
1548
  });
1385
1549
  }
@@ -1462,26 +1626,26 @@ function emptyRunResult(stopReason) {
1462
1626
  // src/client/file-server.ts
1463
1627
  import { randomBytes } from "node:crypto";
1464
1628
  import { realpathSync } from "node:fs";
1465
- import * as fsp from "node:fs/promises";
1466
- import * as path from "node:path";
1629
+ import * as fsp2 from "node:fs/promises";
1630
+ import * as path2 from "node:path";
1467
1631
  var DEFAULT_FILE_OPERATIONS = {
1468
- stat: fsp.stat,
1469
- readFile: fsp.readFile,
1470
- writeFile: fsp.writeFile,
1471
- realpath: fsp.realpath,
1472
- rename: fsp.rename,
1473
- 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
1474
1638
  };
1475
1639
  var DEFAULT_MAX_READ_BYTES = 5 * 1024 * 1024;
1476
1640
  var DEFAULT_MAX_WRITE_BYTES = 5 * 1024 * 1024;
1477
1641
  var FsError = class extends Error {
1478
1642
  code;
1479
1643
  path;
1480
- constructor(code, path4, message) {
1644
+ constructor(code, path5, message) {
1481
1645
  super(message);
1482
1646
  this.name = "FsError";
1483
1647
  this.code = code;
1484
- this.path = path4;
1648
+ this.path = path5;
1485
1649
  }
1486
1650
  };
1487
1651
  var FileServer = class {
@@ -1492,7 +1656,7 @@ var FileServer = class {
1492
1656
  maxWriteBytes;
1493
1657
  operations;
1494
1658
  constructor(opts) {
1495
- this.root = path.resolve(opts.projectRoot);
1659
+ this.root = path2.resolve(opts.projectRoot);
1496
1660
  this.realRoot = safeRealpathSync(this.root);
1497
1661
  this.timeoutMs = opts.timeoutMs ?? 3e4;
1498
1662
  this.maxReadBytes = opts.maxReadBytes ?? DEFAULT_MAX_READ_BYTES;
@@ -1505,14 +1669,14 @@ var FileServer = class {
1505
1669
  const controller = new AbortController();
1506
1670
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
1507
1671
  try {
1508
- const stat2 = await this.operations.stat(safe).catch((err) => {
1672
+ const stat3 = await this.operations.stat(safe).catch((err) => {
1509
1673
  throw mapFsError(err, safe);
1510
1674
  });
1511
- if (stat2.size > this.maxReadBytes) {
1675
+ if (stat3.size > this.maxReadBytes) {
1512
1676
  throw new FsError(
1513
1677
  "TOO_LARGE",
1514
1678
  safe,
1515
- `file is ${stat2.size} bytes, max read is ${this.maxReadBytes} bytes`
1679
+ `file is ${stat3.size} bytes, max read is ${this.maxReadBytes} bytes`
1516
1680
  );
1517
1681
  }
1518
1682
  const content = await this.operations.readFile(safe, {
@@ -1550,7 +1714,7 @@ var FileServer = class {
1550
1714
  signal: controller.signal
1551
1715
  });
1552
1716
  await this.assertRealInside(tmp);
1553
- await this.assertRealInside(path.dirname(safe));
1717
+ await this.assertRealInside(path2.dirname(safe));
1554
1718
  await this.operations.rename(tmp, safe);
1555
1719
  } catch (err) {
1556
1720
  if (err instanceof FsError) {
@@ -1581,11 +1745,11 @@ var FileServer = class {
1581
1745
  if (typeof p !== "string" || p.length === 0) {
1582
1746
  throw new FsError("INVALID_PATH", p, "path is empty or not a string");
1583
1747
  }
1584
- if (!path.isAbsolute(p)) {
1748
+ if (!path2.isAbsolute(p)) {
1585
1749
  throw new FsError("INVALID_PATH", p, "path must be absolute (ACP requirement)");
1586
1750
  }
1587
- const resolved = path.resolve(p);
1588
- 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;
1589
1753
  if (resolved !== this.root && !resolved.startsWith(rootWithSep)) {
1590
1754
  throw new FsError("OUTSIDE_ROOT", resolved, "path is outside the project root");
1591
1755
  }
@@ -1606,7 +1770,7 @@ var FileServer = class {
1606
1770
  } catch (err) {
1607
1771
  const code = err.code;
1608
1772
  if (code === "ENOENT") {
1609
- const parent = path.dirname(probe);
1773
+ const parent = path2.dirname(probe);
1610
1774
  if (parent === probe) {
1611
1775
  throw new FsError("ENOENT", resolvedPath, `no existing ancestor: ${resolvedPath}`);
1612
1776
  }
@@ -1615,7 +1779,7 @@ var FileServer = class {
1615
1779
  }
1616
1780
  throw mapFsError(err, resolvedPath);
1617
1781
  }
1618
- if (real === this.realRoot || real.startsWith(this.realRoot + path.sep)) return;
1782
+ if (real === this.realRoot || real.startsWith(this.realRoot + path2.sep)) return;
1619
1783
  throw new FsError(
1620
1784
  "OUTSIDE_ROOT",
1621
1785
  resolvedPath,
@@ -1688,8 +1852,9 @@ function makePermissionPolicy(decide) {
1688
1852
  // src/client/terminal-server.ts
1689
1853
  import { spawn } from "node:child_process";
1690
1854
  import { realpathSync as realpathSync2 } from "node:fs";
1691
- import * as path2 from "node:path";
1855
+ import * as path3 from "node:path";
1692
1856
  import { buildChildEnv } from "@wrongstack/core/utils";
1857
+ import { treeKill as treeKill2 } from "@wrongstack/core/utils/tree-kill";
1693
1858
  var EMPTY_BUFFER = Buffer.alloc(0);
1694
1859
  var TerminalServer = class {
1695
1860
  terminals = /* @__PURE__ */ new Map();
@@ -1702,7 +1867,7 @@ var TerminalServer = class {
1702
1867
  abortHandler = () => this.releaseAll();
1703
1868
  nextId = 1;
1704
1869
  constructor(opts) {
1705
- this.projectRoot = path2.resolve(opts.projectRoot);
1870
+ this.projectRoot = path3.resolve(opts.projectRoot);
1706
1871
  this.commandTimeoutMs = opts.commandTimeoutMs ?? 5 * 6e4;
1707
1872
  this.outputByteLimit = opts.outputByteLimit ?? 1024 * 1024;
1708
1873
  this.maxOutputByteLimit = opts.maxOutputByteLimit ?? 16 * 1024 * 1024;
@@ -1749,7 +1914,7 @@ var TerminalServer = class {
1749
1914
  truncated: false,
1750
1915
  exitStatus: void 0,
1751
1916
  timeoutHandle: null,
1752
- exitPromise: new Promise((resolve3) => {
1917
+ exitPromise: new Promise((resolve4) => {
1753
1918
  proc.on("close", (code, signalName) => {
1754
1919
  if (state.timeoutHandle) {
1755
1920
  clearTimeout(state.timeoutHandle);
@@ -1760,7 +1925,7 @@ var TerminalServer = class {
1760
1925
  signal: typeof signalName === "string" ? signalName : null
1761
1926
  };
1762
1927
  state.exitStatus = exitStatus;
1763
- resolve3(exitStatus);
1928
+ resolve4(exitStatus);
1764
1929
  });
1765
1930
  proc.on("error", (err) => {
1766
1931
  if (state.timeoutHandle) {
@@ -1779,7 +1944,7 @@ var TerminalServer = class {
1779
1944
  }
1780
1945
  state.outputChunks.push(errorOutput);
1781
1946
  state.retainedBytes = errorOutput.length;
1782
- resolve3(exitStatus);
1947
+ resolve4(exitStatus);
1783
1948
  });
1784
1949
  })
1785
1950
  };
@@ -1809,13 +1974,11 @@ var TerminalServer = class {
1809
1974
  state.outputHead = 0;
1810
1975
  }
1811
1976
  };
1977
+ state.onData = onData;
1812
1978
  proc.stdout?.on("data", onData);
1813
1979
  proc.stderr?.on("data", onData);
1814
1980
  state.timeoutHandle = setTimeout(() => {
1815
- try {
1816
- proc.kill("SIGTERM");
1817
- } catch {
1818
- }
1981
+ treeKill2(proc);
1819
1982
  }, this.commandTimeoutMs);
1820
1983
  this.terminals.set(id, state);
1821
1984
  return { terminalId: id };
@@ -1839,14 +2002,15 @@ var TerminalServer = class {
1839
2002
  if (!state) throw new Error(`unknown terminal: ${terminalId}`);
1840
2003
  return state.exitPromise;
1841
2004
  }
1842
- /** 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
+ */
1843
2010
  kill(terminalId) {
1844
2011
  const state = this.terminals.get(terminalId);
1845
2012
  if (!state) throw new Error(`unknown terminal: ${terminalId}`);
1846
- try {
1847
- state.proc.kill("SIGTERM");
1848
- } catch {
1849
- }
2013
+ treeKill2(state.proc);
1850
2014
  }
1851
2015
  /** Kill the process if alive and remove the record. */
1852
2016
  release(terminalId) {
@@ -1856,10 +2020,17 @@ var TerminalServer = class {
1856
2020
  clearTimeout(state.timeoutHandle);
1857
2021
  state.timeoutHandle = null;
1858
2022
  }
1859
- try {
1860
- state.proc.kill("SIGKILL");
1861
- } catch {
1862
- }
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 });
1863
2034
  this.terminals.delete(terminalId);
1864
2035
  }
1865
2036
  /** Kill all active terminals. Used on session close. */
@@ -1871,15 +2042,15 @@ var TerminalServer = class {
1871
2042
  }
1872
2043
  resolveCwd(cwd) {
1873
2044
  if (!cwd) return this.projectRoot;
1874
- const resolved = path2.resolve(cwd);
1875
- 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;
1876
2047
  if (resolved !== this.projectRoot && !resolved.startsWith(rootWithSep)) {
1877
2048
  return this.projectRoot;
1878
2049
  }
1879
2050
  try {
1880
2051
  const realRoot = realpathSync2(this.projectRoot);
1881
2052
  const realCwd = realpathSync2(resolved);
1882
- const realRootWithSep = realRoot.endsWith(path2.sep) ? realRoot : realRoot + path2.sep;
2053
+ const realRootWithSep = realRoot.endsWith(path3.sep) ? realRoot : realRoot + path3.sep;
1883
2054
  if (realCwd !== realRoot && !realCwd.startsWith(realRootWithSep)) {
1884
2055
  return realRoot;
1885
2056
  }
@@ -2037,20 +2208,23 @@ var WebSocketClientTransport = class {
2037
2208
  );
2038
2209
  }
2039
2210
  const timeoutMs = this.opts.handshakeTimeoutMs ?? 3e4;
2040
- return new Promise((resolve3, reject) => {
2211
+ return new Promise((resolve4, reject) => {
2041
2212
  const ws = new WS(this.opts.url, this.opts.protocols);
2042
2213
  this.ws = ws;
2043
2214
  const timer = setTimeout(() => {
2044
2215
  const pending = this.pendingStart;
2045
2216
  if (pending === null) return;
2046
2217
  this.pendingStart = null;
2218
+ this.closed = true;
2219
+ if (this.ws === ws) this.ws = null;
2220
+ this.handlers.clear();
2047
2221
  try {
2048
2222
  ws.close();
2049
2223
  } catch {
2050
2224
  }
2051
2225
  pending.reject(new Error(`WebSocket failed to open within ${timeoutMs}ms`));
2052
2226
  }, timeoutMs);
2053
- this.pendingStart = { resolve: resolve3, reject, timer };
2227
+ this.pendingStart = { resolve: resolve4, reject, timer };
2054
2228
  ws.addEventListener("open", () => {
2055
2229
  const pending = this.pendingStart;
2056
2230
  if (pending === null) return;
@@ -2061,16 +2235,21 @@ var WebSocketClientTransport = class {
2061
2235
  ws.addEventListener("error", (ev) => {
2062
2236
  const pending = this.pendingStart;
2063
2237
  if (pending === null) {
2064
- this.closed = true;
2238
+ this.stop();
2065
2239
  return;
2066
2240
  }
2067
2241
  this.pendingStart = null;
2242
+ this.closed = true;
2243
+ if (this.ws === ws) this.ws = null;
2244
+ this.handlers.clear();
2068
2245
  clearTimeout(pending.timer);
2069
2246
  const message = ev && typeof ev === "object" && "message" in ev ? String(ev.message) : "WebSocket error";
2070
2247
  pending.reject(new Error(message));
2071
2248
  });
2072
2249
  ws.addEventListener("close", () => {
2073
2250
  this.closed = true;
2251
+ if (this.ws === ws) this.ws = null;
2252
+ this.handlers.clear();
2074
2253
  const pending = this.pendingStart;
2075
2254
  if (pending !== null) {
2076
2255
  this.pendingStart = null;
@@ -2106,6 +2285,7 @@ var WebSocketClientTransport = class {
2106
2285
  }
2107
2286
  stop() {
2108
2287
  this.closed = true;
2288
+ this.handlers.clear();
2109
2289
  if (this.pendingStart !== null) {
2110
2290
  const pending = this.pendingStart;
2111
2291
  this.pendingStart = null;
@@ -3086,7 +3266,7 @@ var ACPSession = class _ACPSession {
3086
3266
  return this.nextId++;
3087
3267
  }
3088
3268
  async sendRequest(id, method, params, timeoutMs) {
3089
- return new Promise((resolve3, reject) => {
3269
+ return new Promise((resolve4, reject) => {
3090
3270
  const effectiveTimeout = timeoutMs ?? this.timeoutMs;
3091
3271
  const handle = setTimeout(() => {
3092
3272
  this.pending.delete(id);
@@ -3096,7 +3276,7 @@ var ACPSession = class _ACPSession {
3096
3276
  }, effectiveTimeout);
3097
3277
  this.pending.set(id, {
3098
3278
  method,
3099
- resolve: resolve3,
3279
+ resolve: resolve4,
3100
3280
  reject,
3101
3281
  timeoutMs: effectiveTimeout,
3102
3282
  timeoutHandle: handle
@@ -3262,12 +3442,12 @@ var ToolTranslator = class {
3262
3442
  id: callId,
3263
3443
  params: { name, arguments: args }
3264
3444
  });
3265
- return new Promise((resolve3, reject) => {
3445
+ return new Promise((resolve4, reject) => {
3266
3446
  const timeout = setTimeout(() => {
3267
3447
  this.pending.delete(callId);
3268
3448
  reject(new Error(`Tool call ${name} timed out after ${this.opts.totalTimeoutMs}ms`));
3269
3449
  }, this.opts.totalTimeoutMs);
3270
- this.pending.set(callId, { resolve: resolve3, reject, timeout });
3450
+ this.pending.set(callId, { resolve: resolve4, reject, timeout });
3271
3451
  });
3272
3452
  }
3273
3453
  cancelAll() {
@@ -3279,8 +3459,8 @@ var ToolTranslator = class {
3279
3459
  };
3280
3460
 
3281
3461
  // src/integration/acp-bench.ts
3282
- import * as fsp2 from "node:fs/promises";
3283
- import * as path3 from "node:path";
3462
+ import * as fsp3 from "node:fs/promises";
3463
+ import * as path4 from "node:path";
3284
3464
  function firstLine(s) {
3285
3465
  const line = s.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
3286
3466
  return line.length > 120 ? `${line.slice(0, 117)}\u2026` : line;
@@ -3347,11 +3527,11 @@ async function benchOne(agentId, cmd, opts) {
3347
3527
  if (opts.checkFs) {
3348
3528
  const fileToken = `FILE_${opts.marker}`;
3349
3529
  const fileName = `acp-bench-${opts.marker}.txt`;
3350
- const filePath = path3.join(opts.projectRoot, fileName);
3530
+ const filePath = path4.join(opts.projectRoot, fileName);
3351
3531
  let fsOk = false;
3352
3532
  let fsDetail;
3353
3533
  try {
3354
- await fsp2.writeFile(filePath, fileToken, "utf8");
3534
+ await fsp3.writeFile(filePath, fileToken, "utf8");
3355
3535
  const fsRes = await session.prompt(
3356
3536
  [
3357
3537
  textContent(
@@ -3487,7 +3667,7 @@ function renderAcpBenchText(result) {
3487
3667
  );
3488
3668
  return lines.join("\n");
3489
3669
  }
3490
- async function removeBenchFile(filePath, remove = fsp2.rm) {
3670
+ async function removeBenchFile(filePath, remove = fsp3.rm) {
3491
3671
  await remove(filePath, { force: true }).catch(() => void 0);
3492
3672
  }
3493
3673
 
@@ -4027,7 +4207,7 @@ import { SubagentBudget } from "@wrongstack/core/coordination";
4027
4207
 
4028
4208
  // src/registry/ensemble-registry.ts
4029
4209
  import { spawn as spawn2 } from "node:child_process";
4030
- import { treeKill as treeKill2 } from "@wrongstack/core/utils/tree-kill";
4210
+ import { treeKill as treeKill3 } from "@wrongstack/core/utils/tree-kill";
4031
4211
  var PROBE_TIMEOUT_MS = 5e3;
4032
4212
  var PROBE_CACHE_MS = 5e3;
4033
4213
  var MAX_PARALLEL_PROBES = 4;
@@ -4053,7 +4233,7 @@ async function probeWithBound(items, worker, limit) {
4053
4233
  }
4054
4234
  async function defaultProbe(desc, timeoutMs, spawnProcess = spawn2, platform = process.platform) {
4055
4235
  const start = Date.now();
4056
- return new Promise((resolve3) => {
4236
+ return new Promise((resolve4) => {
4057
4237
  let settled = false;
4058
4238
  let stdout = "";
4059
4239
  let stderr = "";
@@ -4062,11 +4242,11 @@ async function defaultProbe(desc, timeoutMs, spawnProcess = spawn2, platform = p
4062
4242
  settled = true;
4063
4243
  try {
4064
4244
  if (child.exitCode === null && child.signalCode === null) {
4065
- treeKill2(child);
4245
+ treeKill3(child);
4066
4246
  }
4067
4247
  } catch {
4068
4248
  }
4069
- resolve3(result);
4249
+ resolve4(result);
4070
4250
  };
4071
4251
  let child;
4072
4252
  try {