@songsid/agend 2.1.4-beta.18 → 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 +48 -21
  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 +33 -0
  21. package/dist/daemon.js +134 -6
  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 +2 -0
  26. package/dist/fleet-manager.d.ts +14 -0
  27. package/dist/fleet-manager.js +305 -75
  28. package/dist/fleet-manager.js.map +1 -1
  29. package/dist/locale.js +656 -330
  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 +5 -2
  43. package/dist/topic-commands.js +101 -61
  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 = {};
@@ -332,6 +337,7 @@ export class FleetManager {
332
337
  updateProgressTimer = null;
333
338
  updateProgressEditRunning = false;
334
339
  lastUpdateProgressText = null;
340
+ updateCompletionTipText = null;
335
341
  eventLogPruneTimer = null;
336
342
  logRotateTimer = null;
337
343
  /** Days of event/activity history to keep. */
@@ -380,7 +386,7 @@ export class FleetManager {
380
386
  // fleet.yaml, sent SIGHUP, and got no reaction and no explanation.
381
387
  this.logger.error({ err }, "SIGHUP config reload failed");
382
388
  const message = err instanceof Error ? err.message : String(err);
383
- this.notifyFleetError(`⚠️ fleet.yaml reload FAILED — ${message}\nThe previous configuration is still running.`);
389
+ this.notifyFleetError(t("fleet.reload_failed", message));
384
390
  })
385
391
  .finally(() => {
386
392
  this.reconcileInFlight = null;
@@ -630,6 +636,36 @@ export class FleetManager {
630
636
  child.once("error", err => this.failUpdateProgress(err.message));
631
637
  child.unref();
632
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
+ }
633
669
  /** Admin-only full conversation reset for fleet-topic and Classic instances. */
634
670
  async handleClearSlash(data, adapterId) {
635
671
  if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
@@ -1213,7 +1249,7 @@ export class FleetManager {
1213
1249
  // routing instructions are not injected, so it cannot act as a dispatcher.
1214
1250
  if (backend === "antigravity") {
1215
1251
  this.logger.warn({ name }, "antigravity backend does not support MCP instructions — general dispatcher will not work correctly");
1216
- 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"));
1217
1253
  }
1218
1254
  this.ensureGeneralInstructions(config.working_directory, backend, name);
1219
1255
  }
@@ -1518,6 +1554,7 @@ export class FleetManager {
1518
1554
  messageId,
1519
1555
  });
1520
1556
  this.lastUpdateProgressText = null;
1557
+ this.updateCompletionTipText = null;
1521
1558
  this.startUpdateProgressMonitor(adapter);
1522
1559
  }
1523
1560
  failUpdateProgress(message) {
@@ -1541,7 +1578,17 @@ export class FleetManager {
1541
1578
  const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
1542
1579
  if (!adapter)
1543
1580
  return;
1544
- 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
+ }
1545
1592
  if (text === this.lastUpdateProgressText)
1546
1593
  return;
1547
1594
  this.updateProgressEditRunning = true;
@@ -1829,6 +1876,13 @@ export class FleetManager {
1829
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));
1830
1877
  this.scheduler.init();
1831
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();
1832
1886
  // Inject active decisions as env var for MCP instructions.
1833
1887
  // Snapshotted at startup — new decisions via post_decision are available
1834
1888
  // through list_decisions tool but not auto-injected until restart.
@@ -2011,6 +2065,12 @@ export class FleetManager {
2011
2065
  version: agendVersion,
2012
2066
  pausedNames,
2013
2067
  failedNames,
2068
+ tipText: pendingUpdateProgress && this.tipsEnabled()
2069
+ ? (() => {
2070
+ const tip = this.pickAvailableTip();
2071
+ return tip ? this.formatTip(tip) : undefined;
2072
+ })()
2073
+ : undefined,
2014
2074
  });
2015
2075
  if (!progressCompleted && this.adapter && fleet.channel?.group_id) {
2016
2076
  let text;
@@ -2172,14 +2232,14 @@ export class FleetManager {
2172
2232
  notifyAdapterFailure(adapterId, error) {
2173
2233
  const generalId = this.findGeneralInstance();
2174
2234
  if (generalId) {
2175
- 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));
2176
2236
  }
2177
2237
  }
