@songsid/agend 2.0.11-beta.3 → 2.0.11-beta.30

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 (45) hide show
  1. package/dist/channel/adapters/discord.d.ts +2 -0
  2. package/dist/channel/adapters/discord.js +39 -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 +94 -7
  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/fleet-manager.d.ts +27 -5
  20. package/dist/fleet-manager.js +360 -168
  21. package/dist/fleet-manager.js.map +1 -1
  22. package/dist/instance-lifecycle.d.ts +1 -1
  23. package/dist/instance-lifecycle.js +7 -6
  24. package/dist/instance-lifecycle.js.map +1 -1
  25. package/dist/outbound-schemas.d.ts +1 -0
  26. package/dist/outbound-schemas.js +1 -0
  27. package/dist/outbound-schemas.js.map +1 -1
  28. package/dist/quickstart.js +245 -16
  29. package/dist/quickstart.js.map +1 -1
  30. package/dist/service-installer.d.ts +1 -0
  31. package/dist/service-installer.js +2 -1
  32. package/dist/service-installer.js.map +1 -1
  33. package/dist/settings-api.d.ts +33 -0
  34. package/dist/settings-api.js +283 -0
  35. package/dist/settings-api.js.map +1 -0
  36. package/dist/topic-commands.d.ts +14 -0
  37. package/dist/topic-commands.js +63 -2
  38. package/dist/topic-commands.js.map +1 -1
  39. package/dist/ui/settings.html +457 -0
  40. package/dist/ui/view.html +477 -0
  41. package/dist/view-api.d.ts +29 -0
  42. package/dist/view-api.js +370 -0
  43. package/dist/view-api.js.map +1 -0
  44. package/package.json +1 -1
  45. package/templates/systemd.service.ejs +2 -1
@@ -8,6 +8,8 @@ import { sdNotify } from "./sd-notify.js";
8
8
  import yaml from "js-yaml";
9
9
  const __filename = fileURLToPath(import.meta.url);
10
10
  const __dirname = dirname(__filename);
11
+ /** Fallback access policy for a channel with no `access:` block — open (no gate). */
12
+ const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
11
13
  import { isProbeableRouteTarget } from "./fleet-context.js";
