@songsid/agend 2.0.11-beta.8 → 2.0.11

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 (46) hide show
  1. package/dist/channel/adapters/discord.js +21 -16
  2. package/dist/channel/adapters/discord.js.map +1 -1
  3. package/dist/channel/adapters/telegram.js +1 -0
  4. package/dist/channel/adapters/telegram.js.map +1 -1
  5. package/dist/channel/mcp-tools.js +3 -0
  6. package/dist/channel/mcp-tools.js.map +1 -1
  7. package/dist/channel/tool-router.js +29 -2
  8. package/dist/channel/tool-router.js.map +1 -1
  9. package/dist/channel/types.d.ts +2 -0
  10. package/dist/classic-channel-manager.d.ts +59 -12
  11. package/dist/classic-channel-manager.js +121 -30
  12. package/dist/classic-channel-manager.js.map +1 -1
  13. package/dist/cli.js +70 -21
  14. package/dist/cli.js.map +1 -1
  15. package/dist/config-validator.d.ts +20 -0
  16. package/dist/config-validator.js +154 -0
  17. package/dist/config-validator.js.map +1 -0
  18. package/dist/fleet-manager.d.ts +21 -5
  19. package/dist/fleet-manager.js +412 -258
  20. package/dist/fleet-manager.js.map +1 -1
  21. package/dist/instance-lifecycle.d.ts +1 -1
  22. package/dist/instance-lifecycle.js +12 -11
  23. package/dist/instance-lifecycle.js.map +1 -1
  24. package/dist/locale.d.ts +19 -0
  25. package/dist/locale.js +179 -0
  26. package/dist/locale.js.map +1 -0
  27. package/dist/outbound-schemas.d.ts +1 -0
  28. package/dist/outbound-schemas.js +1 -0
  29. package/dist/outbound-schemas.js.map +1 -1
  30. package/dist/quickstart.js +222 -9
  31. package/dist/quickstart.js.map +1 -1
  32. package/dist/scheduler/scheduler.js +8 -2
  33. package/dist/scheduler/scheduler.js.map +1 -1
  34. package/dist/settings-api.d.ts +33 -0
  35. package/dist/settings-api.js +283 -0
  36. package/dist/settings-api.js.map +1 -0
  37. package/dist/topic-commands.d.ts +14 -0
  38. package/dist/topic-commands.js +78 -15
  39. package/dist/topic-commands.js.map +1 -1
  40. package/dist/types.d.ts +2 -0
  41. package/dist/ui/settings.html +617 -0
  42. package/dist/ui/view.html +271 -43
  43. package/dist/view-api.d.ts +9 -0
  44. package/dist/view-api.js +101 -41
  45. package/dist/view-api.js.map +1 -1
  46. package/package.json +1 -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) {
@@ -179,27 +183,51 @@ export class FleetManager {
179
183
  }
180
184
  return this.routing.map;
181
185
  }
182
- /** 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
+ */
183
194
  reregisterClassicChannels() {
184
195
  if (!this.classicChannels)
185
196
  return;
186
197
  const channels = this.classicChannels.getAll();
187
- for (const ch of channels) {
188
- this.routing.register(ch.channelId, { kind: "classic", name: ch.instanceName });
189
- }
190
198
  // Always update adapter openChannels (including empty — clears stale entries on /stop)
191
- for (const [, w] of this.worlds) {
199
+ for (const [adapterId, w] of this.worlds) {
192
200
  if (typeof w.adapter?.setOpenChannels === "function") {
193
- 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);
194
203
  }
195
204
  }
196
205
  if (channels.length > 0) {
197
- this.logger.info({ count: channels.length }, "Registered classic channel routes");
206
+ this.logger.info({ count: channels.length }, "Refreshed classic channel open-lists");
198
207
  }
199
208
  }
200
209
  getInstanceDir(name) {
201
210
  return join(this.dataDir, "instances", name);
202
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
+ }
203
231
  /** Get the adapter bound to an instance, falling back to primary adapter */
204
232
  getAdapterForInstance(name) {
205
233
  const worldId = this.instanceWorldBinding.get(name);
@@ -237,6 +265,12 @@ export class FleetManager {
237
265
  const cfg = this.fleetConfig?.instances[name];
238
266
  if (fromInbound && (cfg?.general_topic || cfg?.channel_id))
239
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))
273
+ return;
240
274
  this.instanceWorldBinding.set(name, adapterId);
241
275
  }
242
276
  getInstanceStatus(name) {
@@ -364,6 +398,7 @@ export class FleetManager {
364
398
  const { rotateLogIfNeeded } = await import("./logger.js");
365
399
  rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
366
400
  const fleet = this.loadConfig(configPath);
401
+ setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
367
402
  const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
368
403
  // Set tmux socket isolation for custom AGEND_HOME
369
404
  const { getTmuxSocketName: getSocket } = await import("./paths.js");
@@ -405,10 +440,19 @@ export class FleetManager {
405
440
  const pidPath = join(this.dataDir, "fleet.pid");
406
441
  writeFileSync(pidPath, String(process.pid), "utf-8");
407
442
  this.eventLog = new EventLog(join(this.dataDir, "events.db"));
408
- // 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.
409
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).
410
453
  for (const ch of this.classicChannels.getAll()) {
411
- this.routing.register(ch.channelId, { kind: "classic", name: ch.instanceName });
454
+ if (ch.adapterId)
455
+ this.instanceWorldBinding.set(ch.instanceName, ch.adapterId);
412
456
  }
413
457
  // Poll classicBot.yaml for external changes every 30s
414
458
  this.classicReloadTimer = setInterval(async () => {
@@ -416,21 +460,27 @@ export class FleetManager {
416
460
  if (!this.classicChannels)
417
461
  return;
418
462
  const fleetBackend = this.fleetConfig?.defaults?.backend;
463
+ const fleetModel = this.fleetConfig?.defaults?.model;
419
464
  const oldBackends = new Map();
465
+ const oldModels = new Map();
420
466
  for (const ch of this.classicChannels.getAll()) {
421
467
  oldBackends.set(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend));
468
+ oldModels.set(ch.instanceName, this.classicChannels.getModel(ch.channelId, ch.adapterId, fleetModel));
422
469
  }
423
470
  if (!this.classicChannels.checkReload())
424
471
  return;
425
472
  this.reregisterClassicChannels();
426
473
  for (const ch of this.classicChannels.getAll()) {
427
474
  const newBackend = this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend);
428
- if (this.daemons.has(ch.instanceName) && oldBackends.get(ch.instanceName) !== newBackend) {
429
- 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");
430
480
  await this.stopInstance(ch.instanceName).catch(() => { });
431
481
  // Small delay to let tmux window clean up
432
482
  await new Promise(r => setTimeout(r, 2000));
433
- 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"));
434
484
  }
435
485
  }
436
486
  }
@@ -450,11 +500,11 @@ export class FleetManager {
450
500
  this.logger.info({ count: webhookConfigs.length }, "Webhook emitter initialized");
451
501
  }
452
502
  this.costGuard.on("warn", safeHandler((instance, totalCents, limitCents) => {
453
- 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)));
454
504
  this.webhookEmitter?.emit("cost_warning", instance, { cost_cents: totalCents, limit_cents: limitCents });
455
505
  }, this.logger, "costGuard.warn"));