2178
2238
  /** Notify admin that a retried adapter reconnected. */
2179
2239
  notifyAdapterRecovery(adapterId, attempts) {
2180
2240
  const generalId = this.findGeneralInstance();
2181
2241
  if (generalId) {
2182
- this.notifyInstanceTopic(generalId, `✅ Adapter "${adapterId}" reconnected (after ${attempts} ${attempts === 1 ? "retry" : "retries"}).`);
2242
+ this.notifyInstanceTopic(generalId, t("adapter.reconnected", adapterId, attempts));
2183
2243
  }
2184
2244
  }
2185
2245
  /** Get adapter states for /status visibility. */
@@ -2277,6 +2337,10 @@ export class FleetManager {
2277
2337
  await this.handleInboundReaction(r);
2278
2338
  }, this.logger, "adapter.reaction"));
2279
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;
2280
2344
  if (await this.handleClearConfirmation(data, adapterId, this.adapter ?? undefined))
2281
2345
  return;
2282
2346
  if (await this.handleExitRestartPrompt(data, adapterId, this.adapter ?? undefined))
@@ -2482,9 +2546,12 @@ export class FleetManager {
2482
2546
  await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
2483
2547
  }
2484
2548
  catch (err) {
2485
- await data.respond(`⚠️ Usage fetch failed: ${err.message}`);
2549
+ await data.respond(t("usage.failed", err.message));
2486
2550
  }
2487
2551
  }
2552
+ else if (data.command === "tips") {
2553
+ await this.handleTipsSlash(data, adapterId);
2554
+ }
2488
2555
  else if (data.command === "status") {
2489
2556
  // Admin-gated (like the topic path): the merged table shows every
2490
2557
  // instance's cost and IPC health.
@@ -2643,6 +2710,10 @@ export class FleetManager {
2643
2710
  await this.handleInboundReaction(r);
2644
2711
  }, this.logger, `adapter[${adapterId}].reaction`));
2645
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;
2646
2717
  if (await this.handleClearConfirmation(data, adapterId, adapter))
2647
2718
  return;
2648
2719
  if (await this.handleExitRestartPrompt(data, adapterId, adapter))
@@ -2801,9 +2872,12 @@ export class FleetManager {
2801
2872
  await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
2802
2873
  }
2803
2874
  catch (err) {
2804
- await data.respond(`⚠️ Usage fetch failed: ${err.message}`);
2875
+ await data.respond(t("usage.failed", err.message));
2805
2876
  }
2806
2877
  }
