@wrongstack/acp 1.0.27 → 1.0.29

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.
@@ -162,8 +162,10 @@ declare function isRecord(v: unknown): v is Record<string, unknown>;
162
162
  * When the prompt is all text we return a plain string (the common,
163
163
  * cheapest path). When it carries images we build a multimodal
164
164
  * `ContentBlock[]` the provider can pass to a vision-capable model.
165
- * Audio and resource blocks have no core representation yet, so they're
166
- * recorded as bracketed text placeholders alongside the other content.
165
+ * Images go through core's shared ingest (allowlist, base64, size and count
166
+ * caps, the bytes decide the media type): the client's label used to reach
167
+ * the provider unchecked. Every other block renders as in the text path, so
168
+ * an embedded resource keeps its `[embedded resource: <uri>]` header.
167
169
  */
168
170
  declare function promptToAgentInput(blocks: readonly ContentBlock[]): AgentInput;
169
171
  /**
@@ -55,6 +55,13 @@ export interface ACPClientTransport {
55
55
  * message stream via onMessage, so claim semantics are not needed.
56
56
  */
57
57
  onMessageClaim?(handler: (msg: ACPMessage) => boolean): () => void;
58
+ /**
59
+ * Called once when the connection ends for any reason (the agent exited,
60
+ * the socket closed, a frame limit tripped, stop()). Optional for custom
61
+ * transports; without it a client learns of a dead agent only when each
62
+ * in-flight request times out.
63
+ */
64
+ onClose?(handler: (reason: string) => void): () => void;
58
65
  stop(): void;
59
66
  }
60
67
  export declare class StdioTransport implements AgentServerTransport {
@@ -134,6 +141,7 @@ export declare class ClientTransport implements ACPClientTransport {
134
141
  private buffer;
135
142
  private readonly handlers;
136
143
  private readonly claimHandlers;
144
+ private readonly closeHandlers;
137
145
  private closed;
138
146
  private resolveRead;
139
147
  private messageQueue;
@@ -158,7 +166,10 @@ export declare class ClientTransport implements ACPClientTransport {
158
166
  * the gating logic.
159
167
  */
160
168
  onMessageClaim(handler: (msg: ACPMessage) => boolean): () => void;
161
- stop(): void;
169
+ onClose(handler: (reason: string) => void): () => void;
170
+ /** Tell close listeners once; the handlers are dropped with the connection. */
171
+ private notifyClosed;
172
+ stop(reason?: string): void;
162
173
  private onChildData;
163
174
  private onChildError;
164
175
  private onChildClose;
package/dist/agent.js CHANGED
@@ -294,7 +294,7 @@ async function handleSessionLoadOp(ctx, id, params, replayHistory = true) {
294
294
  await ctx.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
295
295
  return false;
296
296
  }
297
- const candidateCwd = persisted.cwd ?? loadCwd ?? ctx.defaultCwd;
297
+ const candidateCwd = loadCwd ?? persisted.cwd ?? ctx.defaultCwd;
298
298
  const restoredCwd = await resolveSessionCwd(candidateCwd) ?? ctx.defaultCwd;
299
299
  const restored = {
300
300
  id: sessionId,
@@ -475,6 +475,7 @@ async function handleSessionPromptOp(ctx, id, params) {
475
475
  let result;
476
476
  const pendingNotifications = [];
477
477
  const emit = (update) => {
478
+ if (ctx.sessions.get(sessionId) !== session) return;
478
479
  const notifPromise = ctx.sendNotification({ sessionId, update });
479
480
  pendingNotifications.push(notifPromise.catch(() => {
480
481
  }));
@@ -534,6 +535,7 @@ async function handleSetModeOp(ctx, id, params) {
534
535
  modeId
535
536
  }
536
537
  });
538
+ if (ctx.sessions.get(session.id) === session) await ctx.persist(session);
537
539
  await ctx.sendResult(id, {});
538
540
  return false;
539
541
  }
@@ -559,7 +561,7 @@ async function handleSetConfigOptionOp(ctx, id, params) {
559
561
  configOptions: options
560
562
  }
561
563
  });
562
- await ctx.persist(session);
564
+ if (ctx.sessions.get(session.id) === session) await ctx.persist(session);
563
565
  await ctx.sendResult(id, { configOptions: options });
564
566
  return false;
565
567
  }
@@ -976,6 +978,7 @@ var ACPProtocolHandler = class {
976
978
  };
977
979
 
978
980
  // src/agent/server-agent-turn.ts
