@songsid/agend 2.0.8-beta.8 → 2.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/agent-endpoint.js +6 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/backend/codex.js +10 -3
- package/dist/backend/codex.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +4 -4
- package/dist/channel/adapters/discord.js +162 -111
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +1 -1
- package/dist/channel/adapters/telegram.js +3 -1
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/tool-router.js +4 -2
- 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 +14 -6
- package/dist/cli.js +150 -95
- package/dist/cli.js.map +1 -1
- package/dist/daemon.d.ts +25 -1
- package/dist/daemon.js +149 -43
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-manager.d.ts +54 -5
- package/dist/fleet-manager.js +423 -170
- package/dist/fleet-manager.js.map +1 -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 +3 -0
- package/dist/outbound-handlers.js +17 -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 +6 -0
- 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 +73 -6
- package/dist/topic-commands.js.map +1 -1
- package/package.json +3 -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,14 @@ 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;
|
|
52
|
+
/** Backstop: every 5min, retire a button whose instance has gone idle. Catches
|
|
53
|
+
* buttons no clear trigger reached (e.g. a scheduled/HTTP turn that never called
|
|
54
|
+
* reply). 5min (not the old 2s idle-watch) so Thinking isn't misread as idle. */
|
|
55
|
+
const CANCEL_BTN_IDLE_CHECK_INTERVAL_MS = 5 * 60_000;
|
|
48
56
|
export class FleetManager {
|
|
49
57
|
dataDir;
|
|
50
58
|
children = new Map();
|
|
@@ -78,9 +86,11 @@ export class FleetManager {
|
|
|
78
86
|
topicIcons = {};
|
|
79
87
|
lastActivity = new Map();
|
|
80
88
|
lastInboundUser = new Map(); // instanceName → last username
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
89
|
+
// Active "🛑 Cancel" buttons, tracked per button (keyed by messageId) rather
|
|
90
|
+
// than one-per-instance. A button is retired (deleted, with bounded retry) on
|
|
91
|
+
// reply, on cancel, or when a newer button supersedes it for the same
|
|
92
|
+
// instance. Per-button tracking means a failed delete never strands a button.
|
|
93
|
+
cancelButtons = new Map();
|
|
84
94
|
// Last user message delivered to each instance — used to react ✅ on completion.
|
|
85
95
|
lastInboundMsg = new Map();
|
|
86
96
|
topicArchiver;
|
|
@@ -448,6 +458,10 @@ export class FleetManager {
|
|
|
448
458
|
.catch(e => this.logger.warn({ err: e }, "Failed to send daily summary"));
|
|
449
459
|
// Rotate classic channel chat logs daily
|
|
450
460
|
this.classicChannels?.rotateLogs();
|
|
461
|
+
this.rotateInboxes();
|
|
462
|
+
// Rotate fleet.log daily too (besides the startup size check above), so a
|
|
463
|
+
// long-running fleet doesn't accumulate an unbounded log.
|
|
464
|
+
rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
|
|
451
465
|
}, () => {
|
|
452
466
|
const instances = Object.keys(this.fleetConfig?.instances ?? {});
|
|
453
467
|
const costMap = new Map();
|
|
@@ -459,6 +473,7 @@ export class FleetManager {
|
|
|
459
473
|
this.dailySummary.start();
|
|
460
474
|
// Rotate classic channel chat logs daily (piggyback on daily summary timer)
|
|
461
475
|
this.classicChannels?.rotateLogs();
|
|
476
|
+
this.rotateInboxes();
|
|
462
477
|
// Auto-create general instance(s) — one per adapter that lacks a general
|
|
463
478
|
const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
|
|
464
479
|
const generalInstances = Object.entries(fleet.instances).filter(([, inst]) => inst.general_topic === true);
|
|
@@ -686,6 +701,41 @@ export class FleetManager {
|
|
|
686
701
|
};
|
|
687
702
|
process.once("SIGUSR1", onFullRestart);
|
|
688
703
|
}
|
|
704
|
+
/**
|
|
705
|
+
* Delete inbox files older than retentionDays (by mtime). Cleans the shared
|
|
706
|
+
* inbox (`<dataDir>/inbox`) and every workspace inbox
|
|
707
|
+
* (`<agendHome>/workspaces/*\/inbox`). Piggybacks on the daily summary timer,
|
|
708
|
+
* mirroring classic chat-log rotation (same 7-day retention).
|
|
709
|
+
*/
|
|
710
|
+
rotateInboxes(retentionDays = 7) {
|
|
711
|
+
const cutoff = Date.now() - retentionDays * 86400_000;
|
|
712
|
+
const dirs = [join(this.dataDir, "inbox")];
|
|
713
|
+
const workspacesDir = join(getAgendHome(), "workspaces");
|
|
714
|
+
if (existsSync(workspacesDir)) {
|
|
715
|
+
for (const ws of readdirSync(workspacesDir)) {
|
|
716
|
+
dirs.push(join(workspacesDir, ws, "inbox"));
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
let deleted = 0;
|
|
720
|
+
for (const dir of dirs) {
|
|
721
|
+
if (!existsSync(dir))
|
|
722
|
+
continue;
|
|
723
|
+
for (const file of readdirSync(dir)) {
|
|
724
|
+
const full = join(dir, file);
|
|
725
|
+
try {
|
|
726
|
+
const st = statSync(full);
|
|
727
|
+
if (st.isFile() && st.mtimeMs < cutoff) {
|
|
728
|
+
unlinkSync(full);
|
|
729
|
+
deleted++;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
catch { /* file vanished or unreadable — skip */ }
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
if (deleted > 0)
|
|
736
|
+
this.logger.info({ deleted }, "Rotated inbox files");
|
|
737
|
+
return deleted;
|
|
738
|
+
}
|
|
689
739
|
/** Start the shared channel adapter(s) for topic mode */
|
|
690
740
|
async startSharedAdapter(fleet) {
|
|
691
741
|
const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
|
|
@@ -737,16 +787,20 @@ export class FleetManager {
|
|
|
737
787
|
await this.startInstance(instanceName, config, topicMode);
|
|
738
788
|
// startInstance already calls connectIpcToInstance
|
|
739
789
|
}
|
|
740
|
-
this.adapter?.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted
|
|
790
|
+
this.adapter?.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
|
|
741
791
|
}
|
|
742
792
|
else {
|
|
743
|
-
this.adapter?.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}
|
|
793
|
+
this.adapter?.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
|
|
744
794
|
}
|
|
745
795
|
return;
|
|
746
796
|
}
|
|
747
797
|
if (data.callbackData.startsWith("cancel:")) {
|
|
748
798
|
const instanceName = data.callbackData.slice("cancel:".length);
|
|
749
|
-
|
|
799
|
+
// Idempotent: a button click only acts while the button is live. A
|
|
800
|
+
// second click (entry already cleared) is a no-op — don't re-send the
|
|
801
|
+
// interrupt key. (The /cancel command path calls cancelInstance directly.)
|
|
802
|
+
if (this.hasCancelButton(instanceName))
|
|
803
|
+
this.cancelInstance(instanceName);
|
|
750
804
|
return;
|
|
751
805
|
}
|
|
752
806
|
}, this.logger, "adapter.callback_query"));
|
|
@@ -790,7 +844,11 @@ export class FleetManager {
|
|
|
790
844
|
timestamp: new Date(),
|
|
791
845
|
});
|
|
792
846
|
}
|
|
793
|
-
else if (data.command === "save"
|
|
847
|
+
else if (data.command === "save") {
|
|
848
|
+
await this.handleSlashSave(data);
|
|
849
|
+
}
|
|
850
|
+
else if (data.command === "load") {
|
|
851
|
+
// load is kiro-cli/classic only — no claude-code equivalent.
|
|
794
852
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
795
853
|
await data.respond("⛔ This command requires admin access.");
|
|
796
854
|
return;
|
|
@@ -800,25 +858,13 @@ export class FleetManager {
|
|
|
800
858
|
await data.respond("No active agent in this channel. Use `/start` first.");
|
|
801
859
|
return;
|
|
802
860
|
}
|
|
803
|
-
|
|
804
|
-
if (
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
808
|
-
return;
|
|
809
|
-
}
|
|
810
|
-
rawCmd = data.options?.force ? `/chat save ${filename} -f` : `/chat save ${filename}`;
|
|
811
|
-
}
|
|
812
|
-
else {
|
|
813
|
-
const filename = data.options?.filename;
|
|
814
|
-
if (!/^[\w.-]+$/.test(filename)) {
|
|
815
|
-
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
816
|
-
return;
|
|
817
|
-
}
|
|
818
|
-
rawCmd = `/chat load ${filename}`;
|
|
861
|
+
const filename = data.options?.filename;
|
|
862
|
+
if (!SAVE_FILENAME_RE.test(filename ?? "")) {
|
|
863
|
+
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
864
|
+
return;
|
|
819
865
|
}
|
|
820
|
-
this.pasteRawToClassicInstance(target.name,
|
|
821
|
-
await data.respond(`✅ Sent
|
|
866
|
+
this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
|
|
867
|
+
await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
|
|
822
868
|
}
|
|
823
869
|
else if (data.command === "compact") {
|
|
824
870
|
const target = this.routing.resolve(data.channelId);
|
|
@@ -844,42 +890,8 @@ export class FleetManager {
|
|
|
844
890
|
await data.respond("No active agent in this channel.");
|
|
845
891
|
return;
|
|
846
892
|
}
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
? (this.classicChannels?.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend) ?? "claude-code")
|
|
850
|
-
: (this.fleetConfig?.instances[instanceName]?.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
|
|
851
|
-
let context = null;
|
|
852
|
-
// Try statusline.json first
|
|
853
|
-
try {
|
|
854
|
-
const statusFile = join(this.getInstanceDir(instanceName), "statusline.json");
|
|
855
|
-
if (existsSync(statusFile)) {
|
|
856
|
-
const d = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
857
|
-
context = d.context_window?.used_percentage ?? null;
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
catch { /* ignore */ }
|
|
861
|
-
// Fallback: capture tmux pane
|
|
862
|
-
if (context == null) {
|
|
863
|
-
try {
|
|
864
|
-
const { execFileSync } = await import("node:child_process");
|
|
865
|
-
const { getTmuxSocketName } = await import("./paths.js");
|
|
866
|
-
const socketName = getTmuxSocketName();
|
|
867
|
-
const tmuxArgs = socketName
|
|
868
|
-
? ["-L", socketName, "capture-pane", "-t", `${getTmuxSession()}:${instanceName}`, "-p"]
|
|
869
|
-
: ["capture-pane", "-t", `${getTmuxSession()}:${instanceName}`, "-p"];
|
|
870
|
-
const pane = execFileSync("tmux", tmuxArgs, { encoding: "utf-8", timeout: 2000, stdio: ["pipe", "pipe", "pipe"] });
|
|
871
|
-
const m = pane.match(/(\d+)%.*[!❯>]/m) || pane.match(/◔\s*(\d+)%/) || pane.match(/\[(\d+)%\]/);
|
|
872
|
-
if (m)
|
|
873
|
-
context = parseInt(m[1], 10);
|
|
874
|
-
}
|
|
875
|
-
catch { /* ignore */ }
|
|
876
|
-
}
|
|
877
|
-
if (context != null) {
|
|
878
|
-
await data.respond(`📊 Context: ${context}% used\nBackend: ${backend}\nInstance: ${instanceName}`);
|
|
879
|
-
}
|
|
880
|
-
else {
|
|
881
|
-
await data.respond(`Context info not available yet.\nBackend: ${backend}\nInstance: ${instanceName}`);
|
|
882
|
-
}
|
|
893
|
+
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
894
|
+
await data.respond(await this.topicCommands.getCtxText(target.name));
|
|
883
895
|
}
|
|
884
896
|
else if (data.command === "collab") {
|
|
885
897
|
const collabTarget = this.routing.resolve(data.channelId);
|
|
@@ -1040,15 +1052,18 @@ export class FleetManager {
|
|
|
1040
1052
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
1041
1053
|
await this.startInstance(instanceName, config, topicMode);
|
|
1042
1054
|
}
|
|
1043
|
-
adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted
|
|
1055
|
+
adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
|
|
1044
1056
|
}
|
|
1045
1057
|
else {
|
|
1046
|
-
adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}
|
|
1058
|
+
adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
|
|
1047
1059
|
}
|
|
1048
1060
|
return;
|
|
1049
1061
|
}
|
|
1050
1062
|
if (data.callbackData.startsWith("cancel:")) {
|
|
1051
|
-
|
|
1063
|
+
const instanceName = data.callbackData.slice("cancel:".length);
|
|
1064
|
+
// Idempotent: only the first click (while the button is live) acts.
|
|
1065
|
+
if (this.hasCancelButton(instanceName))
|
|
1066
|
+
this.cancelInstance(instanceName);
|
|
1052
1067
|
return;
|
|
1053
1068
|
}
|
|
1054
1069
|
}, this.logger, `adapter[${adapterId}].callback_query`));
|
|
@@ -1091,7 +1106,11 @@ export class FleetManager {
|
|
|
1091
1106
|
timestamp: new Date(),
|
|
1092
1107
|
});
|
|
1093
1108
|
}
|
|
1094
|
-
else if (data.command === "save"
|
|
1109
|
+
else if (data.command === "save") {
|
|
1110
|
+
await this.handleSlashSave(data);
|
|
1111
|
+
}
|
|
1112
|
+
else if (data.command === "load") {
|
|
1113
|
+
// load is kiro-cli/classic only — no claude-code equivalent.
|
|
1095
1114
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
1096
1115
|
await data.respond("⛔ This command requires admin access.");
|
|
1097
1116
|
return;
|
|
@@ -1101,25 +1120,13 @@ export class FleetManager {
|
|
|
1101
1120
|
await data.respond("No active agent in this channel. Use `/start` first.");
|
|
1102
1121
|
return;
|
|
1103
1122
|
}
|
|
1104
|
-
|
|
1105
|
-
if (
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
1109
|
-
return;
|
|
1110
|
-
}
|
|
1111
|
-
rawCmd = data.options?.force ? `/chat save ${filename} -f` : `/chat save ${filename}`;
|
|
1112
|
-
}
|
|
1113
|
-
else {
|
|
1114
|
-
const filename = data.options?.filename;
|
|
1115
|
-
if (!/^[\w.-]+$/.test(filename)) {
|
|
1116
|
-
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
1117
|
-
return;
|
|
1118
|
-
}
|
|
1119
|
-
rawCmd = `/chat load ${filename}`;
|
|
1123
|
+
const filename = data.options?.filename;
|
|
1124
|
+
if (!SAVE_FILENAME_RE.test(filename ?? "")) {
|
|
1125
|
+
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
1126
|
+
return;
|
|
1120
1127
|
}
|
|
1121
|
-
this.pasteRawToClassicInstance(target.name,
|
|
1122
|
-
await data.respond(`✅ Sent
|
|
1128
|
+
this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
|
|
1129
|
+
await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
|
|
1123
1130
|
}
|
|
1124
1131
|
else if (data.command === "compact") {
|
|
1125
1132
|
const target = this.routing.resolve(data.channelId);
|
|
@@ -1145,25 +1152,8 @@ export class FleetManager {
|
|
|
1145
1152
|
await data.respond("No active agent in this channel.");
|
|
1146
1153
|
return;
|
|
1147
1154
|
}
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
? (this.classicChannels?.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend) ?? "claude-code")
|
|
1151
|
-
: (this.fleetConfig?.instances[instanceName]?.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
|
|
1152
|
-
let context = null;
|
|
1153
|
-
try {
|
|
1154
|
-
const statusFile = join(this.getInstanceDir(instanceName), "statusline.json");
|
|
1155
|
-
if (existsSync(statusFile)) {
|
|
1156
|
-
const d = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
1157
|
-
context = d.context_window?.used_percentage ?? null;
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
catch { /* ignore */ }
|
|
1161
|
-
if (context != null) {
|
|
1162
|
-
await data.respond(`📊 Context: ${context}% used\nBackend: ${ctxBackend}\nInstance: ${instanceName}`);
|
|
1163
|
-
}
|
|
1164
|
-
else {
|
|
1165
|
-
await data.respond(`Context info not available yet.\nBackend: ${ctxBackend}\nInstance: ${instanceName}`);
|
|
1166
|
-
}
|
|
1155
|
+
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
1156
|
+
await data.respond(await this.topicCommands.getCtxText(target.name));
|
|
1167
1157
|
}
|
|
1168
1158
|
else if (data.command === "collab") {
|
|
1169
1159
|
const collabTarget2 = this.routing.resolve(data.channelId);
|
|
@@ -1531,6 +1521,15 @@ export class FleetManager {
|
|
|
1531
1521
|
const isBotMentioned = !!(botUser && text.toLowerCase().includes(`@${botUser.toLowerCase()}`));
|
|
1532
1522
|
const isPrivateChat = !chatId.startsWith("-"); // Telegram: positive = private, negative = group
|
|
1533
1523
|
const msgAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
1524
|
+
// In a TG Classic group, ignore bare slash commands (no @bot specified).
|
|
1525
|
+
// Prevents multiple bots all responding to the same /ctx, /compact, etc.
|
|
1526
|
+
// `/cmd@otherbot` already returned above; `/cmd@mybot` set cmdMatch, so it
|
|
1527
|
+
// still processes. Private chat (only one bot) always processes.
|
|
1528
|
+
// NOTE: this also silently drops bare `/start` in a group, so group
|
|
1529
|
+
// onboarding now requires `/start@mybot` — consistent with the policy.
|
|
1530
|
+
if (!isPrivateChat && !cmdMatch && rawText.startsWith("/")) {
|
|
1531
|
+
return; // bare slash in group — ignore silently
|
|
1532
|
+
}
|
|
1534
1533
|
// Handle /start command
|
|
1535
1534
|
if (text === "/start" || text.startsWith("/start ")) {
|
|
1536
1535
|
if (isPrivateChat) {
|
|
@@ -1622,6 +1621,36 @@ export class FleetManager {
|
|
|
1622
1621
|
await msgAdapter?.sendText(chatId, reply);
|
|
1623
1622
|
return;
|
|
1624
1623
|
}
|
|
1624
|
+
// Handle /save command (admin only)
|
|
1625
|
+
if (text === "/save" || text.startsWith("/save ") || text.startsWith("/save@")) {
|
|
1626
|
+
if (!this.classicChannels.isAdmin(msg.userId)) {
|
|
1627
|
+
await msgAdapter?.sendText(chatId, "⛔ /save requires admin access.");
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
const saveTarget = this.routing.resolve(chatId);
|
|
1631
|
+
if (!saveTarget || saveTarget.kind !== "classic") {
|
|
1632
|
+
await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
const filename = parseSaveFilename(text);
|
|
1636
|
+
if (!filename) {
|
|
1637
|
+
await msgAdapter?.sendText(chatId, "Usage: /save <filename>");
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
if (!SAVE_FILENAME_RE.test(filename)) {
|
|
1641
|
+
await msgAdapter?.sendText(chatId, "⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
const backend = this.classicChannels.getBackendByInstance(saveTarget.name, this.fleetConfig?.defaults?.backend);
|
|
1645
|
+
const cmd = saveCommandForBackend(backend, filename);
|
|
1646
|
+
if (!cmd) {
|
|
1647
|
+
await msgAdapter?.sendText(chatId, SAVE_UNSUPPORTED_MSG);
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
this.pasteRawToClassicInstance(saveTarget.name, cmd);
|
|
1651
|
+
await msgAdapter?.sendText(chatId, `✅ Sent \`${cmd}\` to ${saveTarget.name}`);
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1625
1654
|
// Route to classic channel if registered
|
|
1626
1655
|
const target = this.routing.resolve(chatId);
|
|
1627
1656
|
if (target?.kind === "classic") {
|
|
@@ -1737,7 +1766,7 @@ export class FleetManager {
|
|
|
1737
1766
|
const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
1738
1767
|
// React immediately — before any other Discord API calls
|
|
1739
1768
|
if (msg.chatId && msg.messageId) {
|
|
1740
|
-
inboundAdapter.react(
|
|
1769
|
+
inboundAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
|
|
1741
1770
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
1742
1771
|
}
|
|
1743
1772
|
// These may hit Discord API (topic icon, archive) — do after react
|
|
@@ -1900,7 +1929,7 @@ export class FleetManager {
|
|
|
1900
1929
|
if (!chatId)
|
|
1901
1930
|
return;
|
|
1902
1931
|
if (editMessageId) {
|
|
1903
|
-
statusAdapter.editMessage(chatId, editMessageId, text).catch(e => this.logger.debug({ err: e }, "Failed to edit tool status message"));
|
|
1932
|
+
statusAdapter.editMessage(chatId, editMessageId, text, threadId).catch(e => this.logger.debug({ err: e }, "Failed to edit tool status message"));
|
|
1904
1933
|
}
|
|
1905
1934
|
else {
|
|
1906
1935
|
statusAdapter.sendText(chatId, text, { threadId }).then((sent) => {
|
|
@@ -1938,6 +1967,8 @@ export class FleetManager {
|
|
|
1938
1967
|
payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
|
|
1939
1968
|
meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
|
|
1940
1969
|
});
|
|
1970
|
+
// A scheduled trigger also puts the instance to work — show a cancel button.
|
|
1971
|
+
void this.sendCancelButton(target);
|
|
1941
1972
|
return true;
|
|
1942
1973
|
};
|
|
1943
1974
|
if (deliver()) {
|
|
@@ -2567,9 +2598,57 @@ export class FleetManager {
|
|
|
2567
2598
|
// Sent after delivering a user message to an instance; clicking it (or
|
|
2568
2599
|
// /cancel) sends Escape to the instance's pane to interrupt generation.
|
|
2569
2600
|
/** Send a "🛑 Cancel" button to the instance's topic/channel after delivery. */
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2601
|
+
/**
|
|
2602
|
+
* Handle the DC `/save` slash command for both classic AND fleet-topic targets.
|
|
2603
|
+
* Picks the backend-appropriate command (kiro → /chat save, claude → /export);
|
|
2604
|
+
* unsupported backends get a clear error. Routes via classic paste or fleet IPC.
|
|
2605
|
+
*/
|
|
2606
|
+
async handleSlashSave(data) {
|
|
2607
|
+
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
2608
|
+
await data.respond("⛔ This command requires admin access.");
|
|
2609
|
+
return;
|
|
2610
|
+
}
|
|
2611
|
+
const target = this.routing.resolve(data.channelId);
|
|
2612
|
+
if (!target) {
|
|
2613
|
+
await data.respond("No active agent in this channel. Use `/start` first.");
|
|
2614
|
+
return;
|
|
2615
|
+
}
|
|
2616
|
+
const filename = data.options?.filename ?? "";
|
|
2617
|
+
if (!SAVE_FILENAME_RE.test(filename)) {
|
|
2618
|
+
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
2619
|
+
return;
|
|
2620
|
+
}
|
|
2621
|
+
const backend = target.kind === "classic"
|
|
2622
|
+
? this.classicChannels.getBackendByInstance(target.name, this.fleetConfig?.defaults?.backend)
|
|
2623
|
+
: (this.fleetConfig?.instances[target.name]?.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
|
|
2624
|
+
// force (-f) is only meaningful for kiro/classic /chat save.
|
|
2625
|
+
const force = target.kind === "classic" && !!data.options?.force;
|
|
2626
|
+
const cmd = saveCommandForBackend(backend, filename, force);
|
|
2627
|
+
if (!cmd) {
|
|
2628
|
+
await data.respond(SAVE_UNSUPPORTED_MSG);
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
if (target.kind === "classic") {
|
|
2632
|
+
this.pasteRawToClassicInstance(target.name, cmd);
|
|
2633
|
+
}
|
|
2634
|
+
else {
|
|
2635
|
+
this.instanceIpcClients.get(target.name)?.send({ type: "raw_paste", content: cmd });
|
|
2636
|
+
}
|
|
2637
|
+
await data.respond(`✅ Sent \`${cmd}\` to ${target.name}`);
|
|
2638
|
+
}
|
|
2639
|
+
/** Whether the instance currently has at least one live cancel button. */
|
|
2640
|
+
hasCancelButton(instanceName) {
|
|
2641
|
+
for (const e of this.cancelButtons.values()) {
|
|
2642
|
+
if (e.instanceName === instanceName)
|
|
2643
|
+
return true;
|
|
2644
|
+
}
|
|
2645
|
+
return false;
|
|
2646
|
+
}
|
|
2647
|
+
async sendCancelButton(instanceName, correlationId) {
|
|
2648
|
+
// At most one button shown per instance: retire any existing ones first
|
|
2649
|
+
// (delete + bounded retry). Each is tracked separately, so a failed delete
|
|
2650
|
+
// here doesn't strand it — it keeps retrying on its own timer.
|
|
2651
|
+
this.retireInstanceButtons(instanceName);
|
|
2573
2652
|
const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
2574
2653
|
if (!adapter)
|
|
2575
2654
|
return;
|
|
@@ -2604,53 +2683,140 @@ export class FleetManager {
|
|
|
2604
2683
|
message: "👀 處理中…",
|
|
2605
2684
|
choices: [{ id: `cancel:${instanceName}`, label: "🛑 取消" }],
|
|
2606
2685
|
}, threadId ? { threadId } : undefined);
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2686
|
+
// A concurrent sendCancelButton for the same instance may have posted its
|
|
2687
|
+
// own button while we awaited notifyAlert. Retire any other buttons for
|
|
2688
|
+
// this instance (not the one we just posted) so only the newest shows.
|
|
2689
|
+
for (const other of this.cancelButtons.values()) {
|
|
2690
|
+
if (other.instanceName === instanceName)
|
|
2691
|
+
this.retireButton(other);
|
|
2692
|
+
}
|
|
2693
|
+
const entry = {
|
|
2694
|
+
instanceName,
|
|
2695
|
+
adapterId,
|
|
2696
|
+
chatId: sent.chatId,
|
|
2697
|
+
messageId: sent.messageId,
|
|
2698
|
+
threadId: sent.threadId ?? threadId,
|
|
2699
|
+
correlationId,
|
|
2700
|
+
retryCount: 0,
|
|
2701
|
+
};
|
|
2702
|
+
// Idle-check backstop: every 5min, if the instance is idle, retire the
|
|
2703
|
+
// button. Covers turns that end without hitting a clear trigger (reply /
|
|
2704
|
+
// cancel / correlation). Cleared in discardButton when the entry is removed.
|
|
2705
|
+
entry.idleCheckTimer = setInterval(() => {
|
|
2706
|
+
if (!this.cancelButtons.has(entry.messageId)) {
|
|
2707
|
+
clearInterval(entry.idleCheckTimer);
|
|
2708
|
+
return;
|
|
2709
|
+
}
|
|
2710
|
+
if (this.getInstanceIdle(instanceName)) {
|
|
2711
|
+
this.logger.info({ instanceName, messageId: entry.messageId }, "Cancel button idle backstop retiring");
|
|
2712
|
+
this.retireButton(entry);
|
|
2713
|
+
}
|
|
2714
|
+
}, CANCEL_BTN_IDLE_CHECK_INTERVAL_MS);
|
|
2715
|
+
this.cancelButtons.set(sent.messageId, entry);
|
|
2716
|
+
this.logger.info({ instanceName, messageId: sent.messageId }, "Cancel button sent");
|
|
2611
2717
|
}
|
|
2612
2718
|
catch (e) {
|
|
2613
|
-
this.logger.
|
|
2719
|
+
this.logger.warn({ err: e.message, instanceName }, "Failed to send cancel button");
|
|
2614
2720
|
}
|
|
2615
2721
|
}
|
|
2616
|
-
/** Retire (delete)
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2722
|
+
/** Retire (delete) every cancel button belonging to an instance. */
|
|
2723
|
+
retireInstanceButtons(instanceName) {
|
|
2724
|
+
// Snapshot first — retireButton may delete entries from the map on success.
|
|
2725
|
+
for (const e of [...this.cancelButtons.values()]) {
|
|
2726
|
+
if (e.instanceName === instanceName)
|
|
2727
|
+
this.retireButton(e);
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
/** Begin retiring one button (delete + bounded retry on failure). Idempotent:
|
|
2731
|
+
* a button already in a retire cycle is left to its own timer, so a second
|
|
2732
|
+
* retire request (e.g. a new send + the post-await sweep) won't double-delete. */
|
|
2733
|
+
retireButton(entry) {
|
|
2734
|
+
if (entry.retiring)
|
|
2620
2735
|
return;
|
|
2621
|
-
|
|
2622
|
-
this.
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2736
|
+
entry.retiring = true;
|
|
2737
|
+
this.attemptButtonDelete(entry);
|
|
2738
|
+
}
|
|
2739
|
+
attemptButtonDelete(entry) {
|
|
2740
|
+
this.deleteButtonMessage(entry)
|
|
2741
|
+
.then(() => {
|
|
2742
|
+
this.discardButton(entry);
|
|
2743
|
+
this.logger.info({ instanceName: entry.instanceName, messageId: entry.messageId }, "Cancel button removed");
|
|
2744
|
+
})
|
|
2745
|
+
.catch((err) => this.scheduleButtonRetry(entry, err));
|
|
2746
|
+
}
|
|
2747
|
+
/** Clear an entry's timers (retry + idle-check) and drop it from the map. */
|
|
2748
|
+
discardButton(entry) {
|
|
2749
|
+
if (entry.retryTimer)
|
|
2750
|
+
clearTimeout(entry.retryTimer);
|
|
2751
|
+
if (entry.idleCheckTimer)
|
|
2752
|
+
clearInterval(entry.idleCheckTimer);
|
|
2753
|
+
this.cancelButtons.delete(entry.messageId);
|
|
2754
|
+
}
|
|
2755
|
+
/** Re-attempt a failed button delete up to CANCEL_BTN_MAX_RETRIES times. */
|
|
2756
|
+
scheduleButtonRetry(entry, err) {
|
|
2757
|
+
if (entry.retryCount >= CANCEL_BTN_MAX_RETRIES) {
|
|
2758
|
+
this.discardButton(entry);
|
|
2759
|
+
this.logger.warn({ instanceName: entry.instanceName, messageId: entry.messageId, err: err.message }, `Cancel button delete gave up after ${CANCEL_BTN_MAX_RETRIES} retries`);
|
|
2626
2760
|
return;
|
|
2627
|
-
if (adapter.deleteMessage) {
|
|
2628
|
-
adapter.deleteMessage(pending.chatId, pending.messageId)
|
|
2629
|
-
.catch((e) => this.logger.debug({ err: e.message, instanceName }, "Failed to delete cancel button"));
|
|
2630
2761
|
}
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2762
|
+
entry.retryCount++;
|
|
2763
|
+
this.logger.warn({ instanceName: entry.instanceName, messageId: entry.messageId, attempt: entry.retryCount, err: err.message }, "Cancel button delete failed, will retry");
|
|
2764
|
+
if (entry.retryTimer)
|
|
2765
|
+
clearTimeout(entry.retryTimer);
|
|
2766
|
+
// Continue the same retire cycle (bypass the retiring-guard in retireButton).
|
|
2767
|
+
entry.retryTimer = setTimeout(() => this.attemptButtonDelete(entry), CANCEL_BTN_RETRY_INTERVAL_MS);
|
|
2768
|
+
}
|
|
2769
|
+
/** Delete one button's message via its own adapter. Resolves on success,
|
|
2770
|
+
* rejects on failure so the caller can retry. */
|
|
2771
|
+
deleteButtonMessage(e) {
|
|
2772
|
+
const adapter = (e.adapterId ? this.worlds.get(e.adapterId)?.adapter : undefined) ?? this.adapter;
|
|
2773
|
+
if (!adapter)
|
|
2774
|
+
return Promise.reject(new Error("no adapter for cancel button"));
|
|
2775
|
+
if (adapter.deleteMessage)
|
|
2776
|
+
return adapter.deleteMessage(e.chatId, e.messageId, e.threadId);
|
|
2777
|
+
if (adapter.editMessageRemoveButtons)
|
|
2778
|
+
return adapter.editMessageRemoveButtons(e.chatId, e.messageId, "✅", e.threadId);
|
|
2779
|
+
return adapter.editMessage(e.chatId, e.messageId, "✅", e.threadId);
|
|
2780
|
+
}
|
|
2781
|
+
/** Retire all cancel buttons for an instance — on reply or cancel. */
|
|
2782
|
+
clearCancelButton(instanceName) {
|
|
2783
|
+
this.retireInstanceButtons(instanceName);
|
|
2784
|
+
}
|
|
2785
|
+
/** Retire the cross-instance button matching a delegate→report correlation id.
|
|
2786
|
+
* Used by report_result, where the sender's self-derived name may not match
|
|
2787
|
+
* the target-address name the button was registered under. */
|
|
2788
|
+
clearCancelButtonByCorrelation(correlationId) {
|
|
2789
|
+
if (!correlationId)
|
|
2790
|
+
return;
|
|
2791
|
+
for (const e of [...this.cancelButtons.values()]) {
|
|
2792
|
+
if (e.correlationId === correlationId)
|
|
2793
|
+
this.retireButton(e);
|
|
2635
2794
|
}
|
|
2636
2795
|
}
|
|
2796
|
+
/**
|
|
2797
|
+
* Reaction target chat id. Telegram reactions key on the supergroup chat_id
|
|
2798
|
+
* (the topic thread is NOT a chat_id), so a forum-topic message must react on
|
|
2799
|
+
* msg.chatId — reacting on threadId silently fails. Discord reactions key on
|
|
2800
|
+
* the channel/thread id.
|
|
2801
|
+
*/
|
|
2802
|
+
reactTarget(msg) {
|
|
2803
|
+
return msg.source === "telegram" ? msg.chatId : (msg.threadId ?? msg.chatId);
|
|
2804
|
+
}
|
|
2637
2805
|
/** Remember the user message just delivered, so we can react ✅ when done. */
|
|
2638
2806
|
trackInboundMsg(instanceName, msg) {
|
|
2639
2807
|
if (!msg.chatId || !msg.messageId)
|
|
2640
2808
|
return;
|
|
2641
2809
|
this.lastInboundMsg.set(instanceName, {
|
|
2642
|
-
adapterId: msg.adapterId, chatId: msg.chatId, threadId: msg.threadId ?? undefined, messageId: msg.messageId,
|
|
2810
|
+
adapterId: msg.adapterId, chatId: msg.chatId, threadId: msg.threadId ?? undefined, messageId: msg.messageId, source: msg.source,
|
|
2643
2811
|
});
|
|
2644
2812
|
}
|
|
2645
|
-
/**
|
|
2813
|
+
/** Clear the tracked last-inbound message after the agent replies. The ✅
|
|
2814
|
+
* reaction is already applied by delivery confirmation (message_confirmed), so
|
|
2815
|
+
* reacting again here would be a duplicate API call — we only drop the entry. */
|
|
2646
2816
|
reactDone(instanceName) {
|
|
2647
|
-
|
|
2648
|
-
if (!m)
|
|
2817
|
+
if (!this.lastInboundMsg.has(instanceName))
|
|
2649
2818
|
return;
|
|
2650
2819
|
this.lastInboundMsg.delete(instanceName);
|
|
2651
|
-
const adapter = (m.adapterId ? this.worlds.get(m.adapterId)?.adapter : undefined)
|
|
2652
|
-
?? this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
2653
|
-
adapter?.react(m.threadId ?? m.chatId, m.messageId, "✅").catch(() => { });
|
|
2654
2820
|
}
|
|
2655
2821
|
/** Interrupt an instance's current generation (cancel button / /cancel). */
|
|
2656
2822
|
cancelInstance(instanceName) {
|
|
@@ -3000,10 +3166,17 @@ When users create specialized instances, suggest these configurations:
|
|
|
3000
3166
|
// Skip empty bot messages (e.g., reactions) — don't pollute chat log
|
|
3001
3167
|
if (msg.isBotMessage && !text && !msg.attachments?.length)
|
|
3002
3168
|
return;
|
|
3169
|
+
// Save attachments FIRST so the chat-log records their inbox paths
|
|
3170
|
+
// (consistent with the /chat path). Otherwise a non-@mention image is
|
|
3171
|
+
// saved to inbox but its path never reaches the agent — the log keeps
|
|
3172
|
+
// only a pathless filename, so later context can't locate the file.
|
|
3173
|
+
const saved = msg.attachments?.length ? await this.saveClassicAttachment(instanceName, msg) : undefined;
|
|
3003
3174
|
// Log every message (including other bots) to chat-logs
|
|
3004
|
-
const collabAttachTag =
|
|
3005
|
-
? ` [${
|
|
3006
|
-
:
|
|
3175
|
+
const collabAttachTag = saved
|
|
3176
|
+
? ` [${saved.kind === "photo" ? "📷" : "📎"} saved: ${saved.paths.join(", ")}]`
|
|
3177
|
+
: (msg.attachments?.length
|
|
3178
|
+
? ` [${msg.attachments.map(a => `${a.kind === "photo" ? "📷" : "📎"} ${a.filename || a.kind}`).join(", ")}]`
|
|
3179
|
+
: "");
|
|
3007
3180
|
ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText);
|
|
3008
3181
|
this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message");
|
|
3009
3182
|
// Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here
|
|
@@ -3011,19 +3184,16 @@ When users create specialized instances, suggest these configurations:
|
|
|
3011
3184
|
const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
|
|
3012
3185
|
const isMentioned = mentionTag && text.includes(mentionTag);
|
|
3013
3186
|
if (!isMentioned) {
|
|
3014
|
-
//
|
|
3015
|
-
if (
|
|
3016
|
-
const
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
const
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
reactAdapter.react(noMentionReactChatId, msg.messageId, emoji)
|
|
3025
|
-
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
3026
|
-
}
|
|
3187
|
+
// Bare attachment (no @mention) — already saved above; just acknowledge.
|
|
3188
|
+
if (saved) {
|
|
3189
|
+
const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
3190
|
+
const noMentionReactChatId = msg.threadId ?? msg.chatId;
|
|
3191
|
+
if (reactAdapter && noMentionReactChatId && msg.messageId) {
|
|
3192
|
+
const emoji = msg.source === "telegram"
|
|
3193
|
+
? (saved.kind === "photo" ? "👌" : "👍")
|
|
3194
|
+
: (saved.kind === "photo" ? "📸" : "📎");
|
|
3195
|
+
reactAdapter.react(noMentionReactChatId, msg.messageId, emoji)
|
|
3196
|
+
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
3027
3197
|
}
|
|
3028
3198
|
}
|
|
3029
3199
|
return;
|
|
@@ -3041,8 +3211,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
3041
3211
|
// Block /raw bypass
|
|
3042
3212
|
if (cleanText.startsWith("/raw "))
|
|
3043
3213
|
return;
|
|
3044
|
-
//
|
|
3045
|
-
const saved = await this.saveClassicAttachment(instanceName, msg);
|
|
3214
|
+
// Attachments already saved at the top of the collab block.
|
|
3046
3215
|
if (saved && classicAdapter && collabReactChatId && msg.messageId) {
|
|
3047
3216
|
const emoji = msg.source === "telegram"
|
|
3048
3217
|
? (saved.kind === "photo" ? "👌" : "👍")
|
|
@@ -3199,21 +3368,32 @@ When users create specialized instances, suggest these configurations:
|
|
|
3199
3368
|
this.logger.warn({ instanceName }, "Classic channel instance IPC not connected");
|
|
3200
3369
|
return;
|
|
3201
3370
|
}
|
|
3371
|
+
const meta = {
|
|
3372
|
+
chat_id: msg.chatId,
|
|
3373
|
+
message_id: msg.messageId,
|
|
3374
|
+
user: msg.username,
|
|
3375
|
+
user_id: msg.userId,
|
|
3376
|
+
ts: msg.timestamp.toISOString(),
|
|
3377
|
+
thread_id: msg.threadId ?? "",
|
|
3378
|
+
source: msg.source,
|
|
3379
|
+
...extraMeta,
|
|
3380
|
+
...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
|
|
3381
|
+
};
|
|
3382
|
+
// If the triggering message carried no image of its own, surface the most
|
|
3383
|
+
// recent image saved earlier in this channel (logged as "[📷 saved: <path>]"
|
|
3384
|
+
// by an untriggered collab message) as image_path, so the agent's
|
|
3385
|
+
// read-the-image trigger fires instead of the path sitting inert in context.
|
|
3386
|
+
if (!meta.image_path && logContext) {
|
|
3387
|
+
const saves = [...logContext.matchAll(/\[📷 saved: ([^\]]+)\]/g)];
|
|
3388
|
+
if (saves.length > 0) {
|
|
3389
|
+
meta.image_path = saves[saves.length - 1][1].split(",")[0].trim();
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3202
3392
|
ipc.send({
|
|
3203
3393
|
type: "fleet_inbound",
|
|
3204
3394
|
content: fullText,
|
|
3205
3395
|
targetSession: instanceName,
|
|
3206
|
-
meta
|
|
3207
|
-
chat_id: msg.chatId,
|
|
3208
|
-
message_id: msg.messageId,
|
|
3209
|
-
user: msg.username,
|
|
3210
|
-
user_id: msg.userId,
|
|
3211
|
-
ts: msg.timestamp.toISOString(),
|
|
3212
|
-
thread_id: msg.threadId ?? "",
|
|
3213
|
-
source: msg.source,
|
|
3214
|
-
...extraMeta,
|
|
3215
|
-
...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
|
|
3216
|
-
},
|
|
3396
|
+
meta,
|
|
3217
3397
|
});
|
|
3218
3398
|
this.lastInboundUser.set(instanceName, msg.username);
|
|
3219
3399
|
this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
|
|
@@ -3695,14 +3875,21 @@ When users create specialized instances, suggest these configurations:
|
|
|
3695
3875
|
const latest = execSync("npm view @songsid/agend version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
|
|
3696
3876
|
let target = latest;
|
|
3697
3877
|
if (currentVersion.includes("-beta")) {
|
|
3878
|
+
// Beta users track the @beta channel (never fall back to @latest, which is
|
|
3879
|
+
// older), but should also hear when a newer STABLE ships — pick whichever
|
|
3880
|
+
// of beta/latest is the newest.
|
|
3881
|
+
let beta = "";
|
|
3698
3882
|
try {
|
|
3699
|
-
|
|
3700
|
-
if (beta && beta !== currentVersion)
|
|
3701
|
-
target = beta;
|
|
3883
|
+
beta = execSync("npm view @songsid/agend@beta version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
|
|
3702
3884
|
}
|
|
3703
3885
|
catch { /* no beta tag */ }
|
|
3886
|
+
target = beta || latest;
|
|
3887
|
+
if (latest && this.semverGt(latest, target))
|
|
3888
|
+
target = latest;
|
|
3704
3889
|
}
|
|
3705
|
-
|
|
3890
|
+
// Only notify when target is genuinely newer (semver), so a beta user on
|
|
3891
|
+
// 2.0.8-beta.16 is never told that stable 2.0.7 is "available".
|
|
3892
|
+
if (target && this.semverGt(target, currentVersion)) {
|
|
3706
3893
|
const generalId = this.findGeneralInstance();
|
|
3707
3894
|
if (generalId) {
|
|
3708
3895
|
this.notifyInstanceTopic(generalId, `🆕 AgEnD v${target} available (current: v${currentVersion}). Use \`/update\` to upgrade.`);
|
|
@@ -3711,6 +3898,49 @@ When users create specialized instances, suggest these configurations:
|
|
|
3711
3898
|
}
|
|
3712
3899
|
catch { /* silent — network issues */ }
|
|
3713
3900
|
}
|
|
3901
|
+
/**
|
|
3902
|
+
* Semver "a > b". Compares major.minor.patch numerically; a version without a
|
|
3903
|
+
* prerelease outranks the same core with one (2.0.8 > 2.0.8-beta.16); two
|
|
3904
|
+
* prereleases compare identifier-by-identifier (numeric < alphanumeric, numeric
|
|
3905
|
+
* fields compared as numbers). Sufficient for our X.Y.Z[-beta.N] scheme.
|
|
3906
|
+
*/
|
|
3907
|
+
semverGt(a, b) {
|
|
3908
|
+
const parse = (v) => {
|
|
3909
|
+
const [core, pre] = v.replace(/^v/, "").split("-");
|
|
3910
|
+
const nums = core.split(".").map(n => parseInt(n, 10) || 0);
|
|
3911
|
+
return { nums: [nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0], pre: pre ? pre.split(".") : [] };
|
|
3912
|
+
};
|
|
3913
|
+
const pa = parse(a), pb = parse(b);
|
|
3914
|
+
for (let i = 0; i < 3; i++) {
|
|
3915
|
+
if (pa.nums[i] !== pb.nums[i])
|
|
3916
|
+
return pa.nums[i] > pb.nums[i];
|
|
3917
|
+
}
|
|
3918
|
+
if (pa.pre.length === 0 && pb.pre.length === 0)
|
|
3919
|
+
return false;
|
|
3920
|
+
if (pa.pre.length === 0)
|
|
3921
|
+
return true; // a stable, b prerelease → a > b
|
|
3922
|
+
if (pb.pre.length === 0)
|
|
3923
|
+
return false; // a prerelease, b stable → a < b
|
|
3924
|
+
const len = Math.max(pa.pre.length, pb.pre.length);
|
|
3925
|
+
for (let i = 0; i < len; i++) {
|
|
3926
|
+
const x = pa.pre[i], y = pb.pre[i];
|
|
3927
|
+
if (x === undefined)
|
|
3928
|
+
return false; // a has fewer identifiers → a < b
|
|
3929
|
+
if (y === undefined)
|
|
3930
|
+
return true; // a has more identifiers → a > b
|
|
3931
|
+
const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y);
|
|
3932
|
+
if (xn && yn) {
|
|
3933
|
+
const dx = parseInt(x, 10), dy = parseInt(y, 10);
|
|
3934
|
+
if (dx !== dy)
|
|
3935
|
+
return dx > dy;
|
|
3936
|
+
}
|
|
3937
|
+
else if (xn !== yn)
|
|
3938
|
+
return yn; // numeric has lower precedence than alphanumeric
|
|
3939
|
+
else if (x !== y)
|
|
3940
|
+
return x > y; // both alphanumeric
|
|
3941
|
+
}
|
|
3942
|
+
return false; // identical
|
|
3943
|
+
}
|
|
3714
3944
|
// ── Health HTTP endpoint ─────────────────────────────────────────────
|
|
3715
3945
|
startHealthServer(port) {
|
|
3716
3946
|
this.startedAt = Date.now();
|
|
@@ -3887,6 +4117,29 @@ When users create specialized instances, suggest these configurations:
|
|
|
3887
4117
|
})();
|
|
3888
4118
|
return;
|
|
3889
4119
|
}
|
|
4120
|
+
if (req.method === "POST" && req.url?.startsWith("/stop/")) {
|
|
4121
|
+
const name = decodeURIComponent(req.url.slice("/stop/".length));
|
|
4122
|
+
this.logger.info({ name }, "Instance stop requested via HTTP");
|
|
4123
|
+
(async () => {
|
|
4124
|
+
try {
|
|
4125
|
+
// Runs inside the live fleet process: lifecycle.stop finds the
|
|
4126
|
+
// in-memory daemon and stops just this instance. (Doing this from a
|
|
4127
|
+
// detached CLI FleetManager would read the shared daemon.pid — the
|
|
4128
|
+
// fleet's own pid — and kill the whole fleet.)
|
|
4129
|
+
await this.stopInstance(name);
|
|
4130
|
+
this.logger.info({ name }, "Instance stopped");
|
|
4131
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
4132
|
+
res.writeHead(200);
|
|
4133
|
+
res.end(JSON.stringify({ stopped: name }));
|
|
4134
|
+
}
|
|
4135
|
+
catch (err) {
|
|
4136
|
+
this.logger.error({ err, name }, "Instance stop failed");
|
|
4137
|
+
res.writeHead(500);
|
|
4138
|
+
res.end(JSON.stringify({ error: `Stop failed: ${err.message}` }));
|
|
4139
|
+
}
|
|
4140
|
+
})();
|
|
4141
|
+
return;
|
|
4142
|
+
}
|
|
3890
4143
|
// ── Agent CLI endpoint ─────
|
|
3891
4144
|
if (req.url === "/agent" && req.method === "POST") {
|
|
3892
4145
|
handleAgentRequest(req, res, this);
|