2878
+ else if (data.command === "tips") {
2879
+ await this.handleTipsSlash(data, adapterId);
2880
+ }
2807
2881
  else if (data.command === "status") {
2808
2882
  // Admin-gated (like the topic path): the merged table shows every
2809
2883
  // instance's cost and IPC health.
@@ -3516,7 +3590,7 @@ export class FleetManager {
3516
3590
  return;
3517
3591
  }
3518
3592
  const ok = this.cancelInstance(cancelName);
3519
- await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelName}。` : `❌ ${cancelName} 未在執行。`);
3593
+ await msgAdapter?.sendText(chatId, ok ? t("cancel.sent", cancelName) : t("cancel.not_running", cancelName));
3520
3594
  return;
3521
3595
  }
3522
3596
  // Handle /ctx command
@@ -3553,7 +3627,7 @@ export class FleetManager {
3553
3627
  const backend = this.classicChannels.getBackendByInstance(saveName, this.fleetConfig?.defaults?.backend);
3554
3628
  const cmd = saveCommandForBackend(backend, filename);
3555
3629
  if (!cmd) {
3556
- await msgAdapter?.sendText(chatId, SAVE_UNSUPPORTED_MSG);
3630
+ await msgAdapter?.sendText(chatId, t("save.unsupported"));
3557
3631
  return;
3558
3632
  }
3559
3633
  this.pasteRawToClassicInstance(saveName, cmd);
@@ -3771,10 +3845,10 @@ export class FleetManager {
3771
3845
  return;
3772
3846
  let warning = "";
3773
3847
  if (rl.five_hour_pct >= 95) {
3774
- 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));
3775
3849
  }
3776
3850
  else if (rl.seven_day_pct >= 95) {
3777
- 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));
3778
3852
  }
3779
3853
  if (!warning)
3780
3854
  return;
@@ -4677,7 +4751,7 @@ export class FleetManager {
4677
4751
  from: primaryModel, to: fallbackModel, five_hour_pct: fiveHourPct,
4678
4752
  });
4679
4753
  this.webhookEmitter?.emit("model_failover", name, { from: primaryModel, to: fallbackModel, five_hour_pct: fiveHourPct });
4680
- this.notifyInstanceTopic(name, `⚡ Rate limit ${fiveHourPct}% — next rotation will use ${fallbackModel} (was ${primaryModel})`);
4754
+ this.notifyInstanceTopic(name, t("failover.triggered", fiveHourPct, fallbackModel, primaryModel));
4681
4755
  }
4682
4756
  else if (fiveHourPct < FleetManager.FAILOVER_RECOVER_PCT && currentFailover) {
4683
4757
  // Recover: switch back to primary
@@ -4688,7 +4762,7 @@ export class FleetManager {
4688
4762
  restored: primaryModel, five_hour_pct: fiveHourPct,
4689
4763
  });
4690
4764
  this.webhookEmitter?.emit("model_recovered", name, { restored: primaryModel, five_hour_pct: fiveHourPct });
4691
- this.notifyInstanceTopic(name, `✅ Rate limit recovered (${fiveHourPct}%) — next rotation will use ${primaryModel}`);
4765
+ this.notifyInstanceTopic(name, t("failover.recovered", fiveHourPct, primaryModel));
4692
4766
  }
4693
4767
  }
4694
4768
  toggleFleetCollab(instanceName) {
@@ -4742,9 +4816,9 @@ export class FleetManager {
4742
4816
  if (output)
4743
4817
  return output;
4744
4818
  if (e.code === "ENOENT") {
4745
- return "Backend doctor unavailable — agend CLI not found in PATH";
4819
+ return t("doctor.cli_missing");
4746
4820
  }
4747
- return stripAnsi(e.message ?? "").trim() || "Doctor failed";
4821
+ return stripAnsi(e.message ?? "").trim() || t("doctor.failed");
4748
4822
  }
4749
4823
  }
4750
4824
  /** Drop event/activity rows older than the retention window. Best-effort. */
@@ -4846,7 +4920,7 @@ export class FleetManager {
4846
4920
  this.fleetErrorNotices.delete(oldest);
4847
4921
  }
4848
4922
  const body = suppressed > 0
4849
- ? `${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))}`
4850
4924
  : text;
4851
4925
  // Resolved from config, NOT findGeneralInstance(): that requires a live daemon,
4852
4926
  // and a fleet-level fault is exactly when the General may be down. The topic
@@ -4900,7 +4974,8 @@ export class FleetManager {
4900
4974
  //
4901
4975
  // One shared lifecycle for every "notification with decision buttons":
4902
4976
  // post with a 128-bit nonce, arm a bounded expiry, bind the click to the
4903
- // 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),
4904
4979
  // consume exactly once. The features differ only in what they post
4905
4980
  // and what a consumed click does.
4906
4981
  /**
@@ -4982,11 +5057,14 @@ export class FleetManager {
4982
5057
  return "consumed";
4983
5058
  }
4984
5059
  // Bind the capability to the exact message/world that created it. Telegram
4985
- // 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.
4986
5062
  const isAuthorized = data.userId
4987
- ? pending.authChannelId
4988
- ? this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
4989
- : 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)
4990
5068
  : false;
4991
5069
  if (pending.adapterId !== callbackAdapterId
4992
5070
  || data.chatId !== pending.chatId
@@ -5019,10 +5097,156 @@ export class FleetManager {
5019
5097
  this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons");
5020
5098
  }
5021
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
+ }
5022
5246
  /** Post the destructive `/clear` confirmation in the invoking channel. */
5023
5247
  async promptClearConfirmation(instanceName, channelId, adapter, chatId, threadId) {
5024
5248
  if (!this.topicCommands.supportsClear(instanceName))
5025
- return CLEAR_UNSUPPORTED_MSG;
5249
+ return t("clear.unsupported");
5026
5250
  const adapterId = adapter.id;
5027
5251
  if (!adapterId)
5028
5252
  return t("clear.prompt_unavailable");
@@ -5296,7 +5520,7 @@ export class FleetManager {
5296
5520
  const force = target.kind === "classic" && !!data.options?.force;
5297
5521
  const cmd = saveCommandForBackend(backend, filename, force);
5298
5522
  if (!cmd) {
5299
- await data.respond(SAVE_UNSUPPORTED_MSG);
5523
+ await data.respond(t("save.unsupported"));
5300
5524
  return;
5301
5525
  }
5302
5526
  if (target.kind === "classic") {
@@ -6857,10 +7081,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6857
7081
  effortMenuHeader(instanceName) {
6858
7082
  const { effort, source } = this.resolveInstanceEffort(instanceName);
6859
7083
  if (!effort)
6860
- return "Current effort: (CLI default)";
7084
+ return t("effort.current_default");
6861
7085
  return source === "fleet-default"
6862
- ? `Current effort: ${effort} (fleet default)`
6863
- : `Current effort: ${effort}`;
7086
+ ? t("effort.current_fleet", effort)
7087
+ : t("effort.current", effort);
6864
7088
  }
6865
7089
  /** `/effort` — DC Select Menu, or apply directly when a level is given. */
6866
7090
  async handleEffortSlash(data, adapterId) {
@@ -6881,11 +7105,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6881
7105
  }
6882
7106
  const levels = this.effortLevelsFor(name);
6883
7107
  if (levels.length === 0) {
6884
- await data.respond(`❌ ${this.backendNameForInstance(name)} has no reasoning-effort setting.`);
7108
+ await data.respond(t("effort.unsupported", this.backendNameForInstance(name)));
6885
7109
  return;
6886
7110
  }
6887
7111
  if (!data.respondChoices) {
6888
- await data.respond(`Usage: /effort <${levels.join("|")}>`);
7112
+ await data.respond(t("effort.usage", levels.join("|")));
6889
7113
  return;
6890
7114
  }
6891
7115
  const current = this.resolveInstanceEffort(name).effort;
@@ -6898,20 +7122,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6898
7122
  timer.unref?.();
6899
7123
  this.pendingEffortSelects.set(nonce, { instanceName: name, userId: data.userId, channelId: data.channelId, timer, respond: data.respond });
6900
7124
  try {
6901
- await data.respondChoices(`${this.effortMenuHeader(name)}\nSelect a new effort level:`, choices);
7125
+ await data.respondChoices(t("effort.menu", this.effortMenuHeader(name)), choices);
6902
7126
  }
6903
7127
  catch (err) {
6904
7128
  this.pendingEffortSelects.delete(nonce);
6905
7129
  clearTimeout(timer);
6906
7130
  this.logger.warn({ err, instanceName: name }, "effort menu failed");
6907
- await data.respond(`Usage: /effort <${levels.join("|")}>`);
7131
+ await data.respond(t("effort.usage", levels.join("|")));
6908
7132
  }
6909
7133
  }
6910
7134
  /** TG inline-keyboard effort menu. Returns null on success, else a fallback string. */
6911
7135
  async promptEffortMenu(instanceName, userId, channelId, adapter, chatId, threadId) {
6912
7136
  const levels = this.effortLevelsFor(instanceName);
6913
7137
  if (levels.length === 0) {
6914
- return `❌ ${this.backendNameForInstance(instanceName)} has no reasoning-effort setting.`;
7138
+ return t("effort.unsupported", this.backendNameForInstance(instanceName));
6915
7139
  }
6916
7140
  const current = this.resolveInstanceEffort(instanceName).effort;
6917
7141
  const nonce = randomBytes(6).toString("hex");
@@ -6927,13 +7151,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6927
7151
  const p = this.pendingEffortSelects.get(nonce);
6928
7152
  if (p) {
6929
7153
  this.pendingEffortSelects.delete(nonce);
6930
- p.respond("⏰ Effort selection expired.").catch(() => { });
7154
+ p.respond(t("effort.selection_expired")).catch(() => { });
6931
7155
  }
6932
7156
  }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
6933
7157
  timer.unref?.();
6934
7158
  this.pendingEffortSelects.set(nonce, { instanceName, userId, channelId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
6935
7159
  try {
6936
- 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 });
6937
7161
  const pending = this.pendingEffortSelects.get(nonce);
6938
7162
  if (pending)
6939
7163
  pending.menuMessageId = menuMessageId;
@@ -6943,7 +7167,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6943
7167
  this.pendingEffortSelects.delete(nonce);
6944
7168
  clearTimeout(timer);
6945
7169
  this.logger.warn({ err, instanceName }, "TG effort menu failed");
6946
- return `Usage: /effort <${levels.join("|")}>`;
7170
+ return t("effort.usage", levels.join("|"));
6947
7171
  }
6948
7172
  }
6949
7173
  /** Consume an `/effort` selection callback. Mirrors handleModelSelection. */
@@ -6964,7 +7188,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6964
7188
  this.pendingEffortSelects.delete(match[1]);
6965
7189
  clearTimeout(pending.timer);
6966
7190
  const level = match[2];
6967
- const progressText = `⏳ Setting ${pending.instanceName} effort to \`${level}\`…`;
7191
+ const progressText = t("effort.setting", pending.instanceName, level);
6968
7192
  let progressMsgId;
6969
7193
  if (pending.adapter && pending.adapterChatId) {
6970
7194
  const menuMessageId = pending.menuMessageId ?? data.messageId;
@@ -6996,7 +7220,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6996
7220
  }
6997
7221
  catch (err) {
6998
7222
  this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
6999
- 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));
7000
7224
  }
7001
7225
  if (pending.adapter && pending.adapterChatId) {
7002
7226
  if (progressMsgId) {
@@ -7033,12 +7257,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7033
7257
  }
7034
7258
  // No arg (or --refresh) → menu. Menu is DC-only this round (respondChoices); TG uses `/model <name>`.
7035
7259
  if (!data.respondChoices) {
7036
- await data.respond("Usage: /model <name> — e.g. /model sonnet");
7260
+ await data.respond(t("model.usage"));
7037
7261
  return;
7038
7262
  }
7039
7263
  const options = await this.getModelOptions(name, isRefresh);
7040
7264
  if (options.length === 0) {
7041
- 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));
7042
7266
  return;
7043
7267
  }
7044
7268
  // Raw id for ✓-matching options; display resolves an inherited CLI default.
@@ -7052,13 +7276,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7052
7276
  timer.unref?.();
7053
7277
  this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, timer, respond: data.respond });
7054
7278
  try {
7055
- await data.respondChoices(`Current model: **${currentDisplay}**\nSelect a new model:`, choices);
7279
+ await data.respondChoices(t("model.menu", `**${currentDisplay}**`), choices);
7056
7280
  }