456
506
  this.costGuard.on("limit", safeHandler(async (instance, totalCents, limitCents) => {
457
- this.notifyInstanceTopic(instance, `🛑 ${instance} daily limit ${formatCents(limitCents)} reached — pausing instance.`);
507
+ this.notifyInstanceTopic(instance, t("cost.limit_reached", instance, formatCents(limitCents)));
458
508
  this.eventLog?.insert(instance, "instance_paused", { reason: "cost_limit", cost_cents: totalCents });
459
509
  this.webhookEmitter?.emit("cost_limit", instance, { cost_cents: totalCents, limit_cents: limitCents });
460
510
  await this.stopInstance(instance);
@@ -486,8 +536,9 @@ export class FleetManager {
486
536
  // Rotate classic channel chat logs daily (piggyback on daily summary timer)
487
537
  this.classicChannels?.rotateLogs();
488
538
  this.rotateInboxes();
489
- // Auto-create general instance(s)one per adapter that lacks a general
539
+ // Auto-create/adopt a general dispatcherONLY for the primary adapter.
490
540
  const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
541
+ const primaryAdapterId = channelConfigs[0] ? (channelConfigs[0].id ?? channelConfigs[0].type) : undefined;
491
542
  const generalInstances = Object.entries(fleet.instances).filter(([, inst]) => inst.general_topic === true);
492
543
  let generalsCreated = false;
493
544
  // Collect unbound generals (no channel_id set) for auto-assignment
@@ -496,6 +547,14 @@ export class FleetManager {
496
547
  const needsGeneral = [];
497
548
  for (const ch of channelConfigs) {
498
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;
499
558
  // Check if any general is explicitly bound to this adapter
500
559
  if (generalInstances.some(([, inst]) => inst.channel_id === adapterId))
501
560
  continue;
@@ -598,7 +657,7 @@ export class FleetManager {
598
657
  if (this.adapter && topicId) {
599
658
  const chatId = this.adapter.getChatId?.() ?? "";
600
659
  if (chatId) {
601
- 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(() => { });
602
661
  }
603
662
  }
604
663
  }
@@ -638,6 +697,23 @@ export class FleetManager {
638
697
  }
639
698
  }
640
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();
641
717
  // Auto-create topics AFTER adapter is ready (needs adapter.createTopic)
642
718
  await this.topicCommands.autoCreateTopics();
643
719
  const routeSummary = this.routing.rebuild(this.fleetConfig);
@@ -657,7 +733,7 @@ export class FleetManager {
657
733
  let idx = 0;
658
734
  while (idx < channels.length) {
659
735
  const batch = channels.slice(idx, idx + concurrency);
660
- 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"))));
661
737
  idx += concurrency;
662
738
  }
663
739
  }
@@ -676,8 +752,8 @@ export class FleetManager {
676
752
  const agendVersion = _require("../package.json").version ?? "unknown";
677
753
  if (this.adapter && fleet.channel?.group_id) {
678
754
  const text = failedNames.length === 0
679
- ? `Fleet ready. ${started}/${total} instances running. v${agendVersion}`
680
- : `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(", "));
681
757
  this.adapter.sendText(String(fleet.channel.group_id), text, {
682
758
  threadId: generalThreadId != null ? String(generalThreadId) : undefined,
683
759
  }).catch(e => this.logger.warn({ err: e }, "Failed to send fleet start notification"));
@@ -761,15 +837,14 @@ export class FleetManager {
761
837
  if (channelConfigs.length === 0)
762
838
  return;
763
839
  // Start primary adapter (first channel) — this.adapter for backward compat.
764
- // The primary owns the guild's slash commands.
765
840
  await this.startSingleAdapter(fleet, channelConfigs[0]);
766
- const primaryGroup = String(channelConfigs[0].group_id ?? "");
767
- // Start additional adapters. A secondary bot in the SAME guild as the primary
768
- // must not re-register the guild's slash commands (they'd appear twice); a bot
769
- // in a different guild still registers its own.
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.
770
846
  for (let i = 1; i < channelConfigs.length; i++) {
771
- const sameGuild = String(channelConfigs[i].group_id ?? "") === primaryGroup;
772
- await this.startAdditionalAdapter(channelConfigs[i], !sameGuild);
847
+ await this.startAdditionalAdapter(channelConfigs[i]);
773
848
  }
774
849
  }
775
850
  /** Start the primary adapter (backward-compatible, sets this.adapter) */
@@ -781,7 +856,7 @@ export class FleetManager {
781
856
  }
782
857
  const accessDir = join(this.dataDir, "access");
783
858
  mkdirSync(accessDir, { recursive: true });
784
- const accessManager = new AccessManager(channelConfig.access, join(accessDir, "access.json"));
859
+ const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, join(accessDir, "access.json"));
785
860
  this.accessManager = accessManager;
786
861
  const inboxDir = join(this.dataDir, "inbox");
787
862
  mkdirSync(inboxDir, { recursive: true });
@@ -837,28 +912,28 @@ export class FleetManager {
837
912
  // Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
838
913
  this.adapter.on("slash_command", safeHandler(async (data) => {
839
914
  if (data.command === "start") {
840
- 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);
841
916
  await data.respond(reply);
842
917
  }
843
918
  else if (data.command === "stop") {
844
- const reply = await this.handleClassicStop(data.channelId);
919
+ const reply = await this.handleClassicStop(data.channelId, adapterId);
845
920
  await data.respond(reply);
846
921
  }
847
922
  else if (data.command === "chat") {
848
923
  const text = data.text ?? "";
849
924
  if (!text) {
850
- await data.respond("Usage: `/chat <message>`");
925
+ await data.respond(t("chat.usage"));
851
926
  return;
852
927
  }
853
- const target = this.routing.resolve(data.channelId);
854
- if (!target || target.kind !== "classic") {
855
- 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"));
856
931
  return;
857
932
  }
858
933
  const replyMsgId = await data.respond("👀");
859
934
  const username = data.username ?? data.userId;
860
- ClassicChannelManager.logMessage(target.name, username, `/chat ${text}`, new Date());
861
- await this.forwardToClassicInstance(target.name, text, {
935
+ ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
936
+ await this.forwardToClassicInstance(name, text, {
862
937
  chatId: data.channelId,
863
938
  threadId: data.channelId,
864
939
  messageId: replyMsgId ?? "",
@@ -869,86 +944,88 @@ export class FleetManager {
869
944
  });
870
945
  }
871
946
  else if (data.command === "save") {
872
- await this.handleSlashSave(data);
947
+ await this.handleSlashSave(data, adapterId);
873
948
  }
874
949
  else if (data.command === "load") {
875
950
  // load is kiro-cli/classic only — no claude-code equivalent.
876
951
  if (!this.classicChannels?.isAdmin(data.userId)) {
877
- await data.respond("⛔ This command requires admin access.");
952
+ await data.respond(t("admin.required"));
878
953
  return;
879
954
  }
880
- const target = this.routing.resolve(data.channelId);
881
- if (!target || target.kind !== "classic") {
882
- 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"));
883
958
  return;
884
959
  }
885
960
  const filename = data.options?.filename;
886
961
  if (!SAVE_FILENAME_RE.test(filename ?? "")) {
887
- await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
962
+ await data.respond(t("filename.invalid"));
888
963
  return;
889
964
  }
890
- this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
891
- 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));
892
967
  }
893
968
  else if (data.command === "compact") {
894
- const target = this.routing.resolve(data.channelId);
895
- if (!target) {
896
- 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"));
897
972
  return;
898
973
  }
899
- const result = await this.topicCommands.sendCompact(target.name);
974
+ const result = await this.topicCommands.sendCompact(name);
900
975
  await data.respond(result);
901
976
  }
902
977
  else if (data.command === "cancel") {
903
- const target = this.routing.resolve(data.channelId);
904
- if (!target) {
905
- 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"));
906
981
  return;
907
982
  }
908
- const ok = this.cancelInstance(target.name);
909
- 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));
910
985
  }
911
986
  else if (data.command === "ctx") {
912
- const target = this.routing.resolve(data.channelId);
913
- if (!target) {
914
- 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"));
915
990
  return;
916
991
  }
917
992
  // Single source of truth (statusline.json + robust tmux pane fallback).
918
- await data.respond(await this.topicCommands.getCtxText(target.name));
993
+ await data.respond(await this.topicCommands.getCtxText(name));
919
994
  }
920
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.
921
998
  const collabTarget = this.routing.resolve(data.channelId);
922
- if (collabTarget && collabTarget.kind !== "classic") {
999
+ if (collabTarget) {
923
1000
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
924
1001
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
925
- await data.respond("⛔ Not authorized");
1002
+ await data.respond(t("not_authorized"));
926
1003
  return;
927
1004
  }
928
1005
  const isCollab = this.toggleFleetCollab(collabTarget.name);
929
- 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"));
930
1007
  return;
931
1008
  }
932
1009
  if (!this.classicChannels?.isAdmin(data.userId)) {
933
- await data.respond("⛔ This command requires admin access.");
1010
+ await data.respond(t("admin.required"));
934
1011
  return;
935
1012
  }
936
- if (!this.classicChannels.isClassicChannel(data.channelId)) {
937
- 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"));
938
1015
  return;
939
1016
  }
940
- const newState = this.classicChannels.toggleCollab(data.channelId);
1017
+ const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
941
1018
  await data.respond(newState
942
- ? "🤝 Collaboration mode **ON** — @mention this bot to trigger the agent. Other bot messages are visible."
943
- : "💬 Collaboration mode **OFF** — use `/chat` to talk to the agent.");
1019
+ ? t("collab.on.classic")
1020
+ : t("collab.off.classic"));
944
1021
  }
945
1022
  else if (data.command === "update") {
946
1023
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
947
1024
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
948
- await data.respond("⛔ Not authorized");
1025
+ await data.respond(t("not_authorized"));
949
1026
  return;
950
1027
  }
951
- await data.respond("📦 Updating AgEnD... Fleet will restart automatically.");
1028
+ await data.respond(t("update.running"));
952
1029
  const { spawn } = await import("node:child_process");
953
1030
  const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
954
1031
  const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
@@ -958,7 +1035,7 @@ export class FleetManager {
958
1035
  else if (data.command === "doctor") {
959
1036
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
960
1037
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
961
- await data.respond("⛔ Not authorized");
1038
+ await data.respond(t("not_authorized"));
962
1039
  return;
963
1040
  }
964
1041
  try {
@@ -980,22 +1057,36 @@ export class FleetManager {
980
1057
  else if (data.command === "sysinfo") {
981
1058
  await data.respond(this.topicCommands.getSysInfoText());
982
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
+ }
983
1074
  else if (data.command === "restart") {
984
1075
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
985
1076
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
986
- await data.respond("⛔ Not authorized");
1077
+ await data.respond(t("not_authorized"));
987
1078
  return;
988
1079
  }
989
- await data.respond("🔄 Graceful restart — waiting for instances to idle...");
1080
+ await data.respond(t("restart.graceful"));
990
1081
  process.kill(process.pid, "SIGUSR2");
991
1082
  }
992
1083
  else if (data.command === "compact") {
993
- const target = this.routing.resolve(data.channelId);
994
- if (!target) {
995
- 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"));
996
1087
  return;
997
1088
  }
998
- const result = await this.topicCommands.sendCompact(target.name);
1089
+ const result = await this.topicCommands.sendCompact(name);
999
1090
  await data.respond(result);
1000
1091
  }
1001
1092
  }, this.logger, "adapter.slash_command"));
@@ -1022,7 +1113,7 @@ export class FleetManager {
1022
1113
  this.restartAdapter(this.adapter, "primary").catch(() => { });
1023
1114
  });
1024
1115
  this.adapter.on("new_group_detected", safeHandler((data) => {
1025
- 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);
1026
1117
  const generalId = this.findGeneralInstance();
1027
1118
  if (generalId)
1028
1119
  this.notifyInstanceTopic(generalId, adminMsg);
@@ -1048,7 +1139,7 @@ export class FleetManager {
1048
1139
  }
1049
1140
  const accessDir = join(this.dataDir, "access");
1050
1141
  mkdirSync(accessDir, { recursive: true });
1051
- 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`));
1052
1143
  const inboxDir = join(this.dataDir, "inbox");
1053
1144
  mkdirSync(inboxDir, { recursive: true });
1054
1145
  const adapter = await createAdapter(channelConfig, {
@@ -1100,28 +1191,28 @@ export class FleetManager {
1100
1191
  // Slash commands: classic bot + admin commands
1101
1192
  adapter.on("slash_command", safeHandler(async (data) => {
1102
1193
  if (data.command === "start") {
1103
- 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);
1104
1195
  await data.respond(reply);
1105
1196
  }
1106
1197
  else if (data.command === "stop") {
1107
- const reply = await this.handleClassicStop(data.channelId);
1198
+ const reply = await this.handleClassicStop(data.channelId, adapterId);
1108
1199
  await data.respond(reply);
1109
1200
  }
1110
1201
  else if (data.command === "chat") {
1111
1202
  const text = data.text ?? "";
1112
1203
  if (!text) {
1113
- await data.respond("Usage: `/chat <message>`");
1204
+ await data.respond(t("chat.usage"));
1114
1205
  return;
1115
1206
  }
1116
- const target = this.routing.resolve(data.channelId);
1117
- if (!target || target.kind !== "classic") {
1118
- 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"));
1119
1210
  return;
1120
1211
  }
1121
1212
  const replyMsgId = await data.respond("👀");
1122
1213
  const username = data.username ?? data.userId;
1123
- ClassicChannelManager.logMessage(target.name, username, `/chat ${text}`, new Date());
1124
- await this.forwardToClassicInstance(target.name, text, {
1214
+ ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
1215
+ await this.forwardToClassicInstance(name, text, {
1125
1216
  chatId: data.channelId,
1126
1217
  threadId: data.channelId,
1127
1218
  messageId: replyMsgId ?? "",
@@ -1132,86 +1223,79 @@ export class FleetManager {
1132
1223
  });
1133
1224
  }
1134
1225
  else if (data.command === "save") {
1135
- await this.handleSlashSave(data);
1226
+ await this.handleSlashSave(data, adapterId);
1136
1227
  }
1137
1228
  else if (data.command === "load") {
1138
1229
  // load is kiro-cli/classic only — no claude-code equivalent.
1139
1230
  if (!this.classicChannels?.isAdmin(data.userId)) {
1140
- await data.respond("⛔ This command requires admin access.");
1231
+ await data.respond(t("admin.required"));
1141
1232
  return;
1142
1233
  }
1143
- const target = this.routing.resolve(data.channelId);
1144
- if (!target || target.kind !== "classic") {
1145
- 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"));
1146
1237
  return;
1147
1238
  }
1148
1239
  const filename = data.options?.filename;
1149
1240
  if (!SAVE_FILENAME_RE.test(filename ?? "")) {
1150
- await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1151
- return;
1152
- }
1153
- this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
1154
- await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
1155
- }
1156
- else if (data.command === "compact") {
1157
- const target = this.routing.resolve(data.channelId);
1158
- if (!target) {
1159
- await data.respond("No active agent in this channel.");
1241
+ await data.respond(t("filename.invalid"));
1160
1242
  return;
1161
1243
  }
1162
- const result = await this.topicCommands.sendCompact(target.name);
1163
- await data.respond(result);
1244
+ this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
1245
+ await data.respond(t("save.sent", `/chat load ${filename}`, name));
1164
1246
  }
1165
1247
  else if (data.command === "cancel") {
1166
- const target = this.routing.resolve(data.channelId);
1167
- if (!target) {
1168
- 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"));
1169
1251
  return;
1170
1252
  }
1171
- const ok = this.cancelInstance(target.name);
1172
- 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));
1173
1255
  }
1174
1256
  else if (data.command === "ctx") {
1175
- const target = this.routing.resolve(data.channelId);
1176
- if (!target) {
1177
- 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"));
1178
1260
  return;
1179
1261
  }
1180
1262
  // Single source of truth (statusline.json + robust tmux pane fallback).
1181
- await data.respond(await this.topicCommands.getCtxText(target.name));
1263
+ await data.respond(await this.topicCommands.getCtxText(name));
1182
1264
  }
1183
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.
1184
1268
  const collabTarget2 = this.routing.resolve(data.channelId);
1185
- if (collabTarget2 && collabTarget2.kind !== "classic") {
1269
+ if (collabTarget2) {
1186
1270
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1187
1271
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
1188
- await data.respond("⛔ Not authorized");
1272
+ await data.respond(t("not_authorized"));
1189
1273
  return;
1190
1274
  }
1191
1275
  const isCollab = this.toggleFleetCollab(collabTarget2.name);
1192
- 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"));
1193
1277
  return;
1194
1278
  }
1195
1279
  if (!this.classicChannels?.isAdmin(data.userId)) {
1196
- await data.respond("⛔ This command requires admin access.");
1280
+ await data.respond(t("admin.required"));
1197
1281
  return;
1198
1282
  }
1199
- if (!this.classicChannels.isClassicChannel(data.channelId)) {
1200
- 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"));
1201
1285
  return;
1202
1286
  }
1203
- const newState = this.classicChannels.toggleCollab(data.channelId);
1287
+ const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
1204
1288
  await data.respond(newState
1205
- ? "🤝 Collaboration mode **ON** — @mention this bot to trigger the agent. Other bot messages are visible."
1206
- : "💬 Collaboration mode **OFF** — use `/chat` to talk to the agent.");
1289
+ ? t("collab.on.classic")
1290
+ : t("collab.off.classic"));
1207
1291
  }
1208
1292
  else if (data.command === "update") {
1209
1293
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1210
1294
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
1211
- await data.respond("⛔ Not authorized");
1295
+ await data.respond(t("not_authorized"));
1212
1296
  return;
1213
1297
  }
1214
- await data.respond("📦 Updating AgEnD... Fleet will restart automatically.");
1298
+ await data.respond(t("update.running"));
1215
1299
  const { spawn } = await import("node:child_process");
1216
1300
  const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
1217
1301
  const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
@@ -1221,7 +1305,7 @@ export class FleetManager {
1221
1305
  else if (data.command === "doctor") {
1222
1306
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1223
1307
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
1224
- await data.respond("⛔ Not authorized");
1308
+ await data.respond(t("not_authorized"));
1225
1309
  return;
1226
1310
  }
1227
1311
  try {
@@ -1243,22 +1327,36 @@ export class FleetManager {
1243
1327
  else if (data.command === "sysinfo") {
1244
1328
  await data.respond(this.topicCommands.getSysInfoText());
1245
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
+ }
1246
1344
  else if (data.command === "restart") {
1247
1345
  const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
1248
1346
  if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
1249
- await data.respond("⛔ Not authorized");
1347
+ await data.respond(t("not_authorized"));
1250
1348
  return;
1251
1349
  }
1252
- await data.respond("🔄 Graceful restart — waiting for instances to idle...");
1350
+ await data.respond(t("restart.graceful"));
1253
1351
  process.kill(process.pid, "SIGUSR2");
1254
1352
  }
1255
1353
  else if (data.command === "compact") {
1256
- const target = this.routing.resolve(data.channelId);
1257
- if (!target) {
1258
- 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"));
1259
1357
  return;
1260
1358
  }
1261
- const result = await this.topicCommands.sendCompact(target.name);
1359
+ const result = await this.topicCommands.sendCompact(name);
1262
1360
  await data.respond(result);
1263
1361
  }
1264
1362
  }, this.logger, `adapter[${adapterId}].slash_command`));
@@ -1276,7 +1374,7 @@ export class FleetManager {
1276
1374
  }
1277
1375
  }, this.logger, `adapter[${adapterId}].started`));
1278
1376
  adapter.on("new_group_detected", safeHandler((data) => {
1279
- 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);
1280
1378
  const generalId = this.findGeneralInstance(adapterId);
1281
1379
  if (generalId)
1282
1380
  this.notifyInstanceTopic(generalId, adminMsg);
@@ -1481,8 +1579,17 @@ export class FleetManager {
1481
1579
  // Routing (by topic/channel) and reply-adapter selection (by channel_id
1482
1580
  // binding) are adapter-independent, so it's safe to let whichever adapter
1483
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.
1484
1587
  if (msg.messageId) {
1485
- const dedupKey = `${msg.source}:${msg.chatId}:${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}`;
1486
1593
  if (this.recentMessageIds.has(dedupKey)) {
1487
1594
  this.logger.debug({ dedupKey, adapterId: msg.adapterId }, "Duplicate inbound across adapters — skipping");
1488
1595
  return;
@@ -1509,21 +1616,25 @@ export class FleetManager {
1509
1616
  return;
1510
1617
  // Fall through to TG classic handling below
1511
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
+ }
1512
1629
  else {
1513
1630
  const target = this.routing.resolve(threadId);
1514
1631
  if (!target)
1515
1632
  return;
1516
- if (target.kind === "classic") {
1517
- if (!this.classicChannels?.isCollab(threadId))
1518
- return;
1519
- }
1520
- else {
1521
- // Fleet topic: allow if collab enabled OR access mode is open
1522
- const channelCfg = this.getChannelConfig(msg.adapterId);
1523
- const isOpen = channelCfg?.access?.mode === "open";
1524
- if (!isOpen && !this.collabInstances.has(target.name))
1525
- return;
1526
- }
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;
1527
1638
  // Fall through to channel handling
1528
1639
  }
1529
1640
  }
@@ -1533,9 +1644,10 @@ export class FleetManager {
1533
1644
  const adapterGroupId = String(this.getChannelConfig(msg.adapterId)?.group_id ?? "");
1534
1645
  const isTelegramClassicCandidate = msg.source === "telegram" && msg.chatId !== adapterGroupId && !threadId;
1535
1646
  if (!isTelegramClassicCandidate) {
1536
- const target = threadId ? this.routing.resolve(threadId) : undefined;
1537
- this.logger.info({ userId: msg.userId, threadId, targetKind: target?.kind, targetName: target?.name }, "Access DENIED for non-allowed user");
1538
- 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)
1539
1651
  return;
1540
1652
  }
1541
1653
  }
@@ -1580,9 +1692,9 @@ export class FleetManager {
1580
1692
  if (!this.classicChannels.isUserAllowed(msg.userId)) {
1581
1693
  const generalId = this.findGeneralInstance(msg.adapterId);
1582
1694
  if (generalId) {
1583
- 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));
1584
1696
  }
1585
- await msgAdapter?.sendText(chatId, "⛔ You are not in the allowed users list.");
1697
+ await msgAdapter?.sendText(chatId, t("classic.not_allowed_user"));
1586
1698
  return;
1587
1699
  }
1588
1700
  }
@@ -1590,136 +1702,135 @@ export class FleetManager {
1590
1702
  if (!this.classicChannels.isGroupAllowed(chatId)) {
1591
1703
  // Notify admin about new group wanting access
1592
1704
  const groupTitle = msg.chatTitle || chatId;
1593
- 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);
1594
1706
  const generalId = this.findGeneralInstance(msg.adapterId);
1595
1707
  if (generalId) {
1596
1708
  this.notifyInstanceTopic(generalId, adminMsg);
1597
1709
  }
1598
- await msgAdapter?.sendText(chatId, "⏳ Access requested. Waiting for admin approval.");
1710
+ await msgAdapter?.sendText(chatId, t("classic.access_requested"));
1599
1711
  return;
1600
1712
  }
1601
1713
  if (!this.classicChannels.isAdmin(msg.userId)) {
1602
- await msgAdapter?.sendText(chatId, "⛔ Only admins can start agents. Ask an admin to /start.");
1714
+ await msgAdapter?.sendText(chatId, t("classic.admin_only_start"));
1603
1715
  const generalId = this.findGeneralInstance(msg.adapterId);
1604
1716
  if (generalId) {
1605
- 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));
1606
1718
  }
1607
1719
  return;
1608
1720
  }
1609
1721
  }
1610
1722
  const channelName = msg.username || chatId;
1611
- const reply = await this.handleClassicStart(chatId, channelName, msg.userId);
1612
- if (msg.adapterId)
1613
- 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);
1614
1725
  await msgAdapter?.sendText(chatId, reply);
1615
1726
  return;
1616
1727
  }
1617
1728
  // Handle /stop command
1618
1729
  if (text === "/stop" || text.startsWith("/stop ")) {
1619
1730
  if (!this.classicChannels.isAdmin(msg.userId)) {
1620
- await msgAdapter?.sendText(chatId, "⛔ Only admins can stop agents.");
1731
+ await msgAdapter?.sendText(chatId, t("classic.admin_only_stop"));
1621
1732
  const generalId = this.findGeneralInstance(msg.adapterId);
1622
1733
  if (generalId) {
1623
- 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));
1624
1735
  }
1625
1736
  return;
1626
1737
  }
