@songsid/agend 2.0.8-beta.2 → 2.0.8-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/README.md +8 -3
- package/dist/adapter-world.d.ts +1 -1
- package/dist/adapter-world.js +2 -2
- package/dist/adapter-world.js.map +1 -1
- package/dist/backend/antigravity.js +6 -2
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.js +3 -2
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.js +4 -2
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/kiro.js +4 -2
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/opencode.js.map +1 -1
- package/dist/backend/types.d.ts +8 -0
- package/dist/backend/types.js +12 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +5 -1
- package/dist/channel/adapters/discord.js +68 -5
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +3 -0
- package/dist/channel/adapters/telegram.js +7 -0
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/tool-router.js +1 -1
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/tool-tracker.js +2 -2
- package/dist/channel/tool-tracker.js.map +1 -1
- package/dist/channel/types.d.ts +12 -2
- package/dist/cli.js +126 -48
- package/dist/cli.js.map +1 -1
- package/dist/daemon.d.ts +25 -1
- package/dist/daemon.js +179 -57
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-context.d.ts +2 -0
- package/dist/fleet-manager.d.ts +40 -0
- package/dist/fleet-manager.js +444 -138
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/session-management/SKILL.md +56 -1
- package/dist/instance-lifecycle.js +9 -0
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/outbound-handlers.d.ts +1 -0
- package/dist/outbound-handlers.js +3 -0
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +1 -1
- package/dist/tmux-control.d.ts +10 -0
- package/dist/tmux-control.js +29 -0
- package/dist/tmux-control.js.map +1 -1
- package/dist/tmux-manager.d.ts +7 -1
- package/dist/tmux-manager.js +17 -0
- package/dist/tmux-manager.js.map +1 -1
- package/dist/topic-commands.d.ts +21 -0
- package/dist/topic-commands.js +81 -6
- package/dist/topic-commands.js.map +1 -1
- package/package.json +1 -1
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, sanitizeInstanceName } from "./topic-commands.js";
|
|
25
|
+
import { TopicCommands, sanitizeInstanceName, 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";
|
|
@@ -78,6 +78,11 @@ export class FleetManager {
|
|
|
78
78
|
topicIcons = {};
|
|
79
79
|
lastActivity = new Map();
|
|
80
80
|
lastInboundUser = new Map(); // instanceName → last username
|
|
81
|
+
// Pending "🛑 Cancel" button per instance — sent after a message is delivered,
|
|
82
|
+
// retired when the agent replies, when cancelled, or after a timeout.
|
|
83
|
+
pendingCancelMessages = new Map();
|
|
84
|
+
// Last user message delivered to each instance — used to react ✅ on completion.
|
|
85
|
+
lastInboundMsg = new Map();
|
|
81
86
|
topicArchiver;
|
|
82
87
|
controlClient = null;
|
|
83
88
|
classicChannels = null;
|
|
@@ -732,13 +737,22 @@ export class FleetManager {
|
|
|
732
737
|
await this.startInstance(instanceName, config, topicMode);
|
|
733
738
|
// startInstance already calls connectIpcToInstance
|
|
734
739
|
}
|
|
735
|
-
this.adapter?.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted
|
|
740
|
+
this.adapter?.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
|
|
736
741
|
}
|
|
737
742
|
else {
|
|
738
|
-
this.adapter?.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}
|
|
743
|
+
this.adapter?.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
|
|
739
744
|
}
|
|
740
745
|
return;
|
|
741
746
|
}
|
|
747
|
+
if (data.callbackData.startsWith("cancel:")) {
|
|
748
|
+
const instanceName = data.callbackData.slice("cancel:".length);
|
|
749
|
+
// Idempotent: a button click only acts while the button is live. A
|
|
750
|
+
// second click (entry already cleared) is a no-op — don't re-send the
|
|
751
|
+
// interrupt key. (The /cancel command path calls cancelInstance directly.)
|
|
752
|
+
if (this.pendingCancelMessages.has(instanceName))
|
|
753
|
+
this.cancelInstance(instanceName);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
742
756
|
}, this.logger, "adapter.callback_query"));
|
|
743
757
|
this.adapter.on("topic_closed", safeHandler(async (data) => {
|
|
744
758
|
// Skip unbind if we archived this topic ourselves
|
|
@@ -780,7 +794,11 @@ export class FleetManager {
|
|
|
780
794
|
timestamp: new Date(),
|
|
781
795
|
});
|
|
782
796
|
}
|
|
783
|
-
else if (data.command === "save"
|
|
797
|
+
else if (data.command === "save") {
|
|
798
|
+
await this.handleSlashSave(data);
|
|
799
|
+
}
|
|
800
|
+
else if (data.command === "load") {
|
|
801
|
+
// load is kiro-cli/classic only — no claude-code equivalent.
|
|
784
802
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
785
803
|
await data.respond("⛔ This command requires admin access.");
|
|
786
804
|
return;
|
|
@@ -790,25 +808,13 @@ export class FleetManager {
|
|
|
790
808
|
await data.respond("No active agent in this channel. Use `/start` first.");
|
|
791
809
|
return;
|
|
792
810
|
}
|
|
793
|
-
|
|
794
|
-
if (
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
798
|
-
return;
|
|
799
|
-
}
|
|
800
|
-
rawCmd = data.options?.force ? `/chat save ${filename} -f` : `/chat save ${filename}`;
|
|
801
|
-
}
|
|
802
|
-
else {
|
|
803
|
-
const filename = data.options?.filename;
|
|
804
|
-
if (!/^[\w.-]+$/.test(filename)) {
|
|
805
|
-
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
806
|
-
return;
|
|
807
|
-
}
|
|
808
|
-
rawCmd = `/chat load ${filename}`;
|
|
811
|
+
const filename = data.options?.filename;
|
|
812
|
+
if (!SAVE_FILENAME_RE.test(filename ?? "")) {
|
|
813
|
+
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
814
|
+
return;
|
|
809
815
|
}
|
|
810
|
-
this.pasteRawToClassicInstance(target.name,
|
|
811
|
-
await data.respond(`✅ Sent
|
|
816
|
+
this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
|
|
817
|
+
await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
|
|
812
818
|
}
|
|
813
819
|
else if (data.command === "compact") {
|
|
814
820
|
const target = this.routing.resolve(data.channelId);
|
|
@@ -819,48 +825,23 @@ export class FleetManager {
|
|
|
819
825
|
const result = await this.topicCommands.sendCompact(target.name);
|
|
820
826
|
await data.respond(result);
|
|
821
827
|
}
|
|
822
|
-
else if (data.command === "
|
|
828
|
+
else if (data.command === "cancel") {
|
|
823
829
|
const target = this.routing.resolve(data.channelId);
|
|
824
830
|
if (!target) {
|
|
825
831
|
await data.respond("No active agent in this channel.");
|
|
826
832
|
return;
|
|
827
833
|
}
|
|
828
|
-
const
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
if (existsSync(statusFile)) {
|
|
837
|
-
const d = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
838
|
-
context = d.context_window?.used_percentage ?? null;
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
catch { /* ignore */ }
|
|
842
|
-
// Fallback: capture tmux pane
|
|
843
|
-
if (context == null) {
|
|
844
|
-
try {
|
|
845
|
-
const { execFileSync } = await import("node:child_process");
|
|
846
|
-
const { getTmuxSocketName } = await import("./paths.js");
|
|
847
|
-
const socketName = getTmuxSocketName();
|
|
848
|
-
const tmuxArgs = socketName
|
|
849
|
-
? ["-L", socketName, "capture-pane", "-t", `${getTmuxSession()}:${instanceName}`, "-p"]
|
|
850
|
-
: ["capture-pane", "-t", `${getTmuxSession()}:${instanceName}`, "-p"];
|
|
851
|
-
const pane = execFileSync("tmux", tmuxArgs, { encoding: "utf-8", timeout: 2000, stdio: ["pipe", "pipe", "pipe"] });
|
|
852
|
-
const m = pane.match(/(\d+)%.*[!❯>]/m) || pane.match(/◔\s*(\d+)%/) || pane.match(/\[(\d+)%\]/);
|
|
853
|
-
if (m)
|
|
854
|
-
context = parseInt(m[1], 10);
|
|
855
|
-
}
|
|
856
|
-
catch { /* ignore */ }
|
|
857
|
-
}
|
|
858
|
-
if (context != null) {
|
|
859
|
-
await data.respond(`📊 Context: ${context}% used\nBackend: ${backend}\nInstance: ${instanceName}`);
|
|
860
|
-
}
|
|
861
|
-
else {
|
|
862
|
-
await data.respond(`Context info not available yet.\nBackend: ${backend}\nInstance: ${instanceName}`);
|
|
834
|
+
const ok = this.cancelInstance(target.name);
|
|
835
|
+
await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
|
|
836
|
+
}
|
|
837
|
+
else if (data.command === "ctx") {
|
|
838
|
+
const target = this.routing.resolve(data.channelId);
|
|
839
|
+
if (!target) {
|
|
840
|
+
await data.respond("No active agent in this channel.");
|
|
841
|
+
return;
|
|
863
842
|
}
|
|
843
|
+
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
844
|
+
await data.respond(await this.topicCommands.getCtxText(target.name));
|
|
864
845
|
}
|
|
865
846
|
else if (data.command === "collab") {
|
|
866
847
|
const collabTarget = this.routing.resolve(data.channelId);
|
|
@@ -1021,11 +1002,19 @@ export class FleetManager {
|
|
|
1021
1002
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
1022
1003
|
await this.startInstance(instanceName, config, topicMode);
|
|
1023
1004
|
}
|
|
1024
|
-
adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted
|
|
1005
|
+
adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
|
|
1025
1006
|
}
|
|
1026
1007
|
else {
|
|
1027
|
-
adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}
|
|
1008
|
+
adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
|
|
1028
1009
|
}
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
if (data.callbackData.startsWith("cancel:")) {
|
|
1013
|
+
const instanceName = data.callbackData.slice("cancel:".length);
|
|
1014
|
+
// Idempotent: only the first click (while the button is live) acts.
|
|
1015
|
+
if (this.pendingCancelMessages.has(instanceName))
|
|
1016
|
+
this.cancelInstance(instanceName);
|
|
1017
|
+
return;
|
|
1029
1018
|
}
|
|
1030
1019
|
}, this.logger, `adapter[${adapterId}].callback_query`));
|
|
1031
1020
|
adapter.on("topic_closed", safeHandler(async (data) => {
|
|
@@ -1067,7 +1056,11 @@ export class FleetManager {
|
|
|
1067
1056
|
timestamp: new Date(),
|
|
1068
1057
|
});
|
|
1069
1058
|
}
|
|
1070
|
-
else if (data.command === "save"
|
|
1059
|
+
else if (data.command === "save") {
|
|
1060
|
+
await this.handleSlashSave(data);
|
|
1061
|
+
}
|
|
1062
|
+
else if (data.command === "load") {
|
|
1063
|
+
// load is kiro-cli/classic only — no claude-code equivalent.
|
|
1071
1064
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
1072
1065
|
await data.respond("⛔ This command requires admin access.");
|
|
1073
1066
|
return;
|
|
@@ -1077,25 +1070,13 @@ export class FleetManager {
|
|
|
1077
1070
|
await data.respond("No active agent in this channel. Use `/start` first.");
|
|
1078
1071
|
return;
|
|
1079
1072
|
}
|
|
1080
|
-
|
|
1081
|
-
if (
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
1085
|
-
return;
|
|
1086
|
-
}
|
|
1087
|
-
rawCmd = data.options?.force ? `/chat save ${filename} -f` : `/chat save ${filename}`;
|
|
1088
|
-
}
|
|
1089
|
-
else {
|
|
1090
|
-
const filename = data.options?.filename;
|
|
1091
|
-
if (!/^[\w.-]+$/.test(filename)) {
|
|
1092
|
-
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
1093
|
-
return;
|
|
1094
|
-
}
|
|
1095
|
-
rawCmd = `/chat load ${filename}`;
|
|
1073
|
+
const filename = data.options?.filename;
|
|
1074
|
+
if (!SAVE_FILENAME_RE.test(filename ?? "")) {
|
|
1075
|
+
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
1076
|
+
return;
|
|
1096
1077
|
}
|
|
1097
|
-
this.pasteRawToClassicInstance(target.name,
|
|
1098
|
-
await data.respond(`✅ Sent
|
|
1078
|
+
this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
|
|
1079
|
+
await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
|
|
1099
1080
|
}
|
|
1100
1081
|
else if (data.command === "compact") {
|
|
1101
1082
|
const target = this.routing.resolve(data.channelId);
|
|
@@ -1106,31 +1087,23 @@ export class FleetManager {
|
|
|
1106
1087
|
const result = await this.topicCommands.sendCompact(target.name);
|
|
1107
1088
|
await data.respond(result);
|
|
1108
1089
|
}
|
|
1109
|
-
else if (data.command === "
|
|
1090
|
+
else if (data.command === "cancel") {
|
|
1110
1091
|
const target = this.routing.resolve(data.channelId);
|
|
1111
1092
|
if (!target) {
|
|
1112
1093
|
await data.respond("No active agent in this channel.");
|
|
1113
1094
|
return;
|
|
1114
1095
|
}
|
|
1115
|
-
const
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
const d = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
1124
|
-
context = d.context_window?.used_percentage ?? null;
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
catch { /* ignore */ }
|
|
1128
|
-
if (context != null) {
|
|
1129
|
-
await data.respond(`📊 Context: ${context}% used\nBackend: ${ctxBackend}\nInstance: ${instanceName}`);
|
|
1130
|
-
}
|
|
1131
|
-
else {
|
|
1132
|
-
await data.respond(`Context info not available yet.\nBackend: ${ctxBackend}\nInstance: ${instanceName}`);
|
|
1096
|
+
const ok = this.cancelInstance(target.name);
|
|
1097
|
+
await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
|
|
1098
|
+
}
|
|
1099
|
+
else if (data.command === "ctx") {
|
|
1100
|
+
const target = this.routing.resolve(data.channelId);
|
|
1101
|
+
if (!target) {
|
|
1102
|
+
await data.respond("No active agent in this channel.");
|
|
1103
|
+
return;
|
|
1133
1104
|
}
|
|
1105
|
+
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
1106
|
+
await data.respond(await this.topicCommands.getCtxText(target.name));
|
|
1134
1107
|
}
|
|
1135
1108
|
else if (data.command === "collab") {
|
|
1136
1109
|
const collabTarget2 = this.routing.resolve(data.channelId);
|
|
@@ -1567,6 +1540,17 @@ export class FleetManager {
|
|
|
1567
1540
|
await msgAdapter?.sendText(chatId, result);
|
|
1568
1541
|
return;
|
|
1569
1542
|
}
|
|
1543
|
+
// Handle /cancel command
|
|
1544
|
+
if (text === "/cancel" || text.startsWith("/cancel@")) {
|
|
1545
|
+
const cancelTarget = this.routing.resolve(chatId);
|
|
1546
|
+
if (!cancelTarget || cancelTarget.kind !== "classic") {
|
|
1547
|
+
await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
const ok = this.cancelInstance(cancelTarget.name);
|
|
1551
|
+
await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelTarget.name}。` : `❌ ${cancelTarget.name} 未在執行。`);
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1570
1554
|
// Handle /ctx command
|
|
1571
1555
|
if (text === "/ctx" || text.startsWith("/ctx@")) {
|
|
1572
1556
|
const ctxTarget = this.routing.resolve(chatId);
|
|
@@ -1578,6 +1562,36 @@ export class FleetManager {
|
|
|
1578
1562
|
await msgAdapter?.sendText(chatId, reply);
|
|
1579
1563
|
return;
|
|
1580
1564
|
}
|
|
1565
|
+
// Handle /save command (admin only)
|
|
1566
|
+
if (text === "/save" || text.startsWith("/save ") || text.startsWith("/save@")) {
|
|
1567
|
+
if (!this.classicChannels.isAdmin(msg.userId)) {
|
|
1568
|
+
await msgAdapter?.sendText(chatId, "⛔ /save requires admin access.");
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
const saveTarget = this.routing.resolve(chatId);
|
|
1572
|
+
if (!saveTarget || saveTarget.kind !== "classic") {
|
|
1573
|
+
await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
const filename = parseSaveFilename(text);
|
|
1577
|
+
if (!filename) {
|
|
1578
|
+
await msgAdapter?.sendText(chatId, "Usage: /save <filename>");
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
if (!SAVE_FILENAME_RE.test(filename)) {
|
|
1582
|
+
await msgAdapter?.sendText(chatId, "⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
const backend = this.classicChannels.getBackendByInstance(saveTarget.name, this.fleetConfig?.defaults?.backend);
|
|
1586
|
+
const cmd = saveCommandForBackend(backend, filename);
|
|
1587
|
+
if (!cmd) {
|
|
1588
|
+
await msgAdapter?.sendText(chatId, SAVE_UNSUPPORTED_MSG);
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
this.pasteRawToClassicInstance(saveTarget.name, cmd);
|
|
1592
|
+
await msgAdapter?.sendText(chatId, `✅ Sent \`${cmd}\` to ${saveTarget.name}`);
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1581
1595
|
// Route to classic channel if registered
|
|
1582
1596
|
const target = this.routing.resolve(chatId);
|
|
1583
1597
|
if (target?.kind === "classic") {
|
|
@@ -1654,6 +1668,8 @@ export class FleetManager {
|
|
|
1654
1668
|
instance: generalInstance, sender: msg.username,
|
|
1655
1669
|
text: (text ?? "").slice(0, 2000), ts: new Date().toISOString(),
|
|
1656
1670
|
});
|
|
1671
|
+
this.trackInboundMsg(generalInstance, msg);
|
|
1672
|
+
void this.sendCancelButton(generalInstance);
|
|
1657
1673
|
}
|
|
1658
1674
|
}
|
|
1659
1675
|
return;
|
|
@@ -1691,7 +1707,7 @@ export class FleetManager {
|
|
|
1691
1707
|
const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
1692
1708
|
// React immediately — before any other Discord API calls
|
|
1693
1709
|
if (msg.chatId && msg.messageId) {
|
|
1694
|
-
inboundAdapter.react(
|
|
1710
|
+
inboundAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
|
|
1695
1711
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
1696
1712
|
}
|
|
1697
1713
|
// These may hit Discord API (topic icon, archive) — do after react
|
|
@@ -1730,6 +1746,8 @@ export class FleetManager {
|
|
|
1730
1746
|
instance: instanceName, sender: msg.username,
|
|
1731
1747
|
text: (text ?? "").slice(0, 2000), ts: new Date().toISOString(),
|
|
1732
1748
|
});
|
|
1749
|
+
this.trackInboundMsg(instanceName, msg);
|
|
1750
|
+
void this.sendCancelButton(instanceName);
|
|
1733
1751
|
}
|
|
1734
1752
|
/** Handle outbound tool calls from a daemon instance */
|
|
1735
1753
|
/** Warn (but don't block) when rate limits are high. 30-min debounce per instance. */
|
|
@@ -1803,6 +1821,9 @@ export class FleetManager {
|
|
|
1803
1821
|
// Route standard channel tools (reply, react, edit_message, download_attachment)
|
|
1804
1822
|
if (routeToolCall(outAdapter, tool, args, threadId, respond)) {
|
|
1805
1823
|
if (tool === "reply") {
|
|
1824
|
+
// Agent answered — retire its pending cancel button and mark ✅ done.
|
|
1825
|
+
this.clearCancelButton(instanceName);
|
|
1826
|
+
this.reactDone(instanceName);
|
|
1806
1827
|
const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
|
|
1807
1828
|
this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
|
|
1808
1829
|
this.emitSseEvent("message", {
|
|
@@ -1849,7 +1870,7 @@ export class FleetManager {
|
|
|
1849
1870
|
if (!chatId)
|
|
1850
1871
|
return;
|
|
1851
1872
|
if (editMessageId) {
|
|
1852
|
-
statusAdapter.editMessage(chatId, editMessageId, text).catch(e => this.logger.debug({ err: e }, "Failed to edit tool status message"));
|
|
1873
|
+
statusAdapter.editMessage(chatId, editMessageId, text, threadId).catch(e => this.logger.debug({ err: e }, "Failed to edit tool status message"));
|
|
1853
1874
|
}
|
|
1854
1875
|
else {
|
|
1855
1876
|
statusAdapter.sendText(chatId, text, { threadId }).then((sent) => {
|
|
@@ -1887,6 +1908,8 @@ export class FleetManager {
|
|
|
1887
1908
|
payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
|
|
1888
1909
|
meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
|
|
1889
1910
|
});
|
|
1911
|
+
// A scheduled trigger also puts the instance to work — show a cancel button.
|
|
1912
|
+
void this.sendCancelButton(target);
|
|
1890
1913
|
return true;
|
|
1891
1914
|
};
|
|
1892
1915
|
if (deliver()) {
|
|
@@ -2512,6 +2535,198 @@ export class FleetManager {
|
|
|
2512
2535
|
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
|
|
2513
2536
|
}
|
|
2514
2537
|
}
|
|
2538
|
+
// ── Cancel button ────────────────────────────────────────────────────
|
|
2539
|
+
// Sent after delivering a user message to an instance; clicking it (or
|
|
2540
|
+
// /cancel) sends Escape to the instance's pane to interrupt generation.
|
|
2541
|
+
/** Send a "🛑 Cancel" button to the instance's topic/channel after delivery. */
|
|
2542
|
+
/**
|
|
2543
|
+
* Handle the DC `/save` slash command for both classic AND fleet-topic targets.
|
|
2544
|
+
* Picks the backend-appropriate command (kiro → /chat save, claude → /export);
|
|
2545
|
+
* unsupported backends get a clear error. Routes via classic paste or fleet IPC.
|
|
2546
|
+
*/
|
|
2547
|
+
async handleSlashSave(data) {
|
|
2548
|
+
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
2549
|
+
await data.respond("⛔ This command requires admin access.");
|
|
2550
|
+
return;
|
|
2551
|
+
}
|
|
2552
|
+
const target = this.routing.resolve(data.channelId);
|
|
2553
|
+
if (!target) {
|
|
2554
|
+
await data.respond("No active agent in this channel. Use `/start` first.");
|
|
2555
|
+
return;
|
|
2556
|
+
}
|
|
2557
|
+
const filename = data.options?.filename ?? "";
|
|
2558
|
+
if (!SAVE_FILENAME_RE.test(filename)) {
|
|
2559
|
+
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
const backend = target.kind === "classic"
|
|
2563
|
+
? this.classicChannels.getBackendByInstance(target.name, this.fleetConfig?.defaults?.backend)
|
|
2564
|
+
: (this.fleetConfig?.instances[target.name]?.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
|
|
2565
|
+
// force (-f) is only meaningful for kiro/classic /chat save.
|
|
2566
|
+
const force = target.kind === "classic" && !!data.options?.force;
|
|
2567
|
+
const cmd = saveCommandForBackend(backend, filename, force);
|
|
2568
|
+
if (!cmd) {
|
|
2569
|
+
await data.respond(SAVE_UNSUPPORTED_MSG);
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
if (target.kind === "classic") {
|
|
2573
|
+
this.pasteRawToClassicInstance(target.name, cmd);
|
|
2574
|
+
}
|
|
2575
|
+
else {
|
|
2576
|
+
this.instanceIpcClients.get(target.name)?.send({ type: "raw_paste", content: cmd });
|
|
2577
|
+
}
|
|
2578
|
+
await data.respond(`✅ Sent \`${cmd}\` to ${target.name}`);
|
|
2579
|
+
}
|
|
2580
|
+
async sendCancelButton(instanceName) {
|
|
2581
|
+
// Replace any stale button for this instance first.
|
|
2582
|
+
this.clearCancelButton(instanceName);
|
|
2583
|
+
const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
2584
|
+
if (!adapter)
|
|
2585
|
+
return;
|
|
2586
|
+
const adapterId = this.instanceWorldBinding.get(instanceName);
|
|
2587
|
+
const groupId = this.getChannelConfig(adapterId)?.group_id;
|
|
2588
|
+
const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
|
|
2589
|
+
let chatId;
|
|
2590
|
+
let threadId;
|
|
2591
|
+
if (topicId != null && groupId) {
|
|
2592
|
+
// Fleet topic instance.
|
|
2593
|
+
chatId = String(groupId);
|
|
2594
|
+
threadId = String(topicId);
|
|
2595
|
+
}
|
|
2596
|
+
else {
|
|
2597
|
+
// Classic instance: chatId from the routing table.
|
|
2598
|
+
for (const [cid, target] of this.routing.entries()) {
|
|
2599
|
+
if (target.kind === "classic" && target.name === instanceName) {
|
|
2600
|
+
chatId = cid;
|
|
2601
|
+
break;
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
// General / flat fallback: post to the group (no thread).
|
|
2605
|
+
if (!chatId && groupId)
|
|
2606
|
+
chatId = String(groupId);
|
|
2607
|
+
}
|
|
2608
|
+
if (!chatId)
|
|
2609
|
+
return;
|
|
2610
|
+
try {
|
|
2611
|
+
const sent = await adapter.notifyAlert(chatId, {
|
|
2612
|
+
type: "cancel",
|
|
2613
|
+
instanceName,
|
|
2614
|
+
message: "👀 處理中…",
|
|
2615
|
+
choices: [{ id: `cancel:${instanceName}`, label: "🛑 取消" }],
|
|
2616
|
+
}, threadId ? { threadId } : undefined);
|
|
2617
|
+
// Retire the button when the instance returns to idle (work done), not on a
|
|
2618
|
+
// fixed timer. A fixed timeout fails for instances that finish without
|
|
2619
|
+
// calling the reply tool — notably the General coordinator, whose cancel
|
|
2620
|
+
// button is also continuously re-posted by inbound cross-instance traffic,
|
|
2621
|
+
// resetting any fixed timer so it never elapses. Tie clearing to actual idle.
|
|
2622
|
+
const timer = this.startCancelButtonIdleWatch(instanceName);
|
|
2623
|
+
this.pendingCancelMessages.set(instanceName, {
|
|
2624
|
+
adapterId, chatId: sent.chatId, messageId: sent.messageId, threadId: sent.threadId ?? threadId, timer,
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
catch (e) {
|
|
2628
|
+
this.logger.debug({ err: e.message, instanceName }, "Failed to send cancel button");
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
/**
|
|
2632
|
+
* Poll the instance's idle state and retire its cancel button once the work is
|
|
2633
|
+
* done. Waits until the CLI has actually gone busy, then requires a short
|
|
2634
|
+
* sustained-idle streak (so a mid-turn thinking pause doesn't clear it early).
|
|
2635
|
+
* A long hard cap guards against leaks without cutting off legitimately
|
|
2636
|
+
* long-running work (the button must stay clickable while the CLI is busy).
|
|
2637
|
+
*/
|
|
2638
|
+
startCancelButtonIdleWatch(instanceName) {
|
|
2639
|
+
const POLL_MS = 2000;
|
|
2640
|
+
const IDLE_STREAK_TO_CLEAR = 3; // ~6s sustained idle
|
|
2641
|
+
const HARD_CAP_MS = 30 * 60_000;
|
|
2642
|
+
const start = Date.now();
|
|
2643
|
+
let sawBusy = false;
|
|
2644
|
+
let idleStreak = 0;
|
|
2645
|
+
return setInterval(() => {
|
|
2646
|
+
if (Date.now() - start > HARD_CAP_MS) {
|
|
2647
|
+
this.clearCancelButton(instanceName);
|
|
2648
|
+
return;
|
|
2649
|
+
}
|
|
2650
|
+
const idle = this.getInstanceIdle(instanceName);
|
|
2651
|
+
if (!idle) {
|
|
2652
|
+
sawBusy = true;
|
|
2653
|
+
idleStreak = 0;
|
|
2654
|
+
return;
|
|
2655
|
+
}
|
|
2656
|
+
// Idle observed. Don't clear until the CLI has actually started working,
|
|
2657
|
+
// otherwise delivery latency (queue → paste → run) would clear it instantly.
|
|
2658
|
+
// But if it never goes busy (delivery failed / no-op message), don't linger
|
|
2659
|
+
// to the hard cap — retire it after a short grace period.
|
|
2660
|
+
if (!sawBusy) {
|
|
2661
|
+
if (Date.now() - start > 60_000)
|
|
2662
|
+
this.clearCancelButton(instanceName);
|
|
2663
|
+
return;
|
|
2664
|
+
}
|
|
2665
|
+
if (++idleStreak >= IDLE_STREAK_TO_CLEAR)
|
|
2666
|
+
this.clearCancelButton(instanceName);
|
|
2667
|
+
}, POLL_MS);
|
|
2668
|
+
}
|
|
2669
|
+
/** Retire (delete) the pending cancel button — on reply, cancel, idle, or cap. */
|
|
2670
|
+
clearCancelButton(instanceName) {
|
|
2671
|
+
const pending = this.pendingCancelMessages.get(instanceName);
|
|
2672
|
+
if (!pending)
|
|
2673
|
+
return;
|
|
2674
|
+
clearInterval(pending.timer);
|
|
2675
|
+
this.pendingCancelMessages.delete(instanceName);
|
|
2676
|
+
const adapter = (pending.adapterId ? this.worlds.get(pending.adapterId)?.adapter : undefined)
|
|
2677
|
+
?? this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
2678
|
+
if (!adapter)
|
|
2679
|
+
return;
|
|
2680
|
+
if (adapter.deleteMessage) {
|
|
2681
|
+
adapter.deleteMessage(pending.chatId, pending.messageId, pending.threadId)
|
|
2682
|
+
.catch((e) => this.logger.debug({ err: e.message, instanceName }, "Failed to delete cancel button"));
|
|
2683
|
+
}
|
|
2684
|
+
else if (adapter.editMessageRemoveButtons) {
|
|
2685
|
+
adapter.editMessageRemoveButtons(pending.chatId, pending.messageId, "✅", pending.threadId)
|
|
2686
|
+
.catch(() => { });
|
|
2687
|
+
}
|
|
2688
|
+
else {
|
|
2689
|
+
// Last resort for adapters without delete or button-removal.
|
|
2690
|
+
adapter.editMessage(pending.chatId, pending.messageId, "✅", pending.threadId).catch(() => { });
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
/**
|
|
2694
|
+
* Reaction target chat id. Telegram reactions key on the supergroup chat_id
|
|
2695
|
+
* (the topic thread is NOT a chat_id), so a forum-topic message must react on
|
|
2696
|
+
* msg.chatId — reacting on threadId silently fails. Discord reactions key on
|
|
2697
|
+
* the channel/thread id.
|
|
2698
|
+
*/
|
|
2699
|
+
reactTarget(msg) {
|
|
2700
|
+
return msg.source === "telegram" ? msg.chatId : (msg.threadId ?? msg.chatId);
|
|
2701
|
+
}
|
|
2702
|
+
/** Remember the user message just delivered, so we can react ✅ when done. */
|
|
2703
|
+
trackInboundMsg(instanceName, msg) {
|
|
2704
|
+
if (!msg.chatId || !msg.messageId)
|
|
2705
|
+
return;
|
|
2706
|
+
this.lastInboundMsg.set(instanceName, {
|
|
2707
|
+
adapterId: msg.adapterId, chatId: msg.chatId, threadId: msg.threadId ?? undefined, messageId: msg.messageId, source: msg.source,
|
|
2708
|
+
});
|
|
2709
|
+
}
|
|
2710
|
+
/** React ✅ on the last user message after the agent replies. */
|
|
2711
|
+
reactDone(instanceName) {
|
|
2712
|
+
const m = this.lastInboundMsg.get(instanceName);
|
|
2713
|
+
if (!m)
|
|
2714
|
+
return;
|
|
2715
|
+
this.lastInboundMsg.delete(instanceName);
|
|
2716
|
+
const adapter = (m.adapterId ? this.worlds.get(m.adapterId)?.adapter : undefined)
|
|
2717
|
+
?? this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
2718
|
+
adapter?.react(this.reactTarget(m), m.messageId, "✅").catch(() => { });
|
|
2719
|
+
}
|
|
2720
|
+
/** Interrupt an instance's current generation (cancel button / /cancel). */
|
|
2721
|
+
cancelInstance(instanceName) {
|
|
2722
|
+
const daemon = this.daemons.get(instanceName);
|
|
2723
|
+
if (!daemon)
|
|
2724
|
+
return false;
|
|
2725
|
+
daemon.sendEscape().catch(e => this.logger.warn({ err: e, instanceName }, "sendEscape failed"));
|
|
2726
|
+
this.lastInboundMsg.delete(instanceName);
|
|
2727
|
+
this.clearCancelButton(instanceName);
|
|
2728
|
+
return true;
|
|
2729
|
+
}
|
|
2515
2730
|
queueMirrorMessage(text) {
|
|
2516
2731
|
const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
|
|
2517
2732
|
if (mirrorTopicId == null || !this.adapter)
|
|
@@ -2850,10 +3065,17 @@ When users create specialized instances, suggest these configurations:
|
|
|
2850
3065
|
// Skip empty bot messages (e.g., reactions) — don't pollute chat log
|
|
2851
3066
|
if (msg.isBotMessage && !text && !msg.attachments?.length)
|
|
2852
3067
|
return;
|
|
3068
|
+
// Save attachments FIRST so the chat-log records their inbox paths
|
|
3069
|
+
// (consistent with the /chat path). Otherwise a non-@mention image is
|
|
3070
|
+
// saved to inbox but its path never reaches the agent — the log keeps
|
|
3071
|
+
// only a pathless filename, so later context can't locate the file.
|
|
3072
|
+
const saved = msg.attachments?.length ? await this.saveClassicAttachment(instanceName, msg) : undefined;
|
|
2853
3073
|
// Log every message (including other bots) to chat-logs
|
|
2854
|
-
const collabAttachTag =
|
|
2855
|
-
? ` [${
|
|
2856
|
-
:
|
|
3074
|
+
const collabAttachTag = saved
|
|
3075
|
+
? ` [${saved.kind === "photo" ? "📷" : "📎"} saved: ${saved.paths.join(", ")}]`
|
|
3076
|
+
: (msg.attachments?.length
|
|
3077
|
+
? ` [${msg.attachments.map(a => `${a.kind === "photo" ? "📷" : "📎"} ${a.filename || a.kind}`).join(", ")}]`
|
|
3078
|
+
: "");
|
|
2857
3079
|
ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText);
|
|
2858
3080
|
this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message");
|
|
2859
3081
|
// Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here
|
|
@@ -2861,19 +3083,16 @@ When users create specialized instances, suggest these configurations:
|
|
|
2861
3083
|
const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
|
|
2862
3084
|
const isMentioned = mentionTag && text.includes(mentionTag);
|
|
2863
3085
|
if (!isMentioned) {
|
|
2864
|
-
//
|
|
2865
|
-
if (
|
|
2866
|
-
const
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
const
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
reactAdapter.react(noMentionReactChatId, msg.messageId, emoji)
|
|
2875
|
-
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
2876
|
-
}
|
|
3086
|
+
// Bare attachment (no @mention) — already saved above; just acknowledge.
|
|
3087
|
+
if (saved) {
|
|
3088
|
+
const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
3089
|
+
const noMentionReactChatId = msg.threadId ?? msg.chatId;
|
|
3090
|
+
if (reactAdapter && noMentionReactChatId && msg.messageId) {
|
|
3091
|
+
const emoji = msg.source === "telegram"
|
|
3092
|
+
? (saved.kind === "photo" ? "👌" : "👍")
|
|
3093
|
+
: (saved.kind === "photo" ? "📸" : "📎");
|
|
3094
|
+
reactAdapter.react(noMentionReactChatId, msg.messageId, emoji)
|
|
3095
|
+
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
2877
3096
|
}
|
|
2878
3097
|
}
|
|
2879
3098
|
return;
|
|
@@ -2891,8 +3110,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
2891
3110
|
// Block /raw bypass
|
|
2892
3111
|
if (cleanText.startsWith("/raw "))
|
|
2893
3112
|
return;
|
|
2894
|
-
//
|
|
2895
|
-
const saved = await this.saveClassicAttachment(instanceName, msg);
|
|
3113
|
+
// Attachments already saved at the top of the collab block.
|
|
2896
3114
|
if (saved && classicAdapter && collabReactChatId && msg.messageId) {
|
|
2897
3115
|
const emoji = msg.source === "telegram"
|
|
2898
3116
|
? (saved.kind === "photo" ? "👌" : "👍")
|
|
@@ -3049,24 +3267,37 @@ When users create specialized instances, suggest these configurations:
|
|
|
3049
3267
|
this.logger.warn({ instanceName }, "Classic channel instance IPC not connected");
|
|
3050
3268
|
return;
|
|
3051
3269
|
}
|
|
3270
|
+
const meta = {
|
|
3271
|
+
chat_id: msg.chatId,
|
|
3272
|
+
message_id: msg.messageId,
|
|
3273
|
+
user: msg.username,
|
|
3274
|
+
user_id: msg.userId,
|
|
3275
|
+
ts: msg.timestamp.toISOString(),
|
|
3276
|
+
thread_id: msg.threadId ?? "",
|
|
3277
|
+
source: msg.source,
|
|
3278
|
+
...extraMeta,
|
|
3279
|
+
...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
|
|
3280
|
+
};
|
|
3281
|
+
// If the triggering message carried no image of its own, surface the most
|
|
3282
|
+
// recent image saved earlier in this channel (logged as "[📷 saved: <path>]"
|
|
3283
|
+
// by an untriggered collab message) as image_path, so the agent's
|
|
3284
|
+
// read-the-image trigger fires instead of the path sitting inert in context.
|
|
3285
|
+
if (!meta.image_path && logContext) {
|
|
3286
|
+
const saves = [...logContext.matchAll(/\[📷 saved: ([^\]]+)\]/g)];
|
|
3287
|
+
if (saves.length > 0) {
|
|
3288
|
+
meta.image_path = saves[saves.length - 1][1].split(",")[0].trim();
|
|
3289
|
+
}
|
|
3290
|
+
}
|
|
3052
3291
|
ipc.send({
|
|
3053
3292
|
type: "fleet_inbound",
|
|
3054
3293
|
content: fullText,
|
|
3055
3294
|
targetSession: instanceName,
|
|
3056
|
-
meta
|
|
3057
|
-
chat_id: msg.chatId,
|
|
3058
|
-
message_id: msg.messageId,
|
|
3059
|
-
user: msg.username,
|
|
3060
|
-
user_id: msg.userId,
|
|
3061
|
-
ts: msg.timestamp.toISOString(),
|
|
3062
|
-
thread_id: msg.threadId ?? "",
|
|
3063
|
-
source: msg.source,
|
|
3064
|
-
...extraMeta,
|
|
3065
|
-
...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
|
|
3066
|
-
},
|
|
3295
|
+
meta,
|
|
3067
3296
|
});
|
|
3068
3297
|
this.lastInboundUser.set(instanceName, msg.username);
|
|
3069
3298
|
this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
|
|
3299
|
+
this.trackInboundMsg(instanceName, msg);
|
|
3300
|
+
void this.sendCancelButton(instanceName);
|
|
3070
3301
|
}
|
|
3071
3302
|
/** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
|
|
3072
3303
|
pasteRawToClassicInstance(instanceName, text) {
|
|
@@ -3186,9 +3417,11 @@ When users create specialized instances, suggest these configurations:
|
|
|
3186
3417
|
this.topicArchiver.stop();
|
|
3187
3418
|
this.scheduler?.shutdown();
|
|
3188
3419
|
// Stop instances in parallel batches to avoid long sequential waits.
|
|
3189
|
-
// Concurrency
|
|
3190
|
-
|
|
3420
|
+
// Concurrency scales with fleet size — larger fleets tolerate more parallel
|
|
3421
|
+
// tmux ops, while small fleets stay conservative to avoid overwhelming the
|
|
3422
|
+
// tmux server.
|
|
3191
3423
|
const entries = [...this.daemons.entries()];
|
|
3424
|
+
const STOP_CONCURRENCY = entries.length > 30 ? 15 : entries.length >= 10 ? 10 : 5;
|
|
3192
3425
|
for (const [name] of entries)
|
|
3193
3426
|
this.ipcStoppingInstances.add(name);
|
|
3194
3427
|
for (let i = 0; i < entries.length; i += STOP_CONCURRENCY) {
|
|
@@ -3203,9 +3436,9 @@ When users create specialized instances, suggest these configurations:
|
|
|
3203
3436
|
this.daemons.delete(name);
|
|
3204
3437
|
}));
|
|
3205
3438
|
}
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
}
|
|
3439
|
+
// Close IPC clients in parallel — serial close over a large fleet adds
|
|
3440
|
+
// noticeable latency.
|
|
3441
|
+
await Promise.all([...this.instanceIpcClients.values()].map(ipc => Promise.resolve(ipc.close()).catch(() => { })));
|
|
3209
3442
|
this.instanceIpcClients.clear();
|
|
3210
3443
|
this.ipcStoppingInstances.clear();
|
|
3211
3444
|
for (const [, w] of this.worlds) {
|
|
@@ -3541,14 +3774,21 @@ When users create specialized instances, suggest these configurations:
|
|
|
3541
3774
|
const latest = execSync("npm view @songsid/agend version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
|
|
3542
3775
|
let target = latest;
|
|
3543
3776
|
if (currentVersion.includes("-beta")) {
|
|
3777
|
+
// Beta users track the @beta channel (never fall back to @latest, which is
|
|
3778
|
+
// older), but should also hear when a newer STABLE ships — pick whichever
|
|
3779
|
+
// of beta/latest is the newest.
|
|
3780
|
+
let beta = "";
|
|
3544
3781
|
try {
|
|
3545
|
-
|
|
3546
|
-
if (beta && beta !== currentVersion)
|
|
3547
|
-
target = beta;
|
|
3782
|
+
beta = execSync("npm view @songsid/agend@beta version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
|
|
3548
3783
|
}
|
|
3549
3784
|
catch { /* no beta tag */ }
|
|
3785
|
+
target = beta || latest;
|
|
3786
|
+
if (latest && this.semverGt(latest, target))
|
|
3787
|
+
target = latest;
|
|
3550
3788
|
}
|
|
3551
|
-
|
|
3789
|
+
// Only notify when target is genuinely newer (semver), so a beta user on
|
|
3790
|
+
// 2.0.8-beta.16 is never told that stable 2.0.7 is "available".
|
|
3791
|
+
if (target && this.semverGt(target, currentVersion)) {
|
|
3552
3792
|
const generalId = this.findGeneralInstance();
|
|
3553
3793
|
if (generalId) {
|
|
3554
3794
|
this.notifyInstanceTopic(generalId, `🆕 AgEnD v${target} available (current: v${currentVersion}). Use \`/update\` to upgrade.`);
|
|
@@ -3557,6 +3797,49 @@ When users create specialized instances, suggest these configurations:
|
|
|
3557
3797
|
}
|
|
3558
3798
|
catch { /* silent — network issues */ }
|
|
3559
3799
|
}
|
|
3800
|
+
/**
|
|
3801
|
+
* Semver "a > b". Compares major.minor.patch numerically; a version without a
|
|
3802
|
+
* prerelease outranks the same core with one (2.0.8 > 2.0.8-beta.16); two
|
|
3803
|
+
* prereleases compare identifier-by-identifier (numeric < alphanumeric, numeric
|
|
3804
|
+
* fields compared as numbers). Sufficient for our X.Y.Z[-beta.N] scheme.
|
|
3805
|
+
*/
|
|
3806
|
+
semverGt(a, b) {
|
|
3807
|
+
const parse = (v) => {
|
|
3808
|
+
const [core, pre] = v.replace(/^v/, "").split("-");
|
|
3809
|
+
const nums = core.split(".").map(n => parseInt(n, 10) || 0);
|
|
3810
|
+
return { nums: [nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0], pre: pre ? pre.split(".") : [] };
|
|
3811
|
+
};
|
|
3812
|
+
const pa = parse(a), pb = parse(b);
|
|
3813
|
+
for (let i = 0; i < 3; i++) {
|
|
3814
|
+
if (pa.nums[i] !== pb.nums[i])
|
|
3815
|
+
return pa.nums[i] > pb.nums[i];
|
|
3816
|
+
}
|
|
3817
|
+
if (pa.pre.length === 0 && pb.pre.length === 0)
|
|
3818
|
+
return false;
|
|
3819
|
+
if (pa.pre.length === 0)
|
|
3820
|
+
return true; // a stable, b prerelease → a > b
|
|
3821
|
+
if (pb.pre.length === 0)
|
|
3822
|
+
return false; // a prerelease, b stable → a < b
|
|
3823
|
+
const len = Math.max(pa.pre.length, pb.pre.length);
|
|
3824
|
+
for (let i = 0; i < len; i++) {
|
|
3825
|
+
const x = pa.pre[i], y = pb.pre[i];
|
|
3826
|
+
if (x === undefined)
|
|
3827
|
+
return false; // a has fewer identifiers → a < b
|
|
3828
|
+
if (y === undefined)
|
|
3829
|
+
return true; // a has more identifiers → a > b
|
|
3830
|
+
const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y);
|
|
3831
|
+
if (xn && yn) {
|
|
3832
|
+
const dx = parseInt(x, 10), dy = parseInt(y, 10);
|
|
3833
|
+
if (dx !== dy)
|
|
3834
|
+
return dx > dy;
|
|
3835
|
+
}
|
|
3836
|
+
else if (xn !== yn)
|
|
3837
|
+
return yn; // numeric has lower precedence than alphanumeric
|
|
3838
|
+
else if (x !== y)
|
|
3839
|
+
return x > y; // both alphanumeric
|
|
3840
|
+
}
|
|
3841
|
+
return false; // identical
|
|
3842
|
+
}
|
|
3560
3843
|
// ── Health HTTP endpoint ─────────────────────────────────────────────
|
|
3561
3844
|
startHealthServer(port) {
|
|
3562
3845
|
this.startedAt = Date.now();
|
|
@@ -3733,6 +4016,29 @@ When users create specialized instances, suggest these configurations:
|
|
|
3733
4016
|
})();
|
|
3734
4017
|
return;
|
|
3735
4018
|
}
|
|
4019
|
+
if (req.method === "POST" && req.url?.startsWith("/stop/")) {
|
|
4020
|
+
const name = decodeURIComponent(req.url.slice("/stop/".length));
|
|
4021
|
+
this.logger.info({ name }, "Instance stop requested via HTTP");
|
|
4022
|
+
(async () => {
|
|
4023
|
+
try {
|
|
4024
|
+
// Runs inside the live fleet process: lifecycle.stop finds the
|
|
4025
|
+
// in-memory daemon and stops just this instance. (Doing this from a
|
|
4026
|
+
// detached CLI FleetManager would read the shared daemon.pid — the
|
|
4027
|
+
// fleet's own pid — and kill the whole fleet.)
|
|
4028
|
+
await this.stopInstance(name);
|
|
4029
|
+
this.logger.info({ name }, "Instance stopped");
|
|
4030
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
4031
|
+
res.writeHead(200);
|
|
4032
|
+
res.end(JSON.stringify({ stopped: name }));
|
|
4033
|
+
}
|
|
4034
|
+
catch (err) {
|
|
4035
|
+
this.logger.error({ err, name }, "Instance stop failed");
|
|
4036
|
+
res.writeHead(500);
|
|
4037
|
+
res.end(JSON.stringify({ error: `Stop failed: ${err.message}` }));
|
|
4038
|
+
}
|
|
4039
|
+
})();
|
|
4040
|
+
return;
|
|
4041
|
+
}
|
|
3736
4042
|
// ── Agent CLI endpoint ─────
|
|
3737
4043
|
if (req.url === "/agent" && req.method === "POST") {
|
|
3738
4044
|
handleAgentRequest(req, res, this);
|