@songsid/agend 2.1.12-beta.7 → 2.1.12-beta.8
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/backend/claude-code.d.ts +34 -1
- package/dist/backend/claude-code.js +123 -0
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/types.d.ts +21 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.js +4 -0
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/cli.js +22 -7
- package/dist/cli.js.map +1 -1
- package/dist/command-table.d.ts +1 -1
- package/dist/command-table.js +7 -1
- package/dist/command-table.js.map +1 -1
- package/dist/daemon.d.ts +27 -1
- package/dist/daemon.js +131 -30
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-context.d.ts +10 -1
- package/dist/fleet-manager.d.ts +32 -2
- package/dist/fleet-manager.js +213 -6
- package/dist/fleet-manager.js.map +1 -1
- package/dist/locale.js +24 -0
- package/dist/locale.js.map +1 -1
- package/dist/pane-input-residue.d.ts +2 -0
- package/dist/pane-input-residue.js +5 -1
- package/dist/pane-input-residue.js.map +1 -1
- package/dist/profile-control.d.ts +20 -0
- package/dist/profile-control.js +308 -0
- package/dist/profile-control.js.map +1 -0
- package/dist/runtime-cpu-profile.d.ts +37 -0
- package/dist/runtime-cpu-profile.js +123 -0
- package/dist/runtime-cpu-profile.js.map +1 -0
- package/dist/service-installer.d.ts +6 -0
- package/dist/service-installer.js +18 -2
- package/dist/service-installer.js.map +1 -1
- package/dist/topic-commands.d.ts +12 -1
- package/dist/topic-commands.js +33 -1
- package/dist/topic-commands.js.map +1 -1
- package/package.json +1 -1
package/dist/fleet-context.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FleetConfig, InstanceConfig } from "./types.js";
|
|
2
|
-
import type { ChannelAdapter } from "./channel/types.js";
|
|
2
|
+
import type { ChannelAdapter, InboundMessage } from "./channel/types.js";
|
|
3
3
|
import type { IpcClient } from "./channel/ipc-bridge.js";
|
|
4
4
|
import type { Scheduler } from "./scheduler/index.js";
|
|
5
5
|
import type { Logger } from "./logger.js";
|
|
@@ -89,6 +89,15 @@ export interface FleetContext {
|
|
|
89
89
|
isFleetAdmin(userId: string, adapterId?: string): boolean;
|
|
90
90
|
/** Whether the adapter has any fleet admin at all (an empty allowlist turns the admin commands off). */
|
|
91
91
|
hasFleetAdmins(adapterId?: string): boolean;
|
|
92
|
+
/** Telegram-only General command; owns admin/route checks and completion notice. */
|
|
93
|
+
runProfileCommand?(msg: InboundMessage, seconds?: string): Promise<void>;
|
|
94
|
+
/**
|
|
95
|
+
* #1346: the adapter that owns an instance's topic. Text commands resolve
|
|
96
|
+
* permission and replies through the owner, never the adapter whose copy
|
|
97
|
+
* happened to win the cross-adapter dedup race. Optional so older stubs
|
|
98
|
+
* keep working (they fall back to the receiving adapter).
|
|
99
|
+
*/
|
|
100
|
+
getInstanceAdapterId?(instanceName: string): string | undefined;
|
|
92
101
|
changeInstancePauseState(name: string, action: "pause" | "wake"): Promise<"paused" | "awake" | "not_idle">;
|
|
93
102
|
startInstance(name: string, config: InstanceConfig, topicMode: boolean): Promise<void>;
|
|
94
103
|
stopInstance(name: string): Promise<void>;
|
package/dist/fleet-manager.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type ProfileTicket } from "./runtime-cpu-profile.js";
|
|
2
|
+
import type { CpuProfile } from "./cpu-profile.js";
|
|
1
3
|
import { type FleetMemory } from "./process-memory.js";
|
|
2
4
|
import { MemoryPressure, type MemoryPressureSnapshot } from "./memory-pressure.js";
|
|
3
5
|
import { ReplyDeduper } from "./reply-dedup.js";
|
|
@@ -8,7 +10,7 @@ import { EventLog } from "./event-log.js";
|
|
|
8
10
|
import { AdapterWorld } from "./adapter-world.js";
|
|
9
11
|
import { CostGuard } from "./cost-guard.js";
|
|
10
12
|
import { IpcClient } from "./channel/ipc-bridge.js";
|
|
11
|
-
import type { AdapterHealthSnapshot, ChannelAdapter, InboundReaction } from "./channel/types.js";
|
|
13
|
+
import type { AdapterHealthSnapshot, ChannelAdapter, InboundMessage, InboundReaction } from "./channel/types.js";
|
|
12
14
|
import { type BackendCliVersionSnapshot } from "./backend/types.js";
|
|
13
15
|
import { type Logger } from "./logger.js";
|
|
14
16
|
import { Scheduler } from "./scheduler/index.js";
|
|
@@ -564,6 +566,10 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
|
|
|
564
566
|
/** SIGUSR1: full process reload. Names who asked (#1120). */
|
|
565
567
|
private onFullRestartSignal;
|
|
566
568
|
private handleUpdateSlash;
|
|
569
|
+
private handleProfileSlash;
|
|
570
|
+
/** Typed /profile belongs only to Telegram's General dispatcher. */
|
|
571
|
+
runProfileCommand(msg: InboundMessage, seconds?: string): Promise<void>;
|
|
572
|
+
private handleGeneralProfile;
|
|
567
573
|
private handleRestartSlash;
|
|
568
574
|
/** #1302: `/visibility [mode]`. The dispatcher has already applied the command table's fleet-admin gate. */
|
|
569
575
|
private handleVisibilitySlash;
|
|
@@ -607,7 +613,7 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
|
|
|
607
613
|
* Fleet instances without channel_id and legacy Classic entries both belong
|
|
608
614
|
* to channels[0]. Runtime bindings remain available for external sessions.
|
|
609
615
|
*/
|
|
610
|
-
|
|
616
|
+
getInstanceAdapterId(name: string): string | undefined;
|
|
611
617
|
/**
|
|
612
618
|
* Bind an instance to a specific world (the bot that answers for it).
|
|
613
619
|
* fromInbound=true (binding inferred from which adapter received a message)
|
|
@@ -704,6 +710,13 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
|
|
|
704
710
|
* type a slash command could `/update` the host.
|
|
705
711
|
*/
|
|
706
712
|
fleetAdminGate(userId: string, adapterId?: string): "ok" | "disabled" | "denied";
|
|
713
|
+
private runtimeCpuProfiler;
|
|
714
|
+
private cpuProfileControl;
|
|
715
|
+
private getCpuProfiler;
|
|
716
|
+
/** Called only by cold CLI startup, after claiming the fleet singleton. */
|
|
717
|
+
startCpuProfileControl(): Promise<void>;
|
|
718
|
+
startEnvironmentCpuProfile(): Promise<CpuProfile | null>;
|
|
719
|
+
startCpuProfile(seconds?: string | number): Promise<ProfileTicket>;
|
|
707
720
|
/**
|
|
708
721
|
* Every Discord slash command, for every adapter, in one place (it used to be two copies that could drift).
|
|
709
722
|
*
|
|
@@ -1110,6 +1123,13 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
|
|
|
1110
1123
|
* the message came from — its own world's group_id. Two Telegram fleet
|
|
1111
1124
|
* worlds can number topics alike; a coincidence is dropped, never delivered.
|
|
1112
1125
|
*/
|
|
1126
|
+
/**
|
|
1127
|
+
* #1346: whether this copy may claim the shared dedup key for
|
|
1128
|
+
* command-like text. Fleet topics resolve an owning adapter and only its
|
|
1129
|
+
* copy proceeds; anything else (classic targets, unknown routing, no
|
|
1130
|
+
* identity) passes through to the existing handling.
|
|
1131
|
+
*/
|
|
1132
|
+
private isOwnerCommandCopy;
|
|
1113
1133
|
private resolveInboundTarget;
|
|
1114
1134
|
private handleInboundMessage;
|
|
1115
1135
|
/** Handle outbound tool calls from a daemon instance */
|
|
@@ -2059,6 +2079,16 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
|
|
|
2059
2079
|
touchActivity(instanceName: string): void;
|
|
2060
2080
|
/** Start periodic idle archive checker */
|
|
2061
2081
|
private clearStatuslineWatchers;
|
|
2082
|
+
/**
|
|
2083
|
+
* #1346: on Discord, plain-text /xxx is never a command — the slash menu
|
|
2084
|
+
* is. Both fleet call sites run this BEFORE the command handlers, so even
|
|
2085
|
+
* the owning adapter's copy never executes text. The owning adapter posts
|
|
2086
|
+
* one system note and consumes the message so it never reaches the agent;
|
|
2087
|
+
* other adapters' copies stay silent (and the shared dedup key means
|
|
2088
|
+
* exactly one copy gets here). Telegram keeps text commands working —
|
|
2089
|
+
* text is its command path — and other sources pass through untouched.
|
|
2090
|
+
*/
|
|
2091
|
+
private replyDiscordNotACommand;
|
|
2062
2092
|
/** Handle a message in a classic channel: log it, forward only /chat messages */
|
|
2063
2093
|
private handleClassicChannelMessage;
|
|
2064
2094
|
/** Download photo or document attachment to classic instance workspace inbox. Returns { path, kind } or undefined. */
|
package/dist/fleet-manager.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { measureSyncWork } from "./sync-work-attribution.js";
|
|
2
|
+
import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
|
|
3
|
+
import { ProfileControlServer } from "./profile-control.js";
|
|
2
4
|
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, constants as fsConstants } from "node:fs";
|
|
3
5
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
4
6
|
import { spawnSync } from "node:child_process";
|
|
@@ -1589,6 +1591,85 @@ export class FleetManager {
|
|
|
1589
1591
|
child.once("error", err => this.failUpdateProgress(err.message));
|
|
1590
1592
|
child.unref();
|
|
1591
1593
|
}
|
|
1594
|
+
async handleProfileSlash(data, adapterId) {
|
|
1595
|
+
const route = this.routing.resolve(data.channelId);
|
|
1596
|
+
await this.handleGeneralProfile(route?.kind === "general" ? route.name : undefined, data.userId, adapterId, data.options?.seconds, data.respond);
|
|
1597
|
+
}
|
|
1598
|
+
/** Typed /profile belongs only to Telegram's General dispatcher. */
|
|
1599
|
+
async runProfileCommand(msg, seconds) {
|
|
1600
|
+
if (msg.source !== "telegram")
|
|
1601
|
+
return;
|
|
1602
|
+
const adapterId = msg.adapterId ?? this.getPrimaryAdapterId();
|
|
1603
|
+
const route = msg.threadId ? this.routing.resolve(msg.threadId) : undefined;
|
|
1604
|
+
const general = msg.threadId ? (route?.kind === "general" ? route.name : undefined)
|
|
1605
|
+
: Object.keys(this.fleetConfig?.instances ?? {}).find(name => this.fleetConfig.instances[name].general_topic
|
|
1606
|
+
&& this.getInstanceAdapterId(name) === adapterId);
|
|
1607
|
+
const adapter = adapterId ? this.adapters.get(adapterId) : this.adapter;
|
|
1608
|
+
if (!adapter)
|
|
1609
|
+
return;
|
|
1610
|
+
await this.handleGeneralProfile(general, msg.userId, adapterId, seconds, async (text) => (await adapter.sendText(msg.chatId, text, { threadId: msg.threadId })).messageId);
|
|
1611
|
+
}
|
|
1612
|
+
async handleGeneralProfile(general, userId, ingressAdapterId, seconds, respond) {
|
|
1613
|
+
if (!general || !this.fleetConfig?.instances[general]?.general_topic) {
|
|
1614
|
+
await respond(t("profile.general_only"));
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
const ownerId = this.getInstanceAdapterId(general);
|
|
1618
|
+
if (!ownerId || ownerId !== ingressAdapterId) {
|
|
1619
|
+
await respond(t("not_authorized"));
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
const gate = this.fleetAdminGate(userId, ownerId);
|
|
1623
|
+
if (gate !== "ok") {
|
|
1624
|
+
await respond(t(gate === "disabled" ? "profile.disabled" : "not_authorized"));
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
const adapter = this.getAdapterForInstance(general);
|
|
1628
|
+
const group = String(this.getGroupIdForInstance(general) ?? "");
|
|
1629
|
+
const topic = this.fleetConfig.instances[general].topic_id?.toString();
|
|
1630
|
+
if (!adapter || this.worlds.get(ownerId)?.adapter !== adapter || !group) {
|
|
1631
|
+
await respond(t("profile.unavailable"));
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
let duration;
|
|
1635
|
+
try {
|
|
1636
|
+
duration = profileDuration(seconds);
|
|
1637
|
+
}
|
|
1638
|
+
catch {
|
|
1639
|
+
await respond(t("profile.invalid"));
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
let ticket;
|
|
1643
|
+
try {
|
|
1644
|
+
ticket = await this.startCpuProfile(duration);
|
|
1645
|
+
}
|
|
1646
|
+
catch (err) {
|
|
1647
|
+
await respond(err instanceof ProfileBusyError ? t("profile.busy", String(err.remainingSeconds)) : t("profile.unavailable"));
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
// A long recording must not hold a Discord interaction (or the inbound handler) open.
|
|
1651
|
+
const initial = Promise.resolve().then(() => respond(t("profile.started", String(ticket.seconds))));
|
|
1652
|
+
void (async () => {
|
|
1653
|
+
await initial.catch(err => this.logger.warn({ err }, "CPU profile acknowledgement failed"));
|
|
1654
|
+
let message;
|
|
1655
|
+
try {
|
|
1656
|
+
const result = await ticket.done;
|
|
1657
|
+
const size = result.bytes === null ? t("profile.size_unknown") : `${(result.bytes / 1048576).toFixed(2)} MiB`;
|
|
1658
|
+
message = t("profile.saved", result.path, size);
|
|
1659
|
+
}
|
|
1660
|
+
catch {
|
|
1661
|
+
message = t("profile.failed");
|
|
1662
|
+
}
|
|
1663
|
+
// Never send a delayed artifact path to a replacement adapter/topic/world.
|
|
1664
|
+
if (this.shuttingDown || this.getInstanceAdapterId(general) !== ownerId
|
|
1665
|
+
|| this.getAdapterForInstance(general) !== adapter || String(this.getGroupIdForInstance(general) ?? "") !== group
|
|
1666
|
+
|| this.fleetConfig?.instances[general]?.topic_id?.toString() !== topic
|
|
1667
|
+
|| !this.fleetConfig?.instances[general]?.general_topic)
|
|
1668
|
+
return;
|
|
1669
|
+
await adapter.sendText(group, message, { threadId: topic });
|
|
1670
|
+
})().catch(err => this.logger.warn({ err }, "CPU profile General notice failed"));
|
|
1671
|
+
await initial.catch(() => { });
|
|
1672
|
+
}
|
|
1592
1673
|
async handleRestartSlash(data, adapterId) {
|
|
1593
1674
|
if (!this.isFleetAdmin(data.userId, adapterId)) {
|
|
1594
1675
|
await data.respond(t("not_authorized"));
|
|
@@ -2360,6 +2441,36 @@ export class FleetManager {
|
|
|
2360
2441
|
return "disabled";
|
|
2361
2442
|
return this.isFleetAdmin(userId, adapterId) ? "ok" : "denied";
|
|
2362
2443
|
}
|
|
2444
|
+
runtimeCpuProfiler = null;
|
|
2445
|
+
cpuProfileControl = null;
|
|
2446
|
+
getCpuProfiler() {
|
|
2447
|
+
if (!this.runtimeCpuProfiler || (this.runtimeCpuProfiler.closed && !this.shuttingDown)) {
|
|
2448
|
+
this.runtimeCpuProfiler = new RuntimeCpuProfiler({ dataDir: this.dataDir,
|
|
2449
|
+
logger: { info: message => this.logger.info(message), warn: message => this.logger.warn(message) } });
|
|
2450
|
+
}
|
|
2451
|
+
return this.runtimeCpuProfiler;
|
|
2452
|
+
}
|
|
2453
|
+
/** Called only by cold CLI startup, after claiming the fleet singleton. */
|
|
2454
|
+
async startCpuProfileControl() {
|
|
2455
|
+
if (this.shuttingDown || this.cpuProfileControl)
|
|
2456
|
+
return;
|
|
2457
|
+
const control = new ProfileControlServer(this.dataDir, this.getCpuProfiler());
|
|
2458
|
+
this.cpuProfileControl = control; // shutdown owns even an in-flight listen
|
|
2459
|
+
try {
|
|
2460
|
+
await control.listen();
|
|
2461
|
+
}
|
|
2462
|
+
catch (err) {
|
|
2463
|
+
if (this.cpuProfileControl === control)
|
|
2464
|
+
this.cpuProfileControl = null;
|
|
2465
|
+
throw err;
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
startEnvironmentCpuProfile() { return this.getCpuProfiler().startFromEnvironment(); }
|
|
2469
|
+
startCpuProfile(seconds) {
|
|
2470
|
+
if (this.shuttingDown)
|
|
2471
|
+
return Promise.reject(new Error("Fleet is stopping."));
|
|
2472
|
+
return this.getCpuProfiler().start(seconds);
|
|
2473
|
+
}
|
|
2363
2474
|
/**
|
|
2364
2475
|
* Every Discord slash command, for every adapter, in one place (it used to be two copies that could drift).
|
|
2365
2476
|
*
|
|
@@ -2492,6 +2603,9 @@ export class FleetManager {
|
|
|
2492
2603
|
else if (data.command === "update") {
|
|
2493
2604
|
await this.handleUpdateSlash(data, adapterId);
|
|
2494
2605
|
}
|
|
2606
|
+
else if (data.command === "profile") {
|
|
2607
|
+
await this.handleProfileSlash(data, adapterId);
|
|
2608
|
+
}
|
|
2495
2609
|
else if (data.command === "doctor") {
|
|
2496
2610
|
await data.respond(await this.runBackendDoctor());
|
|
2497
2611
|
}
|
|
@@ -5325,6 +5439,35 @@ export class FleetManager {
|
|
|
5325
5439
|
* the message came from — its own world's group_id. Two Telegram fleet
|
|
5326
5440
|
* worlds can number topics alike; a coincidence is dropped, never delivered.
|
|
5327
5441
|
*/
|
|
5442
|
+
/**
|
|
5443
|
+
* #1346: whether this copy may claim the shared dedup key for
|
|
5444
|
+
* command-like text. Fleet topics resolve an owning adapter and only its
|
|
5445
|
+
* copy proceeds; anything else (classic targets, unknown routing, no
|
|
5446
|
+
* identity) passes through to the existing handling.
|
|
5447
|
+
*/
|
|
5448
|
+
isOwnerCommandCopy(msg, threadId) {
|
|
5449
|
+
if (!msg.adapterId || threadId === undefined)
|
|
5450
|
+
return true;
|
|
5451
|
+
const target = this.resolveInboundTarget(msg, threadId);
|
|
5452
|
+
if (!target || target.kind === "classic")
|
|
5453
|
+
return true;
|
|
5454
|
+
const owner = this.getInstanceAdapterId(target.name);
|
|
5455
|
+
if (owner && msg.adapterId !== owner)
|
|
5456
|
+
return false;
|
|
5457
|
+
// #1346 6b: /cmd@otherbot is addressed elsewhere — ignore it even on the
|
|
5458
|
+
// owner's copy. An unknown username can't be judged: let it through (the
|
|
5459
|
+
// receiver gate above still applies). Same case-insensitive rule as the
|
|
5460
|
+
// Telegram classic branch.
|
|
5461
|
+
// Hyphenated aliases count too (e.g. /install-cli): Telegram command
|
|
5462
|
+
// names cannot contain "-", but typed aliases can.
|
|
5463
|
+
const suffix = msg.text?.trim().match(/^\/[\w-]+@(\S+)/)?.[1];
|
|
5464
|
+
if (suffix && owner) {
|
|
5465
|
+
const ownerUser = this.worlds.get(owner)?.botUsername;
|
|
5466
|
+
if (ownerUser && suffix.toLowerCase() !== ownerUser.toLowerCase())
|
|
5467
|
+
return false;
|
|
5468
|
+
}
|
|
5469
|
+
return true;
|
|
5470
|
+
}
|
|
5328
5471
|
resolveInboundTarget(msg, threadId) {
|
|
5329
5472
|
if (msg.source !== "telegram")
|
|
5330
5473
|
return this.routing.resolve(threadId);
|
|
@@ -5388,6 +5531,16 @@ export class FleetManager {
|
|
|
5388
5531
|
// EXCEPTION — classic channels with same-channel multi-bot: two bots may own
|
|
5389
5532
|
// separate agents in one channel, so each bot must process its OWN copy of
|
|
5390
5533
|
// the message (the @mention filter downstream decides who actually forwards).
|
|
5534
|
+
// #1346: a non-owner adapter's copy of command-like text must not burn
|
|
5535
|
+
// the shared dedup key — the owner's copy still has to run the command.
|
|
5536
|
+
// (Non-command input keeps first-wins delivery.) Classic channels keep
|
|
5537
|
+
// per-adapter keys — first-/start-contact onboarding needs every copy
|
|
5538
|
+
// to flow — so only fleet targets are judged here; the in-handler gates
|
|
5539
|
+
// (owner entry check, Discord ignore) cover classic instead.
|
|
5540
|
+
if (msg.messageId && /^\/\w/.test(msg.text?.trim() ?? "") && !this.isOwnerCommandCopy(msg, threadId)) {
|
|
5541
|
+
this.logger.debug({ adapterId: msg.adapterId, threadId }, "Non-owner command copy — skipping before dedup claim");
|
|
5542
|
+
return;
|
|
5543
|
+
}
|
|
5391
5544
|
// Key the dedup per-adapter there so a sibling bot's copy isn't dropped.
|
|
5392
5545
|
if (msg.messageId) {
|
|
5393
5546
|
const classicCid = threadId || msg.chatId;
|
|
@@ -5716,11 +5869,14 @@ export class FleetManager {
|
|
|
5716
5869
|
// Unregistered group: ignore
|
|
5717
5870
|
return;
|
|
5718
5871
|
}
|
|
5719
|
-
// General topic:
|
|
5872
|
+
// General topic: Discord /xxx gets the system note first (never a
|
|
5873
|
+
// command); other sources fall through to the handlers below.
|
|
5720
5874
|
const generalInstance = this.findGeneralInstance(msg.adapterId);
|
|
5875
|
+
if (generalInstance && await this.replyDiscordNotACommand(msg, generalInstance))
|
|
5876
|
+
return;
|
|
5721
5877
|
if (generalInstance && await this.topicCommands.handleInstanceCommand(msg, generalInstance))
|
|
5722
5878
|
return;
|
|
5723
|
-
if (await this.topicCommands.handleGeneralCommand(msg))
|
|
5879
|
+
if (generalInstance && await this.topicCommands.handleGeneralCommand(msg, generalInstance))
|
|
5724
5880
|
return;
|
|
5725
5881
|
// Forward to General Topic instance if configured
|
|
5726
5882
|
if (generalInstance) {
|
|
@@ -5805,13 +5961,18 @@ export class FleetManager {
|
|
|
5805
5961
|
return;
|
|
5806
5962
|
}
|
|
5807
5963
|
const instanceName = target.name;
|
|
5964
|
+
// #1346: on Discord, plain-text /xxx runs no command — the owner posts
|
|
5965
|
+
// the system note before the handlers below ever see the text.
|
|
5966
|
+
if (await this.replyDiscordNotACommand(msg, instanceName)) {
|
|
5967
|
+
return;
|
|
5968
|
+
}
|
|
5808
5969
|
// Intercept /ctx /compact /collab in ANY topic (including general)
|
|
5809
5970
|
if (await this.topicCommands.handleInstanceCommand(msg, instanceName)) {
|
|
5810
5971
|
return;
|
|
5811
5972
|
}
|
|
5812
5973
|
// Intercept admin commands (/status, /restart, /sysinfo) in general topics
|
|
5813
5974
|
const instanceConfig = this.fleetConfig?.instances[instanceName];
|
|
5814
|
-
if (instanceConfig?.general_topic && await this.topicCommands.handleGeneralCommand(msg)) {
|
|
5975
|
+
if (instanceConfig?.general_topic && await this.topicCommands.handleGeneralCommand(msg, instanceName)) {
|
|
5815
5976
|
return;
|
|
5816
5977
|
}
|
|
5817
5978
|
// Bind instance to the adapter that delivered this message
|
|
@@ -11077,13 +11238,50 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11077
11238
|
this.failoverActive.clear();
|
|
11078
11239
|
}
|
|
11079
11240
|
// ── Classic Channel Methods ──────────────────────────────────────────
|
|
11241
|
+
/**
|
|
11242
|
+
* #1346: on Discord, plain-text /xxx is never a command — the slash menu
|
|
11243
|
+
* is. Both fleet call sites run this BEFORE the command handlers, so even
|
|
11244
|
+
* the owning adapter's copy never executes text. The owning adapter posts
|
|
11245
|
+
* one system note and consumes the message so it never reaches the agent;
|
|
11246
|
+
* other adapters' copies stay silent (and the shared dedup key means
|
|
11247
|
+
* exactly one copy gets here). Telegram keeps text commands working —
|
|
11248
|
+
* text is its command path — and other sources pass through untouched.
|
|
11249
|
+
*/
|
|
11250
|
+
async replyDiscordNotACommand(msg, instanceName) {
|
|
11251
|
+
if (msg.source !== "discord")
|
|
11252
|
+
return false;
|
|
11253
|
+
if (!/^\/\w/.test(msg.text?.trim() ?? ""))
|
|
11254
|
+
return false;
|
|
11255
|
+
const owner = this.getInstanceAdapterId(instanceName);
|
|
11256
|
+
if (msg.adapterId && owner && msg.adapterId !== owner)
|
|
11257
|
+
return false;
|
|
11258
|
+
const adapter = this.worlds.get(owner ?? msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
11259
|
+
if (!adapter)
|
|
11260
|
+
return false;
|
|
11261
|
+
await adapter.sendText(msg.chatId, t("cmd.not_a_command"), { threadId: msg.threadId });
|
|
11262
|
+
return true;
|
|
11263
|
+
}
|
|
11080
11264
|
/** Handle a message in a classic channel: log it, forward only /chat messages */
|
|
11081
11265
|
async handleClassicChannelMessage(instanceName, msg) {
|
|
11082
11266
|
const text = msg.text ?? "";
|
|
11083
11267
|
const channelId = msg.threadId ?? msg.chatId;
|
|
11084
11268
|
const isCollabMode = this.classicChannels?.isCollab(channelId, msg.adapterId) ?? false;
|
|
11085
|
-
//
|
|
11269
|
+
// #1346: Discord ClassicBot channels ignore plain-text /xxx silently —
|
|
11270
|
+
// no warning (multi-bot groups must not all warn), no command, and no
|
|
11271
|
+
// forward to the agent either. The received-reaction and chat-log paths
|
|
11272
|
+
// below still run (a /chat ack is not a reply). Telegram keeps working
|
|
11273
|
+
// (text is its command path there).
|
|
11274
|
+
const discordSlashText = msg.source === "discord" && /^\/\w/.test(text.trim());
|
|
11275
|
+
// Handle /ctx in classic mode — always, regardless of collab mode.
|
|
11276
|
+
// Only the adapter that owns an entry in this channel answers; the
|
|
11277
|
+
// per-adapter dedup key already scopes copies, this guards the rest.
|
|
11278
|
+
// Discord /chat still flows through (its received-reaction below is an
|
|
11279
|
+
// ack, not a reply); only its forward is skipped.
|
|
11280
|
+
if (discordSlashText && !/^\/chat(\s|$)/.test(text.trim()))
|
|
11281
|
+
return;
|
|
11086
11282
|
if (text === "/ctx" || text.startsWith("/ctx@")) {
|
|
11283
|
+
if (!this.classicChannels?.getInstanceByChannel(channelId, msg.adapterId))
|
|
11284
|
+
return;
|
|
11087
11285
|
const reply = await this.topicCommands.getCtxText(instanceName);
|
|
11088
11286
|
const classicAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
11089
11287
|
if (classicAdapter)
|
|
@@ -11185,7 +11383,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11185
11383
|
finalText = `${tags}\n${finalText}`;
|
|
11186
11384
|
}
|
|
11187
11385
|
}
|
|
11188
|
-
|
|
11386
|
+
// #1346: Discord typed /xxx is never forwarded (reacts above already ran).
|
|
11387
|
+
if (!discordSlashText)
|
|
11388
|
+
await this.forwardToClassicInstance(instanceName, finalText, msg, extraMeta);
|
|
11189
11389
|
return;
|
|
11190
11390
|
}
|
|
11191
11391
|
// Normal mode: /chat trigger
|
|
@@ -11252,7 +11452,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11252
11452
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
11253
11453
|
}
|
|
11254
11454
|
}
|
|
11255
|
-
|
|
11455
|
+
// #1346: Discord typed /chat gets its received-reaction above but is
|
|
11456
|
+
// never forwarded to the agent.
|
|
11457
|
+
if (!discordSlashText)
|
|
11458
|
+
await this.forwardToClassicInstance(instanceName, finalText, msg, extraMeta);
|
|
11256
11459
|
}
|
|
11257
11460
|
/** Download photo or document attachment to classic instance workspace inbox. Returns { path, kind } or undefined. */
|
|
11258
11461
|
async saveClassicAttachment(instanceName, msg) {
|
|
@@ -12690,6 +12893,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12690
12893
|
// because we asked it to. Set synchronously — doStopAll runs to its first
|
|
12691
12894
|
// await in the same tick as the signal handler, so no event can slip in.
|
|
12692
12895
|
this.shuttingDown = true;
|
|
12896
|
+
const profileStopped = this.runtimeCpuProfiler?.shutdown("fleet shutdown");
|
|
12693
12897
|
this.ipcStoppingInstances.add("__fleet_stopping__");
|
|
12694
12898
|
// Release held delivery promises before awaiting daemon shutdown, then
|
|
12695
12899
|
// reject spawn work which has not started. Otherwise a storm backoff could
|
|
@@ -12718,6 +12922,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12718
12922
|
// A login/install window is a dedicated tmux server with its own TTL
|
|
12719
12923
|
// timer and HTTP listener living in THIS process: without an explicit
|
|
12720
12924
|
// shutdown it would outlive us as an owner-less login CLI (sol B3).
|
|
12925
|
+
await profileStopped;
|
|
12926
|
+
await this.cpuProfileControl?.close();
|
|
12927
|
+
this.cpuProfileControl = null;
|
|
12721
12928
|
await this.shutdownLoginWindows();
|
|
12722
12929
|
// Cancel adapter retry timers
|
|
12723
12930
|
for (const state of this.adapterState.values()) {
|