1627
- const reply = await this.handleClassicStop(chatId);
1738
+ const reply = await this.handleClassicStop(chatId, msg.adapterId);
1628
1739
  await msgAdapter?.sendText(chatId, reply);
1629
1740
  return;
1630
1741
  }
1631
1742
  // Handle /compact command (admin only)
1632
1743
  if (text === "/compact" || text.startsWith("/compact@")) {
1633
1744
  if (!this.classicChannels.isAdmin(msg.userId)) {
1634
- await msgAdapter?.sendText(chatId, " /compact requires admin access.");
1745
+ await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/compact"));
1635
1746
  return;
1636
1747
  }
1637
- const compactTarget = this.routing.resolve(chatId);
1638
- if (!compactTarget || compactTarget.kind !== "classic") {
1639
- 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"));
1640
1751
  return;
1641
1752
  }
1642
- const result = await this.topicCommands.sendCompact(compactTarget.name);
1753
+ const result = await this.topicCommands.sendCompact(compactName);
1643
1754
  await msgAdapter?.sendText(chatId, result);
1644
1755
  return;
1645
1756
  }
1646
1757
  // Handle /cancel command
1647
1758
  if (text === "/cancel" || text.startsWith("/cancel@")) {
1648
- const cancelTarget = this.routing.resolve(chatId);
1649
- if (!cancelTarget || cancelTarget.kind !== "classic") {
1650
- 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"));
1651
1762
  return;
1652
1763
  }
1653
- const ok = this.cancelInstance(cancelTarget.name);
1654
- await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelTarget.name}。` : `❌ ${cancelTarget.name} 未在執行。`);
1764
+ const ok = this.cancelInstance(cancelName);
1765
+ await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelName}。` : `❌ ${cancelName} 未在執行。`);
1655
1766
  return;