7057
7281
  catch (err) {
7058
7282
  this.pendingModelSelects.delete(nonce);
7059
7283
  clearTimeout(timer);
7060
7284
  this.logger.warn({ err, instanceName: name }, "model menu failed");
7061
- await data.respond("Usage: /model <name> — e.g. /model sonnet");
7285
+ await data.respond(t("model.usage"));
7062
7286
  }
7063
7287
  }
7064
7288
  /**
@@ -7069,7 +7293,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7069
7293
  async promptModelMenu(instanceName, userId, channelId, adapter, chatId, threadId) {
7070
7294
  const options = await this.getModelOptions(instanceName);
7071
7295
  if (options.length === 0) {
7072
- return `No model list available for ${instanceName}. Use \`/model <name>\` to set one directly.`;
7296
+ return t("model.list_unavailable", instanceName);
7073
7297
  }
7074
7298
  const { model: currentModel, display: currentDisplay } = this.resolveInstanceModel(instanceName);
7075
7299
  const nonce = randomBytes(6).toString("hex");
@@ -7085,13 +7309,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7085
7309
  const p = this.pendingModelSelects.get(nonce);
7086
7310
  if (p) {
7087
7311
  this.pendingModelSelects.delete(nonce);
7088
- p.respond("⏰ Model selection expired.").catch(() => { });
7312
+ p.respond(t("model.selection_expired")).catch(() => { });
7089
7313
  }
7090
7314
  }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
7091
7315
  timer.unref?.();
7092
7316
  this.pendingModelSelects.set(nonce, { instanceName, model: "", userId, channelId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
7093
7317
  try {
7094
- 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 });
7095
7319
  const pending = this.pendingModelSelects.get(nonce);
7096
7320
  if (pending)
7097
7321
  pending.menuMessageId = menuMessageId;
@@ -7101,7 +7325,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7101
7325
  this.pendingModelSelects.delete(nonce);
7102
7326
  clearTimeout(timer);
7103
7327
  this.logger.warn({ err, instanceName }, "TG model menu failed");
7104
- return `Usage: /model <name> — e.g. /model sonnet`;
7328
+ return t("model.usage");
7105
7329
  }
7106
7330
  }
7107
7331
  /** Consume a `/model` selection callback. Returns true for all model-select ids (incl. stale). */
