@songsid/agend 2.1.4-beta.4 → 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.
Files changed (168) hide show
  1. package/README.md +1 -1
  2. package/README.zh-TW.md +1 -1
  3. package/dist/agent-endpoint.js +1 -1
  4. package/dist/agent-endpoint.js.map +1 -1
  5. package/dist/backend/antigravity.d.ts +9 -5
  6. package/dist/backend/antigravity.js +156 -31
  7. package/dist/backend/antigravity.js.map +1 -1
  8. package/dist/backend/claude-code.d.ts +62 -23
  9. package/dist/backend/claude-code.js +296 -43
  10. package/dist/backend/claude-code.js.map +1 -1
  11. package/dist/backend/codex.d.ts +18 -0
  12. package/dist/backend/codex.js +148 -8
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/gemini-cli.js +2 -2
  15. package/dist/backend/gemini-cli.js.map +1 -1
  16. package/dist/backend/grok.js +2 -2
  17. package/dist/backend/grok.js.map +1 -1
  18. package/dist/backend/kiro.d.ts +2 -0
  19. package/dist/backend/kiro.js +24 -3
  20. package/dist/backend/kiro.js.map +1 -1
  21. package/dist/backend/opencode.d.ts +27 -11
  22. package/dist/backend/opencode.js +65 -41
  23. package/dist/backend/opencode.js.map +1 -1
  24. package/dist/backend/types.d.ts +45 -3
  25. package/dist/backend/types.js +12 -1
  26. package/dist/backend/types.js.map +1 -1
  27. package/dist/channel/adapters/discord.d.ts +44 -2
  28. package/dist/channel/adapters/discord.js +526 -78
  29. package/dist/channel/adapters/discord.js.map +1 -1
  30. package/dist/channel/adapters/telegram.d.ts +30 -0
  31. package/dist/channel/adapters/telegram.js +212 -18
  32. package/dist/channel/adapters/telegram.js.map +1 -1
  33. package/dist/channel/agy-mcp-launcher.d.ts +2 -0
  34. package/dist/channel/agy-mcp-launcher.js +28 -0
  35. package/dist/channel/agy-mcp-launcher.js.map +1 -0
  36. package/dist/channel/ipc-bridge.d.ts +2 -1
  37. package/dist/channel/ipc-bridge.js +33 -4
  38. package/dist/channel/ipc-bridge.js.map +1 -1
  39. package/dist/channel/mcp-server.js +28 -25
  40. package/dist/channel/mcp-server.js.map +1 -1
  41. package/dist/channel/mcp-tools.js +4 -2
  42. package/dist/channel/mcp-tools.js.map +1 -1
  43. package/dist/channel/message-queue.js +62 -1
  44. package/dist/channel/message-queue.js.map +1 -1
  45. package/dist/channel/types.d.ts +31 -1
  46. package/dist/classic-channel-manager.d.ts +40 -3
  47. package/dist/classic-channel-manager.js +218 -31
  48. package/dist/classic-channel-manager.js.map +1 -1
  49. package/dist/cli.js +109 -37
  50. package/dist/cli.js.map +1 -1
  51. package/dist/config-validator.js +48 -7
  52. package/dist/config-validator.js.map +1 -1
  53. package/dist/config.d.ts +4 -0
  54. package/dist/config.js +12 -1
  55. package/dist/config.js.map +1 -1
  56. package/dist/cross-instance-envelope.d.ts +6 -0
  57. package/dist/cross-instance-envelope.js +30 -0
  58. package/dist/cross-instance-envelope.js.map +1 -0
  59. package/dist/daemon.d.ts +319 -9
  60. package/dist/daemon.js +1402 -255
  61. package/dist/daemon.js.map +1 -1
  62. package/dist/doctor.d.ts +41 -0
  63. package/dist/doctor.js +267 -0
  64. package/dist/doctor.js.map +1 -0
  65. package/dist/fleet-context.d.ts +47 -0
  66. package/dist/fleet-manager.d.ts +265 -11
  67. package/dist/fleet-manager.js +2273 -455
  68. package/dist/fleet-manager.js.map +1 -1
  69. package/dist/fleet-yaml-slim.d.ts +10 -0
  70. package/dist/fleet-yaml-slim.js +59 -0
  71. package/dist/fleet-yaml-slim.js.map +1 -0
  72. package/dist/general-knowledge/skills/backend-providers/SKILL.md +114 -0
  73. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +3 -1
  74. package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +52 -0
  75. package/dist/general-knowledge/skills/development-workflow/SKILL.md +28 -0
  76. package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -0
  77. package/dist/general-knowledge/skills/fleet-health/SKILL.md +1 -0
  78. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +1 -0
  79. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +1 -0
  80. package/dist/general-knowledge/skills/model-discovery/SKILL.md +64 -16
  81. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -0
  82. package/dist/general-knowledge/skills/scheduling/SKILL.md +1 -0
  83. package/dist/general-knowledge/skills/session-management/SKILL.md +1 -0
  84. package/dist/general-knowledge/skills/tui-effort/SKILL.md +1 -0
  85. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +30 -0
  86. package/dist/instance-lifecycle.d.ts +67 -0
  87. package/dist/instance-lifecycle.js +273 -8
  88. package/dist/instance-lifecycle.js.map +1 -1
  89. package/dist/instructions.d.ts +12 -0
  90. package/dist/instructions.js +35 -1
  91. package/dist/instructions.js.map +1 -1
  92. package/dist/locale.js +958 -288
  93. package/dist/locale.js.map +1 -1
  94. package/dist/login-flows.d.ts +91 -0
  95. package/dist/login-flows.js +170 -0
  96. package/dist/login-flows.js.map +1 -0
  97. package/dist/login-manager.d.ts +63 -0
  98. package/dist/login-manager.js +134 -0
  99. package/dist/login-manager.js.map +1 -0
  100. package/dist/network-family.d.ts +18 -0
  101. package/dist/network-family.js +20 -0
  102. package/dist/network-family.js.map +1 -0
  103. package/dist/outbound-handlers.d.ts +8 -0
  104. package/dist/outbound-handlers.js +101 -18
  105. package/dist/outbound-handlers.js.map +1 -1
  106. package/dist/outbound-schemas.d.ts +7 -1
  107. package/dist/outbound-schemas.js +9 -0
  108. package/dist/outbound-schemas.js.map +1 -1
  109. package/dist/reply-dedup.d.ts +7 -8
  110. package/dist/reply-dedup.js +0 -0
  111. package/dist/reply-dedup.js.map +1 -1
  112. package/dist/restart-progress.d.ts +2 -0
  113. package/dist/restart-progress.js +3 -0
  114. package/dist/restart-progress.js.map +1 -1
  115. package/dist/scheduler/db.d.ts +12 -0
  116. package/dist/scheduler/db.js +59 -0
  117. package/dist/scheduler/db.js.map +1 -1
  118. package/dist/service-installer.d.ts +11 -0
  119. package/dist/service-installer.js +84 -18
  120. package/dist/service-installer.js.map +1 -1
  121. package/dist/settings-api.js +1 -1
  122. package/dist/settings-api.js.map +1 -1
  123. package/dist/setup-wizard.js +2 -2
  124. package/dist/setup-wizard.js.map +1 -1
  125. package/dist/spawn-gate.d.ts +30 -0
  126. package/dist/spawn-gate.js +79 -0
  127. package/dist/spawn-gate.js.map +1 -0
  128. package/dist/storm-window.d.ts +83 -0
  129. package/dist/storm-window.js +251 -0
  130. package/dist/storm-window.js.map +1 -0
  131. package/dist/tips.d.ts +44 -0
  132. package/dist/tips.js +355 -0
  133. package/dist/tips.js.map +1 -0
  134. package/dist/tmux-manager.d.ts +16 -0
  135. package/dist/tmux-manager.js +110 -25
  136. package/dist/tmux-manager.js.map +1 -1
  137. package/dist/tool-progress.d.ts +40 -0
  138. package/dist/tool-progress.js +289 -0
  139. package/dist/tool-progress.js.map +1 -0
  140. package/dist/topic-commands.d.ts +42 -4
  141. package/dist/topic-commands.js +354 -66
  142. package/dist/topic-commands.js.map +1 -1
  143. package/dist/transcript-monitor.d.ts +15 -2
  144. package/dist/transcript-monitor.js +63 -17
  145. package/dist/transcript-monitor.js.map +1 -1
  146. package/dist/transcript-sources.d.ts +131 -0
  147. package/dist/transcript-sources.js +580 -0
  148. package/dist/transcript-sources.js.map +1 -0
  149. package/dist/types.d.ts +13 -0
  150. package/dist/ui/dashboard.html +55 -32
  151. package/dist/ui/settings.html +200 -60
  152. package/dist/ui/view.html +147 -30
  153. package/dist/usage/format-rich.js +24 -23
  154. package/dist/usage/format-rich.js.map +1 -1
  155. package/dist/usage/i18n-keys.d.ts +7 -0
  156. package/dist/usage/i18n-keys.js +34 -0
  157. package/dist/usage/i18n-keys.js.map +1 -0
  158. package/dist/usage/i18n.d.ts +5 -0
  159. package/dist/usage/i18n.js +27 -0
  160. package/dist/usage/i18n.js.map +1 -0
  161. package/dist/usage/providers.d.ts +19 -0
  162. package/dist/usage/providers.js +150 -74
  163. package/dist/usage/providers.js.map +1 -1
  164. package/dist/usage/usage-api.d.ts +8 -2
  165. package/dist/usage/usage-api.js +45 -23
  166. package/dist/usage/usage-api.js.map +1 -1
  167. package/dist/workflow-templates/default.md +2 -1
  168. package/package.json +2 -2
