@songsid/agend 2.0.8-beta.9 → 2.0.9-beta.1
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 +47 -5
- package/dist/fleet-manager.js +362 -139
- 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,10 +787,10 @@ 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
|
}
|
|
@@ -749,7 +799,7 @@ export class FleetManager {
|
|
|
749
799
|
// Idempotent: a button click only acts while the button is live. A
|
|
750
800
|
// second click (entry already cleared) is a no-op — don't re-send the
|
|
751
801
|
// interrupt key. (The /cancel command path calls cancelInstance directly.)
|
|
752
|
-
if (this.
|
|
802
|
+
if (this.hasCancelButton(instanceName))
|
|
753
803
|
this.cancelInstance(instanceName);
|
|
754
804
|
return;
|
|
755
805
|
}
|
|
@@ -794,7 +844,11 @@ export class FleetManager {
|
|
|
794
844
|
timestamp: new Date(),
|
|
795
845
|
});
|
|
796
846
|
}
|
|
797
|
-
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.
|
|
798
852
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
799
853
|
await data.respond("⛔ This command requires admin access.");
|
|
800
854
|
return;
|
|
@@ -804,25 +858,13 @@ export class FleetManager {
|
|
|
804
858
|
await data.respond("No active agent in this channel. Use `/start` first.");
|
|
805
859
|
return;
|
|
806
860
|
}
|
|
807
|
-
|
|
808
|
-
if (
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
812
|
-
return;
|
|
813
|
-
}
|
|
814
|
-
rawCmd = data.options?.force ? `/chat save ${filename} -f` : `/chat save ${filename}`;
|
|
815
|
-
}
|
|
816
|
-
else {
|
|
817
|
-
const filename = data.options?.filename;
|
|
818
|
-
if (!/^[\w.-]+$/.test(filename)) {
|
|
819
|
-
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
820
|
-
return;
|
|
821
|
-
}
|
|
822
|
-
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;
|
|
823
865
|
}
|
|
824
|
-
this.pasteRawToClassicInstance(target.name,
|
|
825
|
-
await data.respond(`✅ Sent
|
|
866
|
+
this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
|
|
867
|
+
await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
|
|
826
868
|
}
|
|
827
869
|
else if (data.command === "compact") {
|
|
828
870
|
const target = this.routing.resolve(data.channelId);
|
|
@@ -848,42 +890,8 @@ export class FleetManager {
|
|
|
848
890
|
await data.respond("No active agent in this channel.");
|
|
849
891
|
return;
|
|
850
892
|
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
? (this.classicChannels?.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend) ?? "claude-code")
|
|
854
|
-
: (this.fleetConfig?.instances[instanceName]?.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
|
|
855
|
-
let context = null;
|
|
856
|
-
// Try statusline.json first
|
|
857
|
-
try {
|
|
858
|
-
const statusFile = join(this.getInstanceDir(instanceName), "statusline.json");
|
|
859
|
-
if (existsSync(statusFile)) {
|
|
860
|
-
const d = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
861
|
-
context = d.context_window?.used_percentage ?? null;
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
catch { /* ignore */ }
|
|
865
|
-
// Fallback: capture tmux pane
|
|
866
|
-
if (context == null) {
|
|
867
|
-
try {
|
|
868
|
-
const { execFileSync } = await import("node:child_process");
|
|
869
|
-
const { getTmuxSocketName } = await import("./paths.js");
|
|
870
|
-
const socketName = getTmuxSocketName();
|
|
871
|
-
const tmuxArgs = socketName
|
|
872
|
-
? ["-L", socketName, "capture-pane", "-t", `${getTmuxSession()}:${instanceName}`, "-p"]
|
|
873
|
-
: ["capture-pane", "-t", `${getTmuxSession()}:${instanceName}`, "-p"];
|
|
874
|
-
const pane = execFileSync("tmux", tmuxArgs, { encoding: "utf-8", timeout: 2000, stdio: ["pipe", "pipe", "pipe"] });
|
|
875
|
-
const m = pane.match(/(\d+)%.*[!❯>]/m) || pane.match(/◔\s*(\d+)%/) || pane.match(/\[(\d+)%\]/);
|
|
876
|
-
if (m)
|
|
877
|
-
context = parseInt(m[1], 10);
|
|
878
|
-
}
|
|
879
|
-
catch { /* ignore */ }
|
|
880
|
-
}
|
|
881
|
-
if (context != null) {
|
|
882
|
-
await data.respond(`📊 Context: ${context}% used\nBackend: ${backend}\nInstance: ${instanceName}`);
|
|
883
|
-
}
|
|
884
|
-
else {
|
|
885
|
-
await data.respond(`Context info not available yet.\nBackend: ${backend}\nInstance: ${instanceName}`);
|
|
886
|
-
}
|
|
893
|
+
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
894
|
+
await data.respond(await this.topicCommands.getCtxText(target.name));
|
|
887
895
|
}
|
|
888
896
|
else if (data.command === "collab") {
|
|
889
897
|
const collabTarget = this.routing.resolve(data.channelId);
|
|
@@ -1044,17 +1052,17 @@ export class FleetManager {
|
|
|
1044
1052
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
1045
1053
|
await this.startInstance(instanceName, config, topicMode);
|
|
1046
1054
|
}
|
|
1047
|
-
adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted
|
|
1055
|
+
adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
|
|
1048
1056
|
}
|
|
1049
1057
|
else {
|
|
1050
|
-
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(() => { });
|
|
1051
1059
|
}
|
|
1052
1060
|
return;
|
|
1053
1061
|
}
|
|
1054
1062
|
if (data.callbackData.startsWith("cancel:")) {
|
|
1055
1063
|
const instanceName = data.callbackData.slice("cancel:".length);
|
|
1056
1064
|
// Idempotent: only the first click (while the button is live) acts.
|
|
1057
|
-
if (this.
|
|
1065
|
+
if (this.hasCancelButton(instanceName))
|
|
1058
1066
|
this.cancelInstance(instanceName);
|
|
1059
1067
|
return;
|
|
1060
1068
|
}
|
|
@@ -1098,7 +1106,11 @@ export class FleetManager {
|
|
|
1098
1106
|
timestamp: new Date(),
|
|
1099
1107
|
});
|
|
1100
1108
|
}
|
|
1101
|
-
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.
|
|
1102
1114
|
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
1103
1115
|
await data.respond("⛔ This command requires admin access.");
|
|
1104
1116
|
return;
|
|
@@ -1108,25 +1120,13 @@ export class FleetManager {
|
|
|
1108
1120
|
await data.respond("No active agent in this channel. Use `/start` first.");
|
|
1109
1121
|
return;
|
|
1110
1122
|
}
|
|
1111
|
-
|
|
1112
|
-
if (
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
1116
|
-
return;
|
|
1117
|
-
}
|
|
1118
|
-
rawCmd = data.options?.force ? `/chat save ${filename} -f` : `/chat save ${filename}`;
|
|
1119
|
-
}
|
|
1120
|
-
else {
|
|
1121
|
-
const filename = data.options?.filename;
|
|
1122
|
-
if (!/^[\w.-]+$/.test(filename)) {
|
|
1123
|
-
await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
|
|
1124
|
-
return;
|
|
1125
|
-
}
|
|
1126
|
-
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;
|
|
1127
1127
|
}
|
|
1128
|
-
this.pasteRawToClassicInstance(target.name,
|
|
1129
|
-
await data.respond(`✅ Sent
|
|
1128
|
+
this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
|
|
1129
|
+
await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
|
|
1130
1130
|
}
|
|
1131
1131
|
else if (data.command === "compact") {
|
|
1132
1132
|
const target = this.routing.resolve(data.channelId);
|
|
@@ -1152,25 +1152,8 @@ export class FleetManager {
|
|
|
1152
1152
|
await data.respond("No active agent in this channel.");
|
|
1153
1153
|
return;
|
|
1154
1154
|
}
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
? (this.classicChannels?.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend) ?? "claude-code")
|
|
1158
|
-
: (this.fleetConfig?.instances[instanceName]?.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
|
|
1159
|
-
let context = null;
|
|
1160
|
-
try {
|
|
1161
|
-
const statusFile = join(this.getInstanceDir(instanceName), "statusline.json");
|
|
1162
|
-
if (existsSync(statusFile)) {
|
|
1163
|
-
const d = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
1164
|
-
context = d.context_window?.used_percentage ?? null;
|
|
1165
|
-
}
|
|
1166
|
-
}
|
|
1167
|
-
catch { /* ignore */ }
|
|
1168
|
-
if (context != null) {
|
|
1169
|
-
await data.respond(`📊 Context: ${context}% used\nBackend: ${ctxBackend}\nInstance: ${instanceName}`);
|
|
1170
|
-
}
|
|
1171
|
-
else {
|
|
1172
|
-
await data.respond(`Context info not available yet.\nBackend: ${ctxBackend}\nInstance: ${instanceName}`);
|
|
1173
|
-
}
|
|
1155
|
+
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
1156
|
+
await data.respond(await this.topicCommands.getCtxText(target.name));
|
|
1174
1157
|
}
|
|
1175
1158
|
else if (data.command === "collab") {
|
|
1176
1159
|
const collabTarget2 = this.routing.resolve(data.channelId);
|
|
@@ -1538,6 +1521,15 @@ export class FleetManager {
|
|
|
1538
1521
|
const isBotMentioned = !!(botUser && text.toLowerCase().includes(`@${botUser.toLowerCase()}`));
|
|
1539
1522
|
const isPrivateChat = !chatId.startsWith("-"); // Telegram: positive = private, negative = group
|
|
1540
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
|
+
}
|
|
1541
1533
|
// Handle /start command
|
|
1542
1534
|
if (text === "/start" || text.startsWith("/start ")) {
|
|
1543
1535
|
if (isPrivateChat) {
|
|
@@ -1629,6 +1621,36 @@ export class FleetManager {
|
|
|
1629
1621
|
await msgAdapter?.sendText(chatId, reply);
|
|
1630
1622
|
return;
|
|
1631
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
|
+
}
|
|
1632
1654
|
// Route to classic channel if registered
|
|
1633
1655
|
const target = this.routing.resolve(chatId);
|
|
1634
1656
|
if (target?.kind === "classic") {
|
|
@@ -1907,7 +1929,7 @@ export class FleetManager {
|
|
|
1907
1929
|
if (!chatId)
|
|
1908
1930
|
return;
|
|
1909
1931
|
if (editMessageId) {
|
|
1910
|
-
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"));
|
|
1911
1933
|
}
|
|
1912
1934
|
else {
|
|
1913
1935
|
statusAdapter.sendText(chatId, text, { threadId }).then((sent) => {
|
|
@@ -1945,6 +1967,8 @@ export class FleetManager {
|
|
|
1945
1967
|
payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
|
|
1946
1968
|
meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
|
|
1947
1969
|
});
|
|
1970
|
+
// A scheduled trigger also puts the instance to work — show a cancel button.
|
|
1971
|
+
void this.sendCancelButton(target);
|
|
1948
1972
|
return true;
|
|
1949
1973
|
};
|
|
1950
1974
|
if (deliver()) {
|
|
@@ -2574,9 +2598,57 @@ export class FleetManager {
|
|
|
2574
2598
|
// Sent after delivering a user message to an instance; clicking it (or
|
|
2575
2599
|
// /cancel) sends Escape to the instance's pane to interrupt generation.
|
|
2576
2600
|
/** Send a "🛑 Cancel" button to the instance's topic/channel after delivery. */
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
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);
|
|
2580
2652
|
const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
2581
2653
|
if (!adapter)
|
|
2582
2654
|
return;
|
|
@@ -2611,34 +2683,114 @@ export class FleetManager {
|
|
|
2611
2683
|
message: "👀 處理中…",
|
|
2612
2684
|
choices: [{ id: `cancel:${instanceName}`, label: "🛑 取消" }],
|
|
2613
2685
|
}, threadId ? { threadId } : undefined);
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
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");
|
|
2618
2717
|
}
|
|
2619
2718
|
catch (e) {
|
|
2620
|
-
this.logger.
|
|
2719
|
+
this.logger.warn({ err: e.message, instanceName }, "Failed to send cancel button");
|
|
2621
2720
|
}
|
|
2622
2721
|
}
|
|
2623
|
-
/** Retire (delete)
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
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)
|
|
2627
2735
|
return;
|
|
2628
|
-
|
|
2629
|
-
this.
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
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`);
|
|
2633
2760
|
return;
|
|
2634
|
-
if (adapter.deleteMessage) {
|
|
2635
|
-
adapter.deleteMessage(pending.chatId, pending.messageId)
|
|
2636
|
-
.catch((e) => this.logger.debug({ err: e.message, instanceName }, "Failed to delete cancel button"));
|
|
2637
2761
|
}
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
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);
|
|
2642
2794
|
}
|
|
2643
2795
|
}
|
|
2644
2796
|
/**
|
|
@@ -2658,15 +2810,13 @@ export class FleetManager {
|
|
|
2658
2810
|
adapterId: msg.adapterId, chatId: msg.chatId, threadId: msg.threadId ?? undefined, messageId: msg.messageId, source: msg.source,
|
|
2659
2811
|
});
|
|
2660
2812
|
}
|
|
2661
|
-
/**
|
|
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. */
|
|
2662
2816
|
reactDone(instanceName) {
|
|
2663
|
-
|
|
2664
|
-
if (!m)
|
|
2817
|
+
if (!this.lastInboundMsg.has(instanceName))
|
|
2665
2818
|
return;
|
|
2666
2819
|
this.lastInboundMsg.delete(instanceName);
|
|
2667
|
-
const adapter = (m.adapterId ? this.worlds.get(m.adapterId)?.adapter : undefined)
|
|
2668
|
-
?? this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
2669
|
-
adapter?.react(this.reactTarget(m), m.messageId, "✅").catch(() => { });
|
|
2670
2820
|
}
|
|
2671
2821
|
/** Interrupt an instance's current generation (cancel button / /cancel). */
|
|
2672
2822
|
cancelInstance(instanceName) {
|
|
@@ -3725,14 +3875,21 @@ When users create specialized instances, suggest these configurations:
|
|
|
3725
3875
|
const latest = execSync("npm view @songsid/agend version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
|
|
3726
3876
|
let target = latest;
|
|
3727
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 = "";
|
|
3728
3882
|
try {
|
|
3729
|
-
|
|
3730
|
-
if (beta && beta !== currentVersion)
|
|
3731
|
-
target = beta;
|
|
3883
|
+
beta = execSync("npm view @songsid/agend@beta version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
|
|
3732
3884
|
}
|
|
3733
3885
|
catch { /* no beta tag */ }
|
|
3886
|
+
target = beta || latest;
|
|
3887
|
+
if (latest && this.semverGt(latest, target))
|
|
3888
|
+
target = latest;
|
|
3734
3889
|
}
|
|
3735
|
-
|
|
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)) {
|
|
3736
3893
|
const generalId = this.findGeneralInstance();
|
|
3737
3894
|
if (generalId) {
|
|
3738
3895
|
this.notifyInstanceTopic(generalId, `🆕 AgEnD v${target} available (current: v${currentVersion}). Use \`/update\` to upgrade.`);
|
|
@@ -3741,6 +3898,49 @@ When users create specialized instances, suggest these configurations:
|
|
|
3741
3898
|
}
|
|
3742
3899
|
catch { /* silent — network issues */ }
|
|
3743
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
|
+
}
|
|
3744
3944
|
// ── Health HTTP endpoint ─────────────────────────────────────────────
|
|
3745
3945
|
startHealthServer(port) {
|
|
3746
3946
|
this.startedAt = Date.now();
|
|
@@ -3917,6 +4117,29 @@ When users create specialized instances, suggest these configurations:
|
|
|
3917
4117
|
})();
|
|
3918
4118
|
return;
|
|
3919
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
|
+
}
|
|
3920
4143
|
// ── Agent CLI endpoint ─────
|
|
3921
4144
|
if (req.url === "/agent" && req.method === "POST") {
|
|
3922
4145
|
handleAgentRequest(req, res, this);
|