@songsid/agend 2.1.4-beta.17 → 2.1.4-beta.19

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 (49) hide show
  1. package/dist/backend/antigravity.d.ts +1 -0
  2. package/dist/backend/antigravity.js +4 -0
  3. package/dist/backend/antigravity.js.map +1 -1
  4. package/dist/backend/codex.d.ts +17 -0
  5. package/dist/backend/codex.js +51 -0
  6. package/dist/backend/codex.js.map +1 -1
  7. package/dist/backend/types.d.ts +10 -0
  8. package/dist/backend/types.js.map +1 -1
  9. package/dist/channel/adapters/discord.js +54 -20
  10. package/dist/channel/adapters/discord.js.map +1 -1
  11. package/dist/channel/adapters/telegram.js +5 -3
  12. package/dist/channel/adapters/telegram.js.map +1 -1
  13. package/dist/channel/types.d.ts +1 -1
  14. package/dist/cli.js +31 -21
  15. package/dist/cli.js.map +1 -1
  16. package/dist/config-validator.js +3 -0
  17. package/dist/config-validator.js.map +1 -1
  18. package/dist/config.js +5 -1
  19. package/dist/config.js.map +1 -1
  20. package/dist/daemon.d.ts +58 -2
  21. package/dist/daemon.js +263 -29
  22. package/dist/daemon.js.map +1 -1
  23. package/dist/doctor.js +34 -27
  24. package/dist/doctor.js.map +1 -1
  25. package/dist/fleet-context.d.ts +4 -0
  26. package/dist/fleet-manager.d.ts +25 -0
  27. package/dist/fleet-manager.js +451 -88
  28. package/dist/fleet-manager.js.map +1 -1
  29. package/dist/locale.js +656 -320
  30. package/dist/locale.js.map +1 -1
  31. package/dist/outbound-handlers.js +5 -1
  32. package/dist/outbound-handlers.js.map +1 -1
  33. package/dist/restart-progress.d.ts +2 -0
  34. package/dist/restart-progress.js +3 -0
  35. package/dist/restart-progress.js.map +1 -1
  36. package/dist/scheduler/db.d.ts +5 -0
  37. package/dist/scheduler/db.js +35 -0
  38. package/dist/scheduler/db.js.map +1 -1
  39. package/dist/tips.d.ts +34 -0
  40. package/dist/tips.js +554 -0
  41. package/dist/tips.js.map +1 -0
  42. package/dist/topic-commands.d.ts +9 -2
  43. package/dist/topic-commands.js +144 -59
  44. package/dist/topic-commands.js.map +1 -1
  45. package/dist/types.d.ts +2 -0
  46. package/dist/ui/dashboard.html +55 -32
  47. package/dist/ui/settings.html +46 -37
  48. package/dist/ui/view.html +23 -15
  49. package/package.json +1 -1
@@ -31,7 +31,7 @@ import { processAttachments } from "./channel/attachment-handler.js";
31
31
  import { routeToolCall } from "./channel/tool-router.js";
32
32
  import { Scheduler } from "./scheduler/index.js";
33
33
  import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
34
- import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, SAVE_UNSUPPORTED_MSG, CLEAR_UNSUPPORTED_MSG, resolveInstanceContext, forgetInstanceContext } from "./topic-commands.js";
34
+ import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, resolveInstanceContext, forgetInstanceContext } from "./topic-commands.js";
35
35
  import { DailySummary } from "./daily-summary.js";
36
36
  import { WebhookEmitter } from "./webhook-emitter.js";
37
37
  import { TmuxControlClient } from "./tmux-control.js";
@@ -45,7 +45,7 @@ import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
45
45
  import { handleViewRequest, isViewPath } from "./view-api.js";