@@ -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;
@@ -265,6 +350,33 @@ export class DiscordAdapter extends EventEmitter {
265
350
  size: att.size,
266
351
  filename: att.name ?? undefined,
267
352
  }));
353
+ // A Discord reply carries only the referenced message id on the new
354
+ // message. Fetch the original so image attachments are delivered with
355
+ // the reply rather than reducing the reference to text alone. Keep this
356
+ // limited to real attachments: ordinary URL auto-embeds must not become
357
+ // downloads (the same policy as the outer message above).
358
+ let replyToText;
359
+ if (msg.reference?.messageId) {
360
+ try {
361
+ const ref = await msg.fetchReference();
362
+ replyToText = ref.content || ref.embeds?.[0]?.description || undefined;
363
+ for (const att of ref.attachments?.values() ?? []) {
364
+ if (!att.contentType?.startsWith("image/"))
365
+ continue;
366
+ if (!attachments.some(existing => existing.fileId === att.id)) {
367
+ attachments.push({
368
+ kind: "photo",
369
+ fileId: att.id,
370
+ mime: att.contentType,
371
+ size: att.size,
372
+ filename: att.name ?? undefined,
373
+ });
374
+ }
375
+ this.attachmentUrls.set(att.id, att.url);
376
+ }
377
+ }
378
+ catch { /* deleted message or no permission */ }
379
+ }
268
380
  // Embed images become synthetic photo attachments so the normal download