1656
1767
  }
1657
1768
  // Handle /ctx command
1658
1769
  if (text === "/ctx" || text.startsWith("/ctx@")) {
1659
- const ctxTarget = this.routing.resolve(chatId);
1660
- if (!ctxTarget || ctxTarget.kind !== "classic") {
1661
- 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"));
1662
1773
  return;
1663
1774
  }
1664
- const reply = await this.topicCommands.getCtxText(ctxTarget.name);
1775
+ const reply = await this.topicCommands.getCtxText(ctxName);
1665
1776
  await msgAdapter?.sendText(chatId, reply);
1666
1777
  return;
1667
1778
  }
1668
1779
  // Handle /save command (admin only)
1669
1780
  if (text === "/save" || text.startsWith("/save ") || text.startsWith("/save@")) {
1670
1781
  if (!this.classicChannels.isAdmin(msg.userId)) {
1671
- await msgAdapter?.sendText(chatId, " /save requires admin access.");
1782
+ await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/save"));
1672
1783
  return;
1673
1784
  }
1674
- const saveTarget = this.routing.resolve(chatId);
1675
- if (!saveTarget || saveTarget.kind !== "classic") {
1676
- 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"));
1677
1788
  return;
1678
1789
  }
1679
1790
  const filename = parseSaveFilename(text);
1680
1791
  if (!filename) {
1681
- await msgAdapter?.sendText(chatId, "Usage: /save <filename>");
1792
+ await msgAdapter?.sendText(chatId, t("save.usage"));
1682
1793
  return;
1683
1794
  }
1684
1795
  if (!SAVE_FILENAME_RE.test(filename)) {
1685
- await msgAdapter?.sendText(chatId, "⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1796
+ await msgAdapter?.sendText(chatId, t("filename.invalid"));
1686
1797
  return;
1687
1798
  }
1688
- const backend = this.classicChannels.getBackendByInstance(saveTarget.name, this.fleetConfig?.defaults?.backend);
1799
+ const backend = this.classicChannels.getBackendByInstance(saveName, this.fleetConfig?.defaults?.backend);
1689
1800
  const cmd = saveCommandForBackend(backend, filename);
1690
1801
  if (!cmd) {
1691
1802
  await msgAdapter?.sendText(chatId, SAVE_UNSUPPORTED_MSG);
1692
1803
  return;
1693
1804
  }
1694
- this.pasteRawToClassicInstance(saveTarget.name, cmd);
1695
- await msgAdapter?.sendText(chatId, `✅ Sent \`${cmd}\` to ${saveTarget.name}`);
1805
+ this.pasteRawToClassicInstance(saveName, cmd);
1806
+ await msgAdapter?.sendText(chatId, t("save.sent", cmd, saveName));
1696
1807
  return;
1697
1808
  }
1698
- // Route to classic channel if registered
1699
- const target = this.routing.resolve(chatId);
1700
- 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) {
1701
1812
  if (msg.adapterId)
1702
- this.bindInstanceAdapter(target.name, msg.adapterId, true);
1813
+ this.bindInstanceAdapter(classicName, msg.adapterId, true);
1703
1814
  // TG ClassicBot: group requires @mention, private chat forwards directly.
1704
1815
  if (!isPrivateChat && !isBotMentioned) {
1705
1816
  // No trigger: save attachments + react, log, but don't forward to agent
1706
1817
  const syntheticMsg = { ...msg, threadId: chatId, text: rawText.startsWith("/") ? "" : rawText };
1707
- await this.handleClassicChannelMessage(target.name, syntheticMsg);
1818
+ await this.handleClassicChannelMessage(classicName, syntheticMsg);
1708
1819
  return;
1709
1820
  }
1710
1821
  // Strip @bot from text and forward as /chat
1711
1822
  const cleanText = botUser ? text.replace(new RegExp(`@${botUser}`, "gi"), "").trim() : text;
1712
1823
  if (cleanText.startsWith("/raw") && !this.classicChannels.isAdmin(msg.userId)) {
1713
- await msgAdapter?.sendText(chatId, " /raw requires admin access.");
1824
+ await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/raw"));
1714
1825
  return;
1715
1826
  }
1716
1827
  const syntheticMsg = { ...msg, threadId: chatId, text: `/chat ${cleanText}` };
1717
- await this.handleClassicChannelMessage(target.name, syntheticMsg);
1828
+ await this.handleClassicChannelMessage(classicName, syntheticMsg);
1718
1829
  return;
1719
1830
  }
