@hheei/pi-subagents 0.1.2 → 0.2.3
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/README.md +45 -137
- package/dist/agent-resolver.d.ts +20 -0
- package/dist/agent-resolver.js +155 -21
- package/dist/bridge-client.d.ts +55 -0
- package/dist/bridge-client.js +453 -0
- package/dist/bridge-server.d.ts +72 -0
- package/dist/bridge-server.js +490 -0
- package/dist/builtin-agents.d.ts +24 -0
- package/dist/builtin-agents.js +152 -0
- package/dist/child-bridge.d.ts +31 -15
- package/dist/child-bridge.js +95 -117
- package/dist/child-control.d.ts +58 -0
- package/dist/child-control.js +176 -0
- package/dist/child-process.d.ts +21 -0
- package/dist/child-process.js +93 -0
- package/dist/commands.d.ts +2 -2
- package/dist/commands.js +52 -60
- package/dist/diagnostics.d.ts +0 -2
- package/dist/diagnostics.js +0 -8
- package/dist/domain.d.ts +57 -27
- package/dist/domain.js +60 -11
- package/dist/extension.d.ts +26 -1
- package/dist/extension.js +243 -46
- package/dist/host-adapter.d.ts +37 -12
- package/dist/host-adapter.js +214 -63
- package/dist/json-lines.js +2 -1
- package/dist/launch-spec.d.ts +8 -6
- package/dist/launch-spec.js +61 -18
- package/dist/manager.d.ts +93 -49
- package/dist/manager.js +1175 -528
- package/dist/markdown.d.ts +8 -0
- package/dist/markdown.js +21 -0
- package/dist/protocol.d.ts +33 -52
- package/dist/protocol.js +41 -52
- package/dist/registry.d.ts +3 -8
- package/dist/registry.js +41 -163
- package/dist/rpc-events.d.ts +3 -5
- package/dist/rpc-events.js +5 -9
- package/dist/runtime.d.ts +66 -11
- package/dist/runtime.js +182 -208
- package/dist/session-bootstrap.d.ts +23 -7
- package/dist/session-bootstrap.js +79 -15
- package/dist/session-fork.d.ts +16 -0
- package/dist/session-fork.js +67 -0
- package/dist/state.d.ts +7 -0
- package/dist/state.js +60 -25
- package/dist/tool-activation.d.ts +18 -0
- package/dist/tool-activation.js +227 -0
- package/dist/tools.d.ts +6 -3
- package/dist/tools.js +213 -72
- package/dist/widget.d.ts +4 -8
- package/dist/widget.js +55 -64
- package/package.json +51 -52
- package/dist/child-nudge.d.ts +0 -24
- package/dist/child-nudge.js +0 -102
- package/dist/connector.d.ts +0 -53
- package/dist/connector.js +0 -283
- package/dist/pause-gate.d.ts +0 -14
- package/dist/pause-gate.js +0 -64
- package/dist/rpc-adapter.d.ts +0 -40
- package/dist/rpc-adapter.js +0 -194
- package/dist/runner-entry.d.ts +0 -24
- package/dist/runner-entry.js +0 -166
- package/dist/runner.d.ts +0 -52
- package/dist/runner.js +0 -930
|
@@ -0,0 +1,453 @@
|
|
|
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
|
+
void this.#flush();
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
#receive(value) {
|
|
281
|
+
if (isResponseFrame(value)) {
|
|
282
|
+
const inFlight = this.#inFlight.get(value.id);
|
|
283
|
+
if (inFlight === undefined)
|
|
284
|
+
return;
|
|
285
|
+
this.#inFlight.delete(value.id);
|
|
286
|
+
clearTimeout(inFlight.timer);
|
|
287
|
+
if (value.ok)
|
|
288
|
+
inFlight.resolve(value.data);
|
|
289
|
+
else
|
|
290
|
+
inFlight.reject(new BridgeError(value.error.code, value.error.message));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (isRequestFrame(value)) {
|
|
294
|
+
void this.#serve(value.id, value.operation, value.payload);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
this.#diagnose("ignored a frame the child bridge does not understand");
|
|
298
|
+
}
|
|
299
|
+
async #serve(id, operation, payload) {
|
|
300
|
+
try {
|
|
301
|
+
const data = await this.#options.handleRequest({ operation, payload });
|
|
302
|
+
this.#send(successResponse(id, data), id);
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
const code = error instanceof BridgeError ? error.code : "bridge_error";
|
|
306
|
+
this.#send(failureResponse(id, code, errorMessage(error)));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/** Delivers queued reports in order, one at a time, so the parent sees them as they happened. */
|
|
310
|
+
async #flush() {
|
|
311
|
+
if (this.#flushing)
|
|
312
|
+
return;
|
|
313
|
+
this.#flushing = true;
|
|
314
|
+
try {
|
|
315
|
+
while (!this.#closed) {
|
|
316
|
+
const delivery = this.#queued[0];
|
|
317
|
+
const socket = this.#socket;
|
|
318
|
+
if (delivery === undefined || socket === undefined || !this.#connected)
|
|
319
|
+
return;
|
|
320
|
+
let value;
|
|
321
|
+
try {
|
|
322
|
+
value = await this.#deliver(socket, delivery);
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
// A dropped connection leaves the report at the head of the queue, marked for retry;
|
|
326
|
+
// only a report the parent actually answered (or a closed bridge) stops being retried.
|
|
327
|
+
if (delivery.requeued)
|
|
328
|
+
return;
|
|
329
|
+
if (this.#queued[0] === delivery)
|
|
330
|
+
this.#queued.shift();
|
|
331
|
+
delivery.reject(error instanceof Error ? error : new Error(errorMessage(error)));
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (this.#queued[0] === delivery)
|
|
335
|
+
this.#queued.shift();
|
|
336
|
+
delivery.resolve(value);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
finally {
|
|
340
|
+
this.#flushing = false;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
async #deliver(socket, delivery) {
|
|
344
|
+
delivery.requeued = false;
|
|
345
|
+
let encoded;
|
|
346
|
+
try {
|
|
347
|
+
encoded = serializeJsonLine({
|
|
348
|
+
version: PROTOCOL_VERSION,
|
|
349
|
+
type: "request",
|
|
350
|
+
id: delivery.id,
|
|
351
|
+
operation: delivery.operation,
|
|
352
|
+
...(delivery.payload === undefined ? {} : { payload: delivery.payload }),
|
|
353
|
+
}, this.#frameLimit());
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
// The frame itself cannot be sent — too large, or not serializable — so retrying it on every
|
|
357
|
+
// reconnect would never succeed. Only writing it is a connection problem.
|
|
358
|
+
throw error instanceof JsonLineError
|
|
359
|
+
? new BridgeError(error.code, error.message)
|
|
360
|
+
: new BridgeError("unserializable_frame", errorMessage(error));
|
|
361
|
+
}
|
|
362
|
+
return await new Promise((resolve, reject) => {
|
|
363
|
+
const timer = setTimeout(() => {
|
|
364
|
+
const inFlight = this.#inFlight.get(delivery.id);
|
|
365
|
+
if (inFlight === undefined)
|
|
366
|
+
return;
|
|
367
|
+
this.#inFlight.delete(delivery.id);
|
|
368
|
+
reject(new BridgeError("timeout", `The parent did not answer the ${delivery.operation} report in time`));
|
|
369
|
+
}, this.#options.reportTimeoutMs ?? DEFAULT_REPORT_TIMEOUT_MS);
|
|
370
|
+
this.#inFlight.set(delivery.id, { delivery, resolve, reject, timer });
|
|
371
|
+
socket.write(encoded, (error) => {
|
|
372
|
+
if (!error)
|
|
373
|
+
return;
|
|
374
|
+
// A transport failure is the connection's problem: the teardown path requeues this report
|
|
375
|
+
// and reconnects, so a report is not lost to a momentary write error.
|
|
376
|
+
this.#teardown(socket);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
#send(frame, responseId) {
|
|
381
|
+
const socket = this.#socket;
|
|
382
|
+
if (socket === undefined || !this.#connected)
|
|
383
|
+
return;
|
|
384
|
+
void writeJsonLine(socket, frame, this.#frameLimit()).catch((error) => {
|
|
385
|
+
if (responseId === undefined) {
|
|
386
|
+
this.#teardown(socket);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
// A response the parent cannot receive must not take the control channel down with it: it
|
|
390
|
+
// gets a visible failure instead, and the connection stays usable.
|
|
391
|
+
this.#diagnose(`could not answer request ${responseId}: ${errorMessage(error)}`);
|
|
392
|
+
void writeJsonLine(socket, failureResponse(responseId, "response_too_large", errorMessage(error)), this.#frameLimit()).catch(() => {
|
|
393
|
+
this.#teardown(socket);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
#teardown(socket) {
|
|
398
|
+
if (this.#closed)
|
|
399
|
+
return;
|
|
400
|
+
if (this.#socket !== undefined && this.#socket !== socket) {
|
|
401
|
+
socket.destroy();
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
this.#socket = undefined;
|
|
405
|
+
this.#connected = false;
|
|
406
|
+
this.#detachReader?.();
|
|
407
|
+
this.#detachReader = undefined;
|
|
408
|
+
if (!socket.destroyed)
|
|
409
|
+
socket.destroy();
|
|
410
|
+
for (const [id, inFlight] of this.#inFlight) {
|
|
411
|
+
this.#inFlight.delete(id);
|
|
412
|
+
clearTimeout(inFlight.timer);
|
|
413
|
+
// The parent never answered, so this report still owes it a delivery. It is already the head
|
|
414
|
+
// of the queue (a report leaves that position only once it is answered), so it is marked for
|
|
415
|
+
// the retry and not pushed again: a duplicate would report contact_parent twice.
|
|
416
|
+
inFlight.delivery.requeued = true;
|
|
417
|
+
inFlight.reject(new BridgeError("connection_lost", "The parent bridge connection was lost"));
|
|
418
|
+
}
|
|
419
|
+
this.#wakeDisconnectWaiters();
|
|
420
|
+
}
|
|
421
|
+
/** Wakes every reconnect wait; the next loop registers its own. */
|
|
422
|
+
#wakeDisconnectWaiters() {
|
|
423
|
+
const waiters = [...this.#disconnectWaiters];
|
|
424
|
+
this.#disconnectWaiters.clear();
|
|
425
|
+
for (const waiter of waiters) {
|
|
426
|
+
waiter();
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
#waitForDisconnect() {
|
|
430
|
+
// The socket may already have dropped between the handshake and this call.
|
|
431
|
+
if (!this.#connected)
|
|
432
|
+
return Promise.resolve();
|
|
433
|
+
return new Promise((resolve) => {
|
|
434
|
+
this.#disconnectWaiters.add(resolve);
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
#delay() {
|
|
438
|
+
const delayMs = this.#options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS;
|
|
439
|
+
return new Promise((resolve) => {
|
|
440
|
+
setTimeout(resolve, delayMs);
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
#frameLimit() {
|
|
444
|
+
return this.#options.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES;
|
|
445
|
+
}
|
|
446
|
+
#diagnose(message) {
|
|
447
|
+
if (this.#options.diagnose !== undefined)
|
|
448
|
+
this.#options.diagnose(message);
|
|
449
|
+
else if (process.env.DEBUG || process.env.PI_SUBAGENTS_DEBUG) {
|
|
450
|
+
console.error(`pi-subagents child bridge: ${message}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
}
|