@songsid/agend 2.1.4-beta.39 → 2.1.4-beta.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.
@@ -6,9 +6,13 @@ import { join } from "node:path";
6
6
  import { pipeline } from "node:stream/promises";
7
7
  import { Readable } from "node:stream";
8
8
  import { createWriteStream } from "node:fs";
9
- import { Client, GatewayIntentBits, Partials, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, ApplicationCommandOptionType, ChannelType, MessageFlags, } from "discord.js";
9
+ import { Client, GatewayIntentBits, Partials, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, ApplicationCommandOptionType, ChannelType, MessageFlags, Status, } from "discord.js";
10
10
  import { MessageQueue } from "../message-queue.js";
11
11
  const DISCORD_MAX_LENGTH = 2000;
12
+ const GATEWAY_WATCHDOG_INTERVAL_MS = 30_000;
13
+ const GATEWAY_STALE_MIN_MS = 180_000;
14
+ const GATEWAY_RECONNECT_WAIT_MS = 30_000;
15
+ const GATEWAY_MAX_BACKOFF_MS = 5 * 60_000;
12
16
  /** Curated ClassicBot backends for Discord's native slash-option dropdown. */
13
17
  export const DISCORD_START_BACKEND_CHOICES = [
14
18
  { name: "Claude Code", value: "claude-code" },
@@ -24,6 +28,27 @@ export class DiscordAdapter extends EventEmitter {
24
28
  topology = "channels";
25
29
  id;
26
30
  client;
31
+ clientFactory;
32
+ now;
33
+ watchdogIntervalMs;
34
+ staleThresholdMs;
35
+ reconnectBaseDelayMs;
36
+ desiredRunning = false;
37
+ lifecycleEpoch = 0;
38
+ clientGeneration = 0;
39
+ reconnectPromise = null;
40
+ watchdogTimer = null;
41
+ lastWatchdogTickAt = 0;
42
+ generationReadyAt = 0;
43
+ generationNonReadySince = 0;
44
+ shardNonReadySince = new Map();
45
+ previousHeartbeatAck = new Map();
46
+ observedHeartbeatInterval = new Map();
47
+ healthStatus = "stopped";
48
+ lastDispatchAt = null;
49
+ lastReconnectAt = null;
50
+ lastReconnectReason = null;
51
+ reconnectCount = 0;
27
52
  botToken;
28
53
  accessManager;
29
54
  inboxDir;
@@ -47,20 +72,12 @@ export class DiscordAdapter extends EventEmitter {
47
72
  this.generalChannelId = opts.generalChannelId;
48
73
  this.registerCommands = opts.registerCommands !== false;
49
74
  mkdirSync(this.inboxDir, { recursive: true });
50
- this.client = new Client({
51
- intents: [
52
- GatewayIntentBits.Guilds,
53
- GatewayIntentBits.GuildMessages,
54
- GatewayIntentBits.MessageContent,
55
- // Required to receive messageReactionAdd/Remove at all.
56
- GatewayIntentBits.GuildMessageReactions,
57
- ],
58
- // Reactions on messages that are not in the cache — which is every message
59
- // sent before the current process started, i.e. the common case after any
60
- // restart — arrive as PARTIAL objects. Without these, discord.js drops those
61
- // events entirely and reactions appear to work only on very recent messages.
62
- partials: [Partials.Message, Partials.Reaction, Partials.User],
63
- });
75
+ this.clientFactory = opts.clientFactory ?? (() => this.buildClient());
76
+ this.now = opts.now ?? Date.now;
77
+ this.watchdogIntervalMs = opts.watchdogIntervalMs ?? GATEWAY_WATCHDOG_INTERVAL_MS;
78
+ this.staleThresholdMs = opts.staleThresholdMs ?? GATEWAY_STALE_MIN_MS;
79
+ this.reconnectBaseDelayMs = opts.reconnectBaseDelayMs ?? 5_000;
80
+ this.client = this.clientFactory();
64
81
  this.queue = new MessageQueue({
65
82
  send: async (chatId, threadId, text) => {
66
83
  const channel = await this._fetchTextChannel(threadId ?? chatId);
@@ -78,23 +95,87 @@ export class DiscordAdapter extends EventEmitter {
78
95
  return { messageId: msg.id };
79
96
  },
80
97
  });
81
- this._registerHandlers();
98
+ this.registerClientHandlers(this.client, this.clientGeneration);
99
+ }
100
+ buildClient() {
101
+ return new Client({
102
+ intents: [
103
+ GatewayIntentBits.Guilds,
104
+ GatewayIntentBits.GuildMessages,
105
+ GatewayIntentBits.MessageContent,
106
+ GatewayIntentBits.GuildMessageReactions,
107
+ ],
108
+ // Events for messages/reactions created before this process started are partial.
109
+ partials: [Partials.Message, Partials.Reaction, Partials.User],
110
+ });
82
111
  }
83
112
  async _fetchTextChannel(channelId) {
84
- const channel = await this.client.channels.fetch(channelId);
113
+ const client = await this.readyClient();
114
+ const channel = await client.channels.fetch(channelId);
85
115
  if (!channel?.isTextBased()) {
86
116
  throw new Error(`Channel ${channelId} is not a text channel`);
87
117
  }
88
118
  return channel;
89
119
  }
90
- _registerHandlers() {
120
+ registerClientHandlers(client, generation) {
91
121
  // Client/shard errors (WebSocket hiccups, gateway resumes, etc.). discord.js
92
122
  // auto-reconnects; we just need a listener so Node's EventEmitter doesn't
93
123
  // rethrow on "error" — without one it surfaces as an uncaughtException and
94
124
  // the process-level handler tears down the whole fleet (the built-in adapter
95
125
  // shares the fleet process).
96
- this.client.on("error", (err) => console.warn(`[discord] client error: ${err?.message ?? err}`));
97
- this.client.on("shardError", (err) => console.warn(`[discord] shard error: ${err?.message ?? err}`));
126
+ client.on("error", (err) => {
127
+ if (!this.isCurrentClient(client, generation))
128
+ return;
129
+ console.warn(`[discord:${this.id}] client error: ${err?.message ?? err}`);
130
+ this.healthStatus = "retrying";
131
+ this.emitHealthChanged();
132
+ });
133
+ client.on("shardError", (err, shardId) => {
134
+ if (!this.isCurrentClient(client, generation))
135
+ return;
136
+ console.warn(`[discord:${this.id}] shard ${shardId} error: ${err?.message ?? err}`);
137
+ this.healthStatus = "retrying";
138
+ this.emitHealthChanged();
139
+ });
140
+ client.on("shardReconnecting", (shardId) => {
141
+ if (!this.isCurrentClient(client, generation))
142
+ return;
143
+ this.healthStatus = "retrying";
144
+ console.warn(`[discord:${this.id}] shard ${shardId} reconnecting`);
145
+ });
146
+ client.on("shardResume", (shardId, replayedEvents) => {
147
+ if (!this.isCurrentClient(client, generation))
148
+ return;
149
+ this.healthStatus = "connected";
150
+ this.shardNonReadySince.delete(shardId);
151
+ console.info(`[discord:${this.id}] shard ${shardId} resumed (${replayedEvents} replayed event(s))`);
152
+ });
153
+ client.on("shardReady", (shardId) => {
154
+ if (!this.isCurrentClient(client, generation))
155
+ return;
156
+ this.healthStatus = "connected";
157
+ this.shardNonReadySince.delete(shardId);
158
+ });
159
+ client.on("shardDisconnect", (event, shardId) => {
160
+ if (!this.isCurrentClient(client, generation))
161
+ return;
162
+ this.healthStatus = "retrying";
163
+ this.shardNonReadySince.set(shardId, this.now());
164
+ console.warn(`[discord:${this.id}] shard ${shardId} disconnected (${event.code}: ${event.reason || "no reason"})`);
165
+ });
166
+ client.on("invalidated", () => {
167
+ if (!this.isCurrentClient(client, generation))
168
+ return;
169
+ console.warn(`[discord:${this.id}] gateway session invalidated; rebuilding client`);
170
+ void this.reconnectGateway("gateway session invalidated").catch(() => { });
171
+ });
172
+ client.on("raw", (packet) => {
173
+ if (!this.isCurrentClient(client, generation))
174
+ return;
175
+ if (packet.t)
176
+ this.lastDispatchAt = this.now();
177
+ });
178
+ client.once("ready", () => void this.handleClientReady(client, generation));
98
179
  // Reactions on the bot's messages, as inbound events (#408). Both add and remove
99
180
  // are reported so an agent can see an approval being withdrawn.
100
181
  const onReaction = async (reaction, user, action) => {
@@ -109,7 +190,9 @@ export class DiscordAdapter extends EventEmitter {
109
190
  return;
110
191
  }
111
192
  }
112
- if (user.id === this.client.user?.id)
193
+ if (!this.isCurrentClient(client, generation))
194
+ return;
195
+ if (user.id === client.user?.id)
113
196
  return; // our own reaction
114
197
  // Other bots' reactions are DELIVERED on purpose: agents react to each
115
198
  // other's messages as signals (agent A 👍 → agent B sees it). The noise
@@ -119,11 +202,11 @@ export class DiscordAdapter extends EventEmitter {
119
202
  const message = reaction.message;
120
203
  // Only reactions on OUR messages are meaningful as agent signals; a user
121
204
  // reacting to another user's message is chatter.
122
- if (message.author?.id && message.author.id !== this.client.user?.id)
205
+ if (message.author?.id && message.author.id !== client.user?.id)
123
206
  return;
124
207
  if (message.guildId && message.guildId !== this.guildId && !this.openChannels.has(message.channelId))
125
208
  return;
126
- this.emit("reaction", {
209
+ this.emitFromClient(client, generation, "reaction", {
127
210
  source: "discord",
128
211
  adapterId: this.id,
129
212
  chatId: this.guildId,
@@ -142,11 +225,13 @@ export class DiscordAdapter extends EventEmitter {
142
225
  console.warn(`[discord] reaction ${action} handler error (${err.message})`);
143
226
  }
144
227
  };
145
- this.client.on("messageReactionAdd", (reaction, user) => void onReaction(reaction, user, "add"));
146
- this.client.on("messageReactionRemove", (reaction, user) => void onReaction(reaction, user, "remove"));
147
- this.client.on("messageCreate", async (msg) => {
228
+ client.on("messageReactionAdd", (reaction, user) => void onReaction(reaction, user, "add"));
229
+ client.on("messageReactionRemove", (reaction, user) => void onReaction(reaction, user, "remove"));
230
+ client.on("messageCreate", async (msg) => {
148
231
  try {
149
- if (msg.author.id === this.client.user?.id)
232
+ if (!this.isCurrentClient(client, generation))
233
+ return;
234
+ if (msg.author.id === client.user?.id)
150
235
  return; // Ignore own messages
151
236
  if (!msg.guildId)
152
237
  return;
@@ -320,7 +405,7 @@ export class DiscordAdapter extends EventEmitter {
320
405
  else
321
406
  break;
322
407
  }
323
- this.emit("message", {
408
+ this.emitFromClient(client, generation, "message", {
324
409
  source: "discord",
325
410
  adapterId: this.id,
326
411
  chatId,
@@ -345,8 +430,10 @@ export class DiscordAdapter extends EventEmitter {
345
430
  // Handle button interactions and slash commands
346
431
  // Trust boundary: interaction responses can throw DiscordAPIError[10062] if the
347
432
  // interaction expires (>3s). Catch to prevent crashing the entire daemon.
348
- this.client.on("interactionCreate", async (interaction) => {
433
+ client.on("interactionCreate", async (interaction) => {
349
434
  try {
435
+ if (!this.isCurrentClient(client, generation))
436
+ return;
350
437
  // Buttons: acknowledge IMMEDIATELY, before any guild/channel filtering.
351
438
  // A button has a 3s ack window; any early return (unknown guild/channel)
352
439
  // or a downstream no-op (e.g. the cancel button was already cleared) would
@@ -363,7 +450,7 @@ export class DiscordAdapter extends EventEmitter {
363
450
  // console.log(`[discord] ignoring button from non-primary guild ${interaction.guildId} channel ${interaction.channelId}`);
364
451
  return;
365
452
  }
366
- this.emit("callback_query", {
453
+ this.emitFromClient(client, generation, "callback_query", {
367
454
  callbackData: interaction.customId,
368
455
  chatId: this.guildId,
369
456
  threadId: interaction.channelId,
@@ -391,7 +478,7 @@ export class DiscordAdapter extends EventEmitter {
391
478
  const callbackData = interaction.values[0];
392
479
  if (!callbackData)
393
480
  return;
394
- this.emit("callback_query", {
481
+ this.emitFromClient(client, generation, "callback_query", {
395
482
  callbackData,
396
483
  chatId: this.guildId,
397
484
  threadId: interaction.channelId,
@@ -411,7 +498,7 @@ export class DiscordAdapter extends EventEmitter {
411
498
  if (interaction.commandName === "chat") {
412
499
  const text = interaction.options.getString("message") ?? "";
413
500
  await interaction.deferReply();
414
- this.emit("slash_command", {
501
+ this.emitFromClient(client, generation, "slash_command", {
415
502
  command: "chat",
416
503
  channelId: interaction.channelId,
417
504
  channelName,
@@ -438,7 +525,7 @@ export class DiscordAdapter extends EventEmitter {
438
525
  for (const opt of interaction.options.data) {
439
526
  options[opt.name] = opt.value;
440
527
  }
441
- this.emit("slash_command", {
528
+ this.emitFromClient(client, generation, "slash_command", {
442
529
  command: interaction.commandName,
443
530
  channelId: interaction.channelId,
444
531
  channelName,
@@ -484,14 +571,18 @@ export class DiscordAdapter extends EventEmitter {
484
571
  }
485
572
  });
486
573
  // Handle channel deletion (equivalent to topic_closed)
487
- this.client.on("guildCreate", (guild) => {
488
- this.emit("new_group_detected", {
574
+ client.on("guildCreate", (guild) => {
575
+ if (!this.isCurrentClient(client, generation))
576
+ return;
577
+ this.emitFromClient(client, generation, "new_group_detected", {
489
578
  groupId: guild.id,
490
579
  groupTitle: guild.name,
491
580
  source: "discord",
492
581
  });
493
582
  });
494
- this.client.on("channelDelete", (channel) => {
583
+ client.on("channelDelete", (channel) => {
584
+ if (!this.isCurrentClient(client, generation))
585
+ return;
495
586
  if (!("guildId" in channel))
496
587
  return;
497
588
  if (channel.guildId !== this.guildId) {
@@ -499,7 +590,7 @@ export class DiscordAdapter extends EventEmitter {
499
590
  return;
500
591
  // Allowed: an open classic channel in a non-primary guild was deleted.
501
592
  }
502
- this.emit("topic_closed", {
593
+ this.emitFromClient(client, generation, "topic_closed", {
503
594
  chatId: this.guildId,
504
595
  threadId: channel.id,
505
596
  });
@@ -511,14 +602,247 @@ export class DiscordAdapter extends EventEmitter {
511
602
  // console.log(`[AgEnD] setOpenChannels: ${channelIds.length} channels`, channelIds);
512
603
  }
513
604
  // ── Lifecycle ──────────────────────────────────────────────────────────
514
- async start() {
515
- this.queue.start();
516
- this.client.once("ready", async () => {
605
+ isCurrentClient(client, generation) {
606
+ // lifecycleEpoch===0 preserves constructor-level handler tests; a real Client
607
+ // cannot dispatch gateway events before start/login.
608
+ return (this.desiredRunning || this.lifecycleEpoch === 0)
609
+ && this.client === client
610
+ && this.clientGeneration === generation;
611
+ }
612
+ emitFromClient(client, generation, event, ...args) {
613
+ return this.isCurrentClient(client, generation) ? this.emit(event, ...args) : false;
614
+ }
615
+ emitHealthChanged() {
616
+ this.emit("gateway_health", this.getHealthSnapshot());
617
+ }
618
+ async loginFreshClient(reason, epoch) {
619
+ const oldClient = this.client;
620
+ // A destroyed discord.js Client cannot be logged in again. Destroy first to
621
+ // avoid overlapping gateway sessions delivering MESSAGE_CREATE twice.
622
+ oldClient.destroy();
623
+ const client = this.clientFactory();
624
+ const generation = ++this.clientGeneration;
625
+ this.client = client;
626
+ this.generationReadyAt = 0;
627
+ this.generationNonReadySince = this.now();
628
+ this.shardNonReadySince.clear();
629
+ this.previousHeartbeatAck.clear();
630
+ this.observedHeartbeatInterval.clear();
631
+ this.registerClientHandlers(client, generation);
632
+ this.emitHealthChanged();
633
+ try {
634
+ await client.login(this.botToken);
635
+ if (!this.desiredRunning || epoch !== this.lifecycleEpoch || this.client !== client) {
636
+ client.destroy();
637
+ throw new Error("Discord gateway login superseded by lifecycle change");
638
+ }
639
+ this.lastReconnectAt = this.now();
640
+ this.lastReconnectReason = reason;
641
+ this.healthStatus = client.isReady() ? "connected" : "starting";
642
+ this.emitHealthChanged();
643
+ }
644
+ catch (err) {
645
+ client.destroy();
646
+ throw err;
647
+ }
648
+ }
649
+ /** Rebuild just this bot's gateway Client. Calls coalesce into one generation. */
650
+ reconnectGateway(reason) {
651
+ if (this.reconnectPromise)
652
+ return this.reconnectPromise;
653
+ if (!this.desiredRunning)
654
+ return Promise.reject(new Error("Discord adapter is stopped"));
655
+ const epoch = this.lifecycleEpoch;
656
+ const reconnect = (async () => {
657
+ // Yield once so reconnectPromise is installed before destroy/login can emit
658
+ // a synchronous gateway error and re-enter this method.
659
+ await Promise.resolve();
660
+ if (!this.desiredRunning || epoch !== this.lifecycleEpoch) {
661
+ throw new Error("Discord gateway reconnect cancelled by adapter stop");
662
+ }
663
+ this.healthStatus = "retrying";
664
+ this.lastReconnectReason = reason;
665
+ this.reconnectCount++;
666
+ this.emitHealthChanged();
667
+ for (let attempt = 0; this.desiredRunning && epoch === this.lifecycleEpoch; attempt++) {
668
+ if (attempt > 0) {
669
+ const delay = Math.min(this.reconnectBaseDelayMs * 2 ** Math.min(attempt - 1, 6), GATEWAY_MAX_BACKOFF_MS);
670
+ await new Promise(resolve => {
671
+ const timer = setTimeout(resolve, delay);
672
+ timer.unref?.();
673
+ });
674
+ if (!this.desiredRunning || epoch !== this.lifecycleEpoch)
675
+ break;
676
+ }
677
+ try {
678
+ await this.loginFreshClient(reason, epoch);
679
+ if (this.desiredRunning && epoch === this.lifecycleEpoch) {
680
+ this.healthStatus = "connected";
681
+ this.emitHealthChanged();
682
+ }
683
+ return;
684
+ }
685
+ catch (err) {
686
+ if (!this.desiredRunning || epoch !== this.lifecycleEpoch)
687
+ break;
688
+ this.healthStatus = "retrying";
689
+ console.warn(`[discord:${this.id}] gateway rebuild attempt ${attempt + 1} failed: ${err.message}`);
690
+ this.emitHealthChanged();
691
+ }
692
+ }
693
+ throw new Error("Discord gateway reconnect cancelled by adapter stop");
694
+ })().finally(() => {
695
+ if (this.reconnectPromise === reconnect)
696
+ this.reconnectPromise = null;
697
+ });
698
+ this.reconnectPromise = reconnect;
699
+ return reconnect;
700
+ }
701
+ async readyClient() {
702
+ // A few focused unit tests install a minimal REST-only client stand-in. Real
703
+ // discord.js Clients always expose isReady().
704
+ const isReady = typeof this.client.isReady === "function"
705
+ ? this.client.isReady()
706
+ : true;
707
+ if (isReady && !this.reconnectPromise)
708
+ return this.client;
709
+ const reconnect = this.reconnectPromise ?? this.reconnectGateway("outbound requested while gateway unavailable");
710
+ let timer;
711
+ try {
712
+ await Promise.race([
713
+ reconnect,
714
+ new Promise((_, reject) => {
715
+ timer = setTimeout(() => reject(new Error("Discord gateway reconnect timed out")), GATEWAY_RECONNECT_WAIT_MS);
716
+ timer.unref?.();
717
+ }),
718
+ ]);
719
+ }
720
+ finally {
721
+ if (timer)
722
+ clearTimeout(timer);
723
+ }
724
+ if (!this.client.isReady())
725
+ throw new Error("Discord gateway is not ready");
726
+ return this.client;
727
+ }
728
+ startGatewayWatchdog() {
729
+ if (this.watchdogTimer)
730
+ clearInterval(this.watchdogTimer);
731
+ this.lastWatchdogTickAt = this.now();
732
+ this.watchdogTimer = setInterval(() => this.checkGatewayLiveness(), this.watchdogIntervalMs);
733
+ this.watchdogTimer.unref?.();
734
+ }
735
+ checkGatewayLiveness() {
736
+ if (!this.desiredRunning || this.reconnectPromise)
737
+ return;
738
+ const now = this.now();
739
+ const elapsed = now - this.lastWatchdogTickAt;
740
+ this.lastWatchdogTickAt = now;
741
+ // Host suspend / an overloaded event loop makes wall-clock ACK age jump. Give
742
+ // discord.js a fresh heartbeat window instead of rebuilding immediately.
743
+ if (elapsed > this.watchdogIntervalMs * 3) {
744
+ this.generationReadyAt = now;
745
+ this.generationNonReadySince = now;
746
+ this.shardNonReadySince.clear();
747
+ return;
748
+ }
749
+ const shards = [...this.client.ws.shards.values()];
750
+ let staleReason = null;
751
+ if (!this.client.isReady() && this.generationNonReadySince === 0)
752
+ this.generationNonReadySince = now;
753
+ if (this.client.isReady())
754
+ this.generationNonReadySince = 0;
755
+ if (!this.client.isReady() && now - this.generationNonReadySince > this.staleThresholdMs) {
756
+ staleReason = `client non-ready for ${now - this.generationNonReadySince}ms`;
757
+ }
758
+ for (const shard of shards) {
759
+ const lastAck = shard.lastPingTimestamp;
760
+ const previous = this.previousHeartbeatAck.get(shard.id);
761
+ if (lastAck >= 0 && previous != null && lastAck > previous) {
762
+ const interval = lastAck - previous;
763
+ if (interval >= 5_000 && interval <= 120_000)
764
+ this.observedHeartbeatInterval.set(shard.id, interval);
765
+ }
766
+ if (lastAck >= 0)
767
+ this.previousHeartbeatAck.set(shard.id, lastAck);
768
+ const threshold = Math.max(this.staleThresholdMs, 3 * (this.observedHeartbeatInterval.get(shard.id) ?? 0));
769
+ if (shard.status === Status.Ready) {
770
+ this.shardNonReadySince.delete(shard.id);
771
+ if (lastAck === -1) {
772
+ if (this.generationReadyAt > 0 && now - this.generationReadyAt > threshold) {
773
+ staleReason = `shard ${shard.id} has no heartbeat ACK after startup grace`;
774
+ }
775
+ }
776
+ else if (now - lastAck > threshold) {
777
+ staleReason = `shard ${shard.id} heartbeat ACK stale by ${now - lastAck}ms`;
778
+ }
779
+ }
780
+ else {
781
+ const since = this.shardNonReadySince.get(shard.id) ?? now;
782
+ this.shardNonReadySince.set(shard.id, since);
783
+ if (now - since > threshold)
784
+ staleReason = `shard ${shard.id} status ${shard.status} for ${now - since}ms`;
785
+ }
786
+ }
787
+ if (staleReason) {
788
+ this.healthStatus = "stale";
789
+ this.emitHealthChanged();
790
+ console.warn(`[discord:${this.id}] gateway watchdog detected ${staleReason}`);
791
+ void this.reconnectGateway(`watchdog: ${staleReason}`).catch(() => { });
792
+ }
793
+ else if (this.client.isReady()
794
+ && shards.every(shard => shard.status === Status.Ready)
795
+ && this.healthStatus !== "connected") {
796
+ // discord.js owns the first recovery layer (RESUME + replay). Clear a
797
+ // transient error marker once ACK/status prove that native recovery won.
798
+ this.healthStatus = "connected";
799
+ this.emitHealthChanged();
800
+ }
801
+ }
802
+ getHealthSnapshot() {
803
+ const now = this.now();
804
+ const shards = [...this.client.ws.shards.values()].map(shard => {
805
+ const lastAck = shard.lastPingTimestamp >= 0 ? shard.lastPingTimestamp : null;
806
+ return {
807
+ id: shard.id,
808
+ status: shard.status,
809
+ lastHeartbeatAckAt: lastAck,
810
+ heartbeatAgeMs: lastAck == null ? null : Math.max(0, now - lastAck),
811
+ };
812
+ });
813
+ const heartbeatAcks = shards
814
+ .map(shard => shard.lastHeartbeatAckAt)
815
+ .filter((value) => value != null);
816
+ const lastHeartbeatAckAt = heartbeatAcks.length > 0 ? Math.min(...heartbeatAcks) : null;
817
+ return {
818
+ id: this.id,
819
+ type: this.type,
820
+ status: this.healthStatus,
821
+ generation: this.clientGeneration,
822
+ isReady: this.client.isReady(),
823
+ wsStatus: this.client.ws.status ?? null,
824
+ lastHeartbeatAckAt,
825
+ heartbeatAgeMs: lastHeartbeatAckAt == null ? null : Math.max(0, now - lastHeartbeatAckAt),
826
+ shards,
827
+ lastDispatchAt: this.lastDispatchAt,
828
+ lastReconnectAt: this.lastReconnectAt,
829
+ lastReconnectReason: this.lastReconnectReason,
830
+ reconnectCount: this.reconnectCount,
831
+ };
832
+ }
833
+ async handleClientReady(client, generation) {
834
+ if (!this.isCurrentClient(client, generation))
835
+ return;
836
+ this.healthStatus = "connected";
837
+ this.generationReadyAt = this.now();
838
+ this.generationNonReadySince = 0;
839
+ this.shardNonReadySince.clear();
840
+ try {
517
841
  // Register classic bot slash commands (skipped for a secondary bot sharing
518
842
  // a guild with the primary — only the primary owns the guild's commands).
519
843
  if (this.registerCommands)
520
844
  try {
521
- await this.client.application?.commands.set([
845
+ await client.application?.commands.set([
522
846
  {
523
847
  name: "start", description: t("slash.start"),
524
848
  options: [{
@@ -647,11 +971,40 @@ export class DiscordAdapter extends EventEmitter {
647
971
  catch (err) {
648
972
  // Non-fatal — slash commands may fail on network issues
649
973
  }
650
- this.emit("started", this.client.user?.username ?? "discord-bot", this.client.user?.id);
651
- });
652
- await this.client.login(this.botToken);
974
+ if (!this.isCurrentClient(client, generation))
975
+ return;
976
+ this.emit("started", client.user?.username ?? "discord-bot", client.user?.id);
977
+ }
978
+ catch (err) {
979
+ console.warn(`[discord:${this.id}] ready handler failed: ${err.message}`);
980
+ }
981
+ }
982
+ async start() {
983
+ if (this.desiredRunning && this.client.isReady())
984
+ return;
985
+ this.desiredRunning = true;
986
+ const epoch = ++this.lifecycleEpoch;
987
+ this.healthStatus = "starting";
988
+ this.queue.start();
989
+ try {
990
+ await this.loginFreshClient("startup", epoch);
991
+ this.startGatewayWatchdog();
992
+ }
993
+ catch (err) {
994
+ if (epoch === this.lifecycleEpoch) {
995
+ this.desiredRunning = false;
996
+ this.healthStatus = "stopped";
997
+ }
998
+ throw err;
999
+ }
653
1000
  }
654
1001
  async stop() {
1002
+ this.desiredRunning = false;
1003
+ this.lifecycleEpoch++;
1004
+ this.healthStatus = "stopped";
1005
+ if (this.watchdogTimer)
1006
+ clearInterval(this.watchdogTimer);
1007
+ this.watchdogTimer = null;
655
1008
  this.queue.stop();
656
1009
  this.client.destroy();
657
1010
  }
@@ -718,7 +1071,7 @@ export class DiscordAdapter extends EventEmitter {
718
1071
  }
719
1072
  catch { /* not in that channel — fall through to scan */ }
720
1073
  try {
721
- const guild = await this.client.guilds.fetch(this.guildId);
1074
+ const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
722
1075
  const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
723
1076
  for (const [, ch] of channels) {
724
1077
  try {
@@ -759,7 +1112,7 @@ export class DiscordAdapter extends EventEmitter {
759
1112
  }
760
1113
  catch { /* not in that channel — fall through to scan */ }
761
1114
  try {
762
- const guild = await this.client.guilds.fetch(this.guildId);
1115
+ const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
763
1116
  const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
764
1117
  for (const [, ch] of channels) {
765
1118
  try {
@@ -786,7 +1139,7 @@ export class DiscordAdapter extends EventEmitter {
786
1139
  }
787
1140
  catch { /* not in that channel — fall through to scan */ }
788
1141
  try {
789
- const guild = await this.client.guilds.fetch(this.guildId);
1142
+ const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
790
1143
  const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
791
1144
  for (const [, ch] of channels) {
792
1145
  try {
@@ -809,7 +1162,7 @@ export class DiscordAdapter extends EventEmitter {
809
1162
  const encoded = encodeURIComponent(emoji);
810
1163
  try {
811
1164
  // Direct REST call — single API request instead of 3 (fetchChannel → fetchMessage → react)
812
- await this.client.rest.put(`/channels/${channelId}/messages/${messageId}/reactions/${encoded}/@me`);
1165
+ await (await this.readyClient()).rest.put(`/channels/${channelId}/messages/${messageId}/reactions/${encoded}/@me`);
813
1166
  return;
814
1167
  }
815
1168
  catch (directError) {
@@ -977,7 +1330,7 @@ export class DiscordAdapter extends EventEmitter {
977
1330
  }
978
1331
  // ── Topology: create channel ────────────────────────────────────────────
979
1332
  async _resolveCategory() {
980
- const guild = await this.client.guilds.fetch(this.guildId);
1333
+ const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
981
1334
  await guild.channels.fetch();
982
1335
  const existing = guild.channels.cache.find((c) => c.type === ChannelType.GuildCategory && c.name === this.categoryName);
983
1336
  if (existing)
@@ -998,7 +1351,7 @@ export class DiscordAdapter extends EventEmitter {
998
1351
  return this.categoryIdPromise;
999
1352
  }
1000
1353
  async createTopic(name) {
1001
- const guild = await this.client.guilds.fetch(this.guildId);
1354
+ const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
1002
1355
  const categoryId = await this.ensureCategoryId();
1003
1356
  try {
1004
1357
  const channel = await guild.channels.create({
@@ -1024,7 +1377,7 @@ export class DiscordAdapter extends EventEmitter {
1024
1377
  }
1025
1378
  }
1026
1379
  async deleteTopic(topicId) {
1027
- const channel = await this.client.channels.fetch(String(topicId));
1380
+ const channel = await (await this.readyClient()).channels.fetch(String(topicId));
1028
1381
  // Only delete GuildText channels created by createTopic — never categories or forums
1029
1382
  if (channel && "type" in channel && channel.type === ChannelType.GuildText && "delete" in channel) {
1030
1383
  await channel.delete();
@@ -1032,7 +1385,7 @@ export class DiscordAdapter extends EventEmitter {
1032
1385
  }
1033
1386
  async topicExists(topicId) {
1034
1387
  try {
1035
- const channel = await this.client.channels.fetch(String(topicId));
1388
+ const channel = await (await this.readyClient()).channels.fetch(String(topicId));
1036
1389
  return channel != null;
1037
1390
  }
1038
1391
  catch {