@fitchmultz/pi-subagents 0.52.19 → 0.52.21

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 (56) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/agent/contracts.js +12 -3
  3. package/dist/agent/index.js +9 -9
  4. package/dist/agent/legacy.js +5 -5
  5. package/dist/agent/notifications.js +21 -14
  6. package/dist/agent/parent-check.js +38 -0
  7. package/dist/agent/process.js +45 -52
  8. package/dist/agent/runner.js +59 -64
  9. package/dist/agent/storage.js +16 -4
  10. package/dist/agent/view.js +1 -3
  11. package/dist/build-info.js +1 -1
  12. package/dist/pi-intercom/bridge-presence.js +30 -18
  13. package/dist/pi-intercom/bridge-wake.js +60 -37
  14. package/dist/pi-intercom/broker/broker.js +4 -26
  15. package/dist/pi-intercom/broker/client-last.js +6 -18
  16. package/dist/pi-intercom/broker/client-park.d.ts +1 -1
  17. package/dist/pi-intercom/broker/client-park.js +22 -6
  18. package/dist/pi-intercom/broker/client.d.ts +3 -2
  19. package/dist/pi-intercom/broker/client.js +104 -112
  20. package/dist/pi-intercom/broker/connection.d.ts +3 -5
  21. package/dist/pi-intercom/broker/connection.js +48 -33
  22. package/dist/pi-intercom/broker/framing.d.ts +1 -1
  23. package/dist/pi-intercom/broker/framing.js +19 -12
  24. package/dist/pi-intercom/broker/pending-requests.d.ts +12 -0
  25. package/dist/pi-intercom/broker/pending-requests.js +26 -0
  26. package/dist/pi-intercom/broker/replace-deliveries.js +38 -0
  27. package/dist/pi-intercom/broker/snapshot-reader.d.ts +3 -2
  28. package/dist/pi-intercom/broker/snapshot-reader.js +34 -15
  29. package/dist/pi-intercom/connection.js +5 -8
  30. package/dist/pi-intercom/inbound-journal.js +5 -2
  31. package/dist/shared/journal-frames.js +19 -24
  32. package/docs/development.md +6 -0
  33. package/package.json +1 -1
  34. package/src/agent/contracts.ts +12 -3
  35. package/src/agent/index.ts +10 -10
  36. package/src/agent/legacy.ts +5 -5
  37. package/src/agent/notifications.ts +35 -15
  38. package/src/agent/parent-check.ts +40 -0
  39. package/src/agent/process.ts +53 -55
  40. package/src/agent/runner.ts +65 -65
  41. package/src/agent/storage.ts +18 -5
  42. package/src/agent/view.ts +1 -4
  43. package/src/pi-intercom/bridge-presence.ts +37 -18
  44. package/src/pi-intercom/bridge-wake.ts +70 -38
  45. package/src/pi-intercom/broker/broker.ts +5 -37
  46. package/src/pi-intercom/broker/client-last.ts +14 -25
  47. package/src/pi-intercom/broker/client-park.ts +27 -6
  48. package/src/pi-intercom/broker/client.ts +127 -126
  49. package/src/pi-intercom/broker/connection.ts +58 -39
  50. package/src/pi-intercom/broker/framing.ts +24 -12
  51. package/src/pi-intercom/broker/pending-requests.ts +37 -0
  52. package/src/pi-intercom/broker/replace-deliveries.ts +56 -0
  53. package/src/pi-intercom/broker/snapshot-reader.ts +44 -16
  54. package/src/pi-intercom/connection.ts +9 -9
  55. package/src/pi-intercom/inbound-journal.ts +5 -2
  56. package/src/shared/journal-frames.ts +22 -23
@@ -3,6 +3,7 @@ import { randomUUID } from "crypto";
3
3
  import { writeMessage } from "./framing.js";
4
4
  import { BrokerConnection, connectBrokerSocket } from "./connection.js";
5
5
  import { SnapshotReader } from "./snapshot-reader.js";
6
+ import { PendingRequests } from "./pending-requests.js";
6
7
  import { LastMessageRequests, lastRequest } from "./client-last.js";
7
8
  import { ParkRequests } from "./client-park.js";
8
9
  import { WakeMessages } from "./client-wake.js";
@@ -36,10 +37,9 @@ export class IntercomClient extends EventEmitter {
36
37
  wakes = new WakeMessages(this, () => this.requireActiveSocket());
37
38
  guardedDeliverySupported = false;
38
39
  lastRequests;
39
- pendingSends = new Map();
40
- pendingLists = new Map();
41
- connecting = false;
42
- disconnecting = false;
40
+ pendingSends = new PendingRequests();
41
+ pendingLists = new PendingRequests();
42
+ phase = { kind: "idle" };
43
43
  sendTimeoutMs;
44
44
  listTimeoutMs;
45
45
  constructor(options = {}) {
@@ -50,14 +50,8 @@ export class IntercomClient extends EventEmitter {
50
50
  }
51
51
  failPending(error) {
52
52
  this.lastRequests.fail(error);
53
- for (const pending of this.pendingSends.values()) {
54
- pending.reject(error);
55
- }
56
- this.pendingSends.clear();
57
- for (const pending of this.pendingLists.values()) {
58
- pending.reject(error);
59
- }
60
- this.pendingLists.clear();
53
+ this.pendingSends.fail(error);
54
+ this.pendingLists.fail(error);
61
55
  }
62
56
  get sessionId() {
63
57
  return this._sessionId;
@@ -70,11 +64,11 @@ export class IntercomClient extends EventEmitter {
70
64
  }
71
65
  isConnected() {
72
66
  return ((this._sessionId ?? "").length > 0 &&
73
- !this.disconnecting &&
67
+ this.phase.kind !== "closing" &&
74
68
  this.connection?.isActive() === true);
75
69
  }
76
70
  requireActiveSocket() {
77
- if (this.disconnecting) {
71
+ if (this.phase.kind === "closing") {
78
72
  throw new Error("Client disconnecting");
79
73
  }
80
74
  const connection = this.connection;
@@ -87,62 +81,72 @@ export class IntercomClient extends EventEmitter {
87
81
  return connection.socket;
88
82
  }
89
83
  async connect(session, requestedId) {
90
- if (this.connection || this.connecting) {
84
+ if (this.connection || this.phase.kind !== "idle") {
91
85
  throw new Error("Already connected");
92
86
  }
93
- this.connecting = true;
87
+ const task = this.establish(session, requestedId);
88
+ this.phase = { kind: "connecting", task };
94
89
  try {
95
- const socket = await connectBrokerSocket();
96
- const connection = new BrokerConnection(socket, {
97
- message: (message) => this.handleBrokerMessage(message),
98
- error: (error) => {
99
- this.emit("error", error);
100
- },
101
- closed: (error, expected) => {
102
- if (this.connection !== connection) {
103
- return;
104
- }
105
- const established = this._sessionId !== null;
106
- this.connection = null;
107
- this._sessionId = null;
108
- this._topicsSupported = false;
109
- this.parking.settle(false);
110
- this.guardedDeliverySupported = false;
111
- this.wakes.supported = false;
112
- this.disconnecting = false;
113
- this.failPending(error);
114
- if (established && !expected) {
115
- this.emit("disconnected", error);
116
- }
117
- },
118
- });
119
- this.connection = connection;
120
- const { topics, subscriptions, ...identity } = session;
121
- try {
122
- await connection.start({
123
- type: "register",
124
- session: identity,
125
- requestedId,
126
- topicFrames: true,
127
- lastMessages: true,
128
- parking: true,
129
- receiptReads: 1,
130
- guardedDelivery: 1,
131
- ...this.wakes.registration(),
132
- });
90
+ await task;
91
+ }
92
+ finally {
93
+ if (this.phase.kind === "connecting" && this.phase.task === task) {
94
+ this.phase = { kind: this.isConnected() ? "connected" : "idle" };
133
95
  }
134
- catch (error) {
135
- if (this.connection === connection) {
136
- this.connection = null;
96
+ }
97
+ }
98
+ async establish(session, requestedId) {
99
+ const socket = await connectBrokerSocket();
100
+ const connection = new BrokerConnection(socket, {
101
+ message: (message) => this.handleBrokerMessage(message),
102
+ error: (error) => {
103
+ this.emit("error", error);
104
+ },
105
+ closed: (error, expected) => {
106
+ if (this.connection !== connection) {
107
+ return;
137
108
  }
138
- throw error;
139
- }
140
- if (this.supportsTopics) {
141
- await this.restoreTopics(topics, subscriptions);
142
- }
109
+ const established = this._sessionId !== null;
110
+ this.connection = null;
111
+ this._sessionId = null;
112
+ this._topicsSupported = false;
113
+ this.parking.settle(false);
114
+ this.guardedDeliverySupported = false;
115
+ this.wakes.supported = false;
116
+ if (this.phase.kind === "connected") {
117
+ this.phase = { kind: "idle" };
118
+ }
119
+ this.failPending(error);
120
+ if (established && !expected) {
121
+ this.emit("disconnected", error);
122
+ }
123
+ },
124
+ });
125
+ this.connection = connection;
126
+ if (this.phase.kind === "closing") {
127
+ await connection.disconnect();
128
+ throw new Error("Client disconnecting");
143
129
  }
144
- finally {
145
- this.connecting = false;
130
+ const { topics, subscriptions, ...identity } = session;
131
+ try {
132
+ await connection.start({
133
+ type: "register",
134
+ session: identity,
135
+ requestedId,
136
+ topicFrames: true,
137
+ lastMessages: true,
138
+ parking: true,
139
+ receiptReads: 1,
140
+ guardedDelivery: 1,
141
+ ...this.wakes.registration(),
142
+ });
143
+ }
144
+ catch (error) {
145
+ await connection.disconnect();
146
+ throw error;
147
+ }
148
+ if (this.supportsTopics) {
149
+ await this.restoreTopics(topics, subscriptions);
146
150
  }
147
151
  }
148
152
  async restoreTopics(topics, subscriptions) {
@@ -240,12 +244,11 @@ export class IntercomClient extends EventEmitter {
240
244
  if (type !== "delivered" && typeof reason !== "string") {
241
245
  throw new Error(`Invalid ${errorMessage(type)} message`);
242
246
  }
243
- const pending = this.pendingSends.get(messageId);
247
+ const pending = this.pendingSends.take(messageId);
244
248
  // Late responses are harmless after the caller has timed out.
245
249
  if (!pending) {
246
250
  return;
247
251
  }
248
- this.pendingSends.delete(messageId);
249
252
  pending.resolve({
250
253
  id: messageId,
251
254
  accepted: type !== "delivery_failed",
@@ -266,27 +269,42 @@ export class IntercomClient extends EventEmitter {
266
269
  return;
267
270
  }
268
271
  if (typeof error === "string") {
269
- this.pendingLists.delete(requestId);
270
- pending.reject(new Error(error));
272
+ this.pendingLists.take(requestId)?.reject(new Error(error));
271
273
  return;
272
274
  }
273
275
  pending.reader.append(sessions, receipts);
274
276
  if (more !== true) {
275
- this.pendingLists.delete(requestId);
276
- pending.resolve(pending.reader.finish());
277
+ this.pendingLists.take(requestId)?.resolve(pending.reader.finish());
277
278
  }
278
279
  }
279
280
  park(on = true) {
280
281
  return this.parking.request(() => this.requireActiveSocket(), on);
281
282
  }
282
- async disconnect() {
283
- const connection = this.connection;
284
- if (!connection) {
285
- return;
286
- }
287
- this.disconnecting = true;
283
+ disconnect() {
284
+ const phase = this.phase;
285
+ if (phase.kind === "closing") {
286
+ return phase.task;
287
+ }
288
+ const connecting = phase.kind === "connecting" ? phase.task : undefined;
289
+ const task = Promise.resolve().then(() => this.close(connecting));
290
+ this.phase = { kind: "closing", task };
291
+ return task;
292
+ }
293
+ async close(connecting) {
288
294
  this.failPending(new Error("Client disconnected"));
289
- await connection.disconnect();
295
+ try {
296
+ await this.connection?.disconnect();
297
+ try {
298
+ await connecting;
299
+ }
300
+ catch {
301
+ // The connect caller owns acquisition failure; close still joins any acquired socket.
302
+ }
303
+ await this.connection?.disconnect();
304
+ }
305
+ finally {
306
+ this.phase = { kind: "idle" };
307
+ }
290
308
  }
291
309
  listSessions() {
292
310
  return this.requestSessions().then((snapshot) => [...snapshot.sessions]);
@@ -319,8 +337,8 @@ export class IntercomClient extends EventEmitter {
319
337
  }
320
338
  return new Promise((resolve, reject) => {
321
339
  const requestId = randomUUID();
340
+ const reader = new SnapshotReader();
322
341
  const wrappedResolve = (snapshot) => {
323
- clearTimeout(timeout);
324
342
  try {
325
343
  // Commit confirmed subscriptions before handling a following message in the same read.
326
344
  onAccepted?.(snapshot);
@@ -331,21 +349,14 @@ export class IntercomClient extends EventEmitter {
331
349
  }
332
350
  };
333
351
  const wrappedReject = (error) => {
334
- clearTimeout(timeout);
352
+ reader.fail();
335
353
  reject(error);
336
354
  };
337
- const timeout = setTimeout(() => {
338
- if (this.pendingLists.has(requestId)) {
339
- this.pendingLists.delete(requestId);
340
- wrappedReject(Object.assign(new Error("List sessions timeout"), { code: "list_timeout" }));
341
- }
342
- }, this.listTimeoutMs);
343
- timeout.unref();
344
355
  this.pendingLists.set(requestId, {
345
356
  resolve: wrappedResolve,
346
357
  reject: wrappedReject,
347
- reader: new SnapshotReader(),
348
- });
358
+ reader,
359
+ }, this.listTimeoutMs, () => Object.assign(new Error("List sessions timeout"), { code: "list_timeout" }));
349
360
  try {
350
361
  writeMessage(socket, {
351
362
  type: "list",
@@ -355,9 +366,7 @@ export class IntercomClient extends EventEmitter {
355
366
  });
356
367
  }
357
368
  catch (error) {
358
- clearTimeout(timeout);
359
- this.pendingLists.delete(requestId);
360
- reject(toError(error));
369
+ this.pendingLists.take(requestId)?.reject(toError(error));
361
370
  }
362
371
  });
363
372
  }
@@ -377,7 +386,7 @@ export class IntercomClient extends EventEmitter {
377
386
  return Promise.reject(toError(error));
378
387
  }
379
388
  const messageId = options.messageId ?? (options.guard ? "" : randomUUID());
380
- if (this.pendingSends.has(messageId)) {
389
+ if (this.pendingSends.get(messageId)) {
381
390
  return Promise.reject(new Error("Message ID already has a pending send"));
382
391
  }
383
392
  const message = {
@@ -402,22 +411,7 @@ export class IntercomClient extends EventEmitter {
402
411
  return Promise.reject(new Error("Invalid message format"));
403
412
  }
404
413
  return new Promise((resolve, reject) => {
405
- const wrappedResolve = (result) => {
406
- clearTimeout(timeout);
407
- resolve(result);
408
- };
409
- const wrappedReject = (error) => {
410
- clearTimeout(timeout);
411
- reject(error);
412
- };
413
- const timeout = setTimeout(() => {
414
- if (this.pendingSends.has(messageId)) {
415
- this.pendingSends.delete(messageId);
416
- wrappedReject(new Error("Send timeout"));
417
- }
418
- }, this.sendTimeoutMs);
419
- timeout.unref();
420
- this.pendingSends.set(messageId, { resolve: wrappedResolve, reject: wrappedReject });
414
+ this.pendingSends.set(messageId, { resolve, reject }, this.sendTimeoutMs, () => new Error("Send timeout"));
421
415
  try {
422
416
  writeMessage(socket, {
423
417
  type: "send",
@@ -426,14 +420,12 @@ export class IntercomClient extends EventEmitter {
426
420
  });
427
421
  }
428
422
  catch (error) {
429
- clearTimeout(timeout);
430
- this.pendingSends.delete(messageId);
431
- reject(toError(error));
423
+ this.pendingSends.take(messageId)?.reject(toError(error));
432
424
  }
433
425
  });
434
426
  }
435
427
  updatePresence(updates) {
436
- if (this.disconnecting) {
428
+ if (this.phase.kind === "closing") {
437
429
  return;
438
430
  }
439
431
  const socket = this.connection?.socket;
@@ -7,22 +7,20 @@ interface ConnectionHandlers {
7
7
  }
8
8
  /** One socket owns registration, read/error listeners, and graceful shutdown. */
9
9
  export declare class BrokerConnection {
10
- private registered;
11
- private closing;
10
+ private phase;
12
11
  private failure;
13
- private registration;
14
- private registrationTimer;
15
12
  private readonly reader;
16
13
  readonly socket: net.Socket;
17
14
  private readonly handlers;
18
15
  constructor(socket: net.Socket, handlers: ConnectionHandlers);
19
16
  start(registration: unknown): Promise<void>;
20
17
  confirmRegistration(): void;
21
- private clearRegistration;
18
+ private takeRegistration;
22
19
  private failRegistration;
23
20
  private protocolError;
24
21
  private readonly onError;
25
22
  private readonly onClose;
23
+ private get registered();
26
24
  isActive(): boolean;
27
25
  disconnect(): Promise<void>;
28
26
  }
@@ -34,11 +34,8 @@ export async function connectBrokerSocket() {
34
34
  }
35
35
  /** One socket owns registration, read/error listeners, and graceful shutdown. */
36
36
  export class BrokerConnection {
37
- registered = false;
38
- closing = false;
37
+ phase = { kind: "acquired" };
39
38
  failure = null;
40
- registration = null;
41
- registrationTimer = null;
42
39
  reader;
43
40
  socket;
44
41
  handlers;
@@ -52,9 +49,9 @@ export class BrokerConnection {
52
49
  }
53
50
  start(registration) {
54
51
  return new Promise((resolve, reject) => {
55
- this.registration = { resolve, reject };
56
- this.registrationTimer = setTimeout(() => this.failRegistration(new Error("Connection timeout")), 10000);
57
- this.registrationTimer.unref();
52
+ const timer = setTimeout(() => this.failRegistration(new Error("Connection timeout")), 10000);
53
+ timer.unref();
54
+ this.phase = { kind: "registering", resolve, reject, timer };
58
55
  try {
59
56
  writeMessage(this.socket, registration);
60
57
  }
@@ -64,21 +61,26 @@ export class BrokerConnection {
64
61
  });
65
62
  }
66
63
  confirmRegistration() {
67
- this.registered = true;
68
- const pending = this.registration;
69
- this.clearRegistration();
70
- pending?.resolve();
64
+ const pending = this.takeRegistration();
65
+ if (pending !== undefined) {
66
+ this.phase = { kind: "active" };
67
+ pending.resolve();
68
+ }
71
69
  }
72
- clearRegistration() {
73
- if (this.registrationTimer) {
74
- clearTimeout(this.registrationTimer);
70
+ takeRegistration() {
71
+ const pending = this.phase;
72
+ if (pending.kind !== "registering") {
73
+ return;
75
74
  }
76
- this.registrationTimer = null;
77
- this.registration = null;
75
+ clearTimeout(pending.timer);
76
+ this.phase = { kind: "acquired" };
77
+ return pending;
78
78
  }
79
79
  failRegistration(error) {
80
- const pending = this.registration;
81
- this.clearRegistration();
80
+ const pending = this.takeRegistration();
81
+ if (this.phase.kind !== "closing") {
82
+ this.phase = { kind: "failed" };
83
+ }
82
84
  this.failure = error;
83
85
  this.socket.destroy();
84
86
  pending?.reject(error);
@@ -102,31 +104,37 @@ export class BrokerConnection {
102
104
  this.handlers.error(error);
103
105
  };
104
106
  onClose = () => {
105
- const pending = this.registration;
106
- this.clearRegistration();
107
+ const closing = this.phase.kind === "closing";
108
+ const pending = this.takeRegistration();
109
+ this.phase = { kind: "closed" };
107
110
  this.socket.off("data", this.reader);
108
111
  this.socket.off("error", this.onError);
109
112
  this.socket.off("close", this.onClose);
110
113
  pending?.reject(new Error("Connection closed before registration"));
111
- this.handlers.closed(this.failure ?? new Error("Client disconnected"), this.closing);
114
+ this.handlers.closed(this.failure ?? new Error("Client disconnected"), closing);
112
115
  };
116
+ get registered() {
117
+ return this.phase.kind === "active" || (this.phase.kind === "closing" && this.phase.registered);
118
+ }
113
119
  isActive() {
114
- return (this.registered &&
115
- !this.closing &&
120
+ return (this.phase.kind === "active" &&
116
121
  !this.socket.destroyed &&
117
122
  !this.socket.writableEnded &&
118
123
  this.socket.writable);
119
124
  }
120
125
  disconnect() {
121
- this.closing = true;
126
+ const phase = this.phase;
127
+ if (phase.kind === "closing") {
128
+ return phase.task;
129
+ }
130
+ if (phase.kind === "closed") {
131
+ return Promise.resolve();
132
+ }
133
+ const pending = this.takeRegistration();
134
+ pending?.reject(new Error("Client disconnected before registration"));
122
135
  this.failure = null;
123
- return new Promise((resolve) => {
124
- let settled = false;
136
+ const task = new Promise((resolve) => {
125
137
  const finish = () => {
126
- if (settled) {
127
- return;
128
- }
129
- settled = true;
130
138
  clearTimeout(timeout);
131
139
  this.socket.off("close", finish);
132
140
  this.socket.off("error", onError);
@@ -140,13 +148,20 @@ export class BrokerConnection {
140
148
  }, 2000);
141
149
  this.socket.once("close", finish);
142
150
  this.socket.once("error", onError);
143
- try {
151
+ });
152
+ this.phase = { kind: "closing", task, registered: phase.kind === "active" };
153
+ try {
154
+ if (phase.kind === "active" || phase.kind === "registering") {
144
155
  writeMessage(this.socket, { type: "unregister" });
145
156
  this.socket.end();
146
157
  }
147
- catch {
158
+ else {
148
159
  this.socket.destroy();
149
160
  }
150
- });
161
+ }
162
+ catch {
163
+ this.socket.destroy();
164
+ }
165
+ return task;
151
166
  }
152
167
  }
@@ -12,6 +12,6 @@ export declare function writeFrame(socket: Socket, json: string, limit?: number)
12
12
  /**
13
13
  * Create a message reader that handles partial reads.
14
14
  * Calls onMessage for each complete message received.
15
- * Protocol or handler errors are reported to onError so the caller can close the socket.
15
+ * Protocol or handler errors end reading and are reported so the caller can close the socket.
16
16
  */
17
17
  export declare function createMessageReader(onMessage: (msg: unknown) => void, onError: (error: Readonly<Error>) => void, limit?: number): (data: Buffer) => void;
@@ -84,33 +84,40 @@ class MessageBuffer {
84
84
  /**
85
85
  * Create a message reader that handles partial reads.
86
86
  * Calls onMessage for each complete message received.
87
- * Protocol or handler errors are reported to onError so the caller can close the socket.
87
+ * Protocol or handler errors end reading and are reported so the caller can close the socket.
88
88
  */
89
89
  export function createMessageReader(onMessage, onError, limit = MAX_FRAME_SIZE_BYTES) {
90
90
  const buffer = new MessageBuffer();
91
- let length;
91
+ let state = { kind: "header" };
92
+ const fail = (error) => {
93
+ state = { kind: "failed" };
94
+ buffer.clear();
95
+ onError(error);
96
+ };
92
97
  return (data) => {
98
+ if (state.kind === "failed") {
99
+ return;
100
+ }
93
101
  buffer.append(data);
94
- while (buffer.length >= (length ?? 4)) {
95
- if (length === undefined) {
96
- length = buffer.read(4).readUInt32BE(0);
102
+ while (buffer.length >= (state.kind === "payload" ? state.length : 4)) {
103
+ if (state.kind === "header") {
104
+ const length = buffer.read(4).readUInt32BE(0);
97
105
  if (length > limit) {
98
- onError(new Error(`Intercom frame too large (${length} bytes; max ${limit})`));
99
- buffer.clear();
100
- length = undefined;
106
+ fail(new Error(`Intercom frame too large (${length} bytes; max ${limit})`));
101
107
  return;
102
108
  }
109
+ state = { kind: "payload", length };
103
110
  continue;
104
111
  }
105
- const payload = buffer.read(length);
106
- length = undefined;
112
+ const payload = buffer.read(state.length);
113
+ state = { kind: "header" };
107
114
  let msg;
108
115
  try {
109
116
  msg = JSON.parse(payload.toString("utf-8"));
110
117
  }
111
118
  catch (error) {
112
119
  const message = errorMessage(error);
113
- onError(new Error(`Failed to parse intercom message: ${message}`, { cause: error }));
120
+ fail(new Error(`Failed to parse intercom message: ${message}`, { cause: error }));
114
121
  return;
115
122
  }
116
123
  try {
@@ -118,7 +125,7 @@ export function createMessageReader(onMessage, onError, limit = MAX_FRAME_SIZE_B
118
125
  }
119
126
  catch (error) {
120
127
  const message = errorMessage(error);
121
- onError(new Error(`Failed to handle intercom message: ${message}`, { cause: error }));
128
+ fail(new Error(`Failed to handle intercom message: ${message}`, { cause: error }));
122
129
  return;
123
130
  }
124
131
  }
@@ -0,0 +1,12 @@
1
+ interface Request {
2
+ readonly reject: (error: Readonly<Error>) => void;
3
+ }
4
+ /** Map membership owns settlement; taking a request also releases its deadline. */
5
+ export declare class PendingRequests<T extends Request> {
6
+ private readonly pending;
7
+ get(id: string): T | undefined;
8
+ set(id: string, value: T, timeoutMs: number, timeoutError: () => Error): void;
9
+ take(id: string): T | undefined;
10
+ fail(error: Readonly<Error>): void;
11
+ }
12
+ export {};
@@ -0,0 +1,26 @@
1
+ /** Map membership owns settlement; taking a request also releases its deadline. */
2
+ export class PendingRequests {
3
+ pending = new Map();
4
+ get(id) {
5
+ return this.pending.get(id)?.value;
6
+ }
7
+ set(id, value, timeoutMs, timeoutError) {
8
+ const timer = setTimeout(() => this.take(id)?.reject(timeoutError()), timeoutMs);
9
+ timer.unref();
10
+ this.pending.set(id, { value, timer });
11
+ }
12
+ take(id) {
13
+ const pending = this.pending.get(id);
14
+ if (pending === undefined) {
15
+ return;
16
+ }
17
+ this.pending.delete(id);
18
+ clearTimeout(pending.timer);
19
+ return pending.value;
20
+ }
21
+ fail(error) {
22
+ for (const id of this.pending.keys()) {
23
+ this.take(id)?.reject(error);
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,38 @@
1
+ const REPLACE_DELIVERY_DELAY_MS = 1500;
2
+ /** Only broker-staged messages remain replaceable; forwarding ends this owner's authority. */
3
+ export class ReplaceDeliveries {
4
+ pending = new Map();
5
+ deliver;
6
+ constructor(deliver) {
7
+ this.deliver = deliver;
8
+ }
9
+ stage(fromId, toId, from, message) {
10
+ const key = `${fromId}\0${toId}\0${message.threadId ?? ""}`;
11
+ this.take(key);
12
+ const timer = setTimeout(() => this.flush(key), REPLACE_DELIVERY_DELAY_MS);
13
+ timer.unref();
14
+ this.pending.set(key, { fromId, toId, from, message, timer });
15
+ }
16
+ take(key) {
17
+ const pending = this.pending.get(key);
18
+ if (pending !== undefined) {
19
+ this.pending.delete(key);
20
+ clearTimeout(pending.timer);
21
+ }
22
+ return pending;
23
+ }
24
+ flush(key) {
25
+ const pending = this.take(key);
26
+ if (pending !== undefined) {
27
+ this.deliver(pending.toId, pending.from, pending.message);
28
+ }
29
+ }
30
+ disconnected(sessionId) {
31
+ for (const [key, pending] of this.pending) {
32
+ if (pending.toId === sessionId ||
33
+ (pending.fromId === sessionId && pending.message.expectsReply === true)) {
34
+ this.take(key);
35
+ }
36
+ }
37
+ }
38
+ }
@@ -1,8 +1,9 @@
1
1
  import { type SessionSnapshot } from "../types.ts";
2
2
  /** Multipart frames build a private cache; only a complete validated snapshot escapes. */
3
3
  export declare class SnapshotReader {
4
- private readonly sessions;
5
- private readonly receipts;
4
+ private phase;
5
+ private open;
6
+ fail(): void;
6
7
  private addSession;
7
8
  append(sessions: readonly unknown[], receipts: unknown): void;
8
9
  finish(): SessionSnapshot;