46
46
  import { handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
47
47
  import { handleSettingsRequest } from "./settings-api.js";
48
- import { setLocale, detectLocale, t } from "./locale.js";
48
+ import { setLocale, detectLocale, getLocale, t } from "./locale.js";
49
49
  import { handleAgentRequest } from "./agent-endpoint.js";
50
50
  import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
51
51
  import { validateFleetConfig } from "./config-validator.js";
@@ -56,6 +56,7 @@ import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
56
56
  import { loadOrCreateWebToken, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
57
57
  import { RestartProgress } from "./restart-progress.js";
58
58
  import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
59
+ import { canUnlockAdvancedTips, DailyTipScheduler, selectTip } from "./tips.js";
59
60
  import { getTmuxSession } from "./config.js";
60
61
  export function resolveReplyThreadId(argsThreadId, instanceConfig) {
61
62
  if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
@@ -206,9 +207,12 @@ const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
206
207
  const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
207
208
  const HANG_CALLBACK_PREFIX = "hang:";
208
209
  const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
210
+ const TIP_DISMISS_CALLBACK_PREFIX = "tip-dismiss:";
211
+ const TIP_UNLOCK_CALLBACK_PREFIX = "tip-unlock:";
209
212
  const CLEAR_CONFIRM_TIMEOUT_MS = 15_000;
210
213
  /** Default lifetime for long-lived nonce prompts (clear overrides this to 15s). */
211
214
  const NONCE_BUTTON_TIMEOUT_MS = 15 * 60_000;
215
+ const TIP_BUTTON_TIMEOUT_MS = 24 * 60 * 60_000;
212
216
  const CLI_ENV_TTL_MS = 24 * 60 * 60 * 1000; // /model reads cached CLI env within 24h
213
217
  export class FleetManager {
214
218
  dataDir;
@@ -255,6 +259,7 @@ export class FleetManager {
255
259
  costGuard = null;
256
260
  statuslineWatcher;
257
261
  dailySummary = null;
262
+ dailyTipScheduler = null;
258
263
  webhookEmitter = null;
259
264
  // Topic icon + auto-archive state
260
265
  topicIcons = {};
@@ -267,6 +272,13 @@ export class FleetManager {
267
272
  warmCapEvicting = new Set();
268
273
  /** Per-instance tail keeps cross-instance and scheduled deliveries FIFO. */
269
274
  idleGatedDeliveryTails = new Map();
275
+ /**
276
+ * Per-instance cancellation epoch for deliveries which have not reached the
277
+ * daemon yet. A cancel advances the epoch; queued work captures the old value
278
+ * and becomes a no-op, while messages arriving after the click capture the new
279
+ * value and remain deliverable.
280
+ */
281
+ deliveryEpochs = new Map();
270
282
  /** Non-user work must observe a fresh idle snapshot after the latest delivery. */
271
283
  lastDeliveryAt = new Map();
272
284
  /** State-cache updates wake event-driven idle waiters without busy polling. */
@@ -325,6 +337,7 @@ export class FleetManager {
325
337
  updateProgressTimer = null;
326
338
  updateProgressEditRunning = false;
327
339
  lastUpdateProgressText = null;
340
+ updateCompletionTipText = null;
328
341
  eventLogPruneTimer = null;
329
342
  logRotateTimer = null;
330
343
  /** Days of event/activity history to keep. */
@@ -373,7 +386,7 @@ export class FleetManager {
373
386
  // fleet.yaml, sent SIGHUP, and got no reaction and no explanation.
374
387
  this.logger.error({ err }, "SIGHUP config reload failed");
375
388
  const message = err instanceof Error ? err.message : String(err);
376
- this.notifyFleetError(`⚠️ fleet.yaml reload FAILED — ${message}\nThe previous configuration is still running.`);
389
+ this.notifyFleetError(t("fleet.reload_failed", message));
377
390
  })
378
391
  .finally(() => {
379
392
  this.reconcileInFlight = null;
@@ -623,6 +636,36 @@ export class FleetManager {
623
636
  child.once("error", err => this.failUpdateProgress(err.message));
624
637
  child.unref();
625
638
  }
639
+ async handleTipsSlash(data, adapterId) {
640
+ if (!this.fleetConfig)
641
+ return;
642
+ const mode = typeof data.options?.mode === "string" ? data.options.mode : "";
643
+ if (mode === "on" || mode === "off") {
644
+ if (!this.isFleetAdmin(data.userId, adapterId)) {
645
+ await data.respond(t("permission.denied"));
646
+ return;
647
+ }
648
+ this.fleetConfig.defaults.tips = mode === "on";
649
+ this.saveFleetConfig();
650
+ await data.respond(t(mode === "on" ? "tips.enabled" : "tips.disabled"));
651
+ return;
652
+ }
653
+ const route = this.routing.resolve(data.channelId);
654
+ if (!route || route.kind !== "general") {
655
+ await data.respond(t("tips.general_only"));
656
+ return;
657
+ }
658
+ const adapter = this.adapters.get(adapterId) ?? this.adapter;
659
+ if (!adapter) {
660
+ await data.respond(t("tips.unavailable"));
661
+ return;
662
+ }
663
+ const chatId = this.getGroupIdForInstance(route.name) || data.channelId;
664
+ const result = await this.promptTip(route.name, adapter, chatId, data.channelId);
665
+ await data.respond(t(result === "posted"
666
+ ? "tips.posted"
667
+ : result === "empty" ? "tips.empty" : "tips.unavailable"));
668
+ }
626
669
  /** Admin-only full conversation reset for fleet-topic and Classic instances. */
627
670
  async handleClearSlash(data, adapterId) {
628
671
  if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
@@ -911,7 +954,7 @@ export class FleetManager {
911
954
  .finally(() => this.warmCapEvicting.delete(victim));
912
955
  }
913
956
  }
914
- waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter = 0) {
957
+ waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter = 0, cancelled) {
915
958
  const isReady = () => {
916
959
  const snapshot = this.instanceStateCache.get(instanceName);
917
960
  return snapshot?.state === "idle"
@@ -933,8 +976,12 @@ export class FleetManager {
933
976
  this.instanceIdleWaiters.delete(instanceName);
934
977
  resolve(idle);
935
978
  };
936
- const check = () => { if (isReady())
937
- finish(true); };
979
+ const check = () => {
980
+ if (cancelled?.())
981
+ finish(false);
982
+ else if (isReady())
983
+ finish(true);
984
+ };
938
985
  const query = () => {
939
986
  const ipc = this.instanceIpcClients.get(instanceName);
940
987
  if (ipc?.connected) {
@@ -992,22 +1039,32 @@ export class FleetManager {
992
1039
  finish(false);
993
1040
  });
994
1041
  }
995
- async deliverWithIdleGate(instanceName, payload, timeoutMs) {
1042
+ async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch) {
1043
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1044
+ return;
996
1045
  let idleObservedAfter = this.lastDeliveryAt.get(instanceName) ?? 0;
997
1046
  if (this.lifecycle.isPaused(instanceName)) {
998
1047
  const wakeStartedAt = Date.now();
999
1048
  await this.lifecycle.wake(instanceName, 30_000);
1049
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1050
+ return;
1000
1051
  // Waking added one to the warm count — make room by evicting a different
1001
1052
  // LRU idle instance (never this one; it's about to work).
1002
1053
  this.enforceWarmCap(instanceName);
1003
1054
  // Never satisfy a post-wake gate from a stale pre-pause cache entry.
1004
1055
  idleObservedAfter = Math.max(idleObservedAfter, wakeStartedAt);
1005
1056
  }
1006
- const idle = await this.waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter);
1057
+ const idle = await this.waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter, () => !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch));
1058
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch)) {
1059
+ this.logger.info({ instanceName }, "Pending delivery dropped by user cancel");
1060
+ return;
1061
+ }
1007
1062
  if (!idle) {
1008
1063
  this.logger.warn({ instanceName, timeoutMs }, "Idle gate timed out; forcing delivery");
1009
1064
  }
1010
- await this.sendWhenConnected(instanceName, payload);
1065
+ await this.sendWhenConnected(instanceName, payload, deliveryEpoch);
1066
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1067
+ return;
1011
1068
  this.lastDeliveryAt.set(instanceName, Date.now());
1012
1069
  }
1013
1070
  /**
@@ -1026,7 +1083,9 @@ export class FleetManager {
1026
1083
  * instance, *including* when the socket happens to be up: otherwise a message
1027
1084
  * arriving after the reconnect could overtake one that has been waiting for it.
1028
1085
  */
1029
- async sendWhenConnected(instanceName, payload) {
1086
+ async sendWhenConnected(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName)) {
1087
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1088
+ return;
1030
1089
  const queued = this.ipcWaitTails.get(instanceName);
1031
1090
  if (!queued) {
1032
1091
  const ipc = this.instanceIpcClients.get(instanceName);
@@ -1035,7 +1094,11 @@ export class FleetManager {
1035
1094
  }
1036
1095
  const attempt = (queued ?? Promise.resolve())
1037
1096
  .catch(() => { })
1038
- .then(() => this.sendAfterIpcReturns(instanceName, payload));
1097
+ .then(() => {
1098
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1099
+ return;
1100
+ return this.sendAfterIpcReturns(instanceName, payload, deliveryEpoch);
1101
+ });
1039
1102
  // The chain stores a settled-either-way promise so one failed delivery cannot
1040
1103
  // wedge every later one, and so `queued` above is safe to await unguarded.
1041
1104
  const tail = attempt.catch(() => { });
@@ -1052,10 +1115,12 @@ export class FleetManager {
1052
1115
  }
1053
1116
  }
1054
1117
  /** Poll for the instance's IPC to come back, then send. Throws if it does not. */
1055
- async sendAfterIpcReturns(instanceName, payload) {
1118
+ async sendAfterIpcReturns(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName)) {
1056
1119
  const deadline = Date.now() + IPC_RECONNECT_GRACE_MS;
1057
1120
  let warned = false;
1058
1121
  for (;;) {
1122
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1123
+ return;
1059
1124
  // Re-read every round: a reconnect replaces the IpcClient object entirely,
1060
1125
  // so a cached reference would stay dead forever.
1061
1126
  const ipc = this.instanceIpcClients.get(instanceName);
@@ -1073,6 +1138,8 @@ export class FleetManager {
1073
1138
  }
1074
1139
  /** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
1075
1140
  async deliverToInstance(instanceName, payload, options = {}) {
1141
+ const deliveryEpoch = this.getDeliveryEpoch(instanceName);
1142
+ const deliveryPayload = { ...payload, delivery_epoch: deliveryEpoch };
1076
1143
  const meta = payload.meta && typeof payload.meta === "object"
1077
1144
  ? payload.meta
1078
1145
  : undefined;
@@ -1084,16 +1151,20 @@ export class FleetManager {
1084
1151
  if (!waitForIdle) {
1085
1152
  if (this.lifecycle.isPaused(instanceName)) {
1086
1153
  await this.lifecycle.wake(instanceName, 30_000);
1154
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1155
+ return;
1087
1156
  this.enforceWarmCap(instanceName); // woke one → evict a different LRU idle if over cap
1088
1157
  }
1089
- await this.sendWhenConnected(instanceName, payload);
1158
+ await this.sendWhenConnected(instanceName, deliveryPayload, deliveryEpoch);
1159
+ if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
1160
+ return;
1090
1161
  // A cross-instance item arriving before the daemon observes this turn as
1091
1162
  // working must not trust the stale idle snapshot from before the send.
1092
1163
  this.lastDeliveryAt.set(instanceName, Date.now());
1093
1164
  return;
1094
1165
  }
1095
1166
  const previous = this.idleGatedDeliveryTails.get(instanceName) ?? Promise.resolve();
1096
- const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, payload, options.idleTimeoutMs ?? 60_000));
1167
+ const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch));
1097
1168
  this.idleGatedDeliveryTails.set(instanceName, delivery);
1098
1169
  try {
1099
1170
  await delivery;
@@ -1178,7 +1249,7 @@ export class FleetManager {
1178
1249
  // routing instructions are not injected, so it cannot act as a dispatcher.
1179
1250
  if (backend === "antigravity") {
1180
1251
  this.logger.warn({ name }, "antigravity backend does not support MCP instructions — general dispatcher will not work correctly");
1181
- this.notifyInstanceTopic(name, "⚠️ antigravity backend is not supported for General instances (no MCP instructions injection). Switch to claude-code or kiro-cli.");
1252
+ this.notifyInstanceTopic(name, t("general.antigravity_unsupported"));
1182
1253
  }
1183
1254
  this.ensureGeneralInstructions(config.working_directory, backend, name);
1184
1255
  }
@@ -1483,6 +1554,7 @@ export class FleetManager {
1483
1554
  messageId,
1484
1555
  });
1485
1556
  this.lastUpdateProgressText = null;
1557
+ this.updateCompletionTipText = null;
1486
1558
  this.startUpdateProgressMonitor(adapter);
1487
1559
  }
1488
1560
  failUpdateProgress(message) {
@@ -1506,7 +1578,17 @@ export class FleetManager {
1506
1578
  const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
1507
1579
  if (!adapter)
1508
1580
  return;
1509
- const text = formatUpdateProgress(marker);
1581
+ let text = formatUpdateProgress(marker);
1582
+ // No-restart updates complete in the old fleet process, so they never
1583
+ // reach RestartProgress.finish(). Append the same optional tip here.
1584
+ if (marker.progress.stage === "complete" && this.tipsEnabled()) {
1585
+ if (this.updateCompletionTipText === null) {
1586
+ const tip = this.pickAvailableTip();
1587
+ this.updateCompletionTipText = tip ? this.formatTip(tip) : "";
1588
+ }
1589
+ if (this.updateCompletionTipText)
1590
+ text += `\n\n${this.updateCompletionTipText}`;
1591
+ }
1510
1592
  if (text === this.lastUpdateProgressText)
1511
1593
  return;
1512
1594
  this.updateProgressEditRunning = true;
@@ -1794,6 +1876,13 @@ export class FleetManager {
1794
1876
  this.scheduler = new Scheduler(join(this.dataDir, "scheduler.db"), (schedule) => this.handleScheduleTrigger(schedule), schedulerConfig, (name) => this.fleetConfig?.instances?.[name] != null || !!this.classicChannels?.getAll().some(ch => ch.instanceName === name));
1795
1877
  this.scheduler.init();
1796
1878
  this.logger.info("Scheduler initialized");
1879
+ // Tips share the daily-report clock but remain an independent internal
1880
+ // job: disabling daily_summary must not disable tips (and vice versa).
1881
+ // The callback checks defaults.tips at fire time, so /tips on|off is hot.
1882
+ this.dailyTipScheduler = new DailyTipScheduler(summaryConfig, costGuardConfig.timezone, () => this.sendTipToGeneral().catch(err => {
1883
+ this.logger.warn({ err }, "Failed to send daily tip");
1884
+ }));
1885
+ this.dailyTipScheduler.start();
1797
1886
  // Inject active decisions as env var for MCP instructions.
1798
1887
  // Snapshotted at startup — new decisions via post_decision are available
1799
1888
  // through list_decisions tool but not auto-injected until restart.
@@ -1976,6 +2065,12 @@ export class FleetManager {
1976
2065
  version: agendVersion,
1977
2066
  pausedNames,
1978
2067
  failedNames,
2068
+ tipText: pendingUpdateProgress && this.tipsEnabled()
2069
+ ? (() => {
2070
+ const tip = this.pickAvailableTip();
2071
+ return tip ? this.formatTip(tip) : undefined;
2072
+ })()
2073
+ : undefined,
1979
2074
  });
1980
2075
  if (!progressCompleted && this.adapter && fleet.channel?.group_id) {
1981
2076
  let text;
@@ -2137,14 +2232,14 @@ export class FleetManager {
2137
2232
  notifyAdapterFailure(adapterId, error) {
2138
2233
  const generalId = this.findGeneralInstance();
2139
2234
  if (generalId) {
2140
- this.notifyInstanceTopic(generalId, `⚠️ Adapter "${adapterId}" failed to start: ${error}\nRetrying in background. Other adapters unaffected.`);
2235
+ this.notifyInstanceTopic(generalId, t("adapter.start_failed", adapterId, error));
2141
2236
  }
2142
2237
  }
2143
2238
  /** Notify admin that a retried adapter reconnected. */
2144
2239
  notifyAdapterRecovery(adapterId, attempts) {
2145
2240
  const generalId = this.findGeneralInstance();
2146
2241
  if (generalId) {
2147
- this.notifyInstanceTopic(generalId, `✅ Adapter "${adapterId}" reconnected (after ${attempts} ${attempts === 1 ? "retry" : "retries"}).`);
2242
+ this.notifyInstanceTopic(generalId, t("adapter.reconnected", adapterId, attempts));
2148
2243
  }
2149
2244
  }
2150
2245
  /** Get adapter states for /status visibility. */
@@ -2242,6 +2337,10 @@ export class FleetManager {
2242
2337
  await this.handleInboundReaction(r);
2243
2338
  }, this.logger, "adapter.reaction"));
2244
2339
  this.adapter.on("callback_query", safeHandler(async (data) => {
2340
+ if (await this.handleTipDismiss(data, adapterId, this.adapter ?? undefined))
2341
+ return;
2342
+ if (await this.handleTipUnlock(data, adapterId, this.adapter ?? undefined))
2343
+ return;
2245
2344
  if (await this.handleClearConfirmation(data, adapterId, this.adapter ?? undefined))
2246
2345
  return;
2247
2346
  if (await this.handleExitRestartPrompt(data, adapterId, this.adapter ?? undefined))
@@ -2354,6 +2453,23 @@ export class FleetManager {
2354
2453
  });
2355
2454
  await data.respond(result);
2356
2455
  }
2456
+ else if (data.command === "btw") {
2457
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2458
+ if (!name) {
2459
+ await data.respond(t("classic.no_agent"));
2460
+ return;
2461
+ }
2462
+ const btwText = String(data.options?.message ?? "").trim();
2463
+ if (!btwText) {
2464
+ await data.respond(t("btw.usage"));
2465
+ return;
2466
+ }
2467
+ const result = this.topicCommands.sendBtw(name, btwText, {
2468
+ chatId: "", messageId: "", username: data.username ?? "user",
2469
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2470
+ });
2471
+ await data.respond(result);
2472
+ }
2357
2473
  else if (data.command === "clear") {
2358
2474
  await this.handleClearSlash(data, adapterId);
2359
2475
  }
@@ -2430,9 +2546,12 @@ export class FleetManager {
2430
2546
  await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
2431
2547
  }
2432
2548
  catch (err) {
2433
- await data.respond(`⚠️ Usage fetch failed: ${err.message}`);
2549
+ await data.respond(t("usage.failed", err.message));
2434
2550
  }
2435
2551
  }
2552
+ else if (data.command === "tips") {
2553
+ await this.handleTipsSlash(data, adapterId);
2554
+ }
2436
2555
  else if (data.command === "status") {
2437
2556
  // Admin-gated (like the topic path): the merged table shows every
2438
2557
  // instance's cost and IPC health.
@@ -2498,6 +2617,23 @@ export class FleetManager {
2498
2617
  });
2499
2618
  await data.respond(result);
2500
2619
  }
2620
+ else if (data.command === "btw") {
2621
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2622
+ if (!name) {
2623
+ await data.respond(t("classic.no_agent"));
2624
+ return;
2625
+ }
2626
+ const btwText = String(data.options?.message ?? "").trim();
2627
+ if (!btwText) {
2628
+ await data.respond(t("btw.usage"));
2629
+ return;
2630
+ }
2631
+ const result = this.topicCommands.sendBtw(name, btwText, {
2632
+ chatId: "", messageId: "", username: data.username ?? "user",
2633
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2634
+ });
2635
+ await data.respond(result);
2636
+ }
2501
2637
  }, this.logger, "adapter.slash_command"));
2502
2638
  await this.topicCommands.registerBotCommands().catch(e => this.logger.warn({ err: e }, "registerBotCommands failed (non-fatal)"));
2503
2639
  // Background-probe each backend's CLI env (version/models) → cli-env cache.
@@ -2574,6 +2710,10 @@ export class FleetManager {
2574
2710
  await this.handleInboundReaction(r);
2575
2711
  }, this.logger, `adapter[${adapterId}].reaction`));
