@wrongstack/acp 1.0.27 → 1.0.28

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
@@ -534,6 +534,7 @@ async function handleSetModeOp(ctx, id, params) {
534
534
  modeId
535
535
  }
536
536
  });
537
+ if (ctx.sessions.get(session.id) === session) await ctx.persist(session);
537
538
  await ctx.sendResult(id, {});
538
539
  return false;
539
540
  }
@@ -559,7 +560,7 @@ async function handleSetConfigOptionOp(ctx, id, params) {
559
560
  configOptions: options
560
561
  }
561
562
  });
562
- await ctx.persist(session);
563
+ if (ctx.sessions.get(session.id) === session) await ctx.persist(session);
563
564
  await ctx.sendResult(id, { configOptions: options });
564
565
  return false;
565
566
  }
@@ -976,6 +977,7 @@ var ACPProtocolHandler = class {
976
977
  };
977
978
 
978
979
  // src/agent/server-agent-turn.ts
980
+ import { parseIncomingImages } from "@wrongstack/core/utils";
979
981
  function makeACPServerAgentTurn(opts) {
980
982
  const agents = /* @__PURE__ */ new Map();
981
983
  const timeouts = /* @__PURE__ */ new Map();
@@ -987,6 +989,7 @@ function makeACPServerAgentTurn(opts) {
987
989
  const maxHistoryBytes = finitePositiveLimit(opts.maxHistoryBytes, 8 * 1024 * 1024);
988
990
  const deliveryNoticeDebounceMs = finiteNonNegativeLimit(opts.deliveryNoticeDebounceMs, 1500);
989
991
  const notifiers = /* @__PURE__ */ new Map();
992
+ const liveTurns = /* @__PURE__ */ new Map();
990
993
  const stillPending = (sessionId) => {
991
994
  if (!opts.pendingDeliveries) return true;
992
995
  try {
@@ -1056,6 +1059,9 @@ function makeACPServerAgentTurn(opts) {
1056
1059
  return state;
1057
1060
  };
1058
1061
  const turn = async (input, emit, api) => {
1062
+ const token = Symbol(input.sessionId);
1063
+ liveTurns.set(input.sessionId, token);
1064
+ const alive = () => liveTurns.get(input.sessionId) === token;
1059
1065
  let agent = agents.get(input.sessionId);
1060
1066
  if (!agent) {
1061
1067
  agent = await opts.agentFor(
@@ -1064,6 +1070,12 @@ function makeACPServerAgentTurn(opts) {
1064
1070
  api,
1065
1071
  input.mcpServers
1066
1072
  );
1073
+ if (!alive()) {
1074
+ const stale = agent;
1075
+ void Promise.resolve().then(() => stale.teardown?.()).catch(() => {
1076
+ });
1077
+ return { stopReason: "cancelled" };
1078
+ }
1067
1079
  agents.set(input.sessionId, agent);
1068
1080
  if (pendingSeed.has(input.sessionId)) {
1069
1081
  pendingSeed.delete(input.sessionId);
@@ -1129,10 +1141,11 @@ function makeACPServerAgentTurn(opts) {
1129
1141
  content: { type: "text", text }
1130
1142
  });
1131
1143
  }
1144
+ const record = alive();
1132
1145
  const userText = promptToText(input.prompt);
1133
1146
  const hist = history.get(input.sessionId) ?? [];
1134
1147
  let retainedHistoryBytes = historyBytes.get(input.sessionId) ?? 0;
1135
- if (userText) {
1148
+ if (record && userText) {
1136
1149
  const update = {
1137
1150
  sessionUpdate: "user_message_chunk",
1138
1151
  content: { type: "text", text: userText }
@@ -1140,7 +1153,7 @@ function makeACPServerAgentTurn(opts) {
1140
1153
  hist.push(update);
1141
1154
  retainedHistoryBytes += replayEntryBytes(update);
1142
1155
  }
1143
- if (text) {
1156
+ if (record && text) {
1144
1157
  const update = {
1145
1158
  sessionUpdate: "agent_message_chunk",
1146
1159
  content: { type: "text", text }
@@ -1188,6 +1201,7 @@ function makeACPServerAgentTurn(opts) {
1188
1201
  if (usage) result_out.usage = usage;
1189
1202
  return result_out;
1190
1203
  } finally {
1204
+ if (alive()) liveTurns.delete(input.sessionId);
1191
1205
  clearTimeout(timer);
1192
1206
  timeouts.delete(input.sessionId);
1193
1207
  input.signal.removeEventListener("abort", onParentAbort);
@@ -1214,6 +1228,7 @@ function makeACPServerAgentTurn(opts) {
1214
1228
  pendingSeed.add(sessionId);
1215
1229
  };
1216
1230
  const dispose = (sessionId) => {
1231
+ liveTurns.delete(sessionId);
1217
1232
  const timer = timeouts.get(sessionId);
1218
1233
  if (timer) clearTimeout(timer);
1219
1234
  timeouts.delete(sessionId);
@@ -1322,26 +1337,24 @@ function isRecord(v) {
1322
1337
  return typeof v === "object" && v !== null && !Array.isArray(v);
1323
1338
  }
1324
1339
  function promptToAgentInput(blocks) {
1325
- const hasImage = blocks.some((b) => b.type === "image");
1326
- if (!hasImage) {
1340
+ const images = blocks.filter((b) => b.type === "image");
1341
+ if (images.length === 0) {
1327
1342
  return promptToText(blocks);
1328
1343
  }
1344
+ let parsed;
1345
+ try {
1346
+ parsed = parseIncomingImages(images.map((b) => ({ data: b.data, mediaType: b.mimeType })));
1347
+ } catch (err) {
1348
+ throw Object.assign(new Error(err.message), { code: -32602 });
1349
+ }
1329
1350
  const out = [];
1351
+ let next = 0;
1330
1352
  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}]` });
1353
+ if (b.type === "image") out.push(parsed[next++]);
1354
+ else if (b.type === "text") out.push({ type: "text", text: b.text });
1355
+ else {
1356
+ const text = promptToText([b]);
1357
+ if (text) out.push({ type: "text", text });
1345
1358
  }
1346
1359
  }
1347
1360
  return out;
@@ -2096,16 +2109,17 @@ var WrongStackACPServer = class {
2096
2109
  return;
2097
2110
  }
2098
2111
  const MAX_HTTP_BODY = 10 * 1024 * 1024;
2099
- let body = "";
2112
+ const chunks = [];
2100
2113
  let bodyBytes = 0;
2101
2114
  let tooLarge = false;
2102
2115
  for await (const chunk of req) {
2103
2116
  bodyBytes += chunk.length;
2104
2117
  if (bodyBytes > MAX_HTTP_BODY) {
2105
2118
  tooLarge = true;
2106
- break;
2119
+ chunks.length = 0;
2120
+ if (bodyBytes > 2 * MAX_HTTP_BODY) break;
2107
2121
  }
2108
- body += chunk;
2122
+ if (!tooLarge) chunks.push(chunk);
2109
2123
  }
2110
2124
  if (tooLarge) {
2111
2125
  res.writeHead(413, { "Content-Type": "application/json" });
@@ -2114,7 +2128,7 @@ var WrongStackACPServer = class {
2114
2128
  }
2115
2129
  let msg;
2116
2130
  try {
2117
- msg = JSON.parse(body);
2131
+ msg = JSON.parse(Buffer.concat(chunks).toString("utf8"));
2118
2132
  } catch {
2119
2133
  res.writeHead(400);
2120
2134
  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
  }
@@ -2174,6 +2229,7 @@ var ACPSession = class _ACPSession {
2174
2229
  this.sessionId = sessionId;
2175
2230
  }
2176
2231
  if (signal.aborted) {
2232
+ this.promptCallbackAbort = null;
2177
2233
  return emptyRunResult("cancelled");
2178
2234
  }
2179
2235
  this.resetScratch();
@@ -2281,11 +2337,7 @@ var ACPSession = class _ACPSession {
2281
2337
  } catch {
2282
2338
  }
2283
2339
  }
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();
2340
+ rejectPendingRequests(this.pending);
2289
2341
  this.transportOff?.();
2290
2342
  this.transportOff = null;
2291
2343
  try {
@@ -2501,6 +2553,7 @@ async function makeACPSubagentRunnerWithStop(options) {
2501
2553
  const timeoutMs = options.timeoutMs ?? 5 * 6e4;
2502
2554
  const persistent = options.persistent === true;
2503
2555
  let shared = null;
2556
+ let turns = Promise.resolve();
2504
2557
  const startSession = async () => {
2505
2558
  return ACPSession.start({
2506
2559
  command: options.command,
@@ -2516,10 +2569,17 @@ async function makeACPSubagentRunnerWithStop(options) {
2516
2569
  };
2517
2570
  const runner = async (task, ctx) => {
2518
2571
  let session;
2519
- const reuse = persistent && shared !== null;
2520
2572
  try {
2521
- session = reuse ? shared : await startSession();
2522
- if (persistent) shared = session;
2573
+ if (persistent) {
2574
+ shared ??= startSession();
2575
+ const starting = shared;
2576
+ session = await starting.catch((err) => {
2577
+ if (shared === starting) shared = null;
2578
+ throw err;
2579
+ });
2580
+ } else {
2581
+ session = await startSession();
2582
+ }
2523
2583
  } catch (err) {
2524
2584
  throw acpErrorToSubagentError(err, options.role ?? "acp-subagent");
2525
2585
  }
@@ -2536,7 +2596,10 @@ async function makeACPSubagentRunnerWithStop(options) {
2536
2596
  };
2537
2597
  let result;
2538
2598
  try {
2539
- result = await session.prompt([textContent(task.description)], ctx.signal, onProgress);
2599
+ const turn = () => session.prompt([textContent(task.description)], ctx.signal, onProgress);
2600
+ const run = persistent ? turns.then(turn, turn) : turn();
2601
+ if (persistent) turns = run;
2602
+ result = await run;
2540
2603
  } catch (err) {
2541
2604
  throw acpErrorToSubagentError(err, options.role ?? "acp-subagent");
2542
2605
  } finally {
@@ -2557,10 +2620,10 @@ async function makeACPSubagentRunnerWithStop(options) {
2557
2620
  };
2558
2621
  const stop = async () => {
2559
2622
  if (shared) {
2560
- const s = shared;
2623
+ const starting = shared;
2561
2624
  shared = null;
2562
2625
  try {
2563
- await s.close();
2626
+ await (await starting).close();
2564
2627
  } catch {
2565
2628
  }
2566
2629
  }
package/dist/index.js CHANGED
@@ -537,6 +537,7 @@ async function handleSetModeOp(ctx, id, params) {
537
537
  modeId
538
538
  }
539
539
  });
540
+ if (ctx.sessions.get(session.id) === session) await ctx.persist(session);
540
541
  await ctx.sendResult(id, {});
541
542
  return false;
542
543
  }
@@ -562,7 +563,7 @@ async function handleSetConfigOptionOp(ctx, id, params) {
562
563
  configOptions: options
563
564
  }
564
565
  });
565
- await ctx.persist(session);
566
+ if (ctx.sessions.get(session.id) === session) await ctx.persist(session);
566
567
  await ctx.sendResult(id, { configOptions: options });
567
568
  return false;
568
569
  }
@@ -1170,6 +1171,7 @@ var ClientTransport = class {
1170
1171
  buffer = "";
1171
1172
  handlers = /* @__PURE__ */ new Set();
1172
1173
  claimHandlers = /* @__PURE__ */ new Set();
1174
+ closeHandlers = /* @__PURE__ */ new Set();
1173
1175
  closed = false;
1174
1176
  resolveRead = null;
1175
1177
  messageQueue = [];
@@ -1277,6 +1279,7 @@ var ClientTransport = class {
1277
1279
  }
1278
1280
  send(msg) {
1279
1281
  if (!this.child) return Promise.reject(new Error("ClientTransport not started"));
1282
+ if (this.closed) return Promise.reject(new Error("ClientTransport is closed"));
1280
1283
  return new Promise((resolve5, reject) => {
1281
1284
  const line = JSON.stringify(msg) + "\n";
1282
1285
  this.child?.stdin.write(line, "utf8", (err) => {
@@ -1314,7 +1317,22 @@ var ClientTransport = class {
1314
1317
  this.claimHandlers.add(handler);
1315
1318
  return () => this.claimHandlers.delete(handler);
1316
1319
  }
1317
- stop() {
1320
+ onClose(handler) {
1321
+ this.closeHandlers.add(handler);
1322
+ return () => this.closeHandlers.delete(handler);
1323
+ }
1324
+ /** Tell close listeners once; the handlers are dropped with the connection. */
1325
+ notifyClosed(reason) {
1326
+ const handlers = [...this.closeHandlers];
1327
+ this.closeHandlers.clear();
1328
+ for (const handler of handlers) {
1329
+ try {
1330
+ handler(reason);
1331
+ } catch {
1332
+ }
1333
+ }
1334
+ }
1335
+ stop(reason = "transport stopped") {
1318
1336
  this.closed = true;
1319
1337
  this.resolveRead?.(null);
1320
1338
  this.resolveRead = null;
@@ -1323,6 +1341,7 @@ var ClientTransport = class {
1323
1341
  this.queuedChars = 0;
1324
1342
  this.handlers.clear();
1325
1343
  this.claimHandlers.clear();
1344
+ this.notifyClosed(reason);
1326
1345
  const child = this.child;
1327
1346
  if (!child) return;
1328
1347
  treeKill(child);
@@ -1334,7 +1353,7 @@ var ClientTransport = class {
1334
1353
  if (this.buffer.length > this.maxFrameChars) {
1335
1354
  writeErr(`[acp-child pending frame exceeds ${this.maxFrameChars} characters]
1336
1355
  `);
1337
- this.stop();
1356
+ this.stop(`agent frame exceeds ${this.maxFrameChars} characters`);
1338
1357
  return;
1339
1358
  }
1340
1359
  for (const raw of lines) {
@@ -1342,7 +1361,7 @@ var ClientTransport = class {
1342
1361
  if (raw.length > this.maxFrameChars) {
1343
1362
  writeErr(`[acp-child frame exceeds ${this.maxFrameChars} characters]
1344
1363
  `);
1345
- this.stop();
1364
+ this.stop(`agent frame exceeds ${this.maxFrameChars} characters`);
1346
1365
  return;
1347
1366
  }
1348
1367
  try {
@@ -1368,6 +1387,7 @@ var ClientTransport = class {
1368
1387
  writeErr(`[acp-child exited with code ${code}]
1369
1388
  `);
1370
1389
  }
1390
+ this.notifyClosed(`agent process exited with code ${code}`);
1371
1391
  }
1372
1392
  dispatch(msg, chars = JSON.stringify(msg).length) {
1373
1393
  if (this.resolveRead) {
@@ -1643,16 +1663,17 @@ var WrongStackACPServer = class {
1643
1663
  return;
1644
1664
  }
1645
1665
  const MAX_HTTP_BODY = 10 * 1024 * 1024;
1646
- let body = "";
1666
+ const chunks = [];
1647
1667
  let bodyBytes = 0;
1648
1668
  let tooLarge = false;
1649
1669
  for await (const chunk of req) {
1650
1670
  bodyBytes += chunk.length;
1651
1671
  if (bodyBytes > MAX_HTTP_BODY) {
1652
1672
  tooLarge = true;
1653
- break;
1673
+ chunks.length = 0;
1674
+ if (bodyBytes > 2 * MAX_HTTP_BODY) break;
1654
1675
  }
1655
- body += chunk;
1676
+ if (!tooLarge) chunks.push(chunk);
1656
1677
  }
1657
1678
  if (tooLarge) {
1658
1679
  res.writeHead(413, { "Content-Type": "application/json" });
@@ -1661,7 +1682,7 @@ var WrongStackACPServer = class {
1661
1682
  }
1662
1683
  let msg;
1663
1684
  try {
1664
- msg = JSON.parse(body);
1685
+ msg = JSON.parse(Buffer.concat(chunks).toString("utf8"));
1665
1686
  } catch {
1666
1687
  res.writeHead(400);
1667
1688
  res.end(JSON.stringify({ error: { code: -32700, message: "Parse error" } }));
@@ -1895,6 +1916,16 @@ function isAuthRequiredError(err) {
1895
1916
  return false;
1896
1917
  }
1897
1918
 
1919
+ // src/client/acp-request-state.ts
1920
+ function rejectPendingRequests(pending, reason) {
1921
+ for (const p of pending.values()) {
1922
+ clearTimeout(p.timeoutHandle);
1923
+ const message = reason === void 0 ? "session was closed" : `agent connection closed (${reason}) during ${p.method}`;
1924
+ p.reject(new ACPSessionError("closed", message));
1925
+ }
1926
+ pending.clear();
1927
+ }
1928
+
1898
1929
  // src/client/acp-session-ops.ts
1899
1930
  function filterMcpServers(agentCapabilities, servers) {
1900
1931
  if (!servers || servers.length === 0) return [];
@@ -3172,7 +3203,7 @@ function isAllowed(decision) {
3172
3203
  }
3173
3204
  function toTrustBoundaryRequest(request, options) {
3174
3205
  const rawSessionId = request.toolCall.rawInput?.sessionId;
3175
- const sessionId = typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : options.actor?.sessionId;
3206
+ const sessionId = options.actor?.sessionId ?? (typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : void 0);
3176
3207
  return {
3177
3208
  version: 1,
3178
3209
  requestId: String(request.toolCall.toolCallId),
@@ -3208,6 +3239,7 @@ function makeTrustBoundaryPermissionPolicy(options) {
3208
3239
  var WebSocketClientTransport = class {
3209
3240
  ws = null;
3210
3241
  handlers = /* @__PURE__ */ new Set();
3242
+ closeHandlers = /* @__PURE__ */ new Set();
3211
3243
  closed = false;
3212
3244
  opts;
3213
3245
  maxBufferedBytes;
@@ -3234,6 +3266,7 @@ var WebSocketClientTransport = class {
3234
3266
  const timeoutMs = this.opts.handshakeTimeoutMs ?? 3e4;
3235
3267
  return new Promise((resolve5, reject) => {
3236
3268
  const ws = new WS(this.opts.url, this.opts.protocols);
3269
+ ws.binaryType = "arraybuffer";
3237
3270
  this.ws = ws;
3238
3271
  const timer = setTimeout(() => {
3239
3272
  const pending = this.pendingStart;
@@ -3259,7 +3292,7 @@ var WebSocketClientTransport = class {
3259
3292
  ws.addEventListener("error", (ev) => {
3260
3293
  const pending = this.pendingStart;
3261
3294
  if (pending === null) {
3262
- this.stop();
3295
+ this.stop("WebSocket error");
3263
3296
  return;
3264
3297
  }
3265
3298
  this.pendingStart = null;
@@ -3280,6 +3313,7 @@ var WebSocketClientTransport = class {
3280
3313
  clearTimeout(pending.timer);
3281
3314
  pending.reject(new Error("WebSocket closed before the connection opened"));
3282
3315
  }
3316
+ this.notifyClosed("WebSocket closed");
3283
3317
  });
3284
3318
  ws.addEventListener("message", (ev) => {
3285
3319
  this.onData(ev.data);
@@ -3307,9 +3341,25 @@ var WebSocketClientTransport = class {
3307
3341
  this.handlers.add(handler);
3308
3342
  return () => this.handlers.delete(handler);
3309
3343
  }
3310
- stop() {
3344
+ onClose(handler) {
3345
+ this.closeHandlers.add(handler);
3346
+ return () => this.closeHandlers.delete(handler);
3347
+ }
3348
+ /** Tell close listeners once; the handlers are dropped with the connection. */
3349
+ notifyClosed(reason) {
3350
+ const handlers = [...this.closeHandlers];
3351
+ this.closeHandlers.clear();
3352
+ for (const handler of handlers) {
3353
+ try {
3354
+ handler(reason);
3355
+ } catch {
3356
+ }
3357
+ }
3358
+ }
3359
+ stop(reason = "transport stopped") {
3311
3360
  this.closed = true;
3312
3361
  this.handlers.clear();
3362
+ this.notifyClosed(reason);
3313
3363
  if (this.pendingStart !== null) {
3314
3364
  const pending = this.pendingStart;
3315
3365
  this.pendingStart = null;
@@ -3330,7 +3380,7 @@ var WebSocketClientTransport = class {
3330
3380
  onData(data) {
3331
3381
  const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : Buffer.isBuffer(data) ? data.toString("utf8") : String(data);
3332
3382
  if (text.length > this.maxMessageChars) {
3333
- this.stop();
3383
+ this.stop(`agent message exceeds ${this.maxMessageChars} characters`);
3334
3384
  return;
3335
3385
  }
3336
3386
  if (!text.trim()) return;
@@ -3499,6 +3549,10 @@ var ACPSession = class _ACPSession {
3499
3549
  }
3500
3550
  const session = new _ACPSession(opts, transport);
3501
3551
  session.transportOff = transport.onMessage((msg) => session.handleMessage(msg));
3552
+ transport.onClose?.((reason) => {
3553
+ if (!session.closed) rejectPendingRequests(session.pending, reason);
3554
+ void session.close();
3555
+ });
3502
3556
  try {
3503
3557
  await session.initialize();
3504
3558
  } catch (err) {
@@ -3625,6 +3679,9 @@ var ACPSession = class _ACPSession {
3625
3679
  if (this.state !== "ready" && this.state !== "authenticated" && this.state !== "done") {
3626
3680
  throw new ACPSessionError("protocol_error", `prompt called in state=${this.state}`);
3627
3681
  }
3682
+ if (this.promptCallbackAbort) {
3683
+ throw new ACPSessionError("protocol_error", "prompt called while another prompt is running");
3684
+ }
3628
3685
  if (signal.aborted) {
3629
3686
  return emptyRunResult("cancelled");
3630
3687
  }
@@ -3696,6 +3753,7 @@ var ACPSession = class _ACPSession {
3696
3753
  this.sessionId = sessionId;
3697
3754
  }
3698
3755
  if (signal.aborted) {
3756
+ this.promptCallbackAbort = null;
3699
3757
  return emptyRunResult("cancelled");
3700
3758
  }
3701
3759
  this.resetScratch();
@@ -3803,11 +3861,7 @@ var ACPSession = class _ACPSession {
3803
3861
  } catch {
3804
3862
  }
3805
3863
  }
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();
3864
+ rejectPendingRequests(this.pending);
3811
3865
  this.transportOff?.();
3812
3866
  this.transportOff = null;
3813
3867
  try {
@@ -4565,6 +4619,7 @@ async function makeACPSubagentRunnerWithStop(options) {
4565
4619
  const timeoutMs = options.timeoutMs ?? 5 * 6e4;
4566
4620
  const persistent = options.persistent === true;
4567
4621
  let shared = null;
4622
+ let turns = Promise.resolve();
4568
4623
  const startSession = async () => {
4569
4624
  return ACPSession.start({
4570
4625
  command: options.command,
@@ -4580,10 +4635,17 @@ async function makeACPSubagentRunnerWithStop(options) {
4580
4635
  };
4581
4636
  const runner = async (task, ctx) => {
4582
4637
  let session;
4583
- const reuse = persistent && shared !== null;
4584
4638
  try {
4585
- session = reuse ? shared : await startSession();
4586
- if (persistent) shared = session;
4639
+ if (persistent) {
4640
+ shared ??= startSession();
4641
+ const starting = shared;
4642
+ session = await starting.catch((err) => {
4643
+ if (shared === starting) shared = null;
4644
+ throw err;
4645
+ });
4646
+ } else {
4647
+ session = await startSession();
4648
+ }
4587
4649
  } catch (err) {
4588
4650
  throw acpErrorToSubagentError(err, options.role ?? "acp-subagent");
4589
4651
  }
@@ -4600,7 +4662,10 @@ async function makeACPSubagentRunnerWithStop(options) {
4600
4662
  };
4601
4663
  let result;
4602
4664
  try {
4603
- result = await session.prompt([textContent(task.description)], ctx.signal, onProgress);
4665
+ const turn = () => session.prompt([textContent(task.description)], ctx.signal, onProgress);
4666
+ const run = persistent ? turns.then(turn, turn) : turn();
4667
+ if (persistent) turns = run;
4668
+ result = await run;
4604
4669
  } catch (err) {
4605
4670
  throw acpErrorToSubagentError(err, options.role ?? "acp-subagent");
4606
4671
  } finally {
@@ -4621,10 +4686,10 @@ async function makeACPSubagentRunnerWithStop(options) {
4621
4686
  };
4622
4687
  const stop = async () => {
4623
4688
  if (shared) {
4624
- const s = shared;
4689
+ const starting = shared;
4625
4690
  shared = null;
4626
4691
  try {
4627
- await s.close();
4692
+ await (await starting).close();
4628
4693
  } catch {
4629
4694
  }
4630
4695
  }
@@ -541,6 +541,7 @@ async function handleSetModeOp(ctx, id, params) {
541
541
  modeId
542
542
  }
543
543
  });
544
+ if (ctx.sessions.get(session.id) === session) await ctx.persist(session);
544
545
  await ctx.sendResult(id, {});
545
546
  return false;
546
547
  }
@@ -566,7 +567,7 @@ async function handleSetConfigOptionOp(ctx, id, params) {
566
567
  configOptions: options
567
568
  }
568
569
  });
569
- await ctx.persist(session);
570
+ if (ctx.sessions.get(session.id) === session) await ctx.persist(session);
570
571
  await ctx.sendResult(id, { configOptions: options });
571
572
  return false;
572
573
  }
@@ -1276,16 +1277,17 @@ var WrongStackACPServer = class {
1276
1277
  return;
1277
1278
  }
1278
1279
  const MAX_HTTP_BODY = 10 * 1024 * 1024;
1279
- let body = "";
1280
+ const chunks = [];
1280
1281
  let bodyBytes = 0;
1281
1282
  let tooLarge = false;
1282
1283
  for await (const chunk of req) {
1283
1284
  bodyBytes += chunk.length;
1284
1285
  if (bodyBytes > MAX_HTTP_BODY) {
1285
1286
  tooLarge = true;
1286
- break;
1287
+ chunks.length = 0;
1288
+ if (bodyBytes > 2 * MAX_HTTP_BODY) break;
1287
1289
  }
1288
- body += chunk;
1290
+ if (!tooLarge) chunks.push(chunk);
1289
1291
  }
1290
1292
  if (tooLarge) {
1291
1293
  res.writeHead(413, { "Content-Type": "application/json" });
@@ -1294,7 +1296,7 @@ var WrongStackACPServer = class {
1294
1296
  }
1295
1297
  let msg;
1296
1298
  try {
1297
- msg = JSON.parse(body);
1299
+ msg = JSON.parse(Buffer.concat(chunks).toString("utf8"));
1298
1300
  } catch {
1299
1301
  res.writeHead(400);
1300
1302
  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.28",
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.28",
56
+ "@wrongstack/primitives": "1.0.28"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@types/node": "^26.5.1",