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

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 (52) hide show
  1. package/dist/channel/adapters/discord.d.ts +2 -0
  2. package/dist/channel/adapters/discord.js +40 -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 +29 -5
  20. package/dist/fleet-manager.js +451 -233
  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/locale.d.ts +19 -0
  26. package/dist/locale.js +137 -0
  27. package/dist/locale.js.map +1 -0
  28. package/dist/outbound-schemas.d.ts +1 -0
  29. package/dist/outbound-schemas.js +1 -0
  30. package/dist/outbound-schemas.js.map +1 -1
  31. package/dist/quickstart.js +252 -16
  32. package/dist/quickstart.js.map +1 -1
  33. package/dist/scheduler/scheduler.d.ts +1 -1
  34. package/dist/scheduler/scheduler.js +15 -4
  35. package/dist/scheduler/scheduler.js.map +1 -1
  36. package/dist/service-installer.d.ts +1 -0
  37. package/dist/service-installer.js +2 -1
  38. package/dist/service-installer.js.map +1 -1
  39. package/dist/settings-api.d.ts +63 -0
  40. package/dist/settings-api.js +331 -0
  41. package/dist/settings-api.js.map +1 -0
  42. package/dist/topic-commands.d.ts +14 -0
  43. package/dist/topic-commands.js +78 -16
  44. package/dist/topic-commands.js.map +1 -1
  45. package/dist/types.d.ts +2 -0
  46. package/dist/ui/settings.html +612 -0
  47. package/dist/ui/view.html +528 -0
  48. package/dist/view-api.d.ts +29 -0
  49. package/dist/view-api.js +370 -0
  50. package/dist/view-api.js.map +1 -0
  51. package/package.json +1 -1
  52. 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,11 @@ 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";
40
+ import { setLocale, detectLocale, t } from "./locale.js";
36
41
  import { handleAgentRequest } from "./agent-endpoint.js";
37
- import { ClassicChannelManager, classicInstanceName } from "./classic-channel-manager.js";
42
+ import { ClassicChannelManager } from "./classic-channel-manager.js";
38
43
  import { getTmuxSession } from "./config.js";