2576
2712
  adapter.on("callback_query", safeHandler(async (data) => {
2713
+ if (await this.handleTipDismiss(data, adapterId, adapter))
2714
+ return;
2715
+ if (await this.handleTipUnlock(data, adapterId, adapter))
2716
+ return;
2577
2717
  if (await this.handleClearConfirmation(data, adapterId, adapter))
2578
2718
  return;
2579
2719
  if (await this.handleExitRestartPrompt(data, adapterId, adapter))
@@ -2732,9 +2872,12 @@ export class FleetManager {
2732
2872
  await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
2733
2873
  }
2734
2874
  catch (err) {
2735
- await data.respond(`⚠️ Usage fetch failed: ${err.message}`);
2875
+ await data.respond(t("usage.failed", err.message));
2736
2876
  }
2737
2877
  }
2878
+ else if (data.command === "tips") {
2879
+ await this.handleTipsSlash(data, adapterId);
2880
+ }
2738
2881
  else if (data.command === "status") {
2739
2882
  // Admin-gated (like the topic path): the merged table shows every
2740
2883
  // instance's cost and IPC health.
@@ -2800,6 +2943,23 @@ export class FleetManager {
2800
2943
  });
2801
2944
  await data.respond(result);
2802
2945
  }
2946
+ else if (data.command === "btw") {
2947
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
2948
+ if (!name) {
2949
+ await data.respond(t("classic.no_agent"));
2950
+ return;
2951
+ }
2952
+ const btwText = String(data.options?.message ?? "").trim();
2953
+ if (!btwText) {
2954
+ await data.respond(t("btw.usage"));
2955
+ return;
2956
+ }
2957
+ const result = this.topicCommands.sendBtw(name, btwText, {
2958
+ chatId: "", messageId: "", username: data.username ?? "user",
2959
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
2960
+ });
2961
+ await data.respond(result);
2962
+ }
2803
2963
  }, this.logger, `adapter[${adapterId}].slash_command`));