1720
1831
  // Handle @bot without active agent
1721
1832
  if (isBotMentioned) {
1722
- await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1833
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
1723
1834
  return;
1724
1835
  }
1725
1836
  // Unregistered private chat: ignore (don't fall through to General)
@@ -1739,9 +1850,12 @@ export class FleetManager {
1739
1850
  if (msg.adapterId)
1740
1851
  this.bindInstanceAdapter(generalInstance, msg.adapterId, true);
1741
1852
  const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1742
- // 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.
1743
1856
  if (msg.chatId && msg.messageId) {
1744
- inboundAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
1857
+ const reactAdapter = this.getAdapterForInstance(generalInstance) ?? inboundAdapter;
1858
+ reactAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
1745
1859
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
1746
1860
  }
1747
1861
  this.warnIfRateLimited(generalInstance, msg);
@@ -1777,6 +1891,18 @@ export class FleetManager {
1777
1891
  }
1778
1892
  return;
1779
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
+ }
1780
1906
  const target = this.routing.resolve(threadId);
1781
1907
  if (!target) {
1782
1908
  // Only show unbound message for actual forum topics (same group, has threadId)
@@ -1808,9 +1934,12 @@ export class FleetManager {
1808
1934
  if (msg.adapterId)
1809
1935
  this.bindInstanceAdapter(instanceName, msg.adapterId, true);
1810
1936
  const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1811
- // 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.
1812
1940
  if (msg.chatId && msg.messageId) {
1813
- inboundAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
1941
+ const reactAdapter = this.getAdapterForInstance(instanceName) ?? inboundAdapter;
1942
+ reactAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
1814
1943
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
1815
1944
  }
1816
1945
  // These may hit Discord API (topic icon, archive) — do after react
@@ -1935,7 +2064,7 @@ export class FleetManager {
1935
2064
  ts: new Date().toISOString(),
1936
2065
  });
1937
2066
  // Log bot reply to classic instance chat-log
1938
- const isClassic = [...this.routing.entries()].some(([, t]) => t.kind === "classic" && t.name === instanceName);
2067
+ const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
1939
2068
  if (isClassic) {
1940
2069
  ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
1941
2070
  }
@@ -1995,7 +2124,7 @@ export class FleetManager {
1995
2124
  five_hour_pct: rl.five_hour_pct,
1996
2125
  });
