@songsid/agend 2.1.5-beta.9 → 2.1.6-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/backend/claude-code.d.ts +12 -0
  2. package/dist/backend/claude-code.js +12 -0
  3. package/dist/backend/claude-code.js.map +1 -1
  4. package/dist/backend/codex.d.ts +41 -1
  5. package/dist/backend/codex.js +126 -0
  6. package/dist/backend/codex.js.map +1 -1
  7. package/dist/backend/kiro.js +30 -2
  8. package/dist/backend/kiro.js.map +1 -1
  9. package/dist/backend/types.d.ts +61 -0
  10. package/dist/backend/types.js.map +1 -1
  11. package/dist/channel/adapters/discord.d.ts +17 -2
  12. package/dist/channel/adapters/discord.js +64 -5
  13. package/dist/channel/adapters/discord.js.map +1 -1
  14. package/dist/channel/adapters/telegram.d.ts +41 -2
  15. package/dist/channel/adapters/telegram.js +116 -20
  16. package/dist/channel/adapters/telegram.js.map +1 -1
  17. package/dist/channel/factory.js +6 -1
  18. package/dist/channel/factory.js.map +1 -1
  19. package/dist/channel/types.d.ts +33 -1
  20. package/dist/classic-channel-manager.d.ts +23 -3
  21. package/dist/classic-channel-manager.js +35 -0
  22. package/dist/classic-channel-manager.js.map +1 -1
  23. package/dist/config-validator.js +23 -0
  24. package/dist/config-validator.js.map +1 -1
  25. package/dist/config.d.ts +4 -0
  26. package/dist/config.js +5 -0
  27. package/dist/config.js.map +1 -1
  28. package/dist/daemon.d.ts +158 -21
  29. package/dist/daemon.js +1099 -200
  30. package/dist/daemon.js.map +1 -1
  31. package/dist/deadline.d.ts +23 -0
  32. package/dist/deadline.js +29 -0
  33. package/dist/deadline.js.map +1 -0
  34. package/dist/fleet-context.d.ts +15 -1
  35. package/dist/fleet-context.js.map +1 -1
  36. package/dist/fleet-manager.d.ts +186 -3
  37. package/dist/fleet-manager.js +833 -119
  38. package/dist/fleet-manager.js.map +1 -1
  39. package/dist/general-knowledge/skills/fleet-config/SKILL.md +26 -2
  40. package/dist/instance-lifecycle.d.ts +8 -2
  41. package/dist/instance-lifecycle.js +47 -3
  42. package/dist/instance-lifecycle.js.map +1 -1
  43. package/dist/instance-removal.d.ts +13 -0
  44. package/dist/instance-removal.js +14 -0
  45. package/dist/instance-removal.js.map +1 -0
  46. package/dist/instructions.js +2 -2
  47. package/dist/instructions.js.map +1 -1
  48. package/dist/locale.js +28 -2
  49. package/dist/locale.js.map +1 -1
  50. package/dist/login-controller.d.ts +31 -4
  51. package/dist/login-controller.js +52 -4
  52. package/dist/login-controller.js.map +1 -1
  53. package/dist/login-flows.d.ts +29 -0
  54. package/dist/login-flows.js +48 -1
  55. package/dist/login-flows.js.map +1 -1
  56. package/dist/outbound-handlers.d.ts +1 -0
  57. package/dist/outbound-handlers.js +118 -29
  58. package/dist/outbound-handlers.js.map +1 -1
  59. package/dist/outbound-schemas.d.ts +7 -0
  60. package/dist/outbound-schemas.js +3 -0
  61. package/dist/outbound-schemas.js.map +1 -1
  62. package/dist/pane-input-residue.d.ts +25 -2
  63. package/dist/pane-input-residue.js +31 -2
  64. package/dist/pane-input-residue.js.map +1 -1
  65. package/dist/restart-progress.js +3 -21
  66. package/dist/restart-progress.js.map +1 -1
  67. package/dist/settings-api.d.ts +1 -1
  68. package/dist/settings-api.js +38 -6
  69. package/dist/settings-api.js.map +1 -1
  70. package/dist/topic-commands.d.ts +27 -2
  71. package/dist/topic-commands.js +37 -6
  72. package/dist/topic-commands.js.map +1 -1
  73. package/dist/turn-reply-guard.d.ts +41 -0
  74. package/dist/turn-reply-guard.js +77 -0
  75. package/dist/turn-reply-guard.js.map +1 -0
  76. package/dist/types.d.ts +7 -0
  77. package/dist/ui/dashboard.html +38 -10
  78. package/dist/ui/settings.html +93 -15
  79. package/dist/ui/view.html +35 -20
  80. package/dist/view-api.d.ts +1 -0
  81. package/dist/view-api.js +12 -2
  82. package/dist/view-api.js.map +1 -1
  83. package/dist/web-api.d.ts +22 -1
  84. package/dist/web-api.js +25 -1
  85. package/dist/web-api.js.map +1 -1
  86. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync } from "node:fs";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { spawnSync } from "node:child_process";
4
- import { freemem, totalmem } from "node:os";
4
+ import { freemem, totalmem, cpus } from "node:os";
5
5
  import { createServer } from "node:http";
6
6
  import { join, dirname, basename } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
@@ -33,7 +33,7 @@ import { processAttachments } from "./channel/attachment-handler.js";
33
33
  import { routeToolCall } from "./channel/tool-router.js";
34
34
  import { Scheduler } from "./scheduler/index.js";
35
35
  import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
36
- import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, resolveInstanceContext, forgetInstanceContext } from "./topic-commands.js";
36
+ import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, resolveInstanceContext, forgetInstanceContext, readStatuslineModel } from "./topic-commands.js";
37
37
  import { DailySummary } from "./daily-summary.js";
38
38
  import { WebhookEmitter } from "./webhook-emitter.js";
39
39
  import { TmuxControlClient } from "./tmux-control.js";
@@ -48,12 +48,14 @@ import { handleViewRequest, isViewPath } from "./view-api.js";
48
48
  import { handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
49
49
  import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES, checkAuthStatus } from "./login-flows.js";
50
50
  import { LoginSession } from "./login-manager.js";
51
- import { LoginController, LOGIN_TOKEN_RESEND_PREFIX } from "./login-controller.js";
51
+ import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS, announcePostLoginRecovery } from "./login-controller.js";
52
+ import { runBeforeDeadline } from "./deadline.js";
52
53
  import { LoginWindowLock } from "./login-window-lock.js";
53
54
  import { handleSettingsRequest } from "./settings-api.js";
54
55
  import { setLocale, detectLocale, getLocale, t } from "./locale.js";
55
56
  import { handleAgentRequest } from "./agent-endpoint.js";
56
57
  import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
58
+ import { assertExplicitInstanceRemoval } from "./instance-removal.js";
57
59
  import { validateFleetConfig } from "./config-validator.js";
58
60
  import { readLastInboundAt } from "./daemon.js";
59
61
  import { clearPausedMarker } from "./pause-marker.js";
@@ -179,6 +181,7 @@ const DELIVERY_STATUS_EMOJIS = new Set(["👀", "⏳", "✅", "❌"]);
179
181
  const IGNORED_REACTION_EMOJIS = new Set(["📷"]);