2804
2964
  await adapter.start();
2805
2965
  if (channelConfig.group_id) {
@@ -3385,6 +3545,24 @@ export class FleetManager {
3385
3545
  await msgAdapter?.sendText(chatId, result);
3386
3546
  return;
3387
3547
  }
3548
+ // /btw — Claude Code's native side-thread command. Like /steer it is
3549
+ // not admin-gated, but it remains a distinct raw CLI command so Claude
3550
+ // does not fold the question into the active task.
3551
+ if (text === "/btw" || text.startsWith("/btw ") || text.startsWith("/btw@")) {
3552
+ const btwName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
3553
+ if (!btwName) {
3554
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
3555
+ return;
3556
+ }
3557
+ const btwContent = text.replace(/^\/btw(@\S+)?/, "").trim();
3558
+ if (!btwContent) {
3559
+ await msgAdapter?.sendText(chatId, t("btw.usage"));
3560
+ return;
3561
+ }
3562
+ const result = this.topicCommands.sendBtw(btwName, btwContent, msg);
3563
+ await msgAdapter?.sendText(chatId, result);
3564
+ return;
3565
+ }
3388
3566
  // Handle /clear command (admin only) — unlike /compact this starts a
3389
3567
  // fresh conversation and intentionally discards the current history.
3390
3568
  if (text === "/clear" || text.startsWith("/clear@")) {
@@ -3412,7 +3590,7 @@ export class FleetManager {
3412
3590
  return;
3413
3591
  }
3414
3592
  const ok = this.cancelInstance(cancelName);
3415
- await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelName}。` : `❌ ${cancelName} 未在執行。`);
3593
+ await msgAdapter?.sendText(chatId, ok ? t("cancel.sent", cancelName) : t("cancel.not_running", cancelName));
3416
3594
  return;
3417
3595
  }
3418
3596
  // Handle /ctx command
@@ -3449,7 +3627,7 @@ export class FleetManager {
3449
3627
  const backend = this.classicChannels.getBackendByInstance(saveName, this.fleetConfig?.defaults?.backend);
3450
3628
  const cmd = saveCommandForBackend(backend, filename);
3451
3629
  if (!cmd) {
3452
- await msgAdapter?.sendText(chatId, SAVE_UNSUPPORTED_MSG);
3630
+ await msgAdapter?.sendText(chatId, t("save.unsupported"));
3453
3631
  return;
3454
3632
  }
3455
3633
  this.pasteRawToClassicInstance(saveName, cmd);
@@ -3667,10 +3845,10 @@ export class FleetManager {
3667
3845
  return;
3668
3846
  let warning = "";
3669
3847
  if (rl.five_hour_pct >= 95) {
3670
- warning = `⚠️ ${instanceName} at ${Math.round(rl.five_hour_pct)}% of 5h rate limit. Responses may be slower.`;
3848
+ warning = t("rate_limit.five_hour", instanceName, Math.round(rl.five_hour_pct));
3671
3849
  }
3672
3850
  else if (rl.seven_day_pct >= 95) {
3673
- warning = `⚠️ ${instanceName} at ${Math.round(rl.seven_day_pct)}% weekly usage. Responses may be slower or fail.`;
3851
+ warning = t("rate_limit.weekly", instanceName, Math.round(rl.seven_day_pct));
3674
3852
  }
3675
3853
  if (!warning)
3676
3854
  return;
@@ -3831,7 +4009,11 @@ export class FleetManager {
3831
4009
  if (silent) {
3832
4010
  const ipc = this.instanceIpcClients.get(target);
3833
4011
  if (ipc) {
3834
- ipc.send({ type: "raw_paste", content: message });
4012
+ ipc.send({
4013
+ type: "raw_paste",
4014
+ content: message,
4015
+ delivery_epoch: this.getDeliveryEpoch(target),
4016
+ });
3835
4017
  this.scheduler.recordRun(id, "delivered");
3836
4018
  this.logger.info({ target, scheduleId: id, label }, "Silent schedule injected via raw_paste");
3837
4019
  }
@@ -3846,10 +4028,19 @@ export class FleetManager {
3846
4028
  const retryInterval = schedulerDefaults?.retry_interval_ms ?? 30_000;
3847
4029
  const deliver = async () => {
3848
4030
  try {
4031
+ // A schedule has no live inbound adapter context. Seed the daemon with
4032
+ // the target instance's configured world so replies use its persona
4033
+ // after a fresh start instead of falling back to channels[0].
4034
+ const adapterId = this.getInstanceAdapterId(target);
3849
4035
  await this.deliverToInstance(target, {
3850
4036
  type: "fleet_schedule_trigger",
3851
4037
  payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
3852
- meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
4038
+ meta: {
4039
+ chat_id: reply_chat_id,
4040
+ thread_id: reply_thread_id,
4041
+ user: "scheduler",
4042
+ ...(adapterId ? { adapter_id: adapterId } : {}),
4043
+ },
3853
4044
  }, { waitForIdle: true });
3854
4045
  // A scheduled trigger also puts the instance to work — show a cancel button.
3855
4046
  void this.sendCancelButton(target);
@@ -4560,7 +4751,7 @@ export class FleetManager {
4560
4751
  from: primaryModel, to: fallbackModel, five_hour_pct: fiveHourPct,
4561
4752
  });
4562
4753
  this.webhookEmitter?.emit("model_failover", name, { from: primaryModel, to: fallbackModel, five_hour_pct: fiveHourPct });
4563
- this.notifyInstanceTopic(name, `⚡ Rate limit ${fiveHourPct}% — next rotation will use ${fallbackModel} (was ${primaryModel})`);
4754
+ this.notifyInstanceTopic(name, t("failover.triggered", fiveHourPct, fallbackModel, primaryModel));
4564
4755
  }
4565
4756
  else if (fiveHourPct < FleetManager.FAILOVER_RECOVER_PCT && currentFailover) {
4566
4757
  // Recover: switch back to primary
@@ -4571,7 +4762,7 @@ export class FleetManager {
4571
4762
  restored: primaryModel, five_hour_pct: fiveHourPct,
4572
4763
  });
4573
4764
  this.webhookEmitter?.emit("model_recovered", name, { restored: primaryModel, five_hour_pct: fiveHourPct });
4574
- this.notifyInstanceTopic(name, `✅ Rate limit recovered (${fiveHourPct}%) — next rotation will use ${primaryModel}`);
4765
+ this.notifyInstanceTopic(name, t("failover.recovered", fiveHourPct, primaryModel));
4575
4766
  }
4576
4767
  }
4577
4768
  toggleFleetCollab(instanceName) {
@@ -4625,9 +4816,9 @@ export class FleetManager {
4625
4816
  if (output)
4626
4817
  return output;
4627
4818
  if (e.code === "ENOENT") {
4628
- return "Backend doctor unavailable — agend CLI not found in PATH";
4819
+ return t("doctor.cli_missing");
4629
4820
  }
4630
- return stripAnsi(e.message ?? "").trim() || "Doctor failed";
4821
+ return stripAnsi(e.message ?? "").trim() || t("doctor.failed");
4631
4822
  }
4632
4823
  }
4633
4824
  /** Drop event/activity rows older than the retention window. Best-effort. */
@@ -4729,7 +4920,7 @@ export class FleetManager {
4729
4920
  this.fleetErrorNotices.delete(oldest);
4730
4921
  }
4731
4922
  const body = suppressed > 0
4732
- ? `${text}\n(plus ${suppressed} more in the last ${Math.round(FleetManager.FLEET_ERROR_THROTTLE_MS / 60_000)}m)`
4923
+ ? `${text}\n${t("fleet.error_suppressed", suppressed, Math.round(FleetManager.FLEET_ERROR_THROTTLE_MS / 60_000))}`
4733
4924
  : text;
4734
4925
  // Resolved from config, NOT findGeneralInstance(): that requires a live daemon,
4735
4926
  // and a fleet-level fault is exactly when the General may be down. The topic
@@ -4783,7 +4974,8 @@ export class FleetManager {
4783
4974
  //
4784
4975
  // One shared lifecycle for every "notification with decision buttons":
4785
4976
  // post with a 128-bit nonce, arm a bounded expiry, bind the click to the
4786
- // exact adapter+chat+thread+message that created it, require fleet admin,
4977
+ // exact adapter+chat+thread+message that created it, require fleet admin for
4978
+ // actions that mutate runtime state (Tips are the harmless exception),
4787
4979
  // consume exactly once. The features differ only in what they post
4788
4980
  // and what a consumed click does.
4789
4981
  /**
@@ -4865,11 +5057,14 @@ export class FleetManager {
4865
5057
  return "consumed";
4866
5058
  }
4867
5059
  // Bind the capability to the exact message/world that created it. Telegram
4868
- // keyboards are visible to everyone, so the click also requires fleet admin.
5060
+ // keyboards are visible to everyone, so mutating actions require fleet admin;
5061
+ // a Tip acknowledgement only records that the shared content was read.
4869
5062
  const isAuthorized = data.userId
4870
- ? pending.authChannelId
4871
- ? this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
4872
- : this.isFleetAdmin(data.userId, callbackAdapterId)
5063
+ ? pending.allowAnyUser
5064
+ ? true
5065
+ : pending.authChannelId
5066
+ ? this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
5067
+ : this.isFleetAdmin(data.userId, callbackAdapterId)
4873
5068
  : false;
4874
5069
  if (pending.adapterId !== callbackAdapterId
4875
5070
  || data.chatId !== pending.chatId
@@ -4902,10 +5097,156 @@ export class FleetManager {
4902
5097
  this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons");
4903
5098
  }
4904
5099
  }
5100
+ tipsEnabled() {
5101
+ return this.fleetConfig?.defaults.tips !== false;
5102
+ }
5103
+ readTipState() {
5104
+ if (!this.scheduler)
5105
+ return null;
5106
+ try {
5107
+ return {
5108
+ dismissed: this.scheduler.db.listDismissedTipIds(),
5109
+ advancedUnlocked: this.scheduler.db.isAdvancedTipsUnlocked(),
5110
+ };
5111
+ }
5112
+ catch (err) {
5113
+ // Tips are additive. A damaged/locked scheduler DB must never block fleet
5114
+ // startup, update completion, or a General command.
5115
+ this.logger.warn({ err }, "Failed to read tip state");
5116
+ return null;
5117
+ }
5118
+ }
5119
+ pickAvailableTip() {
5120
+ const state = this.readTipState();
5121
+ return state ? selectTip(state.dismissed, Math.random, state.advancedUnlocked) : null;
5122
+ }
5123
+ tipText(tip) {
5124
+ return getLocale() === "zh-TW" ? tip.text_zh : tip.text_en;
5125
+ }
5126
+ formatTip(tip) {
5127
+ return `💡 ${t("tips.label")}: ${this.tipText(tip)}`;
5128
+ }
5129
+ /** Post one fresh nonce-armed tip in a known General channel. */
5130
+ async promptTip(generalName, adapter, chatId, threadId) {
5131
+ const state = this.readTipState();
5132
+ if (!state)
5133
+ return "unavailable";
5134
+ if (!state.advancedUnlocked && canUnlockAdvancedTips(state.dismissed)) {
5135
+ return await this.promptAdvancedTipUnlock(generalName, adapter, chatId, threadId)
5136
+ ? "posted"
5137
+ : "unavailable";
5138
+ }
5139
+ const tip = selectTip(state.dismissed, Math.random, state.advancedUnlocked);
5140
+ if (!tip)
5141
+ return this.scheduler ? "empty" : "unavailable";
5142
+ const nonce = await this.postNonceButtonPrompt({
5143
+ prefix: TIP_DISMISS_CALLBACK_PREFIX,
5144
+ alertType: "tip",
5145
+ instanceName: generalName,
5146
+ adapter,
5147
+ adapterId: adapter.id,
5148
+ chatId,
5149
+ threadId,
5150
+ message: this.formatTip(tip),
5151
+ choices: [{ action: "dismiss", label: t("tips.dismiss") }],
5152
+ // Expiry removes only the stale button; the useful tip remains readable.
5153
+ expiredText: this.formatTip(tip),
5154
+ extra: { allowAnyUser: true, tipId: tip.id },
5155
+ timeoutMs: TIP_BUTTON_TIMEOUT_MS,
5156
+ });
5157
+ return nonce ? "posted" : "unavailable";
5158
+ }
5159
+ async promptAdvancedTipUnlock(generalName, adapter, chatId, threadId) {
5160
+ const nonce = await this.postNonceButtonPrompt({
5161
+ prefix: TIP_UNLOCK_CALLBACK_PREFIX,
5162
+ alertType: "tip",
5163
+ instanceName: generalName,
5164
+ adapter,
5165
+ adapterId: adapter.id,
5166
+ chatId,
5167
+ threadId,
5168
+ message: t("tips.advanced.unlock_prompt"),
5169
+ choices: [{ action: "unlock", label: t("tips.advanced.unlock") }],
5170
+ expiredText: t("tips.advanced.expired"),
5171
+ extra: { allowAnyUser: true },
5172
+ timeoutMs: TIP_BUTTON_TIMEOUT_MS,
5173
+ });
5174
+ return nonce !== null;
5175
+ }
5176
+ async sendTipToGeneral() {
5177
+ if (!this.tipsEnabled())
5178
+ return;
5179
+ const generalName = this.findGeneralInstance();
5180
+ if (!generalName)
5181
+ return;
5182
+ const adapter = this.getAdapterForInstance(generalName);
5183
+ const chatId = this.getGroupIdForInstance(generalName);
5184
+ const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
5185
+ if (!adapter || !chatId)
5186
+ return;
5187
+ const result = await this.promptTip(generalName, adapter, chatId, topicId != null ? String(topicId) : undefined);
5188
+ if (result === "unavailable") {
5189
+ this.logger.warn({ generalName }, "Daily tip could not be posted");
5190
+ }
5191
+ }
5192
+ async handleTipDismiss(data, callbackAdapterId, receivingAdapter) {
5193
+ const claimed = this.consumeNonceCallback(TIP_DISMISS_CALLBACK_PREFIX, /^tip-dismiss:([0-9a-f]+):(dismiss)$/, data, callbackAdapterId, receivingAdapter);
5194
+ if (claimed === null)
5195
+ return false;
5196
+ if (claimed === "consumed")
5197
+ return true;
5198
+ const { entry: pending } = claimed;
5199
+ if (!pending.tipId || !data.userId || !this.scheduler) {
5200
+ this.logger.error({ tipId: pending.tipId, userId: data.userId }, "Tip dismissal has incomplete persistence context");
5201
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
5202
+ return true;
5203
+ }
5204
+ try {
5205
+ this.scheduler.db.dismissTip(data.userId, pending.tipId);
5206
+ }
5207
+ catch (err) {
5208
+ this.logger.warn({ err, tipId: pending.tipId, userId: data.userId }, "Failed to persist tip dismissal");
5209
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
5210
+ return true;
5211
+ }
5212
+ this.eventLog?.insert(pending.instanceName, "tip_dismissed", {
5213
+ tipId: pending.tipId,
5214
+ userId: data.userId,
5215
+ });
5216
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.dismissed"));
5217
+ const state = this.readTipState();
5218
+ if (state && !state.advancedUnlocked && canUnlockAdvancedTips(state.dismissed)) {
5219
+ await this.promptAdvancedTipUnlock(pending.instanceName, pending.adapter, pending.chatId, pending.threadId);
5220
+ }
5221
+ return true;
5222
+ }
5223
+ async handleTipUnlock(data, callbackAdapterId, receivingAdapter) {
5224
+ const claimed = this.consumeNonceCallback(TIP_UNLOCK_CALLBACK_PREFIX, /^tip-unlock:([0-9a-f]+):(unlock)$/, data, callbackAdapterId, receivingAdapter);
5225
+ if (claimed === null)
5226
+ return false;
5227
+ if (claimed === "consumed")
5228
+ return true;
5229
+ const { entry: pending } = claimed;
5230
+ if (!data.userId || !this.scheduler) {
5231
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
5232
+ return true;
5233
+ }
5234
+ try {
5235
+ this.scheduler.db.unlockAdvancedTips(data.userId);
5236
+ }
5237
+ catch (err) {
5238
+ this.logger.warn({ err, userId: data.userId }, "Failed to unlock advanced tips");
5239
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
5240
+ return true;
5241
+ }
5242
+ this.eventLog?.insert(pending.instanceName, "tips_advanced_unlocked", { userId: data.userId });
5243
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.advanced.unlocked"));
5244
+ return true;
5245
+ }
4905
5246
  /** Post the destructive `/clear` confirmation in the invoking channel. */
4906
5247
  async promptClearConfirmation(instanceName, channelId, adapter, chatId, threadId) {
4907
5248
  if (!this.topicCommands.supportsClear(instanceName))
4908
- return CLEAR_UNSUPPORTED_MSG;
5249
+ return t("clear.unsupported");
4909
5250
  const adapterId = adapter.id;
4910
5251
  if (!adapterId)
4911
5252
  return t("clear.prompt_unavailable");
@@ -5179,7 +5520,7 @@ export class FleetManager {
5179
5520
  const force = target.kind === "classic" && !!data.options?.force;
5180
5521
  const cmd = saveCommandForBackend(backend, filename, force);
5181
5522
  if (!cmd) {
5182
- await data.respond(SAVE_UNSUPPORTED_MSG);
5523
+ await data.respond(t("save.unsupported"));
5183
5524
  return;
5184
5525
  }
5185
5526
  if (target.kind === "classic") {
@@ -5731,11 +6072,27 @@ export class FleetManager {
5731
6072
  const daemon = this.daemons.get(instanceName);
5732
6073
  if (!daemon)
5733
6074
  return false;
6075
+ const deliveryEpoch = this.cancelPendingDeliveries(instanceName);
6076
+ daemon.clearPendingDeliveries?.(deliveryEpoch);
5734
6077
  daemon.sendEscape().catch(e => this.logger.warn({ err: e, instanceName }, "sendEscape failed"));
5735
6078
  this.lastInboundMsg.delete(instanceName);
5736
6079
  this.clearCancelButton(instanceName);
5737
6080
  return true;
5738
6081
  }
6082
+ getDeliveryEpoch(instanceName) {
6083
+ return this.deliveryEpochs.get(instanceName) ?? 0;
6084
+ }
6085
+ isDeliveryEpochCurrent(instanceName, deliveryEpoch) {
6086
+ return deliveryEpoch === this.getDeliveryEpoch(instanceName);
6087
+ }
6088
+ /** Invalidate work queued before a user cancel and wake idle-gate waiters. */
6089
+ cancelPendingDeliveries(instanceName) {
6090
+ const next = this.getDeliveryEpoch(instanceName) + 1;
6091
+ this.deliveryEpochs.set(instanceName, next);
6092
+ for (const check of this.instanceIdleWaiters.get(instanceName) ?? [])
6093
+ check();
6094
+ return next;
6095
+ }
5739
6096
  queueMirrorMessage(text) {
5740
6097
  const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
5741
6098
  if (mirrorTopicId == null || !this.adapter)
@@ -6724,10 +7081,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6724
7081
  effortMenuHeader(instanceName) {
6725
7082
  const { effort, source } = this.resolveInstanceEffort(instanceName);
6726
7083
  if (!effort)
6727
- return "Current effort: (CLI default)";
7084
+ return t("effort.current_default");
6728
7085
  return source === "fleet-default"
6729
- ? `Current effort: ${effort} (fleet default)`
6730
- : `Current effort: ${effort}`;
7086
+ ? t("effort.current_fleet", effort)
7087
+ : t("effort.current", effort);
6731
7088
  }
6732
7089
  /** `/effort` — DC Select Menu, or apply directly when a level is given. */
6733
7090
  async handleEffortSlash(data, adapterId) {
@@ -6748,11 +7105,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6748
7105
  }
6749
7106
  const levels = this.effortLevelsFor(name);
6750
7107
  if (levels.length === 0) {
6751
- await data.respond(`❌ ${this.backendNameForInstance(name)} has no reasoning-effort setting.`);
7108
+ await data.respond(t("effort.unsupported", this.backendNameForInstance(name)));
6752
7109
  return;
6753
7110
  }
6754
7111
  if (!data.respondChoices) {
6755
- await data.respond(`Usage: /effort <${levels.join("|")}>`);
7112
+ await data.respond(t("effort.usage", levels.join("|")));
6756
7113
  return;
6757
7114
  }
6758
7115
  const current = this.resolveInstanceEffort(name).effort;
@@ -6765,20 +7122,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6765
7122
  timer.unref?.();
6766
7123
  this.pendingEffortSelects.set(nonce, { instanceName: name, userId: data.userId, channelId: data.channelId, timer, respond: data.respond });
6767
7124
  try {
6768
- await data.respondChoices(`${this.effortMenuHeader(name)}\nSelect a new effort level:`, choices);
7125
+ await data.respondChoices(t("effort.menu", this.effortMenuHeader(name)), choices);
6769
7126
  }
6770
7127
  catch (err) {
6771
7128
  this.pendingEffortSelects.delete(nonce);
6772
7129
  clearTimeout(timer);
6773
7130
  this.logger.warn({ err, instanceName: name }, "effort menu failed");
6774
- await data.respond(`Usage: /effort <${levels.join("|")}>`);
7131
+ await data.respond(t("effort.usage", levels.join("|")));
6775
7132
  }
6776
7133
  }
6777
7134
  /** TG inline-keyboard effort menu. Returns null on success, else a fallback string. */
6778
7135
  async promptEffortMenu(instanceName, userId, channelId, adapter, chatId, threadId) {
6779
7136
  const levels = this.effortLevelsFor(instanceName);
6780
7137
  if (levels.length === 0) {
6781
- return `❌ ${this.backendNameForInstance(instanceName)} has no reasoning-effort setting.`;
7138
+ return t("effort.unsupported", this.backendNameForInstance(instanceName));
6782
7139
  }
6783
7140
  const current = this.resolveInstanceEffort(instanceName).effort;
6784
7141
  const nonce = randomBytes(6).toString("hex");
@@ -6794,13 +7151,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6794
7151
  const p = this.pendingEffortSelects.get(nonce);
6795
7152
  if (p) {
6796
7153
  this.pendingEffortSelects.delete(nonce);
6797
- p.respond("⏰ Effort selection expired.").catch(() => { });
7154
+ p.respond(t("effort.selection_expired")).catch(() => { });
6798
7155
  }
6799
7156
  }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
6800
7157
  timer.unref?.();
6801
7158
  this.pendingEffortSelects.set(nonce, { instanceName, userId, channelId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
6802
7159
  try {
6803
- const menuMessageId = await adapter.promptUser(chatId, `${this.effortMenuHeader(instanceName)}\nSelect a new effort level:`, choices, { threadId });
7160
+ const menuMessageId = await adapter.promptUser(chatId, t("effort.menu", this.effortMenuHeader(instanceName)), choices, { threadId });
6804
7161
  const pending = this.pendingEffortSelects.get(nonce);
6805
7162
  if (pending)
6806
7163
  pending.menuMessageId = menuMessageId;
@@ -6810,7 +7167,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6810
7167
  this.pendingEffortSelects.delete(nonce);
6811
7168
  clearTimeout(timer);
6812
7169
  this.logger.warn({ err, instanceName }, "TG effort menu failed");
6813
- return `Usage: /effort <${levels.join("|")}>`;
7170
+ return t("effort.usage", levels.join("|"));
6814
7171
  }
6815
7172
  }
6816
7173
  /** Consume an `/effort` selection callback. Mirrors handleModelSelection. */
@@ -6831,7 +7188,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6831
7188
  this.pendingEffortSelects.delete(match[1]);
6832
7189
  clearTimeout(pending.timer);
6833
7190
  const level = match[2];
6834
- const progressText = `⏳ Setting ${pending.instanceName} effort to \`${level}\`…`;
7191
+ const progressText = t("effort.setting", pending.instanceName, level);
6835
7192
  let progressMsgId;
6836
7193
  if (pending.adapter && pending.adapterChatId) {
6837
7194
  const menuMessageId = pending.menuMessageId ?? data.messageId;
@@ -6863,7 +7220,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6863
7220
  }
6864
7221
  catch (err) {
6865
7222
  this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
6866
- result = `Effort switch to \`${level}\` failed: ${err instanceof Error ? err.message : String(err)}`;
7223
+ result = t("effort.switch_failed", level, err instanceof Error ? err.message : String(err));
6867
7224
  }
6868
7225
  if (pending.adapter && pending.adapterChatId) {
6869
7226
  if (progressMsgId) {
@@ -6900,12 +7257,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6900
7257
  }
6901
7258
  // No arg (or --refresh) → menu. Menu is DC-only this round (respondChoices); TG uses `/model <name>`.
6902
7259
  if (!data.respondChoices) {
6903
- await data.respond("Usage: /model <name> — e.g. /model sonnet");
7260
+ await data.respond(t("model.usage"));
6904
7261
  return;
6905
7262
  }
6906
7263
  const options = await this.getModelOptions(name, isRefresh);
6907
7264
  if (options.length === 0) {
6908
- await data.respond(`No model list available for ${name}. Type \`/model <name>\` to set one directly.`);
7265
+ await data.respond(t("model.list_unavailable", name));
6909
7266
  return;
6910
7267
  }
6911
7268
  // Raw id for ✓-matching options; display resolves an inherited CLI default.
@@ -6919,13 +7276,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6919
7276
  timer.unref?.();
6920
7277
  this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, timer, respond: data.respond });
