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