@selesai/code 0.13.38 → 0.13.40

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 (92) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/core/intercom-rpc.d.ts +135 -0
  3. package/dist/core/intercom-rpc.js +64 -0
  4. package/dist/core/release-rpc.d.ts +137 -0
  5. package/dist/core/release-rpc.js +77 -0
  6. package/dist/core/resource-loader.d.ts +3 -0
  7. package/dist/core/resource-loader.js +3 -0
  8. package/dist/core/subagent-rpc.d.ts +110 -0
  9. package/dist/core/subagent-rpc.js +60 -0
  10. package/dist/extensions/agent-browser.ts +9 -0
  11. package/dist/extensions/pi-graft/cli.test.ts +5 -5
  12. package/dist/extensions/pi-graft/cli.ts +10 -5
  13. package/dist/extensions/pi-graft/commands.test.ts +5 -5
  14. package/dist/extensions/pi-graft/graft.integration.test.ts +1 -1
  15. package/dist/extensions/pi-graft/lifecycle.test.ts +26 -26
  16. package/dist/extensions/pi-graft/prompt.test.ts +1 -1
  17. package/dist/extensions/pi-graft/state.test.ts +9 -9
  18. package/dist/extensions/pi-graft/test-support.ts +1 -1
  19. package/dist/extensions/pi-graft/tools.test.ts +1 -1
  20. package/dist/extensions/pi-intercom/README.md +15 -0
  21. package/dist/extensions/pi-intercom/broker/broker.ts +277 -13
  22. package/dist/extensions/pi-intercom/broker/client.ts +6 -2
  23. package/dist/extensions/pi-intercom/broker/mailbox-persistence.test.ts +523 -0
  24. package/dist/extensions/pi-intercom/broker/paths.test.ts +4 -4
  25. package/dist/extensions/pi-intercom/broker/paths.ts +4 -2
  26. package/dist/extensions/pi-intercom/broker/spawn.test.ts +1 -3
  27. package/dist/extensions/pi-intercom/extension-api.ts +10 -0
  28. package/dist/extensions/pi-intercom/index.ts +188 -8
  29. package/dist/extensions/pi-intercom/intercom.integration.test.ts +280 -0
  30. package/dist/extensions/pi-intercom/package.json +1 -1
  31. package/dist/extensions/pi-intercom/release-readiness.test.ts +116 -0
  32. package/dist/extensions/pi-intercom/release-readiness.ts +93 -0
  33. package/dist/extensions/pi-subagents/docs/extension-api.md +8 -0
  34. package/dist/extensions/pi-subagents/docs/observability.md +8 -0
  35. package/dist/extensions/pi-subagents/src/extension/index.ts +51 -1
  36. package/dist/extensions/pi-subagents/src/extension/observability.ts +164 -0
  37. package/dist/extensions/pi-subagents/src/extension/release-readiness.ts +367 -0
  38. package/dist/extensions/pi-subagents/src/extension/rpc.ts +8 -1
  39. package/dist/extensions/pi-subagents/src/integrations/pi-web-session-liveness.ts +7 -1
  40. package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +1 -44
  41. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +6 -0
  42. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +13 -3
  43. package/dist/extensions/pi-subagents/src/runs/background/result-delivery-ownership.ts +269 -7
  44. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +11 -10
  45. package/dist/extensions/pi-subagents/src/runs/background/retained-nested-route-tracker.ts +15 -3
  46. package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +64 -0
  47. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +69 -6
  48. package/dist/extensions/pi-subagents/src/runs/shared/subagent-control.ts +14 -1
  49. package/dist/extensions/pi-subagents/src/shared/owner-record.ts +107 -0
  50. package/dist/extensions/pi-subagents/src/shared/process-identity.ts +87 -0
  51. package/dist/extensions/pi-subagents/src/shared/types.ts +9 -0
  52. package/dist/extensions/pi-subagents/test/integration/async-attention-clearing.test.ts +191 -0
  53. package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +63 -0
  54. package/dist/extensions/pi-subagents/test/integration/stop-all-background.test.ts +142 -0
  55. package/dist/extensions/pi-subagents/test/support/fake-child-session.ts +8 -0
  56. package/dist/extensions/pi-subagents/test/unit/observability.test.ts +198 -0
  57. package/dist/extensions/pi-subagents/test/unit/process-identity.test.ts +75 -0
  58. package/dist/extensions/pi-subagents/test/unit/release-readiness.test.ts +715 -0
  59. package/dist/extensions/pi-subagents/test/unit/result-takeover.test.ts +332 -0
  60. package/dist/extensions/tokenin-onboarding.ts +142 -3
  61. package/dist/index.d.ts +6 -1
  62. package/dist/index.js +7 -0
  63. package/dist/main.d.ts +6 -0
  64. package/dist/main.js +1 -1
  65. package/dist/modes/index.d.ts +1 -1
  66. package/dist/modes/rpc/intercom-bridge.d.ts +14 -0
  67. package/dist/modes/rpc/intercom-bridge.js +75 -0
  68. package/dist/modes/rpc/intercom-bridge.test.d.ts +1 -0
  69. package/dist/modes/rpc/intercom-bridge.test.js +65 -0
  70. package/dist/modes/rpc/release-bridge.d.ts +35 -0
  71. package/dist/modes/rpc/release-bridge.js +159 -0
  72. package/dist/modes/rpc/release-bridge.test.d.ts +1 -0
  73. package/dist/modes/rpc/release-bridge.test.js +347 -0
  74. package/dist/modes/rpc/rpc-client.d.ts +34 -10
  75. package/dist/modes/rpc/rpc-client.js +45 -2
  76. package/dist/modes/rpc/rpc-intercom-transport.test.d.ts +1 -0
  77. package/dist/modes/rpc/rpc-intercom-transport.test.js +85 -0
  78. package/dist/modes/rpc/rpc-mode.d.ts +1 -13
  79. package/dist/modes/rpc/rpc-mode.js +239 -8
  80. package/dist/modes/rpc/rpc-release-transport.test.d.ts +1 -0
  81. package/dist/modes/rpc/rpc-release-transport.test.js +329 -0
  82. package/dist/modes/rpc/rpc-subagent-transport.test.d.ts +1 -0
  83. package/dist/modes/rpc/rpc-subagent-transport.test.js +60 -0
  84. package/dist/modes/rpc/rpc-types.d.ts +12 -2
  85. package/dist/modes/rpc/rpc-types.js +0 -6
  86. package/dist/modes/rpc/subagent-bridge.d.ts +23 -0
  87. package/dist/modes/rpc/subagent-bridge.js +93 -0
  88. package/dist/modes/rpc/test-fixtures/intercom-host.d.ts +1 -0
  89. package/dist/modes/rpc/test-fixtures/intercom-host.js +13 -0
  90. package/docs/rpc-subagent.schema.json +491 -0
  91. package/docs/rpc.md +210 -1
  92. package/package.json +2 -1
