@fitchmultz/pi-subagents 0.46.2 → 0.48.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +1 -1
  3. package/dist/extension/build-info.js +1 -1
  4. package/dist/pi-intercom/bridge-broadcast.js +60 -0
  5. package/dist/pi-intercom/bridge-catalog.js +372 -0
  6. package/dist/pi-intercom/bridge-config.js +47 -26
  7. package/dist/pi-intercom/bridge-control.js +104 -0
  8. package/dist/pi-intercom/bridge-files.js +47 -0
  9. package/dist/pi-intercom/bridge-groups.js +135 -0
  10. package/dist/pi-intercom/bridge-http.js +8 -1
  11. package/dist/pi-intercom/bridge-launch-journal.js +84 -0
  12. package/dist/pi-intercom/bridge-launch-policy.js +71 -5
  13. package/dist/pi-intercom/bridge-launch.js +186 -76
  14. package/dist/pi-intercom/bridge-mailbox.js +324 -0
  15. package/dist/pi-intercom/bridge-operations.js +127 -0
  16. package/dist/pi-intercom/bridge-presence.js +100 -0
  17. package/dist/pi-intercom/bridge-protocol.js +7 -0
  18. package/dist/pi-intercom/bridge-routes.js +190 -17
  19. package/dist/pi-intercom/bridge-rpc-process.js +43 -15
  20. package/dist/pi-intercom/bridge-session.js +83 -85
  21. package/dist/pi-intercom/bridge-target.js +22 -0
  22. package/dist/pi-intercom/bridge-wake.js +158 -0
  23. package/dist/pi-intercom/bridge.js +84 -44
  24. package/dist/pi-intercom/broker/client.js +1 -0
  25. package/dist/pi-intercom/connection.js +2 -3
  26. package/dist/pi-intercom/inbound.js +2 -4
  27. package/dist/pi-intercom/index.js +7 -1
  28. package/dist/pi-intercom/native-control-auth.js +80 -0
  29. package/dist/pi-intercom/native-control-protocol.js +111 -0
  30. package/dist/pi-intercom/native-control.js +130 -0
  31. package/dist/pi-intercom/saved-transcript.js +135 -0
  32. package/dist/pi-intercom/session-targets.js +3 -0
  33. package/dist/pi-intercom/transcript.js +175 -0
  34. package/docs/intercom-bridge.md +293 -161
  35. package/docs/intercom.md +1 -1
  36. package/docs/reference.md +1 -1
  37. package/package.json +1 -1
  38. package/scripts/intercom-bridge-credentials.mjs +54 -12
  39. package/scripts/intercom-remote.mjs +196 -31
  40. package/scripts/package-smoke.mjs +1 -1
  41. package/src/pi-intercom/bridge-broadcast.ts +70 -0
  42. package/src/pi-intercom/bridge-catalog.ts +489 -0
  43. package/src/pi-intercom/bridge-config.ts +64 -37
  44. package/src/pi-intercom/bridge-control.ts +179 -0
  45. package/src/pi-intercom/bridge-files.ts +59 -0
  46. package/src/pi-intercom/bridge-groups.ts +169 -0
  47. package/src/pi-intercom/bridge-http.ts +15 -1
  48. package/src/pi-intercom/bridge-launch-journal.ts +122 -0
  49. package/src/pi-intercom/bridge-launch-policy.ts +88 -7
  50. package/src/pi-intercom/bridge-launch.ts +296 -107
  51. package/src/pi-intercom/bridge-mailbox.ts +395 -0
  52. package/src/pi-intercom/bridge-operations.ts +169 -0
  53. package/src/pi-intercom/bridge-presence.ts +128 -0
  54. package/src/pi-intercom/bridge-protocol.ts +8 -0
  55. package/src/pi-intercom/bridge-routes.ts +281 -32
  56. package/src/pi-intercom/bridge-rpc-process.ts +53 -16
  57. package/src/pi-intercom/bridge-session.ts +106 -105
  58. package/src/pi-intercom/bridge-target.ts +35 -0
  59. package/src/pi-intercom/bridge-wake.ts +188 -0
  60. package/src/pi-intercom/bridge.ts +88 -54
  61. package/src/pi-intercom/broker/client.ts +3 -0
  62. package/src/pi-intercom/connection.ts +2 -3
  63. package/src/pi-intercom/inbound.ts +2 -4
  64. package/src/pi-intercom/index.ts +7 -1
  65. package/src/pi-intercom/native-control-auth.ts +89 -0
  66. package/src/pi-intercom/native-control-protocol.ts +180 -0
  67. package/src/pi-intercom/native-control.ts +160 -0
  68. package/src/pi-intercom/saved-transcript.ts +175 -0
  69. package/src/pi-intercom/session-targets.ts +4 -0
  70. package/src/pi-intercom/transcript.ts +226 -0
  71. package/src/pi-intercom/types.ts +2 -0