180
182
  const HOT_INSTANCE_CONFIG_KEYS = new Set([
181
183
  "tool_progress",
184
+ "reply_completion_guard",
182
185
  "mcp_proxy_reply",
183
186
  "auto_pause_after",
184
187
  "warm_cap",
@@ -219,7 +222,7 @@ const HANG_CALLBACK_PREFIX = "hang:";
219
222
  const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
220
223
  const TIP_DISMISS_CALLBACK_PREFIX = "tip-dismiss:";
221
224
  const TIP_UNLOCK_CALLBACK_PREFIX = "tip-unlock:";
222
- const LOGIN_CALLBACK_PREFIX = "login:";
225
+ export const LOGIN_CALLBACK_PREFIX = "login:";
223
226
  const INSTALL_CALLBACK_PREFIX = "install-select:";
224
227
  const CLASSIC_APPROVE_CALLBACK_PREFIX = "classic-approve:";
225
228
  const LOGIN_MENU_CALLBACK_PREFIX = "login-menu:";
@@ -262,6 +265,18 @@ const CLI_ENV_FRESH_MS = 60 * 60 * 1000;
262
265
  * The wait is announced before it starts, so it reads as progress, not a stall.
263
266
  */
264
267
  export const CLI_ENV_PROBE_DEADLINE_MS = 16_000;
268
+ /**
269
+ * How many CLIs may cold-start at once, from BOTH memory and cores.
270
+ *
271
+ * Memory alone said 10 on any host with roughly 3GB free, so a three-core box
272
+ * started ten CLIs together, saturated the CPU, and healthy starts then missed
273
+ * their startup budget — which used to cost the user their conversation. Cores
274
+ * bound how many can actually make progress; memory bounds how many fit.
275
+ */
276
+ export function deriveSpawnConcurrency(freeMemMB, cores) {
277
+ const byMemory = Math.floor(freeMemMB / 300);
278
+ return Math.max(2, Math.min(10, byMemory, Math.max(1, cores)));
279
+ }
265
280
  export class FleetManager {
266
281
  dataDir;
267
282
  static signalTarget = null;
@@ -303,6 +318,33 @@ export class FleetManager {
303
318
  reloadPending = false;
304
319
  /** A running reconciliation; only one may mutate lifecycle/config state at a time. */
305
320
  reconcileInFlight = null;
321
+ /** Topology checks are serialized separately from config reconciliation. */
322
+ topicCleanupInFlight = null;
323
+ topicCleanupGeneration = 0;
324
+ topicProbeWarnings = new Map();
325
+ /**
326
+ * Consecutive unknown probe results per route (or per adapter for outage
327
+ * class reasons). A single transient never reaches the operator; only a
328
+ * streak of TOPIC_PROBE_UNKNOWN_ESCALATION does.
329
+ */
330
+ topicProbeUnknownStreak = new Map();
331
+ /** Unknown results in a row before the operator is told. 3 × 5 min poller = 15 min. */
332
+ static TOPIC_PROBE_UNKNOWN_ESCALATION = 3;
333
+ /** Reasons that describe the adapter, not one topic — counted once per adapter. */
334
+ static TOPIC_PROBE_ADAPTER_SCOPED_REASONS = new Set([
335
+ "owner-adapter-unavailable",
336
+ "owner-adapter-not-ready",
337
+ "owner-adapter-generation-changed",
338
+ "owner-adapter-changed-before-action",
339
+ "adapter-not-ready",
340
+ "adapter-not-initialized",
341
+ "adapter-generation-changed",
342
+ "topic-close-from-unready-adapter",
343
+ "topic-close-generation-changed",
344
+ // Telegram probe: the transport or Telegram itself is down, not one topic.
345
+ "transport-failed",
346
+ "provider-unavailable",
347
+ ]);
306
348
  logger = createLogger("info");
307
349
  topicCommands;
308
350
  // sessionName → instanceName mapping for external sessions
@@ -341,6 +383,10 @@ export class FleetManager {
341
383
  // reply, on cancel, or when a newer button supersedes it for the same
342
384
  // instance. Per-button tracking means a failed delete never strands a button.
343
385
  cancelButtons = new Map();
386
+ /** Latest publication generation per instance. Late results from older
387
+ * generations are retired without touching the current button. */
388
+ cancelButtonPublications = new Map();
389
+ nextCancelButtonPublicationGeneration = 0;
344
390
  /** Pending idle-edge retirement, one timer per instance. */
345
391
  cancelButtonIdleRetireTimers = new Map();
346
392
  /** Duplicate-reply suppression across both the MCP and HTTP reply paths. */
@@ -452,8 +498,7 @@ export class FleetManager {
452
498
  const explicit = this.fleetConfig?.defaults?.startup?.concurrency;
453
499
  if (explicit != null)
454
500
  return Math.max(1, Math.min(20, explicit));
455
- const freeMemMB = Math.round(freemem() / (1024 * 1024));
456
- return Math.max(2, Math.min(10, Math.floor(freeMemMB / 300)));
501
+ return deriveSpawnConcurrency(Math.round(freemem() / (1024 * 1024)), cpus().length);
457
502
  }
458
503
  /** Wire the one fleet-wide storm into notification and recovery surfaces. */
459
504
  bindStormWindowEvents() {
@@ -916,6 +961,45 @@ export class FleetManager {
916
961
  }
917
962
  return channels[0];
918
963
  }
964
+ /**
965
+ * The configured world that owns `chatId`, when that is provably NOT the
966
+ * world `target` lives in. Returns undefined when they agree, when there is
967
+ * nothing to check, or when no configured channel claims the id.
968
+ *
969
+ * Deliberately one-sided: only a POSITIVE match against another channel's
970
+ * group id counts as foreign. A chat id that matches nothing may still be
971
+ * legitimate for this world (a classic channel, a DM), and treating
972
+ * "unrecognised" as "wrong" would stop seeding for cases that work today.
973
+ *
974
+ * Read from config rather than the live worlds map on purpose: a channel
975
+ * whose adapter failed to start still owns its group id, and the coordinates
976
+ * are just as unusable by the target's adapter either way.
977
+ *
978
+ * This is the other half of what scheduleSourceAdapter fixed. That one stops
979
+ * the trigger NOTICE being sent through the wrong bot; this one stops the
980
+ * same coordinates being planted as the target instance's reply context,
981
+ * which is what made its own replies fail until someone spoke to it (#752).
982
+ */
983
+ scheduleChatWorldMismatch(target, chatId) {
984
+ if (!chatId)
985
+ return undefined;
986
+ const targetWorld = this.getInstanceAdapterId(target);
987
+ if (!targetWorld)
988
+ return undefined;
989
+ const channels = this.fleetConfig?.channels
990
+ ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
991
+ // ALL owners, not the first: a persona bot shares the primary's guild
992
+ // (quickstart writes `group_id: primary.group_id`), so one group id is
993
+ // legitimately claimed by two channels. Taking the first match called a
994
+ // persona instance's own guild "another world" and stopped seeding a
995
+ // context it can address perfectly well.
996
+ const owners = channels.filter(ch => ch.group_id != null && String(ch.group_id) === String(chatId));
997
+ if (owners.length === 0)
998
+ return undefined;
999
+ if (owners.some(ch => (ch.id ?? ch.type) === targetWorld))
1000
+ return undefined;
1001
+ return owners[0].id ?? owners[0].type;
1002
+ }
919
1003
  /** Get the group_id for an instance's bound adapter */
920
1004
  getGroupIdForInstance(name) {
921
1005
  const adapterId = this.getInstanceAdapterId(name);
@@ -1101,12 +1185,17 @@ export class FleetManager {
1101
1185
  }
1102
1186
  scheduleIdleButtonRetirement(name) {
1103
1187
  this.cancelIdleButtonRetirement(name);
1188
+ // Bind this edge to the publication that was current when idle was
1189
+ // observed. A later inbound may start a new generation before this timer
1190
+ // fires; the old edge must not mark that newer button for retirement.
1191
+ const publication = this.cancelButtonPublications.get(name);
1104
1192
  const timer = setTimeout(() => {
1105
1193
  // Ignore a superseded timer even if it was already queued to run.
1106
1194
  if (this.cancelButtonIdleRetireTimers.get(name) !== timer)
1107
1195
  return;
1108
1196
  this.cancelButtonIdleRetireTimers.delete(name);
1109
1197
  if (this.getInstanceExecutionState(name) === "idle") {
1198
+ this.markCancelButtonPublicationForRetirement(name, publication);
1110
1199
  this.retireInstanceButtons(name);
1111
1200
  }
1112
1201
  }, CANCEL_BTN_IDLE_RETIRE_GRACE_MS);
@@ -1426,8 +1515,27 @@ export class FleetManager {
1426
1515
  await this.lifecycle.pause(name);
1427
1516
  return this.lifecycle.isPaused(name) ? "paused" : "not_idle";
1428
1517
  }
1518
+ /** Deliver an already-resolved hot snapshot without depending on IPC timing. */
1519
+ applyHotConfigUpdate(instanceName, update) {
1520
+ const daemon = this.daemons.get(instanceName);
1521
+ if (!daemon)
1522
+ return false;
1523
+ const ipc = this.instanceIpcClients.get(instanceName);
1524
+ const sent = ipc?.connected === true && ipc.send({ type: "config_update", config: update });
1525
+ if (!sent) {
1526
+ daemon.applyConfigUpdate(update);
1527
+ this.logger.warn({ name: instanceName }, "Config-update IPC unavailable — applied hot config in-process");
1528
+ }
1529
+ return true;
1530
+ }
1531
+ classicBehaviorUpdate(instanceName) {
1532
+ return {
1533
+ tool_progress: this.classicChannels?.getToolProgressByInstance(instanceName, this.fleetConfig?.defaults?.tool_progress) ?? "off",
1534
+ reply_completion_guard: this.classicChannels?.getReplyCompletionGuardByInstance(instanceName, this.fleetConfig?.defaults?.reply_completion_guard) ?? true,
1535
+ };
1536
+ }
1429
1537
  /** Apply a Settings edit to a ClassicBot channel without waiting for the poller. */
1430
- async restartClassicInstanceFromSettings(instanceName) {
1538
+ async restartClassicInstanceFromSettings(instanceName, changedFields = []) {
1431
1539
  if (!this.classicChannels)
1432
1540
  throw new Error("Classic channel manager not initialized");
1433
1541
  const wasRunning = this.daemons.has(instanceName);
@@ -1439,10 +1547,77 @@ export class FleetManager {
1439
1547
  throw new Error("Classic channel not found after reload");
1440
1548
  if (!wasRunning)
1441
1549
  return;
1550
+ const hotOnly = changedFields.length > 0
1551
+ && changedFields.every(field => field === "tool_progress" || field === "reply_completion_guard");
1552
+ if (hotOnly) {
1553
+ this.applyHotConfigUpdate(instanceName, this.classicBehaviorUpdate(instanceName));
1554
+ this.logger.info({ instanceName, fields: changedFields }, "Classic instance hot config reloaded");
1555
+ return;
1556
+ }
1442
1557
  await this.stopInstance(instanceName);
1443
1558
  await new Promise(resolve => setTimeout(resolve, 250));
1444
1559
  await this.startClassicInstance(instanceName, this.classicChannels.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channel.channelId, channel.adapterId), this.classicChannels.getModel(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
1445
1560
  }
1561
+ /** Reload classicBot.yaml once. Kept callable so the periodic production
1562
+ * path is covered without relying on fake timers around startAll(). */
1563
+ async reloadClassicConfigFromDisk() {
1564
+ try {
1565
+ if (!this.classicChannels)
1566
+ return;
1567
+ const fleetBackend = this.fleetConfig?.defaults?.backend;
1568
+ const fleetModel = this.fleetConfig?.defaults?.model;
1569
+ const oldBackends = new Map();
1570
+ const oldModels = new Map();
1571
+ const oldAutoPause = new Map();
1572
+ const oldToolProgress = new Map();
1573
+ const oldReplyGuard = new Map();
1574
+ for (const ch of this.classicChannels.getAll()) {
1575
+ oldBackends.set(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend));
1576
+ oldModels.set(ch.instanceName, this.classicChannels.getModel(ch.channelId, ch.adapterId, fleetModel));
1577
+ oldAutoPause.set(ch.instanceName, this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
1578
+ oldToolProgress.set(ch.instanceName, this.classicChannels.getToolProgress(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.tool_progress));
1579
+ oldReplyGuard.set(ch.instanceName, this.classicChannels.getReplyCompletionGuard(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.reply_completion_guard));
1580
+ }
1581
+ if (!this.classicChannels.checkReload())
1582
+ return;
1583
+ // A reload can introduce a bad id (hand edit) or clear one; the
1584
+ // throttle keeps a repeated report from flooding the topic.
1585
+ this.reportClassicUnrecoverableIds();
1586
+ this.reregisterClassicChannels();
1587
+ for (const ch of this.classicChannels.getAll()) {
1588
+ const newBackend = this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend);
1589
+ const newModel = this.classicChannels.getModel(ch.channelId, ch.adapterId, fleetModel);
1590
+ const newAutoPause = this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after);
1591
+ const newToolProgress = this.classicChannels.getToolProgress(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.tool_progress);
1592
+ const newReplyGuard = this.classicChannels.getReplyCompletionGuard(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.reply_completion_guard);
1593
+ const backendChanged = oldBackends.get(ch.instanceName) !== newBackend;
1594
+ const modelChanged = oldModels.get(ch.instanceName) !== newModel;
1595
+ const autoPauseChanged = oldAutoPause.get(ch.instanceName) !== newAutoPause;
1596
+ if (this.daemons.has(ch.instanceName) && (backendChanged || modelChanged || autoPauseChanged)) {
1597
+ this.logger.info({ instanceName: ch.instanceName, backendFrom: oldBackends.get(ch.instanceName), backendTo: newBackend, modelFrom: oldModels.get(ch.instanceName), modelTo: newModel }, "Backend/model changed — restarting");
1598
+ await this.stopInstance(ch.instanceName).catch(() => { });
1599
+ // Small delay to let tmux window clean up
1600
+ await new Promise(r => setTimeout(r, 2000));
1601
+ // The manager already holds the new backend/model/auto-pause; the
1602
+ // unattended helper reads them from it and schedules the delayed
1603
+ // retry on failure like every other unattended start.
1604
+ await this.startClassicInstanceUnattended(ch, "classic instance after backend/model change");
1605
+ }
1606
+ else if (this.daemons.has(ch.instanceName)
1607
+ && (oldToolProgress.get(ch.instanceName) !== newToolProgress
1608
+ || oldReplyGuard.get(ch.instanceName) !== newReplyGuard)) {
1609
+ this.applyHotConfigUpdate(ch.instanceName, {
1610
+ tool_progress: newToolProgress,
1611
+ reply_completion_guard: newReplyGuard,
1612
+ });
1613
+ this.logger.info({ instanceName: ch.instanceName }, "Classic instance hot config reloaded");
1614
+ }
1615
+ }
1616
+ }
1617
+ catch (err) {
1618
+ this.logger.warn({ err }, "classicBot.yaml reload error");
1619
+ }
1620
+ }
1446
1621
  async startInstance(name, config, topicMode, kind = "fleet-topic",
1447
1622
  /**
1448
1623
  * Explicit starts (CLI/API) may resume a paused or failed daemon. Startup
@@ -2300,48 +2475,8 @@ export class FleetManager {
2300
2475
  this.instanceWorldBinding.set(ch.instanceName, ch.adapterId);
2301
2476
  }
2302
2477
  // Poll classicBot.yaml for external changes every 30s
2303
- this.classicReloadTimer = setInterval(async () => {
2304
- try {
2305
- if (!this.classicChannels)
2306
- return;
2307
- const fleetBackend = this.fleetConfig?.defaults?.backend;
2308
- const fleetModel = this.fleetConfig?.defaults?.model;
2309
- const oldBackends = new Map();
2310
- const oldModels = new Map();
2311
- const oldAutoPause = new Map();
2312
- for (const ch of this.classicChannels.getAll()) {
2313
- oldBackends.set(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend));
2314
- oldModels.set(ch.instanceName, this.classicChannels.getModel(ch.channelId, ch.adapterId, fleetModel));
2315
- oldAutoPause.set(ch.instanceName, this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
2316
- }
2317
- if (!this.classicChannels.checkReload())
2318
- return;
2319
- // A reload can introduce a bad id (hand edit) or clear one; the
2320
- // throttle keeps a repeated report from flooding the topic.
2321
- this.reportClassicUnrecoverableIds();
2322
- this.reregisterClassicChannels();
2323
- for (const ch of this.classicChannels.getAll()) {
2324
- const newBackend = this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend);
2325
- const newModel = this.classicChannels.getModel(ch.channelId, ch.adapterId, fleetModel);
2326
- const newAutoPause = this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after);
2327
- const backendChanged = oldBackends.get(ch.instanceName) !== newBackend;
2328
- const modelChanged = oldModels.get(ch.instanceName) !== newModel;
2329
- const autoPauseChanged = oldAutoPause.get(ch.instanceName) !== newAutoPause;
2330
- if (this.daemons.has(ch.instanceName) && (backendChanged || modelChanged || autoPauseChanged)) {
2331
- this.logger.info({ instanceName: ch.instanceName, backendFrom: oldBackends.get(ch.instanceName), backendTo: newBackend, modelFrom: oldModels.get(ch.instanceName), modelTo: newModel }, "Backend/model changed — restarting");
2332
- await this.stopInstance(ch.instanceName).catch(() => { });
2333
- // Small delay to let tmux window clean up
2334
- await new Promise(r => setTimeout(r, 2000));
2335
- // The manager already holds the new backend/model/auto-pause; the
2336
- // unattended helper reads them from it and schedules the delayed
2337
- // retry on failure like every other unattended start.
2338
- await this.startClassicInstanceUnattended(ch, "classic instance after backend/model change");
2339
- }
2340
- }
2341
- }
2342
- catch (err) {
2343
- this.logger.warn({ err }, "classicBot.yaml reload error");
2344
- }
2478
+ this.classicReloadTimer = setInterval(() => {
2479
+ void this.reloadClassicConfigFromDisk();
2345
2480
  }, 30_000);
2346
2481
  const costGuardConfig = {
2347
2482
  ...DEFAULT_COST_GUARD,
@@ -2369,10 +2504,7 @@ export class FleetManager {
2369
2504
  ...fleet.defaults.daily_summary,
2370
2505
  };
2371
2506
  this.dailySummary = new DailySummary(summaryConfig, costGuardConfig.timezone, (text) => {
2372
- if (!this.adapter || !this.fleetConfig?.channel?.group_id)
2373
- return;
2374
- this.adapter.sendText(String(this.fleetConfig.channel.group_id), text)
2375
- .catch(e => this.logger.warn({ err: e }, "Failed to send daily summary"));
2507
+ this.postDailySummary(text);
2376
2508
  // Rotate classic channel chat logs daily
2377
2509
  this.classicChannels?.rotateLogs();
2378
2510
  this.rotateInboxes();
@@ -2970,10 +3102,11 @@ export class FleetManager {
2970
3102
  accessManager,
2971
3103
  inboxDir,
2972
3104
  });
2973
- const world = new AdapterWorld(adapterId, this.adapter, accessManager, channelConfig);
3105
+ const adapter = this.adapter;
3106
+ const world = new AdapterWorld(adapterId, adapter, accessManager, channelConfig);
2974
3107
  this.worlds.set(adapterId, world);
2975
- this.adapters.set(adapterId, this.adapter);
2976
- this.bindAdapterHealth(this.adapter, adapterId);
3108
+ this.adapters.set(adapterId, adapter);
3109
+ this.bindAdapterHealth(adapter, adapterId);
2977
3110
  this.adapter.on("message", safeHandler(async (msg) => {
2978
3111
  await this.handleInboundMessage(msg);
2979
3112
  }, this.logger, "adapter.message"));
@@ -3018,12 +3151,7 @@ export class FleetManager {
3018
3151
  return;
3019
3152
  }
3020
3153
  }, this.logger, "adapter.callback_query"));
3021
- this.adapter.on("topic_closed", safeHandler(async (data) => {
3022
- // Skip unbind if we archived this topic ourselves
3023
- if (this.topicArchiver.isArchived(data.threadId))
3024
- return;
3025
- await this.topicCommands.handleTopicDeleted(data.threadId);
3026
- }, this.logger, "adapter.topic_closed"));
3154
+ this.bindTopicClosedHandler(adapter, adapterId, "adapter.topic_closed");
3027
3155
  // Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
3028
3156
  this.adapter.on("slash_command", safeHandler(async (data) => {
3029
3157
  if (data.command === "start") {
@@ -3414,11 +3542,7 @@ export class FleetManager {
3414
3542
  return;
3415
3543
  }
3416
3544
  }, this.logger, `adapter[${adapterId}].callback_query`));
3417
- adapter.on("topic_closed", safeHandler(async (data) => {
3418
- if (this.topicArchiver.isArchived(data.threadId))
3419
- return;
3420
- await this.topicCommands.handleTopicDeleted(data.threadId);
3421
- }, this.logger, `adapter[${adapterId}].topic_closed`));
3545
+ this.bindTopicClosedHandler(adapter, adapterId, `adapter[${adapterId}].topic_closed`);
3422
3546
  // Slash commands: classic bot + admin commands
3423
3547
  adapter.on("slash_command", safeHandler(async (data) => {
3424
3548
  if (data.command === "start") {
@@ -4777,7 +4901,9 @@ export class FleetManager {
4777
4901
  // The adapter resolves only after the platform POST returns. Keep
4778
4902
  // outward-facing logs/cancel state on the same confirmation boundary:
4779
4903
  // a routed-but-failed reply is not a delivered reply.
4780
- if (!error && result != null) {
4904
+ // `statusOnly` is daemon-owned envelope metadata, not an MCP argument:
4905
+ // an agent cannot invent it to suppress the normal completion marker.
4906
+ if (!error && result != null && msg.statusOnly !== true) {
4781
4907
  try {
4782
4908
  this.afterReplyRouted(instanceName, args, senderSessionName);
4783
4909
  }
@@ -4883,12 +5009,25 @@ export class FleetManager {
4883
5009
  // the target instance's configured world so replies use its persona
4884
5010
  // after a fresh start instead of falling back to channels[0].
4885
5011
  const adapterId = this.getInstanceAdapterId(target);
5012
+ // ...but the stored reply coordinates belong to the chat the schedule
5013
+ // was CREATED in, which is not always the target's world. Pairing them
5014
+ // with the target's adapter hands one platform's ids to another's API:
5015
+ // a Telegram group + forum topic delivered to a Discord instance made
5016
+ // every reply on that turn fetch `/channels/<telegram topic>` and fail
5017
+ // with Unknown Channel, and because the seeding repeats on each trigger
5018
+ // it stayed broken until a real inbound overwrote the context (#752).
5019
+ const foreignWorld = this.scheduleChatWorldMismatch(target, reply_chat_id);
5020
+ if (foreignWorld) {
5021
+ this.logger.warn({ scheduleId: id, target, foreignWorld, targetWorld: adapterId, chatId: reply_chat_id }, "Schedule reply target belongs to another channel world — not seeding chat context; the instance keeps its own last known chat");
5022
+ }
4886
5023
  await this.deliverToInstance(target, {
4887
5024
  type: "fleet_schedule_trigger",
4888
5025
  payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
4889
5026
  meta: {
4890
- chat_id: reply_chat_id,
4891
- thread_id: reply_thread_id,
5027
+ // Omitted on a mismatch: the daemon then keeps its persisted
5028
+ // last-chat, which is in the right world, instead of being
5029
+ // overwritten with coordinates the target's adapter cannot address.
5030
+ ...(foreignWorld ? {} : { chat_id: reply_chat_id, thread_id: reply_thread_id }),
4892
5031
  user: "scheduler",
4893
5032
  ...(adapterId ? { adapter_id: adapterId } : {}),
4894
5033
  },
@@ -4920,19 +5059,69 @@ export class FleetManager {
4920
5059
  this.scheduler.recordRun(id, "instance_offline", `retry ${retryCount}x failed`);
4921
5060
  this.notifyScheduleFailure(schedule);
4922
5061
  }
5062
+ /**
5063
+ * The adapter that can actually post into a chat, found by its group.
5064
+ *
5065
+ * A schedule records where it was created (reply_chat_id) separately from
5066
+ * what it triggers (target). Those need not share a platform: a Telegram
5067
+ * group can schedule a Discord-topic instance. Picking the adapter from the
5068
+ * target then sends a Telegram chat id through the Discord bot, which fails
5069
+ * with Unknown Channel — the source topic never hears that its schedule ran.
5070
+ *
5071
+ * When several bots share one guild, the primary wins: a persona should not
5072
+ * be the voice announcing fleet scheduling.
5073
+ */
5074
+ adapterForChat(chatId) {
5075
+ const id = String(chatId);
5076
+ const matches = [...this.worlds.values()].filter(world => String(world.groupId) === id);
5077
+ if (matches.length === 0)
5078
+ return undefined;
5079
+ const primaryId = this.getPrimaryAdapterId();
5080
+ return (matches.find(world => world.id === primaryId) ?? matches[0]).adapter;
5081
+ }
5082
+ /**
5083
+ * The adapter that can answer a schedule in the chat it was created from.
5084
+ *
5085
+ * A schedule records its creator (source) and its trigger (target) separately,
5086
+ * and they need not share a platform — the live fleet has a Telegram group
5087
+ * scheduling a Discord-topic instance. Routing by target sends a Telegram chat
5088
+ * id through the Discord bot, which is one half of the Unknown Channel errors.
5089
+ *
5090
+ * The creator's own adapter comes first, and only when its world actually owns
5091
+ * that chat. Classic keeps its own identity, so a schedule made from a
5092
+ * persona-bound Classic channel is answered by that persona: the primary bot
5093
+ * may not even have access there, and would be the wrong voice if it did.
5094
+ * Falling back to the target's adapter is deliberately NOT an option — that is
5095
+ * the misroute itself; callers say why they stayed silent instead.
5096
+ */
5097
+ scheduleSourceAdapter(schedule) {
5098
+ const chatId = String(schedule.reply_chat_id);
5099
+ const sourceAdapterId = schedule.source ? this.getInstanceAdapterId(schedule.source) : undefined;
5100
+ const sourceWorld = sourceAdapterId ? this.worlds.get(sourceAdapterId) : undefined;
5101
+ if (sourceWorld && String(sourceWorld.groupId) === chatId)
5102
+ return sourceWorld.adapter;
5103
+ return this.adapterForChat(chatId);
5104
+ }
4923
5105
  notifySourceTopic(schedule) {
4924
- const adapter = this.getAdapterForInstance(schedule.target) ?? this.adapter;
4925
- if (!adapter)
5106
+ const adapter = this.scheduleSourceAdapter(schedule);
5107
+ if (!adapter) {
5108
+ this.logger.warn({ scheduleId: schedule.id, chatId: schedule.reply_chat_id, source: schedule.source }, "No adapter can reach the schedule's source chat — trigger notice not sent");
4926
5109
  return;
5110
+ }
4927
5111
  const text = `⏰ Schedule "${schedule.label ?? schedule.id}" triggered, target: ${schedule.target}`;
4928
5112
  adapter.sendText(schedule.reply_chat_id, text, {
4929
5113
  threadId: schedule.reply_thread_id ?? undefined,
4930
5114
  }).catch((err) => this.logger.error({ err }, "Failed to send cross-instance notification"));
4931
5115
  }
4932
5116
  notifyScheduleFailure(schedule) {
4933
- const adapter = this.getAdapterForInstance(schedule.target) ?? this.adapter;
4934
- if (!adapter)
5117
+ // Same resolver as the success path: a failure notice was still being sent
5118
+ // through the target's adapter, so a Telegram-created schedule for a
5119
+ // Discord target announced its failure into the wrong platform.
5120
+ const adapter = this.scheduleSourceAdapter(schedule);
5121
+ if (!adapter) {
5122
+ this.logger.warn({ scheduleId: schedule.id, chatId: schedule.reply_chat_id, source: schedule.source }, "No adapter can reach the schedule's source chat — failure notice not sent");
4935
5123
  return;
5124
+ }
4936
5125
  const text = `⏰ Schedule "${schedule.label ?? schedule.id}" trigger failed: instance ${schedule.target} is offline.`;
4937
5126
  adapter.sendText(schedule.reply_chat_id, text, {
4938
5127
  threadId: schedule.reply_thread_id ?? undefined,
@@ -5343,26 +5532,261 @@ export class FleetManager {
5343
5532
  sessionPruneTimer = null;
5344
5533
  classicReloadTimer = null;
5345
5534
  botUserId;
5346
- /** Periodically check if bound topics still exist */
5535
+ /** Periodically check if bound topics still exist. Never deletes user data. */
5347
5536
  startTopicCleanupPoller() {
5348
- this.topicCleanupTimer = setInterval(async () => {
5349
- if (!this.fleetConfig?.channel?.group_id || !this.adapter?.topicExists)
5537
+ if (this.topicCleanupTimer)
5538
+ clearInterval(this.topicCleanupTimer);
5539
+ const generation = ++this.topicCleanupGeneration;
5540
+ this.topicCleanupTimer = setInterval(() => { void this.scheduleTopicCleanup(generation); }, 5 * 60_000);
5541
+ }
5542
+ /** Coalesce timer ticks; an outage must not create overlapping destructive-looking scans. */
5543
+ scheduleTopicCleanup(generation = this.topicCleanupGeneration) {
5544
+ if (this.topicCleanupInFlight)
5545
+ return this.topicCleanupInFlight;
5546
+ const run = this.runTopicCleanup(generation).finally(() => {
5547
+ if (this.topicCleanupInFlight === run)
5548
+ this.topicCleanupInFlight = null;
5549
+ });
5550
+ this.topicCleanupInFlight = run;
5551
+ return run;
5552
+ }
5553
+ confirmedProbeFence(adapterId, adapter) {
5554
+ const health = adapter.getHealthSnapshot?.();
5555
+ if (health) {
5556
+ return health.status === "connected" && health.isReady
5557
+ ? { generation: health.generation }
5558
+ : null;
5559
+ }
5560
+ return this.adapterState.get(adapterId)?.status === "connected" ? {} : null;
5561
+ }
5562
+ sameProbeFence(adapterId, adapter, before, result) {
5563
+ const after = this.confirmedProbeFence(adapterId, adapter);
5564
+ if (!after)
5565
+ return false;
5566
+ if (before.generation !== after.generation)
5567
+ return false;
5568
+ return result?.generation === undefined || result.generation === after.generation;
5569
+ }
5570
+ topicProbeStreakKey(threadId, adapterId, reason) {
5571
+ return FleetManager.TOPIC_PROBE_ADAPTER_SCOPED_REASONS.has(reason)
5572
+ ? `adapter:${adapterId ?? "unbound"}`
5573
+ : `thread:${threadId}`;
5574
+ }
5575
+ /** A definite answer (present or missing) ends the unknown streak for that route and its adapter. */
5576
+ clearTopicProbeUnknownStreak(threadId, adapterId) {
5577
+ this.topicProbeUnknownStreak.delete(`thread:${threadId}`);
5578
+ this.topicProbeUnknownStreak.delete(`adapter:${adapterId ?? "unbound"}`);
5579
+ }
5580
+ /**
5581
+ * Record one unknown probe result. Nothing here can touch quarantine or
5582
+ * removal: unknown is always retained data. The only question is whether
5583
+ * the operator hears about it, and a single transient (one flaky HTTP call
5584
+ * out of dozens per pass) must not — only a streak does.
5585
+ *
5586
+ * Used directly for single-route events (channelDelete, the pre-action
5587
+ * fence). The periodic scan goes through a TopicProbePass instead, so one
5588
+ * pass over N routes of a dead adapter counts as ONE check, not N.
5589
+ */
5590
+ warnTopicProbeUnknown(instanceName, threadId, adapterId, reason, detail) {
5591
+ this.noteTopicProbeUnknown(this.topicProbeStreakKey(threadId, adapterId, reason), { instanceName, threadId, adapterId, reason, detail });
5592
+ }
5593
+ /** One scan's worth of probe outcomes, applied to the streaks after the loop. */
5594
+ newTopicProbePass() {
5595
+ return { unknown: new Map(), definite: new Set() };
5596
+ }
5597
+ passTopicProbeUnknown(pass, instanceName, threadId, adapterId, reason, detail) {
5598
+ const key = this.topicProbeStreakKey(threadId, adapterId, reason);
5599
+ // First unknown per key per pass wins; the rest of the routes on a dead
5600
+ // adapter are the same observation, not additional checks.
5601
+ if (!pass.unknown.has(key))
5602
+ pass.unknown.set(key, { instanceName, threadId, adapterId, reason, detail });
5603
+ }
5604
+ passTopicProbeDefinite(pass, threadId, adapterId) {
5605
+ pass.definite.add(`thread:${threadId}`);
5606
+ pass.definite.add(`adapter:${adapterId ?? "unbound"}`);
5607
+ }
5608
+ /**
5609
+ * Apply a pass: a definite answer resets its keys, and an adapter that
5610
+ * answered for any route this pass is evidently alive, so an unknown for the
5611
+ * same adapter key in the same pass does not count — regardless of the order
5612
+ * the routes happened to be probed in.
5613
+ */
5614
+ applyTopicProbePass(pass) {
5615
+ for (const key of pass.definite)
5616
+ this.topicProbeUnknownStreak.delete(key);
5617
+ for (const [key, ctx] of pass.unknown) {
5618
+ if (pass.definite.has(key))
5619
+ continue;
5620
+ this.noteTopicProbeUnknown(key, ctx);
5621
+ }
5622
+ }
5623
+ noteTopicProbeUnknown(streakKey, { instanceName, threadId, adapterId, reason, detail }) {
5624
+ const streak = (this.topicProbeUnknownStreak.get(streakKey) ?? 0) + 1;
5625
+ this.topicProbeUnknownStreak.set(streakKey, streak);
5626
+ if (streak < FleetManager.TOPIC_PROBE_UNKNOWN_ESCALATION) {
5627
+ this.logger.debug({ instanceName, threadId, adapterId, reason, detail, streak }, "Topic presence not confirmed this pass — transient, retaining instance and all data");
5628
+ return;
5629
+ }
5630
+ const key = `${adapterId ?? "unbound"}:${reason}`;
5631
+ const now = Date.now();
5632
+ const last = this.topicProbeWarnings.get(key) ?? 0;
5633
+ if (now - last < FleetManager.FLEET_ERROR_THROTTLE_MS)
5634
+ return;
5635
+ this.topicProbeWarnings.set(key, now);
5636
+ this.logger.error({ instanceName, threadId, adapterId, reason, detail, streak }, "Topic presence could not be confirmed repeatedly — retaining instance and all data");
5637
+ this.notifyFleetError(t("fleet.topic_probe_unknown", instanceName, adapterId ?? "unbound", streak));
5638
+ }
5639
+ /** One fixed-snapshot topology pass. Automatic evidence can only quarantine. */
5640
+ async runTopicCleanup(generation) {
5641
+ if (generation !== this.topicCleanupGeneration || this.shuttingDown)
5642
+ return;
5643
+ const snapshot = [...this.routing.entries()].filter(([, target]) => isProbeableRouteTarget(target));
5644
+ const missing = [];
5645
+ const pass = this.newTopicProbePass();
5646
+ const skippedOnDemand = new Set();
5647
+ for (const [threadId, target] of snapshot) {
5648
+ if (generation !== this.topicCleanupGeneration || this.shuttingDown)
5350
5649
  return;
5351
- for (const [threadId, target] of this.routing.entries()) {
5352
- try {
5353
- if (!isProbeableRouteTarget(target)) {
5354
- continue;
5355
- }
5356
- const exists = await this.adapter.topicExists(threadId);
5357
- if (!exists) {
5358
- await this.topicCommands.handleTopicDeleted(threadId);
5359
- }
5650
+ // The route may have been replaced while an earlier probe was in flight.
5651
+ if (this.routing.resolve(threadId) !== target)
5652
+ continue;
5653
+ const adapterId = this.getInstanceAdapterId(target.name);
5654
+ const adapter = adapterId ? this.adapters.get(adapterId) : undefined;
5655
+ if (!adapterId || !adapter?.probeTopicPresence) {
5656
+ this.passTopicProbeUnknown(pass, target.name, threadId, adapterId, "owner-adapter-unavailable");
5657
+ continue;
5658
+ }
5659
+ // An on-demand adapter's probe is reserved for confirming a delivery
5660
+ // failure hint (handleProviderTopicClosed); the periodic scan leaves its
5661
+ // routes alone — no probe, no unknown, no streak, no notice.
5662
+ if (adapter.topicProbePolicy?.() === "on-demand") {
5663
+ if (!skippedOnDemand.has(adapterId)) {
5664
+ skippedOnDemand.add(adapterId);
5665
+ this.logger.debug({ adapterId }, "Topic scan skipping on-demand adapter — presence is confirmed only on delivery failure");
5360
5666
  }
5361
- catch (err) {
5362
- this.logger.debug({ err, threadId }, "Topic existence check failed");
5667
+ continue;
5668
+ }
5669
+ const before = this.confirmedProbeFence(adapterId, adapter);
5670
+ if (!before) {
5671
+ this.passTopicProbeUnknown(pass, target.name, threadId, adapterId, "owner-adapter-not-ready");
5672
+ continue;
5673
+ }
5674
+ let result;
5675
+ try {
5676
+ result = await adapter.probeTopicPresence(threadId);
5677
+ }
5678
+ catch {
5679
+ result = { status: "unknown", reason: "provider-probe-threw" };
5680
+ }
5681
+ if (generation !== this.topicCleanupGeneration || this.shuttingDown)
5682
+ return;
5683
+ if (!this.sameProbeFence(adapterId, adapter, before, result)) {
5684
+ this.passTopicProbeUnknown(pass, target.name, threadId, adapterId, "owner-adapter-generation-changed");
5685
+ continue;
5686
+ }
5687
+ if (result.status === "unknown") {
5688
+ this.passTopicProbeUnknown(pass, target.name, threadId, adapterId, result.reason, result.detail);
5689
+ }
5690
+ else {
5691
+ this.passTopicProbeDefinite(pass, threadId, adapterId);
5692
+ if (result.status === "missing") {
5693
+ missing.push({ threadId, target, adapterId, adapter, generation: result.generation });
5363
5694
  }
5364
5695
  }
5365
- }, 5 * 60_000);
5696
+ }
5697
+ if (generation !== this.topicCleanupGeneration || this.shuttingDown)
5698
+ return;
5699
+ // One pass, one check: streaks move by at most one per key here.
5700
+ this.applyTopicProbePass(pass);
5701
+ if (missing.length === 0)
5702
+ return;
5703
+ if (missing.length > 1) {
5704
+ this.logger.error({ missing: missing.map(item => ({ instanceName: item.target.name, threadId: item.threadId, adapterId: item.adapterId })) }, "Multiple topics appeared missing in one pass — treating topology evidence as untrusted and retaining all data");
5705
+ this.notifyFleetError(t("fleet.topic_probe_bulk", missing.length));
5706
+ return;
5707
+ }
5708
+ const item = missing[0];
5709
+ if (this.routing.resolve(item.threadId) !== item.target
5710
+ || this.getInstanceAdapterId(item.target.name) !== item.adapterId
5711
+ || this.adapters.get(item.adapterId) !== item.adapter)
5712
+ return;
5713
+ const current = this.confirmedProbeFence(item.adapterId, item.adapter);
5714
+ if (!current || (item.generation !== undefined && current.generation !== item.generation)) {
5715
+ this.warnTopicProbeUnknown(item.target.name, item.threadId, item.adapterId, "owner-adapter-changed-before-action");
5716
+ return;
5717
+ }
5718
+ this.topicCommands.handleTopicDeleted(item.threadId, {
5719
+ source: "provider-probe",
5720
+ adapterId: item.adapterId,
5721
+ generation: item.generation,
5722
+ });
5723
+ }
5724
+ /**
5725
+ * A gateway event is only a hint. Confirm it through the passive REST probe;
5726
+ * reconnecting gateways have emitted false channelDelete events in practice.
5727
+ */
5728
+ async handleProviderTopicClosed(threadId, adapterId, adapter) {
5729
+ const target = this.routing.resolve(threadId);
5730
+ if (!target || !isProbeableRouteTarget(target))
5731
+ return;
5732
+ if (this.getInstanceAdapterId(target.name) !== adapterId)
5733
+ return;
5734
+ if (this.adapters.get(adapterId) !== adapter)
5735
+ return;
5736
+ const before = this.confirmedProbeFence(adapterId, adapter);
5737
+ if (!before || !adapter.probeTopicPresence) {
5738
+ this.warnTopicProbeUnknown(target.name, threadId, adapterId, "topic-close-from-unready-adapter");
5739
+ return;
5740
+ }
5741
+ let result;
5742
+ try {
5743
+ result = await adapter.probeTopicPresence(threadId);
5744
+ }
5745
+ catch {
5746
+ result = { status: "unknown", reason: "provider-probe-threw" };
5747
+ }
5748
+ if (this.routing.resolve(threadId) !== target
5749
+ || this.getInstanceAdapterId(target.name) !== adapterId
5750
+ || this.adapters.get(adapterId) !== adapter
5751
+ || !this.sameProbeFence(adapterId, adapter, before, result)) {
5752
+ this.warnTopicProbeUnknown(target.name, threadId, adapterId, "topic-close-generation-changed");
5753
+ return;
5754
+ }
5755
+ if (result.status === "unknown") {
5756
+ this.warnTopicProbeUnknown(target.name, threadId, adapterId, result.reason, result.detail);
5757
+ return;
5758
+ }
5759
+ this.clearTopicProbeUnknownStreak(threadId, adapterId);
5760
+ if (result.status !== "missing") {
5761
+ // The gateway said deleted, REST says present: a definite answer, so it
5762
+ // is not an unknown streak — but it is worth one debug line.
5763
+ this.logger.debug({ instanceName: target.name, threadId, adapterId }, "channelDelete hint contradicted by REST — topic present, nothing to do");
5764
+ return;
5765
+ }
5766
+ this.topicCommands.handleTopicDeleted(threadId, {
5767
+ source: "provider-event",
5768
+ adapterId,
5769
+ generation: result.generation,
5770
+ });
5771
+ }
5772
+ bindTopicClosedHandler(adapter, adapterId, label) {
5773
+ adapter.on("topic_closed", safeHandler(async (data) => {
5774
+ if (this.topicArchiver.isArchived(data.threadId))
5775
+ return;
5776
+ await this.handleProviderTopicClosed(data.threadId, adapterId, adapter);
5777
+ }, this.logger, label));
5778
+ }
5779
+ /**
5780
+ * Remove only the volatile route. The instance config, daemon, schedules,
5781
+ * teams, metadata directory, and working tree remain untouched until an
5782
+ * authenticated explicit deletion is requested.
5783
+ */
5784
+ quarantineMissingTopic(threadId, target, evidence) {
5785
+ if (!isProbeableRouteTarget(target) || this.routing.resolve(threadId) !== target)
5786
+ return;
5787
+ this.routing.unregister(threadId);
5788
+ this.logger.error({ instanceName: target.name, threadId, ...evidence }, "Topic is confirmed missing — route quarantined; instance configuration and user data were retained");
5789
+ this.notifyFleetError(t("fleet.topic_quarantined", target.name, threadId));
5366
5790
  }
5367
5791
  /**
5368
5792
  * Patch only values changed in the effective config into the original YAML
@@ -5515,7 +5939,8 @@ export class FleetManager {
5515
5939
  }
5516
5940
  }
5517
5941
  }
5518
- async removeInstance(name) {
5942
+ async removeInstance(name, authorization) {
5943
+ assertExplicitInstanceRemoval(authorization);
5519
5944
  // Drop cached pane context — the map is keyed by instance name and nothing
5520
5945
  // else evicted deleted entries, so it grew for the life of the process.
5521
5946
  forgetInstanceContext(name);
@@ -5541,7 +5966,7 @@ export class FleetManager {
5541
5966
  }
5542
5967
  }
5543
5968
  }
5544
- await this.lifecycle.remove(name);
5969
+ await this.lifecycle.remove(name, authorization);
5545
5970
  // Clean up per-instance tracking maps so they don't grow unbounded
5546
5971
  // as instances are created and deleted over the lifetime of the fleet.
5547
5972
  this.lastActivity.delete(name);
@@ -5778,6 +6203,65 @@ export class FleetManager {
5778
6203
  this.logger.error({ ids: bad }, "classicBot.yaml holds ids that can never match");
5779
6204
  this.notifyFleetError(t("classic.unrecoverable_ids", list));
5780
6205
  }
6206
+ /**
6207
+ * Where a fleet-wide notice can actually be posted, or null if nowhere.
6208
+ *
6209
+ * On Telegram a group id is itself a chat, so posting straight to it is
6210
+ * right. On Discord it is a *guild* id, and sending there makes the adapter
6211
+ * fetch a channel that does not exist — DiscordAPIError 10003 Unknown
6212
+ * Channel. That is why the daily summary never arrived on a Discord fleet:
6213
+ * it had been posting to the guild every night and only the catch handler
6214
+ * ever saw it.
6215
+ *
6216
+ * Discord therefore needs a real channel: the General topic (resolved from
6217
+ * config rather than findGeneralInstance, so a fleet-level fault can still be
6218
+ * reported while the General daemon is down), else the adapter's configured
6219
+ * general_channel_id. With neither, there is no safe target and the caller
6220
+ * should say so rather than send into a guaranteed failure.
6221
+ */
6222
+ fleetNoticeTarget(adapterId) {
6223
+ const cfg = this.getChannelConfig(adapterId);
6224
+ const groupId = cfg?.group_id;
6225
+ if (groupId == null)
6226
+ return null;
6227
+ const chatId = String(groupId);
6228
+ // The General must belong to the SAME adapter as the group above. Taking
6229
+ // whichever General comes first in the instance map produced a mixed target
6230
+ // on a dual-platform fleet — a Telegram group id carrying a Discord channel
6231
+ // as its thread — and made the result depend on map insertion order.
6232
+ // Resolved from config, not from a live daemon, so a fleet-level fault is
6233
+ // still reportable while the General itself is down.
6234
+ const ownerId = cfg?.id ?? cfg?.type;
6235
+ const generalTopic = Object.entries(this.fleetConfig?.instances ?? {})
6236
+ .find(([name, instance]) => instance.general_topic === true
6237
+ && this.getInstanceAdapterId(name) === ownerId)?.[1]?.topic_id;
6238
+ if (generalTopic != null)
6239
+ return { chatId, opts: { threadId: String(generalTopic) } };
6240
+ if (cfg?.type === "discord") {
6241
+ const configured = cfg.options?.general_channel_id;
6242
+ if (configured != null && String(configured)) {
6243
+ return { chatId, opts: { threadId: String(configured) } };
6244
+ }
6245
+ return null; // a guild id is not a channel; sending would always fail
6246
+ }
6247
+ return { chatId, opts: {} };
6248
+ }
6249
+ /**
6250
+ * Post the daily summary where fleet-wide notices go.
6251
+ *
6252
+ * A named method rather than an inline closure so a test can drive the real
6253
+ * thing: asserting on fleetNoticeTarget alone leaves the call site free to go
6254
+ * back to posting at the bare group id, which is the defect this replaced.
6255
+ */
6256
+ postDailySummary(text) {
6257
+ const target = this.fleetNoticeTarget();
6258
+ if (!this.adapter || !target) {
6259
+ this.logger.warn("Daily summary has no postable target — set a General topic or channel.options.general_channel_id");
6260
+ return;
6261
+ }
6262
+ this.adapter.sendText(target.chatId, text, target.opts)
6263
+ .catch(e => this.logger.warn({ err: e }, "Failed to send daily summary"));
6264
+ }
5781
6265
  notifyFleetError(text) {
5782
6266
  const now = Date.now();
5783
6267
  const key = text.slice(0, 200);
@@ -5804,10 +6288,12 @@ export class FleetManager {
5804
6288
  dispatched = this.notifyInstanceTopic(general, body);
5805
6289
  }
5806
6290
  else {
5807
- // No General instance — fall back to the primary channel's group.
5808
- const groupId = this.getChannelConfig()?.group_id;
5809
- if (this.adapter && groupId) {
5810
- this.adapter.sendText(String(groupId), body)
6291
+ // No General instance — fall back to the primary channel's own notice
6292
+ // target. Posting to the bare group id looked right but is a guild id on
6293
+ // Discord, so every such fallback failed inside the catch handler.
6294
+ const target = this.fleetNoticeTarget();
6295
+ if (this.adapter && target) {
6296
+ this.adapter.sendText(target.chatId, body, target.opts)
5811
6297
  .catch(err => this.logger.warn({ err }, "Failed to send fleet error notification"));
5812
6298
  dispatched = true;
5813
6299
  }
@@ -5866,13 +6352,16 @@ export class FleetManager {
5866
6352
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send classic notification"));
5867
6353
  return true;
5868
6354
  }
5869
- // Fallback: send to group without threadId
5870
- if (groupId) {
5871
- adapter.sendText(String(groupId), text, extraOpts)
6355
+ // Fallback: the instance has neither a topic nor a classic channel, so post
6356
+ // where fleet-wide notices go. Not the bare group id: on Discord that is a
6357
+ // guild, and the send fails inside the catch handler.
6358
+ const target = this.fleetNoticeTarget(this.getInstanceAdapterId(instanceName));
6359
+ if (target) {
6360
+ adapter.sendText(target.chatId, text, { ...target.opts, ...extraOpts })
5872
6361
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
5873
6362
  return true;
5874
6363
  }
5875
- this.logger.warn({ instanceName }, "No group id — instance topic notification not sent");
6364
+ this.logger.warn({ instanceName }, "No postable target — instance topic notification not sent");
5876
6365
  return false;
5877
6366
  }
5878
6367
  // ── Nonce-armed button prompts (hang / assist / exit / clear) ──
@@ -6433,6 +6922,41 @@ export class FleetManager {
6433
6922
  extra: { generalName, promptKind: kind },
6434
6923
  });
6435
6924
  }
6925
+ /**
6926
+ * Offer a one-tap re-login next to an auth alert.
6927
+ *
6928
+ * The alert already names the remedy in words (`/login <backend>`), which
6929
+ * still leaves the user to retype it somewhere. The button routes into the
6930
+ * same chooser `/login` uses, so pressing it starts the flow in place.
6931
+ *
6932
+ * Backends with no remote login flow (opencode logs in from a terminal) get
6933
+ * no button — the alert's own wording already tells them what to run.
6934
+ */
6935
+ async offerBackendLogin(targetInstance, backend) {
6936
+ if (!LOGIN_FLOWS[backend])
6937
+ return;
6938
+ const adapterId = this.getInstanceAdapterId(targetInstance);
6939
+ const adapter = this.getAdapterForInstance(targetInstance);
6940
+ const chatId = this.getGroupIdForInstance(targetInstance);
6941
+ const topicId = this.fleetConfig?.instances[targetInstance]?.topic_id;
6942
+ const threadId = topicId != null ? String(topicId) : undefined;
6943
+ if (!adapter || !adapterId || !chatId) {
6944
+ this.logger.warn({ targetInstance, backend, adapterId, chatId }, "Cannot address the re-login button — the alert text still names the command");
6945
+ return;
6946
+ }
6947
+ await this.postNonceButtonPrompt({
6948
+ prefix: LOGIN_CALLBACK_PREFIX,
6949
+ alertType: "login",
6950
+ instanceName: "login",
6951
+ adapter,
6952
+ adapterId,
6953
+ chatId,
6954
+ threadId,
6955
+ message: t("login.offer", backend),
6956
+ choices: [{ action: backend, label: t("login.offer_action", backend) }],
6957
+ expiredText: t("buttons.stale"),
6958
+ });
6959
+ }
6436
6960
  /** Consume a General assist button exactly once. */
6437
6961
  async handleInteractivePromptAssist(data, callbackAdapterId, receivingAdapter) {
6438
6962
  const claimed = this.consumeNonceCallback(INTERACTIVE_ASSIST_CALLBACK_PREFIX, /^interactive-assist:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
@@ -6583,6 +7107,30 @@ export class FleetManager {
6583
7107
  }
6584
7108
  return false;
6585
7109
  }
7110
+ beginCancelButtonPublication(instanceName, correlationId) {
7111
+ // A new handoff supersedes any idle timer left by the previous turn. If the
7112
+ // instance is still idle after this publication, the post-await level check
7113
+ // below starts a fresh grace period for this generation.
7114
+ this.cancelIdleButtonRetirement(instanceName);
7115
+ const publication = {
7116
+ generation: ++this.nextCancelButtonPublicationGeneration,
7117
+ correlationId,
7118
+ inFlight: true,
7119
+ retirePending: false,
7120
+ };
7121
+ this.cancelButtonPublications.set(instanceName, publication);
7122
+ return publication;
7123
+ }
7124
+ /** Remember a clear/cancel/idle decision that arrived before notifyAlert
7125
+ * returned a message id. `expected` fences an idle timer to its generation. */
7126
+ markCancelButtonPublicationForRetirement(instanceName, expected) {
7127
+ const publication = this.cancelButtonPublications.get(instanceName);
7128
+ if (!publication?.inFlight)
7129
+ return;
7130
+ if (expected && publication !== expected)
7131
+ return;
7132
+ publication.retirePending = true;
7133
+ }
6586
7134
  async sendCancelButton(instanceName, correlationId, preserveProgress = false) {
6587
7135
  // Post first, retire after (see the tail of this method). Retiring up front
6588
7136
  // meant that from the delete until the new message came back — a chat API
@@ -6621,6 +7169,7 @@ export class FleetManager {
6621
7169
  this.logger.warn({ instanceName, topicId, groupId }, "Cannot address cancel button (no chat id resolved)");
6622
7170
  return;
6623
7171
  }
7172
+ const publication = this.beginCancelButtonPublication(instanceName, correlationId);
6624
7173
  try {
6625
7174
  const sent = await adapter.notifyAlert(chatId, {
6626
7175
  type: "cancel",
@@ -6628,6 +7177,7 @@ export class FleetManager {
6628
7177
  message: "👀 處理中…",
6629
7178
  choices: [{ id: `cancel:${instanceName}`, label: t("cancel.button") }],
6630
7179
  }, threadId ? { threadId } : undefined);
7180
+ publication.inFlight = false;
6631
7181
  const entry = {
6632
7182
  instanceName,
6633
7183
  adapterId,
@@ -6649,6 +7199,16 @@ export class FleetManager {
6649
7199
  // empty even if the daemon's reset broadcast is still in flight.
6650
7200
  toolProgress: preserveProgress ? this.instanceProgress.get(instanceName) : undefined,
6651
7201
  };
7202
+ // A newer send started while this API call was pending. Track this late
7203
+ // message just long enough for the normal bounded delete/retry machinery
7204
+ // to remove it; critically, do not sweep the newer generation.
7205
+ if (this.cancelButtonPublications.get(instanceName) !== publication) {
7206
+ this.cancelButtons.set(sent.messageId, entry);
7207
+ this.persistCancelButtons();
7208
+ this.logger.debug({ instanceName, messageId: sent.messageId, generation: publication.generation }, "Retiring superseded cancel-button publication");
7209
+ this.retireButton(entry);
7210
+ return;
7211
+ }
6652
7212
  this.startProgressTicker(entry);
6653
7213
  // Idle-check backstop: every 5min, if the instance is idle, retire the
6654
7214
  // button. Covers turns that end without hitting a clear trigger (reply /
@@ -6685,8 +7245,30 @@ export class FleetManager {
6685
7245
  }
6686
7246
  this.persistCancelButtons();
6687
7247
  this.logger.info({ instanceName, messageId: sent.messageId }, "Cancel button sent");
7248
+ // Retirement is normally edge-triggered, but the edge (or an explicit
7249
+ // reply/cancel clear) may have happened while notifyAlert was in flight.
7250
+ // Reconcile the level after publication so a late button cannot resurrect
7251
+ // on an already-idle instance. Preserve the existing two-second grace:
7252
+ // a working transition cancels this timer just as it does for a normal
7253
+ // idle edge.
7254
+ if (publication.retirePending) {
7255
+ this.retireButton(entry);
7256
+ }
7257
+ else if (this.getInstanceExecutionState(instanceName) === "idle") {
7258
+ this.scheduleIdleButtonRetirement(instanceName);
7259
+ }
6688
7260
  }
6689
7261
  catch (e) {
7262
+ if (this.cancelButtonPublications.get(instanceName) === publication) {
7263
+ this.cancelButtonPublications.delete(instanceName);
7264
+ // beginCancelButtonPublication cancelled the previous turn's idle
7265
+ // timer so it could not act on this generation. If the provider POST
7266
+ // failed while the instance remained idle, restore that retirement
7267
+ // opportunity for any older button still on screen.
7268
+ if (this.getInstanceExecutionState(instanceName) === "idle") {
7269
+ this.scheduleIdleButtonRetirement(instanceName);
7270
+ }
7271
+ }
6690
7272
  this.logger.warn({ err: e.message, instanceName }, "Failed to send cancel button");
6691
7273
  }
6692
7274
  }
@@ -7039,6 +7621,7 @@ export class FleetManager {
7039
7621
  }
7040
7622
  /** Retire all cancel buttons for an instance — on reply or cancel. */
7041
7623
  clearCancelButton(instanceName) {
7624
+ this.markCancelButtonPublicationForRetirement(instanceName);
7042
7625
  this.retireInstanceButtons(instanceName);
7043
7626
  }
7044
7627
  /** Retire the cross-instance button matching a delegate→report correlation id.
@@ -7047,6 +7630,11 @@ export class FleetManager {
7047
7630
  clearCancelButtonByCorrelation(correlationId) {
7048
7631
  if (!correlationId)
7049
7632
  return;
7633
+ for (const [instanceName, publication] of this.cancelButtonPublications) {
7634
+ if (publication.inFlight && publication.correlationId === correlationId) {
7635
+ this.markCancelButtonPublicationForRetirement(instanceName, publication);
7636
+ }
7637
+ }
7050
7638
  for (const e of [...this.cancelButtons.values()]) {
7051
7639
  if (e.correlationId === correlationId)
7052
7640
  this.retireButton(e);
@@ -7421,11 +8009,15 @@ export class FleetManager {
7421
8009
  if (cleanupFailed) {
7422
8010
  await chat.adapter.sendText(chat.chatId, t("login.web_cleanup_failed", backend), { threadId: chat.threadId }).catch(() => { });
7423
8011
  }
8012
+ const send = (text) => chat.adapter.sendText(chat.chatId, text, { threadId: chat.threadId }).catch(() => { });
7424
8013
  let text;
7425
8014
  if (ok) {
7426
- const { woken, restarted } = await this.recoverBackendInstances(backend);
7427
- const none = t("login.none");
7428
- text = t("login.success", backend, woken.length ? woken.join(", ") : none, restarted.length ? restarted.join(", ") : none);
8015
+ // Same order as the web-login path: the login result goes out first,
8016
+ // then the recovery reports its own outcome. Waiting for recovery to
8017
+ // build this message is what made a successful login look hung.
8018
+ await send(t("login.completed", backend));
8019
+ await announcePostLoginRecovery(backend, () => this.recoverBackendInstances(backend), send);
8020
+ return;
7429
8021
  }
7430
8022
  else if (detail === "cancelled") {
7431
8023
  // The cancel command's own reply already announced this — a second
@@ -7549,31 +8141,53 @@ export class FleetManager {
7549
8141
  * only re-reads credentials on process start (a paused instance's CLI is
7550
8142
  * already dead, so waking it respawns with the new token for free).
7551
8143
  */
7552
- async recoverBackendInstances(backend) {
8144
+ /**
8145
+ * Wake/restart every instance of a backend after a successful re-login.
8146
+ *
8147
+ * Bounded by a wall-clock deadline. This used to be an unbounded sequential
8148
+ * loop, and the caller only built its "login completed" message AFTER it
8149
+ * returned — so with several instances (or one slow restart) the user was
8150
+ * told nothing at all for minutes, concluded the login had hung, and
8151
+ * restarted things by hand. Instances that do not finish in time are NOT
8152
+ * cancelled: they are still coming back, and are reported as pending so the
8153
+ * message can say so instead of implying failure.
8154
+ */
8155
+ async recoverBackendInstances(backend, deadlineMs = POST_LOGIN_RECOVERY_DEADLINE_MS) {
7553
8156
  const woken = [];
7554
8157
  const restarted = [];
8158
+ const pending = [];
8159
+ const deadline = Date.now() + deadlineMs;
7555
8160
  for (const name of this.configuredBackendInstanceNames()) {
7556
8161
  if (this.backendNameOf(name) !== backend)
7557
8162
  continue;
7558
8163
  const status = this.getInstanceStatus(name);
7559
- try {
8164
+ if (status !== "paused" && status !== "running")
8165
+ continue;
8166
+ const result = await runBeforeDeadline(async () => {
7560
8167
  if (status === "paused") {
7561
8168
  if (this.daemons.has(name))
7562
8169
  await this.lifecycle.wake(name, 30_000);
7563
8170
  else
7564
8171
  await this.startPersistedPausedInstance(name);
7565
- woken.push(name);
7566
8172
  }
7567
- else if (status === "running") {
8173
+ else {
7568
8174
  await this.restartSingleInstance(name);
7569
- restarted.push(name);
7570
8175
  }
8176
+ }, deadline);
8177
+ if (result.status === "fulfilled") {
8178
+ (status === "paused" ? woken : restarted).push(name);
7571
8179
  }
7572
- catch (err) {
7573
- this.logger.warn({ err: err.message, name, status }, "Post-login recovery failed");
8180
+ else if (result.status === "timeout") {
8181
+ // Out of time: record it and stop waiting, but keep walking the list —
8182
+ // the remaining instances are checked against the same deadline and
8183
+ // fall straight through, so the caller still learns about all of them.
8184
+ pending.push(name);
8185
+ }
8186
+ else {
8187
+ this.logger.warn({ err: result.reason?.message, name, status }, "Post-login recovery failed");
7574
8188
  }
7575
8189
  }
7576
- return { woken, restarted };
8190
+ return { woken, restarted, pending };
7577
8191
  }
7578
8192
  /** Backend chooser button → start that backend's login session. */
7579
8193
  async handleLoginBackendSelect(data, callbackAdapterId, receivingAdapter) {
@@ -7985,7 +8599,7 @@ You CAN write code snippets, explain code, and answer technical questions direct
7985
8599
  - **Delegate to 1 instance**: scoped to one project/repo, needs file access or execution.
7986
8600
  - **Coordinate multiple**: spans repos, outputs feed each other, or parallel helps (max 3 per task).
7987
8601
 
7988
- Instance discovery order: list_teams() → list_instances() → describe_instance() → create_instance() only if nothing fits. Prefer reuse; never duplicate a running instance.
8602
+ Instance discovery: start with list_instances(); follow the guidance in its response. Prefer reuse; never duplicate a running instance.
7989
8603
 
7990
8604
  ## Reply Contract
7991
8605
 
@@ -9613,11 +10227,19 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9613
10227
  const workDir = join(getAgendHome(), "workspaces", instanceName);
9614
10228
  ensureWorkspaceGit(workDir);
9615
10229
  const classicIdentity = this.classicChannels?.getAll().find(ch => ch.instanceName === instanceName);
10230
+ const toolProgress = classicIdentity
10231
+ ? this.classicChannels?.getToolProgress(classicIdentity.channelId, classicIdentity.adapterId, this.fleetConfig?.defaults?.tool_progress)
10232
+ : this.fleetConfig?.defaults?.tool_progress;
10233
+ const replyCompletionGuard = classicIdentity
10234
+ ? this.classicChannels?.getReplyCompletionGuard(classicIdentity.channelId, classicIdentity.adapterId, this.fleetConfig?.defaults?.reply_completion_guard)
10235
+ : this.fleetConfig?.defaults?.reply_completion_guard;
9616
10236
  const config = {
9617
10237
  ...DEFAULT_INSTANCE_CONFIG,
9618
10238
  ...this.fleetConfig?.defaults,
9619
10239
  working_directory: workDir,
9620
10240
  lightweight: true,
10241
+ tool_progress: toolProgress ?? "off",
10242
+ reply_completion_guard: replyCompletionGuard ?? true,
9621
10243
  ...(backend ? { backend } : {}),
9622
10244
  ...(model ? { model } : {}),
9623
10245
  ...(classicIdentity?.displayName ? { display_name: classicIdentity.displayName } : {}),
@@ -9750,6 +10372,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9750
10372
  clearInterval(entry.progressTimer);
9751
10373
  }
9752
10374
  this.cancelButtons.clear();
10375
+ this.cancelButtonPublications.clear();
9753
10376
  for (const timer of this.cancelButtonIdleRetireTimers.values())
9754
10377
  clearTimeout(timer);
9755
10378
  this.cancelButtonIdleRetireTimers.clear();
@@ -9757,6 +10380,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9757
10380
  clearInterval(this.topicCleanupTimer);
9758
10381
  this.topicCleanupTimer = null;
9759
10382
  }
10383
+ this.topicCleanupGeneration++;
9760
10384
  if (this.sessionPruneTimer) {
9761
10385
  clearInterval(this.sessionPruneTimer);
9762
10386
  this.sessionPruneTimer = null;
@@ -9899,10 +10523,17 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9899
10523
  // Keep that single message; the new process will adopt and finish it.
9900
10524
  setUpdateProgressStage(this.dataDir, "stopping");
9901
10525
  }
9902
- const groupId = this.fleetConfig?.channel?.group_id;
9903
- if (!trackedFullRestart && groupId && this.adapter) {
9904
- await this.adapter.sendText(String(groupId), t("restart.full_initiated"))
9905
- .catch(e => this.logger.warn({ err: e }, "Failed to post full restart notification"));
10526
+ const restartTarget = this.fleetNoticeTarget();
10527
+ if (!trackedFullRestart && this.adapter) {
10528
+ if (restartTarget) {
10529
+ await this.adapter.sendText(restartTarget.chatId, t("restart.full_initiated"), restartTarget.opts)
10530
+ .catch(e => this.logger.warn({ err: e }, "Failed to post full restart notification"));
10531
+ }
10532
+ else {
10533
+ // Say why nothing was posted. A restart that announces itself nowhere,
10534
+ // for a reason nobody logged, is the harder version of this bug.
10535
+ this.logger.warn("Full restart notice has no postable target — set a General topic or channel.options.general_channel_id");
10536
+ }
9906
10537
  }
9907
10538
  // Wait for idle with 5-minute timeout
9908
10539
  const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
@@ -10002,6 +10633,30 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10002
10633
  this.notifyFleetError(t("fleet.reload_rejected", why));
10003
10634
  return;
10004
10635
  }
10636
+ // Classic behavior settings share the fleet defaults but are not entries
10637
+ // in fleet.yaml. Snapshot the old effective chain before reloading the
10638
+ // Classic file so SIGHUP can hot-apply either source without waiting for
10639
+ // the 30-second Classic poller.
10640
+ const oldClassicBehavior = new Map();
10641
+ if (this.classicChannels) {
10642
+ for (const ch of this.classicChannels.getAll()) {
10643
+ const runtimeConfig = this.daemons.get(ch.instanceName)?.getConfigSnapshot?.();
10644
+ oldClassicBehavior.set(ch.instanceName, {
10645
+ backend: this.classicChannels.getBackend(ch.channelId, ch.adapterId, oldConfig?.defaults?.backend),
10646
+ model: this.classicChannels.getModel(ch.channelId, ch.adapterId, oldConfig?.defaults?.model),
10647
+ autoPauseAfter: this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, oldConfig?.defaults?.auto_pause_after),
10648
+ // Settings mutates FleetManager's in-memory defaults before SIGHUP.
10649
+ // The live daemon is therefore the authority for the previous hot
10650
+ // values, exactly as in the fleet-topic reconciliation below.
10651
+ toolProgress: runtimeConfig?.tool_progress
10652
+ ?? this.classicChannels.getToolProgress(ch.channelId, ch.adapterId, oldConfig?.defaults?.tool_progress),
10653
+ replyCompletionGuard: runtimeConfig?.reply_completion_guard
10654
+ ?? this.classicChannels.getReplyCompletionGuard(ch.channelId, ch.adapterId, oldConfig?.defaults?.reply_completion_guard),
10655
+ });
10656
+ }
10657
+ if (this.classicChannels.checkReload())
10658
+ this.reportClassicUnrecoverableIds();
10659
+ }
10005
10660
  this.routing.rebuild(this.fleetConfig);
10006
10661
  this.reregisterClassicChannels();
10007
10662
  this.scheduler?.reload();
@@ -10066,6 +10721,35 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10066
10721
  }
10067
10722
  }
10068
10723
  }
10724
+ // A Classic channel inherits fleet defaults beneath its own two levels.
10725
+ // Recompute that complete chain on SIGHUP. Only the two behavior switches
10726
+ // are hot; changes to backend/model/auto-pause retain the existing restart
10727
+ // semantics.
10728
+ if (this.classicChannels) {
10729
+ for (const ch of this.classicChannels.getAll()) {
10730
+ const old = oldClassicBehavior.get(ch.instanceName);
10731
+ if (!old || !this.daemons.has(ch.instanceName))
10732
+ continue;
10733
+ const backend = this.classicChannels.getBackend(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.backend);
10734
+ const model = this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model);
10735
+ const autoPauseAfter = this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after);
10736
+ if (old.backend !== backend || old.model !== model || old.autoPauseAfter !== autoPauseAfter) {
10737
+ this.logger.info({ instanceName: ch.instanceName }, "Classic cold config changed — restarting");
10738
+ await this.stopInstance(ch.instanceName).catch(() => { });
10739
+ await this.startClassicInstanceUnattended(ch, "classic instance after fleet reload");
10740
+ continue;
10741
+ }
10742
+ const toolProgress = this.classicChannels.getToolProgress(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.tool_progress);
10743
+ const replyCompletionGuard = this.classicChannels.getReplyCompletionGuard(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.reply_completion_guard);
10744
+ if (old.toolProgress !== toolProgress || old.replyCompletionGuard !== replyCompletionGuard) {
10745
+ this.applyHotConfigUpdate(ch.instanceName, {
10746
+ tool_progress: toolProgress,
10747
+ reply_completion_guard: replyCompletionGuard,
10748
+ });
10749
+ this.logger.info({ instanceName: ch.instanceName }, "Classic inherited hot config reloaded");
10750
+ }
10751
+ }
10752
+ }
10069
10753
  // warm_cap is fleet-owned; enforce the reloaded value immediately against
10070
10754
  // currently idle instances instead of waiting for a future state edge.
10071
10755
  this.enforceWarmCap();
@@ -10683,11 +11367,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10683
11367
  const instances = names.map(name => {
10684
11368
  const statusFile = join(this.getInstanceDir(name), "statusline.json");
10685
11369
  let cost = 0;
10686
- let model = "";
10687
11370
  try {
10688
11371
  const data = JSON.parse(readFileSync(statusFile, "utf-8"));
10689
11372
  cost = data.cost?.total_cost_usd ?? 0;
10690
- model = data.model?.display_name ?? "";
10691
11373
  }
10692
11374
  catch (err) {
10693
11375
  this.logger.debug({ err, name }, "statusline.json read failed (getUiStatus)");
@@ -10700,8 +11382,40 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10700
11382
  ?? this.fleetConfig?.defaults?.backend
10701
11383
  ?? "claude-code");
10702
11384
  const { context } = resolveInstanceContext(this.dataDir, name, backend);
10703
- const context_pct = context ?? 0;
10704
- return { name, status: this.getInstanceStatus(name), context_pct, cost, model };
11385
+ // context_pct: null when unavailable, not 0
11386
+ const context_pct = context ?? null;
11387
+ // Model: Only Claude Code has live statusline; others use the effective resolver.
11388
+ // readStatuslineModel provides the /ctx-aligned display_name+id combo for Claude.
11389
+ // Non-Claude backends must NOT read statusline.json model (may be stale from previous Claude run).
11390
+ const resolved = this.resolveInstanceModel(name);
11391
+ const liveModel = backend === "claude-code" ? readStatuslineModel(this.dataDir, name) : null;
11392
+ // Display value: live model for Claude, resolved.display for others (includes "auto (default)" for Kiro)
11393
+ const model = liveModel ?? resolved.display;
11394
+ // model_source: "live" when Claude statusline succeeded, else the resolver's source
11395
+ const model_source = liveModel ? "live" : resolved.source;
11396
+ // Effort: aligned with /ctx's effortLineFor — unsupported and antigravity don't show effort.
11397
+ const effortStrategy = this.effortStrategyFor(name);
11398
+ const isAgy = backend === "antigravity" || backend === "agy";
11399
+ const effortResolved = this.resolveInstanceEffort(name);
11400
+ // Only show effort if backend supports it and it's not antigravity
11401
+ const effort = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.effort;
11402
+ const effort_source = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.source;
11403
+ // Display name: fleet config → classic channel → undefined
11404
+ const display_name = classic
11405
+ ? this.classicChannels?.getAll().find(ch => ch.instanceName === name)?.displayName
11406
+ : this.fleetConfig?.instances[name]?.display_name;
11407
+ return {
11408
+ name,
11409
+ display_name: display_name || undefined,
11410
+ status: this.getInstanceStatus(name),
11411
+ context_pct,
11412
+ cost,
11413
+ model,
11414
+ model_source,
11415
+ backend,
11416
+ effort,
11417
+ effort_source,
11418
+ };
10705
11419
  });
10706
11420
  return {
10707
11421
  instances,