@songsid/agend 2.1.4-beta.3 → 2.1.4-beta.31
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/README.md +1 -1
- package/README.zh-TW.md +1 -1
- package/dist/agent-endpoint.js +1 -1
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/backend/antigravity.d.ts +9 -5
- package/dist/backend/antigravity.js +155 -30
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.d.ts +33 -21
- package/dist/backend/claude-code.js +103 -27
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.d.ts +17 -0
- package/dist/backend/codex.js +123 -8
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/gemini-cli.js +2 -2
- package/dist/backend/gemini-cli.js.map +1 -1
- package/dist/backend/grok.js +2 -2
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro.d.ts +1 -0
- package/dist/backend/kiro.js +17 -3
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/opencode.d.ts +27 -11
- package/dist/backend/opencode.js +50 -39
- package/dist/backend/opencode.js.map +1 -1
- package/dist/backend/types.d.ts +20 -0
- package/dist/backend/types.js +12 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.js +106 -19
- 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/agy-mcp-launcher.d.ts +2 -0
- package/dist/channel/agy-mcp-launcher.js +28 -0
- package/dist/channel/agy-mcp-launcher.js.map +1 -0
- package/dist/channel/mcp-server.js +6 -20
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.js +4 -2
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/types.d.ts +1 -1
- package/dist/classic-channel-manager.d.ts +40 -3
- package/dist/classic-channel-manager.js +218 -31
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +148 -33
- package/dist/cli.js.map +1 -1
- package/dist/completion-install.d.ts +70 -0
- package/dist/completion-install.js +152 -0
- package/dist/completion-install.js.map +1 -0
- package/dist/completion.d.ts +7 -1
- package/dist/completion.js +14 -4
- package/dist/completion.js.map +1 -1
- package/dist/config-validator.js +38 -7
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +10 -1
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +177 -5
- package/dist/daemon.js +771 -99
- package/dist/daemon.js.map +1 -1
- package/dist/doctor.d.ts +26 -0
- package/dist/doctor.js +216 -0
- package/dist/doctor.js.map +1 -0
- package/dist/fleet-context.d.ts +47 -0
- package/dist/fleet-manager.d.ts +251 -10
- package/dist/fleet-manager.js +2059 -387
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-yaml-slim.d.ts +10 -0
- package/dist/fleet-yaml-slim.js +59 -0
- package/dist/fleet-yaml-slim.js.map +1 -0
- package/dist/general-knowledge/skills/backend-providers/SKILL.md +114 -0
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +3 -1
- package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +52 -0
- package/dist/general-knowledge/skills/development-workflow/SKILL.md +28 -0
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -0
- package/dist/general-knowledge/skills/fleet-health/SKILL.md +1 -0
- package/dist/general-knowledge/skills/fleet-restart/SKILL.md +1 -0
- package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +1 -0
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +64 -16
- package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -0
- package/dist/general-knowledge/skills/scheduling/SKILL.md +1 -0
- package/dist/general-knowledge/skills/session-management/SKILL.md +1 -0
- package/dist/general-knowledge/skills/tui-effort/SKILL.md +1 -0
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +30 -0
- package/dist/instance-lifecycle.d.ts +45 -0
- package/dist/instance-lifecycle.js +150 -5
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.d.ts +12 -0
- package/dist/instructions.js +35 -1
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +750 -288
- package/dist/locale.js.map +1 -1
- package/dist/login-flows.d.ts +85 -0
- package/dist/login-flows.js +159 -0
- package/dist/login-flows.js.map +1 -0
- package/dist/login-manager.d.ts +63 -0
- package/dist/login-manager.js +134 -0
- package/dist/login-manager.js.map +1 -0
- package/dist/outbound-handlers.d.ts +8 -0
- package/dist/outbound-handlers.js +44 -5
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +5 -0
- package/dist/outbound-schemas.js +7 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/quickstart.js +25 -0
- package/dist/quickstart.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 +12 -0
- package/dist/scheduler/db.js +59 -0
- package/dist/scheduler/db.js.map +1 -1
- package/dist/service-installer.d.ts +11 -0
- package/dist/service-installer.js +84 -18
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.js +1 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/setup-wizard.js +2 -2
- package/dist/setup-wizard.js.map +1 -1
- package/dist/tips.d.ts +44 -0
- package/dist/tips.js +355 -0
- package/dist/tips.js.map +1 -0
- package/dist/tmux-manager.d.ts +6 -0
- package/dist/tmux-manager.js +12 -0
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tool-progress.d.ts +40 -0
- package/dist/tool-progress.js +289 -0
- package/dist/tool-progress.js.map +1 -0
- package/dist/topic-commands.d.ts +36 -4
- package/dist/topic-commands.js +322 -64
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-monitor.d.ts +15 -2
- package/dist/transcript-monitor.js +63 -17
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +131 -0
- package/dist/transcript-sources.js +580 -0
- package/dist/transcript-sources.js.map +1 -0
- package/dist/types.d.ts +11 -0
- package/dist/ui/dashboard.html +55 -32
- package/dist/ui/settings.html +120 -51
- package/dist/ui/view.html +23 -15
- package/dist/usage/providers.d.ts +7 -0
- package/dist/usage/providers.js +18 -1
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.d.ts +8 -2
- package/dist/usage/usage-api.js +24 -3
- package/dist/usage/usage-api.js.map +1 -1
- package/dist/workflow-templates/default.md +2 -1
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync } from "node:fs";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
3
4
|
import { createServer } from "node:http";
|
|
4
5
|
import { join, dirname, basename } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
8
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
7
9
|
import { beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, } from "./update-marker.js";
|
|
8
10
|
import { formatUpdateProgress } from "./update-progress.js";
|
|
9
11
|
import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
|
|
10
12
|
import { readFleetMemory } from "./process-memory.js";
|
|
11
13
|
import { ReplyDeduper } from "./reply-dedup.js";
|
|
12
|
-
import { isScalar, parseDocument } from "yaml";
|
|
14
|
+
import { isMap, isScalar, parseDocument } from "yaml";
|
|
13
15
|
const __filename = fileURLToPath(import.meta.url);
|
|
14
16
|
const __dirname = dirname(__filename);
|
|
15
17
|
/** Fallback access policy for a channel with no `access:` block — open (no gate). */
|
|
@@ -30,7 +32,7 @@ import { processAttachments } from "./channel/attachment-handler.js";
|
|
|
30
32
|
import { routeToolCall } from "./channel/tool-router.js";
|
|
31
33
|
import { Scheduler } from "./scheduler/index.js";
|
|
32
34
|
import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
|
|
33
|
-
import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE,
|
|
35
|
+
import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, resolveInstanceContext, forgetInstanceContext } from "./topic-commands.js";
|
|
34
36
|
import { DailySummary } from "./daily-summary.js";
|
|
35
37
|
import { WebhookEmitter } from "./webhook-emitter.js";
|
|
36
38
|
import { TmuxControlClient } from "./tmux-control.js";
|
|
@@ -42,9 +44,11 @@ import { StatuslineWatcher } from "./statusline-watcher.js";
|
|
|
42
44
|
import { outboundHandlers } from "./outbound-handlers.js";
|
|
43
45
|
import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
|
|
44
46
|
import { handleViewRequest, isViewPath } from "./view-api.js";
|
|
45
|
-
import { handleUsageRequest, isUsagePath } from "./usage/usage-api.js";
|
|
47
|
+
import { handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
|
|
48
|
+
import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES, checkAuthStatus } from "./login-flows.js";
|
|
49
|
+
import { LoginSession } from "./login-manager.js";
|
|
46
50
|
import { handleSettingsRequest } from "./settings-api.js";
|
|
47
|
-
import { setLocale, detectLocale, t } from "./locale.js";
|
|
51
|
+
import { setLocale, detectLocale, getLocale, t } from "./locale.js";
|
|
48
52
|
import { handleAgentRequest } from "./agent-endpoint.js";
|
|
49
53
|
import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
|
|
50
54
|
import { validateFleetConfig } from "./config-validator.js";
|
|
@@ -54,6 +58,8 @@ import { releaseProcessFleetLock } from "./fleet-lock.js";
|
|
|
54
58
|
import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
|
|
55
59
|
import { loadOrCreateWebToken, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
|
|
56
60
|
import { RestartProgress } from "./restart-progress.js";
|
|
61
|
+
import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
|
|
62
|
+
import { canUnlockAdvancedTips, DailyTipScheduler, selectTip, visibleTipLevels, } from "./tips.js";
|
|
57
63
|
import { getTmuxSession } from "./config.js";
|
|
58
64
|
export function resolveReplyThreadId(argsThreadId, instanceConfig) {
|
|
59
65
|
if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
|
|
@@ -106,6 +112,8 @@ const CANCEL_BTN_IDLE_RETIRE_GRACE_MS = 2_000;
|
|
|
106
112
|
* button alone (the idle edge retires it when the run really ends).
|
|
107
113
|
*/
|
|
108
114
|
const REPLY_RETIRE_GRACE_MS = 2 * 60_000;
|
|
115
|
+
/** Bound for the daemon to capture the pane and answer a post-reply state query. */
|
|
116
|
+
const REPLY_STATE_REFRESH_TIMEOUT_MS = 2_000;
|
|
109
117
|
/**
|
|
110
118
|
* The daemon only broadcasts execution state on TRANSITIONS, so a long
|
|
111
119
|
* single-state run sends nothing for hours. The idle backstop therefore pokes a
|
|
@@ -135,6 +143,8 @@ const CANCEL_BTN_LEDGER_FILE = "cancel-buttons.json";
|
|
|
135
143
|
* exactly one progress message per turn, and it is the cancel button itself.
|
|
136
144
|
*/
|
|
137
145
|
const PROGRESS_UPDATE_INTERVAL_MS = 60_000;
|
|
146
|
+
/** Floor between tool-progress-driven bubble edits (Telegram flood safety). */
|
|
147
|
+
const TOOL_PROGRESS_EDIT_MIN_MS = 4_000;
|
|
138
148
|
/** Elapsed time is only shown once work has clearly outlasted a quick answer. */
|
|
139
149
|
/**
|
|
140
150
|
* Default delay before the button starts showing elapsed time. Configurable via
|
|
@@ -160,6 +170,30 @@ const DELIVERY_STATUS_EMOJIS = new Set(["👀", "⏳", "✅", "❌"]);
|
|
|
160
170
|
* emoji never changes the documented delivery-state protocol.
|
|
161
171
|
*/
|
|
162
172
|
const IGNORED_REACTION_EMOJIS = new Set(["📷"]);
|
|
173
|
+
const HOT_INSTANCE_CONFIG_KEYS = new Set([
|
|
174
|
+
"tool_progress",
|
|
175
|
+
"mcp_proxy_reply",
|
|
176
|
+
"auto_pause_after",
|
|
177
|
+
"warm_cap",
|
|
178
|
+
"display_name",
|
|
179
|
+
"description",
|
|
180
|
+
"tags",
|
|
181
|
+
"log_level",
|
|
182
|
+
]);
|
|
183
|
+
function splitHotColdConfig(config) {
|
|
184
|
+
const hot = {};
|
|
185
|
+
const cold = {};
|
|
186
|
+
for (const [key, value] of Object.entries(config)) {
|
|
187
|
+
(HOT_INSTANCE_CONFIG_KEYS.has(key) ? hot : cold)[key] = value;
|
|
188
|
+
}
|
|
189
|
+
return { hot, cold };
|
|
190
|
+
}
|
|
191
|
+
function hotConfigUpdate(config) {
|
|
192
|
+
const update = {};
|
|
193
|
+
for (const key of HOT_INSTANCE_CONFIG_KEYS)
|
|
194
|
+
update[key] = config[key] ?? null;
|
|
195
|
+
return update;
|
|
196
|
+
}
|
|
163
197
|
/**
|
|
164
198
|
* How long a delivery waits out a disconnected instance IPC before giving up.
|
|
165
199
|
*
|
|
@@ -175,8 +209,17 @@ const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
|
|
|
175
209
|
const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
|
|
176
210
|
const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
|
|
177
211
|
const HANG_CALLBACK_PREFIX = "hang:";
|
|
178
|
-
|
|
212
|
+
const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
|
|
213
|
+
const TIP_DISMISS_CALLBACK_PREFIX = "tip-dismiss:";
|
|
214
|
+
const TIP_UNLOCK_CALLBACK_PREFIX = "tip-unlock:";
|
|
215
|
+
const LOGIN_CALLBACK_PREFIX = "login:";
|
|
216
|
+
const LOGIN_MENU_CALLBACK_PREFIX = "login-menu:";
|
|
217
|
+
const LOGIN_CONFIRM_CALLBACK_PREFIX = "login-confirm:";
|
|
218
|
+
const INSTALL_LOGIN_CALLBACK_PREFIX = "install-login:";
|
|
219
|
+
const CLEAR_CONFIRM_TIMEOUT_MS = 15_000;
|
|
220
|
+
/** Default lifetime for long-lived nonce prompts (clear overrides this to 15s). */
|
|
179
221
|
const NONCE_BUTTON_TIMEOUT_MS = 15 * 60_000;
|
|
222
|
+
const TIP_BUTTON_TIMEOUT_MS = 24 * 60 * 60_000;
|
|
180
223
|
const CLI_ENV_TTL_MS = 24 * 60 * 60 * 1000; // /model reads cached CLI env within 24h
|
|
181
224
|
export class FleetManager {
|
|
182
225
|
dataDir;
|
|
@@ -223,6 +266,7 @@ export class FleetManager {
|
|
|
223
266
|
costGuard = null;
|
|
224
267
|
statuslineWatcher;
|
|
225
268
|
dailySummary = null;
|
|
269
|
+
dailyTipScheduler = null;
|
|
226
270
|
webhookEmitter = null;
|
|
227
271
|
// Topic icon + auto-archive state
|
|
228
272
|
topicIcons = {};
|
|
@@ -235,6 +279,13 @@ export class FleetManager {
|
|
|
235
279
|
warmCapEvicting = new Set();
|
|
236
280
|
/** Per-instance tail keeps cross-instance and scheduled deliveries FIFO. */
|
|
237
281
|
idleGatedDeliveryTails = new Map();
|
|
282
|
+
/**
|
|
283
|
+
* Per-instance cancellation epoch for deliveries which have not reached the
|
|
284
|
+
* daemon yet. A cancel advances the epoch; queued work captures the old value
|
|
285
|
+
* and becomes a no-op, while messages arriving after the click capture the new
|
|
286
|
+
* value and remain deliverable.
|
|
287
|
+
*/
|
|
288
|
+
deliveryEpochs = new Map();
|
|
238
289
|
/** Non-user work must observe a fresh idle snapshot after the latest delivery. */
|
|
239
290
|
lastDeliveryAt = new Map();
|
|
240
291
|
/** State-cache updates wake event-driven idle waiters without busy polling. */
|
|
@@ -251,6 +302,8 @@ export class FleetManager {
|
|
|
251
302
|
replyDeduper = new ReplyDeduper();
|
|
252
303
|
/** instanceName → what it is doing right now, when the backend can tell us. */
|
|
253
304
|
instanceActivity = new Map();
|
|
305
|
+
/** instanceName → this turn's tool list (multi-line), for the bubble. */
|
|
306
|
+
instanceProgress = new Map();
|
|
254
307
|
/** instanceName → tail of deliveries waiting for its IPC to come back. */
|
|
255
308
|
ipcWaitTails = new Map();
|
|
256
309
|
/** instanceName → restart currently executing; concurrent callers join it. */
|
|
@@ -291,6 +344,7 @@ export class FleetManager {
|
|
|
291
344
|
updateProgressTimer = null;
|
|
292
345
|
updateProgressEditRunning = false;
|
|
293
346
|
lastUpdateProgressText = null;
|
|
347
|
+
updateCompletionTipText = null;
|
|
294
348
|
eventLogPruneTimer = null;
|
|
295
349
|
logRotateTimer = null;
|
|
296
350
|
/** Days of event/activity history to keep. */
|
|
@@ -339,7 +393,7 @@ export class FleetManager {
|
|
|
339
393
|
// fleet.yaml, sent SIGHUP, and got no reaction and no explanation.
|
|
340
394
|
this.logger.error({ err }, "SIGHUP config reload failed");
|
|
341
395
|
const message = err instanceof Error ? err.message : String(err);
|
|
342
|
-
this.notifyFleetError(
|
|
396
|
+
this.notifyFleetError(t("fleet.reload_failed", message));
|
|
343
397
|
})
|
|
344
398
|
.finally(() => {
|
|
345
399
|
this.reconcileInFlight = null;
|
|
@@ -589,6 +643,57 @@ export class FleetManager {
|
|
|
589
643
|
child.once("error", err => this.failUpdateProgress(err.message));
|
|
590
644
|
child.unref();
|
|
591
645
|
}
|
|
646
|
+
async handleTipsSlash(data, adapterId) {
|
|
647
|
+
if (!this.fleetConfig)
|
|
648
|
+
return;
|
|
649
|
+
const mode = typeof data.options?.mode === "string" ? data.options.mode : "";
|
|
650
|
+
if (mode === "advanced on") {
|
|
651
|
+
if (!this.isFleetAdmin(data.userId, adapterId)) {
|
|
652
|
+
await data.respond(t("permission.denied"));
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
await data.respond(this.unlockAdvancedTips(data.userId)
|
|
656
|
+
? t("tips.advanced.unlocked")
|
|
657
|
+
: t("tips.unavailable"));
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (mode === "on" || mode === "off") {
|
|
661
|
+
if (!this.isFleetAdmin(data.userId, adapterId)) {
|
|
662
|
+
await data.respond(t("permission.denied"));
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
this.fleetConfig.defaults.tips = mode === "on";
|
|
666
|
+
this.saveFleetConfig();
|
|
667
|
+
await data.respond(t(mode === "on" ? "tips.enabled" : "tips.disabled"));
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
const adapter = this.adapters.get(adapterId) ?? this.adapter;
|
|
671
|
+
if (!adapter) {
|
|
672
|
+
await data.respond(t("tips.unavailable"));
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
const targetName = this.resolveSlashTarget(data.channelId, adapterId)
|
|
676
|
+
?? this.findGeneralInstance(adapterId)
|
|
677
|
+
?? "general";
|
|
678
|
+
// Discord slash interactions are deferred ephemerally, but the Tip itself
|
|
679
|
+
// belongs in the channel where /tips was invoked. Post there directly,
|
|
680
|
+
// then delete the empty acknowledgement instead of leaving a slash corpse.
|
|
681
|
+
//
|
|
682
|
+
// The callback binding uses (chatId, threadId) that match what the adapter
|
|
683
|
+
// emits on callback_query. Discord emits (guildId, channelId); Telegram
|
|
684
|
+
// emits (supergroup chatId, topic threadId). For /tips invoked in an
|
|
685
|
+
// arbitrary channel, pass the canonical group id as chatId and the
|
|
686
|
+
// invocation channel as threadId, exactly as sendTipToGeneral() does.
|
|
687
|
+
const chatId = this.getGroupIdForInstance(targetName) || data.channelId;
|
|
688
|
+
const result = await this.promptTip(targetName, adapter, chatId, data.channelId);
|
|
689
|
+
if (result === "posted" && data.dismissResponse) {
|
|
690
|
+
await data.dismissResponse();
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
await data.respond(t(result === "posted"
|
|
694
|
+
? "tips.posted"
|
|
695
|
+
: result === "empty" ? "tips.empty" : "tips.unavailable"));
|
|
696
|
+
}
|
|
592
697
|
/** Admin-only full conversation reset for fleet-topic and Classic instances. */
|
|
593
698
|
async handleClearSlash(data, adapterId) {
|
|
594
699
|
if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
|
|
@@ -600,7 +705,14 @@ export class FleetManager {
|
|
|
600
705
|
await data.respond(t("classic.no_agent"));
|
|
601
706
|
return;
|
|
602
707
|
}
|
|
603
|
-
|
|
708
|
+
const adapter = this.adapters.get(adapterId) ?? this.adapter;
|
|
709
|
+
if (!adapter) {
|
|
710
|
+
await data.respond(t("clear.prompt_unavailable"));
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
const chatId = String(this.getChannelConfig(adapterId)?.group_id ?? data.channelId);
|
|
714
|
+
const fallback = await this.promptClearConfirmation(name, data.channelId, adapter, chatId, data.channelId);
|
|
715
|
+
await data.respond(fallback ?? t("clear.confirm_posted"));
|
|
604
716
|
}
|
|
605
717
|
/** Get the adapter bound to an instance, falling back to primary adapter */
|
|
606
718
|
getAdapterForInstance(name) {
|
|
@@ -734,6 +846,39 @@ export class FleetManager {
|
|
|
734
846
|
return null;
|
|
735
847
|
return this.instanceStateCache.get(name)?.state ?? null;
|
|
736
848
|
}
|
|
849
|
+
/**
|
|
850
|
+
* Subscription providers currently relevant to this fleet. Stopped/crashed
|
|
851
|
+
* rows do not keep a usage card alive, while persisted paused instances do.
|
|
852
|
+
* Classic channels use their effective backend merge chain, not the raw row.
|
|
853
|
+
*/
|
|
854
|
+
getActiveUsageProviderIds() {
|
|
855
|
+
const providers = new Set();
|
|
856
|
+
for (const backend of this.getActiveBackendIds()) {
|
|
857
|
+
const provider = usageProviderIdForBackend(backend);
|
|
858
|
+
if (provider)
|
|
859
|
+
providers.add(provider);
|
|
860
|
+
}
|
|
861
|
+
return providers;
|
|
862
|
+
}
|
|
863
|
+
/** Effective backends with a running or persisted-paused fleet/Classic instance. */
|
|
864
|
+
getActiveBackendIds() {
|
|
865
|
+
const backends = new Set();
|
|
866
|
+
const add = (name, backend) => {
|
|
867
|
+
const status = this.getInstanceStatus(name);
|
|
868
|
+
if (status !== "running" && status !== "paused")
|
|
869
|
+
return;
|
|
870
|
+
if (backend)
|
|
871
|
+
backends.add(backend);
|
|
872
|
+
};
|
|
873
|
+
for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
|
|
874
|
+
// loadFleetConfig() has already merged the fleet default into each row.
|
|
875
|
+
add(name, config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
|
|
876
|
+
}
|
|
877
|
+
for (const channel of this.classicChannels?.getAll() ?? []) {
|
|
878
|
+
add(channel.instanceName, this.classicChannels?.getBackendByInstance(channel.instanceName, this.fleetConfig?.defaults?.backend));
|
|
879
|
+
}
|
|
880
|
+
return backends;
|
|
881
|
+
}
|
|
737
882
|
isClassicInstance(name) {
|
|
738
883
|
return this.classicChannels?.getAll().some(channel => channel.instanceName === name) ?? false;
|
|
739
884
|
}
|
|
@@ -846,7 +991,7 @@ export class FleetManager {
|
|
|
846
991
|
.finally(() => this.warmCapEvicting.delete(victim));
|
|
847
992
|
}
|
|
848
993
|
}
|
|
849
|
-
waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter = 0) {
|
|
994
|
+
waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter = 0, cancelled) {
|
|
850
995
|
const isReady = () => {
|
|
851
996
|
const snapshot = this.instanceStateCache.get(instanceName);
|
|
852
997
|
return snapshot?.state === "idle"
|
|
@@ -868,8 +1013,12 @@ export class FleetManager {
|
|
|
868
1013
|
this.instanceIdleWaiters.delete(instanceName);
|
|
869
1014
|
resolve(idle);
|
|
870
1015
|
};
|
|
871
|
-
const check = () => {
|
|
872
|
-
|
|
1016
|
+
const check = () => {
|
|
1017
|
+
if (cancelled?.())
|
|
1018
|
+
finish(false);
|
|
1019
|
+
else if (isReady())
|
|
1020
|
+
finish(true);
|
|
1021
|
+
};
|
|
873
1022
|
const query = () => {
|
|
874
1023
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
875
1024
|
if (ipc?.connected) {
|
|
@@ -885,22 +1034,74 @@ export class FleetManager {
|
|
|
885
1034
|
query();
|
|
886
1035
|
});
|
|
887
1036
|
}
|
|
888
|
-
|
|
1037
|
+
/**
|
|
1038
|
+
* Ask the daemon to capture the pane before answering, then wait for any state
|
|
1039
|
+
* report produced after this request. The ordinary query is intentionally
|
|
1040
|
+
* cache-only (idle gates call it frequently); this opt-in refresh is reserved
|
|
1041
|
+
* for lifecycle decisions where a stale state would strand UI.
|
|
1042
|
+
*/
|
|
1043
|
+
refreshInstanceExecutionState(instanceName, timeoutMs) {
|
|
1044
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
1045
|
+
if (!ipc?.connected)
|
|
1046
|
+
return Promise.resolve(false);
|
|
1047
|
+
const previous = this.instanceStateCache.get(instanceName);
|
|
1048
|
+
return new Promise(resolve => {
|
|
1049
|
+
let settled = false;
|
|
1050
|
+
const finish = (refreshed) => {
|
|
1051
|
+
if (settled)
|
|
1052
|
+
return;
|
|
1053
|
+
settled = true;
|
|
1054
|
+
clearTimeout(timeout);
|
|
1055
|
+
const waiters = this.instanceIdleWaiters.get(instanceName);
|
|
1056
|
+
waiters?.delete(check);
|
|
1057
|
+
if (waiters?.size === 0)
|
|
1058
|
+
this.instanceIdleWaiters.delete(instanceName);
|
|
1059
|
+
resolve(refreshed);
|
|
1060
|
+
};
|
|
1061
|
+
const check = () => {
|
|
1062
|
+
if (this.instanceStateCache.get(instanceName) !== previous)
|
|
1063
|
+
finish(true);
|
|
1064
|
+
};
|
|
1065
|
+
const waiters = this.instanceIdleWaiters.get(instanceName) ?? new Set();
|
|
1066
|
+
waiters.add(check);
|
|
1067
|
+
this.instanceIdleWaiters.set(instanceName, waiters);
|
|
1068
|
+
const timeout = setTimeout(() => finish(false), timeoutMs);
|
|
1069
|
+
timeout.unref?.();
|
|
1070
|
+
const sent = ipc.send({
|
|
1071
|
+
type: "query_instance_state",
|
|
1072
|
+
requestId: `reply-grace-${Date.now()}`,
|
|
1073
|
+
refresh: true,
|
|
1074
|
+
});
|
|
1075
|
+
if (!sent)
|
|
1076
|
+
finish(false);
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch) {
|
|
1080
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1081
|
+
return;
|
|
889
1082
|
let idleObservedAfter = this.lastDeliveryAt.get(instanceName) ?? 0;
|
|
890
1083
|
if (this.lifecycle.isPaused(instanceName)) {
|
|
891
1084
|
const wakeStartedAt = Date.now();
|
|
892
1085
|
await this.lifecycle.wake(instanceName, 30_000);
|
|
1086
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1087
|
+
return;
|
|
893
1088
|
// Waking added one to the warm count — make room by evicting a different
|
|
894
1089
|
// LRU idle instance (never this one; it's about to work).
|
|
895
1090
|
this.enforceWarmCap(instanceName);
|
|
896
1091
|
// Never satisfy a post-wake gate from a stale pre-pause cache entry.
|
|
897
1092
|
idleObservedAfter = Math.max(idleObservedAfter, wakeStartedAt);
|
|
898
1093
|
}
|
|
899
|
-
const idle = await this.waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter);
|
|
1094
|
+
const idle = await this.waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter, () => !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch));
|
|
1095
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch)) {
|
|
1096
|
+
this.logger.info({ instanceName }, "Pending delivery dropped by user cancel");
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
900
1099
|
if (!idle) {
|
|
901
1100
|
this.logger.warn({ instanceName, timeoutMs }, "Idle gate timed out; forcing delivery");
|
|
902
1101
|
}
|
|
903
|
-
await this.sendWhenConnected(instanceName, payload);
|
|
1102
|
+
await this.sendWhenConnected(instanceName, payload, deliveryEpoch);
|
|
1103
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1104
|
+
return;
|
|
904
1105
|
this.lastDeliveryAt.set(instanceName, Date.now());
|
|
905
1106
|
}
|
|
906
1107
|
/**
|
|
@@ -919,7 +1120,9 @@ export class FleetManager {
|
|
|
919
1120
|
* instance, *including* when the socket happens to be up: otherwise a message
|
|
920
1121
|
* arriving after the reconnect could overtake one that has been waiting for it.
|
|
921
1122
|
*/
|
|
922
|
-
async sendWhenConnected(instanceName, payload) {
|
|
1123
|
+
async sendWhenConnected(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName)) {
|
|
1124
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1125
|
+
return;
|
|
923
1126
|
const queued = this.ipcWaitTails.get(instanceName);
|
|
924
1127
|
if (!queued) {
|
|
925
1128
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
@@ -928,7 +1131,11 @@ export class FleetManager {
|
|
|
928
1131
|
}
|
|
929
1132
|
const attempt = (queued ?? Promise.resolve())
|
|
930
1133
|
.catch(() => { })
|
|
931
|
-
.then(() =>
|
|
1134
|
+
.then(() => {
|
|
1135
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1136
|
+
return;
|
|
1137
|
+
return this.sendAfterIpcReturns(instanceName, payload, deliveryEpoch);
|
|
1138
|
+
});
|
|
932
1139
|
// The chain stores a settled-either-way promise so one failed delivery cannot
|
|
933
1140
|
// wedge every later one, and so `queued` above is safe to await unguarded.
|
|
934
1141
|
const tail = attempt.catch(() => { });
|
|
@@ -945,10 +1152,12 @@ export class FleetManager {
|
|
|
945
1152
|
}
|
|
946
1153
|
}
|
|
947
1154
|
/** Poll for the instance's IPC to come back, then send. Throws if it does not. */
|
|
948
|
-
async sendAfterIpcReturns(instanceName, payload) {
|
|
1155
|
+
async sendAfterIpcReturns(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName)) {
|
|
949
1156
|
const deadline = Date.now() + IPC_RECONNECT_GRACE_MS;
|
|
950
1157
|
let warned = false;
|
|
951
1158
|
for (;;) {
|
|
1159
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1160
|
+
return;
|
|
952
1161
|
// Re-read every round: a reconnect replaces the IpcClient object entirely,
|
|
953
1162
|
// so a cached reference would stay dead forever.
|
|
954
1163
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
@@ -966,6 +1175,8 @@ export class FleetManager {
|
|
|
966
1175
|
}
|
|
967
1176
|
/** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
|
|
968
1177
|
async deliverToInstance(instanceName, payload, options = {}) {
|
|
1178
|
+
const deliveryEpoch = this.getDeliveryEpoch(instanceName);
|
|
1179
|
+
const deliveryPayload = { ...payload, delivery_epoch: deliveryEpoch };
|
|
969
1180
|
const meta = payload.meta && typeof payload.meta === "object"
|
|
970
1181
|
? payload.meta
|
|
971
1182
|
: undefined;
|
|
@@ -977,16 +1188,20 @@ export class FleetManager {
|
|
|
977
1188
|
if (!waitForIdle) {
|
|
978
1189
|
if (this.lifecycle.isPaused(instanceName)) {
|
|
979
1190
|
await this.lifecycle.wake(instanceName, 30_000);
|
|
1191
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1192
|
+
return;
|
|
980
1193
|
this.enforceWarmCap(instanceName); // woke one → evict a different LRU idle if over cap
|
|
981
1194
|
}
|
|
982
|
-
await this.sendWhenConnected(instanceName,
|
|
1195
|
+
await this.sendWhenConnected(instanceName, deliveryPayload, deliveryEpoch);
|
|
1196
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1197
|
+
return;
|
|
983
1198
|
// A cross-instance item arriving before the daemon observes this turn as
|
|
984
1199
|
// working must not trust the stale idle snapshot from before the send.
|
|
985
1200
|
this.lastDeliveryAt.set(instanceName, Date.now());
|
|
986
1201
|
return;
|
|
987
1202
|
}
|
|
988
1203
|
const previous = this.idleGatedDeliveryTails.get(instanceName) ?? Promise.resolve();
|
|
989
|
-
const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName,
|
|
1204
|
+
const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch));
|
|
990
1205
|
this.idleGatedDeliveryTails.set(instanceName, delivery);
|
|
991
1206
|
try {
|
|
992
1207
|
await delivery;
|
|
@@ -1065,19 +1280,28 @@ export class FleetManager {
|
|
|
1065
1280
|
this.logger.info({ name }, "Instance already running, skipping");
|
|
1066
1281
|
return;
|
|
1067
1282
|
}
|
|
1283
|
+
const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
1068
1284
|
if (config.general_topic) {
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1285
|
+
this.ensureGeneralInstructions(config.working_directory, backend, name);
|
|
1286
|
+
}
|
|
1287
|
+
else if (kind === "fleet-topic") {
|
|
1288
|
+
// Workers get only role-eligible on-demand skills. Classic instances are
|
|
1289
|
+
// deliberately excluded: their workspace and conversation lifecycle are
|
|
1290
|
+
// managed independently from fleet-topic workers.
|
|
1291
|
+
try {
|
|
1292
|
+
const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
|
|
1293
|
+
this.syncRoleSkills(skillsWorkDir, backend, "worker");
|
|
1294
|
+
}
|
|
1295
|
+
catch (err) {
|
|
1296
|
+
// Skill publishing is additive. A read-only or temporarily unavailable
|
|
1297
|
+
// workspace must not turn an otherwise valid worker startup into a
|
|
1298
|
+
// fleet outage.
|
|
1299
|
+
this.logger.warn({ err, name, backend }, "Failed to sync worker skills — continuing startup");
|
|
1075
1300
|
}
|
|
1076
|
-
this.ensureGeneralInstructions(config.working_directory, config.backend);
|
|
1077
1301
|
}
|
|
1078
1302
|
await this.lifecycle.start(name, config, topicMode, {
|
|
1079
1303
|
kind,
|
|
1080
|
-
backend
|
|
1304
|
+
backend,
|
|
1081
1305
|
model: this.resolveInstanceModel(name).display,
|
|
1082
1306
|
});
|
|
1083
1307
|
// Only clear a stale process status after a real start succeeded. Clearing
|
|
@@ -1361,6 +1585,7 @@ export class FleetManager {
|
|
|
1361
1585
|
messageId,
|
|
1362
1586
|
});
|
|
1363
1587
|
this.lastUpdateProgressText = null;
|
|
1588
|
+
this.updateCompletionTipText = null;
|
|
1364
1589
|
this.startUpdateProgressMonitor(adapter);
|
|
1365
1590
|
}
|
|
1366
1591
|
failUpdateProgress(message) {
|
|
@@ -1384,7 +1609,17 @@ export class FleetManager {
|
|
|
1384
1609
|
const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
|
|
1385
1610
|
if (!adapter)
|
|
1386
1611
|
return;
|
|
1387
|
-
|
|
1612
|
+
let text = formatUpdateProgress(marker);
|
|
1613
|
+
// No-restart updates complete in the old fleet process, so they never
|
|
1614
|
+
// reach RestartProgress.finish(). Append the same optional tip here.
|
|
1615
|
+
if (marker.progress.stage === "complete" && this.tipsEnabled()) {
|
|
1616
|
+
if (this.updateCompletionTipText === null) {
|
|
1617
|
+
const tip = this.pickAvailableTip();
|
|
1618
|
+
this.updateCompletionTipText = tip ? this.formatTip(tip) : "";
|
|
1619
|
+
}
|
|
1620
|
+
if (this.updateCompletionTipText)
|
|
1621
|
+
text += `\n\n${this.updateCompletionTipText}`;
|
|
1622
|
+
}
|
|
1388
1623
|
if (text === this.lastUpdateProgressText)
|
|
1389
1624
|
return;
|
|
1390
1625
|
this.updateProgressEditRunning = true;
|
|
@@ -1425,6 +1660,7 @@ export class FleetManager {
|
|
|
1425
1660
|
// Rotate fleet.log if oversized (before any logging)
|
|
1426
1661
|
rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
|
|
1427
1662
|
const fleet = this.loadConfig(configPath);
|
|
1663
|
+
this.slimFleetConfigAtStartup();
|
|
1428
1664
|
setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
|
|
1429
1665
|
const savedUpdateProgress = readUpdateProgress(this.dataDir);
|
|
1430
1666
|
const pendingUpdateProgress = savedUpdateProgress
|
|
@@ -1441,6 +1677,10 @@ export class FleetManager {
|
|
|
1441
1677
|
const { getTmuxSocketName: getSocket } = await import("./paths.js");
|
|
1442
1678
|
TmuxManager.setSocketName(getSocket());
|
|
1443
1679
|
await TmuxManager.ensureSession(getTmuxSession());
|
|
1680
|
+
// Pre-flight (advisory, fire-and-forget): warm the per-backend auth cache
|
|
1681
|
+
// before instances spawn, so a CLI that comes up on its sign-in screen gets
|
|
1682
|
+
// an instant 🔑 verdict instead of decaying into crash/MCP-died noise.
|
|
1683
|
+
this.lifecycle.primeAuthVerification(Object.values(fleet.instances).map(config => config.backend ?? fleet.defaults?.backend ?? "claude-code"));
|
|
1444
1684
|
// Start tmux control mode client for idle detection
|
|
1445
1685
|
if (!this.controlClient) {
|
|
1446
1686
|
this.controlClient = new TmuxControlClient(getTmuxSession(), 2000, this.logger);
|
|
@@ -1482,9 +1722,8 @@ export class FleetManager {
|
|
|
1482
1722
|
// routing does NOT go through the routing engine (single-key, can't hold two
|
|
1483
1723
|
// bots in one channel) — it resolves per-bot via getInstanceByChannel.
|
|
1484
1724
|
this.classicChannels = new ClassicChannelManager(this.dataDir, this.logger);
|
|
1485
|
-
const
|
|
1486
|
-
|
|
1487
|
-
this.classicChannels.setPrimaryAdapterId(primaryCh.id ?? primaryCh.type);
|
|
1725
|
+
const classicAdapters = fleet.channels?.length ? fleet.channels : (fleet.channel ? [fleet.channel] : []);
|
|
1726
|
+
this.classicChannels.configureAdapters(classicAdapters);
|
|
1488
1727
|
// Restore the persisted bot binding so replies/cancel go through the right
|
|
1489
1728
|
// bot after a restart (before this, inbound would re-bind lazily).
|
|
1490
1729
|
for (const ch of this.classicChannels.getAll()) {
|
|
@@ -1653,7 +1892,7 @@ export class FleetManager {
|
|
|
1653
1892
|
const generalDir = join(getAgendHome(), name);
|
|
1654
1893
|
mkdirSync(generalDir, { recursive: true });
|
|
1655
1894
|
const backendName = fleet.defaults.backend ?? "claude-code";
|
|
1656
|
-
this.ensureGeneralInstructions(generalDir, backendName);
|
|
1895
|
+
this.ensureGeneralInstructions(generalDir, backendName, name);
|
|
1657
1896
|
fleet.instances[name] = {
|
|
1658
1897
|
...DEFAULT_INSTANCE_CONFIG,
|
|
1659
1898
|
working_directory: generalDir,
|
|
@@ -1672,6 +1911,13 @@ export class FleetManager {
|
|
|
1672
1911
|
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));
|
|
1673
1912
|
this.scheduler.init();
|
|
1674
1913
|
this.logger.info("Scheduler initialized");
|
|
1914
|
+
// Tips share the daily-report clock but remain an independent internal
|
|
1915
|
+
// job: disabling daily_summary must not disable tips (and vice versa).
|
|
1916
|
+
// The callback checks defaults.tips at fire time, so /tips on|off is hot.
|
|
1917
|
+
this.dailyTipScheduler = new DailyTipScheduler(summaryConfig, costGuardConfig.timezone, () => this.sendTipToGeneral().catch(err => {
|
|
1918
|
+
this.logger.warn({ err }, "Failed to send daily tip");
|
|
1919
|
+
}));
|
|
1920
|
+
this.dailyTipScheduler.start();
|
|
1675
1921
|
// Inject active decisions as env var for MCP instructions.
|
|
1676
1922
|
// Snapshotted at startup — new decisions via post_decision are available
|
|
1677
1923
|
// through list_decisions tool but not auto-injected until restart.
|
|
@@ -1854,6 +2100,12 @@ export class FleetManager {
|
|
|
1854
2100
|
version: agendVersion,
|
|
1855
2101
|
pausedNames,
|
|
1856
2102
|
failedNames,
|
|
2103
|
+
tipText: pendingUpdateProgress && this.tipsEnabled()
|
|
2104
|
+
? (() => {
|
|
2105
|
+
const tip = this.pickAvailableTip();
|
|
2106
|
+
return tip ? this.formatTip(tip) : undefined;
|
|
2107
|
+
})()
|
|
2108
|
+
: undefined,
|
|
1857
2109
|
});
|
|
1858
2110
|
if (!progressCompleted && this.adapter && fleet.channel?.group_id) {
|
|
1859
2111
|
let text;
|
|
@@ -2015,14 +2267,14 @@ export class FleetManager {
|
|
|
2015
2267
|
notifyAdapterFailure(adapterId, error) {
|
|
2016
2268
|
const generalId = this.findGeneralInstance();
|
|
2017
2269
|
if (generalId) {
|
|
2018
|
-
this.notifyInstanceTopic(generalId,
|
|
2270
|
+
this.notifyInstanceTopic(generalId, t("adapter.start_failed", adapterId, error));
|
|
2019
2271
|
}
|
|
2020
2272
|
}
|
|
2021
2273
|
/** Notify admin that a retried adapter reconnected. */
|
|
2022
2274
|
notifyAdapterRecovery(adapterId, attempts) {
|
|
2023
2275
|
const generalId = this.findGeneralInstance();
|
|
2024
2276
|
if (generalId) {
|
|
2025
|
-
this.notifyInstanceTopic(generalId,
|
|
2277
|
+
this.notifyInstanceTopic(generalId, t("adapter.reconnected", adapterId, attempts));
|
|
2026
2278
|
}
|
|
2027
2279
|
}
|
|
2028
2280
|
/** Get adapter states for /status visibility. */
|
|
@@ -2120,6 +2372,20 @@ export class FleetManager {
|
|
|
2120
2372
|
await this.handleInboundReaction(r);
|
|
2121
2373
|
}, this.logger, "adapter.reaction"));
|
|
2122
2374
|
this.adapter.on("callback_query", safeHandler(async (data) => {
|
|
2375
|
+
if (await this.handleTipDismiss(data, adapterId, this.adapter ?? undefined))
|
|
2376
|
+
return;
|
|
2377
|
+
if (await this.handleTipUnlock(data, adapterId, this.adapter ?? undefined))
|
|
2378
|
+
return;
|
|
2379
|
+
if (await this.handleLoginBackendSelect(data, adapterId, this.adapter ?? undefined))
|
|
2380
|
+
return;
|
|
2381
|
+
if (await this.handleLoginMenuSelect(data, adapterId, this.adapter ?? undefined))
|
|
2382
|
+
return;
|
|
2383
|
+
if (await this.handleLoginConfirm(data, adapterId, this.adapter ?? undefined))
|
|
2384
|
+
return;
|
|
2385
|
+
if (await this.handleInstallLoginConfirm(data, adapterId, this.adapter ?? undefined))
|
|
2386
|
+
return;
|
|
2387
|
+
if (await this.handleClearConfirmation(data, adapterId, this.adapter ?? undefined))
|
|
2388
|
+
return;
|
|
2123
2389
|
if (await this.handleExitRestartPrompt(data, adapterId, this.adapter ?? undefined))
|
|
2124
2390
|
return;
|
|
2125
2391
|
if (await this.handleInteractivePromptAssist(data, adapterId, this.adapter ?? undefined))
|
|
@@ -2210,6 +2476,49 @@ export class FleetManager {
|
|
|
2210
2476
|
const result = await this.topicCommands.sendCompact(name);
|
|
2211
2477
|
await data.respond(result);
|
|
2212
2478
|
}
|
|
2479
|
+
else if (data.command === "steer") {
|
|
2480
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2481
|
+
if (!name) {
|
|
2482
|
+
await data.respond(t("classic.no_agent"));
|
|
2483
|
+
return;
|
|
2484
|
+
}
|
|
2485
|
+
const steerText = String(data.options?.message ?? "").trim();
|
|
2486
|
+
if (!steerText) {
|
|
2487
|
+
await data.respond(t("steer.usage"));
|
|
2488
|
+
return;
|
|
2489
|
+
}
|
|
2490
|
+
// chat_id/message_id stay empty: a slash interaction has no channel
|
|
2491
|
+
// message to react to, and an empty chat_id keeps updateLastChat from
|
|
2492
|
+
// rerouting the instance's replies to the slash context.
|
|
2493
|
+
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
2494
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2495
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2496
|
+
});
|
|
2497
|
+
await data.respond(result);
|
|
2498
|
+
}
|
|
2499
|
+
else if (data.command === "btw") {
|
|
2500
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2501
|
+
if (!name) {
|
|
2502
|
+
await data.respond(t("classic.no_agent"));
|
|
2503
|
+
return;
|
|
2504
|
+
}
|
|
2505
|
+
const btwText = String(data.options?.message ?? "").trim();
|
|
2506
|
+
if (!btwText) {
|
|
2507
|
+
await data.respond(t("btw.usage"));
|
|
2508
|
+
return;
|
|
2509
|
+
}
|
|
2510
|
+
const result = this.topicCommands.sendBtw(name, btwText, {
|
|
2511
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2512
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2513
|
+
});
|
|
2514
|
+
await data.respond(result);
|
|
2515
|
+
}
|
|
2516
|
+
else if (data.command === "login") {
|
|
2517
|
+
await this.handleLoginSlash(data, adapterId, this.adapter);
|
|
2518
|
+
}
|
|
2519
|
+
else if (data.command === "install-cli") {
|
|
2520
|
+
await this.handleInstallCliSlash(data, adapterId, this.adapter);
|
|
2521
|
+
}
|
|
2213
2522
|
else if (data.command === "clear") {
|
|
2214
2523
|
await this.handleClearSlash(data, adapterId);
|
|
2215
2524
|
}
|
|
@@ -2283,12 +2592,15 @@ export class FleetManager {
|
|
|
2283
2592
|
const { getUsageSnapshot } = await import("./usage/usage-api.js");
|
|
2284
2593
|
const { renderUsageMarkdown } = await import("./usage/format-rich.js");
|
|
2285
2594
|
// slash_command is Discord-only; editReply renders Markdown natively.
|
|
2286
|
-
await data.respond(renderUsageMarkdown(await getUsageSnapshot()));
|
|
2595
|
+
await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
|
|
2287
2596
|
}
|
|
2288
2597
|
catch (err) {
|
|
2289
|
-
await data.respond(
|
|
2598
|
+
await data.respond(t("usage.failed", err.message));
|
|
2290
2599
|
}
|
|
2291
2600
|
}
|
|
2601
|
+
else if (data.command === "tips") {
|
|
2602
|
+
await this.handleTipsSlash(data, adapterId);
|
|
2603
|
+
}
|
|
2292
2604
|
else if (data.command === "status") {
|
|
2293
2605
|
// Admin-gated (like the topic path): the merged table shows every
|
|
2294
2606
|
// instance's cost and IPC health.
|
|
@@ -2334,6 +2646,49 @@ export class FleetManager {
|
|
|
2334
2646
|
const result = await this.topicCommands.sendCompact(name);
|
|
2335
2647
|
await data.respond(result);
|
|
2336
2648
|
}
|
|
2649
|
+
else if (data.command === "steer") {
|
|
2650
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2651
|
+
if (!name) {
|
|
2652
|
+
await data.respond(t("classic.no_agent"));
|
|
2653
|
+
return;
|
|
2654
|
+
}
|
|
2655
|
+
const steerText = String(data.options?.message ?? "").trim();
|
|
2656
|
+
if (!steerText) {
|
|
2657
|
+
await data.respond(t("steer.usage"));
|
|
2658
|
+
return;
|
|
2659
|
+
}
|
|
2660
|
+
// chat_id/message_id stay empty: a slash interaction has no channel
|
|
2661
|
+
// message to react to, and an empty chat_id keeps updateLastChat from
|
|
2662
|
+
// rerouting the instance's replies to the slash context.
|
|
2663
|
+
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
2664
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2665
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2666
|
+
});
|
|
2667
|
+
await data.respond(result);
|
|
2668
|
+
}
|
|
2669
|
+
else if (data.command === "btw") {
|
|
2670
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2671
|
+
if (!name) {
|
|
2672
|
+
await data.respond(t("classic.no_agent"));
|
|
2673
|
+
return;
|
|
2674
|
+
}
|
|
2675
|
+
const btwText = String(data.options?.message ?? "").trim();
|
|
2676
|
+
if (!btwText) {
|
|
2677
|
+
await data.respond(t("btw.usage"));
|
|
2678
|
+
return;
|
|
2679
|
+
}
|
|
2680
|
+
const result = this.topicCommands.sendBtw(name, btwText, {
|
|
2681
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2682
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2683
|
+
});
|
|
2684
|
+
await data.respond(result);
|
|
2685
|
+
}
|
|
2686
|
+
else if (data.command === "login") {
|
|
2687
|
+
await this.handleLoginSlash(data, adapterId, this.adapter);
|
|
2688
|
+
}
|
|
2689
|
+
else if (data.command === "install-cli") {
|
|
2690
|
+
await this.handleInstallCliSlash(data, adapterId, this.adapter);
|
|
2691
|
+
}
|
|
2337
2692
|
}, this.logger, "adapter.slash_command"));
|
|
2338
2693
|
await this.topicCommands.registerBotCommands().catch(e => this.logger.warn({ err: e }, "registerBotCommands failed (non-fatal)"));
|
|
2339
2694
|
// Background-probe each backend's CLI env (version/models) → cli-env cache.
|
|
@@ -2410,6 +2765,20 @@ export class FleetManager {
|
|
|
2410
2765
|
await this.handleInboundReaction(r);
|
|
2411
2766
|
}, this.logger, `adapter[${adapterId}].reaction`));
|
|
2412
2767
|
adapter.on("callback_query", safeHandler(async (data) => {
|
|
2768
|
+
if (await this.handleTipDismiss(data, adapterId, adapter))
|
|
2769
|
+
return;
|
|
2770
|
+
if (await this.handleTipUnlock(data, adapterId, adapter))
|
|
2771
|
+
return;
|
|
2772
|
+
if (await this.handleLoginBackendSelect(data, adapterId, adapter))
|
|
2773
|
+
return;
|
|
2774
|
+
if (await this.handleLoginMenuSelect(data, adapterId, adapter))
|
|
2775
|
+
return;
|
|
2776
|
+
if (await this.handleLoginConfirm(data, adapterId, adapter))
|
|
2777
|
+
return;
|
|
2778
|
+
if (await this.handleInstallLoginConfirm(data, adapterId, adapter))
|
|
2779
|
+
return;
|
|
2780
|
+
if (await this.handleClearConfirmation(data, adapterId, adapter))
|
|
2781
|
+
return;
|
|
2413
2782
|
if (await this.handleExitRestartPrompt(data, adapterId, adapter))
|
|
2414
2783
|
return;
|
|
2415
2784
|
if (await this.handleInteractivePromptAssist(data, adapterId, adapter))
|
|
@@ -2563,12 +2932,15 @@ export class FleetManager {
|
|
|
2563
2932
|
const { getUsageSnapshot } = await import("./usage/usage-api.js");
|
|
2564
2933
|
const { renderUsageMarkdown } = await import("./usage/format-rich.js");
|
|
2565
2934
|
// slash_command is Discord-only; editReply renders Markdown natively.
|
|
2566
|
-
await data.respond(renderUsageMarkdown(await getUsageSnapshot()));
|
|
2935
|
+
await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
|
|
2567
2936
|
}
|
|
2568
2937
|
catch (err) {
|
|
2569
|
-
await data.respond(
|
|
2938
|
+
await data.respond(t("usage.failed", err.message));
|
|
2570
2939
|
}
|
|
2571
2940
|
}
|
|
2941
|
+
else if (data.command === "tips") {
|
|
2942
|
+
await this.handleTipsSlash(data, adapterId);
|
|
2943
|
+
}
|
|
2572
2944
|
else if (data.command === "status") {
|
|
2573
2945
|
// Admin-gated (like the topic path): the merged table shows every
|
|
2574
2946
|
// instance's cost and IPC health.
|
|
@@ -2614,6 +2986,49 @@ export class FleetManager {
|
|
|
2614
2986
|
const result = await this.topicCommands.sendCompact(name);
|
|
2615
2987
|
await data.respond(result);
|
|
2616
2988
|
}
|
|
2989
|
+
else if (data.command === "steer") {
|
|
2990
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2991
|
+
if (!name) {
|
|
2992
|
+
await data.respond(t("classic.no_agent"));
|
|
2993
|
+
return;
|
|
2994
|
+
}
|
|
2995
|
+
const steerText = String(data.options?.message ?? "").trim();
|
|
2996
|
+
if (!steerText) {
|
|
2997
|
+
await data.respond(t("steer.usage"));
|
|
2998
|
+
return;
|
|
2999
|
+
}
|
|
3000
|
+
// chat_id/message_id stay empty: a slash interaction has no channel
|
|
3001
|
+
// message to react to, and an empty chat_id keeps updateLastChat from
|
|
3002
|
+
// rerouting the instance's replies to the slash context.
|
|
3003
|
+
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
3004
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
3005
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
3006
|
+
});
|
|
3007
|
+
await data.respond(result);
|
|
3008
|
+
}
|
|
3009
|
+
else if (data.command === "btw") {
|
|
3010
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
3011
|
+
if (!name) {
|
|
3012
|
+
await data.respond(t("classic.no_agent"));
|
|
3013
|
+
return;
|
|
3014
|
+
}
|
|
3015
|
+
const btwText = String(data.options?.message ?? "").trim();
|
|
3016
|
+
if (!btwText) {
|
|
3017
|
+
await data.respond(t("btw.usage"));
|
|
3018
|
+
return;
|
|
3019
|
+
}
|
|
3020
|
+
const result = this.topicCommands.sendBtw(name, btwText, {
|
|
3021
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
3022
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
3023
|
+
});
|
|
3024
|
+
await data.respond(result);
|
|
3025
|
+
}
|
|
3026
|
+
else if (data.command === "login") {
|
|
3027
|
+
await this.handleLoginSlash(data, adapterId, adapter);
|
|
3028
|
+
}
|
|
3029
|
+
else if (data.command === "install-cli") {
|
|
3030
|
+
await this.handleInstallCliSlash(data, adapterId, adapter);
|
|
3031
|
+
}
|
|
2617
3032
|
}, this.logger, `adapter[${adapterId}].slash_command`));
|
|
2618
3033
|
await adapter.start();
|
|
2619
3034
|
if (channelConfig.group_id) {
|
|
@@ -2731,6 +3146,9 @@ export class FleetManager {
|
|
|
2731
3146
|
else if (msg.type === "instance_activity") {
|
|
2732
3147
|
this.cacheInstanceActivity(name, msg.activity);
|
|
2733
3148
|
}
|
|
3149
|
+
else if (msg.type === "instance_progress") {
|
|
3150
|
+
this.cacheInstanceProgress(name, msg.progress || null);
|
|
3151
|
+
}
|
|
2734
3152
|
else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
|
|
2735
3153
|
this.cacheInstanceExecutionState(name, msg);
|
|
2736
3154
|
if (msg.type === "instance_state_response") {
|
|
@@ -3178,6 +3596,42 @@ export class FleetManager {
|
|
|
3178
3596
|
await msgAdapter?.sendText(chatId, result);
|
|
3179
3597
|
return;
|
|
3180
3598
|
}
|
|
3599
|
+
// /steer — interject into the running turn. Not admin-gated: anyone who
|
|
3600
|
+
// can talk to this agent can send it a message; steer only changes when
|
|
3601
|
+
// it lands, and it keeps the full [user:] formatting (unlike /raw).
|
|
3602
|
+
if (text === "/steer" || text.startsWith("/steer ") || text.startsWith("/steer@")) {
|
|
3603
|
+
const steerName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
3604
|
+
if (!steerName) {
|
|
3605
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
3606
|
+
return;
|
|
3607
|
+
}
|
|
3608
|
+
const steerContent = text.replace(/^\/steer(@\S+)?/, "").trim();
|
|
3609
|
+
if (!steerContent) {
|
|
3610
|
+
await msgAdapter?.sendText(chatId, t("steer.usage"));
|
|
3611
|
+
return;
|
|
3612
|
+
}
|
|
3613
|
+
const result = this.topicCommands.sendSteer(steerName, steerContent, msg);
|
|
3614
|
+
await msgAdapter?.sendText(chatId, result);
|
|
3615
|
+
return;
|
|
3616
|
+
}
|
|
3617
|
+
// /btw — Claude Code's native side-thread command. Like /steer it is
|
|
3618
|
+
// not admin-gated, but it remains a distinct raw CLI command so Claude
|
|
3619
|
+
// does not fold the question into the active task.
|
|
3620
|
+
if (text === "/btw" || text.startsWith("/btw ") || text.startsWith("/btw@")) {
|
|
3621
|
+
const btwName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
3622
|
+
if (!btwName) {
|
|
3623
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
3624
|
+
return;
|
|
3625
|
+
}
|
|
3626
|
+
const btwContent = text.replace(/^\/btw(@\S+)?/, "").trim();
|
|
3627
|
+
if (!btwContent) {
|
|
3628
|
+
await msgAdapter?.sendText(chatId, t("btw.usage"));
|
|
3629
|
+
return;
|
|
3630
|
+
}
|
|
3631
|
+
const result = this.topicCommands.sendBtw(btwName, btwContent, msg);
|
|
3632
|
+
await msgAdapter?.sendText(chatId, result);
|
|
3633
|
+
return;
|
|
3634
|
+
}
|
|
3181
3635
|
// Handle /clear command (admin only) — unlike /compact this starts a
|
|
3182
3636
|
// fresh conversation and intentionally discards the current history.
|
|
3183
3637
|
if (text === "/clear" || text.startsWith("/clear@")) {
|
|
@@ -3190,7 +3644,11 @@ export class FleetManager {
|
|
|
3190
3644
|
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
3191
3645
|
return;
|
|
3192
3646
|
}
|
|
3193
|
-
|
|
3647
|
+
if (!msgAdapter)
|
|
3648
|
+
return;
|
|
3649
|
+
const fallback = await this.promptClearConfirmation(clearName, chatId, msgAdapter, chatId);
|
|
3650
|
+
if (fallback)
|
|
3651
|
+
await msgAdapter.sendText(chatId, fallback);
|
|
3194
3652
|
return;
|
|
3195
3653
|
}
|
|
3196
3654
|
// Handle /cancel command
|
|
@@ -3201,7 +3659,7 @@ export class FleetManager {
|
|
|
3201
3659
|
return;
|
|
3202
3660
|
}
|
|
3203
3661
|
const ok = this.cancelInstance(cancelName);
|
|
3204
|
-
await msgAdapter?.sendText(chatId, ok ?
|
|
3662
|
+
await msgAdapter?.sendText(chatId, ok ? t("cancel.sent", cancelName) : t("cancel.not_running", cancelName));
|
|
3205
3663
|
return;
|
|
3206
3664
|
}
|
|
3207
3665
|
// Handle /ctx command
|
|
@@ -3238,7 +3696,7 @@ export class FleetManager {
|
|
|
3238
3696
|
const backend = this.classicChannels.getBackendByInstance(saveName, this.fleetConfig?.defaults?.backend);
|
|
3239
3697
|
const cmd = saveCommandForBackend(backend, filename);
|
|
3240
3698
|
if (!cmd) {
|
|
3241
|
-
await msgAdapter?.sendText(chatId,
|
|
3699
|
+
await msgAdapter?.sendText(chatId, t("save.unsupported"));
|
|
3242
3700
|
return;
|
|
3243
3701
|
}
|
|
3244
3702
|
this.pasteRawToClassicInstance(saveName, cmd);
|
|
@@ -3456,10 +3914,10 @@ export class FleetManager {
|
|
|
3456
3914
|
return;
|
|
3457
3915
|
let warning = "";
|
|
3458
3916
|
if (rl.five_hour_pct >= 95) {
|
|
3459
|
-
warning =
|
|
3917
|
+
warning = t("rate_limit.five_hour", instanceName, Math.round(rl.five_hour_pct));
|
|
3460
3918
|
}
|
|
3461
3919
|
else if (rl.seven_day_pct >= 95) {
|
|
3462
|
-
warning =
|
|
3920
|
+
warning = t("rate_limit.weekly", instanceName, Math.round(rl.seven_day_pct));
|
|
3463
3921
|
}
|
|
3464
3922
|
if (!warning)
|
|
3465
3923
|
return;
|
|
@@ -3583,7 +4041,7 @@ export class FleetManager {
|
|
|
3583
4041
|
this.clearCancelButton(instanceName);
|
|
3584
4042
|
}
|
|
3585
4043
|
else {
|
|
3586
|
-
void this.sendCancelButton(instanceName).then(() => this.armReplyGrace(instanceName));
|
|
4044
|
+
void this.sendCancelButton(instanceName, undefined, true).then(() => this.armReplyGrace(instanceName));
|
|
3587
4045
|
}
|
|
3588
4046
|
this.reactDone(instanceName);
|
|
3589
4047
|
const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
|
|
@@ -3620,7 +4078,11 @@ export class FleetManager {
|
|
|
3620
4078
|
if (silent) {
|
|
3621
4079
|
const ipc = this.instanceIpcClients.get(target);
|
|
3622
4080
|
if (ipc) {
|
|
3623
|
-
ipc.send({
|
|
4081
|
+
ipc.send({
|
|
4082
|
+
type: "raw_paste",
|
|
4083
|
+
content: message,
|
|
4084
|
+
delivery_epoch: this.getDeliveryEpoch(target),
|
|
4085
|
+
});
|
|
3624
4086
|
this.scheduler.recordRun(id, "delivered");
|
|
3625
4087
|
this.logger.info({ target, scheduleId: id, label }, "Silent schedule injected via raw_paste");
|
|
3626
4088
|
}
|
|
@@ -3635,10 +4097,19 @@ export class FleetManager {
|
|
|
3635
4097
|
const retryInterval = schedulerDefaults?.retry_interval_ms ?? 30_000;
|
|
3636
4098
|
const deliver = async () => {
|
|
3637
4099
|
try {
|
|
4100
|
+
// A schedule has no live inbound adapter context. Seed the daemon with
|
|
4101
|
+
// the target instance's configured world so replies use its persona
|
|
4102
|
+
// after a fresh start instead of falling back to channels[0].
|
|
4103
|
+
const adapterId = this.getInstanceAdapterId(target);
|
|
3638
4104
|
await this.deliverToInstance(target, {
|
|
3639
4105
|
type: "fleet_schedule_trigger",
|
|
3640
4106
|
payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
|
|
3641
|
-
meta: {
|
|
4107
|
+
meta: {
|
|
4108
|
+
chat_id: reply_chat_id,
|
|
4109
|
+
thread_id: reply_thread_id,
|
|
4110
|
+
user: "scheduler",
|
|
4111
|
+
...(adapterId ? { adapter_id: adapterId } : {}),
|
|
4112
|
+
},
|
|
3642
4113
|
}, { waitForIdle: true });
|
|
3643
4114
|
// A scheduled trigger also puts the instance to work — show a cancel button.
|
|
3644
4115
|
void this.sendCancelButton(target);
|
|
@@ -3786,7 +4257,29 @@ export class FleetManager {
|
|
|
3786
4257
|
}
|
|
3787
4258
|
/** Resolve display name for an instance, fallback to instance name. */
|
|
3788
4259
|
resolveDisplayName(instanceName) {
|
|
3789
|
-
return this.fleetConfig?.instances[instanceName]?.display_name
|
|
4260
|
+
return this.fleetConfig?.instances[instanceName]?.display_name
|
|
4261
|
+
?? this.classicChannels?.getAll().find(ch => ch.instanceName === instanceName)?.displayName
|
|
4262
|
+
?? instanceName;
|
|
4263
|
+
}
|
|
4264
|
+
/** Persist identity to the instance's actual config store. Classic instances
|
|
4265
|
+
* are registry rows in classicBot.yaml, not fleet.yaml instance entries. */
|
|
4266
|
+
setInstanceDisplayName(instanceName, displayName) {
|
|
4267
|
+
const fleetInstance = this.fleetConfig?.instances[instanceName];
|
|
4268
|
+
if (fleetInstance) {
|
|
4269
|
+
fleetInstance.display_name = displayName;
|
|
4270
|
+
this.saveFleetConfig();
|
|
4271
|
+
return true;
|
|
4272
|
+
}
|
|
4273
|
+
return this.classicChannels?.setDisplayNameByInstance(instanceName, displayName) ?? false;
|
|
4274
|
+
}
|
|
4275
|
+
setInstanceDescription(instanceName, description) {
|
|
4276
|
+
const fleetInstance = this.fleetConfig?.instances[instanceName];
|
|
4277
|
+
if (fleetInstance) {
|
|
4278
|
+
fleetInstance.description = description;
|
|
4279
|
+
this.saveFleetConfig();
|
|
4280
|
+
return true;
|
|
4281
|
+
}
|
|
4282
|
+
return this.classicChannels?.setDescriptionByInstance(instanceName, description) ?? false;
|
|
3790
4283
|
}
|
|
3791
4284
|
handleSetDisplayName(instanceName, msg) {
|
|
3792
4285
|
const fleetRequestId = msg.fleetRequestId;
|
|
@@ -3799,8 +4292,10 @@ export class FleetManager {
|
|
|
3799
4292
|
ipc.send({ type: "fleet_display_name_response", fleetRequestId, error: "Name must be 1-30 characters" });
|
|
3800
4293
|
return;
|
|
3801
4294
|
}
|
|
3802
|
-
this.
|
|
3803
|
-
|
|
4295
|
+
if (!this.setInstanceDisplayName(instanceName, displayName)) {
|
|
4296
|
+
ipc.send({ type: "fleet_display_name_response", fleetRequestId, error: `Instance '${instanceName}' not found` });
|
|
4297
|
+
return;
|
|
4298
|
+
}
|
|
3804
4299
|
this.logger.info({ instanceName, displayName }, "Display name set");
|
|
3805
4300
|
ipc.send({ type: "fleet_display_name_response", fleetRequestId, result: { display_name: displayName } });
|
|
3806
4301
|
}
|
|
@@ -3815,8 +4310,10 @@ export class FleetManager {
|
|
|
3815
4310
|
ipc.send({ type: "fleet_description_response", fleetRequestId, error: "Description cannot be empty" });
|
|
3816
4311
|
return;
|
|
3817
4312
|
}
|
|
3818
|
-
this.
|
|
3819
|
-
|
|
4313
|
+
if (!this.setInstanceDescription(instanceName, description)) {
|
|
4314
|
+
ipc.send({ type: "fleet_description_response", fleetRequestId, error: `Instance '${instanceName}' not found` });
|
|
4315
|
+
return;
|
|
4316
|
+
}
|
|
3820
4317
|
this.logger.info({ instanceName, description: description.slice(0, 80) }, "Description set");
|
|
3821
4318
|
ipc.send({ type: "fleet_description_response", fleetRequestId, result: { description } });
|
|
3822
4319
|
}
|
|
@@ -3945,8 +4442,8 @@ export class FleetManager {
|
|
|
3945
4442
|
return { error: "Fleet config not available" };
|
|
3946
4443
|
if (!name || name.length > 30)
|
|
3947
4444
|
return { error: "Name must be 1-30 characters" };
|
|
3948
|
-
this.
|
|
3949
|
-
|
|
4445
|
+
if (!this.setInstanceDisplayName(instance, name))
|
|
4446
|
+
return { error: `Instance '${instance}' not found` };
|
|
3950
4447
|
return { display_name: name };
|
|
3951
4448
|
}
|
|
3952
4449
|
async handleSetDescriptionHttp(instance, description) {
|
|
@@ -3954,8 +4451,8 @@ export class FleetManager {
|
|
|
3954
4451
|
return { error: "Fleet config not available" };
|
|
3955
4452
|
if (!description)
|
|
3956
4453
|
return { error: "Description cannot be empty" };
|
|
3957
|
-
this.
|
|
3958
|
-
|
|
4454
|
+
if (!this.setInstanceDescription(instance, description))
|
|
4455
|
+
return { error: `Instance '${instance}' not found` };
|
|
3959
4456
|
return { description };
|
|
3960
4457
|
}
|
|
3961
4458
|
summarizeToolCall(tool, args) {
|
|
@@ -4087,7 +4584,8 @@ export class FleetManager {
|
|
|
4087
4584
|
}
|
|
4088
4585
|
/**
|
|
4089
4586
|
* Patch only values changed in the effective config into the original YAML
|
|
4090
|
-
* document. Unknown keys
|
|
4587
|
+
* document. Unknown keys and comments remain untouched; redundant
|
|
4588
|
+
* non-identity instance leaves are canonicalized to inheritance afterward.
|
|
4091
4589
|
*/
|
|
4092
4590
|
saveFleetConfig(explicitPatches = []) {
|
|
4093
4591
|
if (!this.fleetConfig || !this.configPath)
|
|
@@ -4103,9 +4601,9 @@ export class FleetManager {
|
|
|
4103
4601
|
}
|
|
4104
4602
|
this.rawFleetConfig = loadRawFleetConfig(this.configPath);
|
|
4105
4603
|
this.patchFleetDocument(this.rawFleetDocument, [], this.savedFleetConfigSnapshot, this.fleetConfig);
|
|
4106
|
-
// Settings edits are expressed against the raw config
|
|
4107
|
-
//
|
|
4108
|
-
//
|
|
4604
|
+
// Settings edits are expressed against the raw config, so apply them before
|
|
4605
|
+
// canonicalization. A non-identity value equal to its inherited default is
|
|
4606
|
+
// intentionally stored as inheritance rather than an explicit duplicate.
|
|
4109
4607
|
for (const patch of explicitPatches) {
|
|
4110
4608
|
if (patch.remove) {
|
|
4111
4609
|
// YAML's deleteIn throws when an inherited nested key has no raw parent
|
|
@@ -4120,7 +4618,24 @@ export class FleetManager {
|
|
|
4120
4618
|
this.patchFleetDocument(this.rawFleetDocument, patch.path, before, patch.value);
|
|
4121
4619
|
}
|
|
4122
4620
|
}
|
|
4621
|
+
const rawAfterPatches = this.rawFleetDocument.toJS();
|
|
4622
|
+
const redundantPaths = collectRedundantInstanceDefaultPaths(rawAfterPatches);
|
|
4623
|
+
for (const path of redundantPaths) {
|
|
4624
|
+
if (this.rawFleetDocument.hasIn(path))
|
|
4625
|
+
this.rawFleetDocument.deleteIn(path);
|
|
4626
|
+
// Avoid leaving empty operational maps such as `terminal: {}` while
|
|
4627
|
+
// preserving the instance mapping itself and all surrounding comments.
|
|
4628
|
+
for (let depth = path.length - 1; depth > 2; depth--) {
|
|
4629
|
+
const parentPath = path.slice(0, depth);
|
|
4630
|
+
const parent = this.rawFleetDocument.getIn(parentPath, true);
|
|
4631
|
+
if (!isMap(parent) || parent.items.length > 0)
|
|
4632
|
+
break;
|
|
4633
|
+
this.rawFleetDocument.deleteIn(parentPath);
|
|
4634
|
+
}
|
|
4635
|
+
}
|
|
4123
4636
|
const output = String(this.rawFleetDocument);
|
|
4637
|
+
if (redundantPaths.length > 0)
|
|
4638
|
+
this.writeFleetConfigBackup(source);
|
|
4124
4639
|
const tempPath = `${this.configPath}.tmp-${process.pid}`;
|
|
4125
4640
|
writeFileSync(tempPath, output, "utf-8");
|
|
4126
4641
|
if (existsSync(this.configPath))
|
|
@@ -4128,7 +4643,36 @@ export class FleetManager {
|
|
|
4128
4643
|
renameSync(tempPath, this.configPath);
|
|
4129
4644
|
this.rawFleetConfig = loadRawFleetConfig(this.configPath);
|
|
4130
4645
|
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
4131
|
-
this.logger.info({ path: this.configPath }, "Saved fleet config (lossless patch)");
|
|
4646
|
+
this.logger.info({ path: this.configPath, strippedDefaults: redundantPaths.length }, "Saved fleet config (lossless patch)");
|
|
4647
|
+
}
|
|
4648
|
+
/** One-time upgrade migration; invalid YAML is never rewritten. */
|
|
4649
|
+
slimFleetConfigAtStartup() {
|
|
4650
|
+
const redundantPaths = collectRedundantInstanceDefaultPaths(this.rawFleetConfig);
|
|
4651
|
+
if (redundantPaths.length === 0)
|
|
4652
|
+
return;
|
|
4653
|
+
const validation = validateFleetConfig(this.rawFleetConfig);
|
|
4654
|
+
if (!validation.valid) {
|
|
4655
|
+
this.logger.warn({ errors: validation.errors, redundantDefaults: redundantPaths.length }, "Skipping fleet.yaml default slimming because the raw config is invalid");
|
|
4656
|
+
return;
|
|
4657
|
+
}
|
|
4658
|
+
try {
|
|
4659
|
+
this.saveFleetConfig();
|
|
4660
|
+
this.logger.info({ strippedDefaults: redundantPaths.length, backup: `${this.configPath}.bak` }, "Slimmed redundant instance defaults in fleet.yaml");
|
|
4661
|
+
}
|
|
4662
|
+
catch (err) {
|
|
4663
|
+
// A migration must not turn a previously bootable fleet into an outage.
|
|
4664
|
+
this.logger.warn({ err }, "Could not slim fleet.yaml; continuing with the original config");
|
|
4665
|
+
}
|
|
4666
|
+
}
|
|
4667
|
+
writeFleetConfigBackup(source) {
|
|
4668
|
+
if (!this.configPath)
|
|
4669
|
+
return;
|
|
4670
|
+
const backupPath = `${this.configPath}.bak`;
|
|
4671
|
+
const tempPath = `${backupPath}.tmp-${process.pid}`;
|
|
4672
|
+
writeFileSync(tempPath, source, "utf-8");
|
|
4673
|
+
if (existsSync(this.configPath))
|
|
4674
|
+
chmodSync(tempPath, statSync(this.configPath).mode);
|
|
4675
|
+
renameSync(tempPath, backupPath);
|
|
4132
4676
|
}
|
|
4133
4677
|
patchFleetDocument(document, path, before, after) {
|
|
4134
4678
|
if (Object.is(before, after))
|
|
@@ -4180,7 +4724,12 @@ export class FleetManager {
|
|
|
4180
4724
|
currentNode.value = after;
|
|
4181
4725
|
}
|
|
4182
4726
|
else {
|
|
4183
|
-
|
|
4727
|
+
// Use a YAML collection node for newly-added objects. Passing the raw
|
|
4728
|
+
// object creates a scalar wrapper: it serializes, but nested hasIn /
|
|
4729
|
+
// deleteIn cannot traverse it (notably when slimming create_instance).
|
|
4730
|
+
document.setIn(path, after !== null && typeof after === "object"
|
|
4731
|
+
? document.createNode(after)
|
|
4732
|
+
: after);
|
|
4184
4733
|
}
|
|
4185
4734
|
}
|
|
4186
4735
|
}
|
|
@@ -4271,7 +4820,7 @@ export class FleetManager {
|
|
|
4271
4820
|
from: primaryModel, to: fallbackModel, five_hour_pct: fiveHourPct,
|
|
4272
4821
|
});
|
|
4273
4822
|
this.webhookEmitter?.emit("model_failover", name, { from: primaryModel, to: fallbackModel, five_hour_pct: fiveHourPct });
|
|
4274
|
-
this.notifyInstanceTopic(name,
|
|
4823
|
+
this.notifyInstanceTopic(name, t("failover.triggered", fiveHourPct, fallbackModel, primaryModel));
|
|
4275
4824
|
}
|
|
4276
4825
|
else if (fiveHourPct < FleetManager.FAILOVER_RECOVER_PCT && currentFailover) {
|
|
4277
4826
|
// Recover: switch back to primary
|
|
@@ -4282,7 +4831,7 @@ export class FleetManager {
|
|
|
4282
4831
|
restored: primaryModel, five_hour_pct: fiveHourPct,
|
|
4283
4832
|
});
|
|
4284
4833
|
this.webhookEmitter?.emit("model_recovered", name, { restored: primaryModel, five_hour_pct: fiveHourPct });
|
|
4285
|
-
this.notifyInstanceTopic(name,
|
|
4834
|
+
this.notifyInstanceTopic(name, t("failover.recovered", fiveHourPct, primaryModel));
|
|
4286
4835
|
}
|
|
4287
4836
|
}
|
|
4288
4837
|
toggleFleetCollab(instanceName) {
|
|
@@ -4336,9 +4885,9 @@ export class FleetManager {
|
|
|
4336
4885
|
if (output)
|
|
4337
4886
|
return output;
|
|
4338
4887
|
if (e.code === "ENOENT") {
|
|
4339
|
-
return "
|
|
4888
|
+
return t("doctor.cli_missing");
|
|
4340
4889
|
}
|
|
4341
|
-
return stripAnsi(e.message ?? "").trim() || "
|
|
4890
|
+
return stripAnsi(e.message ?? "").trim() || t("doctor.failed");
|
|
4342
4891
|
}
|
|
4343
4892
|
}
|
|
4344
4893
|
/** Drop event/activity rows older than the retention window. Best-effort. */
|
|
@@ -4440,7 +4989,7 @@ export class FleetManager {
|
|
|
4440
4989
|
this.fleetErrorNotices.delete(oldest);
|
|
4441
4990
|
}
|
|
4442
4991
|
const body = suppressed > 0
|
|
4443
|
-
? `${text}\n(
|
|
4992
|
+
? `${text}\n${t("fleet.error_suppressed", suppressed, Math.round(FleetManager.FLEET_ERROR_THROTTLE_MS / 60_000))}`
|
|
4444
4993
|
: text;
|
|
4445
4994
|
// Resolved from config, NOT findGeneralInstance(): that requires a live daemon,
|
|
4446
4995
|
// and a fleet-level fault is exactly when the General may be down. The topic
|
|
@@ -4490,12 +5039,13 @@ export class FleetManager {
|
|
|
4490
5039
|
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
|
|
4491
5040
|
}
|
|
4492
5041
|
}
|
|
4493
|
-
// ── Nonce-armed button prompts (hang /
|
|
5042
|
+
// ── Nonce-armed button prompts (hang / assist / exit / clear) ──
|
|
4494
5043
|
//
|
|
4495
5044
|
// One shared lifecycle for every "notification with decision buttons":
|
|
4496
|
-
// post with a 128-bit nonce, arm a
|
|
4497
|
-
// exact adapter+chat+thread+message that created it, require fleet admin
|
|
4498
|
-
//
|
|
5045
|
+
// post with a 128-bit nonce, arm a bounded expiry, bind the click to the
|
|
5046
|
+
// exact adapter+chat+thread+message that created it, require fleet admin for
|
|
5047
|
+
// actions that mutate runtime state (Tips are the harmless exception),
|
|
5048
|
+
// consume exactly once. The features differ only in what they post
|
|
4499
5049
|
// and what a consumed click does.
|
|
4500
5050
|
/**
|
|
4501
5051
|
* Post decision buttons whose callback ids are `<prefix><nonce>:<action>`.
|
|
@@ -4548,13 +5098,12 @@ export class FleetManager {
|
|
|
4548
5098
|
}
|
|
4549
5099
|
}
|
|
4550
5100
|
/**
|
|
4551
|
-
*
|
|
5101
|
+
* Validate a nonce-armed callback and claim it exactly once.
|
|
4552
5102
|
*
|
|
4553
5103
|
* Returns:
|
|
4554
5104
|
* - null — the callback is not for this prefix; try the next handler
|
|
4555
5105
|
* - "consumed" — for this prefix but stale/denied/malformed; stop dispatch
|
|
4556
|
-
* - the entry+action — the click is authorized and
|
|
4557
|
-
* from the map before any await, so double clicks cannot act twice)
|
|
5106
|
+
* - the entry+action — the click is authorized and claimed before any await
|
|
4558
5107
|
*
|
|
4559
5108
|
* A stale click (expired nonce, or a pre-upgrade button whose payload no
|
|
4560
5109
|
* longer parses) collapses the clicked message so the dead button stops
|
|
@@ -4576,19 +5125,38 @@ export class FleetManager {
|
|
|
4576
5125
|
return "consumed";
|
|
4577
5126
|
}
|
|
4578
5127
|
// Bind the capability to the exact message/world that created it. Telegram
|
|
4579
|
-
// keyboards are visible to everyone, so
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
5128
|
+
// keyboards are visible to everyone, so mutating actions require fleet admin;
|
|
5129
|
+
// a Tip acknowledgement only records that the shared content was read.
|
|
5130
|
+
const isAuthorized = data.userId
|
|
5131
|
+
? pending.allowAnyUser
|
|
5132
|
+
? true
|
|
5133
|
+
: pending.authChannelId
|
|
5134
|
+
? this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
|
|
5135
|
+
: this.isFleetAdmin(data.userId, callbackAdapterId)
|
|
5136
|
+
: false;
|
|
5137
|
+
const mismatchedFields = [];
|
|
5138
|
+
if (pending.adapterId !== callbackAdapterId)
|
|
5139
|
+
mismatchedFields.push("adapterId");
|
|
5140
|
+
if (data.chatId !== pending.chatId)
|
|
5141
|
+
mismatchedFields.push("chatId");
|
|
5142
|
+
if (pending.threadId != null && data.threadId !== pending.threadId)
|
|
5143
|
+
mismatchedFields.push("threadId");
|
|
5144
|
+
if (pending.messageId != null && data.messageId !== pending.messageId)
|
|
5145
|
+
mismatchedFields.push("messageId");
|
|
5146
|
+
if (!isAuthorized)
|
|
5147
|
+
mismatchedFields.push("authorization");
|
|
5148
|
+
if (mismatchedFields.length > 0) {
|
|
4586
5149
|
// Deliberately does NOT consume the nonce: the real admin can still click.
|
|
4587
|
-
this.logger.warn({
|
|
5150
|
+
this.logger.warn({
|
|
5151
|
+
instanceName: pending.instanceName,
|
|
5152
|
+
prefix,
|
|
5153
|
+
userId: data.userId,
|
|
5154
|
+
mismatchedFields: mismatchedFields.join(","),
|
|
5155
|
+
}, "Rejected unauthorized or mismatched button callback");
|
|
4588
5156
|
return "consumed";
|
|
4589
5157
|
}
|
|
4590
|
-
// Claim before any await: double clicks and duplicate callback delivery
|
|
4591
|
-
// never act twice.
|
|
5158
|
+
// Claim before any await: double clicks and duplicate callback delivery
|
|
5159
|
+
// can never act twice for state-changing actions.
|
|
4592
5160
|
this.pendingNonceButtons.delete(match[1]);
|
|
4593
5161
|
if (pending.timer)
|
|
4594
5162
|
clearTimeout(pending.timer);
|
|
@@ -4609,44 +5177,303 @@ export class FleetManager {
|
|
|
4609
5177
|
this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons");
|
|
4610
5178
|
}
|
|
4611
5179
|
}
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
* restarted — a restart offer for an instance the operator just restarted
|
|
4615
|
-
* (or an assist for one they stopped) must not stay clickable for the rest
|
|
4616
|
-
* of its 15 minutes.
|
|
4617
|
-
*/
|
|
4618
|
-
clearNoncePromptsForInstance(instanceName) {
|
|
4619
|
-
for (const [nonce, entry] of this.pendingNonceButtons) {
|
|
4620
|
-
if (entry.instanceName !== instanceName)
|
|
4621
|
-
continue;
|
|
4622
|
-
this.pendingNonceButtons.delete(nonce);
|
|
4623
|
-
if (entry.timer)
|
|
4624
|
-
clearTimeout(entry.timer);
|
|
4625
|
-
if (entry.messageId && entry.adapter.editMessageRemoveButtons) {
|
|
4626
|
-
entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId)
|
|
4627
|
-
.catch(err => this.logger.debug({ err, instanceName, prefix: entry.prefix }, "Failed to collapse prompt during instance stop"));
|
|
4628
|
-
}
|
|
4629
|
-
}
|
|
5180
|
+
tipsEnabled() {
|
|
5181
|
+
return this.fleetConfig?.defaults.tips !== false;
|
|
4630
5182
|
}
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
5183
|
+
readTipState() {
|
|
5184
|
+
if (!this.scheduler)
|
|
5185
|
+
return null;
|
|
5186
|
+
try {
|
|
5187
|
+
return {
|
|
5188
|
+
dismissed: this.scheduler.db.listDismissedTipIds(),
|
|
5189
|
+
advancedUnlocked: this.scheduler.db.isAdvancedTipsUnlocked(),
|
|
5190
|
+
};
|
|
5191
|
+
}
|
|
5192
|
+
catch (err) {
|
|
5193
|
+
// Tips are additive. A damaged/locked scheduler DB must never block fleet
|
|
5194
|
+
// startup, update completion, or a General command.
|
|
5195
|
+
this.logger.warn({ err }, "Failed to read tip state");
|
|
5196
|
+
return null;
|
|
5197
|
+
}
|
|
5198
|
+
}
|
|
5199
|
+
pickAvailableTip() {
|
|
5200
|
+
const state = this.readTipState();
|
|
5201
|
+
return state
|
|
5202
|
+
? selectTip(state.dismissed, Math.random, state.advancedUnlocked, this.getActiveBackendIds())
|
|
5203
|
+
: null;
|
|
5204
|
+
}
|
|
5205
|
+
tipText(tip) {
|
|
5206
|
+
return getLocale() === "zh-TW" ? tip.text_zh : tip.text_en;
|
|
5207
|
+
}
|
|
5208
|
+
formatTip(tip) {
|
|
5209
|
+
return `💡 ${t("tips.label")}: ${this.tipText(tip)}`;
|
|
5210
|
+
}
|
|
5211
|
+
/** Persistently enable advanced tips through the admin-only slash command. */
|
|
5212
|
+
unlockAdvancedTips(userId) {
|
|
5213
|
+
if (!this.scheduler)
|
|
5214
|
+
return false;
|
|
5215
|
+
try {
|
|
5216
|
+
this.scheduler.db.unlockAdvancedTips(userId);
|
|
5217
|
+
this.eventLog?.insert(this.findGeneralInstance() ?? "general", "tips_advanced_unlocked", {
|
|
5218
|
+
userId,
|
|
5219
|
+
source: "command",
|
|
5220
|
+
});
|
|
5221
|
+
return true;
|
|
5222
|
+
}
|
|
5223
|
+
catch (err) {
|
|
5224
|
+
this.logger.warn({ err, userId }, "Failed to unlock advanced tips from command");
|
|
5225
|
+
return false;
|
|
5226
|
+
}
|
|
5227
|
+
}
|
|
5228
|
+
/** Post one fresh nonce-armed tip in a known General channel. */
|
|
5229
|
+
async promptTip(generalName, adapter, chatId, threadId) {
|
|
5230
|
+
const state = this.readTipState();
|
|
5231
|
+
if (!state)
|
|
5232
|
+
return "unavailable";
|
|
5233
|
+
if (!state.advancedUnlocked
|
|
5234
|
+
&& visibleTipLevels(true).has("advanced")
|
|
5235
|
+
&& canUnlockAdvancedTips(state.dismissed)) {
|
|
5236
|
+
return await this.promptAdvancedTipUnlock(generalName, adapter, chatId, threadId)
|
|
5237
|
+
? "posted"
|
|
5238
|
+
: "unavailable";
|
|
5239
|
+
}
|
|
5240
|
+
const tip = selectTip(state.dismissed, Math.random, state.advancedUnlocked, this.getActiveBackendIds());
|
|
5241
|
+
if (!tip)
|
|
5242
|
+
return this.scheduler ? "empty" : "unavailable";
|
|
5243
|
+
const nonce = await this.postNonceButtonPrompt({
|
|
5244
|
+
prefix: TIP_DISMISS_CALLBACK_PREFIX,
|
|
5245
|
+
alertType: "tip",
|
|
5246
|
+
instanceName: generalName,
|
|
5247
|
+
adapter,
|
|
5248
|
+
adapterId: adapter.id,
|
|
5249
|
+
chatId,
|
|
5250
|
+
threadId,
|
|
5251
|
+
message: this.formatTip(tip),
|
|
5252
|
+
choices: [
|
|
5253
|
+
{ action: "dismiss", label: t("tips.dismiss") },
|
|
5254
|
+
{ action: "confused", label: t("tips.confused") },
|
|
5255
|
+
],
|
|
5256
|
+
// Expiry removes only the stale button; the useful tip remains readable.
|
|
5257
|
+
expiredText: this.formatTip(tip),
|
|
5258
|
+
extra: { allowAnyUser: true, tipId: tip.id },
|
|
5259
|
+
timeoutMs: TIP_BUTTON_TIMEOUT_MS,
|
|
5260
|
+
});
|
|
5261
|
+
return nonce ? "posted" : "unavailable";
|
|
5262
|
+
}
|
|
5263
|
+
async promptAdvancedTipUnlock(generalName, adapter, chatId, threadId) {
|
|
5264
|
+
const nonce = await this.postNonceButtonPrompt({
|
|
5265
|
+
prefix: TIP_UNLOCK_CALLBACK_PREFIX,
|
|
5266
|
+
alertType: "tip",
|
|
5267
|
+
instanceName: generalName,
|
|
5268
|
+
adapter,
|
|
5269
|
+
adapterId: adapter.id,
|
|
5270
|
+
chatId,
|
|
5271
|
+
threadId,
|
|
5272
|
+
message: t("tips.advanced.unlock_prompt"),
|
|
5273
|
+
choices: [{ action: "unlock", label: t("tips.advanced.unlock") }],
|
|
5274
|
+
expiredText: t("tips.advanced.expired"),
|
|
5275
|
+
extra: { allowAnyUser: true },
|
|
5276
|
+
timeoutMs: TIP_BUTTON_TIMEOUT_MS,
|
|
5277
|
+
});
|
|
5278
|
+
return nonce !== null;
|
|
5279
|
+
}
|
|
5280
|
+
async sendTipToGeneral() {
|
|
5281
|
+
if (!this.tipsEnabled())
|
|
5282
|
+
return;
|
|
5283
|
+
const generalName = this.findGeneralInstance();
|
|
5284
|
+
if (!generalName)
|
|
5285
|
+
return;
|
|
5286
|
+
const adapter = this.getAdapterForInstance(generalName);
|
|
5287
|
+
const chatId = this.getGroupIdForInstance(generalName);
|
|
5288
|
+
const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
|
|
5289
|
+
if (!adapter || !chatId)
|
|
5290
|
+
return;
|
|
5291
|
+
const result = await this.promptTip(generalName, adapter, chatId, topicId != null ? String(topicId) : undefined);
|
|
5292
|
+
if (result === "unavailable") {
|
|
5293
|
+
this.logger.warn({ generalName }, "Daily tip could not be posted");
|
|
5294
|
+
}
|
|
5295
|
+
}
|
|
5296
|
+
async handleTipDismiss(data, callbackAdapterId, receivingAdapter) {
|
|
5297
|
+
const claimed = this.consumeNonceCallback(TIP_DISMISS_CALLBACK_PREFIX, /^tip-dismiss:([0-9a-f]+):(dismiss|confused)$/, data, callbackAdapterId, receivingAdapter);
|
|
5298
|
+
if (claimed === null)
|
|
5299
|
+
return false;
|
|
5300
|
+
if (claimed === "consumed")
|
|
5301
|
+
return true;
|
|
5302
|
+
const { entry: pending } = claimed;
|
|
5303
|
+
if (claimed.action === "confused") {
|
|
5304
|
+
if (!pending.tipId || !data.userId || !this.scheduler) {
|
|
5305
|
+
this.logger.error({ tipId: pending.tipId, userId: data.userId }, "Tip feedback has incomplete persistence context");
|
|
5306
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, pending.expiredText);
|
|
5307
|
+
await pending.adapter.sendText(pending.chatId, t("tips.unavailable"), { threadId: pending.threadId });
|
|
5308
|
+
return true;
|
|
5309
|
+
}
|
|
5310
|
+
try {
|
|
5311
|
+
this.scheduler.db.recordTipFeedback(data.userId, pending.tipId, "confused");
|
|
5312
|
+
}
|
|
5313
|
+
catch (err) {
|
|
5314
|
+
this.logger.warn({ err, tipId: pending.tipId, userId: data.userId }, "Failed to persist tip feedback");
|
|
5315
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, pending.expiredText);
|
|
5316
|
+
await pending.adapter.sendText(pending.chatId, t("tips.unavailable"), { threadId: pending.threadId }).catch(() => { });
|
|
5317
|
+
return true;
|
|
5318
|
+
}
|
|
5319
|
+
this.logger.info({
|
|
5320
|
+
tipId: pending.tipId,
|
|
5321
|
+
userId: data.userId,
|
|
5322
|
+
feedbackType: "confused",
|
|
5323
|
+
}, "Tip feedback recorded");
|
|
5324
|
+
this.eventLog?.insert(pending.instanceName, "tip_feedback", {
|
|
5325
|
+
tipId: pending.tipId,
|
|
5326
|
+
userId: data.userId,
|
|
5327
|
+
feedbackType: "confused",
|
|
5328
|
+
});
|
|
5329
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId,
|
|
5330
|
+
// Confusion is feedback, not a dismissal. Keep the Tip readable while
|
|
5331
|
+
// retiring this one-shot prompt; it remains eligible for future draws.
|
|
5332
|
+
pending.expiredText);
|
|
5333
|
+
await pending.adapter.sendText(pending.chatId, t("tips.feedback_recorded"), { threadId: pending.threadId }).catch(err => this.logger.warn({ err, tipId: pending.tipId }, "Failed to acknowledge tip feedback"));
|
|
5334
|
+
return true;
|
|
5335
|
+
}
|
|
5336
|
+
if (!pending.tipId || !data.userId || !this.scheduler) {
|
|
5337
|
+
this.logger.error({ tipId: pending.tipId, userId: data.userId }, "Tip dismissal has incomplete persistence context");
|
|
5338
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
|
|
5339
|
+
return true;
|
|
5340
|
+
}
|
|
5341
|
+
try {
|
|
5342
|
+
this.scheduler.db.dismissTip(data.userId, pending.tipId);
|
|
5343
|
+
}
|
|
5344
|
+
catch (err) {
|
|
5345
|
+
this.logger.warn({ err, tipId: pending.tipId, userId: data.userId }, "Failed to persist tip dismissal");
|
|
5346
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
|
|
5347
|
+
return true;
|
|
5348
|
+
}
|
|
5349
|
+
this.eventLog?.insert(pending.instanceName, "tip_dismissed", {
|
|
5350
|
+
tipId: pending.tipId,
|
|
5351
|
+
userId: data.userId,
|
|
5352
|
+
});
|
|
5353
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId,
|
|
5354
|
+
// Dismissal changes future selection only. Keep the useful tip text in
|
|
5355
|
+
// chat history and remove just the now-consumed button. Append a short
|
|
5356
|
+
// confirmation so the user sees feedback (unlike the pre-#605 version
|
|
5357
|
+
// that replaced the entire message with just the confirmation line).
|
|
5358
|
+
`${pending.expiredText}\n\n${t("tips.dismissed")}`);
|
|
5359
|
+
const state = this.readTipState();
|
|
5360
|
+
if (state && !state.advancedUnlocked && canUnlockAdvancedTips(state.dismissed)) {
|
|
5361
|
+
await this.promptAdvancedTipUnlock(pending.instanceName, pending.adapter, pending.chatId, pending.threadId);
|
|
5362
|
+
}
|
|
5363
|
+
return true;
|
|
5364
|
+
}
|
|
5365
|
+
async handleTipUnlock(data, callbackAdapterId, receivingAdapter) {
|
|
5366
|
+
const claimed = this.consumeNonceCallback(TIP_UNLOCK_CALLBACK_PREFIX, /^tip-unlock:([0-9a-f]+):(unlock)$/, data, callbackAdapterId, receivingAdapter);
|
|
5367
|
+
if (claimed === null)
|
|
5368
|
+
return false;
|
|
5369
|
+
if (claimed === "consumed")
|
|
5370
|
+
return true;
|
|
5371
|
+
const { entry: pending } = claimed;
|
|
5372
|
+
if (!data.userId || !this.scheduler) {
|
|
5373
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
|
|
5374
|
+
return true;
|
|
5375
|
+
}
|
|
5376
|
+
try {
|
|
5377
|
+
this.scheduler.db.unlockAdvancedTips(data.userId);
|
|
5378
|
+
}
|
|
5379
|
+
catch (err) {
|
|
5380
|
+
this.logger.warn({ err, userId: data.userId }, "Failed to unlock advanced tips");
|
|
5381
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
|
|
5382
|
+
return true;
|
|
5383
|
+
}
|
|
5384
|
+
this.eventLog?.insert(pending.instanceName, "tips_advanced_unlocked", { userId: data.userId });
|
|
5385
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.advanced.unlocked"));
|
|
5386
|
+
return true;
|
|
5387
|
+
}
|
|
5388
|
+
/** Post the destructive `/clear` confirmation in the invoking channel. */
|
|
5389
|
+
async promptClearConfirmation(instanceName, channelId, adapter, chatId, threadId) {
|
|
5390
|
+
if (!this.topicCommands.supportsClear(instanceName))
|
|
5391
|
+
return t("clear.unsupported");
|
|
5392
|
+
const adapterId = adapter.id;
|
|
5393
|
+
if (!adapterId)
|
|
5394
|
+
return t("clear.prompt_unavailable");
|
|
5395
|
+
const nonce = await this.postNonceButtonPrompt({
|
|
5396
|
+
prefix: CLEAR_CONFIRM_CALLBACK_PREFIX,
|
|
5397
|
+
alertType: "clear_confirm",
|
|
5398
|
+
instanceName,
|
|
5399
|
+
adapter,
|
|
5400
|
+
adapterId,
|
|
5401
|
+
chatId,
|
|
5402
|
+
threadId,
|
|
5403
|
+
message: t("clear.confirm_message", instanceName),
|
|
5404
|
+
choices: [
|
|
5405
|
+
{ action: "confirm", label: t("clear.confirm") },
|
|
5406
|
+
{ action: "cancel", label: t("clear.cancel") },
|
|
5407
|
+
],
|
|
5408
|
+
expiredText: t("clear.expired", instanceName),
|
|
5409
|
+
extra: { authChannelId: channelId },
|
|
5410
|
+
timeoutMs: CLEAR_CONFIRM_TIMEOUT_MS,
|
|
5411
|
+
});
|
|
5412
|
+
return nonce ? null : t("clear.prompt_unavailable");
|
|
5413
|
+
}
|
|
5414
|
+
/** Consume `/clear` Confirm/Cancel exactly once; only Confirm reaches IPC. */
|
|
5415
|
+
async handleClearConfirmation(data, callbackAdapterId, receivingAdapter) {
|
|
5416
|
+
const claimed = this.consumeNonceCallback(CLEAR_CONFIRM_CALLBACK_PREFIX, /^clear-confirm:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
|
|
5417
|
+
if (claimed === null)
|
|
5418
|
+
return false;
|
|
5419
|
+
if (claimed === "consumed")
|
|
5420
|
+
return true;
|
|
5421
|
+
const { entry: pending, action } = claimed;
|
|
5422
|
+
this.eventLog?.insert(pending.instanceName, "clear_action", { action, userId: data.userId });
|
|
5423
|
+
if (action === "cancel") {
|
|
5424
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.cancelled", pending.instanceName));
|
|
5425
|
+
return true;
|
|
5426
|
+
}
|
|
5427
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.clearing", pending.instanceName));
|
|
5428
|
+
try {
|
|
5429
|
+
const result = await this.topicCommands.sendClear(pending.instanceName);
|
|
5430
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, result, pending.threadId);
|
|
5431
|
+
}
|
|
5432
|
+
catch (err) {
|
|
5433
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5434
|
+
this.logger.error({ err, instanceName: pending.instanceName }, "Clear command failed");
|
|
5435
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("clear.failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show clear command error"));
|
|
5436
|
+
}
|
|
5437
|
+
return true;
|
|
5438
|
+
}
|
|
5439
|
+
/**
|
|
5440
|
+
* Drop every pending prompt that refers to an instance being stopped or
|
|
5441
|
+
* restarted — a restart offer for an instance the operator just restarted
|
|
5442
|
+
* (or an assist for one they stopped) must not stay clickable for the rest
|
|
5443
|
+
* of its 15 minutes.
|
|
5444
|
+
*/
|
|
5445
|
+
clearNoncePromptsForInstance(instanceName) {
|
|
5446
|
+
for (const [nonce, entry] of this.pendingNonceButtons) {
|
|
5447
|
+
if (entry.instanceName !== instanceName)
|
|
5448
|
+
continue;
|
|
5449
|
+
this.pendingNonceButtons.delete(nonce);
|
|
5450
|
+
if (entry.timer)
|
|
5451
|
+
clearTimeout(entry.timer);
|
|
5452
|
+
if (entry.messageId && entry.adapter.editMessageRemoveButtons) {
|
|
5453
|
+
entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId)
|
|
5454
|
+
.catch(err => this.logger.debug({ err, instanceName, prefix: entry.prefix }, "Failed to collapse prompt during instance stop"));
|
|
5455
|
+
}
|
|
5456
|
+
}
|
|
5457
|
+
}
|
|
5458
|
+
/** A clean exit is intentional from the CLI's perspective, but often not from
|
|
5459
|
+
* the operator's. Keep the instance notice passive and put the action in the
|
|
5460
|
+
* same-world General topic where an administrator can make the choice. */
|
|
5461
|
+
async notifyNormalExit(instanceName) {
|
|
5462
|
+
this.notifyInstanceTopic(instanceName, t("exit.instance_notice", instanceName));
|
|
5463
|
+
const worldId = this.getInstanceAdapterId(instanceName);
|
|
5464
|
+
const generalName = this.findGeneralInstance(worldId);
|
|
5465
|
+
if (!generalName) {
|
|
5466
|
+
this.logger.warn({ instanceName, worldId }, "Normal CLI exit has no General notification target");
|
|
5467
|
+
return;
|
|
5468
|
+
}
|
|
5469
|
+
const adapterId = this.getInstanceAdapterId(generalName);
|
|
5470
|
+
const adapter = this.getAdapterForInstance(generalName);
|
|
5471
|
+
const chatId = this.getGroupIdForInstance(generalName);
|
|
5472
|
+
const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
|
|
5473
|
+
const threadId = topicId != null ? String(topicId) : undefined;
|
|
5474
|
+
if (!adapter || !adapterId || !chatId) {
|
|
5475
|
+
this.logger.warn({ instanceName, generalName, adapterId, chatId }, "Cannot address normal-exit restart controls");
|
|
5476
|
+
return;
|
|
4650
5477
|
}
|
|
4651
5478
|
await this.postNonceButtonPrompt({
|
|
4652
5479
|
prefix: EXIT_RESTART_CALLBACK_PREFIX,
|
|
@@ -4835,7 +5662,7 @@ export class FleetManager {
|
|
|
4835
5662
|
const force = target.kind === "classic" && !!data.options?.force;
|
|
4836
5663
|
const cmd = saveCommandForBackend(backend, filename, force);
|
|
4837
5664
|
if (!cmd) {
|
|
4838
|
-
await data.respond(
|
|
5665
|
+
await data.respond(t("save.unsupported"));
|
|
4839
5666
|
return;
|
|
4840
5667
|
}
|
|
4841
5668
|
if (target.kind === "classic") {
|
|
@@ -4888,7 +5715,7 @@ export class FleetManager {
|
|
|
4888
5715
|
}
|
|
4889
5716
|
return false;
|
|
4890
5717
|
}
|
|
4891
|
-
async sendCancelButton(instanceName, correlationId) {
|
|
5718
|
+
async sendCancelButton(instanceName, correlationId, preserveProgress = false) {
|
|
4892
5719
|
// Post first, retire after (see the tail of this method). Retiring up front
|
|
4893
5720
|
// meant that from the delete until the new message came back — a chat API
|
|
4894
5721
|
// round trip, and every reply goes through here — the instance had NO live
|
|
@@ -4949,6 +5776,10 @@ export class FleetManager {
|
|
|
4949
5776
|
// not re-edit identical text — which put a "(edited)" mark on Discord
|
|
4950
5777
|
// with nothing visibly changed.
|
|
4951
5778
|
lastProgressText: "👀 處理中…",
|
|
5779
|
+
// A reply can re-post the same in-flight bubble below the reply. Carry
|
|
5780
|
+
// its current list into that replacement; fresh inbound work must start
|
|
5781
|
+
// empty even if the daemon's reset broadcast is still in flight.
|
|
5782
|
+
toolProgress: preserveProgress ? this.instanceProgress.get(instanceName) : undefined,
|
|
4952
5783
|
};
|
|
4953
5784
|
this.startProgressTicker(entry);
|
|
4954
5785
|
// Idle-check backstop: every 5min, if the instance is idle, retire the
|
|
@@ -5002,16 +5833,28 @@ export class FleetManager {
|
|
|
5002
5833
|
static progressText(elapsedMs, activity, minElapsedMs = PROGRESS_MIN_ELAPSED_MS) {
|
|
5003
5834
|
if (elapsedMs < minElapsedMs)
|
|
5004
5835
|
return "👀 處理中…";
|
|
5836
|
+
const elapsed = FleetManager.formatProgressElapsed(elapsedMs);
|
|
5837
|
+
const detail = FleetManager.sanitizeActivity(activity);
|
|
5838
|
+
return detail
|
|
5839
|
+
? `⏳ 處理中… (已進行 ${elapsed} · ${detail})`
|
|
5840
|
+
: `⏳ 處理中… (已進行 ${elapsed})`;
|
|
5841
|
+
}
|
|
5842
|
+
/** Render elapsed time consistently in live and retained progress bubbles. */
|
|
5843
|
+
static formatProgressElapsed(elapsedMs) {
|
|
5005
5844
|
const totalSeconds = Math.floor(elapsedMs / 1000);
|
|
5006
5845
|
const minutes = Math.floor(totalSeconds / 60);
|
|
5007
5846
|
const seconds = totalSeconds % 60;
|
|
5008
|
-
|
|
5847
|
+
return minutes >= 60
|
|
5009
5848
|
? `${Math.floor(minutes / 60)}h ${minutes % 60}m`
|
|
5010
5849
|
: `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5850
|
+
}
|
|
5851
|
+
/** Final read-only form of a bubble that contains semantic tool history.
|
|
5852
|
+
* Neutral wording is intentional: the same retirement primitive is used for
|
|
5853
|
+
* normal completion, a mid-turn bubble replacement, and user cancellation,
|
|
5854
|
+
* so claiming every retained checkpoint is "completed" would be false. */
|
|
5855
|
+
composeRetiredBubbleText(entry) {
|
|
5856
|
+
const elapsed = FleetManager.formatProgressElapsed(Date.now() - (entry.startedAt ?? Date.now()));
|
|
5857
|
+
return `🧾 工具歷程 (記錄至 ${elapsed})\n${entry.toolProgress}`;
|
|
5015
5858
|
}
|
|
5016
5859
|
/**
|
|
5017
5860
|
* Make a tool summary safe to paste into a channel message.
|
|
@@ -5044,6 +5887,88 @@ export class FleetManager {
|
|
|
5044
5887
|
else
|
|
5045
5888
|
this.instanceActivity.delete(name);
|
|
5046
5889
|
}
|
|
5890
|
+
/**
|
|
5891
|
+
* Cache the turn's tool-progress list and push it into the instance's live
|
|
5892
|
+
* bubble, coalesced so a burst of tool events cannot flood the Bot API
|
|
5893
|
+
* (Telegram's flood limit is per-chat across ALL forum topics, so edits are
|
|
5894
|
+
* rate-limited per bubble AND ride behind the daemon-side 3s coalescer).
|
|
5895
|
+
*/
|
|
5896
|
+
cacheInstanceProgress(name, progress) {
|
|
5897
|
+
if (progress)
|
|
5898
|
+
this.instanceProgress.set(name, progress);
|
|
5899
|
+
else
|
|
5900
|
+
this.instanceProgress.delete(name);
|
|
5901
|
+
for (const entry of this.cancelButtons.values()) {
|
|
5902
|
+
if (entry.instanceName !== name)
|
|
5903
|
+
continue;
|
|
5904
|
+
// Empty means the daemon completed/reset the turn. Clear the live cache
|
|
5905
|
+
// for the next turn, but retain this bubble's last non-empty list so its
|
|
5906
|
+
// retirement can preserve the history instead of deleting the message.
|
|
5907
|
+
if (progress)
|
|
5908
|
+
entry.toolProgress = progress;
|
|
5909
|
+
this.scheduleProgressEdit(entry);
|
|
5910
|
+
}
|
|
5911
|
+
}
|
|
5912
|
+
/** At most one progress-driven edit per bubble per TOOL_PROGRESS_EDIT_MIN_MS. */
|
|
5913
|
+
scheduleProgressEdit(entry) {
|
|
5914
|
+
if (entry.retiring)
|
|
5915
|
+
return;
|
|
5916
|
+
const since = Date.now() - (entry.lastProgressEditAt ?? 0);
|
|
5917
|
+
if (since >= TOOL_PROGRESS_EDIT_MIN_MS) {
|
|
5918
|
+
this.refreshBubble(entry);
|
|
5919
|
+
return;
|
|
5920
|
+
}
|
|
5921
|
+
if (entry.progressEditTimer)
|
|
5922
|
+
return; // trailing edit already scheduled
|
|
5923
|
+
entry.progressEditTimer = setTimeout(() => {
|
|
5924
|
+
entry.progressEditTimer = undefined;
|
|
5925
|
+
this.refreshBubble(entry);
|
|
5926
|
+
}, TOOL_PROGRESS_EDIT_MIN_MS - since);
|
|
5927
|
+
entry.progressEditTimer.unref?.();
|
|
5928
|
+
}
|
|
5929
|
+
/**
|
|
5930
|
+
* The ONE composer for the bubble text. Both writers — the elapsed-time
|
|
5931
|
+
* ticker and the tool-progress push — go through here; two independent
|
|
5932
|
+
* renderers editing the same message is how the progress list used to get
|
|
5933
|
+
* wiped by the next elapsed tick (#528 trap 2).
|
|
5934
|
+
*/
|
|
5935
|
+
composeBubbleText(entry) {
|
|
5936
|
+
return FleetManager.bubbleText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs(), entry.toolProgress);
|
|
5937
|
+
}
|
|
5938
|
+
/** Pure composition of header + tool list, exposed for tests. */
|
|
5939
|
+
static bubbleText(elapsedMs, activity, minElapsedMs, progress) {
|
|
5940
|
+
const header = FleetManager.progressText(elapsedMs,
|
|
5941
|
+
// The single-line activity detail is redundant once a tool list exists.
|
|
5942
|
+
progress ? undefined : activity, minElapsedMs);
|
|
5943
|
+
return progress ? `${header}\n${progress}` : header;
|
|
5944
|
+
}
|
|
5945
|
+
/** Recompose and edit the bubble in place; skips when nothing changed. */
|
|
5946
|
+
refreshBubble(entry) {
|
|
5947
|
+
if (entry.retiring || !this.cancelButtons.has(entry.messageId)) {
|
|
5948
|
+
clearInterval(entry.progressTimer);
|
|
5949
|
+
return;
|
|
5950
|
+
}
|
|
5951
|
+
const text = this.composeBubbleText(entry);
|
|
5952
|
+
if (text === entry.lastProgressText)
|
|
5953
|
+
return; // nothing changed — skip the API call
|
|
5954
|
+
const adapter = this.getAdapterForInstance(entry.instanceName) ?? this.adapter;
|
|
5955
|
+
if (!adapter?.editAlert)
|
|
5956
|
+
return;
|
|
5957
|
+
entry.lastProgressText = text;
|
|
5958
|
+
entry.lastProgressEditAt = Date.now();
|
|
5959
|
+
adapter.editAlert(entry.chatId, entry.messageId, {
|
|
5960
|
+
type: "cancel",
|
|
5961
|
+
instanceName: entry.instanceName,
|
|
5962
|
+
message: text,
|
|
5963
|
+
choices: [{ id: `cancel:${entry.instanceName}`, label: t("cancel.button") }],
|
|
5964
|
+
}, entry.threadId ? { threadId: entry.threadId } : undefined)
|
|
5965
|
+
.catch(err => {
|
|
5966
|
+
// A failed progress edit must never escalate: the button still works and
|
|
5967
|
+
// the next tick retries. Common causes are a deleted message or a
|
|
5968
|
+
// rate limit.
|
|
5969
|
+
this.logger.debug({ err, instanceName: entry.instanceName }, "Progress edit failed");
|
|
5970
|
+
});
|
|
5971
|
+
}
|
|
5047
5972
|
/**
|
|
5048
5973
|
* Refresh the button's text in place while the instance keeps working.
|
|
5049
5974
|
*
|
|
@@ -5061,31 +5986,7 @@ export class FleetManager {
|
|
|
5061
5986
|
return PROGRESS_MIN_ELAPSED_MS;
|
|
5062
5987
|
}
|
|
5063
5988
|
startProgressTicker(entry) {
|
|
5064
|
-
const tick = () =>
|
|
5065
|
-
if (!this.cancelButtons.has(entry.messageId)) {
|
|
5066
|
-
clearInterval(entry.progressTimer);
|
|
5067
|
-
return;
|
|
5068
|
-
}
|
|
5069
|
-
const text = FleetManager.progressText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs());
|
|
5070
|
-
if (text === entry.lastProgressText)
|
|
5071
|
-
return; // nothing changed — skip the API call
|
|
5072
|
-
const adapter = this.getAdapterForInstance(entry.instanceName) ?? this.adapter;
|
|
5073
|
-
if (!adapter?.editAlert)
|
|
5074
|
-
return;
|
|
5075
|
-
entry.lastProgressText = text;
|
|
5076
|
-
adapter.editAlert(entry.chatId, entry.messageId, {
|
|
5077
|
-
type: "cancel",
|
|
5078
|
-
instanceName: entry.instanceName,
|
|
5079
|
-
message: text,
|
|
5080
|
-
choices: [{ id: `cancel:${entry.instanceName}`, label: t("cancel.button") }],
|
|
5081
|
-
}, entry.threadId ? { threadId: entry.threadId } : undefined)
|
|
5082
|
-
.catch(err => {
|
|
5083
|
-
// A failed progress edit must never escalate: the button still works and
|
|
5084
|
-
// the next tick retries. Common causes are a deleted message or a
|
|
5085
|
-
// rate limit.
|
|
5086
|
-
this.logger.debug({ err, instanceName: entry.instanceName }, "Progress edit failed");
|
|
5087
|
-
});
|
|
5088
|
-
};
|
|
5989
|
+
const tick = () => this.refreshBubble(entry);
|
|
5089
5990
|
entry.progressTimer = setInterval(tick, PROGRESS_UPDATE_INTERVAL_MS);
|
|
5090
5991
|
entry.progressTimer.unref?.();
|
|
5091
5992
|
// One extra tick right when the threshold passes, so a 30s threshold shows
|
|
@@ -5099,8 +6000,11 @@ export class FleetManager {
|
|
|
5099
6000
|
}
|
|
5100
6001
|
/**
|
|
5101
6002
|
* After a reply: give the instance REPLY_RETIRE_GRACE_MS to resume working; if
|
|
5102
|
-
* it has not, retire its button.
|
|
5103
|
-
*
|
|
6003
|
+
* it has not, retire its button. The daemon is asked for a fresh pane capture
|
|
6004
|
+
* before deciding. Reading only the transition cache stranded the first
|
|
6005
|
+
* post-restart bubble when its startup "working" report never got a matching
|
|
6006
|
+
* idle edge. Re-arming replaces the previous timer, so a burst of replies ends
|
|
6007
|
+
* with exactly one pending check.
|
|
5104
6008
|
*/
|
|
5105
6009
|
armReplyGrace(instanceName) {
|
|
5106
6010
|
for (const entry of this.cancelButtons.values()) {
|
|
@@ -5112,14 +6016,26 @@ export class FleetManager {
|
|
|
5112
6016
|
entry.replyGraceTimer = undefined;
|
|
5113
6017
|
if (!this.cancelButtons.has(entry.messageId))
|
|
5114
6018
|
return;
|
|
5115
|
-
|
|
5116
|
-
return; // resumed — a long run keeps its button
|
|
5117
|
-
this.logger.info({ instanceName, messageId: entry.messageId }, "Cancel button retired — no work resumed after reply");
|
|
5118
|
-
this.retireButton(entry);
|
|
6019
|
+
void this.finishReplyGrace(instanceName, entry);
|
|
5119
6020
|
}, REPLY_RETIRE_GRACE_MS);
|
|
5120
6021
|
entry.replyGraceTimer.unref?.();
|
|
5121
6022
|
}
|
|
5122
6023
|
}
|
|
6024
|
+
async finishReplyGrace(instanceName, entry) {
|
|
6025
|
+
const refreshed = await this.refreshInstanceExecutionState(instanceName, REPLY_STATE_REFRESH_TIMEOUT_MS);
|
|
6026
|
+
if (!this.cancelButtons.has(entry.messageId) || entry.retiring)
|
|
6027
|
+
return;
|
|
6028
|
+
if (!refreshed) {
|
|
6029
|
+
// Fail safe: without an authoritative answer, retain a potentially live
|
|
6030
|
+
// Cancel button. The 5-minute/30-minute/24-hour safety nets still apply.
|
|
6031
|
+
this.logger.debug({ instanceName, messageId: entry.messageId }, "Cancel reply-grace state refresh timed out");
|
|
6032
|
+
return;
|
|
6033
|
+
}
|
|
6034
|
+
if (!this.getInstanceIdle(instanceName))
|
|
6035
|
+
return; // genuine long run keeps its button
|
|
6036
|
+
this.logger.info({ instanceName, messageId: entry.messageId }, "Cancel button retired — no work resumed after reply");
|
|
6037
|
+
this.retireButton(entry);
|
|
6038
|
+
}
|
|
5123
6039
|
/** Retire (delete) every cancel button belonging to an instance. */
|
|
5124
6040
|
retireInstanceButtons(instanceName) {
|
|
5125
6041
|
// Snapshot first — retireButton may delete entries from the map on success.
|
|
@@ -5141,7 +6057,7 @@ export class FleetManager {
|
|
|
5141
6057
|
this.deleteButtonMessage(entry)
|
|
5142
6058
|
.then(() => {
|
|
5143
6059
|
this.discardButton(entry);
|
|
5144
|
-
this.logger.info({ instanceName: entry.instanceName, messageId: entry.messageId }, "Cancel button
|
|
6060
|
+
this.logger.info({ instanceName: entry.instanceName, messageId: entry.messageId, historyPreserved: Boolean(entry.toolProgress) }, "Cancel button retired");
|
|
5145
6061
|
})
|
|
5146
6062
|
.catch((err) => this.scheduleButtonRetry(entry, err));
|
|
5147
6063
|
}
|
|
@@ -5153,6 +6069,8 @@ export class FleetManager {
|
|
|
5153
6069
|
clearInterval(entry.idleCheckTimer);
|
|
5154
6070
|
if (entry.progressTimer)
|
|
5155
6071
|
clearInterval(entry.progressTimer);
|
|
6072
|
+
if (entry.progressEditTimer)
|
|
6073
|
+
clearTimeout(entry.progressEditTimer);
|
|
5156
6074
|
if (entry.replyGraceTimer)
|
|
5157
6075
|
clearTimeout(entry.replyGraceTimer);
|
|
5158
6076
|
this.cancelButtons.delete(entry.messageId);
|
|
@@ -5230,12 +6148,21 @@ export class FleetManager {
|
|
|
5230
6148
|
// Continue the same retire cycle (bypass the retiring-guard in retireButton).
|
|
5231
6149
|
entry.retryTimer = setTimeout(() => this.attemptButtonDelete(entry), CANCEL_BTN_RETRY_INTERVAL_MS);
|
|
5232
6150
|
}
|
|
5233
|
-
/**
|
|
6151
|
+
/** Retire one cancel bubble via its own adapter. A bubble that accumulated
|
|
6152
|
+
* tool progress is kept as a read-only history message; a plain bubble keeps
|
|
6153
|
+
* the legacy delete/edit-to-checkmark behavior. Resolves on success and
|
|
5234
6154
|
* rejects on failure so the caller can retry. */
|
|
5235
6155
|
deleteButtonMessage(e) {
|
|
5236
6156
|
const adapter = (e.adapterId ? this.worlds.get(e.adapterId)?.adapter : undefined) ?? this.adapter;
|
|
5237
6157
|
if (!adapter)
|
|
5238
6158
|
return Promise.reject(new Error("no adapter for cancel button"));
|
|
6159
|
+
if (e.toolProgress && adapter.editMessageRemoveButtons) {
|
|
6160
|
+
return adapter.editMessageRemoveButtons(e.chatId, e.messageId, this.composeRetiredBubbleText(e), e.threadId);
|
|
6161
|
+
}
|
|
6162
|
+
// All production adapters currently implement editMessageRemoveButtons.
|
|
6163
|
+
// A third-party adapter without it degrades to the legacy delete behavior:
|
|
6164
|
+
// removing a live Cancel control is safer than retaining an actionable,
|
|
6165
|
+
// untracked button forever.
|
|
5239
6166
|
if (adapter.deleteMessage)
|
|
5240
6167
|
return adapter.deleteMessage(e.chatId, e.messageId, e.threadId);
|
|
5241
6168
|
if (adapter.editMessageRemoveButtons)
|
|
@@ -5287,11 +6214,457 @@ export class FleetManager {
|
|
|
5287
6214
|
const daemon = this.daemons.get(instanceName);
|
|
5288
6215
|
if (!daemon)
|
|
5289
6216
|
return false;
|
|
6217
|
+
const deliveryEpoch = this.cancelPendingDeliveries(instanceName);
|
|
6218
|
+
daemon.clearPendingDeliveries?.(deliveryEpoch);
|
|
5290
6219
|
daemon.sendEscape().catch(e => this.logger.warn({ err: e, instanceName }, "sendEscape failed"));
|
|
5291
6220
|
this.lastInboundMsg.delete(instanceName);
|
|
5292
6221
|
this.clearCancelButton(instanceName);
|
|
5293
6222
|
return true;
|
|
5294
6223
|
}
|
|
6224
|
+
getDeliveryEpoch(instanceName) {
|
|
6225
|
+
return this.deliveryEpochs.get(instanceName) ?? 0;
|
|
6226
|
+
}
|
|
6227
|
+
isDeliveryEpochCurrent(instanceName, deliveryEpoch) {
|
|
6228
|
+
return deliveryEpoch === this.getDeliveryEpoch(instanceName);
|
|
6229
|
+
}
|
|
6230
|
+
/** Invalidate work queued before a user cancel and wake idle-gate waiters. */
|
|
6231
|
+
cancelPendingDeliveries(instanceName) {
|
|
6232
|
+
const next = this.getDeliveryEpoch(instanceName) + 1;
|
|
6233
|
+
this.deliveryEpochs.set(instanceName, next);
|
|
6234
|
+
for (const check of this.instanceIdleWaiters.get(instanceName) ?? [])
|
|
6235
|
+
check();
|
|
6236
|
+
return next;
|
|
6237
|
+
}
|
|
6238
|
+
// ── Remote CLI login (`/login`) ──────────────────────────────────────────
|
|
6239
|
+
//
|
|
6240
|
+
// One session at a time, in a dedicated tmux window — never an instance
|
|
6241
|
+
// pane. Credentials are per-backend (codex homes symlink auth.json to the
|
|
6242
|
+
// shared ~/.codex; the other CLIs use one real home), so a single sign-in
|
|
6243
|
+
// repairs every instance of that backend, and instance delivery, pane-state
|
|
6244
|
+
// detection, tool progress, and mcp_proxy_reply never observe login output.
|
|
6245
|
+
activeLogin = null;
|
|
6246
|
+
/** Post the backend chooser for a bare `/login`. Caller enforces admin. */
|
|
6247
|
+
async promptLoginBackends(chat) {
|
|
6248
|
+
const configured = new Set();
|
|
6249
|
+
for (const [, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
|
|
6250
|
+
configured.add(config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
|
|
6251
|
+
}
|
|
6252
|
+
const choices = Object.keys(LOGIN_FLOWS)
|
|
6253
|
+
.filter(backend => configured.size === 0 || configured.has(backend))
|
|
6254
|
+
.map(backend => ({ action: backend, label: backend }));
|
|
6255
|
+
if (choices.length === 0) {
|
|
6256
|
+
await chat.adapter.sendText(chat.chatId, t("login.unsupported", [...configured].join(", ")), { threadId: chat.threadId });
|
|
6257
|
+
return;
|
|
6258
|
+
}
|
|
6259
|
+
await this.postNonceButtonPrompt({
|
|
6260
|
+
prefix: LOGIN_CALLBACK_PREFIX,
|
|
6261
|
+
alertType: "login",
|
|
6262
|
+
instanceName: "login",
|
|
6263
|
+
adapter: chat.adapter,
|
|
6264
|
+
adapterId: chat.adapterId,
|
|
6265
|
+
chatId: chat.chatId,
|
|
6266
|
+
threadId: chat.threadId,
|
|
6267
|
+
message: t("login.choose_backend"),
|
|
6268
|
+
choices,
|
|
6269
|
+
expiredText: t("buttons.stale"),
|
|
6270
|
+
});
|
|
6271
|
+
}
|
|
6272
|
+
/**
|
|
6273
|
+
* Start a login session for one backend. Caller enforces admin.
|
|
6274
|
+
* Returns a status line to post, or null when a confirmation prompt was
|
|
6275
|
+
* posted instead (auth still valid — see the pre-check below).
|
|
6276
|
+
*/
|
|
6277
|
+
async startLoginSession(backendArg, chat, opts = {}) {
|
|
6278
|
+
const backend = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
|
|
6279
|
+
const flow = LOGIN_FLOWS[backend];
|
|
6280
|
+
if (!flow)
|
|
6281
|
+
return t("login.unsupported", backendArg);
|
|
6282
|
+
if (this.activeLogin)
|
|
6283
|
+
return t("login.busy", this.activeLogin.backend);
|
|
6284
|
+
// Install sessions share the window namespace — never run both at once.
|
|
6285
|
+
if (this.activeInstall)
|
|
6286
|
+
return t("install.busy");
|
|
6287
|
+
// Token-free pre-check (5s cap): re-login while auth still works is
|
|
6288
|
+
// usually a mistake, so it needs a confirmed click. An invalid OR
|
|
6289
|
+
// uncertain result (timeout, missing binary) proceeds straight to login —
|
|
6290
|
+
// an unreliable probe must never block the re-login the admin asked for.
|
|
6291
|
+
if (!opts.skipAuthCheck && flow.authCheck) {
|
|
6292
|
+
const status = await checkAuthStatus(flow.authCheck);
|
|
6293
|
+
if (status === "valid") {
|
|
6294
|
+
await this.postNonceButtonPrompt({
|
|
6295
|
+
prefix: LOGIN_CONFIRM_CALLBACK_PREFIX,
|
|
6296
|
+
alertType: "login",
|
|
6297
|
+
instanceName: backend,
|
|
6298
|
+
adapter: chat.adapter,
|
|
6299
|
+
adapterId: chat.adapterId,
|
|
6300
|
+
chatId: chat.chatId,
|
|
6301
|
+
threadId: chat.threadId,
|
|
6302
|
+
message: t("login.still_valid", backend),
|
|
6303
|
+
choices: [
|
|
6304
|
+
{ action: "go", label: t("login.relogin_go") },
|
|
6305
|
+
{ action: "cancel", label: t("login.relogin_cancel") },
|
|
6306
|
+
],
|
|
6307
|
+
expiredText: t("buttons.stale"),
|
|
6308
|
+
});
|
|
6309
|
+
return null;
|
|
6310
|
+
}
|
|
6311
|
+
}
|
|
6312
|
+
return this.launchLoginSession(flow, backend, chat);
|
|
6313
|
+
}
|
|
6314
|
+
/** Create the login window and session (pre-check already settled). */
|
|
6315
|
+
async launchLoginSession(flow, backend, chat) {
|
|
6316
|
+
if (this.activeLogin)
|
|
6317
|
+
return t("login.busy", this.activeLogin.backend);
|
|
6318
|
+
// Install sessions share the window namespace — never run both at once.
|
|
6319
|
+
if (this.activeInstall)
|
|
6320
|
+
return t("install.busy");
|
|
6321
|
+
const sessionName = getTmuxSession();
|
|
6322
|
+
await TmuxManager.ensureSession(sessionName);
|
|
6323
|
+
const tmux = new TmuxManager(sessionName, "");
|
|
6324
|
+
const session = new LoginSession(flow, tmux, {
|
|
6325
|
+
onMenu: async (options) => {
|
|
6326
|
+
await this.postNonceButtonPrompt({
|
|
6327
|
+
prefix: LOGIN_MENU_CALLBACK_PREFIX,
|
|
6328
|
+
alertType: "login",
|
|
6329
|
+
instanceName: backend,
|
|
6330
|
+
adapter: chat.adapter,
|
|
6331
|
+
adapterId: chat.adapterId,
|
|
6332
|
+
chatId: chat.chatId,
|
|
6333
|
+
threadId: chat.threadId,
|
|
6334
|
+
message: t("login.choose_provider"),
|
|
6335
|
+
choices: options.map((label, index) => ({ action: String(index), label })),
|
|
6336
|
+
expiredText: t("buttons.stale"),
|
|
6337
|
+
});
|
|
6338
|
+
},
|
|
6339
|
+
onAuthHint: async (url, code) => {
|
|
6340
|
+
await this.sendLoginSecret(chat, backend, url, code);
|
|
6341
|
+
},
|
|
6342
|
+
onNeedInput: async (promptExcerpt) => {
|
|
6343
|
+
await chat.adapter.sendText(chat.chatId, t("login.need_input", backend, promptExcerpt), { threadId: chat.threadId }).catch(() => { });
|
|
6344
|
+
},
|
|
6345
|
+
onDone: async ({ ok, detail }) => {
|
|
6346
|
+
this.activeLogin = null;
|
|
6347
|
+
let text;
|
|
6348
|
+
if (ok) {
|
|
6349
|
+
const { woken, restarted } = await this.recoverBackendInstances(backend);
|
|
6350
|
+
const none = t("login.none");
|
|
6351
|
+
text = t("login.success", backend, woken.length ? woken.join(", ") : none, restarted.length ? restarted.join(", ") : none);
|
|
6352
|
+
}
|
|
6353
|
+
else if (detail === "cancelled") {
|
|
6354
|
+
// The cancel command's own reply already announced this — a second
|
|
6355
|
+
// message here was a duplicate.
|
|
6356
|
+
return;
|
|
6357
|
+
}
|
|
6358
|
+
else {
|
|
6359
|
+
text = t("login.failed", backend, detail);
|
|
6360
|
+
}
|
|
6361
|
+
await chat.adapter.sendText(chat.chatId, text, { threadId: chat.threadId }).catch(() => { });
|
|
6362
|
+
},
|
|
6363
|
+
}, this.logger);
|
|
6364
|
+
// Claim the slot before the first await so two admins racing /login cannot
|
|
6365
|
+
// both create windows; release on startup failure.
|
|
6366
|
+
this.activeLogin = { session, backend, chat };
|
|
6367
|
+
try {
|
|
6368
|
+
await session.start();
|
|
6369
|
+
}
|
|
6370
|
+
catch (err) {
|
|
6371
|
+
this.activeLogin = null;
|
|
6372
|
+
return t("login.failed", backend, err.message);
|
|
6373
|
+
}
|
|
6374
|
+
return t("login.started", backend);
|
|
6375
|
+
}
|
|
6376
|
+
/** `/login code <text>` — paste admin-supplied text into the login window. */
|
|
6377
|
+
async loginSubmitInput(text) {
|
|
6378
|
+
if (!this.activeLogin)
|
|
6379
|
+
return t("login.no_session");
|
|
6380
|
+
const ok = await this.activeLogin.session.submitInput(text);
|
|
6381
|
+
return ok ? t("login.input_sent") : t("login.input_failed");
|
|
6382
|
+
}
|
|
6383
|
+
/** `/login cancel` — abort the active session and remove its window. */
|
|
6384
|
+
async cancelLoginSession() {
|
|
6385
|
+
if (!this.activeLogin)
|
|
6386
|
+
return t("login.no_session");
|
|
6387
|
+
const backend = this.activeLogin.backend;
|
|
6388
|
+
await this.activeLogin.session.cancel();
|
|
6389
|
+
return t("login.cancelled", backend);
|
|
6390
|
+
}
|
|
6391
|
+
/**
|
|
6392
|
+
* The URL (+ code) is a live credential: whoever completes it binds THEIR
|
|
6393
|
+
* account to this fleet's CLI. Telegram gets an HTML spoiler (same treatment
|
|
6394
|
+
* as the dashboard token); other adapters get plain text with the warning.
|
|
6395
|
+
*/
|
|
6396
|
+
async sendLoginSecret(chat, backend, url, code) {
|
|
6397
|
+
const codeLine = code ? `\n${t("login.auth_code", code)}` : "";
|
|
6398
|
+
try {
|
|
6399
|
+
if (chat.adapter.type === "telegram") {
|
|
6400
|
+
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
6401
|
+
await chat.adapter.sendText(chat.chatId, `${esc(t("login.auth_hint", backend))}\n<tg-spoiler>${esc(url)}${esc(codeLine)}</tg-spoiler>`, { threadId: chat.threadId, format: "html" });
|
|
6402
|
+
}
|
|
6403
|
+
else {
|
|
6404
|
+
await chat.adapter.sendText(chat.chatId, `${t("login.auth_hint", backend)}\n${url}${codeLine}`, { threadId: chat.threadId });
|
|
6405
|
+
}
|
|
6406
|
+
}
|
|
6407
|
+
catch (err) {
|
|
6408
|
+
this.logger.warn({ err: err.message, backend }, "Failed to deliver login URL");
|
|
6409
|
+
}
|
|
6410
|
+
}
|
|
6411
|
+
/**
|
|
6412
|
+
* After a successful login: wake the paused instances of that backend AND
|
|
6413
|
+
* restart the running ones — a live CLI holds the old token in memory and
|
|
6414
|
+
* only re-reads credentials on process start (a paused instance's CLI is
|
|
6415
|
+
* already dead, so waking it respawns with the new token for free).
|
|
6416
|
+
*/
|
|
6417
|
+
async recoverBackendInstances(backend) {
|
|
6418
|
+
const woken = [];
|
|
6419
|
+
const restarted = [];
|
|
6420
|
+
for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
|
|
6421
|
+
const effective = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
6422
|
+
if (effective !== backend)
|
|
6423
|
+
continue;
|
|
6424
|
+
const status = this.getInstanceStatus(name);
|
|
6425
|
+
try {
|
|
6426
|
+
if (status === "paused") {
|
|
6427
|
+
if (this.daemons.has(name))
|
|
6428
|
+
await this.lifecycle.wake(name, 30_000);
|
|
6429
|
+
else
|
|
6430
|
+
await this.startPersistedPausedInstance(name);
|
|
6431
|
+
woken.push(name);
|
|
6432
|
+
}
|
|
6433
|
+
else if (status === "running") {
|
|
6434
|
+
await this.restartSingleInstance(name);
|
|
6435
|
+
restarted.push(name);
|
|
6436
|
+
}
|
|
6437
|
+
}
|
|
6438
|
+
catch (err) {
|
|
6439
|
+
this.logger.warn({ err: err.message, name, status }, "Post-login recovery failed");
|
|
6440
|
+
}
|
|
6441
|
+
}
|
|
6442
|
+
return { woken, restarted };
|
|
6443
|
+
}
|
|
6444
|
+
/** Backend chooser button → start that backend's login session. */
|
|
6445
|
+
async handleLoginBackendSelect(data, callbackAdapterId, receivingAdapter) {
|
|
6446
|
+
const claimed = this.consumeNonceCallback(LOGIN_CALLBACK_PREFIX, /^login:([0-9a-f]+):([a-z][a-z-]*)$/, data, callbackAdapterId, receivingAdapter);
|
|
6447
|
+
if (claimed === null)
|
|
6448
|
+
return false;
|
|
6449
|
+
if (claimed === "consumed")
|
|
6450
|
+
return true;
|
|
6451
|
+
const { entry, action: backend } = claimed;
|
|
6452
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", backend));
|
|
6453
|
+
const text = await this.startLoginSession(backend, {
|
|
6454
|
+
adapter: entry.adapter,
|
|
6455
|
+
adapterId: entry.adapterId,
|
|
6456
|
+
chatId: entry.chatId,
|
|
6457
|
+
threadId: entry.threadId,
|
|
6458
|
+
});
|
|
6459
|
+
if (text)
|
|
6460
|
+
await entry.adapter.sendText(entry.chatId, text, { threadId: entry.threadId }).catch(() => { });
|
|
6461
|
+
return true;
|
|
6462
|
+
}
|
|
6463
|
+
/** Re-login confirmation (auth pre-check said credentials still work). */
|
|
6464
|
+
async handleLoginConfirm(data, callbackAdapterId, receivingAdapter) {
|
|
6465
|
+
const claimed = this.consumeNonceCallback(LOGIN_CONFIRM_CALLBACK_PREFIX, /^login-confirm:([0-9a-f]+):(go|cancel)$/, data, callbackAdapterId, receivingAdapter);
|
|
6466
|
+
if (claimed === null)
|
|
6467
|
+
return false;
|
|
6468
|
+
if (claimed === "consumed")
|
|
6469
|
+
return true;
|
|
6470
|
+
const { entry, action } = claimed;
|
|
6471
|
+
const backend = entry.instanceName;
|
|
6472
|
+
if (action === "cancel") {
|
|
6473
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled", backend));
|
|
6474
|
+
return true;
|
|
6475
|
+
}
|
|
6476
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", backend));
|
|
6477
|
+
const text = await this.startLoginSession(backend, {
|
|
6478
|
+
adapter: entry.adapter,
|
|
6479
|
+
adapterId: entry.adapterId,
|
|
6480
|
+
chatId: entry.chatId,
|
|
6481
|
+
threadId: entry.threadId,
|
|
6482
|
+
}, { skipAuthCheck: true });
|
|
6483
|
+
if (text)
|
|
6484
|
+
await entry.adapter.sendText(entry.chatId, text, { threadId: entry.threadId }).catch(() => { });
|
|
6485
|
+
return true;
|
|
6486
|
+
}
|
|
6487
|
+
/** Kiro provider button → drive the CLI's arrow-key selector. */
|
|
6488
|
+
async handleLoginMenuSelect(data, callbackAdapterId, receivingAdapter) {
|
|
6489
|
+
const claimed = this.consumeNonceCallback(LOGIN_MENU_CALLBACK_PREFIX, /^login-menu:([0-9a-f]+):(\d)$/, data, callbackAdapterId, receivingAdapter);
|
|
6490
|
+
if (claimed === null)
|
|
6491
|
+
return false;
|
|
6492
|
+
if (claimed === "consumed")
|
|
6493
|
+
return true;
|
|
6494
|
+
const { entry, action } = claimed;
|
|
6495
|
+
const index = Number(action);
|
|
6496
|
+
const label = this.activeLogin?.session.flow.menu?.options[index] ?? action;
|
|
6497
|
+
const ok = await this.activeLogin?.session.selectMenuOption(index) ?? false;
|
|
6498
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, ok ? t("login.provider_selected", label) : t("login.no_session"));
|
|
6499
|
+
return true;
|
|
6500
|
+
}
|
|
6501
|
+
// ── Remote CLI install (`/install-cli`) ──────────────────────────────────
|
|
6502
|
+
//
|
|
6503
|
+
// Same dedicated-window model as /login (and the same LoginSession state
|
|
6504
|
+
// machine — an install is a login flow with no auth hints): run the
|
|
6505
|
+
// installer, judge by exit code, verify the binary on a fresh login shell,
|
|
6506
|
+
// then offer to chain straight into /login.
|
|
6507
|
+
activeInstall = null;
|
|
6508
|
+
/** Start a CLI install session. Caller enforces admin. */
|
|
6509
|
+
async startInstallSession(backendArg, chat) {
|
|
6510
|
+
const backend = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
|
|
6511
|
+
const info = BACKEND_INSTALLATION_INFO[backend];
|
|
6512
|
+
if (!info)
|
|
6513
|
+
return t("install.unsupported", backendArg);
|
|
6514
|
+
if (checkBinaryInstalled(info.binary))
|
|
6515
|
+
return t("install.already", backend, info.binary);
|
|
6516
|
+
if (this.activeInstall)
|
|
6517
|
+
return t("install.busy");
|
|
6518
|
+
if (this.activeLogin)
|
|
6519
|
+
return t("login.busy", this.activeLogin.backend);
|
|
6520
|
+
const sessionName = getTmuxSession();
|
|
6521
|
+
await TmuxManager.ensureSession(sessionName);
|
|
6522
|
+
const tmux = new TmuxManager(sessionName, "");
|
|
6523
|
+
// A synthetic login flow: success is decided by the installer's exit code
|
|
6524
|
+
// (LoginSession treats a clean exit as success), never by pane text — and
|
|
6525
|
+
// installer output that happens to contain a URL must not be forwarded as
|
|
6526
|
+
// an auth hint, hence the no-op events below.
|
|
6527
|
+
const flow = {
|
|
6528
|
+
backend,
|
|
6529
|
+
command: info.install,
|
|
6530
|
+
successPattern: /$^/,
|
|
6531
|
+
timeoutMs: 10 * 60 * 1000,
|
|
6532
|
+
};
|
|
6533
|
+
const session = new LoginSession(flow, tmux, {
|
|
6534
|
+
onMenu: () => { },
|
|
6535
|
+
onAuthHint: () => { },
|
|
6536
|
+
onNeedInput: () => { },
|
|
6537
|
+
onDone: async ({ ok, detail }) => {
|
|
6538
|
+
this.activeInstall = null;
|
|
6539
|
+
if (!ok) {
|
|
6540
|
+
// A cancel is user-initiated — the cancel command's own reply already
|
|
6541
|
+
// said so; a second message here would be a duplicate.
|
|
6542
|
+
if (detail !== "cancelled") {
|
|
6543
|
+
await chat.adapter.sendText(chat.chatId, t("install.failed", backend, detail), { threadId: chat.threadId }).catch(() => { });
|
|
6544
|
+
}
|
|
6545
|
+
return;
|
|
6546
|
+
}
|
|
6547
|
+
// The installer may only have added the binary to a profile PATH; a
|
|
6548
|
+
// fresh login shell sees that, the fleet process's PATH may not.
|
|
6549
|
+
if (!this.verifyBinaryOnLoginShell(info.binary)) {
|
|
6550
|
+
await chat.adapter.sendText(chat.chatId, t("install.verify_failed", backend, info.binary), { threadId: chat.threadId }).catch(() => { });
|
|
6551
|
+
return;
|
|
6552
|
+
}
|
|
6553
|
+
if (!LOGIN_FLOWS[backend]) {
|
|
6554
|
+
await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
|
|
6555
|
+
return;
|
|
6556
|
+
}
|
|
6557
|
+
await this.postNonceButtonPrompt({
|
|
6558
|
+
prefix: INSTALL_LOGIN_CALLBACK_PREFIX,
|
|
6559
|
+
alertType: "login",
|
|
6560
|
+
instanceName: backend,
|
|
6561
|
+
adapter: chat.adapter,
|
|
6562
|
+
adapterId: chat.adapterId,
|
|
6563
|
+
chatId: chat.chatId,
|
|
6564
|
+
threadId: chat.threadId,
|
|
6565
|
+
message: t("install.login_prompt", backend),
|
|
6566
|
+
choices: [
|
|
6567
|
+
{ action: "go", label: t("install.login_now") },
|
|
6568
|
+
{ action: "later", label: t("install.later") },
|
|
6569
|
+
],
|
|
6570
|
+
expiredText: t("install.later_ack", backend),
|
|
6571
|
+
});
|
|
6572
|
+
},
|
|
6573
|
+
}, this.logger);
|
|
6574
|
+
this.activeInstall = { session, backend, chat };
|
|
6575
|
+
try {
|
|
6576
|
+
await session.start();
|
|
6577
|
+
}
|
|
6578
|
+
catch (err) {
|
|
6579
|
+
this.activeInstall = null;
|
|
6580
|
+
return t("install.failed", backend, err.message);
|
|
6581
|
+
}
|
|
6582
|
+
return t("install.started", backend);
|
|
6583
|
+
}
|
|
6584
|
+
/** `/install-cli cancel` — abort the active install and remove its window. */
|
|
6585
|
+
async cancelInstallSession() {
|
|
6586
|
+
if (!this.activeInstall)
|
|
6587
|
+
return t("install.no_session");
|
|
6588
|
+
const backend = this.activeInstall.backend;
|
|
6589
|
+
await this.activeInstall.session.cancel();
|
|
6590
|
+
return t("install.cancelled", backend);
|
|
6591
|
+
}
|
|
6592
|
+
/** `command -v` on a login shell, so PATH additions from rc files count. */
|
|
6593
|
+
verifyBinaryOnLoginShell(binary) {
|
|
6594
|
+
try {
|
|
6595
|
+
const result = spawnSync("bash", ["-lc", `command -v ${binary}`], { timeout: 10_000, stdio: "pipe" });
|
|
6596
|
+
return result.status === 0;
|
|
6597
|
+
}
|
|
6598
|
+
catch {
|
|
6599
|
+
return false;
|
|
6600
|
+
}
|
|
6601
|
+
}
|
|
6602
|
+
/** "Sign in now?" button after a successful install. */
|
|
6603
|
+
async handleInstallLoginConfirm(data, callbackAdapterId, receivingAdapter) {
|
|
6604
|
+
const claimed = this.consumeNonceCallback(INSTALL_LOGIN_CALLBACK_PREFIX, /^install-login:([0-9a-f]+):(go|later)$/, data, callbackAdapterId, receivingAdapter);
|
|
6605
|
+
if (claimed === null)
|
|
6606
|
+
return false;
|
|
6607
|
+
if (claimed === "consumed")
|
|
6608
|
+
return true;
|
|
6609
|
+
const { entry, action } = claimed;
|
|
6610
|
+
const backend = entry.instanceName;
|
|
6611
|
+
if (action === "later") {
|
|
6612
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("install.later_ack", backend));
|
|
6613
|
+
return true;
|
|
6614
|
+
}
|
|
6615
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", backend));
|
|
6616
|
+
const text = await this.startLoginSession(backend, {
|
|
6617
|
+
adapter: entry.adapter,
|
|
6618
|
+
adapterId: entry.adapterId,
|
|
6619
|
+
chatId: entry.chatId,
|
|
6620
|
+
threadId: entry.threadId,
|
|
6621
|
+
});
|
|
6622
|
+
if (text)
|
|
6623
|
+
await entry.adapter.sendText(entry.chatId, text, { threadId: entry.threadId }).catch(() => { });
|
|
6624
|
+
return true;
|
|
6625
|
+
}
|
|
6626
|
+
/** Discord native `/login` slash — shared by every adapter dispatch block. */
|
|
6627
|
+
async handleLoginSlash(data, adapterId, adapter) {
|
|
6628
|
+
if (!data.userId || !this.isFleetAdmin(data.userId, adapterId)) {
|
|
6629
|
+
await data.respond(t("permission.denied"));
|
|
6630
|
+
return;
|
|
6631
|
+
}
|
|
6632
|
+
const chat = { adapter, adapterId, chatId: data.channelId };
|
|
6633
|
+
if (data.options?.cancel === true) {
|
|
6634
|
+
await data.respond(await this.cancelLoginSession());
|
|
6635
|
+
return;
|
|
6636
|
+
}
|
|
6637
|
+
const code = String(data.options?.code ?? "").trim();
|
|
6638
|
+
if (code) {
|
|
6639
|
+
await data.respond(await this.loginSubmitInput(code));
|
|
6640
|
+
return;
|
|
6641
|
+
}
|
|
6642
|
+
const backend = String(data.options?.backend ?? "").trim();
|
|
6643
|
+
if (backend) {
|
|
6644
|
+
const text = await this.startLoginSession(backend, chat);
|
|
6645
|
+
await data.respond(text ?? t("login.confirm_posted"));
|
|
6646
|
+
return;
|
|
6647
|
+
}
|
|
6648
|
+
await this.promptLoginBackends(chat);
|
|
6649
|
+
await data.respond(t("login.chooser_posted"));
|
|
6650
|
+
}
|
|
6651
|
+
/** Discord native `/install-cli` slash — shared by every dispatch block. */
|
|
6652
|
+
async handleInstallCliSlash(data, adapterId, adapter) {
|
|
6653
|
+
if (!data.userId || !this.isFleetAdmin(data.userId, adapterId)) {
|
|
6654
|
+
await data.respond(t("permission.denied"));
|
|
6655
|
+
return;
|
|
6656
|
+
}
|
|
6657
|
+
if (data.options?.cancel === true) {
|
|
6658
|
+
await data.respond(await this.cancelInstallSession());
|
|
6659
|
+
return;
|
|
6660
|
+
}
|
|
6661
|
+
const backend = String(data.options?.backend ?? "").trim();
|
|
6662
|
+
if (!backend) {
|
|
6663
|
+
await data.respond(t("install.usage"));
|
|
6664
|
+
return;
|
|
6665
|
+
}
|
|
6666
|
+
await data.respond(await this.startInstallSession(backend, { adapter, adapterId, chatId: data.channelId }));
|
|
6667
|
+
}
|
|
5295
6668
|
queueMirrorMessage(text) {
|
|
5296
6669
|
const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
|
|
5297
6670
|
if (mirrorTopicId == null || !this.adapter)
|
|
@@ -5411,6 +6784,10 @@ export class FleetManager {
|
|
|
5411
6784
|
"gemini-cli": "GEMINI.md",
|
|
5412
6785
|
"opencode": "AGENTS.md",
|
|
5413
6786
|
"kiro-cli": ".kiro/steering/project.md",
|
|
6787
|
+
// Grok reads AGENTS.md project docs; agy reads .agents/agents.md — the
|
|
6788
|
+
// same files their writeConfig() appends fleet instructions to.
|
|
6789
|
+
"grok": "AGENTS.md",
|
|
6790
|
+
"antigravity": ".agents/agents.md",
|
|
5414
6791
|
"mock": "CLAUDE.md",
|
|
5415
6792
|
};
|
|
5416
6793
|
static GENERAL_INSTRUCTIONS = `# Fleet Coordinator
|
|
@@ -5420,146 +6797,33 @@ You route tasks, manage instances, enforce policies, and synthesize results.
|
|
|
5420
6797
|
Do NOT modify project files directly — delegate file changes to the project's instance.
|
|
5421
6798
|
You CAN write code snippets, explain code, and answer technical questions directly.
|
|
5422
6799
|
|
|
5423
|
-
|
|
5424
|
-
|
|
5425
|
-
## Task Classification
|
|
5426
|
-
|
|
5427
|
-
Classify every incoming request before acting.
|
|
5428
|
-
|
|
5429
|
-
### Handle Directly (ALL conditions must be true)
|
|
5430
|
-
|
|
5431
|
-
- No file system access needed
|
|
5432
|
-
- No external execution needed
|
|
5433
|
-
- Answerable from static knowledge
|
|
5434
|
-
- ≤ 2 reasoning steps
|
|
5435
|
-
|
|
5436
|
-
Examples: Q&A, translation, fleet status queries, explaining a concept, writing code snippets.
|
|
5437
|
-
|
|
5438
|
-
### Delegate to 1 Instance
|
|
5439
|
-
|
|
5440
|
-
- Task scoped to a single project or repo
|
|
5441
|
-
- Requires file access, code changes, or execution
|
|
5442
|
-
|
|
5443
|
-
### Coordinate Multiple Instances
|
|
5444
|
-
|
|
5445
|
-
- Task spans multiple repos or domains
|
|
5446
|
-
- Requires outputs from one instance to feed into another
|
|
5447
|
-
- Benefits from parallel execution (max 3 instances per task)
|
|
5448
|
-
|
|
5449
|
-
-----
|
|
5450
|
-
|
|
5451
|
-
## Instance Discovery (in this order)
|
|
5452
|
-
1. list_teams() → reuse existing teams first
|
|
5453
|
-
2. list_instances() → find by working_directory, description, or tags
|
|
5454
|
-
3. describe_instance() → confirm capabilities before delegating
|
|
5455
|
-
4. create_instance() → only if no suitable instance exists
|
|
5456
|
-
|
|
5457
|
-
Rules: prefer reuse over creation. Do NOT create duplicates of running instances.
|
|
5458
|
-
|
|
5459
|
-
-----
|
|
6800
|
+
## Task Routing
|
|
5460
6801
|
|
|
5461
|
-
|
|
6802
|
+
- **Handle directly**: no file/exec access needed, answerable from knowledge, ≤2 reasoning steps (Q&A, translation, status queries, code snippets).
|
|
6803
|
+
- **Delegate to 1 instance**: scoped to one project/repo, needs file access or execution.
|
|
6804
|
+
- **Coordinate multiple**: spans repos, outputs feed each other, or parallel helps (max 3 per task).
|
|
5462
6805
|
|
|
5463
|
-
|
|
6806
|
+
Instance discovery order: list_teams() → list_instances() → describe_instance() → create_instance() only if nothing fits. Prefer reuse; never duplicate a running instance.
|
|
5464
6807
|
|
|
5465
|
-
|
|
5466
|
-
2. Expected output — what to return and in what form
|
|
5467
|
-
3. Policy reminder — "Follow Development Workflow policy" (for code tasks)
|
|
6808
|
+
## Reply Contract
|
|
5468
6809
|
|
|
5469
|
-
|
|
5470
|
-
|
|
5471
|
-
- Never re-delegate a task back to the instance that sent it to you
|
|
5472
|
-
- If a task has bounced 3 times, stop and solve locally or reduce scope
|
|
5473
|
-
|
|
5474
|
-
### Execution Strategy
|
|
5475
|
-
|
|
5476
|
-
Parallel — use only when tasks are independent with no shared state
|
|
5477
|
-
Sequential — use when one task's output feeds into the next
|
|
5478
|
-
|
|
5479
|
-
-----
|
|
5480
|
-
|
|
5481
|
-
## Result Handling
|
|
5482
|
-
|
|
5483
|
-
When an instance reports back, classify the outcome:
|
|
5484
|
-
|
|
5485
|
-
- Success → Summarize key results for user. Omit internal coordination noise.
|
|
5486
|
-
- Partial → State what succeeded, what remains, proposed next steps.
|
|
5487
|
-
- Failure → Retry up to 2 times. If still failing: try alternative instance, reduce scope, or return partial result clearly marked.
|
|
5488
|
-
- No response → Ping again after reasonable wait. If still silent: report to user with options.
|
|
5489
|
-
|
|
5490
|
-
### Output to User
|
|
5491
|
-
|
|
5492
|
-
Every final response to the user should contain:
|
|
5493
|
-
|
|
5494
|
-
- Result — the actual answer or deliverable
|
|
5495
|
-
- Gaps — anything incomplete or unresolved (omit if none)
|
|
5496
|
-
|
|
5497
|
-
-----
|
|
5498
|
-
|
|
5499
|
-
## Shared Decisions
|
|
5500
|
-
|
|
5501
|
-
Use post_decision() / list_decisions() for any choice that affects more than 1 instance, changes an API contract, introduces a new dependency, or alters deployment process.
|
|
5502
|
-
|
|
5503
|
-
When instances disagree, collect both viewpoints, make a decision, and record it via post_decision.
|
|
5504
|
-
|
|
5505
|
-
-----
|
|
6810
|
+
Every final response to the user contains: the result (the actual answer or deliverable) and gaps (anything incomplete — omit if none). Summarize instance reports; omit internal coordination noise.
|
|
5506
6811
|
|
|
5507
6812
|
## After Restart
|
|
5508
6813
|
|
|
5509
|
-
|
|
5510
|
-
1. list_instances() → rebuild fleet awareness
|
|
5511
|
-
2. list_teams() → restore team structure
|
|
5512
|
-
3. list_decisions() → reload policies and conventions
|
|
5513
|
-
|
|
5514
|
-
Only then handle incoming requests.
|
|
5515
|
-
|
|
5516
|
-
-----
|
|
5517
|
-
|
|
5518
|
-
## Development Workflow Policy
|
|
5519
|
-
|
|
5520
|
-
All code changes across the fleet should follow this workflow.
|
|
5521
|
-
The coordinator enforces compliance but does not perform these steps directly.
|
|
5522
|
-
Remind instances of this policy when delegating code tasks.
|
|
5523
|
-
|
|
5524
|
-
### Workflow Stages
|
|
5525
|
-
Design Proposed → Design Approved → Implementation → Submit for Review → Under Review → Approved → Merge
|
|
5526
|
-
|
|
5527
|
-
### Policy Rules
|
|
5528
|
-
|
|
5529
|
-
1. Design before code — developer sends design proposal to reviewer before implementation. Consensus required before proceeding.
|
|
5530
|
-
2. Challenger pairing — every code task should have a developer + reviewer. Reviewer actively questions decisions and finds risks.
|
|
5531
|
-
3. Verify by execution — backend/CLI changes must be tested by running them. Do not trust documentation alone.
|
|
5532
|
-
4. Independent review — every merge requires code review from someone other than the author.
|
|
5533
|
-
5. Root cause first — bug fixes require confirmed root cause before proposing a fix.
|
|
5534
|
-
6. Merge conditions: tests pass, reviewer approved, branch and worktree cleaned up.
|
|
5535
|
-
|
|
5536
|
-
### Specialist Instance Rules
|
|
5537
|
-
|
|
5538
|
-
- Execute within defined scope only
|
|
5539
|
-
- Return structured output: result, assumptions, uncertainties, verification status
|
|
5540
|
-
- Do NOT create new instances without coordinator approval
|
|
5541
|
-
|
|
5542
|
-
-----
|
|
5543
|
-
|
|
5544
|
-
## Team Management
|
|
5545
|
-
|
|
5546
|
-
- Always check existing teams before creating new ones
|
|
5547
|
-
- Default to ephemeral teams (created for a specific task, dissolved after completion)
|
|
5548
|
-
- Clean up ephemeral teams and instances after task completion
|
|
5549
|
-
|
|
5550
|
-
-----
|
|
5551
|
-
|
|
5552
|
-
## Instance Configuration Tips
|
|
6814
|
+
BEFORE processing any new messages: 1. list_instances() 2. list_teams() 3. list_decisions(). Only then handle requests.
|
|
5553
6815
|
|
|
5554
|
-
|
|
6816
|
+
## Playbooks (on-demand skills)
|
|
5555
6817
|
|
|
5556
|
-
|
|
5557
|
-
- **
|
|
5558
|
-
- **
|
|
6818
|
+
Detailed procedures live in your skills — consult them when the situation comes up rather than from memory:
|
|
6819
|
+
- **delegation-playbook** — delegation protocol, loop prevention, parallel vs sequential, result/failure handling, team management, instance configuration tips.
|
|
6820
|
+
- **development-workflow** — the fleet-wide code-change policy you enforce when delegating code tasks.
|
|
6821
|
+
Plus the operational skills (fleet-health, instance-lifecycle, scheduling, session-management, …).
|
|
5559
6822
|
`;
|
|
5560
6823
|
/** Ensure the general instance has its project instructions file + knowledge */
|
|
5561
|
-
ensureGeneralInstructions(workDir, backendName) {
|
|
6824
|
+
ensureGeneralInstructions(workDir, backendName, instanceName) {
|
|
5562
6825
|
const backend = backendName ?? "claude-code";
|
|
6826
|
+
workDir = this.resolveKnowledgeWorkDir(workDir, backend, instanceName);
|
|
5563
6827
|
const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
|
|
5564
6828
|
const filePath = join(workDir, filename);
|
|
5565
6829
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
@@ -5567,60 +6831,237 @@ When users create specialized instances, suggest these configurations:
|
|
|
5567
6831
|
writeFileSync(filePath, FleetManager.GENERAL_INSTRUCTIONS, "utf-8");
|
|
5568
6832
|
this.logger.info({ filePath }, "Created general instance instructions file");
|
|
5569
6833
|
}
|
|
5570
|
-
// Sync bundled knowledge files to general's steering
|
|
6834
|
+
// Sync bundled knowledge files to general's steering and skills directories.
|
|
5571
6835
|
this.syncGeneralKnowledge(workDir, backend);
|
|
5572
6836
|
}
|
|
5573
|
-
/**
|
|
6837
|
+
/** Resolve the workspace path a backend actually uses before publishing knowledge. */
|
|
6838
|
+
resolveKnowledgeWorkDir(workDir, backend, instanceName) {
|
|
6839
|
+
// Backend resolution may create/normalize the real cwd. Instructions and
|
|
6840
|
+
// skills must land where the CLI actually runs, not an assumed path.
|
|
6841
|
+
try {
|
|
6842
|
+
const resolved = createBackend(backend, join(getAgendHome(), "cli-env"))
|
|
6843
|
+
.resolveWorkingDirectory?.(workDir, instanceName);
|
|
6844
|
+
if (resolved)
|
|
6845
|
+
workDir = resolved;
|
|
6846
|
+
}
|
|
6847
|
+
catch { /* unknown backend name — keep the raw path */ }
|
|
6848
|
+
return workDir;
|
|
6849
|
+
}
|
|
6850
|
+
/**
|
|
6851
|
+
* Where each backend natively loads on-demand skills from, relative to the
|
|
6852
|
+
* workspace. Backends without a native skill mechanism (opencode, grok,
|
|
6853
|
+
* antigravity, gemini-cli) are deliberately absent: dropping files a CLI
|
|
6854
|
+
* never reads is clutter, not capability. Unknown directories are ignored
|
|
6855
|
+
* by older CLI versions, so publishing is fail-open across upgrades.
|
|
6856
|
+
*/
|
|
6857
|
+
static SKILLS_DIR_SEGMENTS = {
|
|
6858
|
+
"kiro-cli": [".kiro", "skills"],
|
|
6859
|
+
"claude-code": [".claude", "skills"],
|
|
6860
|
+
"codex": [".agents", "skills"],
|
|
6861
|
+
// Live-verified: OpenCode and Antigravity scan .agents/skills; Grok's
|
|
6862
|
+
// vendor-canonical location is .grok/skills.
|
|
6863
|
+
"opencode": [".agents", "skills"],
|
|
6864
|
+
"grok": [".grok", "skills"],
|
|
6865
|
+
"antigravity": [".agents", "skills"],
|
|
6866
|
+
};
|
|
6867
|
+
/** Copy general-knowledge steering + all role-eligible skills to General. */
|
|
5574
6868
|
syncGeneralKnowledge(workDir, backend) {
|
|
5575
6869
|
const knowledgeDir = join(dirname(fileURLToPath(import.meta.url)), "general-knowledge");
|
|
5576
6870
|
if (!existsSync(knowledgeDir))
|
|
5577
6871
|
return;
|
|
5578
|
-
|
|
5579
|
-
|
|
5580
|
-
|
|
5581
|
-
|
|
5582
|
-
|
|
5583
|
-
|
|
5584
|
-
|
|
5585
|
-
|
|
5586
|
-
|
|
5587
|
-
|
|
5588
|
-
|
|
6872
|
+
this.syncGeneralSteering(workDir, backend, join(knowledgeDir, "steering"));
|
|
6873
|
+
this.syncRoleSkills(workDir, backend, "general", knowledgeDir);
|
|
6874
|
+
this.logger.debug({ knowledgeDir, workDir, backend }, "Synced general knowledge files");
|
|
6875
|
+
}
|
|
6876
|
+
/** Publish only the bundled skills eligible for an instance role. */
|
|
6877
|
+
syncRoleSkills(workDir, backend, role, knowledgeDir) {
|
|
6878
|
+
const skillSegments = FleetManager.SKILLS_DIR_SEGMENTS[backend];
|
|
6879
|
+
if (!skillSegments)
|
|
6880
|
+
return;
|
|
6881
|
+
const root = knowledgeDir ?? join(dirname(fileURLToPath(import.meta.url)), "general-knowledge");
|
|
6882
|
+
if (!existsSync(root))
|
|
6883
|
+
return;
|
|
6884
|
+
// Before managed-skill manifests existed, only Kiro General received
|
|
6885
|
+
// bundled skills. Allow that one legacy layout to be adopted so upgrades
|
|
6886
|
+
// can keep those copies current; other backends never had unmanaged
|
|
6887
|
+
// AgEnD-published skills and must retain the normal user-ownership guard.
|
|
6888
|
+
const adoptLegacyUnmanaged = backend === "kiro-cli" && role === "general";
|
|
6889
|
+
this.syncManagedSkills(join(workDir, ...skillSegments), join(root, "skills"), role, adoptLegacyUnmanaged);
|
|
6890
|
+
this.logger.debug({ workDir, backend, role }, "Synced role-based bundled skills");
|
|
6891
|
+
}
|
|
6892
|
+
/**
|
|
6893
|
+
* Steering (always-on rules like core-rules.md). Kiro loads a native
|
|
6894
|
+
* steering directory; every other backend gets the content embedded into
|
|
6895
|
+
* its instructions file (CLAUDE.md / AGENTS.md / …) inside a managed marker
|
|
6896
|
+
* block — the previous behavior dropped bare .md files in the workspace
|
|
6897
|
+
* root, which no CLI ever read. The block is replaced in place on every
|
|
6898
|
+
* sync, so rule updates reach EXISTING workspaces; everything the user
|
|
6899
|
+
* wrote outside the markers is preserved byte-for-byte.
|
|
6900
|
+
*/
|
|
6901
|
+
syncGeneralSteering(workDir, backend, srcSteering) {
|
|
6902
|
+
if (!existsSync(srcSteering))
|
|
6903
|
+
return;
|
|
6904
|
+
const files = readdirSync(srcSteering).filter(f => f.endsWith(".md")).sort();
|
|
6905
|
+
if (files.length === 0)
|
|
6906
|
+
return;
|
|
6907
|
+
if (backend === "kiro-cli") {
|
|
6908
|
+
const steeringDir = join(workDir, ".kiro", "steering");
|
|
6909
|
+
mkdirSync(steeringDir, { recursive: true });
|
|
6910
|
+
for (const file of files) {
|
|
5589
6911
|
const dest = join(steeringDir, file);
|
|
5590
|
-
const newContent = readFileSync(
|
|
6912
|
+
const newContent = readFileSync(join(srcSteering, file), "utf-8");
|
|
5591
6913
|
try {
|
|
5592
6914
|
if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
|
|
5593
6915
|
continue;
|
|
5594
6916
|
}
|
|
5595
|
-
catch { }
|
|
6917
|
+
catch { /* rewrite */ }
|
|
5596
6918
|
writeFileSync(dest, newContent);
|
|
5597
6919
|
}
|
|
6920
|
+
return;
|
|
5598
6921
|
}
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
|
|
5609
|
-
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
5616
|
-
|
|
5617
|
-
|
|
5618
|
-
|
|
5619
|
-
|
|
5620
|
-
|
|
6922
|
+
const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
|
|
6923
|
+
const instructionsPath = join(workDir, filename);
|
|
6924
|
+
const body = files.map(f => readFileSync(join(srcSteering, f), "utf-8").trim()).join("\n\n");
|
|
6925
|
+
const block = `${FleetManager.STEERING_BLOCK_BEGIN}\n${body}\n${FleetManager.STEERING_BLOCK_END}`;
|
|
6926
|
+
let existing = "";
|
|
6927
|
+
try {
|
|
6928
|
+
existing = existsSync(instructionsPath) ? readFileSync(instructionsPath, "utf-8") : "";
|
|
6929
|
+
}
|
|
6930
|
+
catch { /* treat as empty */ }
|
|
6931
|
+
const beginAt = existing.indexOf(FleetManager.STEERING_BLOCK_BEGIN);
|
|
6932
|
+
const endAt = existing.indexOf(FleetManager.STEERING_BLOCK_END);
|
|
6933
|
+
let next;
|
|
6934
|
+
if (beginAt !== -1 && endAt !== -1 && endAt > beginAt) {
|
|
6935
|
+
next = existing.slice(0, beginAt) + block + existing.slice(endAt + FleetManager.STEERING_BLOCK_END.length);
|
|
6936
|
+
}
|
|
6937
|
+
else {
|
|
6938
|
+
next = existing.trimEnd() + (existing.trim() ? "\n\n" : "") + block + "\n";
|
|
6939
|
+
}
|
|
6940
|
+
if (next !== existing) {
|
|
6941
|
+
mkdirSync(dirname(instructionsPath), { recursive: true });
|
|
6942
|
+
writeFileSync(instructionsPath, next);
|
|
6943
|
+
}
|
|
6944
|
+
}
|
|
6945
|
+
static STEERING_BLOCK_BEGIN = "<!-- >>> agend:core-rules — managed by AgEnD; edits inside this block are overwritten -->";
|
|
6946
|
+
static STEERING_BLOCK_END = "<!-- <<< agend:core-rules -->";
|
|
6947
|
+
/**
|
|
6948
|
+
* Publish AgEnD's bundled skills into a CLI's native skills directory,
|
|
6949
|
+
* owning ONLY what we published. A manifest records which skill names AgEnD
|
|
6950
|
+
* wrote; a bundled rename/removal deletes the stale managed copy, while a
|
|
6951
|
+
* skill the user created by hand is never listed and therefore never
|
|
6952
|
+
* touched — even if a future bundle happens to reuse its name (the sync
|
|
6953
|
+
* then skips it and logs, rather than overwrite the user's work). The sole
|
|
6954
|
+
* migration exception is the pre-manifest Kiro General layout explicitly
|
|
6955
|
+
* selected by adoptLegacyUnmanaged.
|
|
6956
|
+
*/
|
|
6957
|
+
syncManagedSkills(destSkills, srcSkills, role, adoptLegacyUnmanaged = false) {
|
|
6958
|
+
if (!existsSync(srcSkills))
|
|
6959
|
+
return;
|
|
6960
|
+
mkdirSync(destSkills, { recursive: true });
|
|
6961
|
+
const manifestPath = join(destSkills, ".agend-managed-skills.json");
|
|
6962
|
+
const hadManifest = existsSync(manifestPath);
|
|
6963
|
+
let previouslyManaged = [];
|
|
6964
|
+
try {
|
|
6965
|
+
const parsed = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
6966
|
+
if (Array.isArray(parsed))
|
|
6967
|
+
previouslyManaged = parsed.filter(n => typeof n === "string");
|
|
6968
|
+
}
|
|
6969
|
+
catch { /* first sync, or corrupt manifest — treat as owning nothing */ }
|
|
6970
|
+
const bundled = readdirSync(srcSkills)
|
|
6971
|
+
.filter(name => existsSync(join(srcSkills, name, "SKILL.md")))
|
|
6972
|
+
.sort();
|
|
6973
|
+
// Pre-manifest Kiro General copied bundled skills directly into
|
|
6974
|
+
// .kiro/skills. If every existing skill is still a bundled name, this is
|
|
6975
|
+
// the unambiguous legacy layout: adopt it once, update it below, and write
|
|
6976
|
+
// the ownership manifest. Any extra skill name keeps the directory fully
|
|
6977
|
+
// on the user-owned/collision path.
|
|
6978
|
+
if (!hadManifest && adoptLegacyUnmanaged) {
|
|
6979
|
+
const existing = readdirSync(destSkills, { withFileTypes: true })
|
|
6980
|
+
.filter(entry => entry.isDirectory() && existsSync(join(destSkills, entry.name, "SKILL.md")))
|
|
6981
|
+
.map(entry => entry.name)
|
|
6982
|
+
.sort();
|
|
6983
|
+
if (existing.length > 0 && existing.every(name => bundled.includes(name))) {
|
|
6984
|
+
previouslyManaged = existing;
|
|
6985
|
+
this.logger.info({ skills: existing, destSkills }, "Adopted legacy AgEnD skills into managed ownership");
|
|
6986
|
+
}
|
|
6987
|
+
}
|
|
6988
|
+
const eligible = bundled.filter(name => {
|
|
6989
|
+
const roles = this.readManagedSkillRoles(join(srcSkills, name, "SKILL.md"));
|
|
6990
|
+
// General is the coordinator and receives both coordinator and worker
|
|
6991
|
+
// playbooks. Workers receive only skills explicitly marked for workers.
|
|
6992
|
+
return role === "general" || roles.includes("worker");
|
|
6993
|
+
});
|
|
6994
|
+
const managed = [];
|
|
6995
|
+
for (const name of eligible) {
|
|
6996
|
+
const destDir = join(destSkills, name);
|
|
6997
|
+
const dest = join(destDir, "SKILL.md");
|
|
6998
|
+
const isOurs = previouslyManaged.includes(name) || !existsSync(destDir);
|
|
6999
|
+
if (!isOurs) {
|
|
7000
|
+
// Name collision with a user-authored skill: theirs wins, loudly.
|
|
7001
|
+
this.logger.warn({ skill: name, destSkills }, "Skipping bundled skill — a skill of this name exists but was not published by AgEnD");
|
|
7002
|
+
continue;
|
|
7003
|
+
}
|
|
7004
|
+
managed.push(name);
|
|
7005
|
+
const newContent = readFileSync(join(srcSkills, name, "SKILL.md"), "utf-8");
|
|
7006
|
+
try {
|
|
7007
|
+
if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
|
|
7008
|
+
continue;
|
|
5621
7009
|
}
|
|
7010
|
+
catch { /* rewrite */ }
|
|
7011
|
+
mkdirSync(destDir, { recursive: true });
|
|
7012
|
+
writeFileSync(dest, newContent);
|
|
7013
|
+
}
|
|
7014
|
+
// Remove managed skills that are no longer bundled OR no longer eligible
|
|
7015
|
+
// for this role. This makes a shared → general-only metadata change take
|
|
7016
|
+
// effect on the next worker startup instead of leaving stale capability.
|
|
7017
|
+
for (const stale of previouslyManaged) {
|
|
7018
|
+
if (eligible.includes(stale))
|
|
7019
|
+
continue;
|
|
7020
|
+
try {
|
|
7021
|
+
rmSync(join(destSkills, stale), { recursive: true, force: true });
|
|
7022
|
+
this.logger.info({ skill: stale, destSkills }, "Removed retired AgEnD-managed skill");
|
|
7023
|
+
}
|
|
7024
|
+
catch (err) {
|
|
7025
|
+
this.logger.debug({ err, skill: stale }, "Failed to remove retired managed skill");
|
|
7026
|
+
}
|
|
7027
|
+
}
|
|
7028
|
+
try {
|
|
7029
|
+
writeFileSync(manifestPath, JSON.stringify(managed, null, 2) + "\n");
|
|
7030
|
+
}
|
|
7031
|
+
catch (err) {
|
|
7032
|
+
this.logger.debug({ err, manifestPath }, "Failed to write managed-skills manifest");
|
|
7033
|
+
}
|
|
7034
|
+
}
|
|
7035
|
+
/** Read AgEnD's roles extension from SKILL.md YAML frontmatter. */
|
|
7036
|
+
readManagedSkillRoles(skillPath) {
|
|
7037
|
+
const fallback = ["general"];
|
|
7038
|
+
try {
|
|
7039
|
+
const content = readFileSync(skillPath, "utf-8");
|
|
7040
|
+
const match = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
7041
|
+
if (!match)
|
|
7042
|
+
return fallback;
|
|
7043
|
+
const document = parseDocument(match[1]);
|
|
7044
|
+
if (document.errors.length > 0)
|
|
7045
|
+
throw document.errors[0];
|
|
7046
|
+
const frontmatter = document.toJS();
|
|
7047
|
+
if (!frontmatter || frontmatter.roles === undefined)
|
|
7048
|
+
return fallback;
|
|
7049
|
+
if (!Array.isArray(frontmatter.roles))
|
|
7050
|
+
throw new Error("roles must be an array");
|
|
7051
|
+
const roles = [...new Set(frontmatter.roles)]
|
|
7052
|
+
.filter((value) => value === "general" || value === "worker");
|
|
7053
|
+
if (roles.length !== frontmatter.roles.length || roles.length === 0) {
|
|
7054
|
+
throw new Error("roles must contain only general and/or worker");
|
|
7055
|
+
}
|
|
7056
|
+
return roles;
|
|
7057
|
+
}
|
|
7058
|
+
catch (err) {
|
|
7059
|
+
// Fail closed for workers: malformed or unknown metadata keeps the
|
|
7060
|
+
// backwards-compatible General-only behavior instead of leaking an
|
|
7061
|
+
// administrative skill into worker workspaces.
|
|
7062
|
+
this.logger.warn({ err, skillPath }, "Invalid bundled skill roles — defaulting to General only");
|
|
7063
|
+
return fallback;
|
|
5622
7064
|
}
|
|
5623
|
-
this.logger.debug({ knowledgeDir, steeringDir }, "Synced general knowledge files");
|
|
5624
7065
|
}
|
|
5625
7066
|
/** Fetch forum topic icon stickers and pick emoji IDs for each state */
|
|
5626
7067
|
async resolveTopicIcons() {
|
|
@@ -6052,6 +7493,24 @@ When users create specialized instances, suggest these configurations:
|
|
|
6052
7493
|
return null;
|
|
6053
7494
|
const probed = await be.probeCLIEnv({ workingDirectory: "", instanceDir: join(getAgendHome(), "cli-env"), instanceName: `probe-${backend}`, mcpServers: {} });
|
|
6054
7495
|
const env = { backend, probedAt: Date.now(), ...probed };
|
|
7496
|
+
// An empty result must never overwrite a catalog we already have. Some
|
|
7497
|
+
// probes hit the network (`agy models` fetches, 5s cap), so a slow moment
|
|
7498
|
+
// returns [] — and writing that would blank the list for the whole 24h
|
|
7499
|
+
// TTL, long after the CLI recovered. Observed live: a good 11-model
|
|
7500
|
+
// antigravity cache replaced by an empty one. Keep the known models and
|
|
7501
|
+
// let the fresher currentModel/version through.
|
|
7502
|
+
if (!env.models?.length) {
|
|
7503
|
+
const previous = this.readCliEnv(backend);
|
|
7504
|
+
if (previous?.models?.length)
|
|
7505
|
+
env.models = previous.models;
|
|
7506
|
+
}
|
|
7507
|
+
// Same protection for the extended catalog: one offline moment must not
|
|
7508
|
+
// blank a good account list for the whole cache TTL.
|
|
7509
|
+
if (!env.apiModels?.length) {
|
|
7510
|
+
const previous = this.readCliEnv(backend);
|
|
7511
|
+
if (previous?.apiModels?.length)
|
|
7512
|
+
env.apiModels = previous.apiModels;
|
|
7513
|
+
}
|
|
6055
7514
|
const path = this.cliEnvPath(backend);
|
|
6056
7515
|
mkdirSync(dirname(path), { recursive: true });
|
|
6057
7516
|
writeFileSync(path, JSON.stringify(env, null, 2));
|
|
@@ -6090,6 +7549,108 @@ When users create specialized instances, suggest these configurations:
|
|
|
6090
7549
|
const env = await this.probeBackend(backendName);
|
|
6091
7550
|
return env?.models ?? [];
|
|
6092
7551
|
}
|
|
7552
|
+
/**
|
|
7553
|
+
* Model catalog behind the `list_models` tool.
|
|
7554
|
+
*
|
|
7555
|
+
* The two scopes are not cosmetic. "global" is the account/CLI catalog served
|
|
7556
|
+
* from the startup probe cache; "instance" is resolved through that instance's
|
|
7557
|
+
* OWN backend config, and for a Codex instance on a custom provider that is a
|
|
7558
|
+
* different catalog entirely — `listModels()` reads models_cache.json out of
|
|
7559
|
+
* the instance's private CODEX_HOME. Answering such an instance with the
|
|
7560
|
+
* account list would name models its CLI rejects, which is exactly the
|
|
7561
|
+
* mistake this tool exists to prevent.
|
|
7562
|
+
*
|
|
7563
|
+
* `scope` always describes where the returned LIST came from, not what was
|
|
7564
|
+
* asked for: an instance query that falls back to the account catalog reports
|
|
7565
|
+
* scope "global" and says so in `note`, rather than implying instance-level
|
|
7566
|
+
* accuracy it does not have.
|
|
7567
|
+
*
|
|
7568
|
+
* Never throws — a model listing is an aid, and failing it must not fail a turn.
|
|
7569
|
+
*/
|
|
7570
|
+
async listModelCatalog(opts = {}) {
|
|
7571
|
+
const { instanceName } = opts;
|
|
7572
|
+
if (instanceName) {
|
|
7573
|
+
const backend = this.backendNameForInstance(instanceName);
|
|
7574
|
+
const resolved = this.resolveInstanceModel(instanceName);
|
|
7575
|
+
const currentModel = resolved.source === "unresolved" ? null : resolved.model;
|
|
7576
|
+
const provider = this.customProviderFor(instanceName, backend);
|
|
7577
|
+
const scoped = await this.instanceScopedModels(instanceName, backend);
|
|
7578
|
+
if (scoped.length) {
|
|
7579
|
+
return {
|
|
7580
|
+
backend, scope: "instance", instance: instanceName,
|
|
7581
|
+
current_model: currentModel, models: scoped, source: "live",
|
|
7582
|
+
...(provider ? { note: `Catalog read through this instance's ${backend} provider "${provider}" — it may differ from the account catalog.` } : {}),
|
|
7583
|
+
};
|
|
7584
|
+
}
|
|
7585
|
+
// No instance-local catalog (never launched, or the backend has no
|
|
7586
|
+
// per-instance list). The account catalog is the best available answer,
|
|
7587
|
+
// but it is labelled honestly rather than dressed up as instance scope.
|
|
7588
|
+
const global = await this.globalModelCatalog(backend);
|
|
7589
|
+
return {
|
|
7590
|
+
...global, instance: instanceName, current_model: currentModel,
|
|
7591
|
+
note: provider
|
|
7592
|
+
? `No instance-local catalog yet; showing the account catalog, which may NOT match this instance's ${backend} provider "${provider}".`
|
|
7593
|
+
: "No instance-local catalog yet; showing the account catalog.",
|
|
7594
|
+
};
|
|
7595
|
+
}
|
|
7596
|
+
return this.globalModelCatalog(opts.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
|
|
7597
|
+
}
|
|
7598
|
+
/** The custom provider an instance overrides its backend with, if any. */
|
|
7599
|
+
customProviderFor(instanceName, backend) {
|
|
7600
|
+
const opts = this.fleetConfig?.instances?.[instanceName]?.backend_options?.[backend]
|
|
7601
|
+
?? this.fleetConfig?.defaults?.backend_options?.[backend];
|
|
7602
|
+
const provider = opts?.provider;
|
|
7603
|
+
return typeof provider === "string" && provider.trim() ? provider.trim() : null;
|
|
7604
|
+
}
|
|
7605
|
+
/** Ask a backend for its catalog using ONE instance's real config. Never throws. */
|
|
7606
|
+
async instanceScopedModels(instanceName, backend) {
|
|
7607
|
+
try {
|
|
7608
|
+
const inst = this.fleetConfig?.instances?.[instanceName];
|
|
7609
|
+
const instanceDir = this.getInstanceDir(instanceName);
|
|
7610
|
+
const be = createBackend(backend, instanceDir);
|
|
7611
|
+
if (!be.listModels)
|
|
7612
|
+
return [];
|
|
7613
|
+
return await be.listModels({
|
|
7614
|
+
workingDirectory: inst?.working_directory ?? "",
|
|
7615
|
+
instanceDir,
|
|
7616
|
+
instanceName,
|
|
7617
|
+
mcpServers: {},
|
|
7618
|
+
model: inst?.model,
|
|
7619
|
+
backendOptions: inst?.backend_options?.[backend] ?? this.fleetConfig?.defaults?.backend_options?.[backend],
|
|
7620
|
+
}) ?? [];
|
|
7621
|
+
}
|
|
7622
|
+
catch {
|
|
7623
|
+
// listModels is documented never to throw, but a backend constructor can
|
|
7624
|
+
// (missing binary). A catalog is an aid; degrade to the account list.
|
|
7625
|
+
return [];
|
|
7626
|
+
}
|
|
7627
|
+
}
|
|
7628
|
+
/** Account-wide catalog: probe cache first, live probe on miss. */
|
|
7629
|
+
async globalModelCatalog(backend) {
|
|
7630
|
+
const cached = this.readCliEnv(backend);
|
|
7631
|
+
if (cached?.models?.length) {
|
|
7632
|
+
return {
|
|
7633
|
+
backend, scope: "global", current_model: cached.currentModel ?? null,
|
|
7634
|
+
models: cached.models, source: "cache",
|
|
7635
|
+
probed_at: new Date(cached.probedAt).toISOString(),
|
|
7636
|
+
};
|
|
7637
|
+
}
|
|
7638
|
+
const env = await this.probeBackend(backend);
|
|
7639
|
+
if (env?.models?.length) {
|
|
7640
|
+
return {
|
|
7641
|
+
backend, scope: "global", current_model: env.currentModel ?? null,
|
|
7642
|
+
models: env.models, source: "live",
|
|
7643
|
+
probed_at: new Date(env.probedAt).toISOString(),
|
|
7644
|
+
};
|
|
7645
|
+
}
|
|
7646
|
+
// Reported rather than thrown: "we could not enumerate" is a useful answer,
|
|
7647
|
+
// and the caller can still set a model by name (AgEnD passes it through).
|
|
7648
|
+
return {
|
|
7649
|
+
backend, scope: "global", current_model: env?.currentModel ?? null,
|
|
7650
|
+
models: [], source: "fallback",
|
|
7651
|
+
note: `Could not enumerate models for ${backend} (CLI missing, not logged in, or it offers no list). Model names are passed through to the CLI, so a known-good name still works.`,
|
|
7652
|
+
};
|
|
7653
|
+
}
|
|
6093
7654
|
/** `/model` slash handler (admin only). No arg → DC menu; `/model <name>` → apply directly. */
|
|
6094
7655
|
/** Label an effort choice, marking the one currently configured. */
|
|
6095
7656
|
effortChoiceLabel(level, current) {
|
|
@@ -6098,10 +7659,10 @@ When users create specialized instances, suggest these configurations:
|
|
|
6098
7659
|
effortMenuHeader(instanceName) {
|
|
6099
7660
|
const { effort, source } = this.resolveInstanceEffort(instanceName);
|
|
6100
7661
|
if (!effort)
|
|
6101
|
-
return "
|
|
7662
|
+
return t("effort.current_default");
|
|
6102
7663
|
return source === "fleet-default"
|
|
6103
|
-
?
|
|
6104
|
-
:
|
|
7664
|
+
? t("effort.current_fleet", effort)
|
|
7665
|
+
: t("effort.current", effort);
|
|
6105
7666
|
}
|
|
6106
7667
|
/** `/effort` — DC Select Menu, or apply directly when a level is given. */
|
|
6107
7668
|
async handleEffortSlash(data, adapterId) {
|
|
@@ -6122,11 +7683,11 @@ When users create specialized instances, suggest these configurations:
|
|
|
6122
7683
|
}
|
|
6123
7684
|
const levels = this.effortLevelsFor(name);
|
|
6124
7685
|
if (levels.length === 0) {
|
|
6125
|
-
await data.respond(
|
|
7686
|
+
await data.respond(t("effort.unsupported", this.backendNameForInstance(name)));
|
|
6126
7687
|
return;
|
|
6127
7688
|
}
|
|
6128
7689
|
if (!data.respondChoices) {
|
|
6129
|
-
await data.respond(
|
|
7690
|
+
await data.respond(t("effort.usage", levels.join("|")));
|
|
6130
7691
|
return;
|
|
6131
7692
|
}
|
|
6132
7693
|
const current = this.resolveInstanceEffort(name).effort;
|
|
@@ -6139,20 +7700,20 @@ When users create specialized instances, suggest these configurations:
|
|
|
6139
7700
|
timer.unref?.();
|
|
6140
7701
|
this.pendingEffortSelects.set(nonce, { instanceName: name, userId: data.userId, channelId: data.channelId, timer, respond: data.respond });
|
|
6141
7702
|
try {
|
|
6142
|
-
await data.respondChoices(
|
|
7703
|
+
await data.respondChoices(t("effort.menu", this.effortMenuHeader(name)), choices);
|
|
6143
7704
|
}
|
|
6144
7705
|
catch (err) {
|
|
6145
7706
|
this.pendingEffortSelects.delete(nonce);
|
|
6146
7707
|
clearTimeout(timer);
|
|
6147
7708
|
this.logger.warn({ err, instanceName: name }, "effort menu failed");
|
|
6148
|
-
await data.respond(
|
|
7709
|
+
await data.respond(t("effort.usage", levels.join("|")));
|
|
6149
7710
|
}
|
|
6150
7711
|
}
|
|
6151
7712
|
/** TG inline-keyboard effort menu. Returns null on success, else a fallback string. */
|
|
6152
7713
|
async promptEffortMenu(instanceName, userId, channelId, adapter, chatId, threadId) {
|
|
6153
7714
|
const levels = this.effortLevelsFor(instanceName);
|
|
6154
7715
|
if (levels.length === 0) {
|
|
6155
|
-
return
|
|
7716
|
+
return t("effort.unsupported", this.backendNameForInstance(instanceName));
|
|
6156
7717
|
}
|
|
6157
7718
|
const current = this.resolveInstanceEffort(instanceName).effort;
|
|
6158
7719
|
const nonce = randomBytes(6).toString("hex");
|
|
@@ -6168,13 +7729,13 @@ When users create specialized instances, suggest these configurations:
|
|
|
6168
7729
|
const p = this.pendingEffortSelects.get(nonce);
|
|
6169
7730
|
if (p) {
|
|
6170
7731
|
this.pendingEffortSelects.delete(nonce);
|
|
6171
|
-
p.respond("
|
|
7732
|
+
p.respond(t("effort.selection_expired")).catch(() => { });
|
|
6172
7733
|
}
|
|
6173
7734
|
}, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
6174
7735
|
timer.unref?.();
|
|
6175
7736
|
this.pendingEffortSelects.set(nonce, { instanceName, userId, channelId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
6176
7737
|
try {
|
|
6177
|
-
const menuMessageId = await adapter.promptUser(chatId,
|
|
7738
|
+
const menuMessageId = await adapter.promptUser(chatId, t("effort.menu", this.effortMenuHeader(instanceName)), choices, { threadId });
|
|
6178
7739
|
const pending = this.pendingEffortSelects.get(nonce);
|
|
6179
7740
|
if (pending)
|
|
6180
7741
|
pending.menuMessageId = menuMessageId;
|
|
@@ -6184,7 +7745,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6184
7745
|
this.pendingEffortSelects.delete(nonce);
|
|
6185
7746
|
clearTimeout(timer);
|
|
6186
7747
|
this.logger.warn({ err, instanceName }, "TG effort menu failed");
|
|
6187
|
-
return
|
|
7748
|
+
return t("effort.usage", levels.join("|"));
|
|
6188
7749
|
}
|
|
6189
7750
|
}
|
|
6190
7751
|
/** Consume an `/effort` selection callback. Mirrors handleModelSelection. */
|
|
@@ -6205,7 +7766,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6205
7766
|
this.pendingEffortSelects.delete(match[1]);
|
|
6206
7767
|
clearTimeout(pending.timer);
|
|
6207
7768
|
const level = match[2];
|
|
6208
|
-
const progressText =
|
|
7769
|
+
const progressText = t("effort.setting", pending.instanceName, level);
|
|
6209
7770
|
let progressMsgId;
|
|
6210
7771
|
if (pending.adapter && pending.adapterChatId) {
|
|
6211
7772
|
const menuMessageId = pending.menuMessageId ?? data.messageId;
|
|
@@ -6237,7 +7798,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6237
7798
|
}
|
|
6238
7799
|
catch (err) {
|
|
6239
7800
|
this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
|
|
6240
|
-
result =
|
|
7801
|
+
result = t("effort.switch_failed", level, err instanceof Error ? err.message : String(err));
|
|
6241
7802
|
}
|
|
6242
7803
|
if (pending.adapter && pending.adapterChatId) {
|
|
6243
7804
|
if (progressMsgId) {
|
|
@@ -6274,32 +7835,36 @@ When users create specialized instances, suggest these configurations:
|
|
|
6274
7835
|
}
|
|
6275
7836
|
// No arg (or --refresh) → menu. Menu is DC-only this round (respondChoices); TG uses `/model <name>`.
|
|
6276
7837
|
if (!data.respondChoices) {
|
|
6277
|
-
await data.respond("
|
|
7838
|
+
await data.respond(t("model.usage"));
|
|
6278
7839
|
return;
|
|
6279
7840
|
}
|
|
6280
7841
|
const options = await this.getModelOptions(name, isRefresh);
|
|
6281
7842
|
if (options.length === 0) {
|
|
6282
|
-
await data.respond(
|
|
7843
|
+
await data.respond(t("model.list_unavailable", name));
|
|
6283
7844
|
return;
|
|
6284
7845
|
}
|
|
6285
7846
|
// Raw id for ✓-matching options; display resolves an inherited CLI default.
|
|
6286
7847
|
const { model: currentModel, display: currentDisplay } = this.resolveInstanceModel(name);
|
|
6287
7848
|
const nonce = randomBytes(6).toString("hex");
|
|
6288
|
-
const
|
|
7849
|
+
const isClaude = this.backendNameForInstance(name) === "claude-code";
|
|
7850
|
+
const choices = options.slice(0, isClaude ? 24 : 25).map(o => ({
|
|
6289
7851
|
id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`,
|
|
6290
7852
|
label: this.modelChoiceLabel(o, currentModel),
|
|
6291
7853
|
}));
|
|
7854
|
+
if (isClaude) {
|
|
7855
|
+
choices.push({ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:__more__`, label: t("model.more") });
|
|
7856
|
+
}
|
|
6292
7857
|
const timer = setTimeout(() => this.pendingModelSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
6293
7858
|
timer.unref?.();
|
|
6294
|
-
this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, timer, respond: data.respond });
|
|
7859
|
+
this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, timer, respond: data.respond, respondChoices: data.respondChoices });
|
|
6295
7860
|
try {
|
|
6296
|
-
await data.respondChoices(
|
|
7861
|
+
await data.respondChoices(t("model.menu", `**${currentDisplay}**`), choices);
|
|
6297
7862
|
}
|
|
6298
7863
|
catch (err) {
|
|
6299
7864
|
this.pendingModelSelects.delete(nonce);
|
|
6300
7865
|
clearTimeout(timer);
|
|
6301
7866
|
this.logger.warn({ err, instanceName: name }, "model menu failed");
|
|
6302
|
-
await data.respond("
|
|
7867
|
+
await data.respond(t("model.usage"));
|
|
6303
7868
|
}
|
|
6304
7869
|
}
|
|
6305
7870
|
/**
|
|
@@ -6310,14 +7875,19 @@ When users create specialized instances, suggest these configurations:
|
|
|
6310
7875
|
async promptModelMenu(instanceName, userId, channelId, adapter, chatId, threadId) {
|
|
6311
7876
|
const options = await this.getModelOptions(instanceName);
|
|
6312
7877
|
if (options.length === 0) {
|
|
6313
|
-
return
|
|
7878
|
+
return t("model.list_unavailable", instanceName);
|
|
6314
7879
|
}
|
|
6315
7880
|
const { model: currentModel, display: currentDisplay } = this.resolveInstanceModel(instanceName);
|
|
6316
7881
|
const nonce = randomBytes(6).toString("hex");
|
|
6317
|
-
const
|
|
7882
|
+
const isClaude = this.backendNameForInstance(instanceName) === "claude-code";
|
|
7883
|
+
// Keep the more-models entry inside Discord's 25-option select cap.
|
|
7884
|
+
const choices = options.slice(0, isClaude ? 24 : 25).map(o => ({
|
|
6318
7885
|
id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`,
|
|
6319
7886
|
label: this.modelChoiceLabel(o, currentModel),
|
|
6320
7887
|
}));
|
|
7888
|
+
if (isClaude) {
|
|
7889
|
+
choices.push({ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:__more__`, label: t("model.more") });
|
|
7890
|
+
}
|
|
6321
7891
|
const respond = async (text) => {
|
|
6322
7892
|
await adapter.sendText(chatId, text, { threadId });
|
|
6323
7893
|
return undefined;
|
|
@@ -6326,13 +7896,13 @@ When users create specialized instances, suggest these configurations:
|
|
|
6326
7896
|
const p = this.pendingModelSelects.get(nonce);
|
|
6327
7897
|
if (p) {
|
|
6328
7898
|
this.pendingModelSelects.delete(nonce);
|
|
6329
|
-
p.respond("
|
|
7899
|
+
p.respond(t("model.selection_expired")).catch(() => { });
|
|
6330
7900
|
}
|
|
6331
7901
|
}, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
6332
7902
|
timer.unref?.();
|
|
6333
7903
|
this.pendingModelSelects.set(nonce, { instanceName, model: "", userId, channelId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
6334
7904
|
try {
|
|
6335
|
-
const menuMessageId = await adapter.promptUser(chatId,
|
|
7905
|
+
const menuMessageId = await adapter.promptUser(chatId, t("model.menu", currentDisplay), choices, { threadId });
|
|
6336
7906
|
const pending = this.pendingModelSelects.get(nonce);
|
|
6337
7907
|
if (pending)
|
|
6338
7908
|
pending.menuMessageId = menuMessageId;
|
|
@@ -6342,7 +7912,63 @@ When users create specialized instances, suggest these configurations:
|
|
|
6342
7912
|
this.pendingModelSelects.delete(nonce);
|
|
6343
7913
|
clearTimeout(timer);
|
|
6344
7914
|
this.logger.warn({ err, instanceName }, "TG model menu failed");
|
|
6345
|
-
return
|
|
7915
|
+
return t("model.usage");
|
|
7916
|
+
}
|
|
7917
|
+
}
|
|
7918
|
+
/** Cached-or-live account catalog behind "/model → more models" (claude). */
|
|
7919
|
+
async claudeApiModelOptions() {
|
|
7920
|
+
const cached = this.readCliEnv("claude-code");
|
|
7921
|
+
if (cached?.apiModels?.length)
|
|
7922
|
+
return cached.apiModels;
|
|
7923
|
+
const env = await this.probeBackend("claude-code");
|
|
7924
|
+
return env?.apiModels ?? [];
|
|
7925
|
+
}
|
|
7926
|
+
/** Replace a consumed "/model" menu with the full account catalog tier. */
|
|
7927
|
+
async expandClaudeModelMenu(pending) {
|
|
7928
|
+
const expanded = await this.claudeApiModelOptions();
|
|
7929
|
+
if (!expanded.length) {
|
|
7930
|
+
await pending.respond(t("model.more_unavailable")).catch(() => { });
|
|
7931
|
+
return;
|
|
7932
|
+
}
|
|
7933
|
+
const { model: currentModel, display: currentDisplay } = this.resolveInstanceModel(pending.instanceName);
|
|
7934
|
+
const nonce = randomBytes(6).toString("hex");
|
|
7935
|
+
const choices = expanded.slice(0, 25).map(o => ({
|
|
7936
|
+
id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`,
|
|
7937
|
+
label: this.modelChoiceLabel(o, currentModel),
|
|
7938
|
+
}));
|
|
7939
|
+
const timer = setTimeout(() => {
|
|
7940
|
+
const p = this.pendingModelSelects.get(nonce);
|
|
7941
|
+
if (p) {
|
|
7942
|
+
this.pendingModelSelects.delete(nonce);
|
|
7943
|
+
p.respond(t("model.selection_expired")).catch(() => { });
|
|
7944
|
+
}
|
|
7945
|
+
}, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
7946
|
+
timer.unref?.();
|
|
7947
|
+
this.pendingModelSelects.set(nonce, { ...pending, model: "", timer });
|
|
7948
|
+
try {
|
|
7949
|
+
if (pending.respondChoices) {
|
|
7950
|
+
// Discord select menu: edit the same interaction reply in place.
|
|
7951
|
+
await pending.respondChoices(t("model.menu", `**${currentDisplay}**`), choices);
|
|
7952
|
+
return;
|
|
7953
|
+
}
|
|
7954
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
7955
|
+
// Telegram: retire the tier-1 keyboard, then post the expanded menu.
|
|
7956
|
+
if (pending.menuMessageId && pending.adapter.editMessageRemoveButtons) {
|
|
7957
|
+
await pending.adapter.editMessageRemoveButtons(pending.adapterChatId, pending.menuMessageId, t("model.more"), pending.adapterThreadId).catch(() => { });
|
|
7958
|
+
}
|
|
7959
|
+
const menuMessageId = await pending.adapter.promptUser(pending.adapterChatId, t("model.menu", currentDisplay), choices, { threadId: pending.adapterThreadId });
|
|
7960
|
+
const fresh = this.pendingModelSelects.get(nonce);
|
|
7961
|
+
if (fresh)
|
|
7962
|
+
fresh.menuMessageId = menuMessageId;
|
|
7963
|
+
return;
|
|
7964
|
+
}
|
|
7965
|
+
await pending.respond(t("model.more_unavailable")).catch(() => { });
|
|
7966
|
+
}
|
|
7967
|
+
catch (err) {
|
|
7968
|
+
this.pendingModelSelects.delete(nonce);
|
|
7969
|
+
clearTimeout(timer);
|
|
7970
|
+
this.logger.warn({ err, instanceName: pending.instanceName }, "Expanded model menu failed");
|
|
7971
|
+
await pending.respond(t("model.more_unavailable")).catch(() => { });
|
|
6346
7972
|
}
|
|
6347
7973
|
}
|
|
6348
7974
|
/** Consume a `/model` selection callback. Returns true for all model-select ids (incl. stale). */
|
|
@@ -6364,8 +7990,14 @@ When users create specialized instances, suggest these configurations:
|
|
|
6364
7990
|
this.pendingModelSelects.delete(match[1]);
|
|
6365
7991
|
clearTimeout(pending.timer);
|
|
6366
7992
|
const model = match[2];
|
|
7993
|
+
// "More models…" is a navigation choice, not a model: swap the menu for the
|
|
7994
|
+
// full account catalog (live /v1/models with [1m] variants).
|
|
7995
|
+
if (model === "__more__") {
|
|
7996
|
+
await this.expandClaudeModelMenu(pending);
|
|
7997
|
+
return true;
|
|
7998
|
+
}
|
|
6367
7999
|
// Send immediate "⏳ Switching..." feedback, then apply in background.
|
|
6368
|
-
const progressText =
|
|
8000
|
+
const progressText = t("model.switching", pending.instanceName, model);
|
|
6369
8001
|
let progressMsgId;
|
|
6370
8002
|
if (pending.adapter && pending.adapterChatId) {
|
|
6371
8003
|
// TG path: turn the original menu into the progress message. This both
|
|
@@ -6402,7 +8034,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6402
8034
|
}
|
|
6403
8035
|
catch (err) {
|
|
6404
8036
|
this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
|
|
6405
|
-
result =
|
|
8037
|
+
result = t("model.switch_failed", model, err instanceof Error ? err.message : String(err));
|
|
6406
8038
|
}
|
|
6407
8039
|
if (pending.adapter && pending.adapterChatId) {
|
|
6408
8040
|
if (progressMsgId) {
|
|
@@ -6499,17 +8131,17 @@ When users create specialized instances, suggest these configurations:
|
|
|
6499
8131
|
}
|
|
6500
8132
|
catch { /* treated as unsupported below */ }
|
|
6501
8133
|
if (strategy === "unsupported" || supported.length === 0) {
|
|
6502
|
-
return
|
|
8134
|
+
return t("effort.unsupported", backendName);
|
|
6503
8135
|
}
|
|
6504
8136
|
if (!FleetManager.EFFORT_LEVELS.includes(level)) {
|
|
6505
|
-
return
|
|
8137
|
+
return t("effort.unknown", level, FleetManager.EFFORT_LEVELS.join(", "));
|
|
6506
8138
|
}
|
|
6507
8139
|
const applied = FleetManager.clampEffort(level, supported);
|
|
6508
8140
|
if (!applied)
|
|
6509
|
-
return
|
|
8141
|
+
return t("effort.no_canonical", backendName);
|
|
6510
8142
|
const warn = applied === level
|
|
6511
8143
|
? ""
|
|
6512
|
-
:
|
|
8144
|
+
: t("effort.clamped", applied, level, backendName);
|
|
6513
8145
|
// Persist either way: a runtime switch must survive the next respawn too,
|
|
6514
8146
|
// or the instance silently reverts on restart.
|
|
6515
8147
|
if (this.fleetConfig?.instances[instanceName]) {
|
|
@@ -6518,12 +8150,12 @@ When users create specialized instances, suggest these configurations:
|
|
|
6518
8150
|
}
|
|
6519
8151
|
if (strategy === "runtime") {
|
|
6520
8152
|
if (!this.instanceIpcClients.get(instanceName))
|
|
6521
|
-
return `${warn}
|
|
8153
|
+
return `${warn}${t("effort.not_running", instanceName)}`;
|
|
6522
8154
|
this.pasteRawToClassicInstance(instanceName, `/effort ${applied}`);
|
|
6523
|
-
return `${warn}
|
|
8155
|
+
return `${warn}${t("effort.runtime_success", instanceName, applied)}`;
|
|
6524
8156
|
}
|
|
6525
8157
|
await this.restartSingleInstance(instanceName);
|
|
6526
|
-
return `${warn}
|
|
8158
|
+
return `${warn}${t("effort.restart_success", instanceName, applied)}`;
|
|
6527
8159
|
}
|
|
6528
8160
|
async applyModel(instanceName, model) {
|
|
6529
8161
|
const backendName = this.backendNameForInstance(instanceName);
|
|
@@ -6532,14 +8164,12 @@ When users create specialized instances, suggest these configurations:
|
|
|
6532
8164
|
strategy = createBackend(backendName, this.getInstanceDir(instanceName)).getModelSwitchStrategy?.(model) ?? "restart";
|
|
6533
8165
|
}
|
|
6534
8166
|
catch { /* default restart */ }
|
|
6535
|
-
const warn = isModelCompatible(backendName, model) ? "" :
|
|
6536
|
-
if (strategy === "runtime") {
|
|
6537
|
-
|
|
6538
|
-
return `${warn}❌ ${instanceName} is not running.`;
|
|
6539
|
-
this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
|
|
6540
|
-
return `${warn}✅ Switched ${instanceName} to \`${model}\` (runtime).${this.effortSuffix(instanceName)}`;
|
|
8167
|
+
const warn = isModelCompatible(backendName, model) ? "" : t("model.pattern_warning", model, backendName);
|
|
8168
|
+
if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
|
|
8169
|
+
return `${warn}${t("effort.not_running", instanceName)}`;
|
|
6541
8170
|
}
|
|
6542
|
-
//
|
|
8171
|
+
// Persist either way: a runtime switch must survive the next respawn too,
|
|
8172
|
+
// or the instance silently reverts to the CLI default after a fleet restart.
|
|
6543
8173
|
let persisted = false;
|
|
6544
8174
|
if (this.fleetConfig?.instances[instanceName]) {
|
|
6545
8175
|
this.fleetConfig.instances[instanceName].model = model;
|
|
@@ -6550,9 +8180,13 @@ When users create specialized instances, suggest these configurations:
|
|
|
6550
8180
|
persisted = true;
|
|
6551
8181
|
}
|
|
6552
8182
|
if (!persisted)
|
|
6553
|
-
return `${warn}
|
|
8183
|
+
return `${warn}${t("model.persist_failed", instanceName)}`;
|
|
8184
|
+
if (strategy === "runtime") {
|
|
8185
|
+
this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
|
|
8186
|
+
return `${warn}${t("model.runtime_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
|
|
8187
|
+
}
|
|
6554
8188
|
await this.restartSingleInstance(instanceName);
|
|
6555
|
-
return `${warn}
|
|
8189
|
+
return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
|
|
6556
8190
|
}
|
|
6557
8191
|
/**
|
|
6558
8192
|
* The trailing "Current effort: …" line for a /model reply.
|
|
@@ -6566,10 +8200,10 @@ When users create specialized instances, suggest these configurations:
|
|
|
6566
8200
|
return "";
|
|
6567
8201
|
const { effort, source } = this.resolveInstanceEffort(instanceName);
|
|
6568
8202
|
if (!effort)
|
|
6569
|
-
return "
|
|
8203
|
+
return `\n${t("effort.current_default")}`;
|
|
6570
8204
|
return source === "fleet-default"
|
|
6571
|
-
? `\
|
|
6572
|
-
: `\
|
|
8205
|
+
? `\n${t("effort.current_fleet", effort)}`
|
|
8206
|
+
: `\n${t("effort.current", effort)}`;
|
|
6573
8207
|
}
|
|
6574
8208
|
/** Read recent chat log for agent context */
|
|
6575
8209
|
getRecentChatLog(instanceName, maxLines = 10) {
|
|
@@ -6604,7 +8238,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6604
8238
|
/** Return a user-facing blocker without mutating ClassicBot state. */
|
|
6605
8239
|
validateClassicStart(channelId, userId, guildId, adapterId) {
|
|
6606
8240
|
if (!this.classicChannels)
|
|
6607
|
-
return "
|
|
8241
|
+
return t("classic.manager_unavailable");
|
|
6608
8242
|
if (guildId && !this.classicChannels.isGuildAllowed(guildId)) {
|
|
6609
8243
|
const generalId = this.findGeneralInstance(adapterId);
|
|
6610
8244
|
if (generalId)
|
|
@@ -6751,6 +8385,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6751
8385
|
return;
|
|
6752
8386
|
const workDir = join(getAgendHome(), "workspaces", instanceName);
|
|
6753
8387
|
ensureWorkspaceGit(workDir);
|
|
8388
|
+
const classicIdentity = this.classicChannels?.getAll().find(ch => ch.instanceName === instanceName);
|
|
6754
8389
|
const config = {
|
|
6755
8390
|
...DEFAULT_INSTANCE_CONFIG,
|
|
6756
8391
|
...this.fleetConfig?.defaults,
|
|
@@ -6758,6 +8393,8 @@ When users create specialized instances, suggest these configurations:
|
|
|
6758
8393
|
lightweight: true,
|
|
6759
8394
|
...(backend ? { backend } : {}),
|
|
6760
8395
|
...(model ? { model } : {}),
|
|
8396
|
+
...(classicIdentity?.displayName ? { display_name: classicIdentity.displayName } : {}),
|
|
8397
|
+
...(classicIdentity?.description ? { description: classicIdentity.description } : {}),
|
|
6761
8398
|
...(autoPauseAfter !== undefined ? { auto_pause_after: autoPauseAfter } : {}),
|
|
6762
8399
|
...(preTaskCommand ? { pre_task_command: preTaskCommand } : {}),
|
|
6763
8400
|
};
|
|
@@ -6771,7 +8408,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6771
8408
|
return blocker;
|
|
6772
8409
|
const classicChannels = this.classicChannels;
|
|
6773
8410
|
if (!classicChannels)
|
|
6774
|
-
return "
|
|
8411
|
+
return t("classic.manager_unavailable");
|
|
6775
8412
|
const instanceName = classicChannels.deriveInstanceName(channelName || channelId, channelId, adapterId);
|
|
6776
8413
|
clearPausedMarker(this.getInstanceDir(instanceName));
|
|
6777
8414
|
const selectedBackend = isSelectableClassicBackend(backend) ? backend : undefined;
|
|
@@ -6793,7 +8430,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6793
8430
|
/** Handle /stop slash command — unregister classic channel */
|
|
6794
8431
|
async handleClassicStop(channelId, adapterId) {
|
|
6795
8432
|
if (!this.classicChannels)
|
|
6796
|
-
return "
|
|
8433
|
+
return t("classic.manager_unavailable");
|
|
6797
8434
|
const ch = this.classicChannels.unregister(channelId, adapterId);
|
|
6798
8435
|
if (!ch)
|
|
6799
8436
|
return t("classic.no_agent");
|
|
@@ -6840,6 +8477,8 @@ When users create specialized instances, suggest these configurations:
|
|
|
6840
8477
|
this.clearStatuslineWatchers();
|
|
6841
8478
|
this.costGuard?.stop();
|
|
6842
8479
|
this.dailySummary?.stop();
|
|
8480
|
+
this.dailyTipScheduler?.stop();
|
|
8481
|
+
this.dailyTipScheduler = null;
|
|
6843
8482
|
if (this.updateCheckTimer) {
|
|
6844
8483
|
clearTimeout(this.updateCheckTimer);
|
|
6845
8484
|
clearInterval(this.updateCheckTimer);
|
|
@@ -7057,7 +8696,8 @@ When users create specialized instances, suggest these configurations:
|
|
|
7057
8696
|
/**
|
|
7058
8697
|
* Hot-reload: re-read fleet.yaml and reconcile running instances.
|
|
7059
8698
|
* Starts new, stops removed, restarts modified instances.
|
|
7060
|
-
*
|
|
8699
|
+
* Whitelisted runtime fields are pushed into live daemons; all other instance
|
|
8700
|
+
* fields, plus cold fleet-level settings, retain restart semantics.
|
|
7061
8701
|
*/
|
|
7062
8702
|
async reconcileInstances() {
|
|
7063
8703
|
if (!this.configPath)
|
|
@@ -7099,11 +8739,11 @@ When users create specialized instances, suggest these configurations:
|
|
|
7099
8739
|
// possible outcome: they change fleet.yaml, send SIGHUP, and nothing happens
|
|
7100
8740
|
// with no explanation anywhere they are looking.
|
|
7101
8741
|
const why = !validation.valid
|
|
7102
|
-
?
|
|
8742
|
+
? t("fleet.reload_validation", validation.errors.map(e => `• ${e.path}: ${e.message}`).join("\n"))
|
|
7103
8743
|
: unsafeEmpty
|
|
7104
|
-
?
|
|
7105
|
-
:
|
|
7106
|
-
this.notifyFleetError(
|
|
8744
|
+
? t("fleet.reload_removed_all", oldCount)
|
|
8745
|
+
: t("fleet.reload_removed_half", oldCount, newCount);
|
|
8746
|
+
this.notifyFleetError(t("fleet.reload_rejected", why));
|
|
7107
8747
|
return;
|
|
7108
8748
|
}
|
|
7109
8749
|
this.routing.rebuild(this.fleetConfig);
|
|
@@ -7111,9 +8751,20 @@ When users create specialized instances, suggest these configurations:
|
|
|
7111
8751
|
this.scheduler?.reload();
|
|
7112
8752
|
const newInstances = this.fleetConfig.instances;
|
|
7113
8753
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
7114
|
-
// Detect fleet-level
|
|
7115
|
-
|
|
7116
|
-
const
|
|
8754
|
+
// Detect fleet-level changes which still need a restart. Hot defaults are
|
|
8755
|
+
// reconciled below and must not produce a misleading restart warning.
|
|
8756
|
+
const oldDefaultColdWithFleetFields = oldConfig?.defaults
|
|
8757
|
+
? splitHotColdConfig(oldConfig.defaults).cold
|
|
8758
|
+
: {};
|
|
8759
|
+
const newDefaultColdWithFleetFields = this.fleetConfig?.defaults
|
|
8760
|
+
? splitHotColdConfig(this.fleetConfig.defaults).cold
|
|
8761
|
+
: {};
|
|
8762
|
+
// `tips` is a fleet-owned hot switch; it is read at post time and never
|
|
8763
|
+
// belongs in a daemon config or a "restart required" warning.
|
|
8764
|
+
const { tips: _oldTips, ...oldDefaultCold } = oldDefaultColdWithFleetFields;
|
|
8765
|
+
const { tips: _newTips, ...newDefaultCold } = newDefaultColdWithFleetFields;
|
|
8766
|
+
const oldFleetLevel = JSON.stringify({ channel: oldConfig?.channel, defaults: oldDefaultCold });
|
|
8767
|
+
const newFleetLevel = JSON.stringify({ channel: this.fleetConfig?.channel, defaults: newDefaultCold });
|
|
7117
8768
|
if (oldFleetLevel !== newFleetLevel) {
|
|
7118
8769
|
this.logger.warn("Fleet-level config changed (channel/defaults) — use /restart for full effect");
|
|
7119
8770
|
}
|
|
@@ -7125,7 +8776,9 @@ When users create specialized instances, suggest these configurations:
|
|
|
7125
8776
|
await this.stopInstance(name).catch(err => this.logger.error({ err, name }, "Failed to stop removed instance"));
|
|
7126
8777
|
}
|
|
7127
8778
|
}
|
|
7128
|
-
// Start new +
|
|
8779
|
+
// Start new + reconcile modified instances. Hot values are always sent as a
|
|
8780
|
+
// complete snapshot: Settings mutates FleetManager's config before SIGHUP,
|
|
8781
|
+
// so an old/new diff alone can miss the live daemon's stale value.
|
|
7129
8782
|
for (const [name, config] of Object.entries(newInstances)) {
|
|
7130
8783
|
if (!this.daemons.has(name)) {
|
|
7131
8784
|
// New instance — startInstance already calls connectIpcToInstance
|
|
@@ -7133,14 +8786,33 @@ When users create specialized instances, suggest these configurations:
|
|
|
7133
8786
|
await this.startInstance(name, config, topicMode).catch(err => this.logger.error({ err, name }, "Failed to start new instance"));
|
|
7134
8787
|
}
|
|
7135
8788
|
else if (oldConfig?.instances[name]) {
|
|
7136
|
-
|
|
7137
|
-
|
|
8789
|
+
const daemon = this.daemons.get(name);
|
|
8790
|
+
const runtimeConfig = daemon.getConfigSnapshot?.() ?? oldConfig.instances[name];
|
|
8791
|
+
const oldParts = splitHotColdConfig(runtimeConfig);
|
|
8792
|
+
const newParts = splitHotColdConfig(config);
|
|
8793
|
+
// Every field not explicitly classified hot is cold by default.
|
|
8794
|
+
if (!isDeepStrictEqual(oldParts.cold, newParts.cold)) {
|
|
7138
8795
|
this.logger.info({ name }, "Instance config changed — restarting");
|
|
7139
8796
|
await this.stopInstance(name).catch(() => { });
|
|
7140
8797
|
await this.startInstance(name, config, topicMode).catch(err => this.logger.error({ err, name }, "Failed to restart modified instance"));
|
|
7141
8798
|
}
|
|
8799
|
+
else if (!isDeepStrictEqual(oldParts.hot, newParts.hot)) {
|
|
8800
|
+
const update = hotConfigUpdate(config);
|
|
8801
|
+
const ipc = this.instanceIpcClients.get(name);
|
|
8802
|
+
const sent = ipc?.connected === true && ipc.send({ type: "config_update", config: update });
|
|
8803
|
+
if (!sent) {
|
|
8804
|
+
// Daemon is in-process, so a reconnect gap must not leave runtime
|
|
8805
|
+
// state stale. Normal operation still uses the explicit IPC contract.
|
|
8806
|
+
daemon.applyConfigUpdate(update);
|
|
8807
|
+
this.logger.warn({ name }, "Config-update IPC unavailable — applied hot config in-process");
|
|
8808
|
+
}
|
|
8809
|
+
this.logger.info({ name, fields: [...HOT_INSTANCE_CONFIG_KEYS] }, "Instance hot config reloaded");
|
|
8810
|
+
}
|
|
7142
8811
|
}
|
|
7143
8812
|
}
|
|
8813
|
+
// warm_cap is fleet-owned; enforce the reloaded value immediately against
|
|
8814
|
+
// currently idle instances instead of waiting for a future state edge.
|
|
8815
|
+
this.enforceWarmCap();
|
|
7144
8816
|
this.logger.info({ running: this.daemons.size, configured: Object.keys(newInstances).length }, "Reconcile complete");
|
|
7145
8817
|
}
|
|
7146
8818
|
async restartInstances() {
|
|
@@ -7380,7 +9052,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
7380
9052
|
if (generalId) {
|
|
7381
9053
|
// No release URL — Discord's SuppressEmbeds proved unreliable and the
|
|
7382
9054
|
// link preview looked bad. Version + /update instruction is enough.
|
|
7383
|
-
this.notifyInstanceTopic(generalId, t("update.
|
|
9055
|
+
this.notifyInstanceTopic(generalId, t("update.available_current", `v${target}`, `v${currentVersion}`));
|
|
7384
9056
|
}
|
|
7385
9057
|
}
|
|
7386
9058
|
}
|
|
@@ -7700,7 +9372,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
7700
9372
|
if (err.code === "EADDRINUSE") {
|
|
7701
9373
|
if (this.healthPortRetried) {
|
|
7702
9374
|
this.logger.error({ err, port }, "Health port still in use after takeover — dashboard disabled");
|
|
7703
|
-
this.notifyFleetError(
|
|
9375
|
+
this.notifyFleetError(t("dashboard.port_in_use", port));
|
|
7704
9376
|
return;
|
|
7705
9377
|
}
|
|
7706
9378
|
this.healthPortRetried = true;
|
|
@@ -7726,7 +9398,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
7726
9398
|
return;
|
|
7727
9399
|
}
|
|
7728
9400
|
this.logger.error({ err, port }, "Health server error");
|
|
7729
|
-
this.notifyFleetError(
|
|
9401
|
+
this.notifyFleetError(t("dashboard.server_failed", err.message));
|
|
7730
9402
|
});
|
|
7731
9403
|
this.healthServer.listen(port, "127.0.0.1", () => markListening());
|
|
7732
9404
|
}
|