269
381
  // path (attachment-handler → downloadAttachment → image_path) serves them
270
382
  // without knowing where the bytes came from. Their synthetic ids are
@@ -293,15 +405,7 @@ export class DiscordAdapter extends EventEmitter {
293
405
  else
294
406
  break;
295
407
  }
296
- let replyToText;
297
- if (msg.reference?.messageId) {
298
- try {
299
- const ref = await msg.fetchReference();
300
- replyToText = ref.content || ref.embeds?.[0]?.description || undefined;
301
- }
302
- catch { /* deleted message or no permission */ }
303
- }
304
- this.emit("message", {
408
+ this.emitFromClient(client, generation, "message", {
305
409
  source: "discord",
306
410
  adapterId: this.id,
307
411
  chatId,
@@ -326,8 +430,10 @@ export class DiscordAdapter extends EventEmitter {
326
430
  // Handle button interactions and slash commands
327
431
  // Trust boundary: interaction responses can throw DiscordAPIError[10062] if the
328
432
  // interaction expires (>3s). Catch to prevent crashing the entire daemon.
329
- this.client.on("interactionCreate", async (interaction) => {
433
+ client.on("interactionCreate", async (interaction) => {
330
434
  try {
435
+ if (!this.isCurrentClient(client, generation))
436
+ return;
331
437
  // Buttons: acknowledge IMMEDIATELY, before any guild/channel filtering.
332
438
  // A button has a 3s ack window; any early return (unknown guild/channel)
333
439
  // or a downstream no-op (e.g. the cancel button was already cleared) would
@@ -344,7 +450,7 @@ export class DiscordAdapter extends EventEmitter {
344
450
  // console.log(`[discord] ignoring button from non-primary guild ${interaction.guildId} channel ${interaction.channelId}`);
345
451
  return;
346
452
  }
347
- this.emit("callback_query", {
453
+ this.emitFromClient(client, generation, "callback_query", {
348
454
  callbackData: interaction.customId,
349
455
  chatId: this.guildId,
350
456
  threadId: interaction.channelId,
@@ -372,7 +478,7 @@ export class DiscordAdapter extends EventEmitter {
372
478
  const callbackData = interaction.values[0];
373
479
  if (!callbackData)
374
480
  return;
375
- this.emit("callback_query", {
481
+ this.emitFromClient(client, generation, "callback_query", {
376
482
  callbackData,
377
483
  chatId: this.guildId,
378
484
  threadId: interaction.channelId,
@@ -392,7 +498,7 @@ export class DiscordAdapter extends EventEmitter {
392
498
  if (interaction.commandName === "chat") {
393
499
  const text = interaction.options.getString("message") ?? "";
394
500
  await interaction.deferReply();
395
- this.emit("slash_command", {
501
+ this.emitFromClient(client, generation, "slash_command", {
396
502
  command: "chat",
397
503
  channelId: interaction.channelId,
398
504
  channelName,
@@ -419,7 +525,7 @@ export class DiscordAdapter extends EventEmitter {
419
525
  for (const opt of interaction.options.data) {
420
526
  options[opt.name] = opt.value;
421
527
  }
422
- this.emit("slash_command", {
528
+ this.emitFromClient(client, generation, "slash_command", {
423
529
  command: interaction.commandName,
424
530
  channelId: interaction.channelId,
425
531
  channelName,
@@ -433,6 +539,12 @@ export class DiscordAdapter extends EventEmitter {
433
539
  catch {
434
540
  return undefined;
435
541
  } },
542
+ dismissResponse: async () => {
543
+ try {
544
+ await interaction.deleteReply();
545
+ }
546
+ catch { /* interaction may already be gone */ }
547
+ },
436
548
  respondChoices: async (text, choices) => {
437
549
  const select = new StringSelectMenuBuilder()
438
550
  .setCustomId(CLASSIC_START_BACKEND_SELECT_ID)
@@ -459,14 +571,18 @@ export class DiscordAdapter extends EventEmitter {
459
571
  }
460
572
  });
461
573
  // Handle channel deletion (equivalent to topic_closed)
462
- this.client.on("guildCreate", (guild) => {
463
- 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", {
464
578
  groupId: guild.id,
465
579
  groupTitle: guild.name,
466
580
  source: "discord",
467
581
  });
468
582
  });
469
- this.client.on("channelDelete", (channel) => {
583
+ client.on("channelDelete", (channel) => {
584
+ if (!this.isCurrentClient(client, generation))
585
+ return;
470
586
  if (!("guildId" in channel))
471
587
  return;
472
588
  if (channel.guildId !== this.guildId) {
@@ -474,7 +590,7 @@ export class DiscordAdapter extends EventEmitter {
474
590
  return;
475
591
  // Allowed: an open classic channel in a non-primary guild was deleted.
476
592
  }
477
- this.emit("topic_closed", {
593
+ this.emitFromClient(client, generation, "topic_closed", {
478
594
  chatId: this.guildId,
479
595
  threadId: channel.id,
480
596
  });
@@ -486,19 +602,252 @@ export class DiscordAdapter extends EventEmitter {
486
602
  // console.log(`[AgEnD] setOpenChannels: ${channelIds.length} channels`, channelIds);
487
603
  }
488
604
  // ── Lifecycle ──────────────────────────────────────────────────────────
489
- async start() {
490
- this.queue.start();
491
- 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 {
492
841
  // Register classic bot slash commands (skipped for a secondary bot sharing
493
842
  // a guild with the primary — only the primary owns the guild's commands).
494
843
  if (this.registerCommands)
495
844
  try {
496
- await this.client.application?.commands.set([
845
+ await client.application?.commands.set([
497
846
  {
498
847
  name: "start", description: t("slash.start"),
499
848
  options: [{
500
849
  name: "backend",
501
- description: "Backend to start",
850
+ description: t("slash.option.backend"),
502
851
  type: ApplicationCommandOptionType.String,
503
852
  required: true,
504
853
  choices: DISCORD_START_BACKEND_CHOICES,
@@ -506,16 +855,16 @@ export class DiscordAdapter extends EventEmitter {
506
855
  },
507
856
  { name: "stop", description: t("slash.stop") },
508
857
  {
509
- name: "pause", description: "🔒 Pause an idle instance",
510
- options: [{ name: "instance", description: "Instance name (required in General)", type: ApplicationCommandOptionType.String, required: false }],
858
+ name: "pause", description: "🔒 " + t("slash.pause"),
859
+ options: [{ name: "instance", description: t("slash.option.instance"), type: ApplicationCommandOptionType.String, required: false }],
511
860
  },
512
861
  {
513
- name: "wake", description: "🔒 Wake a paused instance",
514
- options: [{ name: "instance", description: "Instance name (required in General)", type: ApplicationCommandOptionType.String, required: false }],
862
+ name: "wake", description: "🔒 " + t("slash.wake"),
863
+ options: [{ name: "instance", description: t("slash.option.instance"), type: ApplicationCommandOptionType.String, required: false }],
515
864
  },
516
865
  {
517
866
  name: "chat", description: t("slash.chat"),
518
- options: [{ name: "message", description: "Your message", type: 3, required: true }],
867
+ options: [{ name: "message", description: t("slash.option.message"), type: 3, required: true }],
519
868
  },
520
869
  { name: "status", description: "🔒 " + t("slash.status") },
521
870
  { name: "sysinfo", description: t("slash.sysinfo") },
@@ -525,15 +874,77 @@ export class DiscordAdapter extends EventEmitter {
525
874
  { name: "update", description: "🔒 " + t("slash.update") },
526
875
  { name: "doctor", description: "🔒 " + t("slash.doctor") },
527
876
  { name: "usage", description: t("slash.usage") },
877
+ {
878
+ name: "tips", description: t("slash.tips"),
879
+ options: [{
880
+ name: "mode", description: t("slash.option.tips_mode"),
881
+ type: ApplicationCommandOptionType.String, required: false,
882
+ choices: [
883
+ { name: "on", value: "on" },
884
+ { name: "off", value: "off" },
885
+ { name: "advanced on", value: "advanced on" },
886
+ ],
887
+ }],
888
+ },
528
889
  { name: "compact", description: "🔒 " + t("slash.compact") },
890
+ {
891
+ name: "steer", description: t("slash.steer"),
892
+ options: [{
893
+ name: "message", description: t("slash.option.steer_message"),
894
+ type: ApplicationCommandOptionType.String, required: true,
895
+ }],
896
+ },
897
+ {
898
+ name: "btw", description: t("slash.btw"),
899
+ options: [{
900
+ name: "message", description: t("slash.option.btw_message"),
901
+ type: ApplicationCommandOptionType.String, required: true,
902
+ }],
903
+ },
904
+ {
905
+ name: "login", description: "🔒 " + t("slash.login"),
906
+ options: [
907
+ {
908
+ name: "backend", description: t("slash.option.login_backend"),
909
+ type: ApplicationCommandOptionType.String, required: false,
910
+ choices: [
911
+ { name: "claude-code", value: "claude-code" },
912
+ { name: "codex", value: "codex" },
913
+ { name: "kiro-cli", value: "kiro-cli" },
914
+ { name: "grok", value: "grok" },
915
+ { name: "antigravity", value: "antigravity" },
916
+ ],
917
+ },
918
+ { name: "code", description: t("slash.option.login_code"), type: ApplicationCommandOptionType.String, required: false },
919
+ { name: "cancel", description: t("slash.option.login_cancel"), type: ApplicationCommandOptionType.Boolean, required: false },
920
+ ],
921
+ },
922
+ {
923
+ name: "install-cli", description: "🔒 " + t("slash.install_cli"),
924
+ options: [
925
+ {
926
+ name: "backend", description: t("slash.option.install_backend"),
927
+ type: ApplicationCommandOptionType.String, required: false,
928
+ choices: [
929
+ { name: "claude-code", value: "claude-code" },
930
+ { name: "codex", value: "codex" },
931
+ { name: "kiro-cli", value: "kiro-cli" },
932
+ { name: "grok", value: "grok" },
933
+ { name: "antigravity", value: "antigravity" },
934
+ { name: "opencode", value: "opencode" },
935
+ ],
936
+ },
937
+ { name: "cancel", description: t("slash.option.install_cancel"), type: ApplicationCommandOptionType.Boolean, required: false },
938
+ ],
939
+ },
529
940
  { name: "clear", description: "🔒 " + t("slash.clear") },
530
941
  {
531
- name: "model", description: "🔒 Switch model (admin only)",
942
+ name: "model", description: "🔒 " + t("slash.model"),
532
943
  },
533
944
  {
534
945
  name: "effort", description: "🔒 " + t("slash.effort"),
535
946
  options: [{
536
- name: "level", description: "low | medium | high | xhigh | max",
947
+ name: "level", description: t("slash.option.effort_level"),
537
948
  type: ApplicationCommandOptionType.String, required: false,
538
949
  choices: [
539
950
  { name: "low", value: "low" }, { name: "medium", value: "medium" },
@@ -546,13 +957,13 @@ export class DiscordAdapter extends EventEmitter {
546
957
  {
547
958
  name: "save", description: "🔒 " + t("slash.save"),
548
959
  options: [
549
- { name: "filename", description: "File name to save as", type: 3, required: true },
550
- { name: "force", description: "Overwrite if file exists", type: 5, required: false },
960
+ { name: "filename", description: t("slash.option.save_filename"), type: 3, required: true },
961
+ { name: "force", description: t("slash.option.save_force"), type: 5, required: false },
551
962
  ],
552
963
  },
553
964
  {
554
965
  name: "load", description: "🔒 " + t("slash.load"),
555
- options: [{ name: "filename", description: "File name to load", type: 3, required: true }],
966
+ options: [{ name: "filename", description: t("slash.option.load_filename"), type: 3, required: true }],
556
967
  },
557
968
  { name: "cancel", description: t("slash.cancel") },
558
969
  ]);
@@ -560,11 +971,40 @@ export class DiscordAdapter extends EventEmitter {
560
971
  catch (err) {
561
972
  // Non-fatal — slash commands may fail on network issues
562
973
  }
563
- this.emit("started", this.client.user?.username ?? "discord-bot", this.client.user?.id);
564
- });
565
- 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
+ }
566
1000
  }
567
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;
568
1008
  this.queue.stop();
569
1009
  this.client.destroy();
570
1010
  }
@@ -597,13 +1037,15 @@ export class DiscordAdapter extends EventEmitter {
597
1037
  const chunks = splitText(text, chunkLimit);
598
1038
  if (chunks.length === 0)
599
1039
  throw new Error("Empty text");
600
- // disablePreview → suppress the link embed(s) for this message.
601
- const first = await channel.send(opts?.disablePreview
602
- ? { content: chunks[0], flags: MessageFlags.SuppressEmbeds }
603
- : chunks[0]);
604
- // Enqueue remaining chunks
605
- for (let i = 1; i < chunks.length; i++) {
606
- this.queue.enqueue(chatId, opts?.threadId, { type: "content", text: chunks[i] });
1040
+ // Await every platform POST before reporting success. Previously only the
1041
+ // first chunk was awaited and later chunks were fire-and-forget queue items,
1042
+ // so the reply tool could return success for a silently truncated message.
1043
+ let first;
1044
+ for (const chunk of chunks) {
1045
+ const sent = await channel.send(opts?.disablePreview
1046
+ ? { content: chunk, flags: MessageFlags.SuppressEmbeds }
1047
+ : chunk);
1048
+ first ??= sent;
607
1049
  }
608
1050
  return {
609
1051
  messageId: first.id,
@@ -629,7 +1071,7 @@ export class DiscordAdapter extends EventEmitter {
629
1071
  }
630
1072
  catch { /* not in that channel — fall through to scan */ }
631
1073
  try {
632
- const guild = await this.client.guilds.fetch(this.guildId);
1074
+ const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
633
1075
  const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
634
1076
  for (const [, ch] of channels) {
635
1077
  try {
@@ -653,6 +1095,12 @@ export class DiscordAdapter extends EventEmitter {
653
1095
  }
654
1096
  /** Edit text and clear components (Discord keeps components on a plain edit,
655
1097
  * so we must pass an empty array to drop the Cancel button). */
1098
+ /** Keyboard-only removal — Discord keeps the original content untouched. */
1099
+ async removeMessageButtons(chatId, messageId, threadId) {
1100
+ const channel = await this._fetchTextChannel(threadId ?? chatId);
1101
+ const msg = await channel.messages.fetch(messageId);
1102
+ await msg.edit({ components: [] });
1103
+ }
656
1104
  async editMessageRemoveButtons(chatId, messageId, text, threadId) {
657
1105
  // Prefer the exact channel (handles forum-topic threads, which a GuildText
658
1106
  // scan misses); fall back to scanning top-level text channels.
@@ -664,7 +1112,7 @@ export class DiscordAdapter extends EventEmitter {
664
1112
  }
665
1113
  catch { /* not in that channel — fall through to scan */ }
666
1114
  try {
667
- const guild = await this.client.guilds.fetch(this.guildId);
1115
+ const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
668
1116
  const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
669
1117
  for (const [, ch] of channels) {
670
1118
  try {
@@ -691,7 +1139,7 @@ export class DiscordAdapter extends EventEmitter {
691
1139
  }
692
1140
  catch { /* not in that channel — fall through to scan */ }
693
1141
  try {
694
- const guild = await this.client.guilds.fetch(this.guildId);
1142
+ const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
695
1143
  const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
696
1144
  for (const [, ch] of channels) {
697
1145
  try {
@@ -714,7 +1162,7 @@ export class DiscordAdapter extends EventEmitter {
714
1162
  const encoded = encodeURIComponent(emoji);
715
1163
  try {
716
1164
  // Direct REST call — single API request instead of 3 (fetchChannel → fetchMessage → react)
717
- 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`);
718
1166
  return;
719
1167
  }
720
1168
  catch (directError) {
@@ -882,7 +1330,7 @@ export class DiscordAdapter extends EventEmitter {
882
1330
  }
883
1331
  // ── Topology: create channel ────────────────────────────────────────────
884
1332
  async _resolveCategory() {
885
- const guild = await this.client.guilds.fetch(this.guildId);
1333
+ const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
886
1334
  await guild.channels.fetch();
887
1335
  const existing = guild.channels.cache.find((c) => c.type === ChannelType.GuildCategory && c.name === this.categoryName);
888
1336
  if (existing)
@@ -903,7 +1351,7 @@ export class DiscordAdapter extends EventEmitter {
903
1351
  return this.categoryIdPromise;
904
1352
  }
905
1353
  async createTopic(name) {
906
- const guild = await this.client.guilds.fetch(this.guildId);
1354
+ const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
907
1355
  const categoryId = await this.ensureCategoryId();
908
1356
  try {
909
1357
  const channel = await guild.channels.create({
@@ -929,7 +1377,7 @@ export class DiscordAdapter extends EventEmitter {
929
1377
  }
930
1378
  }
931
1379
  async deleteTopic(topicId) {
932
- const channel = await this.client.channels.fetch(String(topicId));
1380
+ const channel = await (await this.readyClient()).channels.fetch(String(topicId));
933
1381
  // Only delete GuildText channels created by createTopic — never categories or forums
934
1382
  if (channel && "type" in channel && channel.type === ChannelType.GuildText && "delete" in channel) {
935
1383
  await channel.delete();
@@ -937,7 +1385,7 @@ export class DiscordAdapter extends EventEmitter {
937
1385
  }
938
1386
  async topicExists(topicId) {
939
1387
  try {
940
- const channel = await this.client.channels.fetch(String(topicId));
1388
+ const channel = await (await this.readyClient()).channels.fetch(String(topicId));
941
1389
  return channel != null;
942
1390
  }
943
1391
  catch {