hilos-agent 0.6.0 → 0.9.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.
@@ -0,0 +1,654 @@
1
+ // OpenCode runtime-permission relay (0593).
2
+ //
3
+ // This module is deliberately dependency-light and handler-agnostic. It owns
4
+ // only the vendor boundary:
5
+ // 1. parse OpenCode's SSE stream without assuming chunk == event;
6
+ // 2. normalize both permission.asked generations into one hilos-shaped ask;
7
+ // 3. serialize asks within a session while allowing different sessions to
8
+ // wait independently;
9
+ // 4. poll injected hilos callbacks under a hard, abort-aware deadline; and
10
+ // 5. always answer OpenCode once/always/reject, with reject as every failure
11
+ // mode's fail-closed result.
12
+ //
13
+ // handler.mjs intentionally does not import this yet. Its later integration can
14
+ // create one relay per attached OpenCode server and feed response-body chunks
15
+ // into relay.push().
16
+
17
+ const TERMINAL_REPLIES = new Map([
18
+ ["allowed_once", "once"],
19
+ ["allowed_always", "always"],
20
+ ["denied", "reject"],
21
+ ["expired", "reject"],
22
+ ["revoked", "reject"],
23
+ ]);
24
+
25
+ const DEFAULT_TIMEOUT_MS = 30 * 60_000;
26
+ const DEFAULT_POLL_MS = 1_000;
27
+ const DEFAULT_REPLY_TIMEOUT_MS = 5_000;
28
+ const DEFAULT_REPLY_ATTEMPTS = 2;
29
+ const DEFAULT_SEEN_LIMIT = 1_000;
30
+
31
+ function isObject(value) {
32
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
33
+ }
34
+
35
+ function exactArray(value, { optional = false } = {}) {
36
+ // Preserve the source payload exactly. The hilos MCP boundary validates type,
37
+ // size, and secret safety and rejects the whole ask when it cannot render it
38
+ // verbatim; filtering here could hide authority-bearing entries.
39
+ if (Array.isArray(value)) return [...value];
40
+ return optional && value == null ? [] : value;
41
+ }
42
+
43
+ function parseJson(value) {
44
+ if (typeof value !== "string") return value;
45
+ try {
46
+ return JSON.parse(value);
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function basicAuthorization(username, password) {
53
+ const token = Buffer.from(`${username}:${password ?? ""}`, "utf8").toString("base64");
54
+ return `Basic ${token}`;
55
+ }
56
+
57
+ function abortError(reason = "aborted") {
58
+ const error = new Error(String(reason || "aborted"));
59
+ error.name = "AbortError";
60
+ return error;
61
+ }
62
+
63
+ function raceWithAbort(value, signal) {
64
+ if (!signal) return Promise.resolve(value);
65
+ if (signal.aborted) return Promise.reject(abortError(signal.reason));
66
+ return new Promise((resolve, reject) => {
67
+ const onAbort = () => reject(abortError(signal.reason));
68
+ signal.addEventListener("abort", onAbort, { once: true });
69
+ Promise.resolve(value).then(
70
+ (result) => {
71
+ signal.removeEventListener("abort", onAbort);
72
+ resolve(result);
73
+ },
74
+ (error) => {
75
+ signal.removeEventListener("abort", onAbort);
76
+ reject(error);
77
+ },
78
+ );
79
+ });
80
+ }
81
+
82
+ function defaultSleep(ms, signal) {
83
+ if (signal?.aborted) return Promise.reject(abortError(signal.reason));
84
+ return new Promise((resolve, reject) => {
85
+ const timer = setTimeout(done, Math.max(0, ms));
86
+ function done() {
87
+ signal?.removeEventListener("abort", cancelled);
88
+ resolve();
89
+ }
90
+ function cancelled() {
91
+ clearTimeout(timer);
92
+ reject(abortError(signal?.reason));
93
+ }
94
+ signal?.addEventListener("abort", cancelled, { once: true });
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Parse an SSE byte/string stream. `push()` and `flush()` return complete SSE
100
+ * messages shaped as `{ event, data, id? }`; data remains a string so callers
101
+ * can decide their own JSON/error policy.
102
+ */
103
+ export function createSseParser() {
104
+ const decoder = new TextDecoder();
105
+ let buffer = "";
106
+ let eventName = "";
107
+ let data = [];
108
+ let lastEventId = "";
109
+
110
+ function dispatch(out) {
111
+ if (data.length === 0) {
112
+ eventName = "";
113
+ return;
114
+ }
115
+ out.push({
116
+ event: eventName || "message",
117
+ data: data.join("\n"),
118
+ ...(lastEventId ? { id: lastEventId } : {}),
119
+ });
120
+ eventName = "";
121
+ data = [];
122
+ }
123
+
124
+ function consumeLine(line, out) {
125
+ if (line === "") {
126
+ dispatch(out);
127
+ return;
128
+ }
129
+ if (line.startsWith(":")) return;
130
+ const colon = line.indexOf(":");
131
+ const field = colon === -1 ? line : line.slice(0, colon);
132
+ let value = colon === -1 ? "" : line.slice(colon + 1);
133
+ if (value.startsWith(" ")) value = value.slice(1);
134
+ if (field === "event") eventName = value;
135
+ else if (field === "data") data.push(value);
136
+ else if (field === "id" && !value.includes("\0")) lastEventId = value;
137
+ }
138
+
139
+ function drain(final) {
140
+ const out = [];
141
+ let start = 0;
142
+ for (let i = 0; i < buffer.length; i++) {
143
+ const char = buffer[i];
144
+ if (char !== "\n" && char !== "\r") continue;
145
+ // A CR at the end of a non-final chunk may be the first half of CRLF.
146
+ if (char === "\r" && i === buffer.length - 1 && !final) break;
147
+ consumeLine(buffer.slice(start, i), out);
148
+ if (char === "\r" && buffer[i + 1] === "\n") i++;
149
+ start = i + 1;
150
+ }
151
+ buffer = buffer.slice(start);
152
+ if (final && buffer) {
153
+ consumeLine(buffer, out);
154
+ buffer = "";
155
+ }
156
+ if (final) dispatch(out);
157
+ return out;
158
+ }
159
+
160
+ return {
161
+ push(chunk) {
162
+ if (chunk instanceof Uint8Array) buffer += decoder.decode(chunk, { stream: true });
163
+ else buffer += String(chunk ?? "");
164
+ return drain(false);
165
+ },
166
+ flush() {
167
+ buffer += decoder.decode();
168
+ return drain(true);
169
+ },
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Normalize a raw OpenCode SSE message/event into the vendor-neutral request
175
+ * consumed by hilos. Returns null for every non-permission or malformed event.
176
+ *
177
+ * Supports all observed envelopes:
178
+ * { event:"permission.asked", data:"{...request}" }
179
+ * { data:"{\"type\":\"permission.asked\",\"properties\":{...}}" }
180
+ * { directory, payload:{ type, properties } } (OpenCode global event stream)
181
+ * { type:"permission.v2.asked", properties:{...} }
182
+ */
183
+ export function normalizeOpenCodePermissionEvent(input) {
184
+ if (!isObject(input)) return null;
185
+ const sseType = typeof input.event === "string" ? input.event : null;
186
+ let envelope = "data" in input ? parseJson(input.data) : input;
187
+ if (!isObject(envelope)) return null;
188
+ if (isObject(envelope.payload)) envelope = envelope.payload;
189
+ const eventType =
190
+ typeof envelope.type === "string" ? envelope.type : sseType === "message" ? null : sseType;
191
+ if (eventType !== "permission.asked" && eventType !== "permission.v2.asked") return null;
192
+ const request = isObject(envelope.properties) ? envelope.properties : envelope;
193
+ const vendorRequestId = typeof request.id === "string" ? request.id : null;
194
+ const sessionId = typeof request.sessionID === "string" ? request.sessionID : null;
195
+ if (!vendorRequestId || !sessionId) return null;
196
+
197
+ const legacy = eventType === "permission.asked";
198
+ const action = legacy ? request.permission : request.action;
199
+ if (typeof action !== "string" || !action) return null;
200
+ const rawSource = legacy ? request.tool : request.source;
201
+ const source = isObject(rawSource)
202
+ ? legacy && rawSource.type == null
203
+ ? { type: "tool", ...rawSource }
204
+ : { ...rawSource }
205
+ : null;
206
+
207
+ return {
208
+ vendor: "opencode",
209
+ vendorRequestId,
210
+ sessionId,
211
+ action,
212
+ resources: exactArray(legacy ? request.patterns : request.resources),
213
+ suggestedSave: exactArray(legacy ? request.always : request.save, {
214
+ optional: true,
215
+ }),
216
+ metadata: isObject(request.metadata) ? { ...request.metadata } : {},
217
+ source,
218
+ eventType,
219
+ };
220
+ }
221
+
222
+ function decisionState(value) {
223
+ if (typeof value === "string") return value;
224
+ if (!isObject(value)) return null;
225
+ if (typeof value.state === "string") return value.state;
226
+ if (isObject(value.request) && typeof value.request.state === "string") {
227
+ return value.request.state;
228
+ }
229
+ if (isObject(value.permission) && typeof value.permission.state === "string") {
230
+ return value.permission.state;
231
+ }
232
+ if (isObject(value.data)) return decisionState(value.data);
233
+ return null;
234
+ }
235
+
236
+ /**
237
+ * Map a hilos permission row/decision to OpenCode's reply enum.
238
+ * Pending/unknown values return null so the relay keeps polling.
239
+ */
240
+ export function mapOpenCodePermissionDecision(value) {
241
+ const state = decisionState(value);
242
+ // A prior hilos rule may auto-resolve a new ask. That authorizes this one
243
+ // execution only; replying `always` would let OpenCode persist the new ask's
244
+ // unapproved save suggestions. Only an explicit human Always decision may
245
+ // produce the vendor's persistent reply.
246
+ if (state === "allowed_always" && isObject(value) && value.autoAllowed === true) {
247
+ return "once";
248
+ }
249
+ return state ? TERMINAL_REPLIES.get(state) ?? null : null;
250
+ }
251
+
252
+ /**
253
+ * POST one terminal reply to OpenCode's legacy/current permission endpoint.
254
+ * @param {{
255
+ * baseUrl: string,
256
+ * directory: string,
257
+ * requestId: string,
258
+ * reply: string,
259
+ * password: string,
260
+ * username?: string,
261
+ * fetchImpl?: typeof globalThis.fetch,
262
+ * signal?: AbortSignal,
263
+ * }} options
264
+ */
265
+ export async function postOpenCodePermissionReply({
266
+ baseUrl,
267
+ directory,
268
+ requestId,
269
+ reply,
270
+ password,
271
+ username = "opencode",
272
+ fetchImpl = globalThis.fetch,
273
+ signal,
274
+ }) {
275
+ if (typeof fetchImpl !== "function") throw new Error("OpenCode permission reply requires fetch");
276
+ if (!baseUrl) throw new Error("OpenCode permission reply requires baseUrl");
277
+ if (!directory) throw new Error("OpenCode permission reply requires directory");
278
+ if (!requestId) throw new Error("OpenCode permission reply requires requestId");
279
+ if (!["once", "always", "reject"].includes(reply)) {
280
+ throw new Error("Invalid OpenCode permission reply");
281
+ }
282
+ const url = new URL(`/permission/${encodeURIComponent(requestId)}/reply`, baseUrl);
283
+ url.searchParams.set("directory", directory);
284
+ const response = await fetchImpl(url, {
285
+ method: "POST",
286
+ headers: {
287
+ authorization: basicAuthorization(username, password),
288
+ "content-type": "application/json",
289
+ },
290
+ body: JSON.stringify({ reply }),
291
+ signal,
292
+ });
293
+ if (!response?.ok) {
294
+ throw new Error(`OpenCode permission reply failed (${response?.status ?? "transport"})`);
295
+ }
296
+ return response;
297
+ }
298
+
299
+ /**
300
+ * Subscribe to OpenCode's instance SSE stream and feed it into a relay.
301
+ *
302
+ * `directory` is not optional at the protocol boundary: OpenCode uses it to
303
+ * route the request to the correct project instance. The caller should run
304
+ * this concurrently with its HTTP session runner and abort the supplied signal
305
+ * during teardown. This helper deliberately does not launch `opencode run
306
+ * --attach`: that CLI is itself a permission responder and would race hilos.
307
+ * @param {{
308
+ * baseUrl: string,
309
+ * directory: string,
310
+ * password: string,
311
+ * username?: string,
312
+ * relay: {push: (value: Uint8Array) => unknown[], flush: () => unknown[]},
313
+ * fetchImpl?: typeof globalThis.fetch,
314
+ * signal?: AbortSignal,
315
+ * }} options
316
+ */
317
+ export async function consumeOpenCodePermissionEvents({
318
+ baseUrl,
319
+ directory,
320
+ password,
321
+ username = "opencode",
322
+ relay,
323
+ fetchImpl = globalThis.fetch,
324
+ signal,
325
+ }) {
326
+ if (typeof fetchImpl !== "function") throw new Error("OpenCode event stream requires fetch");
327
+ if (!baseUrl) throw new Error("OpenCode event stream requires baseUrl");
328
+ if (!directory) throw new Error("OpenCode event stream requires directory");
329
+ if (!relay || typeof relay.push !== "function" || typeof relay.flush !== "function") {
330
+ throw new Error("OpenCode event stream requires a permission relay");
331
+ }
332
+ const url = new URL("/event", baseUrl);
333
+ url.searchParams.set("directory", directory);
334
+ const response = await fetchImpl(url, {
335
+ method: "GET",
336
+ headers: {
337
+ accept: "text/event-stream",
338
+ authorization: basicAuthorization(username, password),
339
+ },
340
+ signal,
341
+ });
342
+ if (!response?.ok) {
343
+ throw new Error(`OpenCode event subscription failed (${response?.status ?? "transport"})`);
344
+ }
345
+ if (!response.body) throw new Error("OpenCode event subscription returned no body");
346
+
347
+ const reader = response.body.getReader();
348
+ let requests = 0;
349
+ try {
350
+ for (;;) {
351
+ const { done, value } = await raceWithAbort(reader.read(), signal);
352
+ if (done) break;
353
+ requests += relay.push(value).length;
354
+ }
355
+ requests += relay.flush().length;
356
+ return { requests };
357
+ } finally {
358
+ if (signal?.aborted) await reader.cancel(signal.reason).catch(() => {});
359
+ reader.releaseLock();
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Create a bounded permission relay.
365
+ *
366
+ * Callback contract:
367
+ * requestPermission(request, { signal, deadlineAt })
368
+ * Creates (or idempotently returns) the durable hilos permission request.
369
+ * getPermissionDecision(handle, { request, signal, deadlineAt, failClosed })
370
+ * Returns a row/state. Pending/unknown values are polled again; terminal
371
+ * states are mapped by mapOpenCodePermissionDecision(). On a relay failure,
372
+ * one source-bound call with failClosed:true expires the durable card.
373
+ */
374
+ export function createOpenCodePermissionRelay(options = {}) {
375
+ const {
376
+ baseUrl,
377
+ directory,
378
+ password,
379
+ username = "opencode",
380
+ requestPermission,
381
+ getPermissionDecision,
382
+ fetchImpl = globalThis.fetch,
383
+ timeoutMs = DEFAULT_TIMEOUT_MS,
384
+ pollIntervalMs = DEFAULT_POLL_MS,
385
+ replyTimeoutMs = DEFAULT_REPLY_TIMEOUT_MS,
386
+ replyAttempts = DEFAULT_REPLY_ATTEMPTS,
387
+ seenLimit = DEFAULT_SEEN_LIMIT,
388
+ now = () => Date.now(),
389
+ sleep = defaultSleep,
390
+ setTimer = (fn, ms) => setTimeout(fn, ms),
391
+ clearTimer = (timer) => clearTimeout(timer),
392
+ signal,
393
+ onError,
394
+ onResult,
395
+ } = options;
396
+ if (typeof requestPermission !== "function") {
397
+ throw new Error("OpenCode permission relay requires requestPermission");
398
+ }
399
+ if (typeof getPermissionDecision !== "function") {
400
+ throw new Error("OpenCode permission relay requires getPermissionDecision");
401
+ }
402
+
403
+ const parser = createSseParser();
404
+ const relayController = new AbortController();
405
+ const seen = new Map();
406
+ const sessionTails = new Map();
407
+ let closed = false;
408
+
409
+ const forwardAbort = () => relayController.abort(signal?.reason ?? "aborted");
410
+ if (signal?.aborted) forwardAbort();
411
+ else signal?.addEventListener("abort", forwardAbort, { once: true });
412
+
413
+ function reportError(error, context) {
414
+ try {
415
+ onError?.(error, context);
416
+ } catch {
417
+ // Observability must never break the mechanical permission gate.
418
+ }
419
+ }
420
+
421
+ async function sendReply(request, reply) {
422
+ let lastError = null;
423
+ const attempts = Math.max(1, Math.floor(replyAttempts));
424
+ for (let attempt = 1; attempt <= attempts; attempt++) {
425
+ const controller = new AbortController();
426
+ const timer = setTimer(
427
+ () => controller.abort("reply-timeout"),
428
+ Math.max(1, replyTimeoutMs),
429
+ );
430
+ try {
431
+ await raceWithAbort(
432
+ postOpenCodePermissionReply({
433
+ baseUrl,
434
+ directory,
435
+ requestId: request.vendorRequestId,
436
+ reply,
437
+ password,
438
+ username,
439
+ fetchImpl,
440
+ signal: controller.signal,
441
+ }),
442
+ controller.signal,
443
+ );
444
+ return { replied: true, replyAttempts: attempt };
445
+ } catch (error) {
446
+ lastError = error;
447
+ reportError(error, { phase: "reply", request, reply, attempt });
448
+ } finally {
449
+ clearTimer(timer);
450
+ }
451
+ }
452
+ return { replied: false, replyAttempts: attempts, replyError: lastError };
453
+ }
454
+
455
+ async function processRequest(request, receivedAt) {
456
+ const deadlineAt = receivedAt + Math.max(0, timeoutMs);
457
+ const controller = new AbortController();
458
+ let abortKind = null;
459
+ let decision = null;
460
+ let handle = null;
461
+ let reply = "reject";
462
+ let outcome = "transport-error";
463
+ const onRelayAbort = () => {
464
+ abortKind = "aborted";
465
+ controller.abort(relayController.signal.reason ?? "aborted");
466
+ };
467
+ if (relayController.signal.aborted) onRelayAbort();
468
+ else relayController.signal.addEventListener("abort", onRelayAbort, { once: true });
469
+ const remaining = deadlineAt - now();
470
+ const deadlineTimer =
471
+ remaining > 0
472
+ ? setTimer(() => {
473
+ abortKind = "timeout";
474
+ controller.abort("timeout");
475
+ }, remaining)
476
+ : null;
477
+ if (remaining <= 0) {
478
+ abortKind = "timeout";
479
+ controller.abort("timeout");
480
+ }
481
+
482
+ try {
483
+ if (controller.signal.aborted) throw abortError(controller.signal.reason);
484
+ handle = await raceWithAbort(
485
+ requestPermission(request, { signal: controller.signal, deadlineAt }),
486
+ controller.signal,
487
+ );
488
+ decision = handle;
489
+ reply = mapOpenCodePermissionDecision(handle);
490
+ while (!reply) {
491
+ if (now() >= deadlineAt) {
492
+ abortKind = "timeout";
493
+ controller.abort("timeout");
494
+ throw abortError("timeout");
495
+ }
496
+ decision = await raceWithAbort(
497
+ getPermissionDecision(handle, {
498
+ request,
499
+ signal: controller.signal,
500
+ deadlineAt,
501
+ }),
502
+ controller.signal,
503
+ );
504
+ reply = mapOpenCodePermissionDecision(decision);
505
+ if (reply) break;
506
+ const waitMs = Math.min(Math.max(1, pollIntervalMs), Math.max(1, deadlineAt - now()));
507
+ await raceWithAbort(sleep(waitMs, controller.signal), controller.signal);
508
+ }
509
+ outcome = decisionState(decision) ?? "resolved";
510
+ } catch (error) {
511
+ reply = "reject";
512
+ outcome = abortKind === "timeout" ? "timeout" : abortKind === "aborted" ? "aborted" : "transport-error";
513
+ reportError(error, { phase: "decision", request, outcome });
514
+ // OpenCode has already been failed closed. Settle the matching durable
515
+ // card too, using a fresh bounded signal: the run/deadline signal is
516
+ // necessarily aborted here and must not strand visible state as pending.
517
+ if (handle) {
518
+ const settlementController = new AbortController();
519
+ const settlementTimer = setTimer(
520
+ () => settlementController.abort("settlement-timeout"),
521
+ Math.max(1, replyTimeoutMs),
522
+ );
523
+ try {
524
+ await raceWithAbort(
525
+ getPermissionDecision(handle, {
526
+ request,
527
+ signal: settlementController.signal,
528
+ deadlineAt,
529
+ failClosed: true,
530
+ }),
531
+ settlementController.signal,
532
+ );
533
+ } catch (settlementError) {
534
+ reportError(settlementError, {
535
+ phase: "settlement",
536
+ request,
537
+ outcome,
538
+ });
539
+ } finally {
540
+ clearTimer(settlementTimer);
541
+ }
542
+ }
543
+ } finally {
544
+ if (deadlineTimer != null) clearTimer(deadlineTimer);
545
+ relayController.signal.removeEventListener("abort", onRelayAbort);
546
+ }
547
+
548
+ // Do not reuse the run/deadline signal here: even cancellation and timeout
549
+ // must get one bounded chance to close the pending OpenCode tool call.
550
+ const delivery = await sendReply(request, reply);
551
+ const result = {
552
+ vendorRequestId: request.vendorRequestId,
553
+ sessionId: request.sessionId,
554
+ reply,
555
+ outcome,
556
+ ...delivery,
557
+ };
558
+ try {
559
+ onResult?.(result, request);
560
+ } catch {
561
+ // Result observers are optional and non-authoritative.
562
+ }
563
+ return result;
564
+ }
565
+
566
+ function trimSeen() {
567
+ if (seen.size <= Math.max(1, seenLimit)) return;
568
+ for (const [id, record] of seen) {
569
+ if (!record.done) continue;
570
+ seen.delete(id);
571
+ if (seen.size <= seenLimit) break;
572
+ }
573
+ }
574
+
575
+ function handleRequest(request) {
576
+ if (!request?.vendorRequestId || !request?.sessionId) return null;
577
+ const duplicate = seen.get(request.vendorRequestId);
578
+ if (duplicate) return duplicate.promise;
579
+ const receivedAt = now();
580
+ const previous = sessionTails.get(request.sessionId) ?? Promise.resolve();
581
+ const record = { done: false, promise: null };
582
+ const task = previous
583
+ .catch(() => {})
584
+ .then(() => processRequest(request, receivedAt));
585
+ record.promise = task;
586
+ seen.set(request.vendorRequestId, record);
587
+ const tail = task
588
+ .then(
589
+ () => {},
590
+ () => {},
591
+ )
592
+ .finally(() => {
593
+ record.done = true;
594
+ if (sessionTails.get(request.sessionId) === tail) {
595
+ sessionTails.delete(request.sessionId);
596
+ }
597
+ trimSeen();
598
+ });
599
+ sessionTails.set(request.sessionId, tail);
600
+ return task;
601
+ }
602
+
603
+ function handleEvent(event) {
604
+ const request = normalizeOpenCodePermissionEvent(event);
605
+ return request ? handleRequest(request) : null;
606
+ }
607
+
608
+ function schedule(messages) {
609
+ const requests = [];
610
+ for (const message of messages) {
611
+ const request = normalizeOpenCodePermissionEvent(message);
612
+ if (!request) continue;
613
+ requests.push(request);
614
+ void handleRequest(request);
615
+ }
616
+ return requests;
617
+ }
618
+
619
+ async function idle() {
620
+ while (sessionTails.size) {
621
+ await Promise.all([...sessionTails.values()]);
622
+ }
623
+ }
624
+
625
+ return {
626
+ push(chunk) {
627
+ if (closed) return [];
628
+ return schedule(parser.push(chunk));
629
+ },
630
+ flush() {
631
+ if (closed) return [];
632
+ return schedule(parser.flush());
633
+ },
634
+ handleEvent,
635
+ handleRequest,
636
+ result(requestId) {
637
+ return seen.get(requestId)?.promise ?? null;
638
+ },
639
+ pendingCount() {
640
+ let count = 0;
641
+ for (const record of seen.values()) if (!record.done) count++;
642
+ return count;
643
+ },
644
+ idle,
645
+ async close(reason = "relay-closed") {
646
+ if (closed) return idle();
647
+ schedule(parser.flush());
648
+ closed = true;
649
+ relayController.abort(reason);
650
+ signal?.removeEventListener("abort", forwardAbort);
651
+ await idle();
652
+ },
653
+ };
654
+ }