@songsid/agend 2.0.11-beta.4 → 2.0.11-beta.40

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 (54) hide show
  1. package/dist/channel/adapters/discord.d.ts +2 -0
  2. package/dist/channel/adapters/discord.js +45 -36
  3. package/dist/channel/adapters/discord.js.map +1 -1
  4. package/dist/channel/adapters/telegram.js +1 -0
  5. package/dist/channel/adapters/telegram.js.map +1 -1
  6. package/dist/channel/factory.d.ts +4 -0
  7. package/dist/channel/factory.js +1 -0
  8. package/dist/channel/factory.js.map +1 -1
  9. package/dist/channel/mcp-tools.js +3 -0
  10. package/dist/channel/mcp-tools.js.map +1 -1
  11. package/dist/channel/tool-router.js +29 -2
  12. package/dist/channel/tool-router.js.map +1 -1
  13. package/dist/channel/types.d.ts +2 -0
  14. package/dist/classic-channel-manager.d.ts +59 -12
  15. package/dist/classic-channel-manager.js +121 -30
  16. package/dist/classic-channel-manager.js.map +1 -1
  17. package/dist/cli.js +94 -7
  18. package/dist/cli.js.map +1 -1
  19. package/dist/config-validator.d.ts +20 -0
  20. package/dist/config-validator.js +154 -0
  21. package/dist/config-validator.js.map +1 -0
  22. package/dist/fleet-manager.d.ts +29 -6
  23. package/dist/fleet-manager.js +455 -258
  24. package/dist/fleet-manager.js.map +1 -1
  25. package/dist/instance-lifecycle.d.ts +1 -1
  26. package/dist/instance-lifecycle.js +15 -13
  27. package/dist/instance-lifecycle.js.map +1 -1
  28. package/dist/locale.d.ts +19 -0
  29. package/dist/locale.js +179 -0
  30. package/dist/locale.js.map +1 -0
  31. package/dist/outbound-schemas.d.ts +1 -0
  32. package/dist/outbound-schemas.js +1 -0
  33. package/dist/outbound-schemas.js.map +1 -1
  34. package/dist/quickstart.js +252 -16
  35. package/dist/quickstart.js.map +1 -1
  36. package/dist/scheduler/scheduler.js +8 -2
  37. package/dist/scheduler/scheduler.js.map +1 -1
  38. package/dist/service-installer.d.ts +1 -0
  39. package/dist/service-installer.js +2 -1
  40. package/dist/service-installer.js.map +1 -1
  41. package/dist/settings-api.d.ts +33 -0
  42. package/dist/settings-api.js +283 -0
  43. package/dist/settings-api.js.map +1 -0
  44. package/dist/topic-commands.d.ts +14 -0
  45. package/dist/topic-commands.js +78 -16
  46. package/dist/topic-commands.js.map +1 -1
  47. package/dist/types.d.ts +2 -0
  48. package/dist/ui/settings.html +617 -0
  49. package/dist/ui/view.html +295 -39
  50. package/dist/view-api.d.ts +9 -0
  51. package/dist/view-api.js +101 -40
  52. package/dist/view-api.js.map +1 -1
  53. package/package.json +1 -1
  54. 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";
@@ -34,8 +36,10 @@ import { StatuslineWatcher } from "./statusline-watcher.js";
34
36
  import { outboundHandlers } from "./outbound-handlers.js";
35
37
  import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
36
38
  import { handleViewRequest, isViewPath } from "./view-api.js";
39
+ import { handleSettingsRequest } from "./settings-api.js";
40
+ import { setLocale, detectLocale, t } from "./locale.js";
37
41
  import { handleAgentRequest } from "./agent-endpoint.js";
38
- import { ClassicChannelManager, classicInstanceName } from "./classic-channel-manager.js";
42
+ import { ClassicChannelManager } from "./classic-channel-manager.js";
39
43
  import { getTmuxSession } from "./config.js";
40
44
  export function resolveReplyThreadId(argsThreadId, instanceConfig) {
41
45
  if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
@@ -66,6 +70,9 @@ export class FleetManager {
66
70
  adapters = new Map(); // derived view for backward compat
67
71
  /** Track which world each instance is bound to */
68
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();
69
76
  accessManager = null;
70
77
  /** Primary world (first adapter) — used for fleet-level notifications */
71
78
  get primaryWorld() { return this.worlds.values().next().value; }
@@ -176,27 +183,51 @@ export class FleetManager {
176
183
  }
177
184
  return this.routing.map;
178
185
  }
179
- /** 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
+ */
180
194
  reregisterClassicChannels() {
181
195
  if (!this.classicChannels)
182
196
  return;
183
197
  const channels = this.classicChannels.getAll();
184
- for (const ch of channels) {
185
- this.routing.register(ch.channelId, { kind: "classic", name: ch.instanceName });
186
- }
187
198
  // Always update adapter openChannels (including empty — clears stale entries on /stop)
188
- for (const [, w] of this.worlds) {
199
+ for (const [adapterId, w] of this.worlds) {
189
200
  if (typeof w.adapter?.setOpenChannels === "function") {
190
- 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);
191
203
  }
192
204
  }
193
205
  if (channels.length > 0) {
194
- this.logger.info({ count: channels.length }, "Registered classic channel routes");
206
+ this.logger.info({ count: channels.length }, "Refreshed classic channel open-lists");
195
207
  }
196
208
  }
197
209
  getInstanceDir(name) {
198
210
  return join(this.dataDir, "instances", name);
199
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
+ }
200
231
  /** Get the adapter bound to an instance, falling back to primary adapter */
201
232
  getAdapterForInstance(name) {
202
233
  const worldId = this.instanceWorldBinding.get(name);
@@ -223,9 +254,22 @@ export class FleetManager {
223
254
  const world = this.getWorldForInstance(name);
224
255
  return world?.groupId ?? String(this.fleetConfig?.channel?.group_id ?? "");
225
256
  }
226
- /** 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
+ */
227
264
  bindInstanceAdapter(name, adapterId, fromInbound = false) {
228
- 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))
229
273
  return;
230
274
  this.instanceWorldBinding.set(name, adapterId);
231
275
  }
