@songsid/agend 2.1.4-beta.2 → 2.1.4-beta.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-endpoint.js +1 -1
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/backend/antigravity.d.ts +1 -0
- package/dist/backend/antigravity.js +4 -0
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.js +23 -2
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.d.ts +17 -0
- package/dist/backend/codex.js +122 -7
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/kiro.d.ts +1 -0
- package/dist/backend/kiro.js +14 -0
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/opencode.d.ts +27 -11
- package/dist/backend/opencode.js +48 -37
- package/dist/backend/opencode.js.map +1 -1
- package/dist/backend/types.d.ts +14 -0
- package/dist/backend/types.js +7 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.js +72 -25
- 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/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 +159 -36
- 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 +171 -5
- package/dist/daemon.js +696 -82
- 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 +14 -0
- package/dist/fleet-manager.d.ts +231 -10
- package/dist/fleet-manager.js +1798 -493
- 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 +8 -0
- package/dist/instance-lifecycle.js +46 -25
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.d.ts +12 -0
- package/dist/instructions.js +33 -0
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +656 -220
- package/dist/locale.js.map +1 -1
- 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 +9 -1
- package/dist/restart-progress.js +30 -6
- package/dist/restart-progress.js.map +1 -1
- package/dist/scheduler/db.d.ts +5 -0
- package/dist/scheduler/db.js +35 -0
- package/dist/scheduler/db.js.map +1 -1
- package/dist/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/tips.d.ts +39 -0
- package/dist/tips.js +351 -0
- package/dist/tips.js.map +1 -0
- 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 +28 -4
- package/dist/topic-commands.js +217 -65
- 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 +106 -0
- package/dist/transcript-sources.js +428 -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/update-marker.d.ts +30 -0
- package/dist/update-marker.js +71 -20
- package/dist/update-marker.js.map +1 -1
- package/dist/update-progress.d.ts +6 -0
- package/dist/update-progress.js +29 -0
- package/dist/update-progress.js.map +1 -0
- 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
|
@@ -3,12 +3,14 @@ import { randomBytes } from "node:crypto";
|
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
4
|
import { join, dirname, basename } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
7
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
7
|
-
import { clearUpdateMarker, isUpdateInProgress } from "./update-marker.js";
|
|
8
|
+
import { beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, } from "./update-marker.js";
|
|
9
|
+
import { formatUpdateProgress } from "./update-progress.js";
|
|
8
10
|
import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
|
|
9
11
|
import { readFleetMemory } from "./process-memory.js";
|
|
10
12
|
import { ReplyDeduper } from "./reply-dedup.js";
|
|
11
|
-
import { isScalar, parseDocument } from "yaml";
|
|
13
|
+
import { isMap, isScalar, parseDocument } from "yaml";
|
|
12
14
|
const __filename = fileURLToPath(import.meta.url);
|
|
13
15
|
const __dirname = dirname(__filename);
|
|
14
16
|
/** Fallback access policy for a channel with no `access:` block — open (no gate). */
|
|
@@ -29,7 +31,7 @@ import { processAttachments } from "./channel/attachment-handler.js";
|
|
|
29
31
|
import { routeToolCall } from "./channel/tool-router.js";
|
|
30
32
|
import { Scheduler } from "./scheduler/index.js";
|
|
31
33
|
import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
|
|
32
|
-
import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE,
|
|
34
|
+
import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, resolveInstanceContext, forgetInstanceContext } from "./topic-commands.js";
|
|
33
35
|
import { DailySummary } from "./daily-summary.js";
|
|
34
36
|
import { WebhookEmitter } from "./webhook-emitter.js";
|
|
35
37
|
import { TmuxControlClient } from "./tmux-control.js";
|
|
@@ -41,9 +43,9 @@ import { StatuslineWatcher } from "./statusline-watcher.js";
|
|
|
41
43
|
import { outboundHandlers } from "./outbound-handlers.js";
|
|
42
44
|
import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
|
|
43
45
|
import { handleViewRequest, isViewPath } from "./view-api.js";
|
|
44
|
-
import { handleUsageRequest, isUsagePath } from "./usage/usage-api.js";
|
|
46
|
+
import { handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
|
|
45
47
|
import { handleSettingsRequest } from "./settings-api.js";
|
|
46
|
-
import { setLocale, detectLocale, t } from "./locale.js";
|
|
48
|
+
import { setLocale, detectLocale, getLocale, t } from "./locale.js";
|
|
47
49
|
import { handleAgentRequest } from "./agent-endpoint.js";
|
|
48
50
|
import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
|
|
49
51
|
import { validateFleetConfig } from "./config-validator.js";
|
|
@@ -53,6 +55,8 @@ import { releaseProcessFleetLock } from "./fleet-lock.js";
|
|
|
53
55
|
import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
|
|
54
56
|
import { loadOrCreateWebToken, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
|
|
55
57
|
import { RestartProgress } from "./restart-progress.js";
|
|
58
|
+
import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
|
|
59
|
+
import { canUnlockAdvancedTips, DailyTipScheduler, selectTip } from "./tips.js";
|
|
56
60
|
import { getTmuxSession } from "./config.js";
|
|
57
61
|
export function resolveReplyThreadId(argsThreadId, instanceConfig) {
|
|
58
62
|
if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
|
|
@@ -105,6 +109,8 @@ const CANCEL_BTN_IDLE_RETIRE_GRACE_MS = 2_000;
|
|
|
105
109
|
* button alone (the idle edge retires it when the run really ends).
|
|
106
110
|
*/
|
|
107
111
|
const REPLY_RETIRE_GRACE_MS = 2 * 60_000;
|
|
112
|
+
/** Bound for the daemon to capture the pane and answer a post-reply state query. */
|
|
113
|
+
const REPLY_STATE_REFRESH_TIMEOUT_MS = 2_000;
|
|
108
114
|
/**
|
|
109
115
|
* The daemon only broadcasts execution state on TRANSITIONS, so a long
|
|
110
116
|
* single-state run sends nothing for hours. The idle backstop therefore pokes a
|
|
@@ -134,6 +140,8 @@ const CANCEL_BTN_LEDGER_FILE = "cancel-buttons.json";
|
|
|
134
140
|
* exactly one progress message per turn, and it is the cancel button itself.
|
|
135
141
|
*/
|
|
136
142
|
const PROGRESS_UPDATE_INTERVAL_MS = 60_000;
|
|
143
|
+
/** Floor between tool-progress-driven bubble edits (Telegram flood safety). */
|
|
144
|
+
const TOOL_PROGRESS_EDIT_MIN_MS = 4_000;
|
|
137
145
|
/** Elapsed time is only shown once work has clearly outlasted a quick answer. */
|
|
138
146
|
/**
|
|
139
147
|
* Default delay before the button starts showing elapsed time. Configurable via
|
|
@@ -153,6 +161,36 @@ const PROGRESS_ACTIVITY_MAX_CHARS = 48;
|
|
|
153
161
|
* 🫡 passes too — it reads as a deliberate acknowledgement, not plumbing.
|
|
154
162
|
*/
|
|
155
163
|
const DELIVERY_STATUS_EMOJIS = new Set(["👀", "⏳", "✅", "❌"]);
|
|
164
|
+
/**
|
|
165
|
+
* Reactions that are neither delivery plumbing nor meaningful conversational
|
|
166
|
+
* feedback. Keep this separate from DELIVERY_STATUS_EMOJIS so adding a UI-only
|
|
167
|
+
* emoji never changes the documented delivery-state protocol.
|
|
168
|
+
*/
|
|
169
|
+
const IGNORED_REACTION_EMOJIS = new Set(["📷"]);
|
|
170
|
+
const HOT_INSTANCE_CONFIG_KEYS = new Set([
|
|
171
|
+
"tool_progress",
|
|
172
|
+
"mcp_proxy_reply",
|
|
173
|
+
"auto_pause_after",
|
|
174
|
+
"warm_cap",
|
|
175
|
+
"display_name",
|
|
176
|
+
"description",
|
|
177
|
+
"tags",
|
|
178
|
+
"log_level",
|
|
179
|
+
]);
|
|
180
|
+
function splitHotColdConfig(config) {
|
|
181
|
+
const hot = {};
|
|
182
|
+
const cold = {};
|
|
183
|
+
for (const [key, value] of Object.entries(config)) {
|
|
184
|
+
(HOT_INSTANCE_CONFIG_KEYS.has(key) ? hot : cold)[key] = value;
|
|
185
|
+
}
|
|
186
|
+
return { hot, cold };
|
|
187
|
+
}
|
|
188
|
+
function hotConfigUpdate(config) {
|
|
189
|
+
const update = {};
|
|
190
|
+
for (const key of HOT_INSTANCE_CONFIG_KEYS)
|
|
191
|
+
update[key] = config[key] ?? null;
|
|
192
|
+
return update;
|
|
193
|
+
}
|
|
156
194
|
/**
|
|
157
195
|
* How long a delivery waits out a disconnected instance IPC before giving up.
|
|
158
196
|
*
|
|
@@ -166,7 +204,15 @@ const CLASSIC_BACKEND_CALLBACK_PREFIX = "classic-backend:";
|
|
|
166
204
|
const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
|
|
167
205
|
const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
|
|
168
206
|
const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
|
|
169
|
-
const
|
|
207
|
+
const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
|
|
208
|
+
const HANG_CALLBACK_PREFIX = "hang:";
|
|
209
|
+
const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
|
|
210
|
+
const TIP_DISMISS_CALLBACK_PREFIX = "tip-dismiss:";
|
|
211
|
+
const TIP_UNLOCK_CALLBACK_PREFIX = "tip-unlock:";
|
|
212
|
+
const CLEAR_CONFIRM_TIMEOUT_MS = 15_000;
|
|
213
|
+
/** Default lifetime for long-lived nonce prompts (clear overrides this to 15s). */
|
|
214
|
+
const NONCE_BUTTON_TIMEOUT_MS = 15 * 60_000;
|
|
215
|
+
const TIP_BUTTON_TIMEOUT_MS = 24 * 60 * 60_000;
|
|
170
216
|
const CLI_ENV_TTL_MS = 24 * 60 * 60 * 1000; // /model reads cached CLI env within 24h
|
|
171
217
|
export class FleetManager {
|
|
172
218
|
dataDir;
|
|
@@ -213,6 +259,7 @@ export class FleetManager {
|
|
|
213
259
|
costGuard = null;
|
|
214
260
|
statuslineWatcher;
|
|
215
261
|
dailySummary = null;
|
|
262
|
+
dailyTipScheduler = null;
|
|
216
263
|
webhookEmitter = null;
|
|
217
264
|
// Topic icon + auto-archive state
|
|
218
265
|
topicIcons = {};
|
|
@@ -225,6 +272,13 @@ export class FleetManager {
|
|
|
225
272
|
warmCapEvicting = new Set();
|
|
226
273
|
/** Per-instance tail keeps cross-instance and scheduled deliveries FIFO. */
|
|
227
274
|
idleGatedDeliveryTails = new Map();
|
|
275
|
+
/**
|
|
276
|
+
* Per-instance cancellation epoch for deliveries which have not reached the
|
|
277
|
+
* daemon yet. A cancel advances the epoch; queued work captures the old value
|
|
278
|
+
* and becomes a no-op, while messages arriving after the click capture the new
|
|
279
|
+
* value and remain deliverable.
|
|
280
|
+
*/
|
|
281
|
+
deliveryEpochs = new Map();
|
|
228
282
|
/** Non-user work must observe a fresh idle snapshot after the latest delivery. */
|
|
229
283
|
lastDeliveryAt = new Map();
|
|
230
284
|
/** State-cache updates wake event-driven idle waiters without busy polling. */
|
|
@@ -241,6 +295,8 @@ export class FleetManager {
|
|
|
241
295
|
replyDeduper = new ReplyDeduper();
|
|
242
296
|
/** instanceName → what it is doing right now, when the backend can tell us. */
|
|
243
297
|
instanceActivity = new Map();
|
|
298
|
+
/** instanceName → this turn's tool list (multi-line), for the bubble. */
|
|
299
|
+
instanceProgress = new Map();
|
|
244
300
|
/** instanceName → tail of deliveries waiting for its IPC to come back. */
|
|
245
301
|
ipcWaitTails = new Map();
|
|
246
302
|
/** instanceName → restart currently executing; concurrent callers join it. */
|
|
@@ -255,8 +311,8 @@ export class FleetManager {
|
|
|
255
311
|
/** In-flight /effort selections, same coordinator shape as pendingModelSelects. */
|
|
256
312
|
pendingEffortSelects = new Map();
|
|
257
313
|
pendingModelSelects = new Map();
|
|
258
|
-
/**
|
|
259
|
-
|
|
314
|
+
/** nonce → pending button prompt (hang restart, interactive assist, clean-exit restart). */
|
|
315
|
+
pendingNonceButtons = new Map();
|
|
260
316
|
// Model failover state
|
|
261
317
|
failoverActive = new Map(); // instance → current failover model
|
|
262
318
|
// IPC reconnect: tracks instances being intentionally stopped (skip reconnect)
|
|
@@ -278,6 +334,10 @@ export class FleetManager {
|
|
|
278
334
|
healthServer = null;
|
|
279
335
|
healthPortRetried = false;
|
|
280
336
|
updateCheckTimer = null;
|
|
337
|
+
updateProgressTimer = null;
|
|
338
|
+
updateProgressEditRunning = false;
|
|
339
|
+
lastUpdateProgressText = null;
|
|
340
|
+
updateCompletionTipText = null;
|
|
281
341
|
eventLogPruneTimer = null;
|
|
282
342
|
logRotateTimer = null;
|
|
283
343
|
/** Days of event/activity history to keep. */
|
|
@@ -326,7 +386,7 @@ export class FleetManager {
|
|
|
326
386
|
// fleet.yaml, sent SIGHUP, and got no reaction and no explanation.
|
|
327
387
|
this.logger.error({ err }, "SIGHUP config reload failed");
|
|
328
388
|
const message = err instanceof Error ? err.message : String(err);
|
|
329
|
-
this.notifyFleetError(
|
|
389
|
+
this.notifyFleetError(t("fleet.reload_failed", message));
|
|
330
390
|
})
|
|
331
391
|
.finally(() => {
|
|
332
392
|
this.reconcileInFlight = null;
|
|
@@ -557,6 +617,55 @@ export class FleetManager {
|
|
|
557
617
|
}
|
|
558
618
|
await data.respond(await this.topicCommands.runPauseWake(target, action));
|
|
559
619
|
}
|
|
620
|
+
async handleUpdateSlash(data, adapterId) {
|
|
621
|
+
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
622
|
+
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
623
|
+
await data.respond(t("not_authorized"));
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const messageId = await data.respond(t("update.progress.preparing", 0));
|
|
627
|
+
const adapter = this.adapters.get(adapterId) ?? this.adapter;
|
|
628
|
+
if (messageId && adapter) {
|
|
629
|
+
const chatId = String(this.getChannelConfig(adapterId)?.group_id ?? data.channelId);
|
|
630
|
+
this.beginUpdateProgress(adapter, chatId, data.channelId, messageId);
|
|
631
|
+
}
|
|
632
|
+
const { spawn } = await import("node:child_process");
|
|
633
|
+
const currentVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
|
|
634
|
+
const command = currentVersion.includes("beta") ? "agend update --beta" : "agend update";
|
|
635
|
+
const child = spawn("sh", ["-c", `sleep 2 && ${command}`], { detached: true, stdio: "ignore" });
|
|
636
|
+
child.once("error", err => this.failUpdateProgress(err.message));
|
|
637
|
+
child.unref();
|
|
638
|
+
}
|
|
639
|
+
async handleTipsSlash(data, adapterId) {
|
|
640
|
+
if (!this.fleetConfig)
|
|
641
|
+
return;
|
|
642
|
+
const mode = typeof data.options?.mode === "string" ? data.options.mode : "";
|
|
643
|
+
if (mode === "on" || mode === "off") {
|
|
644
|
+
if (!this.isFleetAdmin(data.userId, adapterId)) {
|
|
645
|
+
await data.respond(t("permission.denied"));
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
this.fleetConfig.defaults.tips = mode === "on";
|
|
649
|
+
this.saveFleetConfig();
|
|
650
|
+
await data.respond(t(mode === "on" ? "tips.enabled" : "tips.disabled"));
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
const route = this.routing.resolve(data.channelId);
|
|
654
|
+
if (!route || route.kind !== "general") {
|
|
655
|
+
await data.respond(t("tips.general_only"));
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
const adapter = this.adapters.get(adapterId) ?? this.adapter;
|
|
659
|
+
if (!adapter) {
|
|
660
|
+
await data.respond(t("tips.unavailable"));
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const chatId = this.getGroupIdForInstance(route.name) || data.channelId;
|
|
664
|
+
const result = await this.promptTip(route.name, adapter, chatId, data.channelId);
|
|
665
|
+
await data.respond(t(result === "posted"
|
|
666
|
+
? "tips.posted"
|
|
667
|
+
: result === "empty" ? "tips.empty" : "tips.unavailable"));
|
|
668
|
+
}
|
|
560
669
|
/** Admin-only full conversation reset for fleet-topic and Classic instances. */
|
|
561
670
|
async handleClearSlash(data, adapterId) {
|
|
562
671
|
if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
|
|
@@ -568,7 +677,14 @@ export class FleetManager {
|
|
|
568
677
|
await data.respond(t("classic.no_agent"));
|
|
569
678
|
return;
|
|
570
679
|
}
|
|
571
|
-
|
|
680
|
+
const adapter = this.adapters.get(adapterId) ?? this.adapter;
|
|
681
|
+
if (!adapter) {
|
|
682
|
+
await data.respond(t("clear.prompt_unavailable"));
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const chatId = String(this.getChannelConfig(adapterId)?.group_id ?? data.channelId);
|
|
686
|
+
const fallback = await this.promptClearConfirmation(name, data.channelId, adapter, chatId, data.channelId);
|
|
687
|
+
await data.respond(fallback ?? t("clear.confirm_posted"));
|
|
572
688
|
}
|
|
573
689
|
/** Get the adapter bound to an instance, falling back to primary adapter */
|
|
574
690
|
getAdapterForInstance(name) {
|
|
@@ -702,6 +818,30 @@ export class FleetManager {
|
|
|
702
818
|
return null;
|
|
703
819
|
return this.instanceStateCache.get(name)?.state ?? null;
|
|
704
820
|
}
|
|
821
|
+
/**
|
|
822
|
+
* Subscription providers currently relevant to this fleet. Stopped/crashed
|
|
823
|
+
* rows do not keep a usage card alive, while persisted paused instances do.
|
|
824
|
+
* Classic channels use their effective backend merge chain, not the raw row.
|
|
825
|
+
*/
|
|
826
|
+
getActiveUsageProviderIds() {
|
|
827
|
+
const providers = new Set();
|
|
828
|
+
const add = (name, backend) => {
|
|
829
|
+
const status = this.getInstanceStatus(name);
|
|
830
|
+
if (status !== "running" && status !== "paused")
|
|
831
|
+
return;
|
|
832
|
+
const provider = usageProviderIdForBackend(backend);
|
|
833
|
+
if (provider)
|
|
834
|
+
providers.add(provider);
|
|
835
|
+
};
|
|
836
|
+
for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
|
|
837
|
+
// loadFleetConfig() has already merged the fleet default into each row.
|
|
838
|
+
add(name, config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
|
|
839
|
+
}
|
|
840
|
+
for (const channel of this.classicChannels?.getAll() ?? []) {
|
|
841
|
+
add(channel.instanceName, this.classicChannels?.getBackendByInstance(channel.instanceName, this.fleetConfig?.defaults?.backend));
|
|
842
|
+
}
|
|
843
|
+
return providers;
|
|
844
|
+
}
|
|
705
845
|
isClassicInstance(name) {
|
|
706
846
|
return this.classicChannels?.getAll().some(channel => channel.instanceName === name) ?? false;
|
|
707
847
|
}
|
|
@@ -814,7 +954,7 @@ export class FleetManager {
|
|
|
814
954
|
.finally(() => this.warmCapEvicting.delete(victim));
|
|
815
955
|
}
|
|
816
956
|
}
|
|
817
|
-
waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter = 0) {
|
|
957
|
+
waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter = 0, cancelled) {
|
|
818
958
|
const isReady = () => {
|
|
819
959
|
const snapshot = this.instanceStateCache.get(instanceName);
|
|
820
960
|
return snapshot?.state === "idle"
|
|
@@ -836,8 +976,12 @@ export class FleetManager {
|
|
|
836
976
|
this.instanceIdleWaiters.delete(instanceName);
|
|
837
977
|
resolve(idle);
|
|
838
978
|
};
|
|
839
|
-
const check = () => {
|
|
840
|
-
|
|
979
|
+
const check = () => {
|
|
980
|
+
if (cancelled?.())
|
|
981
|
+
finish(false);
|
|
982
|
+
else if (isReady())
|
|
983
|
+
finish(true);
|
|
984
|
+
};
|
|
841
985
|
const query = () => {
|
|
842
986
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
843
987
|
if (ipc?.connected) {
|
|
@@ -853,22 +997,74 @@ export class FleetManager {
|
|
|
853
997
|
query();
|
|
854
998
|
});
|
|
855
999
|
}
|
|
856
|
-
|
|
1000
|
+
/**
|
|
1001
|
+
* Ask the daemon to capture the pane before answering, then wait for any state
|
|
1002
|
+
* report produced after this request. The ordinary query is intentionally
|
|
1003
|
+
* cache-only (idle gates call it frequently); this opt-in refresh is reserved
|
|
1004
|
+
* for lifecycle decisions where a stale state would strand UI.
|
|
1005
|
+
*/
|
|
1006
|
+
refreshInstanceExecutionState(instanceName, timeoutMs) {
|
|
1007
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
1008
|
+
if (!ipc?.connected)
|
|
1009
|
+
return Promise.resolve(false);
|
|
1010
|
+
const previous = this.instanceStateCache.get(instanceName);
|
|
1011
|
+
return new Promise(resolve => {
|
|
1012
|
+
let settled = false;
|
|
1013
|
+
const finish = (refreshed) => {
|
|
1014
|
+
if (settled)
|
|
1015
|
+
return;
|
|
1016
|
+
settled = true;
|
|
1017
|
+
clearTimeout(timeout);
|
|
1018
|
+
const waiters = this.instanceIdleWaiters.get(instanceName);
|
|
1019
|
+
waiters?.delete(check);
|
|
1020
|
+
if (waiters?.size === 0)
|
|
1021
|
+
this.instanceIdleWaiters.delete(instanceName);
|
|
1022
|
+
resolve(refreshed);
|
|
1023
|
+
};
|
|
1024
|
+
const check = () => {
|
|
1025
|
+
if (this.instanceStateCache.get(instanceName) !== previous)
|
|
1026
|
+
finish(true);
|
|
1027
|
+
};
|
|
1028
|
+
const waiters = this.instanceIdleWaiters.get(instanceName) ?? new Set();
|
|
1029
|
+
waiters.add(check);
|
|
1030
|
+
this.instanceIdleWaiters.set(instanceName, waiters);
|
|
1031
|
+
const timeout = setTimeout(() => finish(false), timeoutMs);
|
|
1032
|
+
timeout.unref?.();
|
|
1033
|
+
const sent = ipc.send({
|
|
1034
|
+
type: "query_instance_state",
|
|
1035
|
+
requestId: `reply-grace-${Date.now()}`,
|
|
1036
|
+
refresh: true,
|
|
1037
|
+
});
|
|
1038
|
+
if (!sent)
|
|
1039
|
+
finish(false);
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch) {
|
|
1043
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1044
|
+
return;
|
|
857
1045
|
let idleObservedAfter = this.lastDeliveryAt.get(instanceName) ?? 0;
|
|
858
1046
|
if (this.lifecycle.isPaused(instanceName)) {
|
|
859
1047
|
const wakeStartedAt = Date.now();
|
|
860
1048
|
await this.lifecycle.wake(instanceName, 30_000);
|
|
1049
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1050
|
+
return;
|
|
861
1051
|
// Waking added one to the warm count — make room by evicting a different
|
|
862
1052
|
// LRU idle instance (never this one; it's about to work).
|
|
863
1053
|
this.enforceWarmCap(instanceName);
|
|
864
1054
|
// Never satisfy a post-wake gate from a stale pre-pause cache entry.
|
|
865
1055
|
idleObservedAfter = Math.max(idleObservedAfter, wakeStartedAt);
|
|
866
1056
|
}
|
|
867
|
-
const idle = await this.waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter);
|
|
1057
|
+
const idle = await this.waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter, () => !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch));
|
|
1058
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch)) {
|
|
1059
|
+
this.logger.info({ instanceName }, "Pending delivery dropped by user cancel");
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
868
1062
|
if (!idle) {
|
|
869
1063
|
this.logger.warn({ instanceName, timeoutMs }, "Idle gate timed out; forcing delivery");
|
|
870
1064
|
}
|
|
871
|
-
await this.sendWhenConnected(instanceName, payload);
|
|
1065
|
+
await this.sendWhenConnected(instanceName, payload, deliveryEpoch);
|
|
1066
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1067
|
+
return;
|
|
872
1068
|
this.lastDeliveryAt.set(instanceName, Date.now());
|
|
873
1069
|
}
|
|
874
1070
|
/**
|
|
@@ -887,7 +1083,9 @@ export class FleetManager {
|
|
|
887
1083
|
* instance, *including* when the socket happens to be up: otherwise a message
|
|
888
1084
|
* arriving after the reconnect could overtake one that has been waiting for it.
|
|
889
1085
|
*/
|
|
890
|
-
async sendWhenConnected(instanceName, payload) {
|
|
1086
|
+
async sendWhenConnected(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName)) {
|
|
1087
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1088
|
+
return;
|
|
891
1089
|
const queued = this.ipcWaitTails.get(instanceName);
|
|
892
1090
|
if (!queued) {
|
|
893
1091
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
@@ -896,7 +1094,11 @@ export class FleetManager {
|
|
|
896
1094
|
}
|
|
897
1095
|
const attempt = (queued ?? Promise.resolve())
|
|
898
1096
|
.catch(() => { })
|
|
899
|
-
.then(() =>
|
|
1097
|
+
.then(() => {
|
|
1098
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1099
|
+
return;
|
|
1100
|
+
return this.sendAfterIpcReturns(instanceName, payload, deliveryEpoch);
|
|
1101
|
+
});
|
|
900
1102
|
// The chain stores a settled-either-way promise so one failed delivery cannot
|
|
901
1103
|
// wedge every later one, and so `queued` above is safe to await unguarded.
|
|
902
1104
|
const tail = attempt.catch(() => { });
|
|
@@ -913,10 +1115,12 @@ export class FleetManager {
|
|
|
913
1115
|
}
|
|
914
1116
|
}
|
|
915
1117
|
/** Poll for the instance's IPC to come back, then send. Throws if it does not. */
|
|
916
|
-
async sendAfterIpcReturns(instanceName, payload) {
|
|
1118
|
+
async sendAfterIpcReturns(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName)) {
|
|
917
1119
|
const deadline = Date.now() + IPC_RECONNECT_GRACE_MS;
|
|
918
1120
|
let warned = false;
|
|
919
1121
|
for (;;) {
|
|
1122
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1123
|
+
return;
|
|
920
1124
|
// Re-read every round: a reconnect replaces the IpcClient object entirely,
|
|
921
1125
|
// so a cached reference would stay dead forever.
|
|
922
1126
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
@@ -934,6 +1138,8 @@ export class FleetManager {
|
|
|
934
1138
|
}
|
|
935
1139
|
/** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
|
|
936
1140
|
async deliverToInstance(instanceName, payload, options = {}) {
|
|
1141
|
+
const deliveryEpoch = this.getDeliveryEpoch(instanceName);
|
|
1142
|
+
const deliveryPayload = { ...payload, delivery_epoch: deliveryEpoch };
|
|
937
1143
|
const meta = payload.meta && typeof payload.meta === "object"
|
|
938
1144
|
? payload.meta
|
|
939
1145
|
: undefined;
|
|
@@ -945,16 +1151,20 @@ export class FleetManager {
|
|
|
945
1151
|
if (!waitForIdle) {
|
|
946
1152
|
if (this.lifecycle.isPaused(instanceName)) {
|
|
947
1153
|
await this.lifecycle.wake(instanceName, 30_000);
|
|
1154
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1155
|
+
return;
|
|
948
1156
|
this.enforceWarmCap(instanceName); // woke one → evict a different LRU idle if over cap
|
|
949
1157
|
}
|
|
950
|
-
await this.sendWhenConnected(instanceName,
|
|
1158
|
+
await this.sendWhenConnected(instanceName, deliveryPayload, deliveryEpoch);
|
|
1159
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1160
|
+
return;
|
|
951
1161
|
// A cross-instance item arriving before the daemon observes this turn as
|
|
952
1162
|
// working must not trust the stale idle snapshot from before the send.
|
|
953
1163
|
this.lastDeliveryAt.set(instanceName, Date.now());
|
|
954
1164
|
return;
|
|
955
1165
|
}
|
|
956
1166
|
const previous = this.idleGatedDeliveryTails.get(instanceName) ?? Promise.resolve();
|
|
957
|
-
const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName,
|
|
1167
|
+
const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch));
|
|
958
1168
|
this.idleGatedDeliveryTails.set(instanceName, delivery);
|
|
959
1169
|
try {
|
|
960
1170
|
await delivery;
|
|
@@ -1033,19 +1243,34 @@ export class FleetManager {
|
|
|
1033
1243
|
this.logger.info({ name }, "Instance already running, skipping");
|
|
1034
1244
|
return;
|
|
1035
1245
|
}
|
|
1246
|
+
const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
1036
1247
|
if (config.general_topic) {
|
|
1037
1248
|
// antigravity (agy) does not read MCP instructions — fleet context and
|
|
1038
1249
|
// routing instructions are not injected, so it cannot act as a dispatcher.
|
|
1039
|
-
const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
1040
1250
|
if (backend === "antigravity") {
|
|
1041
1251
|
this.logger.warn({ name }, "antigravity backend does not support MCP instructions — general dispatcher will not work correctly");
|
|
1042
|
-
this.notifyInstanceTopic(name, "
|
|
1252
|
+
this.notifyInstanceTopic(name, t("general.antigravity_unsupported"));
|
|
1253
|
+
}
|
|
1254
|
+
this.ensureGeneralInstructions(config.working_directory, backend, name);
|
|
1255
|
+
}
|
|
1256
|
+
else if (kind === "fleet-topic") {
|
|
1257
|
+
// Workers get only role-eligible on-demand skills. Classic instances are
|
|
1258
|
+
// deliberately excluded: their workspace and conversation lifecycle are
|
|
1259
|
+
// managed independently from fleet-topic workers.
|
|
1260
|
+
try {
|
|
1261
|
+
const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
|
|
1262
|
+
this.syncRoleSkills(skillsWorkDir, backend, "worker");
|
|
1263
|
+
}
|
|
1264
|
+
catch (err) {
|
|
1265
|
+
// Skill publishing is additive. A read-only or temporarily unavailable
|
|
1266
|
+
// workspace must not turn an otherwise valid worker startup into a
|
|
1267
|
+
// fleet outage.
|
|
1268
|
+
this.logger.warn({ err, name, backend }, "Failed to sync worker skills — continuing startup");
|
|
1043
1269
|
}
|
|
1044
|
-
this.ensureGeneralInstructions(config.working_directory, config.backend);
|
|
1045
1270
|
}
|
|
1046
1271
|
await this.lifecycle.start(name, config, topicMode, {
|
|
1047
1272
|
kind,
|
|
1048
|
-
backend
|
|
1273
|
+
backend,
|
|
1049
1274
|
model: this.resolveInstanceModel(name).display,
|
|
1050
1275
|
});
|
|
1051
1276
|
// Only clear a stale process status after a real start succeeded. Clearing
|
|
@@ -1210,7 +1435,18 @@ export class FleetManager {
|
|
|
1210
1435
|
this.instanceStateCache.delete(name);
|
|
1211
1436
|
this.instanceProcessStatus.delete(name);
|
|
1212
1437
|
this.lastDeliveryAt.delete(name);
|
|
1213
|
-
|
|
1438
|
+
// A pending hang/exit/assist offer refers to the instance being torn down;
|
|
1439
|
+
// left alone it would stay clickable for the rest of its 15 minutes. Clear
|
|
1440
|
+
// again AFTER the stop completes: the teardown itself can emit hang /
|
|
1441
|
+
// interactive-prompt / clean-exit events whose handlers post fresh prompts
|
|
1442
|
+
// while the stop is still awaiting (the TOCTOU sol's review called out).
|
|
1443
|
+
this.clearNoncePromptsForInstance(name);
|
|
1444
|
+
try {
|
|
1445
|
+
return await this.lifecycle.stop(name);
|
|
1446
|
+
}
|
|
1447
|
+
finally {
|
|
1448
|
+
this.clearNoncePromptsForInstance(name);
|
|
1449
|
+
}
|
|
1214
1450
|
}
|
|
1215
1451
|
/** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
|
|
1216
1452
|
async restartSingleInstance(name, opts) {
|
|
@@ -1310,6 +1546,76 @@ export class FleetManager {
|
|
|
1310
1546
|
getDashboardAccess() {
|
|
1311
1547
|
return { ready: this.healthServerListening, token: this.webToken };
|
|
1312
1548
|
}
|
|
1549
|
+
beginUpdateProgress(adapter, chatId, threadId, messageId) {
|
|
1550
|
+
persistUpdateProgress(this.dataDir, {
|
|
1551
|
+
adapterId: adapter.id,
|
|
1552
|
+
chatId,
|
|
1553
|
+
...(threadId ? { threadId } : {}),
|
|
1554
|
+
messageId,
|
|
1555
|
+
});
|
|
1556
|
+
this.lastUpdateProgressText = null;
|
|
1557
|
+
this.updateCompletionTipText = null;
|
|
1558
|
+
this.startUpdateProgressMonitor(adapter);
|
|
1559
|
+
}
|
|
1560
|
+
failUpdateProgress(message) {
|
|
1561
|
+
setUpdateProgressStage(this.dataDir, "failed", { error: message });
|
|
1562
|
+
}
|
|
1563
|
+
/** The old fleet edits CLI install stages; the new fleet adopts the same message below. */
|
|
1564
|
+
startUpdateProgressMonitor(initialAdapter) {
|
|
1565
|
+
if (this.updateProgressTimer)
|
|
1566
|
+
clearInterval(this.updateProgressTimer);
|
|
1567
|
+
const tick = async () => {
|
|
1568
|
+
if (this.updateProgressEditRunning)
|
|
1569
|
+
return;
|
|
1570
|
+
const marker = readUpdateProgress(this.dataDir);
|
|
1571
|
+
if (!marker) {
|
|
1572
|
+
if (this.updateProgressTimer)
|
|
1573
|
+
clearInterval(this.updateProgressTimer);
|
|
1574
|
+
this.updateProgressTimer = null;
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
const target = marker.progress.target;
|
|
1578
|
+
const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
|
|
1579
|
+
if (!adapter)
|
|
1580
|
+
return;
|
|
1581
|
+
let text = formatUpdateProgress(marker);
|
|
1582
|
+
// No-restart updates complete in the old fleet process, so they never
|
|
1583
|
+
// reach RestartProgress.finish(). Append the same optional tip here.
|
|
1584
|
+
if (marker.progress.stage === "complete" && this.tipsEnabled()) {
|
|
1585
|
+
if (this.updateCompletionTipText === null) {
|
|
1586
|
+
const tip = this.pickAvailableTip();
|
|
1587
|
+
this.updateCompletionTipText = tip ? this.formatTip(tip) : "";
|
|
1588
|
+
}
|
|
1589
|
+
if (this.updateCompletionTipText)
|
|
1590
|
+
text += `\n\n${this.updateCompletionTipText}`;
|
|
1591
|
+
}
|
|
1592
|
+
if (text === this.lastUpdateProgressText)
|
|
1593
|
+
return;
|
|
1594
|
+
this.updateProgressEditRunning = true;
|
|
1595
|
+
try {
|
|
1596
|
+
await adapter.editMessage(target.chatId, target.messageId, text, target.threadId);
|
|
1597
|
+
this.lastUpdateProgressText = text;
|
|
1598
|
+
if (marker.progress.stage === "failed" || marker.progress.stage === "complete") {
|
|
1599
|
+
clearUpdateMarker(this.dataDir);
|
|
1600
|
+
if (this.updateProgressTimer)
|
|
1601
|
+
clearInterval(this.updateProgressTimer);
|
|
1602
|
+
this.updateProgressTimer = null;
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
catch (err) {
|
|
1606
|
+
this.logger.warn({ err }, "Failed to edit update progress");
|
|
1607
|
+
}
|
|
1608
|
+
finally {
|
|
1609
|
+
this.updateProgressEditRunning = false;
|
|
1610
|
+
}
|
|
1611
|
+
};
|
|
1612
|
+
void tick();
|
|
1613
|
+
// Poll stages faster than the elapsed-time display. npm verification and
|
|
1614
|
+
// service-file refresh can be short; 200ms prevents those stages from being
|
|
1615
|
+
// skipped while the text cache still limits normal edits to once per second.
|
|
1616
|
+
this.updateProgressTimer = setInterval(() => void tick(), 200);
|
|
1617
|
+
this.updateProgressTimer.unref?.();
|
|
1618
|
+
}
|
|
1313
1619
|
/** Start all instances from fleet config */
|
|
1314
1620
|
async startAll(configPath) {
|
|
1315
1621
|
const startupStartedAt = Date.now();
|
|
@@ -1323,7 +1629,17 @@ export class FleetManager {
|
|
|
1323
1629
|
// Rotate fleet.log if oversized (before any logging)
|
|
1324
1630
|
rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
|
|
1325
1631
|
const fleet = this.loadConfig(configPath);
|
|
1632
|
+
this.slimFleetConfigAtStartup();
|
|
1326
1633
|
setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
|
|
1634
|
+
const savedUpdateProgress = readUpdateProgress(this.dataDir);
|
|
1635
|
+
const pendingUpdateProgress = savedUpdateProgress
|
|
1636
|
+
&& savedUpdateProgress.progress.stage !== "failed"
|
|
1637
|
+
&& savedUpdateProgress.progress.stage !== "complete"
|
|
1638
|
+
? savedUpdateProgress
|
|
1639
|
+
: null;
|
|
1640
|
+
if (pendingUpdateProgress) {
|
|
1641
|
+
setUpdateProgressStage(this.dataDir, "starting", { version: pendingUpdateProgress.progress.version });
|
|
1642
|
+
}
|
|
1327
1643
|
this.initializeWebAuthTokens();
|
|
1328
1644
|
const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
|
|
1329
1645
|
// Set tmux socket isolation for custom AGEND_HOME
|
|
@@ -1371,9 +1687,8 @@ export class FleetManager {
|
|
|
1371
1687
|
// routing does NOT go through the routing engine (single-key, can't hold two
|
|
1372
1688
|
// bots in one channel) — it resolves per-bot via getInstanceByChannel.
|
|
1373
1689
|
this.classicChannels = new ClassicChannelManager(this.dataDir, this.logger);
|
|
1374
|
-
const
|
|
1375
|
-
|
|
1376
|
-
this.classicChannels.setPrimaryAdapterId(primaryCh.id ?? primaryCh.type);
|
|
1690
|
+
const classicAdapters = fleet.channels?.length ? fleet.channels : (fleet.channel ? [fleet.channel] : []);
|
|
1691
|
+
this.classicChannels.configureAdapters(classicAdapters);
|
|
1377
1692
|
// Restore the persisted bot binding so replies/cancel go through the right
|
|
1378
1693
|
// bot after a restart (before this, inbound would re-bind lazily).
|
|
1379
1694
|
for (const ch of this.classicChannels.getAll()) {
|
|
@@ -1542,7 +1857,7 @@ export class FleetManager {
|
|
|
1542
1857
|
const generalDir = join(getAgendHome(), name);
|
|
1543
1858
|
mkdirSync(generalDir, { recursive: true });
|
|
1544
1859
|
const backendName = fleet.defaults.backend ?? "claude-code";
|
|
1545
|
-
this.ensureGeneralInstructions(generalDir, backendName);
|
|
1860
|
+
this.ensureGeneralInstructions(generalDir, backendName, name);
|
|
1546
1861
|
fleet.instances[name] = {
|
|
1547
1862
|
...DEFAULT_INSTANCE_CONFIG,
|
|
1548
1863
|
working_directory: generalDir,
|
|
@@ -1561,6 +1876,13 @@ export class FleetManager {
|
|
|
1561
1876
|
this.scheduler = new Scheduler(join(this.dataDir, "scheduler.db"), (schedule) => this.handleScheduleTrigger(schedule), schedulerConfig, (name) => this.fleetConfig?.instances?.[name] != null || !!this.classicChannels?.getAll().some(ch => ch.instanceName === name));
|
|
1562
1877
|
this.scheduler.init();
|
|
1563
1878
|
this.logger.info("Scheduler initialized");
|
|
1879
|
+
// Tips share the daily-report clock but remain an independent internal
|
|
1880
|
+
// job: disabling daily_summary must not disable tips (and vice versa).
|
|
1881
|
+
// The callback checks defaults.tips at fire time, so /tips on|off is hot.
|
|
1882
|
+
this.dailyTipScheduler = new DailyTipScheduler(summaryConfig, costGuardConfig.timezone, () => this.sendTipToGeneral().catch(err => {
|
|
1883
|
+
this.logger.warn({ err }, "Failed to send daily tip");
|
|
1884
|
+
}));
|
|
1885
|
+
this.dailyTipScheduler.start();
|
|
1564
1886
|
// Inject active decisions as env var for MCP instructions.
|
|
1565
1887
|
// Snapshotted at startup — new decisions via post_decision are available
|
|
1566
1888
|
// through list_decisions tool but not auto-injected until restart.
|
|
@@ -1580,7 +1902,7 @@ export class FleetManager {
|
|
|
1580
1902
|
const allEntries = Object.entries(fleet.instances);
|
|
1581
1903
|
const generals = allEntries.filter(([_, cfg]) => cfg.general_topic);
|
|
1582
1904
|
const others = allEntries.filter(([_, cfg]) => !cfg.general_topic);
|
|
1583
|
-
const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), startupStartedAt, this.logger);
|
|
1905
|
+
const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), pendingUpdateProgress?.startedAt ?? startupStartedAt, this.logger, { mode: pendingUpdateProgress ? "update" : "restart" });
|
|
1584
1906
|
if (generals.length > 0) {
|
|
1585
1907
|
for (const [name, cfg] of generals) {
|
|
1586
1908
|
try {
|
|
@@ -1619,7 +1941,19 @@ export class FleetManager {
|
|
|
1619
1941
|
this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
|
|
1620
1942
|
}
|
|
1621
1943
|
})();
|
|
1622
|
-
progressStart = adapterStartup.then(() =>
|
|
1944
|
+
progressStart = adapterStartup.then(() => {
|
|
1945
|
+
if (pendingUpdateProgress) {
|
|
1946
|
+
const saved = pendingUpdateProgress.progress.target;
|
|
1947
|
+
const adapter = this.adapters.get(saved.adapterId);
|
|
1948
|
+
const target = adapter ? {
|
|
1949
|
+
adapter,
|
|
1950
|
+
chatId: saved.chatId,
|
|
1951
|
+
threadId: saved.threadId,
|
|
1952
|
+
} : null;
|
|
1953
|
+
return startupProgress.resume(target, saved.messageId);
|
|
1954
|
+
}
|
|
1955
|
+
return startupProgress.start(this.restartProgressTarget());
|
|
1956
|
+
});
|
|
1623
1957
|
}
|
|
1624
1958
|
// The systemd watchdog answers exactly one question: is this process still
|
|
1625
1959
|
// turning its event loop? Pinging from a timer proves that, and after the
|
|
@@ -1731,6 +2065,12 @@ export class FleetManager {
|
|
|
1731
2065
|
version: agendVersion,
|
|
1732
2066
|
pausedNames,
|
|
1733
2067
|
failedNames,
|
|
2068
|
+
tipText: pendingUpdateProgress && this.tipsEnabled()
|
|
2069
|
+
? (() => {
|
|
2070
|
+
const tip = this.pickAvailableTip();
|
|
2071
|
+
return tip ? this.formatTip(tip) : undefined;
|
|
2072
|
+
})()
|
|
2073
|
+
: undefined,
|
|
1734
2074
|
});
|
|
1735
2075
|
if (!progressCompleted && this.adapter && fleet.channel?.group_id) {
|
|
1736
2076
|
let text;
|
|
@@ -1892,14 +2232,14 @@ export class FleetManager {
|
|
|
1892
2232
|
notifyAdapterFailure(adapterId, error) {
|
|
1893
2233
|
const generalId = this.findGeneralInstance();
|
|
1894
2234
|
if (generalId) {
|
|
1895
|
-
this.notifyInstanceTopic(generalId,
|
|
2235
|
+
this.notifyInstanceTopic(generalId, t("adapter.start_failed", adapterId, error));
|
|
1896
2236
|
}
|
|
1897
2237
|
}
|
|
1898
2238
|
/** Notify admin that a retried adapter reconnected. */
|
|
1899
2239
|
notifyAdapterRecovery(adapterId, attempts) {
|
|
1900
2240
|
const generalId = this.findGeneralInstance();
|
|
1901
2241
|
if (generalId) {
|
|
1902
|
-
this.notifyInstanceTopic(generalId,
|
|
2242
|
+
this.notifyInstanceTopic(generalId, t("adapter.reconnected", adapterId, attempts));
|
|
1903
2243
|
}
|
|
1904
2244
|
}
|
|
1905
2245
|
/** Get adapter states for /status visibility. */
|
|
@@ -1997,7 +2337,15 @@ export class FleetManager {
|
|
|
1997
2337
|
await this.handleInboundReaction(r);
|
|
1998
2338
|
}, this.logger, "adapter.reaction"));
|
|
1999
2339
|
this.adapter.on("callback_query", safeHandler(async (data) => {
|
|
2000
|
-
if (await this.
|
|
2340
|
+
if (await this.handleTipDismiss(data, adapterId, this.adapter ?? undefined))
|
|
2341
|
+
return;
|
|
2342
|
+
if (await this.handleTipUnlock(data, adapterId, this.adapter ?? undefined))
|
|
2343
|
+
return;
|
|
2344
|
+
if (await this.handleClearConfirmation(data, adapterId, this.adapter ?? undefined))
|
|
2345
|
+
return;
|
|
2346
|
+
if (await this.handleExitRestartPrompt(data, adapterId, this.adapter ?? undefined))
|
|
2347
|
+
return;
|
|
2348
|
+
if (await this.handleInteractivePromptAssist(data, adapterId, this.adapter ?? undefined))
|
|
2001
2349
|
return;
|
|
2002
2350
|
if (await this.handleClassicBackendSelection(data))
|
|
2003
2351
|
return;
|
|
@@ -2005,25 +2353,8 @@ export class FleetManager {
|
|
|
2005
2353
|
return;
|
|
2006
2354
|
if (await this.handleEffortSelection(data))
|
|
2007
2355
|
return;
|
|
2008
|
-
if (data.
|
|
2009
|
-
const parts = data.callbackData.split(":");
|
|
2010
|
-
const action = parts[1];
|
|
2011
|
-
const instanceName = parts[2];
|
|
2012
|
-
if (action === "restart") {
|
|
2013
|
-
await this.stopInstance(instanceName);
|
|
2014
|
-
const config = this.fleetConfig?.instances[instanceName];
|
|
2015
|
-
if (config) {
|
|
2016
|
-
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
2017
|
-
await this.startInstance(instanceName, config, topicMode);
|
|
2018
|
-
// startInstance already calls connectIpcToInstance
|
|
2019
|
-
}
|
|
2020
|
-
this.adapter?.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
|
|
2021
|
-
}
|
|
2022
|
-
else {
|
|
2023
|
-
this.adapter?.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
|
|
2024
|
-
}
|
|
2356
|
+
if (await this.handleHangPrompt(data, adapterId, this.adapter ?? undefined))
|
|
2025
2357
|
return;
|
|
2026
|
-
}
|
|
2027
2358
|
if (data.callbackData.startsWith("cancel:")) {
|
|
2028
2359
|
this.handleCancelClick(data.callbackData.slice("cancel:".length), this.adapter, data);
|
|
2029
2360
|
return;
|
|
@@ -2102,6 +2433,43 @@ export class FleetManager {
|
|
|
2102
2433
|
const result = await this.topicCommands.sendCompact(name);
|
|
2103
2434
|
await data.respond(result);
|
|
2104
2435
|
}
|
|
2436
|
+
else if (data.command === "steer") {
|
|
2437
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2438
|
+
if (!name) {
|
|
2439
|
+
await data.respond(t("classic.no_agent"));
|
|
2440
|
+
return;
|
|
2441
|
+
}
|
|
2442
|
+
const steerText = String(data.options?.message ?? "").trim();
|
|
2443
|
+
if (!steerText) {
|
|
2444
|
+
await data.respond(t("steer.usage"));
|
|
2445
|
+
return;
|
|
2446
|
+
}
|
|
2447
|
+
// chat_id/message_id stay empty: a slash interaction has no channel
|
|
2448
|
+
// message to react to, and an empty chat_id keeps updateLastChat from
|
|
2449
|
+
// rerouting the instance's replies to the slash context.
|
|
2450
|
+
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
2451
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2452
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2453
|
+
});
|
|
2454
|
+
await data.respond(result);
|
|
2455
|
+
}
|
|
2456
|
+
else if (data.command === "btw") {
|
|
2457
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2458
|
+
if (!name) {
|
|
2459
|
+
await data.respond(t("classic.no_agent"));
|
|
2460
|
+
return;
|
|
2461
|
+
}
|
|
2462
|
+
const btwText = String(data.options?.message ?? "").trim();
|
|
2463
|
+
if (!btwText) {
|
|
2464
|
+
await data.respond(t("btw.usage"));
|
|
2465
|
+
return;
|
|
2466
|
+
}
|
|
2467
|
+
const result = this.topicCommands.sendBtw(name, btwText, {
|
|
2468
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2469
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2470
|
+
});
|
|
2471
|
+
await data.respond(result);
|
|
2472
|
+
}
|
|
2105
2473
|
else if (data.command === "clear") {
|
|
2106
2474
|
await this.handleClearSlash(data, adapterId);
|
|
2107
2475
|
}
|
|
@@ -2157,17 +2525,7 @@ export class FleetManager {
|
|
|
2157
2525
|
: t("collab.off.classic"));
|
|
2158
2526
|
}
|
|
2159
2527
|
else if (data.command === "update") {
|
|
2160
|
-
|
|
2161
|
-
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
2162
|
-
await data.respond(t("not_authorized"));
|
|
2163
|
-
return;
|
|
2164
|
-
}
|
|
2165
|
-
await data.respond(t("update.running"));
|
|
2166
|
-
const { spawn } = await import("node:child_process");
|
|
2167
|
-
const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
|
|
2168
|
-
const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
|
|
2169
|
-
const child = spawn("sh", ["-c", `sleep 2 && ${_cmd}`], { detached: true, stdio: "ignore" });
|
|
2170
|
-
child.unref();
|
|
2528
|
+
await this.handleUpdateSlash(data, adapterId);
|
|
2171
2529
|
}
|
|
2172
2530
|
else if (data.command === "doctor") {
|
|
2173
2531
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
@@ -2185,12 +2543,15 @@ export class FleetManager {
|
|
|
2185
2543
|
const { getUsageSnapshot } = await import("./usage/usage-api.js");
|
|
2186
2544
|
const { renderUsageMarkdown } = await import("./usage/format-rich.js");
|
|
2187
2545
|
// slash_command is Discord-only; editReply renders Markdown natively.
|
|
2188
|
-
await data.respond(renderUsageMarkdown(await getUsageSnapshot()));
|
|
2546
|
+
await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
|
|
2189
2547
|
}
|
|
2190
2548
|
catch (err) {
|
|
2191
|
-
await data.respond(
|
|
2549
|
+
await data.respond(t("usage.failed", err.message));
|
|
2192
2550
|
}
|
|
2193
2551
|
}
|
|
2552
|
+
else if (data.command === "tips") {
|
|
2553
|
+
await this.handleTipsSlash(data, adapterId);
|
|
2554
|
+
}
|
|
2194
2555
|
else if (data.command === "status") {
|
|
2195
2556
|
// Admin-gated (like the topic path): the merged table shows every
|
|
2196
2557
|
// instance's cost and IPC health.
|
|
@@ -2236,6 +2597,43 @@ export class FleetManager {
|
|
|
2236
2597
|
const result = await this.topicCommands.sendCompact(name);
|
|
2237
2598
|
await data.respond(result);
|
|
2238
2599
|
}
|
|
2600
|
+
else if (data.command === "steer") {
|
|
2601
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2602
|
+
if (!name) {
|
|
2603
|
+
await data.respond(t("classic.no_agent"));
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
2606
|
+
const steerText = String(data.options?.message ?? "").trim();
|
|
2607
|
+
if (!steerText) {
|
|
2608
|
+
await data.respond(t("steer.usage"));
|
|
2609
|
+
return;
|
|
2610
|
+
}
|
|
2611
|
+
// chat_id/message_id stay empty: a slash interaction has no channel
|
|
2612
|
+
// message to react to, and an empty chat_id keeps updateLastChat from
|
|
2613
|
+
// rerouting the instance's replies to the slash context.
|
|
2614
|
+
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
2615
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2616
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2617
|
+
});
|
|
2618
|
+
await data.respond(result);
|
|
2619
|
+
}
|
|
2620
|
+
else if (data.command === "btw") {
|
|
2621
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2622
|
+
if (!name) {
|
|
2623
|
+
await data.respond(t("classic.no_agent"));
|
|
2624
|
+
return;
|
|
2625
|
+
}
|
|
2626
|
+
const btwText = String(data.options?.message ?? "").trim();
|
|
2627
|
+
if (!btwText) {
|
|
2628
|
+
await data.respond(t("btw.usage"));
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
const result = this.topicCommands.sendBtw(name, btwText, {
|
|
2632
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2633
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2634
|
+
});
|
|
2635
|
+
await data.respond(result);
|
|
2636
|
+
}
|
|
2239
2637
|
}, this.logger, "adapter.slash_command"));
|
|
2240
2638
|
await this.topicCommands.registerBotCommands().catch(e => this.logger.warn({ err: e }, "registerBotCommands failed (non-fatal)"));
|
|
2241
2639
|
// Background-probe each backend's CLI env (version/models) → cli-env cache.
|
|
@@ -2312,7 +2710,15 @@ export class FleetManager {
|
|
|
2312
2710
|
await this.handleInboundReaction(r);
|
|
2313
2711
|
}, this.logger, `adapter[${adapterId}].reaction`));
|
|
2314
2712
|
adapter.on("callback_query", safeHandler(async (data) => {
|
|
2315
|
-
if (await this.
|
|
2713
|
+
if (await this.handleTipDismiss(data, adapterId, adapter))
|
|
2714
|
+
return;
|
|
2715
|
+
if (await this.handleTipUnlock(data, adapterId, adapter))
|
|
2716
|
+
return;
|
|
2717
|
+
if (await this.handleClearConfirmation(data, adapterId, adapter))
|
|
2718
|
+
return;
|
|
2719
|
+
if (await this.handleExitRestartPrompt(data, adapterId, adapter))
|
|
2720
|
+
return;
|
|
2721
|
+
if (await this.handleInteractivePromptAssist(data, adapterId, adapter))
|
|
2316
2722
|
return;
|
|
2317
2723
|
if (await this.handleClassicBackendSelection(data))
|
|
2318
2724
|
return;
|
|
@@ -2320,24 +2726,8 @@ export class FleetManager {
|
|
|
2320
2726
|
return;
|
|
2321
2727
|
if (await this.handleEffortSelection(data))
|
|
2322
2728
|
return;
|
|
2323
|
-
if (
|
|
2324
|
-
const parts = data.callbackData.split(":");
|
|
2325
|
-
const action = parts[1];
|
|
2326
|
-
const instanceName = parts[2];
|
|
2327
|
-
if (action === "restart") {
|
|
2328
|
-
await this.stopInstance(instanceName);
|
|
2329
|
-
const config = this.fleetConfig?.instances[instanceName];
|
|
2330
|
-
if (config) {
|
|
2331
|
-
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
2332
|
-
await this.startInstance(instanceName, config, topicMode);
|
|
2333
|
-
}
|
|
2334
|
-
adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
|
|
2335
|
-
}
|
|
2336
|
-
else {
|
|
2337
|
-
adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
|
|
2338
|
-
}
|
|
2729
|
+
if (await this.handleHangPrompt(data, adapterId, adapter))
|
|
2339
2730
|
return;
|
|
2340
|
-
}
|
|
2341
2731
|
if (data.callbackData.startsWith("cancel:")) {
|
|
2342
2732
|
this.handleCancelClick(data.callbackData.slice("cancel:".length), adapter, data);
|
|
2343
2733
|
return;
|
|
@@ -2461,17 +2851,7 @@ export class FleetManager {
|
|
|
2461
2851
|
: t("collab.off.classic"));
|
|
2462
2852
|
}
|
|
2463
2853
|
else if (data.command === "update") {
|
|
2464
|
-
|
|
2465
|
-
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
2466
|
-
await data.respond(t("not_authorized"));
|
|
2467
|
-
return;
|
|
2468
|
-
}
|
|
2469
|
-
await data.respond(t("update.running"));
|
|
2470
|
-
const { spawn } = await import("node:child_process");
|
|
2471
|
-
const _cv = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
|
|
2472
|
-
const _cmd = _cv.includes("beta") ? "agend update --beta" : "agend update";
|
|
2473
|
-
const child = spawn("sh", ["-c", `sleep 2 && ${_cmd}`], { detached: true, stdio: "ignore" });
|
|
2474
|
-
child.unref();
|
|
2854
|
+
await this.handleUpdateSlash(data, adapterId);
|
|
2475
2855
|
}
|
|
2476
2856
|
else if (data.command === "doctor") {
|
|
2477
2857
|
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
@@ -2489,12 +2869,15 @@ export class FleetManager {
|
|
|
2489
2869
|
const { getUsageSnapshot } = await import("./usage/usage-api.js");
|
|
2490
2870
|
const { renderUsageMarkdown } = await import("./usage/format-rich.js");
|
|
2491
2871
|
// slash_command is Discord-only; editReply renders Markdown natively.
|
|
2492
|
-
await data.respond(renderUsageMarkdown(await getUsageSnapshot()));
|
|
2872
|
+
await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
|
|
2493
2873
|
}
|
|
2494
2874
|
catch (err) {
|
|
2495
|
-
await data.respond(
|
|
2875
|
+
await data.respond(t("usage.failed", err.message));
|
|
2496
2876
|
}
|
|
2497
2877
|
}
|
|
2878
|
+
else if (data.command === "tips") {
|
|
2879
|
+
await this.handleTipsSlash(data, adapterId);
|
|
2880
|
+
}
|
|
2498
2881
|
else if (data.command === "status") {
|
|
2499
2882
|
// Admin-gated (like the topic path): the merged table shows every
|
|
2500
2883
|
// instance's cost and IPC health.
|
|
@@ -2540,6 +2923,43 @@ export class FleetManager {
|
|
|
2540
2923
|
const result = await this.topicCommands.sendCompact(name);
|
|
2541
2924
|
await data.respond(result);
|
|
2542
2925
|
}
|
|
2926
|
+
else if (data.command === "steer") {
|
|
2927
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2928
|
+
if (!name) {
|
|
2929
|
+
await data.respond(t("classic.no_agent"));
|
|
2930
|
+
return;
|
|
2931
|
+
}
|
|
2932
|
+
const steerText = String(data.options?.message ?? "").trim();
|
|
2933
|
+
if (!steerText) {
|
|
2934
|
+
await data.respond(t("steer.usage"));
|
|
2935
|
+
return;
|
|
2936
|
+
}
|
|
2937
|
+
// chat_id/message_id stay empty: a slash interaction has no channel
|
|
2938
|
+
// message to react to, and an empty chat_id keeps updateLastChat from
|
|
2939
|
+
// rerouting the instance's replies to the slash context.
|
|
2940
|
+
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
2941
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2942
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2943
|
+
});
|
|
2944
|
+
await data.respond(result);
|
|
2945
|
+
}
|
|
2946
|
+
else if (data.command === "btw") {
|
|
2947
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2948
|
+
if (!name) {
|
|
2949
|
+
await data.respond(t("classic.no_agent"));
|
|
2950
|
+
return;
|
|
2951
|
+
}
|
|
2952
|
+
const btwText = String(data.options?.message ?? "").trim();
|
|
2953
|
+
if (!btwText) {
|
|
2954
|
+
await data.respond(t("btw.usage"));
|
|
2955
|
+
return;
|
|
2956
|
+
}
|
|
2957
|
+
const result = this.topicCommands.sendBtw(name, btwText, {
|
|
2958
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2959
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2960
|
+
});
|
|
2961
|
+
await data.respond(result);
|
|
2962
|
+
}
|
|
2543
2963
|
}, this.logger, `adapter[${adapterId}].slash_command`));
|
|
2544
2964
|
await adapter.start();
|
|
2545
2965
|
if (channelConfig.group_id) {
|
|
@@ -2657,6 +3077,9 @@ export class FleetManager {
|
|
|
2657
3077
|
else if (msg.type === "instance_activity") {
|
|
2658
3078
|
this.cacheInstanceActivity(name, msg.activity);
|
|
2659
3079
|
}
|
|
3080
|
+
else if (msg.type === "instance_progress") {
|
|
3081
|
+
this.cacheInstanceProgress(name, msg.progress || null);
|
|
3082
|
+
}
|
|
2660
3083
|
else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
|
|
2661
3084
|
this.cacheInstanceExecutionState(name, msg);
|
|
2662
3085
|
if (msg.type === "instance_state_response") {
|
|
@@ -2845,6 +3268,10 @@ export class FleetManager {
|
|
|
2845
3268
|
this.logger.debug({ emoji: r.emoji, user: r.username }, "Ignoring delivery-status emoji as a reaction");
|
|
2846
3269
|
return;
|
|
2847
3270
|
}
|
|
3271
|
+
if (IGNORED_REACTION_EMOJIS.has(r.emoji)) {
|
|
3272
|
+
this.logger.debug({ emoji: r.emoji, user: r.username }, "Ignoring non-contextual emoji reaction");
|
|
3273
|
+
return;
|
|
3274
|
+
}
|
|
2848
3275
|
this.eventLog?.logActivity("reaction", r.username, `${r.emoji} ${r.action}`, instanceName);
|
|
2849
3276
|
if (r.action === "add") {
|
|
2850
3277
|
this.eventLog?.addReaction(instanceName, r.messageId, r.username, r.emoji);
|
|
@@ -3100,6 +3527,42 @@ export class FleetManager {
|
|
|
3100
3527
|
await msgAdapter?.sendText(chatId, result);
|
|
3101
3528
|
return;
|
|
3102
3529
|
}
|
|
3530
|
+
// /steer — interject into the running turn. Not admin-gated: anyone who
|
|
3531
|
+
// can talk to this agent can send it a message; steer only changes when
|
|
3532
|
+
// it lands, and it keeps the full [user:] formatting (unlike /raw).
|
|
3533
|
+
if (text === "/steer" || text.startsWith("/steer ") || text.startsWith("/steer@")) {
|
|
3534
|
+
const steerName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
3535
|
+
if (!steerName) {
|
|
3536
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
3537
|
+
return;
|
|
3538
|
+
}
|
|
3539
|
+
const steerContent = text.replace(/^\/steer(@\S+)?/, "").trim();
|
|
3540
|
+
if (!steerContent) {
|
|
3541
|
+
await msgAdapter?.sendText(chatId, t("steer.usage"));
|
|
3542
|
+
return;
|
|
3543
|
+
}
|
|
3544
|
+
const result = this.topicCommands.sendSteer(steerName, steerContent, msg);
|
|
3545
|
+
await msgAdapter?.sendText(chatId, result);
|
|
3546
|
+
return;
|
|
3547
|
+
}
|
|
3548
|
+
// /btw — Claude Code's native side-thread command. Like /steer it is
|
|
3549
|
+
// not admin-gated, but it remains a distinct raw CLI command so Claude
|
|
3550
|
+
// does not fold the question into the active task.
|
|
3551
|
+
if (text === "/btw" || text.startsWith("/btw ") || text.startsWith("/btw@")) {
|
|
3552
|
+
const btwName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
3553
|
+
if (!btwName) {
|
|
3554
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
3555
|
+
return;
|
|
3556
|
+
}
|
|
3557
|
+
const btwContent = text.replace(/^\/btw(@\S+)?/, "").trim();
|
|
3558
|
+
if (!btwContent) {
|
|
3559
|
+
await msgAdapter?.sendText(chatId, t("btw.usage"));
|
|
3560
|
+
return;
|
|
3561
|
+
}
|
|
3562
|
+
const result = this.topicCommands.sendBtw(btwName, btwContent, msg);
|
|
3563
|
+
await msgAdapter?.sendText(chatId, result);
|
|
3564
|
+
return;
|
|
3565
|
+
}
|
|
3103
3566
|
// Handle /clear command (admin only) — unlike /compact this starts a
|
|
3104
3567
|
// fresh conversation and intentionally discards the current history.
|
|
3105
3568
|
if (text === "/clear" || text.startsWith("/clear@")) {
|
|
@@ -3112,7 +3575,11 @@ export class FleetManager {
|
|
|
3112
3575
|
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
3113
3576
|
return;
|
|
3114
3577
|
}
|
|
3115
|
-
|
|
3578
|
+
if (!msgAdapter)
|
|
3579
|
+
return;
|
|
3580
|
+
const fallback = await this.promptClearConfirmation(clearName, chatId, msgAdapter, chatId);
|
|
3581
|
+
if (fallback)
|
|
3582
|
+
await msgAdapter.sendText(chatId, fallback);
|
|
3116
3583
|
return;
|
|
3117
3584
|
}
|
|
3118
3585
|
// Handle /cancel command
|
|
@@ -3123,7 +3590,7 @@ export class FleetManager {
|
|
|
3123
3590
|
return;
|
|
3124
3591
|
}
|
|
3125
3592
|
const ok = this.cancelInstance(cancelName);
|
|
3126
|
-
await msgAdapter?.sendText(chatId, ok ?
|
|
3593
|
+
await msgAdapter?.sendText(chatId, ok ? t("cancel.sent", cancelName) : t("cancel.not_running", cancelName));
|
|
3127
3594
|
return;
|
|
3128
3595
|
}
|
|
3129
3596
|
// Handle /ctx command
|
|
@@ -3160,7 +3627,7 @@ export class FleetManager {
|
|
|
3160
3627
|
const backend = this.classicChannels.getBackendByInstance(saveName, this.fleetConfig?.defaults?.backend);
|
|
3161
3628
|
const cmd = saveCommandForBackend(backend, filename);
|
|
3162
3629
|
if (!cmd) {
|
|
3163
|
-
await msgAdapter?.sendText(chatId,
|
|
3630
|
+
await msgAdapter?.sendText(chatId, t("save.unsupported"));
|
|
3164
3631
|
return;
|
|
3165
3632
|
}
|
|
3166
3633
|
this.pasteRawToClassicInstance(saveName, cmd);
|
|
@@ -3378,10 +3845,10 @@ export class FleetManager {
|
|
|
3378
3845
|
return;
|
|
3379
3846
|
let warning = "";
|
|
3380
3847
|
if (rl.five_hour_pct >= 95) {
|
|
3381
|
-
warning =
|
|
3848
|
+
warning = t("rate_limit.five_hour", instanceName, Math.round(rl.five_hour_pct));
|
|
3382
3849
|
}
|
|
3383
3850
|
else if (rl.seven_day_pct >= 95) {
|
|
3384
|
-
warning =
|
|
3851
|
+
warning = t("rate_limit.weekly", instanceName, Math.round(rl.seven_day_pct));
|
|
3385
3852
|
}
|
|
3386
3853
|
if (!warning)
|
|
3387
3854
|
return;
|
|
@@ -3505,7 +3972,7 @@ export class FleetManager {
|
|
|
3505
3972
|
this.clearCancelButton(instanceName);
|
|
3506
3973
|
}
|
|
3507
3974
|
else {
|
|
3508
|
-
void this.sendCancelButton(instanceName).then(() => this.armReplyGrace(instanceName));
|
|
3975
|
+
void this.sendCancelButton(instanceName, undefined, true).then(() => this.armReplyGrace(instanceName));
|
|
3509
3976
|
}
|
|
3510
3977
|
this.reactDone(instanceName);
|
|
3511
3978
|
const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
|
|
@@ -3542,7 +4009,11 @@ export class FleetManager {
|
|
|
3542
4009
|
if (silent) {
|
|
3543
4010
|
const ipc = this.instanceIpcClients.get(target);
|
|
3544
4011
|
if (ipc) {
|
|
3545
|
-
ipc.send({
|
|
4012
|
+
ipc.send({
|
|
4013
|
+
type: "raw_paste",
|
|
4014
|
+
content: message,
|
|
4015
|
+
delivery_epoch: this.getDeliveryEpoch(target),
|
|
4016
|
+
});
|
|
3546
4017
|
this.scheduler.recordRun(id, "delivered");
|
|
3547
4018
|
this.logger.info({ target, scheduleId: id, label }, "Silent schedule injected via raw_paste");
|
|
3548
4019
|
}
|
|
@@ -3557,10 +4028,19 @@ export class FleetManager {
|
|
|
3557
4028
|
const retryInterval = schedulerDefaults?.retry_interval_ms ?? 30_000;
|
|
3558
4029
|
const deliver = async () => {
|
|
3559
4030
|
try {
|
|
4031
|
+
// A schedule has no live inbound adapter context. Seed the daemon with
|
|
4032
|
+
// the target instance's configured world so replies use its persona
|
|
4033
|
+
// after a fresh start instead of falling back to channels[0].
|
|
4034
|
+
const adapterId = this.getInstanceAdapterId(target);
|
|
3560
4035
|
await this.deliverToInstance(target, {
|
|
3561
4036
|
type: "fleet_schedule_trigger",
|
|
3562
4037
|
payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
|
|
3563
|
-
meta: {
|
|
4038
|
+
meta: {
|
|
4039
|
+
chat_id: reply_chat_id,
|
|
4040
|
+
thread_id: reply_thread_id,
|
|
4041
|
+
user: "scheduler",
|
|
4042
|
+
...(adapterId ? { adapter_id: adapterId } : {}),
|
|
4043
|
+
},
|
|
3564
4044
|
}, { waitForIdle: true });
|
|
3565
4045
|
// A scheduled trigger also puts the instance to work — show a cancel button.
|
|
3566
4046
|
void this.sendCancelButton(target);
|
|
@@ -3708,7 +4188,29 @@ export class FleetManager {
|
|
|
3708
4188
|
}
|
|
3709
4189
|
/** Resolve display name for an instance, fallback to instance name. */
|
|
3710
4190
|
resolveDisplayName(instanceName) {
|
|
3711
|
-
return this.fleetConfig?.instances[instanceName]?.display_name
|
|
4191
|
+
return this.fleetConfig?.instances[instanceName]?.display_name
|
|
4192
|
+
?? this.classicChannels?.getAll().find(ch => ch.instanceName === instanceName)?.displayName
|
|
4193
|
+
?? instanceName;
|
|
4194
|
+
}
|
|
4195
|
+
/** Persist identity to the instance's actual config store. Classic instances
|
|
4196
|
+
* are registry rows in classicBot.yaml, not fleet.yaml instance entries. */
|
|
4197
|
+
setInstanceDisplayName(instanceName, displayName) {
|
|
4198
|
+
const fleetInstance = this.fleetConfig?.instances[instanceName];
|
|
4199
|
+
if (fleetInstance) {
|
|
4200
|
+
fleetInstance.display_name = displayName;
|
|
4201
|
+
this.saveFleetConfig();
|
|
4202
|
+
return true;
|
|
4203
|
+
}
|
|
4204
|
+
return this.classicChannels?.setDisplayNameByInstance(instanceName, displayName) ?? false;
|
|
4205
|
+
}
|
|
4206
|
+
setInstanceDescription(instanceName, description) {
|
|
4207
|
+
const fleetInstance = this.fleetConfig?.instances[instanceName];
|
|
4208
|
+
if (fleetInstance) {
|
|
4209
|
+
fleetInstance.description = description;
|
|
4210
|
+
this.saveFleetConfig();
|
|
4211
|
+
return true;
|
|
4212
|
+
}
|
|
4213
|
+
return this.classicChannels?.setDescriptionByInstance(instanceName, description) ?? false;
|
|
3712
4214
|
}
|
|
3713
4215
|
handleSetDisplayName(instanceName, msg) {
|
|
3714
4216
|
const fleetRequestId = msg.fleetRequestId;
|
|
@@ -3721,8 +4223,10 @@ export class FleetManager {
|
|
|
3721
4223
|
ipc.send({ type: "fleet_display_name_response", fleetRequestId, error: "Name must be 1-30 characters" });
|
|
3722
4224
|
return;
|
|
3723
4225
|
}
|
|
3724
|
-
this.
|
|
3725
|
-
|
|
4226
|
+
if (!this.setInstanceDisplayName(instanceName, displayName)) {
|
|
4227
|
+
ipc.send({ type: "fleet_display_name_response", fleetRequestId, error: `Instance '${instanceName}' not found` });
|
|
4228
|
+
return;
|
|
4229
|
+
}
|
|
3726
4230
|
this.logger.info({ instanceName, displayName }, "Display name set");
|
|
3727
4231
|
ipc.send({ type: "fleet_display_name_response", fleetRequestId, result: { display_name: displayName } });
|
|
3728
4232
|
}
|
|
@@ -3737,8 +4241,10 @@ export class FleetManager {
|
|
|
3737
4241
|
ipc.send({ type: "fleet_description_response", fleetRequestId, error: "Description cannot be empty" });
|
|
3738
4242
|
return;
|
|
3739
4243
|
}
|
|
3740
|
-
this.
|
|
3741
|
-
|
|
4244
|
+
if (!this.setInstanceDescription(instanceName, description)) {
|
|
4245
|
+
ipc.send({ type: "fleet_description_response", fleetRequestId, error: `Instance '${instanceName}' not found` });
|
|
4246
|
+
return;
|
|
4247
|
+
}
|
|
3742
4248
|
this.logger.info({ instanceName, description: description.slice(0, 80) }, "Description set");
|
|
3743
4249
|
ipc.send({ type: "fleet_description_response", fleetRequestId, result: { description } });
|
|
3744
4250
|
}
|
|
@@ -3867,8 +4373,8 @@ export class FleetManager {
|
|
|
3867
4373
|
return { error: "Fleet config not available" };
|
|
3868
4374
|
if (!name || name.length > 30)
|
|
3869
4375
|
return { error: "Name must be 1-30 characters" };
|
|
3870
|
-
this.
|
|
3871
|
-
|
|
4376
|
+
if (!this.setInstanceDisplayName(instance, name))
|
|
4377
|
+
return { error: `Instance '${instance}' not found` };
|
|
3872
4378
|
return { display_name: name };
|
|
3873
4379
|
}
|
|
3874
4380
|
async handleSetDescriptionHttp(instance, description) {
|
|
@@ -3876,8 +4382,8 @@ export class FleetManager {
|
|
|
3876
4382
|
return { error: "Fleet config not available" };
|
|
3877
4383
|
if (!description)
|
|
3878
4384
|
return { error: "Description cannot be empty" };
|
|
3879
|
-
this.
|
|
3880
|
-
|
|
4385
|
+
if (!this.setInstanceDescription(instance, description))
|
|
4386
|
+
return { error: `Instance '${instance}' not found` };
|
|
3881
4387
|
return { description };
|
|
3882
4388
|
}
|
|
3883
4389
|
summarizeToolCall(tool, args) {
|
|
@@ -4009,7 +4515,8 @@ export class FleetManager {
|
|
|
4009
4515
|
}
|
|
4010
4516
|
/**
|
|
4011
4517
|
* Patch only values changed in the effective config into the original YAML
|
|
4012
|
-
* document. Unknown keys
|
|
4518
|
+
* document. Unknown keys and comments remain untouched; redundant
|
|
4519
|
+
* non-identity instance leaves are canonicalized to inheritance afterward.
|
|
4013
4520
|
*/
|
|
4014
4521
|
saveFleetConfig(explicitPatches = []) {
|
|
4015
4522
|
if (!this.fleetConfig || !this.configPath)
|
|
@@ -4025,9 +4532,9 @@ export class FleetManager {
|
|
|
4025
4532
|
}
|
|
4026
4533
|
this.rawFleetConfig = loadRawFleetConfig(this.configPath);
|
|
4027
4534
|
this.patchFleetDocument(this.rawFleetDocument, [], this.savedFleetConfigSnapshot, this.fleetConfig);
|
|
4028
|
-
// Settings edits are expressed against the raw config
|
|
4029
|
-
//
|
|
4030
|
-
//
|
|
4535
|
+
// Settings edits are expressed against the raw config, so apply them before
|
|
4536
|
+
// canonicalization. A non-identity value equal to its inherited default is
|
|
4537
|
+
// intentionally stored as inheritance rather than an explicit duplicate.
|
|
4031
4538
|
for (const patch of explicitPatches) {
|
|
4032
4539
|
if (patch.remove) {
|
|
4033
4540
|
// YAML's deleteIn throws when an inherited nested key has no raw parent
|
|
@@ -4042,7 +4549,24 @@ export class FleetManager {
|
|
|
4042
4549
|
this.patchFleetDocument(this.rawFleetDocument, patch.path, before, patch.value);
|
|
4043
4550
|
}
|
|
4044
4551
|
}
|
|
4552
|
+
const rawAfterPatches = this.rawFleetDocument.toJS();
|
|
4553
|
+
const redundantPaths = collectRedundantInstanceDefaultPaths(rawAfterPatches);
|
|
4554
|
+
for (const path of redundantPaths) {
|
|
4555
|
+
if (this.rawFleetDocument.hasIn(path))
|
|
4556
|
+
this.rawFleetDocument.deleteIn(path);
|
|
4557
|
+
// Avoid leaving empty operational maps such as `terminal: {}` while
|
|
4558
|
+
// preserving the instance mapping itself and all surrounding comments.
|
|
4559
|
+
for (let depth = path.length - 1; depth > 2; depth--) {
|
|
4560
|
+
const parentPath = path.slice(0, depth);
|
|
4561
|
+
const parent = this.rawFleetDocument.getIn(parentPath, true);
|
|
4562
|
+
if (!isMap(parent) || parent.items.length > 0)
|
|
4563
|
+
break;
|
|
4564
|
+
this.rawFleetDocument.deleteIn(parentPath);
|
|
4565
|
+
}
|
|
4566
|
+
}
|
|
4045
4567
|
const output = String(this.rawFleetDocument);
|
|
4568
|
+
if (redundantPaths.length > 0)
|
|
4569
|
+
this.writeFleetConfigBackup(source);
|
|
4046
4570
|
const tempPath = `${this.configPath}.tmp-${process.pid}`;
|
|
4047
4571
|
writeFileSync(tempPath, output, "utf-8");
|
|
4048
4572
|
if (existsSync(this.configPath))
|
|
@@ -4050,7 +4574,36 @@ export class FleetManager {
|
|
|
4050
4574
|
renameSync(tempPath, this.configPath);
|
|
4051
4575
|
this.rawFleetConfig = loadRawFleetConfig(this.configPath);
|
|
4052
4576
|
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
4053
|
-
this.logger.info({ path: this.configPath }, "Saved fleet config (lossless patch)");
|
|
4577
|
+
this.logger.info({ path: this.configPath, strippedDefaults: redundantPaths.length }, "Saved fleet config (lossless patch)");
|
|
4578
|
+
}
|
|
4579
|
+
/** One-time upgrade migration; invalid YAML is never rewritten. */
|
|
4580
|
+
slimFleetConfigAtStartup() {
|
|
4581
|
+
const redundantPaths = collectRedundantInstanceDefaultPaths(this.rawFleetConfig);
|
|
4582
|
+
if (redundantPaths.length === 0)
|
|
4583
|
+
return;
|
|
4584
|
+
const validation = validateFleetConfig(this.rawFleetConfig);
|
|
4585
|
+
if (!validation.valid) {
|
|
4586
|
+
this.logger.warn({ errors: validation.errors, redundantDefaults: redundantPaths.length }, "Skipping fleet.yaml default slimming because the raw config is invalid");
|
|
4587
|
+
return;
|
|
4588
|
+
}
|
|
4589
|
+
try {
|
|
4590
|
+
this.saveFleetConfig();
|
|
4591
|
+
this.logger.info({ strippedDefaults: redundantPaths.length, backup: `${this.configPath}.bak` }, "Slimmed redundant instance defaults in fleet.yaml");
|
|
4592
|
+
}
|
|
4593
|
+
catch (err) {
|
|
4594
|
+
// A migration must not turn a previously bootable fleet into an outage.
|
|
4595
|
+
this.logger.warn({ err }, "Could not slim fleet.yaml; continuing with the original config");
|
|
4596
|
+
}
|
|
4597
|
+
}
|
|
4598
|
+
writeFleetConfigBackup(source) {
|
|
4599
|
+
if (!this.configPath)
|
|
4600
|
+
return;
|
|
4601
|
+
const backupPath = `${this.configPath}.bak`;
|
|
4602
|
+
const tempPath = `${backupPath}.tmp-${process.pid}`;
|
|
4603
|
+
writeFileSync(tempPath, source, "utf-8");
|
|
4604
|
+
if (existsSync(this.configPath))
|
|
4605
|
+
chmodSync(tempPath, statSync(this.configPath).mode);
|
|
4606
|
+
renameSync(tempPath, backupPath);
|
|
4054
4607
|
}
|
|
4055
4608
|
patchFleetDocument(document, path, before, after) {
|
|
4056
4609
|
if (Object.is(before, after))
|
|
@@ -4102,7 +4655,12 @@ export class FleetManager {
|
|
|
4102
4655
|
currentNode.value = after;
|
|
4103
4656
|
}
|
|
4104
4657
|
else {
|
|
4105
|
-
|
|
4658
|
+
// Use a YAML collection node for newly-added objects. Passing the raw
|
|
4659
|
+
// object creates a scalar wrapper: it serializes, but nested hasIn /
|
|
4660
|
+
// deleteIn cannot traverse it (notably when slimming create_instance).
|
|
4661
|
+
document.setIn(path, after !== null && typeof after === "object"
|
|
4662
|
+
? document.createNode(after)
|
|
4663
|
+
: after);
|
|
4106
4664
|
}
|
|
4107
4665
|
}
|
|
4108
4666
|
}
|
|
@@ -4193,7 +4751,7 @@ export class FleetManager {
|
|
|
4193
4751
|
from: primaryModel, to: fallbackModel, five_hour_pct: fiveHourPct,
|
|
4194
4752
|
});
|
|
4195
4753
|
this.webhookEmitter?.emit("model_failover", name, { from: primaryModel, to: fallbackModel, five_hour_pct: fiveHourPct });
|
|
4196
|
-
this.notifyInstanceTopic(name,
|
|
4754
|
+
this.notifyInstanceTopic(name, t("failover.triggered", fiveHourPct, fallbackModel, primaryModel));
|
|
4197
4755
|
}
|
|
4198
4756
|
else if (fiveHourPct < FleetManager.FAILOVER_RECOVER_PCT && currentFailover) {
|
|
4199
4757
|
// Recover: switch back to primary
|
|
@@ -4204,7 +4762,7 @@ export class FleetManager {
|
|
|
4204
4762
|
restored: primaryModel, five_hour_pct: fiveHourPct,
|
|
4205
4763
|
});
|
|
4206
4764
|
this.webhookEmitter?.emit("model_recovered", name, { restored: primaryModel, five_hour_pct: fiveHourPct });
|
|
4207
|
-
this.notifyInstanceTopic(name,
|
|
4765
|
+
this.notifyInstanceTopic(name, t("failover.recovered", fiveHourPct, primaryModel));
|
|
4208
4766
|
}
|
|
4209
4767
|
}
|
|
4210
4768
|
toggleFleetCollab(instanceName) {
|
|
@@ -4258,9 +4816,9 @@ export class FleetManager {
|
|
|
4258
4816
|
if (output)
|
|
4259
4817
|
return output;
|
|
4260
4818
|
if (e.code === "ENOENT") {
|
|
4261
|
-
return "
|
|
4819
|
+
return t("doctor.cli_missing");
|
|
4262
4820
|
}
|
|
4263
|
-
return stripAnsi(e.message ?? "").trim() || "
|
|
4821
|
+
return stripAnsi(e.message ?? "").trim() || t("doctor.failed");
|
|
4264
4822
|
}
|
|
4265
4823
|
}
|
|
4266
4824
|
/** Drop event/activity rows older than the retention window. Best-effort. */
|
|
@@ -4362,7 +4920,7 @@ export class FleetManager {
|
|
|
4362
4920
|
this.fleetErrorNotices.delete(oldest);
|
|
4363
4921
|
}
|
|
4364
4922
|
const body = suppressed > 0
|
|
4365
|
-
? `${text}\n(
|
|
4923
|
+
? `${text}\n${t("fleet.error_suppressed", suppressed, Math.round(FleetManager.FLEET_ERROR_THROTTLE_MS / 60_000))}`
|
|
4366
4924
|
: text;
|
|
4367
4925
|
// Resolved from config, NOT findGeneralInstance(): that requires a live daemon,
|
|
4368
4926
|
// and a fleet-level fault is exactly when the General may be down. The topic
|
|
@@ -4412,81 +4970,122 @@ export class FleetManager {
|
|
|
4412
4970
|
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
|
|
4413
4971
|
}
|
|
4414
4972
|
}
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4973
|
+
// ── Nonce-armed button prompts (hang / assist / exit / clear) ──
|
|
4974
|
+
//
|
|
4975
|
+
// One shared lifecycle for every "notification with decision buttons":
|
|
4976
|
+
// post with a 128-bit nonce, arm a bounded expiry, bind the click to the
|
|
4977
|
+
// exact adapter+chat+thread+message that created it, require fleet admin for
|
|
4978
|
+
// actions that mutate runtime state (Tips are the harmless exception),
|
|
4979
|
+
// consume exactly once. The features differ only in what they post
|
|
4980
|
+
// and what a consumed click does.
|
|
4420
4981
|
/**
|
|
4421
|
-
*
|
|
4422
|
-
*
|
|
4423
|
-
*
|
|
4424
|
-
*
|
|
4425
|
-
* Only the prompt category crosses channels. The captured terminal line can
|
|
4426
|
-
* contain usernames or secret-adjacent text and stays in daemon.log.
|
|
4982
|
+
* Post decision buttons whose callback ids are `<prefix><nonce>:<action>`.
|
|
4983
|
+
* The entry is registered before the send and rolled back if the send
|
|
4984
|
+
* fails, so a nonce in the map always refers to a message that exists (or
|
|
4985
|
+
* is about to). Returns the nonce, or null when the alert could not be sent.
|
|
4427
4986
|
*/
|
|
4428
|
-
async
|
|
4429
|
-
|
|
4430
|
-
//
|
|
4431
|
-
//
|
|
4432
|
-
const
|
|
4433
|
-
if (!generalName) {
|
|
4434
|
-
this.logger.warn({ instanceName }, "Interactive prompt has no General topic notification target");
|
|
4435
|
-
return;
|
|
4436
|
-
}
|
|
4437
|
-
const adapterId = this.getInstanceAdapterId(generalName);
|
|
4438
|
-
const adapter = this.getAdapterForInstance(generalName);
|
|
4439
|
-
const chatId = this.getGroupIdForInstance(generalName);
|
|
4440
|
-
const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
|
|
4441
|
-
const threadId = topicId != null ? String(topicId) : undefined;
|
|
4442
|
-
if (!adapter || !adapterId || !chatId) {
|
|
4443
|
-
this.logger.warn({ instanceName, generalName, adapterId, chatId }, "Cannot address interactive prompt assistance controls");
|
|
4444
|
-
return;
|
|
4445
|
-
}
|
|
4446
|
-
const nonce = randomBytes(8).toString("hex");
|
|
4447
|
-
const label = this.interactivePromptLabel(kind);
|
|
4987
|
+
async postNonceButtonPrompt(opts) {
|
|
4988
|
+
// 16 bytes = the 128-bit capability the design claims. Telegram's 64-byte
|
|
4989
|
+
// callback_data cap still holds: longest id is "interactive-assist:" (19)
|
|
4990
|
+
// + 32 hex + ":confirm" (8) = 59 bytes.
|
|
4991
|
+
const nonce = randomBytes(16).toString("hex");
|
|
4448
4992
|
const entry = {
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4993
|
+
prefix: opts.prefix,
|
|
4994
|
+
instanceName: opts.instanceName,
|
|
4995
|
+
adapterId: opts.adapterId,
|
|
4996
|
+
adapter: opts.adapter,
|
|
4997
|
+
chatId: opts.chatId,
|
|
4998
|
+
threadId: opts.threadId,
|
|
4999
|
+
expiredText: opts.expiredText,
|
|
5000
|
+
...opts.extra,
|
|
4456
5001
|
};
|
|
4457
5002
|
entry.timer = setTimeout(() => {
|
|
4458
|
-
const pending = this.
|
|
5003
|
+
const pending = this.pendingNonceButtons.get(nonce);
|
|
4459
5004
|
if (pending !== entry)
|
|
4460
5005
|
return;
|
|
4461
|
-
this.
|
|
5006
|
+
this.pendingNonceButtons.delete(nonce);
|
|
4462
5007
|
if (entry.messageId && entry.adapter.editMessageRemoveButtons) {
|
|
4463
|
-
entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId,
|
|
5008
|
+
entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId).catch(err => this.logger.debug({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Failed to expire button prompt"));
|
|
4464
5009
|
}
|
|
4465
|
-
},
|
|
5010
|
+
}, opts.timeoutMs ?? NONCE_BUTTON_TIMEOUT_MS);
|
|
4466
5011
|
entry.timer.unref?.();
|
|
4467
|
-
this.
|
|
5012
|
+
this.pendingNonceButtons.set(nonce, entry);
|
|
4468
5013
|
try {
|
|
4469
|
-
const sent = await adapter.notifyAlert(chatId, {
|
|
4470
|
-
type:
|
|
4471
|
-
instanceName,
|
|
4472
|
-
message:
|
|
4473
|
-
choices:
|
|
4474
|
-
|
|
4475
|
-
{ id: `${INTERACTIVE_ASSIST_CALLBACK_PREFIX}${nonce}:cancel`, label: t("interactive.cancel") },
|
|
4476
|
-
],
|
|
4477
|
-
}, threadId ? { threadId } : undefined);
|
|
5014
|
+
const sent = await opts.adapter.notifyAlert(opts.chatId, {
|
|
5015
|
+
type: opts.alertType,
|
|
5016
|
+
instanceName: opts.instanceName,
|
|
5017
|
+
message: opts.message,
|
|
5018
|
+
choices: opts.choices.map(c => ({ id: `${opts.prefix}${nonce}:${c.action}`, label: c.label })),
|
|
5019
|
+
}, opts.threadId ? { threadId: opts.threadId } : undefined);
|
|
4478
5020
|
entry.messageId = sent.messageId;
|
|
5021
|
+
return nonce;
|
|
4479
5022
|
}
|
|
4480
5023
|
catch (err) {
|
|
4481
|
-
this.
|
|
5024
|
+
this.pendingNonceButtons.delete(nonce);
|
|
4482
5025
|
if (entry.timer)
|
|
4483
5026
|
clearTimeout(entry.timer);
|
|
4484
|
-
this.logger.warn({ err, instanceName,
|
|
5027
|
+
this.logger.warn({ err, instanceName: opts.instanceName, prefix: opts.prefix }, "Failed to send button prompt");
|
|
5028
|
+
return null;
|
|
4485
5029
|
}
|
|
4486
5030
|
}
|
|
4487
|
-
|
|
5031
|
+
/**
|
|
5032
|
+
* Claim a nonce-armed callback exactly once.
|
|
5033
|
+
*
|
|
5034
|
+
* Returns:
|
|
5035
|
+
* - null — the callback is not for this prefix; try the next handler
|
|
5036
|
+
* - "consumed" — for this prefix but stale/denied/malformed; stop dispatch
|
|
5037
|
+
* - the entry+action — the click is authorized and now claimed (removed
|
|
5038
|
+
* from the map before any await, so double clicks cannot act twice)
|
|
5039
|
+
*
|
|
5040
|
+
* A stale click (expired nonce, or a pre-upgrade button whose payload no
|
|
5041
|
+
* longer parses) collapses the clicked message so the dead button stops
|
|
5042
|
+
* inviting clicks — the same courtesy the cancel button extends.
|
|
5043
|
+
*/
|
|
5044
|
+
consumeNonceCallback(prefix, actionRe, data, callbackAdapterId, receivingAdapter) {
|
|
5045
|
+
if (!data.callbackData.startsWith(prefix))
|
|
5046
|
+
return null;
|
|
5047
|
+
const match = data.callbackData.match(actionRe);
|
|
5048
|
+
let pending = match ? this.pendingNonceButtons.get(match[1]) : undefined;
|
|
5049
|
+
// The map is shared across prompt kinds. A nonce that resolves to an entry
|
|
5050
|
+
// of a DIFFERENT kind is not a usable capability for this handler — treat
|
|
5051
|
+
// it as stale rather than acting across kinds (fail closed).
|
|
5052
|
+
if (pending && pending.prefix !== prefix)
|
|
5053
|
+
pending = undefined;
|
|
5054
|
+
if (!match || !pending) {
|
|
5055
|
+
const adapter = receivingAdapter ?? this.adapter;
|
|
5056
|
+
adapter?.editMessageRemoveButtons?.(data.chatId, data.messageId, t("buttons.stale"), data.threadId).catch(() => { });
|
|
5057
|
+
return "consumed";
|
|
5058
|
+
}
|
|
5059
|
+
// Bind the capability to the exact message/world that created it. Telegram
|
|
5060
|
+
// keyboards are visible to everyone, so mutating actions require fleet admin;
|
|
5061
|
+
// a Tip acknowledgement only records that the shared content was read.
|
|
5062
|
+
const isAuthorized = data.userId
|
|
5063
|
+
? pending.allowAnyUser
|
|
5064
|
+
? true
|
|
5065
|
+
: pending.authChannelId
|
|
5066
|
+
? this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
|
|
5067
|
+
: this.isFleetAdmin(data.userId, callbackAdapterId)
|
|
5068
|
+
: false;
|
|
5069
|
+
if (pending.adapterId !== callbackAdapterId
|
|
5070
|
+
|| data.chatId !== pending.chatId
|
|
5071
|
+
|| (pending.threadId != null && data.threadId !== pending.threadId)
|
|
5072
|
+
|| (pending.messageId != null && data.messageId !== pending.messageId)
|
|
5073
|
+
|| !isAuthorized) {
|
|
5074
|
+
// Deliberately does NOT consume the nonce: the real admin can still click.
|
|
5075
|
+
this.logger.warn({ instanceName: pending.instanceName, prefix, userId: data.userId }, "Rejected unauthorized or mismatched button callback");
|
|
5076
|
+
return "consumed";
|
|
5077
|
+
}
|
|
5078
|
+
// Claim before any await: double clicks and duplicate callback delivery can
|
|
5079
|
+
// never act twice.
|
|
5080
|
+
this.pendingNonceButtons.delete(match[1]);
|
|
5081
|
+
if (pending.timer)
|
|
5082
|
+
clearTimeout(pending.timer);
|
|
5083
|
+
return { entry: pending, action: match[2] };
|
|
5084
|
+
}
|
|
5085
|
+
/** Collapse a consumed prompt's buttons into a final status line. */
|
|
5086
|
+
async retireNonceButtons(pending, messageId, text) {
|
|
4488
5087
|
if (!pending.adapter.editMessageRemoveButtons) {
|
|
4489
|
-
this.logger.warn({ instanceName: pending.instanceName, adapterId: pending.adapterId }, "Adapter cannot remove
|
|
5088
|
+
this.logger.warn({ instanceName: pending.instanceName, adapterId: pending.adapterId }, "Adapter cannot remove prompt buttons");
|
|
4490
5089
|
return;
|
|
4491
5090
|
}
|
|
4492
5091
|
try {
|
|
@@ -4494,58 +5093,375 @@ export class FleetManager {
|
|
|
4494
5093
|
}
|
|
4495
5094
|
catch (err) {
|
|
4496
5095
|
// The action was already atomically consumed. An edit failure must not
|
|
4497
|
-
//
|
|
4498
|
-
this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire
|
|
5096
|
+
// undo that or make the button actionable again.
|
|
5097
|
+
this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons");
|
|
4499
5098
|
}
|
|
4500
5099
|
}
|
|
4501
|
-
|
|
4502
|
-
|
|
4503
|
-
|
|
5100
|
+
tipsEnabled() {
|
|
5101
|
+
return this.fleetConfig?.defaults.tips !== false;
|
|
5102
|
+
}
|
|
5103
|
+
readTipState() {
|
|
5104
|
+
if (!this.scheduler)
|
|
5105
|
+
return null;
|
|
5106
|
+
try {
|
|
5107
|
+
return {
|
|
5108
|
+
dismissed: this.scheduler.db.listDismissedTipIds(),
|
|
5109
|
+
advancedUnlocked: this.scheduler.db.isAdvancedTipsUnlocked(),
|
|
5110
|
+
};
|
|
5111
|
+
}
|
|
5112
|
+
catch (err) {
|
|
5113
|
+
// Tips are additive. A damaged/locked scheduler DB must never block fleet
|
|
5114
|
+
// startup, update completion, or a General command.
|
|
5115
|
+
this.logger.warn({ err }, "Failed to read tip state");
|
|
5116
|
+
return null;
|
|
5117
|
+
}
|
|
5118
|
+
}
|
|
5119
|
+
pickAvailableTip() {
|
|
5120
|
+
const state = this.readTipState();
|
|
5121
|
+
return state ? selectTip(state.dismissed, Math.random, state.advancedUnlocked) : null;
|
|
5122
|
+
}
|
|
5123
|
+
tipText(tip) {
|
|
5124
|
+
return getLocale() === "zh-TW" ? tip.text_zh : tip.text_en;
|
|
5125
|
+
}
|
|
5126
|
+
formatTip(tip) {
|
|
5127
|
+
return `💡 ${t("tips.label")}: ${this.tipText(tip)}`;
|
|
5128
|
+
}
|
|
5129
|
+
/** Post one fresh nonce-armed tip in a known General channel. */
|
|
5130
|
+
async promptTip(generalName, adapter, chatId, threadId) {
|
|
5131
|
+
const state = this.readTipState();
|
|
5132
|
+
if (!state)
|
|
5133
|
+
return "unavailable";
|
|
5134
|
+
if (!state.advancedUnlocked && canUnlockAdvancedTips(state.dismissed)) {
|
|
5135
|
+
return await this.promptAdvancedTipUnlock(generalName, adapter, chatId, threadId)
|
|
5136
|
+
? "posted"
|
|
5137
|
+
: "unavailable";
|
|
5138
|
+
}
|
|
5139
|
+
const tip = selectTip(state.dismissed, Math.random, state.advancedUnlocked);
|
|
5140
|
+
if (!tip)
|
|
5141
|
+
return this.scheduler ? "empty" : "unavailable";
|
|
5142
|
+
const nonce = await this.postNonceButtonPrompt({
|
|
5143
|
+
prefix: TIP_DISMISS_CALLBACK_PREFIX,
|
|
5144
|
+
alertType: "tip",
|
|
5145
|
+
instanceName: generalName,
|
|
5146
|
+
adapter,
|
|
5147
|
+
adapterId: adapter.id,
|
|
5148
|
+
chatId,
|
|
5149
|
+
threadId,
|
|
5150
|
+
message: this.formatTip(tip),
|
|
5151
|
+
choices: [{ action: "dismiss", label: t("tips.dismiss") }],
|
|
5152
|
+
// Expiry removes only the stale button; the useful tip remains readable.
|
|
5153
|
+
expiredText: this.formatTip(tip),
|
|
5154
|
+
extra: { allowAnyUser: true, tipId: tip.id },
|
|
5155
|
+
timeoutMs: TIP_BUTTON_TIMEOUT_MS,
|
|
5156
|
+
});
|
|
5157
|
+
return nonce ? "posted" : "unavailable";
|
|
5158
|
+
}
|
|
5159
|
+
async promptAdvancedTipUnlock(generalName, adapter, chatId, threadId) {
|
|
5160
|
+
const nonce = await this.postNonceButtonPrompt({
|
|
5161
|
+
prefix: TIP_UNLOCK_CALLBACK_PREFIX,
|
|
5162
|
+
alertType: "tip",
|
|
5163
|
+
instanceName: generalName,
|
|
5164
|
+
adapter,
|
|
5165
|
+
adapterId: adapter.id,
|
|
5166
|
+
chatId,
|
|
5167
|
+
threadId,
|
|
5168
|
+
message: t("tips.advanced.unlock_prompt"),
|
|
5169
|
+
choices: [{ action: "unlock", label: t("tips.advanced.unlock") }],
|
|
5170
|
+
expiredText: t("tips.advanced.expired"),
|
|
5171
|
+
extra: { allowAnyUser: true },
|
|
5172
|
+
timeoutMs: TIP_BUTTON_TIMEOUT_MS,
|
|
5173
|
+
});
|
|
5174
|
+
return nonce !== null;
|
|
5175
|
+
}
|
|
5176
|
+
async sendTipToGeneral() {
|
|
5177
|
+
if (!this.tipsEnabled())
|
|
5178
|
+
return;
|
|
5179
|
+
const generalName = this.findGeneralInstance();
|
|
5180
|
+
if (!generalName)
|
|
5181
|
+
return;
|
|
5182
|
+
const adapter = this.getAdapterForInstance(generalName);
|
|
5183
|
+
const chatId = this.getGroupIdForInstance(generalName);
|
|
5184
|
+
const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
|
|
5185
|
+
if (!adapter || !chatId)
|
|
5186
|
+
return;
|
|
5187
|
+
const result = await this.promptTip(generalName, adapter, chatId, topicId != null ? String(topicId) : undefined);
|
|
5188
|
+
if (result === "unavailable") {
|
|
5189
|
+
this.logger.warn({ generalName }, "Daily tip could not be posted");
|
|
5190
|
+
}
|
|
5191
|
+
}
|
|
5192
|
+
async handleTipDismiss(data, callbackAdapterId, receivingAdapter) {
|
|
5193
|
+
const claimed = this.consumeNonceCallback(TIP_DISMISS_CALLBACK_PREFIX, /^tip-dismiss:([0-9a-f]+):(dismiss)$/, data, callbackAdapterId, receivingAdapter);
|
|
5194
|
+
if (claimed === null)
|
|
4504
5195
|
return false;
|
|
4505
|
-
|
|
4506
|
-
if (!match)
|
|
5196
|
+
if (claimed === "consumed")
|
|
4507
5197
|
return true;
|
|
4508
|
-
const pending =
|
|
4509
|
-
if (!pending)
|
|
5198
|
+
const { entry: pending } = claimed;
|
|
5199
|
+
if (!pending.tipId || !data.userId || !this.scheduler) {
|
|
5200
|
+
this.logger.error({ tipId: pending.tipId, userId: data.userId }, "Tip dismissal has incomplete persistence context");
|
|
5201
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
|
|
4510
5202
|
return true;
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|| !this.isFleetAdmin(data.userId, callbackAdapterId)) {
|
|
4519
|
-
this.logger.warn({ instanceName: pending.instanceName, userId: data.userId }, "Rejected unauthorized or mismatched interactive prompt callback");
|
|
5203
|
+
}
|
|
5204
|
+
try {
|
|
5205
|
+
this.scheduler.db.dismissTip(data.userId, pending.tipId);
|
|
5206
|
+
}
|
|
5207
|
+
catch (err) {
|
|
5208
|
+
this.logger.warn({ err, tipId: pending.tipId, userId: data.userId }, "Failed to persist tip dismissal");
|
|
5209
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
|
|
4520
5210
|
return true;
|
|
4521
5211
|
}
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
|
|
4527
|
-
const
|
|
5212
|
+
this.eventLog?.insert(pending.instanceName, "tip_dismissed", {
|
|
5213
|
+
tipId: pending.tipId,
|
|
5214
|
+
userId: data.userId,
|
|
5215
|
+
});
|
|
5216
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.dismissed"));
|
|
5217
|
+
const state = this.readTipState();
|
|
5218
|
+
if (state && !state.advancedUnlocked && canUnlockAdvancedTips(state.dismissed)) {
|
|
5219
|
+
await this.promptAdvancedTipUnlock(pending.instanceName, pending.adapter, pending.chatId, pending.threadId);
|
|
5220
|
+
}
|
|
5221
|
+
return true;
|
|
5222
|
+
}
|
|
5223
|
+
async handleTipUnlock(data, callbackAdapterId, receivingAdapter) {
|
|
5224
|
+
const claimed = this.consumeNonceCallback(TIP_UNLOCK_CALLBACK_PREFIX, /^tip-unlock:([0-9a-f]+):(unlock)$/, data, callbackAdapterId, receivingAdapter);
|
|
5225
|
+
if (claimed === null)
|
|
5226
|
+
return false;
|
|
5227
|
+
if (claimed === "consumed")
|
|
5228
|
+
return true;
|
|
5229
|
+
const { entry: pending } = claimed;
|
|
5230
|
+
if (!data.userId || !this.scheduler) {
|
|
5231
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
|
|
5232
|
+
return true;
|
|
5233
|
+
}
|
|
5234
|
+
try {
|
|
5235
|
+
this.scheduler.db.unlockAdvancedTips(data.userId);
|
|
5236
|
+
}
|
|
5237
|
+
catch (err) {
|
|
5238
|
+
this.logger.warn({ err, userId: data.userId }, "Failed to unlock advanced tips");
|
|
5239
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
|
|
5240
|
+
return true;
|
|
5241
|
+
}
|
|
5242
|
+
this.eventLog?.insert(pending.instanceName, "tips_advanced_unlocked", { userId: data.userId });
|
|
5243
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.advanced.unlocked"));
|
|
5244
|
+
return true;
|
|
5245
|
+
}
|
|
5246
|
+
/** Post the destructive `/clear` confirmation in the invoking channel. */
|
|
5247
|
+
async promptClearConfirmation(instanceName, channelId, adapter, chatId, threadId) {
|
|
5248
|
+
if (!this.topicCommands.supportsClear(instanceName))
|
|
5249
|
+
return t("clear.unsupported");
|
|
5250
|
+
const adapterId = adapter.id;
|
|
5251
|
+
if (!adapterId)
|
|
5252
|
+
return t("clear.prompt_unavailable");
|
|
5253
|
+
const nonce = await this.postNonceButtonPrompt({
|
|
5254
|
+
prefix: CLEAR_CONFIRM_CALLBACK_PREFIX,
|
|
5255
|
+
alertType: "clear_confirm",
|
|
5256
|
+
instanceName,
|
|
5257
|
+
adapter,
|
|
5258
|
+
adapterId,
|
|
5259
|
+
chatId,
|
|
5260
|
+
threadId,
|
|
5261
|
+
message: t("clear.confirm_message", instanceName),
|
|
5262
|
+
choices: [
|
|
5263
|
+
{ action: "confirm", label: t("clear.confirm") },
|
|
5264
|
+
{ action: "cancel", label: t("clear.cancel") },
|
|
5265
|
+
],
|
|
5266
|
+
expiredText: t("clear.expired", instanceName),
|
|
5267
|
+
extra: { authChannelId: channelId },
|
|
5268
|
+
timeoutMs: CLEAR_CONFIRM_TIMEOUT_MS,
|
|
5269
|
+
});
|
|
5270
|
+
return nonce ? null : t("clear.prompt_unavailable");
|
|
5271
|
+
}
|
|
5272
|
+
/** Consume `/clear` Confirm/Cancel exactly once; only Confirm reaches IPC. */
|
|
5273
|
+
async handleClearConfirmation(data, callbackAdapterId, receivingAdapter) {
|
|
5274
|
+
const claimed = this.consumeNonceCallback(CLEAR_CONFIRM_CALLBACK_PREFIX, /^clear-confirm:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
|
|
5275
|
+
if (claimed === null)
|
|
5276
|
+
return false;
|
|
5277
|
+
if (claimed === "consumed")
|
|
5278
|
+
return true;
|
|
5279
|
+
const { entry: pending, action } = claimed;
|
|
5280
|
+
this.eventLog?.insert(pending.instanceName, "clear_action", { action, userId: data.userId });
|
|
5281
|
+
if (action === "cancel") {
|
|
5282
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.cancelled", pending.instanceName));
|
|
5283
|
+
return true;
|
|
5284
|
+
}
|
|
5285
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.clearing", pending.instanceName));
|
|
5286
|
+
try {
|
|
5287
|
+
const result = await this.topicCommands.sendClear(pending.instanceName);
|
|
5288
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, result, pending.threadId);
|
|
5289
|
+
}
|
|
5290
|
+
catch (err) {
|
|
5291
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5292
|
+
this.logger.error({ err, instanceName: pending.instanceName }, "Clear command failed");
|
|
5293
|
+
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"));
|
|
5294
|
+
}
|
|
5295
|
+
return true;
|
|
5296
|
+
}
|
|
5297
|
+
/**
|
|
5298
|
+
* Drop every pending prompt that refers to an instance being stopped or
|
|
5299
|
+
* restarted — a restart offer for an instance the operator just restarted
|
|
5300
|
+
* (or an assist for one they stopped) must not stay clickable for the rest
|
|
5301
|
+
* of its 15 minutes.
|
|
5302
|
+
*/
|
|
5303
|
+
clearNoncePromptsForInstance(instanceName) {
|
|
5304
|
+
for (const [nonce, entry] of this.pendingNonceButtons) {
|
|
5305
|
+
if (entry.instanceName !== instanceName)
|
|
5306
|
+
continue;
|
|
5307
|
+
this.pendingNonceButtons.delete(nonce);
|
|
5308
|
+
if (entry.timer)
|
|
5309
|
+
clearTimeout(entry.timer);
|
|
5310
|
+
if (entry.messageId && entry.adapter.editMessageRemoveButtons) {
|
|
5311
|
+
entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId)
|
|
5312
|
+
.catch(err => this.logger.debug({ err, instanceName, prefix: entry.prefix }, "Failed to collapse prompt during instance stop"));
|
|
5313
|
+
}
|
|
5314
|
+
}
|
|
5315
|
+
}
|
|
5316
|
+
/** A clean exit is intentional from the CLI's perspective, but often not from
|
|
5317
|
+
* the operator's. Keep the instance notice passive and put the action in the
|
|
5318
|
+
* same-world General topic where an administrator can make the choice. */
|
|
5319
|
+
async notifyNormalExit(instanceName) {
|
|
5320
|
+
this.notifyInstanceTopic(instanceName, t("exit.instance_notice", instanceName));
|
|
5321
|
+
const worldId = this.getInstanceAdapterId(instanceName);
|
|
5322
|
+
const generalName = this.findGeneralInstance(worldId);
|
|
5323
|
+
if (!generalName) {
|
|
5324
|
+
this.logger.warn({ instanceName, worldId }, "Normal CLI exit has no General notification target");
|
|
5325
|
+
return;
|
|
5326
|
+
}
|
|
5327
|
+
const adapterId = this.getInstanceAdapterId(generalName);
|
|
5328
|
+
const adapter = this.getAdapterForInstance(generalName);
|
|
5329
|
+
const chatId = this.getGroupIdForInstance(generalName);
|
|
5330
|
+
const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
|
|
5331
|
+
const threadId = topicId != null ? String(topicId) : undefined;
|
|
5332
|
+
if (!adapter || !adapterId || !chatId) {
|
|
5333
|
+
this.logger.warn({ instanceName, generalName, adapterId, chatId }, "Cannot address normal-exit restart controls");
|
|
5334
|
+
return;
|
|
5335
|
+
}
|
|
5336
|
+
await this.postNonceButtonPrompt({
|
|
5337
|
+
prefix: EXIT_RESTART_CALLBACK_PREFIX,
|
|
5338
|
+
alertType: "exit_restart",
|
|
5339
|
+
instanceName,
|
|
5340
|
+
adapter,
|
|
5341
|
+
adapterId,
|
|
5342
|
+
chatId,
|
|
5343
|
+
threadId,
|
|
5344
|
+
message: t("exit.general_notice", instanceName),
|
|
5345
|
+
choices: [
|
|
5346
|
+
{ action: "restart", label: t("exit.restart") },
|
|
5347
|
+
{ action: "ignore", label: t("exit.ignore") },
|
|
5348
|
+
],
|
|
5349
|
+
expiredText: t("exit.expired", instanceName),
|
|
5350
|
+
});
|
|
5351
|
+
}
|
|
5352
|
+
/** Consume a clean-exit Restart/Ignore button exactly once. */
|
|
5353
|
+
async handleExitRestartPrompt(data, callbackAdapterId, receivingAdapter) {
|
|
5354
|
+
const claimed = this.consumeNonceCallback(EXIT_RESTART_CALLBACK_PREFIX, /^exit-restart:([0-9a-f]+):(restart|ignore)$/, data, callbackAdapterId, receivingAdapter);
|
|
5355
|
+
if (claimed === null)
|
|
5356
|
+
return false;
|
|
5357
|
+
if (claimed === "consumed")
|
|
5358
|
+
return true;
|
|
5359
|
+
const { entry: pending, action } = claimed;
|
|
5360
|
+
this.eventLog?.insert(pending.instanceName, "normal_exit_action", { action, userId: data.userId });
|
|
5361
|
+
if (action === "ignore") {
|
|
5362
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.ignored", pending.instanceName));
|
|
5363
|
+
return true;
|
|
5364
|
+
}
|
|
5365
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.restarting", pending.instanceName));
|
|
5366
|
+
try {
|
|
5367
|
+
await this.restartSingleInstance(pending.instanceName);
|
|
5368
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("exit.restarted", pending.instanceName), pending.threadId);
|
|
5369
|
+
}
|
|
5370
|
+
catch (err) {
|
|
5371
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5372
|
+
this.logger.error({ err, instanceName: pending.instanceName }, "Normal-exit restart failed");
|
|
5373
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("exit.restart_failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show normal-exit restart error"));
|
|
5374
|
+
}
|
|
5375
|
+
return true;
|
|
5376
|
+
}
|
|
5377
|
+
interactivePromptLabel(kind) {
|
|
5378
|
+
const key = `interactive.kind.${kind}`;
|
|
5379
|
+
const translated = t(key);
|
|
5380
|
+
return translated === key ? kind.replaceAll("_", " ") : translated;
|
|
5381
|
+
}
|
|
5382
|
+
/**
|
|
5383
|
+
* Surface a stable terminal prompt in both places that matter:
|
|
5384
|
+
* - the blocked instance gets a plain pointer to General;
|
|
5385
|
+
* - General gets one-shot Confirm/Cancel controls on its own bound adapter.
|
|
5386
|
+
*
|
|
5387
|
+
* Only the prompt category crosses channels. The captured terminal line can
|
|
5388
|
+
* contain usernames or secret-adjacent text and stays in daemon.log.
|
|
5389
|
+
*/
|
|
5390
|
+
async notifyInteractivePrompt(instanceName, kind) {
|
|
5391
|
+
this.notifyInstanceTopic(instanceName, t("interactive.instance_notice", instanceName));
|
|
5392
|
+
// Multi-world fleets can have one General per bot/platform. Route the assist
|
|
5393
|
+
// request to the General bound to the same adapter as the blocked instance.
|
|
5394
|
+
const generalName = this.findGeneralInstance(this.getInstanceAdapterId(instanceName));
|
|
5395
|
+
if (!generalName) {
|
|
5396
|
+
this.logger.warn({ instanceName }, "Interactive prompt has no General topic notification target");
|
|
5397
|
+
return;
|
|
5398
|
+
}
|
|
5399
|
+
const adapterId = this.getInstanceAdapterId(generalName);
|
|
5400
|
+
const adapter = this.getAdapterForInstance(generalName);
|
|
5401
|
+
const chatId = this.getGroupIdForInstance(generalName);
|
|
5402
|
+
const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
|
|
5403
|
+
const threadId = topicId != null ? String(topicId) : undefined;
|
|
5404
|
+
if (!adapter || !adapterId || !chatId) {
|
|
5405
|
+
this.logger.warn({ instanceName, generalName, adapterId, chatId }, "Cannot address interactive prompt assistance controls");
|
|
5406
|
+
return;
|
|
5407
|
+
}
|
|
5408
|
+
const label = this.interactivePromptLabel(kind);
|
|
5409
|
+
await this.postNonceButtonPrompt({
|
|
5410
|
+
prefix: INTERACTIVE_ASSIST_CALLBACK_PREFIX,
|
|
5411
|
+
alertType: "interactive_prompt",
|
|
5412
|
+
instanceName,
|
|
5413
|
+
adapter,
|
|
5414
|
+
adapterId,
|
|
5415
|
+
chatId,
|
|
5416
|
+
threadId,
|
|
5417
|
+
message: t("interactive.general_notice", instanceName, label),
|
|
5418
|
+
choices: [
|
|
5419
|
+
{ action: "confirm", label: t("interactive.confirm") },
|
|
5420
|
+
{ action: "cancel", label: t("interactive.cancel") },
|
|
5421
|
+
],
|
|
5422
|
+
expiredText: t("interactive.expired", instanceName),
|
|
5423
|
+
extra: { generalName, promptKind: kind },
|
|
5424
|
+
});
|
|
5425
|
+
}
|
|
5426
|
+
/** Consume a General assist button exactly once. */
|
|
5427
|
+
async handleInteractivePromptAssist(data, callbackAdapterId, receivingAdapter) {
|
|
5428
|
+
const claimed = this.consumeNonceCallback(INTERACTIVE_ASSIST_CALLBACK_PREFIX, /^interactive-assist:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
|
|
5429
|
+
if (claimed === null)
|
|
5430
|
+
return false;
|
|
5431
|
+
if (claimed === "consumed")
|
|
5432
|
+
return true;
|
|
5433
|
+
const { entry: pending, action } = claimed;
|
|
4528
5434
|
this.eventLog?.insert(pending.instanceName, "interactive_prompt_assist", {
|
|
4529
5435
|
action,
|
|
4530
5436
|
userId: data.userId,
|
|
4531
5437
|
generalName: pending.generalName,
|
|
4532
5438
|
});
|
|
4533
5439
|
if (action === "cancel") {
|
|
4534
|
-
await this.
|
|
5440
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.ignored", pending.instanceName));
|
|
5441
|
+
return true;
|
|
5442
|
+
}
|
|
5443
|
+
// Fail closed on a malformed entry. generalName is set at posting time for
|
|
5444
|
+
// every interactive-assist entry; its absence means entry confusion, and
|
|
5445
|
+
// falling back to the blocked instance would type the assist text into the
|
|
5446
|
+
// very terminal prompt this feature exists to keep humans in front of.
|
|
5447
|
+
const generalName = pending.generalName;
|
|
5448
|
+
if (!generalName) {
|
|
5449
|
+
this.logger.error({ instanceName: pending.instanceName }, "Interactive-assist entry has no General target — refusing to deliver");
|
|
5450
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.delivery_failed", pending.instanceName));
|
|
4535
5451
|
return true;
|
|
4536
5452
|
}
|
|
4537
5453
|
// Injecting into General while General itself is blocked would type into the
|
|
4538
5454
|
// terminal prompt instead of the agent input. Fail safe and require attach.
|
|
4539
|
-
if (pending.instanceName ===
|
|
4540
|
-
await this.
|
|
5455
|
+
if (pending.instanceName === generalName) {
|
|
5456
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.self_assist", pending.instanceName));
|
|
4541
5457
|
return true;
|
|
4542
5458
|
}
|
|
4543
|
-
await this.
|
|
5459
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("interactive.confirmed", pending.instanceName));
|
|
4544
5460
|
try {
|
|
4545
|
-
await this.deliverToInstance(
|
|
5461
|
+
await this.deliverToInstance(generalName, {
|
|
4546
5462
|
type: "fleet_inbound",
|
|
4547
|
-
content: t("interactive.assist_request", pending.instanceName, this.interactivePromptLabel(pending.
|
|
4548
|
-
targetSession:
|
|
5463
|
+
content: t("interactive.assist_request", pending.instanceName, this.interactivePromptLabel(pending.promptKind ?? "")),
|
|
5464
|
+
targetSession: generalName,
|
|
4549
5465
|
meta: {
|
|
4550
5466
|
chat_id: pending.chatId,
|
|
4551
5467
|
message_id: pending.messageId ?? data.messageId,
|
|
@@ -4604,7 +5520,7 @@ export class FleetManager {
|
|
|
4604
5520
|
const force = target.kind === "classic" && !!data.options?.force;
|
|
4605
5521
|
const cmd = saveCommandForBackend(backend, filename, force);
|
|
4606
5522
|
if (!cmd) {
|
|
4607
|
-
await data.respond(
|
|
5523
|
+
await data.respond(t("save.unsupported"));
|
|
4608
5524
|
return;
|
|
4609
5525
|
}
|
|
4610
5526
|
if (target.kind === "classic") {
|
|
@@ -4657,7 +5573,7 @@ export class FleetManager {
|
|
|
4657
5573
|
}
|
|
4658
5574
|
return false;
|
|
4659
5575
|
}
|
|
4660
|
-
async sendCancelButton(instanceName, correlationId) {
|
|
5576
|
+
async sendCancelButton(instanceName, correlationId, preserveProgress = false) {
|
|
4661
5577
|
// Post first, retire after (see the tail of this method). Retiring up front
|
|
4662
5578
|
// meant that from the delete until the new message came back — a chat API
|
|
4663
5579
|
// round trip, and every reply goes through here — the instance had NO live
|
|
@@ -4718,6 +5634,10 @@ export class FleetManager {
|
|
|
4718
5634
|
// not re-edit identical text — which put a "(edited)" mark on Discord
|
|
4719
5635
|
// with nothing visibly changed.
|
|
4720
5636
|
lastProgressText: "👀 處理中…",
|
|
5637
|
+
// A reply can re-post the same in-flight bubble below the reply. Carry
|
|
5638
|
+
// its current list into that replacement; fresh inbound work must start
|
|
5639
|
+
// empty even if the daemon's reset broadcast is still in flight.
|
|
5640
|
+
toolProgress: preserveProgress ? this.instanceProgress.get(instanceName) : undefined,
|
|
4721
5641
|
};
|
|
4722
5642
|
this.startProgressTicker(entry);
|
|
4723
5643
|
// Idle-check backstop: every 5min, if the instance is idle, retire the
|
|
@@ -4771,16 +5691,28 @@ export class FleetManager {
|
|
|
4771
5691
|
static progressText(elapsedMs, activity, minElapsedMs = PROGRESS_MIN_ELAPSED_MS) {
|
|
4772
5692
|
if (elapsedMs < minElapsedMs)
|
|
4773
5693
|
return "👀 處理中…";
|
|
5694
|
+
const elapsed = FleetManager.formatProgressElapsed(elapsedMs);
|
|
5695
|
+
const detail = FleetManager.sanitizeActivity(activity);
|
|
5696
|
+
return detail
|
|
5697
|
+
? `⏳ 處理中… (已進行 ${elapsed} · ${detail})`
|
|
5698
|
+
: `⏳ 處理中… (已進行 ${elapsed})`;
|
|
5699
|
+
}
|
|
5700
|
+
/** Render elapsed time consistently in live and retained progress bubbles. */
|
|
5701
|
+
static formatProgressElapsed(elapsedMs) {
|
|
4774
5702
|
const totalSeconds = Math.floor(elapsedMs / 1000);
|
|
4775
5703
|
const minutes = Math.floor(totalSeconds / 60);
|
|
4776
5704
|
const seconds = totalSeconds % 60;
|
|
4777
|
-
|
|
5705
|
+
return minutes >= 60
|
|
4778
5706
|
? `${Math.floor(minutes / 60)}h ${minutes % 60}m`
|
|
4779
5707
|
: `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
|
|
5708
|
+
}
|
|
5709
|
+
/** Final read-only form of a bubble that contains semantic tool history.
|
|
5710
|
+
* Neutral wording is intentional: the same retirement primitive is used for
|
|
5711
|
+
* normal completion, a mid-turn bubble replacement, and user cancellation,
|
|
5712
|
+
* so claiming every retained checkpoint is "completed" would be false. */
|
|
5713
|
+
composeRetiredBubbleText(entry) {
|
|
5714
|
+
const elapsed = FleetManager.formatProgressElapsed(Date.now() - (entry.startedAt ?? Date.now()));
|
|
5715
|
+
return `🧾 工具歷程 (記錄至 ${elapsed})\n${entry.toolProgress}`;
|
|
4784
5716
|
}
|
|
4785
5717
|
/**
|
|
4786
5718
|
* Make a tool summary safe to paste into a channel message.
|
|
@@ -4813,6 +5745,88 @@ export class FleetManager {
|
|
|
4813
5745
|
else
|
|
4814
5746
|
this.instanceActivity.delete(name);
|
|
4815
5747
|
}
|
|
5748
|
+
/**
|
|
5749
|
+
* Cache the turn's tool-progress list and push it into the instance's live
|
|
5750
|
+
* bubble, coalesced so a burst of tool events cannot flood the Bot API
|
|
5751
|
+
* (Telegram's flood limit is per-chat across ALL forum topics, so edits are
|
|
5752
|
+
* rate-limited per bubble AND ride behind the daemon-side 3s coalescer).
|
|
5753
|
+
*/
|
|
5754
|
+
cacheInstanceProgress(name, progress) {
|
|
5755
|
+
if (progress)
|
|
5756
|
+
this.instanceProgress.set(name, progress);
|
|
5757
|
+
else
|
|
5758
|
+
this.instanceProgress.delete(name);
|
|
5759
|
+
for (const entry of this.cancelButtons.values()) {
|
|
5760
|
+
if (entry.instanceName !== name)
|
|
5761
|
+
continue;
|
|
5762
|
+
// Empty means the daemon completed/reset the turn. Clear the live cache
|
|
5763
|
+
// for the next turn, but retain this bubble's last non-empty list so its
|
|
5764
|
+
// retirement can preserve the history instead of deleting the message.
|
|
5765
|
+
if (progress)
|
|
5766
|
+
entry.toolProgress = progress;
|
|
5767
|
+
this.scheduleProgressEdit(entry);
|
|
5768
|
+
}
|
|
5769
|
+
}
|
|
5770
|
+
/** At most one progress-driven edit per bubble per TOOL_PROGRESS_EDIT_MIN_MS. */
|
|
5771
|
+
scheduleProgressEdit(entry) {
|
|
5772
|
+
if (entry.retiring)
|
|
5773
|
+
return;
|
|
5774
|
+
const since = Date.now() - (entry.lastProgressEditAt ?? 0);
|
|
5775
|
+
if (since >= TOOL_PROGRESS_EDIT_MIN_MS) {
|
|
5776
|
+
this.refreshBubble(entry);
|
|
5777
|
+
return;
|
|
5778
|
+
}
|
|
5779
|
+
if (entry.progressEditTimer)
|
|
5780
|
+
return; // trailing edit already scheduled
|
|
5781
|
+
entry.progressEditTimer = setTimeout(() => {
|
|
5782
|
+
entry.progressEditTimer = undefined;
|
|
5783
|
+
this.refreshBubble(entry);
|
|
5784
|
+
}, TOOL_PROGRESS_EDIT_MIN_MS - since);
|
|
5785
|
+
entry.progressEditTimer.unref?.();
|
|
5786
|
+
}
|
|
5787
|
+
/**
|
|
5788
|
+
* The ONE composer for the bubble text. Both writers — the elapsed-time
|
|
5789
|
+
* ticker and the tool-progress push — go through here; two independent
|
|
5790
|
+
* renderers editing the same message is how the progress list used to get
|
|
5791
|
+
* wiped by the next elapsed tick (#528 trap 2).
|
|
5792
|
+
*/
|
|
5793
|
+
composeBubbleText(entry) {
|
|
5794
|
+
return FleetManager.bubbleText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs(), entry.toolProgress);
|
|
5795
|
+
}
|
|
5796
|
+
/** Pure composition of header + tool list, exposed for tests. */
|
|
5797
|
+
static bubbleText(elapsedMs, activity, minElapsedMs, progress) {
|
|
5798
|
+
const header = FleetManager.progressText(elapsedMs,
|
|
5799
|
+
// The single-line activity detail is redundant once a tool list exists.
|
|
5800
|
+
progress ? undefined : activity, minElapsedMs);
|
|
5801
|
+
return progress ? `${header}\n${progress}` : header;
|
|
5802
|
+
}
|
|
5803
|
+
/** Recompose and edit the bubble in place; skips when nothing changed. */
|
|
5804
|
+
refreshBubble(entry) {
|
|
5805
|
+
if (entry.retiring || !this.cancelButtons.has(entry.messageId)) {
|
|
5806
|
+
clearInterval(entry.progressTimer);
|
|
5807
|
+
return;
|
|
5808
|
+
}
|
|
5809
|
+
const text = this.composeBubbleText(entry);
|
|
5810
|
+
if (text === entry.lastProgressText)
|
|
5811
|
+
return; // nothing changed — skip the API call
|
|
5812
|
+
const adapter = this.getAdapterForInstance(entry.instanceName) ?? this.adapter;
|
|
5813
|
+
if (!adapter?.editAlert)
|
|
5814
|
+
return;
|
|
5815
|
+
entry.lastProgressText = text;
|
|
5816
|
+
entry.lastProgressEditAt = Date.now();
|
|
5817
|
+
adapter.editAlert(entry.chatId, entry.messageId, {
|
|
5818
|
+
type: "cancel",
|
|
5819
|
+
instanceName: entry.instanceName,
|
|
5820
|
+
message: text,
|
|
5821
|
+
choices: [{ id: `cancel:${entry.instanceName}`, label: t("cancel.button") }],
|
|
5822
|
+
}, entry.threadId ? { threadId: entry.threadId } : undefined)
|
|
5823
|
+
.catch(err => {
|
|
5824
|
+
// A failed progress edit must never escalate: the button still works and
|
|
5825
|
+
// the next tick retries. Common causes are a deleted message or a
|
|
5826
|
+
// rate limit.
|
|
5827
|
+
this.logger.debug({ err, instanceName: entry.instanceName }, "Progress edit failed");
|
|
5828
|
+
});
|
|
5829
|
+
}
|
|
4816
5830
|
/**
|
|
4817
5831
|
* Refresh the button's text in place while the instance keeps working.
|
|
4818
5832
|
*
|
|
@@ -4830,31 +5844,7 @@ export class FleetManager {
|
|
|
4830
5844
|
return PROGRESS_MIN_ELAPSED_MS;
|
|
4831
5845
|
}
|
|
4832
5846
|
startProgressTicker(entry) {
|
|
4833
|
-
const tick = () =>
|
|
4834
|
-
if (!this.cancelButtons.has(entry.messageId)) {
|
|
4835
|
-
clearInterval(entry.progressTimer);
|
|
4836
|
-
return;
|
|
4837
|
-
}
|
|
4838
|
-
const text = FleetManager.progressText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs());
|
|
4839
|
-
if (text === entry.lastProgressText)
|
|
4840
|
-
return; // nothing changed — skip the API call
|
|
4841
|
-
const adapter = this.getAdapterForInstance(entry.instanceName) ?? this.adapter;
|
|
4842
|
-
if (!adapter?.editAlert)
|
|
4843
|
-
return;
|
|
4844
|
-
entry.lastProgressText = text;
|
|
4845
|
-
adapter.editAlert(entry.chatId, entry.messageId, {
|
|
4846
|
-
type: "cancel",
|
|
4847
|
-
instanceName: entry.instanceName,
|
|
4848
|
-
message: text,
|
|
4849
|
-
choices: [{ id: `cancel:${entry.instanceName}`, label: t("cancel.button") }],
|
|
4850
|
-
}, entry.threadId ? { threadId: entry.threadId } : undefined)
|
|
4851
|
-
.catch(err => {
|
|
4852
|
-
// A failed progress edit must never escalate: the button still works and
|
|
4853
|
-
// the next tick retries. Common causes are a deleted message or a
|
|
4854
|
-
// rate limit.
|
|
4855
|
-
this.logger.debug({ err, instanceName: entry.instanceName }, "Progress edit failed");
|
|
4856
|
-
});
|
|
4857
|
-
};
|
|
5847
|
+
const tick = () => this.refreshBubble(entry);
|
|
4858
5848
|
entry.progressTimer = setInterval(tick, PROGRESS_UPDATE_INTERVAL_MS);
|
|
4859
5849
|
entry.progressTimer.unref?.();
|
|
4860
5850
|
// One extra tick right when the threshold passes, so a 30s threshold shows
|
|
@@ -4868,8 +5858,11 @@ export class FleetManager {
|
|
|
4868
5858
|
}
|
|
4869
5859
|
/**
|
|
4870
5860
|
* After a reply: give the instance REPLY_RETIRE_GRACE_MS to resume working; if
|
|
4871
|
-
* it has not, retire its button.
|
|
4872
|
-
*
|
|
5861
|
+
* it has not, retire its button. The daemon is asked for a fresh pane capture
|
|
5862
|
+
* before deciding. Reading only the transition cache stranded the first
|
|
5863
|
+
* post-restart bubble when its startup "working" report never got a matching
|
|
5864
|
+
* idle edge. Re-arming replaces the previous timer, so a burst of replies ends
|
|
5865
|
+
* with exactly one pending check.
|
|
4873
5866
|
*/
|
|
4874
5867
|
armReplyGrace(instanceName) {
|
|
4875
5868
|
for (const entry of this.cancelButtons.values()) {
|
|
@@ -4881,14 +5874,26 @@ export class FleetManager {
|
|
|
4881
5874
|
entry.replyGraceTimer = undefined;
|
|
4882
5875
|
if (!this.cancelButtons.has(entry.messageId))
|
|
4883
5876
|
return;
|
|
4884
|
-
|
|
4885
|
-
return; // resumed — a long run keeps its button
|
|
4886
|
-
this.logger.info({ instanceName, messageId: entry.messageId }, "Cancel button retired — no work resumed after reply");
|
|
4887
|
-
this.retireButton(entry);
|
|
5877
|
+
void this.finishReplyGrace(instanceName, entry);
|
|
4888
5878
|
}, REPLY_RETIRE_GRACE_MS);
|
|
4889
5879
|
entry.replyGraceTimer.unref?.();
|
|
4890
5880
|
}
|
|
4891
5881
|
}
|
|
5882
|
+
async finishReplyGrace(instanceName, entry) {
|
|
5883
|
+
const refreshed = await this.refreshInstanceExecutionState(instanceName, REPLY_STATE_REFRESH_TIMEOUT_MS);
|
|
5884
|
+
if (!this.cancelButtons.has(entry.messageId) || entry.retiring)
|
|
5885
|
+
return;
|
|
5886
|
+
if (!refreshed) {
|
|
5887
|
+
// Fail safe: without an authoritative answer, retain a potentially live
|
|
5888
|
+
// Cancel button. The 5-minute/30-minute/24-hour safety nets still apply.
|
|
5889
|
+
this.logger.debug({ instanceName, messageId: entry.messageId }, "Cancel reply-grace state refresh timed out");
|
|
5890
|
+
return;
|
|
5891
|
+
}
|
|
5892
|
+
if (!this.getInstanceIdle(instanceName))
|
|
5893
|
+
return; // genuine long run keeps its button
|
|
5894
|
+
this.logger.info({ instanceName, messageId: entry.messageId }, "Cancel button retired — no work resumed after reply");
|
|
5895
|
+
this.retireButton(entry);
|
|
5896
|
+
}
|
|
4892
5897
|
/** Retire (delete) every cancel button belonging to an instance. */
|
|
4893
5898
|
retireInstanceButtons(instanceName) {
|
|
4894
5899
|
// Snapshot first — retireButton may delete entries from the map on success.
|
|
@@ -4910,7 +5915,7 @@ export class FleetManager {
|
|
|
4910
5915
|
this.deleteButtonMessage(entry)
|
|
4911
5916
|
.then(() => {
|
|
4912
5917
|
this.discardButton(entry);
|
|
4913
|
-
this.logger.info({ instanceName: entry.instanceName, messageId: entry.messageId }, "Cancel button
|
|
5918
|
+
this.logger.info({ instanceName: entry.instanceName, messageId: entry.messageId, historyPreserved: Boolean(entry.toolProgress) }, "Cancel button retired");
|
|
4914
5919
|
})
|
|
4915
5920
|
.catch((err) => this.scheduleButtonRetry(entry, err));
|
|
4916
5921
|
}
|
|
@@ -4922,6 +5927,8 @@ export class FleetManager {
|
|
|
4922
5927
|
clearInterval(entry.idleCheckTimer);
|
|
4923
5928
|
if (entry.progressTimer)
|
|
4924
5929
|
clearInterval(entry.progressTimer);
|
|
5930
|
+
if (entry.progressEditTimer)
|
|
5931
|
+
clearTimeout(entry.progressEditTimer);
|
|
4925
5932
|
if (entry.replyGraceTimer)
|
|
4926
5933
|
clearTimeout(entry.replyGraceTimer);
|
|
4927
5934
|
this.cancelButtons.delete(entry.messageId);
|
|
@@ -4999,12 +6006,21 @@ export class FleetManager {
|
|
|
4999
6006
|
// Continue the same retire cycle (bypass the retiring-guard in retireButton).
|
|
5000
6007
|
entry.retryTimer = setTimeout(() => this.attemptButtonDelete(entry), CANCEL_BTN_RETRY_INTERVAL_MS);
|
|
5001
6008
|
}
|
|
5002
|
-
/**
|
|
6009
|
+
/** Retire one cancel bubble via its own adapter. A bubble that accumulated
|
|
6010
|
+
* tool progress is kept as a read-only history message; a plain bubble keeps
|
|
6011
|
+
* the legacy delete/edit-to-checkmark behavior. Resolves on success and
|
|
5003
6012
|
* rejects on failure so the caller can retry. */
|
|
5004
6013
|
deleteButtonMessage(e) {
|
|
5005
6014
|
const adapter = (e.adapterId ? this.worlds.get(e.adapterId)?.adapter : undefined) ?? this.adapter;
|
|
5006
6015
|
if (!adapter)
|
|
5007
6016
|
return Promise.reject(new Error("no adapter for cancel button"));
|
|
6017
|
+
if (e.toolProgress && adapter.editMessageRemoveButtons) {
|
|
6018
|
+
return adapter.editMessageRemoveButtons(e.chatId, e.messageId, this.composeRetiredBubbleText(e), e.threadId);
|
|
6019
|
+
}
|
|
6020
|
+
// All production adapters currently implement editMessageRemoveButtons.
|
|
6021
|
+
// A third-party adapter without it degrades to the legacy delete behavior:
|
|
6022
|
+
// removing a live Cancel control is safer than retaining an actionable,
|
|
6023
|
+
// untracked button forever.
|
|
5008
6024
|
if (adapter.deleteMessage)
|
|
5009
6025
|
return adapter.deleteMessage(e.chatId, e.messageId, e.threadId);
|
|
5010
6026
|
if (adapter.editMessageRemoveButtons)
|
|
@@ -5056,11 +6072,27 @@ export class FleetManager {
|
|
|
5056
6072
|
const daemon = this.daemons.get(instanceName);
|
|
5057
6073
|
if (!daemon)
|
|
5058
6074
|
return false;
|
|
6075
|
+
const deliveryEpoch = this.cancelPendingDeliveries(instanceName);
|
|
6076
|
+
daemon.clearPendingDeliveries?.(deliveryEpoch);
|
|
5059
6077
|
daemon.sendEscape().catch(e => this.logger.warn({ err: e, instanceName }, "sendEscape failed"));
|
|
5060
6078
|
this.lastInboundMsg.delete(instanceName);
|
|
5061
6079
|
this.clearCancelButton(instanceName);
|
|
5062
6080
|
return true;
|
|
5063
6081
|
}
|
|
6082
|
+
getDeliveryEpoch(instanceName) {
|
|
6083
|
+
return this.deliveryEpochs.get(instanceName) ?? 0;
|
|
6084
|
+
}
|
|
6085
|
+
isDeliveryEpochCurrent(instanceName, deliveryEpoch) {
|
|
6086
|
+
return deliveryEpoch === this.getDeliveryEpoch(instanceName);
|
|
6087
|
+
}
|
|
6088
|
+
/** Invalidate work queued before a user cancel and wake idle-gate waiters. */
|
|
6089
|
+
cancelPendingDeliveries(instanceName) {
|
|
6090
|
+
const next = this.getDeliveryEpoch(instanceName) + 1;
|
|
6091
|
+
this.deliveryEpochs.set(instanceName, next);
|
|
6092
|
+
for (const check of this.instanceIdleWaiters.get(instanceName) ?? [])
|
|
6093
|
+
check();
|
|
6094
|
+
return next;
|
|
6095
|
+
}
|
|
5064
6096
|
queueMirrorMessage(text) {
|
|
5065
6097
|
const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
|
|
5066
6098
|
if (mirrorTopicId == null || !this.adapter)
|
|
@@ -5095,13 +6127,29 @@ export class FleetManager {
|
|
|
5095
6127
|
}
|
|
5096
6128
|
async sendHangNotification(instanceName, unchangedForMs) {
|
|
5097
6129
|
const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
6130
|
+
const adapterId = this.getInstanceAdapterId(instanceName);
|
|
6131
|
+
// Same three-way addressing as sendCancelButton: fleet topic → group+thread,
|
|
6132
|
+
// Classic → its own channel (Classic instances are absent from
|
|
6133
|
+
// fleetConfig.instances, so the topic path can never address them), else the
|
|
6134
|
+
// world group flat. getGroupIdForInstance (not getChannelConfig().group_id)
|
|
6135
|
+
// because on channels[]-configured fleets the legacy channel: block is empty.
|
|
6136
|
+
const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
|
|
6137
|
+
const groupId = this.getGroupIdForInstance(instanceName) || undefined;
|
|
6138
|
+
let chatId;
|
|
6139
|
+
let threadId;
|
|
6140
|
+
if (topicId != null && groupId) {
|
|
6141
|
+
chatId = String(groupId);
|
|
6142
|
+
threadId = String(topicId);
|
|
6143
|
+
}
|
|
6144
|
+
else {
|
|
6145
|
+
chatId = this.classicChannels?.getChannelIdByInstance(instanceName);
|
|
6146
|
+
if (!chatId && groupId)
|
|
6147
|
+
chatId = String(groupId);
|
|
6148
|
+
}
|
|
6149
|
+
if (!adapter || !adapterId || !chatId) {
|
|
6150
|
+
this.logger.warn({ instanceName, adapterId, chatId }, "Cannot address hang notification");
|
|
5103
6151
|
return;
|
|
5104
|
-
|
|
6152
|
+
}
|
|
5105
6153
|
const instanceHangConfig = this.fleetConfig?.instances[instanceName]?.hang_detector;
|
|
5106
6154
|
const configuredMinutes = instanceHangConfig?.timeout_minutes
|
|
5107
6155
|
?? this.fleetConfig?.defaults?.hang_detector?.timeout_minutes
|
|
@@ -5110,17 +6158,52 @@ export class FleetManager {
|
|
|
5110
6158
|
? configuredMinutes
|
|
5111
6159
|
: Math.max(1, Math.floor(unchangedForMs / 60_000));
|
|
5112
6160
|
this.setTopicIcon(instanceName, "red");
|
|
5113
|
-
await
|
|
5114
|
-
|
|
6161
|
+
await this.postNonceButtonPrompt({
|
|
6162
|
+
prefix: HANG_CALLBACK_PREFIX,
|
|
6163
|
+
alertType: "hang",
|
|
5115
6164
|
instanceName,
|
|
5116
|
-
|
|
6165
|
+
adapter,
|
|
6166
|
+
adapterId,
|
|
6167
|
+
chatId,
|
|
6168
|
+
threadId,
|
|
6169
|
+
message: t("hang.detected", instanceName, unchangedMinutes),
|
|
5117
6170
|
choices: [
|
|
5118
|
-
{
|
|
5119
|
-
{
|
|
6171
|
+
{ action: "restart", label: t("hang.restart") },
|
|
6172
|
+
{ action: "wait", label: t("hang.wait") },
|
|
5120
6173
|
],
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
6174
|
+
expiredText: t("hang.expired", instanceName),
|
|
6175
|
+
});
|
|
6176
|
+
}
|
|
6177
|
+
/**
|
|
6178
|
+
* Consume a hang Force-restart / Keep-waiting button exactly once. Restart
|
|
6179
|
+
* goes through restartSingleInstance — serialized against concurrent restart
|
|
6180
|
+
* sources, and with a Classic-instance fallback (the previous hand-rolled
|
|
6181
|
+
* stop+start silently left Classic instances stopped while reporting
|
|
6182
|
+
* "restarted").
|
|
6183
|
+
*/
|
|
6184
|
+
async handleHangPrompt(data, callbackAdapterId, receivingAdapter) {
|
|
6185
|
+
const claimed = this.consumeNonceCallback(HANG_CALLBACK_PREFIX, /^hang:([0-9a-f]+):(restart|wait)$/, data, callbackAdapterId, receivingAdapter);
|
|
6186
|
+
if (claimed === null)
|
|
6187
|
+
return false;
|
|
6188
|
+
if (claimed === "consumed")
|
|
6189
|
+
return true;
|
|
6190
|
+
const { entry: pending, action } = claimed;
|
|
6191
|
+
this.eventLog?.insert(pending.instanceName, "hang_action", { action, userId: data.userId });
|
|
6192
|
+
if (action === "wait") {
|
|
6193
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.waiting", pending.instanceName));
|
|
6194
|
+
return true;
|
|
6195
|
+
}
|
|
6196
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.restarting", pending.instanceName));
|
|
6197
|
+
try {
|
|
6198
|
+
await this.restartSingleInstance(pending.instanceName);
|
|
6199
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restarted", pending.instanceName), pending.threadId);
|
|
6200
|
+
}
|
|
6201
|
+
catch (err) {
|
|
6202
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
6203
|
+
this.logger.error({ err, instanceName: pending.instanceName }, "Hang force-restart failed");
|
|
6204
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restart_failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show hang restart error"));
|
|
6205
|
+
}
|
|
6206
|
+
return true;
|
|
5124
6207
|
}
|
|
5125
6208
|
// ── Topic icon + auto-archive ─────────────────────────────────────────────
|
|
5126
6209
|
static INSTRUCTIONS_FILENAME = {
|
|
@@ -5129,6 +6212,10 @@ export class FleetManager {
|
|
|
5129
6212
|
"gemini-cli": "GEMINI.md",
|
|
5130
6213
|
"opencode": "AGENTS.md",
|
|
5131
6214
|
"kiro-cli": ".kiro/steering/project.md",
|
|
6215
|
+
// Grok reads AGENTS.md project docs; agy reads .agents/agents.md — the
|
|
6216
|
+
// same files their writeConfig() appends fleet instructions to.
|
|
6217
|
+
"grok": "AGENTS.md",
|
|
6218
|
+
"antigravity": ".agents/agents.md",
|
|
5132
6219
|
"mock": "CLAUDE.md",
|
|
5133
6220
|
};
|
|
5134
6221
|
static GENERAL_INSTRUCTIONS = `# Fleet Coordinator
|
|
@@ -5138,146 +6225,33 @@ You route tasks, manage instances, enforce policies, and synthesize results.
|
|
|
5138
6225
|
Do NOT modify project files directly — delegate file changes to the project's instance.
|
|
5139
6226
|
You CAN write code snippets, explain code, and answer technical questions directly.
|
|
5140
6227
|
|
|
5141
|
-
|
|
5142
|
-
|
|
5143
|
-
## Task Classification
|
|
5144
|
-
|
|
5145
|
-
Classify every incoming request before acting.
|
|
5146
|
-
|
|
5147
|
-
### Handle Directly (ALL conditions must be true)
|
|
5148
|
-
|
|
5149
|
-
- No file system access needed
|
|
5150
|
-
- No external execution needed
|
|
5151
|
-
- Answerable from static knowledge
|
|
5152
|
-
- ≤ 2 reasoning steps
|
|
5153
|
-
|
|
5154
|
-
Examples: Q&A, translation, fleet status queries, explaining a concept, writing code snippets.
|
|
5155
|
-
|
|
5156
|
-
### Delegate to 1 Instance
|
|
5157
|
-
|
|
5158
|
-
- Task scoped to a single project or repo
|
|
5159
|
-
- Requires file access, code changes, or execution
|
|
5160
|
-
|
|
5161
|
-
### Coordinate Multiple Instances
|
|
5162
|
-
|
|
5163
|
-
- Task spans multiple repos or domains
|
|
5164
|
-
- Requires outputs from one instance to feed into another
|
|
5165
|
-
- Benefits from parallel execution (max 3 instances per task)
|
|
5166
|
-
|
|
5167
|
-
-----
|
|
5168
|
-
|
|
5169
|
-
## Instance Discovery (in this order)
|
|
5170
|
-
1. list_teams() → reuse existing teams first
|
|
5171
|
-
2. list_instances() → find by working_directory, description, or tags
|
|
5172
|
-
3. describe_instance() → confirm capabilities before delegating
|
|
5173
|
-
4. create_instance() → only if no suitable instance exists
|
|
5174
|
-
|
|
5175
|
-
Rules: prefer reuse over creation. Do NOT create duplicates of running instances.
|
|
5176
|
-
|
|
5177
|
-
-----
|
|
5178
|
-
|
|
5179
|
-
## Delegation Protocol
|
|
5180
|
-
|
|
5181
|
-
Every delegation via send_to_instance() MUST include:
|
|
5182
|
-
|
|
5183
|
-
1. Task scope — what exactly to do, bounded clearly
|
|
5184
|
-
2. Expected output — what to return and in what form
|
|
5185
|
-
3. Policy reminder — "Follow Development Workflow policy" (for code tasks)
|
|
5186
|
-
|
|
5187
|
-
### Loop Prevention
|
|
5188
|
-
|
|
5189
|
-
- Never re-delegate a task back to the instance that sent it to you
|
|
5190
|
-
- If a task has bounced 3 times, stop and solve locally or reduce scope
|
|
5191
|
-
|
|
5192
|
-
### Execution Strategy
|
|
5193
|
-
|
|
5194
|
-
Parallel — use only when tasks are independent with no shared state
|
|
5195
|
-
Sequential — use when one task's output feeds into the next
|
|
5196
|
-
|
|
5197
|
-
-----
|
|
5198
|
-
|
|
5199
|
-
## Result Handling
|
|
5200
|
-
|
|
5201
|
-
When an instance reports back, classify the outcome:
|
|
5202
|
-
|
|
5203
|
-
- Success → Summarize key results for user. Omit internal coordination noise.
|
|
5204
|
-
- Partial → State what succeeded, what remains, proposed next steps.
|
|
5205
|
-
- Failure → Retry up to 2 times. If still failing: try alternative instance, reduce scope, or return partial result clearly marked.
|
|
5206
|
-
- No response → Ping again after reasonable wait. If still silent: report to user with options.
|
|
6228
|
+
## Task Routing
|
|
5207
6229
|
|
|
5208
|
-
|
|
6230
|
+
- **Handle directly**: no file/exec access needed, answerable from knowledge, ≤2 reasoning steps (Q&A, translation, status queries, code snippets).
|
|
6231
|
+
- **Delegate to 1 instance**: scoped to one project/repo, needs file access or execution.
|
|
6232
|
+
- **Coordinate multiple**: spans repos, outputs feed each other, or parallel helps (max 3 per task).
|
|
5209
6233
|
|
|
5210
|
-
|
|
6234
|
+
Instance discovery order: list_teams() → list_instances() → describe_instance() → create_instance() only if nothing fits. Prefer reuse; never duplicate a running instance.
|
|
5211
6235
|
|
|
5212
|
-
|
|
5213
|
-
- Gaps — anything incomplete or unresolved (omit if none)
|
|
6236
|
+
## Reply Contract
|
|
5214
6237
|
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
## Shared Decisions
|
|
5218
|
-
|
|
5219
|
-
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.
|
|
5220
|
-
|
|
5221
|
-
When instances disagree, collect both viewpoints, make a decision, and record it via post_decision.
|
|
5222
|
-
|
|
5223
|
-
-----
|
|
6238
|
+
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.
|
|
5224
6239
|
|
|
5225
6240
|
## After Restart
|
|
5226
6241
|
|
|
5227
|
-
|
|
5228
|
-
1. list_instances() → rebuild fleet awareness
|
|
5229
|
-
2. list_teams() → restore team structure
|
|
5230
|
-
3. list_decisions() → reload policies and conventions
|
|
5231
|
-
|
|
5232
|
-
Only then handle incoming requests.
|
|
5233
|
-
|
|
5234
|
-
-----
|
|
5235
|
-
|
|
5236
|
-
## Development Workflow Policy
|
|
5237
|
-
|
|
5238
|
-
All code changes across the fleet should follow this workflow.
|
|
5239
|
-
The coordinator enforces compliance but does not perform these steps directly.
|
|
5240
|
-
Remind instances of this policy when delegating code tasks.
|
|
5241
|
-
|
|
5242
|
-
### Workflow Stages
|
|
5243
|
-
Design Proposed → Design Approved → Implementation → Submit for Review → Under Review → Approved → Merge
|
|
5244
|
-
|
|
5245
|
-
### Policy Rules
|
|
5246
|
-
|
|
5247
|
-
1. Design before code — developer sends design proposal to reviewer before implementation. Consensus required before proceeding.
|
|
5248
|
-
2. Challenger pairing — every code task should have a developer + reviewer. Reviewer actively questions decisions and finds risks.
|
|
5249
|
-
3. Verify by execution — backend/CLI changes must be tested by running them. Do not trust documentation alone.
|
|
5250
|
-
4. Independent review — every merge requires code review from someone other than the author.
|
|
5251
|
-
5. Root cause first — bug fixes require confirmed root cause before proposing a fix.
|
|
5252
|
-
6. Merge conditions: tests pass, reviewer approved, branch and worktree cleaned up.
|
|
5253
|
-
|
|
5254
|
-
### Specialist Instance Rules
|
|
5255
|
-
|
|
5256
|
-
- Execute within defined scope only
|
|
5257
|
-
- Return structured output: result, assumptions, uncertainties, verification status
|
|
5258
|
-
- Do NOT create new instances without coordinator approval
|
|
5259
|
-
|
|
5260
|
-
-----
|
|
6242
|
+
BEFORE processing any new messages: 1. list_instances() 2. list_teams() 3. list_decisions(). Only then handle requests.
|
|
5261
6243
|
|
|
5262
|
-
##
|
|
6244
|
+
## Playbooks (on-demand skills)
|
|
5263
6245
|
|
|
5264
|
-
|
|
5265
|
-
-
|
|
5266
|
-
-
|
|
5267
|
-
|
|
5268
|
-
-----
|
|
5269
|
-
|
|
5270
|
-
## Instance Configuration Tips
|
|
5271
|
-
|
|
5272
|
-
When users create specialized instances, suggest these configurations:
|
|
5273
|
-
|
|
5274
|
-
- **Reviewer instances**: Add \`pre_task_command: "/chat load reviewer-base"\` to reset context before each review, preventing influence from previous conversations.
|
|
5275
|
-
- **Collab mode**: For multi-bot channels, use \`/collab\` to enable @mention-based triggering.
|
|
5276
|
-
- **Cost control**: Set per-instance \`cost_guard\` for expensive backends.
|
|
6246
|
+
Detailed procedures live in your skills — consult them when the situation comes up rather than from memory:
|
|
6247
|
+
- **delegation-playbook** — delegation protocol, loop prevention, parallel vs sequential, result/failure handling, team management, instance configuration tips.
|
|
6248
|
+
- **development-workflow** — the fleet-wide code-change policy you enforce when delegating code tasks.
|
|
6249
|
+
Plus the operational skills (fleet-health, instance-lifecycle, scheduling, session-management, …).
|
|
5277
6250
|
`;
|
|
5278
6251
|
/** Ensure the general instance has its project instructions file + knowledge */
|
|
5279
|
-
ensureGeneralInstructions(workDir, backendName) {
|
|
6252
|
+
ensureGeneralInstructions(workDir, backendName, instanceName) {
|
|
5280
6253
|
const backend = backendName ?? "claude-code";
|
|
6254
|
+
workDir = this.resolveKnowledgeWorkDir(workDir, backend, instanceName);
|
|
5281
6255
|
const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
|
|
5282
6256
|
const filePath = join(workDir, filename);
|
|
5283
6257
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
@@ -5285,60 +6259,238 @@ When users create specialized instances, suggest these configurations:
|
|
|
5285
6259
|
writeFileSync(filePath, FleetManager.GENERAL_INSTRUCTIONS, "utf-8");
|
|
5286
6260
|
this.logger.info({ filePath }, "Created general instance instructions file");
|
|
5287
6261
|
}
|
|
5288
|
-
// Sync bundled knowledge files to general's steering
|
|
6262
|
+
// Sync bundled knowledge files to general's steering and skills directories.
|
|
5289
6263
|
this.syncGeneralKnowledge(workDir, backend);
|
|
5290
6264
|
}
|
|
5291
|
-
/**
|
|
6265
|
+
/** Resolve the workspace path a backend actually uses before publishing knowledge. */
|
|
6266
|
+
resolveKnowledgeWorkDir(workDir, backend, instanceName) {
|
|
6267
|
+
// Some backends relocate their real cwd (agy moves hidden paths to
|
|
6268
|
+
// ~/agend-workspaces/<name>). Instructions and skills must land where the
|
|
6269
|
+
// CLI actually runs, not where fleet.yaml points.
|
|
6270
|
+
try {
|
|
6271
|
+
const resolved = createBackend(backend, join(getAgendHome(), "cli-env"))
|
|
6272
|
+
.resolveWorkingDirectory?.(workDir, instanceName);
|
|
6273
|
+
if (resolved)
|
|
6274
|
+
workDir = resolved;
|
|
6275
|
+
}
|
|
6276
|
+
catch { /* unknown backend name — keep the raw path */ }
|
|
6277
|
+
return workDir;
|
|
6278
|
+
}
|
|
6279
|
+
/**
|
|
6280
|
+
* Where each backend natively loads on-demand skills from, relative to the
|
|
6281
|
+
* workspace. Backends without a native skill mechanism (opencode, grok,
|
|
6282
|
+
* antigravity, gemini-cli) are deliberately absent: dropping files a CLI
|
|
6283
|
+
* never reads is clutter, not capability. Unknown directories are ignored
|
|
6284
|
+
* by older CLI versions, so publishing is fail-open across upgrades.
|
|
6285
|
+
*/
|
|
6286
|
+
static SKILLS_DIR_SEGMENTS = {
|
|
6287
|
+
"kiro-cli": [".kiro", "skills"],
|
|
6288
|
+
"claude-code": [".claude", "skills"],
|
|
6289
|
+
"codex": [".agents", "skills"],
|
|
6290
|
+
// Live-verified: OpenCode and Antigravity scan .agents/skills; Grok's
|
|
6291
|
+
// vendor-canonical location is .grok/skills.
|
|
6292
|
+
"opencode": [".agents", "skills"],
|
|
6293
|
+
"grok": [".grok", "skills"],
|
|
6294
|
+
"antigravity": [".agents", "skills"],
|
|
6295
|
+
};
|
|
6296
|
+
/** Copy general-knowledge steering + all role-eligible skills to General. */
|
|
5292
6297
|
syncGeneralKnowledge(workDir, backend) {
|
|
5293
6298
|
const knowledgeDir = join(dirname(fileURLToPath(import.meta.url)), "general-knowledge");
|
|
5294
6299
|
if (!existsSync(knowledgeDir))
|
|
5295
6300
|
return;
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
6301
|
+
this.syncGeneralSteering(workDir, backend, join(knowledgeDir, "steering"));
|
|
6302
|
+
this.syncRoleSkills(workDir, backend, "general", knowledgeDir);
|
|
6303
|
+
this.logger.debug({ knowledgeDir, workDir, backend }, "Synced general knowledge files");
|
|
6304
|
+
}
|
|
6305
|
+
/** Publish only the bundled skills eligible for an instance role. */
|
|
6306
|
+
syncRoleSkills(workDir, backend, role, knowledgeDir) {
|
|
6307
|
+
const skillSegments = FleetManager.SKILLS_DIR_SEGMENTS[backend];
|
|
6308
|
+
if (!skillSegments)
|
|
6309
|
+
return;
|
|
6310
|
+
const root = knowledgeDir ?? join(dirname(fileURLToPath(import.meta.url)), "general-knowledge");
|
|
6311
|
+
if (!existsSync(root))
|
|
6312
|
+
return;
|
|
6313
|
+
// Before managed-skill manifests existed, only Kiro General received
|
|
6314
|
+
// bundled skills. Allow that one legacy layout to be adopted so upgrades
|
|
6315
|
+
// can keep those copies current; other backends never had unmanaged
|
|
6316
|
+
// AgEnD-published skills and must retain the normal user-ownership guard.
|
|
6317
|
+
const adoptLegacyUnmanaged = backend === "kiro-cli" && role === "general";
|
|
6318
|
+
this.syncManagedSkills(join(workDir, ...skillSegments), join(root, "skills"), role, adoptLegacyUnmanaged);
|
|
6319
|
+
this.logger.debug({ workDir, backend, role }, "Synced role-based bundled skills");
|
|
6320
|
+
}
|
|
6321
|
+
/**
|
|
6322
|
+
* Steering (always-on rules like core-rules.md). Kiro loads a native
|
|
6323
|
+
* steering directory; every other backend gets the content embedded into
|
|
6324
|
+
* its instructions file (CLAUDE.md / AGENTS.md / …) inside a managed marker
|
|
6325
|
+
* block — the previous behavior dropped bare .md files in the workspace
|
|
6326
|
+
* root, which no CLI ever read. The block is replaced in place on every
|
|
6327
|
+
* sync, so rule updates reach EXISTING workspaces; everything the user
|
|
6328
|
+
* wrote outside the markers is preserved byte-for-byte.
|
|
6329
|
+
*/
|
|
6330
|
+
syncGeneralSteering(workDir, backend, srcSteering) {
|
|
6331
|
+
if (!existsSync(srcSteering))
|
|
6332
|
+
return;
|
|
6333
|
+
const files = readdirSync(srcSteering).filter(f => f.endsWith(".md")).sort();
|
|
6334
|
+
if (files.length === 0)
|
|
6335
|
+
return;
|
|
6336
|
+
if (backend === "kiro-cli") {
|
|
6337
|
+
const steeringDir = join(workDir, ".kiro", "steering");
|
|
6338
|
+
mkdirSync(steeringDir, { recursive: true });
|
|
6339
|
+
for (const file of files) {
|
|
5307
6340
|
const dest = join(steeringDir, file);
|
|
5308
|
-
const newContent = readFileSync(
|
|
6341
|
+
const newContent = readFileSync(join(srcSteering, file), "utf-8");
|
|
5309
6342
|
try {
|
|
5310
6343
|
if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
|
|
5311
6344
|
continue;
|
|
5312
6345
|
}
|
|
5313
|
-
catch { }
|
|
6346
|
+
catch { /* rewrite */ }
|
|
5314
6347
|
writeFileSync(dest, newContent);
|
|
5315
6348
|
}
|
|
6349
|
+
return;
|
|
5316
6350
|
}
|
|
5317
|
-
|
|
5318
|
-
|
|
5319
|
-
|
|
5320
|
-
|
|
5321
|
-
|
|
5322
|
-
|
|
5323
|
-
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
|
|
5329
|
-
|
|
5330
|
-
|
|
5331
|
-
|
|
5332
|
-
|
|
5333
|
-
|
|
5334
|
-
|
|
5335
|
-
|
|
5336
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
6351
|
+
const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
|
|
6352
|
+
const instructionsPath = join(workDir, filename);
|
|
6353
|
+
const body = files.map(f => readFileSync(join(srcSteering, f), "utf-8").trim()).join("\n\n");
|
|
6354
|
+
const block = `${FleetManager.STEERING_BLOCK_BEGIN}\n${body}\n${FleetManager.STEERING_BLOCK_END}`;
|
|
6355
|
+
let existing = "";
|
|
6356
|
+
try {
|
|
6357
|
+
existing = existsSync(instructionsPath) ? readFileSync(instructionsPath, "utf-8") : "";
|
|
6358
|
+
}
|
|
6359
|
+
catch { /* treat as empty */ }
|
|
6360
|
+
const beginAt = existing.indexOf(FleetManager.STEERING_BLOCK_BEGIN);
|
|
6361
|
+
const endAt = existing.indexOf(FleetManager.STEERING_BLOCK_END);
|
|
6362
|
+
let next;
|
|
6363
|
+
if (beginAt !== -1 && endAt !== -1 && endAt > beginAt) {
|
|
6364
|
+
next = existing.slice(0, beginAt) + block + existing.slice(endAt + FleetManager.STEERING_BLOCK_END.length);
|
|
6365
|
+
}
|
|
6366
|
+
else {
|
|
6367
|
+
next = existing.trimEnd() + (existing.trim() ? "\n\n" : "") + block + "\n";
|
|
6368
|
+
}
|
|
6369
|
+
if (next !== existing) {
|
|
6370
|
+
mkdirSync(dirname(instructionsPath), { recursive: true });
|
|
6371
|
+
writeFileSync(instructionsPath, next);
|
|
6372
|
+
}
|
|
6373
|
+
}
|
|
6374
|
+
static STEERING_BLOCK_BEGIN = "<!-- >>> agend:core-rules — managed by AgEnD; edits inside this block are overwritten -->";
|
|
6375
|
+
static STEERING_BLOCK_END = "<!-- <<< agend:core-rules -->";
|
|
6376
|
+
/**
|
|
6377
|
+
* Publish AgEnD's bundled skills into a CLI's native skills directory,
|
|
6378
|
+
* owning ONLY what we published. A manifest records which skill names AgEnD
|
|
6379
|
+
* wrote; a bundled rename/removal deletes the stale managed copy, while a
|
|
6380
|
+
* skill the user created by hand is never listed and therefore never
|
|
6381
|
+
* touched — even if a future bundle happens to reuse its name (the sync
|
|
6382
|
+
* then skips it and logs, rather than overwrite the user's work). The sole
|
|
6383
|
+
* migration exception is the pre-manifest Kiro General layout explicitly
|
|
6384
|
+
* selected by adoptLegacyUnmanaged.
|
|
6385
|
+
*/
|
|
6386
|
+
syncManagedSkills(destSkills, srcSkills, role, adoptLegacyUnmanaged = false) {
|
|
6387
|
+
if (!existsSync(srcSkills))
|
|
6388
|
+
return;
|
|
6389
|
+
mkdirSync(destSkills, { recursive: true });
|
|
6390
|
+
const manifestPath = join(destSkills, ".agend-managed-skills.json");
|
|
6391
|
+
const hadManifest = existsSync(manifestPath);
|
|
6392
|
+
let previouslyManaged = [];
|
|
6393
|
+
try {
|
|
6394
|
+
const parsed = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
6395
|
+
if (Array.isArray(parsed))
|
|
6396
|
+
previouslyManaged = parsed.filter(n => typeof n === "string");
|
|
6397
|
+
}
|
|
6398
|
+
catch { /* first sync, or corrupt manifest — treat as owning nothing */ }
|
|
6399
|
+
const bundled = readdirSync(srcSkills)
|
|
6400
|
+
.filter(name => existsSync(join(srcSkills, name, "SKILL.md")))
|
|
6401
|
+
.sort();
|
|
6402
|
+
// Pre-manifest Kiro General copied bundled skills directly into
|
|
6403
|
+
// .kiro/skills. If every existing skill is still a bundled name, this is
|
|
6404
|
+
// the unambiguous legacy layout: adopt it once, update it below, and write
|
|
6405
|
+
// the ownership manifest. Any extra skill name keeps the directory fully
|
|
6406
|
+
// on the user-owned/collision path.
|
|
6407
|
+
if (!hadManifest && adoptLegacyUnmanaged) {
|
|
6408
|
+
const existing = readdirSync(destSkills, { withFileTypes: true })
|
|
6409
|
+
.filter(entry => entry.isDirectory() && existsSync(join(destSkills, entry.name, "SKILL.md")))
|
|
6410
|
+
.map(entry => entry.name)
|
|
6411
|
+
.sort();
|
|
6412
|
+
if (existing.length > 0 && existing.every(name => bundled.includes(name))) {
|
|
6413
|
+
previouslyManaged = existing;
|
|
6414
|
+
this.logger.info({ skills: existing, destSkills }, "Adopted legacy AgEnD skills into managed ownership");
|
|
6415
|
+
}
|
|
6416
|
+
}
|
|
6417
|
+
const eligible = bundled.filter(name => {
|
|
6418
|
+
const roles = this.readManagedSkillRoles(join(srcSkills, name, "SKILL.md"));
|
|
6419
|
+
// General is the coordinator and receives both coordinator and worker
|
|
6420
|
+
// playbooks. Workers receive only skills explicitly marked for workers.
|
|
6421
|
+
return role === "general" || roles.includes("worker");
|
|
6422
|
+
});
|
|
6423
|
+
const managed = [];
|
|
6424
|
+
for (const name of eligible) {
|
|
6425
|
+
const destDir = join(destSkills, name);
|
|
6426
|
+
const dest = join(destDir, "SKILL.md");
|
|
6427
|
+
const isOurs = previouslyManaged.includes(name) || !existsSync(destDir);
|
|
6428
|
+
if (!isOurs) {
|
|
6429
|
+
// Name collision with a user-authored skill: theirs wins, loudly.
|
|
6430
|
+
this.logger.warn({ skill: name, destSkills }, "Skipping bundled skill — a skill of this name exists but was not published by AgEnD");
|
|
6431
|
+
continue;
|
|
6432
|
+
}
|
|
6433
|
+
managed.push(name);
|
|
6434
|
+
const newContent = readFileSync(join(srcSkills, name, "SKILL.md"), "utf-8");
|
|
6435
|
+
try {
|
|
6436
|
+
if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
|
|
6437
|
+
continue;
|
|
6438
|
+
}
|
|
6439
|
+
catch { /* rewrite */ }
|
|
6440
|
+
mkdirSync(destDir, { recursive: true });
|
|
6441
|
+
writeFileSync(dest, newContent);
|
|
6442
|
+
}
|
|
6443
|
+
// Remove managed skills that are no longer bundled OR no longer eligible
|
|
6444
|
+
// for this role. This makes a shared → general-only metadata change take
|
|
6445
|
+
// effect on the next worker startup instead of leaving stale capability.
|
|
6446
|
+
for (const stale of previouslyManaged) {
|
|
6447
|
+
if (eligible.includes(stale))
|
|
6448
|
+
continue;
|
|
6449
|
+
try {
|
|
6450
|
+
rmSync(join(destSkills, stale), { recursive: true, force: true });
|
|
6451
|
+
this.logger.info({ skill: stale, destSkills }, "Removed retired AgEnD-managed skill");
|
|
6452
|
+
}
|
|
6453
|
+
catch (err) {
|
|
6454
|
+
this.logger.debug({ err, skill: stale }, "Failed to remove retired managed skill");
|
|
5339
6455
|
}
|
|
5340
6456
|
}
|
|
5341
|
-
|
|
6457
|
+
try {
|
|
6458
|
+
writeFileSync(manifestPath, JSON.stringify(managed, null, 2) + "\n");
|
|
6459
|
+
}
|
|
6460
|
+
catch (err) {
|
|
6461
|
+
this.logger.debug({ err, manifestPath }, "Failed to write managed-skills manifest");
|
|
6462
|
+
}
|
|
6463
|
+
}
|
|
6464
|
+
/** Read AgEnD's roles extension from SKILL.md YAML frontmatter. */
|
|
6465
|
+
readManagedSkillRoles(skillPath) {
|
|
6466
|
+
const fallback = ["general"];
|
|
6467
|
+
try {
|
|
6468
|
+
const content = readFileSync(skillPath, "utf-8");
|
|
6469
|
+
const match = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
6470
|
+
if (!match)
|
|
6471
|
+
return fallback;
|
|
6472
|
+
const document = parseDocument(match[1]);
|
|
6473
|
+
if (document.errors.length > 0)
|
|
6474
|
+
throw document.errors[0];
|
|
6475
|
+
const frontmatter = document.toJS();
|
|
6476
|
+
if (!frontmatter || frontmatter.roles === undefined)
|
|
6477
|
+
return fallback;
|
|
6478
|
+
if (!Array.isArray(frontmatter.roles))
|
|
6479
|
+
throw new Error("roles must be an array");
|
|
6480
|
+
const roles = [...new Set(frontmatter.roles)]
|
|
6481
|
+
.filter((value) => value === "general" || value === "worker");
|
|
6482
|
+
if (roles.length !== frontmatter.roles.length || roles.length === 0) {
|
|
6483
|
+
throw new Error("roles must contain only general and/or worker");
|
|
6484
|
+
}
|
|
6485
|
+
return roles;
|
|
6486
|
+
}
|
|
6487
|
+
catch (err) {
|
|
6488
|
+
// Fail closed for workers: malformed or unknown metadata keeps the
|
|
6489
|
+
// backwards-compatible General-only behavior instead of leaking an
|
|
6490
|
+
// administrative skill into worker workspaces.
|
|
6491
|
+
this.logger.warn({ err, skillPath }, "Invalid bundled skill roles — defaulting to General only");
|
|
6492
|
+
return fallback;
|
|
6493
|
+
}
|
|
5342
6494
|
}
|
|
5343
6495
|
/** Fetch forum topic icon stickers and pick emoji IDs for each state */
|
|
5344
6496
|
async resolveTopicIcons() {
|
|
@@ -5770,6 +6922,17 @@ When users create specialized instances, suggest these configurations:
|
|
|
5770
6922
|
return null;
|
|
5771
6923
|
const probed = await be.probeCLIEnv({ workingDirectory: "", instanceDir: join(getAgendHome(), "cli-env"), instanceName: `probe-${backend}`, mcpServers: {} });
|
|
5772
6924
|
const env = { backend, probedAt: Date.now(), ...probed };
|
|
6925
|
+
// An empty result must never overwrite a catalog we already have. Some
|
|
6926
|
+
// probes hit the network (`agy models` fetches, 5s cap), so a slow moment
|
|
6927
|
+
// returns [] — and writing that would blank the list for the whole 24h
|
|
6928
|
+
// TTL, long after the CLI recovered. Observed live: a good 11-model
|
|
6929
|
+
// antigravity cache replaced by an empty one. Keep the known models and
|
|
6930
|
+
// let the fresher currentModel/version through.
|
|
6931
|
+
if (!env.models?.length) {
|
|
6932
|
+
const previous = this.readCliEnv(backend);
|
|
6933
|
+
if (previous?.models?.length)
|
|
6934
|
+
env.models = previous.models;
|
|
6935
|
+
}
|
|
5773
6936
|
const path = this.cliEnvPath(backend);
|
|
5774
6937
|
mkdirSync(dirname(path), { recursive: true });
|
|
5775
6938
|
writeFileSync(path, JSON.stringify(env, null, 2));
|
|
@@ -5808,6 +6971,108 @@ When users create specialized instances, suggest these configurations:
|
|
|
5808
6971
|
const env = await this.probeBackend(backendName);
|
|
5809
6972
|
return env?.models ?? [];
|
|
5810
6973
|
}
|
|
6974
|
+
/**
|
|
6975
|
+
* Model catalog behind the `list_models` tool.
|
|
6976
|
+
*
|
|
6977
|
+
* The two scopes are not cosmetic. "global" is the account/CLI catalog served
|
|
6978
|
+
* from the startup probe cache; "instance" is resolved through that instance's
|
|
6979
|
+
* OWN backend config, and for a Codex instance on a custom provider that is a
|
|
6980
|
+
* different catalog entirely — `listModels()` reads models_cache.json out of
|
|
6981
|
+
* the instance's private CODEX_HOME. Answering such an instance with the
|
|
6982
|
+
* account list would name models its CLI rejects, which is exactly the
|
|
6983
|
+
* mistake this tool exists to prevent.
|
|
6984
|
+
*
|
|
6985
|
+
* `scope` always describes where the returned LIST came from, not what was
|
|
6986
|
+
* asked for: an instance query that falls back to the account catalog reports
|
|
6987
|
+
* scope "global" and says so in `note`, rather than implying instance-level
|
|
6988
|
+
* accuracy it does not have.
|
|
6989
|
+
*
|
|
6990
|
+
* Never throws — a model listing is an aid, and failing it must not fail a turn.
|
|
6991
|
+
*/
|
|
6992
|
+
async listModelCatalog(opts = {}) {
|
|
6993
|
+
const { instanceName } = opts;
|
|
6994
|
+
if (instanceName) {
|
|
6995
|
+
const backend = this.backendNameForInstance(instanceName);
|
|
6996
|
+
const resolved = this.resolveInstanceModel(instanceName);
|
|
6997
|
+
const currentModel = resolved.source === "unresolved" ? null : resolved.model;
|
|
6998
|
+
const provider = this.customProviderFor(instanceName, backend);
|
|
6999
|
+
const scoped = await this.instanceScopedModels(instanceName, backend);
|
|
7000
|
+
if (scoped.length) {
|
|
7001
|
+
return {
|
|
7002
|
+
backend, scope: "instance", instance: instanceName,
|
|
7003
|
+
current_model: currentModel, models: scoped, source: "live",
|
|
7004
|
+
...(provider ? { note: `Catalog read through this instance's ${backend} provider "${provider}" — it may differ from the account catalog.` } : {}),
|
|
7005
|
+
};
|
|
7006
|
+
}
|
|
7007
|
+
// No instance-local catalog (never launched, or the backend has no
|
|
7008
|
+
// per-instance list). The account catalog is the best available answer,
|
|
7009
|
+
// but it is labelled honestly rather than dressed up as instance scope.
|
|
7010
|
+
const global = await this.globalModelCatalog(backend);
|
|
7011
|
+
return {
|
|
7012
|
+
...global, instance: instanceName, current_model: currentModel,
|
|
7013
|
+
note: provider
|
|
7014
|
+
? `No instance-local catalog yet; showing the account catalog, which may NOT match this instance's ${backend} provider "${provider}".`
|
|
7015
|
+
: "No instance-local catalog yet; showing the account catalog.",
|
|
7016
|
+
};
|
|
7017
|
+
}
|
|
7018
|
+
return this.globalModelCatalog(opts.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
|
|
7019
|
+
}
|
|
7020
|
+
/** The custom provider an instance overrides its backend with, if any. */
|
|
7021
|
+
customProviderFor(instanceName, backend) {
|
|
7022
|
+
const opts = this.fleetConfig?.instances?.[instanceName]?.backend_options?.[backend]
|
|
7023
|
+
?? this.fleetConfig?.defaults?.backend_options?.[backend];
|
|
7024
|
+
const provider = opts?.provider;
|
|
7025
|
+
return typeof provider === "string" && provider.trim() ? provider.trim() : null;
|
|
7026
|
+
}
|
|
7027
|
+
/** Ask a backend for its catalog using ONE instance's real config. Never throws. */
|
|
7028
|
+
async instanceScopedModels(instanceName, backend) {
|
|
7029
|
+
try {
|
|
7030
|
+
const inst = this.fleetConfig?.instances?.[instanceName];
|
|
7031
|
+
const instanceDir = this.getInstanceDir(instanceName);
|
|
7032
|
+
const be = createBackend(backend, instanceDir);
|
|
7033
|
+
if (!be.listModels)
|
|
7034
|
+
return [];
|
|
7035
|
+
return await be.listModels({
|
|
7036
|
+
workingDirectory: inst?.working_directory ?? "",
|
|
7037
|
+
instanceDir,
|
|
7038
|
+
instanceName,
|
|
7039
|
+
mcpServers: {},
|
|
7040
|
+
model: inst?.model,
|
|
7041
|
+
backendOptions: inst?.backend_options?.[backend] ?? this.fleetConfig?.defaults?.backend_options?.[backend],
|
|
7042
|
+
}) ?? [];
|
|
7043
|
+
}
|
|
7044
|
+
catch {
|
|
7045
|
+
// listModels is documented never to throw, but a backend constructor can
|
|
7046
|
+
// (missing binary). A catalog is an aid; degrade to the account list.
|
|
7047
|
+
return [];
|
|
7048
|
+
}
|
|
7049
|
+
}
|
|
7050
|
+
/** Account-wide catalog: probe cache first, live probe on miss. */
|
|
7051
|
+
async globalModelCatalog(backend) {
|
|
7052
|
+
const cached = this.readCliEnv(backend);
|
|
7053
|
+
if (cached?.models?.length) {
|
|
7054
|
+
return {
|
|
7055
|
+
backend, scope: "global", current_model: cached.currentModel ?? null,
|
|
7056
|
+
models: cached.models, source: "cache",
|
|
7057
|
+
probed_at: new Date(cached.probedAt).toISOString(),
|
|
7058
|
+
};
|
|
7059
|
+
}
|
|
7060
|
+
const env = await this.probeBackend(backend);
|
|
7061
|
+
if (env?.models?.length) {
|
|
7062
|
+
return {
|
|
7063
|
+
backend, scope: "global", current_model: env.currentModel ?? null,
|
|
7064
|
+
models: env.models, source: "live",
|
|
7065
|
+
probed_at: new Date(env.probedAt).toISOString(),
|
|
7066
|
+
};
|
|
7067
|
+
}
|
|
7068
|
+
// Reported rather than thrown: "we could not enumerate" is a useful answer,
|
|
7069
|
+
// and the caller can still set a model by name (AgEnD passes it through).
|
|
7070
|
+
return {
|
|
7071
|
+
backend, scope: "global", current_model: env?.currentModel ?? null,
|
|
7072
|
+
models: [], source: "fallback",
|
|
7073
|
+
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.`,
|
|
7074
|
+
};
|
|
7075
|
+
}
|
|
5811
7076
|
/** `/model` slash handler (admin only). No arg → DC menu; `/model <name>` → apply directly. */
|
|
5812
7077
|
/** Label an effort choice, marking the one currently configured. */
|
|
5813
7078
|
effortChoiceLabel(level, current) {
|
|
@@ -5816,10 +7081,10 @@ When users create specialized instances, suggest these configurations:
|
|
|
5816
7081
|
effortMenuHeader(instanceName) {
|
|
5817
7082
|
const { effort, source } = this.resolveInstanceEffort(instanceName);
|
|
5818
7083
|
if (!effort)
|
|
5819
|
-
return "
|
|
7084
|
+
return t("effort.current_default");
|
|
5820
7085
|
return source === "fleet-default"
|
|
5821
|
-
?
|
|
5822
|
-
:
|
|
7086
|
+
? t("effort.current_fleet", effort)
|
|
7087
|
+
: t("effort.current", effort);
|
|
5823
7088
|
}
|
|
5824
7089
|
/** `/effort` — DC Select Menu, or apply directly when a level is given. */
|
|
5825
7090
|
async handleEffortSlash(data, adapterId) {
|
|
@@ -5840,11 +7105,11 @@ When users create specialized instances, suggest these configurations:
|
|
|
5840
7105
|
}
|
|
5841
7106
|
const levels = this.effortLevelsFor(name);
|
|
5842
7107
|
if (levels.length === 0) {
|
|
5843
|
-
await data.respond(
|
|
7108
|
+
await data.respond(t("effort.unsupported", this.backendNameForInstance(name)));
|
|
5844
7109
|
return;
|
|
5845
7110
|
}
|
|
5846
7111
|
if (!data.respondChoices) {
|
|
5847
|
-
await data.respond(
|
|
7112
|
+
await data.respond(t("effort.usage", levels.join("|")));
|
|
5848
7113
|
return;
|
|
5849
7114
|
}
|
|
5850
7115
|
const current = this.resolveInstanceEffort(name).effort;
|
|
@@ -5857,20 +7122,20 @@ When users create specialized instances, suggest these configurations:
|
|
|
5857
7122
|
timer.unref?.();
|
|
5858
7123
|
this.pendingEffortSelects.set(nonce, { instanceName: name, userId: data.userId, channelId: data.channelId, timer, respond: data.respond });
|
|
5859
7124
|
try {
|
|
5860
|
-
await data.respondChoices(
|
|
7125
|
+
await data.respondChoices(t("effort.menu", this.effortMenuHeader(name)), choices);
|
|
5861
7126
|
}
|
|
5862
7127
|
catch (err) {
|
|
5863
7128
|
this.pendingEffortSelects.delete(nonce);
|
|
5864
7129
|
clearTimeout(timer);
|
|
5865
7130
|
this.logger.warn({ err, instanceName: name }, "effort menu failed");
|
|
5866
|
-
await data.respond(
|
|
7131
|
+
await data.respond(t("effort.usage", levels.join("|")));
|
|
5867
7132
|
}
|
|
5868
7133
|
}
|
|
5869
7134
|
/** TG inline-keyboard effort menu. Returns null on success, else a fallback string. */
|
|
5870
7135
|
async promptEffortMenu(instanceName, userId, channelId, adapter, chatId, threadId) {
|
|
5871
7136
|
const levels = this.effortLevelsFor(instanceName);
|
|
5872
7137
|
if (levels.length === 0) {
|
|
5873
|
-
return
|
|
7138
|
+
return t("effort.unsupported", this.backendNameForInstance(instanceName));
|
|
5874
7139
|
}
|
|
5875
7140
|
const current = this.resolveInstanceEffort(instanceName).effort;
|
|
5876
7141
|
const nonce = randomBytes(6).toString("hex");
|
|
@@ -5886,13 +7151,13 @@ When users create specialized instances, suggest these configurations:
|
|
|
5886
7151
|
const p = this.pendingEffortSelects.get(nonce);
|
|
5887
7152
|
if (p) {
|
|
5888
7153
|
this.pendingEffortSelects.delete(nonce);
|
|
5889
|
-
p.respond("
|
|
7154
|
+
p.respond(t("effort.selection_expired")).catch(() => { });
|
|
5890
7155
|
}
|
|
5891
7156
|
}, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
5892
7157
|
timer.unref?.();
|
|
5893
7158
|
this.pendingEffortSelects.set(nonce, { instanceName, userId, channelId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
5894
7159
|
try {
|
|
5895
|
-
const menuMessageId = await adapter.promptUser(chatId,
|
|
7160
|
+
const menuMessageId = await adapter.promptUser(chatId, t("effort.menu", this.effortMenuHeader(instanceName)), choices, { threadId });
|
|
5896
7161
|
const pending = this.pendingEffortSelects.get(nonce);
|
|
5897
7162
|
if (pending)
|
|
5898
7163
|
pending.menuMessageId = menuMessageId;
|
|
@@ -5902,7 +7167,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
5902
7167
|
this.pendingEffortSelects.delete(nonce);
|
|
5903
7168
|
clearTimeout(timer);
|
|
5904
7169
|
this.logger.warn({ err, instanceName }, "TG effort menu failed");
|
|
5905
|
-
return
|
|
7170
|
+
return t("effort.usage", levels.join("|"));
|
|
5906
7171
|
}
|
|
5907
7172
|
}
|
|
5908
7173
|
/** Consume an `/effort` selection callback. Mirrors handleModelSelection. */
|
|
@@ -5923,7 +7188,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
5923
7188
|
this.pendingEffortSelects.delete(match[1]);
|
|
5924
7189
|
clearTimeout(pending.timer);
|
|
5925
7190
|
const level = match[2];
|
|
5926
|
-
const progressText =
|
|
7191
|
+
const progressText = t("effort.setting", pending.instanceName, level);
|
|
5927
7192
|
let progressMsgId;
|
|
5928
7193
|
if (pending.adapter && pending.adapterChatId) {
|
|
5929
7194
|
const menuMessageId = pending.menuMessageId ?? data.messageId;
|
|
@@ -5955,7 +7220,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
5955
7220
|
}
|
|
5956
7221
|
catch (err) {
|
|
5957
7222
|
this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
|
|
5958
|
-
result =
|
|
7223
|
+
result = t("effort.switch_failed", level, err instanceof Error ? err.message : String(err));
|
|
5959
7224
|
}
|
|
5960
7225
|
if (pending.adapter && pending.adapterChatId) {
|
|
5961
7226
|
if (progressMsgId) {
|
|
@@ -5992,12 +7257,12 @@ When users create specialized instances, suggest these configurations:
|
|
|
5992
7257
|
}
|
|
5993
7258
|
// No arg (or --refresh) → menu. Menu is DC-only this round (respondChoices); TG uses `/model <name>`.
|
|
5994
7259
|
if (!data.respondChoices) {
|
|
5995
|
-
await data.respond("
|
|
7260
|
+
await data.respond(t("model.usage"));
|
|
5996
7261
|
return;
|
|
5997
7262
|
}
|
|
5998
7263
|
const options = await this.getModelOptions(name, isRefresh);
|
|
5999
7264
|
if (options.length === 0) {
|
|
6000
|
-
await data.respond(
|
|
7265
|
+
await data.respond(t("model.list_unavailable", name));
|
|
6001
7266
|
return;
|
|
6002
7267
|
}
|
|
6003
7268
|
// Raw id for ✓-matching options; display resolves an inherited CLI default.
|
|
@@ -6011,13 +7276,13 @@ When users create specialized instances, suggest these configurations:
|
|
|
6011
7276
|
timer.unref?.();
|
|
6012
7277
|
this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, timer, respond: data.respond });
|
|
6013
7278
|
try {
|
|
6014
|
-
await data.respondChoices(
|
|
7279
|
+
await data.respondChoices(t("model.menu", `**${currentDisplay}**`), choices);
|
|
6015
7280
|
}
|
|
6016
7281
|
catch (err) {
|
|
6017
7282
|
this.pendingModelSelects.delete(nonce);
|
|
6018
7283
|
clearTimeout(timer);
|
|
6019
7284
|
this.logger.warn({ err, instanceName: name }, "model menu failed");
|
|
6020
|
-
await data.respond("
|
|
7285
|
+
await data.respond(t("model.usage"));
|
|
6021
7286
|
}
|
|
6022
7287
|
}
|
|
6023
7288
|
/**
|
|
@@ -6028,7 +7293,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6028
7293
|
async promptModelMenu(instanceName, userId, channelId, adapter, chatId, threadId) {
|
|
6029
7294
|
const options = await this.getModelOptions(instanceName);
|
|
6030
7295
|
if (options.length === 0) {
|
|
6031
|
-
return
|
|
7296
|
+
return t("model.list_unavailable", instanceName);
|
|
6032
7297
|
}
|
|
6033
7298
|
const { model: currentModel, display: currentDisplay } = this.resolveInstanceModel(instanceName);
|
|
6034
7299
|
const nonce = randomBytes(6).toString("hex");
|
|
@@ -6044,13 +7309,13 @@ When users create specialized instances, suggest these configurations:
|
|
|
6044
7309
|
const p = this.pendingModelSelects.get(nonce);
|
|
6045
7310
|
if (p) {
|
|
6046
7311
|
this.pendingModelSelects.delete(nonce);
|
|
6047
|
-
p.respond("
|
|
7312
|
+
p.respond(t("model.selection_expired")).catch(() => { });
|
|
6048
7313
|
}
|
|
6049
7314
|
}, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
6050
7315
|
timer.unref?.();
|
|
6051
7316
|
this.pendingModelSelects.set(nonce, { instanceName, model: "", userId, channelId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
6052
7317
|
try {
|
|
6053
|
-
const menuMessageId = await adapter.promptUser(chatId,
|
|
7318
|
+
const menuMessageId = await adapter.promptUser(chatId, t("model.menu", currentDisplay), choices, { threadId });
|
|
6054
7319
|
const pending = this.pendingModelSelects.get(nonce);
|
|
6055
7320
|
if (pending)
|
|
6056
7321
|
pending.menuMessageId = menuMessageId;
|
|
@@ -6060,7 +7325,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6060
7325
|
this.pendingModelSelects.delete(nonce);
|
|
6061
7326
|
clearTimeout(timer);
|
|
6062
7327
|
this.logger.warn({ err, instanceName }, "TG model menu failed");
|
|
6063
|
-
return
|
|
7328
|
+
return t("model.usage");
|
|
6064
7329
|
}
|
|
6065
7330
|
}
|
|
6066
7331
|
/** Consume a `/model` selection callback. Returns true for all model-select ids (incl. stale). */
|
|
@@ -6083,7 +7348,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6083
7348
|
clearTimeout(pending.timer);
|
|
6084
7349
|
const model = match[2];
|
|
6085
7350
|
// Send immediate "⏳ Switching..." feedback, then apply in background.
|
|
6086
|
-
const progressText =
|
|
7351
|
+
const progressText = t("model.switching", pending.instanceName, model);
|
|
6087
7352
|
let progressMsgId;
|
|
6088
7353
|
if (pending.adapter && pending.adapterChatId) {
|
|
6089
7354
|
// TG path: turn the original menu into the progress message. This both
|
|
@@ -6120,7 +7385,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6120
7385
|
}
|
|
6121
7386
|
catch (err) {
|
|
6122
7387
|
this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
|
|
6123
|
-
result =
|
|
7388
|
+
result = t("model.switch_failed", model, err instanceof Error ? err.message : String(err));
|
|
6124
7389
|
}
|
|
6125
7390
|
if (pending.adapter && pending.adapterChatId) {
|
|
6126
7391
|
if (progressMsgId) {
|
|
@@ -6217,17 +7482,17 @@ When users create specialized instances, suggest these configurations:
|
|
|
6217
7482
|
}
|
|
6218
7483
|
catch { /* treated as unsupported below */ }
|
|
6219
7484
|
if (strategy === "unsupported" || supported.length === 0) {
|
|
6220
|
-
return
|
|
7485
|
+
return t("effort.unsupported", backendName);
|
|
6221
7486
|
}
|
|
6222
7487
|
if (!FleetManager.EFFORT_LEVELS.includes(level)) {
|
|
6223
|
-
return
|
|
7488
|
+
return t("effort.unknown", level, FleetManager.EFFORT_LEVELS.join(", "));
|
|
6224
7489
|
}
|
|
6225
7490
|
const applied = FleetManager.clampEffort(level, supported);
|
|
6226
7491
|
if (!applied)
|
|
6227
|
-
return
|
|
7492
|
+
return t("effort.no_canonical", backendName);
|
|
6228
7493
|
const warn = applied === level
|
|
6229
7494
|
? ""
|
|
6230
|
-
:
|
|
7495
|
+
: t("effort.clamped", applied, level, backendName);
|
|
6231
7496
|
// Persist either way: a runtime switch must survive the next respawn too,
|
|
6232
7497
|
// or the instance silently reverts on restart.
|
|
6233
7498
|
if (this.fleetConfig?.instances[instanceName]) {
|
|
@@ -6236,12 +7501,12 @@ When users create specialized instances, suggest these configurations:
|
|
|
6236
7501
|
}
|
|
6237
7502
|
if (strategy === "runtime") {
|
|
6238
7503
|
if (!this.instanceIpcClients.get(instanceName))
|
|
6239
|
-
return `${warn}
|
|
7504
|
+
return `${warn}${t("effort.not_running", instanceName)}`;
|
|
6240
7505
|
this.pasteRawToClassicInstance(instanceName, `/effort ${applied}`);
|
|
6241
|
-
return `${warn}
|
|
7506
|
+
return `${warn}${t("effort.runtime_success", instanceName, applied)}`;
|
|
6242
7507
|
}
|
|
6243
7508
|
await this.restartSingleInstance(instanceName);
|
|
6244
|
-
return `${warn}
|
|
7509
|
+
return `${warn}${t("effort.restart_success", instanceName, applied)}`;
|
|
6245
7510
|
}
|
|
6246
7511
|
async applyModel(instanceName, model) {
|
|
6247
7512
|
const backendName = this.backendNameForInstance(instanceName);
|
|
@@ -6250,14 +7515,12 @@ When users create specialized instances, suggest these configurations:
|
|
|
6250
7515
|
strategy = createBackend(backendName, this.getInstanceDir(instanceName)).getModelSwitchStrategy?.(model) ?? "restart";
|
|
6251
7516
|
}
|
|
6252
7517
|
catch { /* default restart */ }
|
|
6253
|
-
const warn = isModelCompatible(backendName, model) ? "" :
|
|
6254
|
-
if (strategy === "runtime") {
|
|
6255
|
-
|
|
6256
|
-
return `${warn}❌ ${instanceName} is not running.`;
|
|
6257
|
-
this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
|
|
6258
|
-
return `${warn}✅ Switched ${instanceName} to \`${model}\` (runtime).${this.effortSuffix(instanceName)}`;
|
|
7518
|
+
const warn = isModelCompatible(backendName, model) ? "" : t("model.pattern_warning", model, backendName);
|
|
7519
|
+
if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
|
|
7520
|
+
return `${warn}${t("effort.not_running", instanceName)}`;
|
|
6259
7521
|
}
|
|
6260
|
-
//
|
|
7522
|
+
// Persist either way: a runtime switch must survive the next respawn too,
|
|
7523
|
+
// or the instance silently reverts to the CLI default after a fleet restart.
|
|
6261
7524
|
let persisted = false;
|
|
6262
7525
|
if (this.fleetConfig?.instances[instanceName]) {
|
|
6263
7526
|
this.fleetConfig.instances[instanceName].model = model;
|
|
@@ -6268,9 +7531,13 @@ When users create specialized instances, suggest these configurations:
|
|
|
6268
7531
|
persisted = true;
|
|
6269
7532
|
}
|
|
6270
7533
|
if (!persisted)
|
|
6271
|
-
return `${warn}
|
|
7534
|
+
return `${warn}${t("model.persist_failed", instanceName)}`;
|
|
7535
|
+
if (strategy === "runtime") {
|
|
7536
|
+
this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
|
|
7537
|
+
return `${warn}${t("model.runtime_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
|
|
7538
|
+
}
|
|
6272
7539
|
await this.restartSingleInstance(instanceName);
|
|
6273
|
-
return `${warn}
|
|
7540
|
+
return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
|
|
6274
7541
|
}
|
|
6275
7542
|
/**
|
|
6276
7543
|
* The trailing "Current effort: …" line for a /model reply.
|
|
@@ -6284,10 +7551,10 @@ When users create specialized instances, suggest these configurations:
|
|
|
6284
7551
|
return "";
|
|
6285
7552
|
const { effort, source } = this.resolveInstanceEffort(instanceName);
|
|
6286
7553
|
if (!effort)
|
|
6287
|
-
return "
|
|
7554
|
+
return `\n${t("effort.current_default")}`;
|
|
6288
7555
|
return source === "fleet-default"
|
|
6289
|
-
? `\
|
|
6290
|
-
: `\
|
|
7556
|
+
? `\n${t("effort.current_fleet", effort)}`
|
|
7557
|
+
: `\n${t("effort.current", effort)}`;
|
|
6291
7558
|
}
|
|
6292
7559
|
/** Read recent chat log for agent context */
|
|
6293
7560
|
getRecentChatLog(instanceName, maxLines = 10) {
|
|
@@ -6322,7 +7589,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6322
7589
|
/** Return a user-facing blocker without mutating ClassicBot state. */
|
|
6323
7590
|
validateClassicStart(channelId, userId, guildId, adapterId) {
|
|
6324
7591
|
if (!this.classicChannels)
|
|
6325
|
-
return "
|
|
7592
|
+
return t("classic.manager_unavailable");
|
|
6326
7593
|
if (guildId && !this.classicChannels.isGuildAllowed(guildId)) {
|
|
6327
7594
|
const generalId = this.findGeneralInstance(adapterId);
|
|
6328
7595
|
if (generalId)
|
|
@@ -6469,6 +7736,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6469
7736
|
return;
|
|
6470
7737
|
const workDir = join(getAgendHome(), "workspaces", instanceName);
|
|
6471
7738
|
ensureWorkspaceGit(workDir);
|
|
7739
|
+
const classicIdentity = this.classicChannels?.getAll().find(ch => ch.instanceName === instanceName);
|
|
6472
7740
|
const config = {
|
|
6473
7741
|
...DEFAULT_INSTANCE_CONFIG,
|
|
6474
7742
|
...this.fleetConfig?.defaults,
|
|
@@ -6476,6 +7744,8 @@ When users create specialized instances, suggest these configurations:
|
|
|
6476
7744
|
lightweight: true,
|
|
6477
7745
|
...(backend ? { backend } : {}),
|
|
6478
7746
|
...(model ? { model } : {}),
|
|
7747
|
+
...(classicIdentity?.displayName ? { display_name: classicIdentity.displayName } : {}),
|
|
7748
|
+
...(classicIdentity?.description ? { description: classicIdentity.description } : {}),
|
|
6479
7749
|
...(autoPauseAfter !== undefined ? { auto_pause_after: autoPauseAfter } : {}),
|
|
6480
7750
|
...(preTaskCommand ? { pre_task_command: preTaskCommand } : {}),
|
|
6481
7751
|
};
|
|
@@ -6489,7 +7759,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6489
7759
|
return blocker;
|
|
6490
7760
|
const classicChannels = this.classicChannels;
|
|
6491
7761
|
if (!classicChannels)
|
|
6492
|
-
return "
|
|
7762
|
+
return t("classic.manager_unavailable");
|
|
6493
7763
|
const instanceName = classicChannels.deriveInstanceName(channelName || channelId, channelId, adapterId);
|
|
6494
7764
|
clearPausedMarker(this.getInstanceDir(instanceName));
|
|
6495
7765
|
const selectedBackend = isSelectableClassicBackend(backend) ? backend : undefined;
|
|
@@ -6511,7 +7781,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6511
7781
|
/** Handle /stop slash command — unregister classic channel */
|
|
6512
7782
|
async handleClassicStop(channelId, adapterId) {
|
|
6513
7783
|
if (!this.classicChannels)
|
|
6514
|
-
return "
|
|
7784
|
+
return t("classic.manager_unavailable");
|
|
6515
7785
|
const ch = this.classicChannels.unregister(channelId, adapterId);
|
|
6516
7786
|
if (!ch)
|
|
6517
7787
|
return t("classic.no_agent");
|
|
@@ -6558,6 +7828,8 @@ When users create specialized instances, suggest these configurations:
|
|
|
6558
7828
|
this.clearStatuslineWatchers();
|
|
6559
7829
|
this.costGuard?.stop();
|
|
6560
7830
|
this.dailySummary?.stop();
|
|
7831
|
+
this.dailyTipScheduler?.stop();
|
|
7832
|
+
this.dailyTipScheduler = null;
|
|
6561
7833
|
if (this.updateCheckTimer) {
|
|
6562
7834
|
clearTimeout(this.updateCheckTimer);
|
|
6563
7835
|
clearInterval(this.updateCheckTimer);
|
|
@@ -6606,11 +7878,11 @@ When users create specialized instances, suggest these configurations:
|
|
|
6606
7878
|
for (const pending of this.pendingClassicStarts.values())
|
|
6607
7879
|
clearTimeout(pending.timer);
|
|
6608
7880
|
this.pendingClassicStarts.clear();
|
|
6609
|
-
for (const pending of this.
|
|
7881
|
+
for (const pending of this.pendingNonceButtons.values()) {
|
|
6610
7882
|
if (pending.timer)
|
|
6611
7883
|
clearTimeout(pending.timer);
|
|
6612
7884
|
}
|
|
6613
|
-
this.
|
|
7885
|
+
this.pendingNonceButtons.clear();
|
|
6614
7886
|
this.topicArchiver.stop();
|
|
6615
7887
|
this.scheduler?.shutdown();
|
|
6616
7888
|
// Stop instances in parallel batches to avoid long sequential waits.
|
|
@@ -6775,7 +8047,8 @@ When users create specialized instances, suggest these configurations:
|
|
|
6775
8047
|
/**
|
|
6776
8048
|
* Hot-reload: re-read fleet.yaml and reconcile running instances.
|
|
6777
8049
|
* Starts new, stops removed, restarts modified instances.
|
|
6778
|
-
*
|
|
8050
|
+
* Whitelisted runtime fields are pushed into live daemons; all other instance
|
|
8051
|
+
* fields, plus cold fleet-level settings, retain restart semantics.
|
|
6779
8052
|
*/
|
|
6780
8053
|
async reconcileInstances() {
|
|
6781
8054
|
if (!this.configPath)
|
|
@@ -6817,11 +8090,11 @@ When users create specialized instances, suggest these configurations:
|
|
|
6817
8090
|
// possible outcome: they change fleet.yaml, send SIGHUP, and nothing happens
|
|
6818
8091
|
// with no explanation anywhere they are looking.
|
|
6819
8092
|
const why = !validation.valid
|
|
6820
|
-
?
|
|
8093
|
+
? t("fleet.reload_validation", validation.errors.map(e => `• ${e.path}: ${e.message}`).join("\n"))
|
|
6821
8094
|
: unsafeEmpty
|
|
6822
|
-
?
|
|
6823
|
-
:
|
|
6824
|
-
this.notifyFleetError(
|
|
8095
|
+
? t("fleet.reload_removed_all", oldCount)
|
|
8096
|
+
: t("fleet.reload_removed_half", oldCount, newCount);
|
|
8097
|
+
this.notifyFleetError(t("fleet.reload_rejected", why));
|
|
6825
8098
|
return;
|
|
6826
8099
|
}
|
|
6827
8100
|
this.routing.rebuild(this.fleetConfig);
|
|
@@ -6829,9 +8102,20 @@ When users create specialized instances, suggest these configurations:
|
|
|
6829
8102
|
this.scheduler?.reload();
|
|
6830
8103
|
const newInstances = this.fleetConfig.instances;
|
|
6831
8104
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
6832
|
-
// Detect fleet-level
|
|
6833
|
-
|
|
6834
|
-
const
|
|
8105
|
+
// Detect fleet-level changes which still need a restart. Hot defaults are
|
|
8106
|
+
// reconciled below and must not produce a misleading restart warning.
|
|
8107
|
+
const oldDefaultColdWithFleetFields = oldConfig?.defaults
|
|
8108
|
+
? splitHotColdConfig(oldConfig.defaults).cold
|
|
8109
|
+
: {};
|
|
8110
|
+
const newDefaultColdWithFleetFields = this.fleetConfig?.defaults
|
|
8111
|
+
? splitHotColdConfig(this.fleetConfig.defaults).cold
|
|
8112
|
+
: {};
|
|
8113
|
+
// `tips` is a fleet-owned hot switch; it is read at post time and never
|
|
8114
|
+
// belongs in a daemon config or a "restart required" warning.
|
|
8115
|
+
const { tips: _oldTips, ...oldDefaultCold } = oldDefaultColdWithFleetFields;
|
|
8116
|
+
const { tips: _newTips, ...newDefaultCold } = newDefaultColdWithFleetFields;
|
|
8117
|
+
const oldFleetLevel = JSON.stringify({ channel: oldConfig?.channel, defaults: oldDefaultCold });
|
|
8118
|
+
const newFleetLevel = JSON.stringify({ channel: this.fleetConfig?.channel, defaults: newDefaultCold });
|
|
6835
8119
|
if (oldFleetLevel !== newFleetLevel) {
|
|
6836
8120
|
this.logger.warn("Fleet-level config changed (channel/defaults) — use /restart for full effect");
|
|
6837
8121
|
}
|
|
@@ -6843,7 +8127,9 @@ When users create specialized instances, suggest these configurations:
|
|
|
6843
8127
|
await this.stopInstance(name).catch(err => this.logger.error({ err, name }, "Failed to stop removed instance"));
|
|
6844
8128
|
}
|
|
6845
8129
|
}
|
|
6846
|
-
// Start new +
|
|
8130
|
+
// Start new + reconcile modified instances. Hot values are always sent as a
|
|
8131
|
+
// complete snapshot: Settings mutates FleetManager's config before SIGHUP,
|
|
8132
|
+
// so an old/new diff alone can miss the live daemon's stale value.
|
|
6847
8133
|
for (const [name, config] of Object.entries(newInstances)) {
|
|
6848
8134
|
if (!this.daemons.has(name)) {
|
|
6849
8135
|
// New instance — startInstance already calls connectIpcToInstance
|
|
@@ -6851,14 +8137,33 @@ When users create specialized instances, suggest these configurations:
|
|
|
6851
8137
|
await this.startInstance(name, config, topicMode).catch(err => this.logger.error({ err, name }, "Failed to start new instance"));
|
|
6852
8138
|
}
|
|
6853
8139
|
else if (oldConfig?.instances[name]) {
|
|
6854
|
-
|
|
6855
|
-
|
|
8140
|
+
const daemon = this.daemons.get(name);
|
|
8141
|
+
const runtimeConfig = daemon.getConfigSnapshot?.() ?? oldConfig.instances[name];
|
|
8142
|
+
const oldParts = splitHotColdConfig(runtimeConfig);
|
|
8143
|
+
const newParts = splitHotColdConfig(config);
|
|
8144
|
+
// Every field not explicitly classified hot is cold by default.
|
|
8145
|
+
if (!isDeepStrictEqual(oldParts.cold, newParts.cold)) {
|
|
6856
8146
|
this.logger.info({ name }, "Instance config changed — restarting");
|
|
6857
8147
|
await this.stopInstance(name).catch(() => { });
|
|
6858
8148
|
await this.startInstance(name, config, topicMode).catch(err => this.logger.error({ err, name }, "Failed to restart modified instance"));
|
|
6859
8149
|
}
|
|
8150
|
+
else if (!isDeepStrictEqual(oldParts.hot, newParts.hot)) {
|
|
8151
|
+
const update = hotConfigUpdate(config);
|
|
8152
|
+
const ipc = this.instanceIpcClients.get(name);
|
|
8153
|
+
const sent = ipc?.connected === true && ipc.send({ type: "config_update", config: update });
|
|
8154
|
+
if (!sent) {
|
|
8155
|
+
// Daemon is in-process, so a reconnect gap must not leave runtime
|
|
8156
|
+
// state stale. Normal operation still uses the explicit IPC contract.
|
|
8157
|
+
daemon.applyConfigUpdate(update);
|
|
8158
|
+
this.logger.warn({ name }, "Config-update IPC unavailable — applied hot config in-process");
|
|
8159
|
+
}
|
|
8160
|
+
this.logger.info({ name, fields: [...HOT_INSTANCE_CONFIG_KEYS] }, "Instance hot config reloaded");
|
|
8161
|
+
}
|
|
6860
8162
|
}
|
|
6861
8163
|
}
|
|
8164
|
+
// warm_cap is fleet-owned; enforce the reloaded value immediately against
|
|
8165
|
+
// currently idle instances instead of waiting for a future state edge.
|
|
8166
|
+
this.enforceWarmCap();
|
|
6862
8167
|
this.logger.info({ running: this.daemons.size, configured: Object.keys(newInstances).length }, "Reconcile complete");
|
|
6863
8168
|
}
|
|
6864
8169
|
async restartInstances() {
|
|
@@ -7098,7 +8403,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
7098
8403
|
if (generalId) {
|
|
7099
8404
|
// No release URL — Discord's SuppressEmbeds proved unreliable and the
|
|
7100
8405
|
// link preview looked bad. Version + /update instruction is enough.
|
|
7101
|
-
this.notifyInstanceTopic(generalId, t("update.
|
|
8406
|
+
this.notifyInstanceTopic(generalId, t("update.available_current", `v${target}`, `v${currentVersion}`));
|
|
7102
8407
|
}
|
|
7103
8408
|
}
|
|
7104
8409
|
}
|
|
@@ -7418,7 +8723,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
7418
8723
|
if (err.code === "EADDRINUSE") {
|
|
7419
8724
|
if (this.healthPortRetried) {
|
|
7420
8725
|
this.logger.error({ err, port }, "Health port still in use after takeover — dashboard disabled");
|
|
7421
|
-
this.notifyFleetError(
|
|
8726
|
+
this.notifyFleetError(t("dashboard.port_in_use", port));
|
|
7422
8727
|
return;
|
|
7423
8728
|
}
|
|
7424
8729
|
this.healthPortRetried = true;
|
|
@@ -7444,7 +8749,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
7444
8749
|
return;
|
|
7445
8750
|
}
|
|
7446
8751
|
this.logger.error({ err, port }, "Health server error");
|
|
7447
|
-
this.notifyFleetError(
|
|
8752
|
+
this.notifyFleetError(t("dashboard.server_failed", err.message));
|
|
7448
8753
|
});
|
|
7449
8754
|
this.healthServer.listen(port, "127.0.0.1", () => markListening());
|
|
7450
8755
|
}
|