@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.
Files changed (56) hide show
  1. package/dist/backend/antigravity.d.ts +1 -0
  2. package/dist/backend/antigravity.js +1 -0
  3. package/dist/backend/antigravity.js.map +1 -1
  4. package/dist/backend/claude-code.d.ts +1 -0
  5. package/dist/backend/claude-code.js +1 -0
  6. package/dist/backend/claude-code.js.map +1 -1
  7. package/dist/backend/codex.d.ts +1 -0
  8. package/dist/backend/codex.js +1 -0
  9. package/dist/backend/codex.js.map +1 -1
  10. package/dist/backend/gemini-cli.d.ts +1 -0
  11. package/dist/backend/gemini-cli.js +1 -0
  12. package/dist/backend/gemini-cli.js.map +1 -1
  13. package/dist/backend/grok.d.ts +1 -0
  14. package/dist/backend/grok.js +1 -0
  15. package/dist/backend/grok.js.map +1 -1
  16. package/dist/backend/kiro.d.ts +1 -0
  17. package/dist/backend/kiro.js +1 -0
  18. package/dist/backend/kiro.js.map +1 -1
  19. package/dist/backend/mock.d.ts +1 -0
  20. package/dist/backend/mock.js +1 -0
  21. package/dist/backend/mock.js.map +1 -1
  22. package/dist/backend/opencode.d.ts +24 -0
  23. package/dist/backend/opencode.js +70 -1
  24. package/dist/backend/opencode.js.map +1 -1
  25. package/dist/backend/types.d.ts +6 -0
  26. package/dist/backend/types.js.map +1 -1
  27. package/dist/channel/adapters/discord.js +17 -7
  28. package/dist/channel/adapters/discord.js.map +1 -1
  29. package/dist/channel/types.d.ts +1 -1
  30. package/dist/cli.js +11 -3
  31. package/dist/cli.js.map +1 -1
  32. package/dist/daemon.js +8 -3
  33. package/dist/daemon.js.map +1 -1
  34. package/dist/fleet-context.d.ts +3 -0
  35. package/dist/fleet-manager.d.ts +68 -0
  36. package/dist/fleet-manager.js +568 -76
  37. package/dist/fleet-manager.js.map +1 -1
  38. package/dist/instance-lifecycle.d.ts +8 -0
  39. package/dist/instance-lifecycle.js +49 -33
  40. package/dist/instance-lifecycle.js.map +1 -1
  41. package/dist/locale.js +98 -0
  42. package/dist/locale.js.map +1 -1
  43. package/dist/outbound-schemas.d.ts +1 -1
  44. package/dist/restart-progress.d.ts +7 -1
  45. package/dist/restart-progress.js +27 -6
  46. package/dist/restart-progress.js.map +1 -1
  47. package/dist/topic-commands.d.ts +9 -0
  48. package/dist/topic-commands.js +52 -1
  49. package/dist/topic-commands.js.map +1 -1
  50. package/dist/update-marker.d.ts +30 -0
  51. package/dist/update-marker.js +71 -20
  52. package/dist/update-marker.js.map +1 -1
  53. package/dist/update-progress.d.ts +6 -0
  54. package/dist/update-progress.js +29 -0
  55. package/dist/update-progress.js.map +1 -0
  56. package/package.json +2 -2
@@ -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
- return this.lifecycle.stop(name);
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(() => startupProgress.start(this.restartProgressTarget()));
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.callbackData.startsWith("hang:")) {
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
- const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
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 (data.callbackData.startsWith("hang:")) {
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
- const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
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
- return stripAnsi(e.stdout ?? e.message ?? "Doctor failed");
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
- if (!adapter)
4894
- return;
4895
- const channelCfg = this.getChannelConfig(this.getInstanceAdapterId(instanceName));
4896
- const groupId = channelCfg?.group_id;
4897
- if (!groupId)
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
- const threadId = this.fleetConfig?.instances[instanceName]?.topic_id;
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 adapter.notifyAlert(String(groupId), {
4909
- type: "hang",
5360
+ await this.postNonceButtonPrompt({
5361
+ prefix: HANG_CALLBACK_PREFIX,
5362
+ alertType: "hang",
4910
5363
  instanceName,
4911
- message: `⚠️ ${instanceName} may be stuck — pane unchanged for ${unchangedMinutes}min, ready prompt not recognized`,
5364
+ adapter,
5365
+ adapterId,
5366
+ chatId,
5367
+ threadId,
5368
+ message: t("hang.detected", instanceName, unchangedMinutes),
4912
5369
  choices: [
4913
- { id: `hang:restart:${instanceName}`, label: "🔄 Force restart" },
4914
- { id: `hang:wait:${instanceName}`, label: "⏳ Keep waiting" },
5370
+ { action: "restart", label: t("hang.restart") },
5371
+ { action: "wait", label: t("hang.wait") },
4915
5372
  ],
4916
- }, {
4917
- threadId: threadId != null ? String(threadId) : undefined,
4918
- }).catch(e => this.logger.warn({ err: e }, "Failed to send hang notification"));
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.