@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.
- package/dist/build-info.json +1 -1
- package/dist/routes/issues.d.ts.map +1 -1
- package/dist/routes/issues.js +1 -0
- package/dist/routes/issues.js.map +1 -1
- package/dist/routes/openapi.d.ts.map +1 -1
- package/dist/routes/openapi.js +133 -53
- package/dist/routes/openapi.js.map +1 -1
- package/dist/routes/paperclip-mcp.d.ts.map +1 -1
- package/dist/routes/paperclip-mcp.js +15 -0
- package/dist/routes/paperclip-mcp.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/acp.d.ts +4 -1
- package/node_modules/@agentclientprotocol/sdk/dist/acp.js +3 -2
- package/node_modules/@agentclientprotocol/sdk/dist/acp.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/connection.d.ts +199 -11
- package/node_modules/@agentclientprotocol/sdk/dist/connection.js +454 -46
- package/node_modules/@agentclientprotocol/sdk/dist/connection.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/connection.test.js +170 -11
- package/node_modules/@agentclientprotocol/sdk/dist/connection.test.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/examples/http-server.js +6 -1
- package/node_modules/@agentclientprotocol/sdk/dist/examples/http-server.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/http-stream.d.ts +2 -0
- package/node_modules/@agentclientprotocol/sdk/dist/http-stream.js +46 -8
- package/node_modules/@agentclientprotocol/sdk/dist/http-stream.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/line-buffer.d.ts +7 -5
- package/node_modules/@agentclientprotocol/sdk/dist/line-buffer.js +36 -29
- package/node_modules/@agentclientprotocol/sdk/dist/line-buffer.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/node-adapter.js +33 -2
- package/node_modules/@agentclientprotocol/sdk/dist/node-adapter.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/node-adapter.test.js +87 -0
- package/node_modules/@agentclientprotocol/sdk/dist/node-adapter.test.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/server-sse.d.ts +6 -1
- package/node_modules/@agentclientprotocol/sdk/dist/server-sse.js +78 -30
- package/node_modules/@agentclientprotocol/sdk/dist/server-sse.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/server-sse.test.js +59 -6
- package/node_modules/@agentclientprotocol/sdk/dist/server-sse.test.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/server-websocket-upgrade.test.js +320 -2
- package/node_modules/@agentclientprotocol/sdk/dist/server-websocket-upgrade.test.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/server.d.ts +4 -3
- package/node_modules/@agentclientprotocol/sdk/dist/server.js +49 -21
- package/node_modules/@agentclientprotocol/sdk/dist/server.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/server.test.js +530 -0
- package/node_modules/@agentclientprotocol/sdk/dist/server.test.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/sse.d.ts +3 -1
- package/node_modules/@agentclientprotocol/sdk/dist/sse.js +59 -36
- package/node_modules/@agentclientprotocol/sdk/dist/sse.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/stream-limits.d.ts +15 -0
- package/node_modules/@agentclientprotocol/sdk/dist/stream-limits.js +58 -0
- package/node_modules/@agentclientprotocol/sdk/dist/stream-limits.js.map +1 -0
- package/node_modules/@agentclientprotocol/sdk/dist/stream.d.ts +4 -1
- package/node_modules/@agentclientprotocol/sdk/dist/stream.js +6 -2
- package/node_modules/@agentclientprotocol/sdk/dist/stream.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/test-support/until.d.ts +5 -0
- package/node_modules/@agentclientprotocol/sdk/dist/test-support/until.js +14 -0
- package/node_modules/@agentclientprotocol/sdk/dist/test-support/until.js.map +1 -0
- package/node_modules/@agentclientprotocol/sdk/dist/v2/acp.d.ts +4 -1
- package/node_modules/@agentclientprotocol/sdk/dist/v2/acp.js +3 -2
- package/node_modules/@agentclientprotocol/sdk/dist/v2/acp.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/ws-server.js +143 -15
- package/node_modules/@agentclientprotocol/sdk/dist/ws-server.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/dist/ws-utils.d.ts +6 -0
- package/node_modules/@agentclientprotocol/sdk/dist/ws-utils.js.map +1 -1
- package/node_modules/@agentclientprotocol/sdk/package.json +1 -1
- package/package.json +21 -21
- package/ui-dist/assets/{Agents.production-Cj5rZo69.js → Agents.production-C3HZ3WkH.js} +2 -2
- package/ui-dist/assets/{CompanyActivity.production-BsesMNQp.js → CompanyActivity.production-4aA8pwxB.js} +1 -1
- package/ui-dist/assets/{CompanyExport-CWmvCY4A.js → CompanyExport-CblRC_yJ.js} +1 -1
- package/ui-dist/assets/{CompanySkills.production-BGtfN-3A.js → CompanySkills.production-AZTd440C.js} +1 -1
- package/ui-dist/assets/{Costs.production-84DycSrt.js → Costs.production-QhFTCjhh.js} +1 -1
- package/ui-dist/assets/{OrgChart.production-D_VwOg1Z.js → OrgChart.production-BUtNNhCY.js} +1 -1
- package/ui-dist/assets/{RoutineDetail.production-DJe0NpxJ.js → RoutineDetail.production-Bi5ZoMXD.js} +1 -1
- package/ui-dist/assets/{Routines.production-VhYuO012.js → Routines.production-Ck_OcEg_.js} +1 -1
- package/ui-dist/assets/{definition-DurZnIaP.js → definition-BsNVlrmu.js} +1 -1
- package/ui-dist/assets/{index-BqdSRbLj.js → index-b6Jdkq01.js} +4 -4
- package/ui-dist/index.html +1 -1
- 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
|
-
|
|
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.
|
|
41
|
-
|
|
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 (
|
|
100
|
+
if (lease.released || this.activeLease !== lease) {
|
|
47
101
|
return { done: true, value: undefined };
|
|
48
102
|
}
|
|
49
|
-
if (this.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
191
|
+
abortAgentInbound = () => { };
|
|
192
|
+
endAgentOutbound = () => { };
|
|
91
193
|
resolveClosed = () => { };
|
|
92
|
-
constructor(agent, transport
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
176
|
-
this.
|
|
177
|
-
|
|
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.
|
|
188
|
-
this.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
628
|
+
this.deliver(stream, message);
|
|
305
629
|
return;
|
|
306
630
|
}
|
|
307
631
|
this.trackClientResponseRoute(message, "connection");
|
|
308
|
-
this.connectionStream
|
|
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
|
|
645
|
+
this.deliver(this.connectionStream, message);
|
|
322
646
|
return;
|
|
323
647
|
}
|
|
324
|
-
this.ensureSession(route.session)
|
|
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
|
-
|
|
685
|
+
const connection = this.connections.get(connectionId);
|
|
686
|
+
return connection?.isClosed ? undefined : connection;
|
|
348
687
|
}
|
|
349
688
|
remove(connectionId) {
|
|
350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
892
|
+
end,
|
|
485
893
|
};
|
|
486
894
|
}
|
|
487
895
|
function isMatchingResponse(msg, id) {
|