981
+ import { parseIncomingImages } from "@wrongstack/core/utils";
979
982
  function makeACPServerAgentTurn(opts) {
980
983
  const agents = /* @__PURE__ */ new Map();
981
984
  const timeouts = /* @__PURE__ */ new Map();
@@ -987,6 +990,7 @@ function makeACPServerAgentTurn(opts) {
987
990
  const maxHistoryBytes = finitePositiveLimit(opts.maxHistoryBytes, 8 * 1024 * 1024);
988
991
  const deliveryNoticeDebounceMs = finiteNonNegativeLimit(opts.deliveryNoticeDebounceMs, 1500);
989
992
  const notifiers = /* @__PURE__ */ new Map();
993
+ const liveTurns = /* @__PURE__ */ new Map();
990
994
  const stillPending = (sessionId) => {
991
995
  if (!opts.pendingDeliveries) return true;
992
996
  try {
@@ -1056,6 +1060,9 @@ function makeACPServerAgentTurn(opts) {
1056
1060
  return state;
1057
1061
  };
1058
1062
  const turn = async (input, emit, api) => {
1063
+ const token = Symbol(input.sessionId);
1064
+ liveTurns.set(input.sessionId, token);
1065
+ const alive = () => liveTurns.get(input.sessionId) === token;
1059
1066
  let agent = agents.get(input.sessionId);
1060
1067
  if (!agent) {
1061
1068
  agent = await opts.agentFor(
@@ -1064,6 +1071,12 @@ function makeACPServerAgentTurn(opts) {
1064
1071
  api,
1065
1072
  input.mcpServers
1066
1073
  );
1074
+ if (!alive()) {
1075
+ const stale = agent;
1076
+ void Promise.resolve().then(() => stale.teardown?.()).catch(() => {
1077
+ });
1078
+ return { stopReason: "cancelled" };
1079
+ }
1067
1080
  agents.set(input.sessionId, agent);
1068
1081
  if (pendingSeed.has(input.sessionId)) {
1069
1082
  pendingSeed.delete(input.sessionId);
@@ -1129,10 +1142,11 @@ function makeACPServerAgentTurn(opts) {
1129
1142
  content: { type: "text", text }
1130
1143
  });
1131
1144
  }
1145
+ const record = alive();
1132
1146
  const userText = promptToText(input.prompt);
1133
1147
  const hist = history.get(input.sessionId) ?? [];
1134
1148
  let retainedHistoryBytes = historyBytes.get(input.sessionId) ?? 0;
1135
- if (userText) {
1149
+ if (record && userText) {
1136
1150
  const update = {
1137
1151
  sessionUpdate: "user_message_chunk",
1138
1152
  content: { type: "text", text: userText }
@@ -1140,7 +1154,7 @@ function makeACPServerAgentTurn(opts) {
1140
1154
  hist.push(update);
1141
1155
  retainedHistoryBytes += replayEntryBytes(update);
1142
1156
  }
1143
- if (text) {
1157
+ if (record && text) {
1144
1158
  const update = {
1145
1159
  sessionUpdate: "agent_message_chunk",
1146
1160
  content: { type: "text", text }
@@ -1188,6 +1202,7 @@ function makeACPServerAgentTurn(opts) {
1188
1202
  if (usage) result_out.usage = usage;
1189
1203
  return result_out;
1190
1204
  } finally {
1205
+ if (alive()) liveTurns.delete(input.sessionId);
1191
1206
  clearTimeout(timer);
1192
1207
  timeouts.delete(input.sessionId);
1193
1208
  input.signal.removeEventListener("abort", onParentAbort);
@@ -1214,6 +1229,7 @@ function makeACPServerAgentTurn(opts) {
1214
1229
  pendingSeed.add(sessionId);
1215
1230
  };
1216
1231
  const dispose = (sessionId) => {
1232
+ liveTurns.delete(sessionId);
1217
1233
  const timer = timeouts.get(sessionId);
1218
1234
  if (timer) clearTimeout(timer);
1219
1235
  timeouts.delete(sessionId);
@@ -1322,26 +1338,24 @@ function isRecord(v) {
1322
1338
  return typeof v === "object" && v !== null && !Array.isArray(v);
1323
1339
  }
1324
1340
  function promptToAgentInput(blocks) {
1325
- const hasImage = blocks.some((b) => b.type === "image");
1326
- if (!hasImage) {
1341
+ const images = blocks.filter((b) => b.type === "image");
1342
+ if (images.length === 0) {
1327
1343
  return promptToText(blocks);
1328
1344
  }
1345
+ let parsed;
1346
+ try {
1347
+ parsed = parseIncomingImages(images.map((b) => ({ data: b.data, mediaType: b.mimeType })));
1348
+ } catch (err) {
1349
+ throw Object.assign(new Error(err.message), { code: -32602 });
1350
+ }
1329
1351
  const out = [];
1352
+ let next = 0;
1330
1353
  for (const b of blocks) {
1331
- if (b.type === "text") {
1332
- out.push({ type: "text", text: b.text });
1333
- } else if (b.type === "image") {
1334
- out.push({
1335
- type: "image",
1336
- source: { type: "base64", media_type: b.mimeType, data: b.data }
1337
- });
1338
- } else if (b.type === "audio") {
1339
- out.push({ type: "text", text: `[audio: ${b.mimeType}]` });
1340
- } else if (b.type === "resource") {
1341
- const text = "text" in b.resource && typeof b.resource.text === "string" ? b.resource.text : `[embedded resource: ${b.resource.uri}]`;
1342
- out.push({ type: "text", text });
1343
- } else if (b.type === "resource_link") {
1344
- out.push({ type: "text", text: `[resource link: ${b.uri}]` });
1354
+ if (b.type === "image") out.push(parsed[next++]);
1355
+ else if (b.type === "text") out.push({ type: "text", text: b.text });
1356
+ else {
1357
+ const text = promptToText([b]);
1358
+ if (text) out.push({ type: "text", text });
1345
1359
  }
1346
1360
  }
1347
1361
  return out;
@@ -2096,16 +2110,17 @@ var WrongStackACPServer = class {
2096
2110
  return;
2097
2111
  }
2098
2112
  const MAX_HTTP_BODY = 10 * 1024 * 1024;
2099
- let body = "";
2113
+ const chunks = [];
2100
2114
  let bodyBytes = 0;
2101
2115
  let tooLarge = false;
2102
2116
  for await (const chunk of req) {
2103
2117
  bodyBytes += chunk.length;
2104
2118
  if (bodyBytes > MAX_HTTP_BODY) {
2105
2119
  tooLarge = true;
2106
- break;
2120
+ chunks.length = 0;
2121
+ if (bodyBytes > 2 * MAX_HTTP_BODY) break;
2107
2122
  }
2108
- body += chunk;
2123
+ if (!tooLarge) chunks.push(chunk);
2109
2124
  }
2110
2125
  if (tooLarge) {
2111
2126
  res.writeHead(413, { "Content-Type": "application/json" });
@@ -2114,7 +2129,7 @@ var WrongStackACPServer = class {
2114
2129
  }
2115
2130
  let msg;
2116
2131
  try {
2117
- msg = JSON.parse(body);
2132
+ msg = JSON.parse(Buffer.concat(chunks).toString("utf8"));
2118
2133
  } catch {
2119
2134
  res.writeHead(400);
2120
2135
  res.end(JSON.stringify({ error: { code: -32700, message: "Parse error" } }));
@@ -6,4 +6,11 @@ export interface PendingRequest {
6
6
  timeoutHandle: ReturnType<typeof setTimeout>;
7
7
  }
8
8
  export type State = 'init' | 'ready' | 'authenticated' | 'sessioning' | 'prompting' | 'done' | 'closed';
9
+ /**
10
+ * Fail and forget every in-flight request. Without a `reason` the session was
11
+ * closed locally; with one, the agent connection ended (the process exited,
12
+ * the socket closed), so no reply can arrive and waiting out each request's
13
+ * timeout would only stall the caller.
14
+ */
15
+ export declare function rejectPendingRequests(pending: Map<string | number, PendingRequest>, reason?: string): void;
9
16
  //# sourceMappingURL=acp-request-state.d.ts.map
@@ -31,6 +31,7 @@ export interface WebSocketClientTransportOptions {
31
31
  export declare class WebSocketClientTransport implements ACPClientTransport {
32
32
  private ws;
33
33
  private readonly handlers;
34
+ private readonly closeHandlers;
34
35
  private closed;
35
36
  private readonly opts;
36
37
  private readonly maxBufferedBytes;
@@ -41,7 +42,10 @@ export declare class WebSocketClientTransport implements ACPClientTransport {
41
42
  start(): Promise<void>;
42
43
  send(msg: ACPMessage): Promise<void>;
43
44
  onMessage(handler: (msg: ACPMessage) => void): () => void;
44
- stop(): void;
45
+ onClose(handler: (reason: string) => void): () => void;
46
+ /** Tell close listeners once; the handlers are dropped with the connection. */
47
+ private notifyClosed;
48
+ stop(reason?: string): void;
45
49
  private onData;
46
50
  private dispatch;
47
51
  }
package/dist/client.js CHANGED
@@ -29,6 +29,7 @@ var ClientTransport = class {
29
29
  buffer = "";
30
30
  handlers = /* @__PURE__ */ new Set();
31
31
  claimHandlers = /* @__PURE__ */ new Set();
32
+ closeHandlers = /* @__PURE__ */ new Set();
32
33
  closed = false;
33
34
  resolveRead = null;
34
35
  messageQueue = [];
@@ -136,6 +137,7 @@ var ClientTransport = class {
136
137
  }
137
138
  send(msg) {
138
139
  if (!this.child) return Promise.reject(new Error("ClientTransport not started"));
140
+ if (this.closed) return Promise.reject(new Error("ClientTransport is closed"));
139
141
  return new Promise((resolve3, reject) => {
140
142
  const line = JSON.stringify(msg) + "\n";
141
143
  this.child?.stdin.write(line, "utf8", (err) => {
@@ -173,7 +175,22 @@ var ClientTransport = class {
173
175
  this.claimHandlers.add(handler);
174
176
  return () => this.claimHandlers.delete(handler);
175
177
  }
176
- stop() {
178
+ onClose(handler) {
179
+ this.closeHandlers.add(handler);
180
+ return () => this.closeHandlers.delete(handler);
181
+ }
182
+ /** Tell close listeners once; the handlers are dropped with the connection. */
183
+ notifyClosed(reason) {
184
+ const handlers = [...this.closeHandlers];
185
+ this.closeHandlers.clear();
186
+ for (const handler of handlers) {
187
+ try {
188
+ handler(reason);
189
+ } catch {
190
+ }
191
+ }
192
+ }
193
+ stop(reason = "transport stopped") {
177
194
  this.closed = true;
178
195
  this.resolveRead?.(null);
179
196
  this.resolveRead = null;
@@ -182,6 +199,7 @@ var ClientTransport = class {
182
199
  this.queuedChars = 0;
183
200
  this.handlers.clear();
184
201
  this.claimHandlers.clear();
202
+ this.notifyClosed(reason);
185
203
  const child = this.child;
186
204
  if (!child) return;
187
205
  treeKill(child);
@@ -193,7 +211,7 @@ var ClientTransport = class {
193
211
  if (this.buffer.length > this.maxFrameChars) {
194
212
  writeErr(`[acp-child pending frame exceeds ${this.maxFrameChars} characters]
195
213
  `);
196
- this.stop();
214
+ this.stop(`agent frame exceeds ${this.maxFrameChars} characters`);
197
215
  return;
198
216
  }
199
217
  for (const raw of lines) {
@@ -201,7 +219,7 @@ var ClientTransport = class {
201
219
  if (raw.length > this.maxFrameChars) {
202
220
  writeErr(`[acp-child frame exceeds ${this.maxFrameChars} characters]
203
221
  `);
204
- this.stop();
222
+ this.stop(`agent frame exceeds ${this.maxFrameChars} characters`);
205
223
  return;
206
224
  }
207
225
  try {
@@ -227,6 +245,7 @@ var ClientTransport = class {
227
245
  writeErr(`[acp-child exited with code ${code}]
228
246
  `);
229
247
  }
248
+ this.notifyClosed(`agent process exited with code ${code}`);
230
249
  }
231
250
  dispatch(msg, chars = JSON.stringify(msg).length) {
232
251
  if (this.resolveRead) {
@@ -373,6 +392,16 @@ function isAuthRequiredError(err) {
373
392
  return false;
374
393
  }
375
394
 
395
+ // src/client/acp-request-state.ts
396
+ function rejectPendingRequests(pending, reason) {
397
+ for (const p of pending.values()) {
398
+ clearTimeout(p.timeoutHandle);
399
+ const message = reason === void 0 ? "session was closed" : `agent connection closed (${reason}) during ${p.method}`;
400
+ p.reject(new ACPSessionError("closed", message));
401
+ }
402
+ pending.clear();
403
+ }
404
+
376
405
  // src/client/acp-session-ops.ts
377
406
  function filterMcpServers(agentCapabilities, servers) {
378
407
  if (!servers || servers.length === 0) return [];
@@ -1650,7 +1679,7 @@ function isAllowed(decision) {
1650
1679
  }
1651
1680
  function toTrustBoundaryRequest(request, options) {
1652
1681
  const rawSessionId = request.toolCall.rawInput?.sessionId;
1653
- const sessionId = typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : options.actor?.sessionId;
1682
+ const sessionId = options.actor?.sessionId ?? (typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : void 0);
1654
1683
  return {
1655
1684
  version: 1,
1656
1685
  requestId: String(request.toolCall.toolCallId),
@@ -1686,6 +1715,7 @@ function makeTrustBoundaryPermissionPolicy(options) {
1686
1715
  var WebSocketClientTransport = class {
1687
1716
  ws = null;
1688
1717
  handlers = /* @__PURE__ */ new Set();
1718
+ closeHandlers = /* @__PURE__ */ new Set();
1689
1719
  closed = false;
1690
1720
  opts;
1691
1721
  maxBufferedBytes;
@@ -1712,6 +1742,7 @@ var WebSocketClientTransport = class {
1712
1742
  const timeoutMs = this.opts.handshakeTimeoutMs ?? 3e4;
1713
1743
  return new Promise((resolve3, reject) => {
1714
1744
  const ws = new WS(this.opts.url, this.opts.protocols);
1745
+ ws.binaryType = "arraybuffer";
1715
1746
  this.ws = ws;
1716
1747
  const timer = setTimeout(() => {
1717
1748
  const pending = this.pendingStart;
@@ -1737,7 +1768,7 @@ var WebSocketClientTransport = class {
1737
1768
  ws.addEventListener("error", (ev) => {
1738
1769
  const pending = this.pendingStart;
1739
1770
  if (pending === null) {
1740
- this.stop();
1771
+ this.stop("WebSocket error");
1741
1772
  return;
1742
1773
  }
1743
1774
  this.pendingStart = null;
@@ -1758,6 +1789,7 @@ var WebSocketClientTransport = class {
1758
1789
  clearTimeout(pending.timer);
1759
1790
  pending.reject(new Error("WebSocket closed before the connection opened"));
1760
1791
  }
1792
+ this.notifyClosed("WebSocket closed");
1761
1793
  });
1762
1794
  ws.addEventListener("message", (ev) => {
1763
1795
  this.onData(ev.data);
@@ -1785,9 +1817,25 @@ var WebSocketClientTransport = class {
1785
1817
  this.handlers.add(handler);
1786
1818
  return () => this.handlers.delete(handler);
1787
1819
  }
1788
- stop() {
1820
+ onClose(handler) {
1821
+ this.closeHandlers.add(handler);
1822
+ return () => this.closeHandlers.delete(handler);
1823
+ }
1824
+ /** Tell close listeners once; the handlers are dropped with the connection. */
1825
+ notifyClosed(reason) {
1826
+ const handlers = [...this.closeHandlers];
1827
+ this.closeHandlers.clear();
1828
+ for (const handler of handlers) {
1829
+ try {
1830
+ handler(reason);
1831
+ } catch {
1832
+ }
1833
+ }
1834
+ }
1835
+ stop(reason = "transport stopped") {
1789
1836
  this.closed = true;
1790
1837
  this.handlers.clear();
1838
+ this.notifyClosed(reason);
1791
1839
  if (this.pendingStart !== null) {
1792
1840
  const pending = this.pendingStart;
1793
1841
  this.pendingStart = null;
@@ -1808,7 +1856,7 @@ var WebSocketClientTransport = class {
1808
1856
  onData(data) {
1809
1857
  const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : Buffer.isBuffer(data) ? data.toString("utf8") : String(data);
1810
1858
  if (text.length > this.maxMessageChars) {
1811
- this.stop();
1859
+ this.stop(`agent message exceeds ${this.maxMessageChars} characters`);
1812
1860
  return;
1813
1861
  }
1814
1862
  if (!text.trim()) return;
@@ -1977,6 +2025,10 @@ var ACPSession = class _ACPSession {
1977
2025
  }
1978
2026
  const session = new _ACPSession(opts, transport);
1979
2027
  session.transportOff = transport.onMessage((msg) => session.handleMessage(msg));
2028
+ transport.onClose?.((reason) => {
2029
+ if (!session.closed) rejectPendingRequests(session.pending, reason);
2030
+ void session.close();
2031
+ });
1980
2032
  try {
1981
2033
  await session.initialize();
1982
2034
  } catch (err) {
@@ -2103,6 +2155,9 @@ var ACPSession = class _ACPSession {
2103
2155
  if (this.state !== "ready" && this.state !== "authenticated" && this.state !== "done") {
2104
2156
  throw new ACPSessionError("protocol_error", `prompt called in state=${this.state}`);
2105
2157
  }
2158
+ if (this.promptCallbackAbort) {
2159
+ throw new ACPSessionError("protocol_error", "prompt called while another prompt is running");
2160
+ }
2106
2161
  if (signal.aborted) {
2107
2162
  return emptyRunResult("cancelled");
2108
2163
  }
@@ -2144,10 +2199,6 @@ var ACPSession = class _ACPSession {
2144
2199
  createPromise,
2145
2200
  new Promise((_, reject) => {
2146
2201
  rejectCreate = reject;
2147
- if (signal.aborted) {
2148
- reject(new ACPSessionError("aborted", "prompt was aborted by the parent"));
2149
- return;
2150
- }
2151
2202
  signal.addEventListener("abort", onCreateAbort, { once: true });
2152
2203
  })
2153
2204
  ]);
@@ -2165,15 +2216,10 @@ var ACPSession = class _ACPSession {
2165
2216
  }
2166
2217
  signal.removeEventListener("abort", onCreateAbort);
2167
2218
  rejectCreate = void 0;
2168
- if (typeof sessionId !== "string" || sessionId.length === 0) {
2169
- signal.removeEventListener("abort", onAbort);
2170
- this.promptCallbackAbort?.abort();
2171
- this.promptCallbackAbort = null;
2172
- throw new ACPSessionError("protocol_error", "session/new returned no session id");
2173
- }
2174
2219
  this.sessionId = sessionId;
2175
2220
  }
2176
2221
  if (signal.aborted) {
2222
+ this.promptCallbackAbort = null;
2177
2223
  return emptyRunResult("cancelled");
2178
2224
  }
2179
2225
  this.resetScratch();
@@ -2281,11 +2327,7 @@ var ACPSession = class _ACPSession {
2281
2327
  } catch {
2282
2328
  }
2283
2329
  }
2284
- for (const [, p] of this.pending) {
2285
- clearTimeout(p.timeoutHandle);
2286
- p.reject(new ACPSessionError("closed", "session was closed"));
2287
- }
2288
- this.pending.clear();
2330
+ rejectPendingRequests(this.pending);
2289
2331
  this.transportOff?.();
2290
2332
  this.transportOff = null;
2291
2333
  try {
@@ -2384,6 +2426,8 @@ var ACPSession = class _ACPSession {
2384
2426
  return;
2385
2427
  }
2386
2428
  if (msg.method === "session/update") {
2429
+ const updateSessionId = msg.params?.sessionId;
2430
+ if (typeof updateSessionId === "string" && updateSessionId !== this.sessionId) return;
2387
2431
  handleAcpSessionUpdate(msg, this.scratch, (event) => this.emitProgress(event));
2388
2432
  return;
2389
2433
  }
@@ -2501,6 +2545,7 @@ async function makeACPSubagentRunnerWithStop(options) {
2501
2545
  const timeoutMs = options.timeoutMs ?? 5 * 6e4;
2502
2546
  const persistent = options.persistent === true;
2503
2547
  let shared = null;
2548
+ let turns = Promise.resolve();
2504
2549
  const startSession = async () => {
2505
2550
  return ACPSession.start({
2506
2551
  command: options.command,
@@ -2516,10 +2561,17 @@ async function makeACPSubagentRunnerWithStop(options) {
2516
2561
  };
2517
2562
  const runner = async (task, ctx) => {
2518
2563
  let session;
2519
- const reuse = persistent && shared !== null;
2520
2564
  try {
2521
- session = reuse ? shared : await startSession();
2522
- if (persistent) shared = session;
2565
+ if (persistent) {
2566
+ shared ??= startSession();
2567
+ const starting = shared;
2568
+ session = await starting.catch((err) => {
2569
+ if (shared === starting) shared = null;
2570
+ throw err;
2571
+ });
2572
+ } else {
2573
+ session = await startSession();
2574
+ }
2523
2575
  } catch (err) {
2524
2576
  throw acpErrorToSubagentError(err, options.role ?? "acp-subagent");
2525
2577
  }
@@ -2536,7 +2588,10 @@ async function makeACPSubagentRunnerWithStop(options) {
2536
2588
  };
2537
2589
  let result;
2538
2590
  try {
2539
- result = await session.prompt([textContent(task.description)], ctx.signal, onProgress);
2591
+ const turn = () => session.prompt([textContent(task.description)], ctx.signal, onProgress);
2592
+ const run = persistent ? turns.then(turn, turn) : turn();
2593
+ if (persistent) turns = run;
2594
+ result = await run;
2540
2595
  } catch (err) {
2541
2596
  throw acpErrorToSubagentError(err, options.role ?? "acp-subagent");
2542
2597
  } finally {
@@ -2557,10 +2612,10 @@ async function makeACPSubagentRunnerWithStop(options) {
2557
2612
  };
2558
2613
  const stop = async () => {
2559
2614
  if (shared) {
2560
- const s = shared;
2615
+ const starting = shared;
2561
2616
  shared = null;
2562
2617
  try {
2563
- await s.close();
2618
+ await (await starting).close();
2564
2619
  } catch {
2565
2620
  }
2566
2621
  }
package/dist/index.js CHANGED
@@ -297,7 +297,7 @@ async function handleSessionLoadOp(ctx, id, params, replayHistory = true) {
297
297
  await ctx.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
298
298
  return false;
299
299
  }
300
- const candidateCwd = persisted.cwd ?? loadCwd ?? ctx.defaultCwd;
300
+ const candidateCwd = loadCwd ?? persisted.cwd ?? ctx.defaultCwd;
301
301
  const restoredCwd = await resolveSessionCwd(candidateCwd) ?? ctx.defaultCwd;
302
302
  const restored = {
303
303
  id: sessionId,
@@ -478,6 +478,7 @@ async function handleSessionPromptOp(ctx, id, params) {
478
478
  let result;
479
479
  const pendingNotifications = [];
480
480
  const emit = (update) => {
481
+ if (ctx.sessions.get(sessionId) !== session) return;
481
482
  const notifPromise = ctx.sendNotification({ sessionId, update });
482
483
  pendingNotifications.push(notifPromise.catch(() => {
483
484
  }));
@@ -537,6 +538,7 @@ async function handleSetModeOp(ctx, id, params) {
537
538
  modeId
538
539
  }
539
540
  });
541
+ if (ctx.sessions.get(session.id) === session) await ctx.persist(session);
540
542
  await ctx.sendResult(id, {});
541
543
  return false;
542
544
  }
@@ -562,7 +564,7 @@ async function handleSetConfigOptionOp(ctx, id, params) {
562
564
  configOptions: options
563
565
  }
564
566
  });
565
- await ctx.persist(session);
567
+ if (ctx.sessions.get(session.id) === session) await ctx.persist(session);
566
568
  await ctx.sendResult(id, { configOptions: options });
567
569
  return false;
568
570
  }
@@ -1170,6 +1172,7 @@ var ClientTransport = class {
1170
1172
  buffer = "";
1171
1173
  handlers = /* @__PURE__ */ new Set();
1172
1174
  claimHandlers = /* @__PURE__ */ new Set();
1175
+ closeHandlers = /* @__PURE__ */ new Set();
1173
1176
  closed = false;
1174
1177
  resolveRead = null;
1175
1178
  messageQueue = [];
@@ -1277,6 +1280,7 @@ var ClientTransport = class {
1277
1280
  }
1278
1281
  send(msg) {
1279
1282
  if (!this.child) return Promise.reject(new Error("ClientTransport not started"));
1283
+ if (this.closed) return Promise.reject(new Error("ClientTransport is closed"));
1280
1284
  return new Promise((resolve5, reject) => {
1281
1285
  const line = JSON.stringify(msg) + "\n";
1282
1286
  this.child?.stdin.write(line, "utf8", (err) => {
@@ -1314,7 +1318,22 @@ var ClientTransport = class {
1314
1318
  this.claimHandlers.add(handler);
1315
1319
  return () => this.claimHandlers.delete(handler);
1316
1320
  }
1317
- stop() {
1321
+ onClose(handler) {
1322
+ this.closeHandlers.add(handler);
1323
+ return () => this.closeHandlers.delete(handler);
1324
+ }
1325
+ /** Tell close listeners once; the handlers are dropped with the connection. */
1326
+ notifyClosed(reason) {
1327
+ const handlers = [...this.closeHandlers];
1328
+ this.closeHandlers.clear();
1329
+ for (const handler of handlers) {
1330
+ try {
1331
+ handler(reason);
1332
+ } catch {
1333
+ }
1334
+ }
1335
+ }
1336
+ stop(reason = "transport stopped") {
1318
1337
  this.closed = true;
1319
1338
  this.resolveRead?.(null);
1320
1339
  this.resolveRead = null;
@@ -1323,6 +1342,7 @@ var ClientTransport = class {
1323
1342
  this.queuedChars = 0;
1324
1343
  this.handlers.clear();
1325
1344
  this.claimHandlers.clear();
1345
+ this.notifyClosed(reason);
1326
1346
  const child = this.child;
1327
1347
  if (!child) return;
1328
1348
  treeKill(child);
@@ -1334,7 +1354,7 @@ var ClientTransport = class {
1334
1354
  if (this.buffer.length > this.maxFrameChars) {
1335
1355
  writeErr(`[acp-child pending frame exceeds ${this.maxFrameChars} characters]
1336
1356
  `);
1337
- this.stop();
1357
+ this.stop(`agent frame exceeds ${this.maxFrameChars} characters`);
1338
1358
  return;
1339
1359
  }
1340
1360
  for (const raw of lines) {
@@ -1342,7 +1362,7 @@ var ClientTransport = class {
1342
1362
  if (raw.length > this.maxFrameChars) {
1343
1363
  writeErr(`[acp-child frame exceeds ${this.maxFrameChars} characters]
1344
1364
  `);
1345
- this.stop();
1365
+ this.stop(`agent frame exceeds ${this.maxFrameChars} characters`);
1346
1366
  return;
1347
1367
  }
1348
1368
  try {
@@ -1368,6 +1388,7 @@ var ClientTransport = class {
1368
1388
  writeErr(`[acp-child exited with code ${code}]
1369
1389
  `);
1370
1390
  }
1391
+ this.notifyClosed(`agent process exited with code ${code}`);
1371
1392
  }
1372
1393
  dispatch(msg, chars = JSON.stringify(msg).length) {
1373
1394
  if (this.resolveRead) {
@@ -1643,16 +1664,17 @@ var WrongStackACPServer = class {
1643
1664
  return;
1644
1665
  }
1645
1666
  const MAX_HTTP_BODY = 10 * 1024 * 1024;
1646
- let body = "";
1667
+ const chunks = [];
1647
1668
  let bodyBytes = 0;
1648
1669
  let tooLarge = false;
1649
1670
  for await (const chunk of req) {
1650
1671
  bodyBytes += chunk.length;
1651
1672
  if (bodyBytes > MAX_HTTP_BODY) {
1652
1673
  tooLarge = true;
1653
- break;
1674
+ chunks.length = 0;
1675
+ if (bodyBytes > 2 * MAX_HTTP_BODY) break;
1654
1676
  }
1655
- body += chunk;
1677
+ if (!tooLarge) chunks.push(chunk);
1656
1678
  }
1657
1679
  if (tooLarge) {
1658
1680
  res.writeHead(413, { "Content-Type": "application/json" });
@@ -1661,7 +1683,7 @@ var WrongStackACPServer = class {
1661
1683
  }
1662
1684
  let msg;
1663
1685
  try {
1664
- msg = JSON.parse(body);
1686
+ msg = JSON.parse(Buffer.concat(chunks).toString("utf8"));
1665
1687
  } catch {
1666
1688
  res.writeHead(400);
1667
1689
  res.end(JSON.stringify({ error: { code: -32700, message: "Parse error" } }));
@@ -1895,6 +1917,16 @@ function isAuthRequiredError(err) {
1895
1917
  return false;
1896
1918
  }
1897
1919
 
1920
+ // src/client/acp-request-state.ts
1921
+ function rejectPendingRequests(pending, reason) {
1922
+ for (const p of pending.values()) {
1923
+ clearTimeout(p.timeoutHandle);
1924
+ const message = reason === void 0 ? "session was closed" : `agent connection closed (${reason}) during ${p.method}`;
1925
+ p.reject(new ACPSessionError("closed", message));
1926
+ }
1927
+ pending.clear();
1928
+ }
1929
+
1898
1930
  // src/client/acp-session-ops.ts
1899
1931
  function filterMcpServers(agentCapabilities, servers) {
1900
1932
  if (!servers || servers.length === 0) return [];
@@ -3172,7 +3204,7 @@ function isAllowed(decision) {
3172
3204
  }
3173
3205
  function toTrustBoundaryRequest(request, options) {
3174
3206
  const rawSessionId = request.toolCall.rawInput?.sessionId;
3175
- const sessionId = typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : options.actor?.sessionId;
3207
+ const sessionId = options.actor?.sessionId ?? (typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : void 0);
3176
3208
  return {
3177
3209
  version: 1,
3178
3210
  requestId: String(request.toolCall.toolCallId),
@@ -3208,6 +3240,7 @@ function makeTrustBoundaryPermissionPolicy(options) {
3208
3240
  var WebSocketClientTransport = class {
3209
3241
  ws = null;
3210
3242
  handlers = /* @__PURE__ */ new Set();
3243
+ closeHandlers = /* @__PURE__ */ new Set();
3211
3244
  closed = false;
3212
3245
  opts;
3213
3246
  maxBufferedBytes;
@@ -3234,6 +3267,7 @@ var WebSocketClientTransport = class {
3234
3267
  const timeoutMs = this.opts.handshakeTimeoutMs ?? 3e4;
3235
3268
  return new Promise((resolve5, reject) => {
3236
3269
  const ws = new WS(this.opts.url, this.opts.protocols);
3270
+ ws.binaryType = "arraybuffer";
3237
3271
  this.ws = ws;
3238
3272
  const timer = setTimeout(() => {
3239
3273
  const pending = this.pendingStart;
@@ -3259,7 +3293,7 @@ var WebSocketClientTransport = class {
3259
3293
  ws.addEventListener("error", (ev) => {
3260
3294
  const pending = this.pendingStart;
3261
3295
  if (pending === null) {
3262
- this.stop();
3296
+ this.stop("WebSocket error");
3263
3297
  return;
3264
3298
  }
3265
3299
  this.pendingStart = null;
@@ -3280,6 +3314,7 @@ var WebSocketClientTransport = class {
3280
3314
  clearTimeout(pending.timer);
3281
3315
  pending.reject(new Error("WebSocket closed before the connection opened"));
3282
3316
  }
3317
+ this.notifyClosed("WebSocket closed");
3283
3318
  });
3284
3319
  ws.addEventListener("message", (ev) => {
3285
3320
  this.onData(ev.data);
@@ -3307,9 +3342,25 @@ var WebSocketClientTransport = class {
3307
3342
  this.handlers.add(handler);
3308
3343
  return () => this.handlers.delete(handler);
3309
3344
  }
3310
- stop() {
3345
+ onClose(handler) {
3346
+ this.closeHandlers.add(handler);
3347
+ return () => this.closeHandlers.delete(handler);
3348
+ }
3349
+ /** Tell close listeners once; the handlers are dropped with the connection. */
3350
+ notifyClosed(reason) {
3351
+ const handlers = [...this.closeHandlers];
3352
+ this.closeHandlers.clear();
3353
+ for (const handler of handlers) {
3354
+ try {
3355
+ handler(reason);
3356
+ } catch {
3357
+ }
3358
+ }
3359
+ }
3360
+ stop(reason = "transport stopped") {
3311
3361
  this.closed = true;
3312
3362
  this.handlers.clear();
3363
+ this.notifyClosed(reason);
3313
3364
  if (this.pendingStart !== null) {
3314
3365
  const pending = this.pendingStart;
3315
3366
  this.pendingStart = null;
@@ -3330,7 +3381,7 @@ var WebSocketClientTransport = class {
3330
3381
  onData(data) {
3331
3382
  const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : Buffer.isBuffer(data) ? data.toString("utf8") : String(data);
3332
3383
  if (text.length > this.maxMessageChars) {
3333
- this.stop();
3384
+ this.stop(`agent message exceeds ${this.maxMessageChars} characters`);
3334
3385
  return;
3335
3386
  }
3336
3387
  if (!text.trim()) return;
@@ -3499,6 +3550,10 @@ var ACPSession = class _ACPSession {
3499
3550
  }
3500
3551
  const session = new _ACPSession(opts, transport);
3501
3552
  session.transportOff = transport.onMessage((msg) => session.handleMessage(msg));
3553
+ transport.onClose?.((reason) => {
3554
+ if (!session.closed) rejectPendingRequests(session.pending, reason);
3555
+ void session.close();
3556
+ });
3502
3557
  try {
3503
3558
  await session.initialize();
3504
3559
  } catch (err) {
@@ -3625,6 +3680,9 @@ var ACPSession = class _ACPSession {
3625
3680
  if (this.state !== "ready" && this.state !== "authenticated" && this.state !== "done") {
3626
3681
  throw new ACPSessionError("protocol_error", `prompt called in state=${this.state}`);
3627
3682
  }
3683
+ if (this.promptCallbackAbort) {
3684
+ throw new ACPSessionError("protocol_error", "prompt called while another prompt is running");
3685
+ }
3628
3686
  if (signal.aborted) {
3629
3687
  return emptyRunResult("cancelled");
3630
3688
  }
@@ -3666,10 +3724,6 @@ var ACPSession = class _ACPSession {
3666
3724
  createPromise,
3667
3725
  new Promise((_, reject) => {
3668
3726
  rejectCreate = reject;
3669
- if (signal.aborted) {
3670
- reject(new ACPSessionError("aborted", "prompt was aborted by the parent"));
3671
- return;
3672
- }
3673
3727
  signal.addEventListener("abort", onCreateAbort, { once: true });
3674
3728
  })
3675
3729
  ]);
@@ -3687,15 +3741,10 @@ var ACPSession = class _ACPSession {
3687
3741
  }
3688
3742
  signal.removeEventListener("abort", onCreateAbort);
3689
3743
  rejectCreate = void 0;
3690
- if (typeof sessionId !== "string" || sessionId.length === 0) {
3691
- signal.removeEventListener("abort", onAbort);
3692
- this.promptCallbackAbort?.abort();
3693
- this.promptCallbackAbort = null;
3694
- throw new ACPSessionError("protocol_error", "session/new returned no session id");
3695
- }
3696
3744
  this.sessionId = sessionId;
3697
3745
  }
3698
3746
  if (signal.aborted) {
3747
+ this.promptCallbackAbort = null;
3699
3748
  return emptyRunResult("cancelled");
3700
3749
  }
3701
3750
  this.resetScratch();
@@ -3803,11 +3852,7 @@ var ACPSession = class _ACPSession {
3803
3852
  } catch {
3804
3853
  }
3805
3854
  }
3806
- for (const [, p] of this.pending) {
3807
- clearTimeout(p.timeoutHandle);
3808
- p.reject(new ACPSessionError("closed", "session was closed"));
3809
- }
3810
- this.pending.clear();
3855
+ rejectPendingRequests(this.pending);
3811
3856
  this.transportOff?.();
3812
3857
  this.transportOff = null;
3813
3858
  try {
@@ -3906,6 +3951,8 @@ var ACPSession = class _ACPSession {
3906
3951
  return;
3907
3952
  }
3908
3953
  if (msg.method === "session/update") {
3954
+ const updateSessionId = msg.params?.sessionId;
3955
+ if (typeof updateSessionId === "string" && updateSessionId !== this.sessionId) return;
3909
3956
  handleAcpSessionUpdate(msg, this.scratch, (event) => this.emitProgress(event));
3910
3957
  return;
3911
3958
  }
@@ -4565,6 +4612,7 @@ async function makeACPSubagentRunnerWithStop(options) {
4565
4612
  const timeoutMs = options.timeoutMs ?? 5 * 6e4;
4566
4613
  const persistent = options.persistent === true;
4567
4614
  let shared = null;
4615
+ let turns = Promise.resolve();
4568
4616
  const startSession = async () => {
4569
4617
  return ACPSession.start({
4570
4618
  command: options.command,
@@ -4580,10 +4628,17 @@ async function makeACPSubagentRunnerWithStop(options) {
4580
4628
  };
4581
4629
  const runner = async (task, ctx) => {
4582
4630
  let session;
4583
- const reuse = persistent && shared !== null;
4584
4631
  try {
4585
- session = reuse ? shared : await startSession();
4586
- if (persistent) shared = session;
4632
+ if (persistent) {
4633
+ shared ??= startSession();
4634
+ const starting = shared;
4635
+ session = await starting.catch((err) => {
4636
+ if (shared === starting) shared = null;
4637
+ throw err;
4638
+ });
4639
+ } else {
4640
+ session = await startSession();
4641
+ }
4587
4642
  } catch (err) {
4588
4643
  throw acpErrorToSubagentError(err, options.role ?? "acp-subagent");
4589
4644
  }
@@ -4600,7 +4655,10 @@ async function makeACPSubagentRunnerWithStop(options) {
4600
4655
  };
4601
4656
  let result;
4602
4657
  try {
4603
- result = await session.prompt([textContent(task.description)], ctx.signal, onProgress);
4658
+ const turn = () => session.prompt([textContent(task.description)], ctx.signal, onProgress);
4659
+ const run = persistent ? turns.then(turn, turn) : turn();
4660
+ if (persistent) turns = run;
4661
+ result = await run;
4604
4662
  } catch (err) {
4605
4663
  throw acpErrorToSubagentError(err, options.role ?? "acp-subagent");
4606
4664
  } finally {
@@ -4621,10 +4679,10 @@ async function makeACPSubagentRunnerWithStop(options) {
4621
4679
  };
4622
4680
  const stop = async () => {
4623
4681
  if (shared) {
4624
- const s = shared;
4682
+ const starting = shared;
4625
4683
  shared = null;
4626
4684
  try {
4627
- await s.close();
4685
+ await (await starting).close();
4628
4686
  } catch {
4629
4687
  }
4630
4688
  }
@@ -301,7 +301,7 @@ async function handleSessionLoadOp(ctx, id, params, replayHistory = true) {
301
301
  await ctx.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
302
302
  return false;
303
303
  }
304
- const candidateCwd = persisted.cwd ?? loadCwd ?? ctx.defaultCwd;
304
+ const candidateCwd = loadCwd ?? persisted.cwd ?? ctx.defaultCwd;
305
305
  const restoredCwd = await resolveSessionCwd(candidateCwd) ?? ctx.defaultCwd;
306
306
  const restored = {
307
307
  id: sessionId,
@@ -482,6 +482,7 @@ async function handleSessionPromptOp(ctx, id, params) {
482
482
  let result;
483
483
  const pendingNotifications = [];
484
484
  const emit = (update) => {
485
+ if (ctx.sessions.get(sessionId) !== session) return;
485
486
  const notifPromise = ctx.sendNotification({ sessionId, update });
486
487
  pendingNotifications.push(notifPromise.catch(() => {
487
488
  }));
@@ -541,6 +542,7 @@ async function handleSetModeOp(ctx, id, params) {
541
542
  modeId
542
543
  }
543
544
  });
545
+ if (ctx.sessions.get(session.id) === session) await ctx.persist(session);
544
546
  await ctx.sendResult(id, {});
545
547
  return false;
546
548
  }
@@ -566,7 +568,7 @@ async function handleSetConfigOptionOp(ctx, id, params) {
566
568
  configOptions: options
567
569
  }
568
570
  });
569
- await ctx.persist(session);
571
+ if (ctx.sessions.get(session.id) === session) await ctx.persist(session);
570
572
  await ctx.sendResult(id, { configOptions: options });
571
573
  return false;
572
574
  }
@@ -1276,16 +1278,17 @@ var WrongStackACPServer = class {
1276
1278
  return;
1277
1279
  }
1278
1280
  const MAX_HTTP_BODY = 10 * 1024 * 1024;
1279
- let body = "";
1281
+ const chunks = [];
1280
1282
  let bodyBytes = 0;
1281
1283
  let tooLarge = false;
1282
1284
  for await (const chunk of req) {
1283
1285
  bodyBytes += chunk.length;
1284
1286
  if (bodyBytes > MAX_HTTP_BODY) {
1285
1287
  tooLarge = true;
1286
- break;
1288
+ chunks.length = 0;
1289
+ if (bodyBytes > 2 * MAX_HTTP_BODY) break;
1287
1290
  }
1288
- body += chunk;
1291
+ if (!tooLarge) chunks.push(chunk);
1289
1292
  }
1290
1293
  if (tooLarge) {
1291
1294
  res.writeHead(413, { "Content-Type": "application/json" });
@@ -1294,7 +1297,7 @@ var WrongStackACPServer = class {
1294
1297
  }
1295
1298
  let msg;
1296
1299
  try {
1297
- msg = JSON.parse(body);
1300
+ msg = JSON.parse(Buffer.concat(chunks).toString("utf8"));
1298
1301
  } catch {
1299
1302
  res.writeHead(400);
1300
1303
  res.end(JSON.stringify({ error: { code: -32700, message: "Parse error" } }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrongstack/acp",
3
- "version": "1.0.27",
3
+ "version": "1.0.29",
4
4
  "license": "MIT",
5
5
  "description": "ACP (Agent Client Protocol) integration for WrongStack — client + agent support",
6
6
  "keywords": [
@@ -52,8 +52,8 @@
52
52
  ],
53
53
  "dependencies": {
54
54
  "@agentclientprotocol/sdk": "^1.4.0",
55
- "@wrongstack/core": "1.0.27",
56
- "@wrongstack/primitives": "1.0.27"
55
+ "@wrongstack/core": "1.0.29",
56
+ "@wrongstack/primitives": "1.0.29"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@types/node": "^26.5.1",
@@ -66,6 +66,7 @@
66
66
  "scripts": {
67
67
  "build": "node ../../scripts/build-package.mjs",
68
68
  "typecheck": "tsc --noEmit",
69
+ "typecheck:tests": "tsc --noEmit -p tsconfig.test.json",
69
70
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
70
71
  "test": "vitest run",
71
72
  "smoke": "pnpm build && node --experimental-strip-types ../../scripts/acp-smoke-test.mts"