@wrongstack/acp 1.0.26 → 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;
@@ -1691,6 +1704,16 @@ import { treeKill } from "@wrongstack/core/utils/tree-kill";
1691
1704
  var DEFAULT_MAX_FRAME_CHARS = 20 * 1024 * 1024;
1692
1705
  var DEFAULT_MAX_QUEUED_MESSAGES = 1e3;
1693
1706
  var DEFAULT_MAX_QUEUED_CHARS = 32 * 1024 * 1024;
1707
+ function takeLines(pending, chunk) {
1708
+ const lines = [];
1709
+ let start = 0;
1710
+ for (let nl = chunk.indexOf("\n"); nl !== -1; nl = chunk.indexOf("\n", start)) {
1711
+ lines.push(pending + chunk.slice(start, nl));
1712
+ pending = "";
1713
+ start = nl + 1;
1714
+ }
1715
+ return { lines, pending: pending + chunk.slice(start) };
1716
+ }
1694
1717
  function positiveLimit(value, fallback) {
1695
1718
  return value !== void 0 && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
1696
1719
  }
@@ -1778,9 +1801,8 @@ var StdioTransport = class {
1778
1801
  this.claimHandlers.clear();
1779
1802
  }
1780
1803
  onData(chunk) {
1781
- this.buffer += chunk;
1782
- const lines = this.buffer.split("\n");
1783
- this.buffer = lines.pop() ?? "";
1804
+ const { lines, pending } = takeLines(this.buffer, chunk);
1805
+ this.buffer = pending;
1784
1806
  if (this.buffer.length > this.maxFrameChars) {
1785
1807
  this.stderr.write(
1786
1808
  `[wstack-acp frame error] pending frame exceeds ${this.maxFrameChars} characters
@@ -2087,16 +2109,17 @@ var WrongStackACPServer = class {
2087
2109
  return;
2088
2110
  }
2089
2111
  const MAX_HTTP_BODY = 10 * 1024 * 1024;
2090
- let body = "";
2112
+ const chunks = [];
2091
2113
  let bodyBytes = 0;
2092
2114
  let tooLarge = false;
2093
2115
  for await (const chunk of req) {
2094
2116
  bodyBytes += chunk.length;
2095
2117
  if (bodyBytes > MAX_HTTP_BODY) {
2096
2118
  tooLarge = true;
2097
- break;
2119
+ chunks.length = 0;
2120
+ if (bodyBytes > 2 * MAX_HTTP_BODY) break;
2098
2121
  }
2099
- body += chunk;
2122
+ if (!tooLarge) chunks.push(chunk);
2100
2123
  }
2101
2124
  if (tooLarge) {
2102
2125
  res.writeHead(413, { "Content-Type": "application/json" });
@@ -2105,7 +2128,7 @@ var WrongStackACPServer = class {
2105
2128
  }
2106
2129
  let msg;
2107
2130
  try {
2108
- msg = JSON.parse(body);
2131
+ msg = JSON.parse(Buffer.concat(chunks).toString("utf8"));
2109
2132
  } catch {
2110
2133
  res.writeHead(400);
2111
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
@@ -11,6 +11,16 @@ import {
11
11
  var DEFAULT_MAX_FRAME_CHARS = 20 * 1024 * 1024;
12
12
  var DEFAULT_MAX_QUEUED_MESSAGES = 1e3;
13
13
  var DEFAULT_MAX_QUEUED_CHARS = 32 * 1024 * 1024;
14
+ function takeLines(pending, chunk) {
15
+ const lines = [];
16
+ let start = 0;
17
+ for (let nl = chunk.indexOf("\n"); nl !== -1; nl = chunk.indexOf("\n", start)) {
18
+ lines.push(pending + chunk.slice(start, nl));
19
+ pending = "";
20
+ start = nl + 1;
21
+ }
22
+ return { lines, pending: pending + chunk.slice(start) };
23
+ }
14
24
  function positiveLimit(value, fallback) {
15
25
  return value !== void 0 && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
16
26
  }
@@ -19,6 +29,7 @@ var ClientTransport = class {
19
29
  buffer = "";
20
30
  handlers = /* @__PURE__ */ new Set();
21
31
  claimHandlers = /* @__PURE__ */ new Set();
32
+ closeHandlers = /* @__PURE__ */ new Set();
22
33
  closed = false;
23
34
  resolveRead = null;
24
35
  messageQueue = [];
@@ -126,6 +137,7 @@ var ClientTransport = class {
126
137
  }
127
138
  send(msg) {
128
139
  if (!this.child) return Promise.reject(new Error("ClientTransport not started"));
140
+ if (this.closed) return Promise.reject(new Error("ClientTransport is closed"));
129
141
  return new Promise((resolve3, reject) => {
130
142
  const line = JSON.stringify(msg) + "\n";
131
143
  this.child?.stdin.write(line, "utf8", (err) => {
@@ -163,7 +175,22 @@ var ClientTransport = class {
163
175
  this.claimHandlers.add(handler);
164
176
  return () => this.claimHandlers.delete(handler);
165
177
  }
166
- 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") {
167
194
  this.closed = true;
168
195
  this.resolveRead?.(null);
169
196
  this.resolveRead = null;
@@ -172,19 +199,19 @@ var ClientTransport = class {
172
199
  this.queuedChars = 0;
173
200
  this.handlers.clear();
174
201
  this.claimHandlers.clear();
202
+ this.notifyClosed(reason);
175
203
  const child = this.child;
176
204
  if (!child) return;
177
205
  treeKill(child);
178
206
  this.child = null;
179
207
  }
180
208
  onChildData(chunk) {
181
- this.buffer += chunk;
182
- const lines = this.buffer.split("\n");
183
- this.buffer = lines.pop() ?? "";
209
+ const { lines, pending } = takeLines(this.buffer, chunk);
210
+ this.buffer = pending;
184
211
  if (this.buffer.length > this.maxFrameChars) {
185
212
  writeErr(`[acp-child pending frame exceeds ${this.maxFrameChars} characters]
186
213
  `);
187
- this.stop();
214
+ this.stop(`agent frame exceeds ${this.maxFrameChars} characters`);
188
215
  return;
189
216
  }
190
217
  for (const raw of lines) {
@@ -192,7 +219,7 @@ var ClientTransport = class {
192
219
  if (raw.length > this.maxFrameChars) {
193
220
  writeErr(`[acp-child frame exceeds ${this.maxFrameChars} characters]
194
221
  `);
195
- this.stop();
222
+ this.stop(`agent frame exceeds ${this.maxFrameChars} characters`);
196
223
  return;
197
224
  }
198
225
  try {
@@ -218,6 +245,7 @@ var ClientTransport = class {
218
245
  writeErr(`[acp-child exited with code ${code}]
219
246
  `);
220
247
  }
248
+ this.notifyClosed(`agent process exited with code ${code}`);
221
249
  }
222
250
  dispatch(msg, chars = JSON.stringify(msg).length) {
223
251
  if (this.resolveRead) {
@@ -364,6 +392,16 @@ function isAuthRequiredError(err) {
364
392
  return false;
365
393
  }
366
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
+
367
405
  // src/client/acp-session-ops.ts
368
406
  function filterMcpServers(agentCapabilities, servers) {
369
407
  if (!servers || servers.length === 0) return [];
@@ -1263,11 +1301,30 @@ function makePermissionPolicy(decide) {
1263
1301
  // src/client/terminal-server.ts
1264
1302
  import { spawn } from "node:child_process";
1265
1303
  import { randomBytes as randomBytes2 } from "node:crypto";
1266
- import { realpathSync as realpathSync2 } from "node:fs";
1304
+ import { accessSync, realpathSync as realpathSync2 } from "node:fs";
1267
1305
  import * as path2 from "node:path";
1268
1306
  import { buildChildEnv } from "@wrongstack/core/utils";
1269
1307
  import { treeKill as treeKill2 } from "@wrongstack/core/utils/tree-kill";
1270
1308
  var EMPTY_BUFFER = Buffer.alloc(0);
1309
+ function win32BatchTarget(command) {
1310
+ if (/\.(?:cmd|bat)$/i.test(command)) return command;
1311
+ if (/[\\/]/.test(command) || path2.extname(command)) return void 0;
1312
+ const exts = (process.env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD").toLowerCase().split(";");
1313
+ for (const dir of (process.env["PATH"] ?? "").split(path2.delimiter)) {
1314
+ if (!dir) continue;
1315
+ for (const ext of exts) {
1316
+ if (!ext) continue;
1317
+ const full = path2.join(dir, `${command}${ext}`);
1318
+ try {
1319
+ accessSync(full);
1320
+ } catch {
1321
+ continue;
1322
+ }
1323
+ return ext === ".cmd" || ext === ".bat" ? full : void 0;
1324
+ }
1325
+ }
1326
+ return void 0;
1327
+ }
1271
1328
  var DEBUG_DISPOSE = typeof process !== "undefined" && !!process.env?.WRONGSTACK_DEBUG && process.env.WRONGSTACK_DEBUG !== "0" && process.env.WRONGSTACK_DEBUG !== "false";
1272
1329
  var TerminalServer = class {
1273
1330
  terminals = /* @__PURE__ */ new Map();
@@ -1318,11 +1375,14 @@ var TerminalServer = class {
1318
1375
  Math.max(1, this.clampFiniteInt(params.outputByteLimit, this.outputByteLimit)),
1319
1376
  this.maxOutputByteLimit
1320
1377
  );
1321
- const proc = spawn(params.command, params.args ?? [], {
1378
+ const batch = process.platform === "win32" ? win32BatchTarget(params.command) : void 0;
1379
+ const shim = batch ? buildWin32CmdShimInvocation(batch, params.args ?? []) : void 0;
1380
+ const proc = spawn(shim?.command ?? params.command, shim?.args ?? params.args ?? [], {
1322
1381
  cwd,
1323
1382
  env: this.buildEnv(params.env),
1324
1383
  stdio: ["ignore", "pipe", "pipe"],
1325
- windowsHide: true
1384
+ windowsHide: true,
1385
+ ...shim ? { windowsVerbatimArguments: shim.windowsVerbatimArguments } : {}
1326
1386
  // shell: false on purpose. The terminal server is invoked with
1327
1387
  // the agent's explicit argv; turning on shell-mode would make
1328
1388
  // the command a single shell-parsed string, which breaks
@@ -1619,7 +1679,7 @@ function isAllowed(decision) {
1619
1679
  }
1620
1680
  function toTrustBoundaryRequest(request, options) {
1621
1681
  const rawSessionId = request.toolCall.rawInput?.sessionId;
1622
- 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);
1623
1683
  return {
1624
1684
  version: 1,
1625
1685
  requestId: String(request.toolCall.toolCallId),
@@ -1655,6 +1715,7 @@ function makeTrustBoundaryPermissionPolicy(options) {
1655
1715
  var WebSocketClientTransport = class {
1656
1716
  ws = null;
1657
1717
  handlers = /* @__PURE__ */ new Set();
1718
+ closeHandlers = /* @__PURE__ */ new Set();
1658
1719
  closed = false;
1659
1720
  opts;
1660
1721
  maxBufferedBytes;
@@ -1681,6 +1742,7 @@ var WebSocketClientTransport = class {
1681
1742
  const timeoutMs = this.opts.handshakeTimeoutMs ?? 3e4;
1682
1743
  return new Promise((resolve3, reject) => {
1683
1744
  const ws = new WS(this.opts.url, this.opts.protocols);
1745
+ ws.binaryType = "arraybuffer";
1684
1746
  this.ws = ws;
1685
1747
  const timer = setTimeout(() => {
1686
1748
  const pending = this.pendingStart;
@@ -1706,7 +1768,7 @@ var WebSocketClientTransport = class {
1706
1768
  ws.addEventListener("error", (ev) => {
1707
1769
  const pending = this.pendingStart;
1708
1770
  if (pending === null) {
1709
- this.stop();
1771
+ this.stop("WebSocket error");
1710
1772
  return;
1711
1773
  }
1712
1774
  this.pendingStart = null;
@@ -1727,6 +1789,7 @@ var WebSocketClientTransport = class {
1727
1789
  clearTimeout(pending.timer);
1728
1790
  pending.reject(new Error("WebSocket closed before the connection opened"));
1729
1791
  }
1792
+ this.notifyClosed("WebSocket closed");
1730
1793
  });
1731
1794
  ws.addEventListener("message", (ev) => {
1732
1795
  this.onData(ev.data);
@@ -1754,9 +1817,25 @@ var WebSocketClientTransport = class {
1754
1817
  this.handlers.add(handler);
1755
1818
  return () => this.handlers.delete(handler);
1756
1819
  }
1757
- 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") {
1758
1836
  this.closed = true;
1759
1837
  this.handlers.clear();
1838
+ this.notifyClosed(reason);
1760
1839
  if (this.pendingStart !== null) {
1761
1840
  const pending = this.pendingStart;
1762
1841
  this.pendingStart = null;
@@ -1777,7 +1856,7 @@ var WebSocketClientTransport = class {
1777
1856
  onData(data) {
1778
1857
  const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : Buffer.isBuffer(data) ? data.toString("utf8") : String(data);
1779
1858
  if (text.length > this.maxMessageChars) {
1780
- this.stop();
1859
+ this.stop(`agent message exceeds ${this.maxMessageChars} characters`);
1781
1860
  return;
1782
1861
  }
1783
1862
  if (!text.trim()) return;
@@ -1946,6 +2025,10 @@ var ACPSession = class _ACPSession {
1946
2025
  }
1947
2026
  const session = new _ACPSession(opts, transport);
1948
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
+ });
1949
2032
  try {
1950
2033
  await session.initialize();
1951
2034
  } catch (err) {
@@ -2072,6 +2155,9 @@ var ACPSession = class _ACPSession {
2072
2155
  if (this.state !== "ready" && this.state !== "authenticated" && this.state !== "done") {
2073
2156
  throw new ACPSessionError("protocol_error", `prompt called in state=${this.state}`);
2074
2157
  }
2158
+ if (this.promptCallbackAbort) {
2159
+ throw new ACPSessionError("protocol_error", "prompt called while another prompt is running");
2160
+ }
2075
2161
  if (signal.aborted) {
2076
2162
  return emptyRunResult("cancelled");
2077
2163
  }
@@ -2143,6 +2229,7 @@ var ACPSession = class _ACPSession {
2143
2229
  this.sessionId = sessionId;
2144
2230
  }
2145
2231
  if (signal.aborted) {
2232
+ this.promptCallbackAbort = null;
2146
2233
  return emptyRunResult("cancelled");
2147
2234
  }
2148
2235
  this.resetScratch();
@@ -2250,11 +2337,7 @@ var ACPSession = class _ACPSession {
2250
2337
  } catch {
2251
2338
  }
2252
2339
  }
2253
- for (const [, p] of this.pending) {
2254
- clearTimeout(p.timeoutHandle);
2255
- p.reject(new ACPSessionError("closed", "session was closed"));
2256
- }
2257
- this.pending.clear();
2340
+ rejectPendingRequests(this.pending);
2258
2341
  this.transportOff?.();
2259
2342
  this.transportOff = null;
2260
2343
  try {
@@ -2470,6 +2553,7 @@ async function makeACPSubagentRunnerWithStop(options) {
2470
2553
  const timeoutMs = options.timeoutMs ?? 5 * 6e4;
2471
2554
  const persistent = options.persistent === true;
2472
2555
  let shared = null;
2556
+ let turns = Promise.resolve();
2473
2557
  const startSession = async () => {
2474
2558
  return ACPSession.start({
2475
2559
  command: options.command,
@@ -2485,10 +2569,17 @@ async function makeACPSubagentRunnerWithStop(options) {
2485
2569
  };
2486
2570
  const runner = async (task, ctx) => {
2487
2571
  let session;
2488
- const reuse = persistent && shared !== null;
2489
2572
  try {
2490
- session = reuse ? shared : await startSession();
2491
- 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
+ }
2492
2583
  } catch (err) {
2493
2584
  throw acpErrorToSubagentError(err, options.role ?? "acp-subagent");
2494
2585
  }
@@ -2505,7 +2596,10 @@ async function makeACPSubagentRunnerWithStop(options) {
2505
2596
  };
2506
2597
  let result;
2507
2598
  try {
2508
- 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;
2509
2603
  } catch (err) {
2510
2604
  throw acpErrorToSubagentError(err, options.role ?? "acp-subagent");
2511
2605
  } finally {
@@ -2526,10 +2620,10 @@ async function makeACPSubagentRunnerWithStop(options) {
2526
2620
  };
2527
2621
  const stop = async () => {
2528
2622
  if (shared) {
2529
- const s = shared;
2623
+ const starting = shared;
2530
2624
  shared = null;
2531
2625
  try {
2532
- await s.close();
2626
+ await (await starting).close();
2533
2627
  } catch {
2534
2628
  }
2535
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
  }
@@ -991,6 +992,16 @@ import {
991
992
  var DEFAULT_MAX_FRAME_CHARS = 20 * 1024 * 1024;
992
993
  var DEFAULT_MAX_QUEUED_MESSAGES = 1e3;
993
994
  var DEFAULT_MAX_QUEUED_CHARS = 32 * 1024 * 1024;
995
+ function takeLines(pending, chunk) {
996
+ const lines = [];
997
+ let start = 0;
998
+ for (let nl = chunk.indexOf("\n"); nl !== -1; nl = chunk.indexOf("\n", start)) {
999
+ lines.push(pending + chunk.slice(start, nl));
1000
+ pending = "";
1001
+ start = nl + 1;
1002
+ }
1003
+ return { lines, pending: pending + chunk.slice(start) };
1004
+ }
994
1005
  function positiveLimit(value, fallback) {
995
1006
  return value !== void 0 && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
996
1007
  }
@@ -1078,9 +1089,8 @@ var StdioTransport = class {
1078
1089
  this.claimHandlers.clear();
1079
1090
  }
1080
1091
  onData(chunk) {
1081
- this.buffer += chunk;
1082
- const lines = this.buffer.split("\n");
1083
- this.buffer = lines.pop() ?? "";
1092
+ const { lines, pending } = takeLines(this.buffer, chunk);
1093
+ this.buffer = pending;
1084
1094
  if (this.buffer.length > this.maxFrameChars) {
1085
1095
  this.stderr.write(
1086
1096
  `[wstack-acp frame error] pending frame exceeds ${this.maxFrameChars} characters
@@ -1161,6 +1171,7 @@ var ClientTransport = class {
1161
1171
  buffer = "";
1162
1172
  handlers = /* @__PURE__ */ new Set();
1163
1173
  claimHandlers = /* @__PURE__ */ new Set();
1174
+ closeHandlers = /* @__PURE__ */ new Set();
1164
1175
  closed = false;
1165
1176
  resolveRead = null;
1166
1177
  messageQueue = [];
@@ -1268,6 +1279,7 @@ var ClientTransport = class {
1268
1279
  }
1269
1280
  send(msg) {
1270
1281
  if (!this.child) return Promise.reject(new Error("ClientTransport not started"));
1282
+ if (this.closed) return Promise.reject(new Error("ClientTransport is closed"));
1271
1283
  return new Promise((resolve5, reject) => {
1272
1284
  const line = JSON.stringify(msg) + "\n";
1273
1285
  this.child?.stdin.write(line, "utf8", (err) => {
@@ -1305,7 +1317,22 @@ var ClientTransport = class {
1305
1317
  this.claimHandlers.add(handler);
1306
1318
  return () => this.claimHandlers.delete(handler);
1307
1319
  }
1308
- 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") {
1309
1336
  this.closed = true;
1310
1337
  this.resolveRead?.(null);
1311
1338
  this.resolveRead = null;
@@ -1314,19 +1341,19 @@ var ClientTransport = class {
1314
1341
  this.queuedChars = 0;
1315
1342
  this.handlers.clear();
1316
1343
  this.claimHandlers.clear();
1344
+ this.notifyClosed(reason);
1317
1345
  const child = this.child;
1318
1346
  if (!child) return;
1319
1347
  treeKill(child);
1320
1348
  this.child = null;
1321
1349
  }
1322
1350
  onChildData(chunk) {
1323
- this.buffer += chunk;
1324
- const lines = this.buffer.split("\n");
1325
- this.buffer = lines.pop() ?? "";
1351
+ const { lines, pending } = takeLines(this.buffer, chunk);
1352
+ this.buffer = pending;
1326
1353
  if (this.buffer.length > this.maxFrameChars) {
1327
1354
  writeErr(`[acp-child pending frame exceeds ${this.maxFrameChars} characters]
1328
1355
  `);
1329
- this.stop();
1356
+ this.stop(`agent frame exceeds ${this.maxFrameChars} characters`);
1330
1357
  return;
1331
1358
  }
1332
1359
  for (const raw of lines) {
@@ -1334,7 +1361,7 @@ var ClientTransport = class {
1334
1361
  if (raw.length > this.maxFrameChars) {
1335
1362
  writeErr(`[acp-child frame exceeds ${this.maxFrameChars} characters]
1336
1363
  `);
1337
- this.stop();
1364
+ this.stop(`agent frame exceeds ${this.maxFrameChars} characters`);
1338
1365
  return;
1339
1366
  }
1340
1367
  try {
@@ -1360,6 +1387,7 @@ var ClientTransport = class {
1360
1387
  writeErr(`[acp-child exited with code ${code}]
1361
1388
  `);
1362
1389
  }
1390
+ this.notifyClosed(`agent process exited with code ${code}`);
1363
1391
  }
1364
1392
  dispatch(msg, chars = JSON.stringify(msg).length) {
1365
1393
  if (this.resolveRead) {
@@ -1635,16 +1663,17 @@ var WrongStackACPServer = class {
1635
1663
  return;
1636
1664
  }
1637
1665
  const MAX_HTTP_BODY = 10 * 1024 * 1024;
1638
- let body = "";
1666
+ const chunks = [];
1639
1667
  let bodyBytes = 0;
1640
1668
  let tooLarge = false;
1641
1669
  for await (const chunk of req) {
1642
1670
  bodyBytes += chunk.length;
1643
1671
  if (bodyBytes > MAX_HTTP_BODY) {
1644
1672
  tooLarge = true;
1645
- break;
1673
+ chunks.length = 0;
1674
+ if (bodyBytes > 2 * MAX_HTTP_BODY) break;
1646
1675
  }
1647
- body += chunk;
1676
+ if (!tooLarge) chunks.push(chunk);
1648
1677
  }
1649
1678
  if (tooLarge) {
1650
1679
  res.writeHead(413, { "Content-Type": "application/json" });
@@ -1653,7 +1682,7 @@ var WrongStackACPServer = class {
1653
1682
  }
1654
1683
  let msg;
1655
1684
  try {
1656
- msg = JSON.parse(body);
1685
+ msg = JSON.parse(Buffer.concat(chunks).toString("utf8"));
1657
1686
  } catch {
1658
1687
  res.writeHead(400);
1659
1688
  res.end(JSON.stringify({ error: { code: -32700, message: "Parse error" } }));
@@ -1887,6 +1916,16 @@ function isAuthRequiredError(err) {
1887
1916
  return false;
1888
1917
  }
1889
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
+
1890
1929
  // src/client/acp-session-ops.ts
1891
1930
  function filterMcpServers(agentCapabilities, servers) {
1892
1931
  if (!servers || servers.length === 0) return [];
@@ -2786,11 +2825,30 @@ function makePermissionPolicy(decide) {
2786
2825
  // src/client/terminal-server.ts
2787
2826
  import { spawn } from "node:child_process";
2788
2827
  import { randomBytes as randomBytes2 } from "node:crypto";
2789
- import { realpathSync as realpathSync2 } from "node:fs";
2828
+ import { accessSync, realpathSync as realpathSync2 } from "node:fs";
2790
2829
  import * as path3 from "node:path";
2791
2830
  import { buildChildEnv } from "@wrongstack/core/utils";
2792
2831
  import { treeKill as treeKill2 } from "@wrongstack/core/utils/tree-kill";
2793
2832
  var EMPTY_BUFFER = Buffer.alloc(0);
2833
+ function win32BatchTarget(command) {
2834
+ if (/\.(?:cmd|bat)$/i.test(command)) return command;
2835
+ if (/[\\/]/.test(command) || path3.extname(command)) return void 0;
2836
+ const exts = (process.env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD").toLowerCase().split(";");
2837
+ for (const dir of (process.env["PATH"] ?? "").split(path3.delimiter)) {
2838
+ if (!dir) continue;
2839
+ for (const ext of exts) {
2840
+ if (!ext) continue;
2841
+ const full = path3.join(dir, `${command}${ext}`);
2842
+ try {
2843
+ accessSync(full);
2844
+ } catch {
2845
+ continue;
2846
+ }
2847
+ return ext === ".cmd" || ext === ".bat" ? full : void 0;
2848
+ }
2849
+ }
2850
+ return void 0;
2851
+ }
2794
2852
  var DEBUG_DISPOSE = typeof process !== "undefined" && !!process.env?.WRONGSTACK_DEBUG && process.env.WRONGSTACK_DEBUG !== "0" && process.env.WRONGSTACK_DEBUG !== "false";
2795
2853
  var TerminalServer = class {
2796
2854
  terminals = /* @__PURE__ */ new Map();
@@ -2841,11 +2899,14 @@ var TerminalServer = class {
2841
2899
  Math.max(1, this.clampFiniteInt(params.outputByteLimit, this.outputByteLimit)),
2842
2900
  this.maxOutputByteLimit
2843
2901
  );
2844
- const proc = spawn(params.command, params.args ?? [], {
2902
+ const batch = process.platform === "win32" ? win32BatchTarget(params.command) : void 0;
2903
+ const shim = batch ? buildWin32CmdShimInvocation(batch, params.args ?? []) : void 0;
2904
+ const proc = spawn(shim?.command ?? params.command, shim?.args ?? params.args ?? [], {
2845
2905
  cwd,
2846
2906
  env: this.buildEnv(params.env),
2847
2907
  stdio: ["ignore", "pipe", "pipe"],
2848
- windowsHide: true
2908
+ windowsHide: true,
2909
+ ...shim ? { windowsVerbatimArguments: shim.windowsVerbatimArguments } : {}
2849
2910
  // shell: false on purpose. The terminal server is invoked with
2850
2911
  // the agent's explicit argv; turning on shell-mode would make
2851
2912
  // the command a single shell-parsed string, which breaks
@@ -3142,7 +3203,7 @@ function isAllowed(decision) {
3142
3203
  }
3143
3204
  function toTrustBoundaryRequest(request, options) {
3144
3205
  const rawSessionId = request.toolCall.rawInput?.sessionId;
3145
- 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);
3146
3207
  return {
3147
3208
  version: 1,
3148
3209
  requestId: String(request.toolCall.toolCallId),
@@ -3178,6 +3239,7 @@ function makeTrustBoundaryPermissionPolicy(options) {
3178
3239
  var WebSocketClientTransport = class {
3179
3240
  ws = null;
3180
3241
  handlers = /* @__PURE__ */ new Set();
3242
+ closeHandlers = /* @__PURE__ */ new Set();
3181
3243
  closed = false;
3182
3244
  opts;
3183
3245
  maxBufferedBytes;
@@ -3204,6 +3266,7 @@ var WebSocketClientTransport = class {
3204
3266
  const timeoutMs = this.opts.handshakeTimeoutMs ?? 3e4;
3205
3267
  return new Promise((resolve5, reject) => {
3206
3268
  const ws = new WS(this.opts.url, this.opts.protocols);
3269
+ ws.binaryType = "arraybuffer";
3207
3270
  this.ws = ws;
3208
3271
  const timer = setTimeout(() => {
3209
3272
  const pending = this.pendingStart;
@@ -3229,7 +3292,7 @@ var WebSocketClientTransport = class {
3229
3292
  ws.addEventListener("error", (ev) => {
3230
3293
  const pending = this.pendingStart;
3231
3294
  if (pending === null) {
3232
- this.stop();
3295
+ this.stop("WebSocket error");
3233
3296
  return;
3234
3297
  }
3235
3298
  this.pendingStart = null;
@@ -3250,6 +3313,7 @@ var WebSocketClientTransport = class {
3250
3313
  clearTimeout(pending.timer);
3251
3314
  pending.reject(new Error("WebSocket closed before the connection opened"));
3252
3315
  }
3316
+ this.notifyClosed("WebSocket closed");
3253
3317
  });
3254
3318
  ws.addEventListener("message", (ev) => {
3255
3319
  this.onData(ev.data);
@@ -3277,9 +3341,25 @@ var WebSocketClientTransport = class {
3277
3341
  this.handlers.add(handler);
3278
3342
  return () => this.handlers.delete(handler);
3279
3343
  }
3280
- 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") {
3281
3360
  this.closed = true;
3282
3361
  this.handlers.clear();
3362
+ this.notifyClosed(reason);
3283
3363
  if (this.pendingStart !== null) {
3284
3364
  const pending = this.pendingStart;
3285
3365
  this.pendingStart = null;
@@ -3300,7 +3380,7 @@ var WebSocketClientTransport = class {
3300
3380
  onData(data) {
3301
3381
  const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : Buffer.isBuffer(data) ? data.toString("utf8") : String(data);
3302
3382
  if (text.length > this.maxMessageChars) {
3303
- this.stop();
3383
+ this.stop(`agent message exceeds ${this.maxMessageChars} characters`);
3304
3384
  return;
3305
3385
  }
3306
3386
  if (!text.trim()) return;
@@ -3469,6 +3549,10 @@ var ACPSession = class _ACPSession {
3469
3549
  }
3470
3550
  const session = new _ACPSession(opts, transport);
3471
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
+ });
3472
3556
  try {
3473
3557
  await session.initialize();
3474
3558
  } catch (err) {
@@ -3595,6 +3679,9 @@ var ACPSession = class _ACPSession {
3595
3679
  if (this.state !== "ready" && this.state !== "authenticated" && this.state !== "done") {
3596
3680
  throw new ACPSessionError("protocol_error", `prompt called in state=${this.state}`);
3597
3681
  }
3682
+ if (this.promptCallbackAbort) {
3683
+ throw new ACPSessionError("protocol_error", "prompt called while another prompt is running");
3684
+ }
3598
3685
  if (signal.aborted) {
3599
3686
  return emptyRunResult("cancelled");
3600
3687
  }
@@ -3666,6 +3753,7 @@ var ACPSession = class _ACPSession {
3666
3753
  this.sessionId = sessionId;
3667
3754
  }
3668
3755
  if (signal.aborted) {
3756
+ this.promptCallbackAbort = null;
3669
3757
  return emptyRunResult("cancelled");
3670
3758
  }
3671
3759
  this.resetScratch();
@@ -3773,11 +3861,7 @@ var ACPSession = class _ACPSession {
3773
3861
  } catch {
3774
3862
  }
3775
3863
  }
3776
- for (const [, p] of this.pending) {
3777
- clearTimeout(p.timeoutHandle);
3778
- p.reject(new ACPSessionError("closed", "session was closed"));
3779
- }
3780
- this.pending.clear();
3864
+ rejectPendingRequests(this.pending);
3781
3865
  this.transportOff?.();
3782
3866
  this.transportOff = null;
3783
3867
  try {
@@ -4535,6 +4619,7 @@ async function makeACPSubagentRunnerWithStop(options) {
4535
4619
  const timeoutMs = options.timeoutMs ?? 5 * 6e4;
4536
4620
  const persistent = options.persistent === true;
4537
4621
  let shared = null;
4622
+ let turns = Promise.resolve();
4538
4623
  const startSession = async () => {
4539
4624
  return ACPSession.start({
4540
4625
  command: options.command,
@@ -4550,10 +4635,17 @@ async function makeACPSubagentRunnerWithStop(options) {
4550
4635
  };
4551
4636
  const runner = async (task, ctx) => {
4552
4637
  let session;
4553
- const reuse = persistent && shared !== null;
4554
4638
  try {
4555
- session = reuse ? shared : await startSession();
4556
- 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
+ }
4557
4649
  } catch (err) {
4558
4650
  throw acpErrorToSubagentError(err, options.role ?? "acp-subagent");
4559
4651
  }
@@ -4570,7 +4662,10 @@ async function makeACPSubagentRunnerWithStop(options) {
4570
4662
  };
4571
4663
  let result;
4572
4664
  try {
4573
- 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;
4574
4669
  } catch (err) {
4575
4670
  throw acpErrorToSubagentError(err, options.role ?? "acp-subagent");
4576
4671
  } finally {
@@ -4591,10 +4686,10 @@ async function makeACPSubagentRunnerWithStop(options) {
4591
4686
  };
4592
4687
  const stop = async () => {
4593
4688
  if (shared) {
4594
- const s = shared;
4689
+ const starting = shared;
4595
4690
  shared = null;
4596
4691
  try {
4597
- await s.close();
4692
+ await (await starting).close();
4598
4693
  } catch {
4599
4694
  }
4600
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
  }
@@ -988,6 +989,16 @@ import { treeKill } from "@wrongstack/core/utils/tree-kill";
988
989
  var DEFAULT_MAX_FRAME_CHARS = 20 * 1024 * 1024;
989
990
  var DEFAULT_MAX_QUEUED_MESSAGES = 1e3;
990
991
  var DEFAULT_MAX_QUEUED_CHARS = 32 * 1024 * 1024;
992
+ function takeLines(pending, chunk) {
993
+ const lines = [];
994
+ let start = 0;
995
+ for (let nl = chunk.indexOf("\n"); nl !== -1; nl = chunk.indexOf("\n", start)) {
996
+ lines.push(pending + chunk.slice(start, nl));
997
+ pending = "";
998
+ start = nl + 1;
999
+ }
1000
+ return { lines, pending: pending + chunk.slice(start) };
1001
+ }
991
1002
  function positiveLimit(value, fallback) {
992
1003
  return value !== void 0 && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
993
1004
  }
@@ -1075,9 +1086,8 @@ var StdioTransport = class {
1075
1086
  this.claimHandlers.clear();
1076
1087
  }
1077
1088
  onData(chunk) {
1078
- this.buffer += chunk;
1079
- const lines = this.buffer.split("\n");
1080
- this.buffer = lines.pop() ?? "";
1089
+ const { lines, pending } = takeLines(this.buffer, chunk);
1090
+ this.buffer = pending;
1081
1091
  if (this.buffer.length > this.maxFrameChars) {
1082
1092
  this.stderr.write(
1083
1093
  `[wstack-acp frame error] pending frame exceeds ${this.maxFrameChars} characters
@@ -1267,16 +1277,17 @@ var WrongStackACPServer = class {
1267
1277
  return;
1268
1278
  }
1269
1279
  const MAX_HTTP_BODY = 10 * 1024 * 1024;
1270
- let body = "";
1280
+ const chunks = [];
1271
1281
  let bodyBytes = 0;
1272
1282
  let tooLarge = false;
1273
1283
  for await (const chunk of req) {
1274
1284
  bodyBytes += chunk.length;
1275
1285
  if (bodyBytes > MAX_HTTP_BODY) {
1276
1286
  tooLarge = true;
1277
- break;
1287
+ chunks.length = 0;
1288
+ if (bodyBytes > 2 * MAX_HTTP_BODY) break;
1278
1289
  }
1279
- body += chunk;
1290
+ if (!tooLarge) chunks.push(chunk);
1280
1291
  }
1281
1292
  if (tooLarge) {
1282
1293
  res.writeHead(413, { "Content-Type": "application/json" });
@@ -1285,7 +1296,7 @@ var WrongStackACPServer = class {
1285
1296
  }
1286
1297
  let msg;
1287
1298
  try {
1288
- msg = JSON.parse(body);
1299
+ msg = JSON.parse(Buffer.concat(chunks).toString("utf8"));
1289
1300
  } catch {
1290
1301
  res.writeHead(400);
1291
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.26",
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.26",
56
- "@wrongstack/primitives": "1.0.26"
55
+ "@wrongstack/core": "1.0.28",
56
+ "@wrongstack/primitives": "1.0.28"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@types/node": "^26.5.1",