@@ -354,6 +398,7 @@ export class FleetManager {
354
398
  const { rotateLogIfNeeded } = await import("./logger.js");
355
399
  rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
356
400
  const fleet = this.loadConfig(configPath);
401
+ setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
357
402
  const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
358
403
  // Set tmux socket isolation for custom AGEND_HOME
359
404
  const { getTmuxSocketName: getSocket } = await import("./paths.js");
@@ -395,10 +440,19 @@ export class FleetManager {
395
440
  const pidPath = join(this.dataDir, "fleet.pid");
396
441
  writeFileSync(pidPath, String(process.pid), "utf-8");
397
442
  this.eventLog = new EventLog(join(this.dataDir, "events.db"));
398
- // 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.
399
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).
400
453
  for (const ch of this.classicChannels.getAll()) {
401
- this.routing.register(ch.channelId, { kind: "classic", name: ch.instanceName });
454
+ if (ch.adapterId)
455
+ this.instanceWorldBinding.set(ch.instanceName, ch.adapterId);
402
456
  }
403
457
  // Poll classicBot.yaml for external changes every 30s
404
458
  this.classicReloadTimer = setInterval(async () => {
@@ -406,21 +460,27 @@ export class FleetManager {
406
460
  if (!this.classicChannels)
407
461
  return;
408
462
  const fleetBackend = this.fleetConfig?.defaults?.backend;
463
+ const fleetModel = this.fleetConfig?.defaults?.model;
409
464
  const oldBackends = new Map();
465
+ const oldModels = new Map();
410
466
  for (const ch of this.classicChannels.getAll()) {
411
467
  oldBackends.set(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend));
468
+ oldModels.set(ch.instanceName, this.classicChannels.getModel(ch.channelId, ch.adapterId, fleetModel));
412
469
  }
413
470
  if (!this.classicChannels.checkReload())
414
471
  return;
415
472
  this.reregisterClassicChannels();
416
473
  for (const ch of this.classicChannels.getAll()) {
417
474
  const newBackend = this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend);
418
- if (this.daemons.has(ch.instanceName) && oldBackends.get(ch.instanceName) !== newBackend) {
419
- this.logger.info({ instanceName: ch.instanceName, from: oldBackends.get(ch.instanceName), to: newBackend }, "Backend changed — restarting");
475
+ const newModel = this.classicChannels.getModel(ch.channelId, ch.adapterId, fleetModel);
476
+ const backendChanged = oldBackends.get(ch.instanceName) !== newBackend;
477
+ const modelChanged = oldModels.get(ch.instanceName) !== newModel;
478
+ if (this.daemons.has(ch.instanceName) && (backendChanged || modelChanged)) {
479
+ this.logger.info({ instanceName: ch.instanceName, backendFrom: oldBackends.get(ch.instanceName), backendTo: newBackend, modelFrom: oldModels.get(ch.instanceName), modelTo: newModel }, "Backend/model changed — restarting");
420
480
  await this.stopInstance(ch.instanceName).catch(() => { });
421
481
  // Small delay to let tmux window clean up
422
482
  await new Promise(r => setTimeout(r, 2000));
423
- await this.startClassicInstance(ch.instanceName, newBackend).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to restart classic instance"));
483
+ await this.startClassicInstance(ch.instanceName, newBackend, this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), newModel).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to restart classic instance"));
424
484
  }
425
485
  }
426
486
  }
@@ -440,11 +500,11 @@ export class FleetManager {
440
500
  this.logger.info({ count: webhookConfigs.length }, "Webhook emitter initialized");
441
501
  }
442
502
  this.costGuard.on("warn", safeHandler((instance, totalCents, limitCents) => {
443
- this.notifyInstanceTopic(instance, `⚠️ ${instance} cost: ${formatCents(totalCents)} / ${formatCents(limitCents)} (${Math.round(totalCents / limitCents * 100)}%)`);
503
+ this.notifyInstanceTopic(instance, t("cost.approaching", instance, formatCents(totalCents), formatCents(limitCents), Math.round(totalCents / limitCents * 100)));
444
504
  this.webhookEmitter?.emit("cost_warning", instance, { cost_cents: totalCents, limit_cents: limitCents });
445
505
  }, this.logger, "costGuard.warn"));
446
506
  this.costGuard.on("limit", safeHandler(async (instance, totalCents, limitCents) => {
447
- this.notifyInstanceTopic(instance, `🛑 ${instance} daily limit ${formatCents(limitCents)} reached — pausing instance.`);
507
+ this.notifyInstanceTopic(instance, t("cost.limit_reached", instance, formatCents(limitCents)));
448
508
  this.eventLog?.insert(instance, "instance_paused", { reason: "cost_limit", cost_cents: totalCents });
449
509
  this.webhookEmitter?.emit("cost_limit", instance, { cost_cents: totalCents, limit_cents: limitCents });
450
510
  await this.stopInstance(instance);
@@ -476,8 +536,9 @@ export class FleetManager {
476
536
  // Rotate classic channel chat logs daily (piggyback on daily summary timer)
477
537
  this.classicChannels?.rotateLogs();
478
538
  this.rotateInboxes();
479
- // Auto-create general instance(s)one per adapter that lacks a general
539
+ // Auto-create/adopt a general dispatcherONLY for the primary adapter.
480
540
  const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
541
+ const primaryAdapterId = channelConfigs[0] ? (channelConfigs[0].id ?? channelConfigs[0].type) : undefined;
481
542
  const generalInstances = Object.entries(fleet.instances).filter(([, inst]) => inst.general_topic === true);
482
543
  let generalsCreated = false;
483
544
  // Collect unbound generals (no channel_id set) for auto-assignment
@@ -486,6 +547,14 @@ export class FleetManager {
486
547
  const needsGeneral = [];
487
548
  for (const ch of channelConfigs) {
488
549
  const adapterId = ch.id ?? ch.type;
550
+ // Only the primary adapter gets an auto-general. Secondary (persona) bots
551
+ // answer for their explicitly-bound instances only — they don't need or
552
+ // auto-claim a general dispatcher, and must never adopt the primary's
553
+ // unbound general. A general a user manually bound to a secondary
554
+ // (channel_id: <persona>) is left untouched — the auto logic just won't
555
+ // create or reassign bindings for non-primary adapters.
556
+ if (adapterId !== primaryAdapterId)
557
+ continue;
489
558
  // Check if any general is explicitly bound to this adapter
490
559
  if (generalInstances.some(([, inst]) => inst.channel_id === adapterId))
491
560
  continue;
@@ -588,7 +657,7 @@ export class FleetManager {
588
657
  if (this.adapter && topicId) {
589
658
  const chatId = this.adapter.getChatId?.() ?? "";
590
659
  if (chatId) {
591
- this.adapter.sendText(chatId, `⚠️ General instance "${name}" failed to start:\n${errorMsg}`, { threadId: topicId }).catch(() => { });
660
+ this.adapter.sendText(chatId, t("general.start_failed", name, errorMsg), { threadId: topicId }).catch(() => { });
592
661
  }
593
662
  }
594
663
  }
@@ -608,12 +677,19 @@ export class FleetManager {
608
677
  catch (err) {
609
678
  this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
610
679
  }
611
- // Pre-bind general instances to their corresponding adapter
680
+ // Bind instances to their adapter (which bot answers on their behalf).
681
+ // An explicit channel_id is authoritative — this is how a persona instance
682
+ // picks its bot when several share one guild. Generals without a channel_id
683
+ // fall back to a name-contains-adapterId heuristic.
684
+ const channelConfigsForBind = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
612
685
  for (const [name, config] of Object.entries(fleet.instances)) {
686
+ if (config.channel_id) {
687
+ this.bindInstanceAdapter(name, config.channel_id);
688
+ continue;
689
+ }
613
690
  if (!config.general_topic)
614
691
  continue;
615
- const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
616
- for (const ch of channelConfigs) {
692
+ for (const ch of channelConfigsForBind) {
617
693
  const id = ch.id ?? ch.type;
618
694
  if (name.includes(id)) {
619
695
  this.bindInstanceAdapter(name, id);
@@ -621,6 +697,23 @@ export class FleetManager {
621
697
  }
622
698
  }
623
699
  }
700
+ // Guard against a stale/invalid general topic_id. An old auto-general
701
+ // could have written the TG-convention "1" for a Discord general; the DC
702
+ // adapter then throws fetching channel "1" → unhandled → fleet crash loop.
703
+ // Unbind (+ warn) so it's simply skipped, never routed to a bogus channel.
704
+ let fixedGeneral = false;
705
+ for (const [name, cfg] of Object.entries(this.fleetConfig.instances)) {
706
+ if (!cfg.general_topic || cfg.topic_id == null)
707
+ continue;
708
+ const adapterId = this.instanceWorldBinding.get(name) ?? cfg.channel_id;
709
+ if (this.getChannelConfig(adapterId)?.type === "discord" && !/^\d{17,}$/.test(String(cfg.topic_id))) {
710
+ this.logger.warn({ name, topic_id: cfg.topic_id }, "Discord general topic_id is not a valid channel — unbinding to avoid a crash loop");
711
+ delete cfg.topic_id;
712
+ fixedGeneral = true;
713
+ }
714
+ }
715
+ if (fixedGeneral)
716
+ this.saveFleetConfig();
624
717
  // Auto-create topics AFTER adapter is ready (needs adapter.createTopic)
625
718
  await this.topicCommands.autoCreateTopics();
626
719
  const routeSummary = this.routing.rebuild(this.fleetConfig);
@@ -640,7 +733,7 @@ export class FleetManager {
640
733
  let idx = 0;
641
734
  while (idx < channels.length) {
642
735
  const batch = channels.slice(idx, idx + concurrency);
643
- await Promise.allSettled(batch.map(ch => this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend)).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance"))));
736
+ await Promise.allSettled(batch.map(ch => this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend), this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model)).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance"))));
644
737
  idx += concurrency;
645
738
  }
646
739
  }
@@ -659,8 +752,8 @@ export class FleetManager {
659
752
  const agendVersion = _require("../package.json").version ?? "unknown";
660
753
  if (this.adapter && fleet.channel?.group_id) {
661
754
  const text = failedNames.length === 0
662
- ? `Fleet ready. ${started}/${total} instances running. v${agendVersion}`
663
- : `Fleet ready. ${started}/${total} instances running. v${agendVersion} | Failed: ${failedNames.join(", ")}`;
755
+ ? t("fleet.ready", started, total, agendVersion)
756
+ : t("fleet.ready_with_failed", started, total, agendVersion, failedNames.join(", "));
664
757
  this.adapter.sendText(String(fleet.channel.group_id), text, {
665
758
  threadId: generalThreadId != null ? String(generalThreadId) : undefined,
666
759
  }).catch(e => this.logger.warn({ err: e }, "Failed to send fleet start notification"));
@@ -743,9 +836,13 @@ export class FleetManager {
743
836
  const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
744
837
  if (channelConfigs.length === 0)
745
838
  return;
746
- // Start primary adapter (first channel) — this.adapter for backward compat
839
+ // Start primary adapter (first channel) — this.adapter for backward compat.
747
840
  await this.startSingleAdapter(fleet, channelConfigs[0]);
748
- // Start additional adapters
841
+ // Start additional adapters. Every bot registers its own slash commands —
842
+ // Discord slash commands are per-application, so a same-guild secondary bot's
843
+ // /start etc. are distinct entries (labelled with the bot name) and the
844
+ // interaction only reaches the invoked bot. This is how ClassicBot supports
845
+ // multiple bots in one guild: the user picks which bot from autocomplete.
749
846
  for (let i = 1; i < channelConfigs.length; i++) {
750
847
  await this.startAdditionalAdapter(channelConfigs[i]);
751
848
  }
@@ -759,7 +856,7 @@ export class FleetManager {
759
856
  }
760
857
  const accessDir = join(this.dataDir, "access");
761
858
  mkdirSync(accessDir, { recursive: true });
762
- const accessManager = new AccessManager(channelConfig.access, join(accessDir, "access.json"));
859
+ const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, join(accessDir, "access.json"));
763
860
  this.accessManager = accessManager;
764
861
  const inboxDir = join(this.dataDir, "inbox");
765
862
  mkdirSync(inboxDir, { recursive: true });
@@ -815,28 +912,28 @@ export class FleetManager {
815
912
  // Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
816
913
  this.adapter.on("slash_command", safeHandler(async (data) => {
817
914
  if (data.command === "start") {
818
- const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId);
915
+ const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId, adapterId);
819
916
  await data.respond(reply);
820
917
  }
821
918
  else if (data.command === "stop") {
822
- const reply = await this.handleClassicStop(data.channelId);
919
+ const reply = await this.handleClassicStop(data.channelId, adapterId);
823
920
  await data.respond(reply);
824
921
  }
825
922
  else if (data.command === "chat") {
826
923
  const text = data.text ?? "";
827
924
  if (!text) {
828
- await data.respond("Usage: `/chat <message>`");
925
+ await data.respond(t("chat.usage"));
829
926
  return;
830
927
  }
831
- const target = this.routing.resolve(data.channelId);
832
- if (!target || target.kind !== "classic") {
833
- await data.respond("No active agent in this channel. Use `/start` first.");
928
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
929
+ if (!name) {
930
+ await data.respond(t("classic.no_agent_start"));
834
931
  return;
835
932
  }
836
933
  const replyMsgId = await data.respond("👀");
837
934
  const username = data.username ?? data.userId;
838
- ClassicChannelManager.logMessage(target.name, username, `/chat ${text}`, new Date());
839
- await this.forwardToClassicInstance(target.name, text, {
935
+ ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
936
+ await this.forwardToClassicInstance(name, text, {
840
937
  chatId: data.channelId,
841
938
  threadId: data.channelId,
842
939
  messageId: replyMsgId ?? "",
@@ -847,86 +944,88 @@ export class FleetManager {
847
944
  });
848
945
  }
849
946
  else if (data.command === "save") {
850
- await this.handleSlashSave(data);
947
+ await this.handleSlashSave(data, adapterId);
851
948
  }
852
949
  else if (data.command === "load") {
853
950
  // load is kiro-cli/classic only — no claude-code equivalent.
854
951
  if (!this.classicChannels?.isAdmin(data.userId)) {
855
- await data.respond("⛔ This command requires admin access.");
952
+ await data.respond(t("admin.required"));
856
953
  return;
857
954
  }
858
- const target = this.routing.resolve(data.channelId);
859
- if (!target || target.kind !== "classic") {
860
- await data.respond("No active agent in this channel. Use `/start` first.");
955
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
956
+ if (!name) {
957
+ await data.respond(t("classic.no_agent_start"));
861
958
  return;
862
959
  }
863
960
  const filename = data.options?.filename;
864
961
  if (!SAVE_FILENAME_RE.test(filename ?? "")) {
865
- await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
962
+ await data.respond(t("filename.invalid"));
866
963
  return;
867
964
  }
868
- this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
869
- await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
965
+ this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
966
+ await data.respond(t("save.sent", `/chat load ${filename}`, name));
870
967
  }
871
968
  else if (data.command === "compact") {
872
- const target = this.routing.resolve(data.channelId);
873
- if (!target) {
874
- await data.respond("No active agent in this channel.");
969
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
970
+ if (!name) {
971
+ await data.respond(t("classic.no_agent"));
875
972
  return;
876
973
  }
877
- const result = await this.topicCommands.sendCompact(target.name);
974
+ const result = await this.topicCommands.sendCompact(name);
878
975
  await data.respond(result);
879
976
  }
880
977
  else if (data.command === "cancel") {
881
- const target = this.routing.resolve(data.channelId);
882
- if (!target) {
883
- await data.respond("No active agent in this channel.");
978
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
979
+ if (!name) {
980
+ await data.respond(t("classic.no_agent"));
884
981
  return;
885
982
  }
886
- const ok = this.cancelInstance(target.name);
887
- await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
983
+ const ok = this.cancelInstance(name);
984
+ await data.respond(ok ? t("cancel.sent", name) : t("cancel.not_running", name));
888
985
  }
889
986
  else if (data.command === "ctx") {
890
- const target = this.routing.resolve(data.channelId);
891
- if (!target) {
892
- await data.respond("No active agent in this channel.");
987
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
988
+ if (!name) {
989
+ await data.respond(t("classic.no_agent"));
893
990
  return;
894
991
  }
895
992
  // Single source of truth (statusline.json + robust tmux pane fallback).
896
- await data.respond(await this.topicCommands.getCtxText(target.name));
993
+ await data.respond(await this.topicCommands.getCtxText(name));
897
994
  }
898
995
  else if (data.command === "collab") {
996
+ // Classic no longer lives in the routing engine, so a routing hit here is
997
+ // always a fleet-topic instance.
899
998
  const collabTarget = this.routing.resolve(data.channelId);
900
- if (collabTarget && collabTarget.kind !== "classic") {
999
+ if (collabTarget) {
901
1000
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
902
1001
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
903
- await data.respond("⛔ Not authorized");
1002
+ await data.respond(t("not_authorized"));
904
1003
  return;
905
1004
  }
906
1005
  const isCollab = this.toggleFleetCollab(collabTarget.name);
907
- await data.respond(isCollab ? "🤝 Collaboration mode **ON** — bot/webhook messages reach the agent." : "💬 Collaboration mode **OFF**");
1006
+ await data.respond(isCollab ? t("collab.on") : t("collab.off"));
908
1007
  return;
909
1008
  }
910
1009
  if (!this.classicChannels?.isAdmin(data.userId)) {
911
- await data.respond("⛔ This command requires admin access.");
1010
+ await data.respond(t("admin.required"));
912
1011
  return;
913
1012
  }
914
- if (!this.classicChannels.isClassicChannel(data.channelId)) {
915
- await data.respond("No active agent in this channel. Use `/start` first.");
1013
+ if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
1014
+ await data.respond(t("classic.no_agent_start"));
916
1015
  return;
917
1016
  }
918
- const newState = this.classicChannels.toggleCollab(data.channelId);
1017
+ const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
919
1018
  await data.respond(newState
920
- ? "🤝 Collaboration mode **ON** — @mention this bot to trigger the agent. Other bot messages are visible."
921
- : "💬 Collaboration mode **OFF** — use `/chat` to talk to the agent.");
1019
+ ? t("collab.on.classic")
1020
+ : t("collab.off.classic"));
922
1021
  }
923
1022
  else if (data.command === "update") {
924
1023
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
925
1024
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
926
- await data.respond("⛔ Not authorized");
1025
+ await data.respond(t("not_authorized"));
927
1026
  return;
928
1027
  }
929
- await data.respond("📦 Updating AgEnD... Fleet will restart automatically.");
1028
+ await data.respond(t("update.running"));
930
1029
  const { spawn } = await import("node:child_process");
931
1030
  const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
932
1031
  const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
@@ -936,7 +1035,7 @@ export class FleetManager {
936
1035
  else if (data.command === "doctor") {
937
1036
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
938
1037
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
939
- await data.respond("⛔ Not authorized");
1038
+ await data.respond(t("not_authorized"));
940
1039
  return;
941
1040
  }
942
1041
  try {
@@ -958,22 +1057,36 @@ export class FleetManager {
958
1057
  else if (data.command === "sysinfo") {
959
1058
  await data.respond(this.topicCommands.getSysInfoText());
960
1059
  }
1060
+ else if (data.command === "dashboard") {
1061
+ // Reply is ephemeral (adapter defers non-chat commands ephemerally), so
1062
+ // the web-token-bearing URLs are only visible to the caller.
1063
+ const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1064
+ if (allowed.length === 0) {
1065
+ await data.respond(t("dashboard.disabled"));
1066
+ return;
1067
+ }
1068
+ if (!allowed.some(u => String(u) === String(data.userId))) {
1069
+ await data.respond(t("not_authorized"));
1070
+ return;
1071
+ }
1072
+ await data.respond(this.topicCommands.getDashboardText());
1073
+ }
961
1074
  else if (data.command === "restart") {
962
1075
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
963
1076
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
964
- await data.respond("⛔ Not authorized");
1077
+ await data.respond(t("not_authorized"));
965
1078
  return;
966
1079
  }
967
- await data.respond("🔄 Graceful restart — waiting for instances to idle...");
1080
+ await data.respond(t("restart.graceful"));
968
1081
  process.kill(process.pid, "SIGUSR2");
969
1082
  }
970
1083
  else if (data.command === "compact") {
971
- const target = this.routing.resolve(data.channelId);
972
- if (!target) {
973
- await data.respond("No active agent in this channel.");
1084
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1085
+ if (!name) {
1086
+ await data.respond(t("classic.no_agent"));
974
1087
  return;
975
1088
  }
976
- const result = await this.topicCommands.sendCompact(target.name);
1089
+ const result = await this.topicCommands.sendCompact(name);
977
1090
  await data.respond(result);
978
1091
  }
979
1092
  }, this.logger, "adapter.slash_command"));
@@ -1000,7 +1113,7 @@ export class FleetManager {
1000
1113
  this.restartAdapter(this.adapter, "primary").catch(() => { });
1001
1114
  });
1002
1115
  this.adapter.on("new_group_detected", safeHandler((data) => {
1003
- const adminMsg = `🆕 Bot added to new server:\n• Name: ${data.groupTitle}\n• ID: ${data.groupId}\n• Platform: ${data.source}\n\nTo allow: add \`${data.groupId}\` to classicBot.yaml \`allowed_guilds\``;
1116
+ const adminMsg = t("alert.bot_added", data.groupTitle, data.groupId, data.source);
1004
1117
  const generalId = this.findGeneralInstance();
1005
1118
  if (generalId)
1006
1119
  this.notifyInstanceTopic(generalId, adminMsg);
@@ -1017,7 +1130,7 @@ export class FleetManager {
1017
1130
  }, 5 * 60 * 1000);
1018
1131
  }
1019
1132
  /** Start an additional (non-primary) adapter */
1020
- async startAdditionalAdapter(channelConfig) {
1133
+ async startAdditionalAdapter(channelConfig, registerCommands = true) {
1021
1134
  const adapterId = channelConfig.id ?? channelConfig.type;
1022
1135
  const botToken = process.env[channelConfig.bot_token_env];
1023
1136
  if (!botToken) {
@@ -1026,7 +1139,7 @@ export class FleetManager {
1026
1139
  }
1027
1140
  const accessDir = join(this.dataDir, "access");
1028
1141
  mkdirSync(accessDir, { recursive: true });
1029
- const accessManager = new AccessManager(channelConfig.access, join(accessDir, `access-${adapterId}.json`));
1142
+ const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, join(accessDir, `access-${adapterId}.json`));
1030
1143
  const inboxDir = join(this.dataDir, "inbox");
1031
1144
  mkdirSync(inboxDir, { recursive: true });
1032
1145
  const adapter = await createAdapter(channelConfig, {
@@ -1034,6 +1147,7 @@ export class FleetManager {
1034
1147
  botToken,
1035
1148
  accessManager,
1036
1149
  inboxDir,
1150
+ registerCommands,
1037
1151
  });
1038
1152
  const world = new AdapterWorld(adapterId, adapter, accessManager, channelConfig);
1039
1153
  this.worlds.set(adapterId, world);
@@ -1077,28 +1191,28 @@ export class FleetManager {
1077
1191
  // Slash commands: classic bot + admin commands
1078
1192
  adapter.on("slash_command", safeHandler(async (data) => {
1079
1193
  if (data.command === "start") {
1080
- const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId);
1194
+ const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId, adapterId);
1081
1195
  await data.respond(reply);
1082
1196
  }
1083
1197
  else if (data.command === "stop") {
1084
- const reply = await this.handleClassicStop(data.channelId);
1198
+ const reply = await this.handleClassicStop(data.channelId, adapterId);
1085
1199
  await data.respond(reply);
1086
1200
  }
1087
1201
  else if (data.command === "chat") {
1088
1202
  const text = data.text ?? "";
1089
1203
  if (!text) {
1090
- await data.respond("Usage: `/chat <message>`");
1204
+ await data.respond(t("chat.usage"));
1091
1205
  return;
1092
1206
  }
1093
- const target = this.routing.resolve(data.channelId);
1094
- if (!target || target.kind !== "classic") {
1095
- await data.respond("No active agent in this channel. Use `/start` first.");
1207
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
1208
+ if (!name) {
1209
+ await data.respond(t("classic.no_agent_start"));
1096
1210
  return;
1097
1211
  }
1098
1212
  const replyMsgId = await data.respond("👀");
1099
1213
  const username = data.username ?? data.userId;
1100
- ClassicChannelManager.logMessage(target.name, username, `/chat ${text}`, new Date());
1101
- await this.forwardToClassicInstance(target.name, text, {
1214
+ ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
1215
+ await this.forwardToClassicInstance(name, text, {
1102
1216
  chatId: data.channelId,
1103
1217
  threadId: data.channelId,
1104
1218
  messageId: replyMsgId ?? "",
@@ -1109,86 +1223,79 @@ export class FleetManager {
1109
1223
  });
1110
1224
  }
1111
1225
  else if (data.command === "save") {
1112
- await this.handleSlashSave(data);
1226
+ await this.handleSlashSave(data, adapterId);
1113
1227
  }
1114
1228
  else if (data.command === "load") {
1115
1229
  // load is kiro-cli/classic only — no claude-code equivalent.
1116
1230
  if (!this.classicChannels?.isAdmin(data.userId)) {
1117
- await data.respond("⛔ This command requires admin access.");
1231
+ await data.respond(t("admin.required"));
1118
1232
  return;
1119
1233
  }
1120
- const target = this.routing.resolve(data.channelId);
1121
- if (!target || target.kind !== "classic") {
1122
- await data.respond("No active agent in this channel. Use `/start` first.");
1234
+ const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
1235
+ if (!name) {
1236
+ await data.respond(t("classic.no_agent_start"));
1123
1237
  return;
1124
1238
  }
1125
1239
  const filename = data.options?.filename;
1126
1240
  if (!SAVE_FILENAME_RE.test(filename ?? "")) {
1127
- await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1241
+ await data.respond(t("filename.invalid"));
1128
1242
  return;
1129
1243
  }
1130
- this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
1131
- await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
1132
- }
1133
- else if (data.command === "compact") {
1134
- const target = this.routing.resolve(data.channelId);
1135
- if (!target) {
1136
- await data.respond("No active agent in this channel.");
1137
- return;
1138
- }
1139
- const result = await this.topicCommands.sendCompact(target.name);
1140
- await data.respond(result);
1244
+ this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
1245
+ await data.respond(t("save.sent", `/chat load ${filename}`, name));
1141
1246
  }
1142
1247
  else if (data.command === "cancel") {
1143
- const target = this.routing.resolve(data.channelId);
1144
- if (!target) {
1145
- await data.respond("No active agent in this channel.");
1248
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1249
+ if (!name) {
1250
+ await data.respond(t("classic.no_agent"));
1146
1251
  return;
1147
1252
  }
1148
- const ok = this.cancelInstance(target.name);
1149
- await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
1253
+ const ok = this.cancelInstance(name);
1254
+ await data.respond(ok ? t("cancel.sent", name) : t("cancel.not_running", name));
1150
1255
  }
1151
1256
  else if (data.command === "ctx") {
1152
- const target = this.routing.resolve(data.channelId);
1153
- if (!target) {
1154
- await data.respond("No active agent in this channel.");
1257
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1258
+ if (!name) {
1259
+ await data.respond(t("classic.no_agent"));
1155
1260
  return;
1156
1261
  }
1157
1262
  // Single source of truth (statusline.json + robust tmux pane fallback).
1158
- await data.respond(await this.topicCommands.getCtxText(target.name));
1263
+ await data.respond(await this.topicCommands.getCtxText(name));
1159
1264
  }
1160
1265
  else if (data.command === "collab") {
1266
+ // Classic no longer lives in the routing engine, so a routing hit here is
1267
+ // always a fleet-topic instance.
1161
1268
  const collabTarget2 = this.routing.resolve(data.channelId);
1162
- if (collabTarget2 && collabTarget2.kind !== "classic") {
1269
+ if (collabTarget2) {
1163
1270
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1164
1271
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
1165
- await data.respond("⛔ Not authorized");
1272
+ await data.respond(t("not_authorized"));
1166
1273
  return;
1167
1274
  }
1168
1275
  const isCollab = this.toggleFleetCollab(collabTarget2.name);
1169
- await data.respond(isCollab ? "🤝 Collaboration mode **ON** — bot/webhook messages reach the agent." : "💬 Collaboration mode **OFF**");
1276
+ await data.respond(isCollab ? t("collab.on") : t("collab.off"));
1170
1277
  return;
1171
1278
  }
1172
1279
  if (!this.classicChannels?.isAdmin(data.userId)) {
1173
- await data.respond("⛔ This command requires admin access.");
1280
+ await data.respond(t("admin.required"));
1174
1281
  return;
1175
1282
  }
1176
- if (!this.classicChannels.isClassicChannel(data.channelId)) {
1177
- await data.respond("No active agent in this channel. Use `/start` first.");
1283
+ if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
1284
+ await data.respond(t("classic.no_agent_start"));
1178
1285
  return;
1179
1286
  }
1180
- const newState = this.classicChannels.toggleCollab(data.channelId);
1287
+ const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
1181
1288
  await data.respond(newState
1182
- ? "🤝 Collaboration mode **ON** — @mention this bot to trigger the agent. Other bot messages are visible."
1183
- : "💬 Collaboration mode **OFF** — use `/chat` to talk to the agent.");
1289
+ ? t("collab.on.classic")
1290
+ : t("collab.off.classic"));
1184
1291
  }
1185
1292
  else if (data.command === "update") {
1186
1293
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1187
1294
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
1188
- await data.respond("⛔ Not authorized");
1295
+ await data.respond(t("not_authorized"));
1189
1296
  return;
1190
1297
  }
1191
- await data.respond("📦 Updating AgEnD... Fleet will restart automatically.");
1298
+ await data.respond(t("update.running"));
1192
1299
  const { spawn } = await import("node:child_process");
1193
1300
  const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
1194
1301
  const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
@@ -1198,7 +1305,7 @@ export class FleetManager {
1198
1305
  else if (data.command === "doctor") {
1199
1306
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1200
1307
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
1201
- await data.respond("⛔ Not authorized");
1308
+ await data.respond(t("not_authorized"));
1202
1309
  return;
1203
1310
  }
1204
1311
  try {
@@ -1220,22 +1327,36 @@ export class FleetManager {
1220
1327
  else if (data.command === "sysinfo") {
1221
1328
  await data.respond(this.topicCommands.getSysInfoText());
1222
1329
  }
1330
+ else if (data.command === "dashboard") {
1331
+ // Reply is ephemeral (adapter defers non-chat commands ephemerally), so
1332
+ // the web-token-bearing URLs are only visible to the caller.
1333
+ const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1334
+ if (allowed.length === 0) {
1335
+ await data.respond(t("dashboard.disabled"));
1336
+ return;
1337
+ }
1338
+ if (!allowed.some(u => String(u) === String(data.userId))) {
1339
+ await data.respond(t("not_authorized"));
1340
+ return;
1341
+ }
1342
+ await data.respond(this.topicCommands.getDashboardText());
1343
+ }
1223
1344
  else if (data.command === "restart") {
1224
1345
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1225
1346
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
1226
- await data.respond("⛔ Not authorized");
1347
+ await data.respond(t("not_authorized"));
1227
1348
  return;
1228
1349
  }
1229
- await data.respond("🔄 Graceful restart — waiting for instances to idle...");
1350
+ await data.respond(t("restart.graceful"));
1230
1351
  process.kill(process.pid, "SIGUSR2");
1231
1352
  }
1232
1353
  else if (data.command === "compact") {
1233
- const target = this.routing.resolve(data.channelId);
1234
- if (!target) {
1235
- await data.respond("No active agent in this channel.");
1354
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
1355
+ if (!name) {
1356
+ await data.respond(t("classic.no_agent"));
1236
1357
  return;
1237
1358
  }
1238
- const result = await this.topicCommands.sendCompact(target.name);
1359
+ const result = await this.topicCommands.sendCompact(name);
1239
1360
  await data.respond(result);
1240
1361
  }
1241
1362
  }, this.logger, `adapter[${adapterId}].slash_command`));
@@ -1253,7 +1374,7 @@ export class FleetManager {
1253
1374
  }
1254
1375
  }, this.logger, `adapter[${adapterId}].started`));
1255
1376
  adapter.on("new_group_detected", safeHandler((data) => {
1256
- const adminMsg = `🆕 Bot added to new server:\n• Name: ${data.groupTitle}\n• ID: ${data.groupId}\n• Platform: ${data.source}\n\nTo allow: add \`${data.groupId}\` to classicBot.yaml \`allowed_guilds\``;
1377
+ const adminMsg = t("alert.bot_added", data.groupTitle, data.groupId, data.source);
1257
1378
  const generalId = this.findGeneralInstance(adapterId);
1258
1379
  if (generalId)
1259
1380
  this.notifyInstanceTopic(generalId, adminMsg);
@@ -1453,6 +1574,34 @@ export class FleetManager {
1453
1574
  async handleInboundMessage(msg) {
1454
1575
  const threadId = msg.threadId || undefined;
1455
1576
  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");
1577
+ // Multi-adapter dedup: when several bots share a guild, each adapter fires
1578
+ // its own "message" event for the same underlying message. Process it once.
1579
+ // Routing (by topic/channel) and reply-adapter selection (by channel_id
1580
+ // binding) are adapter-independent, so it's safe to let whichever adapter
1581
+ // arrives first handle it.
1582
+ //
1583
+ // EXCEPTION — classic channels with same-channel multi-bot: two bots may own
1584
+ // separate agents in one channel, so each bot must process its OWN copy of
1585
+ // the message (the @mention filter downstream decides who actually forwards).
1586
+ // Key the dedup per-adapter there so a sibling bot's copy isn't dropped.
1587
+ if (msg.messageId) {
1588
+ const classicCid = msg.threadId || msg.chatId;
1589
+ const isClassicMsg = this.classicChannels?.hasChannel(classicCid) ?? false;
1590
+ const dedupKey = isClassicMsg
1591
+ ? `${msg.source}:${msg.chatId}:${msg.messageId}:${msg.adapterId ?? ""}`
1592
+ : `${msg.source}:${msg.chatId}:${msg.messageId}`;
1593
+ if (this.recentMessageIds.has(dedupKey)) {
1594
+ this.logger.debug({ dedupKey, adapterId: msg.adapterId }, "Duplicate inbound across adapters — skipping");
1595
+ return;
1596
+ }
1597
+ this.recentMessageIds.add(dedupKey);
1598
+ if (this.recentMessageIds.size > 1000) {
1599
+ // Set preserves insertion order — drop the oldest key.
1600
+ const oldest = this.recentMessageIds.values().next().value;
1601
+ if (oldest !== undefined)
1602
+ this.recentMessageIds.delete(oldest);
1603
+ }
1604
+ }
1456
1605
  // Bot messages: only allow in collab channels or TG classic with @mention
1457
1606
  if (msg.isBotMessage) {
1458
1607
  if (!threadId) {
@@ -1467,21 +1616,25 @@ export class FleetManager {
1467
1616
  return;
1468
1617
  // Fall through to TG classic handling below
1469
1618
  }
1619
+ else if (this.classicChannels?.hasChannel(threadId)) {
1620
+ // Classic channel (per-bot): bot messages only when THIS bot owns an
1621
+ // agent here and collab is on for it.
1622
+ const classicName = this.classicChannels.getInstanceByChannel(threadId, msg.adapterId);
1623
+ if (!classicName)
1624
+ return;
1625
+ if (!this.classicChannels.isCollab(threadId, msg.adapterId))
1626
+ return;
1627
+ // Fall through to channel handling
1628
+ }
1470
1629
  else {
1471
1630
  const target = this.routing.resolve(threadId);
1472
1631
  if (!target)
1473
1632
  return;
1474
- if (target.kind === "classic") {
1475
- if (!this.classicChannels?.isCollab(threadId))
1476
- return;
1477
- }
1478
- else {
1479
- // Fleet topic: allow if collab enabled OR access mode is open
1480
- const channelCfg = this.getChannelConfig(msg.adapterId);
1481
- const isOpen = channelCfg?.access?.mode === "open";
1482
- if (!isOpen && !this.collabInstances.has(target.name))
1483
- return;
1484
- }
1633
+ // Fleet topic: allow if collab enabled OR access mode is open
1634
+ const channelCfg = this.getChannelConfig(msg.adapterId);
1635
+ const isOpen = channelCfg?.access?.mode === "open";
1636
+ if (!isOpen && !this.collabInstances.has(target.name))
1637
+ return;
1485
1638
  // Fall through to channel handling
1486
1639
  }
1487
1640
  }
@@ -1491,9 +1644,10 @@ export class FleetManager {
1491
1644
  const adapterGroupId = String(this.getChannelConfig(msg.adapterId)?.group_id ?? "");
1492
1645
  const isTelegramClassicCandidate = msg.source === "telegram" && msg.chatId !== adapterGroupId && !threadId;
1493
1646
  if (!isTelegramClassicCandidate) {
1494
- const target = threadId ? this.routing.resolve(threadId) : undefined;
1495
- this.logger.info({ userId: msg.userId, threadId, targetKind: target?.kind, targetName: target?.name }, "Access DENIED for non-allowed user");
1496
- if (!target || target.kind !== "classic")
1647
+ // Classic channels are open to all; check per-bot ownership (or fleet topic).
1648
+ const isClassic = !!(threadId && this.classicChannels?.hasChannel(threadId));
1649
+ this.logger.info({ userId: msg.userId, threadId, isClassic }, "Access DENIED for non-allowed user");
1650
+ if (!isClassic)
1497
1651
  return;
1498
1652
  }
1499
1653
  }
@@ -1538,9 +1692,9 @@ export class FleetManager {
1538
1692
  if (!this.classicChannels.isUserAllowed(msg.userId)) {
1539
1693
  const generalId = this.findGeneralInstance(msg.adapterId);
1540
1694
  if (generalId) {
1541
- 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\``);
1695
+ this.notifyInstanceTopic(generalId, t("alert.unauth_user_private", msg.username, msg.userId, msg.source));
1542
1696
  }
1543
- await msgAdapter?.sendText(chatId, "⛔ You are not in the allowed users list.");
1697
+ await msgAdapter?.sendText(chatId, t("classic.not_allowed_user"));
1544
1698
  return;
1545
1699
  }
1546
1700
  }
@@ -1548,136 +1702,135 @@ export class FleetManager {
1548
1702
  if (!this.classicChannels.isGroupAllowed(chatId)) {
1549
1703
  // Notify admin about new group wanting access
1550
1704
  const groupTitle = msg.chatTitle || chatId;
1551
- const adminMsg = `🆕 New group detected:\n• Name: ${groupTitle}\n• ID: ${chatId}\n• User: ${msg.username} (${msg.userId})\n• Platform: ${msg.source}\n\nTo allow: add \`${chatId}\` to classicBot.yaml \`allowed_guilds\``;
1705
+ const adminMsg = t("alert.new_group", groupTitle, chatId, msg.username, msg.userId, msg.source);
1552
1706
  const generalId = this.findGeneralInstance(msg.adapterId);
1553
1707
  if (generalId) {
1554
1708
  this.notifyInstanceTopic(generalId, adminMsg);
1555
1709
  }
1556
- await msgAdapter?.sendText(chatId, "⏳ Access requested. Waiting for admin approval.");
1710
+ await msgAdapter?.sendText(chatId, t("classic.access_requested"));
1557
1711
  return;
1558
1712
  }
1559
1713
  if (!this.classicChannels.isAdmin(msg.userId)) {
1560
- await msgAdapter?.sendText(chatId, "⛔ Only admins can start agents. Ask an admin to /start.");
1714
+ await msgAdapter?.sendText(chatId, t("classic.admin_only_start"));
1561
1715
  const generalId = this.findGeneralInstance(msg.adapterId);
1562
1716
  if (generalId) {
1563
- 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\``);
1717
+ this.notifyInstanceTopic(generalId, t("alert.start_not_admin", msg.username, msg.userId, msg.source, chatId));
1564
1718
  }
1565
1719
  return;
1566
1720
  }
1567
1721
  }
1568
1722
  const channelName = msg.username || chatId;
1569
- const reply = await this.handleClassicStart(chatId, channelName, msg.userId);
1570
- if (msg.adapterId)
1571
- this.bindInstanceAdapter(classicInstanceName(sanitizeInstanceName(channelName || chatId), chatId), msg.adapterId, true);
1723
+ // handleClassicStart binds the instance to this adapter authoritatively.
1724
+ const reply = await this.handleClassicStart(chatId, channelName, msg.userId, undefined, msg.adapterId);
1572
1725
  await msgAdapter?.sendText(chatId, reply);
1573
1726
  return;
1574
1727
  }
1575
1728
  // Handle /stop command
1576
1729
  if (text === "/stop" || text.startsWith("/stop ")) {
1577
1730
  if (!this.classicChannels.isAdmin(msg.userId)) {
1578
- await msgAdapter?.sendText(chatId, "⛔ Only admins can stop agents.");
1731
+ await msgAdapter?.sendText(chatId, t("classic.admin_only_stop"));
1579
1732
  const generalId = this.findGeneralInstance(msg.adapterId);
1580
1733
  if (generalId) {
1581
- 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\``);
1734
+ this.notifyInstanceTopic(generalId, t("alert.stop_not_admin", msg.username, msg.userId, msg.source, chatId));
1582
1735
  }
1583
1736
  return;
1584
1737
  }
1585
- const reply = await this.handleClassicStop(chatId);
1738
+ const reply = await this.handleClassicStop(chatId, msg.adapterId);
1586
1739
  await msgAdapter?.sendText(chatId, reply);
1587
1740
  return;
1588
1741
  }
1589
1742
  // Handle /compact command (admin only)
1590
1743
  if (text === "/compact" || text.startsWith("/compact@")) {
1591
1744
  if (!this.classicChannels.isAdmin(msg.userId)) {
1592
- await msgAdapter?.sendText(chatId, " /compact requires admin access.");
1745
+ await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/compact"));
1593
1746
  return;
1594
1747
  }
1595
- const compactTarget = this.routing.resolve(chatId);
1596
- if (!compactTarget || compactTarget.kind !== "classic") {
1597
- await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1748
+ const compactName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1749
+ if (!compactName) {
1750
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
1598
1751
  return;
1599
1752
  }
1600
- const result = await this.topicCommands.sendCompact(compactTarget.name);
1753
+ const result = await this.topicCommands.sendCompact(compactName);
1601
1754
  await msgAdapter?.sendText(chatId, result);
1602
1755
  return;
1603
1756
  }
1604
1757
  // Handle /cancel command
1605
1758
  if (text === "/cancel" || text.startsWith("/cancel@")) {
1606
- const cancelTarget = this.routing.resolve(chatId);
1607
- if (!cancelTarget || cancelTarget.kind !== "classic") {
1608
- await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1759
+ const cancelName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1760
+ if (!cancelName) {
1761
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
1609
1762
  return;
1610
1763
  }
1611
- const ok = this.cancelInstance(cancelTarget.name);
1612
- await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelTarget.name}。` : `❌ ${cancelTarget.name} 未在執行。`);
1764
+ const ok = this.cancelInstance(cancelName);
1765
+ await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelName}。` : `❌ ${cancelName} 未在執行。`);
1613
1766
  return;
1614
1767
  }
1615
1768
  // Handle /ctx command
1616
1769
  if (text === "/ctx" || text.startsWith("/ctx@")) {
1617
- const ctxTarget = this.routing.resolve(chatId);
1618
- if (!ctxTarget || ctxTarget.kind !== "classic") {
1619
- await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1770
+ const ctxName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1771
+ if (!ctxName) {
1772
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
1620
1773
  return;
1621
1774
  }
1622
- const reply = await this.topicCommands.getCtxText(ctxTarget.name);
1775
+ const reply = await this.topicCommands.getCtxText(ctxName);
1623
1776
  await msgAdapter?.sendText(chatId, reply);
1624
1777
  return;
1625
1778
  }
1626
1779
  // Handle /save command (admin only)
1627
1780
  if (text === "/save" || text.startsWith("/save ") || text.startsWith("/save@")) {
1628
1781
  if (!this.classicChannels.isAdmin(msg.userId)) {
1629
- await msgAdapter?.sendText(chatId, " /save requires admin access.");
1782
+ await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/save"));
1630
1783
  return;
1631
1784
  }
1632
- const saveTarget = this.routing.resolve(chatId);
1633
- if (!saveTarget || saveTarget.kind !== "classic") {
1634
- await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1785
+ const saveName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1786
+ if (!saveName) {
1787
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
1635
1788
  return;
1636
1789
  }
1637
1790
  const filename = parseSaveFilename(text);
1638
1791
  if (!filename) {
1639
- await msgAdapter?.sendText(chatId, "Usage: /save <filename>");
1792
+ await msgAdapter?.sendText(chatId, t("save.usage"));
1640
1793
  return;
1641
1794
  }
1642
1795
  if (!SAVE_FILENAME_RE.test(filename)) {
1643
- await msgAdapter?.sendText(chatId, "⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1796
+ await msgAdapter?.sendText(chatId, t("filename.invalid"));
1644
1797
  return;
1645
1798
  }
1646
- const backend = this.classicChannels.getBackendByInstance(saveTarget.name, this.fleetConfig?.defaults?.backend);
1799
+ const backend = this.classicChannels.getBackendByInstance(saveName, this.fleetConfig?.defaults?.backend);
1647
1800
  const cmd = saveCommandForBackend(backend, filename);
1648
1801
  if (!cmd) {
1649
1802
  await msgAdapter?.sendText(chatId, SAVE_UNSUPPORTED_MSG);
1650
1803
  return;
1651
1804
  }
1652
- this.pasteRawToClassicInstance(saveTarget.name, cmd);
1653
- await msgAdapter?.sendText(chatId, `✅ Sent \`${cmd}\` to ${saveTarget.name}`);
1805
+ this.pasteRawToClassicInstance(saveName, cmd);
1806
+ await msgAdapter?.sendText(chatId, t("save.sent", cmd, saveName));
1654
1807
  return;
1655
1808
  }
1656
- // Route to classic channel if registered
1657
- const target = this.routing.resolve(chatId);
1658
- if (target?.kind === "classic") {
1809
+ // Route to classic channel if this bot has an agent here (per-bot).
1810
+ const classicName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
1811
+ if (classicName) {
1659
1812
  if (msg.adapterId)
1660
- this.bindInstanceAdapter(target.name, msg.adapterId, true);
1813
+ this.bindInstanceAdapter(classicName, msg.adapterId, true);
1661
1814
  // TG ClassicBot: group requires @mention, private chat forwards directly.
1662
1815
  if (!isPrivateChat && !isBotMentioned) {
1663
1816
  // No trigger: save attachments + react, log, but don't forward to agent
1664
1817
  const syntheticMsg = { ...msg, threadId: chatId, text: rawText.startsWith("/") ? "" : rawText };
1665
- await this.handleClassicChannelMessage(target.name, syntheticMsg);
1818
+ await this.handleClassicChannelMessage(classicName, syntheticMsg);
1666
1819
  return;
1667
1820
  }
1668
1821
  // Strip @bot from text and forward as /chat
1669
1822
  const cleanText = botUser ? text.replace(new RegExp(`@${botUser}`, "gi"), "").trim() : text;
1670
1823
  if (cleanText.startsWith("/raw") && !this.classicChannels.isAdmin(msg.userId)) {
1671
- await msgAdapter?.sendText(chatId, " /raw requires admin access.");
1824
+ await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/raw"));
1672
1825
  return;
1673
1826
  }
1674
1827
  const syntheticMsg = { ...msg, threadId: chatId, text: `/chat ${cleanText}` };
1675
- await this.handleClassicChannelMessage(target.name, syntheticMsg);
1828
+ await this.handleClassicChannelMessage(classicName, syntheticMsg);
1676
1829
  return;
1677
1830
  }
1678
1831
  // Handle @bot without active agent
1679
1832
  if (isBotMentioned) {
1680
- await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1833
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
1681
1834
  return;
1682
1835
  }
1683
1836
  // Unregistered private chat: ignore (don't fall through to General)
@@ -1697,9 +1850,12 @@ export class FleetManager {
1697
1850
  if (msg.adapterId)
1698
1851
  this.bindInstanceAdapter(generalInstance, msg.adapterId, true);
1699
1852
  const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1700
- // React immediately — before any other API calls
1853
+ // React immediately — before any other API calls. Use the adapter BOUND to
1854
+ // the instance (not whichever same-guild bot received the event first) so
1855
+ // exactly the owning bot reacts — no duplicate 👀 from a sibling bot.
1701
1856
  if (msg.chatId && msg.messageId) {
1702
- inboundAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
1857
+ const reactAdapter = this.getAdapterForInstance(generalInstance) ?? inboundAdapter;
1858
+ reactAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
1703
1859
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
1704
1860
  }
1705
1861
  this.warnIfRateLimited(generalInstance, msg);
@@ -1735,6 +1891,18 @@ export class FleetManager {
1735
1891
  }
1736
1892
  return;
1737
1893
  }
1894
+ // Classic channels resolve per-bot (same-channel multi-bot) — a channel can
1895
+ // host two bots' agents. If this channel is classic but THIS bot has no
1896
+ // agent here, a sibling bot owns it; skip rather than misroute to it.
1897
+ if (this.classicChannels?.hasChannel(threadId)) {
1898
+ const classicName = this.classicChannels.getInstanceByChannel(threadId, msg.adapterId);
1899
+ if (!classicName)
1900
+ return;
1901
+ if (msg.adapterId)
1902
+ this.bindInstanceAdapter(classicName, msg.adapterId, true);
1903
+ await this.handleClassicChannelMessage(classicName, msg);
1904
+ return;
1905
+ }
1738
1906
  const target = this.routing.resolve(threadId);
1739
1907
  if (!target) {
1740
1908
  // Only show unbound message for actual forum topics (same group, has threadId)
@@ -1766,9 +1934,12 @@ export class FleetManager {
1766
1934
  if (msg.adapterId)
1767
1935
  this.bindInstanceAdapter(instanceName, msg.adapterId, true);
1768
1936
  const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1769
- // React immediately — before any other Discord API calls
1937
+ // React immediately — before any other Discord API calls. Use the adapter
1938
+ // BOUND to the instance (not whichever same-guild bot received the event
1939
+ // first) so exactly the owning bot reacts — no duplicate 👀 from a sibling.
1770
1940
  if (msg.chatId && msg.messageId) {
1771
- inboundAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
1941
+ const reactAdapter = this.getAdapterForInstance(instanceName) ?? inboundAdapter;
1942
+ reactAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
1772
1943
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
1773
1944
  }
1774
1945
  // These may hit Discord API (topic icon, archive) — do after react
@@ -1893,7 +2064,7 @@ export class FleetManager {
1893
2064
  ts: new Date().toISOString(),
1894
2065
  });
1895
2066
  // Log bot reply to classic instance chat-log
1896
- const isClassic = [...this.routing.entries()].some(([, t]) => t.kind === "classic" && t.name === instanceName);
2067
+ const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
1897
2068
  if (isClassic) {
1898
2069
  ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
1899
2070
  }
@@ -1953,7 +2124,7 @@ export class FleetManager {
1953
2124
  five_hour_pct: rl.five_hour_pct,
1954
2125
  });
1955
2126
  this.webhookEmitter?.emit("schedule_deferred", target, { schedule_id: id, label, five_hour_pct: rl.five_hour_pct });
1956
- this.notifyInstanceTopic(target, `⏳ Schedule "${label ?? id}" deferred — rate limit at ${rl.five_hour_pct}%`);
2127
+ this.notifyInstanceTopic(target, t("schedule.deferred", label ?? id, rl.five_hour_pct));
1957
2128
  this.logger.info({ target, scheduleId: id, rateLimitPct: rl.five_hour_pct }, "Schedule deferred due to rate limit");
1958
2129
  return;
1959
2130
  }
@@ -2440,6 +2611,8 @@ export class FleetManager {
2440
2611
  // Preserve all optional user-configured fields so saveFleetConfig() never silently drops them
2441
2612
  if (inst.general_topic)
2442
2613
  serialized.general_topic = true;
2614
+ if (inst.display_name)
2615
+ serialized.display_name = inst.display_name;
2443
2616
  if (inst.channel_id)
2444
2617
  serialized.channel_id = inst.channel_id;
2445
2618
  if (inst.description)
@@ -2512,15 +2685,17 @@ export class FleetManager {
2512
2685
  startStatuslineWatcher(name) {
2513
2686
  this.statuslineWatcher.watch(name);
2514
2687
  }
2515
- reactMessageStatus(chatId, messageId, emoji) {
2516
- // Find the adapter that owns this chatId (check all adapters, not just primary)
2517
- for (const [, w] of this.worlds) {
2518
- if (w.type === "discord") {
2519
- w.react(chatId, messageId, emoji)
2520
- .catch(e => this.logger.debug({ err: e.message }, "Message status react failed"));
2521
- return;
2522
- }
2523
- }
2688
+ reactMessageStatus(instanceName, chatId, messageId, emoji) {
2689
+ // React via the adapter BOUND to this instance NOT the first discord world.
2690
+ // Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
2691
+ // (bound bot) and the delivery/confirm reactions (some other bot) come from
2692
+ // different bots, leaving a duplicate 👀 that never turns into ✅.
2693
+ const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
2694
+ // Status reactions are Discord-only (TG/others use the inbound react path).
2695
+ if (!adapter || adapter.type !== "discord")
2696
+ return;
2697
+ adapter.react(chatId, messageId, emoji)
2698
+ .catch(e => this.logger.debug({ err: e.message }, "Message status react failed"));
2524
2699
  }
2525
2700
  // ── Model failover ──────────────────────────────────────────────────────
2526
2701
  static FAILOVER_TRIGGER_PCT = 90;
@@ -2569,7 +2744,7 @@ export class FleetManager {
2569
2744
  this.collabInstances.add(instanceName);
2570
2745
  return true;
2571
2746
  }
2572
- notifyInstanceTopic(instanceName, text) {
2747
+ notifyInstanceTopic(instanceName, text, extraOpts) {
2573
2748
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
2574
2749
  if (!adapter)
2575
2750
  return;
@@ -2578,21 +2753,20 @@ export class FleetManager {
2578
2753
  // Fleet topic instance
2579
2754
  const threadId = this.fleetConfig?.instances[instanceName]?.topic_id;
2580
2755
  if (threadId != null && groupId) {
2581
- adapter.sendText(String(groupId), text, { threadId: String(threadId) })
2756
+ adapter.sendText(String(groupId), text, { threadId: String(threadId), ...extraOpts })
2582
2757
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send instance topic notification"));
2583
2758
  return;
2584
2759
  }
2585
- // Classic instance: find chatId from routing table
2586
- for (const [chatId, target] of this.routing.entries()) {
2587
- if (target.kind === "classic" && target.name === instanceName) {
2588
- adapter.sendText(chatId, text)
2589
- .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send classic notification"));
2590
- return;
2591
- }
2760
+ // Classic instance: find its channelId from the classic manager
2761
+ const classicChatId = this.classicChannels?.getChannelIdByInstance(instanceName);
2762
+ if (classicChatId) {
2763
+ adapter.sendText(classicChatId, text, extraOpts)
2764
+ .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send classic notification"));
2765
+ return;
2592
2766
  }
2593
2767
  // Fallback: send to group without threadId
2594
2768
  if (groupId) {
2595
- adapter.sendText(String(groupId), text)
2769
+ adapter.sendText(String(groupId), text, extraOpts)
2596
2770
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
2597
2771
  }
2598
2772
  }
@@ -2605,19 +2779,23 @@ export class FleetManager {
2605
2779
  * Picks the backend-appropriate command (kiro → /chat save, claude → /export);
2606
2780
  * unsupported backends get a clear error. Routes via classic paste or fleet IPC.
2607
2781
  */
2608
- async handleSlashSave(data) {
2782
+ async handleSlashSave(data, adapterId) {
2609
2783
  if (!this.classicChannels?.isAdmin(data.userId)) {
2610
- await data.respond("⛔ This command requires admin access.");
2784
+ await data.respond(t("admin.required"));
2611
2785
  return;
2612
2786
  }
2613
- const target = this.routing.resolve(data.channelId);
2787
+ // Classic resolves per-bot (same-channel multi-bot); otherwise a fleet topic.
2788
+ const classicName = this.classicChannels.getInstanceByChannel(data.channelId, adapterId);
2789
+ const target = classicName
2790
+ ? { kind: "classic", name: classicName }
2791
+ : this.routing.resolve(data.channelId);
2614
2792
  if (!target) {
2615
- await data.respond("No active agent in this channel. Use `/start` first.");
2793
+ await data.respond(t("classic.no_agent_start"));
2616
2794
  return;
2617
2795
  }
2618
2796
  const filename = data.options?.filename ?? "";
2619
2797
  if (!SAVE_FILENAME_RE.test(filename)) {
2620
- await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
2798
+ await data.respond(t("filename.invalid"));
2621
2799
  return;
2622
2800
  }
2623
2801
  const backend = target.kind === "classic"
@@ -2636,7 +2814,7 @@ export class FleetManager {
2636
2814
  else {
2637
2815
  this.instanceIpcClients.get(target.name)?.send({ type: "raw_paste", content: cmd });
2638
2816
  }
2639
- await data.respond(`✅ Sent \`${cmd}\` to ${target.name}`);
2817
+ await data.respond(t("save.sent", cmd, target.name));
2640
2818
  }
2641
2819
  /** Whether the instance currently has at least one live cancel button. */
2642
2820
  hasCancelButton(instanceName) {
@@ -2665,13 +2843,8 @@ export class FleetManager {
2665
2843
  threadId = String(topicId);
2666
2844
  }
2667
2845
  else {
2668
- // Classic instance: chatId from the routing table.
2669
- for (const [cid, target] of this.routing.entries()) {
2670
- if (target.kind === "classic" && target.name === instanceName) {
2671
- chatId = cid;
2672
- break;
2673
- }
2674
- }
2846
+ // Classic instance: channelId from the classic manager.
2847
+ chatId = this.classicChannels?.getChannelIdByInstance(instanceName);
2675
2848
  // General / flat fallback: post to the group (no thread).
2676
2849
  if (!chatId && groupId)
2677
2850
  chatId = String(groupId);
@@ -2683,7 +2856,7 @@ export class FleetManager {
2683
2856
  type: "cancel",
2684
2857
  instanceName,
2685
2858
  message: "👀 處理中…",
2686
- choices: [{ id: `cancel:${instanceName}`, label: "🛑 取消" }],
2859
+ choices: [{ id: `cancel:${instanceName}`, label: t("cancel.button") }],
2687
2860
  }, threadId ? { threadId } : undefined);
2688
2861
  // A concurrent sendCancelButton for the same instance may have posted its
2689
2862
  // own button while we awaited notifyAlert. Retire any other buttons for
@@ -3154,7 +3327,7 @@ When users create specialized instances, suggest these configurations:
3154
3327
  async handleClassicChannelMessage(instanceName, msg) {
3155
3328
  const text = msg.text ?? "";
3156
3329
  const channelId = msg.threadId ?? msg.chatId;
3157
- const isCollabMode = this.classicChannels?.isCollab(channelId) ?? false;
3330
+ const isCollabMode = this.classicChannels?.isCollab(channelId, msg.adapterId) ?? false;
3158
3331
  // Handle /ctx in classic mode — always, regardless of collab mode
3159
3332
  if (text === "/ctx" || text.startsWith("/ctx@")) {
3160
3333
  const reply = await this.topicCommands.getCtxText(instanceName);
@@ -3181,8 +3354,15 @@ When users create specialized instances, suggest these configurations:
3181
3354
  : "");
3182
3355
  ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText);
3183
3356
  this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message");
3184
- // Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here
3185
- const adapterBotUserId = this.worlds.get(msg.adapterId ?? "")?.botUserId ?? this.botUserId;
3357
+ // Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here.
3358
+ // Each bot matches ONLY its own id. A secondary bot must NOT fall back to the
3359
+ // process-wide botUserId (the primary's) — otherwise, in a same-channel
3360
+ // multi-bot setup, an @mention of the primary would also match the secondary
3361
+ // and BOTH bots would react 👀 and forward. Only the primary adapter may use
3362
+ // the fallback.
3363
+ const mentionWorld = this.worlds.get(msg.adapterId ?? "");
3364
+ const isPrimaryAdapter = !mentionWorld || mentionWorld.adapter === this.adapter;
3365
+ const adapterBotUserId = mentionWorld?.botUserId ?? (isPrimaryAdapter ? this.botUserId : undefined);
3186
3366
  const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
3187
3367
  const isMentioned = mentionTag && text.includes(mentionTag);
3188
3368
  if (!isMentioned) {
@@ -3360,7 +3540,11 @@ When users create specialized instances, suggest these configurations:
3360
3540
  }
3361
3541
  /** Forward a message to a classic channel instance with chat log context */
3362
3542
  async forwardToClassicInstance(instanceName, text, msg, extraMeta) {
3363
- const contextLines = this.classicChannels?.getContextLines(msg.chatId) ?? 5;
3543
+ // Resolve the channel/adapter from the instance itself so per-channel context
3544
+ // config is correct even for a same-channel second bot.
3545
+ const ctxAdapterId = this.classicChannels?.getAdapterIdByInstance(instanceName);
3546
+ const ctxChannelId = this.classicChannels?.getChannelIdByInstance(instanceName) ?? msg.chatId;
3547
+ const contextLines = this.classicChannels?.getContextLines(ctxChannelId, ctxAdapterId) ?? 5;
3364
3548
  const logContext = this.getRecentChatLog(instanceName, contextLines);
3365
3549
  const fullText = logContext
3366
3550
  ? `[Chat log for context]\n${logContext}\n\n[User message]\n${text}`
@@ -3446,44 +3630,51 @@ When users create specialized instances, suggest these configurations:
3446
3630
  await this.startInstance(instanceName, config, topicMode);
3447
3631
  }
3448
3632
  /** Handle /start slash command — register classic channel */
3449
- async handleClassicStart(channelId, channelName, userId, guildId) {
3633
+ async handleClassicStart(channelId, channelName, userId, guildId, adapterId) {
3450
3634
  if (!this.classicChannels)
3451
3635
  return "Classic channel manager not initialized.";
3452
3636
  if (guildId && !this.classicChannels.isGuildAllowed(guildId)) {
3453
3637
  const generalId = this.findGeneralInstance();
3454
3638
  if (generalId) {
3455
- 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\``);
3639
+ this.notifyInstanceTopic(generalId, t("alert.unauth_guild", guildId, userId));
3456
3640
  }
3457
- return "⛔ This server is not in the allowed guilds list.";
3641
+ return t("classic.not_authorized_guild");
3458
3642
  }
3459
- if (this.classicChannels.isClassicChannel(channelId))
3460
- return "This channel already has an active agent. Use /chat to talk.";
3643
+ // Per-bot check: a second bot may /start in the same channel (own agent).
3644
+ if (this.classicChannels.isClassicChannel(channelId, adapterId))
3645
+ return t("classic.already_active");
3646
+ // Classic no longer lives in the routing engine, so this only guards against
3647
+ // a fleet topic-mode instance colliding with the channel.
3461
3648
  if (this.routing.resolve(channelId))
3462
- return "This channel is already bound to a topic-mode instance.";
3463
- const instanceName = classicInstanceName(sanitizeInstanceName(channelName || channelId), channelId);
3464
- this.classicChannels.register(channelId, instanceName, channelName || channelId, userId);
3465
- this.routing.register(channelId, { kind: "classic", name: instanceName });
3466
- await this.startClassicInstance(instanceName, this.classicChannels.getBackend(channelId, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channelId), this.classicChannels.getModel(channelId, this.fleetConfig?.defaults?.model));
3649
+ return t("classic.topic_bound");
3650
+ const instanceName = this.classicChannels.deriveInstanceName(channelName || channelId, channelId, adapterId);
3651
+ this.classicChannels.register(channelId, adapterId, instanceName, channelName || channelId, userId);
3652
+ // Bind this classic instance to the bot that started it (authoritative), so
3653
+ // replies/cancel go out through that bot even though every same-guild bot
3654
+ // also sees the channel's messages.
3655
+ if (adapterId)
3656
+ this.bindInstanceAdapter(instanceName, adapterId);
3657
+ 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));
3467
3658
  this.reregisterClassicChannels();
3468
3659
  // Auto-enable collab for Discord classic channels (TG uses @mention directly without collab mode)
3469
- if (guildId && !this.classicChannels.isCollab(channelId)) {
3470
- this.classicChannels.toggleCollab(channelId);
3660
+ if (guildId && !this.classicChannels.isCollab(channelId, adapterId)) {
3661
+ this.classicChannels.toggleCollab(channelId, adapterId);
3471
3662
  }
3472
- this.logger.info({ channelId, instanceName, userId }, "Classic channel started");
3473
- return `✅ Agent started in this channel. Use \`/chat <message>\` or @mention to talk.`;
3663
+ this.logger.info({ channelId, adapterId, instanceName, userId }, "Classic channel started");
3664
+ return t("classic.started");
3474
3665
  }
3475
3666
  /** Handle /stop slash command — unregister classic channel */
3476
- async handleClassicStop(channelId) {
3667
+ async handleClassicStop(channelId, adapterId) {
3477
3668
  if (!this.classicChannels)
3478
3669
  return "Classic channel manager not initialized.";
3479
- const ch = this.classicChannels.unregister(channelId);
3670
+ const ch = this.classicChannels.unregister(channelId, adapterId);
3480
3671
  if (!ch)
3481
- return "No active agent in this channel.";
3482
- this.routing.unregister(channelId);
3672
+ return t("classic.no_agent");
3673
+ this.instanceWorldBinding.delete(ch.instanceName);
3483
3674
  await this.stopInstance(ch.instanceName).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance"));
3484
3675
  this.reregisterClassicChannels();
3485
- this.logger.info({ channelId, instanceName: ch.instanceName }, "Classic channel stopped");
3486
- return `🛑 Agent stopped in this channel.`;
3676
+ this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
3677
+ return t("classic.stopped");
3487
3678
  }
3488
3679
  async stopAll() {
3489
3680
  this.ipcStoppingInstances.add("__fleet_stopping__");
@@ -3626,7 +3817,7 @@ When users create specialized instances, suggest these configurations:
3626
3817
  this.logger.info(`Full restart: waiting for ${instanceNames.length} instances to idle...`);
3627
3818
  const groupId = this.fleetConfig?.channel?.group_id;
3628
3819
  if (groupId && this.adapter) {
3629
- await this.adapter.sendText(String(groupId), `🔄 Full restart initiated — waiting for all instances to idle, then reloading process...`)
3820
+ await this.adapter.sendText(String(groupId), t("restart.full_initiated"))
3630
3821
  .catch(e => this.logger.warn({ err: e }, "Failed to post full restart notification"));
3631
3822
  }
3632
3823
  // Wait for idle with 5-minute timeout
@@ -3737,7 +3928,7 @@ When users create specialized instances, suggest these configurations:
3737
3928
  const generalThreadId = generalName ? this.fleetConfig?.instances[generalName]?.topic_id : undefined;
3738
3929
  const notifyOpts = { threadId: generalThreadId != null ? String(generalThreadId) : undefined };
3739
3930
  if (groupId && this.adapter) {
3740
- await this.adapter.sendText(String(groupId), `🔄 Graceful restart initiated — waiting for all instances to idle...`, notifyOpts)
3931
+ await this.adapter.sendText(String(groupId), t("restart.graceful_initiated"), notifyOpts)
3741
3932
  .catch(e => this.logger.warn({ err: e }, "Failed to post restart notification"));
3742
3933
  }
3743
3934
  const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
@@ -3811,7 +4002,7 @@ When users create specialized instances, suggest these configurations:
3811
4002
  let idx = 0;
3812
4003
  while (idx < channels.length) {
3813
4004
  const batch = channels.slice(idx, idx + concurrency);
3814
- await Promise.allSettled(batch.map(ch => this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend)).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance"))));
4005
+ await Promise.allSettled(batch.map(ch => this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend), this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model)).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance"))));
3815
4006
  idx += concurrency;
3816
4007
  }
3817
4008
  }
@@ -3828,8 +4019,8 @@ When users create specialized instances, suggest these configurations:
3828
4019
  const _require2 = createRequire(import.meta.url);
3829
4020
  const agendVersion2 = _require2("../package.json").version ?? "unknown";
3830
4021
  const restartText = failedNames.length === 0
3831
- ? `Fleet ready. ${started}/${total} instances running. v${agendVersion2}`
3832
- : `Fleet ready. ${started}/${total} instances running. v${agendVersion2} | Failed: ${failedNames.join(", ")}`;
4022
+ ? t("fleet.ready", started, total, agendVersion2)
4023
+ : t("fleet.ready_with_failed", started, total, agendVersion2, failedNames.join(", "));
3833
4024
  await this.adapter.sendText(String(groupId), restartText, notifyOpts)
3834
4025
  .catch(e => this.logger.warn({ err: e }, "Failed to post restart completion notification"));
3835
4026
  // Notify each instance's channel — staggered to avoid rate limit storm
@@ -3894,7 +4085,10 @@ When users create specialized instances, suggest these configurations:
3894
4085
  if (target && this.semverGt(target, currentVersion)) {
3895
4086
  const generalId = this.findGeneralInstance();
3896
4087
  if (generalId) {
3897
- this.notifyInstanceTopic(generalId, `🆕 AgEnD v${target} available (current: v${currentVersion}). Use \`/update\` to upgrade.`);
4088
+ // Beta builds have no per-tag release page link to the latest release.
4089
+ const releaseUrl = target.includes("-") ? "https://github.com/songsid/AgEnD/releases/latest" : `https://github.com/songsid/AgEnD/releases/tag/v${target}`;
4090
+ // Suppress the (large, ugly) GitHub release link preview on Telegram.
4091
+ this.notifyInstanceTopic(generalId, t("update.available", `v${target}`) + ` (current: v${currentVersion})\n📄 ${releaseUrl}`, { disablePreview: true });
3898
4092
  }
3899
4093
  }
3900
4094
  }
@@ -4058,6 +4252,7 @@ When users create specialized instances, suggest these configurations:
4058
4252
  res.writeHead(200);
4059
4253
  res.end(JSON.stringify({
4060
4254
  ...sysInfo,
4255
+ version: this.currentVersion,
4061
4256
  instances: enriched,
4062
4257
  }));
4063
4258
  return;
@@ -4164,6 +4359,8 @@ When users create specialized instances, suggest these configurations:
4164
4359
  const url = new URL(req.url ?? "/", `http://localhost:${port}`);
4165
4360
  if (handleViewRequest(req, res, url, this))
4166
4361
  return;
4362
+ if (handleSettingsRequest(req, res, url, this))
4363
+ return;
4167
4364
  if (handleWebRequest(req, res, url, this))
4168
4365
  return;
4169
4366
  res.writeHead(404);