6921
7278
  try {
6922
- await data.respondChoices(`Current model: **${currentDisplay}**\nSelect a new model:`, choices);
7279
+ await data.respondChoices(t("model.menu", `**${currentDisplay}**`), choices);
6923
7280
  }
6924
7281
  catch (err) {
6925
7282
  this.pendingModelSelects.delete(nonce);
6926
7283
  clearTimeout(timer);
6927
7284
  this.logger.warn({ err, instanceName: name }, "model menu failed");
6928
- await data.respond("Usage: /model <name> — e.g. /model sonnet");
7285
+ await data.respond(t("model.usage"));
6929
7286
  }
6930
7287
  }
6931
7288
  /**
@@ -6936,7 +7293,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6936
7293
  async promptModelMenu(instanceName, userId, channelId, adapter, chatId, threadId) {
6937
7294
  const options = await this.getModelOptions(instanceName);
6938
7295
  if (options.length === 0) {
6939
- return `No model list available for ${instanceName}. Use \`/model <name>\` to set one directly.`;
7296
+ return t("model.list_unavailable", instanceName);
6940
7297
  }
6941
7298
  const { model: currentModel, display: currentDisplay } = this.resolveInstanceModel(instanceName);
6942
7299
  const nonce = randomBytes(6).toString("hex");
@@ -6952,13 +7309,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6952
7309
  const p = this.pendingModelSelects.get(nonce);
6953
7310
  if (p) {
6954
7311
  this.pendingModelSelects.delete(nonce);
6955
- p.respond("⏰ Model selection expired.").catch(() => { });
7312
+ p.respond(t("model.selection_expired")).catch(() => { });
6956
7313
  }
6957
7314
  }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
6958
7315
  timer.unref?.();
6959
7316
  this.pendingModelSelects.set(nonce, { instanceName, model: "", userId, channelId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
6960
7317
  try {
6961
- const menuMessageId = await adapter.promptUser(chatId, `Current model: ${currentDisplay}\nSelect a new model:`, choices, { threadId });
7318
+ const menuMessageId = await adapter.promptUser(chatId, t("model.menu", currentDisplay), choices, { threadId });
6962
7319
  const pending = this.pendingModelSelects.get(nonce);
6963
7320
  if (pending)
6964
7321
  pending.menuMessageId = menuMessageId;
@@ -6968,7 +7325,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6968
7325
  this.pendingModelSelects.delete(nonce);
6969
7326
  clearTimeout(timer);
6970
7327
  this.logger.warn({ err, instanceName }, "TG model menu failed");
6971
- return `Usage: /model <name> — e.g. /model sonnet`;
7328
+ return t("model.usage");
6972
7329
  }
6973
7330
  }
6974
7331
  /** Consume a `/model` selection callback. Returns true for all model-select ids (incl. stale). */
@@ -6991,7 +7348,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6991
7348
  clearTimeout(pending.timer);
6992
7349
  const model = match[2];
6993
7350
  // Send immediate "⏳ Switching..." feedback, then apply in background.
6994
- const progressText = `⏳ Switching ${pending.instanceName} to \`${model}\`…`;
7351
+ const progressText = t("model.switching", pending.instanceName, model);
6995
7352
  let progressMsgId;
6996
7353
  if (pending.adapter && pending.adapterChatId) {
6997
7354
  // TG path: turn the original menu into the progress message. This both
@@ -7028,7 +7385,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7028
7385
  }
7029
7386
  catch (err) {
7030
7387
  this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
7031
- result = `Model switch to \`${model}\` failed: ${err instanceof Error ? err.message : String(err)}`;
7388
+ result = t("model.switch_failed", model, err instanceof Error ? err.message : String(err));
7032
7389
  }
7033
7390
  if (pending.adapter && pending.adapterChatId) {
7034
7391
  if (progressMsgId) {
@@ -7125,17 +7482,17 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7125
7482
  }
7126
7483
  catch { /* treated as unsupported below */ }
7127
7484
  if (strategy === "unsupported" || supported.length === 0) {
7128
- return `❌ ${backendName} has no reasoning-effort setting.`;
7485
+ return t("effort.unsupported", backendName);
7129
7486
  }
7130
7487
  if (!FleetManager.EFFORT_LEVELS.includes(level)) {
7131
- return `❌ Unknown effort level \`${level}\`. Use: ${FleetManager.EFFORT_LEVELS.join(", ")}.`;
7488
+ return t("effort.unknown", level, FleetManager.EFFORT_LEVELS.join(", "));
7132
7489
  }
7133
7490
  const applied = FleetManager.clampEffort(level, supported);
7134
7491
  if (!applied)
7135
- return `❌ ${backendName} accepts none of the canonical effort levels.`;
7492
+ return t("effort.no_canonical", backendName);
7136
7493
  const warn = applied === level
7137
7494
  ? ""
7138
- : `⚠️ Clamped to \`${applied}\` (\`${level}\` not supported by ${backendName}).\n`;
7495
+ : t("effort.clamped", applied, level, backendName);
7139
7496
  // Persist either way: a runtime switch must survive the next respawn too,
7140
7497
  // or the instance silently reverts on restart.
7141
7498
  if (this.fleetConfig?.instances[instanceName]) {
@@ -7144,12 +7501,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7144
7501
  }
7145
7502
  if (strategy === "runtime") {
7146
7503
  if (!this.instanceIpcClients.get(instanceName))
7147
- return `${warn}❌ ${instanceName} is not running.`;
7504
+ return `${warn}${t("effort.not_running", instanceName)}`;
7148
7505
  this.pasteRawToClassicInstance(instanceName, `/effort ${applied}`);
7149
- return `${warn}✅ Set ${instanceName} effort to \`${applied}\` (runtime).`;
7506
+ return `${warn}${t("effort.runtime_success", instanceName, applied)}`;
7150
7507
  }
7151
7508
  await this.restartSingleInstance(instanceName);
7152
- return `${warn}✅ Set ${instanceName} effort to \`${applied}\` and restarted.`;
7509
+ return `${warn}${t("effort.restart_success", instanceName, applied)}`;
7153
7510
  }