12
14
  import { loadFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
13
15
  import { EventLog } from "./event-log.js";
@@ -22,7 +24,7 @@ import { processAttachments } from "./channel/attachment-handler.js";
22
24
  import { routeToolCall } from "./channel/tool-router.js";
23
25
  import { Scheduler } from "./scheduler/index.js";
24
26
  import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
25
- import { TopicCommands, sanitizeInstanceName, saveCommandForBackend, parseSaveFilename, SAVE_FILENAME_RE, SAVE_UNSUPPORTED_MSG } from "./topic-commands.js";
27
+ import { TopicCommands, saveCommandForBackend, parseSaveFilename, SAVE_FILENAME_RE, SAVE_UNSUPPORTED_MSG } from "./topic-commands.js";
26
28
  import { DailySummary } from "./daily-summary.js";
27
29
  import { WebhookEmitter } from "./webhook-emitter.js";
28
30
  import { TmuxControlClient } from "./tmux-control.js";
@@ -33,8 +35,10 @@ import { TopicArchiver } from "./topic-archiver.js";
33
35
  import { StatuslineWatcher } from "./statusline-watcher.js";
34
36
  import { outboundHandlers } from "./outbound-handlers.js";
35
37
  import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
38
+ import { handleViewRequest, isViewPath } from "./view-api.js";
39
+ import { handleSettingsRequest } from "./settings-api.js";
36
40
  import { handleAgentRequest } from "./agent-endpoint.js";
37
- import { ClassicChannelManager, classicInstanceName } from "./classic-channel-manager.js";
41
+ import { ClassicChannelManager } from "./classic-channel-manager.js";
38
42
  import { getTmuxSession } from "./config.js";
39
43
  export function resolveReplyThreadId(argsThreadId, instanceConfig) {
40
44
  if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
@@ -65,6 +69,9 @@ export class FleetManager {
65
69
  adapters = new Map(); // derived view for backward compat
66
70
  /** Track which world each instance is bound to */
67
71
  instanceWorldBinding = new Map();
72
+ // Dedup inbound messages seen by more than one adapter (e.g. two DC bots in the
73
+ // same guild both receive every message). Bounded FIFO of recent message keys.
74
+ recentMessageIds = new Set();
68
75
  accessManager = null;
69
76
  /** Primary world (first adapter) — used for fleet-level notifications */
70
77
  get primaryWorld() { return this.worlds.values().next().value; }
@@ -115,6 +122,7 @@ export class FleetManager {
115
122
  // Web UI: SSE clients + auth token
116
123
  sseClients = new Set();
117
124
  webToken = null;
125
+ viewToken = null;
118
126
  constructor(dataDir) {
119
127
  this.dataDir = dataDir;
120
128
  this.lifecycle = new InstanceLifecycle(this);
@@ -174,27 +182,42 @@ export class FleetManager {
174
182
  }
175
183
  return this.routing.map;
176
184
  }
177
- /** Re-register classic channels after routing rebuild (rebuild clears the table) */
185
+ /**
186
+ * Refresh each adapter's open-channel whitelist after a classic change.
187
+ * Classic channels are NOT registered in the routing engine (it's single-key
188
+ * per channel — can't represent two bots in one channel); routing resolves
189
+ * per-bot via ClassicChannelManager.getInstanceByChannel. Each adapter only
190
+ * opens the channels IT owns so a sibling bot doesn't process another's cross-
191
+ * guild channel.
192
+ */
178
193
  reregisterClassicChannels() {
179
194
  if (!this.classicChannels)
180
195
  return;
181
196
  const channels = this.classicChannels.getAll();
182
- for (const ch of channels) {
183
- this.routing.register(ch.channelId, { kind: "classic", name: ch.instanceName });
184
- }
185
197
  // Always update adapter openChannels (including empty — clears stale entries on /stop)
186
- for (const [, w] of this.worlds) {
198
+ for (const [adapterId, w] of this.worlds) {
187
199
  if (typeof w.adapter?.setOpenChannels === "function") {
188
- w.adapter.setOpenChannels(channels.map(ch => ch.channelId));
200
+ const owned = channels.filter(ch => ch.adapterId === adapterId).map(ch => ch.channelId);
201
+ w.adapter.setOpenChannels(owned);
189
202
  }
190
203
  }
191
204
  if (channels.length > 0) {
192
- this.logger.info({ count: channels.length }, "Registered classic channel routes");
205
+ this.logger.info({ count: channels.length }, "Refreshed classic channel open-lists");
193
206
  }
194
207
  }
195
208
  getInstanceDir(name) {
196
209
  return join(this.dataDir, "instances", name);
197
210
  }
211
+ /**
212
+ * Resolve a slash-command target in a channel. Classic channels are looked up
213
+ * per-bot (same-channel multi-bot); a fleet-topic instance is found via the
214
+ * routing engine. Used by commands that work in BOTH contexts (/ctx, /compact,
215
+ * /cancel). Classic-only commands (/chat, /load) must NOT use this.
216
+ */
217
+ resolveSlashTarget(channelId, adapterId) {
218
+ return this.classicChannels?.getInstanceByChannel(channelId, adapterId)
219
+ ?? this.routing.resolve(channelId)?.name;
220
+ }
198
221
  /** Get the adapter bound to an instance, falling back to primary adapter */
199
222
  getAdapterForInstance(name) {
200
223
  const worldId = this.instanceWorldBinding.get(name);
@@ -221,9 +244,22 @@ export class FleetManager {
221
244
  const world = this.getWorldForInstance(name);
222
245
  return world?.groupId ?? String(this.fleetConfig?.channel?.group_id ?? "");
223
246
  }
224
- /** Bind an instance to a specific world. fromInbound=true skips general_topic to prevent overwrite. */
247
+ /**
248
+ * Bind an instance to a specific world (the bot that answers for it).
249
+ * fromInbound=true (binding inferred from which adapter received a message)
250
+ * must not override a configured identity: skip when the instance is a general
251
+ * or has an explicit channel_id — otherwise a persona instance whose message
252
+ * was also seen by the main bot would get rebound to the wrong bot.
253
+ */
225
254
  bindInstanceAdapter(name, adapterId, fromInbound = false) {
226
- if (fromInbound && this.fleetConfig?.instances[name]?.general_topic)
255
+ const cfg = this.fleetConfig?.instances[name];
256
+ if (fromInbound && (cfg?.general_topic || cfg?.channel_id))
257
+ return;
258
+ // A classic instance is bound authoritatively at /start. Don't let an inbound
259
+ // (seen by every same-guild bot) override an existing binding — but if there
260
+ // is none yet (e.g. after a restart, before v2.1 persistence), allow inbound
261
+ // to re-establish it so replies don't fall back to the primary bot forever.
262
+ if (fromInbound && this.classicChannels?.getChannelIdByInstance(name) !== undefined && this.instanceWorldBinding.has(name))
227
263
  return;
228
264
  this.instanceWorldBinding.set(name, adapterId);
229
265
  }
@@ -393,10 +429,19 @@ export class FleetManager {
393
429
  const pidPath = join(this.dataDir, "fleet.pid");
394
430
  writeFileSync(pidPath, String(process.pid), "utf-8");
395
431
  this.eventLog = new EventLog(join(this.dataDir, "events.db"));
396
- // Initialize classic channel manager and register existing channels in routing
432
+ // Initialize classic channel manager. The primary adapter (channels[0])
433
+ // migrates legacy single-bot entries and names without a suffix. Classic
434
+ // routing does NOT go through the routing engine (single-key, can't hold two
435
+ // bots in one channel) — it resolves per-bot via getInstanceByChannel.
397
436
  this.classicChannels = new ClassicChannelManager(this.dataDir, this.logger);
437
+ const primaryCh = fleet.channels?.[0] ?? fleet.channel;
438
+ if (primaryCh)
439
+ this.classicChannels.setPrimaryAdapterId(primaryCh.id ?? primaryCh.type);
440
+ // Restore the persisted bot binding so replies/cancel go through the right
441
+ // bot after a restart (before this, inbound would re-bind lazily).
398
442
  for (const ch of this.classicChannels.getAll()) {
399
- this.routing.register(ch.channelId, { kind: "classic", name: ch.instanceName });
443
+ if (ch.adapterId)
444
+ this.instanceWorldBinding.set(ch.instanceName, ch.adapterId);
400
445
  }
401
446
  // Poll classicBot.yaml for external changes every 30s
402
447
  this.classicReloadTimer = setInterval(async () => {
@@ -474,8 +519,9 @@ export class FleetManager {
474
519
  // Rotate classic channel chat logs daily (piggyback on daily summary timer)
475
520
  this.classicChannels?.rotateLogs();
476
521
  this.rotateInboxes();
477
- // Auto-create general instance(s)one per adapter that lacks a general
522
+ // Auto-create/adopt a general dispatcherONLY for the primary adapter.
478
523
  const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
524
+ const primaryAdapterId = channelConfigs[0] ? (channelConfigs[0].id ?? channelConfigs[0].type) : undefined;
479
525
  const generalInstances = Object.entries(fleet.instances).filter(([, inst]) => inst.general_topic === true);
480
526
  let generalsCreated = false;
481
527
  // Collect unbound generals (no channel_id set) for auto-assignment
@@ -484,6 +530,14 @@ export class FleetManager {
484
530
  const needsGeneral = [];
485
531
  for (const ch of channelConfigs) {
486
532
  const adapterId = ch.id ?? ch.type;
533
+ // Only the primary adapter gets an auto-general. Secondary (persona) bots
534
+ // answer for their explicitly-bound instances only — they don't need or
535
+ // auto-claim a general dispatcher, and must never adopt the primary's
536
+ // unbound general. A general a user manually bound to a secondary
537
+ // (channel_id: <persona>) is left untouched — the auto logic just won't
538
+ // create or reassign bindings for non-primary adapters.
539
+ if (adapterId !== primaryAdapterId)
540
+ continue;
487
541
  // Check if any general is explicitly bound to this adapter
488
542
  if (generalInstances.some(([, inst]) => inst.channel_id === adapterId))
489
543
  continue;
@@ -606,12 +660,19 @@ export class FleetManager {
606
660
  catch (err) {
607
661
  this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
608
662
  }
609
- // Pre-bind general instances to their corresponding adapter
663
+ // Bind instances to their adapter (which bot answers on their behalf).
664
+ // An explicit channel_id is authoritative — this is how a persona instance
665
+ // picks its bot when several share one guild. Generals without a channel_id
666
+ // fall back to a name-contains-adapterId heuristic.
667
+ const channelConfigsForBind = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
610
668
  for (const [name, config] of Object.entries(fleet.instances)) {
669
+ if (config.channel_id) {
670
+ this.bindInstanceAdapter(name, config.channel_id);
671
+ continue;
672
+ }
611
673
  if (!config.general_topic)
612
674
  continue;
613
- const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
614
- for (const ch of channelConfigs) {
675
+ for (const ch of channelConfigsForBind) {
615
676
  const id = ch.id ?? ch.type;
616
677
  if (name.includes(id)) {
617
678
  this.bindInstanceAdapter(name, id);
@@ -619,6 +680,23 @@ export class FleetManager {
619
680
  }
620
681
  }
621
682
  }
683
+ // Guard against a stale/invalid general topic_id. An old auto-general
684
+ // could have written the TG-convention "1" for a Discord general; the DC
685
+ // adapter then throws fetching channel "1" → unhandled → fleet crash loop.
686
+ // Unbind (+ warn) so it's simply skipped, never routed to a bogus channel.
687
+ let fixedGeneral = false;
688
+ for (const [name, cfg] of Object.entries(this.fleetConfig.instances)) {
689
+ if (!cfg.general_topic || cfg.topic_id == null)
690
+ continue;
691
+ const adapterId = this.instanceWorldBinding.get(name) ?? cfg.channel_id;
692
+ if (this.getChannelConfig(adapterId)?.type === "discord" && !/^\d{17,}$/.test(String(cfg.topic_id))) {
693
+ this.logger.warn({ name, topic_id: cfg.topic_id }, "Discord general topic_id is not a valid channel — unbinding to avoid a crash loop");
694
+ delete cfg.topic_id;
695
+ fixedGeneral = true;
696
+ }
697
+ }
698
+ if (fixedGeneral)
699
+ this.saveFleetConfig();
622
700
  // Auto-create topics AFTER adapter is ready (needs adapter.createTopic)
623
701
  await this.topicCommands.autoCreateTopics();
624
702
  const routeSummary = this.routing.rebuild(this.fleetConfig);
@@ -741,9 +819,13 @@ export class FleetManager {
741
819
  const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
742
820
  if (channelConfigs.length === 0)
743
821
  return;
744
- // Start primary adapter (first channel) — this.adapter for backward compat
822
+ // Start primary adapter (first channel) — this.adapter for backward compat.
745
823
  await this.startSingleAdapter(fleet, channelConfigs[0]);
746
- // Start additional adapters
824
+ // Start additional adapters. Every bot registers its own slash commands —
825
+ // Discord slash commands are per-application, so a same-guild secondary bot's
826
+ // /start etc. are distinct entries (labelled with the bot name) and the
827
+ // interaction only reaches the invoked bot. This is how ClassicBot supports
828
+ // multiple bots in one guild: the user picks which bot from autocomplete.
747
829
  for (let i = 1; i < channelConfigs.length; i++) {
748
830
  await this.startAdditionalAdapter(channelConfigs[i]);
749
831
  }
@@ -757,7 +839,7 @@ export class FleetManager {
757
839
  }
758
840
  const accessDir = join(this.dataDir, "access");
759
841
  mkdirSync(accessDir, { recursive: true });
760
- const accessManager = new AccessManager(channelConfig.access, join(accessDir, "access.json"));
842
+ const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, join(accessDir, "access.json"));
761
843
  this.accessManager = accessManager;
762
844
  const inboxDir = join(this.dataDir, "inbox");
763
845
  mkdirSync(inboxDir, { recursive: true });
@@ -813,11 +895,11 @@ export class FleetManager {
813
895
  // Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
814
896
  this.adapter.on("slash_command", safeHandler(async (data) => {
815
897
  if (data.command === "start") {
816
- const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId);
898
+ const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId, adapterId);
817
899
  await data.respond(reply);
818
900
  }
819
901
  else if (data.command === "stop") {
820
- const reply = await this.handleClassicStop(data.channelId);
902
+ const reply = await this.handleClassicStop(data.channelId, adapterId);
821
903
  await data.respond(reply);
822
904
  }
823
905
  else if (data.command === "chat") {
@@ -826,15 +908,15 @@ export class FleetManager {
826
908
  await data.respond("Usage: `/chat <message>`");
827
909
  return;
828
910
  }
829
- const target = this.routing.resolve(data.channelId);
830
- if (!target || target.kind !== "classic") {
911
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
912
+ if (!name) {
831
913
  await data.respond("No active agent in this channel. Use `/start` first.");
832
914
  return;
833
915
  }
834
916
  const replyMsgId = await data.respond("👀");
835
917
  const username = data.username ?? data.userId;
836
- ClassicChannelManager.logMessage(target.name, username, `/chat ${text}`, new Date());
837
- await this.forwardToClassicInstance(target.name, text, {
918
+ ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
919
+ await this.forwardToClassicInstance(name, text, {
838
920
  chatId: data.channelId,
839
921
  threadId: data.channelId,
840
922
  messageId: replyMsgId ?? "",
@@ -845,7 +927,7 @@ export class FleetManager {
845
927
  });
846
928
  }
847
929
  else if (data.command === "save") {
848
- await this.handleSlashSave(data);
930
+ await this.handleSlashSave(data, adapterId);
849
931
  }
850
932
  else if (data.command === "load") {
851
933
  // load is kiro-cli/classic only — no claude-code equivalent.
@@ -853,8 +935,8 @@ export class FleetManager {
853
935
  await data.respond("⛔ This command requires admin access.");
854
936
  return;
855
937
  }
856
- const target = this.routing.resolve(data.channelId);
857
- if (!target || target.kind !== "classic") {
938
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
939
+ if (!name) {
858
940
  await data.respond("No active agent in this channel. Use `/start` first.");
859
941
  return;
860
942
  }
@@ -863,39 +945,41 @@ export class FleetManager {
863
945
  await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
864
946
  return;
865
947
  }
866
- this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
867
- await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
948
+ this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
949
+ await data.respond(`✅ Sent \`/chat load ${filename}\` to ${name}`);
868
950
  }
869
951
  else if (data.command === "compact") {
870
- const target = this.routing.resolve(data.channelId);
871
- if (!target) {
952
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
953
+ if (!name) {
872
954
  await data.respond("No active agent in this channel.");
873
955
  return;
874
956
  }
875
- const result = await this.topicCommands.sendCompact(target.name);
957
+ const result = await this.topicCommands.sendCompact(name);
876
958
  await data.respond(result);
877
959
  }
878
960
  else if (data.command === "cancel") {
879
- const target = this.routing.resolve(data.channelId);
880
- if (!target) {
961
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
962
+ if (!name) {
881
963
  await data.respond("No active agent in this channel.");
882
964
  return;
883
965
  }
884
- const ok = this.cancelInstance(target.name);
885
- await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
966
+ const ok = this.cancelInstance(name);
967
+ await data.respond(ok ? `🛑 Sent cancel to ${name}.` : `❌ ${name} not running.`);
886
968
  }
887
969
  else if (data.command === "ctx") {
888
- const target = this.routing.resolve(data.channelId);
889
- if (!target) {
970
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
971
+ if (!name) {
890
972
  await data.respond("No active agent in this channel.");
891
973
  return;
892
974
  }
893
975
  // Single source of truth (statusline.json + robust tmux pane fallback).
894
- await data.respond(await this.topicCommands.getCtxText(target.name));
976
+ await data.respond(await this.topicCommands.getCtxText(name));
895
977
  }
896
978
  else if (data.command === "collab") {
979
+ // Classic no longer lives in the routing engine, so a routing hit here is
980
+ // always a fleet-topic instance.
897
981
  const collabTarget = this.routing.resolve(data.channelId);
898
- if (collabTarget && collabTarget.kind !== "classic") {
982
+ if (collabTarget) {
899
983
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
900
984
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
901
985
  await data.respond("⛔ Not authorized");
@@ -909,11 +993,11 @@ export class FleetManager {
909
993
  await data.respond("⛔ This command requires admin access.");
910
994
  return;
911
995
  }
912
- if (!this.classicChannels.isClassicChannel(data.channelId)) {
996
+ if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
913
997
  await data.respond("No active agent in this channel. Use `/start` first.");
914
998
  return;
915
999
  }
916
- const newState = this.classicChannels.toggleCollab(data.channelId);
1000
+ const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
917
1001
  await data.respond(newState
918
1002
  ? "🤝 Collaboration mode **ON** — @mention this bot to trigger the agent. Other bot messages are visible."
919
1003
  : "💬 Collaboration mode **OFF** — use `/chat` to talk to the agent.");
@@ -956,6 +1040,20 @@ export class FleetManager {
956
1040
  else if (data.command === "sysinfo") {
957
1041
  await data.respond(this.topicCommands.getSysInfoText());
958
1042
  }
1043
+ else if (data.command === "dashboard") {
1044
+ // Reply is ephemeral (adapter defers non-chat commands ephemerally), so
1045
+ // the web-token-bearing URLs are only visible to the caller.
1046
+ const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1047
+ if (allowed.length === 0) {
1048
+ await data.respond("⛔ /dashboard disabled — no allowed_users configured");
1049
+ return;
1050
+ }
1051
+ if (!allowed.some(u => String(u) === String(data.userId))) {
1052
+ await data.respond("⛔ Not authorized");
1053
+ return;
1054
+ }
1055
+ await data.respond(this.topicCommands.getDashboardText());
1056
+ }
959
1057
  else if (data.command === "restart") {
960
1058
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
961
1059
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
@@ -966,12 +1064,12 @@ export class FleetManager {
966
1064
  process.kill(process.pid, "SIGUSR2");
967
1065
  }
968
1066
  else if (data.command === "compact") {
969
- const target = this.routing.resolve(data.channelId);
970
- if (!target) {
1067
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1068
+ if (!name) {
971
1069
  await data.respond("No active agent in this channel.");
972
1070
  return;
973
1071
  }
974
- const result = await this.topicCommands.sendCompact(target.name);
1072
+ const result = await this.topicCommands.sendCompact(name);
975
1073
  await data.respond(result);
976
1074
  }
977
1075
  }, this.logger, "adapter.slash_command"));
@@ -1015,7 +1113,7 @@ export class FleetManager {
1015
1113
  }, 5 * 60 * 1000);
1016
1114
  }
1017
1115
  /** Start an additional (non-primary) adapter */
1018
- async startAdditionalAdapter(channelConfig) {
1116
+ async startAdditionalAdapter(channelConfig, registerCommands = true) {
1019
1117
  const adapterId = channelConfig.id ?? channelConfig.type;
1020
1118
  const botToken = process.env[channelConfig.bot_token_env];
1021
1119
  if (!botToken) {
@@ -1024,7 +1122,7 @@ export class FleetManager {
1024
1122
  }
1025
1123
  const accessDir = join(this.dataDir, "access");
1026
1124
  mkdirSync(accessDir, { recursive: true });
1027
- const accessManager = new AccessManager(channelConfig.access, join(accessDir, `access-${adapterId}.json`));
1125
+ const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, join(accessDir, `access-${adapterId}.json`));
1028
1126
  const inboxDir = join(this.dataDir, "inbox");
1029
1127
  mkdirSync(inboxDir, { recursive: true });
1030
1128
  const adapter = await createAdapter(channelConfig, {
@@ -1032,6 +1130,7 @@ export class FleetManager {
1032
1130
  botToken,
1033
1131
  accessManager,
1034
1132
  inboxDir,
1133
+ registerCommands,
1035
1134
  });
1036
1135
  const world = new AdapterWorld(adapterId, adapter, accessManager, channelConfig);
1037
1136
  this.worlds.set(adapterId, world);
@@ -1075,11 +1174,11 @@ export class FleetManager {
1075
1174
  // Slash commands: classic bot + admin commands
1076
1175
  adapter.on("slash_command", safeHandler(async (data) => {
1077
1176
  if (data.command === "start") {
1078
- const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId);
1177
+ const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId, adapterId);
1079
1178
  await data.respond(reply);
1080
1179
  }
1081
1180
  else if (data.command === "stop") {
1082
- const reply = await this.handleClassicStop(data.channelId);
1181
+ const reply = await this.handleClassicStop(data.channelId, adapterId);
1083
1182
  await data.respond(reply);
1084
1183
  }
1085
1184
  else if (data.command === "chat") {
@@ -1088,15 +1187,15 @@ export class FleetManager {
1088
1187
  await data.respond("Usage: `/chat <message>`");
1089
1188
  return;
1090
1189
  }
1091
- const target = this.routing.resolve(data.channelId);
1092
- if (!target || target.kind !== "classic") {
1190
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
1191
+ if (!name) {
1093
1192
  await data.respond("No active agent in this channel. Use `/start` first.");
1094
1193
  return;
1095
1194
  }
1096
1195
  const replyMsgId = await data.respond("👀");
1097
1196
  const username = data.username ?? data.userId;
1098
- ClassicChannelManager.logMessage(target.name, username, `/chat ${text}`, new Date());
1099
- await this.forwardToClassicInstance(target.name, text, {
1197
+ ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
1198
+ await this.forwardToClassicInstance(name, text, {
1100
1199
  chatId: data.channelId,
1101
1200
  threadId: data.channelId,
1102
1201
  messageId: replyMsgId ?? "",
@@ -1107,7 +1206,7 @@ export class FleetManager {
1107
1206
  });
1108
1207
  }
1109
1208
  else if (data.command === "save") {
1110
- await this.handleSlashSave(data);
1209
+ await this.handleSlashSave(data, adapterId);
1111
1210
  }
1112
1211
  else if (data.command === "load") {
1113
1212
  // load is kiro-cli/classic only — no claude-code equivalent.
@@ -1115,8 +1214,8 @@ export class FleetManager {
1115
1214
  await data.respond("⛔ This command requires admin access.");
1116
1215
  return;
1117
1216
  }
1118
- const target = this.routing.resolve(data.channelId);
1119
- if (!target || target.kind !== "classic") {
1217
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
1218
+ if (!name) {
1120
1219
  await data.respond("No active agent in this channel. Use `/start` first.");
1121
1220
  return;
1122
1221
  }
@@ -1125,39 +1224,32 @@ export class FleetManager {
1125
1224
  await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1126
1225
  return;
1127
1226
  }
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);
1227
+ this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
1228
+ await data.respond(`✅ Sent \`/chat load ${filename}\` to ${name}`);
1139
1229
  }
1140
1230
  else if (data.command === "cancel") {
1141
- const target = this.routing.resolve(data.channelId);
1142
- if (!target) {
1231
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1232
+ if (!name) {
1143
1233
  await data.respond("No active agent in this channel.");
1144
1234
  return;
1145
1235
  }
1146
- const ok = this.cancelInstance(target.name);
1147
- await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
1236
+ const ok = this.cancelInstance(name);
1237
+ await data.respond(ok ? `🛑 Sent cancel to ${name}.` : `❌ ${name} not running.`);
1148
1238
  }
1149
1239
  else if (data.command === "ctx") {
1150
- const target = this.routing.resolve(data.channelId);
1151
- if (!target) {
1240
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1241
+ if (!name) {
1152
1242
  await data.respond("No active agent in this channel.");
1153
1243
  return;
1154
1244
  }
1155
1245
  // Single source of truth (statusline.json + robust tmux pane fallback).
1156
- await data.respond(await this.topicCommands.getCtxText(target.name));
1246
+ await data.respond(await this.topicCommands.getCtxText(name));
1157
1247
  }
1158
1248
  else if (data.command === "collab") {
1249
+ // Classic no longer lives in the routing engine, so a routing hit here is
1250
+ // always a fleet-topic instance.
1159
1251
  const collabTarget2 = this.routing.resolve(data.channelId);
1160
- if (collabTarget2 && collabTarget2.kind !== "classic") {
1252
+ if (collabTarget2) {
1161
1253
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1162
1254
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
1163
1255
  await data.respond("⛔ Not authorized");
@@ -1171,11 +1263,11 @@ export class FleetManager {
1171
1263
  await data.respond("⛔ This command requires admin access.");
1172
1264
  return;
1173
1265
  }
1174
- if (!this.classicChannels.isClassicChannel(data.channelId)) {
1266
+ if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
1175
1267
  await data.respond("No active agent in this channel. Use `/start` first.");
1176
1268
  return;
1177
1269
  }
1178
- const newState = this.classicChannels.toggleCollab(data.channelId);
1270
+ const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
1179
1271
  await data.respond(newState
1180
1272
  ? "🤝 Collaboration mode **ON** — @mention this bot to trigger the agent. Other bot messages are visible."
1181
1273
  : "💬 Collaboration mode **OFF** — use `/chat` to talk to the agent.");
@@ -1218,6 +1310,20 @@ export class FleetManager {
1218
1310
  else if (data.command === "sysinfo") {
1219
1311
  await data.respond(this.topicCommands.getSysInfoText());
1220
1312
  }
1313
+ else if (data.command === "dashboard") {
1314
+ // Reply is ephemeral (adapter defers non-chat commands ephemerally), so
1315
+ // the web-token-bearing URLs are only visible to the caller.
1316
+ const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1317
+ if (allowed.length === 0) {
1318
+ await data.respond("⛔ /dashboard disabled — no allowed_users configured");
1319
+ return;
1320
+ }
1321
+ if (!allowed.some(u => String(u) === String(data.userId))) {
1322
+ await data.respond("⛔ Not authorized");
1323
+ return;
1324
+ }
1325
+ await data.respond(this.topicCommands.getDashboardText());
1326
+ }
1221
1327
  else if (data.command === "restart") {
1222
1328
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1223
1329
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
@@ -1228,12 +1334,12 @@ export class FleetManager {
1228
1334
  process.kill(process.pid, "SIGUSR2");
1229
1335
  }
1230
1336
  else if (data.command === "compact") {
1231
- const target = this.routing.resolve(data.channelId);
1232
- if (!target) {
1337
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1338
+ if (!name) {
1233
1339
  await data.respond("No active agent in this channel.");
1234
1340
  return;
1235
1341
  }
1236
- const result = await this.topicCommands.sendCompact(target.name);
1342
+ const result = await this.topicCommands.sendCompact(name);
1237
1343
  await data.respond(result);
1238
1344
  }
1239
1345
  }, this.logger, `adapter[${adapterId}].slash_command`));
@@ -1451,6 +1557,34 @@ export class FleetManager {
1451
1557
  async handleInboundMessage(msg) {
1452
1558
  const threadId = msg.threadId || undefined;
1453
1559
  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");
1560
+ // Multi-adapter dedup: when several bots share a guild, each adapter fires
1561
+ // its own "message" event for the same underlying message. Process it once.
1562
+ // Routing (by topic/channel) and reply-adapter selection (by channel_id
1563
+ // binding) are adapter-independent, so it's safe to let whichever adapter
1564
+ // arrives first handle it.
1565
+ //
1566
+ // EXCEPTION — classic channels with same-channel multi-bot: two bots may own
1567
+ // separate agents in one channel, so each bot must process its OWN copy of
1568
+ // the message (the @mention filter downstream decides who actually forwards).
1569
+ // Key the dedup per-adapter there so a sibling bot's copy isn't dropped.
1570
+ if (msg.messageId) {
1571
+ const classicCid = msg.threadId || msg.chatId;
1572
+ const isClassicMsg = this.classicChannels?.hasChannel(classicCid) ?? false;
1573
+ const dedupKey = isClassicMsg
1574
+ ? `${msg.source}:${msg.chatId}:${msg.messageId}:${msg.adapterId ?? ""}`
1575
+ : `${msg.source}:${msg.chatId}:${msg.messageId}`;
1576
+ if (this.recentMessageIds.has(dedupKey)) {
1577
+ this.logger.debug({ dedupKey, adapterId: msg.adapterId }, "Duplicate inbound across adapters — skipping");
1578
+ return;
1579
+ }
1580
+ this.recentMessageIds.add(dedupKey);
1581
+ if (this.recentMessageIds.size > 1000) {
1582
+ // Set preserves insertion order — drop the oldest key.
1583
+ const oldest = this.recentMessageIds.values().next().value;
1584
+ if (oldest !== undefined)
1585
+ this.recentMessageIds.delete(oldest);
1586
+ }
1587
+ }
1454
1588
  // Bot messages: only allow in collab channels or TG classic with @mention
1455
1589
  if (msg.isBotMessage) {
1456
1590
  if (!threadId) {
@@ -1465,21 +1599,25 @@ export class FleetManager {
1465
1599
  return;
1466
1600
  // Fall through to TG classic handling below
1467
1601
  }
1602
+ else if (this.classicChannels?.hasChannel(threadId)) {
1603
+ // Classic channel (per-bot): bot messages only when THIS bot owns an
1604
+ // agent here and collab is on for it.
1605
+ const classicName = this.classicChannels.getInstanceByChannel(threadId, msg.adapterId);
1606
+ if (!classicName)
1607
+ return;
1608
+ if (!this.classicChannels.isCollab(threadId, msg.adapterId))
1609
+ return;
1610
+ // Fall through to channel handling
1611
+ }
1468
1612
  else {
1469
1613
  const target = this.routing.resolve(threadId);
1470
1614
  if (!target)
1471
1615
  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
- }
1616
+ // Fleet topic: allow if collab enabled OR access mode is open
1617
+ const channelCfg = this.getChannelConfig(msg.adapterId);
1618
+ const isOpen = channelCfg?.access?.mode === "open";
1619
+ if (!isOpen && !this.collabInstances.has(target.name))
1620
+ return;
1483
1621
  // Fall through to channel handling
1484
1622
  }
1485
1623
  }
@@ -1489,9 +1627,10 @@ export class FleetManager {
1489
1627
  const adapterGroupId = String(this.getChannelConfig(msg.adapterId)?.group_id ?? "");
1490
1628
  const isTelegramClassicCandidate = msg.source === "telegram" && msg.chatId !== adapterGroupId && !threadId;
1491
1629
  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")
1630
+ // Classic channels are open to all; check per-bot ownership (or fleet topic).
1631
+ const isClassic = !!(threadId && this.classicChannels?.hasChannel(threadId));
1632
+ this.logger.info({ userId: msg.userId, threadId, isClassic }, "Access DENIED for non-allowed user");
1633
+ if (!isClassic)
1495
1634
  return;
1496
1635
  }
1497
1636
  }
@@ -1564,9 +1703,8 @@ export class FleetManager {
1564
1703
  }
1565
1704
  }
1566
1705
  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);
1706
+ // handleClassicStart binds the instance to this adapter authoritatively.
1707
+ const reply = await this.handleClassicStart(chatId, channelName, msg.userId, undefined, msg.adapterId);
1570
1708
  await msgAdapter?.sendText(chatId, reply);
1571
1709
  return;
1572
1710
  }
@@ -1580,7 +1718,7 @@ export class FleetManager {
1580
1718
  }
1581
1719
  return;
1582
1720
  }
1583
- const reply = await this.handleClassicStop(chatId);
1721
+ const reply = await this.handleClassicStop(chatId, msg.adapterId);
1584
1722
  await msgAdapter?.sendText(chatId, reply);
1585
1723
  return;
1586
1724
  }
@@ -1590,34 +1728,34 @@ export class FleetManager {
1590
1728
  await msgAdapter?.sendText(chatId, "⛔ /compact requires admin access.");
1591
1729
  return;
1592
1730
  }
1593
- const compactTarget = this.routing.resolve(chatId);
1594
- if (!compactTarget || compactTarget.kind !== "classic") {
1731
+ const compactName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1732
+ if (!compactName) {
1595
1733
  await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1596
1734
  return;
1597
1735
  }
1598
- const result = await this.topicCommands.sendCompact(compactTarget.name);
1736
+ const result = await this.topicCommands.sendCompact(compactName);
1599
1737
  await msgAdapter?.sendText(chatId, result);
1600
1738
  return;
1601
1739
  }
1602
1740
  // Handle /cancel command
1603
1741
  if (text === "/cancel" || text.startsWith("/cancel@")) {
1604
- const cancelTarget = this.routing.resolve(chatId);
1605
- if (!cancelTarget || cancelTarget.kind !== "classic") {
1742
+ const cancelName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1743
+ if (!cancelName) {
1606
1744
  await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1607
1745
  return;
1608
1746
  }
1609
- const ok = this.cancelInstance(cancelTarget.name);
1610
- await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelTarget.name}。` : `❌ ${cancelTarget.name} 未在執行。`);
1747
+ const ok = this.cancelInstance(cancelName);
1748
+ await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelName}。` : `❌ ${cancelName} 未在執行。`);
1611
1749
  return;
1612
1750
  }
1613
1751
  // Handle /ctx command
1614
1752
  if (text === "/ctx" || text.startsWith("/ctx@")) {
1615
- const ctxTarget = this.routing.resolve(chatId);
1616
- if (!ctxTarget || ctxTarget.kind !== "classic") {
1753
+ const ctxName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1754
+ if (!ctxName) {
1617
1755
  await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1618
1756
  return;
1619
1757
  }
1620
- const reply = await this.topicCommands.getCtxText(ctxTarget.name);
1758
+ const reply = await this.topicCommands.getCtxText(ctxName);
1621
1759
  await msgAdapter?.sendText(chatId, reply);
1622
1760
  return;
1623
1761
  }
@@ -1627,8 +1765,8 @@ export class FleetManager {
1627
1765
  await msgAdapter?.sendText(chatId, "⛔ /save requires admin access.");
1628
1766
  return;
1629
1767
  }
1630
- const saveTarget = this.routing.resolve(chatId);
1631
- if (!saveTarget || saveTarget.kind !== "classic") {
1768
+ const saveName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1769
+ if (!saveName) {
1632
1770
  await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1633
1771
  return;
1634
1772
  }
@@ -1641,26 +1779,26 @@ export class FleetManager {
1641
1779
  await msgAdapter?.sendText(chatId, "⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1642
1780
  return;
1643
1781
  }
1644
- const backend = this.classicChannels.getBackendByInstance(saveTarget.name, this.fleetConfig?.defaults?.backend);
1782
+ const backend = this.classicChannels.getBackendByInstance(saveName, this.fleetConfig?.defaults?.backend);
1645
1783
  const cmd = saveCommandForBackend(backend, filename);
1646
1784
  if (!cmd) {
1647
1785
  await msgAdapter?.sendText(chatId, SAVE_UNSUPPORTED_MSG);
1648
1786
  return;
1649
1787
  }
1650
- this.pasteRawToClassicInstance(saveTarget.name, cmd);
1651
- await msgAdapter?.sendText(chatId, `✅ Sent \`${cmd}\` to ${saveTarget.name}`);
1788
+ this.pasteRawToClassicInstance(saveName, cmd);
1789
+ await msgAdapter?.sendText(chatId, `✅ Sent \`${cmd}\` to ${saveName}`);
1652
1790
  return;
1653
1791
  }
1654
- // Route to classic channel if registered
1655
- const target = this.routing.resolve(chatId);
1656
- if (target?.kind === "classic") {
1792
+ // Route to classic channel if this bot has an agent here (per-bot).
1793
+ const classicName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1794
+ if (classicName) {
1657
1795
  if (msg.adapterId)
1658
- this.bindInstanceAdapter(target.name, msg.adapterId, true);
1796
+ this.bindInstanceAdapter(classicName, msg.adapterId, true);
1659
1797
  // TG ClassicBot: group requires @mention, private chat forwards directly.
1660
1798
  if (!isPrivateChat && !isBotMentioned) {
1661
1799
  // No trigger: save attachments + react, log, but don't forward to agent
1662
1800
  const syntheticMsg = { ...msg, threadId: chatId, text: rawText.startsWith("/") ? "" : rawText };
1663
- await this.handleClassicChannelMessage(target.name, syntheticMsg);
1801
+ await this.handleClassicChannelMessage(classicName, syntheticMsg);
1664
1802
  return;
1665
1803
  }
1666
1804
  // Strip @bot from text and forward as /chat
@@ -1670,7 +1808,7 @@ export class FleetManager {
1670
1808
  return;
1671
1809
  }
1672
1810
  const syntheticMsg = { ...msg, threadId: chatId, text: `/chat ${cleanText}` };
1673
- await this.handleClassicChannelMessage(target.name, syntheticMsg);
1811
+ await this.handleClassicChannelMessage(classicName, syntheticMsg);
1674
1812
  return;
1675
1813
  }
1676
1814
  // Handle @bot without active agent
@@ -1695,9 +1833,12 @@ export class FleetManager {
1695
1833
  if (msg.adapterId)
1696
1834
  this.bindInstanceAdapter(generalInstance, msg.adapterId, true);
1697
1835
  const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1698
- // React immediately — before any other API calls
1836
+ // React immediately — before any other API calls. Use the adapter BOUND to
1837
+ // the instance (not whichever same-guild bot received the event first) so
1838
+ // exactly the owning bot reacts — no duplicate 👀 from a sibling bot.
1699
1839
  if (msg.chatId && msg.messageId) {
1700
- inboundAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
1840
+ const reactAdapter = this.getAdapterForInstance(generalInstance) ?? inboundAdapter;
1841
+ reactAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
1701
1842
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
1702
1843
  }
1703
1844
  this.warnIfRateLimited(generalInstance, msg);
@@ -1733,6 +1874,18 @@ export class FleetManager {
1733
1874
  }
1734
1875
  return;
1735
1876
  }
1877
+ // Classic channels resolve per-bot (same-channel multi-bot) — a channel can
1878
+ // host two bots' agents. If this channel is classic but THIS bot has no
1879
+ // agent here, a sibling bot owns it; skip rather than misroute to it.
1880
+ if (this.classicChannels?.hasChannel(threadId)) {
1881
+ const classicName = this.classicChannels.getInstanceByChannel(threadId, msg.adapterId);
1882
+ if (!classicName)
1883
+ return;
1884
+ if (msg.adapterId)
1885
+ this.bindInstanceAdapter(classicName, msg.adapterId, true);
1886
+ await this.handleClassicChannelMessage(classicName, msg);
1887
+ return;
1888
+ }
1736
1889
  const target = this.routing.resolve(threadId);
1737
1890
  if (!target) {
1738
1891
  // Only show unbound message for actual forum topics (same group, has threadId)
@@ -1764,9 +1917,12 @@ export class FleetManager {
1764
1917
  if (msg.adapterId)
1765
1918
  this.bindInstanceAdapter(instanceName, msg.adapterId, true);
1766
1919
  const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1767
- // React immediately — before any other Discord API calls
1920
+ // React immediately — before any other Discord API calls. Use the adapter
1921
+ // BOUND to the instance (not whichever same-guild bot received the event
1922
+ // first) so exactly the owning bot reacts — no duplicate 👀 from a sibling.
1768
1923
  if (msg.chatId && msg.messageId) {
1769
- inboundAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
1924
+ const reactAdapter = this.getAdapterForInstance(instanceName) ?? inboundAdapter;
1925
+ reactAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
1770
1926
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
1771
1927
  }
1772
1928
  // These may hit Discord API (topic icon, archive) — do after react
@@ -1891,7 +2047,7 @@ export class FleetManager {
1891
2047
  ts: new Date().toISOString(),
1892
2048
  });
1893
2049
  // Log bot reply to classic instance chat-log
1894
- const isClassic = [...this.routing.entries()].some(([, t]) => t.kind === "classic" && t.name === instanceName);
2050
+ const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
1895
2051
  if (isClassic) {
1896
2052
  ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
1897
2053
  }
@@ -2510,15 +2666,17 @@ export class FleetManager {
2510
2666
  startStatuslineWatcher(name) {
2511
2667
  this.statuslineWatcher.watch(name);
2512
2668
  }
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
- }
2669
+ reactMessageStatus(instanceName, chatId, messageId, emoji) {
2670
+ // React via the adapter BOUND to this instance NOT the first discord world.
2671
+ // Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
2672
+ // (bound bot) and the delivery/confirm reactions (some other bot) come from
2673
+ // different bots, leaving a duplicate 👀 that never turns into ✅.
2674
+ const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
2675
+ // Status reactions are Discord-only (TG/others use the inbound react path).
2676
+ if (!adapter || adapter.type !== "discord")
2677
+ return;
2678
+ adapter.react(chatId, messageId, emoji)
2679
+ .catch(e => this.logger.debug({ err: e.message }, "Message status react failed"));
2522
2680
  }
2523
2681
  // ── Model failover ──────────────────────────────────────────────────────
2524
2682
  static FAILOVER_TRIGGER_PCT = 90;
@@ -2580,13 +2738,12 @@ export class FleetManager {
2580
2738
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send instance topic notification"));
2581
2739
  return;
2582
2740
  }
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
- }
2741
+ // Classic instance: find its channelId from the classic manager
2742
+ const classicChatId = this.classicChannels?.getChannelIdByInstance(instanceName);
2743
+ if (classicChatId) {
2744
+ adapter.sendText(classicChatId, text)
2745
+ .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send classic notification"));
2746
+ return;
2590
2747
  }
2591
2748
  // Fallback: send to group without threadId
2592
2749
  if (groupId) {
@@ -2603,12 +2760,16 @@ export class FleetManager {
2603
2760
  * Picks the backend-appropriate command (kiro → /chat save, claude → /export);
2604
2761
  * unsupported backends get a clear error. Routes via classic paste or fleet IPC.
2605
2762
  */
2606
- async handleSlashSave(data) {
2763
+ async handleSlashSave(data, adapterId) {
2607
2764
  if (!this.classicChannels?.isAdmin(data.userId)) {
2608
2765
  await data.respond("⛔ This command requires admin access.");
2609
2766
  return;
2610
2767
  }
2611
- const target = this.routing.resolve(data.channelId);
2768
+ // Classic resolves per-bot (same-channel multi-bot); otherwise a fleet topic.
2769
+ const classicName = this.classicChannels.getInstanceByChannel(data.channelId, adapterId);
2770
+ const target = classicName
2771
+ ? { kind: "classic", name: classicName }
2772
+ : this.routing.resolve(data.channelId);
2612
2773
  if (!target) {
2613
2774
  await data.respond("No active agent in this channel. Use `/start` first.");
2614
2775
  return;
@@ -2663,13 +2824,8 @@ export class FleetManager {
2663
2824
  threadId = String(topicId);
2664
2825
  }
2665
2826
  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
- }
2827
+ // Classic instance: channelId from the classic manager.
2828
+ chatId = this.classicChannels?.getChannelIdByInstance(instanceName);
2673
2829
  // General / flat fallback: post to the group (no thread).
2674
2830
  if (!chatId && groupId)
2675
2831
  chatId = String(groupId);
@@ -3152,7 +3308,7 @@ When users create specialized instances, suggest these configurations:
3152
3308
  async handleClassicChannelMessage(instanceName, msg) {
3153
3309
  const text = msg.text ?? "";
3154
3310
  const channelId = msg.threadId ?? msg.chatId;
3155
- const isCollabMode = this.classicChannels?.isCollab(channelId) ?? false;
3311
+ const isCollabMode = this.classicChannels?.isCollab(channelId, msg.adapterId) ?? false;
3156
3312
  // Handle /ctx in classic mode — always, regardless of collab mode
3157
3313
  if (text === "/ctx" || text.startsWith("/ctx@")) {
3158
3314
  const reply = await this.topicCommands.getCtxText(instanceName);
@@ -3179,8 +3335,15 @@ When users create specialized instances, suggest these configurations:
3179
3335
  : "");
3180
3336
  ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText);
3181
3337
  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;
3338
+ // Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here.
3339
+ // Each bot matches ONLY its own id. A secondary bot must NOT fall back to the
3340
+ // process-wide botUserId (the primary's) — otherwise, in a same-channel
3341
+ // multi-bot setup, an @mention of the primary would also match the secondary
3342
+ // and BOTH bots would react 👀 and forward. Only the primary adapter may use
3343
+ // the fallback.
3344
+ const mentionWorld = this.worlds.get(msg.adapterId ?? "");
3345
+ const isPrimaryAdapter = !mentionWorld || mentionWorld.adapter === this.adapter;
3346
+ const adapterBotUserId = mentionWorld?.botUserId ?? (isPrimaryAdapter ? this.botUserId : undefined);
3184
3347
  const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
3185
3348
  const isMentioned = mentionTag && text.includes(mentionTag);
3186
3349
  if (!isMentioned) {
@@ -3358,7 +3521,11 @@ When users create specialized instances, suggest these configurations:
3358
3521
  }
3359
3522
  /** Forward a message to a classic channel instance with chat log context */
3360
3523
  async forwardToClassicInstance(instanceName, text, msg, extraMeta) {
3361
- const contextLines = this.classicChannels?.getContextLines(msg.chatId) ?? 5;
3524
+ // Resolve the channel/adapter from the instance itself so per-channel context
3525
+ // config is correct even for a same-channel second bot.
3526
+ const ctxAdapterId = this.classicChannels?.getAdapterIdByInstance(instanceName);
3527
+ const ctxChannelId = this.classicChannels?.getChannelIdByInstance(instanceName) ?? msg.chatId;
3528
+ const contextLines = this.classicChannels?.getContextLines(ctxChannelId, ctxAdapterId) ?? 5;
3362
3529
  const logContext = this.getRecentChatLog(instanceName, contextLines);
3363
3530
  const fullText = logContext
3364
3531
  ? `[Chat log for context]\n${logContext}\n\n[User message]\n${text}`
@@ -3444,7 +3611,7 @@ When users create specialized instances, suggest these configurations:
3444
3611
  await this.startInstance(instanceName, config, topicMode);
3445
3612
  }
3446
3613
  /** Handle /start slash command — register classic channel */
3447
- async handleClassicStart(channelId, channelName, userId, guildId) {
3614
+ async handleClassicStart(channelId, channelName, userId, guildId, adapterId) {
3448
3615
  if (!this.classicChannels)
3449
3616
  return "Classic channel manager not initialized.";
3450
3617
  if (guildId && !this.classicChannels.isGuildAllowed(guildId)) {
@@ -3454,33 +3621,40 @@ When users create specialized instances, suggest these configurations:
3454
3621
  }
3455
3622
  return "⛔ This server is not in the allowed guilds list.";
3456
3623
  }
3457
- if (this.classicChannels.isClassicChannel(channelId))
3624
+ // Per-bot check: a second bot may /start in the same channel (own agent).
3625
+ if (this.classicChannels.isClassicChannel(channelId, adapterId))
3458
3626
  return "This channel already has an active agent. Use /chat to talk.";
3627
+ // Classic no longer lives in the routing engine, so this only guards against
3628
+ // a fleet topic-mode instance colliding with the channel.
3459
3629
  if (this.routing.resolve(channelId))
3460
3630
  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));
3631
+ const instanceName = this.classicChannels.deriveInstanceName(channelName || channelId, channelId, adapterId);
3632
+ this.classicChannels.register(channelId, adapterId, instanceName, channelName || channelId, userId);
3633
+ // Bind this classic instance to the bot that started it (authoritative), so
3634
+ // replies/cancel go out through that bot even though every same-guild bot
3635
+ // also sees the channel's messages.
3636
+ if (adapterId)
3637
+ this.bindInstanceAdapter(instanceName, adapterId);
3638
+ 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
3639
  this.reregisterClassicChannels();
3466
3640
  // 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);
3641
+ if (guildId && !this.classicChannels.isCollab(channelId, adapterId)) {
3642
+ this.classicChannels.toggleCollab(channelId, adapterId);
3469
3643
  }
3470
- this.logger.info({ channelId, instanceName, userId }, "Classic channel started");
3644
+ this.logger.info({ channelId, adapterId, instanceName, userId }, "Classic channel started");
3471
3645
  return `✅ Agent started in this channel. Use \`/chat <message>\` or @mention to talk.`;
3472
3646
  }
3473
3647
  /** Handle /stop slash command — unregister classic channel */
3474
- async handleClassicStop(channelId) {
3648
+ async handleClassicStop(channelId, adapterId) {
3475
3649
  if (!this.classicChannels)
3476
3650
  return "Classic channel manager not initialized.";
3477
- const ch = this.classicChannels.unregister(channelId);
3651
+ const ch = this.classicChannels.unregister(channelId, adapterId);
3478
3652
  if (!ch)
3479
3653
  return "No active agent in this channel.";
3480
- this.routing.unregister(channelId);
3654
+ this.instanceWorldBinding.delete(ch.instanceName);
3481
3655
  await this.stopInstance(ch.instanceName).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance"));
3482
3656
  this.reregisterClassicChannels();
3483
- this.logger.info({ channelId, instanceName: ch.instanceName }, "Classic channel stopped");
3657
+ this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
3484
3658
  return `🛑 Agent stopped in this channel.`;
3485
3659
  }
3486
3660
  async stopAll() {
@@ -3955,6 +4129,15 @@ When users create specialized instances, suggest these configurations:
3955
4129
  catch {
3956
4130
  // best-effort
3957
4131
  }
4132
+ // Separate read-only token for the /view page: grants terminal-view + profile
4133
+ // read, but never write (POSTs still require the full web token).
4134
+ this.viewToken = randomBytes(24).toString("hex");
4135
+ const viewTokenPath = join(this.dataDir, "view.token");
4136
+ writeFileSync(viewTokenPath, this.viewToken, { mode: 0o600 });
4137
+ try {
4138
+ chmodSync(viewTokenPath, 0o600);
4139
+ }
4140
+ catch { /* best-effort */ }
3958
4141
  this.healthServer = createServer((req, res) => {
3959
4142
  res.setHeader("Content-Type", "application/json");
3960
4143
  // Public health probe — no auth required.
@@ -3964,6 +4147,10 @@ When users create specialized instances, suggest these configurations:
3964
4147
  else if (req.method === "POST" && req.url === "/agent") {
3965
4148
  // /agent handles its own instance-level auth via X-Agend-Instance-Token
3966
4149
  }
4150
+ else if (isViewPath(new URL(req.url ?? "/", `http://localhost:${port}`).pathname)) {
4151
+ // /view routes accept the read-only view.token (or web.token) and do
4152
+ // their own per-method auth in view-api.ts — skip the web-token gate.
4153
+ }
3967
4154
  else {
3968
4155
  // All other endpoints require a valid token (query ?token= or X-Agend-Token header).
3969
4156
  // /ui/* will also re-check in web-api.ts, which is harmless.
@@ -4147,6 +4334,10 @@ When users create specialized instances, suggest these configurations:
4147
4334
  }
4148
4335
  // ── Web UI endpoints (delegated to web-api.ts) ─────
4149
4336
  const url = new URL(req.url ?? "/", `http://localhost:${port}`);
4337
+ if (handleViewRequest(req, res, url, this))
4338
+ return;
4339
+ if (handleSettingsRequest(req, res, url, this))
4340
+ return;
4150
4341
  if (handleWebRequest(req, res, url, this))
4151
4342
  return;
4152
4343
  res.writeHead(404);
@@ -4188,6 +4379,7 @@ When users create specialized instances, suggest these configurations:
4188
4379
  this.logger.info({ port }, "Health endpoint listening");
4189
4380
  });
4190
4381
  this.logger.info({ url: `http://localhost:${port}/ui?token=${this.webToken}` }, "Web UI available");
4382
+ this.logger.info({ url: `http://localhost:${port}/view?token=${this.viewToken}` }, "Web View available");
4191
4383
  }
4192
4384
  getUiStatus() {
4193
4385
  const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => {