@tickernelz/paperclip-pro-server 2026.928.4 → 2026.928.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/build-info.json +1 -1
  2. package/dist/routes/issues.d.ts.map +1 -1
  3. package/dist/routes/issues.js +1 -0
  4. package/dist/routes/issues.js.map +1 -1
  5. package/dist/routes/openapi.d.ts.map +1 -1
  6. package/dist/routes/openapi.js +133 -53
  7. package/dist/routes/openapi.js.map +1 -1
  8. package/dist/routes/paperclip-mcp.d.ts.map +1 -1
  9. package/dist/routes/paperclip-mcp.js +15 -0
  10. package/dist/routes/paperclip-mcp.js.map +1 -1
  11. package/node_modules/@agentclientprotocol/sdk/dist/acp.d.ts +4 -1
  12. package/node_modules/@agentclientprotocol/sdk/dist/acp.js +3 -2
  13. package/node_modules/@agentclientprotocol/sdk/dist/acp.js.map +1 -1
  14. package/node_modules/@agentclientprotocol/sdk/dist/connection.d.ts +199 -11
  15. package/node_modules/@agentclientprotocol/sdk/dist/connection.js +454 -46
  16. package/node_modules/@agentclientprotocol/sdk/dist/connection.js.map +1 -1
  17. package/node_modules/@agentclientprotocol/sdk/dist/connection.test.js +170 -11
  18. package/node_modules/@agentclientprotocol/sdk/dist/connection.test.js.map +1 -1
  19. package/node_modules/@agentclientprotocol/sdk/dist/examples/http-server.js +6 -1
  20. package/node_modules/@agentclientprotocol/sdk/dist/examples/http-server.js.map +1 -1
  21. package/node_modules/@agentclientprotocol/sdk/dist/http-stream.d.ts +2 -0
  22. package/node_modules/@agentclientprotocol/sdk/dist/http-stream.js +46 -8
  23. package/node_modules/@agentclientprotocol/sdk/dist/http-stream.js.map +1 -1
  24. package/node_modules/@agentclientprotocol/sdk/dist/line-buffer.d.ts +7 -5
  25. package/node_modules/@agentclientprotocol/sdk/dist/line-buffer.js +36 -29
  26. package/node_modules/@agentclientprotocol/sdk/dist/line-buffer.js.map +1 -1
  27. package/node_modules/@agentclientprotocol/sdk/dist/node-adapter.js +33 -2
  28. package/node_modules/@agentclientprotocol/sdk/dist/node-adapter.js.map +1 -1
  29. package/node_modules/@agentclientprotocol/sdk/dist/node-adapter.test.js +87 -0
  30. package/node_modules/@agentclientprotocol/sdk/dist/node-adapter.test.js.map +1 -1
  31. package/node_modules/@agentclientprotocol/sdk/dist/server-sse.d.ts +6 -1
  32. package/node_modules/@agentclientprotocol/sdk/dist/server-sse.js +78 -30
  33. package/node_modules/@agentclientprotocol/sdk/dist/server-sse.js.map +1 -1
  34. package/node_modules/@agentclientprotocol/sdk/dist/server-sse.test.js +59 -6
  35. package/node_modules/@agentclientprotocol/sdk/dist/server-sse.test.js.map +1 -1
  36. package/node_modules/@agentclientprotocol/sdk/dist/server-websocket-upgrade.test.js +320 -2
  37. package/node_modules/@agentclientprotocol/sdk/dist/server-websocket-upgrade.test.js.map +1 -1
  38. package/node_modules/@agentclientprotocol/sdk/dist/server.d.ts +4 -3
  39. package/node_modules/@agentclientprotocol/sdk/dist/server.js +49 -21
  40. package/node_modules/@agentclientprotocol/sdk/dist/server.js.map +1 -1
  41. package/node_modules/@agentclientprotocol/sdk/dist/server.test.js +530 -0
  42. package/node_modules/@agentclientprotocol/sdk/dist/server.test.js.map +1 -1
  43. package/node_modules/@agentclientprotocol/sdk/dist/sse.d.ts +3 -1
  44. package/node_modules/@agentclientprotocol/sdk/dist/sse.js +59 -36
  45. package/node_modules/@agentclientprotocol/sdk/dist/sse.js.map +1 -1
  46. package/node_modules/@agentclientprotocol/sdk/dist/stream-limits.d.ts +15 -0
  47. package/node_modules/@agentclientprotocol/sdk/dist/stream-limits.js +58 -0
  48. package/node_modules/@agentclientprotocol/sdk/dist/stream-limits.js.map +1 -0
  49. package/node_modules/@agentclientprotocol/sdk/dist/stream.d.ts +4 -1
  50. package/node_modules/@agentclientprotocol/sdk/dist/stream.js +6 -2
  51. package/node_modules/@agentclientprotocol/sdk/dist/stream.js.map +1 -1
  52. package/node_modules/@agentclientprotocol/sdk/dist/test-support/until.d.ts +5 -0
  53. package/node_modules/@agentclientprotocol/sdk/dist/test-support/until.js +14 -0
  54. package/node_modules/@agentclientprotocol/sdk/dist/test-support/until.js.map +1 -0
  55. package/node_modules/@agentclientprotocol/sdk/dist/v2/acp.d.ts +4 -1
  56. package/node_modules/@agentclientprotocol/sdk/dist/v2/acp.js +3 -2
  57. package/node_modules/@agentclientprotocol/sdk/dist/v2/acp.js.map +1 -1
  58. package/node_modules/@agentclientprotocol/sdk/dist/ws-server.js +143 -15
  59. package/node_modules/@agentclientprotocol/sdk/dist/ws-server.js.map +1 -1
  60. package/node_modules/@agentclientprotocol/sdk/dist/ws-utils.d.ts +6 -0
  61. package/node_modules/@agentclientprotocol/sdk/dist/ws-utils.js.map +1 -1
  62. package/node_modules/@agentclientprotocol/sdk/package.json +1 -1
  63. package/package.json +21 -21
  64. package/ui-dist/assets/{Agents.production-Cj5rZo69.js → Agents.production-C3HZ3WkH.js} +2 -2
  65. package/ui-dist/assets/{CompanyActivity.production-BsesMNQp.js → CompanyActivity.production-4aA8pwxB.js} +1 -1
  66. package/ui-dist/assets/{CompanyExport-CWmvCY4A.js → CompanyExport-CblRC_yJ.js} +1 -1
  67. package/ui-dist/assets/{CompanySkills.production-BGtfN-3A.js → CompanySkills.production-AZTd440C.js} +1 -1
  68. package/ui-dist/assets/{Costs.production-84DycSrt.js → Costs.production-QhFTCjhh.js} +1 -1
  69. package/ui-dist/assets/{OrgChart.production-D_VwOg1Z.js → OrgChart.production-BUtNNhCY.js} +1 -1
  70. package/ui-dist/assets/{RoutineDetail.production-DJe0NpxJ.js → RoutineDetail.production-Bi5ZoMXD.js} +1 -1
  71. package/ui-dist/assets/{Routines.production-VhYuO012.js → Routines.production-Ck_OcEg_.js} +1 -1
  72. package/ui-dist/assets/{definition-DurZnIaP.js → definition-BsNVlrmu.js} +1 -1
  73. package/ui-dist/assets/{index-BqdSRbLj.js → index-b6Jdkq01.js} +4 -4
  74. package/ui-dist/index.html +1 -1
  75. package/ui-dist/sw.js +1 -1
@@ -1,27 +1,74 @@
1
1
  import { isResponseMessage } from "./jsonrpc.js";
2
2
  import { messageIdKey, sessionIdFromMessageParams, sessionIdFromResponseResult, } from "./protocol.js";
3
+ /** Default for {@link ConnectionLimits.maxBufferedBytes}: 64 MiB. */
4
+ export const DEFAULT_MAX_BUFFERED_BYTES = 64 * 1024 * 1024;
5
+ /** Default for {@link ConnectionLimits.maxOutputStallMs}: 60 seconds. */
6
+ export const DEFAULT_MAX_OUTPUT_STALL_MS = 60_000;
7
+ /** Default for {@link ConnectionLimits.maxBufferedSessionStreams}: 1024. */
8
+ export const DEFAULT_MAX_BUFFERED_SESSION_STREAMS = 1024;
9
+ /** Default for {@link ConnectionLimits.maxIdLength}: 1024. */
10
+ export const DEFAULT_MAX_ID_LENGTH = 1024;
11
+ /** The longest a timer can wait; timers fire at once past it. */
12
+ const MAX_TIMER_MS = 2 ** 31 - 1;
13
+ /** Raised when a client or agent exceeds a {@link ConnectionLimits} limit. */
14
+ export class ConnectionLimitError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = "ConnectionLimitError";
18
+ }
19
+ }
20
+ /** Error for a connection that holds more than a limit allows. */
21
+ export function connectionLimitExceeded(limit, limits) {
22
+ return new ConnectionLimitError(`Connection exceeds ${limit} (${limits[limit]})`);
23
+ }
24
+ /** Error for a connection whose client read none of its output in time. */
25
+ export function connectionOutputStalled(limits) {
26
+ return new ConnectionLimitError(`Connection output stalled for maxOutputStallMs (${limits.maxOutputStallMs})`);
27
+ }
28
+ /** Raised when a message is sent to a connection that has shut down. */
29
+ export class ConnectionClosedError extends Error {
30
+ constructor() {
31
+ super("ACP connection is closed");
32
+ this.name = "ConnectionClosedError";
33
+ }
34
+ }
3
35
  export class OutboundMailbox {
4
36
  enabled;
37
+ onReceiverChange;
38
+ observer;
39
+ /** Queued messages start at `head`; see `dequeue`. */
5
40
  queue = [];
41
+ head = 0;
6
42
  activeLease;
7
43
  isFinished = false;
8
44
  isAborted = false;
9
- constructor(enabled = true) {
45
+ abortError;
46
+ constructor(enabled = true, onReceiverChange, observer) {
10
47
  this.enabled = enabled;
48
+ this.onReceiverChange = onReceiverChange;
49
+ this.observer = observer;
11
50
  }
12
51
  push(message) {
13
52
  if (!this.enabled || this.isFinished) {
14
53
  return;
15
54
  }
16
55
  this.queue.push(message);
56
+ this.observer?.queued?.(message);
17
57
  this.activeLease?.wake();
18
58
  }
59
+ get hasReceiver() {
60
+ return this.activeLease !== undefined;
61
+ }
62
+ get hasQueuedMessages() {
63
+ return this.head < this.queue.length;
64
+ }
19
65
  tryAcquire() {
20
66
  if (!this.enabled || this.isAborted || this.activeLease) {
21
67
  return undefined;
22
68
  }
23
69
  const lease = new MailboxLease(this);
24
70
  this.activeLease = lease;
71
+ this.onReceiverChange?.();
25
72
  return lease;
26
73
  }
27
74
  finish() {
@@ -31,26 +78,36 @@ export class OutboundMailbox {
31
78
  this.isFinished = true;
32
79
  this.activeLease?.wake();
33
80
  }
34
- abort() {
81
+ abort(error) {
35
82
  if (this.isAborted) {
36
83
  return;
37
84
  }
38
85
  this.isAborted = true;
86
+ this.abortError = error;
39
87
  this.isFinished = true;
40
- this.queue.length = 0;
41
- this.activeLease?.wake();
88
+ while (this.hasQueuedMessages) {
89
+ this.dequeue();
90
+ }
91
+ // Whatever still holds this stream, such as a stalled response body,
92
+ // must not keep its connection reachable.
93
+ this.observer = undefined;
94
+ this.onReceiverChange = undefined;
95
+ this.activeLease?.stop(error ?? new ConnectionClosedError());
42
96
  }
43
97
  /** @internal */
44
98
  async receive(lease) {
45
99
  for (;;) {
46
- if (this.isAborted || lease.released || this.activeLease !== lease) {
100
+ if (lease.released || this.activeLease !== lease) {
47
101
  return { done: true, value: undefined };
48
102
  }
49
- if (this.queue.length > 0) {
50
- return {
51
- done: false,
52
- value: this.queue.shift(),
53
- };
103
+ if (this.isAborted) {
104
+ if (this.abortError !== undefined) {
105
+ throw this.abortError;
106
+ }
107
+ return { done: true, value: undefined };
108
+ }
109
+ if (this.hasQueuedMessages) {
110
+ return { done: false, value: this.dequeue() };
54
111
  }
55
112
  if (this.isFinished) {
56
113
  return { done: true, value: undefined };
@@ -65,47 +122,108 @@ export class OutboundMailbox {
65
122
  }
66
123
  this.activeLease = undefined;
67
124
  lease.markReleased();
125
+ this.onReceiverChange?.();
126
+ }
127
+ /**
128
+ * Takes the oldest queued message. `Array.prototype.shift` copies the rest
129
+ * of a large array, which would make draining a full queue quadratic, so
130
+ * this advances `head` instead and drops the taken prefix once the queue
131
+ * empties or is mostly taken.
132
+ */
133
+ dequeue() {
134
+ const message = this.queue[this.head];
135
+ this.queue[this.head] = undefined;
136
+ this.head += 1;
137
+ if (this.head === this.queue.length) {
138
+ this.queue = [];
139
+ this.head = 0;
140
+ }
141
+ else if (this.head >= 1024 && this.head * 2 >= this.queue.length) {
142
+ this.queue = this.queue.slice(this.head);
143
+ this.head = 0;
144
+ }
145
+ this.observer?.dequeued(message);
146
+ return message;
68
147
  }
69
148
  }
70
149
  export class ConnectionState {
71
150
  transport;
151
+ limits;
72
152
  connectionId;
73
153
  inboundTx;
74
154
  outboundRx;
155
+ /** Connection-level HTTP stream, queuing each message as JSON text. */
75
156
  connectionStream;
157
+ /** Every outbound message, for WebSocket connections. */
76
158
  allOutbound;
159
+ /** Session-level HTTP streams, queuing each message as JSON text. */
77
160
  sessionStreams = new Map();
78
161
  pendingRoutes = new Map();
79
162
  clientResponseRoutes = new Map();
80
163
  closed;
81
164
  agentConnection;
165
+ /** Length of the JSON text queued on HTTP streams; see `maxBufferedBytes`. */
166
+ bufferedBytes = 0;
167
+ streamObserver = {
168
+ queued: (json) => {
169
+ this.bufferedBytes += json.length;
170
+ },
171
+ dequeued: (json) => {
172
+ this.bufferedBytes -= json.length;
173
+ this.notifyOutboundProgress();
174
+ },
175
+ };
176
+ /** Session streams without a receiver; see `maxBufferedSessionStreams`. */
177
+ bufferedSessionStreams = 0;
178
+ /** Called when a client takes output, or the connection stops routing. */
179
+ progressWaiters = new Set();
82
180
  supportsBatches = false;
83
181
  hasStartedRouter = false;
182
+ hasAgentOutputEnded = false;
183
+ hasFinishedRouting = false;
184
+ hasStartedShutdown = false;
84
185
  inboundWriteChain = Promise.resolve();
85
186
  initialReader;
86
187
  outboundReader;
87
188
  routerPromise;
88
189
  shutdownPromise;
89
190
  hasResolvedClosed = false;
90
- finishAgentOutbound = () => { };
191
+ abortAgentInbound = () => { };
192
+ endAgentOutbound = () => { };
91
193
  resolveClosed = () => { };
92
- constructor(agent, transport = "http") {
194
+ constructor(agent, transport, limits) {
93
195
  this.transport = transport;
196
+ this.limits = limits;
94
197
  this.connectionId = globalThis.crypto.randomUUID();
95
- this.connectionStream = new OutboundMailbox(transport === "http");
96
- this.allOutbound = new OutboundMailbox(transport === "websocket");
198
+ this.connectionStream = new OutboundMailbox(transport === "http", undefined, this.streamObserver);
199
+ // WebSocket output waits here only until the socket pump takes it; the
200
+ // socket itself holds what the client has not read.
201
+ this.allOutbound = new OutboundMailbox(transport === "websocket", undefined, {
202
+ dequeued: () => {
203
+ this.notifyOutboundProgress();
204
+ },
205
+ });
97
206
  this.closed = new Promise((resolve) => {
98
207
  this.resolveClosed = resolve;
99
208
  });
100
- const inbound = new TransformStream();
101
- const outbound = createBufferedOutboundChannel((message) => {
209
+ const inbound = new TransformStream({
210
+ start: (controller) => {
211
+ this.abortAgentInbound = (error) => controller.error(error);
212
+ },
213
+ });
214
+ const outbound = createOutboundChannel((message) => {
102
215
  if (!this.supportsBatches && Array.isArray(message)) {
103
216
  throw new TypeError("AcpServer transports do not support outbound JSON-RPC batch messages");
104
217
  }
218
+ }, () => {
219
+ // Nothing more will come from the agent, so let the router finish
220
+ // instead of waiting for clients to make room.
221
+ this.hasAgentOutputEnded = true;
222
+ this.notifyOutboundProgress();
105
223
  });
106
224
  this.inboundTx = inbound.writable;
107
225
  this.outboundRx = outbound.readable;
108
- this.finishAgentOutbound = outbound.finish;
226
+ this.endAgentOutbound = outbound.end;
109
227
  const stream = {
110
228
  readable: inbound.readable,
111
229
  writable: outbound.writable,
@@ -167,33 +285,103 @@ export class ConnectionState {
167
285
  get batchesEnabled() {
168
286
  return this.supportsBatches;
169
287
  }
288
+ /** Whether shutdown has begun; a closed connection accepts no more work. */
289
+ get isClosed() {
290
+ return this.hasStartedShutdown;
291
+ }
292
+ /**
293
+ * Whether the connection takes no more agent output for now. HTTP
294
+ * connections hold up to `maxBufferedBytes` in their streams. WebSocket
295
+ * connections hand each message to the socket pump, which waits while the
296
+ * socket holds `maxBufferedBytes`, so they wait until the pump takes it.
297
+ */
298
+ get hasOutboundBacklog() {
299
+ return this.transport === "http"
300
+ ? this.bufferedBytes >= this.limits.maxBufferedBytes
301
+ : this.allOutbound.hasQueuedMessages;
302
+ }
303
+ /** Throws `ConnectionLimitError` for a session ID over `maxIdLength`. */
304
+ validateSessionId(sessionId) {
305
+ this.validateIdLength("Session ID", sessionId);
306
+ }
307
+ /** Throws `ConnectionLimitError` for a request ID over `maxIdLength`. */
308
+ validateRequestId(id) {
309
+ if (typeof id === "string") {
310
+ this.validateIdLength("Request ID", id);
311
+ }
312
+ }
313
+ /**
314
+ * Resolves once the connection can take more agent output, or has stopped,
315
+ * and rejects if `signal` aborts first. Client requests wait on this, so a
316
+ * client that is not reading cannot make the agent produce more.
317
+ */
318
+ async waitForOutboundCapacity(signal) {
319
+ await this.waitForRoom(() => this.hasFinishedRouting, signal);
320
+ }
321
+ /**
322
+ * Returns the stream that delivers messages for a session, creating one
323
+ * that buffers them until a receiver attaches. Throws
324
+ * `ConnectionLimitError` for an over-long ID or when no further session
325
+ * may buffer.
326
+ */
170
327
  ensureSession(sessionId) {
328
+ this.validateSessionId(sessionId);
329
+ // WebSocket connections deliver every message over the socket.
330
+ if (this.transport === "websocket") {
331
+ return this.connectionStream;
332
+ }
171
333
  const existing = this.sessionStreams.get(sessionId);
172
334
  if (existing) {
173
335
  return existing;
174
336
  }
175
- const stream = new OutboundMailbox(this.transport === "http");
176
- this.sessionStreams.set(sessionId, stream);
177
- return stream;
337
+ // A new stream has no receiver yet, so its messages are buffered.
338
+ if (this.bufferedSessionStreams >= this.limits.maxBufferedSessionStreams) {
339
+ throw connectionLimitExceeded("maxBufferedSessionStreams", this.limits);
340
+ }
341
+ return this.createSessionStream(sessionId);
342
+ }
343
+ /**
344
+ * Attaches a receiver to a session's stream, or returns `undefined` if the
345
+ * stream cannot take one. The open request backing the receiver bounds it,
346
+ * so this is not limited by `maxBufferedSessionStreams`. Throws
347
+ * `ConnectionLimitError` for an over-long ID, and `ConnectionClosedError`
348
+ * for a new stream once the connection can no longer route output to it.
349
+ */
350
+ acquireSessionStream(sessionId) {
351
+ this.validateSessionId(sessionId);
352
+ if (this.transport === "websocket") {
353
+ return undefined;
354
+ }
355
+ const existing = this.sessionStreams.get(sessionId);
356
+ if (existing) {
357
+ return existing.tryAcquire();
358
+ }
359
+ if (this.isClosed || this.hasFinishedRouting) {
360
+ throw new ConnectionClosedError();
361
+ }
362
+ return this.createSessionStream(sessionId).tryAcquire();
178
363
  }
179
- async shutdown() {
364
+ async shutdown(error) {
180
365
  if (!this.shutdownPromise) {
181
- this.shutdownPromise = this.runShutdown();
366
+ this.shutdownPromise = this.runShutdown(error);
182
367
  }
183
368
  return this.shutdownPromise;
184
369
  }
185
- async runShutdown() {
370
+ async runShutdown(error) {
186
371
  try {
187
- this.connectionStream.abort();
188
- this.allOutbound.abort();
372
+ this.hasStartedShutdown = true;
373
+ this.notifyOutboundProgress();
374
+ this.connectionStream.abort(error);
375
+ this.allOutbound.abort(error);
189
376
  for (const stream of this.sessionStreams.values()) {
190
- stream.abort();
377
+ stream.abort(error);
191
378
  }
192
379
  this.sessionStreams.clear();
380
+ this.bufferedSessionStreams = 0;
193
381
  this.pendingRoutes.clear();
194
382
  this.clientResponseRoutes.clear();
195
383
  await Promise.allSettled([
196
- this.inboundTx.close(),
384
+ this.closeInbound(error),
197
385
  this.cancelOutboundReader(),
198
386
  ]);
199
387
  }
@@ -213,7 +401,7 @@ export class ConnectionState {
213
401
  await this.shutdown();
214
402
  return;
215
403
  }
216
- this.finishAgentOutbound();
404
+ this.endAgentOutbound();
217
405
  await this.routerPromise;
218
406
  });
219
407
  }
@@ -224,11 +412,30 @@ export class ConnectionState {
224
412
  }
225
413
  return this.outboundRx.cancel();
226
414
  }
415
+ closeInbound(error) {
416
+ if (error !== undefined || this.inboundTx.locked) {
417
+ this.abortAgentInbound(error ?? new ConnectionClosedError());
418
+ return Promise.resolve();
419
+ }
420
+ return this.inboundTx.close();
421
+ }
227
422
  async writeInboundMessage(message) {
423
+ // Once routing has finished, the agent can no longer answer, such as a
424
+ // request that waited for room while the agent exited.
425
+ if (this.isClosed || this.hasFinishedRouting) {
426
+ throw new ConnectionClosedError();
427
+ }
228
428
  const writer = this.inboundTx.getWriter();
229
429
  try {
230
430
  await writer.write(message);
231
431
  }
432
+ catch (error) {
433
+ // Shutdown aborts a write that was still waiting on the agent.
434
+ if (this.isClosed) {
435
+ throw new ConnectionClosedError();
436
+ }
437
+ throw error;
438
+ }
232
439
  finally {
233
440
  writer.releaseLock();
234
441
  }
@@ -239,16 +446,30 @@ export class ConnectionState {
239
446
  try {
240
447
  while (true) {
241
448
  const result = await reader.read();
242
- if (result.done) {
449
+ if (result.done || this.isClosed) {
243
450
  return;
244
451
  }
245
452
  this.routeOutbound(result.value);
453
+ // Take no more output, which makes the agent's sends wait, until
454
+ // clients read what is queued. Once the agent's output has ended,
455
+ // nothing waits on the router, so it routes what is left.
456
+ await this.waitForRoom(() => this.hasAgentOutputEnded);
246
457
  }
247
458
  }
248
459
  catch (error) {
249
- console.error("ACP connection router stopped unexpectedly:", error);
460
+ // Output can no longer be routed, so close the connection rather than
461
+ // leave the agent and its client waiting on it.
462
+ if (error instanceof ConnectionLimitError) {
463
+ this.closeForLimit(error);
464
+ }
465
+ else {
466
+ console.error(`ACP connection ${this.connectionId} router stopped unexpectedly:`, error);
467
+ void this.shutdown(error);
468
+ }
250
469
  }
251
470
  finally {
471
+ this.hasFinishedRouting = true;
472
+ this.notifyOutboundProgress();
252
473
  if (this.outboundReader === reader) {
253
474
  this.outboundReader = undefined;
254
475
  }
@@ -268,15 +489,74 @@ export class ConnectionState {
268
489
  this.hasResolvedClosed = true;
269
490
  this.resolveClosed();
270
491
  }
492
+ /**
493
+ * Waits while the connection holds `maxBufferedBytes` of output, until
494
+ * `stopWaiting()` holds or the connection closes, and rejects if `signal`
495
+ * aborts first. An HTTP client that takes none of the output for
496
+ * `maxOutputStallMs`, having stopped reading or never opened the stream it
497
+ * is for, is not coming back for it, so the connection closes rather than
498
+ * keep the agent or the client's own requests waiting. A WebSocket session
499
+ * times out its socket itself, where it can see each byte the client reads.
500
+ */
501
+ async waitForRoom(stopWaiting, signal) {
502
+ while (!this.isClosed && !stopWaiting() && this.hasOutboundBacklog) {
503
+ if (!(await this.waitForProgress(signal))) {
504
+ this.closeForLimit(connectionOutputStalled(this.limits));
505
+ return;
506
+ }
507
+ }
508
+ }
509
+ /**
510
+ * Resolves `true` the next time a client takes output or the connection
511
+ * stops routing, or on HTTP, `false` if `maxOutputStallMs` passes first.
512
+ * Rejects if `signal` aborts first, leaving nothing behind.
513
+ */
514
+ waitForProgress(signal) {
515
+ const timeoutMs = this.transport === "http" ? this.limits.maxOutputStallMs : undefined;
516
+ return new Promise((resolve, reject) => {
517
+ if (signal?.aborted) {
518
+ reject(signal.reason);
519
+ return;
520
+ }
521
+ let timer;
522
+ const settle = () => {
523
+ this.progressWaiters.delete(onProgress);
524
+ signal?.removeEventListener("abort", onAbort);
525
+ clearTimeout(timer);
526
+ };
527
+ const onProgress = () => {
528
+ settle();
529
+ resolve(true);
530
+ };
531
+ const onAbort = () => {
532
+ settle();
533
+ reject(signal?.reason);
534
+ };
535
+ this.progressWaiters.add(onProgress);
536
+ signal?.addEventListener("abort", onAbort, { once: true });
537
+ if (timeoutMs !== undefined) {
538
+ timer = setTimeout(() => {
539
+ settle();
540
+ resolve(false);
541
+ }, timeoutMs);
542
+ }
543
+ });
544
+ }
545
+ notifyOutboundProgress() {
546
+ for (const onProgress of [...this.progressWaiters]) {
547
+ onProgress();
548
+ }
549
+ }
271
550
  routeOutbound(message) {
272
- this.allOutbound.push(message);
273
551
  if (Array.isArray(message)) {
274
552
  for (const item of message) {
275
553
  this.routeOutboundMessage(item);
276
554
  }
277
- return;
278
555
  }
279
- this.routeOutboundMessage(message);
556
+ else {
557
+ this.routeOutboundMessage(message);
558
+ }
559
+ this.allOutbound.push(message);
280
560
  }
281
561
  routeOutboundMessage(message) {
282
562
  if (isResponseMessage(message)) {
@@ -290,22 +570,66 @@ export class ConnectionState {
290
570
  const route = key ? this.pendingRoutes.get(key) : undefined;
291
571
  const sessionId = sessionIdFromResponseResult(message);
292
572
  if (sessionId) {
293
- this.ensureSession(sessionId);
573
+ // Never hand out a session ID this connection would refuse to route.
574
+ this.validateSessionId(sessionId);
294
575
  }
295
576
  if (key) {
296
577
  this.pendingRoutes.delete(key);
297
578
  }
298
579
  this.pushToRoute(route ?? "connection", message);
299
580
  }
581
+ createSessionStream(sessionId) {
582
+ const stream = new OutboundMailbox(true, () => {
583
+ this.onSessionReceiverChange(sessionId, stream);
584
+ }, this.streamObserver);
585
+ this.sessionStreams.set(sessionId, stream);
586
+ this.bufferedSessionStreams += 1;
587
+ return stream;
588
+ }
589
+ /**
590
+ * Keeps `bufferedSessionStreams` in step as receivers attach and leave. A
591
+ * stream a receiver leaves is kept only while messages still wait on it.
592
+ */
593
+ onSessionReceiverChange(sessionId, stream) {
594
+ // Shutdown discards every stream and resets the count itself.
595
+ if (this.isClosed) {
596
+ return;
597
+ }
598
+ if (stream.hasReceiver) {
599
+ this.bufferedSessionStreams -= 1;
600
+ return;
601
+ }
602
+ if (!stream.hasQueuedMessages) {
603
+ this.sessionStreams.delete(sessionId);
604
+ stream.abort();
605
+ return;
606
+ }
607
+ this.bufferedSessionStreams += 1;
608
+ // Once routing has finished, nothing more can be buffered.
609
+ if (!this.hasFinishedRouting &&
610
+ this.bufferedSessionStreams > this.limits.maxBufferedSessionStreams) {
611
+ this.closeForLimit(connectionLimitExceeded("maxBufferedSessionStreams", this.limits));
612
+ }
613
+ }
614
+ closeForLimit(error) {
615
+ console.warn(`Closing ACP connection ${this.connectionId}:`, error.message);
616
+ void this.shutdown(error);
617
+ }
618
+ validateIdLength(label, id) {
619
+ if (id.length > this.limits.maxIdLength) {
620
+ throw new ConnectionLimitError(`${label} exceeds maxIdLength (${this.limits.maxIdLength})`);
621
+ }
622
+ }
300
623
  routeOutboundRequestOrNotification(message) {
301
624
  const sessionId = sessionIdFromMessageParams(message);
302
625
  if (sessionId) {
626
+ const stream = this.ensureSession(sessionId);
303
627
  this.trackClientResponseRoute(message, { session: sessionId });
304
- this.ensureSession(sessionId).push(message);
628
+ this.deliver(stream, message);
305
629
  return;
306
630
  }
307
631
  this.trackClientResponseRoute(message, "connection");
308
- this.connectionStream.push(message);
632
+ this.deliver(this.connectionStream, message);
309
633
  }
310
634
  trackClientResponseRoute(message, route) {
311
635
  if (!("id" in message) || !("method" in message)) {
@@ -318,23 +642,37 @@ export class ConnectionState {
318
642
  }
319
643
  pushToRoute(route, message) {
320
644
  if (route === "connection") {
321
- this.connectionStream.push(message);
645
+ this.deliver(this.connectionStream, message);
322
646
  return;
323
647
  }
324
- this.ensureSession(route.session).push(message);
648
+ this.deliver(this.ensureSession(route.session), message);
649
+ }
650
+ /**
651
+ * Queues a message on an HTTP stream as JSON text, which is all a stream
652
+ * delivers and all `maxBufferedBytes` needs to count. WebSocket
653
+ * connections deliver through `allOutbound` instead.
654
+ */
655
+ deliver(stream, message) {
656
+ if (this.transport === "http") {
657
+ stream.push(JSON.stringify(message));
658
+ }
325
659
  }
326
660
  }
327
661
  export class ConnectionRegistry {
662
+ limits;
328
663
  connections = new Map();
329
664
  pendingConnections = new Map();
665
+ constructor(options = {}) {
666
+ this.limits = resolveConnectionLimits(options);
667
+ }
330
668
  createConnection(agent, transport = "http") {
331
- const connection = new ConnectionState(agent, transport);
669
+ const connection = new ConnectionState(agent, transport, this.limits);
332
670
  this.connections.set(connection.connectionId, connection);
333
671
  this.trackConnectionClose(connection);
334
672
  return connection;
335
673
  }
336
674
  createPendingConnection(agent, transport = "websocket") {
337
- const connection = new ConnectionState(agent, transport);
675
+ const connection = new ConnectionState(agent, transport, this.limits);
338
676
  this.pendingConnections.set(connection.connectionId, connection);
339
677
  this.trackConnectionClose(connection);
340
678
  return connection;
@@ -344,10 +682,13 @@ export class ConnectionRegistry {
344
682
  this.connections.set(connection.connectionId, connection);
345
683
  }
346
684
  get(connectionId) {
347
- return this.connections.get(connectionId);
685
+ const connection = this.connections.get(connectionId);
686
+ return connection?.isClosed ? undefined : connection;
348
687
  }
349
688
  remove(connectionId) {
350
- const connection = this.get(connectionId);
689
+ // Unlike `get`, include a connection that is already shutting down, so
690
+ // DELETE still succeeds until its shutdown completes.
691
+ const connection = this.connections.get(connectionId);
351
692
  if (!connection) {
352
693
  return undefined;
353
694
  }
@@ -386,15 +727,36 @@ export class ConnectionRegistry {
386
727
  });
387
728
  }
388
729
  }
730
+ function resolveConnectionLimits(options) {
731
+ const limits = {
732
+ maxBufferedBytes: options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES,
733
+ maxOutputStallMs: options.maxOutputStallMs ?? DEFAULT_MAX_OUTPUT_STALL_MS,
734
+ maxBufferedSessionStreams: options.maxBufferedSessionStreams ?? DEFAULT_MAX_BUFFERED_SESSION_STREAMS,
735
+ maxIdLength: options.maxIdLength ?? DEFAULT_MAX_ID_LENGTH,
736
+ };
737
+ for (const [name, value] of Object.entries(limits)) {
738
+ if (!Number.isSafeInteger(value) || value <= 0) {
739
+ throw new RangeError(`${name} must be a positive safe integer`);
740
+ }
741
+ }
742
+ if (limits.maxOutputStallMs > MAX_TIMER_MS) {
743
+ throw new RangeError(`maxOutputStallMs must be at most ${MAX_TIMER_MS}`);
744
+ }
745
+ return limits;
746
+ }
389
747
  class MailboxLease {
390
748
  mailbox;
391
749
  released = false;
750
+ stoppedController = new AbortController();
392
751
  receiving = false;
393
752
  wakePromise;
394
753
  resolveWake;
395
754
  constructor(mailbox) {
396
755
  this.mailbox = mailbox;
397
756
  }
757
+ get stopped() {
758
+ return this.stoppedController.signal;
759
+ }
398
760
  async receive() {
399
761
  if (this.receiving) {
400
762
  throw new Error("ACP outbound mailbox lease already has a pending receive");
@@ -427,10 +789,27 @@ class MailboxLease {
427
789
  this.released = true;
428
790
  this.wake();
429
791
  }
792
+ stop(reason) {
793
+ this.stoppedController.abort(reason);
794
+ this.wake();
795
+ }
430
796
  }
431
- function createBufferedOutboundChannel(validate) {
797
+ /**
798
+ * Carries the agent's output to the router. A write resolves at once if the
799
+ * router is waiting for a message, and otherwise once the router takes it
800
+ * and asks for the next one, so an agent that awaits its sends goes no
801
+ * faster than the router, which pauses while clients are behind.
802
+ */
803
+ function createOutboundChannel(validate, onEnd) {
432
804
  let controller;
433
805
  let isFinished = false;
806
+ let isEnding = false;
807
+ let resumeWriter;
808
+ const resume = () => {
809
+ const resolve = resumeWriter;
810
+ resumeWriter = undefined;
811
+ resolve?.();
812
+ };
434
813
  const finish = () => {
435
814
  if (isFinished) {
436
815
  return;
@@ -442,6 +821,8 @@ function createBufferedOutboundChannel(validate) {
442
821
  catch {
443
822
  // The router may already have cancelled the readable side.
444
823
  }
824
+ resume();
825
+ onEnd();
445
826
  };
446
827
  const fail = (error) => {
447
828
  if (isFinished) {
@@ -454,18 +835,39 @@ function createBufferedOutboundChannel(validate) {
454
835
  catch {
455
836
  // The router may already have cancelled the readable side.
456
837
  }
838
+ resume();
839
+ onEnd();
840
+ };
841
+ /**
842
+ * Ends the channel for an agent that closed without closing its stream, as
843
+ * a connector whose `closed` resolves on its own may. Writes it made before
844
+ * closing can still be queued in the writable stream, which hands them
845
+ * over one per microtask, so they stop waiting for the router and the
846
+ * channel finishes after them.
847
+ */
848
+ const end = () => {
849
+ if (isFinished || isEnding) {
850
+ return;
851
+ }
852
+ isEnding = true;
853
+ resume();
854
+ setTimeout(finish, 0);
457
855
  };
458
856
  return {
459
857
  readable: new ReadableStream({
460
858
  start(readableController) {
461
859
  controller = readableController;
462
860
  },
861
+ // With no high-water mark, this runs only once the router is waiting
862
+ // on an empty queue, which means it took the last message.
863
+ pull: resume,
463
864
  cancel() {
464
865
  isFinished = true;
866
+ resume();
465
867
  },
466
- }),
868
+ }, { highWaterMark: 0 }),
467
869
  writable: new WritableStream({
468
- write(message) {
870
+ async write(message) {
469
871
  if (isFinished) {
470
872
  throw new Error("ACP outbound channel is closed");
471
873
  }
@@ -477,11 +879,17 @@ function createBufferedOutboundChannel(validate) {
477
879
  fail(error);
478
880
  throw error;
479
881
  }
882
+ // The message went straight to a waiting router unless it is queued.
883
+ if (!isFinished && !isEnding && (controller?.desiredSize ?? 0) < 0) {
884
+ await new Promise((resolve) => {
885
+ resumeWriter = resolve;
886
+ });
887
+ }
480
888
  },
481
889
  close: finish,
482
890
  abort: fail,
483
891
  }),
484
- finish,
892
+ end,
485
893
  };
486
894
  }
487
895
  function isMatchingResponse(msg, id) {