1997
2126
  this.webhookEmitter?.emit("schedule_deferred", target, { schedule_id: id, label, five_hour_pct: rl.five_hour_pct });
1998
- 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));
1999
2128
  this.logger.info({ target, scheduleId: id, rateLimitPct: rl.five_hour_pct }, "Schedule deferred due to rate limit");
2000
2129
  return;
2001
2130
  }
@@ -2482,6 +2611,8 @@ export class FleetManager {
2482
2611
  // Preserve all optional user-configured fields so saveFleetConfig() never silently drops them
2483
2612
  if (inst.general_topic)
2484
2613
  serialized.general_topic = true;
2614
+ if (inst.display_name)
2615
+ serialized.display_name = inst.display_name;
2485
2616
  if (inst.channel_id)
2486
2617
  serialized.channel_id = inst.channel_id;
2487
2618
  if (inst.description)
@@ -2554,15 +2685,17 @@ export class FleetManager {
2554
2685
  startStatuslineWatcher(name) {
2555
2686
  this.statuslineWatcher.watch(name);
2556
2687
  }
2557
- reactMessageStatus(chatId, messageId, emoji) {
2558
- // Find the adapter that owns this chatId (check all adapters, not just primary)
2559
- for (const [, w] of this.worlds) {
2560
- if (w.type === "discord") {
2561
- w.react(chatId, messageId, emoji)
2562
- .catch(e => this.logger.debug({ err: e.message }, "Message status react failed"));
2563
- return;
2564
- }
2565
- }
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"));
2566
2699
  }
2567
2700
  // ── Model failover ──────────────────────────────────────────────────────
2568
2701
  static FAILOVER_TRIGGER_PCT = 90;
@@ -2611,7 +2744,7 @@ export class FleetManager {
2611
2744
  this.collabInstances.add(instanceName);
2612
2745
  return true;
2613
2746
  }
2614
- notifyInstanceTopic(instanceName, text) {
2747
+ notifyInstanceTopic(instanceName, text, extraOpts) {
2615
2748
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
2616
2749
  if (!adapter)
2617
2750
  return;
@@ -2620,21 +2753,20 @@ export class FleetManager {
2620
2753
  // Fleet topic instance
2621
2754
  const threadId = this.fleetConfig?.instances[instanceName]?.topic_id;
2622
2755
  if (threadId != null && groupId) {
2623
- adapter.sendText(String(groupId), text, { threadId: String(threadId) })
2756
+ adapter.sendText(String(groupId), text, { threadId: String(threadId), ...extraOpts })
2624
2757
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send instance topic notification"));
2625
2758
  return;
2626
2759
  }
2627
- // Classic instance: find chatId from routing table
2628
- for (const [chatId, target] of this.routing.entries()) {
2629
- if (target.kind === "classic" && target.name === instanceName) {
2630
- adapter.sendText(chatId, text)
2631
- .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send classic notification"));
2632
- return;
2633
- }
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;
2634
2766
  }
2635
2767
  // Fallback: send to group without threadId
2636
2768
  if (groupId) {
2637
- adapter.sendText(String(groupId), text)
2769
+ adapter.sendText(String(groupId), text, extraOpts)
2638
2770
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
2639
2771
  }
2640
2772
  }
@@ -2647,19 +2779,23 @@ export class FleetManager {
2647
2779
  * Picks the backend-appropriate command (kiro → /chat save, claude → /export);
2648
2780
  * unsupported backends get a clear error. Routes via classic paste or fleet IPC.
2649
2781
  */
2650
- async handleSlashSave(data) {
2782
+ async handleSlashSave(data, adapterId) {
2651
2783
  if (!this.classicChannels?.isAdmin(data.userId)) {
2652
- await data.respond("⛔ This command requires admin access.");
2784
+ await data.respond(t("admin.required"));
2653
2785
  return;
2654
2786
  }
2655
- 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);
2656
2792
  if (!target) {
2657
- await data.respond("No active agent in this channel. Use `/start` first.");
2793
+ await data.respond(t("classic.no_agent_start"));
2658
2794
  return;
2659
2795
  }
2660
2796
  const filename = data.options?.filename ?? "";
2661
2797
  if (!SAVE_FILENAME_RE.test(filename)) {
2662
- await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
2798
+ await data.respond(t("filename.invalid"));
2663
2799
  return;
2664
2800
  }
2665
2801
  const backend = target.kind === "classic"
@@ -2678,7 +2814,7 @@ export class FleetManager {
2678
2814
  else {
2679
2815
  this.instanceIpcClients.get(target.name)?.send({ type: "raw_paste", content: cmd });
2680
2816
  }
2681
- await data.respond(`✅ Sent \`${cmd}\` to ${target.name}`);
2817
+ await data.respond(t("save.sent", cmd, target.name));
2682
2818
  }
2683
2819
  /** Whether the instance currently has at least one live cancel button. */
2684
2820
  hasCancelButton(instanceName) {
@@ -2707,13 +2843,8 @@ export class FleetManager {
2707
2843
  threadId = String(topicId);
2708
2844
  }
2709
2845
  else {
2710
- // Classic instance: chatId from the routing table.
2711
- for (const [cid, target] of this.routing.entries()) {
2712
- if (target.kind === "classic" && target.name === instanceName) {
2713
- chatId = cid;
2714
- break;
2715
- }
2716
- }
2846
+ // Classic instance: channelId from the classic manager.
2847
+ chatId = this.classicChannels?.getChannelIdByInstance(instanceName);
2717
2848
  // General / flat fallback: post to the group (no thread).
2718
2849
  if (!chatId && groupId)
2719
2850
  chatId = String(groupId);
@@ -2725,7 +2856,7 @@ export class FleetManager {
2725
2856
  type: "cancel",
2726
2857
  instanceName,
2727
2858
  message: "👀 處理中…",
2728
- choices: [{ id: `cancel:${instanceName}`, label: "🛑 取消" }],
2859
+ choices: [{ id: `cancel:${instanceName}`, label: t("cancel.button") }],
2729
2860
  }, threadId ? { threadId } : undefined);
2730
2861
  // A concurrent sendCancelButton for the same instance may have posted its
2731
2862
  // own button while we awaited notifyAlert. Retire any other buttons for
@@ -3196,7 +3327,7 @@ When users create specialized instances, suggest these configurations:
3196
3327
  async handleClassicChannelMessage(instanceName, msg) {
3197
3328
  const text = msg.text ?? "";
3198
3329
  const channelId = msg.threadId ?? msg.chatId;
3199
- const isCollabMode = this.classicChannels?.isCollab(channelId) ?? false;
3330
+ const isCollabMode = this.classicChannels?.isCollab(channelId, msg.adapterId) ?? false;
3200
3331
  // Handle /ctx in classic mode — always, regardless of collab mode
3201
3332
  if (text === "/ctx" || text.startsWith("/ctx@")) {
3202
3333
  const reply = await this.topicCommands.getCtxText(instanceName);
@@ -3223,8 +3354,15 @@ When users create specialized instances, suggest these configurations:
3223
3354
  : "");
3224
3355
  ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText);
3225
3356
  this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message");
3226
- // Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here
3227
- 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);
3228
3366
  const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
3229
3367
  const isMentioned = mentionTag && text.includes(mentionTag);
3230
3368
  if (!isMentioned) {
@@ -3402,7 +3540,11 @@ When users create specialized instances, suggest these configurations:
3402
3540
  }
3403
3541
  /** Forward a message to a classic channel instance with chat log context */
3404
3542
  async forwardToClassicInstance(instanceName, text, msg, extraMeta) {
3405
- 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;
3406
3548
  const logContext = this.getRecentChatLog(instanceName, contextLines);
3407
3549
  const fullText = logContext
3408
3550
  ? `[Chat log for context]\n${logContext}\n\n[User message]\n${text}`
@@ -3488,44 +3630,51 @@ When users create specialized instances, suggest these configurations:
3488
3630
  await this.startInstance(instanceName, config, topicMode);
3489
3631
  }
3490
3632
  /** Handle /start slash command — register classic channel */
3491
- async handleClassicStart(channelId, channelName, userId, guildId) {
3633
+ async handleClassicStart(channelId, channelName, userId, guildId, adapterId) {
3492
3634
  if (!this.classicChannels)
3493
3635
  return "Classic channel manager not initialized.";
3494
3636
  if (guildId && !this.classicChannels.isGuildAllowed(guildId)) {
3495
3637
  const generalId = this.findGeneralInstance();
3496
3638
  if (generalId) {
3497
- 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));
3498
3640
  }
3499
- return "⛔ This server is not in the allowed guilds list.";
3641
+ return t("classic.not_authorized_guild");
3500
3642
  }
3501
- if (this.classicChannels.isClassicChannel(channelId))
3502
- 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.
3503
3648
  if (this.routing.resolve(channelId))
3504
- return "This channel is already bound to a topic-mode instance.";
3505
- const instanceName = classicInstanceName(sanitizeInstanceName(channelName || channelId), channelId);
3506
- this.classicChannels.register(channelId, instanceName, channelName || channelId, userId);
3507
- this.routing.register(channelId, { kind: "classic", name: instanceName });
3508
- 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));
3509
3658
  this.reregisterClassicChannels();
3510
3659
  // Auto-enable collab for Discord classic channels (TG uses @mention directly without collab mode)
3511
- if (guildId && !this.classicChannels.isCollab(channelId)) {
3512
- this.classicChannels.toggleCollab(channelId);
3660
+ if (guildId && !this.classicChannels.isCollab(channelId, adapterId)) {
3661
+ this.classicChannels.toggleCollab(channelId, adapterId);
3513
3662
  }
3514
- this.logger.info({ channelId, instanceName, userId }, "Classic channel started");
3515
- 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");
3516
3665
  }
3517
3666
  /** Handle /stop slash command — unregister classic channel */
3518
- async handleClassicStop(channelId) {
3667
+ async handleClassicStop(channelId, adapterId) {
3519
3668
  if (!this.classicChannels)
3520
3669
  return "Classic channel manager not initialized.";
3521
- const ch = this.classicChannels.unregister(channelId);
3670
+ const ch = this.classicChannels.unregister(channelId, adapterId);
3522
3671
  if (!ch)
3523
- return "No active agent in this channel.";
3524
- this.routing.unregister(channelId);
3672
+ return t("classic.no_agent");
3673
+ this.instanceWorldBinding.delete(ch.instanceName);
3525
3674
  await this.stopInstance(ch.instanceName).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance"));
3526
3675
  this.reregisterClassicChannels();
3527
- this.logger.info({ channelId, instanceName: ch.instanceName }, "Classic channel stopped");
3528
- return `🛑 Agent stopped in this channel.`;
3676
+ this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
3677
+ return t("classic.stopped");
3529
3678
  }
3530
3679
  async stopAll() {
3531
3680
  this.ipcStoppingInstances.add("__fleet_stopping__");
@@ -3668,7 +3817,7 @@ When users create specialized instances, suggest these configurations:
3668
3817
  this.logger.info(`Full restart: waiting for ${instanceNames.length} instances to idle...`);
3669
3818
  const groupId = this.fleetConfig?.channel?.group_id;
3670
3819
  if (groupId && this.adapter) {
3671
- 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"))
3672
3821
  .catch(e => this.logger.warn({ err: e }, "Failed to post full restart notification"));
3673
3822
  }
3674
3823
  // Wait for idle with 5-minute timeout
@@ -3779,7 +3928,7 @@ When users create specialized instances, suggest these configurations:
3779
3928
  const generalThreadId = generalName ? this.fleetConfig?.instances[generalName]?.topic_id : undefined;
3780
3929
  const notifyOpts = { threadId: generalThreadId != null ? String(generalThreadId) : undefined };
3781
3930
  if (groupId && this.adapter) {
3782
- 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)
3783
3932
  .catch(e => this.logger.warn({ err: e }, "Failed to post restart notification"));
3784
3933
  }
3785
3934
  const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
@@ -3853,7 +4002,7 @@ When users create specialized instances, suggest these configurations:
3853
4002
  let idx = 0;
3854
4003
  while (idx < channels.length) {
3855
4004
  const batch = channels.slice(idx, idx + concurrency);
3856
- 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"))));
3857
4006
  idx += concurrency;
3858
4007
  }
3859
4008
  }
@@ -3870,8 +4019,8 @@ When users create specialized instances, suggest these configurations:
3870
4019
  const _require2 = createRequire(import.meta.url);
3871
4020
  const agendVersion2 = _require2("../package.json").version ?? "unknown";
3872
4021
  const restartText = failedNames.length === 0
3873
- ? `Fleet ready. ${started}/${total} instances running. v${agendVersion2}`
3874
- : `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(", "));
3875
4024
  await this.adapter.sendText(String(groupId), restartText, notifyOpts)
3876
4025
  .catch(e => this.logger.warn({ err: e }, "Failed to post restart completion notification"));
3877
4026
  // Notify each instance's channel — staggered to avoid rate limit storm
@@ -3936,7 +4085,9 @@ When users create specialized instances, suggest these configurations:
3936
4085
  if (target && this.semverGt(target, currentVersion)) {
3937
4086
  const generalId = this.findGeneralInstance();
3938
4087
  if (generalId) {
3939
- this.notifyInstanceTopic(generalId, `🆕 AgEnD v${target} available (current: v${currentVersion}). Use \`/update\` to upgrade.`);
4088
+ // No release URL Discord's SuppressEmbeds proved unreliable and the
4089
+ // link preview looked bad. Version + /update instruction is enough.
4090
+ this.notifyInstanceTopic(generalId, t("update.available", `v${target}`) + ` (current: v${currentVersion})`);
3940
4091
  }
3941
4092
  }
3942
4093
  }
@@ -4100,6 +4251,7 @@ When users create specialized instances, suggest these configurations:
4100
4251
  res.writeHead(200);
4101
4252
  res.end(JSON.stringify({
4102
4253
  ...sysInfo,
4254
+ version: this.currentVersion,
4103
4255
  instances: enriched,
4104
4256
  }));
4105
4257
  return;
@@ -4206,6 +4358,8 @@ When users create specialized instances, suggest these configurations:
4206
4358
  const url = new URL(req.url ?? "/", `http://localhost:${port}`);
4207
4359
  if (handleViewRequest(req, res, url, this))
4208
4360
  return;
4361
+ if (handleSettingsRequest(req, res, url, this))
4362
+ return;
4209
4363
  if (handleWebRequest(req, res, url, this))
4210
4364
  return;
4211
4365
  res.writeHead(404);