@@ -7124,7 +7348,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7124
7348
  clearTimeout(pending.timer);
7125
7349
  const model = match[2];
7126
7350
  // Send immediate "⏳ Switching..." feedback, then apply in background.
7127
- const progressText = `⏳ Switching ${pending.instanceName} to \`${model}\`…`;
7351
+ const progressText = t("model.switching", pending.instanceName, model);
7128
7352
  let progressMsgId;
7129
7353
  if (pending.adapter && pending.adapterChatId) {
7130
7354
  // TG path: turn the original menu into the progress message. This both
@@ -7161,7 +7385,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7161
7385
  }
7162
7386
  catch (err) {
7163
7387
  this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
7164
- 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));
7165
7389
  }
7166
7390
  if (pending.adapter && pending.adapterChatId) {
7167
7391
  if (progressMsgId) {
@@ -7258,17 +7482,17 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7258
7482
  }
7259
7483
  catch { /* treated as unsupported below */ }
7260
7484
  if (strategy === "unsupported" || supported.length === 0) {
7261
- return `❌ ${backendName} has no reasoning-effort setting.`;
7485
+ return t("effort.unsupported", backendName);
7262
7486
  }
7263
7487
  if (!FleetManager.EFFORT_LEVELS.includes(level)) {
7264
- return `❌ Unknown effort level \`${level}\`. Use: ${FleetManager.EFFORT_LEVELS.join(", ")}.`;
7488
+ return t("effort.unknown", level, FleetManager.EFFORT_LEVELS.join(", "));
7265
7489
  }
7266
7490
  const applied = FleetManager.clampEffort(level, supported);
7267
7491
  if (!applied)
7268
- return `❌ ${backendName} accepts none of the canonical effort levels.`;
7492
+ return t("effort.no_canonical", backendName);
7269
7493
  const warn = applied === level
7270
7494
  ? ""
7271
- : `⚠️ Clamped to \`${applied}\` (\`${level}\` not supported by ${backendName}).\n`;
7495
+ : t("effort.clamped", applied, level, backendName);
7272
7496
  // Persist either way: a runtime switch must survive the next respawn too,
7273
7497
  // or the instance silently reverts on restart.
7274
7498
  if (this.fleetConfig?.instances[instanceName]) {
@@ -7277,12 +7501,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7277
7501
  }
7278
7502
  if (strategy === "runtime") {
7279
7503
  if (!this.instanceIpcClients.get(instanceName))
7280
- return `${warn}❌ ${instanceName} is not running.`;
7504
+ return `${warn}${t("effort.not_running", instanceName)}`;
7281
7505
  this.pasteRawToClassicInstance(instanceName, `/effort ${applied}`);
7282
- return `${warn}✅ Set ${instanceName} effort to \`${applied}\` (runtime).`;
7506
+ return `${warn}${t("effort.runtime_success", instanceName, applied)}`;
7283
7507
  }
7284
7508
  await this.restartSingleInstance(instanceName);
7285
- return `${warn}✅ Set ${instanceName} effort to \`${applied}\` and restarted.`;
7509
+ return `${warn}${t("effort.restart_success", instanceName, applied)}`;
7286
7510
  }
