@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
@@ -4,6 +4,11 @@ import { isIP } from "node:net";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { expandIPv6, writeErr as writeErr2 } from "@wrongstack/core/utils";
6
6
 
7
+ // src/agent/protocol-handler.ts
8
+ import { randomUUID } from "node:crypto";
9
+ import * as fsp from "node:fs/promises";
10
+ import * as path from "node:path";
11
+
7
12
  // src/types/acp-v1.ts
8
13
  var ACP_PROTOCOL_VERSION = 1;
9
14
 
@@ -93,12 +98,12 @@ var ACPProtocolHandler = class {
93
98
  */
94
99
  request(method, params, timeoutMs = 6e4) {
95
100
  const id = `srv_${this.nextOutId++}`;
96
- return new Promise((resolve, reject) => {
101
+ return new Promise((resolve2, reject) => {
97
102
  const timer = setTimeout(() => {
98
103
  this.pendingOut.delete(id);
99
104
  reject(new Error(`${method} timed out after ${timeoutMs}ms`));
100
105
  }, timeoutMs);
101
- this.pendingOut.set(id, { resolve, reject, timer });
106
+ this.pendingOut.set(id, { resolve: resolve2, reject, timer });
102
107
  this.transport.send(toWire({ jsonrpc: "2.0", id, method, params })).catch((e) => {
103
108
  clearTimeout(timer);
104
109
  this.pendingOut.delete(id);
@@ -215,62 +220,68 @@ var ACPProtocolHandler = class {
215
220
  this.clientCapabilities = p.clientCapabilities;
216
221
  }
217
222
  this.initialized = true;
218
- await this.transport.send(toWire({
219
- jsonrpc: "2.0",
220
- id,
221
- result: {
222
- protocolVersion: ACP_PROTOCOL_VERSION,
223
- agentCapabilities: {
224
- loadSession: true,
225
- promptCapabilities: {
226
- // We route ACP image blocks into the core agent's multimodal
227
- // input (server-agent-turn.promptToAgentInput); whether the
228
- // model can see them is the configured provider's concern.
229
- image: true,
230
- audio: false,
231
- embeddedContext: true
232
- },
233
- mcpCapabilities: {
234
- http: false,
235
- sse: false
223
+ await this.transport.send(
224
+ toWire({
225
+ jsonrpc: "2.0",
226
+ id,
227
+ result: {
228
+ protocolVersion: ACP_PROTOCOL_VERSION,
229
+ agentCapabilities: {
230
+ loadSession: true,
231
+ promptCapabilities: {
232
+ // We route ACP image blocks into the core agent's multimodal
233
+ // input (server-agent-turn.promptToAgentInput); whether the
234
+ // model can see them is the configured provider's concern.
235
+ image: true,
236
+ audio: false,
237
+ embeddedContext: true
238
+ },
239
+ mcpCapabilities: {
240
+ http: false,
241
+ sse: false
242
+ },
243
+ sessionCapabilities: {
244
+ close: {},
245
+ list: {},
246
+ delete: {},
247
+ resume: {},
248
+ fork: {}
249
+ },
250
+ auth: {
251
+ logout: {}
252
+ }
236
253
  },
237
- sessionCapabilities: {
238
- close: {},
239
- list: {},
240
- delete: {},
241
- resume: {},
242
- fork: {}
254
+ agentInfo: {
255
+ name: this.agentName,
256
+ title: "WrongStack",
257
+ version: WRONGSTACK_VERSION
243
258
  },
244
- auth: {
245
- logout: {}
246
- }
247
- },
248
- agentInfo: {
249
- name: this.agentName,
250
- title: "WrongStack",
251
- version: WRONGSTACK_VERSION
252
- },
253
- authMethods: WRONGSTACK_AUTH_METHODS,
254
- modes: this.modes,
255
- configOptions: this.configOptions
256
- }
257
- }));
259
+ authMethods: WRONGSTACK_AUTH_METHODS,
260
+ modes: this.modes,
261
+ configOptions: this.configOptions
262
+ }
263
+ })
264
+ );
258
265
  return false;
259
266
  }
260
267
  async handleAuthenticate(id, _params) {
261
- await this.transport.send(toWire({
262
- jsonrpc: "2.0",
263
- id,
264
- result: { outcome: "unauthenticated" }
265
- }));
268
+ await this.transport.send(
269
+ toWire({
270
+ jsonrpc: "2.0",
271
+ id,
272
+ result: { outcome: "unauthenticated" }
273
+ })
274
+ );
266
275
  return false;
267
276
  }
268
277
  async handleLogout(id, _params) {
269
- await this.transport.send(toWire({
270
- jsonrpc: "2.0",
271
- id,
272
- result: {}
273
- }));
278
+ await this.transport.send(
279
+ toWire({
280
+ jsonrpc: "2.0",
281
+ id,
282
+ result: {}
283
+ })
284
+ );
274
285
  return false;
275
286
  }
276
287
  async handleSessionNew(id, params) {
@@ -279,7 +290,15 @@ var ACPProtocolHandler = class {
279
290
  return false;
280
291
  }
281
292
  const p = params ?? {};
282
- const cwd = typeof p.cwd === "string" ? p.cwd : this.defaultCwd;
293
+ let cwd = this.defaultCwd;
294
+ if (typeof p.cwd === "string") {
295
+ const resolved = await this.resolveSessionCwd(p.cwd);
296
+ if (resolved === null) {
297
+ await this.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
298
+ return false;
299
+ }
300
+ cwd = resolved;
301
+ }
283
302
  const sessionId = `sess_${this.allocId()}`;
284
303
  const now = (/* @__PURE__ */ new Date()).toISOString();
285
304
  const state = {
@@ -309,15 +328,17 @@ var ACPProtocolHandler = class {
309
328
  }
310
329
  });
311
330
  }
312
- await this.transport.send(toWire({
313
- jsonrpc: "2.0",
314
- id,
315
- result: {
316
- sessionId,
317
- modes: this.modes,
318
- configOptions: this.configOptions
319
- }
320
- }));
331
+ await this.transport.send(
332
+ toWire({
333
+ jsonrpc: "2.0",
334
+ id,
335
+ result: {
336
+ sessionId,
337
+ modes: this.modes,
338
+ configOptions: this.configOptions
339
+ }
340
+ })
341
+ );
321
342
  return false;
322
343
  }
323
344
  async handleSessionLoad(id, params) {
@@ -332,9 +353,15 @@ var ACPProtocolHandler = class {
332
353
  await this.sendError(id, -32e3, `active session limit reached (${this.maxSessions})`);
333
354
  return false;
334
355
  }
356
+ if (loadCwd !== void 0 && await this.resolveSessionCwd(loadCwd) === null) {
357
+ await this.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
358
+ return false;
359
+ }
360
+ const candidateCwd = persisted.cwd ?? loadCwd ?? this.defaultCwd;
361
+ const restoredCwd = await this.resolveSessionCwd(candidateCwd) ?? this.defaultCwd;
335
362
  const restored = {
336
363
  id: sessionId,
337
- cwd: persisted.cwd ?? loadCwd ?? this.defaultCwd,
364
+ cwd: restoredCwd,
338
365
  abort: new AbortController(),
339
366
  modeId: persisted.modeId ?? DEFAULT_MODE_ID,
340
367
  createdAt: persisted.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
@@ -350,13 +377,15 @@ var ACPProtocolHandler = class {
350
377
  sessionId,
351
378
  update: { sessionUpdate: "current_mode_update", modeId: restored.modeId }
352
379
  });
353
- await this.transport.send(toWire({
354
- jsonrpc: "2.0",
355
- id,
356
- result: {
357
- initialMode: { currentModeId: restored.modeId, availableModes: this.modes }
358
- }
359
- }));
380
+ await this.transport.send(
381
+ toWire({
382
+ jsonrpc: "2.0",
383
+ id,
384
+ result: {
385
+ initialMode: { currentModeId: restored.modeId, availableModes: this.modes }
386
+ }
387
+ })
388
+ );
360
389
  return false;
361
390
  }
362
391
  }
@@ -382,16 +411,18 @@ var ACPProtocolHandler = class {
382
411
  modeId: existing.modeId
383
412
  }
384
413
  });
385
- await this.transport.send(toWire({
386
- jsonrpc: "2.0",
387
- id,
388
- result: {
389
- initialMode: {
390
- currentModeId: existing.modeId,
391
- availableModes: this.modes
414
+ await this.transport.send(
415
+ toWire({
416
+ jsonrpc: "2.0",
417
+ id,
418
+ result: {
419
+ initialMode: {
420
+ currentModeId: existing.modeId,
421
+ availableModes: this.modes
422
+ }
392
423
  }
393
- }
394
- }));
424
+ })
425
+ );
395
426
  return false;
396
427
  }
397
428
  await this.sendError(id, -32e3, `session not found: ${sessionId}`);
@@ -403,16 +434,18 @@ var ACPProtocolHandler = class {
403
434
  const existing = sessionId ? this.sessions.get(sessionId) : void 0;
404
435
  if (existing) {
405
436
  existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
406
- await this.transport.send(toWire({
407
- jsonrpc: "2.0",
408
- id,
409
- result: {
410
- initialMode: {
411
- currentModeId: existing.modeId,
412
- availableModes: this.modes
437
+ await this.transport.send(
438
+ toWire({
439
+ jsonrpc: "2.0",
440
+ id,
441
+ result: {
442
+ initialMode: {
443
+ currentModeId: existing.modeId,
444
+ availableModes: this.modes
445
+ }
413
446
  }
414
- }
415
- }));
447
+ })
448
+ );
416
449
  return false;
417
450
  }
418
451
  await this.sendError(id, -32e3, `session not found: ${sessionId}`);
@@ -429,11 +462,13 @@ var ACPProtocolHandler = class {
429
462
  session.abort.abort();
430
463
  this.sessions.delete(sessionId);
431
464
  this.disposeSession(sessionId);
432
- await this.transport.send(toWire({
433
- jsonrpc: "2.0",
434
- id,
435
- result: {}
436
- }));
465
+ await this.transport.send(
466
+ toWire({
467
+ jsonrpc: "2.0",
468
+ id,
469
+ result: {}
470
+ })
471
+ );
437
472
  return false;
438
473
  }
439
474
  async handleSessionDelete(id, params) {
@@ -444,18 +479,22 @@ var ACPProtocolHandler = class {
444
479
  return false;
445
480
  }
446
481
  if (!this.sessions.has(sessionId)) {
447
- await this.transport.send(toWire({ jsonrpc: "2.0", id, result: { configOptions: [...this.configOptions] } }));
482
+ await this.transport.send(
483
+ toWire({ jsonrpc: "2.0", id, result: { configOptions: [...this.configOptions] } })
484
+ );
448
485
  return false;
449
486
  }
450
487
  const session = this.sessions.get(sessionId);
451
488
  session.abort.abort();
452
489
  this.sessions.delete(sessionId);
453
490
  this.disposeSession(sessionId);
454
- await this.transport.send(toWire({
455
- jsonrpc: "2.0",
456
- id,
457
- result: {}
458
- }));
491
+ await this.transport.send(
492
+ toWire({
493
+ jsonrpc: "2.0",
494
+ id,
495
+ result: {}
496
+ })
497
+ );
459
498
  return false;
460
499
  }
461
500
  async handleSessionFork(id, params) {
@@ -470,11 +509,20 @@ var ACPProtocolHandler = class {
470
509
  await this.sendError(id, -32e3, `active session limit reached (${this.maxSessions})`);
471
510
  return false;
472
511
  }
512
+ let forkCwd = source.cwd;
513
+ if (typeof p.cwd === "string") {
514
+ const resolved = await this.resolveSessionCwd(p.cwd);
515
+ if (resolved === null) {
516
+ await this.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
517
+ return false;
518
+ }
519
+ forkCwd = resolved;
520
+ }
473
521
  const now = (/* @__PURE__ */ new Date()).toISOString();
474
522
  const sessionId = `sess_${this.allocId()}`;
475
523
  const forked = {
476
524
  id: sessionId,
477
- cwd: typeof p.cwd === "string" ? p.cwd : source.cwd,
525
+ cwd: forkCwd,
478
526
  abort: new AbortController(),
479
527
  modeId: source.modeId,
480
528
  createdAt: now,
@@ -493,38 +541,48 @@ var ACPProtocolHandler = class {
493
541
  sessionId,
494
542
  update: { sessionUpdate: "current_mode_update", modeId: forked.modeId }
495
543
  });
496
- await this.transport.send(toWire({
497
- jsonrpc: "2.0",
498
- id,
499
- result: {
500
- sessionId,
501
- modes: this.modes,
502
- configOptions: this.configOptions
503
- }
504
- }));
544
+ await this.transport.send(
545
+ toWire({
546
+ jsonrpc: "2.0",
547
+ id,
548
+ result: {
549
+ sessionId,
550
+ modes: this.modes,
551
+ configOptions: this.configOptions
552
+ }
553
+ })
554
+ );
505
555
  return false;
506
556
  }
507
557
  async handleProvidersList(id, _params) {
508
- await this.transport.send(toWire({
509
- jsonrpc: "2.0",
510
- id,
511
- result: {
512
- providers: [],
513
- currentProviderId: null
514
- }
515
- }));
558
+ await this.transport.send(
559
+ toWire({
560
+ jsonrpc: "2.0",
561
+ id,
562
+ result: {
563
+ providers: [],
564
+ currentProviderId: null
565
+ }
566
+ })
567
+ );
516
568
  return false;
517
569
  }
518
570
  async handleProvidersSet(id, _params) {
519
- await this.sendError(id, -32e3, "provider configuration not available through ACP; use wstack auth");
571
+ await this.sendError(
572
+ id,
573
+ -32e3,
574
+ "provider configuration not available through ACP; use wstack auth"
575
+ );
520
576
  return false;
521
577
  }
522
578
  async handleProvidersDisable(id, _params) {
523
- await this.transport.send(toWire({
524
- jsonrpc: "2.0",
525
- id,
526
- result: {}
527
- }));
579
+ await this.transport.send(
580
+ toWire({
581
+ jsonrpc: "2.0",
582
+ id,
583
+ result: {}
584
+ })
585
+ );
528
586
  return false;
529
587
  }
530
588
  async handleMcpMessage(id, _params) {
@@ -577,7 +635,10 @@ var ACPProtocolHandler = class {
577
635
  const terminalId = created?.terminalId;
578
636
  if (!terminalId) return { output: "", exitCode: null };
579
637
  try {
580
- const exit = await this.request("terminal/wait_for_exit", { sessionId, terminalId });
638
+ const exit = await this.request("terminal/wait_for_exit", {
639
+ sessionId,
640
+ terminalId
641
+ });
581
642
  const out = await this.request("terminal/output", { sessionId, terminalId });
582
643
  return {
583
644
  output: String(out?.output ?? ""),
@@ -607,11 +668,13 @@ var ACPProtocolHandler = class {
607
668
  session.abort.signal.removeEventListener("abort", onCancel);
608
669
  session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
609
670
  await this.persist(session);
610
- await this.transport.send(toWire({
611
- jsonrpc: "2.0",
612
- id,
613
- result: { stopReason: result.stopReason }
614
- }));
671
+ await this.transport.send(
672
+ toWire({
673
+ jsonrpc: "2.0",
674
+ id,
675
+ result: { stopReason: result.stopReason }
676
+ })
677
+ );
615
678
  return false;
616
679
  }
617
680
  async handleSetMode(id, params) {
@@ -652,7 +715,9 @@ var ACPProtocolHandler = class {
652
715
  configOptions: [...this.configOptions]
653
716
  }
654
717
  });
655
- await this.transport.send(toWire({ jsonrpc: "2.0", id, result: { configOptions: [...this.configOptions] } }));
718
+ await this.transport.send(
719
+ toWire({ jsonrpc: "2.0", id, result: { configOptions: [...this.configOptions] } })
720
+ );
656
721
  return false;
657
722
  }
658
723
  async handleSessionList(id) {
@@ -665,11 +730,13 @@ var ACPProtocolHandler = class {
665
730
  if (s.title !== void 0) out.title = s.title;
666
731
  return out;
667
732
  });
668
- await this.transport.send(toWire({
669
- jsonrpc: "2.0",
670
- id,
671
- result: { sessions }
672
- }));
733
+ await this.transport.send(
734
+ toWire({
735
+ jsonrpc: "2.0",
736
+ id,
737
+ result: { sessions }
738
+ })
739
+ );
673
740
  return false;
674
741
  }
675
742
  // ────────────────────────────────────────────────────────────────────
@@ -715,8 +782,54 @@ var ACPProtocolHandler = class {
715
782
  if (data !== void 0) error.data = data;
716
783
  await this.transport.send(toWire({ jsonrpc: "2.0", id, error }));
717
784
  }
785
+ /**
786
+ * Allocate a session id (WS-015).
787
+ *
788
+ * This was `this.nextId++`, so ids were `sess_1`, `sess_2`, … — and the
789
+ * handler has no per-connection ownership: any caller that names a session
790
+ * id can `session/load`, `session/prompt`, `session/cancel` or
791
+ * `session/delete` it. Over stdio that is academic (one client per process),
792
+ * but the agent also serves over HTTP, where a guessable id is the whole
793
+ * authorization story for any local process or page that reaches the port.
794
+ *
795
+ * Random ids do not create ownership — they remove the trivial enumeration
796
+ * that made its absence exploitable. Real per-connection ownership is the
797
+ * larger fix and is noted in the WS-015 test file.
798
+ *
799
+ * The counter is retained: it keeps ids ordered for debugging and guarantees
800
+ * uniqueness within a process even in the (impossible) event of a UUID
801
+ * collision. The random half is what makes the id unguessable.
802
+ */
803
+ /**
804
+ * Resolve a client-supplied `cwd` for a session, or `null` when it is not
805
+ * usable (WS-015).
806
+ *
807
+ * `session/new`, `session/load` and `session/fork` all took `params.cwd`
808
+ * with a single `typeof === 'string'` check and nothing else. That value is
809
+ * the working directory the agent then reads, writes and executes in.
810
+ *
811
+ * SCOPE, deliberately stated: this does NOT confine the session to a root.
812
+ * In ACP the client IS the editor and legitimately names its own workspace —
813
+ * Zed and JetBrains pass the project root — so a fixed boundary here would
814
+ * break the integration this package exists for. What it enforces is that
815
+ * the directory is absolute and actually exists as a directory: a relative
816
+ * or missing `cwd` is a bug or an attack under either reading, and silently
817
+ * running the agent somewhere other than where the client asked is worse
818
+ * than refusing. Confinement, if wanted, belongs in an operator-set option
819
+ * on top of this, not in place of it.
820
+ */
821
+ async resolveSessionCwd(requested) {
822
+ if (!path.isAbsolute(requested)) return null;
823
+ const resolved = path.resolve(requested);
824
+ try {
825
+ const stat2 = await fsp.stat(resolved);
826
+ return stat2.isDirectory() ? resolved : null;
827
+ } catch {
828
+ return null;
829
+ }
830
+ }
718
831
  allocId() {
719
- return this.nextId++;
832
+ return `${this.nextId++}_${randomUUID().replaceAll("-", "")}`;
720
833
  }
721
834
  };
722
835
  function errorToJsonRpc(err) {
@@ -740,6 +853,7 @@ import { expectDefined, writeErr } from "@wrongstack/core/utils";
740
853
  import { treeKill } from "@wrongstack/core/utils/tree-kill";
741
854
  var DEFAULT_MAX_FRAME_CHARS = 20 * 1024 * 1024;
742
855
  var DEFAULT_MAX_QUEUED_MESSAGES = 1e3;
856
+ var DEFAULT_MAX_QUEUED_CHARS = 32 * 1024 * 1024;
743
857
  function positiveLimit(value, fallback) {
744
858
  return value !== void 0 && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
745
859
  }
@@ -749,53 +863,82 @@ var StdioTransport = class {
749
863
  stderr = process.stderr;
750
864
  buffer = "";
751
865
  handlers = /* @__PURE__ */ new Set();
866
+ claimHandlers = /* @__PURE__ */ new Set();
752
867
  closed = false;
753
868
  resolveRead = null;
754
869
  messageQueue = [];
870
+ queuedChars = 0;
755
871
  maxFrameChars;
756
872
  maxQueuedMessages;
873
+ maxQueuedChars;
874
+ onStdinData = (chunk) => this.onData(chunk);
875
+ onStdinEnd = () => this.handleClose();
876
+ onStdinError = (err) => this.failAll(err);
757
877
  constructor(opts = {}) {
758
878
  this.maxFrameChars = positiveLimit(opts.maxFrameChars, DEFAULT_MAX_FRAME_CHARS);
759
879
  this.maxQueuedMessages = positiveLimit(opts.maxQueuedMessages, DEFAULT_MAX_QUEUED_MESSAGES);
880
+ this.maxQueuedChars = positiveLimit(opts.maxQueuedChars, DEFAULT_MAX_QUEUED_CHARS);
760
881
  this.stdin.resume();
761
882
  this.stdin.setEncoding("utf8");
762
- this.stdin.on("data", (chunk) => this.onData(chunk));
763
- this.stdin.on("end", () => this.handleClose());
764
- this.stdin.on("error", (err) => this.failAll(err));
883
+ this.stdin.on("data", this.onStdinData);
884
+ this.stdin.on("end", this.onStdinEnd);
885
+ this.stdin.on("error", this.onStdinError);
765
886
  }
766
887
  sendStartupMarker() {
767
888
  this.stdout.write("[wstack-acp]\n", "utf8");
768
889
  }
769
890
  send(msg) {
770
891
  if (this.closed) return Promise.resolve();
771
- return new Promise((resolve) => {
892
+ return new Promise((resolve2) => {
772
893
  const line = JSON.stringify(msg) + "\n";
773
- this.stdout.write(line, "utf8", () => resolve());
894
+ this.stdout.write(line, "utf8", () => resolve2());
774
895
  });
775
896
  }
776
897
  sendRaw(chunk) {
777
898
  this.stdout.write(chunk, "utf8");
778
899
  }
779
900
  read() {
780
- if (this.messageQueue.length > 0)
781
- return Promise.resolve(expectDefined(this.messageQueue.shift()));
901
+ if (this.messageQueue.length > 0) {
902
+ const queued = expectDefined(this.messageQueue.shift());
903
+ this.queuedChars = Math.max(0, this.queuedChars - queued.chars);
904
+ return Promise.resolve(queued.message);
905
+ }
782
906
  if (this.closed) return Promise.resolve(null);
783
- return new Promise((resolve) => {
784
- this.resolveRead = resolve;
907
+ return new Promise((resolve2) => {
908
+ this.resolveRead = resolve2;
785
909
  });
786
910
  }
787
911
  onMessage(handler) {
788
912
  this.handlers.add(handler);
789
913
  return () => this.handlers.delete(handler);
790
914
  }
915
+ /**
916
+ * Register a handler that MAY claim a message by returning `true`.
917
+ * Claimed messages are NOT enqueued for the read() loop — they are
918
+ * considered fully consumed by the handler. This is how the ACP
919
+ * server transport prevents the correlation handler (which always
920
+ * fires on every message but only actually correlates responses)
921
+ * from starving the read() loop of pipelined requests, notifications,
922
+ * and any message the handler chose to ignore. See dispatch() for
923
+ * the gating logic.
924
+ */
925
+ onMessageClaim(handler) {
926
+ this.claimHandlers.add(handler);
927
+ return () => this.claimHandlers.delete(handler);
928
+ }
791
929
  close() {
792
930
  this.closed = true;
931
+ this.stdin.off("data", this.onStdinData);
932
+ this.stdin.off("end", this.onStdinEnd);
933
+ this.stdin.off("error", this.onStdinError);
793
934
  this.stdin.pause();
794
935
  this.resolveRead?.(null);
795
936
  this.resolveRead = null;
796
937
  this.buffer = "";
797
938
  this.messageQueue.length = 0;
939
+ this.queuedChars = 0;
798
940
  this.handlers.clear();
941
+ this.claimHandlers.clear();
799
942
  }
800
943
  onData(chunk) {
801
944
  this.buffer += chunk;
@@ -822,29 +965,41 @@ var StdioTransport = class {
822
965
  return;
823
966
  }
824
967
  try {
825
- this.dispatch(JSON.parse(raw));
968
+ this.dispatch(JSON.parse(raw), raw.length);
826
969
  } catch (err) {
827
970
  this.stderr.write(`[wstack-acp parse error] ${err}
828
971
  `, "utf8");
829
972
  }
830
973
  }
831
974
  }
832
- dispatch(msg) {
975
+ dispatch(msg, chars = JSON.stringify(msg).length) {
833
976
  if (this.resolveRead) {
834
- const resolve = this.resolveRead;
977
+ const resolve2 = this.resolveRead;
835
978
  this.resolveRead = null;
836
- resolve(msg);
979
+ resolve2(msg);
837
980
  } else {
838
- if (this.messageQueue.length >= this.maxQueuedMessages) {
839
- this.stderr.write(
840
- `[wstack-acp queue error] pending message queue exceeds ${this.maxQueuedMessages} entries
981
+ let claimed = false;
982
+ for (const handler of this.claimHandlers) {
983
+ try {
984
+ if (handler(msg)) claimed = true;
985
+ } catch (err) {
986
+ this.stderr.write(`[wstack-acp handler error] ${err}
987
+ `, "utf8");
988
+ }
989
+ }
990
+ if (!claimed) {
991
+ if (this.messageQueue.length >= this.maxQueuedMessages || this.queuedChars + chars > this.maxQueuedChars) {
992
+ this.stderr.write(
993
+ `[wstack-acp queue error] pending message queue exceeds ${this.maxQueuedMessages} entries or ${this.maxQueuedChars} characters
841
994
  `,
842
- "utf8"
843
- );
844
- this.close();
845
- return;
995
+ "utf8"
996
+ );
997
+ this.close();
998
+ return;
999
+ }
1000
+ this.messageQueue.push({ message: msg, chars });
1001
+ this.queuedChars += chars;
846
1002
  }
847
- this.messageQueue.push(msg);
848
1003
  }
849
1004
  for (const handler of this.handlers) {
850
1005
  try {
@@ -1023,12 +1178,12 @@ var WrongStackACPServer = class {
1023
1178
  res.end(JSON.stringify({ error: { code: -32603, message: "Internal error" } }));
1024
1179
  }
1025
1180
  });
1026
- return new Promise((resolve) => {
1181
+ return new Promise((resolve2) => {
1027
1182
  this.httpServer.listen(port, host, () => {
1028
1183
  writeErr2(`[wstack-acp] HTTP server listening on http://${host}:${port}
1029
1184
  `);
1030
1185
  this.running = true;
1031
- resolve();
1186
+ resolve2();
1032
1187
  });
1033
1188
  });
1034
1189
  }