7154
7511
  async applyModel(instanceName, model) {
7155
7512
  const backendName = this.backendNameForInstance(instanceName);
@@ -7158,9 +7515,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7158
7515
  strategy = createBackend(backendName, this.getInstanceDir(instanceName)).getModelSwitchStrategy?.(model) ?? "restart";
7159
7516
  }
7160
7517
  catch { /* default restart */ }
7161
- const warn = isModelCompatible(backendName, model) ? "" : `⚠️ "${model}" doesn't match ${backendName}'s usual pattern — passing through anyway.\n`;
7518
+ const warn = isModelCompatible(backendName, model) ? "" : t("model.pattern_warning", model, backendName);
7162
7519
  if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
7163
- return `${warn}❌ ${instanceName} is not running.`;
7520
+ return `${warn}${t("effort.not_running", instanceName)}`;
7164
7521
  }
7165
7522
  // Persist either way: a runtime switch must survive the next respawn too,
7166
7523
  // or the instance silently reverts to the CLI default after a fleet restart.
@@ -7174,13 +7531,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7174
7531
  persisted = true;
7175
7532
  }
7176
7533
  if (!persisted)
7177
- return `${warn}❌ Could not set model for ${instanceName}.`;
7534
+ return `${warn}${t("model.persist_failed", instanceName)}`;
7178
7535
  if (strategy === "runtime") {
7179
7536
  this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
7180
- return `${warn}✅ Switched ${instanceName} to \`${model}\` (runtime).${this.effortSuffix(instanceName)}`;
7537
+ return `${warn}${t("model.runtime_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
7181
7538
  }
7182
7539
  await this.restartSingleInstance(instanceName);
7183
- return `${warn}✅ Set ${instanceName} to \`${model}\` and restarted.${this.effortSuffix(instanceName)}`;
7540
+ return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
7184
7541
  }
