@songsid/agend 2.1.4-beta.2 → 2.1.4-beta.4
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/dist/backend/claude-code.js +23 -2
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/channel/adapters/discord.js +12 -6
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/types.d.ts +1 -1
- package/dist/cli.js +65 -13
- package/dist/cli.js.map +1 -1
- package/dist/completion-install.d.ts +70 -0
- package/dist/completion-install.js +152 -0
- package/dist/completion-install.js.map +1 -0
- package/dist/completion.d.ts +7 -1
- package/dist/completion.js +14 -4
- package/dist/completion.js.map +1 -1
- package/dist/daemon.js +8 -3
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-context.d.ts +3 -0
- package/dist/fleet-manager.d.ts +54 -3
- package/dist/fleet-manager.js +452 -168
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +6 -0
- package/dist/instance-lifecycle.js +45 -25
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +68 -0
- package/dist/locale.js.map +1 -1
- package/dist/quickstart.js +25 -0
- package/dist/quickstart.js.map +1 -1
- package/dist/restart-progress.d.ts +7 -1
- package/dist/restart-progress.js +27 -6
- package/dist/restart-progress.js.map +1 -1
- package/dist/topic-commands.js +3 -1
- package/dist/topic-commands.js.map +1 -1
- package/dist/update-marker.d.ts +30 -0
- package/dist/update-marker.js +71 -20
- package/dist/update-marker.js.map +1 -1
- package/dist/update-progress.d.ts +6 -0
- package/dist/update-progress.js +29 -0
- package/dist/update-progress.js.map +1 -0
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -4,7 +4,8 @@ import { createServer } from "node:http";
|
|
|
4
4
|
import { join, dirname, basename } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
7
|
-
import { clearUpdateMarker, isUpdateInProgress } from "./update-marker.js";
|
|
7
|
+
import { beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, } from "./update-marker.js";
|
|
8
|
+
import { formatUpdateProgress } from "./update-progress.js";
|
|
8
9
|
import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
|
|
9
10
|
import { readFleetMemory } from "./process-memory.js";
|
|
10
11
|
import { ReplyDeduper } from "./reply-dedup.js";
|
|
@@ -153,6 +154,12 @@ const PROGRESS_ACTIVITY_MAX_CHARS = 48;
|
|
|
153
154
|
* 🫡 passes too — it reads as a deliberate acknowledgement, not plumbing.
|
|
154
155
|
*/
|
|
155
156
|
const DELIVERY_STATUS_EMOJIS = new Set(["👀", "⏳", "✅", "❌"]);
|
|
157
|
+
/**
|
|
158
|
+
* Reactions that are neither delivery plumbing nor meaningful conversational
|
|
159
|
+
* feedback. Keep this separate from DELIVERY_STATUS_EMOJIS so adding a UI-only
|
|
160
|
+
* emoji never changes the documented delivery-state protocol.
|
|
161
|
+
*/
|
|
162
|
+
const IGNORED_REACTION_EMOJIS = new Set(["📷"]);
|
|
156
163
|
/**
|
|
157
164
|
* How long a delivery waits out a disconnected instance IPC before giving up.
|
|
158
165
|
*
|
|
@@ -166,7 +173,10 @@ const CLASSIC_BACKEND_CALLBACK_PREFIX = "classic-backend:";
|
|
|
166
173
|
const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
|
|
167
174
|
const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
|
|
168
175
|
const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
|
|
169
|
-
const
|
|
176
|
+
const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
|
|
177
|
+
const HANG_CALLBACK_PREFIX = "hang:";
|
|
178
|
+
/** One lifetime for every nonce-armed button prompt (hang / assist / exit). */
|
|
179
|
+
const NONCE_BUTTON_TIMEOUT_MS = 15 * 60_000;
|
|
170
180
|
const CLI_ENV_TTL_MS = 24 * 60 * 60 * 1000; // /model reads cached CLI env within 24h
|
|
171
181
|
export class FleetManager {
|
|
172
182
|
dataDir;
|
|
@@ -255,8 +265,8 @@ export class FleetManager {
|
|
|
255
265
|
/** In-flight /effort selections, same coordinator shape as pendingModelSelects. */
|
|
256
266
|
pendingEffortSelects = new Map();
|
|
257
267
|
pendingModelSelects = new Map();
|
|
258
|
-
/**
|
|
259
|
-
|
|
268
|
+
/** nonce → pending button prompt (hang restart, interactive assist, clean-exit restart). */
|
|
269
|
+
pendingNonceButtons = new Map();
|
|
260
270
|
// Model failover state
|
|
261
271
|
failoverActive = new Map(); // instance → current failover model
|
|
262
272
|
// IPC reconnect: tracks instances being intentionally stopped (skip reconnect)
|
|
@@ -278,6 +288,9 @@ export class FleetManager {
|
|
|
278
288
|
healthServer = null;
|
|
279
289
|
healthPortRetried = false;
|
|
280
290
|
updateCheckTimer = null;
|
|
291
|
+
updateProgressTimer = null;
|
|
292
|
+
updateProgressEditRunning = false;
|
|
293
|
+
lastUpdateProgressText = null;
|
|
281
294
|
eventLogPruneTimer = null;
|
|
282
295
|
logRotateTimer = null;
|
|
283
296
|
/** Days of event/activity history to keep. */
|
|
@@ -557,6 +570,25 @@ export class FleetManager {
|
|
|
557
570
|
}
|
|
558
571
|
await data.respond(await this.topicCommands.runPauseWake(target, action));
|
|
559
572
|
}
|
|
573
|
+
async handleUpdateSlash(data, adapterId) {
|
|
574
|
+
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
575
|
+
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
576
|
+
await data.respond(t("not_authorized"));
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
const messageId = await data.respond(t("update.progress.preparing", 0));
|
|
580
|
+
const adapter = this.adapters.get(adapterId) ?? this.adapter;
|
|
581
|
+
if (messageId && adapter) {
|
|
582
|
+
const chatId = String(this.getChannelConfig(adapterId)?.group_id ?? data.channelId);
|
|
583
|
+
this.beginUpdateProgress(adapter, chatId, data.channelId, messageId);
|
|
584
|
+
}
|
|
585
|
+
const { spawn } = await import("node:child_process");
|
|
586
|
+
const currentVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
|
|
587
|
+
const command = currentVersion.includes("beta") ? "agend update --beta" : "agend update";
|
|
588
|
+
const child = spawn("sh", ["-c", `sleep 2 && ${command}`], { detached: true, stdio: "ignore" });
|
|
589
|
+
child.once("error", err => this.failUpdateProgress(err.message));
|
|
590
|
+
child.unref();
|
|
591
|
+
}
|
|
560
592
|
/** Admin-only full conversation reset for fleet-topic and Classic instances. */
|
|
561
593
|
async handleClearSlash(data, adapterId) {
|
|
562
594
|
if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
|
|
@@ -1210,7 +1242,18 @@ export class FleetManager {
|
|
|
1210
1242
|
this.instanceStateCache.delete(name);
|
|
1211
1243
|
this.instanceProcessStatus.delete(name);
|
|
1212
1244
|
this.lastDeliveryAt.delete(name);
|
|
1213
|
-
|
|
1245
|
+
// A pending hang/exit/assist offer refers to the instance being torn down;
|
|
1246
|
+
// left alone it would stay clickable for the rest of its 15 minutes. Clear
|
|
1247
|
+
// again AFTER the stop completes: the teardown itself can emit hang /
|
|
1248
|
+
// interactive-prompt / clean-exit events whose handlers post fresh prompts
|
|
1249
|
+
// while the stop is still awaiting (the TOCTOU sol's review called out).
|
|
1250
|
+
this.clearNoncePromptsForInstance(name);
|
|
1251
|
+
try {
|
|
1252
|
+
return await this.lifecycle.stop(name);
|
|
1253
|
+
}
|
|
1254
|
+
finally {
|
|
1255
|
+
this.clearNoncePromptsForInstance(name);
|
|
1256
|
+
}
|
|
1214
1257
|
}
|
|
1215
1258
|
/** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
|
|
1216
1259
|
async restartSingleInstance(name, opts) {
|
|
@@ -1310,6 +1353,65 @@ export class FleetManager {
|
|
|
1310
1353
|
getDashboardAccess() {
|
|
1311
1354
|
return { ready: this.healthServerListening, token: this.webToken };
|
|
1312
1355
|
}
|
|
1356
|
+
beginUpdateProgress(adapter, chatId, threadId, messageId) {
|
|
1357
|
+
persistUpdateProgress(this.dataDir, {
|
|
1358
|
+
adapterId: adapter.id,
|
|
1359
|
+
chatId,
|
|
1360
|
+
...(threadId ? { threadId } : {}),
|
|
1361
|
+
messageId,
|
|
1362
|
+
});
|
|
1363
|
+
this.lastUpdateProgressText = null;
|
|
1364
|
+
this.startUpdateProgressMonitor(adapter);
|
|
1365
|
+
}
|
|
1366
|
+
failUpdateProgress(message) {
|
|
1367
|
+
setUpdateProgressStage(this.dataDir, "failed", { error: message });
|
|
1368
|
+
}
|
|
1369
|
+
/** The old fleet edits CLI install stages; the new fleet adopts the same message below. */
|
|
1370
|
+
startUpdateProgressMonitor(initialAdapter) {
|
|
1371
|
+
if (this.updateProgressTimer)
|
|
1372
|
+
clearInterval(this.updateProgressTimer);
|
|
1373
|
+
const tick = async () => {
|
|
1374
|
+
if (this.updateProgressEditRunning)
|
|
1375
|
+
return;
|
|
1376
|
+
const marker = readUpdateProgress(this.dataDir);
|
|
1377
|
+
if (!marker) {
|
|
1378
|
+
if (this.updateProgressTimer)
|
|
1379
|
+
clearInterval(this.updateProgressTimer);
|
|
1380
|
+
this.updateProgressTimer = null;
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
const target = marker.progress.target;
|
|
1384
|
+
const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
|
|
1385
|
+
if (!adapter)
|
|
1386
|
+
return;
|
|
1387
|
+
const text = formatUpdateProgress(marker);
|
|
1388
|
+
if (text === this.lastUpdateProgressText)
|
|
1389
|
+
return;
|
|
1390
|
+
this.updateProgressEditRunning = true;
|
|
1391
|
+
try {
|
|
1392
|
+
await adapter.editMessage(target.chatId, target.messageId, text, target.threadId);
|
|
1393
|
+
this.lastUpdateProgressText = text;
|
|
1394
|
+
if (marker.progress.stage === "failed" || marker.progress.stage === "complete") {
|
|
1395
|
+
clearUpdateMarker(this.dataDir);
|
|
1396
|
+
if (this.updateProgressTimer)
|
|
1397
|
+
clearInterval(this.updateProgressTimer);
|
|
1398
|
+
this.updateProgressTimer = null;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
catch (err) {
|
|
1402
|
+
this.logger.warn({ err }, "Failed to edit update progress");
|
|
1403
|
+
}
|
|
1404
|
+
finally {
|
|
1405
|
+
this.updateProgressEditRunning = false;
|
|
1406
|
+
}
|
|
1407
|
+
};
|
|
1408
|
+
void tick();
|
|
1409
|
+
// Poll stages faster than the elapsed-time display. npm verification and
|
|
1410
|
+
// service-file refresh can be short; 200ms prevents those stages from being
|
|
1411
|
+
// skipped while the text cache still limits normal edits to once per second.
|
|
1412
|
+
this.updateProgressTimer = setInterval(() => void tick(), 200);
|
|
1413
|
+
this.updateProgressTimer.unref?.();
|
|
1414
|
+
}
|
|
1313
1415
|
/** Start all instances from fleet config */
|
|
1314
1416
|
async startAll(configPath) {
|
|
1315
1417
|
const startupStartedAt = Date.now();
|
|
@@ -1324,6 +1426,15 @@ export class FleetManager {
|
|
|
1324
1426
|
rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
|
|
1325
1427
|
const fleet = this.loadConfig(configPath);
|
|
1326
1428
|
setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
|
|
1429
|
+
const savedUpdateProgress = readUpdateProgress(this.dataDir);
|
|
1430
|
+
const pendingUpdateProgress = savedUpdateProgress
|
|
1431
|
+
&& savedUpdateProgress.progress.stage !== "failed"
|
|
1432
|
+
&& savedUpdateProgress.progress.stage !== "complete"
|
|
1433
|
+
? savedUpdateProgress
|
|
1434
|
+
: null;
|
|
1435
|
+
if (pendingUpdateProgress) {
|
|
1436
|
+
setUpdateProgressStage(this.dataDir, "starting", { version: pendingUpdateProgress.progress.version });
|
|
1437
|
+
}
|
|
1327
1438
|
this.initializeWebAuthTokens();
|
|
1328
1439
|
const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
|
|
1329
1440
|
// Set tmux socket isolation for custom AGEND_HOME
|
|
@@ -1580,7 +1691,7 @@ export class FleetManager {
|
|
|
1580
1691
|
const allEntries = Object.entries(fleet.instances);
|
|
1581
1692
|
const generals = allEntries.filter(([_, cfg]) => cfg.general_topic);
|
|
1582
1693
|
const others = allEntries.filter(([_, cfg]) => !cfg.general_topic);
|
|
1583
|
-
const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), startupStartedAt, this.logger);
|
|
1694
|
+
const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), pendingUpdateProgress?.startedAt ?? startupStartedAt, this.logger, { mode: pendingUpdateProgress ? "update" : "restart" });
|
|
1584
1695
|
if (generals.length > 0) {
|
|
1585
1696
|
for (const [name, cfg] of generals) {
|
|
1586
1697
|
try {
|
|
@@ -1619,7 +1730,19 @@ export class FleetManager {
|
|
|
1619
1730
|
this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
|
|
1620
1731
|
}
|
|
1621
1732
|
})();
|
|
1622
|
-
progressStart = adapterStartup.then(() =>
|
|
1733
|
+
progressStart = adapterStartup.then(() => {
|
|
1734
|
+
if (pendingUpdateProgress) {
|
|
1735
|
+
const saved = pendingUpdateProgress.progress.target;
|
|
1736
|
+
const adapter = this.adapters.get(saved.adapterId);
|
|
1737
|
+
const target = adapter ? {
|
|
1738
|
+
adapter,
|
|
1739
|
+
chatId: saved.chatId,
|
|
1740
|
+
threadId: saved.threadId,
|
|
1741
|
+
} : null;
|
|
1742
|
+
return startupProgress.resume(target, saved.messageId);
|
|
1743
|
+
}
|
|
1744
|
+
return startupProgress.start(this.restartProgressTarget());
|
|
1745
|
+
});
|
|
1623
1746
|
}
|
|
1624
1747
|
// The systemd watchdog answers exactly one question: is this process still
|
|
1625
1748
|
// turning its event loop? Pinging from a timer proves that, and after the
|
|
@@ -1997,7 +2120,9 @@ export class FleetManager {
|
|
|
1997
2120
|
await this.handleInboundReaction(r);
|
|
1998
2121
|
}, this.logger, "adapter.reaction"));
|
|
1999
2122
|
this.adapter.on("callback_query", safeHandler(async (data) => {
|
|
2000
|
-
if (await this.
|
|
2123
|
+
if (await this.handleExitRestartPrompt(data, adapterId, this.adapter ?? undefined))
|
|
2124
|
+
return;
|
|
2125
|
+
if (await this.handleInteractivePromptAssist(data, adapterId, this.adapter ?? undefined))
|
|
2001
2126
|
return;
|
|
2002
2127
|
if (await this.handleClassicBackendSelection(data))
|
|
2003
2128
|
return;
|
|
@@ -2005,25 +2130,8 @@ export class FleetManager {
|
|
|
2005
2130
|
return;
|
|
2006
2131
|
if (await this.handleEffortSelection(data))
|
|
2007
2132
|
return;
|
|
2008
|
-
if (data.
|
|
2009
|
-
const parts = data.callbackData.split(":");
|
|
2010
|
-
const action = parts[1];
|
|
2011
|
-
const instanceName = parts[2];
|
|
2012
|
-
if (action === "restart") {
|
|
2013
|
-
await this.stopInstance(instanceName);
|
|
2014
|
-
const config = this.fleetConfig?.instances[instanceName];
|
|
2015
|
-
if (config) {
|
|
2016
|
-
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
2017
|
-
await this.startInstance(instanceName, config, topicMode);
|
|
2018
|
-
// startInstance already calls connectIpcToInstance
|
|
2019
|
-
}
|
|
2020
|
-
this.adapter?.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
|
|
2021
|
-
}
|
|
2022
|
-
else {
|
|
2023
|
-
this.adapter?.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
|
|
2024
|
-
}
|
|
2133
|
+
if (await this.handleHangPrompt(data, adapterId, this.adapter ?? undefined))
|
|
2025
2134
|
return;
|
|
2026
|
-
}
|
|
2027
2135
|
if (data.callbackData.startsWith("cancel:")) {
|
|
2028
2136
|
this.handleCancelClick(data.callbackData.slice("cancel:".length), this.adapter, data);
|
|
2029
2137
|
return;
|
|
@@ -2157,17 +2265,7 @@ export class FleetManager {
|
|
|
2157
2265
|
: t("collab.off.classic"));
|
|
2158
2266
|
}
|
|
2159
2267
|
else if (data.command === "update") {
|
|
2160
|
-
|
|
2161
|
-
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
2162
|
-
await data.respond(t("not_authorized"));
|
|
2163
|
-
return;
|
|
2164
|
-
}
|
|
2165
|
-
await data.respond(t("update.running"));
|
|
2166
|
-
const { spawn } = await import("node:child_process");
|
|
2167
|
-
const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
|
|
2168
|
-
const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
|
|
2169
|
-
const child = spawn("sh", ["-c", `sleep 2 && ${_cmd}`], { detached: true, stdio: "ignore" });
|
|
2170
|
-
child.unref();
|
|
2268
|
+
await this.handleUpdateSlash(data, adapterId);
|
|
2171
2269
|
}
|
|
2172
2270
|
else if (data.command === "doctor") {
|
|
2173
2271
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
@@ -2312,7 +2410,9 @@ export class FleetManager {
|
|
|
2312
2410
|
await this.handleInboundReaction(r);
|
|
2313
2411
|
}, this.logger, `adapter[${adapterId}].reaction`));
|
|
2314
2412
|
adapter.on("callback_query", safeHandler(async (data) => {
|
|
2315
|
-
if (await this.
|
|
2413
|
+
if (await this.handleExitRestartPrompt(data, adapterId, adapter))
|
|
2414
|
+
return;
|
|
2415
|
+
if (await this.handleInteractivePromptAssist(data, adapterId, adapter))
|
|
2316
2416
|
return;
|
|
2317
2417
|
if (await this.handleClassicBackendSelection(data))
|
|
2318
2418
|
return;
|
|
@@ -2320,24 +2420,8 @@ export class FleetManager {
|
|
|
2320
2420
|
return;
|
|
2321
2421
|
if (await this.handleEffortSelection(data))
|
|
2322
2422
|
return;
|
|
2323
|
-
if (
|
|
2324
|
-
const parts = data.callbackData.split(":");
|
|
2325
|
-
const action = parts[1];
|
|
2326
|
-
const instanceName = parts[2];
|
|
2327
|
-
if (action === "restart") {
|
|
2328
|
-
await this.stopInstance(instanceName);
|
|
2329
|
-
const config = this.fleetConfig?.instances[instanceName];
|
|
2330
|
-
if (config) {
|
|
2331
|
-
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
2332
|
-
await this.startInstance(instanceName, config, topicMode);
|
|
2333
|
-
}
|
|
2334
|
-
adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
|
|
2335
|
-
}
|
|
2336
|
-
else {
|
|
2337
|
-
adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
|
|
2338
|
-
}
|
|
2423
|
+
if (await this.handleHangPrompt(data, adapterId, adapter))
|
|
2339
2424
|
return;
|
|
2340
|
-
}
|
|
2341
2425
|
if (data.callbackData.startsWith("cancel:")) {
|
|
2342
2426
|
this.handleCancelClick(data.callbackData.slice("cancel:".length), adapter, data);
|
|
2343
2427
|
return;
|
|
@@ -2461,17 +2545,7 @@ export class FleetManager {
|
|
|
2461
2545
|
: t("collab.off.classic"));
|
|
2462
2546
|
}
|
|
2463
2547
|
else if (data.command === "update") {
|
|
2464
|
-
|
|
2465
|
-
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
2466
|
-
await data.respond(t("not_authorized"));
|
|
2467
|
-
return;
|
|
2468
|
-
}
|
|
2469
|
-
await data.respond(t("update.running"));
|
|
2470
|
-
const { spawn } = await import("node:child_process");
|
|
2471
|
-
const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
|
|
2472
|
-
const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
|
|
2473
|
-
const child = spawn("sh", ["-c", `sleep 2 && ${_cmd}`], { detached: true, stdio: "ignore" });
|
|
2474
|
-
child.unref();
|
|
2548
|
+
await this.handleUpdateSlash(data, adapterId);
|
|
2475
2549
|
}
|
|
2476
2550
|
else if (data.command === "doctor") {
|
|
2477
2551
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
@@ -2845,6 +2919,10 @@ export class FleetManager {
|
|
|
2845
2919
|
this.logger.debug({ emoji: r.emoji, user: r.username }, "Ignoring delivery-status emoji as a reaction");
|
|
2846
2920
|
return;
|
|
2847
2921
|
}
|
|
2922
|
+
if (IGNORED_REACTION_EMOJIS.has(r.emoji)) {
|
|
2923
|
+
this.logger.debug({ emoji: r.emoji, user: r.username }, "Ignoring non-contextual emoji reaction");
|
|
2924
|
+
return;
|
|
2925
|
+
}
|
|
2848
2926
|
this.eventLog?.logActivity("reaction", r.username, `${r.emoji} ${r.action}`, instanceName);
|
|
2849
2927
|
if (r.action === "add") {
|
|
2850
2928
|
this.eventLog?.addReaction(instanceName, r.messageId, r.username, r.emoji);
|
|
@@ -4412,6 +4490,205 @@ export class FleetManager {
|
|
|
4412
4490
|
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
|
|
4413
4491
|
}
|
|
4414
4492
|
}
|
|
4493
|
+
// ── Nonce-armed button prompts (hang / interactive-assist / exit-restart) ──
|
|
4494
|
+
//
|
|
4495
|
+
// One shared lifecycle for every "notification with decision buttons":
|
|
4496
|
+
// post with a 128-bit nonce, arm a 15-min expiry, bind the click to the
|
|
4497
|
+
// exact adapter+chat+thread+message that created it, require fleet admin,
|
|
4498
|
+
// consume exactly once. The three features differ only in what they post
|
|
4499
|
+
// and what a consumed click does.
|
|
4500
|
+
/**
|
|
4501
|
+
* Post decision buttons whose callback ids are `<prefix><nonce>:<action>`.
|
|
4502
|
+
* The entry is registered before the send and rolled back if the send
|
|
4503
|
+
* fails, so a nonce in the map always refers to a message that exists (or
|
|
4504
|
+
* is about to). Returns the nonce, or null when the alert could not be sent.
|
|
4505
|
+
*/
|
|
4506
|
+
async postNonceButtonPrompt(opts) {
|
|
4507
|
+
// 16 bytes = the 128-bit capability the design claims. Telegram's 64-byte
|
|
4508
|
+
// callback_data cap still holds: longest id is "interactive-assist:" (19)
|
|
4509
|
+
// + 32 hex + ":confirm" (8) = 59 bytes.
|
|
4510
|
+
const nonce = randomBytes(16).toString("hex");
|
|
4511
|
+
const entry = {
|
|
4512
|
+
prefix: opts.prefix,
|
|
4513
|
+
instanceName: opts.instanceName,
|
|
4514
|
+
adapterId: opts.adapterId,
|
|
4515
|
+
adapter: opts.adapter,
|
|
4516
|
+
chatId: opts.chatId,
|
|
4517
|
+
threadId: opts.threadId,
|
|
4518
|
+
expiredText: opts.expiredText,
|
|
4519
|
+
...opts.extra,
|
|
4520
|
+
};
|
|
4521
|
+
entry.timer = setTimeout(() => {
|
|
4522
|
+
const pending = this.pendingNonceButtons.get(nonce);
|
|
4523
|
+
if (pending !== entry)
|
|
4524
|
+
return;
|
|
4525
|
+
this.pendingNonceButtons.delete(nonce);
|
|
4526
|
+
if (entry.messageId && entry.adapter.editMessageRemoveButtons) {
|
|
4527
|
+
entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId).catch(err => this.logger.debug({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Failed to expire button prompt"));
|
|
4528
|
+
}
|
|
4529
|
+
}, opts.timeoutMs ?? NONCE_BUTTON_TIMEOUT_MS);
|
|
4530
|
+
entry.timer.unref?.();
|
|
4531
|
+
this.pendingNonceButtons.set(nonce, entry);
|
|
4532
|
+
try {
|
|
4533
|
+
const sent = await opts.adapter.notifyAlert(opts.chatId, {
|
|
4534
|
+
type: opts.alertType,
|
|
4535
|
+
instanceName: opts.instanceName,
|
|
4536
|
+
message: opts.message,
|
|
4537
|
+
choices: opts.choices.map(c => ({ id: `${opts.prefix}${nonce}:${c.action}`, label: c.label })),
|
|
4538
|
+
}, opts.threadId ? { threadId: opts.threadId } : undefined);
|
|
4539
|
+
entry.messageId = sent.messageId;
|
|
4540
|
+
return nonce;
|
|
4541
|
+
}
|
|
4542
|
+
catch (err) {
|
|
4543
|
+
this.pendingNonceButtons.delete(nonce);
|
|
4544
|
+
if (entry.timer)
|
|
4545
|
+
clearTimeout(entry.timer);
|
|
4546
|
+
this.logger.warn({ err, instanceName: opts.instanceName, prefix: opts.prefix }, "Failed to send button prompt");
|
|
4547
|
+
return null;
|
|
4548
|
+
}
|
|
4549
|
+
}
|
|
4550
|
+
/**
|
|
4551
|
+
* Claim a nonce-armed callback exactly once.
|
|
4552
|
+
*
|
|
4553
|
+
* Returns:
|
|
4554
|
+
* - null — the callback is not for this prefix; try the next handler
|
|
4555
|
+
* - "consumed" — for this prefix but stale/denied/malformed; stop dispatch
|
|
4556
|
+
* - the entry+action — the click is authorized and now claimed (removed
|
|
4557
|
+
* from the map before any await, so double clicks cannot act twice)
|
|
4558
|
+
*
|
|
4559
|
+
* A stale click (expired nonce, or a pre-upgrade button whose payload no
|
|
4560
|
+
* longer parses) collapses the clicked message so the dead button stops
|
|
4561
|
+
* inviting clicks — the same courtesy the cancel button extends.
|
|
4562
|
+
*/
|
|
4563
|
+
consumeNonceCallback(prefix, actionRe, data, callbackAdapterId, receivingAdapter) {
|
|
4564
|
+
if (!data.callbackData.startsWith(prefix))
|
|
4565
|
+
return null;
|
|
4566
|
+
const match = data.callbackData.match(actionRe);
|
|
4567
|
+
let pending = match ? this.pendingNonceButtons.get(match[1]) : undefined;
|
|
4568
|
+
// The map is shared across prompt kinds. A nonce that resolves to an entry
|
|
4569
|
+
// of a DIFFERENT kind is not a usable capability for this handler — treat
|
|
4570
|
+
// it as stale rather than acting across kinds (fail closed).
|
|
4571
|
+
if (pending && pending.prefix !== prefix)
|
|
4572
|
+
pending = undefined;
|
|
4573
|
+
if (!match || !pending) {
|
|
4574
|
+
const adapter = receivingAdapter ?? this.adapter;
|
|
4575
|
+
adapter?.editMessageRemoveButtons?.(data.chatId, data.messageId, t("buttons.stale"), data.threadId).catch(() => { });
|
|
4576
|
+
return "consumed";
|
|
4577
|
+
}
|
|
4578
|
+
// Bind the capability to the exact message/world that created it. Telegram
|
|
4579
|
+
// keyboards are visible to everyone, so the click also requires fleet admin.
|
|
4580
|
+
if (pending.adapterId !== callbackAdapterId
|
|
4581
|
+
|| data.chatId !== pending.chatId
|
|
4582
|
+
|| (pending.threadId != null && data.threadId !== pending.threadId)
|
|
4583
|
+
|| (pending.messageId != null && data.messageId !== pending.messageId)
|
|
4584
|
+
|| !data.userId
|
|
4585
|
+
|| !this.isFleetAdmin(data.userId, callbackAdapterId)) {
|
|
4586
|
+
// Deliberately does NOT consume the nonce: the real admin can still click.
|
|
4587
|
+
this.logger.warn({ instanceName: pending.instanceName, prefix, userId: data.userId }, "Rejected unauthorized or mismatched button callback");
|
|
4588
|
+
return "consumed";
|
|
4589
|
+
}
|
|
4590
|
+
// Claim before any await: double clicks and duplicate callback delivery can
|
|
4591
|
+
// never act twice.
|
|
4592
|
+
this.pendingNonceButtons.delete(match[1]);
|
|
4593
|
+
if (pending.timer)
|
|
4594
|
+
clearTimeout(pending.timer);
|
|
4595
|
+
return { entry: pending, action: match[2] };
|
|
4596
|
+
}
|
|
4597
|
+
/** Collapse a consumed prompt's buttons into a final status line. */
|
|
4598
|
+
async retireNonceButtons(pending, messageId, text) {
|
|
4599
|
+
if (!pending.adapter.editMessageRemoveButtons) {
|
|
4600
|
+
this.logger.warn({ instanceName: pending.instanceName, adapterId: pending.adapterId }, "Adapter cannot remove prompt buttons");
|
|
4601
|
+
return;
|
|
4602
|
+
}
|
|
4603
|
+
try {
|
|
4604
|
+
await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
|
|
4605
|
+
}
|
|
4606
|
+
catch (err) {
|
|
4607
|
+
// The action was already atomically consumed. An edit failure must not
|
|
4608
|
+
// undo that or make the button actionable again.
|
|
4609
|
+
this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons");
|
|
4610
|
+
}
|
|
4611
|
+
}
|
|
4612
|
+
/**
|
|
4613
|
+
* Drop every pending prompt that refers to an instance being stopped or
|
|
4614
|
+
* restarted — a restart offer for an instance the operator just restarted
|
|
4615
|
+
* (or an assist for one they stopped) must not stay clickable for the rest
|
|
4616
|
+
* of its 15 minutes.
|
|
4617
|
+
*/
|
|
4618
|
+
clearNoncePromptsForInstance(instanceName) {
|
|
4619
|
+
for (const [nonce, entry] of this.pendingNonceButtons) {
|
|
4620
|
+
if (entry.instanceName !== instanceName)
|
|
4621
|
+
continue;
|
|
4622
|
+
this.pendingNonceButtons.delete(nonce);
|
|
4623
|
+
if (entry.timer)
|
|
4624
|
+
clearTimeout(entry.timer);
|
|
4625
|
+
if (entry.messageId && entry.adapter.editMessageRemoveButtons) {
|
|
4626
|
+
entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId)
|
|
4627
|
+
.catch(err => this.logger.debug({ err, instanceName, prefix: entry.prefix }, "Failed to collapse prompt during instance stop"));
|
|
4628
|
+
}
|
|
4629
|
+
}
|
|
4630
|
+
}
|
|
4631
|
+
/** A clean exit is intentional from the CLI's perspective, but often not from
|
|
4632
|
+
* the operator's. Keep the instance notice passive and put the action in the
|
|
4633
|
+
* same-world General topic where an administrator can make the choice. */
|
|
4634
|
+
async notifyNormalExit(instanceName) {
|
|
4635
|
+
this.notifyInstanceTopic(instanceName, t("exit.instance_notice", instanceName));
|
|
4636
|
+
const worldId = this.getInstanceAdapterId(instanceName);
|
|
4637
|
+
const generalName = this.findGeneralInstance(worldId);
|
|
4638
|
+
if (!generalName) {
|
|
4639
|
+
this.logger.warn({ instanceName, worldId }, "Normal CLI exit has no General notification target");
|
|
4640
|
+
return;
|
|
4641
|
+
}
|
|
4642
|
+
const adapterId = this.getInstanceAdapterId(generalName);
|
|
4643
|
+
const adapter = this.getAdapterForInstance(generalName);
|
|
4644
|
+
const chatId = this.getGroupIdForInstance(generalName);
|
|
4645
|
+
const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
|
|
4646
|
+
const threadId = topicId != null ? String(topicId) : undefined;
|
|
4647
|
+
if (!adapter || !adapterId || !chatId) {
|
|
4648
|
+
this.logger.warn({ instanceName, generalName, adapterId, chatId }, "Cannot address normal-exit restart controls");
|
|
4649
|
+
return;
|
|
4650
|
+
}
|
|
4651
|
+
await this.postNonceButtonPrompt({
|
|
4652
|
+
prefix: EXIT_RESTART_CALLBACK_PREFIX,
|
|
4653
|
+
alertType: "exit_restart",
|
|
4654
|
+
instanceName,
|
|
4655
|
+
adapter,
|
|
4656
|
+
adapterId,
|
|
4657
|
+
chatId,
|
|
4658
|
+
threadId,
|
|
4659
|
+
message: t("exit.general_notice", instanceName),
|
|
4660
|
+
choices: [
|
|
4661
|
+
{ action: "restart", label: t("exit.restart") },
|
|
4662
|
+
{ action: "ignore", label: t("exit.ignore") },
|
|
4663
|
+
],
|
|
4664
|
+
expiredText: t("exit.expired", instanceName),
|
|
4665
|
+
});
|
|
4666
|
+
}
|
|
4667
|
+
/** Consume a clean-exit Restart/Ignore button exactly once. */
|
|
4668
|
+
async handleExitRestartPrompt(data, callbackAdapterId, receivingAdapter) {
|
|
4669
|
+
const claimed = this.consumeNonceCallback(EXIT_RESTART_CALLBACK_PREFIX, /^exit-restart:([0-9a-f]+):(restart|ignore)$/, data, callbackAdapterId, receivingAdapter);
|
|
4670
|
+
if (claimed === null)
|
|
4671
|
+
return false;
|
|
4672
|
+
if (claimed === "consumed")
|
|
4673
|
+
return true;
|
|
4674
|
+
const { entry: pending, action } = claimed;
|
|
4675
|
+
this.eventLog?.insert(pending.instanceName, "normal_exit_action", { action, userId: data.userId });
|
|
4676
|
+
if (action === "ignore") {
|
|
4677
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.ignored", pending.instanceName));
|
|
4678
|
+
return true;
|
|
4679
|
+
}
|
|
4680
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.restarting", pending.instanceName));
|
|
4681
|
+
try {
|
|
4682
|
+
await this.restartSingleInstance(pending.instanceName);
|
|
4683
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("exit.restarted", pending.instanceName), pending.threadId);
|
|
4684
|
+
}
|
|
4685
|
+
catch (err) {
|
|
4686
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4687
|
+
this.logger.error({ err, instanceName: pending.instanceName }, "Normal-exit restart failed");
|
|
4688
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("exit.restart_failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show normal-exit restart error"));
|
|
4689
|
+
}
|
|
4690
|
+
return true;
|
|
4691
|
+
}
|
|
4415
4692
|
interactivePromptLabel(kind) {
|
|
4416
4693
|
const key = `interactive.kind.${kind}`;
|
|
4417
4694
|
const translated = t(key);
|
|
@@ -4443,109 +4720,63 @@ export class FleetManager {
|
|
|
4443
4720
|
this.logger.warn({ instanceName, generalName, adapterId, chatId }, "Cannot address interactive prompt assistance controls");
|
|
4444
4721
|
return;
|
|
4445
4722
|
}
|
|
4446
|
-
const nonce = randomBytes(8).toString("hex");
|
|
4447
4723
|
const label = this.interactivePromptLabel(kind);
|
|
4448
|
-
|
|
4724
|
+
await this.postNonceButtonPrompt({
|
|
4725
|
+
prefix: INTERACTIVE_ASSIST_CALLBACK_PREFIX,
|
|
4726
|
+
alertType: "interactive_prompt",
|
|
4449
4727
|
instanceName,
|
|
4450
|
-
generalName,
|
|
4451
|
-
kind,
|
|
4452
|
-
adapterId,
|
|
4453
4728
|
adapter,
|
|
4729
|
+
adapterId,
|
|
4454
4730
|
chatId,
|
|
4455
4731
|
threadId,
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
}
|
|
4465
|
-
}, INTERACTIVE_ASSIST_TIMEOUT_MS);
|
|
4466
|
-
entry.timer.unref?.();
|
|
4467
|
-
this.pendingInteractivePromptAssists.set(nonce, entry);
|
|
4468
|
-
try {
|
|
4469
|
-
const sent = await adapter.notifyAlert(chatId, {
|
|
4470
|
-
type: "interactive_prompt",
|
|
4471
|
-
instanceName,
|
|
4472
|
-
message: t("interactive.general_notice", instanceName, label),
|
|
4473
|
-
choices: [
|
|
4474
|
-
{ id: `${INTERACTIVE_ASSIST_CALLBACK_PREFIX}${nonce}:confirm`, label: t("interactive.confirm") },
|
|
4475
|
-
{ id: `${INTERACTIVE_ASSIST_CALLBACK_PREFIX}${nonce}:cancel`, label: t("interactive.cancel") },
|
|
4476
|
-
],
|
|
4477
|
-
}, threadId ? { threadId } : undefined);
|
|
4478
|
-
entry.messageId = sent.messageId;
|
|
4479
|
-
}
|
|
4480
|
-
catch (err) {
|
|
4481
|
-
this.pendingInteractivePromptAssists.delete(nonce);
|
|
4482
|
-
if (entry.timer)
|
|
4483
|
-
clearTimeout(entry.timer);
|
|
4484
|
-
this.logger.warn({ err, instanceName, generalName }, "Failed to send interactive prompt assistance controls");
|
|
4485
|
-
}
|
|
4486
|
-
}
|
|
4487
|
-
async retireInteractivePromptButtons(pending, messageId, text) {
|
|
4488
|
-
if (!pending.adapter.editMessageRemoveButtons) {
|
|
4489
|
-
this.logger.warn({ instanceName: pending.instanceName, adapterId: pending.adapterId }, "Adapter cannot remove interactive prompt controls");
|
|
4490
|
-
return;
|
|
4491
|
-
}
|
|
4492
|
-
try {
|
|
4493
|
-
await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
|
|
4494
|
-
}
|
|
4495
|
-
catch (err) {
|
|
4496
|
-
// The action was already atomically consumed. An edit failure must not
|
|
4497
|
-
// prevent Confirm from reaching General or make Cancel actionable again.
|
|
4498
|
-
this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire interactive prompt controls");
|
|
4499
|
-
}
|
|
4732
|
+
message: t("interactive.general_notice", instanceName, label),
|
|
4733
|
+
choices: [
|
|
4734
|
+
{ action: "confirm", label: t("interactive.confirm") },
|
|
4735
|
+
{ action: "cancel", label: t("interactive.cancel") },
|
|
4736
|
+
],
|
|
4737
|
+
expiredText: t("interactive.expired", instanceName),
|
|
4738
|
+
extra: { generalName, promptKind: kind },
|
|
4739
|
+
});
|
|
4500
4740
|
}
|
|
4501
4741
|
/** Consume a General assist button exactly once. */
|
|
4502
|
-
async handleInteractivePromptAssist(data, callbackAdapterId) {
|
|
4503
|
-
|
|
4742
|
+
async handleInteractivePromptAssist(data, callbackAdapterId, receivingAdapter) {
|
|
4743
|
+
const claimed = this.consumeNonceCallback(INTERACTIVE_ASSIST_CALLBACK_PREFIX, /^interactive-assist:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
|
|
4744
|
+
if (claimed === null)
|
|
4504
4745
|
return false;
|
|
4505
|
-
|
|
4506
|
-
if (!match)
|
|
4507
|
-
return true;
|
|
4508
|
-
const pending = this.pendingInteractivePromptAssists.get(match[1]);
|
|
4509
|
-
if (!pending)
|
|
4746
|
+
if (claimed === "consumed")
|
|
4510
4747
|
return true;
|
|
4511
|
-
|
|
4512
|
-
// keyboards are visible to everyone, so the click also requires fleet admin.
|
|
4513
|
-
if (pending.adapterId !== callbackAdapterId
|
|
4514
|
-
|| data.chatId !== pending.chatId
|
|
4515
|
-
|| (pending.threadId != null && data.threadId !== pending.threadId)
|
|
4516
|
-
|| (pending.messageId != null && data.messageId !== pending.messageId)
|
|
4517
|
-
|| !data.userId
|
|
4518
|
-
|| !this.isFleetAdmin(data.userId, callbackAdapterId)) {
|
|
4519
|
-
this.logger.warn({ instanceName: pending.instanceName, userId: data.userId }, "Rejected unauthorized or mismatched interactive prompt callback");
|
|
4520
|
-
return true;
|
|
4521
|
-
}
|
|
4522
|
-
// Claim before any await: double clicks and duplicate callback delivery can
|
|
4523
|
-
// never inject two messages into General.
|
|
4524
|
-
this.pendingInteractivePromptAssists.delete(match[1]);
|
|
4525
|
-
if (pending.timer)
|
|
4526
|
-
clearTimeout(pending.timer);
|
|
4527
|
-
const action = match[2];
|
|
4748
|
+
const { entry: pending, action } = claimed;
|
|
4528
4749
|
this.eventLog?.insert(pending.instanceName, "interactive_prompt_assist", {
|
|
4529
4750
|
action,
|
|
4530
4751
|
userId: data.userId,
|
|
4531
4752
|
generalName: pending.generalName,
|
|
4532
4753
|
});
|
|
4533
4754
|
if (action === "cancel") {
|
|
4534
|
-
await this.
|
|
4755
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.ignored", pending.instanceName));
|
|
4756
|
+
return true;
|
|
4757
|
+
}
|
|
4758
|
+
// Fail closed on a malformed entry. generalName is set at posting time for
|
|
4759
|
+
// every interactive-assist entry; its absence means entry confusion, and
|
|
4760
|
+
// falling back to the blocked instance would type the assist text into the
|
|
4761
|
+
// very terminal prompt this feature exists to keep humans in front of.
|
|
4762
|
+
const generalName = pending.generalName;
|
|
4763
|
+
if (!generalName) {
|
|
4764
|
+
this.logger.error({ instanceName: pending.instanceName }, "Interactive-assist entry has no General target — refusing to deliver");
|
|
4765
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.delivery_failed", pending.instanceName));
|
|
4535
4766
|
return true;
|
|
4536
4767
|
}
|
|
4537
4768
|
// Injecting into General while General itself is blocked would type into the
|
|
4538
4769
|
// terminal prompt instead of the agent input. Fail safe and require attach.
|
|
4539
|
-
if (pending.instanceName ===
|
|
4540
|
-
await this.
|
|
4770
|
+
if (pending.instanceName === generalName) {
|
|
4771
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.self_assist", pending.instanceName));
|
|
4541
4772
|
return true;
|
|
4542
4773
|
}
|
|
4543
|
-
await this.
|
|
4774
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.confirmed", pending.instanceName));
|
|
4544
4775
|
try {
|
|
4545
|
-
await this.deliverToInstance(
|
|
4776
|
+
await this.deliverToInstance(generalName, {
|
|
4546
4777
|
type: "fleet_inbound",
|
|
4547
|
-
content: t("interactive.assist_request", pending.instanceName, this.interactivePromptLabel(pending.
|
|
4548
|
-
targetSession:
|
|
4778
|
+
content: t("interactive.assist_request", pending.instanceName, this.interactivePromptLabel(pending.promptKind ?? "")),
|
|
4779
|
+
targetSession: generalName,
|
|
4549
4780
|
meta: {
|
|
4550
4781
|
chat_id: pending.chatId,
|
|
4551
4782
|
message_id: pending.messageId ?? data.messageId,
|
|
@@ -5095,13 +5326,29 @@ export class FleetManager {
|
|
|
5095
5326
|
}
|
|
5096
5327
|
async sendHangNotification(instanceName, unchangedForMs) {
|
|
5097
5328
|
const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
5329
|
+
const adapterId = this.getInstanceAdapterId(instanceName);
|
|
5330
|
+
// Same three-way addressing as sendCancelButton: fleet topic → group+thread,
|
|
5331
|
+
// Classic → its own channel (Classic instances are absent from
|
|
5332
|
+
// fleetConfig.instances, so the topic path can never address them), else the
|
|
5333
|
+
// world group flat. getGroupIdForInstance (not getChannelConfig().group_id)
|
|
5334
|
+
// because on channels[]-configured fleets the legacy channel: block is empty.
|
|
5335
|
+
const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
|
|
5336
|
+
const groupId = this.getGroupIdForInstance(instanceName) || undefined;
|
|
5337
|
+
let chatId;
|
|
5338
|
+
let threadId;
|
|
5339
|
+
if (topicId != null && groupId) {
|
|
5340
|
+
chatId = String(groupId);
|
|
5341
|
+
threadId = String(topicId);
|
|
5342
|
+
}
|
|
5343
|
+
else {
|
|
5344
|
+
chatId = this.classicChannels?.getChannelIdByInstance(instanceName);
|
|
5345
|
+
if (!chatId && groupId)
|
|
5346
|
+
chatId = String(groupId);
|
|
5347
|
+
}
|
|
5348
|
+
if (!adapter || !adapterId || !chatId) {
|
|
5349
|
+
this.logger.warn({ instanceName, adapterId, chatId }, "Cannot address hang notification");
|
|
5103
5350
|
return;
|
|
5104
|
-
|
|
5351
|
+
}
|
|
5105
5352
|
const instanceHangConfig = this.fleetConfig?.instances[instanceName]?.hang_detector;
|
|
5106
5353
|
const configuredMinutes = instanceHangConfig?.timeout_minutes
|
|
5107
5354
|
?? this.fleetConfig?.defaults?.hang_detector?.timeout_minutes
|
|
@@ -5110,17 +5357,52 @@ export class FleetManager {
|
|
|
5110
5357
|
? configuredMinutes
|
|
5111
5358
|
: Math.max(1, Math.floor(unchangedForMs / 60_000));
|
|
5112
5359
|
this.setTopicIcon(instanceName, "red");
|
|
5113
|
-
await
|
|
5114
|
-
|
|
5360
|
+
await this.postNonceButtonPrompt({
|
|
5361
|
+
prefix: HANG_CALLBACK_PREFIX,
|
|
5362
|
+
alertType: "hang",
|
|
5115
5363
|
instanceName,
|
|
5116
|
-
|
|
5364
|
+
adapter,
|
|
5365
|
+
adapterId,
|
|
5366
|
+
chatId,
|
|
5367
|
+
threadId,
|
|
5368
|
+
message: t("hang.detected", instanceName, unchangedMinutes),
|
|
5117
5369
|
choices: [
|
|
5118
|
-
{
|
|
5119
|
-
{
|
|
5370
|
+
{ action: "restart", label: t("hang.restart") },
|
|
5371
|
+
{ action: "wait", label: t("hang.wait") },
|
|
5120
5372
|
],
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
5373
|
+
expiredText: t("hang.expired", instanceName),
|
|
5374
|
+
});
|
|
5375
|
+
}
|
|
5376
|
+
/**
|
|
5377
|
+
* Consume a hang Force-restart / Keep-waiting button exactly once. Restart
|
|
5378
|
+
* goes through restartSingleInstance — serialized against concurrent restart
|
|
5379
|
+
* sources, and with a Classic-instance fallback (the previous hand-rolled
|
|
5380
|
+
* stop+start silently left Classic instances stopped while reporting
|
|
5381
|
+
* "restarted").
|
|
5382
|
+
*/
|
|
5383
|
+
async handleHangPrompt(data, callbackAdapterId, receivingAdapter) {
|
|
5384
|
+
const claimed = this.consumeNonceCallback(HANG_CALLBACK_PREFIX, /^hang:([0-9a-f]+):(restart|wait)$/, data, callbackAdapterId, receivingAdapter);
|
|
5385
|
+
if (claimed === null)
|
|
5386
|
+
return false;
|
|
5387
|
+
if (claimed === "consumed")
|
|
5388
|
+
return true;
|
|
5389
|
+
const { entry: pending, action } = claimed;
|
|
5390
|
+
this.eventLog?.insert(pending.instanceName, "hang_action", { action, userId: data.userId });
|
|
5391
|
+
if (action === "wait") {
|
|
5392
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.waiting", pending.instanceName));
|
|
5393
|
+
return true;
|
|
5394
|
+
}
|
|
5395
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.restarting", pending.instanceName));
|
|
5396
|
+
try {
|
|
5397
|
+
await this.restartSingleInstance(pending.instanceName);
|
|
5398
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restarted", pending.instanceName), pending.threadId);
|
|
5399
|
+
}
|
|
5400
|
+
catch (err) {
|
|
5401
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5402
|
+
this.logger.error({ err, instanceName: pending.instanceName }, "Hang force-restart failed");
|
|
5403
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restart_failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show hang restart error"));
|
|
5404
|
+
}
|
|
5405
|
+
return true;
|
|
5124
5406
|
}
|
|
5125
5407
|
// ── Topic icon + auto-archive ─────────────────────────────────────────────
|
|
5126
5408
|
static INSTRUCTIONS_FILENAME = {
|
|
@@ -6251,13 +6533,11 @@ When users create specialized instances, suggest these configurations:
|
|
|
6251
6533
|
}
|
|
6252
6534
|
catch { /* default restart */ }
|
|
6253
6535
|
const warn = isModelCompatible(backendName, model) ? "" : `⚠️ "${model}" doesn't match ${backendName}'s usual pattern — passing through anyway.\n`;
|
|
6254
|
-
if (strategy === "runtime") {
|
|
6255
|
-
|
|
6256
|
-
return `${warn}❌ ${instanceName} is not running.`;
|
|
6257
|
-
this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
|
|
6258
|
-
return `${warn}✅ Switched ${instanceName} to \`${model}\` (runtime).${this.effortSuffix(instanceName)}`;
|
|
6536
|
+
if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
|
|
6537
|
+
return `${warn}❌ ${instanceName} is not running.`;
|
|
6259
6538
|
}
|
|
6260
|
-
//
|
|
6539
|
+
// Persist either way: a runtime switch must survive the next respawn too,
|
|
6540
|
+
// or the instance silently reverts to the CLI default after a fleet restart.
|
|
6261
6541
|
let persisted = false;
|
|
6262
6542
|
if (this.fleetConfig?.instances[instanceName]) {
|
|
6263
6543
|
this.fleetConfig.instances[instanceName].model = model;
|
|
@@ -6269,6 +6549,10 @@ When users create specialized instances, suggest these configurations:
|
|
|
6269
6549
|
}
|
|
6270
6550
|
if (!persisted)
|
|
6271
6551
|
return `${warn}❌ Could not set model for ${instanceName}.`;
|
|
6552
|
+
if (strategy === "runtime") {
|
|
6553
|
+
this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
|
|
6554
|
+
return `${warn}✅ Switched ${instanceName} to \`${model}\` (runtime).${this.effortSuffix(instanceName)}`;
|
|
6555
|
+
}
|
|
6272
6556
|
await this.restartSingleInstance(instanceName);
|
|
6273
6557
|
return `${warn}✅ Set ${instanceName} to \`${model}\` and restarted.${this.effortSuffix(instanceName)}`;
|
|
6274
6558
|
}
|
|
@@ -6606,11 +6890,11 @@ When users create specialized instances, suggest these configurations:
|
|
|
6606
6890
|
for (const pending of this.pendingClassicStarts.values())
|
|
6607
6891
|
clearTimeout(pending.timer);
|
|
6608
6892
|
this.pendingClassicStarts.clear();
|
|
6609
|
-
for (const pending of this.
|
|
6893
|
+
for (const pending of this.pendingNonceButtons.values()) {
|
|
6610
6894
|
if (pending.timer)
|
|
6611
6895
|
clearTimeout(pending.timer);
|
|
6612
6896
|
}
|
|
6613
|
-
this.
|
|
6897
|
+
this.pendingNonceButtons.clear();
|
|
6614
6898
|
this.topicArchiver.stop();
|
|
6615
6899
|
this.scheduler?.shutdown();
|
|
6616
6900
|
// Stop instances in parallel batches to avoid long sequential waits.
|