@songsid/agend 2.0.11-beta.2 → 2.0.11-beta.21

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 (42) hide show
  1. package/dist/channel/adapters/discord.d.ts +2 -0
  2. package/dist/channel/adapters/discord.js +38 -34
  3. package/dist/channel/adapters/discord.js.map +1 -1
  4. package/dist/channel/factory.d.ts +4 -0
  5. package/dist/channel/factory.js +1 -0
  6. package/dist/channel/factory.js.map +1 -1
  7. package/dist/channel/mcp-tools.js +3 -0
  8. package/dist/channel/mcp-tools.js.map +1 -1
  9. package/dist/channel/tool-router.js +29 -2
  10. package/dist/channel/tool-router.js.map +1 -1
  11. package/dist/classic-channel-manager.d.ts +59 -12
  12. package/dist/classic-channel-manager.js +121 -30
  13. package/dist/classic-channel-manager.js.map +1 -1
  14. package/dist/cli.js +83 -13
  15. package/dist/cli.js.map +1 -1
  16. package/dist/config-validator.d.ts +20 -0
  17. package/dist/config-validator.js +154 -0
  18. package/dist/config-validator.js.map +1 -0
  19. package/dist/daemon.js +25 -3
  20. package/dist/daemon.js.map +1 -1
  21. package/dist/fleet-manager.d.ts +27 -5
  22. package/dist/fleet-manager.js +311 -166
  23. package/dist/fleet-manager.js.map +1 -1
  24. package/dist/instance-lifecycle.d.ts +1 -1
  25. package/dist/instance-lifecycle.js +4 -4
  26. package/dist/instance-lifecycle.js.map +1 -1
  27. package/dist/outbound-schemas.d.ts +1 -0
  28. package/dist/outbound-schemas.js +1 -0
  29. package/dist/outbound-schemas.js.map +1 -1
  30. package/dist/quickstart.js +226 -21
  31. package/dist/quickstart.js.map +1 -1
  32. package/dist/service-installer.js +1 -7
  33. package/dist/service-installer.js.map +1 -1
  34. package/dist/settings-api.d.ts +33 -0
  35. package/dist/settings-api.js +283 -0
  36. package/dist/settings-api.js.map +1 -0
  37. package/dist/ui/settings.html +397 -0
  38. package/dist/ui/view.html +464 -0
  39. package/dist/view-api.d.ts +29 -0
  40. package/dist/view-api.js +361 -0
  41. package/dist/view-api.js.map +1 -0
  42. package/package.json +1 -3
@@ -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";
@@ -33,8 +33,10 @@ import { TopicArchiver } from "./topic-archiver.js";
33
33
  import { StatuslineWatcher } from "./statusline-watcher.js";
34
34
  import { outboundHandlers } from "./outbound-handlers.js";
35
35
  import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
36
+ import { handleViewRequest, isViewPath } from "./view-api.js";
37
+ import { handleSettingsRequest } from "./settings-api.js";
36
38
  import { handleAgentRequest } from "./agent-endpoint.js";
37
- import { ClassicChannelManager, classicInstanceName } from "./classic-channel-manager.js";
39
+ import { ClassicChannelManager } from "./classic-channel-manager.js";
38
40
  import { getTmuxSession } from "./config.js";
