@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/agent.js CHANGED
@@ -3,6 +3,7 @@ import { expectDefined, writeErr } from "@wrongstack/core/utils";
3
3
  import { treeKill } from "@wrongstack/core/utils/tree-kill";
4
4
  var DEFAULT_MAX_FRAME_CHARS = 20 * 1024 * 1024;
5
5
  var DEFAULT_MAX_QUEUED_MESSAGES = 1e3;
6
+ var DEFAULT_MAX_QUEUED_CHARS = 32 * 1024 * 1024;
6
7
  function positiveLimit(value, fallback) {
7
8
  return value !== void 0 && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
8
9
  }
@@ -12,53 +13,82 @@ var StdioTransport = class {
12
13
  stderr = process.stderr;
13
14
  buffer = "";
14
15
  handlers = /* @__PURE__ */ new Set();
16
+ claimHandlers = /* @__PURE__ */ new Set();
15
17
  closed = false;
16
18
  resolveRead = null;
17
19
  messageQueue = [];
20
+ queuedChars = 0;
18
21
  maxFrameChars;
19
22
  maxQueuedMessages;
23
+ maxQueuedChars;
24
+ onStdinData = (chunk) => this.onData(chunk);
25
+ onStdinEnd = () => this.handleClose();
26
+ onStdinError = (err) => this.failAll(err);
20
27
  constructor(opts = {}) {
21
28
  this.maxFrameChars = positiveLimit(opts.maxFrameChars, DEFAULT_MAX_FRAME_CHARS);
22
29
  this.maxQueuedMessages = positiveLimit(opts.maxQueuedMessages, DEFAULT_MAX_QUEUED_MESSAGES);
30
+ this.maxQueuedChars = positiveLimit(opts.maxQueuedChars, DEFAULT_MAX_QUEUED_CHARS);
23
31
  this.stdin.resume();
24
32
  this.stdin.setEncoding("utf8");
25
- this.stdin.on("data", (chunk) => this.onData(chunk));
26
- this.stdin.on("end", () => this.handleClose());
27
- this.stdin.on("error", (err) => this.failAll(err));
33
+ this.stdin.on("data", this.onStdinData);
34
+ this.stdin.on("end", this.onStdinEnd);
35
+ this.stdin.on("error", this.onStdinError);
28
36
  }
29
37
  sendStartupMarker() {
30
38
  this.stdout.write("[wstack-acp]\n", "utf8");
31
39
  }
32
40
  send(msg) {
33
41
  if (this.closed) return Promise.resolve();
34
- return new Promise((resolve) => {
42
+ return new Promise((resolve2) => {
35
43
  const line = JSON.stringify(msg) + "\n";
36
- this.stdout.write(line, "utf8", () => resolve());
44
+ this.stdout.write(line, "utf8", () => resolve2());
37
45
  });
38
46
  }
39
47
  sendRaw(chunk) {
40
48
  this.stdout.write(chunk, "utf8");
41
49
  }
42
50
  read() {
43
- if (this.messageQueue.length > 0)
44
- return Promise.resolve(expectDefined(this.messageQueue.shift()));
51
+ if (this.messageQueue.length > 0) {
52
+ const queued = expectDefined(this.messageQueue.shift());
53
+ this.queuedChars = Math.max(0, this.queuedChars - queued.chars);
54
+ return Promise.resolve(queued.message);
55
+ }
45
56
  if (this.closed) return Promise.resolve(null);
46
- return new Promise((resolve) => {
47
- this.resolveRead = resolve;
57
+ return new Promise((resolve2) => {
58
+ this.resolveRead = resolve2;
48
59
  });
49
60
  }
50
61
  onMessage(handler) {
51
62
  this.handlers.add(handler);
52
63
  return () => this.handlers.delete(handler);
53
64
  }
65
+ /**
66
+ * Register a handler that MAY claim a message by returning `true`.
67
+ * Claimed messages are NOT enqueued for the read() loop — they are
68
+ * considered fully consumed by the handler. This is how the ACP
69
+ * server transport prevents the correlation handler (which always
70
+ * fires on every message but only actually correlates responses)
71
+ * from starving the read() loop of pipelined requests, notifications,
72
+ * and any message the handler chose to ignore. See dispatch() for
73
+ * the gating logic.
74
+ */
75
+ onMessageClaim(handler) {
76
+ this.claimHandlers.add(handler);
77
+ return () => this.claimHandlers.delete(handler);
78
+ }
54
79
  close() {
55
80
  this.closed = true;
81
+ this.stdin.off("data", this.onStdinData);
82
+ this.stdin.off("end", this.onStdinEnd);
83
+ this.stdin.off("error", this.onStdinError);
56
84
  this.stdin.pause();
57
85
  this.resolveRead?.(null);
58
86
  this.resolveRead = null;
59
87
  this.buffer = "";
60
88
  this.messageQueue.length = 0;
89
+ this.queuedChars = 0;
61
90
  this.handlers.clear();
91
+ this.claimHandlers.clear();
62
92
  }
63
93
  onData(chunk) {
64
94
  this.buffer += chunk;
@@ -85,29 +115,41 @@ var StdioTransport = class {
85
115
  return;
86
116
  }
87
117
  try {
88
- this.dispatch(JSON.parse(raw));
118
+ this.dispatch(JSON.parse(raw), raw.length);
89
119
  } catch (err) {
90
120
  this.stderr.write(`[wstack-acp parse error] ${err}
91
121
  `, "utf8");
92
122
  }
93
123
  }
94
124
  }
95
- dispatch(msg) {
125
+ dispatch(msg, chars = JSON.stringify(msg).length) {
96
126
  if (this.resolveRead) {
97
- const resolve = this.resolveRead;
127
+ const resolve2 = this.resolveRead;
98
128
  this.resolveRead = null;
99
- resolve(msg);
129
+ resolve2(msg);
100
130
  } else {
101
- if (this.messageQueue.length >= this.maxQueuedMessages) {
102
- this.stderr.write(
103
- `[wstack-acp queue error] pending message queue exceeds ${this.maxQueuedMessages} entries
131
+ let claimed = false;
132
+ for (const handler of this.claimHandlers) {
133
+ try {
134
+ if (handler(msg)) claimed = true;
135
+ } catch (err) {
136
+ this.stderr.write(`[wstack-acp handler error] ${err}
137
+ `, "utf8");
138
+ }
139
+ }
140
+ if (!claimed) {
141
+ if (this.messageQueue.length >= this.maxQueuedMessages || this.queuedChars + chars > this.maxQueuedChars) {
142
+ this.stderr.write(
143
+ `[wstack-acp queue error] pending message queue exceeds ${this.maxQueuedMessages} entries or ${this.maxQueuedChars} characters
104
144
  `,
105
- "utf8"
106
- );
107
- this.close();
108
- return;
145
+ "utf8"
146
+ );
147
+ this.close();
148
+ return;
149
+ }
150
+ this.messageQueue.push({ message: msg, chars });
151
+ this.queuedChars += chars;
109
152
  }
110
- this.messageQueue.push(msg);
111
153
  }
112
154
  for (const handler of this.handlers) {
113
155
  try {
@@ -242,6 +284,11 @@ function toolToPriority(tool) {
242
284
  return "low";
243
285
  }
244
286
 
287
+ // src/agent/protocol-handler.ts
288
+ import { randomUUID } from "node:crypto";
289
+ import * as fsp from "node:fs/promises";
290
+ import * as path from "node:path";
291
+
245
292
  // src/types/acp-v1.ts
246
293
  var ACP_PROTOCOL_VERSION = 1;
247
294
 
@@ -331,12 +378,12 @@ var ACPProtocolHandler = class {
331
378
  */
332
379
  request(method, params, timeoutMs = 6e4) {
333
380
  const id = `srv_${this.nextOutId++}`;
334
- return new Promise((resolve, reject) => {
381
+ return new Promise((resolve2, reject) => {
335
382
  const timer = setTimeout(() => {
336
383
  this.pendingOut.delete(id);
337
384
  reject(new Error(`${method} timed out after ${timeoutMs}ms`));
338
385
  }, timeoutMs);
339
- this.pendingOut.set(id, { resolve, reject, timer });
386
+ this.pendingOut.set(id, { resolve: resolve2, reject, timer });
340
387
  this.transport.send(toWire({ jsonrpc: "2.0", id, method, params })).catch((e) => {
341
388
  clearTimeout(timer);
342
389
  this.pendingOut.delete(id);
@@ -453,62 +500,68 @@ var ACPProtocolHandler = class {
453
500
  this.clientCapabilities = p.clientCapabilities;
454
501
  }
455
502
  this.initialized = true;
456
- await this.transport.send(toWire({
457
- jsonrpc: "2.0",
458
- id,
459
- result: {
460
- protocolVersion: ACP_PROTOCOL_VERSION,
461
- agentCapabilities: {
462
- loadSession: true,
463
- promptCapabilities: {
464
- // We route ACP image blocks into the core agent's multimodal
465
- // input (server-agent-turn.promptToAgentInput); whether the
466
- // model can see them is the configured provider's concern.
467
- image: true,
468
- audio: false,
469
- embeddedContext: true
470
- },
471
- mcpCapabilities: {
472
- http: false,
473
- sse: false
503
+ await this.transport.send(
504
+ toWire({
505
+ jsonrpc: "2.0",
506
+ id,
507
+ result: {
508
+ protocolVersion: ACP_PROTOCOL_VERSION,
509
+ agentCapabilities: {
510
+ loadSession: true,
511
+ promptCapabilities: {
512
+ // We route ACP image blocks into the core agent's multimodal
513
+ // input (server-agent-turn.promptToAgentInput); whether the
514
+ // model can see them is the configured provider's concern.
515
+ image: true,
516
+ audio: false,
517
+ embeddedContext: true
518
+ },
519
+ mcpCapabilities: {
520
+ http: false,
521
+ sse: false
522
+ },
523
+ sessionCapabilities: {
524
+ close: {},
525
+ list: {},
526
+ delete: {},
527
+ resume: {},
528
+ fork: {}
529
+ },
530
+ auth: {
531
+ logout: {}
532
+ }
474
533
  },
475
- sessionCapabilities: {
476
- close: {},
477
- list: {},
478
- delete: {},
479
- resume: {},
480
- fork: {}
534
+ agentInfo: {
535
+ name: this.agentName,
536
+ title: "WrongStack",
537
+ version: WRONGSTACK_VERSION
481
538
  },
482
- auth: {
483
- logout: {}
484
- }
485
- },
486
- agentInfo: {
487
- name: this.agentName,
488
- title: "WrongStack",
489
- version: WRONGSTACK_VERSION
490
- },
491
- authMethods: WRONGSTACK_AUTH_METHODS,
492
- modes: this.modes,
493
- configOptions: this.configOptions
494
- }
495
- }));
539
+ authMethods: WRONGSTACK_AUTH_METHODS,
540
+ modes: this.modes,
541
+ configOptions: this.configOptions
542
+ }
543
+ })
544
+ );
496
545
  return false;
497
546
  }
498
547
  async handleAuthenticate(id, _params) {
499
- await this.transport.send(toWire({
500
- jsonrpc: "2.0",
501
- id,
502
- result: { outcome: "unauthenticated" }
503
- }));
548
+ await this.transport.send(
549
+ toWire({
550
+ jsonrpc: "2.0",
551
+ id,
552
+ result: { outcome: "unauthenticated" }
553
+ })
554
+ );
504
555
  return false;
505
556
  }
506
557
  async handleLogout(id, _params) {
507
- await this.transport.send(toWire({
508
- jsonrpc: "2.0",
509
- id,
510
- result: {}
511
- }));
558
+ await this.transport.send(
559
+ toWire({
560
+ jsonrpc: "2.0",
561
+ id,
562
+ result: {}
563
+ })
564
+ );
512
565
  return false;
513
566
  }
514
567
  async handleSessionNew(id, params) {
@@ -517,7 +570,15 @@ var ACPProtocolHandler = class {
517
570
  return false;
518
571
  }
519
572
  const p = params ?? {};
520
- const cwd = typeof p.cwd === "string" ? p.cwd : this.defaultCwd;
573
+ let cwd = this.defaultCwd;
574
+ if (typeof p.cwd === "string") {
575
+ const resolved = await this.resolveSessionCwd(p.cwd);
576
+ if (resolved === null) {
577
+ await this.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
578
+ return false;
579
+ }
580
+ cwd = resolved;
581
+ }
521
582
  const sessionId = `sess_${this.allocId()}`;
522
583
  const now = (/* @__PURE__ */ new Date()).toISOString();
523
584
  const state = {
@@ -547,15 +608,17 @@ var ACPProtocolHandler = class {
547
608
  }
548
609
  });
549
610
  }
550
- await this.transport.send(toWire({
551
- jsonrpc: "2.0",
552
- id,
553
- result: {
554
- sessionId,
555
- modes: this.modes,
556
- configOptions: this.configOptions
557
- }
558
- }));
611
+ await this.transport.send(
612
+ toWire({
613
+ jsonrpc: "2.0",
614
+ id,
615
+ result: {
616
+ sessionId,
617
+ modes: this.modes,
618
+ configOptions: this.configOptions
619
+ }
620
+ })
621
+ );
559
622
  return false;
560
623
  }
561
624
  async handleSessionLoad(id, params) {
@@ -570,9 +633,15 @@ var ACPProtocolHandler = class {
570
633
  await this.sendError(id, -32e3, `active session limit reached (${this.maxSessions})`);
571
634
  return false;
572
635
  }
636
+ if (loadCwd !== void 0 && await this.resolveSessionCwd(loadCwd) === null) {
637
+ await this.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
638
+ return false;
639
+ }
640
+ const candidateCwd = persisted.cwd ?? loadCwd ?? this.defaultCwd;
641
+ const restoredCwd = await this.resolveSessionCwd(candidateCwd) ?? this.defaultCwd;
573
642
  const restored = {
574
643
  id: sessionId,
575
- cwd: persisted.cwd ?? loadCwd ?? this.defaultCwd,
644
+ cwd: restoredCwd,
576
645
  abort: new AbortController(),
577
646
  modeId: persisted.modeId ?? DEFAULT_MODE_ID,
578
647
  createdAt: persisted.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
@@ -588,13 +657,15 @@ var ACPProtocolHandler = class {
588
657
  sessionId,
589
658
  update: { sessionUpdate: "current_mode_update", modeId: restored.modeId }
590
659
  });
591
- await this.transport.send(toWire({
592
- jsonrpc: "2.0",
593
- id,
594
- result: {
595
- initialMode: { currentModeId: restored.modeId, availableModes: this.modes }
596
- }
597
- }));
660
+ await this.transport.send(
661
+ toWire({
662
+ jsonrpc: "2.0",
663
+ id,
664
+ result: {
665
+ initialMode: { currentModeId: restored.modeId, availableModes: this.modes }
666
+ }
667
+ })
668
+ );
598
669
  return false;
599
670
  }
600
671
  }
@@ -620,16 +691,18 @@ var ACPProtocolHandler = class {
620
691
  modeId: existing.modeId
621
692
  }
622
693
  });
623
- await this.transport.send(toWire({
624
- jsonrpc: "2.0",
625
- id,
626
- result: {
627
- initialMode: {
628
- currentModeId: existing.modeId,
629
- availableModes: this.modes
694
+ await this.transport.send(
695
+ toWire({
696
+ jsonrpc: "2.0",
697
+ id,
698
+ result: {
699
+ initialMode: {
700
+ currentModeId: existing.modeId,
701
+ availableModes: this.modes
702
+ }
630
703
  }
631
- }
632
- }));
704
+ })
705
+ );
633
706
  return false;
634
707
  }
635
708
  await this.sendError(id, -32e3, `session not found: ${sessionId}`);
@@ -641,16 +714,18 @@ var ACPProtocolHandler = class {
641
714
  const existing = sessionId ? this.sessions.get(sessionId) : void 0;
642
715
  if (existing) {
643
716
  existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
644
- await this.transport.send(toWire({
645
- jsonrpc: "2.0",
646
- id,
647
- result: {
648
- initialMode: {
649
- currentModeId: existing.modeId,
650
- availableModes: this.modes
717
+ await this.transport.send(
718
+ toWire({
719
+ jsonrpc: "2.0",
720
+ id,
721
+ result: {
722
+ initialMode: {
723
+ currentModeId: existing.modeId,
724
+ availableModes: this.modes
725
+ }
651
726
  }
652
- }
653
- }));
727
+ })
728
+ );
654
729
  return false;
655
730
  }
656
731
  await this.sendError(id, -32e3, `session not found: ${sessionId}`);
@@ -667,11 +742,13 @@ var ACPProtocolHandler = class {
667
742
  session.abort.abort();
668
743
  this.sessions.delete(sessionId);
669
744
  this.disposeSession(sessionId);
670
- await this.transport.send(toWire({
671
- jsonrpc: "2.0",
672
- id,
673
- result: {}
674
- }));
745
+ await this.transport.send(
746
+ toWire({
747
+ jsonrpc: "2.0",
748
+ id,
749
+ result: {}
750
+ })
751
+ );
675
752
  return false;
676
753
  }
677
754
  async handleSessionDelete(id, params) {
@@ -682,18 +759,22 @@ var ACPProtocolHandler = class {
682
759
  return false;
683
760
  }
684
761
  if (!this.sessions.has(sessionId)) {
685
- await this.transport.send(toWire({ jsonrpc: "2.0", id, result: { configOptions: [...this.configOptions] } }));
762
+ await this.transport.send(
763
+ toWire({ jsonrpc: "2.0", id, result: { configOptions: [...this.configOptions] } })
764
+ );
686
765
  return false;
687
766
  }
688
767
  const session = this.sessions.get(sessionId);
689
768
  session.abort.abort();
690
769
  this.sessions.delete(sessionId);
691
770
  this.disposeSession(sessionId);
692
- await this.transport.send(toWire({
693
- jsonrpc: "2.0",
694
- id,
695
- result: {}
696
- }));
771
+ await this.transport.send(
772
+ toWire({
773
+ jsonrpc: "2.0",
774
+ id,
775
+ result: {}
776
+ })
777
+ );
697
778
  return false;
698
779
  }
699
780
  async handleSessionFork(id, params) {
@@ -708,11 +789,20 @@ var ACPProtocolHandler = class {
708
789
  await this.sendError(id, -32e3, `active session limit reached (${this.maxSessions})`);
709
790
  return false;
710
791
  }
792
+ let forkCwd = source.cwd;
793
+ if (typeof p.cwd === "string") {
794
+ const resolved = await this.resolveSessionCwd(p.cwd);
795
+ if (resolved === null) {
796
+ await this.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
797
+ return false;
798
+ }
799
+ forkCwd = resolved;
800
+ }
711
801
  const now = (/* @__PURE__ */ new Date()).toISOString();
712
802
  const sessionId = `sess_${this.allocId()}`;
713
803
  const forked = {
714
804
  id: sessionId,
715
- cwd: typeof p.cwd === "string" ? p.cwd : source.cwd,
805
+ cwd: forkCwd,
716
806
  abort: new AbortController(),
717
807
  modeId: source.modeId,
718
808
  createdAt: now,
@@ -731,38 +821,48 @@ var ACPProtocolHandler = class {
731
821
  sessionId,
732
822
  update: { sessionUpdate: "current_mode_update", modeId: forked.modeId }
733
823
  });
734
- await this.transport.send(toWire({
735
- jsonrpc: "2.0",
736
- id,
737
- result: {
738
- sessionId,
739
- modes: this.modes,
740
- configOptions: this.configOptions
741
- }
742
- }));
824
+ await this.transport.send(
825
+ toWire({
826
+ jsonrpc: "2.0",
827
+ id,
828
+ result: {
829
+ sessionId,
830
+ modes: this.modes,
831
+ configOptions: this.configOptions
832
+ }
833
+ })
834
+ );
743
835
  return false;
744
836
  }
745
837
  async handleProvidersList(id, _params) {
746
- await this.transport.send(toWire({
747
- jsonrpc: "2.0",
748
- id,
749
- result: {
750
- providers: [],
751
- currentProviderId: null
752
- }
753
- }));
838
+ await this.transport.send(
839
+ toWire({
840
+ jsonrpc: "2.0",
841
+ id,
842
+ result: {
843
+ providers: [],
844
+ currentProviderId: null
845
+ }
846
+ })
847
+ );
754
848
  return false;
755
849
  }
756
850
  async handleProvidersSet(id, _params) {
757
- await this.sendError(id, -32e3, "provider configuration not available through ACP; use wstack auth");
851
+ await this.sendError(
852
+ id,
853
+ -32e3,
854
+ "provider configuration not available through ACP; use wstack auth"
855
+ );
758
856
  return false;
759
857
  }
760
858
  async handleProvidersDisable(id, _params) {
761
- await this.transport.send(toWire({
762
- jsonrpc: "2.0",
763
- id,
764
- result: {}
765
- }));
859
+ await this.transport.send(
860
+ toWire({
861
+ jsonrpc: "2.0",
862
+ id,
863
+ result: {}
864
+ })
865
+ );
766
866
  return false;
767
867
  }
768
868
  async handleMcpMessage(id, _params) {
@@ -815,7 +915,10 @@ var ACPProtocolHandler = class {
815
915
  const terminalId = created?.terminalId;
816
916
  if (!terminalId) return { output: "", exitCode: null };
817
917
  try {
818
- const exit = await this.request("terminal/wait_for_exit", { sessionId, terminalId });
918
+ const exit = await this.request("terminal/wait_for_exit", {
919
+ sessionId,
920
+ terminalId
921
+ });
819
922
  const out = await this.request("terminal/output", { sessionId, terminalId });
820
923
  return {
821
924
  output: String(out?.output ?? ""),
@@ -845,11 +948,13 @@ var ACPProtocolHandler = class {
845
948
  session.abort.signal.removeEventListener("abort", onCancel);
846
949
  session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
847
950
  await this.persist(session);
848
- await this.transport.send(toWire({
849
- jsonrpc: "2.0",
850
- id,
851
- result: { stopReason: result.stopReason }
852
- }));
951
+ await this.transport.send(
952
+ toWire({
953
+ jsonrpc: "2.0",
954
+ id,
955
+ result: { stopReason: result.stopReason }
956
+ })
957
+ );
853
958
  return false;
854
959
  }
855
960
  async handleSetMode(id, params) {
@@ -890,7 +995,9 @@ var ACPProtocolHandler = class {
890
995
  configOptions: [...this.configOptions]
891
996
  }
892
997
  });
893
- await this.transport.send(toWire({ jsonrpc: "2.0", id, result: { configOptions: [...this.configOptions] } }));
998
+ await this.transport.send(
999
+ toWire({ jsonrpc: "2.0", id, result: { configOptions: [...this.configOptions] } })
1000
+ );
894
1001
  return false;
895
1002
  }
896
1003
  async handleSessionList(id) {
@@ -903,11 +1010,13 @@ var ACPProtocolHandler = class {
903
1010
  if (s.title !== void 0) out.title = s.title;
904
1011
  return out;
905
1012
  });
906
- await this.transport.send(toWire({
907
- jsonrpc: "2.0",
908
- id,
909
- result: { sessions }
910
- }));
1013
+ await this.transport.send(
1014
+ toWire({
1015
+ jsonrpc: "2.0",
1016
+ id,
1017
+ result: { sessions }
1018
+ })
1019
+ );
911
1020
  return false;
912
1021
  }
913
1022
  // ────────────────────────────────────────────────────────────────────
@@ -953,8 +1062,54 @@ var ACPProtocolHandler = class {
953
1062
  if (data !== void 0) error.data = data;
954
1063
  await this.transport.send(toWire({ jsonrpc: "2.0", id, error }));
955
1064
  }
1065
+ /**
1066
+ * Allocate a session id (WS-015).
1067
+ *
1068
+ * This was `this.nextId++`, so ids were `sess_1`, `sess_2`, … — and the
1069
+ * handler has no per-connection ownership: any caller that names a session
1070
+ * id can `session/load`, `session/prompt`, `session/cancel` or
1071
+ * `session/delete` it. Over stdio that is academic (one client per process),
1072
+ * but the agent also serves over HTTP, where a guessable id is the whole
1073
+ * authorization story for any local process or page that reaches the port.
1074
+ *
1075
+ * Random ids do not create ownership — they remove the trivial enumeration
1076
+ * that made its absence exploitable. Real per-connection ownership is the
1077
+ * larger fix and is noted in the WS-015 test file.
1078
+ *
1079
+ * The counter is retained: it keeps ids ordered for debugging and guarantees
1080
+ * uniqueness within a process even in the (impossible) event of a UUID
1081
+ * collision. The random half is what makes the id unguessable.
1082
+ */
1083
+ /**
1084
+ * Resolve a client-supplied `cwd` for a session, or `null` when it is not
1085
+ * usable (WS-015).
1086
+ *
1087
+ * `session/new`, `session/load` and `session/fork` all took `params.cwd`
1088
+ * with a single `typeof === 'string'` check and nothing else. That value is
1089
+ * the working directory the agent then reads, writes and executes in.
1090
+ *
1091
+ * SCOPE, deliberately stated: this does NOT confine the session to a root.
1092
+ * In ACP the client IS the editor and legitimately names its own workspace —
1093
+ * Zed and JetBrains pass the project root — so a fixed boundary here would
1094
+ * break the integration this package exists for. What it enforces is that
1095
+ * the directory is absolute and actually exists as a directory: a relative
1096
+ * or missing `cwd` is a bug or an attack under either reading, and silently
1097
+ * running the agent somewhere other than where the client asked is worse
1098
+ * than refusing. Confinement, if wanted, belongs in an operator-set option
1099
+ * on top of this, not in place of it.
1100
+ */
1101
+ async resolveSessionCwd(requested) {
1102
+ if (!path.isAbsolute(requested)) return null;
1103
+ const resolved = path.resolve(requested);
1104
+ try {
1105
+ const stat2 = await fsp.stat(resolved);
1106
+ return stat2.isDirectory() ? resolved : null;
1107
+ } catch {
1108
+ return null;
1109
+ }
1110
+ }
956
1111
  allocId() {
957
- return this.nextId++;
1112
+ return `${this.nextId++}_${randomUUID().replaceAll("-", "")}`;
958
1113
  }
959
1114
  };
960
1115
  function errorToJsonRpc(err) {
@@ -974,8 +1129,10 @@ function errorToJsonRpc(err) {
974
1129
  }
975
1130
 
976
1131
  // src/agent/session-store.ts
977
- import * as fsp from "node:fs/promises";
978
- import * as path from "node:path";
1132
+ import * as fsp2 from "node:fs/promises";
1133
+ import * as path2 from "node:path";
1134
+ import { isSafePathSegment, resolveContainedPath } from "@wrongstack/core/utils";
1135
+ var RESERVED_SESSION_IDS = /* @__PURE__ */ new Set(["index"]);
979
1136
  var ACPSessionStore = class {
980
1137
  dir;
981
1138
  /**
@@ -985,13 +1142,53 @@ var ACPSessionStore = class {
985
1142
  * Cleared automatically if the directory disappears between calls.
986
1143
  */
987
1144
  initialized = false;
1145
+ /**
1146
+ * Tail of in-flight sidecar-index mutations. `readIndex`/`writeIndex`/
1147
+ * `updateIndex`/delete's index-touching branch all run through the
1148
+ * `withIndexLock` gate so a concurrent save/delete/save cannot read the
1149
+ * same baseline twice and clobber the other writer's edit (Chimera
1150
+ * HIGH — race that could permanently omit or resurrect sessions in
1151
+ * `list()`). Mirrors the `writeChains` pattern in
1152
+ * `packages/core/src/storage/tool-audit-log.ts`.
1153
+ */
1154
+ indexChain = Promise.resolve();
1155
+ /**
1156
+ * Monotonic per-store counter. Used to make tmp filenames unique so two
1157
+ * concurrent saves started in the same millisecond cannot collide on
1158
+ * `<target>.<pid>.<ts>.tmp` and lose one's tmp mid-write (latent bug
1159
+ * discovered by the concurrent same-id save/delete stress test).
1160
+ */
1161
+ writeSeq = 0;
988
1162
  constructor(opts = {}) {
989
- this.dir = opts.dir ?? path.join(process.cwd(), ".acp-sessions");
1163
+ this.dir = opts.dir ?? path2.join(process.cwd(), ".acp-sessions");
1164
+ }
1165
+ /**
1166
+ * Path of `<sessionId>.json` inside the store, or `null` when `sessionId`
1167
+ * is not usable as a single path segment (WS-015).
1168
+ *
1169
+ * The session id arrives from the ACP client — `session/load`,
1170
+ * `session/close` and `session/delete` all take it verbatim from the wire.
1171
+ * It was joined straight onto the store directory, so `../../..` escaped it,
1172
+ * and the `.json` suffix was the only thing narrowing the blast radius:
1173
+ *
1174
+ * - `load` — read any `.json` on the machine, e.g. the provider config
1175
+ * holding API keys
1176
+ * - `delete` — unlink any `.json` on the machine
1177
+ * - `save` — the worst one: a loaded session keeps the traversing id as
1178
+ * its `state.id`, so the next persist WRITES to that path
1179
+ *
1180
+ * All three now route through here, so the escape is closed once rather than
1181
+ * three times.
1182
+ */
1183
+ sessionFile(sessionId) {
1184
+ if (!isSafePathSegment(sessionId)) return null;
1185
+ if (RESERVED_SESSION_IDS.has(sessionId)) return null;
1186
+ return resolveContainedPath(this.dir, `${sessionId}.json`);
990
1187
  }
991
1188
  /** Ensure the store directory exists. Memoized — only mkdirs once. */
992
1189
  async init() {
993
1190
  if (this.initialized) return;
994
- await fsp.mkdir(this.dir, { recursive: true });
1191
+ await fsp2.mkdir(this.dir, { recursive: true });
995
1192
  this.initialized = true;
996
1193
  }
997
1194
  /**
@@ -1000,30 +1197,43 @@ var ACPSessionStore = class {
1000
1197
  * `session/load` replay.
1001
1198
  */
1002
1199
  async save(state, history) {
1200
+ const target = this.sessionFile(state.id);
1201
+ if (target === null) {
1202
+ throw new Error(`ACPSessionStore: refusing to persist unsafe session id "${state.id}"`);
1203
+ }
1003
1204
  await this.init();
1004
- const target = path.join(this.dir, `${state.id}.json`);
1005
- const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
1006
- await fsp.writeFile(
1007
- tmp,
1008
- JSON.stringify({
1009
- id: state.id,
1010
- cwd: state.cwd,
1011
- modeId: state.modeId,
1012
- createdAt: state.createdAt,
1013
- updatedAt: state.updatedAt,
1014
- title: state.title,
1015
- ...history && history.length > 0 ? { history } : {}
1016
- }),
1017
- "utf8"
1018
- );
1019
- await fsp.rename(tmp, target);
1205
+ const tmp = `${target}.${process.pid}.${Date.now()}.${++this.writeSeq}.tmp`;
1206
+ let renamed = false;
1207
+ try {
1208
+ await fsp2.writeFile(
1209
+ tmp,
1210
+ JSON.stringify({
1211
+ id: state.id,
1212
+ cwd: state.cwd,
1213
+ modeId: state.modeId,
1214
+ createdAt: state.createdAt,
1215
+ updatedAt: state.updatedAt,
1216
+ title: state.title,
1217
+ ...history && history.length > 0 ? { history } : {}
1218
+ }),
1219
+ "utf8"
1220
+ );
1221
+ await fsp2.rename(tmp, target);
1222
+ renamed = true;
1223
+ } finally {
1224
+ if (!renamed) {
1225
+ await fsp2.unlink(tmp).catch(() => void 0);
1226
+ }
1227
+ }
1020
1228
  await this.updateIndex(state.id, state.updatedAt);
1021
1229
  return state.id;
1022
1230
  }
1023
1231
  /** Load a persisted session (metadata + history) from disk, or null. */
1024
1232
  async load(sessionId) {
1233
+ const target = this.sessionFile(sessionId);
1234
+ if (target === null) return null;
1025
1235
  try {
1026
- const data = await fsp.readFile(path.join(this.dir, `${sessionId}.json`), "utf8");
1236
+ const data = await fsp2.readFile(target, "utf8");
1027
1237
  return JSON.parse(data);
1028
1238
  } catch {
1029
1239
  return null;
@@ -1035,9 +1245,20 @@ var ACPSessionStore = class {
1035
1245
  if (indexEntries !== null) {
1036
1246
  return indexEntries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
1037
1247
  }
1248
+ const sessions = await this.scanForSessions();
1249
+ void this.writeIndex(sessions).catch(() => void 0);
1250
+ return sessions;
1251
+ }
1252
+ /**
1253
+ * Walk `<dir>` and parse every session file into `{id, updatedAt}`
1254
+ * metadata. Used as the slow-path fallback by `list()` and by
1255
+ * `updateIndex()` when the sidecar index is missing. Pure read —
1256
+ * does NOT touch `index.json`.
1257
+ */
1258
+ async scanForSessions() {
1038
1259
  const files = [];
1039
1260
  try {
1040
- const entries = await fsp.readdir(this.dir);
1261
+ const entries = await fsp2.readdir(this.dir);
1041
1262
  for (const entry of entries) {
1042
1263
  if (entry.endsWith(".json") && entry !== "index.json") {
1043
1264
  files.push(entry);
@@ -1049,7 +1270,7 @@ var ACPSessionStore = class {
1049
1270
  const sessions = [];
1050
1271
  for (const file of files) {
1051
1272
  try {
1052
- const data = await fsp.readFile(path.join(this.dir, file), "utf8");
1273
+ const data = await fsp2.readFile(path2.join(this.dir, file), "utf8");
1053
1274
  const parsed = JSON.parse(data);
1054
1275
  if (parsed.id) {
1055
1276
  sessions.push({ id: parsed.id, updatedAt: parsed.updatedAt ?? "" });
@@ -1058,17 +1279,40 @@ var ACPSessionStore = class {
1058
1279
  }
1059
1280
  }
1060
1281
  sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
1061
- void this.writeIndex(sessions).catch(() => void 0);
1062
1282
  return sessions;
1063
1283
  }
1064
1284
  /** Sidecar path that stores `{id, updatedAt}` for every saved session. */
1065
1285
  indexPath() {
1066
- return path.join(this.dir, "index.json");
1286
+ return path2.join(this.dir, "index.json");
1287
+ }
1288
+ /**
1289
+ * Serialize sidecar-index mutations. Each call appends `fn` to the tail
1290
+ * of `indexChain` so reads and writes see a linearized order — a second
1291
+ * caller cannot start until the first one's index update has finished.
1292
+ *
1293
+ * The chain swallows errors (`prev.then(fn, fn)`) so one failing writer
1294
+ * does not poison every subsequent caller, and the catch keeps an
1295
+ * unhandled rejection from leaking out of the stored tail. The cleanup
1296
+ * removes the tail entry once settled, so a quiet store does not retain
1297
+ * stale promises forever.
1298
+ */
1299
+ withIndexLock(fn) {
1300
+ const prev = this.indexChain;
1301
+ const next = prev.then(fn, fn);
1302
+ const settled = next.then(
1303
+ () => void 0,
1304
+ () => void 0
1305
+ );
1306
+ this.indexChain = settled;
1307
+ void settled.finally(() => {
1308
+ if (this.indexChain === settled) this.indexChain = Promise.resolve();
1309
+ });
1310
+ return next;
1067
1311
  }
1068
1312
  /** Read the sidecar index. Returns `null` when missing or unreadable. */
1069
1313
  async readIndex() {
1070
1314
  try {
1071
- const data = await fsp.readFile(this.indexPath(), "utf8");
1315
+ const data = await fsp2.readFile(this.indexPath(), "utf8");
1072
1316
  const parsed = JSON.parse(data);
1073
1317
  if (!Array.isArray(parsed)) return null;
1074
1318
  const out = [];
@@ -1088,40 +1332,58 @@ var ACPSessionStore = class {
1088
1332
  /** Atomically replace the sidecar index with the supplied entries. */
1089
1333
  async writeIndex(entries) {
1090
1334
  const target = this.indexPath();
1091
- const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
1092
- await fsp.writeFile(tmp, JSON.stringify(entries), "utf8");
1093
- await fsp.rename(tmp, target);
1335
+ const tmp = `${target}.${process.pid}.${Date.now()}.${++this.writeSeq}.tmp`;
1336
+ let renamed = false;
1337
+ try {
1338
+ await fsp2.writeFile(tmp, JSON.stringify(entries), "utf8");
1339
+ await fsp2.rename(tmp, target);
1340
+ renamed = true;
1341
+ } finally {
1342
+ if (!renamed) {
1343
+ await fsp2.unlink(tmp).catch(() => void 0);
1344
+ }
1345
+ }
1094
1346
  }
1095
1347
  /** Update one entry in the index, adding it if missing. Best-effort. */
1096
1348
  async updateIndex(id, updatedAt) {
1097
- const entries = await this.readIndex();
1098
- if (entries === null) {
1099
- await this.list();
1100
- return;
1101
- }
1102
- const i = entries.findIndex((e) => e.id === id);
1103
- if (i >= 0) entries[i] = { id, updatedAt };
1104
- else entries.push({ id, updatedAt });
1105
- try {
1106
- await this.writeIndex(entries);
1107
- } catch {
1108
- }
1349
+ await this.withIndexLock(async () => {
1350
+ const entries = await this.readIndex();
1351
+ if (entries === null) {
1352
+ const sessions = await this.scanForSessions();
1353
+ try {
1354
+ await this.writeIndex(sessions);
1355
+ } catch {
1356
+ }
1357
+ return;
1358
+ }
1359
+ const i = entries.findIndex((e) => e.id === id);
1360
+ if (i >= 0) entries[i] = { id, updatedAt };
1361
+ else entries.push({ id, updatedAt });
1362
+ try {
1363
+ await this.writeIndex(entries);
1364
+ } catch {
1365
+ }
1366
+ });
1109
1367
  }
1110
1368
  /** Delete a session file. */
1111
1369
  async delete(sessionId) {
1370
+ const target = this.sessionFile(sessionId);
1371
+ if (target === null) return;
1112
1372
  try {
1113
- await fsp.unlink(path.join(this.dir, `${sessionId}.json`));
1373
+ await fsp2.unlink(target);
1114
1374
  } catch {
1115
1375
  }
1116
- const entries = await this.readIndex();
1117
- if (entries === null) return;
1118
- const next = entries.filter((e) => e.id !== sessionId);
1119
- if (next.length !== entries.length) {
1120
- try {
1121
- await this.writeIndex(next);
1122
- } catch {
1376
+ await this.withIndexLock(async () => {
1377
+ const entries = await this.readIndex();
1378
+ if (entries === null) return;
1379
+ const next = entries.filter((e) => e.id !== sessionId);
1380
+ if (next.length !== entries.length) {
1381
+ try {
1382
+ await this.writeIndex(next);
1383
+ } catch {
1384
+ }
1123
1385
  }
1124
- }
1386
+ });
1125
1387
  }
1126
1388
  /** Get the store directory path. */
1127
1389
  getDirectory() {
@@ -1338,12 +1600,12 @@ var WrongStackACPServer = class {
1338
1600
  res.end(JSON.stringify({ error: { code: -32603, message: "Internal error" } }));
1339
1601
  }
1340
1602
  });
1341
- return new Promise((resolve) => {
1603
+ return new Promise((resolve2) => {
1342
1604
  this.httpServer.listen(port, host, () => {
1343
1605
  writeErr2(`[wstack-acp] HTTP server listening on http://${host}:${port}
1344
1606
  `);
1345
1607
  this.running = true;
1346
- resolve();
1608
+ resolve2();
1347
1609
  });
1348
1610
  });
1349
1611
  }
@@ -1591,9 +1853,9 @@ function toolNameToKind(name) {
1591
1853
  }
1592
1854
  function toolTitle(name, input) {
1593
1855
  if (isRecord(input)) {
1594
- const path2 = input.path ?? input.file ?? input.filePath ?? input.pattern ?? input.command;
1595
- if (typeof path2 === "string" && path2.length > 0) {
1596
- return `${name}: ${path2.length > 80 ? `${path2.slice(0, 77)}\u2026` : path2}`;
1856
+ const path3 = input.path ?? input.file ?? input.filePath ?? input.pattern ?? input.command;
1857
+ if (typeof path3 === "string" && path3.length > 0) {
1858
+ return `${name}: ${path3.length > 80 ? `${path3.slice(0, 77)}\u2026` : path3}`;
1597
1859
  }
1598
1860
  }
1599
1861
  return name;