@songsid/agend 2.1.4-beta.2 → 2.1.4-beta.4

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