39
41
  export function resolveReplyThreadId(argsThreadId, instanceConfig) {
40
42
  if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
@@ -65,6 +67,9 @@ export class FleetManager {
65
67
  adapters = new Map(); // derived view for backward compat
66
68
  /** Track which world each instance is bound to */
67
69
  instanceWorldBinding = new Map();
70
+ // Dedup inbound messages seen by more than one adapter (e.g. two DC bots in the
71
+ // same guild both receive every message). Bounded FIFO of recent message keys.
72
+ recentMessageIds = new Set();
68
73
  accessManager = null;
69
74
  /** Primary world (first adapter) — used for fleet-level notifications */
70
75
  get primaryWorld() { return this.worlds.values().next().value; }
@@ -115,6 +120,7 @@ export class FleetManager {
115
120
  // Web UI: SSE clients + auth token
116
121
  sseClients = new Set();
117
122
  webToken = null;
123
+ viewToken = null;
118
124
  constructor(dataDir) {
119
125
  this.dataDir = dataDir;
120
126
  this.lifecycle = new InstanceLifecycle(this);
@@ -174,27 +180,42 @@ export class FleetManager {
174
180
  }
175
181
  return this.routing.map;
176
182
  }
177
- /** Re-register classic channels after routing rebuild (rebuild clears the table) */
183
+ /**
184
+ * Refresh each adapter's open-channel whitelist after a classic change.
185
+ * Classic channels are NOT registered in the routing engine (it's single-key
186
+ * per channel — can't represent two bots in one channel); routing resolves
187
+ * per-bot via ClassicChannelManager.getInstanceByChannel. Each adapter only
188
+ * opens the channels IT owns so a sibling bot doesn't process another's cross-
189
+ * guild channel.
190
+ */
178
191
  reregisterClassicChannels() {
179
192
  if (!this.classicChannels)
180
193
  return;
181
194
  const channels = this.classicChannels.getAll();
182
- for (const ch of channels) {
183
- this.routing.register(ch.channelId, { kind: "classic", name: ch.instanceName });
184
- }
185
195
  // Always update adapter openChannels (including empty — clears stale entries on /stop)
186
- for (const [, w] of this.worlds) {
196
+ for (const [adapterId, w] of this.worlds) {
187
197
  if (typeof w.adapter?.setOpenChannels === "function") {
188
- w.adapter.setOpenChannels(channels.map(ch => ch.channelId));
198
+ const owned = channels.filter(ch => ch.adapterId === adapterId).map(ch => ch.channelId);
199
+ w.adapter.setOpenChannels(owned);
189
200
  }
190
201
  }
191
202
  if (channels.length > 0) {
192
- this.logger.info({ count: channels.length }, "Registered classic channel routes");
203
+ this.logger.info({ count: channels.length }, "Refreshed classic channel open-lists");
193
204
  }
194
205
  }
195
206
  getInstanceDir(name) {
196
207
  return join(this.dataDir, "instances", name);
197
208
  }
209
+ /**
210
+ * Resolve a slash-command target in a channel. Classic channels are looked up
211
+ * per-bot (same-channel multi-bot); a fleet-topic instance is found via the
212
+ * routing engine. Used by commands that work in BOTH contexts (/ctx, /compact,
213
+ * /cancel). Classic-only commands (/chat, /load) must NOT use this.
214
+ */
215
+ resolveSlashTarget(channelId, adapterId) {
216
+ return this.classicChannels?.getInstanceByChannel(channelId, adapterId)
217
+ ?? this.routing.resolve(channelId)?.name;
218
+ }
198
219
  /** Get the adapter bound to an instance, falling back to primary adapter */
199
220
  getAdapterForInstance(name) {
200
221
  const worldId = this.instanceWorldBinding.get(name);
@@ -221,9 +242,22 @@ export class FleetManager {
221
242
  const world = this.getWorldForInstance(name);
222
243
  return world?.groupId ?? String(this.fleetConfig?.channel?.group_id ?? "");
223
244
  }
224
- /** Bind an instance to a specific world. fromInbound=true skips general_topic to prevent overwrite. */
245
+ /**
246
+ * Bind an instance to a specific world (the bot that answers for it).
247
+ * fromInbound=true (binding inferred from which adapter received a message)
248
+ * must not override a configured identity: skip when the instance is a general
249
+ * or has an explicit channel_id — otherwise a persona instance whose message
250
+ * was also seen by the main bot would get rebound to the wrong bot.
251
+ */
225
252
  bindInstanceAdapter(name, adapterId, fromInbound = false) {
226
- if (fromInbound && this.fleetConfig?.instances[name]?.general_topic)
253
+ const cfg = this.fleetConfig?.instances[name];
254
+ if (fromInbound && (cfg?.general_topic || cfg?.channel_id))
255
+ return;
256
+ // A classic instance is bound authoritatively at /start. Don't let an inbound
257
+ // (seen by every same-guild bot) override an existing binding — but if there
258
+ // is none yet (e.g. after a restart, before v2.1 persistence), allow inbound
259
+ // to re-establish it so replies don't fall back to the primary bot forever.
260
+ if (fromInbound && this.classicChannels?.getChannelIdByInstance(name) !== undefined && this.instanceWorldBinding.has(name))
227
261
  return;
228
262
  this.instanceWorldBinding.set(name, adapterId);
229
263
  }
@@ -393,10 +427,19 @@ export class FleetManager {
393
427
  const pidPath = join(this.dataDir, "fleet.pid");
394
428
  writeFileSync(pidPath, String(process.pid), "utf-8");
395
429
  this.eventLog = new EventLog(join(this.dataDir, "events.db"));
396
- // Initialize classic channel manager and register existing channels in routing
430
+ // Initialize classic channel manager. The primary adapter (channels[0])
431
+ // migrates legacy single-bot entries and names without a suffix. Classic
432
+ // routing does NOT go through the routing engine (single-key, can't hold two
433
+ // bots in one channel) — it resolves per-bot via getInstanceByChannel.
397
434
  this.classicChannels = new ClassicChannelManager(this.dataDir, this.logger);
435
+ const primaryCh = fleet.channels?.[0] ?? fleet.channel;
436
+ if (primaryCh)
437
+ this.classicChannels.setPrimaryAdapterId(primaryCh.id ?? primaryCh.type);
438
+ // Restore the persisted bot binding so replies/cancel go through the right
439
+ // bot after a restart (before this, inbound would re-bind lazily).
398
440
  for (const ch of this.classicChannels.getAll()) {
399
- this.routing.register(ch.channelId, { kind: "classic", name: ch.instanceName });
441
+ if (ch.adapterId)
442
+ this.instanceWorldBinding.set(ch.instanceName, ch.adapterId);
400
443
  }
401
444
  // Poll classicBot.yaml for external changes every 30s
402
445
  this.classicReloadTimer = setInterval(async () => {
@@ -474,8 +517,9 @@ export class FleetManager {
474
517
  // Rotate classic channel chat logs daily (piggyback on daily summary timer)
475
518
  this.classicChannels?.rotateLogs();
476
519
  this.rotateInboxes();
477
- // Auto-create general instance(s)one per adapter that lacks a general
520
+ // Auto-create/adopt a general dispatcherONLY for the primary adapter.
478
521
  const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
522
+ const primaryAdapterId = channelConfigs[0] ? (channelConfigs[0].id ?? channelConfigs[0].type) : undefined;
479
523
  const generalInstances = Object.entries(fleet.instances).filter(([, inst]) => inst.general_topic === true);
480
524
  let generalsCreated = false;
481
525
  // Collect unbound generals (no channel_id set) for auto-assignment
@@ -484,6 +528,14 @@ export class FleetManager {
484
528
  const needsGeneral = [];
485
529
  for (const ch of channelConfigs) {
486
530
  const adapterId = ch.id ?? ch.type;
531
+ // Only the primary adapter gets an auto-general. Secondary (persona) bots
532
+ // answer for their explicitly-bound instances only — they don't need or
533
+ // auto-claim a general dispatcher, and must never adopt the primary's
534
+ // unbound general. A general a user manually bound to a secondary
535
+ // (channel_id: <persona>) is left untouched — the auto logic just won't
536
+ // create or reassign bindings for non-primary adapters.
537
+ if (adapterId !== primaryAdapterId)
538
+ continue;
487
539
  // Check if any general is explicitly bound to this adapter
488
540
  if (generalInstances.some(([, inst]) => inst.channel_id === adapterId))
489
541
  continue;
@@ -606,12 +658,19 @@ export class FleetManager {
606
658
  catch (err) {
607
659
  this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
608
660
  }
609
- // Pre-bind general instances to their corresponding adapter
661
+ // Bind instances to their adapter (which bot answers on their behalf).
662
+ // An explicit channel_id is authoritative — this is how a persona instance
663
+ // picks its bot when several share one guild. Generals without a channel_id
664
+ // fall back to a name-contains-adapterId heuristic.
665
+ const channelConfigsForBind = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
610
666
  for (const [name, config] of Object.entries(fleet.instances)) {
667
+ if (config.channel_id) {
668
+ this.bindInstanceAdapter(name, config.channel_id);
669
+ continue;
670
+ }
611
671
  if (!config.general_topic)
612
672
  continue;
613
- const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
614
- for (const ch of channelConfigs) {
673
+ for (const ch of channelConfigsForBind) {
615
674
  const id = ch.id ?? ch.type;
616
675
  if (name.includes(id)) {
617
676
  this.bindInstanceAdapter(name, id);
@@ -741,9 +800,13 @@ export class FleetManager {
741
800
  const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
742
801
  if (channelConfigs.length === 0)
743
802
  return;
744
- // Start primary adapter (first channel) — this.adapter for backward compat
803
+ // Start primary adapter (first channel) — this.adapter for backward compat.
745
804
  await this.startSingleAdapter(fleet, channelConfigs[0]);
746
- // Start additional adapters
805
+ // Start additional adapters. Every bot registers its own slash commands —
806
+ // Discord slash commands are per-application, so a same-guild secondary bot's
807
+ // /start etc. are distinct entries (labelled with the bot name) and the
808
+ // interaction only reaches the invoked bot. This is how ClassicBot supports
809
+ // multiple bots in one guild: the user picks which bot from autocomplete.
747
810
  for (let i = 1; i < channelConfigs.length; i++) {
748
811
  await this.startAdditionalAdapter(channelConfigs[i]);
749
812
  }
@@ -813,11 +876,11 @@ export class FleetManager {
813
876
  // Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
814
877
  this.adapter.on("slash_command", safeHandler(async (data) => {
815
878
  if (data.command === "start") {
816
- const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId);
879
+ const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId, adapterId);
817
880
  await data.respond(reply);
818
881
  }
819
882
  else if (data.command === "stop") {
820
- const reply = await this.handleClassicStop(data.channelId);
883
+ const reply = await this.handleClassicStop(data.channelId, adapterId);
821
884
  await data.respond(reply);
822
885
  }
823
886
  else if (data.command === "chat") {
@@ -826,15 +889,15 @@ export class FleetManager {
826
889
  await data.respond("Usage: `/chat <message>`");
827
890
  return;
828
891
  }
829
- const target = this.routing.resolve(data.channelId);
830
- if (!target || target.kind !== "classic") {
892
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
893
+ if (!name) {
831
894
  await data.respond("No active agent in this channel. Use `/start` first.");
832
895
  return;
833
896
  }
834
897
  const replyMsgId = await data.respond("👀");
835
898
  const username = data.username ?? data.userId;
836
- ClassicChannelManager.logMessage(target.name, username, `/chat ${text}`, new Date());
837
- await this.forwardToClassicInstance(target.name, text, {
899
+ ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
900
+ await this.forwardToClassicInstance(name, text, {
838
901
  chatId: data.channelId,
839
902
  threadId: data.channelId,
840
903
  messageId: replyMsgId ?? "",
@@ -845,7 +908,7 @@ export class FleetManager {
845
908
  });
846
909
  }
847
910
  else if (data.command === "save") {
848
- await this.handleSlashSave(data);
911
+ await this.handleSlashSave(data, adapterId);
849
912
  }
850
913
  else if (data.command === "load") {
851
914
  // load is kiro-cli/classic only — no claude-code equivalent.
@@ -853,8 +916,8 @@ export class FleetManager {
853
916
  await data.respond("⛔ This command requires admin access.");
854
917
  return;
855
918
  }
856
- const target = this.routing.resolve(data.channelId);
857
- if (!target || target.kind !== "classic") {
919
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
920
+ if (!name) {
858
921
  await data.respond("No active agent in this channel. Use `/start` first.");
859
922
  return;
860
923
  }
@@ -863,39 +926,41 @@ export class FleetManager {
863
926
  await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
864
927
  return;
865
928
  }
866
- this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
867
- await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
929
+ this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
930
+ await data.respond(`✅ Sent \`/chat load ${filename}\` to ${name}`);
868
931
  }
869
932
  else if (data.command === "compact") {
870
- const target = this.routing.resolve(data.channelId);
871
- if (!target) {
933
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
934
+ if (!name) {
872
935
  await data.respond("No active agent in this channel.");
873
936
  return;
874
937
  }
875
- const result = await this.topicCommands.sendCompact(target.name);
938
+ const result = await this.topicCommands.sendCompact(name);
876
939
  await data.respond(result);
877
940
  }
878
941
  else if (data.command === "cancel") {
879
- const target = this.routing.resolve(data.channelId);
880
- if (!target) {
942
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
943
+ if (!name) {
881
944
  await data.respond("No active agent in this channel.");
882
945
  return;
883
946
  }
884
- const ok = this.cancelInstance(target.name);
885
- await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
947
+ const ok = this.cancelInstance(name);
948
+ await data.respond(ok ? `🛑 Sent cancel to ${name}.` : `❌ ${name} not running.`);
886
949
  }
887
950
  else if (data.command === "ctx") {
888
- const target = this.routing.resolve(data.channelId);
889
- if (!target) {
951
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
952
+ if (!name) {
890
953
  await data.respond("No active agent in this channel.");
891
954
  return;
892
955
  }
893
956
  // Single source of truth (statusline.json + robust tmux pane fallback).
894
- await data.respond(await this.topicCommands.getCtxText(target.name));
957
+ await data.respond(await this.topicCommands.getCtxText(name));
895
958
  }
896
959
  else if (data.command === "collab") {
960
+ // Classic no longer lives in the routing engine, so a routing hit here is
961
+ // always a fleet-topic instance.
897
962
  const collabTarget = this.routing.resolve(data.channelId);
898
- if (collabTarget && collabTarget.kind !== "classic") {
963
+ if (collabTarget) {
899
964
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
900
965
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
901
966
  await data.respond("⛔ Not authorized");
@@ -909,11 +974,11 @@ export class FleetManager {
909
974
  await data.respond("⛔ This command requires admin access.");
910
975
  return;
911
976
  }
912
- if (!this.classicChannels.isClassicChannel(data.channelId)) {
977
+ if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
913
978
  await data.respond("No active agent in this channel. Use `/start` first.");
914
979
  return;
915
980
  }
916
- const newState = this.classicChannels.toggleCollab(data.channelId);
981
+ const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
917
982
  await data.respond(newState
918
983
  ? "🤝 Collaboration mode **ON** — @mention this bot to trigger the agent. Other bot messages are visible."
919
984
  : "💬 Collaboration mode **OFF** — use `/chat` to talk to the agent.");
@@ -966,12 +1031,12 @@ export class FleetManager {
966
1031
  process.kill(process.pid, "SIGUSR2");
967
1032
  }
968
1033
  else if (data.command === "compact") {
969
- const target = this.routing.resolve(data.channelId);
970
- if (!target) {
1034
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1035
+ if (!name) {
971
1036
  await data.respond("No active agent in this channel.");
972
1037
  return;
973
1038
  }
974
- const result = await this.topicCommands.sendCompact(target.name);
1039
+ const result = await this.topicCommands.sendCompact(name);
975
1040
  await data.respond(result);
976
1041
  }
977
1042
  }, this.logger, "adapter.slash_command"));
@@ -1015,7 +1080,7 @@ export class FleetManager {
1015
1080
  }, 5 * 60 * 1000);
1016
1081
  }
1017
1082
  /** Start an additional (non-primary) adapter */
1018
- async startAdditionalAdapter(channelConfig) {
1083
+ async startAdditionalAdapter(channelConfig, registerCommands = true) {
1019
1084
  const adapterId = channelConfig.id ?? channelConfig.type;
1020
1085
  const botToken = process.env[channelConfig.bot_token_env];
1021
1086
  if (!botToken) {
@@ -1032,6 +1097,7 @@ export class FleetManager {
1032
1097
  botToken,
1033
1098
  accessManager,
1034
1099
  inboxDir,
1100
+ registerCommands,
1035
1101
  });
1036
1102
  const world = new AdapterWorld(adapterId, adapter, accessManager, channelConfig);
1037
1103
  this.worlds.set(adapterId, world);
@@ -1075,11 +1141,11 @@ export class FleetManager {
1075
1141
  // Slash commands: classic bot + admin commands
1076
1142
  adapter.on("slash_command", safeHandler(async (data) => {
1077
1143
  if (data.command === "start") {
1078
- const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId);
1144
+ const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId, adapterId);
1079
1145
  await data.respond(reply);
1080
1146
  }
1081
1147
  else if (data.command === "stop") {
1082
- const reply = await this.handleClassicStop(data.channelId);
1148
+ const reply = await this.handleClassicStop(data.channelId, adapterId);
1083
1149
  await data.respond(reply);
1084
1150
  }
1085
1151
  else if (data.command === "chat") {
@@ -1088,15 +1154,15 @@ export class FleetManager {
1088
1154
  await data.respond("Usage: `/chat <message>`");
1089
1155
  return;
1090
1156
  }
1091
- const target = this.routing.resolve(data.channelId);
1092
- if (!target || target.kind !== "classic") {
1157
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
1158
+ if (!name) {
1093
1159
  await data.respond("No active agent in this channel. Use `/start` first.");
1094
1160
  return;
1095
1161
  }
1096
1162
  const replyMsgId = await data.respond("👀");
1097
1163
  const username = data.username ?? data.userId;
1098
- ClassicChannelManager.logMessage(target.name, username, `/chat ${text}`, new Date());
1099
- await this.forwardToClassicInstance(target.name, text, {
1164
+ ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
1165
+ await this.forwardToClassicInstance(name, text, {
1100
1166
  chatId: data.channelId,
1101
1167
  threadId: data.channelId,
1102
1168
  messageId: replyMsgId ?? "",
@@ -1107,7 +1173,7 @@ export class FleetManager {
1107
1173
  });
1108
1174
  }
1109
1175
  else if (data.command === "save") {
1110
- await this.handleSlashSave(data);
1176
+ await this.handleSlashSave(data, adapterId);
1111
1177
  }
1112
1178
  else if (data.command === "load") {
1113
1179
  // load is kiro-cli/classic only — no claude-code equivalent.
@@ -1115,8 +1181,8 @@ export class FleetManager {
1115
1181
  await data.respond("⛔ This command requires admin access.");
1116
1182
  return;
1117
1183
  }
1118
- const target = this.routing.resolve(data.channelId);
1119
- if (!target || target.kind !== "classic") {
1184
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
1185
+ if (!name) {
1120
1186
  await data.respond("No active agent in this channel. Use `/start` first.");
1121
1187
  return;
1122
1188
  }
@@ -1125,39 +1191,32 @@ export class FleetManager {
1125
1191
  await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1126
1192
  return;
1127
1193
  }
1128
- this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
1129
- await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
1130
- }
1131
- else if (data.command === "compact") {
1132
- const target = this.routing.resolve(data.channelId);
1133
- if (!target) {
1134
- await data.respond("No active agent in this channel.");
1135
- return;
1136
- }
1137
- const result = await this.topicCommands.sendCompact(target.name);
1138
- await data.respond(result);
1194
+ this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
1195
+ await data.respond(`✅ Sent \`/chat load ${filename}\` to ${name}`);
1139
1196
  }
1140
1197
  else if (data.command === "cancel") {
1141
- const target = this.routing.resolve(data.channelId);
1142
- if (!target) {
1198
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1199
+ if (!name) {
1143
1200
  await data.respond("No active agent in this channel.");
1144
1201
  return;
1145
1202
  }
1146
- const ok = this.cancelInstance(target.name);
1147
- await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
1203
+ const ok = this.cancelInstance(name);
1204
+ await data.respond(ok ? `🛑 Sent cancel to ${name}.` : `❌ ${name} not running.`);
1148
1205
  }
1149
1206
  else if (data.command === "ctx") {
1150
- const target = this.routing.resolve(data.channelId);
1151
- if (!target) {
1207
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1208
+ if (!name) {
1152
1209
  await data.respond("No active agent in this channel.");
1153
1210
  return;
1154
1211
  }
1155
1212
  // Single source of truth (statusline.json + robust tmux pane fallback).
1156
- await data.respond(await this.topicCommands.getCtxText(target.name));
1213
+ await data.respond(await this.topicCommands.getCtxText(name));
1157
1214
  }
1158
1215
  else if (data.command === "collab") {
1216
+ // Classic no longer lives in the routing engine, so a routing hit here is
1217
+ // always a fleet-topic instance.
1159
1218
  const collabTarget2 = this.routing.resolve(data.channelId);
1160
- if (collabTarget2 && collabTarget2.kind !== "classic") {
1219
+ if (collabTarget2) {
1161
1220
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1162
1221
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
1163
1222
  await data.respond("⛔ Not authorized");
@@ -1171,11 +1230,11 @@ export class FleetManager {
1171
1230
  await data.respond("⛔ This command requires admin access.");
1172
1231
  return;
1173
1232
  }
1174
- if (!this.classicChannels.isClassicChannel(data.channelId)) {
1233
+ if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
1175
1234
  await data.respond("No active agent in this channel. Use `/start` first.");
1176
1235
  return;
1177
1236
  }
1178
- const newState = this.classicChannels.toggleCollab(data.channelId);
1237
+ const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
1179
1238
  await data.respond(newState
1180
1239
  ? "🤝 Collaboration mode **ON** — @mention this bot to trigger the agent. Other bot messages are visible."
1181
1240
  : "💬 Collaboration mode **OFF** — use `/chat` to talk to the agent.");
@@ -1228,12 +1287,12 @@ export class FleetManager {
1228
1287
  process.kill(process.pid, "SIGUSR2");
1229
1288
  }
1230
1289
  else if (data.command === "compact") {
1231
- const target = this.routing.resolve(data.channelId);
1232
- if (!target) {
1290
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1291
+ if (!name) {
1233
1292
  await data.respond("No active agent in this channel.");
1234
1293
  return;
1235
1294
  }
1236
- const result = await this.topicCommands.sendCompact(target.name);
1295
+ const result = await this.topicCommands.sendCompact(name);
1237
1296
  await data.respond(result);
1238
1297
  }
1239
1298
  }, this.logger, `adapter[${adapterId}].slash_command`));
@@ -1451,6 +1510,34 @@ export class FleetManager {
1451
1510
  async handleInboundMessage(msg) {
1452
1511
  const threadId = msg.threadId || undefined;
1453
1512
  this.logger.debug({ source: msg.source, chatId: msg.chatId, threadId, userId: msg.userId, isBotMessage: msg.isBotMessage, textLen: (msg.text ?? "").length, text: (msg.text ?? "").slice(0, 80) }, "handleInboundMessage entry");
1513
+ // Multi-adapter dedup: when several bots share a guild, each adapter fires
1514
+ // its own "message" event for the same underlying message. Process it once.
1515
+ // Routing (by topic/channel) and reply-adapter selection (by channel_id
1516
+ // binding) are adapter-independent, so it's safe to let whichever adapter
1517
+ // arrives first handle it.
1518
+ //
1519
+ // EXCEPTION — classic channels with same-channel multi-bot: two bots may own
1520
+ // separate agents in one channel, so each bot must process its OWN copy of
1521
+ // the message (the @mention filter downstream decides who actually forwards).
1522
+ // Key the dedup per-adapter there so a sibling bot's copy isn't dropped.
1523
+ if (msg.messageId) {
1524
+ const classicCid = msg.threadId || msg.chatId;
1525
+ const isClassicMsg = this.classicChannels?.hasChannel(classicCid) ?? false;
1526
+ const dedupKey = isClassicMsg
1527
+ ? `${msg.source}:${msg.chatId}:${msg.messageId}:${msg.adapterId ?? ""}`
1528
+ : `${msg.source}:${msg.chatId}:${msg.messageId}`;
1529
+ if (this.recentMessageIds.has(dedupKey)) {
1530
+ this.logger.debug({ dedupKey, adapterId: msg.adapterId }, "Duplicate inbound across adapters — skipping");
1531
+ return;
1532
+ }
1533
+ this.recentMessageIds.add(dedupKey);
1534
+ if (this.recentMessageIds.size > 1000) {
1535
+ // Set preserves insertion order — drop the oldest key.
1536
+ const oldest = this.recentMessageIds.values().next().value;
1537
+ if (oldest !== undefined)
1538
+ this.recentMessageIds.delete(oldest);
1539
+ }
1540
+ }
1454
1541
  // Bot messages: only allow in collab channels or TG classic with @mention
1455
1542
  if (msg.isBotMessage) {
1456
1543
  if (!threadId) {
@@ -1465,21 +1552,25 @@ export class FleetManager {
1465
1552
  return;
1466
1553
  // Fall through to TG classic handling below
1467
1554
  }
1555
+ else if (this.classicChannels?.hasChannel(threadId)) {
1556
+ // Classic channel (per-bot): bot messages only when THIS bot owns an
1557
+ // agent here and collab is on for it.
1558
+ const classicName = this.classicChannels.getInstanceByChannel(threadId, msg.adapterId);
1559
+ if (!classicName)
1560
+ return;
1561
+ if (!this.classicChannels.isCollab(threadId, msg.adapterId))
1562
+ return;
1563
+ // Fall through to channel handling
1564
+ }
1468
1565
  else {
1469
1566
  const target = this.routing.resolve(threadId);
1470
1567
  if (!target)
1471
1568
  return;
1472
- if (target.kind === "classic") {
1473
- if (!this.classicChannels?.isCollab(threadId))
1474
- return;
1475
- }
1476
- else {
1477
- // Fleet topic: allow if collab enabled OR access mode is open
1478
- const channelCfg = this.getChannelConfig(msg.adapterId);
1479
- const isOpen = channelCfg?.access?.mode === "open";
1480
- if (!isOpen && !this.collabInstances.has(target.name))
1481
- return;
1482
- }
1569
+ // Fleet topic: allow if collab enabled OR access mode is open
1570
+ const channelCfg = this.getChannelConfig(msg.adapterId);
1571
+ const isOpen = channelCfg?.access?.mode === "open";
1572
+ if (!isOpen && !this.collabInstances.has(target.name))
1573
+ return;
1483
1574
  // Fall through to channel handling
1484
1575
  }
1485
1576
  }
@@ -1489,9 +1580,10 @@ export class FleetManager {
1489
1580
  const adapterGroupId = String(this.getChannelConfig(msg.adapterId)?.group_id ?? "");
1490
1581
  const isTelegramClassicCandidate = msg.source === "telegram" && msg.chatId !== adapterGroupId && !threadId;
1491
1582
  if (!isTelegramClassicCandidate) {
1492
- const target = threadId ? this.routing.resolve(threadId) : undefined;
1493
- this.logger.info({ userId: msg.userId, threadId, targetKind: target?.kind, targetName: target?.name }, "Access DENIED for non-allowed user");
1494
- if (!target || target.kind !== "classic")
1583
+ // Classic channels are open to all; check per-bot ownership (or fleet topic).
1584
+ const isClassic = !!(threadId && this.classicChannels?.hasChannel(threadId));
1585
+ this.logger.info({ userId: msg.userId, threadId, isClassic }, "Access DENIED for non-allowed user");
1586
+ if (!isClassic)
1495
1587
  return;
1496
1588
  }
1497
1589
  }
@@ -1564,9 +1656,8 @@ export class FleetManager {
1564
1656
  }
1565
1657
  }
1566
1658
  const channelName = msg.username || chatId;
1567
- const reply = await this.handleClassicStart(chatId, channelName, msg.userId);
1568
- if (msg.adapterId)
1569
- this.bindInstanceAdapter(classicInstanceName(sanitizeInstanceName(channelName || chatId), chatId), msg.adapterId, true);
1659
+ // handleClassicStart binds the instance to this adapter authoritatively.
1660
+ const reply = await this.handleClassicStart(chatId, channelName, msg.userId, undefined, msg.adapterId);
1570
1661
  await msgAdapter?.sendText(chatId, reply);
1571
1662
  return;
1572
1663
  }
@@ -1580,7 +1671,7 @@ export class FleetManager {
1580
1671
  }
1581
1672
  return;
1582
1673
  }
1583
- const reply = await this.handleClassicStop(chatId);
1674
+ const reply = await this.handleClassicStop(chatId, msg.adapterId);
1584
1675
  await msgAdapter?.sendText(chatId, reply);
1585
1676
  return;
1586
1677
  }
@@ -1590,34 +1681,34 @@ export class FleetManager {
1590
1681
  await msgAdapter?.sendText(chatId, "⛔ /compact requires admin access.");
1591
1682
  return;
1592
1683
  }
1593
- const compactTarget = this.routing.resolve(chatId);
1594
- if (!compactTarget || compactTarget.kind !== "classic") {
1684
+ const compactName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1685
+ if (!compactName) {
1595
1686
  await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1596
1687
  return;
1597
1688
  }
1598
- const result = await this.topicCommands.sendCompact(compactTarget.name);
1689
+ const result = await this.topicCommands.sendCompact(compactName);
1599
1690
  await msgAdapter?.sendText(chatId, result);
1600
1691
  return;
1601
1692
  }
1602
1693
  // Handle /cancel command
1603
1694
  if (text === "/cancel" || text.startsWith("/cancel@")) {
1604
- const cancelTarget = this.routing.resolve(chatId);
1605
- if (!cancelTarget || cancelTarget.kind !== "classic") {
1695
+ const cancelName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1696
+ if (!cancelName) {
1606
1697
  await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1607
1698
  return;
1608
1699
  }
1609
- const ok = this.cancelInstance(cancelTarget.name);
1610
- await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelTarget.name}。` : `❌ ${cancelTarget.name} 未在執行。`);
1700
+ const ok = this.cancelInstance(cancelName);
1701
+ await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelName}。` : `❌ ${cancelName} 未在執行。`);
1611
1702
  return;
1612
1703
  }
1613
1704
  // Handle /ctx command
1614
1705
  if (text === "/ctx" || text.startsWith("/ctx@")) {
1615
- const ctxTarget = this.routing.resolve(chatId);
1616
- if (!ctxTarget || ctxTarget.kind !== "classic") {
1706
+ const ctxName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1707
+ if (!ctxName) {
1617
1708
  await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1618
1709
  return;
1619
1710
  }
1620
- const reply = await this.topicCommands.getCtxText(ctxTarget.name);
1711
+ const reply = await this.topicCommands.getCtxText(ctxName);
1621
1712
  await msgAdapter?.sendText(chatId, reply);
1622
1713
  return;
1623
1714
  }
@@ -1627,8 +1718,8 @@ export class FleetManager {
1627
1718
  await msgAdapter?.sendText(chatId, "⛔ /save requires admin access.");
1628
1719
  return;
1629
1720
  }
1630
- const saveTarget = this.routing.resolve(chatId);
1631
- if (!saveTarget || saveTarget.kind !== "classic") {
1721
+ const saveName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1722
+ if (!saveName) {
1632
1723
  await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1633
1724
  return;
1634
1725
  }
@@ -1641,26 +1732,26 @@ export class FleetManager {
1641
1732
  await msgAdapter?.sendText(chatId, "⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1642
1733
  return;
1643
1734
  }
1644
- const backend = this.classicChannels.getBackendByInstance(saveTarget.name, this.fleetConfig?.defaults?.backend);
1735
+ const backend = this.classicChannels.getBackendByInstance(saveName, this.fleetConfig?.defaults?.backend);
1645
1736
  const cmd = saveCommandForBackend(backend, filename);
1646
1737
  if (!cmd) {
1647
1738
  await msgAdapter?.sendText(chatId, SAVE_UNSUPPORTED_MSG);
1648
1739
  return;
1649
1740
  }
1650
- this.pasteRawToClassicInstance(saveTarget.name, cmd);
1651
- await msgAdapter?.sendText(chatId, `✅ Sent \`${cmd}\` to ${saveTarget.name}`);
1741
+ this.pasteRawToClassicInstance(saveName, cmd);
1742
+ await msgAdapter?.sendText(chatId, `✅ Sent \`${cmd}\` to ${saveName}`);
1652
1743
  return;
1653
1744
  }
1654
- // Route to classic channel if registered
1655
- const target = this.routing.resolve(chatId);
1656
- if (target?.kind === "classic") {
1745
+ // Route to classic channel if this bot has an agent here (per-bot).
1746
+ const classicName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1747
+ if (classicName) {
1657
1748
  if (msg.adapterId)
1658
- this.bindInstanceAdapter(target.name, msg.adapterId, true);
1749
+ this.bindInstanceAdapter(classicName, msg.adapterId, true);
1659
1750
  // TG ClassicBot: group requires @mention, private chat forwards directly.
1660
1751
  if (!isPrivateChat && !isBotMentioned) {
1661
1752
  // No trigger: save attachments + react, log, but don't forward to agent
1662
1753
  const syntheticMsg = { ...msg, threadId: chatId, text: rawText.startsWith("/") ? "" : rawText };
1663
- await this.handleClassicChannelMessage(target.name, syntheticMsg);
1754
+ await this.handleClassicChannelMessage(classicName, syntheticMsg);
1664
1755
  return;
1665
1756
  }
1666
1757
  // Strip @bot from text and forward as /chat
@@ -1670,7 +1761,7 @@ export class FleetManager {
1670
1761
  return;
1671
1762
  }
1672
1763
  const syntheticMsg = { ...msg, threadId: chatId, text: `/chat ${cleanText}` };
1673
- await this.handleClassicChannelMessage(target.name, syntheticMsg);
1764
+ await this.handleClassicChannelMessage(classicName, syntheticMsg);
1674
1765
  return;
1675
1766
  }
1676
1767
  // Handle @bot without active agent
@@ -1695,9 +1786,12 @@ export class FleetManager {
1695
1786
  if (msg.adapterId)
1696
1787
  this.bindInstanceAdapter(generalInstance, msg.adapterId, true);
1697
1788
  const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1698
- // React immediately — before any other API calls
1789
+ // React immediately — before any other API calls. Use the adapter BOUND to
1790
+ // the instance (not whichever same-guild bot received the event first) so
1791
+ // exactly the owning bot reacts — no duplicate 👀 from a sibling bot.
1699
1792
  if (msg.chatId && msg.messageId) {
1700
- inboundAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
1793
+ const reactAdapter = this.getAdapterForInstance(generalInstance) ?? inboundAdapter;
1794
+ reactAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
1701
1795
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
1702
1796
  }
1703
1797
  this.warnIfRateLimited(generalInstance, msg);
@@ -1733,6 +1827,18 @@ export class FleetManager {
1733
1827
  }
1734
1828
  return;
1735
1829
  }
1830
+ // Classic channels resolve per-bot (same-channel multi-bot) — a channel can
1831
+ // host two bots' agents. If this channel is classic but THIS bot has no
1832
+ // agent here, a sibling bot owns it; skip rather than misroute to it.
1833
+ if (this.classicChannels?.hasChannel(threadId)) {
1834
+ const classicName = this.classicChannels.getInstanceByChannel(threadId, msg.adapterId);
1835
+ if (!classicName)
1836
+ return;
1837
+ if (msg.adapterId)
1838
+ this.bindInstanceAdapter(classicName, msg.adapterId, true);
1839
+ await this.handleClassicChannelMessage(classicName, msg);
1840
+ return;
1841
+ }
1736
1842
  const target = this.routing.resolve(threadId);
1737
1843
  if (!target) {
1738
1844
  // Only show unbound message for actual forum topics (same group, has threadId)
@@ -1764,9 +1870,12 @@ export class FleetManager {
1764
1870
  if (msg.adapterId)
1765
1871
  this.bindInstanceAdapter(instanceName, msg.adapterId, true);
1766
1872
  const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1767
- // React immediately — before any other Discord API calls
1873
+ // React immediately — before any other Discord API calls. Use the adapter
1874
+ // BOUND to the instance (not whichever same-guild bot received the event
1875
+ // first) so exactly the owning bot reacts — no duplicate 👀 from a sibling.
1768
1876
  if (msg.chatId && msg.messageId) {
1769
- inboundAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
1877
+ const reactAdapter = this.getAdapterForInstance(instanceName) ?? inboundAdapter;
1878
+ reactAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
1770
1879
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
1771
1880
  }
1772
1881
  // These may hit Discord API (topic icon, archive) — do after react
@@ -1891,7 +2000,7 @@ export class FleetManager {
1891
2000
  ts: new Date().toISOString(),
1892
2001
  });
1893
2002
  // Log bot reply to classic instance chat-log
1894
- const isClassic = [...this.routing.entries()].some(([, t]) => t.kind === "classic" && t.name === instanceName);
2003
+ const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
1895
2004
  if (isClassic) {
1896
2005
  ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
1897
2006
  }
@@ -2510,15 +2619,17 @@ export class FleetManager {
2510
2619
  startStatuslineWatcher(name) {
2511
2620
  this.statuslineWatcher.watch(name);
2512
2621
  }
2513
- reactMessageStatus(chatId, messageId, emoji) {
2514
- // Find the adapter that owns this chatId (check all adapters, not just primary)
2515
- for (const [, w] of this.worlds) {
2516
- if (w.type === "discord") {
2517
- w.react(chatId, messageId, emoji)
2518
- .catch(e => this.logger.debug({ err: e.message }, "Message status react failed"));
2519
- return;
2520
- }
2521
- }
2622
+ reactMessageStatus(instanceName, chatId, messageId, emoji) {
2623
+ // React via the adapter BOUND to this instance NOT the first discord world.
2624
+ // Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
2625
+ // (bound bot) and the delivery/confirm reactions (some other bot) come from
2626
+ // different bots, leaving a duplicate 👀 that never turns into ✅.
2627
+ const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
2628
+ // Status reactions are Discord-only (TG/others use the inbound react path).
2629
+ if (!adapter || adapter.type !== "discord")
2630
+ return;
2631
+ adapter.react(chatId, messageId, emoji)
2632
+ .catch(e => this.logger.debug({ err: e.message }, "Message status react failed"));
2522
2633
  }
2523
2634
  // ── Model failover ──────────────────────────────────────────────────────
2524
2635
  static FAILOVER_TRIGGER_PCT = 90;
@@ -2580,13 +2691,12 @@ export class FleetManager {
2580
2691
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send instance topic notification"));
2581
2692
  return;
2582
2693
  }
2583
- // Classic instance: find chatId from routing table
2584
- for (const [chatId, target] of this.routing.entries()) {
2585
- if (target.kind === "classic" && target.name === instanceName) {
2586
- adapter.sendText(chatId, text)
2587
- .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send classic notification"));
2588
- return;
2589
- }
2694
+ // Classic instance: find its channelId from the classic manager
2695
+ const classicChatId = this.classicChannels?.getChannelIdByInstance(instanceName);
2696
+ if (classicChatId) {
2697
+ adapter.sendText(classicChatId, text)
2698
+ .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send classic notification"));
2699
+ return;
2590
2700
  }
2591
2701
  // Fallback: send to group without threadId
2592
2702
  if (groupId) {
@@ -2603,12 +2713,16 @@ export class FleetManager {
2603
2713
  * Picks the backend-appropriate command (kiro → /chat save, claude → /export);
2604
2714
  * unsupported backends get a clear error. Routes via classic paste or fleet IPC.
2605
2715
  */
2606
- async handleSlashSave(data) {
2716
+ async handleSlashSave(data, adapterId) {
2607
2717
  if (!this.classicChannels?.isAdmin(data.userId)) {
2608
2718
  await data.respond("⛔ This command requires admin access.");
2609
2719
  return;
2610
2720
  }
2611
- const target = this.routing.resolve(data.channelId);
2721
+ // Classic resolves per-bot (same-channel multi-bot); otherwise a fleet topic.
2722
+ const classicName = this.classicChannels.getInstanceByChannel(data.channelId, adapterId);
2723
+ const target = classicName
2724
+ ? { kind: "classic", name: classicName }
2725
+ : this.routing.resolve(data.channelId);
2612
2726
  if (!target) {
2613
2727
  await data.respond("No active agent in this channel. Use `/start` first.");
2614
2728
  return;
@@ -2663,13 +2777,8 @@ export class FleetManager {
2663
2777
  threadId = String(topicId);
2664
2778
  }
2665
2779
  else {
2666
- // Classic instance: chatId from the routing table.
2667
- for (const [cid, target] of this.routing.entries()) {
2668
- if (target.kind === "classic" && target.name === instanceName) {
2669
- chatId = cid;
2670
- break;
2671
- }
2672
- }
2780
+ // Classic instance: channelId from the classic manager.
2781
+ chatId = this.classicChannels?.getChannelIdByInstance(instanceName);
2673
2782
  // General / flat fallback: post to the group (no thread).
2674
2783
  if (!chatId && groupId)
2675
2784
  chatId = String(groupId);
@@ -3152,7 +3261,7 @@ When users create specialized instances, suggest these configurations:
3152
3261
  async handleClassicChannelMessage(instanceName, msg) {
3153
3262
  const text = msg.text ?? "";
3154
3263
  const channelId = msg.threadId ?? msg.chatId;
3155
- const isCollabMode = this.classicChannels?.isCollab(channelId) ?? false;
3264
+ const isCollabMode = this.classicChannels?.isCollab(channelId, msg.adapterId) ?? false;
3156
3265
  // Handle /ctx in classic mode — always, regardless of collab mode
3157
3266
  if (text === "/ctx" || text.startsWith("/ctx@")) {
3158
3267
  const reply = await this.topicCommands.getCtxText(instanceName);
@@ -3179,8 +3288,15 @@ When users create specialized instances, suggest these configurations:
3179
3288
  : "");
3180
3289
  ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText);
3181
3290
  this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message");
3182
- // Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here
3183
- const adapterBotUserId = this.worlds.get(msg.adapterId ?? "")?.botUserId ?? this.botUserId;
3291
+ // Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here.
3292
+ // Each bot matches ONLY its own id. A secondary bot must NOT fall back to the
3293
+ // process-wide botUserId (the primary's) — otherwise, in a same-channel
3294
+ // multi-bot setup, an @mention of the primary would also match the secondary
3295
+ // and BOTH bots would react 👀 and forward. Only the primary adapter may use
3296
+ // the fallback.
3297
+ const mentionWorld = this.worlds.get(msg.adapterId ?? "");
3298
+ const isPrimaryAdapter = !mentionWorld || mentionWorld.adapter === this.adapter;
3299
+ const adapterBotUserId = mentionWorld?.botUserId ?? (isPrimaryAdapter ? this.botUserId : undefined);
3184
3300
  const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
3185
3301
  const isMentioned = mentionTag && text.includes(mentionTag);
3186
3302
  if (!isMentioned) {
@@ -3358,7 +3474,11 @@ When users create specialized instances, suggest these configurations:
3358
3474
  }
3359
3475
  /** Forward a message to a classic channel instance with chat log context */
3360
3476
  async forwardToClassicInstance(instanceName, text, msg, extraMeta) {
3361
- const contextLines = this.classicChannels?.getContextLines(msg.chatId) ?? 5;
3477
+ // Resolve the channel/adapter from the instance itself so per-channel context
3478
+ // config is correct even for a same-channel second bot.
3479
+ const ctxAdapterId = this.classicChannels?.getAdapterIdByInstance(instanceName);
3480
+ const ctxChannelId = this.classicChannels?.getChannelIdByInstance(instanceName) ?? msg.chatId;
3481
+ const contextLines = this.classicChannels?.getContextLines(ctxChannelId, ctxAdapterId) ?? 5;
3362
3482
  const logContext = this.getRecentChatLog(instanceName, contextLines);
3363
3483
  const fullText = logContext
3364
3484
  ? `[Chat log for context]\n${logContext}\n\n[User message]\n${text}`
@@ -3444,7 +3564,7 @@ When users create specialized instances, suggest these configurations:
3444
3564
  await this.startInstance(instanceName, config, topicMode);
3445
3565
  }
3446
3566
  /** Handle /start slash command — register classic channel */
3447
- async handleClassicStart(channelId, channelName, userId, guildId) {
3567
+ async handleClassicStart(channelId, channelName, userId, guildId, adapterId) {
3448
3568
  if (!this.classicChannels)
3449
3569
  return "Classic channel manager not initialized.";
3450
3570
  if (guildId && !this.classicChannels.isGuildAllowed(guildId)) {
@@ -3454,33 +3574,40 @@ When users create specialized instances, suggest these configurations:
3454
3574
  }
3455
3575
  return "⛔ This server is not in the allowed guilds list.";
3456
3576
  }
3457
- if (this.classicChannels.isClassicChannel(channelId))
3577
+ // Per-bot check: a second bot may /start in the same channel (own agent).
3578
+ if (this.classicChannels.isClassicChannel(channelId, adapterId))
3458
3579
  return "This channel already has an active agent. Use /chat to talk.";
3580
+ // Classic no longer lives in the routing engine, so this only guards against
3581
+ // a fleet topic-mode instance colliding with the channel.
3459
3582
  if (this.routing.resolve(channelId))
3460
3583
  return "This channel is already bound to a topic-mode instance.";
3461
- const instanceName = classicInstanceName(sanitizeInstanceName(channelName || channelId), channelId);
3462
- this.classicChannels.register(channelId, instanceName, channelName || channelId, userId);
3463
- this.routing.register(channelId, { kind: "classic", name: instanceName });
3464
- await this.startClassicInstance(instanceName, this.classicChannels.getBackend(channelId, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channelId), this.classicChannels.getModel(channelId, this.fleetConfig?.defaults?.model));
3584
+ const instanceName = this.classicChannels.deriveInstanceName(channelName || channelId, channelId, adapterId);
3585
+ this.classicChannels.register(channelId, adapterId, instanceName, channelName || channelId, userId);
3586
+ // Bind this classic instance to the bot that started it (authoritative), so
3587
+ // replies/cancel go out through that bot even though every same-guild bot
3588
+ // also sees the channel's messages.
3589
+ if (adapterId)
3590
+ this.bindInstanceAdapter(instanceName, adapterId);
3591
+ 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));
3465
3592
  this.reregisterClassicChannels();
3466
3593
  // Auto-enable collab for Discord classic channels (TG uses @mention directly without collab mode)
3467
- if (guildId && !this.classicChannels.isCollab(channelId)) {
3468
- this.classicChannels.toggleCollab(channelId);
3594
+ if (guildId && !this.classicChannels.isCollab(channelId, adapterId)) {
3595
+ this.classicChannels.toggleCollab(channelId, adapterId);
3469
3596
  }
3470
- this.logger.info({ channelId, instanceName, userId }, "Classic channel started");
3597
+ this.logger.info({ channelId, adapterId, instanceName, userId }, "Classic channel started");
3471
3598
  return `✅ Agent started in this channel. Use \`/chat <message>\` or @mention to talk.`;
3472
3599
  }
3473
3600
  /** Handle /stop slash command — unregister classic channel */
3474
- async handleClassicStop(channelId) {
3601
+ async handleClassicStop(channelId, adapterId) {
3475
3602
  if (!this.classicChannels)
3476
3603
  return "Classic channel manager not initialized.";
3477
- const ch = this.classicChannels.unregister(channelId);
3604
+ const ch = this.classicChannels.unregister(channelId, adapterId);
3478
3605
  if (!ch)
3479
3606
  return "No active agent in this channel.";
3480
- this.routing.unregister(channelId);
3607
+ this.instanceWorldBinding.delete(ch.instanceName);
3481
3608
  await this.stopInstance(ch.instanceName).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance"));
3482
3609
  this.reregisterClassicChannels();
3483
- this.logger.info({ channelId, instanceName: ch.instanceName }, "Classic channel stopped");
3610
+ this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
3484
3611
  return `🛑 Agent stopped in this channel.`;
3485
3612
  }
3486
3613
  async stopAll() {
@@ -3955,6 +4082,15 @@ When users create specialized instances, suggest these configurations:
3955
4082
  catch {
3956
4083
  // best-effort
3957
4084
  }
4085
+ // Separate read-only token for the /view page: grants terminal-view + profile
4086
+ // read, but never write (POSTs still require the full web token).
4087
+ this.viewToken = randomBytes(24).toString("hex");
4088
+ const viewTokenPath = join(this.dataDir, "view.token");
4089
+ writeFileSync(viewTokenPath, this.viewToken, { mode: 0o600 });
4090
+ try {
4091
+ chmodSync(viewTokenPath, 0o600);
4092
+ }
4093
+ catch { /* best-effort */ }
3958
4094
  this.healthServer = createServer((req, res) => {
3959
4095
  res.setHeader("Content-Type", "application/json");
3960
4096
  // Public health probe — no auth required.
@@ -3964,6 +4100,10 @@ When users create specialized instances, suggest these configurations:
3964
4100
  else if (req.method === "POST" && req.url === "/agent") {
3965
4101
  // /agent handles its own instance-level auth via X-Agend-Instance-Token
3966
4102
  }
4103
+ else if (isViewPath(new URL(req.url ?? "/", `http://localhost:${port}`).pathname)) {
4104
+ // /view routes accept the read-only view.token (or web.token) and do
4105
+ // their own per-method auth in view-api.ts — skip the web-token gate.
4106
+ }
3967
4107
  else {
3968
4108
  // All other endpoints require a valid token (query ?token= or X-Agend-Token header).
3969
4109
  // /ui/* will also re-check in web-api.ts, which is harmless.
@@ -4147,6 +4287,10 @@ When users create specialized instances, suggest these configurations:
4147
4287
  }
4148
4288
  // ── Web UI endpoints (delegated to web-api.ts) ─────
4149
4289
  const url = new URL(req.url ?? "/", `http://localhost:${port}`);
4290
+ if (handleViewRequest(req, res, url, this))
4291
+ return;
4292
+ if (handleSettingsRequest(req, res, url, this))
4293
+ return;
4150
4294
  if (handleWebRequest(req, res, url, this))
4151
4295
  return;
4152
4296
  res.writeHead(404);
@@ -4188,6 +4332,7 @@ When users create specialized instances, suggest these configurations:
4188
4332
  this.logger.info({ port }, "Health endpoint listening");
4189
4333
  });
4190
4334
  this.logger.info({ url: `http://localhost:${port}/ui?token=${this.webToken}` }, "Web UI available");
4335
+ this.logger.info({ url: `http://localhost:${port}/view?token=${this.viewToken}` }, "Web View available");
4191
4336
  }
4192
4337
  getUiStatus() {
4193
4338
  const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => {