baychat 0.13.0 → 0.14.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.
@@ -36,10 +36,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.RelayDaemon = void 0;
37
37
  const fs = __importStar(require("fs"));
38
38
  const net = __importStar(require("net"));
39
+ const path = __importStar(require("path"));
39
40
  const config_1 = require("../config");
41
+ const runtime_binary_1 = require("../runtime-binary");
40
42
  const adapters_1 = require("./adapters");
41
43
  const queue_1 = require("./queue");
42
44
  const registry_1 = require("./registry");
45
+ const mailbox_1 = require("./mailbox");
46
+ const mailbox_watcher_1 = require("./mailbox-watcher");
43
47
  const socket_1 = require("./socket");
44
48
  const transport_1 = require("./transport");
45
49
  const watermarks_1 = require("./watermarks");
@@ -57,6 +61,9 @@ class RelayDaemon {
57
61
  abort = new AbortController();
58
62
  /** Live attach sockets by session name. */
59
63
  attached = new Map();
64
+ /** Sessions reachable over a mailbox FIFO, by session name. */
65
+ mailboxes = new Map();
66
+ mailboxWatchers;
60
67
  /** Per (session, conversation) delivery positions — the 409 catch-up baseline. */
61
68
  watermarks = new watermarks_1.Watermarks();
62
69
  pending = [];
@@ -75,11 +82,58 @@ class RelayDaemon {
75
82
  discoveryReasons = new Map();
76
83
  discovery;
77
84
  spawnHeadless;
85
+ resolveBinary;
86
+ runTurn;
87
+ /**
88
+ * Resolutions already proven this process, by runtime binary name.
89
+ *
90
+ * Resolution costs a `--version` spawn per candidate, which must not be paid
91
+ * per message. The entry is dropped whenever a headless turn fails, so a user
92
+ * who installs the missing runtime is believed on the next message rather
93
+ * than after a relay restart nothing would have told them to perform.
94
+ */
95
+ binaries = new Map();
78
96
  log;
79
97
  constructor(opts = {}) {
80
98
  this.log = opts.log ?? ((line) => console.log(`[relay] ${line}`));
99
+ // EVERY candidate root, because the daemon cannot know which one the agent's
100
+ // sandbox turned out to permit — it may have fallen back past the one this
101
+ // process would have chosen.
102
+ this.mailboxWatchers = (0, mailbox_1.mailboxRootCandidates)().map((root) => new mailbox_watcher_1.MailboxWatcher({
103
+ root,
104
+ onRegister: (reg) => {
105
+ this.registry.upsert((0, mailbox_1.registrationToTarget)(reg));
106
+ // A session that names itself supersedes anything discovery guessed,
107
+ // exactly as an attach frame does.
108
+ if (reg.resumeId)
109
+ this.discoveryReasons.delete(reg.session);
110
+ // The daemon makes the FIFO, because the agent cannot: creating one
111
+ // needs `mkfifo(1)` spawned, and a sandbox that lets an agent write
112
+ // files still refuses it a helper process. This side has the privilege,
113
+ // so this side does the privileged step.
114
+ try {
115
+ // ENSURE, never recreate: a re-arm must not unlink the FIFO a live
116
+ // reader is already blocked on. See `ensureWakeFifo`.
117
+ (0, mailbox_1.ensureWakeFifo)(path.dirname(reg.fifo));
118
+ }
119
+ catch (err) {
120
+ this.log(`could not create the wake FIFO for ${reg.session}: ${errText(err)}`);
121
+ return;
122
+ }
123
+ this.mailboxes.set(reg.session, reg);
124
+ this.registry.setAttached(reg.session, true);
125
+ this.log(`attached via mailbox: ${reg.session} (${reg.runtime}) at ${reg.fifo}`);
126
+ },
127
+ onDrop: (session, reason) => {
128
+ this.mailboxes.delete(session);
129
+ this.registry.setAttached(session, false);
130
+ this.log(`detached: ${session} — ${reason}`);
131
+ },
132
+ }));
81
133
  this.discovery = opts.discovery ?? {};
82
134
  this.spawnHeadless = opts.spawnHeadless ?? adapters_1.runHeadless;
135
+ this.resolveBinary = opts.resolveBinary ?? ((name) => (0, runtime_binary_1.resolveRuntimeBinary)(name, (0, runtime_binary_1.currentBinaryEnv)()));
136
+ this.runTurn = opts.runTurn;
83
137
  this.queue = new queue_1.SessionQueue((session, batch) => this.deliver(session, batch), (session, err) => {
84
138
  this.lastError = `delivery failed for ${session}: ${errText(err)}`;
85
139
  this.log(this.lastError);
@@ -104,6 +158,10 @@ class RelayDaemon {
104
158
  throw new Error(`a relay is already listening on ${sockPath} — run \`baychat relay status\``);
105
159
  }
106
160
  (0, socket_1.unlinkStaleSocket)(sockPath);
161
+ // Sandboxed agents cannot reach the socket below, so the mailbox has to be
162
+ // watched from the moment the daemon is up — not lazily on first delivery.
163
+ for (const w of this.mailboxWatchers)
164
+ w.start();
107
165
  this.server = net.createServer((sock) => this.onConnection(sock));
108
166
  await new Promise((resolve, reject) => {
109
167
  this.server.once("error", reject);
@@ -189,6 +247,26 @@ class RelayDaemon {
189
247
  async deliver(session, batch) {
190
248
  const target = this.registry.get(session);
191
249
  if (!target) {
250
+ // NOT reported as pending, though an earlier version of this did.
251
+ //
252
+ // `liveSessions` lists every session of the USER, not of this machine. A
253
+ // person with WSL and Windows side by side has BOTH relays receiving
254
+ // events for BOTH machines' sessions, because the server fans out per
255
+ // credential and a session's host is not something it records. Treating
256
+ // "live for this user" as "ours" made each relay report DELIVERY PENDING
257
+ // for sessions the other machine was serving perfectly well — and a false
258
+ // pending is worse than none, because the entire value of that signal is
259
+ // that it means something is genuinely wrong.
260
+ //
261
+ // A session in the registry cannot reach here at all (`target` would be
262
+ // set), so there is no local evidence to appeal to: this name has never
263
+ // been seen on this machine. Another device is very likely handling it,
264
+ // and silence is the honest answer.
265
+ //
266
+ // The genuinely uncovered case — a session that joined a room and never
267
+ // attached ANYWHERE — is per-machine, not per-message, and belongs in
268
+ // `doctor`, which can compare the Bay's live sessions against this
269
+ // machine's registry without ever firing a false alarm.
192
270
  this.record({ kind: "ignored", reason: `unknown session ${session}` }, session, batch);
193
271
  return;
194
272
  }
@@ -202,10 +280,98 @@ class RelayDaemon {
202
280
  return;
203
281
  }
204
282
  const adapter = (0, adapters_1.adapterFor)(target.runtime);
283
+ // ORDERED ABOVE THE FIFO, deliberately.
284
+ //
285
+ // A FIFO hands bytes to a blocked `relay attach`, which prints them and
286
+ // exits. Where the harness re-invokes on that exit (Claude) it is a wake;
287
+ // for an interactive TUI it is only a PRINT — the text appears and nothing
288
+ // makes the agent act on it. Measured 2026-08-31 01:08: delivered via fifo
289
+ // to a live Codex, which then said nothing.
290
+ //
291
+ // `codex queue` puts the message in the session's own turn queue, so the
292
+ // agent ACTS. Prefer the transport that produces an ANSWER over the one
293
+ // that only produces a delivery.
294
+ // RUNG 2: the runtime's OWN inter-session queue, where it has one.
295
+ //
296
+ // Preferred over the headless spawn below because it reaches the LIVE
297
+ // session, where the human already is — so approvals work normally and the
298
+ // relay never acquires a privilege on a chat message's behalf. The headless
299
+ // rung must run `approvalPolicy: "never"`, which blocks Codex's own BayChat
300
+ // write path: observed 2026-08-31, a headlessly-woken Codex read the room
301
+ // and was structurally unable to answer it.
302
+ const resolvedForQueue = await this.resolveResume(target);
303
+ if (adapter.queueMessage && resolvedForQueue.resumeId) {
304
+ // Same rule as the headless spawn: an ABSOLUTE runtimeBin came from the
305
+ // session itself and is never re-resolved; only a bare name is looked up.
306
+ const declared = resolvedForQueue.runtimeBin ?? target.runtime;
307
+ let queueBin = declared;
308
+ if (!path.isAbsolute(declared)) {
309
+ const resolved = this.binaryFor(declared);
310
+ queueBin = resolved.ok ? resolved.path : "";
311
+ }
312
+ if (!queueBin) {
313
+ this.log(`queue unavailable for ${session}: no usable ${target.runtime} binary`);
314
+ }
315
+ else {
316
+ const outcome = await adapter.queueMessage({
317
+ binaryPath: queueBin,
318
+ target: resolvedForQueue,
319
+ message: (0, adapters_1.buildWakePrompt)(session, batch[0].conversationId, batch, undefined),
320
+ });
321
+ if (outcome.kind === "queued") {
322
+ this.record({ kind: "woken", via: "queue", session }, session, batch);
323
+ return;
324
+ }
325
+ // EVERY non-success falls through, including `failed`.
326
+ //
327
+ // The queue is an OPTIMISATION, not a gate: it reaches the live session so
328
+ // approvals work, but the headless rung below is strictly more capable —
329
+ // it carries the Windows `.cmd` spawn plan, the app-server transport, and
330
+ // the binary resolution this path does not replicate. A rung that could
331
+ // not deliver must never shadow one that might; that exact mistake made a
332
+ // stale FIFO swallow three messages earlier tonight, and it would be the
333
+ // same mistake to repeat here for a different reason.
334
+ this.log(`queue did not deliver for ${session} (${outcome.reason}) — falling through to headless`);
335
+ }
336
+ }
205
337
  // A target with no resume id is the unbounded failure this whole path
206
338
  // exists to close: without one, `canResume` says no and the message waits
207
339
  // for a human. Ask the runtime's own on-disk state who this session is
208
340
  // before accepting that answer.
341
+ // RUNG 3: a session whose sandbox refuses the socket, waiting on a FIFO.
342
+ //
343
+ // Ordered after the socket and before headless because it means the SAME
344
+ // thing the socket does — a live session is waiting — while headless means
345
+ // the opposite. `writeWake` never blocks: a FIFO write with no reader would
346
+ // otherwise stall this daemon on one detached agent and stop it serving
347
+ // every other session, which is worse than the bug this rung fixes.
348
+ const mailbox = this.mailboxes.get(session);
349
+ if (mailbox) {
350
+ const payload = `${JSON.stringify({ type: "wake", conversationId: batch[0].conversationId, messages: batch })}\n`;
351
+ const written = (0, mailbox_1.writeWake)(mailbox.fifo, payload);
352
+ if (written.ok) {
353
+ // The one rung where `delivered` is EVIDENCED rather than assumed: a
354
+ // FIFO write completes only when a reader takes the bytes.
355
+ this.record({ kind: "woken", via: "fifo", session }, session, batch);
356
+ return;
357
+ }
358
+ // NOBODY IS WAITING — so FALL THROUGH, do not stop here.
359
+ //
360
+ // A registration is not a listener. The daemon cannot tell a stale
361
+ // registration from a live one on this rung, because a sandboxed agent
362
+ // records a NAMESPACED pid that `processIsAlive` reports alive forever
363
+ // (spec §13). Measured 2026-08-31 00:04: a stale mailbox intercepted three
364
+ // deliveries in a row and recorded each `pending`, while the session had a
365
+ // perfectly good resume id and could have been woken headlessly the whole
366
+ // time. A dead rung was shadowing a working one.
367
+ //
368
+ // ENXIO is the only trustworthy evidence available here, and what it
369
+ // proves is exactly "no live session is waiting" — which is the condition
370
+ // the rung below exists for. So we take it.
371
+ this.log(`mailbox for ${session} has no reader (${written.reason}) — falling through to headless`);
372
+ this.mailboxes.delete(session);
373
+ this.registry.setAttached(session, false);
374
+ }
209
375
  const resolved = await this.resolveResume(target);
210
376
  const check = adapter.canResume(resolved);
211
377
  if (!check.ok) {
@@ -215,13 +381,75 @@ class RelayDaemon {
215
381
  this.record({ kind: "pending", session, reason }, session, batch);
216
382
  return;
217
383
  }
218
- const prompt = (0, adapters_1.buildWakePrompt)(session, batch[0].conversationId, batch);
384
+ // The daemon knows both absolute paths because it IS them — and a session it
385
+ // resumes inherits ITS environment, where neither `node` nor `baychat` is on
386
+ // PATH. Without this the woken session is told to re-arm with a command it
387
+ // cannot run, answers once, and goes quiet.
388
+ const prompt = (0, adapters_1.buildWakePrompt)(session, batch[0].conversationId, batch, {
389
+ node: process.execPath,
390
+ cli: process.argv[1] ?? "",
391
+ runtime: target.runtime,
392
+ // A session that last armed over a mailbox is sandboxed, and a sandboxed
393
+ // session cannot background its re-arm. Telling it to would hand it an
394
+ // instruction that silently does nothing.
395
+ transport: target.transport,
396
+ });
219
397
  const { file, args } = adapter.headlessCommand(resolved, prompt);
398
+ // An ABSOLUTE path came from `SessionTarget.runtimeBin` — recorded by
399
+ // `attach`, inside the session, because the binary a session was launched
400
+ // with is knowable there and nowhere else. Re-resolving it against the
401
+ // daemon's PATH is precisely the guess that rule exists to forbid, and
402
+ // doing so broke a live session: the path ended in `claude.exe`, PATH
403
+ // resolution was asked for a file of that name, and every wake died with
404
+ // "not found on PATH".
405
+ //
406
+ // A BARE NAME has no such provenance, and is where PATH gets it wrong —
407
+ // a Windows shim ahead of the real binary under WSL. That one is resolved.
408
+ let executable = file;
409
+ if (!path.isAbsolute(file)) {
410
+ const binary = this.binaryFor(file);
411
+ if (!binary.ok) {
412
+ this.record({ kind: "pending", session, reason: (0, runtime_binary_1.summarizeResolutionFailure)(binary) }, session, batch);
413
+ return;
414
+ }
415
+ executable = binary.path;
416
+ }
417
+ // A runtime with a richer transport than "spawn a command" gets to use it.
418
+ // Only a transport that could not be used AT ALL falls through to the spawn:
419
+ // a failure that is a real answer about this session — an id that names no
420
+ // thread, a turn the runtime refused — would say exactly the same thing
421
+ // again down the older path, more slowly and less clearly.
422
+ const richTransport = this.runTurn
423
+ ? (input) => this.runTurn({ runtime: target.runtime, ...input })
424
+ : adapter.runTurn?.bind(adapter);
425
+ if (richTransport) {
426
+ const outcome = await richTransport({ binaryPath: executable, target: resolved, prompt });
427
+ if (outcome.kind === "completed") {
428
+ this.record({ kind: "woken", via: "headless", session, exitCode: 0 }, session, batch);
429
+ return;
430
+ }
431
+ if (!outcome.transportUnusable) {
432
+ this.record({ kind: "pending", session, reason: outcome.reason }, session, batch);
433
+ return;
434
+ }
435
+ this.log(`${target.runtime}: falling back to a plain spawn — ${outcome.reason}`);
436
+ }
437
+ // A Windows .cmd needs its interpreter named explicitly, whatever produced
438
+ // the path — resolution or the session's own runtimeBin — so the decision
439
+ // lives here, after both, rather than inside either. Never a shell: these
440
+ // args carry the wake prompt, and therefore other people's message text.
441
+ const plan = (0, runtime_binary_1.spawnPlanFor)(executable, process.platform);
220
442
  // The session's own directory when we know it: `cwd` is only where the
221
443
  // attach process ran, and `codex exec` refuses to start outside a trusted
222
444
  // directory at all.
223
- const { exitCode, stderr } = await this.spawnHeadless(file, args, { cwd: resolved.resumeCwd ?? resolved.cwd });
445
+ const { exitCode, stderr } = await this.spawnHeadless(plan.file, [...plan.prefixArgs, ...args], {
446
+ cwd: resolved.resumeCwd ?? resolved.cwd,
447
+ });
224
448
  if (exitCode !== 0) {
449
+ // The binary ran and the turn still failed, so what we resolved may be
450
+ // stale — a half-finished upgrade, a runtime removed since. Drop it and
451
+ // prove it again next time rather than trusting it for the process's life.
452
+ this.binaries.delete(file);
225
453
  // A non-zero headless turn did not necessarily reply. Recording it as
226
454
  // delivered would claim an answer we cannot evidence.
227
455
  this.record({ kind: "pending", session, reason: `headless ${target.runtime} exited ${exitCode}: ${stderr.slice(0, 200)}` }, session, batch);
@@ -229,6 +457,21 @@ class RelayDaemon {
229
457
  }
230
458
  this.record({ kind: "woken", via: "headless", session, exitCode }, session, batch);
231
459
  }
460
+ /**
461
+ * The proven executable for a runtime, resolved at most once per process
462
+ * until something gives us reason to doubt it.
463
+ */
464
+ binaryFor(name) {
465
+ const known = this.binaries.get(name);
466
+ if (known)
467
+ return known;
468
+ const resolved = this.resolveBinary(name);
469
+ this.binaries.set(name, resolved);
470
+ this.log(resolved.ok
471
+ ? `${name} resolved to ${resolved.path} (${resolved.version})`
472
+ : `${name} unresolved: ${(0, runtime_binary_1.summarizeResolutionFailure)(resolved)}`);
473
+ return resolved;
474
+ }
232
475
  /**
233
476
  * Fill in a missing resume id from the runtime's on-disk state, at most once
234
477
  * every `DISCOVERY_INTERVAL_MS` per session.
@@ -309,6 +552,16 @@ class RelayDaemon {
309
552
  // for it, so drop the throttle and let the next wake use the new id.
310
553
  if (frame.resumeId)
311
554
  this.discoveryReasons.delete(frame.session);
555
+ // EVICT THE PREVIOUS HOLDER. A session name has exactly one listener,
556
+ // and `set` alone would leave the old socket open and its process
557
+ // running — which is how two orphaned attaches came to be stacked on
558
+ // one name on 2026-08-30, each one a place a wake could vanish into.
559
+ // Destroying it makes the displaced attach exit instead of lingering.
560
+ const previous = this.attached.get(frame.session);
561
+ if (previous && previous !== sock) {
562
+ this.log(`replacing attach for ${frame.session}: dropping the previous one`);
563
+ previous.destroy();
564
+ }
312
565
  this.attached.set(frame.session, sock);
313
566
  this.registry.setAttached(frame.session, true);
314
567
  (0, socket_1.writeFrame)(sock, { type: "attached", session: frame.session });
@@ -340,7 +593,8 @@ class RelayDaemon {
340
593
  status() {
341
594
  const sessions = this.registry.all().map((t) => ({
342
595
  ...t,
343
- attached: this.attached.has(t.name),
596
+ attached: this.attached.has(t.name) || this.mailboxes.has(t.name),
597
+ transport: this.attached.has(t.name) ? "socket" : this.mailboxes.has(t.name) ? "fifo" : undefined,
344
598
  }));
345
599
  return {
346
600
  running: true,
@@ -362,6 +616,8 @@ class RelayDaemon {
362
616
  sock.destroy();
363
617
  this.attached.clear();
364
618
  await this.queue.idle();
619
+ for (const w of this.mailboxWatchers)
620
+ w.stop();
365
621
  await new Promise((resolve) => (this.server ? this.server.close(() => resolve()) : resolve()));
366
622
  (0, socket_1.unlinkStaleSocket)((0, socket_1.socketPath)());
367
623
  try {
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.MailboxWatcher = void 0;
37
+ const crypto = __importStar(require("crypto"));
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const mailbox_1 = require("./mailbox");
41
+ const parent_watch_1 = require("./parent-watch");
42
+ /**
43
+ * Watch the mailbox root for sessions arming and dying.
44
+ *
45
+ * `fs.watch` AND a periodic sweep, not one or the other. `fs.watch` gives
46
+ * instant pickup but is unreliable across filesystems — notably a tmpfs
47
+ * directory the agent created moments earlier — and a sweep alone would add
48
+ * seconds to every arming. The sweep is also the only thing that can notice a
49
+ * registration whose process has since died, because nothing on disk changes
50
+ * when a process exits and there is no event to watch for.
51
+ */
52
+ class MailboxWatcher {
53
+ opts;
54
+ seen = new Map();
55
+ isAlive;
56
+ sweepMs;
57
+ watcher;
58
+ timer;
59
+ constructor(opts) {
60
+ this.opts = opts;
61
+ this.isAlive = opts.isAlive ?? parent_watch_1.processIsAlive;
62
+ this.sweepMs = opts.sweepMs ?? 2_000;
63
+ }
64
+ start() {
65
+ fs.mkdirSync(this.opts.root, { recursive: true, mode: 0o700 });
66
+ try {
67
+ this.watcher = fs.watch(this.opts.root, { recursive: true }, () => this.sweep());
68
+ }
69
+ catch {
70
+ // Not every platform or filesystem supports recursive watching. The sweep
71
+ // below is the floor, so this degrades to slower pickup, never to none.
72
+ }
73
+ this.timer = setInterval(() => this.sweep(), this.sweepMs);
74
+ this.timer.unref();
75
+ this.sweep();
76
+ }
77
+ stop() {
78
+ this.watcher?.close();
79
+ if (this.timer)
80
+ clearInterval(this.timer);
81
+ }
82
+ sweep() {
83
+ let entries;
84
+ try {
85
+ entries = fs.readdirSync(this.opts.root, { withFileTypes: true });
86
+ }
87
+ catch {
88
+ return; // No root yet: nothing has armed. Not a fault.
89
+ }
90
+ for (const entry of entries) {
91
+ if (!entry.isDirectory())
92
+ continue;
93
+ const dir = path.join(this.opts.root, entry.name);
94
+ const reg = (0, mailbox_1.readRegistration)(dir);
95
+ if (!reg)
96
+ continue;
97
+ if (!this.isAlive(reg.pid)) {
98
+ this.seen.delete(reg.session);
99
+ fs.rmSync(dir, { recursive: true, force: true });
100
+ this.opts.onDrop(reg.session, `mailbox process ${reg.pid} is no longer running`);
101
+ continue;
102
+ }
103
+ // Identity is the registration's CONTENT, not its timestamp.
104
+ //
105
+ // `registeredAt` has millisecond resolution, so two armings inside one
106
+ // millisecond share a timestamp — which in a test is a flake and in
107
+ // production is a re-arm that goes unannounced. Hashing the content is
108
+ // exact: anything the daemon would act on differently is a different hash,
109
+ // and a byte-identical rewrite is one the daemon already has right.
110
+ const stamp = crypto.createHash("sha256").update(JSON.stringify(reg)).digest("hex");
111
+ if (this.seen.get(reg.session) === stamp)
112
+ continue;
113
+ this.seen.set(reg.session, stamp);
114
+ this.opts.onRegister(reg);
115
+ }
116
+ }
117
+ }
118
+ exports.MailboxWatcher = MailboxWatcher;