@songsid/agend 2.0.11-beta.13 → 2.0.11-beta.15

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.
@@ -22,7 +22,7 @@ import { processAttachments } from "./channel/attachment-handler.js";
22
22
  import { routeToolCall } from "./channel/tool-router.js";
23
23
  import { Scheduler } from "./scheduler/index.js";
24
24
  import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
25
- import { TopicCommands, sanitizeInstanceName, saveCommandForBackend, parseSaveFilename, SAVE_FILENAME_RE, SAVE_UNSUPPORTED_MSG } from "./topic-commands.js";
25
+ import { TopicCommands, saveCommandForBackend, parseSaveFilename, SAVE_FILENAME_RE, SAVE_UNSUPPORTED_MSG } from "./topic-commands.js";
26
26
  import { DailySummary } from "./daily-summary.js";
27
27
  import { WebhookEmitter } from "./webhook-emitter.js";
28
28
  import { TmuxControlClient } from "./tmux-control.js";
@@ -35,7 +35,7 @@ import { outboundHandlers } from "./outbound-handlers.js";
35
35
  import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
36
36
  import { handleViewRequest, isViewPath } from "./view-api.js";
37
37
  import { handleAgentRequest } from "./agent-endpoint.js";
38
- import { ClassicChannelManager, classicInstanceName } from "./classic-channel-manager.js";
38
+ import { ClassicChannelManager } from "./classic-channel-manager.js";
39
39
  import { getTmuxSession } from "./config.js";
40
40
  export function resolveReplyThreadId(argsThreadId, instanceConfig) {
41
41
  if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
@@ -179,27 +179,42 @@ export class FleetManager {
179
179
  }
180
180
  return this.routing.map;
181
181
  }