7287
7511
  async applyModel(instanceName, model) {
7288
7512
  const backendName = this.backendNameForInstance(instanceName);
@@ -7291,9 +7515,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7291
7515
  strategy = createBackend(backendName, this.getInstanceDir(instanceName)).getModelSwitchStrategy?.(model) ?? "restart";
7292
7516
  }
7293
7517
  catch { /* default restart */ }
7294
- 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);
7295
7519
  if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
7296
- return `${warn}❌ ${instanceName} is not running.`;
7520
+ return `${warn}${t("effort.not_running", instanceName)}`;
7297
7521
  }
7298
7522
  // Persist either way: a runtime switch must survive the next respawn too,
7299
7523
  // or the instance silently reverts to the CLI default after a fleet restart.
@@ -7307,13 +7531,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7307
7531
  persisted = true;
7308
7532
  }
7309
7533
  if (!persisted)
7310
- return `${warn}❌ Could not set model for ${instanceName}.`;
7534
+ return `${warn}${t("model.persist_failed", instanceName)}`;
7311
7535
  if (strategy === "runtime") {
7312
7536
  this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
7313
- return `${warn}✅ Switched ${instanceName} to \`${model}\` (runtime).${this.effortSuffix(instanceName)}`;
7537
+ return `${warn}${t("model.runtime_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
7314
7538
  }
7315
7539
  await this.restartSingleInstance(instanceName);
7316
- return `${warn}✅ Set ${instanceName} to \`${model}\` and restarted.${this.effortSuffix(instanceName)}`;
7540
+ return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
7317
7541
  }
7318
7542
  /**
7319
7543
  * The trailing "Current effort: …" line for a /model reply.
@@ -7327,10 +7551,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7327
7551
  return "";
7328
7552
  const { effort, source } = this.resolveInstanceEffort(instanceName);
7329
7553
  if (!effort)
7330
- return "\nCurrent effort: (CLI default)";
7554
+ return `\n${t("effort.current_default")}`;
7331
7555
  return source === "fleet-default"
7332
- ? `\nCurrent effort: ${effort} (fleet default)`
7333
- : `\nCurrent effort: ${effort}`;
7556
+ ? `\n${t("effort.current_fleet", effort)}`
7557
+ : `\n${t("effort.current", effort)}`;
7334
7558
  }
7335
7559
  /** Read recent chat log for agent context */