39
44
  export function resolveReplyThreadId(argsThreadId, instanceConfig) {
40
45
  if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
@@ -65,6 +70,9 @@ export class FleetManager {
65
70
  adapters = new Map(); // derived view for backward compat
66
71
  /** Track which world each instance is bound to */
67
72
  instanceWorldBinding = new Map();
73
+ // Dedup inbound messages seen by more than one adapter (e.g. two DC bots in the
74
+ // same guild both receive every message). Bounded FIFO of recent message keys.
75
+ recentMessageIds = new Set();
68
76
  accessManager = null;
69
77
  /** Primary world (first adapter) — used for fleet-level notifications */
70
78
  get primaryWorld() { return this.worlds.values().next().value; }
@@ -115,6 +123,7 @@ export class FleetManager {
115
123
  // Web UI: SSE clients + auth token
116
124
  sseClients = new Set();
117
125
  webToken = null;
126
+ viewToken = null;
118
127
  constructor(dataDir) {
119
128
  this.dataDir = dataDir;
120
129
  this.lifecycle = new InstanceLifecycle(this);
@@ -174,27 +183,51 @@ export class FleetManager {
174
183
  }
175
184
  return this.routing.map;
176
185
  }
177
- /** Re-register classic channels after routing rebuild (rebuild clears the table) */
186
+ /**
187
+ * Refresh each adapter's open-channel whitelist after a classic change.
188
+ * Classic channels are NOT registered in the routing engine (it's single-key
189
+ * per channel — can't represent two bots in one channel); routing resolves
190
+ * per-bot via ClassicChannelManager.getInstanceByChannel. Each adapter only
191
+ * opens the channels IT owns so a sibling bot doesn't process another's cross-
192
+ * guild channel.
193
+ */
178
194
  reregisterClassicChannels() {
179
195
  if (!this.classicChannels)
180
196
  return;
181
197
  const channels = this.classicChannels.getAll();
182
- for (const ch of channels) {
183
- this.routing.register(ch.channelId, { kind: "classic", name: ch.instanceName });
184
- }
185
198
  // Always update adapter openChannels (including empty — clears stale entries on /stop)
186
- for (const [, w] of this.worlds) {
199
+ for (const [adapterId, w] of this.worlds) {
187
200
  if (typeof w.adapter?.setOpenChannels === "function") {
188
- w.adapter.setOpenChannels(channels.map(ch => ch.channelId));
201
+ const owned = channels.filter(ch => ch.adapterId === adapterId).map(ch => ch.channelId);
202
+ w.adapter.setOpenChannels(owned);
189
203
  }
190
204
  }
191
205
  if (channels.length > 0) {
192
- this.logger.info({ count: channels.length }, "Registered classic channel routes");
206
+ this.logger.info({ count: channels.length }, "Refreshed classic channel open-lists");
193
207
  }
194
208
  }
195
209
  getInstanceDir(name) {
196
210
  return join(this.dataDir, "instances", name);
197
211
  }
212
+ /** AgEnD package version (for the Settings "current version" / What's New). */
213
+ get currentVersion() {
214
+ try {
215
+ return JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8")).version ?? "unknown";
216
+ }
217
+ catch {
218
+ return "unknown";
219
+ }
220
+ }
221
+ /**
222
+ * Resolve a slash-command target in a channel. Classic channels are looked up
223
+ * per-bot (same-channel multi-bot); a fleet-topic instance is found via the
224
+ * routing engine. Used by commands that work in BOTH contexts (/ctx, /compact,
225
+ * /cancel). Classic-only commands (/chat, /load) must NOT use this.
226
+ */
227
+ resolveSlashTarget(channelId, adapterId) {
228
+ return this.classicChannels?.getInstanceByChannel(channelId, adapterId)
229
+ ?? this.routing.resolve(channelId)?.name;
230
+ }
198
231
  /** Get the adapter bound to an instance, falling back to primary adapter */
199
232
  getAdapterForInstance(name) {
200
233
  const worldId = this.instanceWorldBinding.get(name);
@@ -221,9 +254,22 @@ export class FleetManager {
221
254
  const world = this.getWorldForInstance(name);
222
255
  return world?.groupId ?? String(this.fleetConfig?.channel?.group_id ?? "");
223
256
  }
224
- /** Bind an instance to a specific world. fromInbound=true skips general_topic to prevent overwrite. */
257
+ /**
258
+ * Bind an instance to a specific world (the bot that answers for it).
259
+ * fromInbound=true (binding inferred from which adapter received a message)
260
+ * must not override a configured identity: skip when the instance is a general
261
+ * or has an explicit channel_id — otherwise a persona instance whose message
262
+ * was also seen by the main bot would get rebound to the wrong bot.
263
+ */
225
264
  bindInstanceAdapter(name, adapterId, fromInbound = false) {
226
- if (fromInbound && this.fleetConfig?.instances[name]?.general_topic)
265
+ const cfg = this.fleetConfig?.instances[name];
266
+ if (fromInbound && (cfg?.general_topic || cfg?.channel_id))
267
+ return;
268
+ // A classic instance is bound authoritatively at /start. Don't let an inbound
269
+ // (seen by every same-guild bot) override an existing binding — but if there
270
+ // is none yet (e.g. after a restart, before v2.1 persistence), allow inbound
271
+ // to re-establish it so replies don't fall back to the primary bot forever.
272
+ if (fromInbound && this.classicChannels?.getChannelIdByInstance(name) !== undefined && this.instanceWorldBinding.has(name))
227
273
  return;
228
274
  this.instanceWorldBinding.set(name, adapterId);
229
275
  }
@@ -352,6 +398,7 @@ export class FleetManager {
352
398
  const { rotateLogIfNeeded } = await import("./logger.js");
353
399
  rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
354
400
  const fleet = this.loadConfig(configPath);
401
+ setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
355
402
  const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
356
403
  // Set tmux socket isolation for custom AGEND_HOME
357
404
  const { getTmuxSocketName: getSocket } = await import("./paths.js");
@@ -393,10 +440,19 @@ export class FleetManager {
393
440
  const pidPath = join(this.dataDir, "fleet.pid");
394
441
  writeFileSync(pidPath, String(process.pid), "utf-8");
395
442
  this.eventLog = new EventLog(join(this.dataDir, "events.db"));
396
- // Initialize classic channel manager and register existing channels in routing
443
+ // Initialize classic channel manager. The primary adapter (channels[0])
444
+ // migrates legacy single-bot entries and names without a suffix. Classic
445
+ // routing does NOT go through the routing engine (single-key, can't hold two
446
+ // bots in one channel) — it resolves per-bot via getInstanceByChannel.
397
447
  this.classicChannels = new ClassicChannelManager(this.dataDir, this.logger);
448
+ const primaryCh = fleet.channels?.[0] ?? fleet.channel;
449
+ if (primaryCh)
450
+ this.classicChannels.setPrimaryAdapterId(primaryCh.id ?? primaryCh.type);
451
+ // Restore the persisted bot binding so replies/cancel go through the right
452
+ // bot after a restart (before this, inbound would re-bind lazily).
398
453
  for (const ch of this.classicChannels.getAll()) {
399
- this.routing.register(ch.channelId, { kind: "classic", name: ch.instanceName });
454
+ if (ch.adapterId)
455
+ this.instanceWorldBinding.set(ch.instanceName, ch.adapterId);
400
456
  }
401
457
  // Poll classicBot.yaml for external changes every 30s
402
458
  this.classicReloadTimer = setInterval(async () => {
@@ -474,8 +530,9 @@ export class FleetManager {
474
530
  // Rotate classic channel chat logs daily (piggyback on daily summary timer)
475
531
  this.classicChannels?.rotateLogs();
476
532
  this.rotateInboxes();
477
- // Auto-create general instance(s)one per adapter that lacks a general
533
+ // Auto-create/adopt a general dispatcherONLY for the primary adapter.
478
534
  const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
535
+ const primaryAdapterId = channelConfigs[0] ? (channelConfigs[0].id ?? channelConfigs[0].type) : undefined;
479
536
  const generalInstances = Object.entries(fleet.instances).filter(([, inst]) => inst.general_topic === true);
480
537
  let generalsCreated = false;
481
538
  // Collect unbound generals (no channel_id set) for auto-assignment
@@ -484,6 +541,14 @@ export class FleetManager {
484
541
  const needsGeneral = [];
485
542
  for (const ch of channelConfigs) {
486
543
  const adapterId = ch.id ?? ch.type;
544
+ // Only the primary adapter gets an auto-general. Secondary (persona) bots
545
+ // answer for their explicitly-bound instances only — they don't need or
546
+ // auto-claim a general dispatcher, and must never adopt the primary's
547
+ // unbound general. A general a user manually bound to a secondary
548
+ // (channel_id: <persona>) is left untouched — the auto logic just won't
549
+ // create or reassign bindings for non-primary adapters.
550
+ if (adapterId !== primaryAdapterId)
551
+ continue;
487
552
  // Check if any general is explicitly bound to this adapter
488
553
  if (generalInstances.some(([, inst]) => inst.channel_id === adapterId))
489
554
  continue;
@@ -606,12 +671,19 @@ export class FleetManager {
606
671
  catch (err) {
607
672
  this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
608
673
  }
609
- // Pre-bind general instances to their corresponding adapter
674
+ // Bind instances to their adapter (which bot answers on their behalf).
675
+ // An explicit channel_id is authoritative — this is how a persona instance
676
+ // picks its bot when several share one guild. Generals without a channel_id
677
+ // fall back to a name-contains-adapterId heuristic.
678
+ const channelConfigsForBind = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
610
679
  for (const [name, config] of Object.entries(fleet.instances)) {
680
+ if (config.channel_id) {
681
+ this.bindInstanceAdapter(name, config.channel_id);
682
+ continue;
683
+ }
611
684
  if (!config.general_topic)
612
685
  continue;
613
- const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
614
- for (const ch of channelConfigs) {
686
+ for (const ch of channelConfigsForBind) {
615
687
  const id = ch.id ?? ch.type;
616
688
  if (name.includes(id)) {
617
689
  this.bindInstanceAdapter(name, id);
@@ -619,6 +691,23 @@ export class FleetManager {
619
691
  }
620
692
  }
621
693
  }
694
+ // Guard against a stale/invalid general topic_id. An old auto-general
695
+ // could have written the TG-convention "1" for a Discord general; the DC
696
+ // adapter then throws fetching channel "1" → unhandled → fleet crash loop.
697
+ // Unbind (+ warn) so it's simply skipped, never routed to a bogus channel.
698
+ let fixedGeneral = false;
699
+ for (const [name, cfg] of Object.entries(this.fleetConfig.instances)) {
700
+ if (!cfg.general_topic || cfg.topic_id == null)
701
+ continue;
702
+ const adapterId = this.instanceWorldBinding.get(name) ?? cfg.channel_id;
703
+ if (this.getChannelConfig(adapterId)?.type === "discord" && !/^\d{17,}$/.test(String(cfg.topic_id))) {
704
+ this.logger.warn({ name, topic_id: cfg.topic_id }, "Discord general topic_id is not a valid channel — unbinding to avoid a crash loop");
705
+ delete cfg.topic_id;
706
+ fixedGeneral = true;
707
+ }
708
+ }
709
+ if (fixedGeneral)
710
+ this.saveFleetConfig();
622
711
  // Auto-create topics AFTER adapter is ready (needs adapter.createTopic)
623
712
  await this.topicCommands.autoCreateTopics();
624
713
  const routeSummary = this.routing.rebuild(this.fleetConfig);
@@ -741,9 +830,13 @@ export class FleetManager {
741
830
  const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
742
831
  if (channelConfigs.length === 0)
743
832
  return;
744
- // Start primary adapter (first channel) — this.adapter for backward compat
833
+ // Start primary adapter (first channel) — this.adapter for backward compat.
745
834
  await this.startSingleAdapter(fleet, channelConfigs[0]);
746
- // Start additional adapters
835
+ // Start additional adapters. Every bot registers its own slash commands —
836
+ // Discord slash commands are per-application, so a same-guild secondary bot's
837
+ // /start etc. are distinct entries (labelled with the bot name) and the
838
+ // interaction only reaches the invoked bot. This is how ClassicBot supports
839
+ // multiple bots in one guild: the user picks which bot from autocomplete.
747
840
  for (let i = 1; i < channelConfigs.length; i++) {
748
841
  await this.startAdditionalAdapter(channelConfigs[i]);
749
842
  }
@@ -757,7 +850,7 @@ export class FleetManager {
757
850
  }
758
851
  const accessDir = join(this.dataDir, "access");
759
852
  mkdirSync(accessDir, { recursive: true });
760
- const accessManager = new AccessManager(channelConfig.access, join(accessDir, "access.json"));
853
+ const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, join(accessDir, "access.json"));
761
854
  this.accessManager = accessManager;
762
855
  const inboxDir = join(this.dataDir, "inbox");
763
856
  mkdirSync(inboxDir, { recursive: true });
@@ -813,28 +906,28 @@ export class FleetManager {
813
906
  // Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
814
907
  this.adapter.on("slash_command", safeHandler(async (data) => {
815
908
  if (data.command === "start") {
816
- const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId);
909
+ const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId, adapterId);
817
910
  await data.respond(reply);
818
911
  }
819
912
  else if (data.command === "stop") {
820
- const reply = await this.handleClassicStop(data.channelId);
913
+ const reply = await this.handleClassicStop(data.channelId, adapterId);
821
914
  await data.respond(reply);
822
915
  }
823
916
  else if (data.command === "chat") {
824
917
  const text = data.text ?? "";
825
918
  if (!text) {
826
- await data.respond("Usage: `/chat <message>`");
919
+ await data.respond(t("chat.usage"));
827
920
  return;
828
921
  }
829
- const target = this.routing.resolve(data.channelId);
830
- if (!target || target.kind !== "classic") {
831
- await data.respond("No active agent in this channel. Use `/start` first.");
922
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
923
+ if (!name) {
924
+ await data.respond(t("classic.no_agent_start"));
832
925
  return;
833
926
  }
834
927
  const replyMsgId = await data.respond("👀");
835
928
  const username = data.username ?? data.userId;
836
- ClassicChannelManager.logMessage(target.name, username, `/chat ${text}`, new Date());
837
- await this.forwardToClassicInstance(target.name, text, {
929
+ ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
930
+ await this.forwardToClassicInstance(name, text, {
838
931
  chatId: data.channelId,
839
932
  threadId: data.channelId,
840
933
  messageId: replyMsgId ?? "",
@@ -845,86 +938,88 @@ export class FleetManager {
845
938
  });
846
939
  }
847
940
  else if (data.command === "save") {
848
- await this.handleSlashSave(data);
941
+ await this.handleSlashSave(data, adapterId);
849
942
  }
850
943
  else if (data.command === "load") {
851
944
  // load is kiro-cli/classic only — no claude-code equivalent.
852
945
  if (!this.classicChannels?.isAdmin(data.userId)) {
853
- await data.respond("⛔ This command requires admin access.");
946
+ await data.respond(t("admin.required"));
854
947
  return;
855
948
  }
856
- const target = this.routing.resolve(data.channelId);
857
- if (!target || target.kind !== "classic") {
858
- await data.respond("No active agent in this channel. Use `/start` first.");
949
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
950
+ if (!name) {
951
+ await data.respond(t("classic.no_agent_start"));
859
952
  return;
860
953
  }
861
954
  const filename = data.options?.filename;
862
955
  if (!SAVE_FILENAME_RE.test(filename ?? "")) {
863
- await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
956
+ await data.respond(t("filename.invalid"));
864
957
  return;
865
958
  }
866
- this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
867
- await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
959
+ this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
960
+ await data.respond(t("save.sent", `/chat load ${filename}`, name));
868
961
  }
869
962
  else if (data.command === "compact") {
870
- const target = this.routing.resolve(data.channelId);
871
- if (!target) {
872
- await data.respond("No active agent in this channel.");
963
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
964
+ if (!name) {
965
+ await data.respond(t("classic.no_agent"));
873
966
  return;
874
967
  }
875
- const result = await this.topicCommands.sendCompact(target.name);
968
+ const result = await this.topicCommands.sendCompact(name);
876
969
  await data.respond(result);
877
970
  }
878
971
  else if (data.command === "cancel") {
879
- const target = this.routing.resolve(data.channelId);
880
- if (!target) {
881
- await data.respond("No active agent in this channel.");
972
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
973
+ if (!name) {
974
+ await data.respond(t("classic.no_agent"));
882
975
  return;
883
976
  }
884
- const ok = this.cancelInstance(target.name);
885
- await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
977
+ const ok = this.cancelInstance(name);
978
+ await data.respond(ok ? t("cancel.sent", name) : t("cancel.not_running", name));
886
979
  }
887
980
  else if (data.command === "ctx") {
888
- const target = this.routing.resolve(data.channelId);
889
- if (!target) {
890
- await data.respond("No active agent in this channel.");
981
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
982
+ if (!name) {
983
+ await data.respond(t("classic.no_agent"));
891
984
  return;
892
985
  }
893
986
  // Single source of truth (statusline.json + robust tmux pane fallback).
894
- await data.respond(await this.topicCommands.getCtxText(target.name));
987
+ await data.respond(await this.topicCommands.getCtxText(name));
895
988
  }
896
989
  else if (data.command === "collab") {
990
+ // Classic no longer lives in the routing engine, so a routing hit here is
991
+ // always a fleet-topic instance.
897
992
  const collabTarget = this.routing.resolve(data.channelId);
898
- if (collabTarget && collabTarget.kind !== "classic") {
993
+ if (collabTarget) {
899
994
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
900
995
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
901
- await data.respond("⛔ Not authorized");
996
+ await data.respond(t("not_authorized"));
902
997
  return;
903
998
  }
904
999
  const isCollab = this.toggleFleetCollab(collabTarget.name);
905
- await data.respond(isCollab ? "🤝 Collaboration mode **ON** — bot/webhook messages reach the agent." : "💬 Collaboration mode **OFF**");
1000
+ await data.respond(isCollab ? t("collab.on") : t("collab.off"));
906
1001
  return;
907
1002
  }
908
1003
  if (!this.classicChannels?.isAdmin(data.userId)) {
909
- await data.respond("⛔ This command requires admin access.");
1004
+ await data.respond(t("admin.required"));
910
1005
  return;
911
1006
  }
912
- if (!this.classicChannels.isClassicChannel(data.channelId)) {
913
- await data.respond("No active agent in this channel. Use `/start` first.");
1007
+ if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
1008
+ await data.respond(t("classic.no_agent_start"));
914
1009
  return;
915
1010
  }
916
- const newState = this.classicChannels.toggleCollab(data.channelId);
1011
+ const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
917
1012
  await data.respond(newState
918
- ? "🤝 Collaboration mode **ON** — @mention this bot to trigger the agent. Other bot messages are visible."
919
- : "💬 Collaboration mode **OFF** — use `/chat` to talk to the agent.");
1013
+ ? t("collab.on.classic")
1014
+ : t("collab.off.classic"));
920
1015
  }
921
1016
  else if (data.command === "update") {
922
1017
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
923
1018
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
924
- await data.respond("⛔ Not authorized");
1019
+ await data.respond(t("not_authorized"));
925
1020
  return;
926
1021
  }
927
- await data.respond("📦 Updating AgEnD... Fleet will restart automatically.");
1022
+ await data.respond(t("update.running"));
928
1023
  const { spawn } = await import("node:child_process");
929
1024
  const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
930
1025
  const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
@@ -934,7 +1029,7 @@ export class FleetManager {
934
1029
  else if (data.command === "doctor") {
935
1030
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
936
1031
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
937
- await data.respond("⛔ Not authorized");
1032
+ await data.respond(t("not_authorized"));
938
1033
  return;
939
1034
  }
940
1035
  try {
@@ -956,22 +1051,36 @@ export class FleetManager {
956
1051
  else if (data.command === "sysinfo") {
957
1052
  await data.respond(this.topicCommands.getSysInfoText());
958
1053
  }
1054
+ else if (data.command === "dashboard") {
1055
+ // Reply is ephemeral (adapter defers non-chat commands ephemerally), so
1056
+ // the web-token-bearing URLs are only visible to the caller.
1057
+ const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1058
+ if (allowed.length === 0) {
1059
+ await data.respond(t("dashboard.disabled"));
1060
+ return;
1061
+ }
1062
+ if (!allowed.some(u => String(u) === String(data.userId))) {
1063
+ await data.respond(t("not_authorized"));
1064
+ return;
1065
+ }
1066
+ await data.respond(this.topicCommands.getDashboardText());
1067
+ }
959
1068
  else if (data.command === "restart") {
960
1069
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
961
1070
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
962
- await data.respond("⛔ Not authorized");
1071
+ await data.respond(t("not_authorized"));
963
1072
  return;
964
1073
  }
965
- await data.respond("🔄 Graceful restart — waiting for instances to idle...");
1074
+ await data.respond(t("restart.graceful"));
966
1075
  process.kill(process.pid, "SIGUSR2");
967
1076
  }
968
1077
  else if (data.command === "compact") {
969
- const target = this.routing.resolve(data.channelId);
970
- if (!target) {
971
- await data.respond("No active agent in this channel.");
1078
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1079
+ if (!name) {
1080
+ await data.respond(t("classic.no_agent"));
972
1081
  return;
973
1082
  }
974
- const result = await this.topicCommands.sendCompact(target.name);
1083
+ const result = await this.topicCommands.sendCompact(name);
975
1084
  await data.respond(result);
976
1085
  }
977
1086
  }, this.logger, "adapter.slash_command"));
@@ -1015,7 +1124,7 @@ export class FleetManager {
1015
1124
  }, 5 * 60 * 1000);
1016
1125
  }
1017
1126
  /** Start an additional (non-primary) adapter */
1018
- async startAdditionalAdapter(channelConfig) {
1127
+ async startAdditionalAdapter(channelConfig, registerCommands = true) {
1019
1128
  const adapterId = channelConfig.id ?? channelConfig.type;
1020
1129
  const botToken = process.env[channelConfig.bot_token_env];
1021
1130
  if (!botToken) {
@@ -1024,7 +1133,7 @@ export class FleetManager {
1024
1133
  }
1025
1134
  const accessDir = join(this.dataDir, "access");
1026
1135
  mkdirSync(accessDir, { recursive: true });
1027
- const accessManager = new AccessManager(channelConfig.access, join(accessDir, `access-${adapterId}.json`));
1136
+ const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, join(accessDir, `access-${adapterId}.json`));
1028
1137
  const inboxDir = join(this.dataDir, "inbox");
1029
1138
  mkdirSync(inboxDir, { recursive: true });
1030
1139
  const adapter = await createAdapter(channelConfig, {
@@ -1032,6 +1141,7 @@ export class FleetManager {
1032
1141
  botToken,
1033
1142
  accessManager,
1034
1143
  inboxDir,
1144
+ registerCommands,
1035
1145
  });
1036
1146
  const world = new AdapterWorld(adapterId, adapter, accessManager, channelConfig);
1037
1147
  this.worlds.set(adapterId, world);
@@ -1075,28 +1185,28 @@ export class FleetManager {
1075
1185
  // Slash commands: classic bot + admin commands
1076
1186
  adapter.on("slash_command", safeHandler(async (data) => {
1077
1187
  if (data.command === "start") {
1078
- const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId);
1188
+ const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId, adapterId);
1079
1189
  await data.respond(reply);
1080
1190
  }
1081
1191
  else if (data.command === "stop") {
1082
- const reply = await this.handleClassicStop(data.channelId);
1192
+ const reply = await this.handleClassicStop(data.channelId, adapterId);
1083
1193
  await data.respond(reply);
1084
1194
  }
1085
1195
  else if (data.command === "chat") {
1086
1196
  const text = data.text ?? "";
1087
1197
  if (!text) {
1088
- await data.respond("Usage: `/chat <message>`");
1198
+ await data.respond(t("chat.usage"));
1089
1199
  return;
1090
1200
  }
1091
- const target = this.routing.resolve(data.channelId);
1092
- if (!target || target.kind !== "classic") {
1093
- await data.respond("No active agent in this channel. Use `/start` first.");
1201
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
1202
+ if (!name) {
1203
+ await data.respond(t("classic.no_agent_start"));
1094
1204
  return;
1095
1205
  }
1096
1206
  const replyMsgId = await data.respond("👀");
1097
1207
  const username = data.username ?? data.userId;
1098
- ClassicChannelManager.logMessage(target.name, username, `/chat ${text}`, new Date());
1099
- await this.forwardToClassicInstance(target.name, text, {
1208
+ ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
1209
+ await this.forwardToClassicInstance(name, text, {
1100
1210
  chatId: data.channelId,
1101
1211
  threadId: data.channelId,
1102
1212
  messageId: replyMsgId ?? "",
@@ -1107,86 +1217,79 @@ export class FleetManager {
1107
1217
  });
1108
1218
  }
1109
1219
  else if (data.command === "save") {
1110
- await this.handleSlashSave(data);
1220
+ await this.handleSlashSave(data, adapterId);
1111
1221
  }
1112
1222
  else if (data.command === "load") {
1113
1223
  // load is kiro-cli/classic only — no claude-code equivalent.
1114
1224
  if (!this.classicChannels?.isAdmin(data.userId)) {
1115
- await data.respond("⛔ This command requires admin access.");
1225
+ await data.respond(t("admin.required"));
1116
1226
  return;
1117
1227
  }
1118
- const target = this.routing.resolve(data.channelId);
1119
- if (!target || target.kind !== "classic") {
1120
- await data.respond("No active agent in this channel. Use `/start` first.");
1228
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
1229
+ if (!name) {
1230
+ await data.respond(t("classic.no_agent_start"));
1121
1231
  return;
1122
1232
  }
1123
1233
  const filename = data.options?.filename;
1124
1234
  if (!SAVE_FILENAME_RE.test(filename ?? "")) {
1125
- await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1235
+ await data.respond(t("filename.invalid"));
1126
1236
  return;
1127
1237
  }
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);
1238
+ this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
1239
+ await data.respond(t("save.sent", `/chat load ${filename}`, name));
1139
1240
  }
1140
1241
  else if (data.command === "cancel") {
1141
- const target = this.routing.resolve(data.channelId);
1142
- if (!target) {
1143
- await data.respond("No active agent in this channel.");
1242
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1243
+ if (!name) {
1244
+ await data.respond(t("classic.no_agent"));
1144
1245
  return;
1145
1246
  }
1146
- const ok = this.cancelInstance(target.name);
1147
- await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
1247
+ const ok = this.cancelInstance(name);
1248
+ await data.respond(ok ? t("cancel.sent", name) : t("cancel.not_running", name));
1148
1249
  }
1149
1250
  else if (data.command === "ctx") {
1150
- const target = this.routing.resolve(data.channelId);
1151
- if (!target) {
1152
- await data.respond("No active agent in this channel.");
1251
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1252
+ if (!name) {
1253
+ await data.respond(t("classic.no_agent"));
1153
1254
  return;
1154
1255
  }
1155
1256
  // Single source of truth (statusline.json + robust tmux pane fallback).
1156
- await data.respond(await this.topicCommands.getCtxText(target.name));
1257
+ await data.respond(await this.topicCommands.getCtxText(name));
1157
1258
  }
1158
1259
  else if (data.command === "collab") {
1260
+ // Classic no longer lives in the routing engine, so a routing hit here is
1261
+ // always a fleet-topic instance.
1159
1262
  const collabTarget2 = this.routing.resolve(data.channelId);
1160
- if (collabTarget2 && collabTarget2.kind !== "classic") {
1263
+ if (collabTarget2) {
1161
1264
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1162
1265
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
1163
- await data.respond("⛔ Not authorized");
1266
+ await data.respond(t("not_authorized"));
1164
1267
  return;
1165
1268
  }
1166
1269
  const isCollab = this.toggleFleetCollab(collabTarget2.name);
1167
- await data.respond(isCollab ? "🤝 Collaboration mode **ON** — bot/webhook messages reach the agent." : "💬 Collaboration mode **OFF**");
1270
+ await data.respond(isCollab ? t("collab.on") : t("collab.off"));
1168
1271
  return;
1169
1272
  }
1170
1273
  if (!this.classicChannels?.isAdmin(data.userId)) {
1171
- await data.respond("⛔ This command requires admin access.");
1274
+ await data.respond(t("admin.required"));
1172
1275
  return;
1173
1276
  }
1174
- if (!this.classicChannels.isClassicChannel(data.channelId)) {
1175
- await data.respond("No active agent in this channel. Use `/start` first.");
1277
+ if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
1278
+ await data.respond(t("classic.no_agent_start"));
1176
1279
  return;
1177
1280
  }
1178
- const newState = this.classicChannels.toggleCollab(data.channelId);
1281
+ const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
1179
1282
  await data.respond(newState
1180
- ? "🤝 Collaboration mode **ON** — @mention this bot to trigger the agent. Other bot messages are visible."
1181
- : "💬 Collaboration mode **OFF** — use `/chat` to talk to the agent.");
1283
+ ? t("collab.on.classic")
1284
+ : t("collab.off.classic"));
1182
1285
  }
1183
1286
  else if (data.command === "update") {
1184
1287
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1185
1288
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
1186
- await data.respond("⛔ Not authorized");
1289
+ await data.respond(t("not_authorized"));
1187
1290
  return;
1188
1291
  }
1189
- await data.respond("📦 Updating AgEnD... Fleet will restart automatically.");
1292
+ await data.respond(t("update.running"));
1190
1293
  const { spawn } = await import("node:child_process");
1191
1294
  const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
1192
1295
  const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
@@ -1196,7 +1299,7 @@ export class FleetManager {
1196
1299
  else if (data.command === "doctor") {
1197
1300
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1198
1301
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
1199
- await data.respond("⛔ Not authorized");
1302
+ await data.respond(t("not_authorized"));
1200
1303
  return;
1201
1304
  }
1202
1305
  try {
@@ -1218,22 +1321,36 @@ export class FleetManager {
1218
1321
  else if (data.command === "sysinfo") {
1219
1322
  await data.respond(this.topicCommands.getSysInfoText());
1220
1323
  }
1324
+ else if (data.command === "dashboard") {
1325
+ // Reply is ephemeral (adapter defers non-chat commands ephemerally), so
1326
+ // the web-token-bearing URLs are only visible to the caller.
1327
+ const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1328
+ if (allowed.length === 0) {
1329
+ await data.respond(t("dashboard.disabled"));
1330
+ return;
1331
+ }
1332
+ if (!allowed.some(u => String(u) === String(data.userId))) {
1333
+ await data.respond(t("not_authorized"));
1334
+ return;
1335
+ }
1336
+ await data.respond(this.topicCommands.getDashboardText());
1337
+ }
1221
1338
  else if (data.command === "restart") {
1222
1339
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1223
1340
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
1224
- await data.respond("⛔ Not authorized");
1341
+ await data.respond(t("not_authorized"));
1225
1342
  return;
1226
1343
  }
1227
- await data.respond("🔄 Graceful restart — waiting for instances to idle...");
1344
+ await data.respond(t("restart.graceful"));
1228
1345
  process.kill(process.pid, "SIGUSR2");
1229
1346
  }
1230
1347
  else if (data.command === "compact") {
1231
- const target = this.routing.resolve(data.channelId);
1232
- if (!target) {
1233
- await data.respond("No active agent in this channel.");
1348
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1349
+ if (!name) {
1350
+ await data.respond(t("classic.no_agent"));
1234
1351
  return;
1235
1352
  }
1236
- const result = await this.topicCommands.sendCompact(target.name);
1353
+ const result = await this.topicCommands.sendCompact(name);
1237
1354
  await data.respond(result);
1238
1355
  }
1239
1356
  }, this.logger, `adapter[${adapterId}].slash_command`));
@@ -1451,6 +1568,34 @@ export class FleetManager {
1451
1568
  async handleInboundMessage(msg) {
1452
1569
  const threadId = msg.threadId || undefined;
1453
1570
  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");
1571
+ // Multi-adapter dedup: when several bots share a guild, each adapter fires
1572
+ // its own "message" event for the same underlying message. Process it once.
1573
+ // Routing (by topic/channel) and reply-adapter selection (by channel_id
1574
+ // binding) are adapter-independent, so it's safe to let whichever adapter
1575
+ // arrives first handle it.
1576
+ //
1577
+ // EXCEPTION — classic channels with same-channel multi-bot: two bots may own
1578
+ // separate agents in one channel, so each bot must process its OWN copy of
1579
+ // the message (the @mention filter downstream decides who actually forwards).
1580
+ // Key the dedup per-adapter there so a sibling bot's copy isn't dropped.
1581
+ if (msg.messageId) {
1582
+ const classicCid = msg.threadId || msg.chatId;
1583
+ const isClassicMsg = this.classicChannels?.hasChannel(classicCid) ?? false;
1584
+ const dedupKey = isClassicMsg
1585
+ ? `${msg.source}:${msg.chatId}:${msg.messageId}:${msg.adapterId ?? ""}`
1586
+ : `${msg.source}:${msg.chatId}:${msg.messageId}`;
1587
+ if (this.recentMessageIds.has(dedupKey)) {
1588
+ this.logger.debug({ dedupKey, adapterId: msg.adapterId }, "Duplicate inbound across adapters — skipping");
1589
+ return;
1590
+ }
1591
+ this.recentMessageIds.add(dedupKey);
1592
+ if (this.recentMessageIds.size > 1000) {
1593
+ // Set preserves insertion order — drop the oldest key.
1594
+ const oldest = this.recentMessageIds.values().next().value;
1595
+ if (oldest !== undefined)
1596
+ this.recentMessageIds.delete(oldest);
1597
+ }
1598
+ }
1454
1599
  // Bot messages: only allow in collab channels or TG classic with @mention
1455
1600
  if (msg.isBotMessage) {
1456
1601
  if (!threadId) {
@@ -1465,21 +1610,25 @@ export class FleetManager {
1465
1610
  return;
1466
1611
  // Fall through to TG classic handling below
1467
1612
  }
1613
+ else if (this.classicChannels?.hasChannel(threadId)) {
1614
+ // Classic channel (per-bot): bot messages only when THIS bot owns an
1615
+ // agent here and collab is on for it.
1616
+ const classicName = this.classicChannels.getInstanceByChannel(threadId, msg.adapterId);
1617
+ if (!classicName)
1618
+ return;
1619
+ if (!this.classicChannels.isCollab(threadId, msg.adapterId))
1620
+ return;
1621
+ // Fall through to channel handling
1622
+ }
1468
1623
  else {
1469
1624
  const target = this.routing.resolve(threadId);
1470
1625
  if (!target)
1471
1626
  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
- }
1627
+ // Fleet topic: allow if collab enabled OR access mode is open
1628
+ const channelCfg = this.getChannelConfig(msg.adapterId);
1629
+ const isOpen = channelCfg?.access?.mode === "open";
1630
+ if (!isOpen && !this.collabInstances.has(target.name))
1631
+ return;
1483
1632
  // Fall through to channel handling
1484
1633
  }
1485
1634
  }
@@ -1489,9 +1638,10 @@ export class FleetManager {
1489
1638
  const adapterGroupId = String(this.getChannelConfig(msg.adapterId)?.group_id ?? "");
1490
1639
  const isTelegramClassicCandidate = msg.source === "telegram" && msg.chatId !== adapterGroupId && !threadId;
1491
1640
  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")
1641
+ // Classic channels are open to all; check per-bot ownership (or fleet topic).
1642
+ const isClassic = !!(threadId && this.classicChannels?.hasChannel(threadId));
1643
+ this.logger.info({ userId: msg.userId, threadId, isClassic }, "Access DENIED for non-allowed user");
1644
+ if (!isClassic)
1495
1645
  return;
1496
1646
  }
1497
1647
  }
@@ -1538,7 +1688,7 @@ export class FleetManager {
1538
1688
  if (generalId) {
1539
1689
  this.notifyInstanceTopic(generalId, `🆕 Unauthorized user tried /start in private chat:\n• Name: ${msg.username}\n• ID: ${msg.userId}\n• Platform: ${msg.source}\n\nTo allow: add \`${msg.userId}\` to classicBot.yaml \`allowed_users\``);
1540
1690
  }
1541
- await msgAdapter?.sendText(chatId, "⛔ You are not in the allowed users list.");
1691
+ await msgAdapter?.sendText(chatId, t("classic.not_allowed_user"));
1542
1692
  return;
1543
1693
  }
1544
1694
  }
@@ -1551,11 +1701,11 @@ export class FleetManager {
1551
1701
  if (generalId) {
1552
1702
  this.notifyInstanceTopic(generalId, adminMsg);
1553
1703
  }
1554
- await msgAdapter?.sendText(chatId, "⏳ Access requested. Waiting for admin approval.");
1704
+ await msgAdapter?.sendText(chatId, t("classic.access_requested"));
1555
1705
  return;
1556
1706
  }
1557
1707
  if (!this.classicChannels.isAdmin(msg.userId)) {
1558
- await msgAdapter?.sendText(chatId, "⛔ Only admins can start agents. Ask an admin to /start.");
1708
+ await msgAdapter?.sendText(chatId, t("classic.admin_only_start"));
1559
1709
  const generalId = this.findGeneralInstance(msg.adapterId);
1560
1710
  if (generalId) {
1561
1711
  this.notifyInstanceTopic(generalId, `🔑 User wants to /start but is not admin:\n• Name: ${msg.username}\n• ID: ${msg.userId}\n• Platform: ${msg.source}\n• Group: ${chatId}\n\nTo approve: add \`${msg.userId}\` to classicBot.yaml \`admin_users\``);
@@ -1564,118 +1714,117 @@ export class FleetManager {
1564
1714
  }
1565
1715
  }
1566
1716
  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);
1717
+ // handleClassicStart binds the instance to this adapter authoritatively.
1718
+ const reply = await this.handleClassicStart(chatId, channelName, msg.userId, undefined, msg.adapterId);
1570
1719
  await msgAdapter?.sendText(chatId, reply);
1571
1720
  return;
1572
1721
  }
1573
1722
  // Handle /stop command
1574
1723
  if (text === "/stop" || text.startsWith("/stop ")) {
1575
1724
  if (!this.classicChannels.isAdmin(msg.userId)) {
1576
- await msgAdapter?.sendText(chatId, "⛔ Only admins can stop agents.");
1725
+ await msgAdapter?.sendText(chatId, t("classic.admin_only_stop"));
1577
1726
  const generalId = this.findGeneralInstance(msg.adapterId);
1578
1727
  if (generalId) {
1579
1728
  this.notifyInstanceTopic(generalId, `🔑 User wants to /stop but is not admin:\n• Name: ${msg.username}\n• ID: ${msg.userId}\n• Platform: ${msg.source}\n• Group: ${chatId}\n\nTo approve: add \`${msg.userId}\` to classicBot.yaml \`admin_users\``);
1580
1729
  }
1581
1730
  return;
1582
1731
  }
1583
- const reply = await this.handleClassicStop(chatId);
1732
+ const reply = await this.handleClassicStop(chatId, msg.adapterId);
1584
1733
  await msgAdapter?.sendText(chatId, reply);
1585
1734
  return;
1586
1735
  }
1587
1736
  // Handle /compact command (admin only)
1588
1737
  if (text === "/compact" || text.startsWith("/compact@")) {
1589
1738
  if (!this.classicChannels.isAdmin(msg.userId)) {
1590
- await msgAdapter?.sendText(chatId, " /compact requires admin access.");
1739
+ await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/compact"));
1591
1740
  return;
1592
1741
  }
1593
- const compactTarget = this.routing.resolve(chatId);
1594
- if (!compactTarget || compactTarget.kind !== "classic") {
1595
- await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1742
+ const compactName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1743
+ if (!compactName) {
1744
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
1596
1745
  return;
1597
1746
  }
1598
- const result = await this.topicCommands.sendCompact(compactTarget.name);
1747
+ const result = await this.topicCommands.sendCompact(compactName);
1599
1748
  await msgAdapter?.sendText(chatId, result);
1600
1749
  return;
1601
1750
  }
1602
1751
  // Handle /cancel command
1603
1752
  if (text === "/cancel" || text.startsWith("/cancel@")) {
1604
- const cancelTarget = this.routing.resolve(chatId);
1605
- if (!cancelTarget || cancelTarget.kind !== "classic") {
1606
- await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1753
+ const cancelName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1754
+ if (!cancelName) {
1755
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
1607
1756
  return;
1608
1757
  }
1609
- const ok = this.cancelInstance(cancelTarget.name);
1610
- await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelTarget.name}。` : `❌ ${cancelTarget.name} 未在執行。`);
1758
+ const ok = this.cancelInstance(cancelName);
1759
+ await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelName}。` : `❌ ${cancelName} 未在執行。`);
1611
1760
  return;
1612
1761
  }
1613
1762
  // Handle /ctx command
1614
1763
  if (text === "/ctx" || text.startsWith("/ctx@")) {
1615
- const ctxTarget = this.routing.resolve(chatId);
1616
- if (!ctxTarget || ctxTarget.kind !== "classic") {
1617
- await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1764
+ const ctxName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1765
+ if (!ctxName) {
1766
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
1618
1767
  return;
1619
1768
  }
1620
- const reply = await this.topicCommands.getCtxText(ctxTarget.name);
1769
+ const reply = await this.topicCommands.getCtxText(ctxName);
1621
1770
  await msgAdapter?.sendText(chatId, reply);
1622
1771
  return;
1623
1772
  }
1624
1773
  // Handle /save command (admin only)
1625
1774
  if (text === "/save" || text.startsWith("/save ") || text.startsWith("/save@")) {
1626
1775
  if (!this.classicChannels.isAdmin(msg.userId)) {
1627
- await msgAdapter?.sendText(chatId, " /save requires admin access.");
1776
+ await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/save"));
1628
1777
  return;
1629
1778
  }
1630
- const saveTarget = this.routing.resolve(chatId);
1631
- if (!saveTarget || saveTarget.kind !== "classic") {
1632
- await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1779
+ const saveName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1780
+ if (!saveName) {
1781
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
1633
1782
  return;
1634
1783
  }
1635
1784
  const filename = parseSaveFilename(text);
1636
1785
  if (!filename) {
1637
- await msgAdapter?.sendText(chatId, "Usage: /save <filename>");
1786
+ await msgAdapter?.sendText(chatId, t("save.usage"));
1638
1787
  return;
1639
1788
  }
1640
1789
  if (!SAVE_FILENAME_RE.test(filename)) {
1641
- await msgAdapter?.sendText(chatId, "⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1790
+ await msgAdapter?.sendText(chatId, t("filename.invalid"));
1642
1791
  return;
1643
1792
  }
1644
- const backend = this.classicChannels.getBackendByInstance(saveTarget.name, this.fleetConfig?.defaults?.backend);
1793
+ const backend = this.classicChannels.getBackendByInstance(saveName, this.fleetConfig?.defaults?.backend);
1645
1794
  const cmd = saveCommandForBackend(backend, filename);
1646
1795
  if (!cmd) {
1647
1796
  await msgAdapter?.sendText(chatId, SAVE_UNSUPPORTED_MSG);
1648
1797
  return;
1649
1798
  }
1650
- this.pasteRawToClassicInstance(saveTarget.name, cmd);
1651
- await msgAdapter?.sendText(chatId, `✅ Sent \`${cmd}\` to ${saveTarget.name}`);
1799
+ this.pasteRawToClassicInstance(saveName, cmd);
1800
+ await msgAdapter?.sendText(chatId, t("save.sent", cmd, saveName));
1652
1801
  return;
1653
1802
  }
1654
- // Route to classic channel if registered
1655
- const target = this.routing.resolve(chatId);
1656
- if (target?.kind === "classic") {
1803
+ // Route to classic channel if this bot has an agent here (per-bot).
1804
+ const classicName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1805
+ if (classicName) {
1657
1806
  if (msg.adapterId)
1658
- this.bindInstanceAdapter(target.name, msg.adapterId, true);
1807
+ this.bindInstanceAdapter(classicName, msg.adapterId, true);
1659
1808
  // TG ClassicBot: group requires @mention, private chat forwards directly.
1660
1809
  if (!isPrivateChat && !isBotMentioned) {
1661
1810
  // No trigger: save attachments + react, log, but don't forward to agent
1662
1811
  const syntheticMsg = { ...msg, threadId: chatId, text: rawText.startsWith("/") ? "" : rawText };
1663
- await this.handleClassicChannelMessage(target.name, syntheticMsg);
1812
+ await this.handleClassicChannelMessage(classicName, syntheticMsg);
1664
1813
  return;
1665
1814
  }
1666
1815
  // Strip @bot from text and forward as /chat
1667
1816
  const cleanText = botUser ? text.replace(new RegExp(`@${botUser}`, "gi"), "").trim() : text;
1668
1817
  if (cleanText.startsWith("/raw") && !this.classicChannels.isAdmin(msg.userId)) {
1669
- await msgAdapter?.sendText(chatId, " /raw requires admin access.");
1818
+ await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/raw"));
1670
1819
  return;
1671
1820
  }
1672
1821
  const syntheticMsg = { ...msg, threadId: chatId, text: `/chat ${cleanText}` };
1673
- await this.handleClassicChannelMessage(target.name, syntheticMsg);
1822
+ await this.handleClassicChannelMessage(classicName, syntheticMsg);
1674
1823
  return;
1675
1824
  }
1676
1825
  // Handle @bot without active agent
1677
1826
  if (isBotMentioned) {
1678
- await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1827
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
1679
1828
  return;
1680
1829
  }
1681
1830
  // Unregistered private chat: ignore (don't fall through to General)
@@ -1695,9 +1844,12 @@ export class FleetManager {
1695
1844
  if (msg.adapterId)
1696
1845
  this.bindInstanceAdapter(generalInstance, msg.adapterId, true);
1697
1846
  const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1698
- // React immediately — before any other API calls
1847
+ // React immediately — before any other API calls. Use the adapter BOUND to
1848
+ // the instance (not whichever same-guild bot received the event first) so
1849
+ // exactly the owning bot reacts — no duplicate 👀 from a sibling bot.
1699
1850
  if (msg.chatId && msg.messageId) {
1700
- inboundAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
1851
+ const reactAdapter = this.getAdapterForInstance(generalInstance) ?? inboundAdapter;
1852
+ reactAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
1701
1853
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
1702
1854
  }
1703
1855
  this.warnIfRateLimited(generalInstance, msg);
@@ -1733,6 +1885,18 @@ export class FleetManager {
1733
1885
  }
1734
1886
  return;
1735
1887
  }
1888
+ // Classic channels resolve per-bot (same-channel multi-bot) — a channel can
1889
+ // host two bots' agents. If this channel is classic but THIS bot has no
1890
+ // agent here, a sibling bot owns it; skip rather than misroute to it.
1891
+ if (this.classicChannels?.hasChannel(threadId)) {
1892
+ const classicName = this.classicChannels.getInstanceByChannel(threadId, msg.adapterId);
1893
+ if (!classicName)
1894
+ return;
1895
+ if (msg.adapterId)
1896
+ this.bindInstanceAdapter(classicName, msg.adapterId, true);
1897
+ await this.handleClassicChannelMessage(classicName, msg);
1898
+ return;
1899
+ }
1736
1900
  const target = this.routing.resolve(threadId);
1737
1901
  if (!target) {
1738
1902
  // Only show unbound message for actual forum topics (same group, has threadId)
@@ -1764,9 +1928,12 @@ export class FleetManager {
1764
1928
  if (msg.adapterId)
1765
1929
  this.bindInstanceAdapter(instanceName, msg.adapterId, true);
1766
1930
  const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1767
- // React immediately — before any other Discord API calls
1931
+ // React immediately — before any other Discord API calls. Use the adapter
1932
+ // BOUND to the instance (not whichever same-guild bot received the event
1933
+ // first) so exactly the owning bot reacts — no duplicate 👀 from a sibling.
1768
1934
  if (msg.chatId && msg.messageId) {
1769
- inboundAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
1935
+ const reactAdapter = this.getAdapterForInstance(instanceName) ?? inboundAdapter;
1936
+ reactAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
1770
1937
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
1771
1938
  }
1772
1939
  // These may hit Discord API (topic icon, archive) — do after react
@@ -1891,7 +2058,7 @@ export class FleetManager {
1891
2058
  ts: new Date().toISOString(),
1892
2059
  });
1893
2060
  // Log bot reply to classic instance chat-log
1894
- const isClassic = [...this.routing.entries()].some(([, t]) => t.kind === "classic" && t.name === instanceName);
2061
+ const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
1895
2062
  if (isClassic) {
1896
2063
  ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
1897
2064
  }
@@ -1941,6 +2108,21 @@ export class FleetManager {
1941
2108
  // ===================== Scheduler =====================
1942
2109
  async handleScheduleTrigger(schedule) {
1943
2110
  const { target, reply_chat_id, reply_thread_id, message, label, id, source } = schedule;
2111
+ // Reserved auto-update schedule: run `agend update` instead of delivering a
2112
+ // message to an instance. message === "beta" selects the beta channel.
2113
+ if (target === "__fleet_update__") {
2114
+ const cmd = message === "beta" ? "agend update --beta" : "agend update";
2115
+ this.logger.info({ cmd, scheduleId: id }, "Auto-update schedule fired");
2116
+ try {
2117
+ const { spawn } = await import("node:child_process");
2118
+ spawn("sh", ["-c", `sleep 1 && ${cmd}`], { detached: true, stdio: "ignore" }).unref();
2119
+ this.scheduler?.recordRun(id, "delivered", `ran ${cmd}`);
2120
+ }
2121
+ catch (err) {
2122
+ this.scheduler?.recordRun(id, "retry", `update spawn failed: ${err.message}`);
2123
+ }
2124
+ return;
2125
+ }
1944
2126
  const RATE_LIMIT_DEFER_THRESHOLD = 85;
1945
2127
  const rl = this.statuslineWatcher.getRateLimits(target);
1946
2128
  if (rl && rl.five_hour_pct > RATE_LIMIT_DEFER_THRESHOLD) {
@@ -2510,15 +2692,17 @@ export class FleetManager {
2510
2692
  startStatuslineWatcher(name) {
2511
2693
  this.statuslineWatcher.watch(name);
2512
2694
  }
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
- }
2695
+ reactMessageStatus(instanceName, chatId, messageId, emoji) {
2696
+ // React via the adapter BOUND to this instance NOT the first discord world.
2697
+ // Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
2698
+ // (bound bot) and the delivery/confirm reactions (some other bot) come from
2699
+ // different bots, leaving a duplicate 👀 that never turns into ✅.
2700
+ const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
2701
+ // Status reactions are Discord-only (TG/others use the inbound react path).
2702
+ if (!adapter || adapter.type !== "discord")
2703
+ return;
2704
+ adapter.react(chatId, messageId, emoji)
2705
+ .catch(e => this.logger.debug({ err: e.message }, "Message status react failed"));
2522
2706
  }
2523
2707
  // ── Model failover ──────────────────────────────────────────────────────
2524
2708
  static FAILOVER_TRIGGER_PCT = 90;
@@ -2580,13 +2764,12 @@ export class FleetManager {
2580
2764
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send instance topic notification"));
2581
2765
  return;
2582
2766
  }
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
- }
2767
+ // Classic instance: find its channelId from the classic manager
2768
+ const classicChatId = this.classicChannels?.getChannelIdByInstance(instanceName);
2769
+ if (classicChatId) {
2770
+ adapter.sendText(classicChatId, text)
2771
+ .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send classic notification"));
2772
+ return;
2590
2773
  }
2591
2774
  // Fallback: send to group without threadId
2592
2775
  if (groupId) {
@@ -2603,19 +2786,23 @@ export class FleetManager {
2603
2786
  * Picks the backend-appropriate command (kiro → /chat save, claude → /export);
2604
2787
  * unsupported backends get a clear error. Routes via classic paste or fleet IPC.
2605
2788
  */
2606
- async handleSlashSave(data) {
2789
+ async handleSlashSave(data, adapterId) {
2607
2790
  if (!this.classicChannels?.isAdmin(data.userId)) {
2608
- await data.respond("⛔ This command requires admin access.");
2791
+ await data.respond(t("admin.required"));
2609
2792
  return;
2610
2793
  }
2611
- const target = this.routing.resolve(data.channelId);
2794
+ // Classic resolves per-bot (same-channel multi-bot); otherwise a fleet topic.
2795
+ const classicName = this.classicChannels.getInstanceByChannel(data.channelId, adapterId);
2796
+ const target = classicName
2797
+ ? { kind: "classic", name: classicName }
2798
+ : this.routing.resolve(data.channelId);
2612
2799
  if (!target) {
2613
- await data.respond("No active agent in this channel. Use `/start` first.");
2800
+ await data.respond(t("classic.no_agent_start"));
2614
2801
  return;
2615
2802
  }
2616
2803
  const filename = data.options?.filename ?? "";
2617
2804
  if (!SAVE_FILENAME_RE.test(filename)) {
2618
- await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
2805
+ await data.respond(t("filename.invalid"));
2619
2806
  return;
2620
2807
  }
2621
2808
  const backend = target.kind === "classic"
@@ -2634,7 +2821,7 @@ export class FleetManager {
2634
2821
  else {
2635
2822
  this.instanceIpcClients.get(target.name)?.send({ type: "raw_paste", content: cmd });
2636
2823
  }
2637
- await data.respond(`✅ Sent \`${cmd}\` to ${target.name}`);
2824
+ await data.respond(t("save.sent", cmd, target.name));
2638
2825
  }
2639
2826
  /** Whether the instance currently has at least one live cancel button. */
2640
2827
  hasCancelButton(instanceName) {
@@ -2663,13 +2850,8 @@ export class FleetManager {
2663
2850
  threadId = String(topicId);
2664
2851
  }
2665
2852
  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
- }
2853
+ // Classic instance: channelId from the classic manager.
2854
+ chatId = this.classicChannels?.getChannelIdByInstance(instanceName);
2673
2855
  // General / flat fallback: post to the group (no thread).
2674
2856
  if (!chatId && groupId)
2675
2857
  chatId = String(groupId);
@@ -2681,7 +2863,7 @@ export class FleetManager {
2681
2863
  type: "cancel",
2682
2864
  instanceName,
2683
2865
  message: "👀 處理中…",
2684
- choices: [{ id: `cancel:${instanceName}`, label: "🛑 取消" }],
2866
+ choices: [{ id: `cancel:${instanceName}`, label: t("cancel.button") }],
2685
2867
  }, threadId ? { threadId } : undefined);
2686
2868
  // A concurrent sendCancelButton for the same instance may have posted its
2687
2869
  // own button while we awaited notifyAlert. Retire any other buttons for
@@ -3152,7 +3334,7 @@ When users create specialized instances, suggest these configurations:
3152
3334
  async handleClassicChannelMessage(instanceName, msg) {
3153
3335
  const text = msg.text ?? "";
3154
3336
  const channelId = msg.threadId ?? msg.chatId;
3155
- const isCollabMode = this.classicChannels?.isCollab(channelId) ?? false;
3337
+ const isCollabMode = this.classicChannels?.isCollab(channelId, msg.adapterId) ?? false;
3156
3338
  // Handle /ctx in classic mode — always, regardless of collab mode
3157
3339
  if (text === "/ctx" || text.startsWith("/ctx@")) {
3158
3340
  const reply = await this.topicCommands.getCtxText(instanceName);
@@ -3179,8 +3361,15 @@ When users create specialized instances, suggest these configurations:
3179
3361
  : "");
3180
3362
  ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText);
3181
3363
  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;
3364
+ // Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here.
3365
+ // Each bot matches ONLY its own id. A secondary bot must NOT fall back to the
3366
+ // process-wide botUserId (the primary's) — otherwise, in a same-channel
3367
+ // multi-bot setup, an @mention of the primary would also match the secondary
3368
+ // and BOTH bots would react 👀 and forward. Only the primary adapter may use
3369
+ // the fallback.
3370
+ const mentionWorld = this.worlds.get(msg.adapterId ?? "");
3371
+ const isPrimaryAdapter = !mentionWorld || mentionWorld.adapter === this.adapter;
3372
+ const adapterBotUserId = mentionWorld?.botUserId ?? (isPrimaryAdapter ? this.botUserId : undefined);
3184
3373
  const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
3185
3374
  const isMentioned = mentionTag && text.includes(mentionTag);
3186
3375
  if (!isMentioned) {
@@ -3358,7 +3547,11 @@ When users create specialized instances, suggest these configurations:
3358
3547
  }
3359
3548
  /** Forward a message to a classic channel instance with chat log context */
3360
3549
  async forwardToClassicInstance(instanceName, text, msg, extraMeta) {
3361
- const contextLines = this.classicChannels?.getContextLines(msg.chatId) ?? 5;
3550
+ // Resolve the channel/adapter from the instance itself so per-channel context
3551
+ // config is correct even for a same-channel second bot.
3552
+ const ctxAdapterId = this.classicChannels?.getAdapterIdByInstance(instanceName);
3553
+ const ctxChannelId = this.classicChannels?.getChannelIdByInstance(instanceName) ?? msg.chatId;
3554
+ const contextLines = this.classicChannels?.getContextLines(ctxChannelId, ctxAdapterId) ?? 5;
3362
3555
  const logContext = this.getRecentChatLog(instanceName, contextLines);
3363
3556
  const fullText = logContext
3364
3557
  ? `[Chat log for context]\n${logContext}\n\n[User message]\n${text}`
@@ -3444,7 +3637,7 @@ When users create specialized instances, suggest these configurations:
3444
3637
  await this.startInstance(instanceName, config, topicMode);
3445
3638
  }
3446
3639
  /** Handle /start slash command — register classic channel */
3447
- async handleClassicStart(channelId, channelName, userId, guildId) {
3640
+ async handleClassicStart(channelId, channelName, userId, guildId, adapterId) {
3448
3641
  if (!this.classicChannels)
3449
3642
  return "Classic channel manager not initialized.";
3450
3643
  if (guildId && !this.classicChannels.isGuildAllowed(guildId)) {
@@ -3452,36 +3645,43 @@ When users create specialized instances, suggest these configurations:
3452
3645
  if (generalId) {
3453
3646
  this.notifyInstanceTopic(generalId, `🆕 Unauthorized guild tried /start:\n• Guild ID: ${guildId}\n• User: ${userId}\n• Platform: discord\n\nTo allow: add \`${guildId}\` to classicBot.yaml \`allowed_guilds\``);
3454
3647
  }
3455
- return "⛔ This server is not in the allowed guilds list.";
3648
+ return t("classic.not_authorized_guild");
3456
3649
  }
3457
- if (this.classicChannels.isClassicChannel(channelId))
3458
- return "This channel already has an active agent. Use /chat to talk.";
3650
+ // Per-bot check: a second bot may /start in the same channel (own agent).
3651
+ if (this.classicChannels.isClassicChannel(channelId, adapterId))
3652
+ return t("classic.already_active");
3653
+ // Classic no longer lives in the routing engine, so this only guards against
3654
+ // a fleet topic-mode instance colliding with the channel.
3459
3655
  if (this.routing.resolve(channelId))
3460
- 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));
3656
+ return t("classic.topic_bound");
3657
+ const instanceName = this.classicChannels.deriveInstanceName(channelName || channelId, channelId, adapterId);
3658
+ this.classicChannels.register(channelId, adapterId, instanceName, channelName || channelId, userId);
3659
+ // Bind this classic instance to the bot that started it (authoritative), so
3660
+ // replies/cancel go out through that bot even though every same-guild bot
3661
+ // also sees the channel's messages.
3662
+ if (adapterId)
3663
+ this.bindInstanceAdapter(instanceName, adapterId);
3664
+ 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
3665
  this.reregisterClassicChannels();
3466
3666
  // 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);
3667
+ if (guildId && !this.classicChannels.isCollab(channelId, adapterId)) {
3668
+ this.classicChannels.toggleCollab(channelId, adapterId);
3469
3669
  }
3470
- this.logger.info({ channelId, instanceName, userId }, "Classic channel started");
3471
- return `✅ Agent started in this channel. Use \`/chat <message>\` or @mention to talk.`;
3670
+ this.logger.info({ channelId, adapterId, instanceName, userId }, "Classic channel started");
3671
+ return t("classic.started");
3472
3672
  }
3473
3673
  /** Handle /stop slash command — unregister classic channel */
3474
- async handleClassicStop(channelId) {
3674
+ async handleClassicStop(channelId, adapterId) {
3475
3675
  if (!this.classicChannels)
3476
3676
  return "Classic channel manager not initialized.";
3477
- const ch = this.classicChannels.unregister(channelId);
3677
+ const ch = this.classicChannels.unregister(channelId, adapterId);
3478
3678
  if (!ch)
3479
- return "No active agent in this channel.";
3480
- this.routing.unregister(channelId);
3679
+ return t("classic.no_agent");
3680
+ this.instanceWorldBinding.delete(ch.instanceName);
3481
3681
  await this.stopInstance(ch.instanceName).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance"));
3482
3682
  this.reregisterClassicChannels();
3483
- this.logger.info({ channelId, instanceName: ch.instanceName }, "Classic channel stopped");
3484
- return `🛑 Agent stopped in this channel.`;
3683
+ this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
3684
+ return t("classic.stopped");
3485
3685
  }
3486
3686
  async stopAll() {
3487
3687
  this.ipcStoppingInstances.add("__fleet_stopping__");
@@ -3892,7 +4092,7 @@ When users create specialized instances, suggest these configurations:
3892
4092
  if (target && this.semverGt(target, currentVersion)) {
3893
4093
  const generalId = this.findGeneralInstance();
3894
4094
  if (generalId) {
3895
- this.notifyInstanceTopic(generalId, `🆕 AgEnD v${target} available (current: v${currentVersion}). Use \`/update\` to upgrade.`);
4095
+ this.notifyInstanceTopic(generalId, t("update.available", `v${target}`) + ` (current: v${currentVersion})`);
3896
4096
  }
3897
4097
  }
3898
4098
  }
@@ -3955,6 +4155,15 @@ When users create specialized instances, suggest these configurations:
3955
4155
  catch {
3956
4156
  // best-effort
3957
4157
  }
4158
+ // Separate read-only token for the /view page: grants terminal-view + profile
4159
+ // read, but never write (POSTs still require the full web token).
4160
+ this.viewToken = randomBytes(24).toString("hex");
4161
+ const viewTokenPath = join(this.dataDir, "view.token");
4162
+ writeFileSync(viewTokenPath, this.viewToken, { mode: 0o600 });
4163
+ try {
4164
+ chmodSync(viewTokenPath, 0o600);
4165
+ }
4166
+ catch { /* best-effort */ }
3958
4167
  this.healthServer = createServer((req, res) => {
3959
4168
  res.setHeader("Content-Type", "application/json");
3960
4169
  // Public health probe — no auth required.
@@ -3964,6 +4173,10 @@ When users create specialized instances, suggest these configurations:
3964
4173
  else if (req.method === "POST" && req.url === "/agent") {
3965
4174
  // /agent handles its own instance-level auth via X-Agend-Instance-Token
3966
4175
  }
4176
+ else if (isViewPath(new URL(req.url ?? "/", `http://localhost:${port}`).pathname)) {
4177
+ // /view routes accept the read-only view.token (or web.token) and do
4178
+ // their own per-method auth in view-api.ts — skip the web-token gate.
4179
+ }
3967
4180
  else {
3968
4181
  // All other endpoints require a valid token (query ?token= or X-Agend-Token header).
3969
4182
  // /ui/* will also re-check in web-api.ts, which is harmless.
@@ -4147,6 +4360,10 @@ When users create specialized instances, suggest these configurations:
4147
4360
  }
4148
4361
  // ── Web UI endpoints (delegated to web-api.ts) ─────
4149
4362
  const url = new URL(req.url ?? "/", `http://localhost:${port}`);
4363
+ if (handleViewRequest(req, res, url, this))
4364
+ return;
4365
+ if (handleSettingsRequest(req, res, url, this))
4366
+ return;
4150
4367
  if (handleWebRequest(req, res, url, this))
4151
4368
  return;
4152
4369
  res.writeHead(404);
@@ -4188,6 +4405,7 @@ When users create specialized instances, suggest these configurations:
4188
4405
  this.logger.info({ port }, "Health endpoint listening");
4189
4406
  });
4190
4407
  this.logger.info({ url: `http://localhost:${port}/ui?token=${this.webToken}` }, "Web UI available");
4408
+ this.logger.info({ url: `http://localhost:${port}/view?token=${this.viewToken}` }, "Web View available");
4191
4409
  }
4192
4410
  getUiStatus() {
4193
4411
  const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => {