@fitchmultz/pi-subagents 0.52.20 → 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 (44) hide show
  1. package/CHANGELOG.md +30 -2
  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/broker/broker.js +4 -26
  13. package/dist/pi-intercom/broker/client-park.d.ts +1 -1
  14. package/dist/pi-intercom/broker/client-park.js +22 -6
  15. package/dist/pi-intercom/broker/client.d.ts +3 -2
  16. package/dist/pi-intercom/broker/client.js +88 -62
  17. package/dist/pi-intercom/broker/connection.d.ts +3 -5
  18. package/dist/pi-intercom/broker/connection.js +48 -33
  19. package/dist/pi-intercom/broker/framing.d.ts +1 -1
  20. package/dist/pi-intercom/broker/framing.js +19 -12
  21. package/dist/pi-intercom/broker/replace-deliveries.js +38 -0
  22. package/dist/pi-intercom/connection.js +5 -8
  23. package/dist/pi-intercom/inbound-journal.js +5 -2
  24. package/dist/shared/journal-frames.js +19 -24
  25. package/docs/development.md +6 -0
  26. package/package.json +1 -1
  27. package/src/agent/contracts.ts +12 -3
  28. package/src/agent/index.ts +10 -10
  29. package/src/agent/legacy.ts +5 -5
  30. package/src/agent/notifications.ts +35 -15
  31. package/src/agent/parent-check.ts +40 -0
  32. package/src/agent/process.ts +53 -55
  33. package/src/agent/runner.ts +65 -65
  34. package/src/agent/storage.ts +18 -5
  35. package/src/agent/view.ts +1 -4
  36. package/src/pi-intercom/broker/broker.ts +5 -37
  37. package/src/pi-intercom/broker/client-park.ts +27 -6
  38. package/src/pi-intercom/broker/client.ts +91 -61
  39. package/src/pi-intercom/broker/connection.ts +58 -39
  40. package/src/pi-intercom/broker/framing.ts +24 -12
  41. package/src/pi-intercom/broker/replace-deliveries.ts +56 -0
  42. package/src/pi-intercom/connection.ts +9 -9
  43. package/src/pi-intercom/inbound-journal.ts +5 -2
  44. package/src/shared/journal-frames.ts +22 -23
@@ -2,12 +2,10 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { readOutputPage } from "../shared/output-page.js";
4
4
  import { actor, alive, observeAlive } from "./process.js";
5
- import { admissionSchema, commandSchema, exitSchema, successorSchema, wakeFailureSchema, } from "./contracts.js";
5
+ import { admissionSchema, commandMetadataSchema, exitSchema, successorSchema, wakeFailureSchema, } from "./contracts.js";
6
6
  import { conversation, read, runDirectory } from "./storage.js";
7
7
  import { published } from "./notifications.js";
8
8
  import { accountingFields, accountingState, readCanonicalResult, readResult } from "./usage.js";
9
- import { Type } from "./typebox.js";
10
- const commandMetadataSchema = Type.Pick(commandSchema, ["id", "runId", "action", "createdAt"]);
11
9
  function toolResult(value, text) {
12
10
  return { content: [{ type: "text", text }], details: value };
13
11
  }
@@ -1 +1 @@
1
- export const EXTENSION_BUILD = Object.freeze({"version":"0.52.20","sha256":"6698668f485ef551faf914031fee7ad7165c9062a38c31b2827077d8e93e94b9"});
1
+ export const EXTENSION_BUILD = Object.freeze({"version":"0.52.21","sha256":"d0c9a96a0eca06a58dc73d77b64300d7e41a9eb5c75b1deee8751ae3af4b87ce"});
@@ -14,13 +14,13 @@ import { guardedDeliveryError } from "./guard.js";
14
14
  import { compactTopicMessage, normalizeMessage, normalizeSessionInfo, } from "../types.js";
15
15
  import { errorMessage, isRecord } from "../../shared/unknown.js";
16
16
  import { messageSender, snapshotFrames, TopicSnapshot, publicationMessage, preparedPublication, deliveryAcknowledgement, shouldReplaceDelivery, } from "./snapshots.js";
17
+ import { ReplaceDeliveries } from "./replace-deliveries.js";
17
18
  const PID_PATH = join(getBrokerRuntimeDir(), "broker.pid");
