@songsid/agend 2.0.8-beta.3 → 2.0.8-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/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 +14 -5
- 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 +192 -105
- 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 +150 -95
- package/dist/cli.js.map +1 -1
- package/dist/daemon.d.ts +31 -1
- package/dist/daemon.js +163 -47
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-context.d.ts +2 -0
- package/dist/fleet-manager.d.ts +41 -0
- package/dist/fleet-manager.js +471 -139
- 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/logger.d.ts +9 -1
- package/dist/logger.js +17 -7
- package/dist/logger.js.map +1 -1
- package/dist/outbound-handlers.d.ts +2 -0
- package/dist/outbound-handlers.js +13 -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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync } from "node:fs";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
4
|
import { join, dirname, basename } from "node:path";
|
|
@@ -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;
|
|
@@ -443,6 +448,10 @@ export class FleetManager {
|
|
|
443
448
|
.catch(e => this.logger.warn({ err: e }, "Failed to send daily summary"));
|
|
444
449
|
// Rotate classic channel chat logs daily
|
|
445
450
|
this.classicChannels?.rotateLogs();
|
|
451
|
+
this.rotateInboxes();
|
|
452
|
+
// Rotate fleet.log daily too (besides the startup size check above), so a
|
|
453
|
+
// long-running fleet doesn't accumulate an unbounded log.
|
|
454
|
+
rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
|
|
446
455
|
}, () => {
|
|
447
456
|
const instances = Object.keys(this.fleetConfig?.instances ?? {});
|
|
448
457
|
const costMap = new Map();
|
|
@@ -454,6 +463,7 @@ export class FleetManager {
|
|
|
454
463
|
this.dailySummary.start();
|
|
455
464
|
// Rotate classic channel chat logs daily (piggyback on daily summary timer)
|
|
456
465
|
this.classicChannels?.rotateLogs();
|
|
466
|
+
this.rotateInboxes();
|
|
457
467
|
// Auto-create general instance(s) — one per adapter that lacks a general
|
|
458
468
|
const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
|
|
459
469
|
const generalInstances = Object.entries(fleet.instances).filter(([, inst]) => inst.general_topic === true);
|
|
@@ -681,6 +691,41 @@ export class FleetManager {
|
|
|
681
691
|
};
|
|
682
692
|
process.once("SIGUSR1", onFullRestart);
|
|
683
693
|
}
|
|
694
|
+
/**
|
|
695
|
+
* Delete inbox files older than retentionDays (by mtime). Cleans the shared
|
|
696
|
+
* inbox (`<dataDir>/inbox`) and every workspace inbox
|
|
697
|
+
* (`<agendHome>/workspaces/*\/inbox`). Piggybacks on the daily summary timer,
|
|
698
|
+
* mirroring classic chat-log rotation (same 7-day retention).
|
|
699
|
+
*/
|
|
700
|
+
rotateInboxes(retentionDays = 7) {
|
|
701
|
+
const cutoff = Date.now() - retentionDays * 86400_000;
|
|
702
|
+
const dirs = [join(this.dataDir, "inbox")];
|
|
703
|
+
const workspacesDir = join(getAgendHome(), "workspaces");
|
|
704
|
+
if (existsSync(workspacesDir)) {
|
|
705
|
+
for (const ws of readdirSync(workspacesDir)) {
|
|
706
|
+
dirs.push(join(workspacesDir, ws, "inbox"));
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
let deleted = 0;
|
|
710
|
+
for (const dir of dirs) {
|
|
711
|
+
if (!existsSync(dir))
|
|
712
|
+
continue;
|
|
713
|
+
for (const file of readdirSync(dir)) {
|
|
714
|
+
const full = join(dir, file);
|
|
715
|
+
try {
|
|
716
|
+
const st = statSync(full);
|
|
717
|
+
if (st.isFile() && st.mtimeMs < cutoff) {
|
|
718
|
+
unlinkSync(full);
|
|
719
|
+
deleted++;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
catch { /* file vanished or unreadable — skip */ }
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (deleted > 0)
|
|
726
|
+
this.logger.info({ deleted }, "Rotated inbox files");
|
|
727
|
+
return deleted;
|
|
728
|
+
}
|
|
684
729
|
/** Start the shared channel adapter(s) for topic mode */
|
|
685
730
|
async startSharedAdapter(fleet) {
|
|
686
731
|
const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
|
|
@@ -732,13 +777,22 @@ export class FleetManager {
|
|
|
732
777
|
await this.startInstance(instanceName, config, topicMode);
|
|
733
778
|
// startInstance already calls connectIpcToInstance
|
|
734
779
|
}
|
|
735
|
-
this.adapter?.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted
|
|
780
|
+
this.adapter?.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
|
|
736
781
|
}
|
|
737
782
|
else {
|
|
738
|
-
this.adapter?.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}
|
|
783
|
+
this.adapter?.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
|
|
739
784
|
}
|
|
740
785
|
return;
|
|
741
786
|
}
|
|
787
|
+
if (data.callbackData.startsWith("cancel:")) {
|
|
788
|
+
const instanceName = data.callbackData.slice("cancel:".length);
|
|
789
|
+
// Idempotent: a button click only acts while the button is live. A
|
|
790
|
+
// second click (entry already cleared) is a no-op — don't re-send the
|
|
791
|
+
// interrupt key. (The /cancel command path calls cancelInstance directly.)
|
|
792
|
+
if (this.pendingCancelMessages.has(instanceName))
|
|
793
|
+
this.cancelInstance(instanceName);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
742
796
|
}, this.logger, "adapter.callback_query"));
|
|
743
797
|
this.adapter.on("topic_closed", safeHandler(async (data) => {
|
|
744
798
|
// Skip unbind if we archived this topic ourselves
|
|
@@ -780,7 +834,11 @@ export class FleetManager {
|
|
|
780
834
|
timestamp: new Date(),
|
|
781
835
|
});
|
|
782
836
|
}
|
|
783
|
-
else if (data.command === "save"
|
|
837
|
+
else if (data.command === "save") {
|
|
838
|
+
await this.handleSlashSave(data);
|
|
839
|
+
}
|
|
840
|
+
else if (data.command === "load") {
|
|
841
|
+
// load is kiro-cli/classic only — no claude-code equivalent.
|
|
784
842
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
785
843
|
await data.respond("⛔ This command requires admin access.");
|
|
786
844
|
return;
|
|
@@ -790,25 +848,13 @@ export class FleetManager {
|
|
|
790
848
|
await data.respond("No active agent in this channel. Use `/start` first.");
|
|
791
849
|
return;
|
|
792
850
|
}
|
|
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}`;
|
|
851
|
+
const filename = data.options?.filename;
|
|
852
|
+
if (!SAVE_FILENAME_RE.test(filename ?? "")) {
|
|
853
|
+
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
854
|
+
return;
|
|
809
855
|
}
|
|
810
|
-
this.pasteRawToClassicInstance(target.name,
|
|
811
|
-
await data.respond(`✅ Sent
|
|
856
|
+
this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
|
|
857
|
+
await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
|
|
812
858
|
}
|
|
813
859
|
else if (data.command === "compact") {
|
|
814
860
|
const target = this.routing.resolve(data.channelId);
|
|
@@ -819,48 +865,23 @@ export class FleetManager {
|
|
|
819
865
|
const result = await this.topicCommands.sendCompact(target.name);
|
|
820
866
|
await data.respond(result);
|
|
821
867
|
}
|
|
822
|
-
else if (data.command === "
|
|
868
|
+
else if (data.command === "cancel") {
|
|
823
869
|
const target = this.routing.resolve(data.channelId);
|
|
824
870
|
if (!target) {
|
|
825
871
|
await data.respond("No active agent in this channel.");
|
|
826
872
|
return;
|
|
827
873
|
}
|
|
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}`);
|
|
874
|
+
const ok = this.cancelInstance(target.name);
|
|
875
|
+
await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
|
|
876
|
+
}
|
|
877
|
+
else if (data.command === "ctx") {
|
|
878
|
+
const target = this.routing.resolve(data.channelId);
|
|
879
|
+
if (!target) {
|
|
880
|
+
await data.respond("No active agent in this channel.");
|
|
881
|
+
return;
|
|
863
882
|
}
|
|
883
|
+
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
884
|
+
await data.respond(await this.topicCommands.getCtxText(target.name));
|
|
864
885
|
}
|
|
865
886
|
else if (data.command === "collab") {
|
|
866
887
|
const collabTarget = this.routing.resolve(data.channelId);
|
|
@@ -1021,11 +1042,19 @@ export class FleetManager {
|
|
|
1021
1042
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
1022
1043
|
await this.startInstance(instanceName, config, topicMode);
|
|
1023
1044
|
}
|
|
1024
|
-
adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted
|
|
1045
|
+
adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
|
|
1025
1046
|
}
|
|
1026
1047
|
else {
|
|
1027
|
-
adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}
|
|
1048
|
+
adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
|
|
1028
1049
|
}
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
if (data.callbackData.startsWith("cancel:")) {
|
|
1053
|
+
const instanceName = data.callbackData.slice("cancel:".length);
|
|
1054
|
+
// Idempotent: only the first click (while the button is live) acts.
|
|
1055
|
+
if (this.pendingCancelMessages.has(instanceName))
|
|
1056
|
+
this.cancelInstance(instanceName);
|
|
1057
|
+
return;
|
|
1029
1058
|
}
|
|
1030
1059
|
}, this.logger, `adapter[${adapterId}].callback_query`));
|
|
1031
1060
|
adapter.on("topic_closed", safeHandler(async (data) => {
|
|
@@ -1067,7 +1096,11 @@ export class FleetManager {
|
|
|
1067
1096
|
timestamp: new Date(),
|
|
1068
1097
|
});
|
|
1069
1098
|
}
|
|
1070
|
-
else if (data.command === "save"
|
|
1099
|
+
else if (data.command === "save") {
|
|
1100
|
+
await this.handleSlashSave(data);
|
|
1101
|
+
}
|
|
1102
|
+
else if (data.command === "load") {
|
|
1103
|
+
// load is kiro-cli/classic only — no claude-code equivalent.
|
|
1071
1104
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
1072
1105
|
await data.respond("⛔ This command requires admin access.");
|
|
1073
1106
|
return;
|
|
@@ -1077,25 +1110,13 @@ export class FleetManager {
|
|
|
1077
1110
|
await data.respond("No active agent in this channel. Use `/start` first.");
|
|
1078
1111
|
return;
|
|
1079
1112
|
}
|
|
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}`;
|
|
1113
|
+
const filename = data.options?.filename;
|
|
1114
|
+
if (!SAVE_FILENAME_RE.test(filename ?? "")) {
|
|
1115
|
+
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
1116
|
+
return;
|
|
1096
1117
|
}
|
|
1097
|
-
this.pasteRawToClassicInstance(target.name,
|
|
1098
|
-
await data.respond(`✅ Sent
|
|
1118
|
+
this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
|
|
1119
|
+
await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
|
|
1099
1120
|
}
|
|
1100
1121
|
else if (data.command === "compact") {
|
|
1101
1122
|
const target = this.routing.resolve(data.channelId);
|
|
@@ -1106,31 +1127,23 @@ export class FleetManager {
|
|
|
1106
1127
|
const result = await this.topicCommands.sendCompact(target.name);
|
|
1107
1128
|
await data.respond(result);
|
|
1108
1129
|
}
|
|
1109
|
-
else if (data.command === "
|
|
1130
|
+
else if (data.command === "cancel") {
|
|
1110
1131
|
const target = this.routing.resolve(data.channelId);
|
|
1111
1132
|
if (!target) {
|
|
1112
1133
|
await data.respond("No active agent in this channel.");
|
|
1113
1134
|
return;
|
|
1114
1135
|
}
|
|
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}`);
|
|
1136
|
+
const ok = this.cancelInstance(target.name);
|
|
1137
|
+
await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
|
|
1138
|
+
}
|
|
1139
|
+
else if (data.command === "ctx") {
|
|
1140
|
+
const target = this.routing.resolve(data.channelId);
|
|
1141
|
+
if (!target) {
|
|
1142
|
+
await data.respond("No active agent in this channel.");
|
|
1143
|
+
return;
|
|
1133
1144
|
}
|
|
1145
|
+
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
1146
|
+
await data.respond(await this.topicCommands.getCtxText(target.name));
|
|
1134
1147
|
}
|
|
1135
1148
|
else if (data.command === "collab") {
|
|
1136
1149
|
const collabTarget2 = this.routing.resolve(data.channelId);
|
|
@@ -1498,6 +1511,15 @@ export class FleetManager {
|
|
|
1498
1511
|
const isBotMentioned = !!(botUser && text.toLowerCase().includes(`@${botUser.toLowerCase()}`));
|
|
1499
1512
|
const isPrivateChat = !chatId.startsWith("-"); // Telegram: positive = private, negative = group
|
|
1500
1513
|
const msgAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
1514
|
+
// In a TG Classic group, ignore bare slash commands (no @bot specified).
|
|
1515
|
+
// Prevents multiple bots all responding to the same /ctx, /compact, etc.
|
|
1516
|
+
// `/cmd@otherbot` already returned above; `/cmd@mybot` set cmdMatch, so it
|
|
1517
|
+
// still processes. Private chat (only one bot) always processes.
|
|
1518
|
+
// NOTE: this also silently drops bare `/start` in a group, so group
|
|
1519
|
+
// onboarding now requires `/start@mybot` — consistent with the policy.
|
|
1520
|
+
if (!isPrivateChat && !cmdMatch && rawText.startsWith("/")) {
|
|
1521
|
+
return; // bare slash in group — ignore silently
|
|
1522
|
+
}
|
|
1501
1523
|
// Handle /start command
|
|
1502
1524
|
if (text === "/start" || text.startsWith("/start ")) {
|
|
1503
1525
|
if (isPrivateChat) {
|
|
@@ -1567,6 +1589,17 @@ export class FleetManager {
|
|
|
1567
1589
|
await msgAdapter?.sendText(chatId, result);
|
|
1568
1590
|
return;
|
|
1569
1591
|
}
|
|
1592
|
+
// Handle /cancel command
|
|
1593
|
+
if (text === "/cancel" || text.startsWith("/cancel@")) {
|
|
1594
|
+
const cancelTarget = this.routing.resolve(chatId);
|
|
1595
|
+
if (!cancelTarget || cancelTarget.kind !== "classic") {
|
|
1596
|
+
await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
const ok = this.cancelInstance(cancelTarget.name);
|
|
1600
|
+
await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelTarget.name}。` : `❌ ${cancelTarget.name} 未在執行。`);
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1570
1603
|
// Handle /ctx command
|
|
1571
1604
|
if (text === "/ctx" || text.startsWith("/ctx@")) {
|
|
1572
1605
|
const ctxTarget = this.routing.resolve(chatId);
|
|
@@ -1578,6 +1611,36 @@ export class FleetManager {
|
|
|
1578
1611
|
await msgAdapter?.sendText(chatId, reply);
|
|
1579
1612
|
return;
|
|
1580
1613
|
}
|
|
1614
|
+
// Handle /save command (admin only)
|
|
1615
|
+
if (text === "/save" || text.startsWith("/save ") || text.startsWith("/save@")) {
|
|
1616
|
+
if (!this.classicChannels.isAdmin(msg.userId)) {
|
|
1617
|
+
await msgAdapter?.sendText(chatId, "⛔ /save requires admin access.");
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
const saveTarget = this.routing.resolve(chatId);
|
|
1621
|
+
if (!saveTarget || saveTarget.kind !== "classic") {
|
|
1622
|
+
await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
const filename = parseSaveFilename(text);
|
|
1626
|
+
if (!filename) {
|
|
1627
|
+
await msgAdapter?.sendText(chatId, "Usage: /save <filename>");
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
if (!SAVE_FILENAME_RE.test(filename)) {
|
|
1631
|
+
await msgAdapter?.sendText(chatId, "⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
const backend = this.classicChannels.getBackendByInstance(saveTarget.name, this.fleetConfig?.defaults?.backend);
|
|
1635
|
+
const cmd = saveCommandForBackend(backend, filename);
|
|
1636
|
+
if (!cmd) {
|
|
1637
|
+
await msgAdapter?.sendText(chatId, SAVE_UNSUPPORTED_MSG);
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
this.pasteRawToClassicInstance(saveTarget.name, cmd);
|
|
1641
|
+
await msgAdapter?.sendText(chatId, `✅ Sent \`${cmd}\` to ${saveTarget.name}`);
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1581
1644
|
// Route to classic channel if registered
|
|
1582
1645
|
const target = this.routing.resolve(chatId);
|
|
1583
1646
|
if (target?.kind === "classic") {
|
|
@@ -1654,6 +1717,8 @@ export class FleetManager {
|
|
|
1654
1717
|
instance: generalInstance, sender: msg.username,
|
|
1655
1718
|
text: (text ?? "").slice(0, 2000), ts: new Date().toISOString(),
|
|
1656
1719
|
});
|
|
1720
|
+
this.trackInboundMsg(generalInstance, msg);
|
|
1721
|
+
void this.sendCancelButton(generalInstance);
|
|
1657
1722
|
}
|
|
1658
1723
|
}
|
|
1659
1724
|
return;
|
|
@@ -1691,7 +1756,7 @@ export class FleetManager {
|
|
|
1691
1756
|
const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
1692
1757
|
// React immediately — before any other Discord API calls
|
|
1693
1758
|
if (msg.chatId && msg.messageId) {
|
|
1694
|
-
inboundAdapter.react(
|
|
1759
|
+
inboundAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
|
|
1695
1760
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
1696
1761
|
}
|
|
1697
1762
|
// These may hit Discord API (topic icon, archive) — do after react
|
|
@@ -1730,6 +1795,8 @@ export class FleetManager {
|
|
|
1730
1795
|
instance: instanceName, sender: msg.username,
|
|
1731
1796
|
text: (text ?? "").slice(0, 2000), ts: new Date().toISOString(),
|
|
1732
1797
|
});
|
|
1798
|
+
this.trackInboundMsg(instanceName, msg);
|
|
1799
|
+
void this.sendCancelButton(instanceName);
|
|
1733
1800
|
}
|
|
1734
1801
|
/** Handle outbound tool calls from a daemon instance */
|
|
1735
1802
|
/** Warn (but don't block) when rate limits are high. 30-min debounce per instance. */
|
|
@@ -1803,6 +1870,9 @@ export class FleetManager {
|
|
|
1803
1870
|
// Route standard channel tools (reply, react, edit_message, download_attachment)
|
|
1804
1871
|
if (routeToolCall(outAdapter, tool, args, threadId, respond)) {
|
|
1805
1872
|
if (tool === "reply") {
|
|
1873
|
+
// Agent answered — retire its pending cancel button and mark ✅ done.
|
|
1874
|
+
this.clearCancelButton(instanceName);
|
|
1875
|
+
this.reactDone(instanceName);
|
|
1806
1876
|
const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
|
|
1807
1877
|
this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
|
|
1808
1878
|
this.emitSseEvent("message", {
|
|
@@ -1849,7 +1919,7 @@ export class FleetManager {
|
|
|
1849
1919
|
if (!chatId)
|
|
1850
1920
|
return;
|
|
1851
1921
|
if (editMessageId) {
|
|
1852
|
-
statusAdapter.editMessage(chatId, editMessageId, text).catch(e => this.logger.debug({ err: e }, "Failed to edit tool status message"));
|
|
1922
|
+
statusAdapter.editMessage(chatId, editMessageId, text, threadId).catch(e => this.logger.debug({ err: e }, "Failed to edit tool status message"));
|
|
1853
1923
|
}
|
|
1854
1924
|
else {
|
|
1855
1925
|
statusAdapter.sendText(chatId, text, { threadId }).then((sent) => {
|
|
@@ -1887,6 +1957,8 @@ export class FleetManager {
|
|
|
1887
1957
|
payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
|
|
1888
1958
|
meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
|
|
1889
1959
|
});
|
|
1960
|
+
// A scheduled trigger also puts the instance to work — show a cancel button.
|
|
1961
|
+
void this.sendCancelButton(target);
|
|
1890
1962
|
return true;
|
|
1891
1963
|
};
|
|
1892
1964
|
if (deliver()) {
|
|
@@ -2512,6 +2584,175 @@ export class FleetManager {
|
|
|
2512
2584
|
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
|
|
2513
2585
|
}
|
|
2514
2586
|
}
|
|
2587
|
+
// ── Cancel button ────────────────────────────────────────────────────
|
|
2588
|
+
// Sent after delivering a user message to an instance; clicking it (or
|
|
2589
|
+
// /cancel) sends Escape to the instance's pane to interrupt generation.
|
|
2590
|
+
/** Send a "🛑 Cancel" button to the instance's topic/channel after delivery. */
|
|
2591
|
+
/**
|
|
2592
|
+
* Handle the DC `/save` slash command for both classic AND fleet-topic targets.
|
|
2593
|
+
* Picks the backend-appropriate command (kiro → /chat save, claude → /export);
|
|
2594
|
+
* unsupported backends get a clear error. Routes via classic paste or fleet IPC.
|
|
2595
|
+
*/
|
|
2596
|
+
async handleSlashSave(data) {
|
|
2597
|
+
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
2598
|
+
await data.respond("⛔ This command requires admin access.");
|
|
2599
|
+
return;
|
|
2600
|
+
}
|
|
2601
|
+
const target = this.routing.resolve(data.channelId);
|
|
2602
|
+
if (!target) {
|
|
2603
|
+
await data.respond("No active agent in this channel. Use `/start` first.");
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
2606
|
+
const filename = data.options?.filename ?? "";
|
|
2607
|
+
if (!SAVE_FILENAME_RE.test(filename)) {
|
|
2608
|
+
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
2609
|
+
return;
|
|
2610
|
+
}
|
|
2611
|
+
const backend = target.kind === "classic"
|
|
2612
|
+
? this.classicChannels.getBackendByInstance(target.name, this.fleetConfig?.defaults?.backend)
|
|
2613
|
+
: (this.fleetConfig?.instances[target.name]?.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
|
|
2614
|
+
// force (-f) is only meaningful for kiro/classic /chat save.
|
|
2615
|
+
const force = target.kind === "classic" && !!data.options?.force;
|
|
2616
|
+
const cmd = saveCommandForBackend(backend, filename, force);
|
|
2617
|
+
if (!cmd) {
|
|
2618
|
+
await data.respond(SAVE_UNSUPPORTED_MSG);
|
|
2619
|
+
return;
|
|
2620
|
+
}
|
|
2621
|
+
if (target.kind === "classic") {
|
|
2622
|
+
this.pasteRawToClassicInstance(target.name, cmd);
|
|
2623
|
+
}
|
|
2624
|
+
else {
|
|
2625
|
+
this.instanceIpcClients.get(target.name)?.send({ type: "raw_paste", content: cmd });
|
|
2626
|
+
}
|
|
2627
|
+
await data.respond(`✅ Sent \`${cmd}\` to ${target.name}`);
|
|
2628
|
+
}
|
|
2629
|
+
async sendCancelButton(instanceName) {
|
|
2630
|
+
// Replace any stale button for this instance first.
|
|
2631
|
+
this.clearCancelButton(instanceName);
|
|
2632
|
+
const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
2633
|
+
if (!adapter)
|
|
2634
|
+
return;
|
|
2635
|
+
const adapterId = this.instanceWorldBinding.get(instanceName);
|
|
2636
|
+
const groupId = this.getChannelConfig(adapterId)?.group_id;
|
|
2637
|
+
const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
|
|
2638
|
+
let chatId;
|
|
2639
|
+
let threadId;
|
|
2640
|
+
if (topicId != null && groupId) {
|
|
2641
|
+
// Fleet topic instance.
|
|
2642
|
+
chatId = String(groupId);
|
|
2643
|
+
threadId = String(topicId);
|
|
2644
|
+
}
|
|
2645
|
+
else {
|
|
2646
|
+
// Classic instance: chatId from the routing table.
|
|
2647
|
+
for (const [cid, target] of this.routing.entries()) {
|
|
2648
|
+
if (target.kind === "classic" && target.name === instanceName) {
|
|
2649
|
+
chatId = cid;
|
|
2650
|
+
break;
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
// General / flat fallback: post to the group (no thread).
|
|
2654
|
+
if (!chatId && groupId)
|
|
2655
|
+
chatId = String(groupId);
|
|
2656
|
+
}
|
|
2657
|
+
if (!chatId)
|
|
2658
|
+
return;
|
|
2659
|
+
try {
|
|
2660
|
+
const sent = await adapter.notifyAlert(chatId, {
|
|
2661
|
+
type: "cancel",
|
|
2662
|
+
instanceName,
|
|
2663
|
+
message: "👀 處理中…",
|
|
2664
|
+
choices: [{ id: `cancel:${instanceName}`, label: "🛑 取消" }],
|
|
2665
|
+
}, threadId ? { threadId } : undefined);
|
|
2666
|
+
const entry = { adapterId, chatId: sent.chatId, messageId: sent.messageId, threadId: sent.threadId ?? threadId };
|
|
2667
|
+
// A concurrent sendCancelButton for the same instance may have installed a
|
|
2668
|
+
// pending entry while we awaited notifyAlert — we BOTH passed the
|
|
2669
|
+
// clear-before-send when the slot was still empty. Retire whatever is there
|
|
2670
|
+
// now (delete its message + clear its timer) so overwriting the slot doesn't
|
|
2671
|
+
// orphan that button or leak its timer.
|
|
2672
|
+
const prev = this.pendingCancelMessages.get(instanceName);
|
|
2673
|
+
if (prev) {
|
|
2674
|
+
clearTimeout(prev.timer);
|
|
2675
|
+
this.deleteCancelMessage(prev);
|
|
2676
|
+
}
|
|
2677
|
+
// Hard cap: delete THIS specific button after 30min. The timer targets its
|
|
2678
|
+
// own captured message (not "whatever is pending for the instance" — which
|
|
2679
|
+
// could be a different, later button), so a replaced/orphaned button is
|
|
2680
|
+
// still reliably cleaned even if the immediate delete above ever missed it.
|
|
2681
|
+
const timer = setTimeout(() => {
|
|
2682
|
+
this.logger.debug({ instanceName, messageId: entry.messageId }, "Cancel button 30min cap fired");
|
|
2683
|
+
this.deleteCancelMessage(entry);
|
|
2684
|
+
const cur = this.pendingCancelMessages.get(instanceName);
|
|
2685
|
+
if (cur && cur.messageId === entry.messageId)
|
|
2686
|
+
this.pendingCancelMessages.delete(instanceName);
|
|
2687
|
+
}, 30 * 60_000);
|
|
2688
|
+
this.pendingCancelMessages.set(instanceName, { ...entry, timer });
|
|
2689
|
+
}
|
|
2690
|
+
catch (e) {
|
|
2691
|
+
this.logger.debug({ err: e.message, instanceName }, "Failed to send cancel button");
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
/** Delete a specific cancel-button message (best-effort, via its own adapter). */
|
|
2695
|
+
deleteCancelMessage(e) {
|
|
2696
|
+
const adapter = (e.adapterId ? this.worlds.get(e.adapterId)?.adapter : undefined) ?? this.adapter;
|
|
2697
|
+
if (!adapter)
|
|
2698
|
+
return;
|
|
2699
|
+
if (adapter.deleteMessage) {
|
|
2700
|
+
adapter.deleteMessage(e.chatId, e.messageId, e.threadId)
|
|
2701
|
+
.catch((err) => this.logger.debug({ err: err.message }, "Failed to delete cancel button"));
|
|
2702
|
+
}
|
|
2703
|
+
else if (adapter.editMessageRemoveButtons) {
|
|
2704
|
+
adapter.editMessageRemoveButtons(e.chatId, e.messageId, "✅", e.threadId).catch(() => { });
|
|
2705
|
+
}
|
|
2706
|
+
else {
|
|
2707
|
+
adapter.editMessage(e.chatId, e.messageId, "✅", e.threadId).catch(() => { });
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
/** Retire (delete) the pending cancel button — on reply, cancel, report, or 30min cap. */
|
|
2711
|
+
clearCancelButton(instanceName) {
|
|
2712
|
+
const pending = this.pendingCancelMessages.get(instanceName);
|
|
2713
|
+
if (!pending)
|
|
2714
|
+
return;
|
|
2715
|
+
clearTimeout(pending.timer);
|
|
2716
|
+
this.pendingCancelMessages.delete(instanceName);
|
|
2717
|
+
this.deleteCancelMessage(pending);
|
|
2718
|
+
}
|
|
2719
|
+
/**
|
|
2720
|
+
* Reaction target chat id. Telegram reactions key on the supergroup chat_id
|
|
2721
|
+
* (the topic thread is NOT a chat_id), so a forum-topic message must react on
|
|
2722
|
+
* msg.chatId — reacting on threadId silently fails. Discord reactions key on
|
|
2723
|
+
* the channel/thread id.
|
|
2724
|
+
*/
|
|
2725
|
+
reactTarget(msg) {
|
|
2726
|
+
return msg.source === "telegram" ? msg.chatId : (msg.threadId ?? msg.chatId);
|
|
2727
|
+
}
|
|
2728
|
+
/** Remember the user message just delivered, so we can react ✅ when done. */
|
|
2729
|
+
trackInboundMsg(instanceName, msg) {
|
|
2730
|
+
if (!msg.chatId || !msg.messageId)
|
|
2731
|
+
return;
|
|
2732
|
+
this.lastInboundMsg.set(instanceName, {
|
|
2733
|
+
adapterId: msg.adapterId, chatId: msg.chatId, threadId: msg.threadId ?? undefined, messageId: msg.messageId, source: msg.source,
|
|
2734
|
+
});
|
|
2735
|
+
}
|
|
2736
|
+
/** React ✅ on the last user message after the agent replies. */
|
|
2737
|
+
reactDone(instanceName) {
|
|
2738
|
+
const m = this.lastInboundMsg.get(instanceName);
|
|
2739
|
+
if (!m)
|
|
2740
|
+
return;
|
|
2741
|
+
this.lastInboundMsg.delete(instanceName);
|
|
2742
|
+
const adapter = (m.adapterId ? this.worlds.get(m.adapterId)?.adapter : undefined)
|
|
2743
|
+
?? this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
2744
|
+
adapter?.react(this.reactTarget(m), m.messageId, "✅").catch(() => { });
|
|
2745
|
+
}
|
|
2746
|
+
/** Interrupt an instance's current generation (cancel button / /cancel). */
|
|
2747
|
+
cancelInstance(instanceName) {
|
|
2748
|
+
const daemon = this.daemons.get(instanceName);
|
|
2749
|
+
if (!daemon)
|
|
2750
|
+
return false;
|
|
2751
|
+
daemon.sendEscape().catch(e => this.logger.warn({ err: e, instanceName }, "sendEscape failed"));
|
|
2752
|
+
this.lastInboundMsg.delete(instanceName);
|
|
2753
|
+
this.clearCancelButton(instanceName);
|
|
2754
|
+
return true;
|
|
2755
|
+
}
|
|
2515
2756
|
queueMirrorMessage(text) {
|
|
2516
2757
|
const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
|
|
2517
2758
|
if (mirrorTopicId == null || !this.adapter)
|
|
@@ -2850,10 +3091,17 @@ When users create specialized instances, suggest these configurations:
|
|
|
2850
3091
|
// Skip empty bot messages (e.g., reactions) — don't pollute chat log
|
|
2851
3092
|
if (msg.isBotMessage && !text && !msg.attachments?.length)
|
|
2852
3093
|
return;
|
|
3094
|
+
// Save attachments FIRST so the chat-log records their inbox paths
|
|
3095
|
+
// (consistent with the /chat path). Otherwise a non-@mention image is
|
|
3096
|
+
// saved to inbox but its path never reaches the agent — the log keeps
|
|
3097
|
+
// only a pathless filename, so later context can't locate the file.
|
|
3098
|
+
const saved = msg.attachments?.length ? await this.saveClassicAttachment(instanceName, msg) : undefined;
|
|
2853
3099
|
// Log every message (including other bots) to chat-logs
|
|
2854
|
-
const collabAttachTag =
|
|
2855
|
-
? ` [${
|
|
2856
|
-
:
|
|
3100
|
+
const collabAttachTag = saved
|
|
3101
|
+
? ` [${saved.kind === "photo" ? "📷" : "📎"} saved: ${saved.paths.join(", ")}]`
|
|
3102
|
+
: (msg.attachments?.length
|
|
3103
|
+
? ` [${msg.attachments.map(a => `${a.kind === "photo" ? "📷" : "📎"} ${a.filename || a.kind}`).join(", ")}]`
|
|
3104
|
+
: "");
|
|
2857
3105
|
ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText);
|
|
2858
3106
|
this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message");
|
|
2859
3107
|
// Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here
|
|
@@ -2861,19 +3109,16 @@ When users create specialized instances, suggest these configurations:
|
|
|
2861
3109
|
const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
|
|
2862
3110
|
const isMentioned = mentionTag && text.includes(mentionTag);
|
|
2863
3111
|
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
|
-
}
|
|
3112
|
+
// Bare attachment (no @mention) — already saved above; just acknowledge.
|
|
3113
|
+
if (saved) {
|
|
3114
|
+
const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
3115
|
+
const noMentionReactChatId = msg.threadId ?? msg.chatId;
|
|
3116
|
+
if (reactAdapter && noMentionReactChatId && msg.messageId) {
|
|
3117
|
+
const emoji = msg.source === "telegram"
|
|
3118
|
+
? (saved.kind === "photo" ? "👌" : "👍")
|
|
3119
|
+
: (saved.kind === "photo" ? "📸" : "📎");
|
|
3120
|
+
reactAdapter.react(noMentionReactChatId, msg.messageId, emoji)
|
|
3121
|
+
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
2877
3122
|
}
|
|
2878
3123
|
}
|
|
2879
3124
|
return;
|
|
@@ -2891,8 +3136,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
2891
3136
|
// Block /raw bypass
|
|
2892
3137
|
if (cleanText.startsWith("/raw "))
|
|
2893
3138
|
return;
|
|
2894
|
-
//
|
|
2895
|
-
const saved = await this.saveClassicAttachment(instanceName, msg);
|
|
3139
|
+
// Attachments already saved at the top of the collab block.
|
|
2896
3140
|
if (saved && classicAdapter && collabReactChatId && msg.messageId) {
|
|
2897
3141
|
const emoji = msg.source === "telegram"
|
|
2898
3142
|
? (saved.kind === "photo" ? "👌" : "👍")
|
|
@@ -3049,24 +3293,37 @@ When users create specialized instances, suggest these configurations:
|
|
|
3049
3293
|
this.logger.warn({ instanceName }, "Classic channel instance IPC not connected");
|
|
3050
3294
|
return;
|
|
3051
3295
|
}
|
|
3296
|
+
const meta = {
|
|
3297
|
+
chat_id: msg.chatId,
|
|
3298
|
+
message_id: msg.messageId,
|
|
3299
|
+
user: msg.username,
|
|
3300
|
+
user_id: msg.userId,
|
|
3301
|
+
ts: msg.timestamp.toISOString(),
|
|
3302
|
+
thread_id: msg.threadId ?? "",
|
|
3303
|
+
source: msg.source,
|
|
3304
|
+
...extraMeta,
|
|
3305
|
+
...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
|
|
3306
|
+
};
|
|
3307
|
+
// If the triggering message carried no image of its own, surface the most
|
|
3308
|
+
// recent image saved earlier in this channel (logged as "[📷 saved: <path>]"
|
|
3309
|
+
// by an untriggered collab message) as image_path, so the agent's
|
|
3310
|
+
// read-the-image trigger fires instead of the path sitting inert in context.
|
|
3311
|
+
if (!meta.image_path && logContext) {
|
|
3312
|
+
const saves = [...logContext.matchAll(/\[📷 saved: ([^\]]+)\]/g)];
|
|
3313
|
+
if (saves.length > 0) {
|
|
3314
|
+
meta.image_path = saves[saves.length - 1][1].split(",")[0].trim();
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3052
3317
|
ipc.send({
|
|
3053
3318
|
type: "fleet_inbound",
|
|
3054
3319
|
content: fullText,
|
|
3055
3320
|
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
|
-
},
|
|
3321
|
+
meta,
|
|
3067
3322
|
});
|
|
3068
3323
|
this.lastInboundUser.set(instanceName, msg.username);
|
|
3069
3324
|
this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
|
|
3325
|
+
this.trackInboundMsg(instanceName, msg);
|
|
3326
|
+
void this.sendCancelButton(instanceName);
|
|
3070
3327
|
}
|
|
3071
3328
|
/** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
|
|
3072
3329
|
pasteRawToClassicInstance(instanceName, text) {
|
|
@@ -3186,9 +3443,11 @@ When users create specialized instances, suggest these configurations:
|
|
|
3186
3443
|
this.topicArchiver.stop();
|
|
3187
3444
|
this.scheduler?.shutdown();
|
|
3188
3445
|
// Stop instances in parallel batches to avoid long sequential waits.
|
|
3189
|
-
// Concurrency
|
|
3190
|
-
|
|
3446
|
+
// Concurrency scales with fleet size — larger fleets tolerate more parallel
|
|
3447
|
+
// tmux ops, while small fleets stay conservative to avoid overwhelming the
|
|
3448
|
+
// tmux server.
|
|
3191
3449
|
const entries = [...this.daemons.entries()];
|
|
3450
|
+
const STOP_CONCURRENCY = entries.length > 30 ? 15 : entries.length >= 10 ? 10 : 5;
|
|
3192
3451
|
for (const [name] of entries)
|
|
3193
3452
|
this.ipcStoppingInstances.add(name);
|
|
3194
3453
|
for (let i = 0; i < entries.length; i += STOP_CONCURRENCY) {
|
|
@@ -3203,9 +3462,9 @@ When users create specialized instances, suggest these configurations:
|
|
|
3203
3462
|
this.daemons.delete(name);
|
|
3204
3463
|
}));
|
|
3205
3464
|
}
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
}
|
|
3465
|
+
// Close IPC clients in parallel — serial close over a large fleet adds
|
|
3466
|
+
// noticeable latency.
|
|
3467
|
+
await Promise.all([...this.instanceIpcClients.values()].map(ipc => Promise.resolve(ipc.close()).catch(() => { })));
|
|
3209
3468
|
this.instanceIpcClients.clear();
|
|
3210
3469
|
this.ipcStoppingInstances.clear();
|
|
3211
3470
|
for (const [, w] of this.worlds) {
|
|
@@ -3541,14 +3800,21 @@ When users create specialized instances, suggest these configurations:
|
|
|
3541
3800
|
const latest = execSync("npm view @songsid/agend version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
|
|
3542
3801
|
let target = latest;
|
|
3543
3802
|
if (currentVersion.includes("-beta")) {
|
|
3803
|
+
// Beta users track the @beta channel (never fall back to @latest, which is
|
|
3804
|
+
// older), but should also hear when a newer STABLE ships — pick whichever
|
|
3805
|
+
// of beta/latest is the newest.
|
|
3806
|
+
let beta = "";
|
|
3544
3807
|
try {
|
|
3545
|
-
|
|
3546
|
-
if (beta && beta !== currentVersion)
|
|
3547
|
-
target = beta;
|
|
3808
|
+
beta = execSync("npm view @songsid/agend@beta version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
|
|
3548
3809
|
}
|
|
3549
3810
|
catch { /* no beta tag */ }
|
|
3811
|
+
target = beta || latest;
|
|
3812
|
+
if (latest && this.semverGt(latest, target))
|
|
3813
|
+
target = latest;
|
|
3550
3814
|
}
|
|
3551
|
-
|
|
3815
|
+
// Only notify when target is genuinely newer (semver), so a beta user on
|
|
3816
|
+
// 2.0.8-beta.16 is never told that stable 2.0.7 is "available".
|
|
3817
|
+
if (target && this.semverGt(target, currentVersion)) {
|
|
3552
3818
|
const generalId = this.findGeneralInstance();
|
|
3553
3819
|
if (generalId) {
|
|
3554
3820
|
this.notifyInstanceTopic(generalId, `🆕 AgEnD v${target} available (current: v${currentVersion}). Use \`/update\` to upgrade.`);
|
|
@@ -3557,6 +3823,49 @@ When users create specialized instances, suggest these configurations:
|
|
|
3557
3823
|
}
|
|
3558
3824
|
catch { /* silent — network issues */ }
|
|
3559
3825
|
}
|
|
3826
|
+
/**
|
|
3827
|
+
* Semver "a > b". Compares major.minor.patch numerically; a version without a
|
|
3828
|
+
* prerelease outranks the same core with one (2.0.8 > 2.0.8-beta.16); two
|
|
3829
|
+
* prereleases compare identifier-by-identifier (numeric < alphanumeric, numeric
|
|
3830
|
+
* fields compared as numbers). Sufficient for our X.Y.Z[-beta.N] scheme.
|
|
3831
|
+
*/
|
|
3832
|
+
semverGt(a, b) {
|
|
3833
|
+
const parse = (v) => {
|
|
3834
|
+
const [core, pre] = v.replace(/^v/, "").split("-");
|
|
3835
|
+
const nums = core.split(".").map(n => parseInt(n, 10) || 0);
|
|
3836
|
+
return { nums: [nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0], pre: pre ? pre.split(".") : [] };
|
|
3837
|
+
};
|
|
3838
|
+
const pa = parse(a), pb = parse(b);
|
|
3839
|
+
for (let i = 0; i < 3; i++) {
|
|
3840
|
+
if (pa.nums[i] !== pb.nums[i])
|
|
3841
|
+
return pa.nums[i] > pb.nums[i];
|
|
3842
|
+
}
|
|
3843
|
+
if (pa.pre.length === 0 && pb.pre.length === 0)
|
|
3844
|
+
return false;
|
|
3845
|
+
if (pa.pre.length === 0)
|
|
3846
|
+
return true; // a stable, b prerelease → a > b
|
|
3847
|
+
if (pb.pre.length === 0)
|
|
3848
|
+
return false; // a prerelease, b stable → a < b
|
|
3849
|
+
const len = Math.max(pa.pre.length, pb.pre.length);
|
|
3850
|
+
for (let i = 0; i < len; i++) {
|
|
3851
|
+
const x = pa.pre[i], y = pb.pre[i];
|
|
3852
|
+
if (x === undefined)
|
|
3853
|
+
return false; // a has fewer identifiers → a < b
|
|
3854
|
+
if (y === undefined)
|
|
3855
|
+
return true; // a has more identifiers → a > b
|
|
3856
|
+
const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y);
|
|
3857
|
+
if (xn && yn) {
|
|
3858
|
+
const dx = parseInt(x, 10), dy = parseInt(y, 10);
|
|
3859
|
+
if (dx !== dy)
|
|
3860
|
+
return dx > dy;
|
|
3861
|
+
}
|
|
3862
|
+
else if (xn !== yn)
|
|
3863
|
+
return yn; // numeric has lower precedence than alphanumeric
|
|
3864
|
+
else if (x !== y)
|
|
3865
|
+
return x > y; // both alphanumeric
|
|
3866
|
+
}
|
|
3867
|
+
return false; // identical
|
|
3868
|
+
}
|
|
3560
3869
|
// ── Health HTTP endpoint ─────────────────────────────────────────────
|
|
3561
3870
|
startHealthServer(port) {
|
|
3562
3871
|
this.startedAt = Date.now();
|
|
@@ -3733,6 +4042,29 @@ When users create specialized instances, suggest these configurations:
|
|
|
3733
4042
|
})();
|
|
3734
4043
|
return;
|
|
3735
4044
|
}
|
|
4045
|
+
if (req.method === "POST" && req.url?.startsWith("/stop/")) {
|
|
4046
|
+
const name = decodeURIComponent(req.url.slice("/stop/".length));
|
|
4047
|
+
this.logger.info({ name }, "Instance stop requested via HTTP");
|
|
4048
|
+
(async () => {
|
|
4049
|
+
try {
|
|
4050
|
+
// Runs inside the live fleet process: lifecycle.stop finds the
|
|
4051
|
+
// in-memory daemon and stops just this instance. (Doing this from a
|
|
4052
|
+
// detached CLI FleetManager would read the shared daemon.pid — the
|
|
4053
|
+
// fleet's own pid — and kill the whole fleet.)
|
|
4054
|
+
await this.stopInstance(name);
|
|
4055
|
+
this.logger.info({ name }, "Instance stopped");
|
|
4056
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
4057
|
+
res.writeHead(200);
|
|
4058
|
+
res.end(JSON.stringify({ stopped: name }));
|
|
4059
|
+
}
|
|
4060
|
+
catch (err) {
|
|
4061
|
+
this.logger.error({ err, name }, "Instance stop failed");
|
|
4062
|
+
res.writeHead(500);
|
|
4063
|
+
res.end(JSON.stringify({ error: `Stop failed: ${err.message}` }));
|
|
4064
|
+
}
|
|
4065
|
+
})();
|
|
4066
|
+
return;
|
|
4067
|
+
}
|
|
3736
4068
|
// ── Agent CLI endpoint ─────
|
|
3737
4069
|
if (req.url === "/agent" && req.method === "POST") {
|
|
3738
4070
|
handleAgentRequest(req, res, this);
|