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