7336
7560
  getRecentChatLog(instanceName, maxLines = 10) {
@@ -7365,7 +7589,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7365
7589
  /** Return a user-facing blocker without mutating ClassicBot state. */
7366
7590
  validateClassicStart(channelId, userId, guildId, adapterId) {
7367
7591
  if (!this.classicChannels)
7368
- return "Classic channel manager not initialized.";
7592
+ return t("classic.manager_unavailable");
7369
7593
  if (guildId && !this.classicChannels.isGuildAllowed(guildId)) {
7370
7594
  const generalId = this.findGeneralInstance(adapterId);
7371
7595
  if (generalId)
@@ -7535,7 +7759,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7535
7759
  return blocker;
7536
7760
  const classicChannels = this.classicChannels;
7537
7761
  if (!classicChannels)
7538
- return "Classic channel manager not initialized.";
7762
+ return t("classic.manager_unavailable");
7539
7763
  const instanceName = classicChannels.deriveInstanceName(channelName || channelId, channelId, adapterId);
7540
7764
  clearPausedMarker(this.getInstanceDir(instanceName));
7541
7765
  const selectedBackend = isSelectableClassicBackend(backend) ? backend : undefined;
@@ -7557,7 +7781,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7557
7781
  /** Handle /stop slash command — unregister classic channel */
7558
7782
  async handleClassicStop(channelId, adapterId) {
7559
7783
  if (!this.classicChannels)
7560
- return "Classic channel manager not initialized.";
7784
+ return t("classic.manager_unavailable");
7561
7785
  const ch = this.classicChannels.unregister(channelId, adapterId);
7562
7786
  if (!ch)
7563
7787
  return t("classic.no_agent");
@@ -7604,6 +7828,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7604
7828
  this.clearStatuslineWatchers();
7605
7829
  this.costGuard?.stop();
7606
7830
  this.dailySummary?.stop();
7831
+ this.dailyTipScheduler?.stop();
7832
+ this.dailyTipScheduler = null;
7607
7833
  if (this.updateCheckTimer) {
7608
7834
  clearTimeout(this.updateCheckTimer);
7609
7835
  clearInterval(this.updateCheckTimer);
@@ -7864,11 +8090,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7864
8090
  // possible outcome: they change fleet.yaml, send SIGHUP, and nothing happens
7865
8091
  // with no explanation anywhere they are looking.
7866
8092
  const why = !validation.valid
7867
- ? `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"))
7868
8094
  : unsafeEmpty
7869
- ? `it removed every instance (${oldCount} → 0)`
7870
- : `it removed more than half the instances (${oldCount} → ${newCount})`;
7871
- 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));
7872
8098
  return;
7873
8099
  }
7874
8100
  this.routing.rebuild(this.fleetConfig);
@@ -7878,12 +8104,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
7878
8104
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
7879
8105
  // Detect fleet-level changes which still need a restart. Hot defaults are
7880
8106
  // reconciled below and must not produce a misleading restart warning.
7881
- const oldDefaultCold = oldConfig?.defaults
8107
+ const oldDefaultColdWithFleetFields = oldConfig?.defaults
7882
8108
  ? splitHotColdConfig(oldConfig.defaults).cold
7883
8109
  : {};
7884
- const newDefaultCold = this.fleetConfig?.defaults
8110
+ const newDefaultColdWithFleetFields = this.fleetConfig?.defaults
7885
8111
  ? splitHotColdConfig(this.fleetConfig.defaults).cold
7886
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;
7887
8117
  const oldFleetLevel = JSON.stringify({ channel: oldConfig?.channel, defaults: oldDefaultCold });
7888
8118
  const newFleetLevel = JSON.stringify({ channel: this.fleetConfig?.channel, defaults: newDefaultCold });
7889
8119
  if (oldFleetLevel !== newFleetLevel) {
@@ -8173,7 +8403,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
8173
8403
  if (generalId) {
8174
8404
  // No release URL — Discord's SuppressEmbeds proved unreliable and the
8175
8405
  // link preview looked bad. Version + /update instruction is enough.
8176
- this.notifyInstanceTopic(generalId, t("update.available", `v${target}`) + ` (current: v${currentVersion})`);
8406
+ this.notifyInstanceTopic(generalId, t("update.available_current", `v${target}`, `v${currentVersion}`));
8177
8407
  }
8178
8408
  }
8179
8409
  }
@@ -8493,7 +8723,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
8493
8723
  if (err.code === "EADDRINUSE") {
8494
8724
  if (this.healthPortRetried) {
8495
8725
  this.logger.error({ err, port }, "Health port still in use after takeover — dashboard disabled");
8496
- 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));
8497
8727
  return;
8498
8728
  }
8499
8729
  this.healthPortRetried = true;
@@ -8519,7 +8749,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
8519
8749
  return;
8520
8750
  }
8521
8751
  this.logger.error({ err, port }, "Health server error");
8522
- this.notifyFleetError(`⚠️ Dashboard unavailable — health server failed: ${err.message}`);
8752
+ this.notifyFleetError(t("dashboard.server_failed", err.message));
8523
8753
  });
8524
8754
  this.healthServer.listen(port, "127.0.0.1", () => markListening());
8525
8755
  }