@songsid/agend 2.0.8-beta.3 → 2.0.8-beta.31
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 +53 -0
- package/dist/fleet-manager.js +514 -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";
|
|
@@ -45,6 +45,10 @@ export function resolveReplyThreadId(argsThreadId, instanceConfig) {
|
|
|
45
45
|
}
|
|
46
46
|
return instanceConfig?.topic_id != null ? String(instanceConfig.topic_id) : undefined;
|
|
47
47
|
}
|
|
48
|
+
/** Retry cadence for retiring a cancel button whose delete failed (e.g. a DC
|
|
49
|
+
* forum thread the bot momentarily can't reach). 3 retries × 5min = 15min. */
|
|
50
|
+
const CANCEL_BTN_RETRY_INTERVAL_MS = 5 * 60_000;
|
|
51
|
+
const CANCEL_BTN_MAX_RETRIES = 3;
|
|
48
52
|
export class FleetManager {
|
|
49
53
|
dataDir;
|
|
50
54
|
children = new Map();
|
|
@@ -78,6 +82,13 @@ export class FleetManager {
|
|
|
78
82
|
topicIcons = {};
|
|
79
83
|
lastActivity = new Map();
|
|
80
84
|
lastInboundUser = new Map(); // instanceName → last username
|
|
85
|
+
// Active "🛑 Cancel" buttons, tracked per button (keyed by messageId) rather
|
|
86
|
+
// than one-per-instance. A button is retired (deleted, with bounded retry) on
|
|
87
|
+
// reply, on cancel, or when a newer button supersedes it for the same
|
|
88
|
+
// instance. Per-button tracking means a failed delete never strands a button.
|
|
89
|
+
cancelButtons = new Map();
|
|
90
|
+
// Last user message delivered to each instance — used to react ✅ on completion.
|
|
91
|
+
lastInboundMsg = new Map();
|
|
81
92
|
topicArchiver;
|
|
82
93
|
controlClient = null;
|
|
83
94
|
classicChannels = null;
|
|
@@ -443,6 +454,10 @@ export class FleetManager {
|
|
|
443
454
|
.catch(e => this.logger.warn({ err: e }, "Failed to send daily summary"));
|
|
444
455
|
// Rotate classic channel chat logs daily
|
|
445
456
|
this.classicChannels?.rotateLogs();
|
|
457
|
+
this.rotateInboxes();
|
|
458
|
+
// Rotate fleet.log daily too (besides the startup size check above), so a
|
|
459
|
+
// long-running fleet doesn't accumulate an unbounded log.
|
|
460
|
+
rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
|
|
446
461
|
}, () => {
|
|
447
462
|
const instances = Object.keys(this.fleetConfig?.instances ?? {});
|
|
448
463
|
const costMap = new Map();
|
|
@@ -454,6 +469,7 @@ export class FleetManager {
|
|
|
454
469
|
this.dailySummary.start();
|
|
455
470
|
// Rotate classic channel chat logs daily (piggyback on daily summary timer)
|
|
456
471
|
this.classicChannels?.rotateLogs();
|
|
472
|
+
this.rotateInboxes();
|
|
457
473
|
// Auto-create general instance(s) — one per adapter that lacks a general
|
|
458
474
|
const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
|
|
459
475
|
const generalInstances = Object.entries(fleet.instances).filter(([, inst]) => inst.general_topic === true);
|
|
@@ -681,6 +697,41 @@ export class FleetManager {
|
|
|
681
697
|
};
|
|
682
698
|
process.once("SIGUSR1", onFullRestart);
|
|
683
699
|
}
|
|
700
|
+
/**
|
|
701
|
+
* Delete inbox files older than retentionDays (by mtime). Cleans the shared
|
|
702
|
+
* inbox (`<dataDir>/inbox`) and every workspace inbox
|
|
703
|
+
* (`<agendHome>/workspaces/*\/inbox`). Piggybacks on the daily summary timer,
|
|
704
|
+
* mirroring classic chat-log rotation (same 7-day retention).
|
|
705
|
+
*/
|
|
706
|
+
rotateInboxes(retentionDays = 7) {
|
|
707
|
+
const cutoff = Date.now() - retentionDays * 86400_000;
|
|
708
|
+
const dirs = [join(this.dataDir, "inbox")];
|
|
709
|
+
const workspacesDir = join(getAgendHome(), "workspaces");
|
|
710
|
+
if (existsSync(workspacesDir)) {
|
|
711
|
+
for (const ws of readdirSync(workspacesDir)) {
|
|
712
|
+
dirs.push(join(workspacesDir, ws, "inbox"));
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
let deleted = 0;
|
|
716
|
+
for (const dir of dirs) {
|
|
717
|
+
if (!existsSync(dir))
|
|
718
|
+
continue;
|
|
719
|
+
for (const file of readdirSync(dir)) {
|
|
720
|
+
const full = join(dir, file);
|
|
721
|
+
try {
|
|
722
|
+
const st = statSync(full);
|
|
723
|
+
if (st.isFile() && st.mtimeMs < cutoff) {
|
|
724
|
+
unlinkSync(full);
|
|
725
|
+
deleted++;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
catch { /* file vanished or unreadable — skip */ }
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
if (deleted > 0)
|
|
732
|
+
this.logger.info({ deleted }, "Rotated inbox files");
|
|
733
|
+
return deleted;
|
|
734
|
+
}
|
|
684
735
|
/** Start the shared channel adapter(s) for topic mode */
|
|
685
736
|
async startSharedAdapter(fleet) {
|
|
686
737
|
const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
|
|
@@ -732,13 +783,22 @@ export class FleetManager {
|
|
|
732
783
|
await this.startInstance(instanceName, config, topicMode);
|
|
733
784
|
// startInstance already calls connectIpcToInstance
|
|
734
785
|
}
|
|
735
|
-
this.adapter?.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted
|
|
786
|
+
this.adapter?.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
|
|
736
787
|
}
|
|
737
788
|
else {
|
|
738
|
-
this.adapter?.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}
|
|
789
|
+
this.adapter?.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
|
|
739
790
|
}
|
|
740
791
|
return;
|
|
741
792
|
}
|
|
793
|
+
if (data.callbackData.startsWith("cancel:")) {
|
|
794
|
+
const instanceName = data.callbackData.slice("cancel:".length);
|
|
795
|
+
// Idempotent: a button click only acts while the button is live. A
|
|
796
|
+
// second click (entry already cleared) is a no-op — don't re-send the
|
|
797
|
+
// interrupt key. (The /cancel command path calls cancelInstance directly.)
|
|
798
|
+
if (this.hasCancelButton(instanceName))
|
|
799
|
+
this.cancelInstance(instanceName);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
742
802
|
}, this.logger, "adapter.callback_query"));
|
|
743
803
|
this.adapter.on("topic_closed", safeHandler(async (data) => {
|
|
744
804
|
// Skip unbind if we archived this topic ourselves
|
|
@@ -780,7 +840,11 @@ export class FleetManager {
|
|
|
780
840
|
timestamp: new Date(),
|
|
781
841
|
});
|
|
782
842
|
}
|
|
783
|
-
else if (data.command === "save"
|
|
843
|
+
else if (data.command === "save") {
|
|
844
|
+
await this.handleSlashSave(data);
|
|
845
|
+
}
|
|
846
|
+
else if (data.command === "load") {
|
|
847
|
+
// load is kiro-cli/classic only — no claude-code equivalent.
|
|
784
848
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
785
849
|
await data.respond("⛔ This command requires admin access.");
|
|
786
850
|
return;
|
|
@@ -790,25 +854,13 @@ export class FleetManager {
|
|
|
790
854
|
await data.respond("No active agent in this channel. Use `/start` first.");
|
|
791
855
|
return;
|
|
792
856
|
}
|
|
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}`;
|
|
857
|
+
const filename = data.options?.filename;
|
|
858
|
+
if (!SAVE_FILENAME_RE.test(filename ?? "")) {
|
|
859
|
+
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
860
|
+
return;
|
|
809
861
|
}
|
|
810
|
-
this.pasteRawToClassicInstance(target.name,
|
|
811
|
-
await data.respond(`✅ Sent
|
|
862
|
+
this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
|
|
863
|
+
await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
|
|
812
864
|
}
|
|
813
865
|
else if (data.command === "compact") {
|
|
814
866
|
const target = this.routing.resolve(data.channelId);
|
|
@@ -819,48 +871,23 @@ export class FleetManager {
|
|
|
819
871
|
const result = await this.topicCommands.sendCompact(target.name);
|
|
820
872
|
await data.respond(result);
|
|
821
873
|
}
|
|
822
|
-
else if (data.command === "
|
|
874
|
+
else if (data.command === "cancel") {
|
|
823
875
|
const target = this.routing.resolve(data.channelId);
|
|
824
876
|
if (!target) {
|
|
825
877
|
await data.respond("No active agent in this channel.");
|
|
826
878
|
return;
|
|
827
879
|
}
|
|
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}`);
|
|
880
|
+
const ok = this.cancelInstance(target.name);
|
|
881
|
+
await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
|
|
882
|
+
}
|
|
883
|
+
else if (data.command === "ctx") {
|
|
884
|
+
const target = this.routing.resolve(data.channelId);
|
|
885
|
+
if (!target) {
|
|
886
|
+
await data.respond("No active agent in this channel.");
|
|
887
|
+
return;
|
|
863
888
|
}
|
|
889
|
+
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
890
|
+
await data.respond(await this.topicCommands.getCtxText(target.name));
|
|
864
891
|
}
|
|
865
892
|
else if (data.command === "collab") {
|
|
866
893
|
const collabTarget = this.routing.resolve(data.channelId);
|
|
@@ -1021,11 +1048,19 @@ export class FleetManager {
|
|
|
1021
1048
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
1022
1049
|
await this.startInstance(instanceName, config, topicMode);
|
|
1023
1050
|
}
|
|
1024
|
-
adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted
|
|
1051
|
+
adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
|
|
1025
1052
|
}
|
|
1026
1053
|
else {
|
|
1027
|
-
adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}
|
|
1054
|
+
adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
|
|
1028
1055
|
}
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
if (data.callbackData.startsWith("cancel:")) {
|
|
1059
|
+
const instanceName = data.callbackData.slice("cancel:".length);
|
|
1060
|
+
// Idempotent: only the first click (while the button is live) acts.
|
|
1061
|
+
if (this.hasCancelButton(instanceName))
|
|
1062
|
+
this.cancelInstance(instanceName);
|
|
1063
|
+
return;
|
|
1029
1064
|
}
|
|
1030
1065
|
}, this.logger, `adapter[${adapterId}].callback_query`));
|
|
1031
1066
|
adapter.on("topic_closed", safeHandler(async (data) => {
|
|
@@ -1067,7 +1102,11 @@ export class FleetManager {
|
|
|
1067
1102
|
timestamp: new Date(),
|
|
1068
1103
|
});
|
|
1069
1104
|
}
|
|
1070
|
-
else if (data.command === "save"
|
|
1105
|
+
else if (data.command === "save") {
|
|
1106
|
+
await this.handleSlashSave(data);
|
|
1107
|
+
}
|
|
1108
|
+
else if (data.command === "load") {
|
|
1109
|
+
// load is kiro-cli/classic only — no claude-code equivalent.
|
|
1071
1110
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
1072
1111
|
await data.respond("⛔ This command requires admin access.");
|
|
1073
1112
|
return;
|
|
@@ -1077,25 +1116,13 @@ export class FleetManager {
|
|
|
1077
1116
|
await data.respond("No active agent in this channel. Use `/start` first.");
|
|
1078
1117
|
return;
|
|
1079
1118
|
}
|
|
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}`;
|
|
1119
|
+
const filename = data.options?.filename;
|
|
1120
|
+
if (!SAVE_FILENAME_RE.test(filename ?? "")) {
|
|
1121
|
+
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
1122
|
+
return;
|
|
1096
1123
|
}
|
|
1097
|
-
this.pasteRawToClassicInstance(target.name,
|
|
1098
|
-
await data.respond(`✅ Sent
|
|
1124
|
+
this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
|
|
1125
|
+
await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
|
|
1099
1126
|
}
|
|
1100
1127
|
else if (data.command === "compact") {
|
|
1101
1128
|
const target = this.routing.resolve(data.channelId);
|
|
@@ -1106,31 +1133,23 @@ export class FleetManager {
|
|
|
1106
1133
|
const result = await this.topicCommands.sendCompact(target.name);
|
|
1107
1134
|
await data.respond(result);
|
|
1108
1135
|
}
|
|
1109
|
-
else if (data.command === "
|
|
1136
|
+
else if (data.command === "cancel") {
|
|
1110
1137
|
const target = this.routing.resolve(data.channelId);
|
|
1111
1138
|
if (!target) {
|
|
1112
1139
|
await data.respond("No active agent in this channel.");
|
|
1113
1140
|
return;
|
|
1114
1141
|
}
|
|
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}`);
|
|
1142
|
+
const ok = this.cancelInstance(target.name);
|
|
1143
|
+
await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
|
|
1144
|
+
}
|
|
1145
|
+
else if (data.command === "ctx") {
|
|
1146
|
+
const target = this.routing.resolve(data.channelId);
|
|
1147
|
+
if (!target) {
|
|
1148
|
+
await data.respond("No active agent in this channel.");
|
|
1149
|
+
return;
|
|
1133
1150
|
}
|
|
1151
|
+
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
1152
|
+
await data.respond(await this.topicCommands.getCtxText(target.name));
|
|
1134
1153
|
}
|
|
1135
1154
|
else if (data.command === "collab") {
|
|
1136
1155
|
const collabTarget2 = this.routing.resolve(data.channelId);
|
|
@@ -1498,6 +1517,15 @@ export class FleetManager {
|
|
|
1498
1517
|
const isBotMentioned = !!(botUser && text.toLowerCase().includes(`@${botUser.toLowerCase()}`));
|
|
1499
1518
|
const isPrivateChat = !chatId.startsWith("-"); // Telegram: positive = private, negative = group
|
|
1500
1519
|
const msgAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
1520
|
+
// In a TG Classic group, ignore bare slash commands (no @bot specified).
|
|
1521
|
+
// Prevents multiple bots all responding to the same /ctx, /compact, etc.
|
|
1522
|
+
// `/cmd@otherbot` already returned above; `/cmd@mybot` set cmdMatch, so it
|
|
1523
|
+
// still processes. Private chat (only one bot) always processes.
|
|
1524
|
+
// NOTE: this also silently drops bare `/start` in a group, so group
|
|
1525
|
+
// onboarding now requires `/start@mybot` — consistent with the policy.
|
|
1526
|
+
if (!isPrivateChat && !cmdMatch && rawText.startsWith("/")) {
|
|
1527
|
+
return; // bare slash in group — ignore silently
|
|
1528
|
+
}
|
|
1501
1529
|
// Handle /start command
|
|
1502
1530
|
if (text === "/start" || text.startsWith("/start ")) {
|
|
1503
1531
|
if (isPrivateChat) {
|
|
@@ -1567,6 +1595,17 @@ export class FleetManager {
|
|
|
1567
1595
|
await msgAdapter?.sendText(chatId, result);
|
|
1568
1596
|
return;
|
|
1569
1597
|
}
|
|
1598
|
+
// Handle /cancel command
|
|
1599
|
+
if (text === "/cancel" || text.startsWith("/cancel@")) {
|
|
1600
|
+
const cancelTarget = this.routing.resolve(chatId);
|
|
1601
|
+
if (!cancelTarget || cancelTarget.kind !== "classic") {
|
|
1602
|
+
await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
const ok = this.cancelInstance(cancelTarget.name);
|
|
1606
|
+
await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelTarget.name}。` : `❌ ${cancelTarget.name} 未在執行。`);
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1570
1609
|
// Handle /ctx command
|
|
1571
1610
|
if (text === "/ctx" || text.startsWith("/ctx@")) {
|
|
1572
1611
|
const ctxTarget = this.routing.resolve(chatId);
|
|
@@ -1578,6 +1617,36 @@ export class FleetManager {
|
|
|
1578
1617
|
await msgAdapter?.sendText(chatId, reply);
|
|
1579
1618
|
return;
|
|
1580
1619
|
}
|
|
1620
|
+
// Handle /save command (admin only)
|
|
1621
|
+
if (text === "/save" || text.startsWith("/save ") || text.startsWith("/save@")) {
|
|
1622
|
+
if (!this.classicChannels.isAdmin(msg.userId)) {
|
|
1623
|
+
await msgAdapter?.sendText(chatId, "⛔ /save requires admin access.");
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
const saveTarget = this.routing.resolve(chatId);
|
|
1627
|
+
if (!saveTarget || saveTarget.kind !== "classic") {
|
|
1628
|
+
await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
const filename = parseSaveFilename(text);
|
|
1632
|
+
if (!filename) {
|
|
1633
|
+
await msgAdapter?.sendText(chatId, "Usage: /save <filename>");
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
if (!SAVE_FILENAME_RE.test(filename)) {
|
|
1637
|
+
await msgAdapter?.sendText(chatId, "⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
const backend = this.classicChannels.getBackendByInstance(saveTarget.name, this.fleetConfig?.defaults?.backend);
|
|
1641
|
+
const cmd = saveCommandForBackend(backend, filename);
|
|
1642
|
+
if (!cmd) {
|
|
1643
|
+
await msgAdapter?.sendText(chatId, SAVE_UNSUPPORTED_MSG);
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
this.pasteRawToClassicInstance(saveTarget.name, cmd);
|
|
1647
|
+
await msgAdapter?.sendText(chatId, `✅ Sent \`${cmd}\` to ${saveTarget.name}`);
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1581
1650
|
// Route to classic channel if registered
|
|
1582
1651
|
const target = this.routing.resolve(chatId);
|
|
1583
1652
|
if (target?.kind === "classic") {
|
|
@@ -1654,6 +1723,8 @@ export class FleetManager {
|
|
|
1654
1723
|
instance: generalInstance, sender: msg.username,
|
|
1655
1724
|
text: (text ?? "").slice(0, 2000), ts: new Date().toISOString(),
|
|
1656
1725
|
});
|
|
1726
|
+
this.trackInboundMsg(generalInstance, msg);
|
|
1727
|
+
void this.sendCancelButton(generalInstance);
|
|
1657
1728
|
}
|
|
1658
1729
|
}
|
|
1659
1730
|
return;
|
|
@@ -1691,7 +1762,7 @@ export class FleetManager {
|
|
|
1691
1762
|
const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
1692
1763
|
// React immediately — before any other Discord API calls
|
|
1693
1764
|
if (msg.chatId && msg.messageId) {
|
|
1694
|
-
inboundAdapter.react(
|
|
1765
|
+
inboundAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
|
|
1695
1766
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
1696
1767
|
}
|
|
1697
1768
|
// These may hit Discord API (topic icon, archive) — do after react
|
|
@@ -1730,6 +1801,8 @@ export class FleetManager {
|
|
|
1730
1801
|
instance: instanceName, sender: msg.username,
|
|
1731
1802
|
text: (text ?? "").slice(0, 2000), ts: new Date().toISOString(),
|
|
1732
1803
|
});
|
|
1804
|
+
this.trackInboundMsg(instanceName, msg);
|
|
1805
|
+
void this.sendCancelButton(instanceName);
|
|
1733
1806
|
}
|
|
1734
1807
|
/** Handle outbound tool calls from a daemon instance */
|
|
1735
1808
|
/** Warn (but don't block) when rate limits are high. 30-min debounce per instance. */
|
|
@@ -1803,6 +1876,9 @@ export class FleetManager {
|
|
|
1803
1876
|
// Route standard channel tools (reply, react, edit_message, download_attachment)
|
|
1804
1877
|
if (routeToolCall(outAdapter, tool, args, threadId, respond)) {
|
|
1805
1878
|
if (tool === "reply") {
|
|
1879
|
+
// Agent answered — retire its pending cancel button and mark ✅ done.
|
|
1880
|
+
this.clearCancelButton(instanceName);
|
|
1881
|
+
this.reactDone(instanceName);
|
|
1806
1882
|
const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
|
|
1807
1883
|
this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
|
|
1808
1884
|
this.emitSseEvent("message", {
|
|
@@ -1849,7 +1925,7 @@ export class FleetManager {
|
|
|
1849
1925
|
if (!chatId)
|
|
1850
1926
|
return;
|
|
1851
1927
|
if (editMessageId) {
|
|
1852
|
-
statusAdapter.editMessage(chatId, editMessageId, text).catch(e => this.logger.debug({ err: e }, "Failed to edit tool status message"));
|
|
1928
|
+
statusAdapter.editMessage(chatId, editMessageId, text, threadId).catch(e => this.logger.debug({ err: e }, "Failed to edit tool status message"));
|
|
1853
1929
|
}
|
|
1854
1930
|
else {
|
|
1855
1931
|
statusAdapter.sendText(chatId, text, { threadId }).then((sent) => {
|
|
@@ -1887,6 +1963,8 @@ export class FleetManager {
|
|
|
1887
1963
|
payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
|
|
1888
1964
|
meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
|
|
1889
1965
|
});
|
|
1966
|
+
// A scheduled trigger also puts the instance to work — show a cancel button.
|
|
1967
|
+
void this.sendCancelButton(target);
|
|
1890
1968
|
return true;
|
|
1891
1969
|
};
|
|
1892
1970
|
if (deliver()) {
|
|
@@ -2512,6 +2590,212 @@ export class FleetManager {
|
|
|
2512
2590
|
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
|
|
2513
2591
|
}
|
|
2514
2592
|
}
|
|
2593
|
+
// ── Cancel button ────────────────────────────────────────────────────
|
|
2594
|
+
// Sent after delivering a user message to an instance; clicking it (or
|
|
2595
|
+
// /cancel) sends Escape to the instance's pane to interrupt generation.
|
|
2596
|
+
/** Send a "🛑 Cancel" button to the instance's topic/channel after delivery. */
|
|
2597
|
+
/**
|
|
2598
|
+
* Handle the DC `/save` slash command for both classic AND fleet-topic targets.
|
|
2599
|
+
* Picks the backend-appropriate command (kiro → /chat save, claude → /export);
|
|
2600
|
+
* unsupported backends get a clear error. Routes via classic paste or fleet IPC.
|
|
2601
|
+
*/
|
|
2602
|
+
async handleSlashSave(data) {
|
|
2603
|
+
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
2604
|
+
await data.respond("⛔ This command requires admin access.");
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2607
|
+
const target = this.routing.resolve(data.channelId);
|
|
2608
|
+
if (!target) {
|
|
2609
|
+
await data.respond("No active agent in this channel. Use `/start` first.");
|
|
2610
|
+
return;
|
|
2611
|
+
}
|
|
2612
|
+
const filename = data.options?.filename ?? "";
|
|
2613
|
+
if (!SAVE_FILENAME_RE.test(filename)) {
|
|
2614
|
+
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
2615
|
+
return;
|
|
2616
|
+
}
|
|
2617
|
+
const backend = target.kind === "classic"
|
|
2618
|
+
? this.classicChannels.getBackendByInstance(target.name, this.fleetConfig?.defaults?.backend)
|
|
2619
|
+
: (this.fleetConfig?.instances[target.name]?.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
|
|
2620
|
+
// force (-f) is only meaningful for kiro/classic /chat save.
|
|
2621
|
+
const force = target.kind === "classic" && !!data.options?.force;
|
|
2622
|
+
const cmd = saveCommandForBackend(backend, filename, force);
|
|
2623
|
+
if (!cmd) {
|
|
2624
|
+
await data.respond(SAVE_UNSUPPORTED_MSG);
|
|
2625
|
+
return;
|
|
2626
|
+
}
|
|
2627
|
+
if (target.kind === "classic") {
|
|
2628
|
+
this.pasteRawToClassicInstance(target.name, cmd);
|
|
2629
|
+
}
|
|
2630
|
+
else {
|
|
2631
|
+
this.instanceIpcClients.get(target.name)?.send({ type: "raw_paste", content: cmd });
|
|
2632
|
+
}
|
|
2633
|
+
await data.respond(`✅ Sent \`${cmd}\` to ${target.name}`);
|
|
2634
|
+
}
|
|
2635
|
+
/** Whether the instance currently has at least one live cancel button. */
|
|
2636
|
+
hasCancelButton(instanceName) {
|
|
2637
|
+
for (const e of this.cancelButtons.values()) {
|
|
2638
|
+
if (e.instanceName === instanceName)
|
|
2639
|
+
return true;
|
|
2640
|
+
}
|
|
2641
|
+
return false;
|
|
2642
|
+
}
|
|
2643
|
+
async sendCancelButton(instanceName) {
|
|
2644
|
+
// At most one button shown per instance: retire any existing ones first
|
|
2645
|
+
// (delete + bounded retry). Each is tracked separately, so a failed delete
|
|
2646
|
+
// here doesn't strand it — it keeps retrying on its own timer.
|
|
2647
|
+
this.retireInstanceButtons(instanceName);
|
|
2648
|
+
const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
2649
|
+
if (!adapter)
|
|
2650
|
+
return;
|
|
2651
|
+
const adapterId = this.instanceWorldBinding.get(instanceName);
|
|
2652
|
+
const groupId = this.getChannelConfig(adapterId)?.group_id;
|
|
2653
|
+
const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
|
|
2654
|
+
let chatId;
|
|
2655
|
+
let threadId;
|
|
2656
|
+
if (topicId != null && groupId) {
|
|
2657
|
+
// Fleet topic instance.
|
|
2658
|
+
chatId = String(groupId);
|
|
2659
|
+
threadId = String(topicId);
|
|
2660
|
+
}
|
|
2661
|
+
else {
|
|
2662
|
+
// Classic instance: chatId from the routing table.
|
|
2663
|
+
for (const [cid, target] of this.routing.entries()) {
|
|
2664
|
+
if (target.kind === "classic" && target.name === instanceName) {
|
|
2665
|
+
chatId = cid;
|
|
2666
|
+
break;
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
// General / flat fallback: post to the group (no thread).
|
|
2670
|
+
if (!chatId && groupId)
|
|
2671
|
+
chatId = String(groupId);
|
|
2672
|
+
}
|
|
2673
|
+
if (!chatId)
|
|
2674
|
+
return;
|
|
2675
|
+
try {
|
|
2676
|
+
const sent = await adapter.notifyAlert(chatId, {
|
|
2677
|
+
type: "cancel",
|
|
2678
|
+
instanceName,
|
|
2679
|
+
message: "👀 處理中…",
|
|
2680
|
+
choices: [{ id: `cancel:${instanceName}`, label: "🛑 取消" }],
|
|
2681
|
+
}, threadId ? { threadId } : undefined);
|
|
2682
|
+
// A concurrent sendCancelButton for the same instance may have posted its
|
|
2683
|
+
// own button while we awaited notifyAlert. Retire any other buttons for
|
|
2684
|
+
// this instance (not the one we just posted) so only the newest shows.
|
|
2685
|
+
for (const other of this.cancelButtons.values()) {
|
|
2686
|
+
if (other.instanceName === instanceName)
|
|
2687
|
+
this.retireButton(other);
|
|
2688
|
+
}
|
|
2689
|
+
this.cancelButtons.set(sent.messageId, {
|
|
2690
|
+
instanceName,
|
|
2691
|
+
adapterId,
|
|
2692
|
+
chatId: sent.chatId,
|
|
2693
|
+
messageId: sent.messageId,
|
|
2694
|
+
threadId: sent.threadId ?? threadId,
|
|
2695
|
+
retryCount: 0,
|
|
2696
|
+
});
|
|
2697
|
+
this.logger.info({ instanceName, messageId: sent.messageId }, "Cancel button sent");
|
|
2698
|
+
}
|
|
2699
|
+
catch (e) {
|
|
2700
|
+
this.logger.warn({ err: e.message, instanceName }, "Failed to send cancel button");
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
/** Retire (delete) every cancel button belonging to an instance. */
|
|
2704
|
+
retireInstanceButtons(instanceName) {
|
|
2705
|
+
// Snapshot first — retireButton may delete entries from the map on success.
|
|
2706
|
+
for (const e of [...this.cancelButtons.values()]) {
|
|
2707
|
+
if (e.instanceName === instanceName)
|
|
2708
|
+
this.retireButton(e);
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
/** Begin retiring one button (delete + bounded retry on failure). Idempotent:
|
|
2712
|
+
* a button already in a retire cycle is left to its own timer, so a second
|
|
2713
|
+
* retire request (e.g. a new send + the post-await sweep) won't double-delete. */
|
|
2714
|
+
retireButton(entry) {
|
|
2715
|
+
if (entry.retiring)
|
|
2716
|
+
return;
|
|
2717
|
+
entry.retiring = true;
|
|
2718
|
+
this.attemptButtonDelete(entry);
|
|
2719
|
+
}
|
|
2720
|
+
attemptButtonDelete(entry) {
|
|
2721
|
+
this.deleteButtonMessage(entry)
|
|
2722
|
+
.then(() => {
|
|
2723
|
+
if (entry.retryTimer)
|
|
2724
|
+
clearTimeout(entry.retryTimer);
|
|
2725
|
+
this.cancelButtons.delete(entry.messageId);
|
|
2726
|
+
this.logger.info({ instanceName: entry.instanceName, messageId: entry.messageId }, "Cancel button removed");
|
|
2727
|
+
})
|
|
2728
|
+
.catch((err) => this.scheduleButtonRetry(entry, err));
|
|
2729
|
+
}
|
|
2730
|
+
/** Re-attempt a failed button delete up to CANCEL_BTN_MAX_RETRIES times. */
|
|
2731
|
+
scheduleButtonRetry(entry, err) {
|
|
2732
|
+
if (entry.retryCount >= CANCEL_BTN_MAX_RETRIES) {
|
|
2733
|
+
if (entry.retryTimer)
|
|
2734
|
+
clearTimeout(entry.retryTimer);
|
|
2735
|
+
this.cancelButtons.delete(entry.messageId);
|
|
2736
|
+
this.logger.warn({ instanceName: entry.instanceName, messageId: entry.messageId, err: err.message }, `Cancel button delete gave up after ${CANCEL_BTN_MAX_RETRIES} retries`);
|
|
2737
|
+
return;
|
|
2738
|
+
}
|
|
2739
|
+
entry.retryCount++;
|
|
2740
|
+
this.logger.warn({ instanceName: entry.instanceName, messageId: entry.messageId, attempt: entry.retryCount, err: err.message }, "Cancel button delete failed, will retry");
|
|
2741
|
+
if (entry.retryTimer)
|
|
2742
|
+
clearTimeout(entry.retryTimer);
|
|
2743
|
+
// Continue the same retire cycle (bypass the retiring-guard in retireButton).
|
|
2744
|
+
entry.retryTimer = setTimeout(() => this.attemptButtonDelete(entry), CANCEL_BTN_RETRY_INTERVAL_MS);
|
|
2745
|
+
}
|
|
2746
|
+
/** Delete one button's message via its own adapter. Resolves on success,
|
|
2747
|
+
* rejects on failure so the caller can retry. */
|
|
2748
|
+
deleteButtonMessage(e) {
|
|
2749
|
+
const adapter = (e.adapterId ? this.worlds.get(e.adapterId)?.adapter : undefined) ?? this.adapter;
|
|
2750
|
+
if (!adapter)
|
|
2751
|
+
return Promise.reject(new Error("no adapter for cancel button"));
|
|
2752
|
+
if (adapter.deleteMessage)
|
|
2753
|
+
return adapter.deleteMessage(e.chatId, e.messageId, e.threadId);
|
|
2754
|
+
if (adapter.editMessageRemoveButtons)
|
|
2755
|
+
return adapter.editMessageRemoveButtons(e.chatId, e.messageId, "✅", e.threadId);
|
|
2756
|
+
return adapter.editMessage(e.chatId, e.messageId, "✅", e.threadId);
|
|
2757
|
+
}
|
|
2758
|
+
/** Retire all cancel buttons for an instance — on reply, cancel, or report. */
|
|
2759
|
+
clearCancelButton(instanceName) {
|
|
2760
|
+
this.retireInstanceButtons(instanceName);
|
|
2761
|
+
}
|
|
2762
|
+
/**
|
|
2763
|
+
* Reaction target chat id. Telegram reactions key on the supergroup chat_id
|
|
2764
|
+
* (the topic thread is NOT a chat_id), so a forum-topic message must react on
|
|
2765
|
+
* msg.chatId — reacting on threadId silently fails. Discord reactions key on
|
|
2766
|
+
* the channel/thread id.
|
|
2767
|
+
*/
|
|
2768
|
+
reactTarget(msg) {
|
|
2769
|
+
return msg.source === "telegram" ? msg.chatId : (msg.threadId ?? msg.chatId);
|
|
2770
|
+
}
|
|
2771
|
+
/** Remember the user message just delivered, so we can react ✅ when done. */
|
|
2772
|
+
trackInboundMsg(instanceName, msg) {
|
|
2773
|
+
if (!msg.chatId || !msg.messageId)
|
|
2774
|
+
return;
|
|
2775
|
+
this.lastInboundMsg.set(instanceName, {
|
|
2776
|
+
adapterId: msg.adapterId, chatId: msg.chatId, threadId: msg.threadId ?? undefined, messageId: msg.messageId, source: msg.source,
|
|
2777
|
+
});
|
|
2778
|
+
}
|
|
2779
|
+
/** React ✅ on the last user message after the agent replies. */
|
|
2780
|
+
reactDone(instanceName) {
|
|
2781
|
+
const m = this.lastInboundMsg.get(instanceName);
|
|
2782
|
+
if (!m)
|
|
2783
|
+
return;
|
|
2784
|
+
this.lastInboundMsg.delete(instanceName);
|
|
2785
|
+
const adapter = (m.adapterId ? this.worlds.get(m.adapterId)?.adapter : undefined)
|
|
2786
|
+
?? this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
2787
|
+
adapter?.react(this.reactTarget(m), m.messageId, "✅").catch(() => { });
|
|
2788
|
+
}
|
|
2789
|
+
/** Interrupt an instance's current generation (cancel button / /cancel). */
|
|
2790
|
+
cancelInstance(instanceName) {
|
|
2791
|
+
const daemon = this.daemons.get(instanceName);
|
|
2792
|
+
if (!daemon)
|
|
2793
|
+
return false;
|
|
2794
|
+
daemon.sendEscape().catch(e => this.logger.warn({ err: e, instanceName }, "sendEscape failed"));
|
|
2795
|
+
this.lastInboundMsg.delete(instanceName);
|
|
2796
|
+
this.clearCancelButton(instanceName);
|
|
2797
|
+
return true;
|
|
2798
|
+
}
|
|
2515
2799
|
queueMirrorMessage(text) {
|
|
2516
2800
|
const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
|
|
2517
2801
|
if (mirrorTopicId == null || !this.adapter)
|
|
@@ -2850,10 +3134,17 @@ When users create specialized instances, suggest these configurations:
|
|
|
2850
3134
|
// Skip empty bot messages (e.g., reactions) — don't pollute chat log
|
|
2851
3135
|
if (msg.isBotMessage && !text && !msg.attachments?.length)
|
|
2852
3136
|
return;
|
|
3137
|
+
// Save attachments FIRST so the chat-log records their inbox paths
|
|
3138
|
+
// (consistent with the /chat path). Otherwise a non-@mention image is
|
|
3139
|
+
// saved to inbox but its path never reaches the agent — the log keeps
|
|
3140
|
+
// only a pathless filename, so later context can't locate the file.
|
|
3141
|
+
const saved = msg.attachments?.length ? await this.saveClassicAttachment(instanceName, msg) : undefined;
|
|
2853
3142
|
// Log every message (including other bots) to chat-logs
|
|
2854
|
-
const collabAttachTag =
|
|
2855
|
-
? ` [${
|
|
2856
|
-
:
|
|
3143
|
+
const collabAttachTag = saved
|
|
3144
|
+
? ` [${saved.kind === "photo" ? "📷" : "📎"} saved: ${saved.paths.join(", ")}]`
|
|
3145
|
+
: (msg.attachments?.length
|
|
3146
|
+
? ` [${msg.attachments.map(a => `${a.kind === "photo" ? "📷" : "📎"} ${a.filename || a.kind}`).join(", ")}]`
|
|
3147
|
+
: "");
|
|
2857
3148
|
ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText);
|
|
2858
3149
|
this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message");
|
|
2859
3150
|
// Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here
|
|
@@ -2861,19 +3152,16 @@ When users create specialized instances, suggest these configurations:
|
|
|
2861
3152
|
const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
|
|
2862
3153
|
const isMentioned = mentionTag && text.includes(mentionTag);
|
|
2863
3154
|
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
|
-
}
|
|
3155
|
+
// Bare attachment (no @mention) — already saved above; just acknowledge.
|
|
3156
|
+
if (saved) {
|
|
3157
|
+
const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
3158
|
+
const noMentionReactChatId = msg.threadId ?? msg.chatId;
|
|
3159
|
+
if (reactAdapter && noMentionReactChatId && msg.messageId) {
|
|
3160
|
+
const emoji = msg.source === "telegram"
|
|
3161
|
+
? (saved.kind === "photo" ? "👌" : "👍")
|
|
3162
|
+
: (saved.kind === "photo" ? "📸" : "📎");
|
|
3163
|
+
reactAdapter.react(noMentionReactChatId, msg.messageId, emoji)
|
|
3164
|
+
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
2877
3165
|
}
|
|
2878
3166
|
}
|
|
2879
3167
|
return;
|
|
@@ -2891,8 +3179,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
2891
3179
|
// Block /raw bypass
|
|
2892
3180
|
if (cleanText.startsWith("/raw "))
|
|
2893
3181
|
return;
|
|
2894
|
-
//
|
|
2895
|
-
const saved = await this.saveClassicAttachment(instanceName, msg);
|
|
3182
|
+
// Attachments already saved at the top of the collab block.
|
|
2896
3183
|
if (saved && classicAdapter && collabReactChatId && msg.messageId) {
|
|
2897
3184
|
const emoji = msg.source === "telegram"
|
|
2898
3185
|
? (saved.kind === "photo" ? "👌" : "👍")
|
|
@@ -3049,24 +3336,37 @@ When users create specialized instances, suggest these configurations:
|
|
|
3049
3336
|
this.logger.warn({ instanceName }, "Classic channel instance IPC not connected");
|
|
3050
3337
|
return;
|
|
3051
3338
|
}
|
|
3339
|
+
const meta = {
|
|
3340
|
+
chat_id: msg.chatId,
|
|
3341
|
+
message_id: msg.messageId,
|
|
3342
|
+
user: msg.username,
|
|
3343
|
+
user_id: msg.userId,
|
|
3344
|
+
ts: msg.timestamp.toISOString(),
|
|
3345
|
+
thread_id: msg.threadId ?? "",
|
|
3346
|
+
source: msg.source,
|
|
3347
|
+
...extraMeta,
|
|
3348
|
+
...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
|
|
3349
|
+
};
|
|
3350
|
+
// If the triggering message carried no image of its own, surface the most
|
|
3351
|
+
// recent image saved earlier in this channel (logged as "[📷 saved: <path>]"
|
|
3352
|
+
// by an untriggered collab message) as image_path, so the agent's
|
|
3353
|
+
// read-the-image trigger fires instead of the path sitting inert in context.
|
|
3354
|
+
if (!meta.image_path && logContext) {
|
|
3355
|
+
const saves = [...logContext.matchAll(/\[📷 saved: ([^\]]+)\]/g)];
|
|
3356
|
+
if (saves.length > 0) {
|
|
3357
|
+
meta.image_path = saves[saves.length - 1][1].split(",")[0].trim();
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3052
3360
|
ipc.send({
|
|
3053
3361
|
type: "fleet_inbound",
|
|
3054
3362
|
content: fullText,
|
|
3055
3363
|
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
|
-
},
|
|
3364
|
+
meta,
|
|
3067
3365
|
});
|
|
3068
3366
|
this.lastInboundUser.set(instanceName, msg.username);
|
|
3069
3367
|
this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
|
|
3368
|
+
this.trackInboundMsg(instanceName, msg);
|
|
3369
|
+
void this.sendCancelButton(instanceName);
|
|
3070
3370
|
}
|
|
3071
3371
|
/** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
|
|
3072
3372
|
pasteRawToClassicInstance(instanceName, text) {
|
|
@@ -3186,9 +3486,11 @@ When users create specialized instances, suggest these configurations:
|
|
|
3186
3486
|
this.topicArchiver.stop();
|
|
3187
3487
|
this.scheduler?.shutdown();
|
|
3188
3488
|
// Stop instances in parallel batches to avoid long sequential waits.
|
|
3189
|
-
// Concurrency
|
|
3190
|
-
|
|
3489
|
+
// Concurrency scales with fleet size — larger fleets tolerate more parallel
|
|
3490
|
+
// tmux ops, while small fleets stay conservative to avoid overwhelming the
|
|
3491
|
+
// tmux server.
|
|
3191
3492
|
const entries = [...this.daemons.entries()];
|
|
3493
|
+
const STOP_CONCURRENCY = entries.length > 30 ? 15 : entries.length >= 10 ? 10 : 5;
|
|
3192
3494
|
for (const [name] of entries)
|
|
3193
3495
|
this.ipcStoppingInstances.add(name);
|
|
3194
3496
|
for (let i = 0; i < entries.length; i += STOP_CONCURRENCY) {
|
|
@@ -3203,9 +3505,9 @@ When users create specialized instances, suggest these configurations:
|
|
|
3203
3505
|
this.daemons.delete(name);
|
|
3204
3506
|
}));
|
|
3205
3507
|
}
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
}
|
|
3508
|
+
// Close IPC clients in parallel — serial close over a large fleet adds
|
|
3509
|
+
// noticeable latency.
|
|
3510
|
+
await Promise.all([...this.instanceIpcClients.values()].map(ipc => Promise.resolve(ipc.close()).catch(() => { })));
|
|
3209
3511
|
this.instanceIpcClients.clear();
|
|
3210
3512
|
this.ipcStoppingInstances.clear();
|
|
3211
3513
|
for (const [, w] of this.worlds) {
|
|
@@ -3541,14 +3843,21 @@ When users create specialized instances, suggest these configurations:
|
|
|
3541
3843
|
const latest = execSync("npm view @songsid/agend version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
|
|
3542
3844
|
let target = latest;
|
|
3543
3845
|
if (currentVersion.includes("-beta")) {
|
|
3846
|
+
// Beta users track the @beta channel (never fall back to @latest, which is
|
|
3847
|
+
// older), but should also hear when a newer STABLE ships — pick whichever
|
|
3848
|
+
// of beta/latest is the newest.
|
|
3849
|
+
let beta = "";
|
|
3544
3850
|
try {
|
|
3545
|
-
|
|
3546
|
-
if (beta && beta !== currentVersion)
|
|
3547
|
-
target = beta;
|
|
3851
|
+
beta = execSync("npm view @songsid/agend@beta version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
|
|
3548
3852
|
}
|
|
3549
3853
|
catch { /* no beta tag */ }
|
|
3854
|
+
target = beta || latest;
|
|
3855
|
+
if (latest && this.semverGt(latest, target))
|
|
3856
|
+
target = latest;
|
|
3550
3857
|
}
|
|
3551
|
-
|
|
3858
|
+
// Only notify when target is genuinely newer (semver), so a beta user on
|
|
3859
|
+
// 2.0.8-beta.16 is never told that stable 2.0.7 is "available".
|
|
3860
|
+
if (target && this.semverGt(target, currentVersion)) {
|
|
3552
3861
|
const generalId = this.findGeneralInstance();
|
|
3553
3862
|
if (generalId) {
|
|
3554
3863
|
this.notifyInstanceTopic(generalId, `🆕 AgEnD v${target} available (current: v${currentVersion}). Use \`/update\` to upgrade.`);
|
|
@@ -3557,6 +3866,49 @@ When users create specialized instances, suggest these configurations:
|
|
|
3557
3866
|
}
|
|
3558
3867
|
catch { /* silent — network issues */ }
|
|
3559
3868
|
}
|
|
3869
|
+
/**
|
|
3870
|
+
* Semver "a > b". Compares major.minor.patch numerically; a version without a
|
|
3871
|
+
* prerelease outranks the same core with one (2.0.8 > 2.0.8-beta.16); two
|
|
3872
|
+
* prereleases compare identifier-by-identifier (numeric < alphanumeric, numeric
|
|
3873
|
+
* fields compared as numbers). Sufficient for our X.Y.Z[-beta.N] scheme.
|
|
3874
|
+
*/
|
|
3875
|
+
semverGt(a, b) {
|
|
3876
|
+
const parse = (v) => {
|
|
3877
|
+
const [core, pre] = v.replace(/^v/, "").split("-");
|
|
3878
|
+
const nums = core.split(".").map(n => parseInt(n, 10) || 0);
|
|
3879
|
+
return { nums: [nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0], pre: pre ? pre.split(".") : [] };
|
|
3880
|
+
};
|
|
3881
|
+
const pa = parse(a), pb = parse(b);
|
|
3882
|
+
for (let i = 0; i < 3; i++) {
|
|
3883
|
+
if (pa.nums[i] !== pb.nums[i])
|
|
3884
|
+
return pa.nums[i] > pb.nums[i];
|
|
3885
|
+
}
|
|
3886
|
+
if (pa.pre.length === 0 && pb.pre.length === 0)
|
|
3887
|
+
return false;
|
|
3888
|
+
if (pa.pre.length === 0)
|
|
3889
|
+
return true; // a stable, b prerelease → a > b
|
|
3890
|
+
if (pb.pre.length === 0)
|
|
3891
|
+
return false; // a prerelease, b stable → a < b
|
|
3892
|
+
const len = Math.max(pa.pre.length, pb.pre.length);
|
|
3893
|
+
for (let i = 0; i < len; i++) {
|
|
3894
|
+
const x = pa.pre[i], y = pb.pre[i];
|
|
3895
|
+
if (x === undefined)
|
|
3896
|
+
return false; // a has fewer identifiers → a < b
|
|
3897
|
+
if (y === undefined)
|
|
3898
|
+
return true; // a has more identifiers → a > b
|
|
3899
|
+
const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y);
|
|
3900
|
+
if (xn && yn) {
|
|
3901
|
+
const dx = parseInt(x, 10), dy = parseInt(y, 10);
|
|
3902
|
+
if (dx !== dy)
|
|
3903
|
+
return dx > dy;
|
|
3904
|
+
}
|
|
3905
|
+
else if (xn !== yn)
|
|
3906
|
+
return yn; // numeric has lower precedence than alphanumeric
|
|
3907
|
+
else if (x !== y)
|
|
3908
|
+
return x > y; // both alphanumeric
|
|
3909
|
+
}
|
|
3910
|
+
return false; // identical
|
|
3911
|
+
}
|
|
3560
3912
|
// ── Health HTTP endpoint ─────────────────────────────────────────────
|
|
3561
3913
|
startHealthServer(port) {
|
|
3562
3914
|
this.startedAt = Date.now();
|
|
@@ -3733,6 +4085,29 @@ When users create specialized instances, suggest these configurations:
|
|
|
3733
4085
|
})();
|
|
3734
4086
|
return;
|
|
3735
4087
|
}
|
|
4088
|
+
if (req.method === "POST" && req.url?.startsWith("/stop/")) {
|
|
4089
|
+
const name = decodeURIComponent(req.url.slice("/stop/".length));
|
|
4090
|
+
this.logger.info({ name }, "Instance stop requested via HTTP");
|
|
4091
|
+
(async () => {
|
|
4092
|
+
try {
|
|
4093
|
+
// Runs inside the live fleet process: lifecycle.stop finds the
|
|
4094
|
+
// in-memory daemon and stops just this instance. (Doing this from a
|
|
4095
|
+
// detached CLI FleetManager would read the shared daemon.pid — the
|
|
4096
|
+
// fleet's own pid — and kill the whole fleet.)
|
|
4097
|
+
await this.stopInstance(name);
|
|
4098
|
+
this.logger.info({ name }, "Instance stopped");
|
|
4099
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
4100
|
+
res.writeHead(200);
|
|
4101
|
+
res.end(JSON.stringify({ stopped: name }));
|
|
4102
|
+
}
|
|
4103
|
+
catch (err) {
|
|
4104
|
+
this.logger.error({ err, name }, "Instance stop failed");
|
|
4105
|
+
res.writeHead(500);
|
|
4106
|
+
res.end(JSON.stringify({ error: `Stop failed: ${err.message}` }));
|
|
4107
|
+
}
|
|
4108
|
+
})();
|
|
4109
|
+
return;
|
|
4110
|
+
}
|
|
3736
4111
|
// ── Agent CLI endpoint ─────
|
|
3737
4112
|
if (req.url === "/agent" && req.method === "POST") {
|
|
3738
4113
|
handleAgentRequest(req, res, this);
|