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