18
- const REPLACE_DELIVERY_DELAY_MS = 1500;
19
19
  class IntercomBroker {
20
20
  sessions = new Map();
21
21
  lastMessages = new LastMessageRelay(this.sessions);
22
22
  parking = new ParkedSessions(this.sessions, this.deliverMessage.bind(this));
23
- pendingReplaceDeliveries = new Map();
23
+ replacements = new ReplaceDeliveries(this.deliverMessage.bind(this));
24
24
  server;
25
25
  shutdownTimer = null;
26
26
  socketPath;
@@ -153,7 +153,7 @@ class IntercomBroker {
153
153
  this.lastMessages.disconnected(sessionId);
154
154
  if (!this.parking.closed(session)) {
155
155
  this.sessions.delete(sessionId);
156
- this.clearPendingReplaceDeliveries(sessionId);
156
+ this.replacements.disconnected(sessionId);
157
157
  this.broadcast({ type: "session_left", sessionId }, session.info);
158
158
  }
159
159
  this.scheduleShutdownCheck();
@@ -348,25 +348,12 @@ class IntercomBroker {
348
348
  ...(comms ? { lastIntercomActivity: now } : {}),
349
349
  };
350
350
  }
351
- replaceKey(fromId, toId, threadId) {
352
- return `${fromId}\0${toId}\0${threadId}`;
353
- }
354
351
  queueReplaceDelivery(fromId, toId, from, message) {
355
352
  const validationError = this.validateDeliveryPayload(from, message, this.sessions.get(toId));
356
353
  if (validationError !== null) {
357
354
  return { id: message.id, accepted: false, delivered: false, reason: validationError };
358
355
  }
359
- const key = this.replaceKey(fromId, toId, message.threadId ?? "");
360
- const existing = this.pendingReplaceDeliveries.get(key);
361
- if (existing) {
362
- clearTimeout(existing.timer);
363
- }
364
- const timer = setTimeout(() => {
365
- this.pendingReplaceDeliveries.delete(key);
366
- this.deliverMessage(toId, from, message);
367
- }, REPLACE_DELIVERY_DELAY_MS);
368
- timer.unref();
369
- this.pendingReplaceDeliveries.set(key, { fromId, toId, message, timer });
356
+ this.replacements.stage(fromId, toId, from, message);
370
357
  return {
371
358
  id: message.id,
372
359
  accepted: true,
@@ -375,15 +362,6 @@ class IntercomBroker {
375
362
  reason: "Queued for replace-mode delivery",
376
363
  };
377
364
  }
378
- clearPendingReplaceDeliveries(sessionId) {
379
- for (const [key, pending] of this.pendingReplaceDeliveries) {
380
- if (pending.toId === sessionId ||
381
- (pending.fromId === sessionId && pending.message.expectsReply === true)) {
382
- clearTimeout(pending.timer);
383
- this.pendingReplaceDeliveries.delete(key);
384
- }
385
- }
386
- }
387
365
  /** One admission rule; retained input is checked as the uncompacted envelope any later
388
366
  * transport generation accepts. */
389
367
  validateDeliveryPayload(from, message, target) {
@@ -2,7 +2,7 @@ import type net from "node:net";
2
2
  /** Parking negotiation and its single outstanding broker acknowledgement. */
3
3
  export declare class ParkRequests {
4
4
  supported: boolean;
5
- private acknowledged?;
5
+ private state;
6
6
  settle(parked: boolean): void;
7
7
  /** True once the broker retains later input; false when it cannot or the socket closed. */
8
8
  request(socket: () => net.Socket, on: boolean): Promise<boolean>;
@@ -2,20 +2,36 @@ import { writeMessage } from "./framing.js";
2
2
  /** Parking negotiation and its single outstanding broker acknowledgement. */
3
3
  export class ParkRequests {
4
4
  supported = false;
5
- acknowledged;
5
+ state = { kind: "idle" };
6
6
  settle(parked) {
7
- this.acknowledged?.(parked);
7
+ const state = this.state;
8
+ this.state = { kind: "idle" };
9
+ if (state.kind === "pending") {
10
+ state.resolve(parked);
11
+ }
8
12
  }
9
13
  /** True once the broker retains later input; false when it cannot or the socket closed. */
10
14
  request(socket, on) {
11
- return new Promise((resolve) => {
12
- this.acknowledged = on && this.supported ? resolve : undefined;
15
+ if (on && this.state.kind === "pending") {
16
+ return this.state.promise;
17
+ }
18
+ const { promise, resolve, reject } = Promise.withResolvers();
19
+ this.settle(false);
20
+ if (on && this.supported) {
21
+ this.state = { kind: "pending", promise, resolve };
22
+ }
23
+ try {
13
24
  if (this.supported) {
14
25
  writeMessage(socket(), { type: on ? "park" : "unpark" });
15
26
  }
16
- if (this.acknowledged === undefined) {
27
+ if (this.state.kind === "idle") {
17
28
  resolve(false);
18
29
  }
19
- });
30
+ }
31
+ catch (error) {
32
+ this.state = { kind: "idle" };
33
+ reject(error);
34
+ }
35
+ return promise;
20
36
  }
21
37
  }
@@ -18,8 +18,7 @@ export declare class IntercomClient extends EventEmitter {
18
18
  private readonly lastRequests;
19
19
  private readonly pendingSends;
20
20
  private readonly pendingLists;
21
- private connecting;
22
- private disconnecting;
21
+ private phase;
23
22
  private readonly sendTimeoutMs;
24
23
  private readonly listTimeoutMs;
25
24
  constructor(options?: IntercomClientOptions);
@@ -30,6 +29,7 @@ export declare class IntercomClient extends EventEmitter {
30
29
  isConnected(): boolean;
31
30
  private requireActiveSocket;
32
31
  connect(session: Omit<SessionInfo, "id">, requestedId?: string): Promise<void>;
32
+ private establish;
33
33
  private restoreTopics;
34
34
  private handleBrokerMessage;
35
35
  private receiveRegistration;
@@ -39,6 +39,7 @@ export declare class IntercomClient extends EventEmitter {
39
39
  private receiveSessions;
40
40
  park(on?: boolean): Promise<boolean>;
41
41
  disconnect(): Promise<void>;
42
+ private close;
42
43
  listSessions(): Promise<SessionInfo[]>;
43
44
  last(to: string, query?: LastMessageQuery): Promise<LastMessageResult>;
44
45
  receipt(to: string, query: ReceiptQuery): Promise<ReceiptResult>;
@@ -39,8 +39,7 @@ export class IntercomClient extends EventEmitter {
39
39
  lastRequests;
40
40
  pendingSends = new PendingRequests();
41
41
  pendingLists = new PendingRequests();
42
- connecting = false;
43
- disconnecting = false;
42
+ phase = { kind: "idle" };
44
43
  sendTimeoutMs;
45
44
  listTimeoutMs;
46
45
  constructor(options = {}) {
@@ -65,11 +64,11 @@ export class IntercomClient extends EventEmitter {
65
64
  }
66
65
  isConnected() {
67
66
  return ((this._sessionId ?? "").length > 0 &&
68
- !this.disconnecting &&
67
+ this.phase.kind !== "closing" &&
69
68
  this.connection?.isActive() === true);
70
69
  }
71
70
  requireActiveSocket() {
72
- if (this.disconnecting) {
71
+ if (this.phase.kind === "closing") {
73
72
  throw new Error("Client disconnecting");
74
73
  }
75
74
  const connection = this.connection;
@@ -82,62 +81,72 @@ export class IntercomClient extends EventEmitter {
82
81
  return connection.socket;
83
82
  }
84
83
  async connect(session, requestedId) {
85
- if (this.connection || this.connecting) {
84
+ if (this.connection || this.phase.kind !== "idle") {
86
85
  throw new Error("Already connected");
87
86
  }
88
- this.connecting = true;
87
+ const task = this.establish(session, requestedId);
88
+ this.phase = { kind: "connecting", task };
89
89
  try {
90
- const socket = await connectBrokerSocket();
91
- const connection = new BrokerConnection(socket, {
92
- message: (message) => this.handleBrokerMessage(message),
93
- error: (error) => {
94
- this.emit("error", error);
95
- },
96
- closed: (error, expected) => {
97
- if (this.connection !== connection) {
98
- return;
99
- }
100
- const established = this._sessionId !== null;
101
- this.connection = null;
102
- this._sessionId = null;
103
- this._topicsSupported = false;
104
- this.parking.settle(false);
105
- this.guardedDeliverySupported = false;
106
- this.wakes.supported = false;
107
- this.disconnecting = false;
108
- this.failPending(error);
109
- if (established && !expected) {
110
- this.emit("disconnected", error);
111
- }
112
- },
113
- });
114
- this.connection = connection;
115
- const { topics, subscriptions, ...identity } = session;
116
- try {
117
- await connection.start({
118
- type: "register",
119
- session: identity,
120
- requestedId,
121
- topicFrames: true,
122
- lastMessages: true,
123
- parking: true,
124
- receiptReads: 1,
125
- guardedDelivery: 1,
126
- ...this.wakes.registration(),
127
- });
90
+ await task;
91
+ }
92
+ finally {
93
+ if (this.phase.kind === "connecting" && this.phase.task === task) {
94
+ this.phase = { kind: this.isConnected() ? "connected" : "idle" };
128
95
  }
129
- catch (error) {
130
- if (this.connection === connection) {
131
- 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;
132
108
  }
133
- throw error;
134
- }
135
- if (this.supportsTopics) {
136
- await this.restoreTopics(topics, subscriptions);
137
- }
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");
138
129
  }
139
- finally {
140
- 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);
141
150
  }
142
151
  }
143
152
  async restoreTopics(topics, subscriptions) {
@@ -271,14 +280,31 @@ export class IntercomClient extends EventEmitter {
271
280
  park(on = true) {
272
281
  return this.parking.request(() => this.requireActiveSocket(), on);
273
282
  }
274
- async disconnect() {
275
- const connection = this.connection;
276
- if (!connection) {
277
- return;
278
- }
279
- 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) {
280
294
  this.failPending(new Error("Client disconnected"));
281
- 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
+ }
282
308
  }
283
309
  listSessions() {
284
310
  return this.requestSessions().then((snapshot) => [...snapshot.sessions]);
@@ -399,7 +425,7 @@ export class IntercomClient extends EventEmitter {
399
425
  });
400
426
  }
401
427
  updatePresence(updates) {
402
- if (this.disconnecting) {
428
+ if (this.phase.kind === "closing") {
403
429
  return;
404
430
  }
405
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,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
+ }