182
- /** Re-register classic channels after routing rebuild (rebuild clears the table) */
182
+ /**
183
+ * Refresh each adapter's open-channel whitelist after a classic change.
184
+ * Classic channels are NOT registered in the routing engine (it's single-key
185
+ * per channel — can't represent two bots in one channel); routing resolves
186
+ * per-bot via ClassicChannelManager.getInstanceByChannel. Each adapter only
187
+ * opens the channels IT owns so a sibling bot doesn't process another's cross-
188
+ * guild channel.
189
+ */
183
190
  reregisterClassicChannels() {
184
191
  if (!this.classicChannels)
185
192
  return;
186
193
  const channels = this.classicChannels.getAll();
187
- for (const ch of channels) {
188
- this.routing.register(ch.channelId, { kind: "classic", name: ch.instanceName });
189
- }
190
194
  // Always update adapter openChannels (including empty — clears stale entries on /stop)
191
- for (const [, w] of this.worlds) {
195
+ for (const [adapterId, w] of this.worlds) {
192
196
  if (typeof w.adapter?.setOpenChannels === "function") {
193
- w.adapter.setOpenChannels(channels.map(ch => ch.channelId));
197
+ const owned = channels.filter(ch => ch.adapterId === adapterId).map(ch => ch.channelId);
198
+ w.adapter.setOpenChannels(owned);
194
199
  }
195
200
  }
196
201
  if (channels.length > 0) {
197
- this.logger.info({ count: channels.length }, "Registered classic channel routes");
202
+ this.logger.info({ count: channels.length }, "Refreshed classic channel open-lists");
198
203
  }
199
204
  }
200
205
  getInstanceDir(name) {
201
206
  return join(this.dataDir, "instances", name);
202
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
+ }
203
218
  /** Get the adapter bound to an instance, falling back to primary adapter */
204
219
  getAdapterForInstance(name) {
205
220
  const worldId = this.instanceWorldBinding.get(name);
@@ -411,10 +426,19 @@ export class FleetManager {
411
426
  const pidPath = join(this.dataDir, "fleet.pid");
412
427
  writeFileSync(pidPath, String(process.pid), "utf-8");
413
428
  this.eventLog = new EventLog(join(this.dataDir, "events.db"));
414
- // Initialize classic channel manager and register existing channels in routing
429
+ // Initialize classic channel manager. The primary adapter (channels[0])
430
+ // migrates legacy single-bot entries and names without a suffix. Classic
431
+ // routing does NOT go through the routing engine (single-key, can't hold two
432
+ // bots in one channel) — it resolves per-bot via getInstanceByChannel.
415
433
  this.classicChannels = new ClassicChannelManager(this.dataDir, this.logger);
434
+ const primaryCh = fleet.channels?.[0] ?? fleet.channel;
435
+ if (primaryCh)
436
+ this.classicChannels.setPrimaryAdapterId(primaryCh.id ?? primaryCh.type);
437
+ // Restore the persisted bot binding so replies/cancel go through the right
438
+ // bot after a restart (before this, inbound would re-bind lazily).
416
439
  for (const ch of this.classicChannels.getAll()) {
417
- this.routing.register(ch.channelId, { kind: "classic", name: ch.instanceName });
440
+ if (ch.adapterId)
441
+ this.instanceWorldBinding.set(ch.instanceName, ch.adapterId);
418
442
  }
419
443
  // Poll classicBot.yaml for external changes every 30s
420
444
  this.classicReloadTimer = setInterval(async () => {
@@ -846,7 +870,7 @@ export class FleetManager {
846
870
  await data.respond(reply);
847
871
  }
848
872
  else if (data.command === "stop") {
849
- const reply = await this.handleClassicStop(data.channelId);
873
+ const reply = await this.handleClassicStop(data.channelId, adapterId);
850
874
  await data.respond(reply);
851
875
  }
852
876
  else if (data.command === "chat") {
@@ -855,15 +879,15 @@ export class FleetManager {
855
879
  await data.respond("Usage: `/chat <message>`");
856
880
  return;
857
881
  }
858
- const target = this.routing.resolve(data.channelId);
859
- if (!target || target.kind !== "classic") {
882
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
883
+ if (!name) {
860
884
  await data.respond("No active agent in this channel. Use `/start` first.");
861
885
  return;
862
886
  }
863
887
  const replyMsgId = await data.respond("👀");
864
888
  const username = data.username ?? data.userId;
865
- ClassicChannelManager.logMessage(target.name, username, `/chat ${text}`, new Date());
866
- await this.forwardToClassicInstance(target.name, text, {
889
+ ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
890
+ await this.forwardToClassicInstance(name, text, {
867
891
  chatId: data.channelId,
868
892
  threadId: data.channelId,
869
893
  messageId: replyMsgId ?? "",
@@ -874,7 +898,7 @@ export class FleetManager {
874
898
  });
875
899
  }
876
900
  else if (data.command === "save") {
877
- await this.handleSlashSave(data);
901
+ await this.handleSlashSave(data, adapterId);
878
902
  }
879
903
  else if (data.command === "load") {
880
904
  // load is kiro-cli/classic only — no claude-code equivalent.
@@ -882,8 +906,8 @@ export class FleetManager {
882
906
  await data.respond("⛔ This command requires admin access.");
883
907
  return;
884
908
  }
885
- const target = this.routing.resolve(data.channelId);
886
- if (!target || target.kind !== "classic") {
909
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
910
+ if (!name) {
887
911
  await data.respond("No active agent in this channel. Use `/start` first.");
888
912
  return;
889
913
  }
@@ -892,39 +916,41 @@ export class FleetManager {
892
916
  await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
893
917
  return;
894
918
  }
895
- this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
896
- await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
919
+ this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
920
+ await data.respond(`✅ Sent \`/chat load ${filename}\` to ${name}`);
897
921
  }
898
922
  else if (data.command === "compact") {
899
- const target = this.routing.resolve(data.channelId);
900
- if (!target) {
923
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
924
+ if (!name) {
901
925
  await data.respond("No active agent in this channel.");
902
926
  return;
903
927
  }
904
- const result = await this.topicCommands.sendCompact(target.name);
928
+ const result = await this.topicCommands.sendCompact(name);
905
929
  await data.respond(result);
906
930
  }
907
931
  else if (data.command === "cancel") {
908
- const target = this.routing.resolve(data.channelId);
909
- if (!target) {
932
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
933
+ if (!name) {
910
934
  await data.respond("No active agent in this channel.");
911
935
  return;
912
936
  }
913
- const ok = this.cancelInstance(target.name);
914
- await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
937
+ const ok = this.cancelInstance(name);
938
+ await data.respond(ok ? `🛑 Sent cancel to ${name}.` : `❌ ${name} not running.`);
915
939
  }
916
940
  else if (data.command === "ctx") {
917
- const target = this.routing.resolve(data.channelId);
918
- if (!target) {
941
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
942
+ if (!name) {
919
943
  await data.respond("No active agent in this channel.");
920
944
  return;
921
945
  }
922
946
  // Single source of truth (statusline.json + robust tmux pane fallback).
923
- await data.respond(await this.topicCommands.getCtxText(target.name));
947
+ await data.respond(await this.topicCommands.getCtxText(name));
924
948
  }
925
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.
926
952
  const collabTarget = this.routing.resolve(data.channelId);
927
- if (collabTarget && collabTarget.kind !== "classic") {
953
+ if (collabTarget) {
928
954
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
929
955
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
930
956
  await data.respond("⛔ Not authorized");
@@ -938,11 +964,11 @@ export class FleetManager {
938
964
  await data.respond("⛔ This command requires admin access.");
939
965
  return;
940
966
  }
941
- if (!this.classicChannels.isClassicChannel(data.channelId)) {
967
+ if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
942
968
  await data.respond("No active agent in this channel. Use `/start` first.");
943
969
  return;
944
970
  }
945
- const newState = this.classicChannels.toggleCollab(data.channelId);
971
+ const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
946
972
  await data.respond(newState
947
973
  ? "🤝 Collaboration mode **ON** — @mention this bot to trigger the agent. Other bot messages are visible."
948
974
  : "💬 Collaboration mode **OFF** — use `/chat` to talk to the agent.");
@@ -995,12 +1021,12 @@ export class FleetManager {
995
1021
  process.kill(process.pid, "SIGUSR2");
996
1022
  }
997
1023
  else if (data.command === "compact") {
998
- const target = this.routing.resolve(data.channelId);
999
- if (!target) {
1024
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1025
+ if (!name) {
1000
1026
  await data.respond("No active agent in this channel.");
1001
1027
  return;
1002
1028
  }
1003
- const result = await this.topicCommands.sendCompact(target.name);
1029
+ const result = await this.topicCommands.sendCompact(name);
1004
1030
  await data.respond(result);
1005
1031
  }
1006
1032
  }, this.logger, "adapter.slash_command"));
@@ -1109,7 +1135,7 @@ export class FleetManager {
1109
1135
  await data.respond(reply);
1110
1136
  }
1111
1137
  else if (data.command === "stop") {
1112
- const reply = await this.handleClassicStop(data.channelId);
1138
+ const reply = await this.handleClassicStop(data.channelId, adapterId);
1113
1139
  await data.respond(reply);
1114
1140
  }
1115
1141
  else if (data.command === "chat") {
@@ -1118,15 +1144,15 @@ export class FleetManager {
1118
1144
  await data.respond("Usage: `/chat <message>`");
1119
1145
  return;
1120
1146
  }
1121
- const target = this.routing.resolve(data.channelId);
1122
- if (!target || target.kind !== "classic") {
1147
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
1148
+ if (!name) {
1123
1149
  await data.respond("No active agent in this channel. Use `/start` first.");
1124
1150
  return;
1125
1151
  }
1126
1152
  const replyMsgId = await data.respond("👀");
1127
1153
  const username = data.username ?? data.userId;
1128
- ClassicChannelManager.logMessage(target.name, username, `/chat ${text}`, new Date());
1129
- await this.forwardToClassicInstance(target.name, text, {
1154
+ ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
1155
+ await this.forwardToClassicInstance(name, text, {
1130
1156
  chatId: data.channelId,
1131
1157
  threadId: data.channelId,
1132
1158
  messageId: replyMsgId ?? "",
@@ -1137,7 +1163,7 @@ export class FleetManager {
1137
1163
  });
1138
1164
  }
1139
1165
  else if (data.command === "save") {
1140
- await this.handleSlashSave(data);
1166
+ await this.handleSlashSave(data, adapterId);
1141
1167
  }
1142
1168
  else if (data.command === "load") {
1143
1169
  // load is kiro-cli/classic only — no claude-code equivalent.
@@ -1145,8 +1171,8 @@ export class FleetManager {
1145
1171
  await data.respond("⛔ This command requires admin access.");
1146
1172
  return;
1147
1173
  }
1148
- const target = this.routing.resolve(data.channelId);
1149
- if (!target || target.kind !== "classic") {
1174
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
1175
+ if (!name) {
1150
1176
  await data.respond("No active agent in this channel. Use `/start` first.");
1151
1177
  return;
1152
1178
  }
@@ -1155,39 +1181,32 @@ export class FleetManager {
1155
1181
  await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1156
1182
  return;
1157
1183
  }
1158
- this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
1159
- await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
1160
- }
1161
- else if (data.command === "compact") {
1162
- const target = this.routing.resolve(data.channelId);
1163
- if (!target) {
1164
- await data.respond("No active agent in this channel.");
1165
- return;
1166
- }
1167
- const result = await this.topicCommands.sendCompact(target.name);
1168
- await data.respond(result);
1184
+ this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
1185
+ await data.respond(`✅ Sent \`/chat load ${filename}\` to ${name}`);
1169
1186
  }
1170
1187
  else if (data.command === "cancel") {
1171
- const target = this.routing.resolve(data.channelId);
1172
- if (!target) {
1188
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1189
+ if (!name) {
1173
1190
  await data.respond("No active agent in this channel.");
1174
1191
  return;
1175
1192
  }
1176
- const ok = this.cancelInstance(target.name);
1177
- await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
1193
+ const ok = this.cancelInstance(name);
1194
+ await data.respond(ok ? `🛑 Sent cancel to ${name}.` : `❌ ${name} not running.`);
1178
1195
  }
1179
1196
  else if (data.command === "ctx") {
1180
- const target = this.routing.resolve(data.channelId);
1181
- if (!target) {
1197
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1198
+ if (!name) {
1182
1199
  await data.respond("No active agent in this channel.");
1183
1200
  return;
1184
1201
  }
1185
1202
  // Single source of truth (statusline.json + robust tmux pane fallback).
1186
- await data.respond(await this.topicCommands.getCtxText(target.name));
1203
+ await data.respond(await this.topicCommands.getCtxText(name));
1187
1204
  }
1188
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.
1189
1208
  const collabTarget2 = this.routing.resolve(data.channelId);
1190
- if (collabTarget2 && collabTarget2.kind !== "classic") {
1209
+ if (collabTarget2) {
1191
1210
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1192
1211
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
1193
1212
  await data.respond("⛔ Not authorized");
@@ -1201,11 +1220,11 @@ export class FleetManager {
1201
1220
  await data.respond("⛔ This command requires admin access.");
1202
1221
  return;
1203
1222
  }
1204
- if (!this.classicChannels.isClassicChannel(data.channelId)) {
1223
+ if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
1205
1224
  await data.respond("No active agent in this channel. Use `/start` first.");
1206
1225
  return;
1207
1226
  }
1208
- const newState = this.classicChannels.toggleCollab(data.channelId);
1227
+ const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
1209
1228
  await data.respond(newState
1210
1229
  ? "🤝 Collaboration mode **ON** — @mention this bot to trigger the agent. Other bot messages are visible."
1211
1230
  : "💬 Collaboration mode **OFF** — use `/chat` to talk to the agent.");
@@ -1258,12 +1277,12 @@ export class FleetManager {
1258
1277
  process.kill(process.pid, "SIGUSR2");
1259
1278
  }
1260
1279
  else if (data.command === "compact") {
1261
- const target = this.routing.resolve(data.channelId);
1262
- if (!target) {
1280
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1281
+ if (!name) {
1263
1282
  await data.respond("No active agent in this channel.");
1264
1283
  return;
1265
1284
  }
1266
- const result = await this.topicCommands.sendCompact(target.name);
1285
+ const result = await this.topicCommands.sendCompact(name);
1267
1286
  await data.respond(result);
1268
1287
  }
1269
1288
  }, this.logger, `adapter[${adapterId}].slash_command`));
@@ -1486,8 +1505,17 @@ export class FleetManager {
1486
1505
  // Routing (by topic/channel) and reply-adapter selection (by channel_id
1487
1506
  // binding) are adapter-independent, so it's safe to let whichever adapter
1488
1507
  // arrives first handle it.
1508
+ //
1509
+ // EXCEPTION — classic channels with same-channel multi-bot: two bots may own
1510
+ // separate agents in one channel, so each bot must process its OWN copy of
1511
+ // the message (the @mention filter downstream decides who actually forwards).
1512
+ // Key the dedup per-adapter there so a sibling bot's copy isn't dropped.
1489
1513
  if (msg.messageId) {
1490
- const dedupKey = `${msg.source}:${msg.chatId}:${msg.messageId}`;
1514
+ const classicCid = msg.threadId || msg.chatId;
1515
+ const isClassicMsg = this.classicChannels?.hasChannel(classicCid) ?? false;
1516
+ const dedupKey = isClassicMsg
1517
+ ? `${msg.source}:${msg.chatId}:${msg.messageId}:${msg.adapterId ?? ""}`
1518
+ : `${msg.source}:${msg.chatId}:${msg.messageId}`;
1491
1519
  if (this.recentMessageIds.has(dedupKey)) {
1492
1520
  this.logger.debug({ dedupKey, adapterId: msg.adapterId }, "Duplicate inbound across adapters — skipping");
1493
1521
  return;
@@ -1514,21 +1542,25 @@ export class FleetManager {
1514
1542
  return;
1515
1543
  // Fall through to TG classic handling below
1516
1544
  }
1545
+ else if (this.classicChannels?.hasChannel(threadId)) {
1546
+ // Classic channel (per-bot): bot messages only when THIS bot owns an
1547
+ // agent here and collab is on for it.
1548
+ const classicName = this.classicChannels.getInstanceByChannel(threadId, msg.adapterId);
1549
+ if (!classicName)
1550
+ return;
1551
+ if (!this.classicChannels.isCollab(threadId, msg.adapterId))
1552
+ return;
1553
+ // Fall through to channel handling
1554
+ }
1517
1555
  else {
1518
1556
  const target = this.routing.resolve(threadId);
1519
1557
  if (!target)
1520
1558
  return;
1521
- if (target.kind === "classic") {
1522
- if (!this.classicChannels?.isCollab(threadId))
1523
- return;
1524
- }
1525
- else {
1526
- // Fleet topic: allow if collab enabled OR access mode is open
1527
- const channelCfg = this.getChannelConfig(msg.adapterId);
1528
- const isOpen = channelCfg?.access?.mode === "open";
1529
- if (!isOpen && !this.collabInstances.has(target.name))
1530
- return;
1531
- }
1559
+ // Fleet topic: allow if collab enabled OR access mode is open
1560
+ const channelCfg = this.getChannelConfig(msg.adapterId);
1561
+ const isOpen = channelCfg?.access?.mode === "open";
1562
+ if (!isOpen && !this.collabInstances.has(target.name))
1563
+ return;
1532
1564
  // Fall through to channel handling
1533
1565
  }
1534
1566
  }
@@ -1538,9 +1570,10 @@ export class FleetManager {
1538
1570
  const adapterGroupId = String(this.getChannelConfig(msg.adapterId)?.group_id ?? "");
1539
1571
  const isTelegramClassicCandidate = msg.source === "telegram" && msg.chatId !== adapterGroupId && !threadId;
1540
1572
  if (!isTelegramClassicCandidate) {
1541
- const target = threadId ? this.routing.resolve(threadId) : undefined;
1542
- this.logger.info({ userId: msg.userId, threadId, targetKind: target?.kind, targetName: target?.name }, "Access DENIED for non-allowed user");
1543
- if (!target || target.kind !== "classic")
1573
+ // Classic channels are open to all; check per-bot ownership (or fleet topic).
1574
+ const isClassic = !!(threadId && this.classicChannels?.hasChannel(threadId));
1575
+ this.logger.info({ userId: msg.userId, threadId, isClassic }, "Access DENIED for non-allowed user");
1576
+ if (!isClassic)
1544
1577
  return;
1545
1578
  }
1546
1579
  }
@@ -1628,7 +1661,7 @@ export class FleetManager {
1628
1661
  }
1629
1662
  return;
1630
1663
  }
1631
- const reply = await this.handleClassicStop(chatId);
1664
+ const reply = await this.handleClassicStop(chatId, msg.adapterId);
1632
1665
  await msgAdapter?.sendText(chatId, reply);
1633
1666
  return;
1634
1667
  }
@@ -1638,34 +1671,34 @@ export class FleetManager {
1638
1671
  await msgAdapter?.sendText(chatId, "⛔ /compact requires admin access.");
1639
1672
  return;
1640
1673
  }
1641
- const compactTarget = this.routing.resolve(chatId);
1642
- if (!compactTarget || compactTarget.kind !== "classic") {
1674
+ const compactName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1675
+ if (!compactName) {
1643
1676
  await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1644
1677
  return;
1645
1678
  }
1646
- const result = await this.topicCommands.sendCompact(compactTarget.name);
1679
+ const result = await this.topicCommands.sendCompact(compactName);
1647
1680
  await msgAdapter?.sendText(chatId, result);
1648
1681
  return;
1649
1682
  }
1650
1683
  // Handle /cancel command
1651
1684
  if (text === "/cancel" || text.startsWith("/cancel@")) {
1652
- const cancelTarget = this.routing.resolve(chatId);
1653
- if (!cancelTarget || cancelTarget.kind !== "classic") {
1685
+ const cancelName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1686
+ if (!cancelName) {
1654
1687
  await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1655
1688
  return;
1656
1689
  }
1657
- const ok = this.cancelInstance(cancelTarget.name);
1658
- await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelTarget.name}。` : `❌ ${cancelTarget.name} 未在執行。`);
1690
+ const ok = this.cancelInstance(cancelName);
1691
+ await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelName}。` : `❌ ${cancelName} 未在執行。`);
1659
1692
  return;
1660
1693
  }
1661
1694
  // Handle /ctx command
1662
1695
  if (text === "/ctx" || text.startsWith("/ctx@")) {
1663
- const ctxTarget = this.routing.resolve(chatId);
1664
- if (!ctxTarget || ctxTarget.kind !== "classic") {
1696
+ const ctxName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1697
+ if (!ctxName) {
1665
1698
  await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1666
1699
  return;
1667
1700
  }
1668
- const reply = await this.topicCommands.getCtxText(ctxTarget.name);
1701
+ const reply = await this.topicCommands.getCtxText(ctxName);
1669
1702
  await msgAdapter?.sendText(chatId, reply);
1670
1703
  return;
1671
1704
  }
@@ -1675,8 +1708,8 @@ export class FleetManager {
1675
1708
  await msgAdapter?.sendText(chatId, "⛔ /save requires admin access.");
1676
1709
  return;
1677
1710
  }
1678
- const saveTarget = this.routing.resolve(chatId);
1679
- if (!saveTarget || saveTarget.kind !== "classic") {
1711
+ const saveName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1712
+ if (!saveName) {
1680
1713
  await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1681
1714
  return;
1682
1715
  }
@@ -1689,26 +1722,26 @@ export class FleetManager {
1689
1722
  await msgAdapter?.sendText(chatId, "⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1690
1723
  return;
1691
1724
  }
1692
- const backend = this.classicChannels.getBackendByInstance(saveTarget.name, this.fleetConfig?.defaults?.backend);
1725
+ const backend = this.classicChannels.getBackendByInstance(saveName, this.fleetConfig?.defaults?.backend);
1693
1726
  const cmd = saveCommandForBackend(backend, filename);
1694
1727
  if (!cmd) {
1695
1728
  await msgAdapter?.sendText(chatId, SAVE_UNSUPPORTED_MSG);
1696
1729
  return;
1697
1730
  }
1698
- this.pasteRawToClassicInstance(saveTarget.name, cmd);
1699
- await msgAdapter?.sendText(chatId, `✅ Sent \`${cmd}\` to ${saveTarget.name}`);
1731
+ this.pasteRawToClassicInstance(saveName, cmd);
1732
+ await msgAdapter?.sendText(chatId, `✅ Sent \`${cmd}\` to ${saveName}`);
1700
1733
  return;
1701
1734
  }
1702
- // Route to classic channel if registered
1703
- const target = this.routing.resolve(chatId);
1704
- if (target?.kind === "classic") {
1735
+ // Route to classic channel if this bot has an agent here (per-bot).
1736
+ const classicName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1737
+ if (classicName) {
1705
1738
  if (msg.adapterId)
1706
- this.bindInstanceAdapter(target.name, msg.adapterId, true);
1739
+ this.bindInstanceAdapter(classicName, msg.adapterId, true);
1707
1740
  // TG ClassicBot: group requires @mention, private chat forwards directly.
1708
1741
  if (!isPrivateChat && !isBotMentioned) {
1709
1742
  // No trigger: save attachments + react, log, but don't forward to agent
1710
1743
  const syntheticMsg = { ...msg, threadId: chatId, text: rawText.startsWith("/") ? "" : rawText };
1711
- await this.handleClassicChannelMessage(target.name, syntheticMsg);
1744
+ await this.handleClassicChannelMessage(classicName, syntheticMsg);
1712
1745
  return;
1713
1746
  }
1714
1747
  // Strip @bot from text and forward as /chat
@@ -1718,7 +1751,7 @@ export class FleetManager {
1718
1751
  return;
1719
1752
  }
1720
1753
  const syntheticMsg = { ...msg, threadId: chatId, text: `/chat ${cleanText}` };
1721
- await this.handleClassicChannelMessage(target.name, syntheticMsg);
1754
+ await this.handleClassicChannelMessage(classicName, syntheticMsg);
1722
1755
  return;
1723
1756
  }
1724
1757
  // Handle @bot without active agent
@@ -1743,9 +1776,12 @@ export class FleetManager {
1743
1776
  if (msg.adapterId)
1744
1777
  this.bindInstanceAdapter(generalInstance, msg.adapterId, true);
1745
1778
  const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1746
- // 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.
1747
1782
  if (msg.chatId && msg.messageId) {
1748
- inboundAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
1783
+ const reactAdapter = this.getAdapterForInstance(generalInstance) ?? inboundAdapter;
1784
+ reactAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
1749
1785
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
1750
1786
  }
1751
1787
  this.warnIfRateLimited(generalInstance, msg);
@@ -1781,6 +1817,18 @@ export class FleetManager {
1781
1817
  }
1782
1818
  return;
1783
1819
  }
1820
+ // Classic channels resolve per-bot (same-channel multi-bot) — a channel can
1821
+ // host two bots' agents. If this channel is classic but THIS bot has no
1822
+ // agent here, a sibling bot owns it; skip rather than misroute to it.
1823
+ if (this.classicChannels?.hasChannel(threadId)) {
1824
+ const classicName = this.classicChannels.getInstanceByChannel(threadId, msg.adapterId);
1825
+ if (!classicName)
1826
+ return;
1827
+ if (msg.adapterId)
1828
+ this.bindInstanceAdapter(classicName, msg.adapterId, true);
1829
+ await this.handleClassicChannelMessage(classicName, msg);
1830
+ return;
1831
+ }
1784
1832
  const target = this.routing.resolve(threadId);
1785
1833
  if (!target) {
1786
1834
  // Only show unbound message for actual forum topics (same group, has threadId)
@@ -1812,9 +1860,12 @@ export class FleetManager {
1812
1860
  if (msg.adapterId)
1813
1861
  this.bindInstanceAdapter(instanceName, msg.adapterId, true);
1814
1862
  const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1815
- // 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.
1816
1866
  if (msg.chatId && msg.messageId) {
1817
- inboundAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
1867
+ const reactAdapter = this.getAdapterForInstance(instanceName) ?? inboundAdapter;
1868
+ reactAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
1818
1869
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
1819
1870
  }
1820
1871
  // These may hit Discord API (topic icon, archive) — do after react
@@ -1939,7 +1990,7 @@ export class FleetManager {
1939
1990
  ts: new Date().toISOString(),
1940
1991
  });
1941
1992
  // Log bot reply to classic instance chat-log
1942
- const isClassic = [...this.routing.entries()].some(([, t]) => t.kind === "classic" && t.name === instanceName);
1993
+ const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
1943
1994
  if (isClassic) {
1944
1995
  ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
1945
1996
  }
@@ -2628,13 +2679,12 @@ export class FleetManager {
2628
2679
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send instance topic notification"));
2629
2680
  return;
2630
2681
  }
2631
- // Classic instance: find chatId from routing table
2632
- for (const [chatId, target] of this.routing.entries()) {
2633
- if (target.kind === "classic" && target.name === instanceName) {
2634
- adapter.sendText(chatId, text)
2635
- .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send classic notification"));
2636
- return;
2637
- }
2682
+ // Classic instance: find its channelId from the classic manager
2683
+ const classicChatId = this.classicChannels?.getChannelIdByInstance(instanceName);
2684
+ if (classicChatId) {
2685
+ adapter.sendText(classicChatId, text)
2686
+ .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send classic notification"));
2687
+ return;
2638
2688
  }
2639
2689
  // Fallback: send to group without threadId
2640
2690
  if (groupId) {
@@ -2651,12 +2701,16 @@ export class FleetManager {
2651
2701
  * Picks the backend-appropriate command (kiro → /chat save, claude → /export);
2652
2702
  * unsupported backends get a clear error. Routes via classic paste or fleet IPC.
2653
2703
  */
2654
- async handleSlashSave(data) {
2704
+ async handleSlashSave(data, adapterId) {
2655
2705
  if (!this.classicChannels?.isAdmin(data.userId)) {
2656
2706
  await data.respond("⛔ This command requires admin access.");
2657
2707
  return;
2658
2708
  }
2659
- const target = this.routing.resolve(data.channelId);
2709
+ // Classic resolves per-bot (same-channel multi-bot); otherwise a fleet topic.
2710
+ const classicName = this.classicChannels.getInstanceByChannel(data.channelId, adapterId);
2711
+ const target = classicName
2712
+ ? { kind: "classic", name: classicName }
2713
+ : this.routing.resolve(data.channelId);
2660
2714
  if (!target) {
2661
2715
  await data.respond("No active agent in this channel. Use `/start` first.");
2662
2716
  return;
@@ -2711,13 +2765,8 @@ export class FleetManager {
2711
2765
  threadId = String(topicId);
2712
2766
  }
2713
2767
  else {
2714
- // Classic instance: chatId from the routing table.
2715
- for (const [cid, target] of this.routing.entries()) {
2716
- if (target.kind === "classic" && target.name === instanceName) {
2717
- chatId = cid;
2718
- break;
2719
- }
2720
- }
2768
+ // Classic instance: channelId from the classic manager.
2769
+ chatId = this.classicChannels?.getChannelIdByInstance(instanceName);
2721
2770
  // General / flat fallback: post to the group (no thread).
2722
2771
  if (!chatId && groupId)
2723
2772
  chatId = String(groupId);
@@ -3200,7 +3249,7 @@ When users create specialized instances, suggest these configurations:
3200
3249
  async handleClassicChannelMessage(instanceName, msg) {
3201
3250
  const text = msg.text ?? "";
3202
3251
  const channelId = msg.threadId ?? msg.chatId;
3203
- const isCollabMode = this.classicChannels?.isCollab(channelId) ?? false;
3252
+ const isCollabMode = this.classicChannels?.isCollab(channelId, msg.adapterId) ?? false;
3204
3253
  // Handle /ctx in classic mode — always, regardless of collab mode
3205
3254
  if (text === "/ctx" || text.startsWith("/ctx@")) {
3206
3255
  const reply = await this.topicCommands.getCtxText(instanceName);
@@ -3227,8 +3276,15 @@ When users create specialized instances, suggest these configurations:
3227
3276
  : "");
3228
3277
  ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText);
3229
3278
  this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message");
3230
- // Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here
3231
- const adapterBotUserId = this.worlds.get(msg.adapterId ?? "")?.botUserId ?? this.botUserId;
3279
+ // Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here.
3280
+ // Each bot matches ONLY its own id. A secondary bot must NOT fall back to the
3281
+ // process-wide botUserId (the primary's) — otherwise, in a same-channel
3282
+ // multi-bot setup, an @mention of the primary would also match the secondary
3283
+ // and BOTH bots would react 👀 and forward. Only the primary adapter may use
3284
+ // the fallback.
3285
+ const mentionWorld = this.worlds.get(msg.adapterId ?? "");
3286
+ const isPrimaryAdapter = !mentionWorld || mentionWorld.adapter === this.adapter;
3287
+ const adapterBotUserId = mentionWorld?.botUserId ?? (isPrimaryAdapter ? this.botUserId : undefined);
3232
3288
  const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
3233
3289
  const isMentioned = mentionTag && text.includes(mentionTag);
3234
3290
  if (!isMentioned) {
@@ -3406,7 +3462,11 @@ When users create specialized instances, suggest these configurations:
3406
3462
  }
3407
3463
  /** Forward a message to a classic channel instance with chat log context */
3408
3464
  async forwardToClassicInstance(instanceName, text, msg, extraMeta) {
3409
- const contextLines = this.classicChannels?.getContextLines(msg.chatId) ?? 5;
3465
+ // Resolve the channel/adapter from the instance itself so per-channel context
3466
+ // config is correct even for a same-channel second bot.
3467
+ const ctxAdapterId = this.classicChannels?.getAdapterIdByInstance(instanceName);
3468
+ const ctxChannelId = this.classicChannels?.getChannelIdByInstance(instanceName) ?? msg.chatId;
3469
+ const contextLines = this.classicChannels?.getContextLines(ctxChannelId, ctxAdapterId) ?? 5;
3410
3470
  const logContext = this.getRecentChatLog(instanceName, contextLines);
3411
3471
  const fullText = logContext
3412
3472
  ? `[Chat log for context]\n${logContext}\n\n[User message]\n${text}`
@@ -3502,38 +3562,40 @@ When users create specialized instances, suggest these configurations:
3502
3562
  }
3503
3563
  return "⛔ This server is not in the allowed guilds list.";
3504
3564
  }
3505
- if (this.classicChannels.isClassicChannel(channelId))
3565
+ // Per-bot check: a second bot may /start in the same channel (own agent).
3566
+ if (this.classicChannels.isClassicChannel(channelId, adapterId))
3506
3567
  return "This channel already has an active agent. Use /chat to talk.";
3568
+ // Classic no longer lives in the routing engine, so this only guards against
3569
+ // a fleet topic-mode instance colliding with the channel.
3507
3570
  if (this.routing.resolve(channelId))
3508
3571
  return "This channel is already bound to a topic-mode instance.";
3509
- const instanceName = classicInstanceName(sanitizeInstanceName(channelName || channelId), channelId);
3510
- this.classicChannels.register(channelId, instanceName, channelName || channelId, userId);
3511
- this.routing.register(channelId, { kind: "classic", name: instanceName });
3572
+ const instanceName = this.classicChannels.deriveInstanceName(channelName || channelId, channelId, adapterId);
3573
+ this.classicChannels.register(channelId, adapterId, instanceName, channelName || channelId, userId);
3512
3574
  // Bind this classic instance to the bot that started it (authoritative), so
3513
3575
  // replies/cancel go out through that bot even though every same-guild bot
3514
- // also sees the channel's messages. (Persisting this survives to v2.1.)
3576
+ // also sees the channel's messages.
3515
3577
  if (adapterId)
3516
3578
  this.bindInstanceAdapter(instanceName, adapterId);
3517
- await this.startClassicInstance(instanceName, this.classicChannels.getBackend(channelId, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channelId), this.classicChannels.getModel(channelId, this.fleetConfig?.defaults?.model));
3579
+ await this.startClassicInstance(instanceName, this.classicChannels.getBackend(channelId, adapterId, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channelId, adapterId), this.classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model));
3518
3580
  this.reregisterClassicChannels();
3519
3581
  // Auto-enable collab for Discord classic channels (TG uses @mention directly without collab mode)
3520
- if (guildId && !this.classicChannels.isCollab(channelId)) {
3521
- this.classicChannels.toggleCollab(channelId);
3582
+ if (guildId && !this.classicChannels.isCollab(channelId, adapterId)) {
3583
+ this.classicChannels.toggleCollab(channelId, adapterId);
3522
3584
  }
3523
- this.logger.info({ channelId, instanceName, userId }, "Classic channel started");
3585
+ this.logger.info({ channelId, adapterId, instanceName, userId }, "Classic channel started");
3524
3586
  return `✅ Agent started in this channel. Use \`/chat <message>\` or @mention to talk.`;
3525
3587
  }
3526
3588
  /** Handle /stop slash command — unregister classic channel */
3527
- async handleClassicStop(channelId) {
3589
+ async handleClassicStop(channelId, adapterId) {
3528
3590
  if (!this.classicChannels)
3529
3591
  return "Classic channel manager not initialized.";
3530
- const ch = this.classicChannels.unregister(channelId);
3592
+ const ch = this.classicChannels.unregister(channelId, adapterId);
3531
3593
  if (!ch)
3532
3594
  return "No active agent in this channel.";
3533
- this.routing.unregister(channelId);
3595
+ this.instanceWorldBinding.delete(ch.instanceName);
3534
3596
  await this.stopInstance(ch.instanceName).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance"));
3535
3597
  this.reregisterClassicChannels();
3536
- this.logger.info({ channelId, instanceName: ch.instanceName }, "Classic channel stopped");
3598
+ this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
3537
3599
  return `🛑 Agent stopped in this channel.`;
3538
3600
  }
3539
3601
  async stopAll() {