agentschat-mcp 0.33.7 → 0.34.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.
@@ -63,6 +63,29 @@ export class IdentityTable {
63
63
  all(): Identity[] {
64
64
  return [...this.byBot.values()];
65
65
  }
66
+
67
+ /**
68
+ * Hot-replace the identity set (RELAY_IDENTITIES reload). Fails closed on
69
+ * duplicate botIds the same way the constructor does.
70
+ */
71
+ replace(identities: Identity[]): void {
72
+ const next = new Map<string, Identity>();
73
+ for (const id of identities) {
74
+ if (next.has(id.botId)) {
75
+ throw new Error(`duplicate identity botId "${id.botId}" — ambiguous routing`);
76
+ }
77
+ next.set(id.botId, id);
78
+ }
79
+ this.byBot.clear();
80
+ for (const [k, v] of next) this.byBot.set(k, v);
81
+ }
82
+ }
83
+
84
+ /** Platform hooks must never substitute another identity's credentials. */
85
+ export function requireIdentity(identities: readonly Identity[], botId: string): Identity {
86
+ const id = identities.find(id => id.botId === botId);
87
+ if (!id) throw new Error(`unknown identity: ${botId}`);
88
+ return id;
66
89
  }
67
90
 
68
91
  export interface InboundContext {
@@ -90,6 +113,23 @@ export interface InboundContext {
90
113
  * the agent's session and burn its tokens on chatter not addressed to it.
91
114
  * `mentioned_ids` is still honored when a host provides it (explicit signal wins).
92
115
  */
116
+ function matchesExactMention(content: string, id: string): boolean {
117
+ if (!id) return false;
118
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
119
+ return new RegExp(`(?:^|[^\\p{L}\\p{N}_@-])@(?:${escaped}(?![\\p{L}\\p{N}_(-])|[^\\s@()]+\\(${escaped}\\))`, "u").test(content);
120
+ }
121
+
122
+ /** All exact addressed targets, once each, in identity-table order. */
123
+ export function routeInboundTargets(table: IdentityTable, ctx: InboundContext): Identity[] {
124
+ if (ctx.channel_id?.startsWith("dm-")) {
125
+ const owner = ctx.dmOwnerBotId ? table.forBot(ctx.dmOwnerBotId) : null;
126
+ return owner ? [owner] : [];
127
+ }
128
+ const mentioned = Array.isArray(ctx.mentioned_ids) ? ctx.mentioned_ids : [];
129
+ return table.all().filter(id => mentioned.includes(id.botId) ||
130
+ matchesExactMention(ctx.content ?? "", id.agentId) || matchesExactMention(ctx.content ?? "", id.botId));
131
+ }
132
+
93
133
  export function routeInbound(table: IdentityTable, ctx: InboundContext): Identity | null {
94
134
  // DM: route to the identity that owns the DM channel.
95
135
  if (ctx.channel_id?.startsWith("dm-")) {
@@ -33,6 +33,10 @@ export function ingestAgentsChatFrame(
33
33
  deps.advanceCursor(id, frame.channel_id, frame.timestamp);
34
34
  }
35
35
  const key = messageDedupKey(frame as { id?: string; channel_id?: string });
36
- if (key && deps.dedup.recordOrSkip(key)) return false;
37
- return frame.sender_id !== id.agentId && frame.content !== "__typing__";
36
+ const isDm = frame.channel_id?.startsWith("dm-");
37
+ const scopedKey = key && (isDm ? JSON.stringify([id.botId, frame.channel_id, frame.id]) : key);
38
+ if (scopedKey && deps.dedup.recordOrSkip(scopedKey)) return false;
39
+ // Group fanout must filter self per TARGET, not per arrival socket: A can
40
+ // mention B, and A's self echo may be the first copy of the shared message.
41
+ return (!frame.channel_id?.startsWith("dm-") || frame.sender_id !== id.agentId) && frame.content !== "__typing__";
38
42
  }
package/connector/run.ts CHANGED
@@ -10,22 +10,31 @@
10
10
  * Multiplex (N identities, one per Hermes profile/agent):
11
11
  * RELAY_IDENTITIES = JSON array, one entry per identity:
12
12
  * [{"botId":"<agentschat agent_id>","token":"ac_...","gatewayId":"...","secret":"...","profile":"<hermes profile>"}, ...]
13
+ * OR RELAY_IDENTITIES_FILE = path to that JSON array (preferred for hot-reload).
13
14
  * Each botId is an agentschat agent_id. Optional `profile` is the Hermes profile
14
15
  * name (for gateway.multiplex_profiles). Do NOT put the AgentsChat agent_id in
15
16
  * profile — that splits Hermes session keys and breaks clarify. The connector
16
17
  * holds all identities, opens one agentschat WS per identity, and routes by
17
- * identity — identity A's messages never cross to identity B.
18
+ * identity. Each gateway must hello its own authorized botId. Prefer distinct
19
+ * gatewayId/secret pairs per bot with separate single-identity gateways.
20
+ * Shared multi-hello connections require explicit, correct outbound botIds.
21
+ *
22
+ * Hot-reload (no process rewrite):
23
+ * SIGHUP re-reads RELAY_IDENTITIES_FILE (or RELAY_IDENTITIES env), opens WS for
24
+ * new botIds, disconnects removed ones. New identities need a gateway hello.
18
25
  *
19
26
  * Common:
20
27
  * AGENTCHAT_API_URL REST base (default https://agents-chat.com)
21
28
  * AGENTCHAT_WS_URL WebSocket (default wss://agents-chat.com/ws)
22
29
  * RELAY_PORT port to listen on (default 8765)
23
30
  * RELAY_HOST bind host (default 127.0.0.1)
31
+ * RELAY_IDENTITIES_FILE path to JSON identities array (SIGHUP reloads it)
24
32
  */
25
33
 
26
34
  import WS from "ws";
27
- import { startConnector } from "./server.ts";
28
- import type { Identity } from "./identities.ts";
35
+ import { readFileSync } from "node:fs";
36
+ import { startConnector, PlatformHttpError, PlatformNetworkError } from "./server.ts";
37
+ import { requireIdentity, type Identity } from "./identities.ts";
29
38
  import { join } from "node:path";
30
39
  import { redactSecrets } from "../src/redact.ts";
31
40
  import { flushCursor, loadCursor, persistCursor } from "../src/read-cursor.ts";
@@ -51,36 +60,50 @@ const WS_URL = process.env.AGENTCHAT_WS_URL || API.replace(/^http/, "ws") + "/ws
51
60
  const PORT = Number(process.env.RELAY_PORT || 8765);
52
61
  const HOST = process.env.RELAY_HOST || "127.0.0.1";
53
62
 
54
- // ── Resolve identities: multiplex (RELAY_IDENTITIES) or single-tenant (legacy env) ──
63
+ // ── Resolve identities: multiplex (RELAY_IDENTITIES[_FILE]) or single-tenant (legacy env) ──
55
64
 
56
- function resolveIdentities(): Identity[] {
65
+ function parseIdentitiesJson(raw: string, source: string): Identity[] {
66
+ let parsed: any;
67
+ try {
68
+ parsed = JSON.parse(raw);
69
+ } catch {
70
+ throw new Error(`${source} is not valid JSON`);
71
+ }
72
+ if (!Array.isArray(parsed) || parsed.length === 0) {
73
+ throw new Error(`${source} must be a non-empty JSON array`);
74
+ }
75
+ const seen = new Set<string>();
76
+ for (const [index, it] of parsed.entries()) {
77
+ const invalid = ["botId", "token", "gatewayId", "secret"].filter(k => typeof it?.[k] !== "string" || !it[k].trim());
78
+ for (const k of ["agentId", "profile"]) {
79
+ if (it?.[k] !== undefined && (typeof it[k] !== "string" || !it[k].trim())) invalid.push(k);
80
+ }
81
+ if (invalid.length) throw new Error(`${source} entry ${index}: missing or invalid string fields: ${invalid.join(", ")}`);
82
+ if (seen.has(it.botId)) throw new Error(`${source} entry ${index}: duplicate botId`);
83
+ seen.add(it.botId);
84
+ }
85
+ return parsed.map((it: any) => ({
86
+ botId: String(it.botId),
87
+ agentId: String(it.agentId ?? it.botId),
88
+ token: String(it.token),
89
+ gatewayId: String(it.gatewayId),
90
+ secret: String(it.secret),
91
+ ...(it.profile ? { profile: String(it.profile) } : {}),
92
+ }));
93
+ }
94
+
95
+ /** Load identities; throws on bad JSON/shape (SIGHUP keeps prior set). */
96
+ function loadIdentities(): Identity[] {
97
+ const file = (process.env.RELAY_IDENTITIES_FILE || "").trim();
98
+ if (file) {
99
+ let raw: string;
100
+ try { raw = readFileSync(file, "utf8"); }
101
+ catch { throw new Error("RELAY_IDENTITIES_FILE could not be read"); }
102
+ return parseIdentitiesJson(raw, "RELAY_IDENTITIES_FILE");
103
+ }
57
104
  const raw = (process.env.RELAY_IDENTITIES || "").trim();
58
105
  if (raw) {
59
- let parsed: any;
60
- try {
61
- parsed = JSON.parse(raw);
62
- } catch (e) {
63
- log(`ERROR: RELAY_IDENTITIES is not valid JSON: ${e}`);
64
- process.exit(1);
65
- }
66
- if (!Array.isArray(parsed) || parsed.length === 0) {
67
- log(`ERROR: RELAY_IDENTITIES must be a non-empty JSON array`);
68
- process.exit(1);
69
- }
70
- for (const it of parsed) {
71
- if (!it?.botId || !it?.token || !it?.gatewayId || !it?.secret) {
72
- log(`ERROR: each RELAY_IDENTITIES entry needs botId, token, gatewayId, secret — got: ${JSON.stringify(it).slice(0, 80)}`);
73
- process.exit(1);
74
- }
75
- }
76
- return parsed.map((it: any) => ({
77
- botId: String(it.botId),
78
- agentId: String(it.agentId ?? it.botId),
79
- token: String(it.token),
80
- gatewayId: String(it.gatewayId),
81
- secret: String(it.secret),
82
- ...(it.profile ? { profile: String(it.profile) } : {}),
83
- }));
106
+ return parseIdentitiesJson(raw, "RELAY_IDENTITIES");
84
107
  }
85
108
  // Single-tenant legacy env.
86
109
  const agentId = need("AGENTCHAT_AGENT_ID");
@@ -90,10 +113,20 @@ function resolveIdentities(): Identity[] {
90
113
  return [{ botId: agentId, agentId, token, gatewayId, secret }];
91
114
  }
92
115
 
93
- const identities = resolveIdentities();
94
- const single = identities.length === 1;
116
+ function resolveIdentitiesAtStartup(): Identity[] {
117
+ try {
118
+ return loadIdentities();
119
+ } catch (e: any) {
120
+ log(`ERROR: ${e?.message ?? e}`);
121
+ process.exit(1);
122
+ }
123
+ }
124
+
125
+ /** Mutable identity list — hooks and reload close over this binding. */
126
+ let identities = resolveIdentitiesAtStartup();
95
127
 
96
128
  // The per-gateway secret table for the relay upgrade auth (gatewayId → secrets).
129
+ // Mutated in place on hot-reload so startConnector's closed-over object stays valid.
97
130
  const secrets: Record<string, string[]> = {};
98
131
  for (const id of identities) {
99
132
  (secrets[id.gatewayId] ??= []).push(id.secret);
@@ -103,6 +136,24 @@ for (const id of identities) {
103
136
 
104
137
  let broadcast: ((msg: any) => void) | null = null;
105
138
  const socketsByBot = new Map<string, WS>();
139
+ const reconnectTimerByBot = new Map<string, ReturnType<typeof setTimeout>>();
140
+ // Identity object is the generation token; unchanged reload rows retain it.
141
+ const currentIdentity = (id: Identity) => !(process as any).__shutdown && identities.includes(id);
142
+ function cancelReconnect(botId: string) {
143
+ const timer = reconnectTimerByBot.get(botId);
144
+ if (timer) clearTimeout(timer);
145
+ reconnectTimerByBot.delete(botId);
146
+ }
147
+ function scheduleReconnect(id: Identity, delay: number) {
148
+ if (!currentIdentity(id)) return;
149
+ cancelReconnect(id.botId);
150
+ const timer = setTimeout(() => {
151
+ if (reconnectTimerByBot.get(id.botId) !== timer) return;
152
+ reconnectTimerByBot.delete(id.botId);
153
+ if (currentIdentity(id)) connectIdentity(id);
154
+ }, delay);
155
+ reconnectTimerByBot.set(id.botId, timer);
156
+ }
106
157
  const backoffByBot = new Map<string, number>();
107
158
  const backfillTimerByBot = new Map<string, ReturnType<typeof setTimeout>>();
108
159
  const heartbeatsByBot = new Map<string, HeartbeatMonitor>();
@@ -187,6 +238,7 @@ async function joinMemberships(ws: WS, id: Identity) {
187
238
  return;
188
239
  }
189
240
  const body = await r.json() as any;
241
+ if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws) return;
190
242
  const channels = Array.isArray(body) ? body : (body.channels || []);
191
243
  const joined: string[] = [];
192
244
  for (const ch of channels) {
@@ -201,7 +253,7 @@ async function joinMemberships(ws: WS, id: Identity) {
201
253
  if (prev) clearTimeout(prev);
202
254
  const t = setTimeout(() => {
203
255
  backfillTimerByBot.delete(id.botId);
204
- if ((process as any).__shutdown) return;
256
+ if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws) return;
205
257
  void backfillIdentity(id, joined);
206
258
  }, 2000);
207
259
  (t as any).unref?.();
@@ -214,6 +266,7 @@ async function joinMemberships(ws: WS, id: Identity) {
214
266
  async function backfillIdentity(id: Identity, channelIds: string[]) {
215
267
  const s = cursorFor(id);
216
268
  for (const channelId of channelIds) {
269
+ if (!currentIdentity(id)) return;
217
270
  try {
218
271
  const after = s.map.get(channelId);
219
272
  const params = after ? `?after=${encodeURIComponent(after)}&limit=50` : `?limit=1`;
@@ -222,6 +275,7 @@ async function backfillIdentity(id: Identity, channelIds: string[]) {
222
275
  });
223
276
  if (!r.ok) continue;
224
277
  const msgs = (((await r.json()) as any)?.messages ?? []) as any[];
278
+ if (!currentIdentity(id)) return;
225
279
  const plan = planBackfill(after, msgs, id.agentId);
226
280
  if (plan.seed) {
227
281
  s.map.set(channelId, plan.seed);
@@ -243,6 +297,8 @@ async function backfillIdentity(id: Identity, channelIds: string[]) {
243
297
  }
244
298
 
245
299
  function connectIdentity(id: Identity) {
300
+ if (!currentIdentity(id)) return;
301
+ cancelReconnect(id.botId);
246
302
  // Replace any prior socket/heartbeat for this identity (heartbeat-forced
247
303
  // reconnect calls us again after closing the old socket).
248
304
  stopHeartbeat(id.botId);
@@ -269,7 +325,7 @@ function connectIdentity(id: Identity) {
269
325
  } catch {}
270
326
  },
271
327
  reconnect: () => {
272
- if ((process as any).__shutdown || heartbeatForced) return;
328
+ if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws || heartbeatForced) return;
273
329
  heartbeatForced = true;
274
330
  log(`agentschat WS heartbeat timeout for ${id.botId}; forcing reconnect`);
275
331
  backoffByBot.set(id.botId, 1000);
@@ -279,9 +335,7 @@ function connectIdentity(id: Identity) {
279
335
  } catch {}
280
336
  socketsByBot.delete(id.botId);
281
337
  stopHeartbeat(id.botId);
282
- setTimeout(() => {
283
- if (!(process as any).__shutdown) connectIdentity(id);
284
- }, 500);
338
+ scheduleReconnect(id, 500);
285
339
  },
286
340
  getReadyState: () => ws.readyState ?? WS_CLOSED,
287
341
  },
@@ -293,12 +347,14 @@ function connectIdentity(id: Identity) {
293
347
  hb.start();
294
348
 
295
349
  ws.on("open", () => {
350
+ if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws) { ws.close(); return; }
296
351
  backoffByBot.set(id.botId, 1000);
297
352
  try {
298
353
  ws.send(JSON.stringify({ type: "auth", agent_id: id.agentId, token: id.token, capabilities: ["chat"] }));
299
354
  } catch {}
300
355
  });
301
356
  ws.on("message", (raw: any) => {
357
+ if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws) return;
302
358
  let data: any;
303
359
  try { data = JSON.parse(String(raw)); } catch { return; }
304
360
  if (data.type === "pong") {
@@ -326,12 +382,13 @@ function connectIdentity(id: Identity) {
326
382
  }
327
383
  });
328
384
  ws.on("close", () => {
385
+ if (!currentIdentity(id) || socketsByBot.get(id.botId) !== ws) return;
329
386
  stopHeartbeat(id.botId);
330
387
  if ((process as any).__shutdown) return;
331
388
  if (heartbeatForced) return; // reconnect already scheduled by HeartbeatMonitor
332
389
  const delay = backoffByBot.get(id.botId) ?? 1000;
333
390
  log(`agentschat WS closed for ${id.botId}; reconnecting in ${delay}ms`);
334
- setTimeout(() => connectIdentity(id), delay);
391
+ scheduleReconnect(id, delay);
335
392
  backoffByBot.set(id.botId, Math.min(delay * 2, 30000));
336
393
  });
337
394
  ws.on("error", (e: any) => log(`agentschat WS error (${id.botId}): ${e?.message ?? e}`));
@@ -346,18 +403,18 @@ const connector = startConnector({
346
403
  identities,
347
404
  agentschat: {
348
405
  async sendMessage(botId, chatId, content, replyTo) {
349
- const id = identities.find((i) => i.botId === botId) ?? identities[0];
406
+ const id = requireIdentity(identities, botId);
350
407
  const res = await fetch(`${API}/api/channels/${encodeURIComponent(chatId)}/messages`, {
351
408
  method: "POST",
352
409
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${id.token}` },
353
410
  body: JSON.stringify({ sender_id: id.agentId, content_type: "text", content, ...(replyTo ? { parent_id: replyTo } : {}) }),
354
- });
355
- if (!res.ok) throw new Error(`agentschat send failed: ${res.status}`);
411
+ }).catch(() => { throw new PlatformNetworkError(); });
412
+ if (!res.ok) throw new PlatformHttpError(res.status);
356
413
  const data = (await res.json()) as any;
357
414
  return { id: data?.id };
358
415
  },
359
416
  async getChatInfo(botId, chatId) {
360
- const id = identities.find((i) => i.botId === botId) ?? identities[0];
417
+ const id = requireIdentity(identities, botId);
361
418
  const res = await fetch(`${API}/api/channels/${encodeURIComponent(chatId)}`, {
362
419
  headers: { Authorization: `Bearer ${id.token}` },
363
420
  });
@@ -366,7 +423,7 @@ const connector = startConnector({
366
423
  return { name: data?.name ?? chatId, type: chatId.startsWith("dm-") ? "dm" : "group" };
367
424
  },
368
425
  async sendTyping(botId, chatId) {
369
- const id = identities.find((i) => i.botId === botId) ?? identities[0];
426
+ const id = requireIdentity(identities, botId);
370
427
  const ws = socketsByBot.get(id.botId);
371
428
  if (ws && ws.readyState === WS.OPEN) {
372
429
  try { ws.send(JSON.stringify({ type: "typing", channel_id: chatId, sender_id: id.agentId })); } catch {}
@@ -376,9 +433,10 @@ const connector = startConnector({
376
433
  // oldest→newest, trigger message excluded, secrets redacted (a group channel is
377
434
  // untrusted content — never forward a leaked key downstream). Best-effort: any
378
435
  // failure returns null and the addressed message still delivers without it.
379
- async getChannelContext(botId, chatId, sinceTs, excludeId) {
380
- const id = identities.find((i) => i.botId === botId) ?? identities[0];
436
+ async getChannelContext(botId, chatId, sinceTs, excludeId, signal) {
437
+ const id = requireIdentity(identities, botId);
381
438
  const res = await fetch(`${API}/api/channels/${encodeURIComponent(chatId)}/messages?limit=50`, {
439
+ signal,
382
440
  headers: { Authorization: `Bearer ${id.token}` },
383
441
  });
384
442
  if (!res.ok) return null;
@@ -399,13 +457,81 @@ const connector = startConnector({
399
457
  });
400
458
 
401
459
  broadcast = (msg) => connector.injectAgentsChatMessage(msg);
460
+ await connector.ready;
402
461
  log(`listening on ${HOST}:${connector.port} (contract v1, ${identities.length} identit${identities.length === 1 ? "y" : "ies"})`);
403
462
 
404
463
  for (const id of identities) connectIdentity(id);
405
464
 
465
+ /**
466
+ * Hot-reload RELAY_IDENTITIES without rewriting the connector process.
467
+ * Prefer RELAY_IDENTITIES_FILE so SIGHUP re-reads disk; env-only deployments
468
+ * re-parse RELAY_IDENTITIES (unchanged unless the supervisor rewrote the env).
469
+ * Opens AgentsChat WS for new botIds, disconnects removed ones. Gateway hello
470
+ * must include each new identity before it receives any inbound traffic.
471
+ */
472
+ function reloadIdentitiesFromConfig() {
473
+ const next = loadIdentities().map(id => identities.find(prev =>
474
+ prev.botId === id.botId && prev.agentId === id.agentId && prev.token === id.token &&
475
+ prev.gatewayId === id.gatewayId && prev.secret === id.secret && prev.profile === id.profile) ?? id);
476
+ const prevByBot = new Map(identities.map((i) => [i.botId, i]));
477
+ const nextByBot = new Map(next.map((i) => [i.botId, i]));
478
+ const added = next.filter((i) => !prevByBot.has(i.botId));
479
+ const removed = identities.filter((i) => !nextByBot.has(i.botId));
480
+ const kept = next.filter((i) => prevByBot.has(i.botId));
481
+
482
+ // Table first — if replace throws, leave AgentsChat sockets / `identities` untouched.
483
+ connector.reloadIdentities(next);
484
+ identities = next;
485
+
486
+ for (const id of removed) {
487
+ cancelReconnect(id.botId);
488
+ log(`hot-reload: disconnecting removed identity ${id.botId}`);
489
+ stopHeartbeat(id.botId);
490
+ const t = backfillTimerByBot.get(id.botId);
491
+ if (t) { clearTimeout(t); backfillTimerByBot.delete(id.botId); }
492
+ const ws = socketsByBot.get(id.botId);
493
+ if (ws) {
494
+ try {
495
+ ws.removeAllListeners("close");
496
+ ws.close();
497
+ } catch {}
498
+ socketsByBot.delete(id.botId);
499
+ }
500
+ backoffByBot.delete(id.botId);
501
+ // Keep cursor files on disk (reconnect watermark survives a temporary remove).
502
+ }
503
+ for (const id of kept) {
504
+ // Token/gateway rotation: update in-memory row already done via `identities = next`.
505
+ // If the agentschat token changed, force reconnect so auth uses the new key.
506
+ const prev = prevByBot.get(id.botId)!;
507
+ if (prev !== id) {
508
+ cancelReconnect(id.botId);
509
+ const timer = backfillTimerByBot.get(id.botId);
510
+ if (timer) clearTimeout(timer);
511
+ backfillTimerByBot.delete(id.botId);
512
+ log(`hot-reload: reconnecting ${id.botId} (credentials changed)`);
513
+ connectIdentity(id);
514
+ }
515
+ }
516
+ for (const id of added) {
517
+ log(`hot-reload: connecting new identity ${id.botId}`);
518
+ connectIdentity(id);
519
+ }
520
+ log(`hot-reload complete: ${identities.length} identit${identities.length === 1 ? "y" : "ies"} (${added.length} added, ${removed.length} removed)`);
521
+ }
522
+
523
+ process.on("SIGHUP", () => {
524
+ try {
525
+ reloadIdentitiesFromConfig();
526
+ } catch (e: any) {
527
+ log(`hot-reload FAILED (keeping previous identities): ${e?.message ?? e}`);
528
+ }
529
+ });
530
+
406
531
  for (const sig of ["SIGINT", "SIGTERM"] as const) {
407
532
  process.on(sig, () => {
408
533
  (process as any).__shutdown = true;
534
+ for (const botId of reconnectTimerByBot.keys()) cancelReconnect(botId);
409
535
  for (const botId of [...heartbeatsByBot.keys()]) stopHeartbeat(botId);
410
536
  for (const ws of socketsByBot.values()) {
411
537
  try { ws.close(); } catch {}