@@ -1,9 +1,9 @@
1
1
  import net from "net";
2
- import { chmodSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
2
+ import { chmodSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync, unlinkSync } from "fs";
3
3
  import { join } from "path";
4
- import { createHash, randomUUID } from "crypto";
4
+ import { createHash, randomBytes, randomUUID } from "crypto";
5
5
  import { writeMessage, createMessageReader } from "./framing.ts";
6
- import { isMessage, isMessageReceipt, isSessionId, isSessionRegistration } from "./protocol.ts";
6
+ import { isMessage, isMessageReceipt, isSessionId, isSessionInfo, isSessionRegistration } from "./protocol.ts";
7
7
  import {
8
8
  ensureIntercomRuntimeDir,
9
9
  getBrokerListenTarget,
@@ -28,6 +28,10 @@ const LISTEN_TARGET = getBrokerListenTarget();
28
28
  const PID_PATH = join(INTERCOM_DIR, "broker.pid");
29
29
  const PORT_PATH = getBrokerPortFilePath(INTERCOM_DIR);
30
30
  const PENDING_ASKS_DIR = join(INTERCOM_DIR, "pending-asks");
31
+ const MAILBOX_DIR = join(INTERCOM_DIR, "mailbox");
32
+ const DISCONNECTED_SESSIONS_DIR = join(INTERCOM_DIR, "disconnected-sessions");
33
+ const PERSISTED_RECORD_VERSION = 1;
34
+ const MAX_PERSISTED_ID_FILENAME_CHARS = 100;
31
35
  const BROKER_STATE_ID = randomUUID();
32
36
  const MAX_SESSIONS = 128;
33
37
  const MAX_UNREGISTERED_CONNECTIONS = 32;
@@ -199,12 +203,113 @@ function ensurePendingAskRecordDir(): void {
199
203
  }
200
204
  }
201
205
 
206
+ function sha256Hex(value: string): string {
207
+ return createHash("sha256").update(value).digest("hex");
208
+ }
209
+
210
+ // One file per queued message. The sender key is part of the name because
211
+ // message ids are only unique per sender (delivery records key on both).
212
+ function mailboxFilePath(scopeId: string | undefined, fromKey: string, messageId: string): string {
213
+ const scopePart = scopeId ? sha256Hex(scopeId).slice(0, 16) : "global";
214
+ const encodedId = encodeURIComponent(messageId);
215
+ const idPart = encodedId.length <= MAX_PERSISTED_ID_FILENAME_CHARS ? encodedId : sha256Hex(messageId);
216
+ return join(MAILBOX_DIR, `${scopePart}-${sha256Hex(fromKey).slice(0, 16)}-${idPart}.json`);
217
+ }
218
+
219
+ function disconnectedSessionFilePath(sessionKey: string): string {
220
+ return join(DISCONNECTED_SESSIONS_DIR, `${sha256Hex(sessionKey)}.json`);
221
+ }
222
+
223
+ function ensureRuntimeDir(dirPath: string): void {
224
+ mkdirSync(dirPath, { recursive: true, mode: INTERCOM_DIR_MODE });
225
+ if (process.platform !== "win32") {
226
+ chmodSync(dirPath, INTERCOM_DIR_MODE);
227
+ }
228
+ }
229
+
230
+ // Temp file + rename so a crash mid-write never leaves a half-written record.
231
+ // Temp names end in .tmp (never .json) so loaders ignore and sweep them.
232
+ function writeFileAtomic(filePath: string, content: string): void {
233
+ const tempPath = `${filePath}.${randomBytes(6).toString("hex")}.tmp`;
234
+ try {
235
+ writeFileSync(tempPath, content, { mode: INTERCOM_RUNTIME_FILE_MODE });
236
+ restrictIntercomRuntimeFile(tempPath);
237
+ renameSync(tempPath, filePath);
238
+ } catch (error) {
239
+ try {
240
+ unlinkSync(tempPath);
241
+ } catch {
242
+ // The temp file may never have been created.
243
+ }
244
+ throw error;
245
+ }
246
+ }
247
+
248
+ function isOptionalString(value: unknown): value is string | undefined {
249
+ return value === undefined || typeof value === "string";
250
+ }
251
+
252
+ function parseMailboxRecord(value: unknown): MailboxMessage | null {
253
+ if (!isRecord(value) || value.version !== PERSISTED_RECORD_VERSION) {
254
+ return null;
255
+ }
256
+ if (
257
+ !isSessionInfo(value.from)
258
+ || typeof value.fromKey !== "string"
259
+ || !isOptionalString(value.fromScopeId)
260
+ || !isSessionInfo(value.target)
261
+ || typeof value.targetKey !== "string"
262
+ || !isOptionalString(value.targetScopeId)
263
+ || !isMessage(value.message)
264
+ || !Number.isSafeInteger(value.queuedAt)
265
+ ) {
266
+ return null;
267
+ }
268
+ // Blocking asks are never queued; a record claiming otherwise is not ours.
269
+ if (value.message.expectsReply) {
270
+ return null;
271
+ }
272
+ return {
273
+ from: value.from,
274
+ fromKey: value.fromKey,
275
+ ...(value.fromScopeId ? { fromScopeId: value.fromScopeId } : {}),
276
+ target: value.target,
277
+ targetKey: value.targetKey,
278
+ ...(value.targetScopeId ? { targetScopeId: value.targetScopeId } : {}),
279
+ message: value.message,
280
+ queuedAt: value.queuedAt as number,
281
+ };
282
+ }
283
+
284
+ function parseDisconnectedSessionRecord(value: unknown): DisconnectedSession | null {
285
+ if (!isRecord(value) || value.version !== PERSISTED_RECORD_VERSION) {
286
+ return null;
287
+ }
288
+ if (
289
+ typeof value.key !== "string"
290
+ || !isSessionInfo(value.info)
291
+ || !isOptionalString(value.scopeId)
292
+ || !Number.isSafeInteger(value.disconnectedAt)
293
+ ) {
294
+ return null;
295
+ }
296
+ return {
297
+ info: value.info,
298
+ key: value.key,
299
+ ...(value.scopeId ? { scopeId: value.scopeId } : {}),
300
+ disconnectedAt: value.disconnectedAt as number,
301
+ };
302
+ }
303
+
202
304
  class IntercomBroker {
203
305
  private sessions = new Map<string, ConnectedSession>();
204
306
  private askEdges = new Map<string, AskEdge>();
205
307
  private messageReceiptRoutes = new Map<string, MessageReceiptRoute>();
206
308
  private disconnectedSessions = new Map<string, DisconnectedSession>();
207
309
  private mailboxMessages: MailboxMessage[] = [];
310
+ // Where each queued message is persisted, so removal deletes the exact file
311
+ // it was loaded from or written to.
312
+ private mailboxFilePaths = new WeakMap<MailboxMessage, string>();
208
313
  private deliveryRecords = new Map<string, DeliveryRecord>();
209
314
  private connections = new Set<net.Socket>();
210
315
  private unregisteredConnections = new Set<net.Socket>();
@@ -220,6 +325,8 @@ class IntercomBroker {
220
325
  assertNoLiveBroker(PID_PATH);
221
326
  ensurePendingAskRecordDir();
222
327
  this.prunePendingAskRecords();
328
+ this.loadDisconnectedSessions();
329
+ this.loadMailboxMessages();
223
330
  this.extensionStateManager = new ExtensionStateManager(INTERCOM_DIR);
224
331
  if (typeof LISTEN_TARGET === "string" && process.platform !== "win32") {
225
332
  try {
@@ -486,7 +593,7 @@ class IntercomBroker {
486
593
  extensions,
487
594
  };
488
595
  this.sessions.set(key, connectedSession);
489
- this.disconnectedSessions.delete(key);
596
+ this.forgetDisconnectedSession(key);
490
597
 
491
598
  if (this.shutdownTimer) {
492
599
  clearTimeout(this.shutdownTimer);
@@ -819,7 +926,8 @@ class IntercomBroker {
819
926
  const sender = this.sessions.get(currentKey);
820
927
  const queuedIndex = this.mailboxMessages.findIndex(entry => entry.message.id === clientMessage.messageId && entry.fromKey === currentKey);
821
928
  if (queuedIndex >= 0 && sender?.socket === socket) {
822
- this.mailboxMessages.splice(queuedIndex, 1);
929
+ const [cancelled] = this.mailboxMessages.splice(queuedIndex, 1);
930
+ this.removeMailboxFile(cancelled!);
823
931
  this.updateDeliveryRecord(currentKey, clientMessage.messageId, "failed", "Sender cancelled the queued delivery", "E_DELIVERY_CANCELLED");
824
932
  const edge = this.askEdges.get(clientMessage.messageId);
825
933
  if (edge?.from === currentKey) {
@@ -976,23 +1084,156 @@ class IntercomBroker {
976
1084
  }
977
1085
 
978
1086
  private rememberDisconnectedSession(session: ConnectedSession, now = Date.now()): void {
979
- this.disconnectedSessions.set(session.key, {
1087
+ const remembered: DisconnectedSession = {
980
1088
  info: { ...session.info },
981
1089
  key: session.key,
982
1090
  ...(session.scopeId ? { scopeId: session.scopeId } : {}),
983
1091
  disconnectedAt: now,
984
- });
1092
+ };
1093
+ this.disconnectedSessions.set(session.key, remembered);
1094
+ this.persistDisconnectedSession(remembered);
985
1095
  this.pruneDisconnectedSessions(now);
986
1096
  }
987
1097
 
1098
+ private forgetDisconnectedSession(sessionKey: string): void {
1099
+ if (this.disconnectedSessions.delete(sessionKey)) {
1100
+ this.removePersistedFile(disconnectedSessionFilePath(sessionKey), "disconnected session");
1101
+ }
1102
+ }
1103
+
988
1104
  private pruneDisconnectedSessions(now = Date.now()): void {
989
1105
  for (const [sessionId, session] of this.disconnectedSessions) {
990
1106
  if (now - session.disconnectedAt > DISCONNECTED_SESSION_RETENTION_MS) {
991
- this.disconnectedSessions.delete(sessionId);
1107
+ this.forgetDisconnectedSession(sessionId);
1108
+ }
1109
+ }
1110
+ }
1111
+
1112
+ // Persistence is best effort: a full disk or bad permissions must never break
1113
+ // live delivery, so failures are logged and the broker keeps going in memory.
1114
+ private persistDisconnectedSession(session: DisconnectedSession): void {
1115
+ try {
1116
+ mkdirSync(DISCONNECTED_SESSIONS_DIR, { recursive: true, mode: INTERCOM_DIR_MODE });
1117
+ const record = { version: PERSISTED_RECORD_VERSION, ...session };
1118
+ writeFileAtomic(disconnectedSessionFilePath(session.key), `${JSON.stringify(record, null, 2)}\n`);
1119
+ } catch (error) {
1120
+ console.error("Failed to persist disconnected intercom session:", error);
1121
+ }
1122
+ }
1123
+
1124
+ private persistMailboxMessage(entry: MailboxMessage): void {
1125
+ try {
1126
+ mkdirSync(MAILBOX_DIR, { recursive: true, mode: INTERCOM_DIR_MODE });
1127
+ const filePath = mailboxFilePath(entry.fromScopeId, entry.fromKey, entry.message.id);
1128
+ const record = { version: PERSISTED_RECORD_VERSION, ...entry };
1129
+ writeFileAtomic(filePath, `${JSON.stringify(record, null, 2)}\n`);
1130
+ this.mailboxFilePaths.set(entry, filePath);
1131
+ } catch (error) {
1132
+ console.error("Failed to persist intercom mailbox message:", error);
1133
+ }
1134
+ }
1135
+
1136
+ /** Call at every place an entry leaves `mailboxMessages` (never on shutdown). */
1137
+ private removeMailboxFile(entry: MailboxMessage): void {
1138
+ const filePath = this.mailboxFilePaths.get(entry) ?? mailboxFilePath(entry.fromScopeId, entry.fromKey, entry.message.id);
1139
+ this.mailboxFilePaths.delete(entry);
1140
+ this.removePersistedFile(filePath, "mailbox message");
1141
+ }
1142
+
1143
+ private removePersistedFile(filePath: string, label: string): void {
1144
+ try {
1145
+ unlinkSync(filePath);
1146
+ } catch (error) {
1147
+ if (!isRecord(error) || error.code !== "ENOENT") {
1148
+ console.error(`Failed to remove persisted intercom ${label}:`, error);
992
1149
  }
993
1150
  }
994
1151
  }
995
1152
 
1153
+ /**
1154
+ * Reads every `*.json` record in `dirPath`, deleting corrupt, invalid and
1155
+ * (via `isExpired`) stale files. Leftover `*.tmp` files from an interrupted
1156
+ * atomic write are swept. Never throws: an unreadable directory just means
1157
+ * nothing is restored.
1158
+ */
1159
+ private loadPersistedRecords<T>(
1160
+ dirPath: string,
1161
+ label: string,
1162
+ parse: (value: unknown) => T | null,
1163
+ isExpired: (record: T) => boolean,
1164
+ ): Array<{ record: T; filePath: string }> {
1165
+ const loaded: Array<{ record: T; filePath: string }> = [];
1166
+ try {
1167
+ ensureRuntimeDir(dirPath);
1168
+ for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
1169
+ if (!entry.isFile()) {
1170
+ continue;
1171
+ }
1172
+ const filePath = join(dirPath, entry.name);
1173
+ if (entry.name.endsWith(".tmp")) {
1174
+ this.removePersistedFile(filePath, label);
1175
+ continue;
1176
+ }
1177
+ if (!entry.name.endsWith(".json")) {
1178
+ continue;
1179
+ }
1180
+ let parsed: unknown;
1181
+ try {
1182
+ parsed = JSON.parse(readFileSync(filePath, "utf-8"));
1183
+ } catch {
1184
+ this.removePersistedFile(filePath, label);
1185
+ continue;
1186
+ }
1187
+ const record = parse(parsed);
1188
+ if (!record || isExpired(record)) {
1189
+ this.removePersistedFile(filePath, label);
1190
+ continue;
1191
+ }
1192
+ loaded.push({ record, filePath });
1193
+ }
1194
+ } catch (error) {
1195
+ console.error(`Failed to load persisted intercom ${label}s:`, error);
1196
+ }
1197
+ return loaded;
1198
+ }
1199
+
1200
+ private loadDisconnectedSessions(now = Date.now()): void {
1201
+ const loaded = this.loadPersistedRecords(
1202
+ DISCONNECTED_SESSIONS_DIR,
1203
+ "disconnected session",
1204
+ parseDisconnectedSessionRecord,
1205
+ (session) => now - session.disconnectedAt > DISCONNECTED_SESSION_RETENTION_MS,
1206
+ );
1207
+ for (const { record, filePath } of loaded) {
1208
+ const existing = this.disconnectedSessions.get(record.key);
1209
+ if (existing && existing.disconnectedAt >= record.disconnectedAt) {
1210
+ this.removePersistedFile(filePath, "disconnected session");
1211
+ continue;
1212
+ }
1213
+ this.disconnectedSessions.set(record.key, record);
1214
+ }
1215
+ }
1216
+
1217
+ private loadMailboxMessages(now = Date.now()): void {
1218
+ const loaded = this.loadPersistedRecords(
1219
+ MAILBOX_DIR,
1220
+ "mailbox message",
1221
+ parseMailboxRecord,
1222
+ (entry) => now - entry.queuedAt > MAILBOX_MESSAGE_RETENTION_MS,
1223
+ );
1224
+ // Array.prototype.sort is stable, so equal timestamps keep directory order.
1225
+ loaded.sort((a, b) => a.record.queuedAt - b.record.queuedAt);
1226
+ // Same bound as live queueing: keep the newest, drop the oldest.
1227
+ const overflow = Math.max(0, loaded.length - MAX_MAILBOX_MESSAGES);
1228
+ for (const { filePath } of loaded.slice(0, overflow)) {
1229
+ this.removePersistedFile(filePath, "mailbox message");
1230
+ }
1231
+ for (const { record, filePath } of loaded.slice(overflow)) {
1232
+ this.mailboxFilePaths.set(record, filePath);
1233
+ this.mailboxMessages.push(record);
1234
+ }
1235
+ }
1236
+
996
1237
  private pruneMailboxMessages(now = Date.now()): void {
997
1238
  for (let index = this.mailboxMessages.length - 1; index >= 0; index -= 1) {
998
1239
  const entry = this.mailboxMessages[index]!;
@@ -1004,6 +1245,7 @@ class IntercomBroker {
1004
1245
  this.messageReceiptRoutes.delete(entry.message.id);
1005
1246
  this.updateDeliveryRecord(entry.fromKey, entry.message.id, "failed", "Mailbox delivery expired", "E_DELIVERY_EXPIRED");
1006
1247
  this.mailboxMessages.splice(index, 1);
1248
+ this.removeMailboxFile(entry);
1007
1249
  }
1008
1250
  }
1009
1251
  }
@@ -1013,6 +1255,7 @@ class IntercomBroker {
1013
1255
  while (this.mailboxMessages.length >= MAX_MAILBOX_MESSAGES) {
1014
1256
  const evicted = this.mailboxMessages.shift();
1015
1257
  if (!evicted) break;
1258
+ this.removeMailboxFile(evicted);
1016
1259
  if (evicted.message.expectsReply) {
1017
1260
  this.askEdges.delete(evicted.message.id);
1018
1261
  this.removePendingAskRecord(evicted.message.id, evicted.fromScopeId);
@@ -1020,7 +1263,7 @@ class IntercomBroker {
1020
1263
  this.messageReceiptRoutes.delete(evicted.message.id);
1021
1264
  this.updateDeliveryRecord(evicted.fromKey, evicted.message.id, "failed", "Mailbox capacity evicted the delivery", "E_DELIVERY_EVICTED");
1022
1265
  }
1023
- this.mailboxMessages.push({
1266
+ const queued: MailboxMessage = {
1024
1267
  from: { ...from.info },
1025
1268
  fromKey: from.key,
1026
1269
  ...(from.scopeId ? { fromScopeId: from.scopeId } : {}),
@@ -1029,7 +1272,9 @@ class IntercomBroker {
1029
1272
  ...(target.scopeId ? { targetScopeId: target.scopeId } : {}),
1030
1273
  message: { ...message, brokerReceivedAt },
1031
1274
  queuedAt: brokerReceivedAt,
1032
- });
1275
+ };
1276
+ this.mailboxMessages.push(queued);
1277
+ this.persistMailboxMessage(queued);
1033
1278
  }
1034
1279
 
1035
1280
  private writeDeliverySuccess(socket: net.Socket, messageId: string, delivery: "socket_delivered" | "queued"): void {
@@ -1141,6 +1386,9 @@ class IntercomBroker {
1141
1386
  }
1142
1387
 
1143
1388
  this.mailboxMessages.splice(index, 1);
1389
+ // Drop the file before the socket write: a failed write already loses the
1390
+ // in-memory entry, so keeping the file would only risk a duplicate later.
1391
+ this.removeMailboxFile(entry);
1144
1392
  const edge = this.askEdges.get(entry.message.id);
1145
1393
  if (edge?.to === entry.targetKey) {
1146
1394
  edge.to = session.key;
@@ -1173,11 +1421,25 @@ class IntercomBroker {
1173
1421
  }
1174
1422
  }
1175
1423
 
1176
- private clearAskEdgesForSession(sessionKey: string): void {
1424
+ /**
1425
+ * Intentionally NOT called on disconnect/unregister: edges age out via
1426
+ * `pruneAskEdges`, and a late reply to a disconnected asker is mailboxed on
1427
+ * purpose so a resumed asker (or target) still receives it.
1428
+ * `asker`: drop asks this session was waiting on. `target`: drop asks
1429
+ * addressed to this session.
1430
+ */
1431
+ private clearAskEdgesForSession(sessionKey: string, role: "asker" | "target" | "either"): void {
1177
1432
  for (const [messageId, edge] of this.askEdges) {
1178
- if (edge.from === sessionKey || edge.to === sessionKey) {
1179
- this.askEdges.delete(messageId);
1433
+ const matches = (role !== "target" && edge.from === sessionKey) || (role !== "asker" && edge.to === sessionKey);
1434
+ if (!matches) {
1435
+ continue;
1436
+ }
1437
+ this.askEdges.delete(messageId);
1438
+ try {
1180
1439
  this.removePendingAskRecord(messageId, edge.scopeId);
1440
+ } catch (error) {
1441
+ // Disconnect cleanup must not throw out of a socket event handler.
1442
+ console.error("Failed to remove pending ask record:", error);
1181
1443
  }
1182
1444
  }
1183
1445
  }
@@ -1694,6 +1956,8 @@ class IntercomBroker {
1694
1956
  this.sessions.clear();
1695
1957
  this.askEdges.clear();
1696
1958
  this.messageReceiptRoutes.clear();
1959
+ // Only the in-memory copies are cleared. The persisted mailbox and
1960
+ // disconnected-session files are kept on purpose: the next broker reloads them.
1697
1961
  this.disconnectedSessions.clear();
1698
1962
  this.mailboxMessages.length = 0;
1699
1963
  if (typeof LISTEN_TARGET === "string" && process.platform !== "win32") {
@@ -383,7 +383,9 @@ export class IntercomClient extends EventEmitter {
383
383
  }
384
384
 
385
385
  this.pendingSends.delete(messageId);
386
- pending.resolve({ id: messageId, delivered: true, delivery: delivery as "socket_delivered" | "queued" | undefined ?? "socket_delivered", retryable: retryable as boolean | undefined ?? false, outcomeKnown: outcomeKnown as boolean | undefined ?? true, ...(typeof brokerMessage.code === "string" ? { code: brokerMessage.code } : {}) });
386
+ const result: SendResult = { id: messageId, delivered: true, delivery: delivery as "socket_delivered" | "queued" | undefined ?? "socket_delivered", retryable: retryable as boolean | undefined ?? false, outcomeKnown: outcomeKnown as boolean | undefined ?? true, ...(typeof brokerMessage.code === "string" ? { code: brokerMessage.code } : {}) };
387
+ pending.resolve(result);
388
+ this.emit("broker_message", { type: "delivered", messageId, delivery: result.delivery, retryable: result.retryable, outcomeKnown: result.outcomeKnown, ...(result.code ? { code: result.code } : {}) });
387
389
  break;
388
390
  }
389
391
 
@@ -400,7 +402,9 @@ export class IntercomClient extends EventEmitter {
400
402
  }
401
403
 
402
404
  this.pendingSends.delete(messageId);
403
- pending.resolve({ id: messageId, delivered: false, reason, delivery: delivery as "failed" | "unknown" | undefined ?? "failed", retryable: retryable as boolean | undefined ?? false, outcomeKnown: outcomeKnown as boolean | undefined ?? true, ...(typeof brokerMessage.code === "string" ? { code: brokerMessage.code } : {}) });
405
+ const result: SendResult = { id: messageId, delivered: false, reason, delivery: delivery as "failed" | "unknown" | undefined ?? "failed", retryable: retryable as boolean | undefined ?? false, outcomeKnown: outcomeKnown as boolean | undefined ?? true, ...(typeof brokerMessage.code === "string" ? { code: brokerMessage.code } : {}) };
406
+ pending.resolve(result);
407
+ this.emit("broker_message", { type: "delivery_failed", messageId, reason, delivery: result.delivery, retryable: result.retryable, outcomeKnown: result.outcomeKnown, ...(result.code ? { code: result.code } : {}) });
404
408
  break;
405
409
  }
406
410