@@ -1,37 +1,31 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { IntercomClient } from "./broker/client.ts";
3
- import {
4
- formatSessionTarget,
5
- resolveSessionProjectId,
6
- resolveSessionTarget,
7
- } from "./session-targets.ts";
3
+ import { resolveSessionProjectId } from "./session-targets.ts";
8
4
  import type { Message, SendResult, SessionInfo } from "./types.ts";
9
5
  import type { Identity } from "./bridge-config.ts";
10
- import { Fault, INBOX_BYTES, INBOX_COUNT, cancelled, log, wait } from "./bridge-protocol.ts";
6
+ import { Fault, cancelled, log, wait } from "./bridge-protocol.ts";
7
+ import { Mailbox } from "./bridge-mailbox.ts";
11
8
  import type { UnknownRecord } from "../shared/unknown.ts";
12
9
  import type { MessagingOperation as Operation } from "./bridge-routes.ts";
10
+ import type { RequestContext } from "./bridge-http.ts";
11
+ import { resolveBridgeTarget, bridgeSessionList } from "./bridge-target.ts";
12
+ import { ControlReplies } from "./bridge-control.ts";
13
+ import type { NativeControlOperation } from "./native-control-protocol.ts";
13
14
 
14
- import { WaitingAsk, type Envelope } from "./bridge-ask.ts";
15
+ import { WaitingAsk } from "./bridge-ask.ts";
15
16
  const MAX_REQUESTS = 8;
16
17
  const IDLE_TIMEOUT = 5 * 60 * 1000;
17
18
  const SEND_TIMEOUT = 8000;
18
19
  const LIST_TIMEOUT = 5000;
19
20
 
20
- interface InboxEntry extends Envelope {
21
- readonly bytes: number;
22
- replying: boolean;
23
- }
24
- interface RequestContext {
25
- readonly signal: AbortSignal;
26
- readonly requestId: string;
27
- readonly onPeer: (peer: string) => void;
28
- }
29
21
  interface SendOptions {
30
22
  readonly messageId?: string;
31
23
  readonly replyTo?: string;
32
24
  readonly expectsReply?: boolean;
33
25
  }
34
26
  export interface SessionOwner {
27
+ readonly stateDir?: string;
28
+ readonly notifications?: boolean;
35
29
  readonly allowed: (identity: Identity) => boolean;
36
30
  readonly lost: (fp: string, count?: number) => void;
37
31
  readonly retired: (fp: string, generation: string) => void;
@@ -48,9 +42,10 @@ export class Session {
48
42
  listTimeoutMs: LIST_TIMEOUT,
49
43
  });
50
44
  private readonly requests = new Set<AbortController>();
51
- private readonly inbox = new Map<string, InboxEntry>();
52
- private inboxBytes = 0;
45
+ private readonly inbox: Mailbox;
46
+ private readonly replying = new Set<string>();
53
47
  private pending?: WaitingAsk;
48
+ private readonly controls = new ControlReplies();
54
49
  private closed = false;
55
50
  private connecting?: Promise<void>;
56
51
  private disconnecting?: Promise<void>;