7185
7542
  /**
7186
7543
  * The trailing "Current effort: …" line for a /model reply.
@@ -7194,10 +7551,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7194
7551
  return "";
7195
7552
  const { effort, source } = this.resolveInstanceEffort(instanceName);
7196
7553
  if (!effort)
7197
- return "\nCurrent effort: (CLI default)";
7554
+ return `\n${t("effort.current_default")}`;
7198
7555
  return source === "fleet-default"
7199
- ? `\nCurrent effort: ${effort} (fleet default)`
7200
- : `\nCurrent effort: ${effort}`;
7556
+ ? `\n${t("effort.current_fleet", effort)}`
7557
+ : `\n${t("effort.current", effort)}`;
7201
7558
  }
7202
7559
  /** Read recent chat log for agent context */
7203
7560
  getRecentChatLog(instanceName, maxLines = 10) {
@@ -7232,7 +7589,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7232
7589
  /** Return a user-facing blocker without mutating ClassicBot state. */
7233
7590
  validateClassicStart(channelId, userId, guildId, adapterId) {
7234
7591
  if (!this.classicChannels)
7235
- return "Classic channel manager not initialized.";
7592
+ return t("classic.manager_unavailable");
7236
7593
  if (guildId && !this.classicChannels.isGuildAllowed(guildId)) {
7237
7594
  const generalId = this.findGeneralInstance(adapterId);
7238
7595
  if (generalId)
@@ -7402,7 +7759,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7402
7759
  return blocker;
7403
7760
  const classicChannels = this.classicChannels;
7404
7761
  if (!classicChannels)
7405
- return "Classic channel manager not initialized.";
7762
+ return t("classic.manager_unavailable");
7406
7763
  const instanceName = classicChannels.deriveInstanceName(channelName || channelId, channelId, adapterId);
7407
7764
  clearPausedMarker(this.getInstanceDir(instanceName));
7408
7765
  const selectedBackend = isSelectableClassicBackend(backend) ? backend : undefined;
@@ -7424,7 +7781,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7424
7781
  /** Handle /stop slash command — unregister classic channel */
7425
7782
  async handleClassicStop(channelId, adapterId) {
7426
7783
  if (!this.classicChannels)
7427
- return "Classic channel manager not initialized.";
7784
+ return t("classic.manager_unavailable");
7428
7785
  const ch = this.classicChannels.unregister(channelId, adapterId);
7429
7786
  if (!ch)
7430
7787
  return t("classic.no_agent");
@@ -7471,6 +7828,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7471
7828
  this.clearStatuslineWatchers();
7472
7829
  this.costGuard?.stop();
7473
7830
  this.dailySummary?.stop();
7831
+ this.dailyTipScheduler?.stop();
7832
+ this.dailyTipScheduler = null;
7474
7833
  if (this.updateCheckTimer) {
7475
7834
  clearTimeout(this.updateCheckTimer);
7476
7835
  clearInterval(this.updateCheckTimer);
@@ -7731,11 +8090,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7731
8090
  // possible outcome: they change fleet.yaml, send SIGHUP, and nothing happens
7732
8091
  // with no explanation anywhere they are looking.
7733
8092
  const why = !validation.valid
7734
- ? `validation failed:\n${validation.errors.map(e => `• ${e.path}: ${e.message}`).join("\n")}`
8093
+ ? t("fleet.reload_validation", validation.errors.map(e => `• ${e.path}: ${e.message}`).join("\n"))
7735
8094
  : unsafeEmpty
7736
- ? `it removed every instance (${oldCount} → 0)`
7737
- : `it removed more than half the instances (${oldCount} → ${newCount})`;
7738
- this.notifyFleetError(`⚠️ fleet.yaml reload REJECTED — ${why}\nThe previous configuration is still running.`);
8095
+ ? t("fleet.reload_removed_all", oldCount)
8096
+ : t("fleet.reload_removed_half", oldCount, newCount);
8097
+ this.notifyFleetError(t("fleet.reload_rejected", why));
7739
8098
  return;
7740
8099
  }
7741
8100
  this.routing.rebuild(this.fleetConfig);
@@ -7745,12 +8104,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7745
8104
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
7746
8105
  // Detect fleet-level changes which still need a restart. Hot defaults are
7747
8106
  // reconciled below and must not produce a misleading restart warning.
7748
- const oldDefaultCold = oldConfig?.defaults
8107
+ const oldDefaultColdWithFleetFields = oldConfig?.defaults
7749
8108
  ? splitHotColdConfig(oldConfig.defaults).cold
7750
8109
  : {};
7751
- const newDefaultCold = this.fleetConfig?.defaults
8110
+ const newDefaultColdWithFleetFields = this.fleetConfig?.defaults
7752
8111
  ? splitHotColdConfig(this.fleetConfig.defaults).cold
7753
8112
  : {};
8113
+ // `tips` is a fleet-owned hot switch; it is read at post time and never
8114
+ // belongs in a daemon config or a "restart required" warning.
8115
+ const { tips: _oldTips, ...oldDefaultCold } = oldDefaultColdWithFleetFields;
8116
+ const { tips: _newTips, ...newDefaultCold } = newDefaultColdWithFleetFields;
7754
8117
  const oldFleetLevel = JSON.stringify({ channel: oldConfig?.channel, defaults: oldDefaultCold });
7755
8118
  const newFleetLevel = JSON.stringify({ channel: this.fleetConfig?.channel, defaults: newDefaultCold });
7756
8119
  if (oldFleetLevel !== newFleetLevel) {
@@ -8040,7 +8403,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
8040
8403
  if (generalId) {
8041
8404
  // No release URL — Discord's SuppressEmbeds proved unreliable and the
8042
8405
  // link preview looked bad. Version + /update instruction is enough.
8043
- this.notifyInstanceTopic(generalId, t("update.available", `v${target}`) + ` (current: v${currentVersion})`);
8406
+ this.notifyInstanceTopic(generalId, t("update.available_current", `v${target}`, `v${currentVersion}`));
8044
8407
  }
8045
8408
  }
8046
8409
  }
@@ -8360,7 +8723,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
8360
8723
  if (err.code === "EADDRINUSE") {
8361
8724
  if (this.healthPortRetried) {
8362
8725
  this.logger.error({ err, port }, "Health port still in use after takeover — dashboard disabled");
8363
- this.notifyFleetError(`⚠️ Dashboard unavailable — health port ${port} is already in use. Stop the conflicting process or configure a different health_port.`);
8726
+ this.notifyFleetError(t("dashboard.port_in_use", port));
8364
8727
  return;
8365
8728
  }
8366
8729
  this.healthPortRetried = true;
@@ -8386,7 +8749,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
8386
8749
  return;
8387
8750
  }
8388
8751
  this.logger.error({ err, port }, "Health server error");
8389
- this.notifyFleetError(`⚠️ Dashboard unavailable — health server failed: ${err.message}`);
8752
+ this.notifyFleetError(t("dashboard.server_failed", err.message));
8390
8753
  });
8391
8754
  this.healthServer.listen(port, "127.0.0.1", () => markListening());
8392
8755
  }