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

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,22 +179,27 @@ 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) {
@@ -411,10 +416,19 @@ export class FleetManager {
411
416
  const pidPath = join(this.dataDir, "fleet.pid");
412
417
  writeFileSync(pidPath, String(process.pid), "utf-8");
413
418
  this.eventLog = new EventLog(join(this.dataDir, "events.db"));
414
- // Initialize classic channel manager and register existing channels in routing
419
+ // Initialize classic channel manager. The primary adapter (channels[0])
420
+ // migrates legacy single-bot entries and names without a suffix. Classic
421
+ // routing does NOT go through the routing engine (single-key, can't hold two
422
+ // bots in one channel) — it resolves per-bot via getInstanceByChannel.
415
423
  this.classicChannels = new ClassicChannelManager(this.dataDir, this.logger);
424
+ const primaryCh = fleet.channels?.[0] ?? fleet.channel;
425
+ if (primaryCh)
426
+ this.classicChannels.setPrimaryAdapterId(primaryCh.id ?? primaryCh.type);
427
+ // Restore the persisted bot binding so replies/cancel go through the right
428
+ // bot after a restart (before this, inbound would re-bind lazily).
416
429
  for (const ch of this.classicChannels.getAll()) {
417
- this.routing.register(ch.channelId, { kind: "classic", name: ch.instanceName });
430
+ if (ch.adapterId)
431
+ this.instanceWorldBinding.set(ch.instanceName, ch.adapterId);
418
432
  }
419
433
  // Poll classicBot.yaml for external changes every 30s
420
434
  this.classicReloadTimer = setInterval(async () => {
@@ -846,7 +860,7 @@ export class FleetManager {
846
860
  await data.respond(reply);
847
861
  }
848
862
  else if (data.command === "stop") {
849
- const reply = await this.handleClassicStop(data.channelId);
863
+ const reply = await this.handleClassicStop(data.channelId, adapterId);
850
864
  await data.respond(reply);
851
865
  }
852
866
  else if (data.command === "chat") {
@@ -855,15 +869,15 @@ export class FleetManager {
855
869
  await data.respond("Usage: `/chat <message>`");
856
870
  return;
857
871
  }
858
- const target = this.routing.resolve(data.channelId);
859
- if (!target || target.kind !== "classic") {
872
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
873
+ if (!name) {
860
874
  await data.respond("No active agent in this channel. Use `/start` first.");
861
875
  return;
862
876
  }
863
877
  const replyMsgId = await data.respond("👀");
864
878
  const username = data.username ?? data.userId;
865
- ClassicChannelManager.logMessage(target.name, username, `/chat ${text}`, new Date());
866
- await this.forwardToClassicInstance(target.name, text, {
879
+ ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
880
+ await this.forwardToClassicInstance(name, text, {
867
881
  chatId: data.channelId,
868
882
  threadId: data.channelId,
869
883
  messageId: replyMsgId ?? "",
@@ -874,7 +888,7 @@ export class FleetManager {
874
888
  });
875
889
  }
876
890
  else if (data.command === "save") {
877
- await this.handleSlashSave(data);
891
+ await this.handleSlashSave(data, adapterId);
878
892
  }
879
893
  else if (data.command === "load") {
880
894
  // load is kiro-cli/classic only — no claude-code equivalent.
@@ -882,8 +896,8 @@ export class FleetManager {
882
896
  await data.respond("⛔ This command requires admin access.");
883
897
  return;
884
898
  }
885
- const target = this.routing.resolve(data.channelId);
886
- if (!target || target.kind !== "classic") {
899
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
900
+ if (!name) {
887
901
  await data.respond("No active agent in this channel. Use `/start` first.");
888
902
  return;
889
903
  }
@@ -892,35 +906,35 @@ export class FleetManager {
892
906
  await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
893
907
  return;
894
908
  }
895
- this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
896
- await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
909
+ this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
910
+ await data.respond(`✅ Sent \`/chat load ${filename}\` to ${name}`);
897
911
  }
898
912
  else if (data.command === "compact") {
899
- const target = this.routing.resolve(data.channelId);
900
- if (!target) {
913
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
914
+ if (!name) {
901
915
  await data.respond("No active agent in this channel.");
902
916
  return;
903
917
  }
904
- const result = await this.topicCommands.sendCompact(target.name);
918
+ const result = await this.topicCommands.sendCompact(name);
905
919
  await data.respond(result);
906
920
  }
907
921
  else if (data.command === "cancel") {
908
- const target = this.routing.resolve(data.channelId);
909
- if (!target) {
922
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
923
+ if (!name) {
910
924
  await data.respond("No active agent in this channel.");
911
925
  return;
912
926
  }
913
- const ok = this.cancelInstance(target.name);
914
- await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
927
+ const ok = this.cancelInstance(name);
928
+ await data.respond(ok ? `🛑 Sent cancel to ${name}.` : `❌ ${name} not running.`);
915
929
  }
916
930
  else if (data.command === "ctx") {
917
- const target = this.routing.resolve(data.channelId);
918
- if (!target) {
931
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
932
+ if (!name) {
919
933
  await data.respond("No active agent in this channel.");
920
934
  return;
921
935
  }
922
936
  // Single source of truth (statusline.json + robust tmux pane fallback).
923
- await data.respond(await this.topicCommands.getCtxText(target.name));
937
+ await data.respond(await this.topicCommands.getCtxText(name));
924
938
  }
925
939
  else if (data.command === "collab") {
926
940
  const collabTarget = this.routing.resolve(data.channelId);
@@ -938,11 +952,11 @@ export class FleetManager {
938
952
  await data.respond("⛔ This command requires admin access.");
939
953
  return;
940
954
  }
941
- if (!this.classicChannels.isClassicChannel(data.channelId)) {
955
+ if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
942
956
  await data.respond("No active agent in this channel. Use `/start` first.");
943
957
  return;
944
958
  }
945
- const newState = this.classicChannels.toggleCollab(data.channelId);
959
+ const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
946
960
  await data.respond(newState
947
961
  ? "🤝 Collaboration mode **ON** — @mention this bot to trigger the agent. Other bot messages are visible."
948
962
  : "💬 Collaboration mode **OFF** — use `/chat` to talk to the agent.");
@@ -995,12 +1009,12 @@ export class FleetManager {
995
1009
  process.kill(process.pid, "SIGUSR2");
996
1010
  }
997
1011
  else if (data.command === "compact") {
998
- const target = this.routing.resolve(data.channelId);
999
- if (!target) {
1012
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
1013
+ if (!name) {
1000
1014
  await data.respond("No active agent in this channel.");
1001
1015
  return;
1002
1016
  }
1003
- const result = await this.topicCommands.sendCompact(target.name);
1017
+ const result = await this.topicCommands.sendCompact(name);
1004
1018
  await data.respond(result);
1005
1019
  }
1006
1020
  }, this.logger, "adapter.slash_command"));
@@ -1109,7 +1123,7 @@ export class FleetManager {
1109
1123
  await data.respond(reply);
1110
1124
  }
1111
1125
  else if (data.command === "stop") {
1112
- const reply = await this.handleClassicStop(data.channelId);
1126
+ const reply = await this.handleClassicStop(data.channelId, adapterId);
1113
1127
  await data.respond(reply);
1114
1128
  }
1115
1129
  else if (data.command === "chat") {
@@ -1118,15 +1132,15 @@ export class FleetManager {
1118
1132
  await data.respond("Usage: `/chat <message>`");
1119
1133
  return;
1120
1134
  }
1121
- const target = this.routing.resolve(data.channelId);
1122
- if (!target || target.kind !== "classic") {
1135
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
1136
+ if (!name) {
1123
1137
  await data.respond("No active agent in this channel. Use `/start` first.");
1124
1138
  return;
1125
1139
  }
1126
1140
  const replyMsgId = await data.respond("👀");
1127
1141
  const username = data.username ?? data.userId;
1128
- ClassicChannelManager.logMessage(target.name, username, `/chat ${text}`, new Date());
1129
- await this.forwardToClassicInstance(target.name, text, {
1142
+ ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
1143
+ await this.forwardToClassicInstance(name, text, {
1130
1144
  chatId: data.channelId,
1131
1145
  threadId: data.channelId,
1132
1146
  messageId: replyMsgId ?? "",
@@ -1137,7 +1151,7 @@ export class FleetManager {
1137
1151
  });
1138
1152
  }
1139
1153
  else if (data.command === "save") {
1140
- await this.handleSlashSave(data);
1154
+ await this.handleSlashSave(data, adapterId);
1141
1155
  }
1142
1156
  else if (data.command === "load") {
1143
1157
  // load is kiro-cli/classic only — no claude-code equivalent.
@@ -1145,8 +1159,8 @@ export class FleetManager {
1145
1159
  await data.respond("⛔ This command requires admin access.");
1146
1160
  return;
1147
1161
  }
1148
- const target = this.routing.resolve(data.channelId);
1149
- if (!target || target.kind !== "classic") {
1162
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
1163
+ if (!name) {
1150
1164
  await data.respond("No active agent in this channel. Use `/start` first.");
1151
1165
  return;
1152
1166
  }
@@ -1155,35 +1169,26 @@ export class FleetManager {
1155
1169
  await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1156
1170
  return;
1157
1171
  }
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);
1172
+ this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
1173
+ await data.respond(`✅ Sent \`/chat load ${filename}\` to ${name}`);
1169
1174
  }
1170
1175
  else if (data.command === "cancel") {
1171
- const target = this.routing.resolve(data.channelId);
1172
- if (!target) {
1176
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
1177
+ if (!name) {
1173
1178
  await data.respond("No active agent in this channel.");
1174
1179
  return;
1175
1180
  }
1176
- const ok = this.cancelInstance(target.name);
1177
- await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
1181
+ const ok = this.cancelInstance(name);
1182
+ await data.respond(ok ? `🛑 Sent cancel to ${name}.` : `❌ ${name} not running.`);
1178
1183
  }
1179
1184
  else if (data.command === "ctx") {
1180
- const target = this.routing.resolve(data.channelId);
1181
- if (!target) {
1185
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
1186
+ if (!name) {
1182
1187
  await data.respond("No active agent in this channel.");
1183
1188
  return;
1184
1189
  }
1185
1190
  // Single source of truth (statusline.json + robust tmux pane fallback).
1186
- await data.respond(await this.topicCommands.getCtxText(target.name));
1191
+ await data.respond(await this.topicCommands.getCtxText(name));
1187
1192
  }
1188
1193
  else if (data.command === "collab") {
1189
1194
  const collabTarget2 = this.routing.resolve(data.channelId);
@@ -1201,11 +1206,11 @@ export class FleetManager {
1201
1206
  await data.respond("⛔ This command requires admin access.");
1202
1207
  return;
1203
1208
  }
1204
- if (!this.classicChannels.isClassicChannel(data.channelId)) {
1209
+ if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
1205
1210
  await data.respond("No active agent in this channel. Use `/start` first.");
1206
1211
  return;
1207
1212
  }
1208
- const newState = this.classicChannels.toggleCollab(data.channelId);
1213
+ const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
1209
1214
  await data.respond(newState
1210
1215
  ? "🤝 Collaboration mode **ON** — @mention this bot to trigger the agent. Other bot messages are visible."
1211
1216
  : "💬 Collaboration mode **OFF** — use `/chat` to talk to the agent.");
@@ -1258,12 +1263,12 @@ export class FleetManager {
1258
1263
  process.kill(process.pid, "SIGUSR2");
1259
1264
  }
1260
1265
  else if (data.command === "compact") {
1261
- const target = this.routing.resolve(data.channelId);
1262
- if (!target) {
1266
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
1267
+ if (!name) {
1263
1268
  await data.respond("No active agent in this channel.");
1264
1269
  return;
1265
1270
  }
1266
- const result = await this.topicCommands.sendCompact(target.name);
1271
+ const result = await this.topicCommands.sendCompact(name);
1267
1272
  await data.respond(result);
1268
1273
  }
1269
1274
  }, this.logger, `adapter[${adapterId}].slash_command`));
@@ -1486,8 +1491,17 @@ export class FleetManager {
1486
1491
  // Routing (by topic/channel) and reply-adapter selection (by channel_id
1487
1492
  // binding) are adapter-independent, so it's safe to let whichever adapter
1488
1493
  // arrives first handle it.
1494
+ //
1495
+ // EXCEPTION — classic channels with same-channel multi-bot: two bots may own
1496
+ // separate agents in one channel, so each bot must process its OWN copy of
1497
+ // the message (the @mention filter downstream decides who actually forwards).
1498
+ // Key the dedup per-adapter there so a sibling bot's copy isn't dropped.
1489
1499
  if (msg.messageId) {
1490
- const dedupKey = `${msg.source}:${msg.chatId}:${msg.messageId}`;
1500
+ const classicCid = msg.threadId || msg.chatId;
1501
+ const isClassicMsg = this.classicChannels?.hasChannel(classicCid) ?? false;
1502
+ const dedupKey = isClassicMsg
1503
+ ? `${msg.source}:${msg.chatId}:${msg.messageId}:${msg.adapterId ?? ""}`
1504
+ : `${msg.source}:${msg.chatId}:${msg.messageId}`;
1491
1505
  if (this.recentMessageIds.has(dedupKey)) {
1492
1506
  this.logger.debug({ dedupKey, adapterId: msg.adapterId }, "Duplicate inbound across adapters — skipping");
1493
1507
  return;
@@ -1514,21 +1528,25 @@ export class FleetManager {
1514
1528
  return;
1515
1529
  // Fall through to TG classic handling below
1516
1530
  }
1531
+ else if (this.classicChannels?.hasChannel(threadId)) {
1532
+ // Classic channel (per-bot): bot messages only when THIS bot owns an
1533
+ // agent here and collab is on for it.
1534
+ const classicName = this.classicChannels.getInstanceByChannel(threadId, msg.adapterId);
1535
+ if (!classicName)
1536
+ return;
1537
+ if (!this.classicChannels.isCollab(threadId, msg.adapterId))
1538
+ return;
1539
+ // Fall through to channel handling
1540
+ }
1517
1541
  else {
1518
1542
  const target = this.routing.resolve(threadId);
1519
1543
  if (!target)
1520
1544
  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
- }
1545
+ // Fleet topic: allow if collab enabled OR access mode is open
1546
+ const channelCfg = this.getChannelConfig(msg.adapterId);
1547
+ const isOpen = channelCfg?.access?.mode === "open";
1548
+ if (!isOpen && !this.collabInstances.has(target.name))
1549
+ return;
1532
1550
  // Fall through to channel handling
1533
1551
  }
1534
1552
  }
@@ -1538,9 +1556,10 @@ export class FleetManager {
1538
1556
  const adapterGroupId = String(this.getChannelConfig(msg.adapterId)?.group_id ?? "");
1539
1557
  const isTelegramClassicCandidate = msg.source === "telegram" && msg.chatId !== adapterGroupId && !threadId;
1540
1558
  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")
1559
+ // Classic channels are open to all; check per-bot ownership (or fleet topic).
1560
+ const isClassic = !!(threadId && this.classicChannels?.hasChannel(threadId));
1561
+ this.logger.info({ userId: msg.userId, threadId, isClassic }, "Access DENIED for non-allowed user");
1562
+ if (!isClassic)
1544
1563
  return;
1545
1564
  }
1546
1565
  }
@@ -1628,7 +1647,7 @@ export class FleetManager {
1628
1647
  }
1629
1648
  return;
1630
1649
  }
1631
- const reply = await this.handleClassicStop(chatId);
1650
+ const reply = await this.handleClassicStop(chatId, msg.adapterId);
1632
1651
  await msgAdapter?.sendText(chatId, reply);
1633
1652
  return;
1634
1653
  }
@@ -1638,34 +1657,34 @@ export class FleetManager {
1638
1657
  await msgAdapter?.sendText(chatId, "⛔ /compact requires admin access.");
1639
1658
  return;
1640
1659
  }
1641
- const compactTarget = this.routing.resolve(chatId);
1642
- if (!compactTarget || compactTarget.kind !== "classic") {
1660
+ const compactName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1661
+ if (!compactName) {
1643
1662
  await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1644
1663
  return;
1645
1664
  }
1646
- const result = await this.topicCommands.sendCompact(compactTarget.name);
1665
+ const result = await this.topicCommands.sendCompact(compactName);
1647
1666
  await msgAdapter?.sendText(chatId, result);
1648
1667
  return;
1649
1668
  }
1650
1669
  // Handle /cancel command
1651
1670
  if (text === "/cancel" || text.startsWith("/cancel@")) {
1652
- const cancelTarget = this.routing.resolve(chatId);
1653
- if (!cancelTarget || cancelTarget.kind !== "classic") {
1671
+ const cancelName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1672
+ if (!cancelName) {
1654
1673
  await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1655
1674
  return;
1656
1675
  }
1657
- const ok = this.cancelInstance(cancelTarget.name);
1658
- await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelTarget.name}。` : `❌ ${cancelTarget.name} 未在執行。`);
1676
+ const ok = this.cancelInstance(cancelName);
1677
+ await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelName}。` : `❌ ${cancelName} 未在執行。`);
1659
1678
  return;
1660
1679
  }
1661
1680
  // Handle /ctx command
1662
1681
  if (text === "/ctx" || text.startsWith("/ctx@")) {
1663
- const ctxTarget = this.routing.resolve(chatId);
1664
- if (!ctxTarget || ctxTarget.kind !== "classic") {
1682
+ const ctxName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1683
+ if (!ctxName) {
1665
1684
  await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1666
1685
  return;
1667
1686
  }
1668
- const reply = await this.topicCommands.getCtxText(ctxTarget.name);
1687
+ const reply = await this.topicCommands.getCtxText(ctxName);
1669
1688
  await msgAdapter?.sendText(chatId, reply);
1670
1689
  return;
1671
1690
  }
@@ -1675,8 +1694,8 @@ export class FleetManager {
1675
1694
  await msgAdapter?.sendText(chatId, "⛔ /save requires admin access.");
1676
1695
  return;
1677
1696
  }
1678
- const saveTarget = this.routing.resolve(chatId);
1679
- if (!saveTarget || saveTarget.kind !== "classic") {
1697
+ const saveName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1698
+ if (!saveName) {
1680
1699
  await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1681
1700
  return;
1682
1701
  }
@@ -1689,26 +1708,26 @@ export class FleetManager {
1689
1708
  await msgAdapter?.sendText(chatId, "⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1690
1709
  return;
1691
1710
  }
1692
- const backend = this.classicChannels.getBackendByInstance(saveTarget.name, this.fleetConfig?.defaults?.backend);
1711
+ const backend = this.classicChannels.getBackendByInstance(saveName, this.fleetConfig?.defaults?.backend);
1693
1712
  const cmd = saveCommandForBackend(backend, filename);
1694
1713
  if (!cmd) {
1695
1714
  await msgAdapter?.sendText(chatId, SAVE_UNSUPPORTED_MSG);
1696
1715
  return;
1697
1716
  }
1698
- this.pasteRawToClassicInstance(saveTarget.name, cmd);
1699
- await msgAdapter?.sendText(chatId, `✅ Sent \`${cmd}\` to ${saveTarget.name}`);
1717
+ this.pasteRawToClassicInstance(saveName, cmd);
1718
+ await msgAdapter?.sendText(chatId, `✅ Sent \`${cmd}\` to ${saveName}`);
1700
1719
  return;
1701
1720
  }
1702
- // Route to classic channel if registered
1703
- const target = this.routing.resolve(chatId);
1704
- if (target?.kind === "classic") {
1721
+ // Route to classic channel if this bot has an agent here (per-bot).
1722
+ const classicName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1723
+ if (classicName) {
1705
1724
  if (msg.adapterId)
1706
- this.bindInstanceAdapter(target.name, msg.adapterId, true);
1725
+ this.bindInstanceAdapter(classicName, msg.adapterId, true);
1707
1726
  // TG ClassicBot: group requires @mention, private chat forwards directly.
1708
1727
  if (!isPrivateChat && !isBotMentioned) {
1709
1728
  // No trigger: save attachments + react, log, but don't forward to agent
1710
1729
  const syntheticMsg = { ...msg, threadId: chatId, text: rawText.startsWith("/") ? "" : rawText };
1711
- await this.handleClassicChannelMessage(target.name, syntheticMsg);
1730
+ await this.handleClassicChannelMessage(classicName, syntheticMsg);
1712
1731
  return;
1713
1732
  }
1714
1733
  // Strip @bot from text and forward as /chat
@@ -1718,7 +1737,7 @@ export class FleetManager {
1718
1737
  return;
1719
1738
  }
1720
1739
  const syntheticMsg = { ...msg, threadId: chatId, text: `/chat ${cleanText}` };
1721
- await this.handleClassicChannelMessage(target.name, syntheticMsg);
1740
+ await this.handleClassicChannelMessage(classicName, syntheticMsg);
1722
1741
  return;
1723
1742
  }
1724
1743
  // Handle @bot without active agent
@@ -1781,6 +1800,18 @@ export class FleetManager {
1781
1800
  }
1782
1801
  return;
1783
1802
  }
1803
+ // Classic channels resolve per-bot (same-channel multi-bot) — a channel can
1804
+ // host two bots' agents. If this channel is classic but THIS bot has no
1805
+ // agent here, a sibling bot owns it; skip rather than misroute to it.
1806
+ if (this.classicChannels?.hasChannel(threadId)) {
1807
+ const classicName = this.classicChannels.getInstanceByChannel(threadId, msg.adapterId);
1808
+ if (!classicName)
1809
+ return;
1810
+ if (msg.adapterId)
1811
+ this.bindInstanceAdapter(classicName, msg.adapterId, true);
1812
+ await this.handleClassicChannelMessage(classicName, msg);
1813
+ return;
1814
+ }
1784
1815
  const target = this.routing.resolve(threadId);
1785
1816
  if (!target) {
1786
1817
  // Only show unbound message for actual forum topics (same group, has threadId)
@@ -1939,7 +1970,7 @@ export class FleetManager {
1939
1970
  ts: new Date().toISOString(),
1940
1971
  });
1941
1972
  // Log bot reply to classic instance chat-log
1942
- const isClassic = [...this.routing.entries()].some(([, t]) => t.kind === "classic" && t.name === instanceName);
1973
+ const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
1943
1974
  if (isClassic) {
1944
1975
  ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
1945
1976
  }
@@ -2628,13 +2659,12 @@ export class FleetManager {
2628
2659
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send instance topic notification"));
2629
2660
  return;
2630
2661
  }
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
- }
2662
+ // Classic instance: find its channelId from the classic manager
2663
+ const classicChatId = this.classicChannels?.getChannelIdByInstance(instanceName);
2664
+ if (classicChatId) {
2665
+ adapter.sendText(classicChatId, text)
2666
+ .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send classic notification"));
2667
+ return;
2638
2668
  }
2639
2669
  // Fallback: send to group without threadId
2640
2670
  if (groupId) {
@@ -2651,12 +2681,16 @@ export class FleetManager {
2651
2681
  * Picks the backend-appropriate command (kiro → /chat save, claude → /export);
2652
2682
  * unsupported backends get a clear error. Routes via classic paste or fleet IPC.
2653
2683
  */
2654
- async handleSlashSave(data) {
2684
+ async handleSlashSave(data, adapterId) {
2655
2685
  if (!this.classicChannels?.isAdmin(data.userId)) {
2656
2686
  await data.respond("⛔ This command requires admin access.");
2657
2687
  return;
2658
2688
  }
2659
- const target = this.routing.resolve(data.channelId);
2689
+ // Classic resolves per-bot (same-channel multi-bot); otherwise a fleet topic.
2690
+ const classicName = this.classicChannels.getInstanceByChannel(data.channelId, adapterId);
2691
+ const target = classicName
2692
+ ? { kind: "classic", name: classicName }
2693
+ : this.routing.resolve(data.channelId);
2660
2694
  if (!target) {
2661
2695
  await data.respond("No active agent in this channel. Use `/start` first.");
2662
2696
  return;
@@ -2711,13 +2745,8 @@ export class FleetManager {
2711
2745
  threadId = String(topicId);
2712
2746
  }
2713
2747
  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
- }
2748
+ // Classic instance: channelId from the classic manager.
2749
+ chatId = this.classicChannels?.getChannelIdByInstance(instanceName);
2721
2750
  // General / flat fallback: post to the group (no thread).
2722
2751
  if (!chatId && groupId)
2723
2752
  chatId = String(groupId);
@@ -3200,7 +3229,7 @@ When users create specialized instances, suggest these configurations:
3200
3229
  async handleClassicChannelMessage(instanceName, msg) {
3201
3230
  const text = msg.text ?? "";
3202
3231
  const channelId = msg.threadId ?? msg.chatId;
3203
- const isCollabMode = this.classicChannels?.isCollab(channelId) ?? false;
3232
+ const isCollabMode = this.classicChannels?.isCollab(channelId, msg.adapterId) ?? false;
3204
3233
  // Handle /ctx in classic mode — always, regardless of collab mode
3205
3234
  if (text === "/ctx" || text.startsWith("/ctx@")) {
3206
3235
  const reply = await this.topicCommands.getCtxText(instanceName);
@@ -3406,7 +3435,11 @@ When users create specialized instances, suggest these configurations:
3406
3435
  }
3407
3436
  /** Forward a message to a classic channel instance with chat log context */
3408
3437
  async forwardToClassicInstance(instanceName, text, msg, extraMeta) {
3409
- const contextLines = this.classicChannels?.getContextLines(msg.chatId) ?? 5;
3438
+ // Resolve the channel/adapter from the instance itself so per-channel context
3439
+ // config is correct even for a same-channel second bot.
3440
+ const ctxAdapterId = this.classicChannels?.getAdapterIdByInstance(instanceName);
3441
+ const ctxChannelId = this.classicChannels?.getChannelIdByInstance(instanceName) ?? msg.chatId;
3442
+ const contextLines = this.classicChannels?.getContextLines(ctxChannelId, ctxAdapterId) ?? 5;
3410
3443
  const logContext = this.getRecentChatLog(instanceName, contextLines);
3411
3444
  const fullText = logContext
3412
3445
  ? `[Chat log for context]\n${logContext}\n\n[User message]\n${text}`
@@ -3502,38 +3535,40 @@ When users create specialized instances, suggest these configurations:
3502
3535
  }
3503
3536
  return "⛔ This server is not in the allowed guilds list.";
3504
3537
  }
3505
- if (this.classicChannels.isClassicChannel(channelId))
3538
+ // Per-bot check: a second bot may /start in the same channel (own agent).
3539
+ if (this.classicChannels.isClassicChannel(channelId, adapterId))
3506
3540
  return "This channel already has an active agent. Use /chat to talk.";
3541
+ // Classic no longer lives in the routing engine, so this only guards against
3542
+ // a fleet topic-mode instance colliding with the channel.
3507
3543
  if (this.routing.resolve(channelId))
3508
3544
  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 });
3545
+ const instanceName = this.classicChannels.deriveInstanceName(channelName || channelId, channelId, adapterId);
3546
+ this.classicChannels.register(channelId, adapterId, instanceName, channelName || channelId, userId);
3512
3547
  // Bind this classic instance to the bot that started it (authoritative), so
3513
3548
  // 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.)
3549
+ // also sees the channel's messages.
3515
3550
  if (adapterId)
3516
3551
  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));
3552
+ 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
3553
  this.reregisterClassicChannels();
3519
3554
  // 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);
3555
+ if (guildId && !this.classicChannels.isCollab(channelId, adapterId)) {
3556
+ this.classicChannels.toggleCollab(channelId, adapterId);
3522
3557
  }
3523
- this.logger.info({ channelId, instanceName, userId }, "Classic channel started");
3558
+ this.logger.info({ channelId, adapterId, instanceName, userId }, "Classic channel started");
3524
3559
  return `✅ Agent started in this channel. Use \`/chat <message>\` or @mention to talk.`;
3525
3560
  }
3526
3561
  /** Handle /stop slash command — unregister classic channel */
3527
- async handleClassicStop(channelId) {
3562
+ async handleClassicStop(channelId, adapterId) {
3528
3563
  if (!this.classicChannels)
3529
3564
  return "Classic channel manager not initialized.";
3530
- const ch = this.classicChannels.unregister(channelId);
3565
+ const ch = this.classicChannels.unregister(channelId, adapterId);
3531
3566
  if (!ch)
3532
3567
  return "No active agent in this channel.";
3533
- this.routing.unregister(channelId);
3568
+ this.instanceWorldBinding.delete(ch.instanceName);
3534
3569
  await this.stopInstance(ch.instanceName).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance"));
3535
3570
  this.reregisterClassicChannels();
3536
- this.logger.info({ channelId, instanceName: ch.instanceName }, "Classic channel stopped");
3571
+ this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
3537
3572
  return `🛑 Agent stopped in this channel.`;
3538
3573
  }
3539
3574
  async stopAll() {