@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,459 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { createConnection } from "node:net";
3
- import { errorMessage } from "@hheei/pi-ext-core";
4
- import { PROTOCOL_VERSION } from "./domain.js";
5
- import { attachJsonLineReader, JsonLineError, serializeJsonLine, writeJsonLine, } from "./json-lines.js";
6
- import { BridgeError, CHILD_INPUT_EVENT, DEFAULT_MAX_BUFFERED_EVENTS, DEFAULT_MAX_FRAME_BYTES, failureResponse, isHelloAckFrame, isRequestFrame, isResponseFrame, successResponse, } from "./protocol.js";
7
- /** How long a child waits before dialing the parent again. */
8
- const DEFAULT_RECONNECT_DELAY_MS = 500;
9
- /** A socket the parent accepts but never answers on is not a working control channel. */
10
- const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
11
- /**
12
- * How long a delivered report waits for the parent's answer before it fails. Without this a parent
13
- * that keeps the socket open and never answers would hold the report queue forever.
14
- */
15
- const DEFAULT_REPORT_TIMEOUT_MS = 30_000;
16
- /**
17
- * The child side of the bridge: one long-lived connection to the parent, carrying both directions
18
- * of control.
19
- *
20
- * The child therefore never owns its own lifecycle endpoint: it dials the parent, keeps dialing
21
- * while the parent is away, and reconnects with the same identity when the parent comes back (a
22
- * parent restart re-listens the same socket path). Reports the parent has not accepted yet are
23
- * buffered here, bounded, and flushed in order after a reconnect, so a report is never silently
24
- * lost just because the parent was momentarily gone. Pi events are advisory snapshots and are
25
- * dropped instead of buffered.
26
- */
27
- export class ChildBridgeClient {
28
- #options;
29
- #queued = [];
30
- #inFlight = new Map();
31
- #disconnectWaiters = new Set();
32
- #socket;
33
- /** The socket of a handshake in progress, so close() can end it too. */
34
- #handshakeSocket;
35
- /** Identifies this client instance, so request ids stay unique across child relaunches. */
36
- #instanceId = randomUUID().slice(0, 8);
37
- #detachReader;
38
- #requestSeq = 0;
39
- #connected = false;
40
- #closed = false;
41
- #flushing = false;
42
- #loop;
43
- constructor(options) {
44
- this.#options = options;
45
- }
46
- get connected() {
47
- return this.#connected;
48
- }
49
- /**
50
- * Starts the connect loop. It keeps retrying on its own: a panel child outlives the parent
51
- * process that started it, so being unable to reach the parent is a state to wait out, not a
52
- * failure to give up on. Call `close()` to stop it.
53
- */
54
- start() {
55
- if (this.#loop !== undefined || this.#closed)
56
- return;
57
- this.#loop = this.#run();
58
- }
59
- /** Sends an advisory frame: Pi events and child lifecycle signals. */
60
- sendEvent(event) {
61
- const socket = this.#socket;
62
- if (socket === undefined || !this.#connected)
63
- return;
64
- let encoded;
65
- try {
66
- encoded = serializeJsonLine({ version: PROTOCOL_VERSION, type: "event", event }, this.#frameLimit());
67
- }
68
- catch (error) {
69
- // The frame can never be sent — too large, or not serializable — and that says nothing about
70
- // the connection. An advisory event is dropped so the control channel stays usable.
71
- if (process.env.DEBUG || process.env.PI_SUBAGENTS_DEBUG) {
72
- this.#diagnose(`dropped an advisory frame: ${errorMessage(error)}`);
73
- }
74
- return;
75
- }
76
- socket.write(encoded, (error) => {
77
- // A write failure is the connection's problem: the reconnect loop owns it from here.
78
- if (error)
79
- this.#teardown(socket);
80
- });
81
- }
82
- /**
83
- * Tells the parent that input arrived, with its source and nothing else. The parent uses this
84
- * to cancel an idle countdown, so the text of a human message stays inside the child.
85
- */
86
- sendChildInput(source) {
87
- const { parentSessionId, subagentId, runtimeIdentity } = this.#options.identity;
88
- this.sendEvent({
89
- type: CHILD_INPUT_EVENT,
90
- parentSessionId,
91
- childId: subagentId,
92
- runtimeIdentity,
93
- source,
94
- });
95
- }
96
- /**
97
- * Reports to the parent, waiting for its answer. While the parent is unreachable the report is
98
- * buffered instead of failed, and delivered after the reconnect.
99
- */
100
- sendReport(operation, payload, signal) {
101
- if (this.#closed) {
102
- return Promise.reject(new BridgeError("bridge_closed", "The child bridge is closed"));
103
- }
104
- let delivery;
105
- const delivered = new Promise((resolve, reject) => {
106
- const limit = this.#options.maxBufferedReports ?? DEFAULT_MAX_BUFFERED_EVENTS;
107
- while (this.#queued.length >= limit) {
108
- // The report being delivered is still the head of the queue: evicting it would fail the
109
- // caller with "dropped" while the parent may already be acting on it.
110
- const index = this.#inFlight.size === 0 ? 0 : 1;
111
- const dropped = this.#queued.splice(index, 1)[0];
112
- if (dropped === undefined)
113
- break;
114
- this.#diagnose(`dropped a buffered ${dropped.operation} report: the buffer is full`);
115
- dropped.reject(new BridgeError("report_buffer_full", `The ${dropped.operation} report was dropped: the parent is unreachable and the buffer is full`));
116
- }
117
- delivery = {
118
- id: `child-${this.#instanceId}-${++this.#requestSeq}`,
119
- operation,
120
- payload,
121
- resolve,
122
- reject,
123
- requeued: false,
124
- };
125
- this.#queued.push(delivery);
126
- void this.#flush();
127
- });
128
- if (signal === undefined)
129
- return delivered;
130
- // The caller is a tool call that can be cancelled. Without this the report would wait for the
131
- // parent to come back, and a cancelled call would hang until the process ends.
132
- if (signal.aborted) {
133
- this.#abandon(delivery);
134
- return Promise.reject(new BridgeError("aborted", `The ${operation} report was cancelled`));
135
- }
136
- return new Promise((resolve, reject) => {
137
- const onAbort = () => {
138
- this.#abandon(delivery);
139
- reject(new BridgeError("aborted", `The ${operation} report was cancelled`));
140
- };
141
- signal.addEventListener("abort", onAbort, { once: true });
142
- void delivered.then((value) => {
143
- signal.removeEventListener("abort", onAbort);
144
- resolve(value);
145
- }, (error) => {
146
- signal.removeEventListener("abort", onAbort);
147
- reject(error instanceof Error ? error : new Error(errorMessage(error)));
148
- });
149
- });
150
- }
151
- /**
152
- * Gives up on a report whose caller was cancelled: it leaves the queue instead of being delivered
153
- * later, because the request it carried belongs to a tool call that no longer exists.
154
- */
155
- #abandon(delivery) {
156
- const index = this.#queued.indexOf(delivery);
157
- if (index >= 0)
158
- this.#queued.splice(index, 1);
159
- const inFlight = this.#inFlight.get(delivery.id);
160
- if (inFlight !== undefined) {
161
- clearTimeout(inFlight.timer);
162
- this.#inFlight.delete(delivery.id);
163
- }
164
- delivery.reject(new BridgeError("aborted", `The ${delivery.operation} report was cancelled`));
165
- }
166
- /** Stops reconnecting and fails everything still waiting for the parent. */
167
- close() {
168
- if (this.#closed)
169
- return;
170
- this.#closed = true;
171
- const socket = this.#socket ?? this.#handshakeSocket;
172
- this.#socket = undefined;
173
- this.#handshakeSocket = undefined;
174
- this.#connected = false;
175
- this.#detachReader?.();
176
- this.#detachReader = undefined;
177
- // `destroySoon` ends the socket after what is already written has gone out, so a lifecycle
178
- // notice reported just before the close is not dropped with the socket.
179
- socket?.destroySoon();
180
- const error = new BridgeError("bridge_closed", "The child bridge is closed");
181
- for (const inFlight of this.#inFlight.values()) {
182
- clearTimeout(inFlight.timer);
183
- inFlight.reject(error);
184
- }
185
- this.#inFlight.clear();
186
- for (const delivery of this.#queued.splice(0)) {
187
- delivery.reject(error);
188
- }
189
- this.#wakeDisconnectWaiters();
190
- }
191
- async #run() {
192
- while (!this.#closed) {
193
- const opened = await this.#attemptOpen();
194
- if (this.#closed)
195
- return;
196
- if (opened)
197
- await this.#waitForDisconnect();
198
- if (this.#closed)
199
- return;
200
- await this.#delay();
201
- }
202
- }
203
- /** Dials the parent and completes the handshake; false means "try again later". */
204
- async #attemptOpen() {
205
- const socket = createConnection(this.#options.endpoint);
206
- this.#handshakeSocket = socket;
207
- let handshaked = false;
208
- try {
209
- await new Promise((resolve, reject) => {
210
- let settled = false;
211
- const settle = (error) => {
212
- if (settled)
213
- return;
214
- settled = true;
215
- clearTimeout(timer);
216
- if (error === undefined)
217
- resolve();
218
- else
219
- reject(error);
220
- };
221
- const timer = setTimeout(() => settle(new BridgeError("connect_timeout", "The parent did not answer the hello")), this.#options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
222
- socket.on("error", (error) => settle(error));
223
- socket.on("close", () => settle(new BridgeError("connection_lost", "The parent closed the bridge socket")));
224
- socket.once("connect", () => {
225
- void writeJsonLine(socket, {
226
- version: PROTOCOL_VERSION,
227
- type: "hello",
228
- role: "child",
229
- ...this.#options.identity,
230
- token: this.#options.identity.token,
231
- }, this.#frameLimit()).catch((error) => settle(new Error(errorMessage(error))));
232
- });
233
- this.#detachReader = attachJsonLineReader(socket, {
234
- maxFrameBytes: this.#frameLimit(),
235
- onError: (error) => {
236
- if (!handshaked) {
237
- settle(error);
238
- return;
239
- }
240
- // A frame the reader cannot parse means this socket can never deliver a response, so
241
- // it is dropped like any other lost connection instead of staying "connected" and mute.
242
- this.#teardown(socket);
243
- },
244
- onValue: (value) => {
245
- if (!handshaked) {
246
- if (!isHelloAckFrame(value)) {
247
- settle(new BridgeError("invalid_handshake", "The parent sent an invalid handshake response"));
248
- return;
249
- }
250
- handshaked = true;
251
- settle();
252
- return;
253
- }
254
- this.#receive(value);
255
- },
256
- });
257
- });
258
- }
259
- catch {
260
- this.#detachReader?.();
261
- this.#detachReader = undefined;
262
- if (this.#handshakeSocket === socket)
263
- this.#handshakeSocket = undefined;
264
- socket.destroy();
265
- return false;
266
- }
267
- if (this.#handshakeSocket === socket)
268
- this.#handshakeSocket = undefined;
269
- if (this.#closed || socket.destroyed) {
270
- socket.destroy();
271
- return false;
272
- }
273
- this.#socket = socket;
274
- this.#connected = true;
275
- socket.on("close", () => this.#teardown(socket));
276
- socket.on("error", () => this.#teardown(socket));
277
- try {
278
- this.#options.onConnect?.();
279
- }
280
- catch (error) {
281
- this.#diagnose(`onConnect handler failed: ${errorMessage(error)}`);
282
- }
283
- void this.#flush();
284
- return true;
285
- }
286
- #receive(value) {
287
- if (isResponseFrame(value)) {
288
- const inFlight = this.#inFlight.get(value.id);
289
- if (inFlight === undefined)
290
- return;
291
- this.#inFlight.delete(value.id);
292
- clearTimeout(inFlight.timer);
293
- if (value.ok)
294
- inFlight.resolve(value.data);
295
- else
296
- inFlight.reject(new BridgeError(value.error.code, value.error.message));
297
- return;
298
- }
299
- if (isRequestFrame(value)) {
300
- void this.#serve(value.id, value.operation, value.payload);
301
- return;
302
- }
303
- this.#diagnose("ignored a frame the child bridge does not understand");
304
- }
305
- async #serve(id, operation, payload) {
306
- try {
307
- const data = await this.#options.handleRequest({ operation, payload });
308
- this.#send(successResponse(id, data), id);
309
- }
310
- catch (error) {
311
- const code = error instanceof BridgeError ? error.code : "bridge_error";
312
- this.#send(failureResponse(id, code, errorMessage(error)));
313
- }
314
- }
315
- /** Delivers queued reports in order, one at a time, so the parent sees them as they happened. */
316
- async #flush() {
317
- if (this.#flushing)
318
- return;
319
- this.#flushing = true;
320
- try {
321
- while (!this.#closed) {
322
- const delivery = this.#queued[0];
323
- const socket = this.#socket;
324
- if (delivery === undefined || socket === undefined || !this.#connected)
325
- return;
326
- let value;
327
- try {
328
- value = await this.#deliver(socket, delivery);
329
- }
330
- catch (error) {
331
- // A dropped connection leaves the report at the head of the queue, marked for retry;
332
- // only a report the parent actually answered (or a closed bridge) stops being retried.
333
- if (delivery.requeued)
334
- return;
335
- if (this.#queued[0] === delivery)
336
- this.#queued.shift();
337
- delivery.reject(error instanceof Error ? error : new Error(errorMessage(error)));
338
- continue;
339
- }
340
- if (this.#queued[0] === delivery)
341
- this.#queued.shift();
342
- delivery.resolve(value);
343
- }
344
- }
345
- finally {
346
- this.#flushing = false;
347
- }
348
- }
349
- async #deliver(socket, delivery) {
350
- delivery.requeued = false;
351
- let encoded;
352
- try {
353
- encoded = serializeJsonLine({
354
- version: PROTOCOL_VERSION,
355
- type: "request",
356
- id: delivery.id,
357
- operation: delivery.operation,
358
- ...(delivery.payload === undefined ? {} : { payload: delivery.payload }),
359
- }, this.#frameLimit());
360
- }
361
- catch (error) {
362
- // The frame itself cannot be sent — too large, or not serializable — so retrying it on every
363
- // reconnect would never succeed. Only writing it is a connection problem.
364
- throw error instanceof JsonLineError
365
- ? new BridgeError(error.code, error.message)
366
- : new BridgeError("unserializable_frame", errorMessage(error));
367
- }
368
- return await new Promise((resolve, reject) => {
369
- const timer = setTimeout(() => {
370
- const inFlight = this.#inFlight.get(delivery.id);
371
- if (inFlight === undefined)
372
- return;
373
- this.#inFlight.delete(delivery.id);
374
- reject(new BridgeError("timeout", `The parent did not answer the ${delivery.operation} report in time`));
375
- }, this.#options.reportTimeoutMs ?? DEFAULT_REPORT_TIMEOUT_MS);
376
- this.#inFlight.set(delivery.id, { delivery, resolve, reject, timer });
377
- socket.write(encoded, (error) => {
378
- if (!error)
379
- return;
380
- // A transport failure is the connection's problem: the teardown path requeues this report
381
- // and reconnects, so a report is not lost to a momentary write error.
382
- this.#teardown(socket);
383
- });
384
- });
385
- }
386
- #send(frame, responseId) {
387
- const socket = this.#socket;
388
- if (socket === undefined || !this.#connected)
389
- return;
390
- void writeJsonLine(socket, frame, this.#frameLimit()).catch((error) => {
391
- if (responseId === undefined) {
392
- this.#teardown(socket);
393
- return;
394
- }
395
- // A response the parent cannot receive must not take the control channel down with it: it
396
- // gets a visible failure instead, and the connection stays usable.
397
- this.#diagnose(`could not answer request ${responseId}: ${errorMessage(error)}`);
398
- void writeJsonLine(socket, failureResponse(responseId, "response_too_large", errorMessage(error)), this.#frameLimit()).catch(() => {
399
- this.#teardown(socket);
400
- });
401
- });
402
- }
403
- #teardown(socket) {
404
- if (this.#closed)
405
- return;
406
- if (this.#socket !== undefined && this.#socket !== socket) {
407
- socket.destroy();
408
- return;
409
- }
410
- this.#socket = undefined;
411
- this.#connected = false;
412
- this.#detachReader?.();
413
- this.#detachReader = undefined;
414
- if (!socket.destroyed)
415
- socket.destroy();
416
- for (const [id, inFlight] of this.#inFlight) {
417
- this.#inFlight.delete(id);
418
- clearTimeout(inFlight.timer);
419
- // The parent never answered, so this report still owes it a delivery. It is already the head
420
- // of the queue (a report leaves that position only once it is answered), so it is marked for
421
- // the retry and not pushed again: a duplicate would report contact_parent twice.
422
- inFlight.delivery.requeued = true;
423
- inFlight.reject(new BridgeError("connection_lost", "The parent bridge connection was lost"));
424
- }
425
- this.#wakeDisconnectWaiters();
426
- }
427
- /** Wakes every reconnect wait; the next loop registers its own. */
428
- #wakeDisconnectWaiters() {
429
- const waiters = [...this.#disconnectWaiters];
430
- this.#disconnectWaiters.clear();
431
- for (const waiter of waiters) {
432
- waiter();
433
- }
434
- }
435
- #waitForDisconnect() {
436
- // The socket may already have dropped between the handshake and this call.
437
- if (!this.#connected)
438
- return Promise.resolve();
439
- return new Promise((resolve) => {
440
- this.#disconnectWaiters.add(resolve);
441
- });
442
- }
443
- #delay() {
444
- const delayMs = this.#options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS;
445
- return new Promise((resolve) => {
446
- setTimeout(resolve, delayMs);
447
- });
448
- }
449
- #frameLimit() {
450
- return this.#options.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES;
451
- }
452
- #diagnose(message) {
453
- if (this.#options.diagnose !== undefined)
454
- this.#options.diagnose(message);
455
- else if (process.env.DEBUG || process.env.PI_SUBAGENTS_DEBUG) {
456
- console.error(`pi-subagents child bridge: ${message}`);
457
- }
458
- }
459
- }
@@ -1,72 +0,0 @@
1
- import { type HelloFrame } from "./protocol.js";
2
- /** How long the parent waits for a child's answer before failing the call visibly. */
3
- export declare const DEFAULT_CHILD_REQUEST_TIMEOUT_MS = 30000;
4
- export interface ChildRequest {
5
- readonly childId: string;
6
- readonly runtimeIdentity: string;
7
- readonly operation: string;
8
- readonly payload: unknown;
9
- }
10
- export interface ChildBridgeServerOptions {
11
- /** Socket path this parent session owns; children connect here. */
12
- readonly endpoint: string;
13
- /** The parent session served here; a hello naming another session is refused. */
14
- readonly parentSessionId: string;
15
- /**
16
- * Verifies the child's hello — its runtime token, and whatever else the parent needs to believe
17
- * it (for example that this runtime is the one the registry records for this child). Returns a
18
- * message to refuse the connection, or undefined to accept it.
19
- */
20
- readonly authorize: (frame: HelloFrame) => string | undefined | Promise<string | undefined>;
21
- /**
22
- * Child-initiated requests the parent answers: reports and task results.
23
- */
24
- readonly handleRequest: (request: ChildRequest) => Promise<unknown>;
25
- readonly requestTimeoutMs?: number;
26
- readonly maxFrameBytes?: number;
27
- readonly maxPendingRequests?: number;
28
- readonly diagnose?: (message: string) => void;
29
- }
30
- export interface ChildRequestOptions {
31
- readonly timeoutMs?: number;
32
- }
33
- /**
34
- * The parent side of the child bridge: one Unix socket per parent session, one long-lived
35
- * connection per child, carrying both directions of control.
36
- *
37
- * Control lives in the parent process instead of a helper process, so a child presented in a host
38
- * panel and a child running headless are driven by exactly the same requests. A child that
39
- * reconnects (parent restart, extension reload, dropped socket) replaces its previous connection
40
- * and is otherwise treated as the same runtime.
41
- */
42
- export declare class ChildBridgeServer {
43
- #private;
44
- constructor(options: ChildBridgeServerOptions);
45
- get endpoint(): string;
46
- /** Binds the endpoint, refusing one that another live bridge server still owns. */
47
- listen(): Promise<void>;
48
- isConnected(childId: string): boolean;
49
- connectedChildren(): string[];
50
- /** Sends a request to a connected child and waits for its answer. */
51
- request(childId: string, operation: string, payload?: unknown, options?: ChildRequestOptions): Promise<unknown>;
52
- /**
53
- * Waits for a child's bridge to come up. A child that survived a parent restart reconnects on
54
- * its own schedule, so adoption is a bounded wait rather than a spawn.
55
- */
56
- waitForConnection(childId: string, options?: {
57
- readonly timeoutMs?: number;
58
- readonly signal?: AbortSignal;
59
- }): Promise<boolean>;
60
- /**
61
- * Ends a child's connection from this side. The parent uses it when the process stopped being the
62
- * child: leaving the socket open would let a relaunch mistake the old connection for the new
63
- * runtime and send into a session the parent no longer owns.
64
- */
65
- disconnect(childId: string): void;
66
- /** Subscribes to the fire-and-forget frames a child pushes: Pi events, lifecycle, input. */
67
- onEvent(listener: (childId: string, event: unknown) => void): () => void;
68
- /** Subscribes to connection transitions; a reconnect is what tells the parent a child is alive. */
69
- onConnectionChange(listener: (childId: string, connected: boolean) => void): () => void;
70
- /** Closes every connection and removes the socket this server created. */
71
- close(): Promise<void>;
72
- }