@songsid/agend 2.0.11-beta.14 → 2.0.11-beta.16

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.
@@ -205,6 +205,16 @@ export class FleetManager {
205
205
  getInstanceDir(name) {
206
206
  return join(this.dataDir, "instances", name);
207
207
  }
208
+ /**
209
+ * Resolve a slash-command target in a channel. Classic channels are looked up
210
+ * per-bot (same-channel multi-bot); a fleet-topic instance is found via the
211
+ * routing engine. Used by commands that work in BOTH contexts (/ctx, /compact,
212
+ * /cancel). Classic-only commands (/chat, /load) must NOT use this.
213
+ */
214
+ resolveSlashTarget(channelId, adapterId) {
215
+ return this.classicChannels?.getInstanceByChannel(channelId, adapterId)
216
+ ?? this.routing.resolve(channelId)?.name;
217
+ }
208
218
  /** Get the adapter bound to an instance, falling back to primary adapter */
209
219
  getAdapterForInstance(name) {
210
220
  const worldId = this.instanceWorldBinding.get(name);
@@ -910,7 +920,7 @@ export class FleetManager {
910
920
  await data.respond(`✅ Sent \`/chat load ${filename}\` to ${name}`);
911
921
  }
912
922
  else if (data.command === "compact") {
913
- const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
923
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
914
924
  if (!name) {
915
925
  await data.respond("No active agent in this channel.");
916
926
  return;
@@ -919,7 +929,7 @@ export class FleetManager {
919
929
  await data.respond(result);
920
930
  }
921
931
  else if (data.command === "cancel") {
922
- const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
932
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
923
933
  if (!name) {
924
934
  await data.respond("No active agent in this channel.");
925
935
  return;
@@ -928,7 +938,7 @@ export class FleetManager {
928
938
  await data.respond(ok ? `🛑 Sent cancel to ${name}.` : `❌ ${name} not running.`);
929
939
  }
930
940
  else if (data.command === "ctx") {
931
- const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
941
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
932
942
  if (!name) {
933
943
  await data.respond("No active agent in this channel.");
934
944
  return;
@@ -937,8 +947,10 @@ export class FleetManager {
937
947
  await data.respond(await this.topicCommands.getCtxText(name));
938
948
  }
939
949
  else if (data.command === "collab") {
950
+ // Classic no longer lives in the routing engine, so a routing hit here is
951
+ // always a fleet-topic instance.
940
952
  const collabTarget = this.routing.resolve(data.channelId);
941
- if (collabTarget && collabTarget.kind !== "classic") {
953
+ if (collabTarget) {
942
954
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
943
955
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
944
956
  await data.respond("⛔ Not authorized");
@@ -1009,7 +1021,7 @@ export class FleetManager {
1009
1021
  process.kill(process.pid, "SIGUSR2");
1010
1022
  }
1011
1023
  else if (data.command === "compact") {
1012
- const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
1024
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1013
1025
  if (!name) {
1014
1026
  await data.respond("No active agent in this channel.");
1015
1027
  return;
@@ -1173,7 +1185,7 @@ export class FleetManager {
1173
1185
  await data.respond(`✅ Sent \`/chat load ${filename}\` to ${name}`);
1174
1186
  }
1175
1187
  else if (data.command === "cancel") {
1176
- const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
1188
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1177
1189
  if (!name) {
1178
1190
  await data.respond("No active agent in this channel.");
1179
1191
  return;
@@ -1182,7 +1194,7 @@ export class FleetManager {
1182
1194
  await data.respond(ok ? `🛑 Sent cancel to ${name}.` : `❌ ${name} not running.`);
1183
1195
  }
1184
1196
  else if (data.command === "ctx") {
1185
- const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
1197
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1186
1198
  if (!name) {
1187
1199
  await data.respond("No active agent in this channel.");
1188
1200
  return;
@@ -1191,8 +1203,10 @@ export class FleetManager {
1191
1203
  await data.respond(await this.topicCommands.getCtxText(name));
1192
1204
  }
1193
1205
  else if (data.command === "collab") {
1206
+ // Classic no longer lives in the routing engine, so a routing hit here is
1207
+ // always a fleet-topic instance.
1194
1208
  const collabTarget2 = this.routing.resolve(data.channelId);
1195
- if (collabTarget2 && collabTarget2.kind !== "classic") {
1209
+ if (collabTarget2) {
1196
1210
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1197
1211
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
1198
1212
  await data.respond("⛔ Not authorized");
@@ -1263,7 +1277,7 @@ export class FleetManager {
1263
1277
  process.kill(process.pid, "SIGUSR2");
1264
1278
  }
1265
1279
  else if (data.command === "compact") {
1266
- const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
1280
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1267
1281
  if (!name) {
1268
1282
  await data.respond("No active agent in this channel.");
1269
1283
  return;
@@ -1762,9 +1776,12 @@ export class FleetManager {
1762
1776
  if (msg.adapterId)
1763
1777
  this.bindInstanceAdapter(generalInstance, msg.adapterId, true);
1764
1778
  const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1765
- // React immediately — before any other API calls
1779
+ // React immediately — before any other API calls. Use the adapter BOUND to
1780
+ // the instance (not whichever same-guild bot received the event first) so
1781
+ // exactly the owning bot reacts — no duplicate 👀 from a sibling bot.
1766
1782
  if (msg.chatId && msg.messageId) {
1767
- inboundAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
1783
+ const reactAdapter = this.getAdapterForInstance(generalInstance) ?? inboundAdapter;
1784
+ reactAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
1768
1785
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
1769
1786
  }
1770
1787
  this.warnIfRateLimited(generalInstance, msg);
@@ -1843,9 +1860,12 @@ export class FleetManager {
1843
1860
  if (msg.adapterId)
1844
1861
  this.bindInstanceAdapter(instanceName, msg.adapterId, true);
1845
1862
  const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1846
- // React immediately — before any other Discord API calls
1863
+ // React immediately — before any other Discord API calls. Use the adapter
1864
+ // BOUND to the instance (not whichever same-guild bot received the event
1865
+ // first) so exactly the owning bot reacts — no duplicate 👀 from a sibling.
1847
1866
  if (msg.chatId && msg.messageId) {
1848
- inboundAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
1867
+ const reactAdapter = this.getAdapterForInstance(instanceName) ?? inboundAdapter;
1868
+ reactAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
1849
1869
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
1850
1870
  }
1851
1871
  // These may hit Discord API (topic icon, archive) — do after react
@@ -2589,15 +2609,17 @@ export class FleetManager {
2589
2609
  startStatuslineWatcher(name) {
2590
2610
  this.statuslineWatcher.watch(name);
2591
2611
  }
2592
- reactMessageStatus(chatId, messageId, emoji) {
2593
- // Find the adapter that owns this chatId (check all adapters, not just primary)
2594
- for (const [, w] of this.worlds) {
2595
- if (w.type === "discord") {
2596
- w.react(chatId, messageId, emoji)
2597
- .catch(e => this.logger.debug({ err: e.message }, "Message status react failed"));
2598
- return;
2599
- }
2600
- }
2612
+ reactMessageStatus(instanceName, chatId, messageId, emoji) {
2613
+ // React via the adapter BOUND to this instance NOT the first discord world.
2614
+ // Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
2615
+ // (bound bot) and the delivery/confirm reactions (some other bot) come from
2616
+ // different bots, leaving a duplicate 👀 that never turns into ✅.
2617
+ const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
2618
+ // Status reactions are Discord-only (TG/others use the inbound react path).
2619
+ if (!adapter || adapter.type !== "discord")
2620
+ return;
2621
+ adapter.react(chatId, messageId, emoji)
2622
+ .catch(e => this.logger.debug({ err: e.message }, "Message status react failed"));
2601
2623
  }
2602
2624
  // ── Model failover ──────────────────────────────────────────────────────
2603
2625
  static FAILOVER_TRIGGER_PCT = 90;
@@ -3256,8 +3278,15 @@ When users create specialized instances, suggest these configurations:
3256
3278
  : "");
3257
3279
  ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText);
3258
3280
  this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message");
3259
- // Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here
3260
- const adapterBotUserId = this.worlds.get(msg.adapterId ?? "")?.botUserId ?? this.botUserId;
3281
+ // Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here.
3282
+ // Each bot matches ONLY its own id. A secondary bot must NOT fall back to the
3283
+ // process-wide botUserId (the primary's) — otherwise, in a same-channel
3284
+ // multi-bot setup, an @mention of the primary would also match the secondary
3285
+ // and BOTH bots would react 👀 and forward. Only the primary adapter may use
3286
+ // the fallback.
3287
+ const mentionWorld = this.worlds.get(msg.adapterId ?? "");
3288
+ const isPrimaryAdapter = !mentionWorld || mentionWorld.adapter === this.adapter;
3289
+ const adapterBotUserId = mentionWorld?.botUserId ?? (isPrimaryAdapter ? this.botUserId : undefined);
3261
3290
  const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
3262
3291
  const isMentioned = mentionTag && text.includes(mentionTag);
3263
3292
  if (!isMentioned) {