@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.
- package/dist/backend/antigravity.d.ts +1 -0
- package/dist/backend/antigravity.js +4 -0
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/codex.d.ts +17 -0
- package/dist/backend/codex.js +51 -0
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/types.d.ts +10 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.js +48 -21
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.js +5 -3
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/types.d.ts +1 -1
- package/dist/cli.js +31 -21
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +3 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.js +5 -1
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +33 -0
- package/dist/daemon.js +134 -6
- package/dist/daemon.js.map +1 -1
- package/dist/doctor.js +34 -27
- package/dist/doctor.js.map +1 -1
- package/dist/fleet-context.d.ts +2 -0
- package/dist/fleet-manager.d.ts +14 -0
- package/dist/fleet-manager.js +305 -75
- package/dist/fleet-manager.js.map +1 -1
- package/dist/locale.js +656 -330
- package/dist/locale.js.map +1 -1
- package/dist/outbound-handlers.js +5 -1
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/restart-progress.d.ts +2 -0
- package/dist/restart-progress.js +3 -0
- package/dist/restart-progress.js.map +1 -1
- package/dist/scheduler/db.d.ts +5 -0
- package/dist/scheduler/db.js +35 -0
- package/dist/scheduler/db.js.map +1 -1
- package/dist/tips.d.ts +34 -0
- package/dist/tips.js +554 -0
- package/dist/tips.js.map +1 -0
- package/dist/topic-commands.d.ts +5 -2
- package/dist/topic-commands.js +101 -61
- package/dist/topic-commands.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/ui/dashboard.html +55 -32
- package/dist/ui/settings.html +46 -37
- package/dist/ui/view.html +23 -15
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -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,
|
|
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(
|
|
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, "
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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(
|
|
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(
|
|
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 ?
|
|
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,
|
|
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 =
|
|
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 =
|
|
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,
|
|
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,
|
|
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 "
|
|
4819
|
+
return t("doctor.cli_missing");
|
|
4746
4820
|
}
|
|
4747
|
-
return stripAnsi(e.message ?? "").trim() || "
|
|
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(
|
|
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
|
|
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.
|
|
4988
|
-
?
|
|
4989
|
-
:
|
|
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
|
|
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(
|
|
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 "
|
|
7084
|
+
return t("effort.current_default");
|
|
6861
7085
|
return source === "fleet-default"
|
|
6862
|
-
?
|
|
6863
|
-
:
|
|
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(
|
|
7108
|
+
await data.respond(t("effort.unsupported", this.backendNameForInstance(name)));
|
|
6885
7109
|
return;
|
|
6886
7110
|
}
|
|
6887
7111
|
if (!data.respondChoices) {
|
|
6888
|
-
await data.respond(
|
|
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(
|
|
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(
|
|
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
|
|
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("
|
|
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,
|
|
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
|
|
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 =
|
|
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 =
|
|
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("
|
|
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(
|
|
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(
|
|
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("
|
|
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
|
|
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("
|
|
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,
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
7485
|
+
return t("effort.unsupported", backendName);
|
|
7262
7486
|
}
|
|
7263
7487
|
if (!FleetManager.EFFORT_LEVELS.includes(level)) {
|
|
7264
|
-
return
|
|
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
|
|
7492
|
+
return t("effort.no_canonical", backendName);
|
|
7269
7493
|
const warn = applied === level
|
|
7270
7494
|
? ""
|
|
7271
|
-
:
|
|
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}
|
|
7504
|
+
return `${warn}${t("effort.not_running", instanceName)}`;
|
|
7281
7505
|
this.pasteRawToClassicInstance(instanceName, `/effort ${applied}`);
|
|
7282
|
-
return `${warn}
|
|
7506
|
+
return `${warn}${t("effort.runtime_success", instanceName, applied)}`;
|
|
7283
7507
|
}
|
|
7284
7508
|
await this.restartSingleInstance(instanceName);
|
|
7285
|
-
return `${warn}
|
|
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) ? "" :
|
|
7518
|
+
const warn = isModelCompatible(backendName, model) ? "" : t("model.pattern_warning", model, backendName);
|
|
7295
7519
|
if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
|
|
7296
|
-
return `${warn}
|
|
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}
|
|
7534
|
+
return `${warn}${t("model.persist_failed", instanceName)}`;
|
|
7311
7535
|
if (strategy === "runtime") {
|
|
7312
7536
|
this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
|
|
7313
|
-
return `${warn}
|
|
7537
|
+
return `${warn}${t("model.runtime_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
|
|
7314
7538
|
}
|
|
7315
7539
|
await this.restartSingleInstance(instanceName);
|
|
7316
|
-
return `${warn}
|
|
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 "
|
|
7554
|
+
return `\n${t("effort.current_default")}`;
|
|
7331
7555
|
return source === "fleet-default"
|
|
7332
|
-
? `\
|
|
7333
|
-
: `\
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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
|
-
?
|
|
8093
|
+
? t("fleet.reload_validation", validation.errors.map(e => `• ${e.path}: ${e.message}`).join("\n"))
|
|
7868
8094
|
: unsafeEmpty
|
|
7869
|
-
?
|
|
7870
|
-
:
|
|
7871
|
-
this.notifyFleetError(
|
|
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
|
|
8107
|
+
const oldDefaultColdWithFleetFields = oldConfig?.defaults
|
|
7882
8108
|
? splitHotColdConfig(oldConfig.defaults).cold
|
|
7883
8109
|
: {};
|
|
7884
|
-
const
|
|
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.
|
|
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(
|
|
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(
|
|
8752
|
+
this.notifyFleetError(t("dashboard.server_failed", err.message));
|
|
8523
8753
|
});
|
|
8524
8754
|
this.healthServer.listen(port, "127.0.0.1", () => markListening());
|
|
8525
8755
|
}
|