@hheei/pi-subagents 0.3.0 → 0.4.0

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.
@@ -1,490 +0,0 @@
1
- import { chmod, lstat, unlink } from "node:fs/promises";
2
- import { connect, createServer } from "node:net";
3
- import { errorMessage, isRecord } from "@hheei/pi-ext-core";
4
- import { PROTOCOL_VERSION } from "./domain.js";
5
- import { attachJsonLineReader, serializeJsonLine } from "./json-lines.js";
6
- import { BRIDGE_OPERATIONS, BridgeError, DEFAULT_MAX_FRAME_BYTES, DEFAULT_MAX_PENDING_REQUESTS, failureResponse, isEventFrame, isHelloFrame, isRequestFrame, isResponseFrame, successResponse, } from "./protocol.js";
7
- /**
8
- * A handshake must arrive promptly: a socket that connects and then stays silent is either a
9
- * probe or a broken client, and neither may occupy the server.
10
- */
11
- const HANDSHAKE_TIMEOUT_MS = 10_000;
12
- /** How long the parent waits for a child's answer before failing the call visibly. */
13
- export const DEFAULT_CHILD_REQUEST_TIMEOUT_MS = 30_000;
14
- /**
15
- * Reports are at-least-once: a child that loses the connection before the answer arrives sends the
16
- * same request again, and the parent answers it from the first result instead of acting twice. The
17
- * cache is per child and in memory — it covers a reconnect, not a parent restart.
18
- */
19
- const MAX_SERVED_REQUESTS = 256;
20
- /** How long adoption waits for a surviving child to reconnect. */
21
- const DEFAULT_ADOPT_WAIT_MS = 5_000;
22
- /**
23
- * The parent side of the child bridge: one Unix socket per parent session, one long-lived
24
- * connection per child, carrying both directions of control.
25
- *
26
- * Control lives in the parent process instead of a helper process, so a child presented in a host
27
- * panel and a child running headless are driven by exactly the same requests. A child that
28
- * reconnects (parent restart, extension reload, dropped socket) replaces its previous connection
29
- * and is otherwise treated as the same runtime.
30
- */
31
- export class ChildBridgeServer {
32
- #options;
33
- #connections = new Map();
34
- /**
35
- * Requests already answered, keyed by runtime and request id, so a retry is not executed twice.
36
- * The runtime identity belongs in the key: a relaunched child starts counting its request ids
37
- * from the beginning again, and answering its first report with the previous runtime's result
38
- * would silently drop a report the child believes was delivered.
39
- */
40
- #served = new Map();
41
- /** Requests being handled right now, so two copies in flight share one answer. */
42
- #serving = new Map();
43
- /** Sockets that have connected but not finished their handshake, so close() can end them. */
44
- #handshaking = new Set();
45
- #adoptWaiters = new Set();
46
- #eventListeners = new Set();
47
- #connectionListeners = new Set();
48
- #server;
49
- #boundEndpoint = false;
50
- #requestSeq = 0;
51
- #closed = false;
52
- constructor(options) {
53
- this.#options = options;
54
- }
55
- get endpoint() {
56
- return this.#options.endpoint;
57
- }
58
- /** Binds the endpoint, refusing one that another live bridge server still owns. */
59
- async listen() {
60
- if (this.#server !== undefined)
61
- throw new Error("bridge server is already listening");
62
- await this.#clearStaleEndpoint();
63
- const server = createServer({ allowHalfOpen: false });
64
- this.#server = server;
65
- server.on("connection", (socket) => this.#accept(socket));
66
- server.on("error", (error) => this.#diagnose(`socket server error: ${errorMessage(error)}`));
67
- const listening = new Promise((resolve, reject) => {
68
- server.once("error", reject);
69
- server.listen(this.#options.endpoint, () => {
70
- server.off("error", reject);
71
- resolve();
72
- });
73
- });
74
- try {
75
- await listening;
76
- }
77
- catch (error) {
78
- this.#server = undefined;
79
- server.close();
80
- throw error instanceof Error ? error : new Error(errorMessage(error));
81
- }
82
- this.#boundEndpoint = true;
83
- await this.#restrictEndpoint();
84
- }
85
- isConnected(childId) {
86
- const connection = this.#connections.get(childId);
87
- return connection !== undefined && !connection.closed;
88
- }
89
- connectedChildren() {
90
- return [...this.#connections.values()]
91
- .filter((entry) => !entry.closed)
92
- .map((entry) => entry.childId);
93
- }
94
- /** Sends a request to a connected child and waits for its answer. */
95
- async request(childId, operation, payload, options = {}) {
96
- const connection = this.#connections.get(childId);
97
- if (connection === undefined || connection.closed) {
98
- throw new BridgeError("child_not_connected", `Child ${childId} has no live bridge connection`);
99
- }
100
- if (!BRIDGE_OPERATIONS.includes(operation)) {
101
- // The child closes a connection that carries a frame it cannot parse, so an operation
102
- // outside the protocol must fail here instead of hanging until the request timeout.
103
- throw new BridgeError("unsupported_operation", `${operation} is not a bridge operation`);
104
- }
105
- const maxPending = this.#options.maxPendingRequests ?? DEFAULT_MAX_PENDING_REQUESTS;
106
- if (connection.pending.size >= maxPending) {
107
- throw new BridgeError("too_many_pending_requests", `Child ${childId} has too many requests in flight`);
108
- }
109
- const id = `bridge-${++this.#requestSeq}`;
110
- const timeoutMs = options.timeoutMs ?? this.#options.requestTimeoutMs ?? DEFAULT_CHILD_REQUEST_TIMEOUT_MS;
111
- const frame = {
112
- version: PROTOCOL_VERSION,
113
- type: "request",
114
- id,
115
- operation,
116
- ...(payload === undefined ? {} : { payload }),
117
- };
118
- return await new Promise((resolve, reject) => {
119
- const timer = setTimeout(() => {
120
- connection.pending.delete(id);
121
- reject(new BridgeError("timeout", `Child ${childId} did not answer ${operation} in time`));
122
- }, timeoutMs);
123
- connection.pending.set(id, { resolve, reject, timer });
124
- if (this.#write(connection, frame))
125
- return;
126
- // A request that never left the parent must fail now, not after the timeout.
127
- connection.pending.delete(id);
128
- clearTimeout(timer);
129
- reject(new BridgeError("write_failed", `Could not send ${operation} to child ${childId}`));
130
- });
131
- }
132
- /**
133
- * Waits for a child's bridge to come up. A child that survived a parent restart reconnects on
134
- * its own schedule, so adoption is a bounded wait rather than a spawn.
135
- */
136
- async waitForConnection(childId, options = {}) {
137
- if (this.isConnected(childId))
138
- return true;
139
- return await new Promise((resolve) => {
140
- let settled = false;
141
- const finish = (connected) => {
142
- if (settled)
143
- return;
144
- settled = true;
145
- clearTimeout(timer);
146
- this.#adoptWaiters.delete(waiter);
147
- options.signal?.removeEventListener("abort", onAbort);
148
- resolve(connected);
149
- };
150
- const waiter = { childId, notify: finish };
151
- const timer = setTimeout(() => finish(false), options.timeoutMs ?? DEFAULT_ADOPT_WAIT_MS);
152
- const onAbort = () => finish(false);
153
- options.signal?.addEventListener("abort", onAbort, { once: true });
154
- this.#adoptWaiters.add(waiter);
155
- });
156
- }
157
- /**
158
- * Ends a child's connection from this side. The parent uses it when the process stopped being the
159
- * child: leaving the socket open would let a relaunch mistake the old connection for the new
160
- * runtime and send into a session the parent no longer owns.
161
- */
162
- disconnect(childId) {
163
- const connection = this.#connections.get(childId);
164
- if (connection === undefined)
165
- return;
166
- this.#connections.delete(childId);
167
- this.#drop(connection, "the child left the delegated session");
168
- this.#emitConnection(childId, false);
169
- }
170
- /** Subscribes to the fire-and-forget frames a child pushes: Pi events, lifecycle, input. */
171
- onEvent(listener) {
172
- this.#eventListeners.add(listener);
173
- return () => {
174
- this.#eventListeners.delete(listener);
175
- };
176
- }
177
- /** Subscribes to connection transitions; a reconnect is what tells the parent a child is alive. */
178
- onConnectionChange(listener) {
179
- this.#connectionListeners.add(listener);
180
- return () => {
181
- this.#connectionListeners.delete(listener);
182
- };
183
- }
184
- #emitEvent(childId, event) {
185
- for (const listener of [...this.#eventListeners]) {
186
- try {
187
- listener(childId, event);
188
- }
189
- catch (error) {
190
- this.#diagnose(`event listener failed: ${errorMessage(error)}`);
191
- }
192
- }
193
- }
194
- #emitConnection(childId, connected) {
195
- for (const listener of [...this.#connectionListeners]) {
196
- try {
197
- listener(childId, connected);
198
- }
199
- catch (error) {
200
- this.#diagnose(`connection listener failed: ${errorMessage(error)}`);
201
- }
202
- }
203
- }
204
- #notifyAdopters(childId) {
205
- for (const waiter of [...this.#adoptWaiters]) {
206
- if (waiter.childId === childId)
207
- waiter.notify(true);
208
- }
209
- }
210
- /** Closes every connection and removes the socket this server created. */
211
- async close() {
212
- if (this.#closed)
213
- return;
214
- this.#closed = true;
215
- for (const connection of [...this.#connections.values()]) {
216
- this.#drop(connection, "bridge server is closing");
217
- }
218
- this.#connections.clear();
219
- // A handshake that is still waiting for its answer holds a socket that would otherwise keep
220
- // the server open, so teardown must end it here.
221
- for (const socket of [...this.#handshaking])
222
- socket.destroy();
223
- this.#handshaking.clear();
224
- for (const waiter of [...this.#adoptWaiters])
225
- waiter.notify(false);
226
- const server = this.#server;
227
- this.#server = undefined;
228
- if (server !== undefined) {
229
- await new Promise((resolve) => {
230
- server.close(() => resolve());
231
- });
232
- }
233
- if (!this.#boundEndpoint)
234
- return;
235
- this.#boundEndpoint = false;
236
- try {
237
- await unlink(this.#options.endpoint);
238
- }
239
- catch (error) {
240
- if (!isErrno(error, "ENOENT")) {
241
- this.#diagnose(`could not remove endpoint: ${errorMessage(error)}`);
242
- }
243
- }
244
- }
245
- #accept(socket) {
246
- let connection;
247
- this.#handshaking.add(socket);
248
- const handshake = setTimeout(() => {
249
- if (connection !== undefined)
250
- return;
251
- this.#diagnose("closing a connection that never completed the bridge handshake");
252
- socket.destroy();
253
- }, HANDSHAKE_TIMEOUT_MS);
254
- const stop = attachJsonLineReader(socket, {
255
- maxFrameBytes: this.#frameLimit(),
256
- onValue: (value) => {
257
- if (connection === undefined) {
258
- // A child waits for `hello_ack` before sending anything else, so nothing is pipelined
259
- // behind the hello: the handshake can complete asynchronously without losing frames.
260
- void this.#handshake(socket, value).then((accepted) => {
261
- if (accepted === undefined)
262
- return;
263
- clearTimeout(handshake);
264
- connection = accepted;
265
- });
266
- return;
267
- }
268
- this.#receive(connection, value);
269
- },
270
- onError: (error) => {
271
- this.#diagnose(`bridge connection frame error: ${error.message}`);
272
- socket.destroy();
273
- },
274
- });
275
- const teardown = () => {
276
- clearTimeout(handshake);
277
- stop();
278
- this.#handshaking.delete(socket);
279
- const current = connection;
280
- if (current === undefined)
281
- return;
282
- connection = undefined;
283
- // A replaced connection already stopped being the child's connection.
284
- if (current.closed || this.#connections.get(current.childId) !== current)
285
- return;
286
- this.#connections.delete(current.childId);
287
- this.#drop(current, "bridge connection closed");
288
- this.#emitConnection(current.childId, false);
289
- };
290
- socket.on("close", teardown);
291
- socket.on("error", (error) => this.#diagnose(`bridge connection error: ${error.message}`));
292
- }
293
- async #handshake(socket, value) {
294
- if (!isHelloFrame(value)) {
295
- this.#diagnose("refused a bridge connection with an invalid hello frame");
296
- socket.destroy();
297
- return undefined;
298
- }
299
- if (value.parentSessionId !== this.#options.parentSessionId) {
300
- this.#diagnose(`refused a bridge hello for another parent session: ${value.parentSessionId}`);
301
- socket.destroy();
302
- return undefined;
303
- }
304
- // The parent may need to read persisted state to answer this, so the handshake waits for it:
305
- // a connection is never accepted on a partially verified hello. The socket can be gone by the
306
- // time the answer arrives (a handshake timeout or a disconnect), and then there is nothing to
307
- // accept.
308
- const refusal = await this.#options.authorize(value);
309
- // The answer can arrive after the server started closing, or after the socket went away; a late
310
- // handshake must not create a connection that teardown is no longer waiting for.
311
- if (this.#closed || socket.destroyed || !this.#handshaking.has(socket))
312
- return undefined;
313
- if (refusal !== undefined) {
314
- this.#diagnose(`refused a bridge hello for ${value.subagentId}: ${refusal}`);
315
- socket.destroy();
316
- return undefined;
317
- }
318
- const previous = this.#connections.get(value.subagentId);
319
- const connection = {
320
- childId: value.subagentId,
321
- runtimeIdentity: value.runtimeIdentity,
322
- socket,
323
- pending: new Map(),
324
- closed: false,
325
- };
326
- this.#connections.set(value.subagentId, connection);
327
- this.#write(connection, { version: PROTOCOL_VERSION, type: "hello_ack" });
328
- if (previous !== undefined && previous !== connection) {
329
- // The child reconnected: the newer socket is the live one, the old one is stale.
330
- this.#drop(previous, "replaced by a newer bridge connection");
331
- return connection;
332
- }
333
- this.#emitConnection(connection.childId, true);
334
- this.#notifyAdopters(connection.childId);
335
- return connection;
336
- }
337
- #receive(connection, value) {
338
- if (isEventFrame(value)) {
339
- this.#emitEvent(connection.childId, value.event);
340
- return;
341
- }
342
- if (isResponseFrame(value)) {
343
- const pending = connection.pending.get(value.id);
344
- if (pending === undefined)
345
- return;
346
- connection.pending.delete(value.id);
347
- clearTimeout(pending.timer);
348
- if (value.ok)
349
- pending.resolve(value.data);
350
- else
351
- pending.reject(new BridgeError(value.error.code, value.error.message));
352
- return;
353
- }
354
- if (isRequestFrame(value)) {
355
- const key = this.#requestKey(connection, value.id);
356
- const answered = this.#served.get(key);
357
- if (answered !== undefined) {
358
- // The child did not receive the first answer, so it sent the same request again. It is
359
- // answered from that first result: the parent already acted on it.
360
- this.#write(connection, answered);
361
- return;
362
- }
363
- const inFlight = this.#serving.get(key);
364
- if (inFlight !== undefined) {
365
- // The same request arrived twice, either because the answer was lost or because the child
366
- // reconnected while the first copy was still being handled. Both askers want that one
367
- // result, and the one asking now gets it on the connection it is asking on.
368
- void inFlight.then((response) => this.#write(connection, response));
369
- return;
370
- }
371
- const pending = this.#serve(connection, key, value.id, value.operation, value.payload);
372
- this.#serving.set(key, pending);
373
- return;
374
- }
375
- this.#diagnose("closing a bridge connection that sent an unrecognised frame");
376
- connection.socket.destroy();
377
- }
378
- /**
379
- * A request is identified by the runtime that sent it. A relaunched child — the same session, a
380
- * new process — counts its request ids from one again, so the child alone is not an identity.
381
- */
382
- #requestKey(connection, id) {
383
- return `${connection.childId}:${connection.runtimeIdentity}:${id}`;
384
- }
385
- /** Handles one child request and returns the frame that answers it. */
386
- async #serve(connection, key, id, operation, payload) {
387
- let response;
388
- try {
389
- const data = await this.#options.handleRequest({
390
- childId: connection.childId,
391
- runtimeIdentity: connection.runtimeIdentity,
392
- operation,
393
- payload,
394
- });
395
- response = successResponse(id, data);
396
- }
397
- catch (error) {
398
- const code = error instanceof BridgeError ? error.code : "bridge_error";
399
- response = failureResponse(id, code, errorMessage(error));
400
- }
401
- this.#serving.delete(key);
402
- this.#served.set(key, response);
403
- while (this.#served.size > MAX_SERVED_REQUESTS) {
404
- const oldest = this.#served.keys().next();
405
- if (oldest.done === true)
406
- break;
407
- this.#served.delete(oldest.value);
408
- }
409
- this.#write(connection, response);
410
- return response;
411
- }
412
- /** Reports whether the frame left the parent; a frame that did not is diagnosed. */
413
- #write(connection, frame) {
414
- if (connection.closed || connection.socket.destroyed) {
415
- this.#diagnose(`dropped a frame for child ${connection.childId}: the connection is gone`);
416
- return false;
417
- }
418
- let encoded;
419
- try {
420
- encoded = serializeJsonLine(frame, this.#frameLimit());
421
- }
422
- catch (error) {
423
- this.#diagnose(`could not serialize a frame for child ${connection.childId}: ${errorMessage(error)}`);
424
- return false;
425
- }
426
- connection.socket.write(encoded);
427
- return true;
428
- }
429
- #frameLimit() {
430
- return this.#options.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES;
431
- }
432
- /** Fails every request in flight for a connection that is going away. */
433
- #drop(connection, reason) {
434
- if (connection.closed)
435
- return;
436
- connection.closed = true;
437
- for (const [id, pending] of connection.pending) {
438
- connection.pending.delete(id);
439
- clearTimeout(pending.timer);
440
- pending.reject(new BridgeError("connection_lost", `Child ${connection.childId} connection closed: ${reason}`));
441
- }
442
- connection.socket.destroy();
443
- }
444
- async #clearStaleEndpoint() {
445
- const endpoint = this.#options.endpoint;
446
- try {
447
- const stats = await lstat(endpoint);
448
- if (!stats.isSocket()) {
449
- throw new Error(`bridge endpoint path exists and is not a Unix socket: ${endpoint}`);
450
- }
451
- }
452
- catch (error) {
453
- if (isErrno(error, "ENOENT"))
454
- return;
455
- throw error instanceof Error ? error : new Error(errorMessage(error));
456
- }
457
- if (await isEndpointLive(endpoint)) {
458
- throw new Error(`a live bridge server already owns endpoint ${endpoint}`);
459
- }
460
- await unlink(endpoint);
461
- this.#diagnose(`removed stale endpoint ${endpoint}`);
462
- }
463
- async #restrictEndpoint() {
464
- try {
465
- await chmod(this.#options.endpoint, 0o600);
466
- }
467
- catch (error) {
468
- this.#diagnose(`could not restrict endpoint permissions: ${errorMessage(error)}`);
469
- }
470
- }
471
- #diagnose(message) {
472
- this.#options.diagnose?.(message);
473
- }
474
- }
475
- /** Probes whether something is still listening on a socket path. */
476
- function isEndpointLive(endpoint) {
477
- return new Promise((resolve) => {
478
- const socket = connect(endpoint);
479
- const done = (live) => {
480
- socket.destroy();
481
- resolve(live);
482
- };
483
- socket.once("connect", () => done(true));
484
- socket.once("error", () => done(false));
485
- setTimeout(() => done(false), 1_000).unref();
486
- });
487
- }
488
- function isErrno(error, code) {
489
- return isRecord(error) && error.code === code;
490
- }