@@ -62,6 +57,7 @@ export class Session {
62
57
  this.owner = owner;
63
58
  this.identity = identity;
64
59
  this.expires = expires;
60
+ this.inbox = new Mailbox(owner.stateDir, identity.fingerprint256, owner.notifications);
65
61
  this.client.on("error", () =>
66
62
  this.close(new Fault(503, "broker_unavailable", "Broker connection failed.")),
67
63
  );
@@ -86,6 +82,9 @@ export class Session {
86
82
  if (this.closed) {
87
83
  throw this.closeReason;
88
84
  }
85
+ this.authorize();
86
+ }
87
+ authorize(): void {
89
88
  if (!this.owner.allowed(this.identity)) {
90
89
  throw new Fault(403, "revoked", "Identity is no longer authorized.");
91
90
  }
@@ -153,6 +152,7 @@ export class Session {
153
152
  this.close(fault);
154
153
  throw fault;
155
154
  }
155
+ this.presence();
156
156
  log("registered", this.fields());
157
157
  }
158
158
  private async connect(signal: AbortSignal): Promise<void> {
@@ -192,27 +192,23 @@ export class Session {
192
192
  // The original request receives the connection failure; teardown must still close a late socket.
193
193
  }
194
194
  await this.disconnect();
195
+ this.owner.lost(this.identity.fingerprint256, this.inbox.close());
195
196
  this.owner.retired(this.identity.fingerprint256, this.generation);
196
197
  log("session_closed", { ...this.fields(), result: this.closeReason.code });
197
198
  }
198
- close(error: Readonly<Fault>): void {
199
+ close(fault: Readonly<Fault>): void {
199
200
  if (this.closed) {
200
201
  return;
201
202
  }
202
203
  this.closed = true;
203
- this.closeReason = error;
204
+ this.closeReason = fault;
204
205
  clearTimeout(this.lease);
205
- this.pending?.reject(error);
206
+ this.pending?.reject(fault);
207
+ this.controls.close(fault);
206
208
  for (const controller of this.requests) {
207
- controller.abort(error);
209
+ controller.abort(fault);
208
210
  }
209
- this.owner.lost(this.identity.fingerprint256, this.inbox.size);
210
- this.inbox.clear();
211
- this.inboxBytes = 0;
212
211
  // Retain the generation until acquisition/registration AND final disconnect settle.
213
- this.startRetirement();
214
- }
215
- private startRetirement(): void {
216
212
  this.retirement = this.retire().catch((caught: unknown) => this.cleanupFailed(caught));
217
213
  }
218
214
  async settled(): Promise<void> {
@@ -220,17 +216,12 @@ export class Session {
220
216
  }
221
217
 
222
218
  private peerLeft(id: string): void {
219
+ this.controls.peerLeft(id);
223
220
  if (this.pending?.peer === id && !this.pending.settled) {
224
221
  this.pending.reject(
225
222
  new Fault(409, "peer_offline", "Ask recipient disconnected.", { id: this.pending.id }),
226
223
  );
227
224
  }
228
- for (const [messageId, entry] of this.inbox) {
229
- if (entry.from.id === id && entry.message.expectsReply === true) {
230
- this.remove(messageId);
231
- this.owner.lost(this.identity.fingerprint256);
232
- }
233
- }
234
225
  this.presence();
235
226
  }
236
227
  private receive(from: SessionInfo, message: Message): void {
@@ -247,50 +238,69 @@ export class Session {
247
238
  );
248
239
  return;
249
240
  }
250
- log("received", { ...this.fields(), peer: from.id, messageId: message.id, result: "received" });
251
- const pending = this.pending;
252
- if (pending?.matches(from.id, message.replyTo) === true) {
253
- pending.resolve({ from, message });
241
+ if (this.controls.receive(from.id, message)) {
254
242
  return;
255
243
  }
256
- if (this.inbox.has(message.id)) {
257
- this.owner.lost(this.identity.fingerprint256);
244
+ if (!this.inbox.persistent && this.resolveReply(from, message)) {
258
245
  return;
259
246
  }
260
- const bytes = Buffer.byteLength(JSON.stringify({ from, message }));
261
- if (this.inbox.size >= INBOX_COUNT || this.inboxBytes + bytes > INBOX_BYTES) {
262
- this.owner.lost(this.identity.fingerprint256);
247
+ this.storeReceive(from, message);
248
+ }
249
+ private storeReceive(from: SessionInfo, message: Message): void {
250
+ let result: "stored" | "duplicate" | "lost";
251
+ try {
252
+ result = this.inbox.add({ from, message });
253
+ } catch {
263
254
  log("inbox_overflow", {
264
255
  ...this.fields(),
265
256
  peer: from.id,
266
257
  messageId: message.id,
267
- result: "lost",
258
+ result: "storage_failed",
259
+ lossRecorded: false,
268
260
  });
261
+ this.close(
262
+ new Fault(
263
+ 507,
264
+ "mailbox_storage_failed",
265
+ "Mailbox storage failed; inspect the metadata audit for unrecorded losses.",
266
+ ),
267
+ );
269
268
  return;
270
269
  }
271
- // ponytail: bounded volatile inbox; use a private durable journal if restart/lease-loss recovery is required.
272
- this.inbox.set(message.id, { from, message, bytes, replying: false });
273
- this.inboxBytes += bytes;
270
+ if (result === "lost" || (result === "duplicate" && !this.inbox.persistent)) {
271
+ this.owner.lost(this.identity.fingerprint256);
272
+ }
273
+ this.recordReceive(from.id, message.id, result);
274
+ if (result !== "lost") {
275
+ this.resolveReply(from, message);
276
+ }
274
277
  this.presence();
275
278
  }
276
- private remove(id: string): void {
277
- const entry = this.inbox.get(id);
278
- if (entry) {
279
- this.inboxBytes -= entry.bytes;
280
- this.inbox.delete(id);
279
+ private resolveReply(from: SessionInfo, message: Message): boolean {
280
+ const pending = this.pending;
281
+ if (pending?.matches(from.id, message.replyTo) !== true) {
282
+ return false;
281
283
  }
284
+ pending.resolve({ from, message });
285
+ return true;
286
+ }
287
+ private recordReceive(
288
+ peer: string,
289
+ messageId: string,
290
+ result: "stored" | "duplicate" | "lost",
291
+ ): void {
292
+ log(result === "lost" ? "inbox_overflow" : "received", {
293
+ ...this.fields(),
294
+ peer,
295
+ messageId,
296
+ result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
297
+ });
282
298
  }
283
299
  private presence(): void {
284
300
  if (this.closed) {
285
301
  return;
286
302
  }
287
- const pendingAsks = [...this.inbox.values()].filter(
288
- (entry) => entry.message.expectsReply === true,
289
- ).length;
290
- this.client.updatePresence({
291
- pendingAsks,
292
- acceptsAsks: this.inbox.size < INBOX_COUNT && this.inboxBytes < INBOX_BYTES,
293
- });
303
+ this.client.updatePresence(this.inbox.presence());
294
304
  }
295
305
  private async peers(signal: AbortSignal): Promise<SessionInfo[]> {
296
306
  this.guard(signal);
@@ -311,29 +321,29 @@ export class Session {
311
321
  await this.connect(signal);
312
322
  return this.peers(signal);
313
323
  }
324
+ async nativeControl(
325
+ operation: NativeControlOperation,
326
+ certificate: string,
327
+ context: RequestContext,
328
+ ): Promise<UnknownRecord> {
329
+ await this.connect(context.signal);
330
+ const peer = await this.target(operation.to, context.signal);
331
+ return this.controls.request(
332
+ this.client,
333
+ { ...operation, peer: peer.id, certificate },
334
+ { ...context, guard: () => this.guard(context.signal) },
335
+ );
336
+ }
314
337
  private async target(to: string, signal: AbortSignal): Promise<SessionInfo> {
315
- const resolution = resolveSessionTarget(await this.peers(signal), to);
316
- if (resolution.status === "none") {
317
- throw new Fault(404, "peer_offline", "Peer is not connected.");
318
- }
319
- if (resolution.status !== "found" || !resolution.target) {
320
- throw new Fault(
321
- 409,
322
- "ambiguous_target",
323
- "Use an unambiguous peer name or session ID (prefixes need at least 8 characters).",
324
- );
325
- }
326
- if (resolution.target.id === this.client.sessionId) {
327
- throw new Fault(400, "self_target", "Choose another peer.");
328
- }
329
- return resolution.target;
338
+ return resolveBridgeTarget(await this.peers(signal), to, this.client.sessionId);
330
339
  }
331
- private async send(
340
+ async send(
332
341
  peer: string,
333
342
  message: string,
334
343
  context: RequestContext,
335
344
  options: SendOptions = {},
336
345
  ): Promise<SendResult> {
346
+ await this.connect(context.signal);
337
347
  const { signal, requestId } = context;
338
348
  context.onPeer(peer);
339
349
  this.guard(signal);
@@ -421,19 +431,9 @@ export class Session {
421
431
  }
422
432
  }
423
433
  private ack(ids: readonly string[]): UnknownRecord {
424
- const acked: string[] = [];
425
- const retained: string[] = [];
426
- for (const id of ids) {
427
- const entry = this.inbox.get(id);
428
- if (entry?.message.expectsReply === true) {
429
- retained.push(id);
430
- } else if (entry) {
431
- this.remove(id);
432
- acked.push(id);
433
- }
434
- }
434
+ const result = this.inbox.ack(ids);
435
435
  this.presence();
436
- return { acked, retained };
436
+ return result;
437
437
  }
438
438
  private async answer(
439
439
  replyTo: string,
@@ -444,10 +444,10 @@ export class Session {
444
444
  if (entry?.message.expectsReply !== true) {
445
445
  throw new Fault(404, "unknown_ask", "replyTo must identify an unanswered inbound ask.");
446
446
  }
447
- if (entry.replying) {
447
+ if (this.replying.has(replyTo)) {
448
448
  throw new Fault(409, "reply_in_progress", "This ask already has a reply in flight.");
449
449
  }
450
- entry.replying = true;
450
+ this.replying.add(replyTo);
451
451
  const peer = entry.from.id;
452
452
  context.onPeer(peer);
453
453
  try {
@@ -456,15 +456,15 @@ export class Session {
456
456
  throw new Fault(409, "peer_offline", "Original ask sender is disconnected.");
457
457
  }
458
458
  this.guard(context.signal);
459
- if (this.inbox.get(replyTo) !== entry) {
459
+ if (!this.inbox.get(replyTo)) {
460
460
  throw new Fault(409, "ask_retired", "Inbound ask is no longer replyable.");
461
461
  }
462
462
  const data = { ...(await this.send(peer, message, context, { replyTo })) };
463
- this.remove(replyTo);
463
+ this.inbox.remove(replyTo);
464
464
  this.presence();
465
465
  return data;
466
466
  } finally {
467
- entry.replying = false;
467
+ this.replying.delete(replyTo);
468
468
  }
469
469
  }
470
470
  async execute(operation: Operation, context: RequestContext): Promise<UnknownRecord> {
@@ -472,6 +472,19 @@ export class Session {
472
472
  if (operation.route === "POST /v1/ask") {
473
473
  return this.ask(operation, context);
474
474
  }
475
+ if (operation.route === "GET /v1/inbox" || operation.route === "POST /v1/ack") {
476
+ // Durable reads/acks remain available while the local broker is down.
477
+ if (!this.inbox.persistent) {
478
+ await this.connect(context.signal);
479
+ }
480
+ if (operation.route === "POST /v1/ack") {
481
+ return this.ack(operation.ids);
482
+ }
483
+ return {
484
+ ...this.inbox.page(operation.after, operation.limit),
485
+ ...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
486
+ };
487
+ }
475
488
  await this.connect(context.signal);
476
489
  switch (operation.route) {
477
490
  case "POST /v1/register":
@@ -480,21 +493,9 @@ export class Session {
480
493
  const sessions = await this.peers(context.signal);
481
494
  return {
482
495
  sessionId: this.client.sessionId,
483
- sessions: sessions.map(({ topics: _topics, subscriptions: _subscriptions, ...entry }) => {
484
- return Object.assign(entry, { target: formatSessionTarget(entry, sessions) });
485
- }),
486
- };
487
- }
488
- case "GET /v1/inbox": {
489
- const lostMessages = this.owner.losses(this.identity.fingerprint256);
490
- return {
491
- messages: [...this.inbox.values()].map(({ from, message }) => ({ from, message })),
492
- overflow: lostMessages > 0,
493
- lostMessages,
496
+ sessions: bridgeSessionList(sessions),
494
497
  };
495
498
  }
496
- case "POST /v1/ack":
497
- return this.ack(operation.ids);
498
499
  case "POST /v1/send":
499
500
  return {
500
501
  ...(await this.send(
@@ -0,0 +1,35 @@
1
+ import { resolveSessionTarget, formatSessionTarget, nativePeerId } from "./session-targets.ts";
2
+ import { Fault } from "./bridge-protocol.ts";
3
+ import type { SessionInfo } from "./types.ts";
4
+
5
+ export function bridgeSessionList(
6
+ peers: readonly SessionInfo[],
7
+ ): readonly (SessionInfo & { readonly target: string })[] {
8
+ return peers.map(({ topics: _topics, subscriptions: _subscriptions, ...entry }) => ({
9
+ ...entry,
10
+ target: formatSessionTarget(entry, peers),
11
+ }));
12
+ }
13
+
14
+ export function resolveBridgeTarget(
15
+ peers: readonly SessionInfo[],
16
+ to: string,
17
+ self: string | null,
18
+ ): SessionInfo {
19
+ const exactNative = peers.find((peer) => peer.id === nativePeerId(to));
20
+ const resolution = resolveSessionTarget(peers, exactNative?.id ?? to);
21
+ if (resolution.status === "none") {
22
+ throw new Fault(404, "peer_offline", "Peer is not connected.");
23
+ }
24
+ if (resolution.status !== "found" || !resolution.target) {
25
+ throw new Fault(
26
+ 409,
27
+ "ambiguous_target",
28
+ "Use an unambiguous peer name or session ID (prefixes need at least 8 characters).",
29
+ );
30
+ }
31
+ if (resolution.target.id === self) {
32
+ throw new Fault(400, "self_target", "Choose another peer.");
33
+ }
34
+ return resolution.target;
35
+ }
@@ -0,0 +1,188 @@
1
+ import { log } from "./bridge-protocol.ts";
2
+ import { Mailbox, type PendingNotification } from "./bridge-mailbox.ts";
3
+ import type { Identity, WakePolicy } from "./bridge-config.ts";
4
+
5
+ const POLL_MS = 15000;
6
+ const FALLBACK_AFTER_MS = 120000;
7
+ const MAX_BACKOFF_MS = 300000;
8
+
9
+ export class WakeDelivery {
10
+ private readonly controller = new AbortController();
11
+ private timer?: NodeJS.Timeout;
12
+ private running?: Promise<void>;
13
+ private backoff = POLL_MS;
14
+ private readonly name: string;
15
+ private readonly mailbox: Pick<Mailbox, "pendingNotifications" | "notificationAccepted">;
16
+ private readonly policy: WakePolicy;
17
+ private readonly authorized: () => boolean;
18
+
19
+ constructor(
20
+ name: string,
21
+ mailbox: Pick<Mailbox, "pendingNotifications" | "notificationAccepted">,
22
+ policy: WakePolicy,
23
+ authorized: () => boolean,
24
+ ) {
25
+ this.name = name;
26
+ this.mailbox = mailbox;
27
+ this.policy = policy;
28
+ this.authorized = authorized;
29
+ }
30
+
31
+ start(): void {
32
+ if (!this.running && !this.controller.signal.aborted) {
33
+ this.schedule(0);
34
+ }
35
+ }
36
+
37
+ private schedule(delay: number): void {
38
+ clearTimeout(this.timer);
39
+ this.timer = setTimeout(() => {
40
+ this.running = this.deliver()
41
+ .catch(() => {
42
+ log("wake_delivery", { name: this.name, result: "storage_unavailable" });
43
+ this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
44
+ })
45
+ .finally(() => {
46
+ this.running = undefined;
47
+ if (!this.controller.signal.aborted) {
48
+ this.schedule(this.backoff);
49
+ }
50
+ });
51
+ }, delay);
52
+ }
53
+
54
+ private async deliver(): Promise<void> {
55
+ if (!this.authorized()) {
56
+ return;
57
+ }
58
+ const entries = this.mailbox.pendingNotifications().messages;
59
+ for (const entry of entries) {
60
+ if (entry.acknowledged) {
61
+ // The box's durable worker owns primary wake/replay after acknowledgement.
62
+ this.mailbox.notificationAccepted(entry.message.id);
63
+ }
64
+ }
65
+ const next = entries.find(
66
+ (entry) => !entry.acknowledged && Date.now() - entry.receivedAt >= FALLBACK_AFTER_MS,
67
+ );
68
+ if (next) {
69
+ // One fallback wakes the box to drain the remaining mailbox; never flood a reset VM.
70
+ await this.post(next);
71
+ return;
72
+ }
73
+ this.backoff = POLL_MS;
74
+ }
75
+
76
+ private async post(entry: PendingNotification): Promise<void> {
77
+ if (!this.authorized()) {
78
+ return;
79
+ }
80
+ const fields = { name: this.name, messageId: entry.message.id };
81
+ try {
82
+ const response = await fetch(this.policy.url, {
83
+ method: "POST",
84
+ redirect: "error",
85
+ headers: {
86
+ Authorization: `Bearer ${this.policy.key}`,
87
+ "Content-Type": "application/json",
88
+ },
89
+ body: JSON.stringify({
90
+ from: entry.from.name ?? entry.from.id,
91
+ from_id: entry.from.id,
92
+ to: this.name,
93
+ message_id: entry.message.id,
94
+ ask_id: entry.message.expectsReply === true ? entry.message.id : null,
95
+ text: entry.message.content.text,
96
+ }),
97
+ signal: AbortSignal.any([this.controller.signal, AbortSignal.timeout(10000)]),
98
+ });
99
+ await response.body?.cancel();
100
+ if (response.ok && !this.controller.signal.aborted && this.authorized()) {
101
+ // HTTP acceptance cannot prove durable intake; the same ID remains pending until inbox ACK.
102
+ this.backoff = MAX_BACKOFF_MS;
103
+ log("wake_delivery", { ...fields, result: "accepted" });
104
+ } else {
105
+ this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
106
+ log("wake_delivery", { ...fields, result: "http_rejected", status: response.status });
107
+ }
108
+ } catch {
109
+ this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
110
+ if (!this.controller.signal.aborted) {
111
+ log("wake_delivery", { ...fields, result: "transport_unconfirmed" });
112
+ }
113
+ }
114
+ }
115
+
116
+ async stop(): Promise<void> {
117
+ this.controller.abort();
118
+ clearTimeout(this.timer);
119
+ await this.running;
120
+ }
121
+ }
122
+
123
+ export class WakeDeliveries {
124
+ private readonly stateDir: string | undefined;
125
+ private readonly allowed: (identity: Identity) => boolean;
126
+ private readonly deliveries = new Map<
127
+ string,
128
+ { readonly identity: Identity; readonly mailbox: Mailbox; readonly worker: WakeDelivery }
129
+ >();
130
+ private readonly cleanup = new Set<Promise<unknown>>();
131
+
132
+ constructor(stateDir: string | undefined, allowed: (identity: Identity) => boolean) {
133
+ this.stateDir = stateDir;
134
+ this.allowed = allowed;
135
+ }
136
+
137
+ session(identity: Identity, expires: number): void {
138
+ if (
139
+ !identity.wake ||
140
+ this.stateDir === undefined ||
141
+ this.deliveries.has(identity.fingerprint256)
142
+ ) {
143
+ return;
144
+ }
145
+ const mailbox = new Mailbox(this.stateDir, identity.fingerprint256, true);
146
+ const worker = new WakeDelivery(
147
+ identity.name,
148
+ mailbox,
149
+ identity.wake,
150
+ () => this.allowed(identity) && expires > Date.now(),
151
+ );
152
+ this.deliveries.set(identity.fingerprint256, { identity, mailbox, worker });
153
+ worker.start();
154
+ }
155
+
156
+ drop(fp: string): void {
157
+ const delivery = this.deliveries.get(fp);
158
+ if (!delivery) {
159
+ return;
160
+ }
161
+ this.deliveries.delete(fp);
162
+ const cleanup = delivery.worker
163
+ .stop()
164
+ .then(() => delivery.mailbox.close())
165
+ .catch(() => {
166
+ log("wake_delivery", { name: delivery.identity.name, result: "cleanup_failed" });
167
+ })
168
+ .finally(() => {
169
+ this.cleanup.delete(cleanup);
170
+ });
171
+ this.cleanup.add(cleanup);
172
+ }
173
+
174
+ reload(): void {
175
+ for (const [fp, delivery] of this.deliveries) {
176
+ if (!this.allowed(delivery.identity)) {
177
+ this.drop(fp);
178
+ }
179
+ }
180
+ }
181
+
182
+ async stop(): Promise<void> {
183
+ for (const fp of this.deliveries.keys()) {
184
+ this.drop(fp);
185
+ }
186
+ await Promise.all(this.cleanup);
187
+ }
188
+ }