@songsid/agend 2.1.4-beta.6 → 2.1.4-beta.60

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 (185) hide show
  1. package/README.md +1 -1
  2. package/README.zh-TW.md +1 -1
  3. package/dist/agent-cli-instructions.md +2 -2
  4. package/dist/agent-endpoint.js +1 -1
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/backend/antigravity.d.ts +12 -8
  7. package/dist/backend/antigravity.js +189 -42
  8. package/dist/backend/antigravity.js.map +1 -1
  9. package/dist/backend/claude-code.d.ts +87 -23
  10. package/dist/backend/claude-code.js +361 -46
  11. package/dist/backend/claude-code.js.map +1 -1
  12. package/dist/backend/codex.d.ts +18 -0
  13. package/dist/backend/codex.js +143 -7
  14. package/dist/backend/codex.js.map +1 -1
  15. package/dist/backend/gemini-cli.js +2 -2
  16. package/dist/backend/gemini-cli.js.map +1 -1
  17. package/dist/backend/grok.js +2 -2
  18. package/dist/backend/grok.js.map +1 -1
  19. package/dist/backend/kiro.d.ts +52 -2
  20. package/dist/backend/kiro.js +267 -12
  21. package/dist/backend/kiro.js.map +1 -1
  22. package/dist/backend/opencode.js +17 -4
  23. package/dist/backend/opencode.js.map +1 -1
  24. package/dist/backend/types.d.ts +122 -3
  25. package/dist/backend/types.js +5 -1
  26. package/dist/backend/types.js.map +1 -1
  27. package/dist/backend-outage.d.ts +61 -0
  28. package/dist/backend-outage.js +71 -0
  29. package/dist/backend-outage.js.map +1 -0
  30. package/dist/channel/adapters/discord.d.ts +44 -2
  31. package/dist/channel/adapters/discord.js +529 -89
  32. package/dist/channel/adapters/discord.js.map +1 -1
  33. package/dist/channel/adapters/telegram.d.ts +30 -0
  34. package/dist/channel/adapters/telegram.js +221 -19
  35. package/dist/channel/adapters/telegram.js.map +1 -1
  36. package/dist/channel/agy-mcp-launcher.d.ts +2 -0
  37. package/dist/channel/agy-mcp-launcher.js +28 -0
  38. package/dist/channel/agy-mcp-launcher.js.map +1 -0
  39. package/dist/channel/ipc-bridge.d.ts +2 -1
  40. package/dist/channel/ipc-bridge.js +33 -4
  41. package/dist/channel/ipc-bridge.js.map +1 -1
  42. package/dist/channel/markdown-chunk.d.ts +42 -0
  43. package/dist/channel/markdown-chunk.js +205 -0
  44. package/dist/channel/markdown-chunk.js.map +1 -0
  45. package/dist/channel/mcp-server.js +28 -25
  46. package/dist/channel/mcp-server.js.map +1 -1
  47. package/dist/channel/mcp-tools.js +6 -4
  48. package/dist/channel/mcp-tools.js.map +1 -1
  49. package/dist/channel/message-queue.js +64 -8
  50. package/dist/channel/message-queue.js.map +1 -1
  51. package/dist/channel/types.d.ts +31 -1
  52. package/dist/classic-channel-manager.d.ts +65 -0
  53. package/dist/classic-channel-manager.js +172 -3
  54. package/dist/classic-channel-manager.js.map +1 -1
  55. package/dist/cli.js +124 -83
  56. package/dist/cli.js.map +1 -1
  57. package/dist/config-validator.js +16 -0
  58. package/dist/config-validator.js.map +1 -1
  59. package/dist/config.d.ts +4 -0
  60. package/dist/config.js +12 -1
  61. package/dist/config.js.map +1 -1
  62. package/dist/cross-instance-envelope.d.ts +6 -0
  63. package/dist/cross-instance-envelope.js +30 -0
  64. package/dist/cross-instance-envelope.js.map +1 -0
  65. package/dist/daemon.d.ts +506 -11
  66. package/dist/daemon.js +2162 -313
  67. package/dist/daemon.js.map +1 -1
  68. package/dist/doctor.d.ts +41 -0
  69. package/dist/doctor.js +267 -0
  70. package/dist/doctor.js.map +1 -0
  71. package/dist/fleet-context.d.ts +63 -0
  72. package/dist/fleet-context.js.map +1 -1
  73. package/dist/fleet-manager.d.ts +422 -12
  74. package/dist/fleet-manager.js +2961 -635
  75. package/dist/fleet-manager.js.map +1 -1
  76. package/dist/fleet-yaml-slim.d.ts +10 -0
  77. package/dist/fleet-yaml-slim.js +59 -0
  78. package/dist/fleet-yaml-slim.js.map +1 -0
  79. package/dist/general-knowledge/skills/backend-providers/SKILL.md +114 -0
  80. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +4 -1
  81. package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +52 -0
  82. package/dist/general-knowledge/skills/development-workflow/SKILL.md +28 -0
  83. package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -0
  84. package/dist/general-knowledge/skills/fleet-health/SKILL.md +1 -0
  85. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +1 -0
  86. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +1 -0
  87. package/dist/general-knowledge/skills/model-discovery/SKILL.md +64 -16
  88. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -0
  89. package/dist/general-knowledge/skills/scheduling/SKILL.md +1 -0
  90. package/dist/general-knowledge/skills/session-management/SKILL.md +172 -8
  91. package/dist/general-knowledge/skills/tui-effort/SKILL.md +1 -0
  92. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +30 -0
  93. package/dist/instance-lifecycle.d.ts +99 -0
  94. package/dist/instance-lifecycle.js +357 -11
  95. package/dist/instance-lifecycle.js.map +1 -1
  96. package/dist/instructions.d.ts +12 -0
  97. package/dist/instructions.js +74 -3
  98. package/dist/instructions.js.map +1 -1
  99. package/dist/locale.js +1022 -310
  100. package/dist/locale.js.map +1 -1
  101. package/dist/login-flows.d.ts +91 -0
  102. package/dist/login-flows.js +170 -0
  103. package/dist/login-flows.js.map +1 -0
  104. package/dist/login-manager.d.ts +63 -0
  105. package/dist/login-manager.js +134 -0
  106. package/dist/login-manager.js.map +1 -0
  107. package/dist/network-family.d.ts +18 -0
  108. package/dist/network-family.js +20 -0
  109. package/dist/network-family.js.map +1 -0
  110. package/dist/outbound-handlers.d.ts +16 -1
  111. package/dist/outbound-handlers.js +174 -40
  112. package/dist/outbound-handlers.js.map +1 -1
  113. package/dist/outbound-schemas.d.ts +11 -3
  114. package/dist/outbound-schemas.js +11 -0
  115. package/dist/outbound-schemas.js.map +1 -1
  116. package/dist/pane-input-residue.d.ts +52 -0
  117. package/dist/pane-input-residue.js +107 -0
  118. package/dist/pane-input-residue.js.map +1 -0
  119. package/dist/process-memory.d.ts +6 -0
  120. package/dist/process-memory.js +45 -0
  121. package/dist/process-memory.js.map +1 -1
  122. package/dist/reply-dedup.d.ts +7 -8
  123. package/dist/reply-dedup.js +0 -0
  124. package/dist/reply-dedup.js.map +1 -1
  125. package/dist/restart-progress.d.ts +2 -0
  126. package/dist/restart-progress.js +3 -0
  127. package/dist/restart-progress.js.map +1 -1
  128. package/dist/scheduler/db.d.ts +12 -0
  129. package/dist/scheduler/db.js +59 -0
  130. package/dist/scheduler/db.js.map +1 -1
  131. package/dist/service-installer.d.ts +11 -0
  132. package/dist/service-installer.js +84 -18
  133. package/dist/service-installer.js.map +1 -1
  134. package/dist/settings-api.js +1 -1
  135. package/dist/settings-api.js.map +1 -1
  136. package/dist/setup-wizard.js +2 -2
  137. package/dist/setup-wizard.js.map +1 -1
  138. package/dist/spawn-gate.d.ts +30 -0
  139. package/dist/spawn-gate.js +79 -0
  140. package/dist/spawn-gate.js.map +1 -0
  141. package/dist/steer-capability.d.ts +1 -0
  142. package/dist/steer-capability.js +6 -0
  143. package/dist/steer-capability.js.map +1 -0
  144. package/dist/storm-window.d.ts +83 -0
  145. package/dist/storm-window.js +251 -0
  146. package/dist/storm-window.js.map +1 -0
  147. package/dist/tips.d.ts +44 -0
  148. package/dist/tips.js +355 -0
  149. package/dist/tips.js.map +1 -0
  150. package/dist/tmux-manager.d.ts +16 -0
  151. package/dist/tmux-manager.js +110 -25
  152. package/dist/tmux-manager.js.map +1 -1
  153. package/dist/tool-progress.d.ts +40 -0
  154. package/dist/tool-progress.js +289 -0
  155. package/dist/tool-progress.js.map +1 -0
  156. package/dist/topic-commands.d.ts +45 -6
  157. package/dist/topic-commands.js +394 -66
  158. package/dist/topic-commands.js.map +1 -1
  159. package/dist/transcript-monitor.d.ts +15 -2
  160. package/dist/transcript-monitor.js +63 -17
  161. package/dist/transcript-monitor.js.map +1 -1
  162. package/dist/transcript-sources.d.ts +131 -0
  163. package/dist/transcript-sources.js +580 -0
  164. package/dist/transcript-sources.js.map +1 -0
  165. package/dist/types.d.ts +11 -0
  166. package/dist/ui/dashboard.html +55 -32
  167. package/dist/ui/settings.html +200 -60
  168. package/dist/ui/view.html +147 -30
  169. package/dist/usage/format-rich.d.ts +1 -1
  170. package/dist/usage/format-rich.js +28 -24
  171. package/dist/usage/format-rich.js.map +1 -1
  172. package/dist/usage/i18n-keys.d.ts +7 -0
  173. package/dist/usage/i18n-keys.js +34 -0
  174. package/dist/usage/i18n-keys.js.map +1 -0
  175. package/dist/usage/i18n.d.ts +5 -0
  176. package/dist/usage/i18n.js +27 -0
  177. package/dist/usage/i18n.js.map +1 -0
  178. package/dist/usage/providers.d.ts +21 -0
  179. package/dist/usage/providers.js +153 -75
  180. package/dist/usage/providers.js.map +1 -1
  181. package/dist/usage/usage-api.d.ts +11 -3
  182. package/dist/usage/usage-api.js +61 -24
  183. package/dist/usage/usage-api.js.map +1 -1
  184. package/dist/workflow-templates/default.md +2 -1
  185. package/package.json +2 -2
