@songsid/agend 2.1.4-beta.1 → 2.1.4-beta.3
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/antigravity.d.ts +1 -0
- package/dist/backend/antigravity.js +1 -0
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.d.ts +1 -0
- package/dist/backend/claude-code.js +1 -0
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.d.ts +1 -0
- package/dist/backend/codex.js +1 -0
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/gemini-cli.d.ts +1 -0
- package/dist/backend/gemini-cli.js +1 -0
- package/dist/backend/gemini-cli.js.map +1 -1
- package/dist/backend/grok.d.ts +1 -0
- package/dist/backend/grok.js +1 -0
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro.d.ts +1 -0
- package/dist/backend/kiro.js +1 -0
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/mock.d.ts +1 -0
- package/dist/backend/mock.js +1 -0
- package/dist/backend/mock.js.map +1 -1
- package/dist/backend/opencode.d.ts +24 -0
- package/dist/backend/opencode.js +70 -1
- package/dist/backend/opencode.js.map +1 -1
- package/dist/backend/types.d.ts +6 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.js +17 -7
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/types.d.ts +1 -1
- package/dist/cli.js +11 -3
- package/dist/cli.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 +68 -0
- package/dist/fleet-manager.js +568 -76
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +8 -0
- package/dist/instance-lifecycle.js +49 -33
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +98 -0
- package/dist/locale.js.map +1 -1
- package/dist/outbound-schemas.d.ts +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.d.ts +9 -0
- package/dist/topic-commands.js +52 -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 +2 -2
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
|
*
|
|
@@ -165,6 +172,11 @@ const CLASSIC_BACKEND_SELECTION_TIMEOUT_MS = 60_000;
|
|
|
165
172
|
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:";
|
|
175
|
+
const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
|
|
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;
|
|
168
180
|
const CLI_ENV_TTL_MS = 24 * 60 * 60 * 1000; // /model reads cached CLI env within 24h
|
|
169
181
|
export class FleetManager {
|
|
170
182
|
dataDir;
|
|
@@ -253,6 +265,8 @@ export class FleetManager {
|
|
|
253
265
|
/** In-flight /effort selections, same coordinator shape as pendingModelSelects. */
|
|
254
266
|
pendingEffortSelects = new Map();
|
|
255
267
|
pendingModelSelects = new Map();
|
|
268
|
+
/** nonce → pending button prompt (hang restart, interactive assist, clean-exit restart). */
|
|
269
|
+
pendingNonceButtons = new Map();
|
|
256
270
|
// Model failover state
|
|
257
271
|
failoverActive = new Map(); // instance → current failover model
|
|
258
272
|
// IPC reconnect: tracks instances being intentionally stopped (skip reconnect)
|
|
@@ -274,6 +288,9 @@ export class FleetManager {
|
|
|
274
288
|
healthServer = null;
|
|
275
289
|
healthPortRetried = false;
|
|
276
290
|
updateCheckTimer = null;
|
|
291
|
+
updateProgressTimer = null;
|
|
292
|
+
updateProgressEditRunning = false;
|
|
293
|
+
lastUpdateProgressText = null;
|
|
277
294
|
eventLogPruneTimer = null;
|
|
278
295
|
logRotateTimer = null;
|
|
279
296
|
/** Days of event/activity history to keep. */
|
|
@@ -553,6 +570,38 @@ export class FleetManager {
|
|
|
553
570
|
}
|
|
554
571
|
await data.respond(await this.topicCommands.runPauseWake(target, action));
|
|
555
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
|
+
}
|
|
592
|
+
/** Admin-only full conversation reset for fleet-topic and Classic instances. */
|
|
593
|
+
async handleClearSlash(data, adapterId) {
|
|
594
|
+
if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
|
|
595
|
+
await data.respond(t("permission.denied"));
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
599
|
+
if (!name) {
|
|
600
|
+
await data.respond(t("classic.no_agent"));
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
await data.respond(await this.topicCommands.sendClear(name));
|
|
604
|
+
}
|
|
556
605
|
/** Get the adapter bound to an instance, falling back to primary adapter */
|
|
557
606
|
getAdapterForInstance(name) {
|
|
558
607
|
const worldId = this.getInstanceAdapterId(name);
|
|
@@ -1193,7 +1242,18 @@ export class FleetManager {
|
|
|
1193
1242
|
this.instanceStateCache.delete(name);
|
|
1194
1243
|
this.instanceProcessStatus.delete(name);
|
|
1195
1244
|
this.lastDeliveryAt.delete(name);
|
|
1196
|
-
|
|
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
|
+
}
|
|
1197
1257
|
}
|
|
1198
1258
|
/** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
|
|
1199
1259
|
async restartSingleInstance(name, opts) {
|
|
@@ -1293,6 +1353,65 @@ export class FleetManager {
|
|
|
1293
1353
|
getDashboardAccess() {
|
|
1294
1354
|
return { ready: this.healthServerListening, token: this.webToken };
|
|
1295
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
|
+
}
|
|
1296
1415
|
/** Start all instances from fleet config */
|
|
1297
1416
|
async startAll(configPath) {
|
|
1298
1417
|
const startupStartedAt = Date.now();
|
|
@@ -1307,6 +1426,15 @@ export class FleetManager {
|
|
|
1307
1426
|
rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
|
|
1308
1427
|
const fleet = this.loadConfig(configPath);
|
|
1309
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
|
+
}
|
|
1310
1438
|
this.initializeWebAuthTokens();
|
|
1311
1439
|
const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
|
|
1312
1440
|
// Set tmux socket isolation for custom AGEND_HOME
|
|
@@ -1563,7 +1691,7 @@ export class FleetManager {
|
|
|
1563
1691
|
const allEntries = Object.entries(fleet.instances);
|
|
1564
1692
|
const generals = allEntries.filter(([_, cfg]) => cfg.general_topic);
|
|
1565
1693
|
const others = allEntries.filter(([_, cfg]) => !cfg.general_topic);
|
|
1566
|
-
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" });
|
|
1567
1695
|
if (generals.length > 0) {
|
|
1568
1696
|
for (const [name, cfg] of generals) {
|
|
1569
1697
|
try {
|
|
@@ -1602,7 +1730,19 @@ export class FleetManager {
|
|
|
1602
1730
|
this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
|
|
1603
1731
|
}
|
|
1604
1732
|
})();
|
|
1605
|
-
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
|
+
});
|
|
1606
1746
|
}
|
|
1607
1747
|
// The systemd watchdog answers exactly one question: is this process still
|
|
1608
1748
|
// turning its event loop? Pinging from a timer proves that, and after the
|
|
@@ -1980,31 +2120,18 @@ export class FleetManager {
|
|
|
1980
2120
|
await this.handleInboundReaction(r);
|
|
1981
2121
|
}, this.logger, "adapter.reaction"));
|
|
1982
2122
|
this.adapter.on("callback_query", safeHandler(async (data) => {
|
|
2123
|
+
if (await this.handleExitRestartPrompt(data, adapterId, this.adapter ?? undefined))
|
|
2124
|
+
return;
|
|
2125
|
+
if (await this.handleInteractivePromptAssist(data, adapterId, this.adapter ?? undefined))
|
|
2126
|
+
return;
|
|
1983
2127
|
if (await this.handleClassicBackendSelection(data))
|
|
1984
2128
|
return;
|
|
1985
2129
|
if (await this.handleModelSelection(data))
|
|
1986
2130
|
return;
|
|
1987
2131
|
if (await this.handleEffortSelection(data))
|
|
1988
2132
|
return;
|
|
1989
|
-
if (data.
|
|
1990
|
-
const parts = data.callbackData.split(":");
|
|
1991
|
-
const action = parts[1];
|
|
1992
|
-
const instanceName = parts[2];
|
|
1993
|
-
if (action === "restart") {
|
|
1994
|
-
await this.stopInstance(instanceName);
|
|
1995
|
-
const config = this.fleetConfig?.instances[instanceName];
|
|
1996
|
-
if (config) {
|
|
1997
|
-
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
1998
|
-
await this.startInstance(instanceName, config, topicMode);
|
|
1999
|
-
// startInstance already calls connectIpcToInstance
|
|
2000
|
-
}
|
|
2001
|
-
this.adapter?.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
|
|
2002
|
-
}
|
|
2003
|
-
else {
|
|
2004
|
-
this.adapter?.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
|
|
2005
|
-
}
|
|
2133
|
+
if (await this.handleHangPrompt(data, adapterId, this.adapter ?? undefined))
|
|
2006
2134
|
return;
|
|
2007
|
-
}
|
|
2008
2135
|
if (data.callbackData.startsWith("cancel:")) {
|
|
2009
2136
|
this.handleCancelClick(data.callbackData.slice("cancel:".length), this.adapter, data);
|
|
2010
2137
|
return;
|
|
@@ -2083,6 +2210,9 @@ export class FleetManager {
|
|
|
2083
2210
|
const result = await this.topicCommands.sendCompact(name);
|
|
2084
2211
|
await data.respond(result);
|
|
2085
2212
|
}
|
|
2213
|
+
else if (data.command === "clear") {
|
|
2214
|
+
await this.handleClearSlash(data, adapterId);
|
|
2215
|
+
}
|
|
2086
2216
|
else if (data.command === "model") {
|
|
2087
2217
|
await this.handleModelSlash(data, adapterId);
|
|
2088
2218
|
}
|
|
@@ -2135,17 +2265,7 @@ export class FleetManager {
|
|
|
2135
2265
|
: t("collab.off.classic"));
|
|
2136
2266
|
}
|
|
2137
2267
|
else if (data.command === "update") {
|
|
2138
|
-
|
|
2139
|
-
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
2140
|
-
await data.respond(t("not_authorized"));
|
|
2141
|
-
return;
|
|
2142
|
-
}
|
|
2143
|
-
await data.respond(t("update.running"));
|
|
2144
|
-
const { spawn } = await import("node:child_process");
|
|
2145
|
-
const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
|
|
2146
|
-
const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
|
|
2147
|
-
const child = spawn("sh", ["-c", `sleep 2 && ${_cmd}`], { detached: true, stdio: "ignore" });
|
|
2148
|
-
child.unref();
|
|
2268
|
+
await this.handleUpdateSlash(data, adapterId);
|
|
2149
2269
|
}
|
|
2150
2270
|
else if (data.command === "doctor") {
|
|
2151
2271
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
@@ -2290,30 +2410,18 @@ export class FleetManager {
|
|
|
2290
2410
|
await this.handleInboundReaction(r);
|
|
2291
2411
|
}, this.logger, `adapter[${adapterId}].reaction`));
|
|
2292
2412
|
adapter.on("callback_query", safeHandler(async (data) => {
|
|
2413
|
+
if (await this.handleExitRestartPrompt(data, adapterId, adapter))
|
|
2414
|
+
return;
|
|
2415
|
+
if (await this.handleInteractivePromptAssist(data, adapterId, adapter))
|
|
2416
|
+
return;
|
|
2293
2417
|
if (await this.handleClassicBackendSelection(data))
|
|
2294
2418
|
return;
|
|
2295
2419
|
if (await this.handleModelSelection(data))
|
|
2296
2420
|
return;
|
|
2297
2421
|
if (await this.handleEffortSelection(data))
|
|
2298
2422
|
return;
|
|
2299
|
-
if (
|
|
2300
|
-
const parts = data.callbackData.split(":");
|
|
2301
|
-
const action = parts[1];
|
|
2302
|
-
const instanceName = parts[2];
|
|
2303
|
-
if (action === "restart") {
|
|
2304
|
-
await this.stopInstance(instanceName);
|
|
2305
|
-
const config = this.fleetConfig?.instances[instanceName];
|
|
2306
|
-
if (config) {
|
|
2307
|
-
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
2308
|
-
await this.startInstance(instanceName, config, topicMode);
|
|
2309
|
-
}
|
|
2310
|
-
adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
|
|
2311
|
-
}
|
|
2312
|
-
else {
|
|
2313
|
-
adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
|
|
2314
|
-
}
|
|
2423
|
+
if (await this.handleHangPrompt(data, adapterId, adapter))
|
|
2315
2424
|
return;
|
|
2316
|
-
}
|
|
2317
2425
|
if (data.callbackData.startsWith("cancel:")) {
|
|
2318
2426
|
this.handleCancelClick(data.callbackData.slice("cancel:".length), adapter, data);
|
|
2319
2427
|
return;
|
|
@@ -2385,6 +2493,9 @@ export class FleetManager {
|
|
|
2385
2493
|
else if (data.command === "model") {
|
|
2386
2494
|
await this.handleModelSlash(data, adapterId);
|
|
2387
2495
|
}
|
|
2496
|
+
else if (data.command === "clear") {
|
|
2497
|
+
await this.handleClearSlash(data, adapterId);
|
|
2498
|
+
}
|
|
2388
2499
|
else if (data.command === "effort") {
|
|
2389
2500
|
await this.handleEffortSlash(data, adapterId);
|
|
2390
2501
|
}
|
|
@@ -2434,17 +2545,7 @@ export class FleetManager {
|
|
|
2434
2545
|
: t("collab.off.classic"));
|
|
2435
2546
|
}
|
|
2436
2547
|
else if (data.command === "update") {
|
|
2437
|
-
|
|
2438
|
-
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
2439
|
-
await data.respond(t("not_authorized"));
|
|
2440
|
-
return;
|
|
2441
|
-
}
|
|
2442
|
-
await data.respond(t("update.running"));
|
|
2443
|
-
const { spawn } = await import("node:child_process");
|
|
2444
|
-
const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
|
|
2445
|
-
const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
|
|
2446
|
-
const child = spawn("sh", ["-c", `sleep 2 && ${_cmd}`], { detached: true, stdio: "ignore" });
|
|
2447
|
-
child.unref();
|
|
2548
|
+
await this.handleUpdateSlash(data, adapterId);
|
|
2448
2549
|
}
|
|
2449
2550
|
else if (data.command === "doctor") {
|
|
2450
2551
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
@@ -2818,6 +2919,10 @@ export class FleetManager {
|
|
|
2818
2919
|
this.logger.debug({ emoji: r.emoji, user: r.username }, "Ignoring delivery-status emoji as a reaction");
|
|
2819
2920
|
return;
|
|
2820
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
|
+
}
|
|
2821
2926
|
this.eventLog?.logActivity("reaction", r.username, `${r.emoji} ${r.action}`, instanceName);
|
|
2822
2927
|
if (r.action === "add") {
|
|
2823
2928
|
this.eventLog?.addReaction(instanceName, r.messageId, r.username, r.emoji);
|
|
@@ -3073,6 +3178,21 @@ export class FleetManager {
|
|
|
3073
3178
|
await msgAdapter?.sendText(chatId, result);
|
|
3074
3179
|
return;
|
|
3075
3180
|
}
|
|
3181
|
+
// Handle /clear command (admin only) — unlike /compact this starts a
|
|
3182
|
+
// fresh conversation and intentionally discards the current history.
|
|
3183
|
+
if (text === "/clear" || text.startsWith("/clear@")) {
|
|
3184
|
+
if (!this.isModelAdmin(msg.userId, chatId, msg.adapterId)) {
|
|
3185
|
+
await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/clear"));
|
|
3186
|
+
return;
|
|
3187
|
+
}
|
|
3188
|
+
const clearName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
3189
|
+
if (!clearName) {
|
|
3190
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
3191
|
+
return;
|
|
3192
|
+
}
|
|
3193
|
+
await msgAdapter?.sendText(chatId, await this.topicCommands.sendClear(clearName));
|
|
3194
|
+
return;
|
|
3195
|
+
}
|
|
3076
3196
|
// Handle /cancel command
|
|
3077
3197
|
if (text === "/cancel" || text.startsWith("/cancel@")) {
|
|
3078
3198
|
const cancelName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
@@ -4212,7 +4332,13 @@ export class FleetManager {
|
|
|
4212
4332
|
}
|
|
4213
4333
|
catch (err) {
|
|
4214
4334
|
const e = err;
|
|
4215
|
-
|
|
4335
|
+
const output = stripAnsi(e.stdout ?? "").trim();
|
|
4336
|
+
if (output)
|
|
4337
|
+
return output;
|
|
4338
|
+
if (e.code === "ENOENT") {
|
|
4339
|
+
return "Backend doctor unavailable — agend CLI not found in PATH";
|
|
4340
|
+
}
|
|
4341
|
+
return stripAnsi(e.message ?? "").trim() || "Doctor failed";
|
|
4216
4342
|
}
|
|
4217
4343
|
}
|
|
4218
4344
|
/** Drop event/activity rows older than the retention window. Best-effort. */
|
|
@@ -4364,6 +4490,316 @@ export class FleetManager {
|
|
|
4364
4490
|
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
|
|
4365
4491
|
}
|
|
4366
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
|
+
}
|
|
4692
|
+
interactivePromptLabel(kind) {
|
|
4693
|
+
const key = `interactive.kind.${kind}`;
|
|
4694
|
+
const translated = t(key);
|
|
4695
|
+
return translated === key ? kind.replaceAll("_", " ") : translated;
|
|
4696
|
+
}
|
|
4697
|
+
/**
|
|
4698
|
+
* Surface a stable terminal prompt in both places that matter:
|
|
4699
|
+
* - the blocked instance gets a plain pointer to General;
|
|
4700
|
+
* - General gets one-shot Confirm/Cancel controls on its own bound adapter.
|
|
4701
|
+
*
|
|
4702
|
+
* Only the prompt category crosses channels. The captured terminal line can
|
|
4703
|
+
* contain usernames or secret-adjacent text and stays in daemon.log.
|
|
4704
|
+
*/
|
|
4705
|
+
async notifyInteractivePrompt(instanceName, kind) {
|
|
4706
|
+
this.notifyInstanceTopic(instanceName, t("interactive.instance_notice", instanceName));
|
|
4707
|
+
// Multi-world fleets can have one General per bot/platform. Route the assist
|
|
4708
|
+
// request to the General bound to the same adapter as the blocked instance.
|
|
4709
|
+
const generalName = this.findGeneralInstance(this.getInstanceAdapterId(instanceName));
|
|
4710
|
+
if (!generalName) {
|
|
4711
|
+
this.logger.warn({ instanceName }, "Interactive prompt has no General topic notification target");
|
|
4712
|
+
return;
|
|
4713
|
+
}
|
|
4714
|
+
const adapterId = this.getInstanceAdapterId(generalName);
|
|
4715
|
+
const adapter = this.getAdapterForInstance(generalName);
|
|
4716
|
+
const chatId = this.getGroupIdForInstance(generalName);
|
|
4717
|
+
const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
|
|
4718
|
+
const threadId = topicId != null ? String(topicId) : undefined;
|
|
4719
|
+
if (!adapter || !adapterId || !chatId) {
|
|
4720
|
+
this.logger.warn({ instanceName, generalName, adapterId, chatId }, "Cannot address interactive prompt assistance controls");
|
|
4721
|
+
return;
|
|
4722
|
+
}
|
|
4723
|
+
const label = this.interactivePromptLabel(kind);
|
|
4724
|
+
await this.postNonceButtonPrompt({
|
|
4725
|
+
prefix: INTERACTIVE_ASSIST_CALLBACK_PREFIX,
|
|
4726
|
+
alertType: "interactive_prompt",
|
|
4727
|
+
instanceName,
|
|
4728
|
+
adapter,
|
|
4729
|
+
adapterId,
|
|
4730
|
+
chatId,
|
|
4731
|
+
threadId,
|
|
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
|
+
});
|
|
4740
|
+
}
|
|
4741
|
+
/** Consume a General assist button exactly once. */
|
|
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)
|
|
4745
|
+
return false;
|
|
4746
|
+
if (claimed === "consumed")
|
|
4747
|
+
return true;
|
|
4748
|
+
const { entry: pending, action } = claimed;
|
|
4749
|
+
this.eventLog?.insert(pending.instanceName, "interactive_prompt_assist", {
|
|
4750
|
+
action,
|
|
4751
|
+
userId: data.userId,
|
|
4752
|
+
generalName: pending.generalName,
|
|
4753
|
+
});
|
|
4754
|
+
if (action === "cancel") {
|
|
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));
|
|
4766
|
+
return true;
|
|
4767
|
+
}
|
|
4768
|
+
// Injecting into General while General itself is blocked would type into the
|
|
4769
|
+
// terminal prompt instead of the agent input. Fail safe and require attach.
|
|
4770
|
+
if (pending.instanceName === generalName) {
|
|
4771
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.self_assist", pending.instanceName));
|
|
4772
|
+
return true;
|
|
4773
|
+
}
|
|
4774
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.confirmed", pending.instanceName));
|
|
4775
|
+
try {
|
|
4776
|
+
await this.deliverToInstance(generalName, {
|
|
4777
|
+
type: "fleet_inbound",
|
|
4778
|
+
content: t("interactive.assist_request", pending.instanceName, this.interactivePromptLabel(pending.promptKind ?? "")),
|
|
4779
|
+
targetSession: generalName,
|
|
4780
|
+
meta: {
|
|
4781
|
+
chat_id: pending.chatId,
|
|
4782
|
+
message_id: pending.messageId ?? data.messageId,
|
|
4783
|
+
user: "AgEnD",
|
|
4784
|
+
user_id: data.userId,
|
|
4785
|
+
ts: new Date().toISOString(),
|
|
4786
|
+
thread_id: pending.threadId ?? "",
|
|
4787
|
+
adapter_id: pending.adapterId,
|
|
4788
|
+
source: pending.adapter.type,
|
|
4789
|
+
},
|
|
4790
|
+
}, {
|
|
4791
|
+
// This is system-generated work, not a live user message. Queue behind
|
|
4792
|
+
// General's current turn so Kiro/Claude do not lose it while busy.
|
|
4793
|
+
isCrossInstance: true,
|
|
4794
|
+
});
|
|
4795
|
+
this.logger.info({ instanceName: pending.instanceName, generalName: pending.generalName }, "Interactive prompt assistance delivered to General");
|
|
4796
|
+
}
|
|
4797
|
+
catch (err) {
|
|
4798
|
+
this.logger.warn({ err, instanceName: pending.instanceName, generalName: pending.generalName }, "Failed to deliver interactive prompt assistance to General");
|
|
4799
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("interactive.delivery_failed", pending.instanceName), pending.threadId);
|
|
4800
|
+
}
|
|
4801
|
+
return true;
|
|
4802
|
+
}
|
|
4367
4803
|
// ── Cancel button ────────────────────────────────────────────────────
|
|
4368
4804
|
// Sent after delivering a user message to an instance; clicking it (or
|
|
4369
4805
|
// /cancel) sends Escape to the instance's pane to interrupt generation.
|
|
@@ -4890,13 +5326,29 @@ export class FleetManager {
|
|
|
4890
5326
|
}
|
|
4891
5327
|
async sendHangNotification(instanceName, unchangedForMs) {
|
|
4892
5328
|
const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
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");
|
|
4898
5350
|
return;
|
|
4899
|
-
|
|
5351
|
+
}
|
|
4900
5352
|
const instanceHangConfig = this.fleetConfig?.instances[instanceName]?.hang_detector;
|
|
4901
5353
|
const configuredMinutes = instanceHangConfig?.timeout_minutes
|
|
4902
5354
|
?? this.fleetConfig?.defaults?.hang_detector?.timeout_minutes
|
|
@@ -4905,17 +5357,52 @@ export class FleetManager {
|
|
|
4905
5357
|
? configuredMinutes
|
|
4906
5358
|
: Math.max(1, Math.floor(unchangedForMs / 60_000));
|
|
4907
5359
|
this.setTopicIcon(instanceName, "red");
|
|
4908
|
-
await
|
|
4909
|
-
|
|
5360
|
+
await this.postNonceButtonPrompt({
|
|
5361
|
+
prefix: HANG_CALLBACK_PREFIX,
|
|
5362
|
+
alertType: "hang",
|
|
4910
5363
|
instanceName,
|
|
4911
|
-
|
|
5364
|
+
adapter,
|
|
5365
|
+
adapterId,
|
|
5366
|
+
chatId,
|
|
5367
|
+
threadId,
|
|
5368
|
+
message: t("hang.detected", instanceName, unchangedMinutes),
|
|
4912
5369
|
choices: [
|
|
4913
|
-
{
|
|
4914
|
-
{
|
|
5370
|
+
{ action: "restart", label: t("hang.restart") },
|
|
5371
|
+
{ action: "wait", label: t("hang.wait") },
|
|
4915
5372
|
],
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
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;
|
|
4919
5406
|
}
|
|
4920
5407
|
// ── Topic icon + auto-archive ─────────────────────────────────────────────
|
|
4921
5408
|
static INSTRUCTIONS_FILENAME = {
|
|
@@ -6401,6 +6888,11 @@ When users create specialized instances, suggest these configurations:
|
|
|
6401
6888
|
for (const pending of this.pendingClassicStarts.values())
|
|
6402
6889
|
clearTimeout(pending.timer);
|
|
6403
6890
|
this.pendingClassicStarts.clear();
|
|
6891
|
+
for (const pending of this.pendingNonceButtons.values()) {
|
|
6892
|
+
if (pending.timer)
|
|
6893
|
+
clearTimeout(pending.timer);
|
|
6894
|
+
}
|
|
6895
|
+
this.pendingNonceButtons.clear();
|
|
6404
6896
|
this.topicArchiver.stop();
|
|
6405
6897
|
this.scheduler?.shutdown();
|
|
6406
6898
|
// Stop instances in parallel batches to avoid long sequential waits.
|