@@ -6,9 +6,14 @@ 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
+ import { splitTextFenceAware } from "../markdown-chunk.js";
11
12
  const DISCORD_MAX_LENGTH = 2000;
13
+ const GATEWAY_WATCHDOG_INTERVAL_MS = 30_000;
14
+ const GATEWAY_STALE_MIN_MS = 180_000;
15
+ const GATEWAY_RECONNECT_WAIT_MS = 30_000;
16
+ const GATEWAY_MAX_BACKOFF_MS = 5 * 60_000;
12
17
  /** Curated ClassicBot backends for Discord's native slash-option dropdown. */
13
18
  export const DISCORD_START_BACKEND_CHOICES = [
14
19
  { name: "Claude Code", value: "claude-code" },
@@ -24,6 +29,27 @@ export class DiscordAdapter extends EventEmitter {
24
29
  topology = "channels";
25
30
  id;
26
31
  client;
32
+ clientFactory;
33
+ now;
34
+ watchdogIntervalMs;
35
+ staleThresholdMs;
36
+ reconnectBaseDelayMs;
37
+ desiredRunning = false;
38
+ lifecycleEpoch = 0;
39
+ clientGeneration = 0;
40
+ reconnectPromise = null;
41
+ watchdogTimer = null;
42
+ lastWatchdogTickAt = 0;
43
+ generationReadyAt = 0;
44
+ generationNonReadySince = 0;
45
+ shardNonReadySince = new Map();
46
+ previousHeartbeatAck = new Map();
47
+ observedHeartbeatInterval = new Map();
48
+ healthStatus = "stopped";
49
+ lastDispatchAt = null;
50
+ lastReconnectAt = null;
51
+ lastReconnectReason = null;
52
+ reconnectCount = 0;
27
53
  botToken;
28
54
  accessManager;
29
55
  inboxDir;
@@ -47,20 +73,12 @@ export class DiscordAdapter extends EventEmitter {
47
73
  this.generalChannelId = opts.generalChannelId;
48
74
  this.registerCommands = opts.registerCommands !== false;
49
75
  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
- });
76
+ this.clientFactory = opts.clientFactory ?? (() => this.buildClient());
77
+ this.now = opts.now ?? Date.now;
78
+ this.watchdogIntervalMs = opts.watchdogIntervalMs ?? GATEWAY_WATCHDOG_INTERVAL_MS;
79
+ this.staleThresholdMs = opts.staleThresholdMs ?? GATEWAY_STALE_MIN_MS;
80
+ this.reconnectBaseDelayMs = opts.reconnectBaseDelayMs ?? 5_000;
81
+ this.client = this.clientFactory();
64
82
  this.queue = new MessageQueue({
65
83
  send: async (chatId, threadId, text) => {
66
84
  const channel = await this._fetchTextChannel(threadId ?? chatId);
@@ -78,23 +96,87 @@ export class DiscordAdapter extends EventEmitter {
78
96
  return { messageId: msg.id };
79
97
  },
80
98
  });
81
- this._registerHandlers();
99
+ this.registerClientHandlers(this.client, this.clientGeneration);
100
+ }
101
+ buildClient() {
102
+ return new Client({
103
+ intents: [
104
+ GatewayIntentBits.Guilds,
105
+ GatewayIntentBits.GuildMessages,
106
+ GatewayIntentBits.MessageContent,
107
+ GatewayIntentBits.GuildMessageReactions,
108
+ ],
109
+ // Events for messages/reactions created before this process started are partial.
110
+ partials: [Partials.Message, Partials.Reaction, Partials.User],
111
+ });
82
112
  }
83
113
  async _fetchTextChannel(channelId) {
84
- const channel = await this.client.channels.fetch(channelId);
114
+ const client = await this.readyClient();
115
+ const channel = await client.channels.fetch(channelId);
85
116
  if (!channel?.isTextBased()) {
86
117
  throw new Error(`Channel ${channelId} is not a text channel`);
87
118
  }
88
119
  return channel;
89
120
  }
90
- _registerHandlers() {
121
+ registerClientHandlers(client, generation) {
91
122
  // Client/shard errors (WebSocket hiccups, gateway resumes, etc.). discord.js
92
123
  // auto-reconnects; we just need a listener so Node's EventEmitter doesn't
93
124
  // rethrow on "error" — without one it surfaces as an uncaughtException and
94
125
  // the process-level handler tears down the whole fleet (the built-in adapter
95
126
  // 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}`));
127
+ client.on("error", (err) => {
128
+ if (!this.isCurrentClient(client, generation))
129
+ return;
130
+ console.warn(`[discord:${this.id}] client error: ${err?.message ?? err}`);
131
+ this.healthStatus = "retrying";
132
+ this.emitHealthChanged();
133
+ });
134
+ client.on("shardError", (err, shardId) => {
135
+ if (!this.isCurrentClient(client, generation))
136
+ return;
137
+ console.warn(`[discord:${this.id}] shard ${shardId} error: ${err?.message ?? err}`);
138
+ this.healthStatus = "retrying";
139
+ this.emitHealthChanged();
140
+ });
141
+ client.on("shardReconnecting", (shardId) => {
142
+ if (!this.isCurrentClient(client, generation))
143
+ return;
144
+ this.healthStatus = "retrying";
145
+ console.warn(`[discord:${this.id}] shard ${shardId} reconnecting`);
146
+ });
147
+ client.on("shardResume", (shardId, replayedEvents) => {
148
+ if (!this.isCurrentClient(client, generation))
149
+ return;
150
+ this.healthStatus = "connected";
151
+ this.shardNonReadySince.delete(shardId);
152
+ console.info(`[discord:${this.id}] shard ${shardId} resumed (${replayedEvents} replayed event(s))`);
153
+ });
154
+ client.on("shardReady", (shardId) => {
155
+ if (!this.isCurrentClient(client, generation))
156
+ return;
157
+ this.healthStatus = "connected";
158
+ this.shardNonReadySince.delete(shardId);
159
+ });
160
+ client.on("shardDisconnect", (event, shardId) => {
161
+ if (!this.isCurrentClient(client, generation))
162
+ return;
163
+ this.healthStatus = "retrying";
164
+ this.shardNonReadySince.set(shardId, this.now());
165
+ console.warn(`[discord:${this.id}] shard ${shardId} disconnected (${event.code}: ${event.reason || "no reason"})`);
166
+ });
167
+ client.on("invalidated", () => {
168
+ if (!this.isCurrentClient(client, generation))
169
+ return;
170
+ console.warn(`[discord:${this.id}] gateway session invalidated; rebuilding client`);
171
+ void this.reconnectGateway("gateway session invalidated").catch(() => { });
172
+ });
173
+ client.on("raw", (packet) => {
174
+ if (!this.isCurrentClient(client, generation))
175
+ return;
176
+ if (packet.t)
177
+ this.lastDispatchAt = this.now();
178
+ });
179
+ client.once("ready", () => void this.handleClientReady(client, generation));
98
180
  // Reactions on the bot's messages, as inbound events (#408). Both add and remove
99
181
  // are reported so an agent can see an approval being withdrawn.
100
182
  const onReaction = async (reaction, user, action) => {
@@ -109,7 +191,9 @@ export class DiscordAdapter extends EventEmitter {
109
191
  return;
110
192
  }
111
193
  }
112
- if (user.id === this.client.user?.id)
194
+ if (!this.isCurrentClient(client, generation))
195
+ return;
196
+ if (user.id === client.user?.id)
113
197
  return; // our own reaction
114
198
  // Other bots' reactions are DELIVERED on purpose: agents react to each
115
199
  // other's messages as signals (agent A 👍 → agent B sees it). The noise
@@ -119,11 +203,11 @@ export class DiscordAdapter extends EventEmitter {
119
203
  const message = reaction.message;
120
204
  // Only reactions on OUR messages are meaningful as agent signals; a user
121
205
  // reacting to another user's message is chatter.
122
- if (message.author?.id && message.author.id !== this.client.user?.id)
206
+ if (message.author?.id && message.author.id !== client.user?.id)
123
207
  return;
124
208
  if (message.guildId && message.guildId !== this.guildId && !this.openChannels.has(message.channelId))
125
209
  return;
126
- this.emit("reaction", {
210
+ this.emitFromClient(client, generation, "reaction", {
127
211
  source: "discord",
128
212
  adapterId: this.id,
129
213
  chatId: this.guildId,
@@ -142,11 +226,13 @@ export class DiscordAdapter extends EventEmitter {
142
226
  console.warn(`[discord] reaction ${action} handler error (${err.message})`);
143
227
  }
144
228
  };
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) => {
229
+ client.on("messageReactionAdd", (reaction, user) => void onReaction(reaction, user, "add"));
230
+ client.on("messageReactionRemove", (reaction, user) => void onReaction(reaction, user, "remove"));
231
+ client.on("messageCreate", async (msg) => {
148
232
  try {
149
- if (msg.author.id === this.client.user?.id)
233
+ if (!this.isCurrentClient(client, generation))
234
+ return;
235
+ if (msg.author.id === client.user?.id)
150
236
  return; // Ignore own messages
151
237
  if (!msg.guildId)
152
238
  return;
@@ -265,6 +351,33 @@ export class DiscordAdapter extends EventEmitter {
265
351
  size: att.size,
266
352
  filename: att.name ?? undefined,
267
353
  }));
354
+ // A Discord reply carries only the referenced message id on the new
355
+ // message. Fetch the original so image attachments are delivered with
356
+ // the reply rather than reducing the reference to text alone. Keep this
357
+ // limited to real attachments: ordinary URL auto-embeds must not become
358
+ // downloads (the same policy as the outer message above).
359
+ let replyToText;
360
+ if (msg.reference?.messageId) {
361
+ try {
362
+ const ref = await msg.fetchReference();
363
+ replyToText = ref.content || ref.embeds?.[0]?.description || undefined;
364
+ for (const att of ref.attachments?.values() ?? []) {
365
+ if (!att.contentType?.startsWith("image/"))
366
+ continue;
367
+ if (!attachments.some(existing => existing.fileId === att.id)) {
368
+ attachments.push({
369
+ kind: "photo",
370
+ fileId: att.id,
371
+ mime: att.contentType,
372
+ size: att.size,
373
+ filename: att.name ?? undefined,
374
+ });
375
+ }
376
+ this.attachmentUrls.set(att.id, att.url);
377
+ }
378
+ }
379
+ catch { /* deleted message or no permission */ }
380
+ }
268
381
  // Embed images become synthetic photo attachments so the normal download
269
382
  // path (attachment-handler → downloadAttachment → image_path) serves them
270
383
  // without knowing where the bytes came from. Their synthetic ids are
@@ -293,15 +406,7 @@ export class DiscordAdapter extends EventEmitter {
293
406
  else
294
407
  break;
295
408
  }
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", {
409
+ this.emitFromClient(client, generation, "message", {
305
410
  source: "discord",
306
411
  adapterId: this.id,
307
412
  chatId,
@@ -326,8 +431,10 @@ export class DiscordAdapter extends EventEmitter {
326
431
  // Handle button interactions and slash commands
327
432
  // Trust boundary: interaction responses can throw DiscordAPIError[10062] if the
328
433
  // interaction expires (>3s). Catch to prevent crashing the entire daemon.
329
- this.client.on("interactionCreate", async (interaction) => {
434
+ client.on("interactionCreate", async (interaction) => {
330
435
  try {
436
+ if (!this.isCurrentClient(client, generation))
437
+ return;
331
438
  // Buttons: acknowledge IMMEDIATELY, before any guild/channel filtering.
332
439
  // A button has a 3s ack window; any early return (unknown guild/channel)
333
440
  // or a downstream no-op (e.g. the cancel button was already cleared) would
@@ -344,7 +451,7 @@ export class DiscordAdapter extends EventEmitter {
344
451
  // console.log(`[discord] ignoring button from non-primary guild ${interaction.guildId} channel ${interaction.channelId}`);
345
452
  return;
346
453
  }
347
- this.emit("callback_query", {
454
+ this.emitFromClient(client, generation, "callback_query", {
348
455
  callbackData: interaction.customId,
349
456
  chatId: this.guildId,
350
457
  threadId: interaction.channelId,
@@ -372,7 +479,7 @@ export class DiscordAdapter extends EventEmitter {
372
479
  const callbackData = interaction.values[0];
373
480
  if (!callbackData)
374
481
  return;
375
- this.emit("callback_query", {
482
+ this.emitFromClient(client, generation, "callback_query", {
376
483
  callbackData,
377
484
  chatId: this.guildId,
378
485
  threadId: interaction.channelId,
@@ -392,7 +499,7 @@ export class DiscordAdapter extends EventEmitter {
392
499
  if (interaction.commandName === "chat") {
393
500
  const text = interaction.options.getString("message") ?? "";
394
501
  await interaction.deferReply();
395
- this.emit("slash_command", {
502
+ this.emitFromClient(client, generation, "slash_command", {
396
503
  command: "chat",
397
504
  channelId: interaction.channelId,
398
505
  channelName,
@@ -419,7 +526,7 @@ export class DiscordAdapter extends EventEmitter {
419
526
  for (const opt of interaction.options.data) {
420
527
  options[opt.name] = opt.value;
421
528
  }
422
- this.emit("slash_command", {
529
+ this.emitFromClient(client, generation, "slash_command", {
423
530
  command: interaction.commandName,
424
531
  channelId: interaction.channelId,
425
532
  channelName,
@@ -433,6 +540,12 @@ export class DiscordAdapter extends EventEmitter {
433
540
  catch {
434
541
  return undefined;
435
542
  } },
543
+ dismissResponse: async () => {
544
+ try {
545
+ await interaction.deleteReply();
546
+ }
547
+ catch { /* interaction may already be gone */ }
548
+ },
436
549
  respondChoices: async (text, choices) => {
437
550
  const select = new StringSelectMenuBuilder()
438
551
  .setCustomId(CLASSIC_START_BACKEND_SELECT_ID)
@@ -459,14 +572,18 @@ export class DiscordAdapter extends EventEmitter {
459
572
  }
460
573
  });
461
574
  // Handle channel deletion (equivalent to topic_closed)
462
- this.client.on("guildCreate", (guild) => {
463
- this.emit("new_group_detected", {
575
+ client.on("guildCreate", (guild) => {
576
+ if (!this.isCurrentClient(client, generation))
577
+ return;
578
+ this.emitFromClient(client, generation, "new_group_detected", {
464
579
  groupId: guild.id,
465
580
  groupTitle: guild.name,
466
581
  source: "discord",
467
582
  });
468
583
  });
469
- this.client.on("channelDelete", (channel) => {
584
+ client.on("channelDelete", (channel) => {
585
+ if (!this.isCurrentClient(client, generation))
586
+ return;
470
587
  if (!("guildId" in channel))
471
588
  return;
472
589
  if (channel.guildId !== this.guildId) {
@@ -474,7 +591,7 @@ export class DiscordAdapter extends EventEmitter {
474
591
  return;
475
592
  // Allowed: an open classic channel in a non-primary guild was deleted.
476
593
  }
477
- this.emit("topic_closed", {
594
+ this.emitFromClient(client, generation, "topic_closed", {
478
595
  chatId: this.guildId,
479
596
  threadId: channel.id,
480
597
  });
@@ -486,19 +603,252 @@ export class DiscordAdapter extends EventEmitter {
486
603
  // console.log(`[AgEnD] setOpenChannels: ${channelIds.length} channels`, channelIds);
487
604
  }
488
605
  // ── Lifecycle ──────────────────────────────────────────────────────────
489
- async start() {
490
- this.queue.start();
491
- this.client.once("ready", async () => {
606
+ isCurrentClient(client, generation) {
607
+ // lifecycleEpoch===0 preserves constructor-level handler tests; a real Client
608
+ // cannot dispatch gateway events before start/login.
609
+ return (this.desiredRunning || this.lifecycleEpoch === 0)
610
+ && this.client === client
611
+ && this.clientGeneration === generation;
612
+ }
613
+ emitFromClient(client, generation, event, ...args) {
614
+ return this.isCurrentClient(client, generation) ? this.emit(event, ...args) : false;
615
+ }
616
+ emitHealthChanged() {
617
+ this.emit("gateway_health", this.getHealthSnapshot());
618
+ }
619
+ async loginFreshClient(reason, epoch) {
620
+ const oldClient = this.client;
621
+ // A destroyed discord.js Client cannot be logged in again. Destroy first to
622
+ // avoid overlapping gateway sessions delivering MESSAGE_CREATE twice.
623
+ oldClient.destroy();
624
+ const client = this.clientFactory();
625
+ const generation = ++this.clientGeneration;
626
+ this.client = client;
627
+ this.generationReadyAt = 0;
628
+ this.generationNonReadySince = this.now();
629
+ this.shardNonReadySince.clear();
630
+ this.previousHeartbeatAck.clear();
631
+ this.observedHeartbeatInterval.clear();
632
+ this.registerClientHandlers(client, generation);
633
+ this.emitHealthChanged();
634
+ try {
635
+ await client.login(this.botToken);
636
+ if (!this.desiredRunning || epoch !== this.lifecycleEpoch || this.client !== client) {
637
+ client.destroy();
638
+ throw new Error("Discord gateway login superseded by lifecycle change");
639
+ }
640
+ this.lastReconnectAt = this.now();
641
+ this.lastReconnectReason = reason;
642
+ this.healthStatus = client.isReady() ? "connected" : "starting";
643
+ this.emitHealthChanged();
644
+ }
645
+ catch (err) {
646
+ client.destroy();
647
+ throw err;
648
+ }
649
+ }
650
+ /** Rebuild just this bot's gateway Client. Calls coalesce into one generation. */
651
+ reconnectGateway(reason) {
652
+ if (this.reconnectPromise)
653
+ return this.reconnectPromise;
654
+ if (!this.desiredRunning)
655
+ return Promise.reject(new Error("Discord adapter is stopped"));
656
+ const epoch = this.lifecycleEpoch;
657
+ const reconnect = (async () => {
658
+ // Yield once so reconnectPromise is installed before destroy/login can emit
659
+ // a synchronous gateway error and re-enter this method.
660
+ await Promise.resolve();
661
+ if (!this.desiredRunning || epoch !== this.lifecycleEpoch) {
662
+ throw new Error("Discord gateway reconnect cancelled by adapter stop");
663
+ }
664
+ this.healthStatus = "retrying";
665
+ this.lastReconnectReason = reason;
666
+ this.reconnectCount++;
667
+ this.emitHealthChanged();
668
+ for (let attempt = 0; this.desiredRunning && epoch === this.lifecycleEpoch; attempt++) {
669
+ if (attempt > 0) {
670
+ const delay = Math.min(this.reconnectBaseDelayMs * 2 ** Math.min(attempt - 1, 6), GATEWAY_MAX_BACKOFF_MS);
671
+ await new Promise(resolve => {
672
+ const timer = setTimeout(resolve, delay);
673
+ timer.unref?.();
674
+ });
675
+ if (!this.desiredRunning || epoch !== this.lifecycleEpoch)
676
+ break;
677
+ }
678
+ try {
679
+ await this.loginFreshClient(reason, epoch);
680
+ if (this.desiredRunning && epoch === this.lifecycleEpoch) {
681
+ this.healthStatus = "connected";
682
+ this.emitHealthChanged();
683
+ }
684
+ return;
685
+ }
686
+ catch (err) {
687
+ if (!this.desiredRunning || epoch !== this.lifecycleEpoch)
688
+ break;
689
+ this.healthStatus = "retrying";
690
+ console.warn(`[discord:${this.id}] gateway rebuild attempt ${attempt + 1} failed: ${err.message}`);
691
+ this.emitHealthChanged();
692
+ }
693
+ }
694
+ throw new Error("Discord gateway reconnect cancelled by adapter stop");
695
+ })().finally(() => {
696
+ if (this.reconnectPromise === reconnect)
697
+ this.reconnectPromise = null;
698
+ });
699
+ this.reconnectPromise = reconnect;
700
+ return reconnect;
701
+ }
702
+ async readyClient() {
703
+ // A few focused unit tests install a minimal REST-only client stand-in. Real
704
+ // discord.js Clients always expose isReady().
705
+ const isReady = typeof this.client.isReady === "function"
706
+ ? this.client.isReady()
707
+ : true;
708
+ if (isReady && !this.reconnectPromise)
709
+ return this.client;
710
+ const reconnect = this.reconnectPromise ?? this.reconnectGateway("outbound requested while gateway unavailable");
711
+ let timer;
712
+ try {
713
+ await Promise.race([
714
+ reconnect,
715
+ new Promise((_, reject) => {
716
+ timer = setTimeout(() => reject(new Error("Discord gateway reconnect timed out")), GATEWAY_RECONNECT_WAIT_MS);
717
+ timer.unref?.();
718
+ }),
719
+ ]);
720
+ }
721
+ finally {
722
+ if (timer)
723
+ clearTimeout(timer);
724
+ }
725
+ if (!this.client.isReady())
726
+ throw new Error("Discord gateway is not ready");
727
+ return this.client;
728
+ }
729
+ startGatewayWatchdog() {
730
+ if (this.watchdogTimer)
731
+ clearInterval(this.watchdogTimer);
732
+ this.lastWatchdogTickAt = this.now();
733
+ this.watchdogTimer = setInterval(() => this.checkGatewayLiveness(), this.watchdogIntervalMs);
734
+ this.watchdogTimer.unref?.();
735
+ }
736
+ checkGatewayLiveness() {
737
+ if (!this.desiredRunning || this.reconnectPromise)
738
+ return;
739
+ const now = this.now();
740
+ const elapsed = now - this.lastWatchdogTickAt;
741
+ this.lastWatchdogTickAt = now;
742
+ // Host suspend / an overloaded event loop makes wall-clock ACK age jump. Give
743
+ // discord.js a fresh heartbeat window instead of rebuilding immediately.
744
+ if (elapsed > this.watchdogIntervalMs * 3) {
745
+ this.generationReadyAt = now;
746
+ this.generationNonReadySince = now;
747
+ this.shardNonReadySince.clear();
748
+ return;
749
+ }
750
+ const shards = [...this.client.ws.shards.values()];
751
+ let staleReason = null;
752
+ if (!this.client.isReady() && this.generationNonReadySince === 0)
753
+ this.generationNonReadySince = now;
754
+ if (this.client.isReady())
755
+ this.generationNonReadySince = 0;
756
+ if (!this.client.isReady() && now - this.generationNonReadySince > this.staleThresholdMs) {
757
+ staleReason = `client non-ready for ${now - this.generationNonReadySince}ms`;
758
+ }
759
+ for (const shard of shards) {
760
+ const lastAck = shard.lastPingTimestamp;
761
+ const previous = this.previousHeartbeatAck.get(shard.id);
762
+ if (lastAck >= 0 && previous != null && lastAck > previous) {
763
+ const interval = lastAck - previous;
764
+ if (interval >= 5_000 && interval <= 120_000)
765
+ this.observedHeartbeatInterval.set(shard.id, interval);
766
+ }
767
+ if (lastAck >= 0)
768
+ this.previousHeartbeatAck.set(shard.id, lastAck);
769
+ const threshold = Math.max(this.staleThresholdMs, 3 * (this.observedHeartbeatInterval.get(shard.id) ?? 0));
770
+ if (shard.status === Status.Ready) {
771
+ this.shardNonReadySince.delete(shard.id);
772
+ if (lastAck === -1) {
773
+ if (this.generationReadyAt > 0 && now - this.generationReadyAt > threshold) {
774
+ staleReason = `shard ${shard.id} has no heartbeat ACK after startup grace`;
775
+ }
776
+ }
777
+ else if (now - lastAck > threshold) {
778
+ staleReason = `shard ${shard.id} heartbeat ACK stale by ${now - lastAck}ms`;
779
+ }
780
+ }
781
+ else {
782
+ const since = this.shardNonReadySince.get(shard.id) ?? now;
783
+ this.shardNonReadySince.set(shard.id, since);
784
+ if (now - since > threshold)
785
+ staleReason = `shard ${shard.id} status ${shard.status} for ${now - since}ms`;
786
+ }
787
+ }
788
+ if (staleReason) {
789
+ this.healthStatus = "stale";
790
+ this.emitHealthChanged();
791
+ console.warn(`[discord:${this.id}] gateway watchdog detected ${staleReason}`);
792
+ void this.reconnectGateway(`watchdog: ${staleReason}`).catch(() => { });
793
+ }
794
+ else if (this.client.isReady()
795
+ && shards.every(shard => shard.status === Status.Ready)
796
+ && this.healthStatus !== "connected") {
797
+ // discord.js owns the first recovery layer (RESUME + replay). Clear a
798
+ // transient error marker once ACK/status prove that native recovery won.
799
+ this.healthStatus = "connected";
800
+ this.emitHealthChanged();
801
+ }
802
+ }
803
+ getHealthSnapshot() {
804
+ const now = this.now();
805
+ const shards = [...this.client.ws.shards.values()].map(shard => {
806
+ const lastAck = shard.lastPingTimestamp >= 0 ? shard.lastPingTimestamp : null;
807
+ return {
808
+ id: shard.id,
809
+ status: shard.status,
810
+ lastHeartbeatAckAt: lastAck,
811
+ heartbeatAgeMs: lastAck == null ? null : Math.max(0, now - lastAck),
812
+ };
813
+ });
814
+ const heartbeatAcks = shards
815
+ .map(shard => shard.lastHeartbeatAckAt)
816
+ .filter((value) => value != null);
817
+ const lastHeartbeatAckAt = heartbeatAcks.length > 0 ? Math.min(...heartbeatAcks) : null;
818
+ return {
819
+ id: this.id,
820
+ type: this.type,
821
+ status: this.healthStatus,
822
+ generation: this.clientGeneration,
823
+ isReady: this.client.isReady(),
824
+ wsStatus: this.client.ws.status ?? null,
825
+ lastHeartbeatAckAt,
826
+ heartbeatAgeMs: lastHeartbeatAckAt == null ? null : Math.max(0, now - lastHeartbeatAckAt),
827
+ shards,
828
+ lastDispatchAt: this.lastDispatchAt,
829
+ lastReconnectAt: this.lastReconnectAt,
830
+ lastReconnectReason: this.lastReconnectReason,
831
+ reconnectCount: this.reconnectCount,
832
+ };
833
+ }
834
+ async handleClientReady(client, generation) {
835
+ if (!this.isCurrentClient(client, generation))
836
+ return;
837
+ this.healthStatus = "connected";
838
+ this.generationReadyAt = this.now();
839
+ this.generationNonReadySince = 0;
840
+ this.shardNonReadySince.clear();
841
+ try {
492
842
  // Register classic bot slash commands (skipped for a secondary bot sharing
493
843
  // a guild with the primary — only the primary owns the guild's commands).
494
844
  if (this.registerCommands)
495
845
  try {
496
- await this.client.application?.commands.set([
846
+ await client.application?.commands.set([
497
847
  {
498
848
  name: "start", description: t("slash.start"),
499
849
  options: [{
500
850
  name: "backend",
501
- description: "Backend to start",
851
+ description: t("slash.option.backend"),
502
852
  type: ApplicationCommandOptionType.String,
503
853
  required: true,
504
854
  choices: DISCORD_START_BACKEND_CHOICES,
@@ -506,16 +856,16 @@ export class DiscordAdapter extends EventEmitter {
506
856
  },
507
857
  { name: "stop", description: t("slash.stop") },
508
858
  {
509
- name: "pause", description: "🔒 Pause an idle instance",
510
- options: [{ name: "instance", description: "Instance name (required in General)", type: ApplicationCommandOptionType.String, required: false }],
859
+ name: "pause", description: "🔒 " + t("slash.pause"),
860
+ options: [{ name: "instance", description: t("slash.option.instance"), type: ApplicationCommandOptionType.String, required: false }],
511
861
  },
512
862
  {
513
- name: "wake", description: "🔒 Wake a paused instance",
514
- options: [{ name: "instance", description: "Instance name (required in General)", type: ApplicationCommandOptionType.String, required: false }],
863
+ name: "wake", description: "🔒 " + t("slash.wake"),
864
+ options: [{ name: "instance", description: t("slash.option.instance"), type: ApplicationCommandOptionType.String, required: false }],
515
865
  },
516
866
  {
517
867
  name: "chat", description: t("slash.chat"),
518
- options: [{ name: "message", description: "Your message", type: 3, required: true }],
868
+ options: [{ name: "message", description: t("slash.option.message"), type: 3, required: true }],
519
869
  },
520
870
  { name: "status", description: "🔒 " + t("slash.status") },
521
871
  { name: "sysinfo", description: t("slash.sysinfo") },
@@ -525,15 +875,77 @@ export class DiscordAdapter extends EventEmitter {
525
875
  { name: "update", description: "🔒 " + t("slash.update") },
526
876
  { name: "doctor", description: "🔒 " + t("slash.doctor") },
527
877
  { name: "usage", description: t("slash.usage") },
878
+ {
879
+ name: "tips", description: t("slash.tips"),
880
+ options: [{
881
+ name: "mode", description: t("slash.option.tips_mode"),
882
+ type: ApplicationCommandOptionType.String, required: false,
883
+ choices: [
884
+ { name: "on", value: "on" },
885
+ { name: "off", value: "off" },
886
+ { name: "advanced on", value: "advanced on" },
887
+ ],
888
+ }],
889
+ },
528
890
  { name: "compact", description: "🔒 " + t("slash.compact") },
891
+ {
892
+ name: "steer", description: t("slash.steer"),
893
+ options: [{
894
+ name: "message", description: t("slash.option.steer_message"),
895
+ type: ApplicationCommandOptionType.String, required: true,
896
+ }],
897
+ },
898
+ {
899
+ name: "btw", description: t("slash.btw"),
900
+ options: [{
901
+ name: "message", description: t("slash.option.btw_message"),
902
+ type: ApplicationCommandOptionType.String, required: true,
903
+ }],
904
+ },
905
+ {
906
+ name: "login", description: "🔒 " + t("slash.login"),
907
+ options: [
908
+ {
909
+ name: "backend", description: t("slash.option.login_backend"),
910
+ type: ApplicationCommandOptionType.String, required: false,
911
+ choices: [
912
+ { name: "claude-code", value: "claude-code" },
913
+ { name: "codex", value: "codex" },
914
+ { name: "kiro-cli", value: "kiro-cli" },
915
+ { name: "grok", value: "grok" },
916
+ { name: "antigravity", value: "antigravity" },
917
+ ],
918
+ },
919
+ { name: "code", description: t("slash.option.login_code"), type: ApplicationCommandOptionType.String, required: false },
920
+ { name: "cancel", description: t("slash.option.login_cancel"), type: ApplicationCommandOptionType.Boolean, required: false },
921
+ ],
922
+ },
923
+ {
924
+ name: "install-cli", description: "🔒 " + t("slash.install_cli"),
925
+ options: [
926
+ {
927
+ name: "backend", description: t("slash.option.install_backend"),
928
+ type: ApplicationCommandOptionType.String, required: false,
929
+ choices: [
930
+ { name: "claude-code", value: "claude-code" },
931
+ { name: "codex", value: "codex" },
932
+ { name: "kiro-cli", value: "kiro-cli" },
933
+ { name: "grok", value: "grok" },
934
+ { name: "antigravity", value: "antigravity" },
935
+ { name: "opencode", value: "opencode" },
936
+ ],
937
+ },
938
+ { name: "cancel", description: t("slash.option.install_cancel"), type: ApplicationCommandOptionType.Boolean, required: false },
939
+ ],
940
+ },
529
941
  { name: "clear", description: "🔒 " + t("slash.clear") },
530
942
  {
531
- name: "model", description: "🔒 Switch model (admin only)",
943
+ name: "model", description: "🔒 " + t("slash.model"),
532
944
  },
533
945
  {
534
946
  name: "effort", description: "🔒 " + t("slash.effort"),
535
947
  options: [{
536
- name: "level", description: "low | medium | high | xhigh | max",
948
+ name: "level", description: t("slash.option.effort_level"),
537
949
  type: ApplicationCommandOptionType.String, required: false,
538
950
  choices: [
539
951
  { name: "low", value: "low" }, { name: "medium", value: "medium" },
@@ -546,13 +958,13 @@ export class DiscordAdapter extends EventEmitter {
546
958
  {
547
959
  name: "save", description: "🔒 " + t("slash.save"),
548
960
  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 },
961
+ { name: "filename", description: t("slash.option.save_filename"), type: 3, required: true },
962
+ { name: "force", description: t("slash.option.save_force"), type: 5, required: false },
551
963
  ],
552
964
  },
553
965
  {
554
966
  name: "load", description: "🔒 " + t("slash.load"),
555
- options: [{ name: "filename", description: "File name to load", type: 3, required: true }],
967
+ options: [{ name: "filename", description: t("slash.option.load_filename"), type: 3, required: true }],
556
968
  },
557
969
  { name: "cancel", description: t("slash.cancel") },
558
970
  ]);
@@ -560,11 +972,40 @@ export class DiscordAdapter extends EventEmitter {
560
972
  catch (err) {
561
973
  // Non-fatal — slash commands may fail on network issues
562
974
  }
563
- this.emit("started", this.client.user?.username ?? "discord-bot", this.client.user?.id);
564
- });
565
- await this.client.login(this.botToken);
975
+ if (!this.isCurrentClient(client, generation))
976
+ return;
977
+ this.emit("started", client.user?.username ?? "discord-bot", client.user?.id);
978
+ }
979
+ catch (err) {
980
+ console.warn(`[discord:${this.id}] ready handler failed: ${err.message}`);
981
+ }
982
+ }
983
+ async start() {
984
+ if (this.desiredRunning && this.client.isReady())
985
+ return;
986
+ this.desiredRunning = true;
987
+ const epoch = ++this.lifecycleEpoch;
988
+ this.healthStatus = "starting";
989
+ this.queue.start();
990
+ try {
991
+ await this.loginFreshClient("startup", epoch);
992
+ this.startGatewayWatchdog();
993
+ }
994
+ catch (err) {
995
+ if (epoch === this.lifecycleEpoch) {
996
+ this.desiredRunning = false;
997
+ this.healthStatus = "stopped";
998
+ }
999
+ throw err;
1000
+ }
566
1001
  }
567
1002
  async stop() {
1003
+ this.desiredRunning = false;
1004
+ this.lifecycleEpoch++;
1005
+ this.healthStatus = "stopped";
1006
+ if (this.watchdogTimer)
1007
+ clearInterval(this.watchdogTimer);
1008
+ this.watchdogTimer = null;
568
1009
  this.queue.stop();
569
1010
  this.client.destroy();
570
1011
  }
@@ -583,7 +1024,7 @@ export class DiscordAdapter extends EventEmitter {
583
1024
  const message = await interaction.editReply({ content: reply, components: [] });
584
1025
  return message.id;
585
1026
  }
586
- const chunks = splitText(reply, EMBED_MAX);
1027
+ const chunks = splitTextFenceAware(reply, EMBED_MAX);
587
1028
  const message = await interaction.editReply({ content: "", embeds: [{ description: chunks[0] }], components: [] });
588
1029
  for (let i = 1; i < chunks.length; i++) {
589
1030
  await interaction.followUp({ ephemeral: true, embeds: [{ description: chunks[i] }] });
@@ -594,16 +1035,18 @@ export class DiscordAdapter extends EventEmitter {
594
1035
  const channelId = opts?.threadId ?? chatId;
595
1036
  const channel = await this._fetchTextChannel(channelId);
596
1037
  const chunkLimit = opts?.chunkLimit ?? DISCORD_MAX_LENGTH;
597
- const chunks = splitText(text, chunkLimit);
1038
+ const chunks = splitTextFenceAware(text, chunkLimit);
598
1039
  if (chunks.length === 0)
599
1040
  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] });
1041
+ // Await every platform POST before reporting success. Previously only the
1042
+ // first chunk was awaited and later chunks were fire-and-forget queue items,
1043
+ // so the reply tool could return success for a silently truncated message.
1044
+ let first;
1045
+ for (const chunk of chunks) {
1046
+ const sent = await channel.send(opts?.disablePreview
1047
+ ? { content: chunk, flags: MessageFlags.SuppressEmbeds }
1048
+ : chunk);
1049
+ first ??= sent;
607
1050
  }
608
1051
  return {
609
1052
  messageId: first.id,
@@ -629,7 +1072,7 @@ export class DiscordAdapter extends EventEmitter {
629
1072
  }
630
1073
  catch { /* not in that channel — fall through to scan */ }
631
1074
  try {
632
- const guild = await this.client.guilds.fetch(this.guildId);
1075
+ const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
633
1076
  const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
634
1077
  for (const [, ch] of channels) {
635
1078
  try {
@@ -653,6 +1096,12 @@ export class DiscordAdapter extends EventEmitter {
653
1096
  }
654
1097
  /** Edit text and clear components (Discord keeps components on a plain edit,
655
1098
  * so we must pass an empty array to drop the Cancel button). */
1099
+ /** Keyboard-only removal — Discord keeps the original content untouched. */
1100
+ async removeMessageButtons(chatId, messageId, threadId) {
1101
+ const channel = await this._fetchTextChannel(threadId ?? chatId);
1102
+ const msg = await channel.messages.fetch(messageId);
1103
+ await msg.edit({ components: [] });
1104
+ }
656
1105
  async editMessageRemoveButtons(chatId, messageId, text, threadId) {
657
1106
  // Prefer the exact channel (handles forum-topic threads, which a GuildText
658
1107
  // scan misses); fall back to scanning top-level text channels.
@@ -664,7 +1113,7 @@ export class DiscordAdapter extends EventEmitter {
664
1113
  }
665
1114
  catch { /* not in that channel — fall through to scan */ }
666
1115
  try {
667
- const guild = await this.client.guilds.fetch(this.guildId);
1116
+ const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
668
1117
  const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
669
1118
  for (const [, ch] of channels) {
670
1119
  try {
@@ -691,7 +1140,7 @@ export class DiscordAdapter extends EventEmitter {
691
1140
  }
692
1141
  catch { /* not in that channel — fall through to scan */ }
693
1142
  try {
694
- const guild = await this.client.guilds.fetch(this.guildId);
1143
+ const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
695
1144
  const channels = guild.channels.cache.filter((c) => c.type === ChannelType.GuildText);
696
1145
  for (const [, ch] of channels) {
697
1146
  try {
@@ -714,7 +1163,7 @@ export class DiscordAdapter extends EventEmitter {
714
1163
  const encoded = encodeURIComponent(emoji);
715
1164
  try {
716
1165
  // 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`);
1166
+ await (await this.readyClient()).rest.put(`/channels/${channelId}/messages/${messageId}/reactions/${encoded}/@me`);
718
1167
  return;
719
1168
  }
720
1169
  catch (directError) {
@@ -882,7 +1331,7 @@ export class DiscordAdapter extends EventEmitter {
882
1331
  }
883
1332
  // ── Topology: create channel ────────────────────────────────────────────
884
1333
  async _resolveCategory() {
885
- const guild = await this.client.guilds.fetch(this.guildId);
1334
+ const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
886
1335
  await guild.channels.fetch();
887
1336
  const existing = guild.channels.cache.find((c) => c.type === ChannelType.GuildCategory && c.name === this.categoryName);
888
1337
  if (existing)
@@ -903,7 +1352,7 @@ export class DiscordAdapter extends EventEmitter {
903
1352
  return this.categoryIdPromise;
904
1353
  }
905
1354
  async createTopic(name) {
906
- const guild = await this.client.guilds.fetch(this.guildId);
1355
+ const guild = await (await this.readyClient()).guilds.fetch(this.guildId);
907
1356
  const categoryId = await this.ensureCategoryId();
908
1357
  try {
909
1358
  const channel = await guild.channels.create({
@@ -929,7 +1378,7 @@ export class DiscordAdapter extends EventEmitter {
929
1378
  }
930
1379
  }
931
1380
  async deleteTopic(topicId) {
932
- const channel = await this.client.channels.fetch(String(topicId));
1381
+ const channel = await (await this.readyClient()).channels.fetch(String(topicId));
933
1382
  // Only delete GuildText channels created by createTopic — never categories or forums
934
1383
  if (channel && "type" in channel && channel.type === ChannelType.GuildText && "delete" in channel) {
935
1384
  await channel.delete();
@@ -937,7 +1386,7 @@ export class DiscordAdapter extends EventEmitter {
937
1386
  }
938
1387
  async topicExists(topicId) {
939
1388
  try {
940
- const channel = await this.client.channels.fetch(String(topicId));
1389
+ const channel = await (await this.readyClient()).channels.fetch(String(topicId));
941
1390
  return channel != null;
942
1391
  }
943
1392
  catch {
@@ -954,13 +1403,4 @@ export class DiscordAdapter extends EventEmitter {
954
1403
  }
955
1404
  }
956
1405
  // ── Helpers ──────────────────────────────────────────────────────────────
957
- function splitText(text, limit) {
958
- const chunks = [];
959
- let offset = 0;
960
- while (offset < text.length) {
961
- chunks.push(text.slice(offset, offset + limit));
962
- offset += limit;
963
- }
964
- return chunks;
965
- }
966
1406
  //# sourceMappingURL=discord.js.map