@songsid/agend 2.1.4-beta.4 → 2.1.4-beta.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-TW.md +1 -1
- package/dist/agent-endpoint.js +1 -1
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/backend/antigravity.d.ts +9 -5
- package/dist/backend/antigravity.js +156 -31
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.d.ts +62 -23
- package/dist/backend/claude-code.js +296 -43
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.d.ts +18 -0
- package/dist/backend/codex.js +148 -8
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/gemini-cli.js +2 -2
- package/dist/backend/gemini-cli.js.map +1 -1
- package/dist/backend/grok.js +2 -2
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro.d.ts +2 -0
- package/dist/backend/kiro.js +24 -3
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/opencode.d.ts +27 -11
- package/dist/backend/opencode.js +65 -41
- package/dist/backend/opencode.js.map +1 -1
- package/dist/backend/types.d.ts +45 -3
- package/dist/backend/types.js +12 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +44 -2
- package/dist/channel/adapters/discord.js +526 -78
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +30 -0
- package/dist/channel/adapters/telegram.js +212 -18
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/agy-mcp-launcher.d.ts +2 -0
- package/dist/channel/agy-mcp-launcher.js +28 -0
- package/dist/channel/agy-mcp-launcher.js.map +1 -0
- package/dist/channel/ipc-bridge.d.ts +2 -1
- package/dist/channel/ipc-bridge.js +33 -4
- package/dist/channel/ipc-bridge.js.map +1 -1
- package/dist/channel/mcp-server.js +28 -25
- 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/message-queue.js +62 -1
- package/dist/channel/message-queue.js.map +1 -1
- package/dist/channel/types.d.ts +31 -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 +109 -37
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +48 -7
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +4 -0
- package/dist/config.js +12 -1
- package/dist/config.js.map +1 -1
- package/dist/cross-instance-envelope.d.ts +6 -0
- package/dist/cross-instance-envelope.js +30 -0
- package/dist/cross-instance-envelope.js.map +1 -0
- package/dist/daemon.d.ts +319 -9
- package/dist/daemon.js +1402 -255
- package/dist/daemon.js.map +1 -1
- package/dist/doctor.d.ts +41 -0
- package/dist/doctor.js +267 -0
- package/dist/doctor.js.map +1 -0
- package/dist/fleet-context.d.ts +47 -0
- package/dist/fleet-manager.d.ts +265 -11
- package/dist/fleet-manager.js +2273 -455
- 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 +67 -0
- package/dist/instance-lifecycle.js +273 -8
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.d.ts +12 -0
- package/dist/instructions.js +35 -1
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +966 -288
- package/dist/locale.js.map +1 -1
- package/dist/login-flows.d.ts +91 -0
- package/dist/login-flows.js +170 -0
- package/dist/login-flows.js.map +1 -0
- package/dist/login-manager.d.ts +63 -0
- package/dist/login-manager.js +134 -0
- package/dist/login-manager.js.map +1 -0
- package/dist/network-family.d.ts +18 -0
- package/dist/network-family.js +20 -0
- package/dist/network-family.js.map +1 -0
- package/dist/outbound-handlers.d.ts +8 -0
- package/dist/outbound-handlers.js +101 -18
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +7 -1
- package/dist/outbound-schemas.js +9 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/reply-dedup.d.ts +7 -8
- package/dist/reply-dedup.js +0 -0
- package/dist/reply-dedup.js.map +1 -1
- package/dist/restart-progress.d.ts +2 -0
- package/dist/restart-progress.js +3 -0
- package/dist/restart-progress.js.map +1 -1
- package/dist/scheduler/db.d.ts +12 -0
- package/dist/scheduler/db.js +59 -0
- package/dist/scheduler/db.js.map +1 -1
- package/dist/service-installer.d.ts +11 -0
- package/dist/service-installer.js +84 -18
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.js +1 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/setup-wizard.js +2 -2
- package/dist/setup-wizard.js.map +1 -1
- package/dist/spawn-gate.d.ts +30 -0
- package/dist/spawn-gate.js +79 -0
- package/dist/spawn-gate.js.map +1 -0
- package/dist/storm-window.d.ts +83 -0
- package/dist/storm-window.js +251 -0
- package/dist/storm-window.js.map +1 -0
- package/dist/tips.d.ts +44 -0
- package/dist/tips.js +355 -0
- package/dist/tips.js.map +1 -0
- package/dist/tmux-manager.d.ts +16 -0
- package/dist/tmux-manager.js +110 -25
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tool-progress.d.ts +40 -0
- package/dist/tool-progress.js +289 -0
- package/dist/tool-progress.js.map +1 -0
- package/dist/topic-commands.d.ts +42 -4
- package/dist/topic-commands.js +361 -67
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-monitor.d.ts +15 -2
- package/dist/transcript-monitor.js +63 -17
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +131 -0
- package/dist/transcript-sources.js +580 -0
- package/dist/transcript-sources.js.map +1 -0
- package/dist/types.d.ts +13 -0
- package/dist/ui/dashboard.html +55 -32
- package/dist/ui/settings.html +200 -60
- package/dist/ui/view.html +147 -30
- package/dist/usage/format-rich.js +24 -23
- package/dist/usage/format-rich.js.map +1 -1
- package/dist/usage/i18n-keys.d.ts +7 -0
- package/dist/usage/i18n-keys.js +34 -0
- package/dist/usage/i18n-keys.js.map +1 -0
- package/dist/usage/i18n.d.ts +5 -0
- package/dist/usage/i18n.js +27 -0
- package/dist/usage/i18n.js.map +1 -0
- package/dist/usage/providers.d.ts +19 -0
- package/dist/usage/providers.js +150 -74
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.d.ts +8 -2
- package/dist/usage/usage-api.js +45 -23
- package/dist/usage/usage-api.js.map +1 -1
- package/dist/workflow-templates/default.md +2 -1
- package/package.json +2 -2
package/dist/fleet-manager.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync } from "node:fs";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { freemem } from "node:os";
|
|
3
5
|
import { createServer } from "node:http";
|
|
4
6
|
import { join, dirname, basename } from "node:path";
|
|
5
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
9
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
7
10
|
import { beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, } from "./update-marker.js";
|
|
8
11
|
import { formatUpdateProgress } from "./update-progress.js";
|
|
9
12
|
import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
|
|
10
13
|
import { readFleetMemory } from "./process-memory.js";
|
|
11
14
|
import { ReplyDeduper } from "./reply-dedup.js";
|
|
12
|
-
import { isScalar, parseDocument } from "yaml";
|
|
15
|
+
import { isMap, isScalar, parseDocument } from "yaml";
|
|
13
16
|
const __filename = fileURLToPath(import.meta.url);
|
|
14
17
|
const __dirname = dirname(__filename);
|
|
15
18
|
/** Fallback access policy for a channel with no `access:` block — open (no gate). */
|
|
@@ -30,7 +33,7 @@ import { processAttachments } from "./channel/attachment-handler.js";
|
|
|
30
33
|
import { routeToolCall } from "./channel/tool-router.js";
|
|
31
34
|
import { Scheduler } from "./scheduler/index.js";
|
|
32
35
|
import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
|
|
33
|
-
import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE,
|
|
36
|
+
import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, resolveInstanceContext, forgetInstanceContext } from "./topic-commands.js";
|
|
34
37
|
import { DailySummary } from "./daily-summary.js";
|
|
35
38
|
import { WebhookEmitter } from "./webhook-emitter.js";
|
|
36
39
|
import { TmuxControlClient } from "./tmux-control.js";
|
|
@@ -42,9 +45,11 @@ import { StatuslineWatcher } from "./statusline-watcher.js";
|
|
|
42
45
|
import { outboundHandlers } from "./outbound-handlers.js";
|
|
43
46
|
import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
|
|
44
47
|
import { handleViewRequest, isViewPath } from "./view-api.js";
|
|
45
|
-
import { handleUsageRequest, isUsagePath } from "./usage/usage-api.js";
|
|
48
|
+
import { handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
|
|
49
|
+
import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES, checkAuthStatus } from "./login-flows.js";
|
|
50
|
+
import { LoginSession } from "./login-manager.js";
|
|
46
51
|
import { handleSettingsRequest } from "./settings-api.js";
|
|
47
|
-
import { setLocale, detectLocale, t } from "./locale.js";
|
|
52
|
+
import { setLocale, detectLocale, getLocale, t } from "./locale.js";
|
|
48
53
|
import { handleAgentRequest } from "./agent-endpoint.js";
|
|
49
54
|
import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
|
|
50
55
|
import { validateFleetConfig } from "./config-validator.js";
|
|
@@ -54,6 +59,10 @@ import { releaseProcessFleetLock } from "./fleet-lock.js";
|
|
|
54
59
|
import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
|
|
55
60
|
import { loadOrCreateWebToken, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
|
|
56
61
|
import { RestartProgress } from "./restart-progress.js";
|
|
62
|
+
import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
|
|
63
|
+
import { StormWindow } from "./storm-window.js";
|
|
64
|
+
import { SpawnGate } from "./spawn-gate.js";
|
|
65
|
+
import { canUnlockAdvancedTips, DailyTipScheduler, selectTip, visibleTipLevels, } from "./tips.js";
|
|
57
66
|
import { getTmuxSession } from "./config.js";
|
|
58
67
|
export function resolveReplyThreadId(argsThreadId, instanceConfig) {
|
|
59
68
|
if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
|
|
@@ -106,6 +115,8 @@ const CANCEL_BTN_IDLE_RETIRE_GRACE_MS = 2_000;
|
|
|
106
115
|
* button alone (the idle edge retires it when the run really ends).
|
|
107
116
|
*/
|
|
108
117
|
const REPLY_RETIRE_GRACE_MS = 2 * 60_000;
|
|
118
|
+
/** Bound for the daemon to capture the pane and answer a post-reply state query. */
|
|
119
|
+
const REPLY_STATE_REFRESH_TIMEOUT_MS = 2_000;
|
|
109
120
|
/**
|
|
110
121
|
* The daemon only broadcasts execution state on TRANSITIONS, so a long
|
|
111
122
|
* single-state run sends nothing for hours. The idle backstop therefore pokes a
|
|
@@ -135,6 +146,8 @@ const CANCEL_BTN_LEDGER_FILE = "cancel-buttons.json";
|
|
|
135
146
|
* exactly one progress message per turn, and it is the cancel button itself.
|
|
136
147
|
*/
|
|
137
148
|
const PROGRESS_UPDATE_INTERVAL_MS = 60_000;
|
|
149
|
+
/** Floor between tool-progress-driven bubble edits (Telegram flood safety). */
|
|
150
|
+
const TOOL_PROGRESS_EDIT_MIN_MS = 4_000;
|
|
138
151
|
/** Elapsed time is only shown once work has clearly outlasted a quick answer. */
|
|
139
152
|
/**
|
|
140
153
|
* Default delay before the button starts showing elapsed time. Configurable via
|
|
@@ -160,6 +173,30 @@ const DELIVERY_STATUS_EMOJIS = new Set(["👀", "⏳", "✅", "❌"]);
|
|
|
160
173
|
* emoji never changes the documented delivery-state protocol.
|
|
161
174
|
*/
|
|
162
175
|
const IGNORED_REACTION_EMOJIS = new Set(["📷"]);
|
|
176
|
+
const HOT_INSTANCE_CONFIG_KEYS = new Set([
|
|
177
|
+
"tool_progress",
|
|
178
|
+
"mcp_proxy_reply",
|
|
179
|
+
"auto_pause_after",
|
|
180
|
+
"warm_cap",
|
|
181
|
+
"display_name",
|
|
182
|
+
"description",
|
|
183
|
+
"tags",
|
|
184
|
+
"log_level",
|
|
185
|
+
]);
|
|
186
|
+
function splitHotColdConfig(config) {
|
|
187
|
+
const hot = {};
|
|
188
|
+
const cold = {};
|
|
189
|
+
for (const [key, value] of Object.entries(config)) {
|
|
190
|
+
(HOT_INSTANCE_CONFIG_KEYS.has(key) ? hot : cold)[key] = value;
|
|
191
|
+
}
|
|
192
|
+
return { hot, cold };
|
|
193
|
+
}
|
|
194
|
+
function hotConfigUpdate(config) {
|
|
195
|
+
const update = {};
|
|
196
|
+
for (const key of HOT_INSTANCE_CONFIG_KEYS)
|
|
197
|
+
update[key] = config[key] ?? null;
|
|
198
|
+
return update;
|
|
199
|
+
}
|
|
163
200
|
/**
|
|
164
201
|
* How long a delivery waits out a disconnected instance IPC before giving up.
|
|
165
202
|
*
|
|
@@ -175,8 +212,17 @@ const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
|
|
|
175
212
|
const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
|
|
176
213
|
const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
|
|
177
214
|
const HANG_CALLBACK_PREFIX = "hang:";
|
|
178
|
-
|
|
215
|
+
const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
|
|
216
|
+
const TIP_DISMISS_CALLBACK_PREFIX = "tip-dismiss:";
|
|
217
|
+
const TIP_UNLOCK_CALLBACK_PREFIX = "tip-unlock:";
|
|
218
|
+
const LOGIN_CALLBACK_PREFIX = "login:";
|
|
219
|
+
const LOGIN_MENU_CALLBACK_PREFIX = "login-menu:";
|
|
220
|
+
const LOGIN_CONFIRM_CALLBACK_PREFIX = "login-confirm:";
|
|
221
|
+
const INSTALL_LOGIN_CALLBACK_PREFIX = "install-login:";
|
|
222
|
+
const CLEAR_CONFIRM_TIMEOUT_MS = 15_000;
|
|
223
|
+
/** Default lifetime for long-lived nonce prompts (clear overrides this to 15s). */
|
|
179
224
|
const NONCE_BUTTON_TIMEOUT_MS = 15 * 60_000;
|
|
225
|
+
const TIP_BUTTON_TIMEOUT_MS = 24 * 60 * 60_000;
|
|
180
226
|
const CLI_ENV_TTL_MS = 24 * 60 * 60 * 1000; // /model reads cached CLI env within 24h
|
|
181
227
|
export class FleetManager {
|
|
182
228
|
dataDir;
|
|
@@ -184,6 +230,8 @@ export class FleetManager {
|
|
|
184
230
|
static sighupHandlerInstalled = false;
|
|
185
231
|
children = new Map();
|
|
186
232
|
lifecycle;
|
|
233
|
+
stormWindow;
|
|
234
|
+
spawnGate;
|
|
187
235
|
/** Live view of lifecycle.daemons — used throughout; not deprecated. */
|
|
188
236
|
get daemons() { return this.lifecycle.daemons; }
|
|
189
237
|
fleetConfig = null;
|
|
@@ -223,6 +271,7 @@ export class FleetManager {
|
|
|
223
271
|
costGuard = null;
|
|
224
272
|
statuslineWatcher;
|
|
225
273
|
dailySummary = null;
|
|
274
|
+
dailyTipScheduler = null;
|
|
226
275
|
webhookEmitter = null;
|
|
227
276
|
// Topic icon + auto-archive state
|
|
228
277
|
topicIcons = {};
|
|
@@ -235,6 +284,13 @@ export class FleetManager {
|
|
|
235
284
|
warmCapEvicting = new Set();
|
|
236
285
|
/** Per-instance tail keeps cross-instance and scheduled deliveries FIFO. */
|
|
237
286
|
idleGatedDeliveryTails = new Map();
|
|
287
|
+
/**
|
|
288
|
+
* Per-instance cancellation epoch for deliveries which have not reached the
|
|
289
|
+
* daemon yet. A cancel advances the epoch; queued work captures the old value
|
|
290
|
+
* and becomes a no-op, while messages arriving after the click capture the new
|
|
291
|
+
* value and remain deliverable.
|
|
292
|
+
*/
|
|
293
|
+
deliveryEpochs = new Map();
|
|
238
294
|
/** Non-user work must observe a fresh idle snapshot after the latest delivery. */
|
|
239
295
|
lastDeliveryAt = new Map();
|
|
240
296
|
/** State-cache updates wake event-driven idle waiters without busy polling. */
|
|
@@ -251,6 +307,8 @@ export class FleetManager {
|
|
|
251
307
|
replyDeduper = new ReplyDeduper();
|
|
252
308
|
/** instanceName → what it is doing right now, when the backend can tell us. */
|
|
253
309
|
instanceActivity = new Map();
|
|
310
|
+
/** instanceName → this turn's tool list (multi-line), for the bubble. */
|
|
311
|
+
instanceProgress = new Map();
|
|
254
312
|
/** instanceName → tail of deliveries waiting for its IPC to come back. */
|
|
255
313
|
ipcWaitTails = new Map();
|
|
256
314
|
/** instanceName → restart currently executing; concurrent callers join it. */
|
|
@@ -291,12 +349,14 @@ export class FleetManager {
|
|
|
291
349
|
updateProgressTimer = null;
|
|
292
350
|
updateProgressEditRunning = false;
|
|
293
351
|
lastUpdateProgressText = null;
|
|
352
|
+
updateCompletionTipText = null;
|
|
294
353
|
eventLogPruneTimer = null;
|
|
295
354
|
logRotateTimer = null;
|
|
296
355
|
/** Days of event/activity history to keep. */
|
|
297
356
|
static EVENT_LOG_RETENTION_DAYS = 30;
|
|
298
357
|
watchdogTimer = null;
|
|
299
358
|
startedAt = 0;
|
|
359
|
+
stormOpenNotifyTimer = null;
|
|
300
360
|
// Mirror topic: buffer cross-instance messages, flush every 3s
|
|
301
361
|
mirrorBuffer = [];
|
|
302
362
|
mirrorTimer = null;
|
|
@@ -312,11 +372,63 @@ export class FleetManager {
|
|
|
312
372
|
process.on("SIGHUP", () => FleetManager.signalTarget?.handleSighup());
|
|
313
373
|
FleetManager.sighupHandlerInstalled = true;
|
|
314
374
|
}
|
|
375
|
+
this.stormWindow = new StormWindow();
|
|
376
|
+
this.spawnGate = new SpawnGate({
|
|
377
|
+
storm: this.stormWindow,
|
|
378
|
+
concurrency: () => this.spawnConcurrency(),
|
|
379
|
+
staggerMs: () => this.fleetConfig?.defaults?.startup?.stagger_delay_ms ?? 500,
|
|
380
|
+
});
|
|
381
|
+
this.bindStormWindowEvents();
|
|
315
382
|
this.lifecycle = new InstanceLifecycle(this);
|
|
316
383
|
this.topicCommands = new TopicCommands(this);
|
|
317
384
|
this.topicArchiver = new TopicArchiver(this);
|
|
318
385
|
this.statuslineWatcher = new StatuslineWatcher(this);
|
|
319
386
|
}
|
|
387
|
+
spawnConcurrency() {
|
|
388
|
+
const explicit = this.fleetConfig?.defaults?.startup?.concurrency;
|
|
389
|
+
if (explicit != null)
|
|
390
|
+
return Math.max(1, Math.min(20, explicit));
|
|
391
|
+
const freeMemMB = Math.round(freemem() / (1024 * 1024));
|
|
392
|
+
return Math.max(2, Math.min(10, Math.floor(freeMemMB / 300)));
|
|
393
|
+
}
|
|
394
|
+
/** Wire the one fleet-wide storm into notification and recovery surfaces. */
|
|
395
|
+
bindStormWindowEvents() {
|
|
396
|
+
this.stormWindow.on("opened", (snapshot) => {
|
|
397
|
+
for (const [name, daemon] of this.daemons) {
|
|
398
|
+
if (!daemon.isPaused)
|
|
399
|
+
this.stormWindow.addAffected(name);
|
|
400
|
+
}
|
|
401
|
+
this.logger.error({ ...snapshot }, "tmux server storm opened — holding respawns and delivery");
|
|
402
|
+
if (this.stormOpenNotifyTimer)
|
|
403
|
+
clearTimeout(this.stormOpenNotifyTimer);
|
|
404
|
+
this.stormOpenNotifyTimer = setTimeout(() => {
|
|
405
|
+
this.stormOpenNotifyTimer = null;
|
|
406
|
+
const current = this.stormWindow.snapshot();
|
|
407
|
+
this.notifyFleetError(t("storm.opened", current.affected.length, this.formatStormDelay(current.backoffMs)));
|
|
408
|
+
}, 1_000);
|
|
409
|
+
this.stormOpenNotifyTimer.unref?.();
|
|
410
|
+
});
|
|
411
|
+
this.stormWindow.on("extended", (snapshot) => {
|
|
412
|
+
this.logger.error({ ...snapshot }, "tmux server storm repeated — backoff extended");
|
|
413
|
+
this.notifyFleetError(t("storm.extended", snapshot.crashCount, this.formatStormDelay(snapshot.backoffMs)));
|
|
414
|
+
});
|
|
415
|
+
this.stormWindow.on("recovery_due", (snapshot) => {
|
|
416
|
+
this.logger.warn({ ...snapshot }, "tmux storm backoff elapsed — rolling recovery started");
|
|
417
|
+
});
|
|
418
|
+
this.stormWindow.on("closed", (snapshot, reason) => {
|
|
419
|
+
const unresolved = snapshot.affected.filter(name => !snapshot.recovered.includes(name));
|
|
420
|
+
this.logger.info({ ...snapshot, reason, unresolved }, "tmux server storm closed");
|
|
421
|
+
this.notifyFleetError(t(reason === "timeout" ? "storm.timed_out" : "storm.recovered", snapshot.recovered.length, snapshot.affected.length, unresolved.length > 0 ? unresolved.join(", ") : t("storm.none")));
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
formatStormDelay(ms) {
|
|
425
|
+
if (ms >= 60_000)
|
|
426
|
+
return `${Math.round(ms / 60_000)}m`;
|
|
427
|
+
return `${Math.round(ms / 1_000)}s`;
|
|
428
|
+
}
|
|
429
|
+
stormSuppressed(kind) {
|
|
430
|
+
return this.stormWindow.shouldSuppress(kind);
|
|
431
|
+
}
|
|
320
432
|
handleSighup() {
|
|
321
433
|
this.logger.info("Received SIGHUP, hot-reloading config...");
|
|
322
434
|
if (!this.startupComplete) {
|
|
@@ -339,7 +451,7 @@ export class FleetManager {
|
|
|
339
451
|
// fleet.yaml, sent SIGHUP, and got no reaction and no explanation.
|
|
340
452
|
this.logger.error({ err }, "SIGHUP config reload failed");
|
|
341
453
|
const message = err instanceof Error ? err.message : String(err);
|
|
342
|
-
this.notifyFleetError(
|
|
454
|
+
this.notifyFleetError(t("fleet.reload_failed", message));
|
|
343
455
|
})
|
|
344
456
|
.finally(() => {
|
|
345
457
|
this.reconcileInFlight = null;
|
|
@@ -589,6 +701,57 @@ export class FleetManager {
|
|
|
589
701
|
child.once("error", err => this.failUpdateProgress(err.message));
|
|
590
702
|
child.unref();
|
|
591
703
|
}
|
|
704
|
+
async handleTipsSlash(data, adapterId) {
|
|
705
|
+
if (!this.fleetConfig)
|
|
706
|
+
return;
|
|
707
|
+
const mode = typeof data.options?.mode === "string" ? data.options.mode : "";
|
|
708
|
+
if (mode === "advanced on") {
|
|
709
|
+
if (!this.isFleetAdmin(data.userId, adapterId)) {
|
|
710
|
+
await data.respond(t("permission.denied"));
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
await data.respond(this.unlockAdvancedTips(data.userId)
|
|
714
|
+
? t("tips.advanced.unlocked")
|
|
715
|
+
: t("tips.unavailable"));
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (mode === "on" || mode === "off") {
|
|
719
|
+
if (!this.isFleetAdmin(data.userId, adapterId)) {
|
|
720
|
+
await data.respond(t("permission.denied"));
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
this.fleetConfig.defaults.tips = mode === "on";
|
|
724
|
+
this.saveFleetConfig();
|
|
725
|
+
await data.respond(t(mode === "on" ? "tips.enabled" : "tips.disabled"));
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
const adapter = this.adapters.get(adapterId) ?? this.adapter;
|
|
729
|
+
if (!adapter) {
|
|
730
|
+
await data.respond(t("tips.unavailable"));
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
const targetName = this.resolveSlashTarget(data.channelId, adapterId)
|
|
734
|
+
?? this.findGeneralInstance(adapterId)
|
|
735
|
+
?? "general";
|
|
736
|
+
// Discord slash interactions are deferred ephemerally, but the Tip itself
|
|
737
|
+
// belongs in the channel where /tips was invoked. Post there directly,
|
|
738
|
+
// then delete the empty acknowledgement instead of leaving a slash corpse.
|
|
739
|
+
//
|
|
740
|
+
// The callback binding uses (chatId, threadId) that match what the adapter
|
|
741
|
+
// emits on callback_query. Discord emits (guildId, channelId); Telegram
|
|
742
|
+
// emits (supergroup chatId, topic threadId). For /tips invoked in an
|
|
743
|
+
// arbitrary channel, pass the canonical group id as chatId and the
|
|
744
|
+
// invocation channel as threadId, exactly as sendTipToGeneral() does.
|
|
745
|
+
const chatId = this.getGroupIdForInstance(targetName) || data.channelId;
|
|
746
|
+
const result = await this.promptTip(targetName, adapter, chatId, data.channelId);
|
|
747
|
+
if (result === "posted" && data.dismissResponse) {
|
|
748
|
+
await data.dismissResponse();
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
await data.respond(t(result === "posted"
|
|
752
|
+
? "tips.posted"
|
|
753
|
+
: result === "empty" ? "tips.empty" : "tips.unavailable"));
|
|
754
|
+
}
|
|
592
755
|
/** Admin-only full conversation reset for fleet-topic and Classic instances. */
|
|
593
756
|
async handleClearSlash(data, adapterId) {
|
|
594
757
|
if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
|
|
@@ -600,7 +763,14 @@ export class FleetManager {
|
|
|
600
763
|
await data.respond(t("classic.no_agent"));
|
|
601
764
|
return;
|
|
602
765
|
}
|
|
603
|
-
|
|
766
|
+
const adapter = this.adapters.get(adapterId) ?? this.adapter;
|
|
767
|
+
if (!adapter) {
|
|
768
|
+
await data.respond(t("clear.prompt_unavailable"));
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const chatId = String(this.getChannelConfig(adapterId)?.group_id ?? data.channelId);
|
|
772
|
+
const fallback = await this.promptClearConfirmation(name, data.channelId, adapter, chatId, data.channelId);
|
|
773
|
+
await data.respond(fallback ?? t("clear.confirm_posted"));
|
|
604
774
|
}
|
|
605
775
|
/** Get the adapter bound to an instance, falling back to primary adapter */
|
|
606
776
|
getAdapterForInstance(name) {
|
|
@@ -734,6 +904,39 @@ export class FleetManager {
|
|
|
734
904
|
return null;
|
|
735
905
|
return this.instanceStateCache.get(name)?.state ?? null;
|
|
736
906
|
}
|
|
907
|
+
/**
|
|
908
|
+
* Subscription providers currently relevant to this fleet. Stopped/crashed
|
|
909
|
+
* rows do not keep a usage card alive, while persisted paused instances do.
|
|
910
|
+
* Classic channels use their effective backend merge chain, not the raw row.
|
|
911
|
+
*/
|
|
912
|
+
getActiveUsageProviderIds() {
|
|
913
|
+
const providers = new Set();
|
|
914
|
+
for (const backend of this.getActiveBackendIds()) {
|
|
915
|
+
const provider = usageProviderIdForBackend(backend);
|
|
916
|
+
if (provider)
|
|
917
|
+
providers.add(provider);
|
|
918
|
+
}
|
|
919
|
+
return providers;
|
|
920
|
+
}
|
|
921
|
+
/** Effective backends with a running or persisted-paused fleet/Classic instance. */
|
|
922
|
+
getActiveBackendIds() {
|
|
923
|
+
const backends = new Set();
|
|
924
|
+
const add = (name, backend) => {
|
|
925
|
+
const status = this.getInstanceStatus(name);
|
|
926
|
+
if (status !== "running" && status !== "paused")
|
|
927
|
+
return;
|
|
928
|
+
if (backend)
|
|
929
|
+
backends.add(backend);
|
|
930
|
+
};
|
|
931
|
+
for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
|
|
932
|
+
// loadFleetConfig() has already merged the fleet default into each row.
|
|
933
|
+
add(name, config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
|
|
934
|
+
}
|
|
935
|
+
for (const channel of this.classicChannels?.getAll() ?? []) {
|
|
936
|
+
add(channel.instanceName, this.classicChannels?.getBackendByInstance(channel.instanceName, this.fleetConfig?.defaults?.backend));
|
|
937
|
+
}
|
|
938
|
+
return backends;
|
|
939
|
+
}
|
|
737
940
|
isClassicInstance(name) {
|
|
738
941
|
return this.classicChannels?.getAll().some(channel => channel.instanceName === name) ?? false;
|
|
739
942
|
}
|
|
@@ -846,7 +1049,7 @@ export class FleetManager {
|
|
|
846
1049
|
.finally(() => this.warmCapEvicting.delete(victim));
|
|
847
1050
|
}
|
|
848
1051
|
}
|
|
849
|
-
waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter = 0) {
|
|
1052
|
+
waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter = 0, cancelled) {
|
|
850
1053
|
const isReady = () => {
|
|
851
1054
|
const snapshot = this.instanceStateCache.get(instanceName);
|
|
852
1055
|
return snapshot?.state === "idle"
|
|
@@ -868,8 +1071,12 @@ export class FleetManager {
|
|
|
868
1071
|
this.instanceIdleWaiters.delete(instanceName);
|
|
869
1072
|
resolve(idle);
|
|
870
1073
|
};
|
|
871
|
-
const check = () => {
|
|
872
|
-
|
|
1074
|
+
const check = () => {
|
|
1075
|
+
if (cancelled?.())
|
|
1076
|
+
finish(false);
|
|
1077
|
+
else if (isReady())
|
|
1078
|
+
finish(true);
|
|
1079
|
+
};
|
|
873
1080
|
const query = () => {
|
|
874
1081
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
875
1082
|
if (ipc?.connected) {
|
|
@@ -885,24 +1092,90 @@ export class FleetManager {
|
|
|
885
1092
|
query();
|
|
886
1093
|
});
|
|
887
1094
|
}
|
|
888
|
-
|
|
1095
|
+
/**
|
|
1096
|
+
* Ask the daemon to capture the pane before answering, then wait for any state
|
|
1097
|
+
* report produced after this request. The ordinary query is intentionally
|
|
1098
|
+
* cache-only (idle gates call it frequently); this opt-in refresh is reserved
|
|
1099
|
+
* for lifecycle decisions where a stale state would strand UI.
|
|
1100
|
+
*/
|
|
1101
|
+
refreshInstanceExecutionState(instanceName, timeoutMs) {
|
|
1102
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
1103
|
+
if (!ipc?.connected)
|
|
1104
|
+
return Promise.resolve(false);
|
|
1105
|
+
const previous = this.instanceStateCache.get(instanceName);
|
|
1106
|
+
return new Promise(resolve => {
|
|
1107
|
+
let settled = false;
|
|
1108
|
+
const finish = (refreshed) => {
|
|
1109
|
+
if (settled)
|
|
1110
|
+
return;
|
|
1111
|
+
settled = true;
|
|
1112
|
+
clearTimeout(timeout);
|
|
1113
|
+
const waiters = this.instanceIdleWaiters.get(instanceName);
|
|
1114
|
+
waiters?.delete(check);
|
|
1115
|
+
if (waiters?.size === 0)
|
|
1116
|
+
this.instanceIdleWaiters.delete(instanceName);
|
|
1117
|
+
resolve(refreshed);
|
|
1118
|
+
};
|
|
1119
|
+
const check = () => {
|
|
1120
|
+
if (this.instanceStateCache.get(instanceName) !== previous)
|
|
1121
|
+
finish(true);
|
|
1122
|
+
};
|
|
1123
|
+
const waiters = this.instanceIdleWaiters.get(instanceName) ?? new Set();
|
|
1124
|
+
waiters.add(check);
|
|
1125
|
+
this.instanceIdleWaiters.set(instanceName, waiters);
|
|
1126
|
+
const timeout = setTimeout(() => finish(false), timeoutMs);
|
|
1127
|
+
timeout.unref?.();
|
|
1128
|
+
const sent = ipc.send({
|
|
1129
|
+
type: "query_instance_state",
|
|
1130
|
+
requestId: `reply-grace-${Date.now()}`,
|
|
1131
|
+
refresh: true,
|
|
1132
|
+
});
|
|
1133
|
+
if (!sent)
|
|
1134
|
+
finish(false);
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch) {
|
|
1138
|
+
if (this.shuttingDown || !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1139
|
+
return;
|
|
1140
|
+
await this.holdDeliveryForStorm(instanceName, deliveryEpoch);
|
|
1141
|
+
if (this.shuttingDown || !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1142
|
+
return;
|
|
889
1143
|
let idleObservedAfter = this.lastDeliveryAt.get(instanceName) ?? 0;
|
|
890
1144
|
if (this.lifecycle.isPaused(instanceName)) {
|
|
891
1145
|
const wakeStartedAt = Date.now();
|
|
892
1146
|
await this.lifecycle.wake(instanceName, 30_000);
|
|
1147
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1148
|
+
return;
|
|
893
1149
|
// Waking added one to the warm count — make room by evicting a different
|
|
894
1150
|
// LRU idle instance (never this one; it's about to work).
|
|
895
1151
|
this.enforceWarmCap(instanceName);
|
|
896
1152
|
// Never satisfy a post-wake gate from a stale pre-pause cache entry.
|
|
897
1153
|
idleObservedAfter = Math.max(idleObservedAfter, wakeStartedAt);
|
|
898
1154
|
}
|
|
899
|
-
const idle = await this.waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter);
|
|
1155
|
+
const idle = await this.waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter, () => !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch));
|
|
1156
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch)) {
|
|
1157
|
+
this.logger.info({ instanceName }, "Pending delivery dropped by user cancel");
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
// A server crash can land while waitForInstanceIdle is pending. Re-check
|
|
1161
|
+
// immediately before the old timeout path would force text into a boot UI.
|
|
1162
|
+
await this.holdDeliveryForStorm(instanceName, deliveryEpoch);
|
|
1163
|
+
if (this.shuttingDown || !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1164
|
+
return;
|
|
900
1165
|
if (!idle) {
|
|
901
1166
|
this.logger.warn({ instanceName, timeoutMs }, "Idle gate timed out; forcing delivery");
|
|
902
1167
|
}
|
|
903
|
-
await this.sendWhenConnected(instanceName, payload);
|
|
1168
|
+
await this.sendWhenConnected(instanceName, payload, deliveryEpoch);
|
|
1169
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1170
|
+
return;
|
|
904
1171
|
this.lastDeliveryAt.set(instanceName, Date.now());
|
|
905
1172
|
}
|
|
1173
|
+
async holdDeliveryForStorm(instanceName, deliveryEpoch) {
|
|
1174
|
+
if (!this.stormWindow.isDeliveryHeld(instanceName))
|
|
1175
|
+
return;
|
|
1176
|
+
this.logger.warn({ instanceName, deliveryEpoch }, "Delivery held during tmux server recovery");
|
|
1177
|
+
await this.stormWindow.waitForDeliveryAllowed(instanceName);
|
|
1178
|
+
}
|
|
906
1179
|
/**
|
|
907
1180
|
* Hand a payload to an instance's IPC, waiting out a *transient* disconnect.
|
|
908
1181
|
*
|
|
@@ -919,7 +1192,9 @@ export class FleetManager {
|
|
|
919
1192
|
* instance, *including* when the socket happens to be up: otherwise a message
|
|
920
1193
|
* arriving after the reconnect could overtake one that has been waiting for it.
|
|
921
1194
|
*/
|
|
922
|
-
async sendWhenConnected(instanceName, payload) {
|
|
1195
|
+
async sendWhenConnected(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName)) {
|
|
1196
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1197
|
+
return;
|
|
923
1198
|
const queued = this.ipcWaitTails.get(instanceName);
|
|
924
1199
|
if (!queued) {
|
|
925
1200
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
@@ -928,7 +1203,11 @@ export class FleetManager {
|
|
|
928
1203
|
}
|
|
929
1204
|
const attempt = (queued ?? Promise.resolve())
|
|
930
1205
|
.catch(() => { })
|
|
931
|
-
.then(() =>
|
|
1206
|
+
.then(() => {
|
|
1207
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1208
|
+
return;
|
|
1209
|
+
return this.sendAfterIpcReturns(instanceName, payload, deliveryEpoch);
|
|
1210
|
+
});
|
|
932
1211
|
// The chain stores a settled-either-way promise so one failed delivery cannot
|
|
933
1212
|
// wedge every later one, and so `queued` above is safe to await unguarded.
|
|
934
1213
|
const tail = attempt.catch(() => { });
|
|
@@ -945,10 +1224,12 @@ export class FleetManager {
|
|
|
945
1224
|
}
|
|
946
1225
|
}
|
|
947
1226
|
/** Poll for the instance's IPC to come back, then send. Throws if it does not. */
|
|
948
|
-
async sendAfterIpcReturns(instanceName, payload) {
|
|
1227
|
+
async sendAfterIpcReturns(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName)) {
|
|
949
1228
|
const deadline = Date.now() + IPC_RECONNECT_GRACE_MS;
|
|
950
1229
|
let warned = false;
|
|
951
1230
|
for (;;) {
|
|
1231
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1232
|
+
return;
|
|
952
1233
|
// Re-read every round: a reconnect replaces the IpcClient object entirely,
|
|
953
1234
|
// so a cached reference would stay dead forever.
|
|
954
1235
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
@@ -966,6 +1247,8 @@ export class FleetManager {
|
|
|
966
1247
|
}
|
|
967
1248
|
/** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
|
|
968
1249
|
async deliverToInstance(instanceName, payload, options = {}) {
|
|
1250
|
+
const deliveryEpoch = this.getDeliveryEpoch(instanceName);
|
|
1251
|
+
const deliveryPayload = { ...payload, delivery_epoch: deliveryEpoch };
|
|
969
1252
|
const meta = payload.meta && typeof payload.meta === "object"
|
|
970
1253
|
? payload.meta
|
|
971
1254
|
: undefined;
|
|
@@ -977,16 +1260,20 @@ export class FleetManager {
|
|
|
977
1260
|
if (!waitForIdle) {
|
|
978
1261
|
if (this.lifecycle.isPaused(instanceName)) {
|
|
979
1262
|
await this.lifecycle.wake(instanceName, 30_000);
|
|
1263
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1264
|
+
return;
|
|
980
1265
|
this.enforceWarmCap(instanceName); // woke one → evict a different LRU idle if over cap
|
|
981
1266
|
}
|
|
982
|
-
await this.sendWhenConnected(instanceName,
|
|
1267
|
+
await this.sendWhenConnected(instanceName, deliveryPayload, deliveryEpoch);
|
|
1268
|
+
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1269
|
+
return;
|
|
983
1270
|
// A cross-instance item arriving before the daemon observes this turn as
|
|
984
1271
|
// working must not trust the stale idle snapshot from before the send.
|
|
985
1272
|
this.lastDeliveryAt.set(instanceName, Date.now());
|
|
986
1273
|
return;
|
|
987
1274
|
}
|
|
988
1275
|
const previous = this.idleGatedDeliveryTails.get(instanceName) ?? Promise.resolve();
|
|
989
|
-
const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName,
|
|
1276
|
+
const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch));
|
|
990
1277
|
this.idleGatedDeliveryTails.set(instanceName, delivery);
|
|
991
1278
|
try {
|
|
992
1279
|
await delivery;
|
|
@@ -1065,19 +1352,28 @@ export class FleetManager {
|
|
|
1065
1352
|
this.logger.info({ name }, "Instance already running, skipping");
|
|
1066
1353
|
return;
|
|
1067
1354
|
}
|
|
1355
|
+
const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
1068
1356
|
if (config.general_topic) {
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1357
|
+
this.ensureGeneralInstructions(config.working_directory, backend, name);
|
|
1358
|
+
}
|
|
1359
|
+
else if (kind === "fleet-topic") {
|
|
1360
|
+
// Workers get only role-eligible on-demand skills. Classic instances are
|
|
1361
|
+
// deliberately excluded: their workspace and conversation lifecycle are
|
|
1362
|
+
// managed independently from fleet-topic workers.
|
|
1363
|
+
try {
|
|
1364
|
+
const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
|
|
1365
|
+
this.syncRoleSkills(skillsWorkDir, backend, "worker");
|
|
1366
|
+
}
|
|
1367
|
+
catch (err) {
|
|
1368
|
+
// Skill publishing is additive. A read-only or temporarily unavailable
|
|
1369
|
+
// workspace must not turn an otherwise valid worker startup into a
|
|
1370
|
+
// fleet outage.
|
|
1371
|
+
this.logger.warn({ err, name, backend }, "Failed to sync worker skills — continuing startup");
|
|
1075
1372
|
}
|
|
1076
|
-
this.ensureGeneralInstructions(config.working_directory, config.backend);
|
|
1077
1373
|
}
|
|
1078
1374
|
await this.lifecycle.start(name, config, topicMode, {
|
|
1079
1375
|
kind,
|
|
1080
|
-
backend
|
|
1376
|
+
backend,
|
|
1081
1377
|
model: this.resolveInstanceModel(name).display,
|
|
1082
1378
|
});
|
|
1083
1379
|
// Only clear a stale process status after a real start succeeded. Clearing
|
|
@@ -1123,86 +1419,27 @@ export class FleetManager {
|
|
|
1123
1419
|
}
|
|
1124
1420
|
if (runnableEntries.length === 0)
|
|
1125
1421
|
return;
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
const byWorkDir = new Map();
|
|
1143
|
-
for (const [name, config] of runnableEntries) {
|
|
1144
|
-
const dir = config.working_directory;
|
|
1145
|
-
if (!byWorkDir.has(dir))
|
|
1146
|
-
byWorkDir.set(dir, []);
|
|
1147
|
-
byWorkDir.get(dir).push([name, config]);
|
|
1148
|
-
}
|
|
1149
|
-
const groups = [...byWorkDir.values()];
|
|
1150
|
-
let running = 0;
|
|
1151
|
-
let idx = 0;
|
|
1152
|
-
let lastStartAt = 0;
|
|
1153
|
-
let pendingTimer = false;
|
|
1154
|
-
await new Promise((resolve) => {
|
|
1155
|
-
if (groups.length === 0) {
|
|
1156
|
-
resolve();
|
|
1157
|
-
return;
|
|
1422
|
+
if (this.fleetConfig?.defaults?.startup?.concurrency == null) {
|
|
1423
|
+
this.logger.info({
|
|
1424
|
+
concurrency: this.spawnConcurrency(),
|
|
1425
|
+
freeMemMB: Math.round(freemem() / (1024 * 1024)),
|
|
1426
|
+
totalInstances: runnableEntries.length,
|
|
1427
|
+
}, "Adaptive startup concurrency");
|
|
1428
|
+
}
|
|
1429
|
+
await Promise.all(runnableEntries.map(([name, config]) => this.spawnGate.run({
|
|
1430
|
+
instanceName: name,
|
|
1431
|
+
workingDirectory: config.working_directory,
|
|
1432
|
+
reason: "startup",
|
|
1433
|
+
}, async () => {
|
|
1434
|
+
try {
|
|
1435
|
+
await this.startInstance(name, config, topicMode);
|
|
1436
|
+
if (this.daemons.has(name))
|
|
1437
|
+
onReady?.(name);
|
|
1158
1438
|
}
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
// Re-check memory if adaptive (no explicit concurrency set)
|
|
1164
|
-
if (explicitConcurrency == null && running > 0) {
|
|
1165
|
-
const nowFreeMB = Math.round(freemem() / (1024 * 1024));
|
|
1166
|
-
if (nowFreeMB < ESTIMATED_MB_PER_INSTANCE) {
|
|
1167
|
-
this.logger.warn({ freeMemMB: nowFreeMB, remaining: groups.length - idx }, "Low memory — pausing instance startup");
|
|
1168
|
-
// Wait and retry in 5s
|
|
1169
|
-
pendingTimer = true;
|
|
1170
|
-
setTimeout(() => { pendingTimer = false; startNext(); }, 5000);
|
|
1171
|
-
return;
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
const now = Date.now();
|
|
1175
|
-
const elapsed = now - lastStartAt;
|
|
1176
|
-
if (lastStartAt > 0 && elapsed < staggerMs) {
|
|
1177
|
-
pendingTimer = true;
|
|
1178
|
-
setTimeout(() => { pendingTimer = false; startNext(); }, staggerMs - elapsed);
|
|
1179
|
-
return;
|
|
1180
|
-
}
|
|
1181
|
-
const group = groups[idx++];
|
|
1182
|
-
running++;
|
|
1183
|
-
lastStartAt = Date.now();
|
|
1184
|
-
(async () => {
|
|
1185
|
-
for (const [name, config] of group) {
|
|
1186
|
-
try {
|
|
1187
|
-
await this.startInstance(name, config, topicMode);
|
|
1188
|
-
if (this.daemons.has(name))
|
|
1189
|
-
onReady?.(name);
|
|
1190
|
-
}
|
|
1191
|
-
catch (err) {
|
|
1192
|
-
this.logger.error({ err, name }, "Failed to start instance");
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
})().finally(() => {
|
|
1196
|
-
running--;
|
|
1197
|
-
if (idx >= groups.length && running === 0)
|
|
1198
|
-
resolve();
|
|
1199
|
-
else
|
|
1200
|
-
startNext();
|
|
1201
|
-
});
|
|
1202
|
-
}
|
|
1203
|
-
};
|
|
1204
|
-
startNext();
|
|
1205
|
-
});
|
|
1439
|
+
catch (err) {
|
|
1440
|
+
this.logger.error({ err, name }, "Failed to start instance");
|
|
1441
|
+
}
|
|
1442
|
+
})));
|
|
1206
1443
|
}
|
|
1207
1444
|
runnableStartupCount(fleet, includeClassic) {
|
|
1208
1445
|
const names = this.configuredStartupInstanceNames(fleet, includeClassic);
|
|
@@ -1266,7 +1503,12 @@ export class FleetManager {
|
|
|
1266
1503
|
this.logger.info({ name }, "restartSingleInstance: joining the restart already in flight");
|
|
1267
1504
|
return inFlight;
|
|
1268
1505
|
}
|
|
1269
|
-
const
|
|
1506
|
+
const workingDirectory = this.fleetConfig?.instances[name]?.working_directory || this.getInstanceDir(name);
|
|
1507
|
+
const run = this.spawnGate.run({
|
|
1508
|
+
instanceName: name,
|
|
1509
|
+
workingDirectory,
|
|
1510
|
+
reason: "restart",
|
|
1511
|
+
}, () => this.doRestartSingleInstance(name, opts))
|
|
1270
1512
|
.finally(() => this.restartsInFlight.delete(name));
|
|
1271
1513
|
this.restartsInFlight.set(name, run);
|
|
1272
1514
|
return run;
|
|
@@ -1284,6 +1526,7 @@ export class FleetManager {
|
|
|
1284
1526
|
this.writeFreshStartMarker(name);
|
|
1285
1527
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
1286
1528
|
await this.startInstance(name, config, topicMode ?? false);
|
|
1529
|
+
this.stormWindow.markRecovered(name);
|
|
1287
1530
|
return;
|
|
1288
1531
|
}
|
|
1289
1532
|
// Classic instance fallback
|
|
@@ -1296,6 +1539,7 @@ export class FleetManager {
|
|
|
1296
1539
|
if (opts?.freshStart)
|
|
1297
1540
|
this.writeFreshStartMarker(name);
|
|
1298
1541
|
await this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, fleetBackend), this.classicChannels.getPreTaskCommand(channelId, adapterId), this.classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channelId, adapterId, this.fleetConfig?.defaults?.auto_pause_after));
|
|
1542
|
+
this.stormWindow.markRecovered(name);
|
|
1299
1543
|
return;
|
|
1300
1544
|
}
|
|
1301
1545
|
throw new Error(`Instance not found: ${name}`);
|
|
@@ -1361,6 +1605,7 @@ export class FleetManager {
|
|
|
1361
1605
|
messageId,
|
|
1362
1606
|
});
|
|
1363
1607
|
this.lastUpdateProgressText = null;
|
|
1608
|
+
this.updateCompletionTipText = null;
|
|
1364
1609
|
this.startUpdateProgressMonitor(adapter);
|
|
1365
1610
|
}
|
|
1366
1611
|
failUpdateProgress(message) {
|
|
@@ -1384,7 +1629,17 @@ export class FleetManager {
|
|
|
1384
1629
|
const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
|
|
1385
1630
|
if (!adapter)
|
|
1386
1631
|
return;
|
|
1387
|
-
|
|
1632
|
+
let text = formatUpdateProgress(marker);
|
|
1633
|
+
// No-restart updates complete in the old fleet process, so they never
|
|
1634
|
+
// reach RestartProgress.finish(). Append the same optional tip here.
|
|
1635
|
+
if (marker.progress.stage === "complete" && this.tipsEnabled()) {
|
|
1636
|
+
if (this.updateCompletionTipText === null) {
|
|
1637
|
+
const tip = this.pickAvailableTip();
|
|
1638
|
+
this.updateCompletionTipText = tip ? this.formatTip(tip) : "";
|
|
1639
|
+
}
|
|
1640
|
+
if (this.updateCompletionTipText)
|
|
1641
|
+
text += `\n\n${this.updateCompletionTipText}`;
|
|
1642
|
+
}
|
|
1388
1643
|
if (text === this.lastUpdateProgressText)
|
|
1389
1644
|
return;
|
|
1390
1645
|
this.updateProgressEditRunning = true;
|
|
@@ -1425,6 +1680,7 @@ export class FleetManager {
|
|
|
1425
1680
|
// Rotate fleet.log if oversized (before any logging)
|
|
1426
1681
|
rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
|
|
1427
1682
|
const fleet = this.loadConfig(configPath);
|
|
1683
|
+
this.slimFleetConfigAtStartup();
|
|
1428
1684
|
setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
|
|
1429
1685
|
const savedUpdateProgress = readUpdateProgress(this.dataDir);
|
|
1430
1686
|
const pendingUpdateProgress = savedUpdateProgress
|
|
@@ -1441,6 +1697,10 @@ export class FleetManager {
|
|
|
1441
1697
|
const { getTmuxSocketName: getSocket } = await import("./paths.js");
|
|
1442
1698
|
TmuxManager.setSocketName(getSocket());
|
|
1443
1699
|
await TmuxManager.ensureSession(getTmuxSession());
|
|
1700
|
+
// Pre-flight (advisory, fire-and-forget): warm the per-backend auth cache
|
|
1701
|
+
// before instances spawn, so a CLI that comes up on its sign-in screen gets
|
|
1702
|
+
// an instant 🔑 verdict instead of decaying into crash/MCP-died noise.
|
|
1703
|
+
this.lifecycle.primeAuthVerification(Object.values(fleet.instances).map(config => config.backend ?? fleet.defaults?.backend ?? "claude-code"));
|
|
1444
1704
|
// Start tmux control mode client for idle detection
|
|
1445
1705
|
if (!this.controlClient) {
|
|
1446
1706
|
this.controlClient = new TmuxControlClient(getTmuxSession(), 2000, this.logger);
|
|
@@ -1482,9 +1742,8 @@ export class FleetManager {
|
|
|
1482
1742
|
// routing does NOT go through the routing engine (single-key, can't hold two
|
|
1483
1743
|
// bots in one channel) — it resolves per-bot via getInstanceByChannel.
|
|
1484
1744
|
this.classicChannels = new ClassicChannelManager(this.dataDir, this.logger);
|
|
1485
|
-
const
|
|
1486
|
-
|
|
1487
|
-
this.classicChannels.setPrimaryAdapterId(primaryCh.id ?? primaryCh.type);
|
|
1745
|
+
const classicAdapters = fleet.channels?.length ? fleet.channels : (fleet.channel ? [fleet.channel] : []);
|
|
1746
|
+
this.classicChannels.configureAdapters(classicAdapters);
|
|
1488
1747
|
// Restore the persisted bot binding so replies/cancel go through the right
|
|
1489
1748
|
// bot after a restart (before this, inbound would re-bind lazily).
|
|
1490
1749
|
for (const ch of this.classicChannels.getAll()) {
|
|
@@ -1653,7 +1912,7 @@ export class FleetManager {
|
|
|
1653
1912
|
const generalDir = join(getAgendHome(), name);
|
|
1654
1913
|
mkdirSync(generalDir, { recursive: true });
|
|
1655
1914
|
const backendName = fleet.defaults.backend ?? "claude-code";
|
|
1656
|
-
this.ensureGeneralInstructions(generalDir, backendName);
|
|
1915
|
+
this.ensureGeneralInstructions(generalDir, backendName, name);
|
|
1657
1916
|
fleet.instances[name] = {
|
|
1658
1917
|
...DEFAULT_INSTANCE_CONFIG,
|
|
1659
1918
|
working_directory: generalDir,
|
|
@@ -1672,6 +1931,13 @@ export class FleetManager {
|
|
|
1672
1931
|
this.scheduler = new Scheduler(join(this.dataDir, "scheduler.db"), (schedule) => this.handleScheduleTrigger(schedule), schedulerConfig, (name) => this.fleetConfig?.instances?.[name] != null || !!this.classicChannels?.getAll().some(ch => ch.instanceName === name));
|
|
1673
1932
|
this.scheduler.init();
|
|
1674
1933
|
this.logger.info("Scheduler initialized");
|
|
1934
|
+
// Tips share the daily-report clock but remain an independent internal
|
|
1935
|
+
// job: disabling daily_summary must not disable tips (and vice versa).
|
|
1936
|
+
// The callback checks defaults.tips at fire time, so /tips on|off is hot.
|
|
1937
|
+
this.dailyTipScheduler = new DailyTipScheduler(summaryConfig, costGuardConfig.timezone, () => this.sendTipToGeneral().catch(err => {
|
|
1938
|
+
this.logger.warn({ err }, "Failed to send daily tip");
|
|
1939
|
+
}));
|
|
1940
|
+
this.dailyTipScheduler.start();
|
|
1675
1941
|
// Inject active decisions as env var for MCP instructions.
|
|
1676
1942
|
// Snapshotted at startup — new decisions via post_decision are available
|
|
1677
1943
|
// through list_decisions tool but not auto-injected until restart.
|
|
@@ -1854,6 +2120,12 @@ export class FleetManager {
|
|
|
1854
2120
|
version: agendVersion,
|
|
1855
2121
|
pausedNames,
|
|
1856
2122
|
failedNames,
|
|
2123
|
+
tipText: pendingUpdateProgress && this.tipsEnabled()
|
|
2124
|
+
? (() => {
|
|
2125
|
+
const tip = this.pickAvailableTip();
|
|
2126
|
+
return tip ? this.formatTip(tip) : undefined;
|
|
2127
|
+
})()
|
|
2128
|
+
: undefined,
|
|
1857
2129
|
});
|
|
1858
2130
|
if (!progressCompleted && this.adapter && fleet.channel?.group_id) {
|
|
1859
2131
|
let text;
|
|
@@ -2015,20 +2287,33 @@ export class FleetManager {
|
|
|
2015
2287
|
notifyAdapterFailure(adapterId, error) {
|
|
2016
2288
|
const generalId = this.findGeneralInstance();
|
|
2017
2289
|
if (generalId) {
|
|
2018
|
-
this.notifyInstanceTopic(generalId,
|
|
2290
|
+
this.notifyInstanceTopic(generalId, t("adapter.start_failed", adapterId, error));
|
|
2019
2291
|
}
|
|
2020
2292
|
}
|
|
2021
2293
|
/** Notify admin that a retried adapter reconnected. */
|
|
2022
2294
|
notifyAdapterRecovery(adapterId, attempts) {
|
|
2023
2295
|
const generalId = this.findGeneralInstance();
|
|
2024
2296
|
if (generalId) {
|
|
2025
|
-
this.notifyInstanceTopic(generalId,
|
|
2297
|
+
this.notifyInstanceTopic(generalId, t("adapter.reconnected", adapterId, attempts));
|
|
2026
2298
|
}
|
|
2027
2299
|
}
|
|
2028
2300
|
/** Get adapter states for /status visibility. */
|
|
2029
2301
|
getAdapterStates() {
|
|
2030
2302
|
return this.adapterState;
|
|
2031
2303
|
}
|
|
2304
|
+
bindAdapterHealth(adapter, adapterId) {
|
|
2305
|
+
adapter.on("gateway_health", (snapshot) => {
|
|
2306
|
+
const previous = this.adapterState.get(adapterId);
|
|
2307
|
+
const status = snapshot.status === "connected" ? "connected"
|
|
2308
|
+
: snapshot.status === "stopped" ? "failed"
|
|
2309
|
+
: "retrying";
|
|
2310
|
+
this.adapterState.set(adapterId, {
|
|
2311
|
+
status,
|
|
2312
|
+
retryCount: previous?.retryCount ?? 0,
|
|
2313
|
+
lastError: status === "connected" ? undefined : snapshot.lastReconnectReason ?? previous?.lastError,
|
|
2314
|
+
});
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2032
2317
|
/**
|
|
2033
2318
|
* Real, checkable fleet health for `/health` and the operator.
|
|
2034
2319
|
*
|
|
@@ -2058,10 +2343,17 @@ export class FleetManager {
|
|
|
2058
2343
|
counts.stopped++;
|
|
2059
2344
|
}
|
|
2060
2345
|
const states = {};
|
|
2346
|
+
const details = {};
|
|
2061
2347
|
let connected = 0;
|
|
2062
2348
|
for (const [id, state] of this.adapterState) {
|
|
2063
|
-
|
|
2064
|
-
if (
|
|
2349
|
+
const snapshot = this.adapters.get(id)?.getHealthSnapshot?.();
|
|
2350
|
+
if (snapshot)
|
|
2351
|
+
details[id] = snapshot;
|
|
2352
|
+
const effectiveStatus = snapshot?.status === "connected" ? "connected"
|
|
2353
|
+
: snapshot && snapshot.status !== "stopped" ? "retrying"
|
|
2354
|
+
: state.status;
|
|
2355
|
+
states[id] = effectiveStatus;
|
|
2356
|
+
if (effectiveStatus === "connected")
|
|
2065
2357
|
connected++;
|
|
2066
2358
|
}
|
|
2067
2359
|
const problems = [];
|
|
@@ -2069,9 +2361,9 @@ export class FleetManager {
|
|
|
2069
2361
|
problems.push("no channel adapter is connected");
|
|
2070
2362
|
if (counts.crashed > 0)
|
|
2071
2363
|
problems.push(`${counts.crashed} instance(s) crashed`);
|
|
2072
|
-
for (const [id, state] of
|
|
2073
|
-
if (state
|
|
2074
|
-
problems.push(`adapter ${id} is ${state
|
|
2364
|
+
for (const [id, state] of Object.entries(states)) {
|
|
2365
|
+
if (state !== "connected")
|
|
2366
|
+
problems.push(`adapter ${id} is ${state}`);
|
|
2075
2367
|
}
|
|
2076
2368
|
if (!this.startupComplete)
|
|
2077
2369
|
problems.push("startup has not completed");
|
|
@@ -2084,7 +2376,7 @@ export class FleetManager {
|
|
|
2084
2376
|
status,
|
|
2085
2377
|
uptime: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
2086
2378
|
instances: counts,
|
|
2087
|
-
adapters: { total: this.adapterState.size, connected, states },
|
|
2379
|
+
adapters: { total: this.adapterState.size, connected, states, details },
|
|
2088
2380
|
startupComplete: this.startupComplete,
|
|
2089
2381
|
memory: readFleetMemory(),
|
|
2090
2382
|
problems,
|
|
@@ -2113,6 +2405,7 @@ export class FleetManager {
|
|
|
2113
2405
|
const world = new AdapterWorld(adapterId, this.adapter, accessManager, channelConfig);
|
|
2114
2406
|
this.worlds.set(adapterId, world);
|
|
2115
2407
|
this.adapters.set(adapterId, this.adapter);
|
|
2408
|
+
this.bindAdapterHealth(this.adapter, adapterId);
|
|
2116
2409
|
this.adapter.on("message", safeHandler(async (msg) => {
|
|
2117
2410
|
await this.handleInboundMessage(msg);
|
|
2118
2411
|
}, this.logger, "adapter.message"));
|
|
@@ -2120,6 +2413,20 @@ export class FleetManager {
|
|
|
2120
2413
|
await this.handleInboundReaction(r);
|
|
2121
2414
|
}, this.logger, "adapter.reaction"));
|
|
2122
2415
|
this.adapter.on("callback_query", safeHandler(async (data) => {
|
|
2416
|
+
if (await this.handleTipDismiss(data, adapterId, this.adapter ?? undefined))
|
|
2417
|
+
return;
|
|
2418
|
+
if (await this.handleTipUnlock(data, adapterId, this.adapter ?? undefined))
|
|
2419
|
+
return;
|
|
2420
|
+
if (await this.handleLoginBackendSelect(data, adapterId, this.adapter ?? undefined))
|
|
2421
|
+
return;
|
|
2422
|
+
if (await this.handleLoginMenuSelect(data, adapterId, this.adapter ?? undefined))
|
|
2423
|
+
return;
|
|
2424
|
+
if (await this.handleLoginConfirm(data, adapterId, this.adapter ?? undefined))
|
|
2425
|
+
return;
|
|
2426
|
+
if (await this.handleInstallLoginConfirm(data, adapterId, this.adapter ?? undefined))
|
|
2427
|
+
return;
|
|
2428
|
+
if (await this.handleClearConfirmation(data, adapterId, this.adapter ?? undefined))
|
|
2429
|
+
return;
|
|
2123
2430
|
if (await this.handleExitRestartPrompt(data, adapterId, this.adapter ?? undefined))
|
|
2124
2431
|
return;
|
|
2125
2432
|
if (await this.handleInteractivePromptAssist(data, adapterId, this.adapter ?? undefined))
|
|
@@ -2210,6 +2517,49 @@ export class FleetManager {
|
|
|
2210
2517
|
const result = await this.topicCommands.sendCompact(name);
|
|
2211
2518
|
await data.respond(result);
|
|
2212
2519
|
}
|
|
2520
|
+
else if (data.command === "steer") {
|
|
2521
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2522
|
+
if (!name) {
|
|
2523
|
+
await data.respond(t("classic.no_agent"));
|
|
2524
|
+
return;
|
|
2525
|
+
}
|
|
2526
|
+
const steerText = String(data.options?.message ?? "").trim();
|
|
2527
|
+
if (!steerText) {
|
|
2528
|
+
await data.respond(t("steer.usage"));
|
|
2529
|
+
return;
|
|
2530
|
+
}
|
|
2531
|
+
// chat_id/message_id stay empty: a slash interaction has no channel
|
|
2532
|
+
// message to react to, and an empty chat_id keeps updateLastChat from
|
|
2533
|
+
// rerouting the instance's replies to the slash context.
|
|
2534
|
+
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
2535
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2536
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2537
|
+
});
|
|
2538
|
+
await data.respond(result);
|
|
2539
|
+
}
|
|
2540
|
+
else if (data.command === "btw") {
|
|
2541
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2542
|
+
if (!name) {
|
|
2543
|
+
await data.respond(t("classic.no_agent"));
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
const btwText = String(data.options?.message ?? "").trim();
|
|
2547
|
+
if (!btwText) {
|
|
2548
|
+
await data.respond(t("btw.usage"));
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
const result = this.topicCommands.sendBtw(name, btwText, {
|
|
2552
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2553
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2554
|
+
});
|
|
2555
|
+
await data.respond(result);
|
|
2556
|
+
}
|
|
2557
|
+
else if (data.command === "login") {
|
|
2558
|
+
await this.handleLoginSlash(data, adapterId, this.adapter);
|
|
2559
|
+
}
|
|
2560
|
+
else if (data.command === "install-cli") {
|
|
2561
|
+
await this.handleInstallCliSlash(data, adapterId, this.adapter);
|
|
2562
|
+
}
|
|
2213
2563
|
else if (data.command === "clear") {
|
|
2214
2564
|
await this.handleClearSlash(data, adapterId);
|
|
2215
2565
|
}
|
|
@@ -2283,12 +2633,15 @@ export class FleetManager {
|
|
|
2283
2633
|
const { getUsageSnapshot } = await import("./usage/usage-api.js");
|
|
2284
2634
|
const { renderUsageMarkdown } = await import("./usage/format-rich.js");
|
|
2285
2635
|
// slash_command is Discord-only; editReply renders Markdown natively.
|
|
2286
|
-
await data.respond(renderUsageMarkdown(await getUsageSnapshot()));
|
|
2636
|
+
await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
|
|
2287
2637
|
}
|
|
2288
2638
|
catch (err) {
|
|
2289
|
-
await data.respond(
|
|
2639
|
+
await data.respond(t("usage.failed", err.message));
|
|
2290
2640
|
}
|
|
2291
2641
|
}
|
|
2642
|
+
else if (data.command === "tips") {
|
|
2643
|
+
await this.handleTipsSlash(data, adapterId);
|
|
2644
|
+
}
|
|
2292
2645
|
else if (data.command === "status") {
|
|
2293
2646
|
// Admin-gated (like the topic path): the merged table shows every
|
|
2294
2647
|
// instance's cost and IPC health.
|
|
@@ -2334,6 +2687,49 @@ export class FleetManager {
|
|
|
2334
2687
|
const result = await this.topicCommands.sendCompact(name);
|
|
2335
2688
|
await data.respond(result);
|
|
2336
2689
|
}
|
|
2690
|
+
else if (data.command === "steer") {
|
|
2691
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2692
|
+
if (!name) {
|
|
2693
|
+
await data.respond(t("classic.no_agent"));
|
|
2694
|
+
return;
|
|
2695
|
+
}
|
|
2696
|
+
const steerText = String(data.options?.message ?? "").trim();
|
|
2697
|
+
if (!steerText) {
|
|
2698
|
+
await data.respond(t("steer.usage"));
|
|
2699
|
+
return;
|
|
2700
|
+
}
|
|
2701
|
+
// chat_id/message_id stay empty: a slash interaction has no channel
|
|
2702
|
+
// message to react to, and an empty chat_id keeps updateLastChat from
|
|
2703
|
+
// rerouting the instance's replies to the slash context.
|
|
2704
|
+
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
2705
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2706
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2707
|
+
});
|
|
2708
|
+
await data.respond(result);
|
|
2709
|
+
}
|
|
2710
|
+
else if (data.command === "btw") {
|
|
2711
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2712
|
+
if (!name) {
|
|
2713
|
+
await data.respond(t("classic.no_agent"));
|
|
2714
|
+
return;
|
|
2715
|
+
}
|
|
2716
|
+
const btwText = String(data.options?.message ?? "").trim();
|
|
2717
|
+
if (!btwText) {
|
|
2718
|
+
await data.respond(t("btw.usage"));
|
|
2719
|
+
return;
|
|
2720
|
+
}
|
|
2721
|
+
const result = this.topicCommands.sendBtw(name, btwText, {
|
|
2722
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2723
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2724
|
+
});
|
|
2725
|
+
await data.respond(result);
|
|
2726
|
+
}
|
|
2727
|
+
else if (data.command === "login") {
|
|
2728
|
+
await this.handleLoginSlash(data, adapterId, this.adapter);
|
|
2729
|
+
}
|
|
2730
|
+
else if (data.command === "install-cli") {
|
|
2731
|
+
await this.handleInstallCliSlash(data, adapterId, this.adapter);
|
|
2732
|
+
}
|
|
2337
2733
|
}, this.logger, "adapter.slash_command"));
|
|
2338
2734
|
await this.topicCommands.registerBotCommands().catch(e => this.logger.warn({ err: e }, "registerBotCommands failed (non-fatal)"));
|
|
2339
2735
|
// Background-probe each backend's CLI env (version/models) → cli-env cache.
|
|
@@ -2360,7 +2756,7 @@ export class FleetManager {
|
|
|
2360
2756
|
}, this.logger, "adapter.handler_error"));
|
|
2361
2757
|
this.adapter.on("error", (err) => {
|
|
2362
2758
|
this.logger.error({ err }, "Primary adapter fatal error");
|
|
2363
|
-
this.restartAdapter(this.adapter,
|
|
2759
|
+
this.restartAdapter(this.adapter, adapterId).catch(() => { });
|
|
2364
2760
|
});
|
|
2365
2761
|
this.adapter.on("new_group_detected", safeHandler((data) => {
|
|
2366
2762
|
const adminMsg = t("alert.bot_added", data.groupTitle, data.groupId, data.source);
|
|
@@ -2402,6 +2798,7 @@ export class FleetManager {
|
|
|
2402
2798
|
const world = new AdapterWorld(adapterId, adapter, accessManager, channelConfig);
|
|
2403
2799
|
this.worlds.set(adapterId, world);
|
|
2404
2800
|
this.adapters.set(adapterId, adapter);
|
|
2801
|
+
this.bindAdapterHealth(adapter, adapterId);
|
|
2405
2802
|
// Wire up event handlers (same as primary, routes through shared handleInboundMessage)
|
|
2406
2803
|
adapter.on("message", safeHandler(async (msg) => {
|
|
2407
2804
|
await this.handleInboundMessage(msg);
|
|
@@ -2410,6 +2807,20 @@ export class FleetManager {
|
|
|
2410
2807
|
await this.handleInboundReaction(r);
|
|
2411
2808
|
}, this.logger, `adapter[${adapterId}].reaction`));
|
|
2412
2809
|
adapter.on("callback_query", safeHandler(async (data) => {
|
|
2810
|
+
if (await this.handleTipDismiss(data, adapterId, adapter))
|
|
2811
|
+
return;
|
|
2812
|
+
if (await this.handleTipUnlock(data, adapterId, adapter))
|
|
2813
|
+
return;
|
|
2814
|
+
if (await this.handleLoginBackendSelect(data, adapterId, adapter))
|
|
2815
|
+
return;
|
|
2816
|
+
if (await this.handleLoginMenuSelect(data, adapterId, adapter))
|
|
2817
|
+
return;
|
|
2818
|
+
if (await this.handleLoginConfirm(data, adapterId, adapter))
|
|
2819
|
+
return;
|
|
2820
|
+
if (await this.handleInstallLoginConfirm(data, adapterId, adapter))
|
|
2821
|
+
return;
|
|
2822
|
+
if (await this.handleClearConfirmation(data, adapterId, adapter))
|
|
2823
|
+
return;
|
|
2413
2824
|
if (await this.handleExitRestartPrompt(data, adapterId, adapter))
|
|
2414
2825
|
return;
|
|
2415
2826
|
if (await this.handleInteractivePromptAssist(data, adapterId, adapter))
|
|
@@ -2563,12 +2974,15 @@ export class FleetManager {
|
|
|
2563
2974
|
const { getUsageSnapshot } = await import("./usage/usage-api.js");
|
|
2564
2975
|
const { renderUsageMarkdown } = await import("./usage/format-rich.js");
|
|
2565
2976
|
// slash_command is Discord-only; editReply renders Markdown natively.
|
|
2566
|
-
await data.respond(renderUsageMarkdown(await getUsageSnapshot()));
|
|
2977
|
+
await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
|
|
2567
2978
|
}
|
|
2568
2979
|
catch (err) {
|
|
2569
|
-
await data.respond(
|
|
2980
|
+
await data.respond(t("usage.failed", err.message));
|
|
2570
2981
|
}
|
|
2571
2982
|
}
|
|
2983
|
+
else if (data.command === "tips") {
|
|
2984
|
+
await this.handleTipsSlash(data, adapterId);
|
|
2985
|
+
}
|
|
2572
2986
|
else if (data.command === "status") {
|
|
2573
2987
|
// Admin-gated (like the topic path): the merged table shows every
|
|
2574
2988
|
// instance's cost and IPC health.
|
|
@@ -2614,11 +3028,50 @@ export class FleetManager {
|
|
|
2614
3028
|
const result = await this.topicCommands.sendCompact(name);
|
|
2615
3029
|
await data.respond(result);
|
|
2616
3030
|
}
|
|
3031
|
+
else if (data.command === "steer") {
|
|
3032
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
3033
|
+
if (!name) {
|
|
3034
|
+
await data.respond(t("classic.no_agent"));
|
|
3035
|
+
return;
|
|
3036
|
+
}
|
|
3037
|
+
const steerText = String(data.options?.message ?? "").trim();
|
|
3038
|
+
if (!steerText) {
|
|
3039
|
+
await data.respond(t("steer.usage"));
|
|
3040
|
+
return;
|
|
3041
|
+
}
|
|
3042
|
+
// chat_id/message_id stay empty: a slash interaction has no channel
|
|
3043
|
+
// message to react to, and an empty chat_id keeps updateLastChat from
|
|
3044
|
+
// rerouting the instance's replies to the slash context.
|
|
3045
|
+
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
3046
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
3047
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
3048
|
+
});
|
|
3049
|
+
await data.respond(result);
|
|
3050
|
+
}
|
|
3051
|
+
else if (data.command === "btw") {
|
|
3052
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
3053
|
+
if (!name) {
|
|
3054
|
+
await data.respond(t("classic.no_agent"));
|
|
3055
|
+
return;
|
|
3056
|
+
}
|
|
3057
|
+
const btwText = String(data.options?.message ?? "").trim();
|
|
3058
|
+
if (!btwText) {
|
|
3059
|
+
await data.respond(t("btw.usage"));
|
|
3060
|
+
return;
|
|
3061
|
+
}
|
|
3062
|
+
const result = this.topicCommands.sendBtw(name, btwText, {
|
|
3063
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
3064
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
3065
|
+
});
|
|
3066
|
+
await data.respond(result);
|
|
3067
|
+
}
|
|
3068
|
+
else if (data.command === "login") {
|
|
3069
|
+
await this.handleLoginSlash(data, adapterId, adapter);
|
|
3070
|
+
}
|
|
3071
|
+
else if (data.command === "install-cli") {
|
|
3072
|
+
await this.handleInstallCliSlash(data, adapterId, adapter);
|
|
3073
|
+
}
|
|
2617
3074
|
}, this.logger, `adapter[${adapterId}].slash_command`));
|
|
2618
|
-
await adapter.start();
|
|
2619
|
-
if (channelConfig.group_id) {
|
|
2620
|
-
adapter.setChatId(String(channelConfig.group_id));
|
|
2621
|
-
}
|
|
2622
3075
|
adapter.on("started", safeHandler((username, userId) => {
|
|
2623
3076
|
this.logger.info(`[${adapterId}] Bot @${username} polling started.`);
|
|
2624
3077
|
const world = this.worlds.get(adapterId);
|
|
@@ -2638,6 +3091,11 @@ export class FleetManager {
|
|
|
2638
3091
|
this.logger.error({ err, adapterId }, "Additional adapter fatal error");
|
|
2639
3092
|
this.restartAdapter(adapter, adapterId).catch(() => { });
|
|
2640
3093
|
});
|
|
3094
|
+
// Register lifecycle listeners before login; a fast ready/error must not be lost.
|
|
3095
|
+
await adapter.start();
|
|
3096
|
+
if (channelConfig.group_id) {
|
|
3097
|
+
adapter.setChatId(String(channelConfig.group_id));
|
|
3098
|
+
}
|
|
2641
3099
|
this.logger.info({ adapterId, type: channelConfig.type }, "Additional adapter started");
|
|
2642
3100
|
}
|
|
2643
3101
|
/** Connect IPC to a single instance with all handlers */
|
|
@@ -2731,6 +3189,42 @@ export class FleetManager {
|
|
|
2731
3189
|
else if (msg.type === "instance_activity") {
|
|
2732
3190
|
this.cacheInstanceActivity(name, msg.activity);
|
|
2733
3191
|
}
|
|
3192
|
+
else if (msg.type === "instance_progress") {
|
|
3193
|
+
this.cacheInstanceProgress(name, msg.progress || null);
|
|
3194
|
+
}
|
|
3195
|
+
else if (msg.type === "cross_instance_delivery_failed") {
|
|
3196
|
+
const senderSession = String(msg.senderSession ?? "");
|
|
3197
|
+
const targetInstance = String(msg.targetInstance ?? name);
|
|
3198
|
+
const correlationId = String(msg.correlationId ?? "unknown");
|
|
3199
|
+
const error = String(msg.error ?? "unknown delivery failure");
|
|
3200
|
+
const senderInstance = this.instanceIpcClients.has(senderSession)
|
|
3201
|
+
? senderSession
|
|
3202
|
+
: this.sessionRegistry.get(senderSession);
|
|
3203
|
+
this.logger.error({ senderSession, targetInstance, correlationId, error }, "Cross-instance pane delivery failed after queue acceptance");
|
|
3204
|
+
this.eventLog?.insert(targetInstance, "cross_instance_delivery_failed", {
|
|
3205
|
+
from: senderSession, correlation_id: correlationId, error,
|
|
3206
|
+
});
|
|
3207
|
+
if (senderInstance) {
|
|
3208
|
+
this.notifyInstanceTopic(senderInstance, t("cross_instance.delivery_failed_notice", targetInstance, error, correlationId));
|
|
3209
|
+
void this.deliverToInstance(senderInstance, {
|
|
3210
|
+
type: "fleet_inbound",
|
|
3211
|
+
targetSession: senderSession,
|
|
3212
|
+
content: t("cross_instance.delivery_failed_agent", targetInstance, error, correlationId),
|
|
3213
|
+
meta: {
|
|
3214
|
+
chat_id: "",
|
|
3215
|
+
message_id: `delivery-failed-${Date.now()}`,
|
|
3216
|
+
user: "AgEnD",
|
|
3217
|
+
user_id: "system:agend",
|
|
3218
|
+
ts: new Date().toISOString(),
|
|
3219
|
+
thread_id: "",
|
|
3220
|
+
request_kind: "update",
|
|
3221
|
+
correlation_id: correlationId,
|
|
3222
|
+
},
|
|
3223
|
+
}, { waitForIdle: true }).catch(deliveryError => {
|
|
3224
|
+
this.logger.error({ err: deliveryError, senderSession, correlationId }, "Could not deliver cross-instance failure status back to sender");
|
|
3225
|
+
});
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
2734
3228
|
else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
|
|
2735
3229
|
this.cacheInstanceExecutionState(name, msg);
|
|
2736
3230
|
if (msg.type === "instance_state_response") {
|
|
@@ -2844,6 +3338,26 @@ export class FleetManager {
|
|
|
2844
3338
|
const previous = this.adapterState.get(id);
|
|
2845
3339
|
this.adapterState.set(id, { status: "retrying", retryCount: 0, lastError: previous?.lastError });
|
|
2846
3340
|
try {
|
|
3341
|
+
if (adapter.reconnectGateway) {
|
|
3342
|
+
try {
|
|
3343
|
+
// Discord requires a fresh Client after destroy(). Its adapter owns the
|
|
3344
|
+
// single-flight, generation fence and bounded IDENTIFY backoff, so all
|
|
3345
|
+
// watchdog/manual/error triggers must converge here instead of stop/start.
|
|
3346
|
+
await adapter.reconnectGateway(previous?.lastError ?? "fleet adapter restart");
|
|
3347
|
+
this.adapterState.set(id, { status: "connected", retryCount: 0 });
|
|
3348
|
+
this.logger.info({ id }, "Adapter gateway rebuilt successfully");
|
|
3349
|
+
}
|
|
3350
|
+
catch (err) {
|
|
3351
|
+
if (!this.ipcStoppingInstances.has("__fleet_stopping__")) {
|
|
3352
|
+
this.adapterState.set(id, {
|
|
3353
|
+
status: "failed",
|
|
3354
|
+
retryCount: previous?.retryCount ?? 0,
|
|
3355
|
+
lastError: err?.message ?? String(err),
|
|
3356
|
+
});
|
|
3357
|
+
}
|
|
3358
|
+
}
|
|
3359
|
+
return;
|
|
3360
|
+
}
|
|
2847
3361
|
for (let attempt = 1;; attempt++) {
|
|
2848
3362
|
if (this.ipcStoppingInstances.has("__fleet_stopping__"))
|
|
2849
3363
|
return;
|
|
@@ -3178,6 +3692,42 @@ export class FleetManager {
|
|
|
3178
3692
|
await msgAdapter?.sendText(chatId, result);
|
|
3179
3693
|
return;
|
|
3180
3694
|
}
|
|
3695
|
+
// /steer — interject into the running turn. Not admin-gated: anyone who
|
|
3696
|
+
// can talk to this agent can send it a message; steer only changes when
|
|
3697
|
+
// it lands, and it keeps the full [user:] formatting (unlike /raw).
|
|
3698
|
+
if (text === "/steer" || text.startsWith("/steer ") || text.startsWith("/steer@")) {
|
|
3699
|
+
const steerName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
3700
|
+
if (!steerName) {
|
|
3701
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
3702
|
+
return;
|
|
3703
|
+
}
|
|
3704
|
+
const steerContent = text.replace(/^\/steer(@\S+)?/, "").trim();
|
|
3705
|
+
if (!steerContent) {
|
|
3706
|
+
await msgAdapter?.sendText(chatId, t("steer.usage"));
|
|
3707
|
+
return;
|
|
3708
|
+
}
|
|
3709
|
+
const result = this.topicCommands.sendSteer(steerName, steerContent, msg);
|
|
3710
|
+
await msgAdapter?.sendText(chatId, result);
|
|
3711
|
+
return;
|
|
3712
|
+
}
|
|
3713
|
+
// /btw — Claude Code's native side-thread command. Like /steer it is
|
|
3714
|
+
// not admin-gated, but it remains a distinct raw CLI command so Claude
|
|
3715
|
+
// does not fold the question into the active task.
|
|
3716
|
+
if (text === "/btw" || text.startsWith("/btw ") || text.startsWith("/btw@")) {
|
|
3717
|
+
const btwName = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
3718
|
+
if (!btwName) {
|
|
3719
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
3720
|
+
return;
|
|
3721
|
+
}
|
|
3722
|
+
const btwContent = text.replace(/^\/btw(@\S+)?/, "").trim();
|
|
3723
|
+
if (!btwContent) {
|
|
3724
|
+
await msgAdapter?.sendText(chatId, t("btw.usage"));
|
|
3725
|
+
return;
|
|
3726
|
+
}
|
|
3727
|
+
const result = this.topicCommands.sendBtw(btwName, btwContent, msg);
|
|
3728
|
+
await msgAdapter?.sendText(chatId, result);
|
|
3729
|
+
return;
|
|
3730
|
+
}
|
|
3181
3731
|
// Handle /clear command (admin only) — unlike /compact this starts a
|
|
3182
3732
|
// fresh conversation and intentionally discards the current history.
|
|
3183
3733
|
if (text === "/clear" || text.startsWith("/clear@")) {
|
|
@@ -3190,7 +3740,11 @@ export class FleetManager {
|
|
|
3190
3740
|
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
3191
3741
|
return;
|
|
3192
3742
|
}
|
|
3193
|
-
|
|
3743
|
+
if (!msgAdapter)
|
|
3744
|
+
return;
|
|
3745
|
+
const fallback = await this.promptClearConfirmation(clearName, chatId, msgAdapter, chatId);
|
|
3746
|
+
if (fallback)
|
|
3747
|
+
await msgAdapter.sendText(chatId, fallback);
|
|
3194
3748
|
return;
|
|
3195
3749
|
}
|
|
3196
3750
|
// Handle /cancel command
|
|
@@ -3201,7 +3755,7 @@ export class FleetManager {
|
|
|
3201
3755
|
return;
|
|
3202
3756
|
}
|
|
3203
3757
|
const ok = this.cancelInstance(cancelName);
|
|
3204
|
-
await msgAdapter?.sendText(chatId, ok ?
|
|
3758
|
+
await msgAdapter?.sendText(chatId, ok ? t("cancel.sent", cancelName) : t("cancel.not_running", cancelName));
|
|
3205
3759
|
return;
|
|
3206
3760
|
}
|
|
3207
3761
|
// Handle /ctx command
|
|
@@ -3238,7 +3792,7 @@ export class FleetManager {
|
|
|
3238
3792
|
const backend = this.classicChannels.getBackendByInstance(saveName, this.fleetConfig?.defaults?.backend);
|
|
3239
3793
|
const cmd = saveCommandForBackend(backend, filename);
|
|
3240
3794
|
if (!cmd) {
|
|
3241
|
-
await msgAdapter?.sendText(chatId,
|
|
3795
|
+
await msgAdapter?.sendText(chatId, t("save.unsupported"));
|
|
3242
3796
|
return;
|
|
3243
3797
|
}
|
|
3244
3798
|
this.pasteRawToClassicInstance(saveName, cmd);
|
|
@@ -3456,10 +4010,10 @@ export class FleetManager {
|
|
|
3456
4010
|
return;
|
|
3457
4011
|
let warning = "";
|
|
3458
4012
|
if (rl.five_hour_pct >= 95) {
|
|
3459
|
-
warning =
|
|
4013
|
+
warning = t("rate_limit.five_hour", instanceName, Math.round(rl.five_hour_pct));
|
|
3460
4014
|
}
|
|
3461
4015
|
else if (rl.seven_day_pct >= 95) {
|
|
3462
|
-
warning =
|
|
4016
|
+
warning = t("rate_limit.weekly", instanceName, Math.round(rl.seven_day_pct));
|
|
3463
4017
|
}
|
|
3464
4018
|
if (!warning)
|
|
3465
4019
|
return;
|
|
@@ -3474,8 +4028,6 @@ export class FleetManager {
|
|
|
3474
4028
|
}
|
|
3475
4029
|
/** Handle outbound tool calls from a daemon instance */
|
|
3476
4030
|
async handleOutboundFromInstance(instanceName, msg) {
|
|
3477
|
-
if (this.worlds.size === 0)
|
|
3478
|
-
return;
|
|
3479
4031
|
this.touchActivity(instanceName);
|
|
3480
4032
|
this.setTopicIcon(instanceName, "green");
|
|
3481
4033
|
const tool = msg.tool;
|
|
@@ -3485,13 +4037,21 @@ export class FleetManager {
|
|
|
3485
4037
|
const senderSessionName = msg.senderSessionName;
|
|
3486
4038
|
const respond = (result, error) => {
|
|
3487
4039
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
4040
|
+
let sent = false;
|
|
3488
4041
|
if (fleetRequestId) {
|
|
3489
|
-
ipc?.send({ type: "fleet_outbound_response", fleetRequestId, result, error });
|
|
4042
|
+
sent = ipc?.send({ type: "fleet_outbound_response", fleetRequestId, result, error }) ?? false;
|
|
3490
4043
|
}
|
|
3491
4044
|
else {
|
|
3492
|
-
ipc?.send({ type: "fleet_outbound_response", requestId, result, error });
|
|
4045
|
+
sent = ipc?.send({ type: "fleet_outbound_response", requestId, result, error }) ?? false;
|
|
4046
|
+
}
|
|
4047
|
+
if (!sent) {
|
|
4048
|
+
this.logger.warn({ instanceName, tool, requestId, fleetRequestId, error }, "Fleet outbound result could not be returned — instance IPC is disconnected");
|
|
3493
4049
|
}
|
|
3494
4050
|
};
|
|
4051
|
+
if (this.worlds.size === 0) {
|
|
4052
|
+
respond(null, "Channel adapters are not ready — retry shortly");
|
|
4053
|
+
return;
|
|
4054
|
+
}
|
|
3495
4055
|
// Resolve threadId: use sender's topic_id if sender is a known fleet instance,
|
|
3496
4056
|
// fall back to general topic if sender is unknown, or IPC owner if no sender.
|
|
3497
4057
|
const senderInstanceName = senderSessionName && this.fleetConfig?.instances[senderSessionName]
|
|
@@ -3536,17 +4096,30 @@ export class FleetManager {
|
|
|
3536
4096
|
if (tool === "reply") {
|
|
3537
4097
|
const ticket = this.replyDeduper.begin(instanceName, String(args.text ?? ""), Array.isArray(args.files) ? args.files : []);
|
|
3538
4098
|
if (ticket.duplicate) {
|
|
3539
|
-
this.logger.info({ instanceName }, "
|
|
4099
|
+
this.logger.info({ instanceName }, "Concurrent duplicate reply joined — awaiting the original send's outcome");
|
|
3540
4100
|
ticket.subscribe(respond);
|
|
3541
4101
|
return;
|
|
3542
4102
|
}
|
|
3543
4103
|
const original = respond;
|
|
3544
4104
|
const respondAndRecord = (result, error) => {
|
|
3545
4105
|
ticket.complete(result, error);
|
|
4106
|
+
// Return the platform outcome first. Bookkeeping below must never turn
|
|
4107
|
+
// a confirmed Discord/Telegram POST into a tool error if a secondary
|
|
4108
|
+
// log/button side effect happens to throw.
|
|
3546
4109
|
original(result, error);
|
|
4110
|
+
// The adapter resolves only after the platform POST returns. Keep
|
|
4111
|
+
// outward-facing logs/cancel state on the same confirmation boundary:
|
|
4112
|
+
// a routed-but-failed reply is not a delivered reply.
|
|
4113
|
+
if (!error && result != null) {
|
|
4114
|
+
try {
|
|
4115
|
+
this.afterReplyRouted(instanceName, args, senderSessionName);
|
|
4116
|
+
}
|
|
4117
|
+
catch (err) {
|
|
4118
|
+
this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
|
|
4119
|
+
}
|
|
4120
|
+
}
|
|
3547
4121
|
};
|
|
3548
4122
|
if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord)) {
|
|
3549
|
-
this.afterReplyRouted(instanceName, args, senderSessionName);
|
|
3550
4123
|
return;
|
|
3551
4124
|
}
|
|
3552
4125
|
// routeToolCall knows "reply"; not handling it means the world changed.
|
|
@@ -3583,7 +4156,7 @@ export class FleetManager {
|
|
|
3583
4156
|
this.clearCancelButton(instanceName);
|
|
3584
4157
|
}
|
|
3585
4158
|
else {
|
|
3586
|
-
void this.sendCancelButton(instanceName).then(() => this.armReplyGrace(instanceName));
|
|
4159
|
+
void this.sendCancelButton(instanceName, undefined, true).then(() => this.armReplyGrace(instanceName));
|
|
3587
4160
|
}
|
|
3588
4161
|
this.reactDone(instanceName);
|
|
3589
4162
|
const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
|
|
@@ -3620,7 +4193,11 @@ export class FleetManager {
|
|
|
3620
4193
|
if (silent) {
|
|
3621
4194
|
const ipc = this.instanceIpcClients.get(target);
|
|
3622
4195
|
if (ipc) {
|
|
3623
|
-
ipc.send({
|
|
4196
|
+
ipc.send({
|
|
4197
|
+
type: "raw_paste",
|
|
4198
|
+
content: message,
|
|
4199
|
+
delivery_epoch: this.getDeliveryEpoch(target),
|
|
4200
|
+
});
|
|
3624
4201
|
this.scheduler.recordRun(id, "delivered");
|
|
3625
4202
|
this.logger.info({ target, scheduleId: id, label }, "Silent schedule injected via raw_paste");
|
|
3626
4203
|
}
|
|
@@ -3635,10 +4212,19 @@ export class FleetManager {
|
|
|
3635
4212
|
const retryInterval = schedulerDefaults?.retry_interval_ms ?? 30_000;
|
|
3636
4213
|
const deliver = async () => {
|
|
3637
4214
|
try {
|
|
4215
|
+
// A schedule has no live inbound adapter context. Seed the daemon with
|
|
4216
|
+
// the target instance's configured world so replies use its persona
|
|
4217
|
+
// after a fresh start instead of falling back to channels[0].
|
|
4218
|
+
const adapterId = this.getInstanceAdapterId(target);
|
|
3638
4219
|
await this.deliverToInstance(target, {
|
|
3639
4220
|
type: "fleet_schedule_trigger",
|
|
3640
4221
|
payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
|
|
3641
|
-
meta: {
|
|
4222
|
+
meta: {
|
|
4223
|
+
chat_id: reply_chat_id,
|
|
4224
|
+
thread_id: reply_thread_id,
|
|
4225
|
+
user: "scheduler",
|
|
4226
|
+
...(adapterId ? { adapter_id: adapterId } : {}),
|
|
4227
|
+
},
|
|
3642
4228
|
}, { waitForIdle: true });
|
|
3643
4229
|
// A scheduled trigger also puts the instance to work — show a cancel button.
|
|
3644
4230
|
void this.sendCancelButton(target);
|
|
@@ -3786,21 +4372,45 @@ export class FleetManager {
|
|
|
3786
4372
|
}
|
|
3787
4373
|
/** Resolve display name for an instance, fallback to instance name. */
|
|
3788
4374
|
resolveDisplayName(instanceName) {
|
|
3789
|
-
return this.fleetConfig?.instances[instanceName]?.display_name
|
|
4375
|
+
return this.fleetConfig?.instances[instanceName]?.display_name
|
|
4376
|
+
?? this.classicChannels?.getAll().find(ch => ch.instanceName === instanceName)?.displayName
|
|
4377
|
+
?? instanceName;
|
|
3790
4378
|
}
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
const
|
|
3795
|
-
if (
|
|
3796
|
-
|
|
3797
|
-
|
|
4379
|
+
/** Persist identity to the instance's actual config store. Classic instances
|
|
4380
|
+
* are registry rows in classicBot.yaml, not fleet.yaml instance entries. */
|
|
4381
|
+
setInstanceDisplayName(instanceName, displayName) {
|
|
4382
|
+
const fleetInstance = this.fleetConfig?.instances[instanceName];
|
|
4383
|
+
if (fleetInstance) {
|
|
4384
|
+
fleetInstance.display_name = displayName;
|
|
4385
|
+
this.saveFleetConfig();
|
|
4386
|
+
return true;
|
|
4387
|
+
}
|
|
4388
|
+
return this.classicChannels?.setDisplayNameByInstance(instanceName, displayName) ?? false;
|
|
4389
|
+
}
|
|
4390
|
+
setInstanceDescription(instanceName, description) {
|
|
4391
|
+
const fleetInstance = this.fleetConfig?.instances[instanceName];
|
|
4392
|
+
if (fleetInstance) {
|
|
4393
|
+
fleetInstance.description = description;
|
|
4394
|
+
this.saveFleetConfig();
|
|
4395
|
+
return true;
|
|
4396
|
+
}
|
|
4397
|
+
return this.classicChannels?.setDescriptionByInstance(instanceName, description) ?? false;
|
|
4398
|
+
}
|
|
4399
|
+
handleSetDisplayName(instanceName, msg) {
|
|
4400
|
+
const fleetRequestId = msg.fleetRequestId;
|
|
4401
|
+
const payload = (msg.payload ?? {});
|
|
4402
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
4403
|
+
if (!ipc || !this.fleetConfig)
|
|
4404
|
+
return;
|
|
4405
|
+
const displayName = payload.name;
|
|
3798
4406
|
if (!displayName || displayName.length > 30) {
|
|
3799
4407
|
ipc.send({ type: "fleet_display_name_response", fleetRequestId, error: "Name must be 1-30 characters" });
|
|
3800
4408
|
return;
|
|
3801
4409
|
}
|
|
3802
|
-
this.
|
|
3803
|
-
|
|
4410
|
+
if (!this.setInstanceDisplayName(instanceName, displayName)) {
|
|
4411
|
+
ipc.send({ type: "fleet_display_name_response", fleetRequestId, error: `Instance '${instanceName}' not found` });
|
|
4412
|
+
return;
|
|
4413
|
+
}
|
|
3804
4414
|
this.logger.info({ instanceName, displayName }, "Display name set");
|
|
3805
4415
|
ipc.send({ type: "fleet_display_name_response", fleetRequestId, result: { display_name: displayName } });
|
|
3806
4416
|
}
|
|
@@ -3815,8 +4425,10 @@ export class FleetManager {
|
|
|
3815
4425
|
ipc.send({ type: "fleet_description_response", fleetRequestId, error: "Description cannot be empty" });
|
|
3816
4426
|
return;
|
|
3817
4427
|
}
|
|
3818
|
-
this.
|
|
3819
|
-
|
|
4428
|
+
if (!this.setInstanceDescription(instanceName, description)) {
|
|
4429
|
+
ipc.send({ type: "fleet_description_response", fleetRequestId, error: `Instance '${instanceName}' not found` });
|
|
4430
|
+
return;
|
|
4431
|
+
}
|
|
3820
4432
|
this.logger.info({ instanceName, description: description.slice(0, 80) }, "Description set");
|
|
3821
4433
|
ipc.send({ type: "fleet_description_response", fleetRequestId, result: { description } });
|
|
3822
4434
|
}
|
|
@@ -3945,8 +4557,8 @@ export class FleetManager {
|
|
|
3945
4557
|
return { error: "Fleet config not available" };
|
|
3946
4558
|
if (!name || name.length > 30)
|
|
3947
4559
|
return { error: "Name must be 1-30 characters" };
|
|
3948
|
-
this.
|
|
3949
|
-
|
|
4560
|
+
if (!this.setInstanceDisplayName(instance, name))
|
|
4561
|
+
return { error: `Instance '${instance}' not found` };
|
|
3950
4562
|
return { display_name: name };
|
|
3951
4563
|
}
|
|
3952
4564
|
async handleSetDescriptionHttp(instance, description) {
|
|
@@ -3954,8 +4566,8 @@ export class FleetManager {
|
|
|
3954
4566
|
return { error: "Fleet config not available" };
|
|
3955
4567
|
if (!description)
|
|
3956
4568
|
return { error: "Description cannot be empty" };
|
|
3957
|
-
this.
|
|
3958
|
-
|
|
4569
|
+
if (!this.setInstanceDescription(instance, description))
|
|
4570
|
+
return { error: `Instance '${instance}' not found` };
|
|
3959
4571
|
return { description };
|
|
3960
4572
|
}
|
|
3961
4573
|
summarizeToolCall(tool, args) {
|
|
@@ -4087,7 +4699,8 @@ export class FleetManager {
|
|
|
4087
4699
|
}
|
|
4088
4700
|
/**
|
|
4089
4701
|
* Patch only values changed in the effective config into the original YAML
|
|
4090
|
-
* document. Unknown keys
|
|
4702
|
+
* document. Unknown keys and comments remain untouched; redundant
|
|
4703
|
+
* non-identity instance leaves are canonicalized to inheritance afterward.
|
|
4091
4704
|
*/
|
|
4092
4705
|
saveFleetConfig(explicitPatches = []) {
|
|
4093
4706
|
if (!this.fleetConfig || !this.configPath)
|
|
@@ -4103,9 +4716,9 @@ export class FleetManager {
|
|
|
4103
4716
|
}
|
|
4104
4717
|
this.rawFleetConfig = loadRawFleetConfig(this.configPath);
|
|
4105
4718
|
this.patchFleetDocument(this.rawFleetDocument, [], this.savedFleetConfigSnapshot, this.fleetConfig);
|
|
4106
|
-
// Settings edits are expressed against the raw config
|
|
4107
|
-
//
|
|
4108
|
-
//
|
|
4719
|
+
// Settings edits are expressed against the raw config, so apply them before
|
|
4720
|
+
// canonicalization. A non-identity value equal to its inherited default is
|
|
4721
|
+
// intentionally stored as inheritance rather than an explicit duplicate.
|
|
4109
4722
|
for (const patch of explicitPatches) {
|
|
4110
4723
|
if (patch.remove) {
|
|
4111
4724
|
// YAML's deleteIn throws when an inherited nested key has no raw parent
|
|
@@ -4120,7 +4733,24 @@ export class FleetManager {
|
|
|
4120
4733
|
this.patchFleetDocument(this.rawFleetDocument, patch.path, before, patch.value);
|
|
4121
4734
|
}
|
|
4122
4735
|
}
|
|
4736
|
+
const rawAfterPatches = this.rawFleetDocument.toJS();
|
|
4737
|
+
const redundantPaths = collectRedundantInstanceDefaultPaths(rawAfterPatches);
|
|
4738
|
+
for (const path of redundantPaths) {
|
|
4739
|
+
if (this.rawFleetDocument.hasIn(path))
|
|
4740
|
+
this.rawFleetDocument.deleteIn(path);
|
|
4741
|
+
// Avoid leaving empty operational maps such as `terminal: {}` while
|
|
4742
|
+
// preserving the instance mapping itself and all surrounding comments.
|
|
4743
|
+
for (let depth = path.length - 1; depth > 2; depth--) {
|
|
4744
|
+
const parentPath = path.slice(0, depth);
|
|
4745
|
+
const parent = this.rawFleetDocument.getIn(parentPath, true);
|
|
4746
|
+
if (!isMap(parent) || parent.items.length > 0)
|
|
4747
|
+
break;
|
|
4748
|
+
this.rawFleetDocument.deleteIn(parentPath);
|
|
4749
|
+
}
|
|
4750
|
+
}
|
|
4123
4751
|
const output = String(this.rawFleetDocument);
|
|
4752
|
+
if (redundantPaths.length > 0)
|
|
4753
|
+
this.writeFleetConfigBackup(source);
|
|
4124
4754
|
const tempPath = `${this.configPath}.tmp-${process.pid}`;
|
|
4125
4755
|
writeFileSync(tempPath, output, "utf-8");
|
|
4126
4756
|
if (existsSync(this.configPath))
|
|
@@ -4128,7 +4758,36 @@ export class FleetManager {
|
|
|
4128
4758
|
renameSync(tempPath, this.configPath);
|
|
4129
4759
|
this.rawFleetConfig = loadRawFleetConfig(this.configPath);
|
|
4130
4760
|
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
4131
|
-
this.logger.info({ path: this.configPath }, "Saved fleet config (lossless patch)");
|
|
4761
|
+
this.logger.info({ path: this.configPath, strippedDefaults: redundantPaths.length }, "Saved fleet config (lossless patch)");
|
|
4762
|
+
}
|
|
4763
|
+
/** One-time upgrade migration; invalid YAML is never rewritten. */
|
|
4764
|
+
slimFleetConfigAtStartup() {
|
|
4765
|
+
const redundantPaths = collectRedundantInstanceDefaultPaths(this.rawFleetConfig);
|
|
4766
|
+
if (redundantPaths.length === 0)
|
|
4767
|
+
return;
|
|
4768
|
+
const validation = validateFleetConfig(this.rawFleetConfig);
|
|
4769
|
+
if (!validation.valid) {
|
|
4770
|
+
this.logger.warn({ errors: validation.errors, redundantDefaults: redundantPaths.length }, "Skipping fleet.yaml default slimming because the raw config is invalid");
|
|
4771
|
+
return;
|
|
4772
|
+
}
|
|
4773
|
+
try {
|
|
4774
|
+
this.saveFleetConfig();
|
|
4775
|
+
this.logger.info({ strippedDefaults: redundantPaths.length, backup: `${this.configPath}.bak` }, "Slimmed redundant instance defaults in fleet.yaml");
|
|
4776
|
+
}
|
|
4777
|
+
catch (err) {
|
|
4778
|
+
// A migration must not turn a previously bootable fleet into an outage.
|
|
4779
|
+
this.logger.warn({ err }, "Could not slim fleet.yaml; continuing with the original config");
|
|
4780
|
+
}
|
|
4781
|
+
}
|
|
4782
|
+
writeFleetConfigBackup(source) {
|
|
4783
|
+
if (!this.configPath)
|
|
4784
|
+
return;
|
|
4785
|
+
const backupPath = `${this.configPath}.bak`;
|
|
4786
|
+
const tempPath = `${backupPath}.tmp-${process.pid}`;
|
|
4787
|
+
writeFileSync(tempPath, source, "utf-8");
|
|
4788
|
+
if (existsSync(this.configPath))
|
|
4789
|
+
chmodSync(tempPath, statSync(this.configPath).mode);
|
|
4790
|
+
renameSync(tempPath, backupPath);
|
|
4132
4791
|
}
|
|
4133
4792
|
patchFleetDocument(document, path, before, after) {
|
|
4134
4793
|
if (Object.is(before, after))
|
|
@@ -4180,7 +4839,12 @@ export class FleetManager {
|
|
|
4180
4839
|
currentNode.value = after;
|
|
4181
4840
|
}
|
|
4182
4841
|
else {
|
|
4183
|
-
|
|
4842
|
+
// Use a YAML collection node for newly-added objects. Passing the raw
|
|
4843
|
+
// object creates a scalar wrapper: it serializes, but nested hasIn /
|
|
4844
|
+
// deleteIn cannot traverse it (notably when slimming create_instance).
|
|
4845
|
+
document.setIn(path, after !== null && typeof after === "object"
|
|
4846
|
+
? document.createNode(after)
|
|
4847
|
+
: after);
|
|
4184
4848
|
}
|
|
4185
4849
|
}
|
|
4186
4850
|
}
|
|
@@ -4271,7 +4935,7 @@ export class FleetManager {
|
|
|
4271
4935
|
from: primaryModel, to: fallbackModel, five_hour_pct: fiveHourPct,
|
|
4272
4936
|
});
|
|
4273
4937
|
this.webhookEmitter?.emit("model_failover", name, { from: primaryModel, to: fallbackModel, five_hour_pct: fiveHourPct });
|
|
4274
|
-
this.notifyInstanceTopic(name,
|
|
4938
|
+
this.notifyInstanceTopic(name, t("failover.triggered", fiveHourPct, fallbackModel, primaryModel));
|
|
4275
4939
|
}
|
|
4276
4940
|
else if (fiveHourPct < FleetManager.FAILOVER_RECOVER_PCT && currentFailover) {
|
|
4277
4941
|
// Recover: switch back to primary
|
|
@@ -4282,7 +4946,7 @@ export class FleetManager {
|
|
|
4282
4946
|
restored: primaryModel, five_hour_pct: fiveHourPct,
|
|
4283
4947
|
});
|
|
4284
4948
|
this.webhookEmitter?.emit("model_recovered", name, { restored: primaryModel, five_hour_pct: fiveHourPct });
|
|
4285
|
-
this.notifyInstanceTopic(name,
|
|
4949
|
+
this.notifyInstanceTopic(name, t("failover.recovered", fiveHourPct, primaryModel));
|
|
4286
4950
|
}
|
|
4287
4951
|
}
|
|
4288
4952
|
toggleFleetCollab(instanceName) {
|
|
@@ -4336,9 +5000,9 @@ export class FleetManager {
|
|
|
4336
5000
|
if (output)
|
|
4337
5001
|
return output;
|
|
4338
5002
|
if (e.code === "ENOENT") {
|
|
4339
|
-
return "
|
|
5003
|
+
return t("doctor.cli_missing");
|
|
4340
5004
|
}
|
|
4341
|
-
return stripAnsi(e.message ?? "").trim() || "
|
|
5005
|
+
return stripAnsi(e.message ?? "").trim() || t("doctor.failed");
|
|
4342
5006
|
}
|
|
4343
5007
|
}
|
|
4344
5008
|
/** Drop event/activity rows older than the retention window. Best-effort. */
|
|
@@ -4440,7 +5104,7 @@ export class FleetManager {
|
|
|
4440
5104
|
this.fleetErrorNotices.delete(oldest);
|
|
4441
5105
|
}
|
|
4442
5106
|
const body = suppressed > 0
|
|
4443
|
-
? `${text}\n(
|
|
5107
|
+
? `${text}\n${t("fleet.error_suppressed", suppressed, Math.round(FleetManager.FLEET_ERROR_THROTTLE_MS / 60_000))}`
|
|
4444
5108
|
: text;
|
|
4445
5109
|
// Resolved from config, NOT findGeneralInstance(): that requires a live daemon,
|
|
4446
5110
|
// and a fleet-level fault is exactly when the General may be down. The topic
|
|
@@ -4490,12 +5154,13 @@ export class FleetManager {
|
|
|
4490
5154
|
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
|
|
4491
5155
|
}
|
|
4492
5156
|
}
|
|
4493
|
-
// ── Nonce-armed button prompts (hang /
|
|
5157
|
+
// ── Nonce-armed button prompts (hang / assist / exit / clear) ──
|
|
4494
5158
|
//
|
|
4495
5159
|
// One shared lifecycle for every "notification with decision buttons":
|
|
4496
|
-
// post with a 128-bit nonce, arm a
|
|
4497
|
-
// exact adapter+chat+thread+message that created it, require fleet admin
|
|
4498
|
-
//
|
|
5160
|
+
// post with a 128-bit nonce, arm a bounded expiry, bind the click to the
|
|
5161
|
+
// exact adapter+chat+thread+message that created it, require fleet admin for
|
|
5162
|
+
// actions that mutate runtime state (Tips are the harmless exception),
|
|
5163
|
+
// consume exactly once. The features differ only in what they post
|
|
4499
5164
|
// and what a consumed click does.
|
|
4500
5165
|
/**
|
|
4501
5166
|
* Post decision buttons whose callback ids are `<prefix><nonce>:<action>`.
|
|
@@ -4548,19 +5213,18 @@ export class FleetManager {
|
|
|
4548
5213
|
}
|
|
4549
5214
|
}
|
|
4550
5215
|
/**
|
|
4551
|
-
*
|
|
5216
|
+
* Validate a nonce-armed callback and claim it exactly once.
|
|
4552
5217
|
*
|
|
4553
5218
|
* Returns:
|
|
4554
5219
|
* - null — the callback is not for this prefix; try the next handler
|
|
4555
5220
|
* - "consumed" — for this prefix but stale/denied/malformed; stop dispatch
|
|
4556
|
-
* - the entry+action — the click is authorized and
|
|
4557
|
-
* from the map before any await, so double clicks cannot act twice)
|
|
5221
|
+
* - the entry+action — the click is authorized and claimed before any await
|
|
4558
5222
|
*
|
|
4559
5223
|
* A stale click (expired nonce, or a pre-upgrade button whose payload no
|
|
4560
5224
|
* longer parses) collapses the clicked message so the dead button stops
|
|
4561
5225
|
* inviting clicks — the same courtesy the cancel button extends.
|
|
4562
5226
|
*/
|
|
4563
|
-
consumeNonceCallback(prefix, actionRe, data, callbackAdapterId, receivingAdapter) {
|
|
5227
|
+
consumeNonceCallback(prefix, actionRe, data, callbackAdapterId, receivingAdapter, staleHandling) {
|
|
4564
5228
|
if (!data.callbackData.startsWith(prefix))
|
|
4565
5229
|
return null;
|
|
4566
5230
|
const match = data.callbackData.match(actionRe);
|
|
@@ -4572,23 +5236,52 @@ export class FleetManager {
|
|
|
4572
5236
|
pending = undefined;
|
|
4573
5237
|
if (!match || !pending) {
|
|
4574
5238
|
const adapter = receivingAdapter ?? this.adapter;
|
|
4575
|
-
|
|
5239
|
+
if (staleHandling?.keepText && adapter?.removeMessageButtons) {
|
|
5240
|
+
adapter.removeMessageButtons(data.chatId, data.messageId, data.threadId)
|
|
5241
|
+
.catch(() => { });
|
|
5242
|
+
}
|
|
5243
|
+
else {
|
|
5244
|
+
adapter?.editMessageRemoveButtons?.(data.chatId, data.messageId, t("buttons.stale"), data.threadId).catch(() => { });
|
|
5245
|
+
}
|
|
5246
|
+
if (staleHandling?.notice) {
|
|
5247
|
+
adapter?.sendText(data.chatId, staleHandling.notice, { threadId: data.threadId })
|
|
5248
|
+
.catch(() => { });
|
|
5249
|
+
}
|
|
4576
5250
|
return "consumed";
|
|
4577
5251
|
}
|
|
4578
5252
|
// Bind the capability to the exact message/world that created it. Telegram
|
|
4579
|
-
// keyboards are visible to everyone, so
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
5253
|
+
// keyboards are visible to everyone, so mutating actions require fleet admin;
|
|
5254
|
+
// a Tip acknowledgement only records that the shared content was read.
|
|
5255
|
+
const isAuthorized = data.userId
|
|
5256
|
+
? pending.allowAnyUser
|
|
5257
|
+
? true
|
|
5258
|
+
: pending.authChannelId
|
|
5259
|
+
? this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
|
|
5260
|
+
: this.isFleetAdmin(data.userId, callbackAdapterId)
|
|
5261
|
+
: false;
|
|
5262
|
+
const mismatchedFields = [];
|
|
5263
|
+
if (pending.adapterId !== callbackAdapterId)
|
|
5264
|
+
mismatchedFields.push("adapterId");
|
|
5265
|
+
if (data.chatId !== pending.chatId)
|
|
5266
|
+
mismatchedFields.push("chatId");
|
|
5267
|
+
if (pending.threadId != null && data.threadId !== pending.threadId)
|
|
5268
|
+
mismatchedFields.push("threadId");
|
|
5269
|
+
if (pending.messageId != null && data.messageId !== pending.messageId)
|
|
5270
|
+
mismatchedFields.push("messageId");
|
|
5271
|
+
if (!isAuthorized)
|
|
5272
|
+
mismatchedFields.push("authorization");
|
|
5273
|
+
if (mismatchedFields.length > 0) {
|
|
4586
5274
|
// Deliberately does NOT consume the nonce: the real admin can still click.
|
|
4587
|
-
this.logger.warn({
|
|
5275
|
+
this.logger.warn({
|
|
5276
|
+
instanceName: pending.instanceName,
|
|
5277
|
+
prefix,
|
|
5278
|
+
userId: data.userId,
|
|
5279
|
+
mismatchedFields: mismatchedFields.join(","),
|
|
5280
|
+
}, "Rejected unauthorized or mismatched button callback");
|
|
4588
5281
|
return "consumed";
|
|
4589
5282
|
}
|
|
4590
|
-
// Claim before any await: double clicks and duplicate callback delivery
|
|
4591
|
-
// never act twice.
|
|
5283
|
+
// Claim before any await: double clicks and duplicate callback delivery
|
|
5284
|
+
// can never act twice for state-changing actions.
|
|
4592
5285
|
this.pendingNonceButtons.delete(match[1]);
|
|
4593
5286
|
if (pending.timer)
|
|
4594
5287
|
clearTimeout(pending.timer);
|
|
@@ -4609,6 +5302,270 @@ export class FleetManager {
|
|
|
4609
5302
|
this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons");
|
|
4610
5303
|
}
|
|
4611
5304
|
}
|
|
5305
|
+
tipsEnabled() {
|
|
5306
|
+
return this.fleetConfig?.defaults.tips !== false;
|
|
5307
|
+
}
|
|
5308
|
+
readTipState() {
|
|
5309
|
+
if (!this.scheduler)
|
|
5310
|
+
return null;
|
|
5311
|
+
try {
|
|
5312
|
+
return {
|
|
5313
|
+
dismissed: this.scheduler.db.listDismissedTipIds(),
|
|
5314
|
+
advancedUnlocked: this.scheduler.db.isAdvancedTipsUnlocked(),
|
|
5315
|
+
};
|
|
5316
|
+
}
|
|
5317
|
+
catch (err) {
|
|
5318
|
+
// Tips are additive. A damaged/locked scheduler DB must never block fleet
|
|
5319
|
+
// startup, update completion, or a General command.
|
|
5320
|
+
this.logger.warn({ err }, "Failed to read tip state");
|
|
5321
|
+
return null;
|
|
5322
|
+
}
|
|
5323
|
+
}
|
|
5324
|
+
pickAvailableTip() {
|
|
5325
|
+
const state = this.readTipState();
|
|
5326
|
+
return state
|
|
5327
|
+
? selectTip(state.dismissed, Math.random, state.advancedUnlocked, this.getActiveBackendIds())
|
|
5328
|
+
: null;
|
|
5329
|
+
}
|
|
5330
|
+
tipText(tip) {
|
|
5331
|
+
return getLocale() === "zh-TW" ? tip.text_zh : tip.text_en;
|
|
5332
|
+
}
|
|
5333
|
+
formatTip(tip) {
|
|
5334
|
+
return `💡 ${t("tips.label")}: ${this.tipText(tip)}`;
|
|
5335
|
+
}
|
|
5336
|
+
/** Persistently enable advanced tips through the admin-only slash command. */
|
|
5337
|
+
unlockAdvancedTips(userId) {
|
|
5338
|
+
if (!this.scheduler)
|
|
5339
|
+
return false;
|
|
5340
|
+
try {
|
|
5341
|
+
this.scheduler.db.unlockAdvancedTips(userId);
|
|
5342
|
+
this.eventLog?.insert(this.findGeneralInstance() ?? "general", "tips_advanced_unlocked", {
|
|
5343
|
+
userId,
|
|
5344
|
+
source: "command",
|
|
5345
|
+
});
|
|
5346
|
+
return true;
|
|
5347
|
+
}
|
|
5348
|
+
catch (err) {
|
|
5349
|
+
this.logger.warn({ err, userId }, "Failed to unlock advanced tips from command");
|
|
5350
|
+
return false;
|
|
5351
|
+
}
|
|
5352
|
+
}
|
|
5353
|
+
/** Post one fresh nonce-armed tip in a known General channel. */
|
|
5354
|
+
async promptTip(generalName, adapter, chatId, threadId) {
|
|
5355
|
+
const state = this.readTipState();
|
|
5356
|
+
if (!state)
|
|
5357
|
+
return "unavailable";
|
|
5358
|
+
if (!state.advancedUnlocked
|
|
5359
|
+
&& visibleTipLevels(true).has("advanced")
|
|
5360
|
+
&& canUnlockAdvancedTips(state.dismissed)) {
|
|
5361
|
+
return await this.promptAdvancedTipUnlock(generalName, adapter, chatId, threadId)
|
|
5362
|
+
? "posted"
|
|
5363
|
+
: "unavailable";
|
|
5364
|
+
}
|
|
5365
|
+
const tip = selectTip(state.dismissed, Math.random, state.advancedUnlocked, this.getActiveBackendIds());
|
|
5366
|
+
if (!tip)
|
|
5367
|
+
return this.scheduler ? "empty" : "unavailable";
|
|
5368
|
+
const nonce = await this.postNonceButtonPrompt({
|
|
5369
|
+
prefix: TIP_DISMISS_CALLBACK_PREFIX,
|
|
5370
|
+
alertType: "tip",
|
|
5371
|
+
instanceName: generalName,
|
|
5372
|
+
adapter,
|
|
5373
|
+
adapterId: adapter.id,
|
|
5374
|
+
chatId,
|
|
5375
|
+
threadId,
|
|
5376
|
+
message: this.formatTip(tip),
|
|
5377
|
+
choices: [
|
|
5378
|
+
{ action: "dismiss", label: t("tips.dismiss") },
|
|
5379
|
+
{ action: "confused", label: t("tips.confused") },
|
|
5380
|
+
],
|
|
5381
|
+
// Expiry removes only the stale button; the useful tip remains readable.
|
|
5382
|
+
expiredText: this.formatTip(tip),
|
|
5383
|
+
extra: { allowAnyUser: true, tipId: tip.id },
|
|
5384
|
+
timeoutMs: TIP_BUTTON_TIMEOUT_MS,
|
|
5385
|
+
});
|
|
5386
|
+
return nonce ? "posted" : "unavailable";
|
|
5387
|
+
}
|
|
5388
|
+
async promptAdvancedTipUnlock(generalName, adapter, chatId, threadId) {
|
|
5389
|
+
const nonce = await this.postNonceButtonPrompt({
|
|
5390
|
+
prefix: TIP_UNLOCK_CALLBACK_PREFIX,
|
|
5391
|
+
alertType: "tip",
|
|
5392
|
+
instanceName: generalName,
|
|
5393
|
+
adapter,
|
|
5394
|
+
adapterId: adapter.id,
|
|
5395
|
+
chatId,
|
|
5396
|
+
threadId,
|
|
5397
|
+
message: t("tips.advanced.unlock_prompt"),
|
|
5398
|
+
choices: [{ action: "unlock", label: t("tips.advanced.unlock") }],
|
|
5399
|
+
expiredText: t("tips.advanced.expired"),
|
|
5400
|
+
extra: { allowAnyUser: true },
|
|
5401
|
+
timeoutMs: TIP_BUTTON_TIMEOUT_MS,
|
|
5402
|
+
});
|
|
5403
|
+
return nonce !== null;
|
|
5404
|
+
}
|
|
5405
|
+
async sendTipToGeneral() {
|
|
5406
|
+
if (!this.tipsEnabled())
|
|
5407
|
+
return;
|
|
5408
|
+
const generalName = this.findGeneralInstance();
|
|
5409
|
+
if (!generalName)
|
|
5410
|
+
return;
|
|
5411
|
+
const adapter = this.getAdapterForInstance(generalName);
|
|
5412
|
+
const chatId = this.getGroupIdForInstance(generalName);
|
|
5413
|
+
const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
|
|
5414
|
+
if (!adapter || !chatId)
|
|
5415
|
+
return;
|
|
5416
|
+
const result = await this.promptTip(generalName, adapter, chatId, topicId != null ? String(topicId) : undefined);
|
|
5417
|
+
if (result === "unavailable") {
|
|
5418
|
+
this.logger.warn({ generalName }, "Daily tip could not be posted");
|
|
5419
|
+
}
|
|
5420
|
+
}
|
|
5421
|
+
async handleTipDismiss(data, callbackAdapterId, receivingAdapter) {
|
|
5422
|
+
const claimed = this.consumeNonceCallback(TIP_DISMISS_CALLBACK_PREFIX, /^tip-dismiss:([0-9a-f]+):(dismiss|confused)$/, data, callbackAdapterId, receivingAdapter,
|
|
5423
|
+
// A restart orphans the expiry timer, so tip buttons can outlive their
|
|
5424
|
+
// nonce by days. Keep the tip text readable and always tell the clicker
|
|
5425
|
+
// what to do next (the keyboard edit itself can be refused: Telegram
|
|
5426
|
+
// rejects edits on messages older than 48h).
|
|
5427
|
+
{ keepText: true, notice: t("tips.expired_use_tips") });
|
|
5428
|
+
if (claimed === null)
|
|
5429
|
+
return false;
|
|
5430
|
+
if (claimed === "consumed")
|
|
5431
|
+
return true;
|
|
5432
|
+
const { entry: pending } = claimed;
|
|
5433
|
+
if (claimed.action === "confused") {
|
|
5434
|
+
if (!pending.tipId || !data.userId || !this.scheduler) {
|
|
5435
|
+
this.logger.error({ tipId: pending.tipId, userId: data.userId }, "Tip feedback has incomplete persistence context");
|
|
5436
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, pending.expiredText);
|
|
5437
|
+
await pending.adapter.sendText(pending.chatId, t("tips.unavailable"), { threadId: pending.threadId });
|
|
5438
|
+
return true;
|
|
5439
|
+
}
|
|
5440
|
+
try {
|
|
5441
|
+
this.scheduler.db.recordTipFeedback(data.userId, pending.tipId, "confused");
|
|
5442
|
+
}
|
|
5443
|
+
catch (err) {
|
|
5444
|
+
this.logger.warn({ err, tipId: pending.tipId, userId: data.userId }, "Failed to persist tip feedback");
|
|
5445
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, pending.expiredText);
|
|
5446
|
+
await pending.adapter.sendText(pending.chatId, t("tips.unavailable"), { threadId: pending.threadId }).catch(() => { });
|
|
5447
|
+
return true;
|
|
5448
|
+
}
|
|
5449
|
+
this.logger.info({
|
|
5450
|
+
tipId: pending.tipId,
|
|
5451
|
+
userId: data.userId,
|
|
5452
|
+
feedbackType: "confused",
|
|
5453
|
+
}, "Tip feedback recorded");
|
|
5454
|
+
this.eventLog?.insert(pending.instanceName, "tip_feedback", {
|
|
5455
|
+
tipId: pending.tipId,
|
|
5456
|
+
userId: data.userId,
|
|
5457
|
+
feedbackType: "confused",
|
|
5458
|
+
});
|
|
5459
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId,
|
|
5460
|
+
// Confusion is feedback, not a dismissal. Keep the Tip readable while
|
|
5461
|
+
// retiring this one-shot prompt; it remains eligible for future draws.
|
|
5462
|
+
pending.expiredText);
|
|
5463
|
+
await pending.adapter.sendText(pending.chatId, t("tips.feedback_recorded"), { threadId: pending.threadId }).catch(err => this.logger.warn({ err, tipId: pending.tipId }, "Failed to acknowledge tip feedback"));
|
|
5464
|
+
return true;
|
|
5465
|
+
}
|
|
5466
|
+
if (!pending.tipId || !data.userId || !this.scheduler) {
|
|
5467
|
+
this.logger.error({ tipId: pending.tipId, userId: data.userId }, "Tip dismissal has incomplete persistence context");
|
|
5468
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
|
|
5469
|
+
return true;
|
|
5470
|
+
}
|
|
5471
|
+
try {
|
|
5472
|
+
this.scheduler.db.dismissTip(data.userId, pending.tipId);
|
|
5473
|
+
}
|
|
5474
|
+
catch (err) {
|
|
5475
|
+
this.logger.warn({ err, tipId: pending.tipId, userId: data.userId }, "Failed to persist tip dismissal");
|
|
5476
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
|
|
5477
|
+
return true;
|
|
5478
|
+
}
|
|
5479
|
+
this.eventLog?.insert(pending.instanceName, "tip_dismissed", {
|
|
5480
|
+
tipId: pending.tipId,
|
|
5481
|
+
userId: data.userId,
|
|
5482
|
+
});
|
|
5483
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId,
|
|
5484
|
+
// Dismissal changes future selection only. Keep the useful tip text in
|
|
5485
|
+
// chat history and remove just the now-consumed button. Append a short
|
|
5486
|
+
// confirmation so the user sees feedback (unlike the pre-#605 version
|
|
5487
|
+
// that replaced the entire message with just the confirmation line).
|
|
5488
|
+
`${pending.expiredText}\n\n${t("tips.dismissed")}`);
|
|
5489
|
+
const state = this.readTipState();
|
|
5490
|
+
if (state && !state.advancedUnlocked && canUnlockAdvancedTips(state.dismissed)) {
|
|
5491
|
+
await this.promptAdvancedTipUnlock(pending.instanceName, pending.adapter, pending.chatId, pending.threadId);
|
|
5492
|
+
}
|
|
5493
|
+
return true;
|
|
5494
|
+
}
|
|
5495
|
+
async handleTipUnlock(data, callbackAdapterId, receivingAdapter) {
|
|
5496
|
+
const claimed = this.consumeNonceCallback(TIP_UNLOCK_CALLBACK_PREFIX, /^tip-unlock:([0-9a-f]+):(unlock)$/, data, callbackAdapterId, receivingAdapter, { keepText: true, notice: t("tips.expired_use_tips") });
|
|
5497
|
+
if (claimed === null)
|
|
5498
|
+
return false;
|
|
5499
|
+
if (claimed === "consumed")
|
|
5500
|
+
return true;
|
|
5501
|
+
const { entry: pending } = claimed;
|
|
5502
|
+
if (!data.userId || !this.scheduler) {
|
|
5503
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
|
|
5504
|
+
return true;
|
|
5505
|
+
}
|
|
5506
|
+
try {
|
|
5507
|
+
this.scheduler.db.unlockAdvancedTips(data.userId);
|
|
5508
|
+
}
|
|
5509
|
+
catch (err) {
|
|
5510
|
+
this.logger.warn({ err, userId: data.userId }, "Failed to unlock advanced tips");
|
|
5511
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.unavailable"));
|
|
5512
|
+
return true;
|
|
5513
|
+
}
|
|
5514
|
+
this.eventLog?.insert(pending.instanceName, "tips_advanced_unlocked", { userId: data.userId });
|
|
5515
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("tips.advanced.unlocked"));
|
|
5516
|
+
return true;
|
|
5517
|
+
}
|
|
5518
|
+
/** Post the destructive `/clear` confirmation in the invoking channel. */
|
|
5519
|
+
async promptClearConfirmation(instanceName, channelId, adapter, chatId, threadId) {
|
|
5520
|
+
if (!this.topicCommands.supportsClear(instanceName))
|
|
5521
|
+
return t("clear.unsupported");
|
|
5522
|
+
const adapterId = adapter.id;
|
|
5523
|
+
if (!adapterId)
|
|
5524
|
+
return t("clear.prompt_unavailable");
|
|
5525
|
+
const nonce = await this.postNonceButtonPrompt({
|
|
5526
|
+
prefix: CLEAR_CONFIRM_CALLBACK_PREFIX,
|
|
5527
|
+
alertType: "clear_confirm",
|
|
5528
|
+
instanceName,
|
|
5529
|
+
adapter,
|
|
5530
|
+
adapterId,
|
|
5531
|
+
chatId,
|
|
5532
|
+
threadId,
|
|
5533
|
+
message: t("clear.confirm_message", instanceName),
|
|
5534
|
+
choices: [
|
|
5535
|
+
{ action: "confirm", label: t("clear.confirm") },
|
|
5536
|
+
{ action: "cancel", label: t("clear.cancel") },
|
|
5537
|
+
],
|
|
5538
|
+
expiredText: t("clear.expired", instanceName),
|
|
5539
|
+
extra: { authChannelId: channelId },
|
|
5540
|
+
timeoutMs: CLEAR_CONFIRM_TIMEOUT_MS,
|
|
5541
|
+
});
|
|
5542
|
+
return nonce ? null : t("clear.prompt_unavailable");
|
|
5543
|
+
}
|
|
5544
|
+
/** Consume `/clear` Confirm/Cancel exactly once; only Confirm reaches IPC. */
|
|
5545
|
+
async handleClearConfirmation(data, callbackAdapterId, receivingAdapter) {
|
|
5546
|
+
const claimed = this.consumeNonceCallback(CLEAR_CONFIRM_CALLBACK_PREFIX, /^clear-confirm:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
|
|
5547
|
+
if (claimed === null)
|
|
5548
|
+
return false;
|
|
5549
|
+
if (claimed === "consumed")
|
|
5550
|
+
return true;
|
|
5551
|
+
const { entry: pending, action } = claimed;
|
|
5552
|
+
this.eventLog?.insert(pending.instanceName, "clear_action", { action, userId: data.userId });
|
|
5553
|
+
if (action === "cancel") {
|
|
5554
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.cancelled", pending.instanceName));
|
|
5555
|
+
return true;
|
|
5556
|
+
}
|
|
5557
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.clearing", pending.instanceName));
|
|
5558
|
+
try {
|
|
5559
|
+
const result = await this.topicCommands.sendClear(pending.instanceName);
|
|
5560
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, result, pending.threadId);
|
|
5561
|
+
}
|
|
5562
|
+
catch (err) {
|
|
5563
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5564
|
+
this.logger.error({ err, instanceName: pending.instanceName }, "Clear command failed");
|
|
5565
|
+
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"));
|
|
5566
|
+
}
|
|
5567
|
+
return true;
|
|
5568
|
+
}
|
|
4612
5569
|
/**
|
|
4613
5570
|
* Drop every pending prompt that refers to an instance being stopped or
|
|
4614
5571
|
* restarted — a restart offer for an instance the operator just restarted
|
|
@@ -4835,7 +5792,7 @@ export class FleetManager {
|
|
|
4835
5792
|
const force = target.kind === "classic" && !!data.options?.force;
|
|
4836
5793
|
const cmd = saveCommandForBackend(backend, filename, force);
|
|
4837
5794
|
if (!cmd) {
|
|
4838
|
-
await data.respond(
|
|
5795
|
+
await data.respond(t("save.unsupported"));
|
|
4839
5796
|
return;
|
|
4840
5797
|
}
|
|
4841
5798
|
if (target.kind === "classic") {
|
|
@@ -4888,7 +5845,7 @@ export class FleetManager {
|
|
|
4888
5845
|
}
|
|
4889
5846
|
return false;
|
|
4890
5847
|
}
|
|
4891
|
-
async sendCancelButton(instanceName, correlationId) {
|
|
5848
|
+
async sendCancelButton(instanceName, correlationId, preserveProgress = false) {
|
|
4892
5849
|
// Post first, retire after (see the tail of this method). Retiring up front
|
|
4893
5850
|
// meant that from the delete until the new message came back — a chat API
|
|
4894
5851
|
// round trip, and every reply goes through here — the instance had NO live
|
|
@@ -4949,6 +5906,10 @@ export class FleetManager {
|
|
|
4949
5906
|
// not re-edit identical text — which put a "(edited)" mark on Discord
|
|
4950
5907
|
// with nothing visibly changed.
|
|
4951
5908
|
lastProgressText: "👀 處理中…",
|
|
5909
|
+
// A reply can re-post the same in-flight bubble below the reply. Carry
|
|
5910
|
+
// its current list into that replacement; fresh inbound work must start
|
|
5911
|
+
// empty even if the daemon's reset broadcast is still in flight.
|
|
5912
|
+
toolProgress: preserveProgress ? this.instanceProgress.get(instanceName) : undefined,
|
|
4952
5913
|
};
|
|
4953
5914
|
this.startProgressTicker(entry);
|
|
4954
5915
|
// Idle-check backstop: every 5min, if the instance is idle, retire the
|
|
@@ -5002,16 +5963,28 @@ export class FleetManager {
|
|
|
5002
5963
|
static progressText(elapsedMs, activity, minElapsedMs = PROGRESS_MIN_ELAPSED_MS) {
|
|
5003
5964
|
if (elapsedMs < minElapsedMs)
|
|
5004
5965
|
return "👀 處理中…";
|
|
5966
|
+
const elapsed = FleetManager.formatProgressElapsed(elapsedMs);
|
|
5967
|
+
const detail = FleetManager.sanitizeActivity(activity);
|
|
5968
|
+
return detail
|
|
5969
|
+
? `⏳ 處理中… (已進行 ${elapsed} · ${detail})`
|
|
5970
|
+
: `⏳ 處理中… (已進行 ${elapsed})`;
|
|
5971
|
+
}
|
|
5972
|
+
/** Render elapsed time consistently in live and retained progress bubbles. */
|
|
5973
|
+
static formatProgressElapsed(elapsedMs) {
|
|
5005
5974
|
const totalSeconds = Math.floor(elapsedMs / 1000);
|
|
5006
5975
|
const minutes = Math.floor(totalSeconds / 60);
|
|
5007
5976
|
const seconds = totalSeconds % 60;
|
|
5008
|
-
|
|
5977
|
+
return minutes >= 60
|
|
5009
5978
|
? `${Math.floor(minutes / 60)}h ${minutes % 60}m`
|
|
5010
5979
|
: `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5980
|
+
}
|
|
5981
|
+
/** Final read-only form of a bubble that contains semantic tool history.
|
|
5982
|
+
* Neutral wording is intentional: the same retirement primitive is used for
|
|
5983
|
+
* normal completion, a mid-turn bubble replacement, and user cancellation,
|
|
5984
|
+
* so claiming every retained checkpoint is "completed" would be false. */
|
|
5985
|
+
composeRetiredBubbleText(entry) {
|
|
5986
|
+
const elapsed = FleetManager.formatProgressElapsed(Date.now() - (entry.startedAt ?? Date.now()));
|
|
5987
|
+
return `🧾 工具歷程 (記錄至 ${elapsed})\n${entry.toolProgress}`;
|
|
5015
5988
|
}
|
|
5016
5989
|
/**
|
|
5017
5990
|
* Make a tool summary safe to paste into a channel message.
|
|
@@ -5044,6 +6017,88 @@ export class FleetManager {
|
|
|
5044
6017
|
else
|
|
5045
6018
|
this.instanceActivity.delete(name);
|
|
5046
6019
|
}
|
|
6020
|
+
/**
|
|
6021
|
+
* Cache the turn's tool-progress list and push it into the instance's live
|
|
6022
|
+
* bubble, coalesced so a burst of tool events cannot flood the Bot API
|
|
6023
|
+
* (Telegram's flood limit is per-chat across ALL forum topics, so edits are
|
|
6024
|
+
* rate-limited per bubble AND ride behind the daemon-side 3s coalescer).
|
|
6025
|
+
*/
|
|
6026
|
+
cacheInstanceProgress(name, progress) {
|
|
6027
|
+
if (progress)
|
|
6028
|
+
this.instanceProgress.set(name, progress);
|
|
6029
|
+
else
|
|
6030
|
+
this.instanceProgress.delete(name);
|
|
6031
|
+
for (const entry of this.cancelButtons.values()) {
|
|
6032
|
+
if (entry.instanceName !== name)
|
|
6033
|
+
continue;
|
|
6034
|
+
// Empty means the daemon completed/reset the turn. Clear the live cache
|
|
6035
|
+
// for the next turn, but retain this bubble's last non-empty list so its
|
|
6036
|
+
// retirement can preserve the history instead of deleting the message.
|
|
6037
|
+
if (progress)
|
|
6038
|
+
entry.toolProgress = progress;
|
|
6039
|
+
this.scheduleProgressEdit(entry);
|
|
6040
|
+
}
|
|
6041
|
+
}
|
|
6042
|
+
/** At most one progress-driven edit per bubble per TOOL_PROGRESS_EDIT_MIN_MS. */
|
|
6043
|
+
scheduleProgressEdit(entry) {
|
|
6044
|
+
if (entry.retiring)
|
|
6045
|
+
return;
|
|
6046
|
+
const since = Date.now() - (entry.lastProgressEditAt ?? 0);
|
|
6047
|
+
if (since >= TOOL_PROGRESS_EDIT_MIN_MS) {
|
|
6048
|
+
this.refreshBubble(entry);
|
|
6049
|
+
return;
|
|
6050
|
+
}
|
|
6051
|
+
if (entry.progressEditTimer)
|
|
6052
|
+
return; // trailing edit already scheduled
|
|
6053
|
+
entry.progressEditTimer = setTimeout(() => {
|
|
6054
|
+
entry.progressEditTimer = undefined;
|
|
6055
|
+
this.refreshBubble(entry);
|
|
6056
|
+
}, TOOL_PROGRESS_EDIT_MIN_MS - since);
|
|
6057
|
+
entry.progressEditTimer.unref?.();
|
|
6058
|
+
}
|
|
6059
|
+
/**
|
|
6060
|
+
* The ONE composer for the bubble text. Both writers — the elapsed-time
|
|
6061
|
+
* ticker and the tool-progress push — go through here; two independent
|
|
6062
|
+
* renderers editing the same message is how the progress list used to get
|
|
6063
|
+
* wiped by the next elapsed tick (#528 trap 2).
|
|
6064
|
+
*/
|
|
6065
|
+
composeBubbleText(entry) {
|
|
6066
|
+
return FleetManager.bubbleText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs(), entry.toolProgress);
|
|
6067
|
+
}
|
|
6068
|
+
/** Pure composition of header + tool list, exposed for tests. */
|
|
6069
|
+
static bubbleText(elapsedMs, activity, minElapsedMs, progress) {
|
|
6070
|
+
const header = FleetManager.progressText(elapsedMs,
|
|
6071
|
+
// The single-line activity detail is redundant once a tool list exists.
|
|
6072
|
+
progress ? undefined : activity, minElapsedMs);
|
|
6073
|
+
return progress ? `${header}\n${progress}` : header;
|
|
6074
|
+
}
|
|
6075
|
+
/** Recompose and edit the bubble in place; skips when nothing changed. */
|
|
6076
|
+
refreshBubble(entry) {
|
|
6077
|
+
if (entry.retiring || !this.cancelButtons.has(entry.messageId)) {
|
|
6078
|
+
clearInterval(entry.progressTimer);
|
|
6079
|
+
return;
|
|
6080
|
+
}
|
|
6081
|
+
const text = this.composeBubbleText(entry);
|
|
6082
|
+
if (text === entry.lastProgressText)
|
|
6083
|
+
return; // nothing changed — skip the API call
|
|
6084
|
+
const adapter = this.getAdapterForInstance(entry.instanceName) ?? this.adapter;
|
|
6085
|
+
if (!adapter?.editAlert)
|
|
6086
|
+
return;
|
|
6087
|
+
entry.lastProgressText = text;
|
|
6088
|
+
entry.lastProgressEditAt = Date.now();
|
|
6089
|
+
adapter.editAlert(entry.chatId, entry.messageId, {
|
|
6090
|
+
type: "cancel",
|
|
6091
|
+
instanceName: entry.instanceName,
|
|
6092
|
+
message: text,
|
|
6093
|
+
choices: [{ id: `cancel:${entry.instanceName}`, label: t("cancel.button") }],
|
|
6094
|
+
}, entry.threadId ? { threadId: entry.threadId } : undefined)
|
|
6095
|
+
.catch(err => {
|
|
6096
|
+
// A failed progress edit must never escalate: the button still works and
|
|
6097
|
+
// the next tick retries. Common causes are a deleted message or a
|
|
6098
|
+
// rate limit.
|
|
6099
|
+
this.logger.debug({ err, instanceName: entry.instanceName }, "Progress edit failed");
|
|
6100
|
+
});
|
|
6101
|
+
}
|
|
5047
6102
|
/**
|
|
5048
6103
|
* Refresh the button's text in place while the instance keeps working.
|
|
5049
6104
|
*
|
|
@@ -5061,31 +6116,7 @@ export class FleetManager {
|
|
|
5061
6116
|
return PROGRESS_MIN_ELAPSED_MS;
|
|
5062
6117
|
}
|
|
5063
6118
|
startProgressTicker(entry) {
|
|
5064
|
-
const tick = () =>
|
|
5065
|
-
if (!this.cancelButtons.has(entry.messageId)) {
|
|
5066
|
-
clearInterval(entry.progressTimer);
|
|
5067
|
-
return;
|
|
5068
|
-
}
|
|
5069
|
-
const text = FleetManager.progressText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs());
|
|
5070
|
-
if (text === entry.lastProgressText)
|
|
5071
|
-
return; // nothing changed — skip the API call
|
|
5072
|
-
const adapter = this.getAdapterForInstance(entry.instanceName) ?? this.adapter;
|
|
5073
|
-
if (!adapter?.editAlert)
|
|
5074
|
-
return;
|
|
5075
|
-
entry.lastProgressText = text;
|
|
5076
|
-
adapter.editAlert(entry.chatId, entry.messageId, {
|
|
5077
|
-
type: "cancel",
|
|
5078
|
-
instanceName: entry.instanceName,
|
|
5079
|
-
message: text,
|
|
5080
|
-
choices: [{ id: `cancel:${entry.instanceName}`, label: t("cancel.button") }],
|
|
5081
|
-
}, entry.threadId ? { threadId: entry.threadId } : undefined)
|
|
5082
|
-
.catch(err => {
|
|
5083
|
-
// A failed progress edit must never escalate: the button still works and
|
|
5084
|
-
// the next tick retries. Common causes are a deleted message or a
|
|
5085
|
-
// rate limit.
|
|
5086
|
-
this.logger.debug({ err, instanceName: entry.instanceName }, "Progress edit failed");
|
|
5087
|
-
});
|
|
5088
|
-
};
|
|
6119
|
+
const tick = () => this.refreshBubble(entry);
|
|
5089
6120
|
entry.progressTimer = setInterval(tick, PROGRESS_UPDATE_INTERVAL_MS);
|
|
5090
6121
|
entry.progressTimer.unref?.();
|
|
5091
6122
|
// One extra tick right when the threshold passes, so a 30s threshold shows
|
|
@@ -5099,8 +6130,11 @@ export class FleetManager {
|
|
|
5099
6130
|
}
|
|
5100
6131
|
/**
|
|
5101
6132
|
* After a reply: give the instance REPLY_RETIRE_GRACE_MS to resume working; if
|
|
5102
|
-
* it has not, retire its button.
|
|
5103
|
-
*
|
|
6133
|
+
* it has not, retire its button. The daemon is asked for a fresh pane capture
|
|
6134
|
+
* before deciding. Reading only the transition cache stranded the first
|
|
6135
|
+
* post-restart bubble when its startup "working" report never got a matching
|
|
6136
|
+
* idle edge. Re-arming replaces the previous timer, so a burst of replies ends
|
|
6137
|
+
* with exactly one pending check.
|
|
5104
6138
|
*/
|
|
5105
6139
|
armReplyGrace(instanceName) {
|
|
5106
6140
|
for (const entry of this.cancelButtons.values()) {
|
|
@@ -5112,14 +6146,26 @@ export class FleetManager {
|
|
|
5112
6146
|
entry.replyGraceTimer = undefined;
|
|
5113
6147
|
if (!this.cancelButtons.has(entry.messageId))
|
|
5114
6148
|
return;
|
|
5115
|
-
|
|
5116
|
-
return; // resumed — a long run keeps its button
|
|
5117
|
-
this.logger.info({ instanceName, messageId: entry.messageId }, "Cancel button retired — no work resumed after reply");
|
|
5118
|
-
this.retireButton(entry);
|
|
6149
|
+
void this.finishReplyGrace(instanceName, entry);
|
|
5119
6150
|
}, REPLY_RETIRE_GRACE_MS);
|
|
5120
6151
|
entry.replyGraceTimer.unref?.();
|
|
5121
6152
|
}
|
|
5122
6153
|
}
|
|
6154
|
+
async finishReplyGrace(instanceName, entry) {
|
|
6155
|
+
const refreshed = await this.refreshInstanceExecutionState(instanceName, REPLY_STATE_REFRESH_TIMEOUT_MS);
|
|
6156
|
+
if (!this.cancelButtons.has(entry.messageId) || entry.retiring)
|
|
6157
|
+
return;
|
|
6158
|
+
if (!refreshed) {
|
|
6159
|
+
// Fail safe: without an authoritative answer, retain a potentially live
|
|
6160
|
+
// Cancel button. The 5-minute/30-minute/24-hour safety nets still apply.
|
|
6161
|
+
this.logger.debug({ instanceName, messageId: entry.messageId }, "Cancel reply-grace state refresh timed out");
|
|
6162
|
+
return;
|
|
6163
|
+
}
|
|
6164
|
+
if (!this.getInstanceIdle(instanceName))
|
|
6165
|
+
return; // genuine long run keeps its button
|
|
6166
|
+
this.logger.info({ instanceName, messageId: entry.messageId }, "Cancel button retired — no work resumed after reply");
|
|
6167
|
+
this.retireButton(entry);
|
|
6168
|
+
}
|
|
5123
6169
|
/** Retire (delete) every cancel button belonging to an instance. */
|
|
5124
6170
|
retireInstanceButtons(instanceName) {
|
|
5125
6171
|
// Snapshot first — retireButton may delete entries from the map on success.
|
|
@@ -5141,7 +6187,7 @@ export class FleetManager {
|
|
|
5141
6187
|
this.deleteButtonMessage(entry)
|
|
5142
6188
|
.then(() => {
|
|
5143
6189
|
this.discardButton(entry);
|
|
5144
|
-
this.logger.info({ instanceName: entry.instanceName, messageId: entry.messageId }, "Cancel button
|
|
6190
|
+
this.logger.info({ instanceName: entry.instanceName, messageId: entry.messageId, historyPreserved: Boolean(entry.toolProgress) }, "Cancel button retired");
|
|
5145
6191
|
})
|
|
5146
6192
|
.catch((err) => this.scheduleButtonRetry(entry, err));
|
|
5147
6193
|
}
|
|
@@ -5153,6 +6199,8 @@ export class FleetManager {
|
|
|
5153
6199
|
clearInterval(entry.idleCheckTimer);
|
|
5154
6200
|
if (entry.progressTimer)
|
|
5155
6201
|
clearInterval(entry.progressTimer);
|
|
6202
|
+
if (entry.progressEditTimer)
|
|
6203
|
+
clearTimeout(entry.progressEditTimer);
|
|
5156
6204
|
if (entry.replyGraceTimer)
|
|
5157
6205
|
clearTimeout(entry.replyGraceTimer);
|
|
5158
6206
|
this.cancelButtons.delete(entry.messageId);
|
|
@@ -5230,12 +6278,21 @@ export class FleetManager {
|
|
|
5230
6278
|
// Continue the same retire cycle (bypass the retiring-guard in retireButton).
|
|
5231
6279
|
entry.retryTimer = setTimeout(() => this.attemptButtonDelete(entry), CANCEL_BTN_RETRY_INTERVAL_MS);
|
|
5232
6280
|
}
|
|
5233
|
-
/**
|
|
6281
|
+
/** Retire one cancel bubble via its own adapter. A bubble that accumulated
|
|
6282
|
+
* tool progress is kept as a read-only history message; a plain bubble keeps
|
|
6283
|
+
* the legacy delete/edit-to-checkmark behavior. Resolves on success and
|
|
5234
6284
|
* rejects on failure so the caller can retry. */
|
|
5235
6285
|
deleteButtonMessage(e) {
|
|
5236
6286
|
const adapter = (e.adapterId ? this.worlds.get(e.adapterId)?.adapter : undefined) ?? this.adapter;
|
|
5237
6287
|
if (!adapter)
|
|
5238
6288
|
return Promise.reject(new Error("no adapter for cancel button"));
|
|
6289
|
+
if (e.toolProgress && adapter.editMessageRemoveButtons) {
|
|
6290
|
+
return adapter.editMessageRemoveButtons(e.chatId, e.messageId, this.composeRetiredBubbleText(e), e.threadId);
|
|
6291
|
+
}
|
|
6292
|
+
// All production adapters currently implement editMessageRemoveButtons.
|
|
6293
|
+
// A third-party adapter without it degrades to the legacy delete behavior:
|
|
6294
|
+
// removing a live Cancel control is safer than retaining an actionable,
|
|
6295
|
+
// untracked button forever.
|
|
5239
6296
|
if (adapter.deleteMessage)
|
|
5240
6297
|
return adapter.deleteMessage(e.chatId, e.messageId, e.threadId);
|
|
5241
6298
|
if (adapter.editMessageRemoveButtons)
|
|
@@ -5287,11 +6344,457 @@ export class FleetManager {
|
|
|
5287
6344
|
const daemon = this.daemons.get(instanceName);
|
|
5288
6345
|
if (!daemon)
|
|
5289
6346
|
return false;
|
|
6347
|
+
const deliveryEpoch = this.cancelPendingDeliveries(instanceName);
|
|
6348
|
+
daemon.clearPendingDeliveries?.(deliveryEpoch);
|
|
5290
6349
|
daemon.sendEscape().catch(e => this.logger.warn({ err: e, instanceName }, "sendEscape failed"));
|
|
5291
6350
|
this.lastInboundMsg.delete(instanceName);
|
|
5292
6351
|
this.clearCancelButton(instanceName);
|
|
5293
6352
|
return true;
|
|
5294
6353
|
}
|
|
6354
|
+
getDeliveryEpoch(instanceName) {
|
|
6355
|
+
return this.deliveryEpochs.get(instanceName) ?? 0;
|
|
6356
|
+
}
|
|
6357
|
+
isDeliveryEpochCurrent(instanceName, deliveryEpoch) {
|
|
6358
|
+
return deliveryEpoch === this.getDeliveryEpoch(instanceName);
|
|
6359
|
+
}
|
|
6360
|
+
/** Invalidate work queued before a user cancel and wake idle-gate waiters. */
|
|
6361
|
+
cancelPendingDeliveries(instanceName) {
|
|
6362
|
+
const next = this.getDeliveryEpoch(instanceName) + 1;
|
|
6363
|
+
this.deliveryEpochs.set(instanceName, next);
|
|
6364
|
+
for (const check of this.instanceIdleWaiters.get(instanceName) ?? [])
|
|
6365
|
+
check();
|
|
6366
|
+
return next;
|
|
6367
|
+
}
|
|
6368
|
+
// ── Remote CLI login (`/login`) ──────────────────────────────────────────
|
|
6369
|
+
//
|
|
6370
|
+
// One session at a time, in a dedicated tmux window — never an instance
|
|
6371
|
+
// pane. Credentials are per-backend (codex homes symlink auth.json to the
|
|
6372
|
+
// shared ~/.codex; the other CLIs use one real home), so a single sign-in
|
|
6373
|
+
// repairs every instance of that backend, and instance delivery, pane-state
|
|
6374
|
+
// detection, tool progress, and mcp_proxy_reply never observe login output.
|
|
6375
|
+
activeLogin = null;
|
|
6376
|
+
/** Post the backend chooser for a bare `/login`. Caller enforces admin. */
|
|
6377
|
+
async promptLoginBackends(chat) {
|
|
6378
|
+
const configured = new Set();
|
|
6379
|
+
for (const [, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
|
|
6380
|
+
configured.add(config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
|
|
6381
|
+
}
|
|
6382
|
+
const choices = Object.keys(LOGIN_FLOWS)
|
|
6383
|
+
.filter(backend => configured.size === 0 || configured.has(backend))
|
|
6384
|
+
.map(backend => ({ action: backend, label: backend }));
|
|
6385
|
+
if (choices.length === 0) {
|
|
6386
|
+
await chat.adapter.sendText(chat.chatId, t("login.unsupported", [...configured].join(", ")), { threadId: chat.threadId });
|
|
6387
|
+
return;
|
|
6388
|
+
}
|
|
6389
|
+
await this.postNonceButtonPrompt({
|
|
6390
|
+
prefix: LOGIN_CALLBACK_PREFIX,
|
|
6391
|
+
alertType: "login",
|
|
6392
|
+
instanceName: "login",
|
|
6393
|
+
adapter: chat.adapter,
|
|
6394
|
+
adapterId: chat.adapterId,
|
|
6395
|
+
chatId: chat.chatId,
|
|
6396
|
+
threadId: chat.threadId,
|
|
6397
|
+
message: t("login.choose_backend"),
|
|
6398
|
+
choices,
|
|
6399
|
+
expiredText: t("buttons.stale"),
|
|
6400
|
+
});
|
|
6401
|
+
}
|
|
6402
|
+
/**
|
|
6403
|
+
* Start a login session for one backend. Caller enforces admin.
|
|
6404
|
+
* Returns a status line to post, or null when a confirmation prompt was
|
|
6405
|
+
* posted instead (auth still valid — see the pre-check below).
|
|
6406
|
+
*/
|
|
6407
|
+
async startLoginSession(backendArg, chat, opts = {}) {
|
|
6408
|
+
const backend = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
|
|
6409
|
+
const flow = LOGIN_FLOWS[backend];
|
|
6410
|
+
if (!flow)
|
|
6411
|
+
return t("login.unsupported", backendArg);
|
|
6412
|
+
if (this.activeLogin)
|
|
6413
|
+
return t("login.busy", this.activeLogin.backend);
|
|
6414
|
+
// Install sessions share the window namespace — never run both at once.
|
|
6415
|
+
if (this.activeInstall)
|
|
6416
|
+
return t("install.busy");
|
|
6417
|
+
// Token-free pre-check (5s cap): re-login while auth still works is
|
|
6418
|
+
// usually a mistake, so it needs a confirmed click. An invalid OR
|
|
6419
|
+
// uncertain result (timeout, missing binary) proceeds straight to login —
|
|
6420
|
+
// an unreliable probe must never block the re-login the admin asked for.
|
|
6421
|
+
if (!opts.skipAuthCheck && flow.authCheck) {
|
|
6422
|
+
const status = await checkAuthStatus(flow.authCheck);
|
|
6423
|
+
if (status === "valid") {
|
|
6424
|
+
await this.postNonceButtonPrompt({
|
|
6425
|
+
prefix: LOGIN_CONFIRM_CALLBACK_PREFIX,
|
|
6426
|
+
alertType: "login",
|
|
6427
|
+
instanceName: backend,
|
|
6428
|
+
adapter: chat.adapter,
|
|
6429
|
+
adapterId: chat.adapterId,
|
|
6430
|
+
chatId: chat.chatId,
|
|
6431
|
+
threadId: chat.threadId,
|
|
6432
|
+
message: t("login.still_valid", backend),
|
|
6433
|
+
choices: [
|
|
6434
|
+
{ action: "go", label: t("login.relogin_go") },
|
|
6435
|
+
{ action: "cancel", label: t("login.relogin_cancel") },
|
|
6436
|
+
],
|
|
6437
|
+
expiredText: t("buttons.stale"),
|
|
6438
|
+
});
|
|
6439
|
+
return null;
|
|
6440
|
+
}
|
|
6441
|
+
}
|
|
6442
|
+
return this.launchLoginSession(flow, backend, chat);
|
|
6443
|
+
}
|
|
6444
|
+
/** Create the login window and session (pre-check already settled). */
|
|
6445
|
+
async launchLoginSession(flow, backend, chat) {
|
|
6446
|
+
if (this.activeLogin)
|
|
6447
|
+
return t("login.busy", this.activeLogin.backend);
|
|
6448
|
+
// Install sessions share the window namespace — never run both at once.
|
|
6449
|
+
if (this.activeInstall)
|
|
6450
|
+
return t("install.busy");
|
|
6451
|
+
const sessionName = getTmuxSession();
|
|
6452
|
+
await TmuxManager.ensureSession(sessionName);
|
|
6453
|
+
const tmux = new TmuxManager(sessionName, "");
|
|
6454
|
+
const session = new LoginSession(flow, tmux, {
|
|
6455
|
+
onMenu: async (options) => {
|
|
6456
|
+
await this.postNonceButtonPrompt({
|
|
6457
|
+
prefix: LOGIN_MENU_CALLBACK_PREFIX,
|
|
6458
|
+
alertType: "login",
|
|
6459
|
+
instanceName: backend,
|
|
6460
|
+
adapter: chat.adapter,
|
|
6461
|
+
adapterId: chat.adapterId,
|
|
6462
|
+
chatId: chat.chatId,
|
|
6463
|
+
threadId: chat.threadId,
|
|
6464
|
+
message: t("login.choose_provider"),
|
|
6465
|
+
choices: options.map((label, index) => ({ action: String(index), label })),
|
|
6466
|
+
expiredText: t("buttons.stale"),
|
|
6467
|
+
});
|
|
6468
|
+
},
|
|
6469
|
+
onAuthHint: async (url, code) => {
|
|
6470
|
+
await this.sendLoginSecret(chat, backend, url, code);
|
|
6471
|
+
},
|
|
6472
|
+
onNeedInput: async (promptExcerpt) => {
|
|
6473
|
+
await chat.adapter.sendText(chat.chatId, t("login.need_input", backend, promptExcerpt), { threadId: chat.threadId }).catch(() => { });
|
|
6474
|
+
},
|
|
6475
|
+
onDone: async ({ ok, detail }) => {
|
|
6476
|
+
this.activeLogin = null;
|
|
6477
|
+
let text;
|
|
6478
|
+
if (ok) {
|
|
6479
|
+
const { woken, restarted } = await this.recoverBackendInstances(backend);
|
|
6480
|
+
const none = t("login.none");
|
|
6481
|
+
text = t("login.success", backend, woken.length ? woken.join(", ") : none, restarted.length ? restarted.join(", ") : none);
|
|
6482
|
+
}
|
|
6483
|
+
else if (detail === "cancelled") {
|
|
6484
|
+
// The cancel command's own reply already announced this — a second
|
|
6485
|
+
// message here was a duplicate.
|
|
6486
|
+
return;
|
|
6487
|
+
}
|
|
6488
|
+
else {
|
|
6489
|
+
text = t("login.failed", backend, detail);
|
|
6490
|
+
}
|
|
6491
|
+
await chat.adapter.sendText(chat.chatId, text, { threadId: chat.threadId }).catch(() => { });
|
|
6492
|
+
},
|
|
6493
|
+
}, this.logger);
|
|
6494
|
+
// Claim the slot before the first await so two admins racing /login cannot
|
|
6495
|
+
// both create windows; release on startup failure.
|
|
6496
|
+
this.activeLogin = { session, backend, chat };
|
|
6497
|
+
try {
|
|
6498
|
+
await session.start();
|
|
6499
|
+
}
|
|
6500
|
+
catch (err) {
|
|
6501
|
+
this.activeLogin = null;
|
|
6502
|
+
return t("login.failed", backend, err.message);
|
|
6503
|
+
}
|
|
6504
|
+
return t("login.started", backend);
|
|
6505
|
+
}
|
|
6506
|
+
/** `/login code <text>` — paste admin-supplied text into the login window. */
|
|
6507
|
+
async loginSubmitInput(text) {
|
|
6508
|
+
if (!this.activeLogin)
|
|
6509
|
+
return t("login.no_session");
|
|
6510
|
+
const ok = await this.activeLogin.session.submitInput(text);
|
|
6511
|
+
return ok ? t("login.input_sent") : t("login.input_failed");
|
|
6512
|
+
}
|
|
6513
|
+
/** `/login cancel` — abort the active session and remove its window. */
|
|
6514
|
+
async cancelLoginSession() {
|
|
6515
|
+
if (!this.activeLogin)
|
|
6516
|
+
return t("login.no_session");
|
|
6517
|
+
const backend = this.activeLogin.backend;
|
|
6518
|
+
await this.activeLogin.session.cancel();
|
|
6519
|
+
return t("login.cancelled", backend);
|
|
6520
|
+
}
|
|
6521
|
+
/**
|
|
6522
|
+
* The URL (+ code) is a live credential: whoever completes it binds THEIR
|
|
6523
|
+
* account to this fleet's CLI. Telegram gets an HTML spoiler (same treatment
|
|
6524
|
+
* as the dashboard token); other adapters get plain text with the warning.
|
|
6525
|
+
*/
|
|
6526
|
+
async sendLoginSecret(chat, backend, url, code) {
|
|
6527
|
+
const codeLine = code ? `\n${t("login.auth_code", code)}` : "";
|
|
6528
|
+
try {
|
|
6529
|
+
if (chat.adapter.type === "telegram") {
|
|
6530
|
+
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
6531
|
+
await chat.adapter.sendText(chat.chatId, `${esc(t("login.auth_hint", backend))}\n<tg-spoiler>${esc(url)}${esc(codeLine)}</tg-spoiler>`, { threadId: chat.threadId, format: "html" });
|
|
6532
|
+
}
|
|
6533
|
+
else {
|
|
6534
|
+
await chat.adapter.sendText(chat.chatId, `${t("login.auth_hint", backend)}\n${url}${codeLine}`, { threadId: chat.threadId });
|
|
6535
|
+
}
|
|
6536
|
+
}
|
|
6537
|
+
catch (err) {
|
|
6538
|
+
this.logger.warn({ err: err.message, backend }, "Failed to deliver login URL");
|
|
6539
|
+
}
|
|
6540
|
+
}
|
|
6541
|
+
/**
|
|
6542
|
+
* After a successful login: wake the paused instances of that backend AND
|
|
6543
|
+
* restart the running ones — a live CLI holds the old token in memory and
|
|
6544
|
+
* only re-reads credentials on process start (a paused instance's CLI is
|
|
6545
|
+
* already dead, so waking it respawns with the new token for free).
|
|
6546
|
+
*/
|
|
6547
|
+
async recoverBackendInstances(backend) {
|
|
6548
|
+
const woken = [];
|
|
6549
|
+
const restarted = [];
|
|
6550
|
+
for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
|
|
6551
|
+
const effective = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
6552
|
+
if (effective !== backend)
|
|
6553
|
+
continue;
|
|
6554
|
+
const status = this.getInstanceStatus(name);
|
|
6555
|
+
try {
|
|
6556
|
+
if (status === "paused") {
|
|
6557
|
+
if (this.daemons.has(name))
|
|
6558
|
+
await this.lifecycle.wake(name, 30_000);
|
|
6559
|
+
else
|
|
6560
|
+
await this.startPersistedPausedInstance(name);
|
|
6561
|
+
woken.push(name);
|
|
6562
|
+
}
|
|
6563
|
+
else if (status === "running") {
|
|
6564
|
+
await this.restartSingleInstance(name);
|
|
6565
|
+
restarted.push(name);
|
|
6566
|
+
}
|
|
6567
|
+
}
|
|
6568
|
+
catch (err) {
|
|
6569
|
+
this.logger.warn({ err: err.message, name, status }, "Post-login recovery failed");
|
|
6570
|
+
}
|
|
6571
|
+
}
|
|
6572
|
+
return { woken, restarted };
|
|
6573
|
+
}
|
|
6574
|
+
/** Backend chooser button → start that backend's login session. */
|
|
6575
|
+
async handleLoginBackendSelect(data, callbackAdapterId, receivingAdapter) {
|
|
6576
|
+
const claimed = this.consumeNonceCallback(LOGIN_CALLBACK_PREFIX, /^login:([0-9a-f]+):([a-z][a-z-]*)$/, data, callbackAdapterId, receivingAdapter);
|
|
6577
|
+
if (claimed === null)
|
|
6578
|
+
return false;
|
|
6579
|
+
if (claimed === "consumed")
|
|
6580
|
+
return true;
|
|
6581
|
+
const { entry, action: backend } = claimed;
|
|
6582
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", backend));
|
|
6583
|
+
const text = await this.startLoginSession(backend, {
|
|
6584
|
+
adapter: entry.adapter,
|
|
6585
|
+
adapterId: entry.adapterId,
|
|
6586
|
+
chatId: entry.chatId,
|
|
6587
|
+
threadId: entry.threadId,
|
|
6588
|
+
});
|
|
6589
|
+
if (text)
|
|
6590
|
+
await entry.adapter.sendText(entry.chatId, text, { threadId: entry.threadId }).catch(() => { });
|
|
6591
|
+
return true;
|
|
6592
|
+
}
|
|
6593
|
+
/** Re-login confirmation (auth pre-check said credentials still work). */
|
|
6594
|
+
async handleLoginConfirm(data, callbackAdapterId, receivingAdapter) {
|
|
6595
|
+
const claimed = this.consumeNonceCallback(LOGIN_CONFIRM_CALLBACK_PREFIX, /^login-confirm:([0-9a-f]+):(go|cancel)$/, data, callbackAdapterId, receivingAdapter);
|
|
6596
|
+
if (claimed === null)
|
|
6597
|
+
return false;
|
|
6598
|
+
if (claimed === "consumed")
|
|
6599
|
+
return true;
|
|
6600
|
+
const { entry, action } = claimed;
|
|
6601
|
+
const backend = entry.instanceName;
|
|
6602
|
+
if (action === "cancel") {
|
|
6603
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled", backend));
|
|
6604
|
+
return true;
|
|
6605
|
+
}
|
|
6606
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", backend));
|
|
6607
|
+
const text = await this.startLoginSession(backend, {
|
|
6608
|
+
adapter: entry.adapter,
|
|
6609
|
+
adapterId: entry.adapterId,
|
|
6610
|
+
chatId: entry.chatId,
|
|
6611
|
+
threadId: entry.threadId,
|
|
6612
|
+
}, { skipAuthCheck: true });
|
|
6613
|
+
if (text)
|
|
6614
|
+
await entry.adapter.sendText(entry.chatId, text, { threadId: entry.threadId }).catch(() => { });
|
|
6615
|
+
return true;
|
|
6616
|
+
}
|
|
6617
|
+
/** Kiro provider button → drive the CLI's arrow-key selector. */
|
|
6618
|
+
async handleLoginMenuSelect(data, callbackAdapterId, receivingAdapter) {
|
|
6619
|
+
const claimed = this.consumeNonceCallback(LOGIN_MENU_CALLBACK_PREFIX, /^login-menu:([0-9a-f]+):(\d)$/, data, callbackAdapterId, receivingAdapter);
|
|
6620
|
+
if (claimed === null)
|
|
6621
|
+
return false;
|
|
6622
|
+
if (claimed === "consumed")
|
|
6623
|
+
return true;
|
|
6624
|
+
const { entry, action } = claimed;
|
|
6625
|
+
const index = Number(action);
|
|
6626
|
+
const label = this.activeLogin?.session.flow.menu?.options[index] ?? action;
|
|
6627
|
+
const ok = await this.activeLogin?.session.selectMenuOption(index) ?? false;
|
|
6628
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, ok ? t("login.provider_selected", label) : t("login.no_session"));
|
|
6629
|
+
return true;
|
|
6630
|
+
}
|
|
6631
|
+
// ── Remote CLI install (`/install-cli`) ──────────────────────────────────
|
|
6632
|
+
//
|
|
6633
|
+
// Same dedicated-window model as /login (and the same LoginSession state
|
|
6634
|
+
// machine — an install is a login flow with no auth hints): run the
|
|
6635
|
+
// installer, judge by exit code, verify the binary on a fresh login shell,
|
|
6636
|
+
// then offer to chain straight into /login.
|
|
6637
|
+
activeInstall = null;
|
|
6638
|
+
/** Start a CLI install session. Caller enforces admin. */
|
|
6639
|
+
async startInstallSession(backendArg, chat) {
|
|
6640
|
+
const backend = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
|
|
6641
|
+
const info = BACKEND_INSTALLATION_INFO[backend];
|
|
6642
|
+
if (!info)
|
|
6643
|
+
return t("install.unsupported", backendArg);
|
|
6644
|
+
if (checkBinaryInstalled(info.binary))
|
|
6645
|
+
return t("install.already", backend, info.binary);
|
|
6646
|
+
if (this.activeInstall)
|
|
6647
|
+
return t("install.busy");
|
|
6648
|
+
if (this.activeLogin)
|
|
6649
|
+
return t("login.busy", this.activeLogin.backend);
|
|
6650
|
+
const sessionName = getTmuxSession();
|
|
6651
|
+
await TmuxManager.ensureSession(sessionName);
|
|
6652
|
+
const tmux = new TmuxManager(sessionName, "");
|
|
6653
|
+
// A synthetic login flow: success is decided by the installer's exit code
|
|
6654
|
+
// (LoginSession treats a clean exit as success), never by pane text — and
|
|
6655
|
+
// installer output that happens to contain a URL must not be forwarded as
|
|
6656
|
+
// an auth hint, hence the no-op events below.
|
|
6657
|
+
const flow = {
|
|
6658
|
+
backend,
|
|
6659
|
+
command: info.install,
|
|
6660
|
+
successPattern: /$^/,
|
|
6661
|
+
timeoutMs: 10 * 60 * 1000,
|
|
6662
|
+
};
|
|
6663
|
+
const session = new LoginSession(flow, tmux, {
|
|
6664
|
+
onMenu: () => { },
|
|
6665
|
+
onAuthHint: () => { },
|
|
6666
|
+
onNeedInput: () => { },
|
|
6667
|
+
onDone: async ({ ok, detail }) => {
|
|
6668
|
+
this.activeInstall = null;
|
|
6669
|
+
if (!ok) {
|
|
6670
|
+
// A cancel is user-initiated — the cancel command's own reply already
|
|
6671
|
+
// said so; a second message here would be a duplicate.
|
|
6672
|
+
if (detail !== "cancelled") {
|
|
6673
|
+
await chat.adapter.sendText(chat.chatId, t("install.failed", backend, detail), { threadId: chat.threadId }).catch(() => { });
|
|
6674
|
+
}
|
|
6675
|
+
return;
|
|
6676
|
+
}
|
|
6677
|
+
// The installer may only have added the binary to a profile PATH; a
|
|
6678
|
+
// fresh login shell sees that, the fleet process's PATH may not.
|
|
6679
|
+
if (!this.verifyBinaryOnLoginShell(info.binary)) {
|
|
6680
|
+
await chat.adapter.sendText(chat.chatId, t("install.verify_failed", backend, info.binary), { threadId: chat.threadId }).catch(() => { });
|
|
6681
|
+
return;
|
|
6682
|
+
}
|
|
6683
|
+
if (!LOGIN_FLOWS[backend]) {
|
|
6684
|
+
await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
|
|
6685
|
+
return;
|
|
6686
|
+
}
|
|
6687
|
+
await this.postNonceButtonPrompt({
|
|
6688
|
+
prefix: INSTALL_LOGIN_CALLBACK_PREFIX,
|
|
6689
|
+
alertType: "login",
|
|
6690
|
+
instanceName: backend,
|
|
6691
|
+
adapter: chat.adapter,
|
|
6692
|
+
adapterId: chat.adapterId,
|
|
6693
|
+
chatId: chat.chatId,
|
|
6694
|
+
threadId: chat.threadId,
|
|
6695
|
+
message: t("install.login_prompt", backend),
|
|
6696
|
+
choices: [
|
|
6697
|
+
{ action: "go", label: t("install.login_now") },
|
|
6698
|
+
{ action: "later", label: t("install.later") },
|
|
6699
|
+
],
|
|
6700
|
+
expiredText: t("install.later_ack", backend),
|
|
6701
|
+
});
|
|
6702
|
+
},
|
|
6703
|
+
}, this.logger);
|
|
6704
|
+
this.activeInstall = { session, backend, chat };
|
|
6705
|
+
try {
|
|
6706
|
+
await session.start();
|
|
6707
|
+
}
|
|
6708
|
+
catch (err) {
|
|
6709
|
+
this.activeInstall = null;
|
|
6710
|
+
return t("install.failed", backend, err.message);
|
|
6711
|
+
}
|
|
6712
|
+
return t("install.started", backend);
|
|
6713
|
+
}
|
|
6714
|
+
/** `/install-cli cancel` — abort the active install and remove its window. */
|
|
6715
|
+
async cancelInstallSession() {
|
|
6716
|
+
if (!this.activeInstall)
|
|
6717
|
+
return t("install.no_session");
|
|
6718
|
+
const backend = this.activeInstall.backend;
|
|
6719
|
+
await this.activeInstall.session.cancel();
|
|
6720
|
+
return t("install.cancelled", backend);
|
|
6721
|
+
}
|
|
6722
|
+
/** `command -v` on a login shell, so PATH additions from rc files count. */
|
|
6723
|
+
verifyBinaryOnLoginShell(binary) {
|
|
6724
|
+
try {
|
|
6725
|
+
const result = spawnSync("bash", ["-lc", `command -v ${binary}`], { timeout: 10_000, stdio: "pipe" });
|
|
6726
|
+
return result.status === 0;
|
|
6727
|
+
}
|
|
6728
|
+
catch {
|
|
6729
|
+
return false;
|
|
6730
|
+
}
|
|
6731
|
+
}
|
|
6732
|
+
/** "Sign in now?" button after a successful install. */
|
|
6733
|
+
async handleInstallLoginConfirm(data, callbackAdapterId, receivingAdapter) {
|
|
6734
|
+
const claimed = this.consumeNonceCallback(INSTALL_LOGIN_CALLBACK_PREFIX, /^install-login:([0-9a-f]+):(go|later)$/, data, callbackAdapterId, receivingAdapter);
|
|
6735
|
+
if (claimed === null)
|
|
6736
|
+
return false;
|
|
6737
|
+
if (claimed === "consumed")
|
|
6738
|
+
return true;
|
|
6739
|
+
const { entry, action } = claimed;
|
|
6740
|
+
const backend = entry.instanceName;
|
|
6741
|
+
if (action === "later") {
|
|
6742
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("install.later_ack", backend));
|
|
6743
|
+
return true;
|
|
6744
|
+
}
|
|
6745
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", backend));
|
|
6746
|
+
const text = await this.startLoginSession(backend, {
|
|
6747
|
+
adapter: entry.adapter,
|
|
6748
|
+
adapterId: entry.adapterId,
|
|
6749
|
+
chatId: entry.chatId,
|
|
6750
|
+
threadId: entry.threadId,
|
|
6751
|
+
});
|
|
6752
|
+
if (text)
|
|
6753
|
+
await entry.adapter.sendText(entry.chatId, text, { threadId: entry.threadId }).catch(() => { });
|
|
6754
|
+
return true;
|
|
6755
|
+
}
|
|
6756
|
+
/** Discord native `/login` slash — shared by every adapter dispatch block. */
|
|
6757
|
+
async handleLoginSlash(data, adapterId, adapter) {
|
|
6758
|
+
if (!data.userId || !this.isFleetAdmin(data.userId, adapterId)) {
|
|
6759
|
+
await data.respond(t("permission.denied"));
|
|
6760
|
+
return;
|
|
6761
|
+
}
|
|
6762
|
+
const chat = { adapter, adapterId, chatId: data.channelId };
|
|
6763
|
+
if (data.options?.cancel === true) {
|
|
6764
|
+
await data.respond(await this.cancelLoginSession());
|
|
6765
|
+
return;
|
|
6766
|
+
}
|
|
6767
|
+
const code = String(data.options?.code ?? "").trim();
|
|
6768
|
+
if (code) {
|
|
6769
|
+
await data.respond(await this.loginSubmitInput(code));
|
|
6770
|
+
return;
|
|
6771
|
+
}
|
|
6772
|
+
const backend = String(data.options?.backend ?? "").trim();
|
|
6773
|
+
if (backend) {
|
|
6774
|
+
const text = await this.startLoginSession(backend, chat);
|
|
6775
|
+
await data.respond(text ?? t("login.confirm_posted"));
|
|
6776
|
+
return;
|
|
6777
|
+
}
|
|
6778
|
+
await this.promptLoginBackends(chat);
|
|
6779
|
+
await data.respond(t("login.chooser_posted"));
|
|
6780
|
+
}
|
|
6781
|
+
/** Discord native `/install-cli` slash — shared by every dispatch block. */
|
|
6782
|
+
async handleInstallCliSlash(data, adapterId, adapter) {
|
|
6783
|
+
if (!data.userId || !this.isFleetAdmin(data.userId, adapterId)) {
|
|
6784
|
+
await data.respond(t("permission.denied"));
|
|
6785
|
+
return;
|
|
6786
|
+
}
|
|
6787
|
+
if (data.options?.cancel === true) {
|
|
6788
|
+
await data.respond(await this.cancelInstallSession());
|
|
6789
|
+
return;
|
|
6790
|
+
}
|
|
6791
|
+
const backend = String(data.options?.backend ?? "").trim();
|
|
6792
|
+
if (!backend) {
|
|
6793
|
+
await data.respond(t("install.usage"));
|
|
6794
|
+
return;
|
|
6795
|
+
}
|
|
6796
|
+
await data.respond(await this.startInstallSession(backend, { adapter, adapterId, chatId: data.channelId }));
|
|
6797
|
+
}
|
|
5295
6798
|
queueMirrorMessage(text) {
|
|
5296
6799
|
const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
|
|
5297
6800
|
if (mirrorTopicId == null || !this.adapter)
|
|
@@ -5411,6 +6914,10 @@ export class FleetManager {
|
|
|
5411
6914
|
"gemini-cli": "GEMINI.md",
|
|
5412
6915
|
"opencode": "AGENTS.md",
|
|
5413
6916
|
"kiro-cli": ".kiro/steering/project.md",
|
|
6917
|
+
// Grok reads AGENTS.md project docs; agy reads .agents/agents.md — the
|
|
6918
|
+
// same files their writeConfig() appends fleet instructions to.
|
|
6919
|
+
"grok": "AGENTS.md",
|
|
6920
|
+
"antigravity": ".agents/agents.md",
|
|
5414
6921
|
"mock": "CLAUDE.md",
|
|
5415
6922
|
};
|
|
5416
6923
|
static GENERAL_INSTRUCTIONS = `# Fleet Coordinator
|
|
@@ -5420,146 +6927,33 @@ You route tasks, manage instances, enforce policies, and synthesize results.
|
|
|
5420
6927
|
Do NOT modify project files directly — delegate file changes to the project's instance.
|
|
5421
6928
|
You CAN write code snippets, explain code, and answer technical questions directly.
|
|
5422
6929
|
|
|
5423
|
-
|
|
5424
|
-
|
|
5425
|
-
## Task Classification
|
|
5426
|
-
|
|
5427
|
-
Classify every incoming request before acting.
|
|
5428
|
-
|
|
5429
|
-
### Handle Directly (ALL conditions must be true)
|
|
5430
|
-
|
|
5431
|
-
- No file system access needed
|
|
5432
|
-
- No external execution needed
|
|
5433
|
-
- Answerable from static knowledge
|
|
5434
|
-
- ≤ 2 reasoning steps
|
|
5435
|
-
|
|
5436
|
-
Examples: Q&A, translation, fleet status queries, explaining a concept, writing code snippets.
|
|
5437
|
-
|
|
5438
|
-
### Delegate to 1 Instance
|
|
6930
|
+
## Task Routing
|
|
5439
6931
|
|
|
5440
|
-
-
|
|
5441
|
-
-
|
|
6932
|
+
- **Handle directly**: no file/exec access needed, answerable from knowledge, ≤2 reasoning steps (Q&A, translation, status queries, code snippets).
|
|
6933
|
+
- **Delegate to 1 instance**: scoped to one project/repo, needs file access or execution.
|
|
6934
|
+
- **Coordinate multiple**: spans repos, outputs feed each other, or parallel helps (max 3 per task).
|
|
5442
6935
|
|
|
5443
|
-
|
|
6936
|
+
Instance discovery order: list_teams() → list_instances() → describe_instance() → create_instance() only if nothing fits. Prefer reuse; never duplicate a running instance.
|
|
5444
6937
|
|
|
5445
|
-
|
|
5446
|
-
- Requires outputs from one instance to feed into another
|
|
5447
|
-
- Benefits from parallel execution (max 3 instances per task)
|
|
6938
|
+
## Reply Contract
|
|
5448
6939
|
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
## Instance Discovery (in this order)
|
|
5452
|
-
1. list_teams() → reuse existing teams first
|
|
5453
|
-
2. list_instances() → find by working_directory, description, or tags
|
|
5454
|
-
3. describe_instance() → confirm capabilities before delegating
|
|
5455
|
-
4. create_instance() → only if no suitable instance exists
|
|
5456
|
-
|
|
5457
|
-
Rules: prefer reuse over creation. Do NOT create duplicates of running instances.
|
|
5458
|
-
|
|
5459
|
-
-----
|
|
5460
|
-
|
|
5461
|
-
## Delegation Protocol
|
|
5462
|
-
|
|
5463
|
-
Every delegation via send_to_instance() MUST include:
|
|
5464
|
-
|
|
5465
|
-
1. Task scope — what exactly to do, bounded clearly
|
|
5466
|
-
2. Expected output — what to return and in what form
|
|
5467
|
-
3. Policy reminder — "Follow Development Workflow policy" (for code tasks)
|
|
5468
|
-
|
|
5469
|
-
### Loop Prevention
|
|
5470
|
-
|
|
5471
|
-
- Never re-delegate a task back to the instance that sent it to you
|
|
5472
|
-
- If a task has bounced 3 times, stop and solve locally or reduce scope
|
|
5473
|
-
|
|
5474
|
-
### Execution Strategy
|
|
5475
|
-
|
|
5476
|
-
Parallel — use only when tasks are independent with no shared state
|
|
5477
|
-
Sequential — use when one task's output feeds into the next
|
|
5478
|
-
|
|
5479
|
-
-----
|
|
5480
|
-
|
|
5481
|
-
## Result Handling
|
|
5482
|
-
|
|
5483
|
-
When an instance reports back, classify the outcome:
|
|
5484
|
-
|
|
5485
|
-
- Success → Summarize key results for user. Omit internal coordination noise.
|
|
5486
|
-
- Partial → State what succeeded, what remains, proposed next steps.
|
|
5487
|
-
- Failure → Retry up to 2 times. If still failing: try alternative instance, reduce scope, or return partial result clearly marked.
|
|
5488
|
-
- No response → Ping again after reasonable wait. If still silent: report to user with options.
|
|
5489
|
-
|
|
5490
|
-
### Output to User
|
|
5491
|
-
|
|
5492
|
-
Every final response to the user should contain:
|
|
5493
|
-
|
|
5494
|
-
- Result — the actual answer or deliverable
|
|
5495
|
-
- Gaps — anything incomplete or unresolved (omit if none)
|
|
5496
|
-
|
|
5497
|
-
-----
|
|
5498
|
-
|
|
5499
|
-
## Shared Decisions
|
|
5500
|
-
|
|
5501
|
-
Use post_decision() / list_decisions() for any choice that affects more than 1 instance, changes an API contract, introduces a new dependency, or alters deployment process.
|
|
5502
|
-
|
|
5503
|
-
When instances disagree, collect both viewpoints, make a decision, and record it via post_decision.
|
|
5504
|
-
|
|
5505
|
-
-----
|
|
6940
|
+
Every final response to the user contains: the result (the actual answer or deliverable) and gaps (anything incomplete — omit if none). Summarize instance reports; omit internal coordination noise.
|
|
5506
6941
|
|
|
5507
6942
|
## After Restart
|
|
5508
6943
|
|
|
5509
|
-
|
|
5510
|
-
1. list_instances() → rebuild fleet awareness
|
|
5511
|
-
2. list_teams() → restore team structure
|
|
5512
|
-
3. list_decisions() → reload policies and conventions
|
|
5513
|
-
|
|
5514
|
-
Only then handle incoming requests.
|
|
5515
|
-
|
|
5516
|
-
-----
|
|
5517
|
-
|
|
5518
|
-
## Development Workflow Policy
|
|
5519
|
-
|
|
5520
|
-
All code changes across the fleet should follow this workflow.
|
|
5521
|
-
The coordinator enforces compliance but does not perform these steps directly.
|
|
5522
|
-
Remind instances of this policy when delegating code tasks.
|
|
5523
|
-
|
|
5524
|
-
### Workflow Stages
|
|
5525
|
-
Design Proposed → Design Approved → Implementation → Submit for Review → Under Review → Approved → Merge
|
|
5526
|
-
|
|
5527
|
-
### Policy Rules
|
|
5528
|
-
|
|
5529
|
-
1. Design before code — developer sends design proposal to reviewer before implementation. Consensus required before proceeding.
|
|
5530
|
-
2. Challenger pairing — every code task should have a developer + reviewer. Reviewer actively questions decisions and finds risks.
|
|
5531
|
-
3. Verify by execution — backend/CLI changes must be tested by running them. Do not trust documentation alone.
|
|
5532
|
-
4. Independent review — every merge requires code review from someone other than the author.
|
|
5533
|
-
5. Root cause first — bug fixes require confirmed root cause before proposing a fix.
|
|
5534
|
-
6. Merge conditions: tests pass, reviewer approved, branch and worktree cleaned up.
|
|
6944
|
+
BEFORE processing any new messages: 1. list_instances() 2. list_teams() 3. list_decisions(). Only then handle requests.
|
|
5535
6945
|
|
|
5536
|
-
|
|
6946
|
+
## Playbooks (on-demand skills)
|
|
5537
6947
|
|
|
5538
|
-
|
|
5539
|
-
-
|
|
5540
|
-
-
|
|
5541
|
-
|
|
5542
|
-
-----
|
|
5543
|
-
|
|
5544
|
-
## Team Management
|
|
5545
|
-
|
|
5546
|
-
- Always check existing teams before creating new ones
|
|
5547
|
-
- Default to ephemeral teams (created for a specific task, dissolved after completion)
|
|
5548
|
-
- Clean up ephemeral teams and instances after task completion
|
|
5549
|
-
|
|
5550
|
-
-----
|
|
5551
|
-
|
|
5552
|
-
## Instance Configuration Tips
|
|
5553
|
-
|
|
5554
|
-
When users create specialized instances, suggest these configurations:
|
|
5555
|
-
|
|
5556
|
-
- **Reviewer instances**: Add \`pre_task_command: "/chat load reviewer-base"\` to reset context before each review, preventing influence from previous conversations.
|
|
5557
|
-
- **Collab mode**: For multi-bot channels, use \`/collab\` to enable @mention-based triggering.
|
|
5558
|
-
- **Cost control**: Set per-instance \`cost_guard\` for expensive backends.
|
|
6948
|
+
Detailed procedures live in your skills — consult them when the situation comes up rather than from memory:
|
|
6949
|
+
- **delegation-playbook** — delegation protocol, loop prevention, parallel vs sequential, result/failure handling, team management, instance configuration tips.
|
|
6950
|
+
- **development-workflow** — the fleet-wide code-change policy you enforce when delegating code tasks.
|
|
6951
|
+
Plus the operational skills (fleet-health, instance-lifecycle, scheduling, session-management, …).
|
|
5559
6952
|
`;
|
|
5560
6953
|
/** Ensure the general instance has its project instructions file + knowledge */
|
|
5561
|
-
ensureGeneralInstructions(workDir, backendName) {
|
|
6954
|
+
ensureGeneralInstructions(workDir, backendName, instanceName) {
|
|
5562
6955
|
const backend = backendName ?? "claude-code";
|
|
6956
|
+
workDir = this.resolveKnowledgeWorkDir(workDir, backend, instanceName);
|
|
5563
6957
|
const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
|
|
5564
6958
|
const filePath = join(workDir, filename);
|
|
5565
6959
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
@@ -5567,60 +6961,237 @@ When users create specialized instances, suggest these configurations:
|
|
|
5567
6961
|
writeFileSync(filePath, FleetManager.GENERAL_INSTRUCTIONS, "utf-8");
|
|
5568
6962
|
this.logger.info({ filePath }, "Created general instance instructions file");
|
|
5569
6963
|
}
|
|
5570
|
-
// Sync bundled knowledge files to general's steering
|
|
6964
|
+
// Sync bundled knowledge files to general's steering and skills directories.
|
|
5571
6965
|
this.syncGeneralKnowledge(workDir, backend);
|
|
5572
6966
|
}
|
|
5573
|
-
/**
|
|
6967
|
+
/** Resolve the workspace path a backend actually uses before publishing knowledge. */
|
|
6968
|
+
resolveKnowledgeWorkDir(workDir, backend, instanceName) {
|
|
6969
|
+
// Backend resolution may create/normalize the real cwd. Instructions and
|
|
6970
|
+
// skills must land where the CLI actually runs, not an assumed path.
|
|
6971
|
+
try {
|
|
6972
|
+
const resolved = createBackend(backend, join(getAgendHome(), "cli-env"))
|
|
6973
|
+
.resolveWorkingDirectory?.(workDir, instanceName);
|
|
6974
|
+
if (resolved)
|
|
6975
|
+
workDir = resolved;
|
|
6976
|
+
}
|
|
6977
|
+
catch { /* unknown backend name — keep the raw path */ }
|
|
6978
|
+
return workDir;
|
|
6979
|
+
}
|
|
6980
|
+
/**
|
|
6981
|
+
* Where each backend natively loads on-demand skills from, relative to the
|
|
6982
|
+
* workspace. Backends without a native skill mechanism (opencode, grok,
|
|
6983
|
+
* antigravity, gemini-cli) are deliberately absent: dropping files a CLI
|
|
6984
|
+
* never reads is clutter, not capability. Unknown directories are ignored
|
|
6985
|
+
* by older CLI versions, so publishing is fail-open across upgrades.
|
|
6986
|
+
*/
|
|
6987
|
+
static SKILLS_DIR_SEGMENTS = {
|
|
6988
|
+
"kiro-cli": [".kiro", "skills"],
|
|
6989
|
+
"claude-code": [".claude", "skills"],
|
|
6990
|
+
"codex": [".agents", "skills"],
|
|
6991
|
+
// Live-verified: OpenCode and Antigravity scan .agents/skills; Grok's
|
|
6992
|
+
// vendor-canonical location is .grok/skills.
|
|
6993
|
+
"opencode": [".agents", "skills"],
|
|
6994
|
+
"grok": [".grok", "skills"],
|
|
6995
|
+
"antigravity": [".agents", "skills"],
|
|
6996
|
+
};
|
|
6997
|
+
/** Copy general-knowledge steering + all role-eligible skills to General. */
|
|
5574
6998
|
syncGeneralKnowledge(workDir, backend) {
|
|
5575
6999
|
const knowledgeDir = join(dirname(fileURLToPath(import.meta.url)), "general-knowledge");
|
|
5576
7000
|
if (!existsSync(knowledgeDir))
|
|
5577
7001
|
return;
|
|
5578
|
-
|
|
5579
|
-
|
|
5580
|
-
|
|
5581
|
-
|
|
5582
|
-
|
|
5583
|
-
|
|
5584
|
-
|
|
5585
|
-
|
|
5586
|
-
|
|
5587
|
-
|
|
5588
|
-
|
|
7002
|
+
this.syncGeneralSteering(workDir, backend, join(knowledgeDir, "steering"));
|
|
7003
|
+
this.syncRoleSkills(workDir, backend, "general", knowledgeDir);
|
|
7004
|
+
this.logger.debug({ knowledgeDir, workDir, backend }, "Synced general knowledge files");
|
|
7005
|
+
}
|
|
7006
|
+
/** Publish only the bundled skills eligible for an instance role. */
|
|
7007
|
+
syncRoleSkills(workDir, backend, role, knowledgeDir) {
|
|
7008
|
+
const skillSegments = FleetManager.SKILLS_DIR_SEGMENTS[backend];
|
|
7009
|
+
if (!skillSegments)
|
|
7010
|
+
return;
|
|
7011
|
+
const root = knowledgeDir ?? join(dirname(fileURLToPath(import.meta.url)), "general-knowledge");
|
|
7012
|
+
if (!existsSync(root))
|
|
7013
|
+
return;
|
|
7014
|
+
// Before managed-skill manifests existed, only Kiro General received
|
|
7015
|
+
// bundled skills. Allow that one legacy layout to be adopted so upgrades
|
|
7016
|
+
// can keep those copies current; other backends never had unmanaged
|
|
7017
|
+
// AgEnD-published skills and must retain the normal user-ownership guard.
|
|
7018
|
+
const adoptLegacyUnmanaged = backend === "kiro-cli" && role === "general";
|
|
7019
|
+
this.syncManagedSkills(join(workDir, ...skillSegments), join(root, "skills"), role, adoptLegacyUnmanaged);
|
|
7020
|
+
this.logger.debug({ workDir, backend, role }, "Synced role-based bundled skills");
|
|
7021
|
+
}
|
|
7022
|
+
/**
|
|
7023
|
+
* Steering (always-on rules like core-rules.md). Kiro loads a native
|
|
7024
|
+
* steering directory; every other backend gets the content embedded into
|
|
7025
|
+
* its instructions file (CLAUDE.md / AGENTS.md / …) inside a managed marker
|
|
7026
|
+
* block — the previous behavior dropped bare .md files in the workspace
|
|
7027
|
+
* root, which no CLI ever read. The block is replaced in place on every
|
|
7028
|
+
* sync, so rule updates reach EXISTING workspaces; everything the user
|
|
7029
|
+
* wrote outside the markers is preserved byte-for-byte.
|
|
7030
|
+
*/
|
|
7031
|
+
syncGeneralSteering(workDir, backend, srcSteering) {
|
|
7032
|
+
if (!existsSync(srcSteering))
|
|
7033
|
+
return;
|
|
7034
|
+
const files = readdirSync(srcSteering).filter(f => f.endsWith(".md")).sort();
|
|
7035
|
+
if (files.length === 0)
|
|
7036
|
+
return;
|
|
7037
|
+
if (backend === "kiro-cli") {
|
|
7038
|
+
const steeringDir = join(workDir, ".kiro", "steering");
|
|
7039
|
+
mkdirSync(steeringDir, { recursive: true });
|
|
7040
|
+
for (const file of files) {
|
|
5589
7041
|
const dest = join(steeringDir, file);
|
|
5590
|
-
const newContent = readFileSync(
|
|
7042
|
+
const newContent = readFileSync(join(srcSteering, file), "utf-8");
|
|
5591
7043
|
try {
|
|
5592
7044
|
if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
|
|
5593
7045
|
continue;
|
|
5594
7046
|
}
|
|
5595
|
-
catch { }
|
|
7047
|
+
catch { /* rewrite */ }
|
|
5596
7048
|
writeFileSync(dest, newContent);
|
|
5597
7049
|
}
|
|
7050
|
+
return;
|
|
5598
7051
|
}
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
|
|
5609
|
-
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
5616
|
-
|
|
5617
|
-
|
|
5618
|
-
|
|
5619
|
-
|
|
5620
|
-
|
|
7052
|
+
const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
|
|
7053
|
+
const instructionsPath = join(workDir, filename);
|
|
7054
|
+
const body = files.map(f => readFileSync(join(srcSteering, f), "utf-8").trim()).join("\n\n");
|
|
7055
|
+
const block = `${FleetManager.STEERING_BLOCK_BEGIN}\n${body}\n${FleetManager.STEERING_BLOCK_END}`;
|
|
7056
|
+
let existing = "";
|
|
7057
|
+
try {
|
|
7058
|
+
existing = existsSync(instructionsPath) ? readFileSync(instructionsPath, "utf-8") : "";
|
|
7059
|
+
}
|
|
7060
|
+
catch { /* treat as empty */ }
|
|
7061
|
+
const beginAt = existing.indexOf(FleetManager.STEERING_BLOCK_BEGIN);
|
|
7062
|
+
const endAt = existing.indexOf(FleetManager.STEERING_BLOCK_END);
|
|
7063
|
+
let next;
|
|
7064
|
+
if (beginAt !== -1 && endAt !== -1 && endAt > beginAt) {
|
|
7065
|
+
next = existing.slice(0, beginAt) + block + existing.slice(endAt + FleetManager.STEERING_BLOCK_END.length);
|
|
7066
|
+
}
|
|
7067
|
+
else {
|
|
7068
|
+
next = existing.trimEnd() + (existing.trim() ? "\n\n" : "") + block + "\n";
|
|
7069
|
+
}
|
|
7070
|
+
if (next !== existing) {
|
|
7071
|
+
mkdirSync(dirname(instructionsPath), { recursive: true });
|
|
7072
|
+
writeFileSync(instructionsPath, next);
|
|
7073
|
+
}
|
|
7074
|
+
}
|
|
7075
|
+
static STEERING_BLOCK_BEGIN = "<!-- >>> agend:core-rules — managed by AgEnD; edits inside this block are overwritten -->";
|
|
7076
|
+
static STEERING_BLOCK_END = "<!-- <<< agend:core-rules -->";
|
|
7077
|
+
/**
|
|
7078
|
+
* Publish AgEnD's bundled skills into a CLI's native skills directory,
|
|
7079
|
+
* owning ONLY what we published. A manifest records which skill names AgEnD
|
|
7080
|
+
* wrote; a bundled rename/removal deletes the stale managed copy, while a
|
|
7081
|
+
* skill the user created by hand is never listed and therefore never
|
|
7082
|
+
* touched — even if a future bundle happens to reuse its name (the sync
|
|
7083
|
+
* then skips it and logs, rather than overwrite the user's work). The sole
|
|
7084
|
+
* migration exception is the pre-manifest Kiro General layout explicitly
|
|
7085
|
+
* selected by adoptLegacyUnmanaged.
|
|
7086
|
+
*/
|
|
7087
|
+
syncManagedSkills(destSkills, srcSkills, role, adoptLegacyUnmanaged = false) {
|
|
7088
|
+
if (!existsSync(srcSkills))
|
|
7089
|
+
return;
|
|
7090
|
+
mkdirSync(destSkills, { recursive: true });
|
|
7091
|
+
const manifestPath = join(destSkills, ".agend-managed-skills.json");
|
|
7092
|
+
const hadManifest = existsSync(manifestPath);
|
|
7093
|
+
let previouslyManaged = [];
|
|
7094
|
+
try {
|
|
7095
|
+
const parsed = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
7096
|
+
if (Array.isArray(parsed))
|
|
7097
|
+
previouslyManaged = parsed.filter(n => typeof n === "string");
|
|
7098
|
+
}
|
|
7099
|
+
catch { /* first sync, or corrupt manifest — treat as owning nothing */ }
|
|
7100
|
+
const bundled = readdirSync(srcSkills)
|
|
7101
|
+
.filter(name => existsSync(join(srcSkills, name, "SKILL.md")))
|
|
7102
|
+
.sort();
|
|
7103
|
+
// Pre-manifest Kiro General copied bundled skills directly into
|
|
7104
|
+
// .kiro/skills. If every existing skill is still a bundled name, this is
|
|
7105
|
+
// the unambiguous legacy layout: adopt it once, update it below, and write
|
|
7106
|
+
// the ownership manifest. Any extra skill name keeps the directory fully
|
|
7107
|
+
// on the user-owned/collision path.
|
|
7108
|
+
if (!hadManifest && adoptLegacyUnmanaged) {
|
|
7109
|
+
const existing = readdirSync(destSkills, { withFileTypes: true })
|
|
7110
|
+
.filter(entry => entry.isDirectory() && existsSync(join(destSkills, entry.name, "SKILL.md")))
|
|
7111
|
+
.map(entry => entry.name)
|
|
7112
|
+
.sort();
|
|
7113
|
+
if (existing.length > 0 && existing.every(name => bundled.includes(name))) {
|
|
7114
|
+
previouslyManaged = existing;
|
|
7115
|
+
this.logger.info({ skills: existing, destSkills }, "Adopted legacy AgEnD skills into managed ownership");
|
|
7116
|
+
}
|
|
7117
|
+
}
|
|
7118
|
+
const eligible = bundled.filter(name => {
|
|
7119
|
+
const roles = this.readManagedSkillRoles(join(srcSkills, name, "SKILL.md"));
|
|
7120
|
+
// General is the coordinator and receives both coordinator and worker
|
|
7121
|
+
// playbooks. Workers receive only skills explicitly marked for workers.
|
|
7122
|
+
return role === "general" || roles.includes("worker");
|
|
7123
|
+
});
|
|
7124
|
+
const managed = [];
|
|
7125
|
+
for (const name of eligible) {
|
|
7126
|
+
const destDir = join(destSkills, name);
|
|
7127
|
+
const dest = join(destDir, "SKILL.md");
|
|
7128
|
+
const isOurs = previouslyManaged.includes(name) || !existsSync(destDir);
|
|
7129
|
+
if (!isOurs) {
|
|
7130
|
+
// Name collision with a user-authored skill: theirs wins, loudly.
|
|
7131
|
+
this.logger.warn({ skill: name, destSkills }, "Skipping bundled skill — a skill of this name exists but was not published by AgEnD");
|
|
7132
|
+
continue;
|
|
7133
|
+
}
|
|
7134
|
+
managed.push(name);
|
|
7135
|
+
const newContent = readFileSync(join(srcSkills, name, "SKILL.md"), "utf-8");
|
|
7136
|
+
try {
|
|
7137
|
+
if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
|
|
7138
|
+
continue;
|
|
5621
7139
|
}
|
|
7140
|
+
catch { /* rewrite */ }
|
|
7141
|
+
mkdirSync(destDir, { recursive: true });
|
|
7142
|
+
writeFileSync(dest, newContent);
|
|
7143
|
+
}
|
|
7144
|
+
// Remove managed skills that are no longer bundled OR no longer eligible
|
|
7145
|
+
// for this role. This makes a shared → general-only metadata change take
|
|
7146
|
+
// effect on the next worker startup instead of leaving stale capability.
|
|
7147
|
+
for (const stale of previouslyManaged) {
|
|
7148
|
+
if (eligible.includes(stale))
|
|
7149
|
+
continue;
|
|
7150
|
+
try {
|
|
7151
|
+
rmSync(join(destSkills, stale), { recursive: true, force: true });
|
|
7152
|
+
this.logger.info({ skill: stale, destSkills }, "Removed retired AgEnD-managed skill");
|
|
7153
|
+
}
|
|
7154
|
+
catch (err) {
|
|
7155
|
+
this.logger.debug({ err, skill: stale }, "Failed to remove retired managed skill");
|
|
7156
|
+
}
|
|
7157
|
+
}
|
|
7158
|
+
try {
|
|
7159
|
+
writeFileSync(manifestPath, JSON.stringify(managed, null, 2) + "\n");
|
|
7160
|
+
}
|
|
7161
|
+
catch (err) {
|
|
7162
|
+
this.logger.debug({ err, manifestPath }, "Failed to write managed-skills manifest");
|
|
7163
|
+
}
|
|
7164
|
+
}
|
|
7165
|
+
/** Read AgEnD's roles extension from SKILL.md YAML frontmatter. */
|
|
7166
|
+
readManagedSkillRoles(skillPath) {
|
|
7167
|
+
const fallback = ["general"];
|
|
7168
|
+
try {
|
|
7169
|
+
const content = readFileSync(skillPath, "utf-8");
|
|
7170
|
+
const match = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
7171
|
+
if (!match)
|
|
7172
|
+
return fallback;
|
|
7173
|
+
const document = parseDocument(match[1]);
|
|
7174
|
+
if (document.errors.length > 0)
|
|
7175
|
+
throw document.errors[0];
|
|
7176
|
+
const frontmatter = document.toJS();
|
|
7177
|
+
if (!frontmatter || frontmatter.roles === undefined)
|
|
7178
|
+
return fallback;
|
|
7179
|
+
if (!Array.isArray(frontmatter.roles))
|
|
7180
|
+
throw new Error("roles must be an array");
|
|
7181
|
+
const roles = [...new Set(frontmatter.roles)]
|
|
7182
|
+
.filter((value) => value === "general" || value === "worker");
|
|
7183
|
+
if (roles.length !== frontmatter.roles.length || roles.length === 0) {
|
|
7184
|
+
throw new Error("roles must contain only general and/or worker");
|
|
7185
|
+
}
|
|
7186
|
+
return roles;
|
|
7187
|
+
}
|
|
7188
|
+
catch (err) {
|
|
7189
|
+
// Fail closed for workers: malformed or unknown metadata keeps the
|
|
7190
|
+
// backwards-compatible General-only behavior instead of leaking an
|
|
7191
|
+
// administrative skill into worker workspaces.
|
|
7192
|
+
this.logger.warn({ err, skillPath }, "Invalid bundled skill roles — defaulting to General only");
|
|
7193
|
+
return fallback;
|
|
5622
7194
|
}
|
|
5623
|
-
this.logger.debug({ knowledgeDir, steeringDir }, "Synced general knowledge files");
|
|
5624
7195
|
}
|
|
5625
7196
|
/** Fetch forum topic icon stickers and pick emoji IDs for each state */
|
|
5626
7197
|
async resolveTopicIcons() {
|
|
@@ -6052,6 +7623,24 @@ When users create specialized instances, suggest these configurations:
|
|
|
6052
7623
|
return null;
|
|
6053
7624
|
const probed = await be.probeCLIEnv({ workingDirectory: "", instanceDir: join(getAgendHome(), "cli-env"), instanceName: `probe-${backend}`, mcpServers: {} });
|
|
6054
7625
|
const env = { backend, probedAt: Date.now(), ...probed };
|
|
7626
|
+
// An empty result must never overwrite a catalog we already have. Some
|
|
7627
|
+
// probes hit the network (`agy models` fetches, 5s cap), so a slow moment
|
|
7628
|
+
// returns [] — and writing that would blank the list for the whole 24h
|
|
7629
|
+
// TTL, long after the CLI recovered. Observed live: a good 11-model
|
|
7630
|
+
// antigravity cache replaced by an empty one. Keep the known models and
|
|
7631
|
+
// let the fresher currentModel/version through.
|
|
7632
|
+
if (!env.models?.length) {
|
|
7633
|
+
const previous = this.readCliEnv(backend);
|
|
7634
|
+
if (previous?.models?.length)
|
|
7635
|
+
env.models = previous.models;
|
|
7636
|
+
}
|
|
7637
|
+
// Same protection for the extended catalog: one offline moment must not
|
|
7638
|
+
// blank a good account list for the whole cache TTL.
|
|
7639
|
+
if (!env.apiModels?.length) {
|
|
7640
|
+
const previous = this.readCliEnv(backend);
|
|
7641
|
+
if (previous?.apiModels?.length)
|
|
7642
|
+
env.apiModels = previous.apiModels;
|
|
7643
|
+
}
|
|
6055
7644
|
const path = this.cliEnvPath(backend);
|
|
6056
7645
|
mkdirSync(dirname(path), { recursive: true });
|
|
6057
7646
|
writeFileSync(path, JSON.stringify(env, null, 2));
|
|
@@ -6090,6 +7679,108 @@ When users create specialized instances, suggest these configurations:
|
|
|
6090
7679
|
const env = await this.probeBackend(backendName);
|
|
6091
7680
|
return env?.models ?? [];
|
|
6092
7681
|
}
|
|
7682
|
+
/**
|
|
7683
|
+
* Model catalog behind the `list_models` tool.
|
|
7684
|
+
*
|
|
7685
|
+
* The two scopes are not cosmetic. "global" is the account/CLI catalog served
|
|
7686
|
+
* from the startup probe cache; "instance" is resolved through that instance's
|
|
7687
|
+
* OWN backend config, and for a Codex instance on a custom provider that is a
|
|
7688
|
+
* different catalog entirely — `listModels()` reads models_cache.json out of
|
|
7689
|
+
* the instance's private CODEX_HOME. Answering such an instance with the
|
|
7690
|
+
* account list would name models its CLI rejects, which is exactly the
|
|
7691
|
+
* mistake this tool exists to prevent.
|
|
7692
|
+
*
|
|
7693
|
+
* `scope` always describes where the returned LIST came from, not what was
|
|
7694
|
+
* asked for: an instance query that falls back to the account catalog reports
|
|
7695
|
+
* scope "global" and says so in `note`, rather than implying instance-level
|
|
7696
|
+
* accuracy it does not have.
|
|
7697
|
+
*
|
|
7698
|
+
* Never throws — a model listing is an aid, and failing it must not fail a turn.
|
|
7699
|
+
*/
|
|
7700
|
+
async listModelCatalog(opts = {}) {
|
|
7701
|
+
const { instanceName } = opts;
|
|
7702
|
+
if (instanceName) {
|
|
7703
|
+
const backend = this.backendNameForInstance(instanceName);
|
|
7704
|
+
const resolved = this.resolveInstanceModel(instanceName);
|
|
7705
|
+
const currentModel = resolved.source === "unresolved" ? null : resolved.model;
|
|
7706
|
+
const provider = this.customProviderFor(instanceName, backend);
|
|
7707
|
+
const scoped = await this.instanceScopedModels(instanceName, backend);
|
|
7708
|
+
if (scoped.length) {
|
|
7709
|
+
return {
|
|
7710
|
+
backend, scope: "instance", instance: instanceName,
|
|
7711
|
+
current_model: currentModel, models: scoped, source: "live",
|
|
7712
|
+
...(provider ? { note: `Catalog read through this instance's ${backend} provider "${provider}" — it may differ from the account catalog.` } : {}),
|
|
7713
|
+
};
|
|
7714
|
+
}
|
|
7715
|
+
// No instance-local catalog (never launched, or the backend has no
|
|
7716
|
+
// per-instance list). The account catalog is the best available answer,
|
|
7717
|
+
// but it is labelled honestly rather than dressed up as instance scope.
|
|
7718
|
+
const global = await this.globalModelCatalog(backend);
|
|
7719
|
+
return {
|
|
7720
|
+
...global, instance: instanceName, current_model: currentModel,
|
|
7721
|
+
note: provider
|
|
7722
|
+
? `No instance-local catalog yet; showing the account catalog, which may NOT match this instance's ${backend} provider "${provider}".`
|
|
7723
|
+
: "No instance-local catalog yet; showing the account catalog.",
|
|
7724
|
+
};
|
|
7725
|
+
}
|
|
7726
|
+
return this.globalModelCatalog(opts.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
|
|
7727
|
+
}
|
|
7728
|
+
/** The custom provider an instance overrides its backend with, if any. */
|
|
7729
|
+
customProviderFor(instanceName, backend) {
|
|
7730
|
+
const opts = this.fleetConfig?.instances?.[instanceName]?.backend_options?.[backend]
|
|
7731
|
+
?? this.fleetConfig?.defaults?.backend_options?.[backend];
|
|
7732
|
+
const provider = opts?.provider;
|
|
7733
|
+
return typeof provider === "string" && provider.trim() ? provider.trim() : null;
|
|
7734
|
+
}
|
|
7735
|
+
/** Ask a backend for its catalog using ONE instance's real config. Never throws. */
|
|
7736
|
+
async instanceScopedModels(instanceName, backend) {
|
|
7737
|
+
try {
|
|
7738
|
+
const inst = this.fleetConfig?.instances?.[instanceName];
|
|
7739
|
+
const instanceDir = this.getInstanceDir(instanceName);
|
|
7740
|
+
const be = createBackend(backend, instanceDir);
|
|
7741
|
+
if (!be.listModels)
|
|
7742
|
+
return [];
|
|
7743
|
+
return await be.listModels({
|
|
7744
|
+
workingDirectory: inst?.working_directory ?? "",
|
|
7745
|
+
instanceDir,
|
|
7746
|
+
instanceName,
|
|
7747
|
+
mcpServers: {},
|
|
7748
|
+
model: inst?.model,
|
|
7749
|
+
backendOptions: inst?.backend_options?.[backend] ?? this.fleetConfig?.defaults?.backend_options?.[backend],
|
|
7750
|
+
}) ?? [];
|
|
7751
|
+
}
|
|
7752
|
+
catch {
|
|
7753
|
+
// listModels is documented never to throw, but a backend constructor can
|
|
7754
|
+
// (missing binary). A catalog is an aid; degrade to the account list.
|
|
7755
|
+
return [];
|
|
7756
|
+
}
|
|
7757
|
+
}
|
|
7758
|
+
/** Account-wide catalog: probe cache first, live probe on miss. */
|
|
7759
|
+
async globalModelCatalog(backend) {
|
|
7760
|
+
const cached = this.readCliEnv(backend);
|
|
7761
|
+
if (cached?.models?.length) {
|
|
7762
|
+
return {
|
|
7763
|
+
backend, scope: "global", current_model: cached.currentModel ?? null,
|
|
7764
|
+
models: cached.models, source: "cache",
|
|
7765
|
+
probed_at: new Date(cached.probedAt).toISOString(),
|
|
7766
|
+
};
|
|
7767
|
+
}
|
|
7768
|
+
const env = await this.probeBackend(backend);
|
|
7769
|
+
if (env?.models?.length) {
|
|
7770
|
+
return {
|
|
7771
|
+
backend, scope: "global", current_model: env.currentModel ?? null,
|
|
7772
|
+
models: env.models, source: "live",
|
|
7773
|
+
probed_at: new Date(env.probedAt).toISOString(),
|
|
7774
|
+
};
|
|
7775
|
+
}
|
|
7776
|
+
// Reported rather than thrown: "we could not enumerate" is a useful answer,
|
|
7777
|
+
// and the caller can still set a model by name (AgEnD passes it through).
|
|
7778
|
+
return {
|
|
7779
|
+
backend, scope: "global", current_model: env?.currentModel ?? null,
|
|
7780
|
+
models: [], source: "fallback",
|
|
7781
|
+
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.`,
|
|
7782
|
+
};
|
|
7783
|
+
}
|
|
6093
7784
|
/** `/model` slash handler (admin only). No arg → DC menu; `/model <name>` → apply directly. */
|
|
6094
7785
|
/** Label an effort choice, marking the one currently configured. */
|
|
6095
7786
|
effortChoiceLabel(level, current) {
|
|
@@ -6098,10 +7789,10 @@ When users create specialized instances, suggest these configurations:
|
|
|
6098
7789
|
effortMenuHeader(instanceName) {
|
|
6099
7790
|
const { effort, source } = this.resolveInstanceEffort(instanceName);
|
|
6100
7791
|
if (!effort)
|
|
6101
|
-
return "
|
|
7792
|
+
return t("effort.current_default");
|
|
6102
7793
|
return source === "fleet-default"
|
|
6103
|
-
?
|
|
6104
|
-
:
|
|
7794
|
+
? t("effort.current_fleet", effort)
|
|
7795
|
+
: t("effort.current", effort);
|
|
6105
7796
|
}
|
|
6106
7797
|
/** `/effort` — DC Select Menu, or apply directly when a level is given. */
|
|
6107
7798
|
async handleEffortSlash(data, adapterId) {
|
|
@@ -6122,11 +7813,11 @@ When users create specialized instances, suggest these configurations:
|
|
|
6122
7813
|
}
|
|
6123
7814
|
const levels = this.effortLevelsFor(name);
|
|
6124
7815
|
if (levels.length === 0) {
|
|
6125
|
-
await data.respond(
|
|
7816
|
+
await data.respond(t("effort.unsupported", this.backendNameForInstance(name)));
|
|
6126
7817
|
return;
|
|
6127
7818
|
}
|
|
6128
7819
|
if (!data.respondChoices) {
|
|
6129
|
-
await data.respond(
|
|
7820
|
+
await data.respond(t("effort.usage", levels.join("|")));
|
|
6130
7821
|
return;
|
|
6131
7822
|
}
|
|
6132
7823
|
const current = this.resolveInstanceEffort(name).effort;
|
|
@@ -6139,20 +7830,20 @@ When users create specialized instances, suggest these configurations:
|
|
|
6139
7830
|
timer.unref?.();
|
|
6140
7831
|
this.pendingEffortSelects.set(nonce, { instanceName: name, userId: data.userId, channelId: data.channelId, timer, respond: data.respond });
|
|
6141
7832
|
try {
|
|
6142
|
-
await data.respondChoices(
|
|
7833
|
+
await data.respondChoices(t("effort.menu", this.effortMenuHeader(name)), choices);
|
|
6143
7834
|
}
|
|
6144
7835
|
catch (err) {
|
|
6145
7836
|
this.pendingEffortSelects.delete(nonce);
|
|
6146
7837
|
clearTimeout(timer);
|
|
6147
7838
|
this.logger.warn({ err, instanceName: name }, "effort menu failed");
|
|
6148
|
-
await data.respond(
|
|
7839
|
+
await data.respond(t("effort.usage", levels.join("|")));
|
|
6149
7840
|
}
|
|
6150
7841
|
}
|
|
6151
7842
|
/** TG inline-keyboard effort menu. Returns null on success, else a fallback string. */
|
|
6152
7843
|
async promptEffortMenu(instanceName, userId, channelId, adapter, chatId, threadId) {
|
|
6153
7844
|
const levels = this.effortLevelsFor(instanceName);
|
|
6154
7845
|
if (levels.length === 0) {
|
|
6155
|
-
return
|
|
7846
|
+
return t("effort.unsupported", this.backendNameForInstance(instanceName));
|
|
6156
7847
|
}
|
|
6157
7848
|
const current = this.resolveInstanceEffort(instanceName).effort;
|
|
6158
7849
|
const nonce = randomBytes(6).toString("hex");
|
|
@@ -6168,13 +7859,13 @@ When users create specialized instances, suggest these configurations:
|
|
|
6168
7859
|
const p = this.pendingEffortSelects.get(nonce);
|
|
6169
7860
|
if (p) {
|
|
6170
7861
|
this.pendingEffortSelects.delete(nonce);
|
|
6171
|
-
p.respond("
|
|
7862
|
+
p.respond(t("effort.selection_expired")).catch(() => { });
|
|
6172
7863
|
}
|
|
6173
7864
|
}, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
6174
7865
|
timer.unref?.();
|
|
6175
7866
|
this.pendingEffortSelects.set(nonce, { instanceName, userId, channelId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
6176
7867
|
try {
|
|
6177
|
-
const menuMessageId = await adapter.promptUser(chatId,
|
|
7868
|
+
const menuMessageId = await adapter.promptUser(chatId, t("effort.menu", this.effortMenuHeader(instanceName)), choices, { threadId });
|
|
6178
7869
|
const pending = this.pendingEffortSelects.get(nonce);
|
|
6179
7870
|
if (pending)
|
|
6180
7871
|
pending.menuMessageId = menuMessageId;
|
|
@@ -6184,7 +7875,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6184
7875
|
this.pendingEffortSelects.delete(nonce);
|
|
6185
7876
|
clearTimeout(timer);
|
|
6186
7877
|
this.logger.warn({ err, instanceName }, "TG effort menu failed");
|
|
6187
|
-
return
|
|
7878
|
+
return t("effort.usage", levels.join("|"));
|
|
6188
7879
|
}
|
|
6189
7880
|
}
|
|
6190
7881
|
/** Consume an `/effort` selection callback. Mirrors handleModelSelection. */
|
|
@@ -6205,7 +7896,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6205
7896
|
this.pendingEffortSelects.delete(match[1]);
|
|
6206
7897
|
clearTimeout(pending.timer);
|
|
6207
7898
|
const level = match[2];
|
|
6208
|
-
const progressText =
|
|
7899
|
+
const progressText = t("effort.setting", pending.instanceName, level);
|
|
6209
7900
|
let progressMsgId;
|
|
6210
7901
|
if (pending.adapter && pending.adapterChatId) {
|
|
6211
7902
|
const menuMessageId = pending.menuMessageId ?? data.messageId;
|
|
@@ -6237,7 +7928,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6237
7928
|
}
|
|
6238
7929
|
catch (err) {
|
|
6239
7930
|
this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
|
|
6240
|
-
result =
|
|
7931
|
+
result = t("effort.switch_failed", level, err instanceof Error ? err.message : String(err));
|
|
6241
7932
|
}
|
|
6242
7933
|
if (pending.adapter && pending.adapterChatId) {
|
|
6243
7934
|
if (progressMsgId) {
|
|
@@ -6274,32 +7965,36 @@ When users create specialized instances, suggest these configurations:
|
|
|
6274
7965
|
}
|
|
6275
7966
|
// No arg (or --refresh) → menu. Menu is DC-only this round (respondChoices); TG uses `/model <name>`.
|
|
6276
7967
|
if (!data.respondChoices) {
|
|
6277
|
-
await data.respond("
|
|
7968
|
+
await data.respond(t("model.usage"));
|
|
6278
7969
|
return;
|
|
6279
7970
|
}
|
|
6280
7971
|
const options = await this.getModelOptions(name, isRefresh);
|
|
6281
7972
|
if (options.length === 0) {
|
|
6282
|
-
await data.respond(
|
|
7973
|
+
await data.respond(t("model.list_unavailable", name));
|
|
6283
7974
|
return;
|
|
6284
7975
|
}
|
|
6285
7976
|
// Raw id for ✓-matching options; display resolves an inherited CLI default.
|
|
6286
7977
|
const { model: currentModel, display: currentDisplay } = this.resolveInstanceModel(name);
|
|
6287
7978
|
const nonce = randomBytes(6).toString("hex");
|
|
6288
|
-
const
|
|
7979
|
+
const isClaude = this.backendNameForInstance(name) === "claude-code";
|
|
7980
|
+
const choices = options.slice(0, isClaude ? 24 : 25).map(o => ({
|
|
6289
7981
|
id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`,
|
|
6290
7982
|
label: this.modelChoiceLabel(o, currentModel),
|
|
6291
7983
|
}));
|
|
7984
|
+
if (isClaude) {
|
|
7985
|
+
choices.push({ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:__more__`, label: t("model.more") });
|
|
7986
|
+
}
|
|
6292
7987
|
const timer = setTimeout(() => this.pendingModelSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
6293
7988
|
timer.unref?.();
|
|
6294
|
-
this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, timer, respond: data.respond });
|
|
7989
|
+
this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, timer, respond: data.respond, respondChoices: data.respondChoices });
|
|
6295
7990
|
try {
|
|
6296
|
-
await data.respondChoices(
|
|
7991
|
+
await data.respondChoices(t("model.menu", `**${currentDisplay}**`), choices);
|
|
6297
7992
|
}
|
|
6298
7993
|
catch (err) {
|
|
6299
7994
|
this.pendingModelSelects.delete(nonce);
|
|
6300
7995
|
clearTimeout(timer);
|
|
6301
7996
|
this.logger.warn({ err, instanceName: name }, "model menu failed");
|
|
6302
|
-
await data.respond("
|
|
7997
|
+
await data.respond(t("model.usage"));
|
|
6303
7998
|
}
|
|
6304
7999
|
}
|
|
6305
8000
|
/**
|
|
@@ -6310,14 +8005,19 @@ When users create specialized instances, suggest these configurations:
|
|
|
6310
8005
|
async promptModelMenu(instanceName, userId, channelId, adapter, chatId, threadId) {
|
|
6311
8006
|
const options = await this.getModelOptions(instanceName);
|
|
6312
8007
|
if (options.length === 0) {
|
|
6313
|
-
return
|
|
8008
|
+
return t("model.list_unavailable", instanceName);
|
|
6314
8009
|
}
|
|
6315
8010
|
const { model: currentModel, display: currentDisplay } = this.resolveInstanceModel(instanceName);
|
|
6316
8011
|
const nonce = randomBytes(6).toString("hex");
|
|
6317
|
-
const
|
|
8012
|
+
const isClaude = this.backendNameForInstance(instanceName) === "claude-code";
|
|
8013
|
+
// Keep the more-models entry inside Discord's 25-option select cap.
|
|
8014
|
+
const choices = options.slice(0, isClaude ? 24 : 25).map(o => ({
|
|
6318
8015
|
id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`,
|
|
6319
8016
|
label: this.modelChoiceLabel(o, currentModel),
|
|
6320
8017
|
}));
|
|
8018
|
+
if (isClaude) {
|
|
8019
|
+
choices.push({ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:__more__`, label: t("model.more") });
|
|
8020
|
+
}
|
|
6321
8021
|
const respond = async (text) => {
|
|
6322
8022
|
await adapter.sendText(chatId, text, { threadId });
|
|
6323
8023
|
return undefined;
|
|
@@ -6326,13 +8026,13 @@ When users create specialized instances, suggest these configurations:
|
|
|
6326
8026
|
const p = this.pendingModelSelects.get(nonce);
|
|
6327
8027
|
if (p) {
|
|
6328
8028
|
this.pendingModelSelects.delete(nonce);
|
|
6329
|
-
p.respond("
|
|
8029
|
+
p.respond(t("model.selection_expired")).catch(() => { });
|
|
6330
8030
|
}
|
|
6331
8031
|
}, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
6332
8032
|
timer.unref?.();
|
|
6333
8033
|
this.pendingModelSelects.set(nonce, { instanceName, model: "", userId, channelId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
6334
8034
|
try {
|
|
6335
|
-
const menuMessageId = await adapter.promptUser(chatId,
|
|
8035
|
+
const menuMessageId = await adapter.promptUser(chatId, t("model.menu", currentDisplay), choices, { threadId });
|
|
6336
8036
|
const pending = this.pendingModelSelects.get(nonce);
|
|
6337
8037
|
if (pending)
|
|
6338
8038
|
pending.menuMessageId = menuMessageId;
|
|
@@ -6342,7 +8042,63 @@ When users create specialized instances, suggest these configurations:
|
|
|
6342
8042
|
this.pendingModelSelects.delete(nonce);
|
|
6343
8043
|
clearTimeout(timer);
|
|
6344
8044
|
this.logger.warn({ err, instanceName }, "TG model menu failed");
|
|
6345
|
-
return
|
|
8045
|
+
return t("model.usage");
|
|
8046
|
+
}
|
|
8047
|
+
}
|
|
8048
|
+
/** Cached-or-live account catalog behind "/model → more models" (claude). */
|
|
8049
|
+
async claudeApiModelOptions() {
|
|
8050
|
+
const cached = this.readCliEnv("claude-code");
|
|
8051
|
+
if (cached?.apiModels?.length)
|
|
8052
|
+
return cached.apiModels;
|
|
8053
|
+
const env = await this.probeBackend("claude-code");
|
|
8054
|
+
return env?.apiModels ?? [];
|
|
8055
|
+
}
|
|
8056
|
+
/** Replace a consumed "/model" menu with the full account catalog tier. */
|
|
8057
|
+
async expandClaudeModelMenu(pending) {
|
|
8058
|
+
const expanded = await this.claudeApiModelOptions();
|
|
8059
|
+
if (!expanded.length) {
|
|
8060
|
+
await pending.respond(t("model.more_unavailable")).catch(() => { });
|
|
8061
|
+
return;
|
|
8062
|
+
}
|
|
8063
|
+
const { model: currentModel, display: currentDisplay } = this.resolveInstanceModel(pending.instanceName);
|
|
8064
|
+
const nonce = randomBytes(6).toString("hex");
|
|
8065
|
+
const choices = expanded.slice(0, 25).map(o => ({
|
|
8066
|
+
id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`,
|
|
8067
|
+
label: this.modelChoiceLabel(o, currentModel),
|
|
8068
|
+
}));
|
|
8069
|
+
const timer = setTimeout(() => {
|
|
8070
|
+
const p = this.pendingModelSelects.get(nonce);
|
|
8071
|
+
if (p) {
|
|
8072
|
+
this.pendingModelSelects.delete(nonce);
|
|
8073
|
+
p.respond(t("model.selection_expired")).catch(() => { });
|
|
8074
|
+
}
|
|
8075
|
+
}, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
8076
|
+
timer.unref?.();
|
|
8077
|
+
this.pendingModelSelects.set(nonce, { ...pending, model: "", timer });
|
|
8078
|
+
try {
|
|
8079
|
+
if (pending.respondChoices) {
|
|
8080
|
+
// Discord select menu: edit the same interaction reply in place.
|
|
8081
|
+
await pending.respondChoices(t("model.menu", `**${currentDisplay}**`), choices);
|
|
8082
|
+
return;
|
|
8083
|
+
}
|
|
8084
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
8085
|
+
// Telegram: retire the tier-1 keyboard, then post the expanded menu.
|
|
8086
|
+
if (pending.menuMessageId && pending.adapter.editMessageRemoveButtons) {
|
|
8087
|
+
await pending.adapter.editMessageRemoveButtons(pending.adapterChatId, pending.menuMessageId, t("model.more"), pending.adapterThreadId).catch(() => { });
|
|
8088
|
+
}
|
|
8089
|
+
const menuMessageId = await pending.adapter.promptUser(pending.adapterChatId, t("model.menu", currentDisplay), choices, { threadId: pending.adapterThreadId });
|
|
8090
|
+
const fresh = this.pendingModelSelects.get(nonce);
|
|
8091
|
+
if (fresh)
|
|
8092
|
+
fresh.menuMessageId = menuMessageId;
|
|
8093
|
+
return;
|
|
8094
|
+
}
|
|
8095
|
+
await pending.respond(t("model.more_unavailable")).catch(() => { });
|
|
8096
|
+
}
|
|
8097
|
+
catch (err) {
|
|
8098
|
+
this.pendingModelSelects.delete(nonce);
|
|
8099
|
+
clearTimeout(timer);
|
|
8100
|
+
this.logger.warn({ err, instanceName: pending.instanceName }, "Expanded model menu failed");
|
|
8101
|
+
await pending.respond(t("model.more_unavailable")).catch(() => { });
|
|
6346
8102
|
}
|
|
6347
8103
|
}
|
|
6348
8104
|
/** Consume a `/model` selection callback. Returns true for all model-select ids (incl. stale). */
|
|
@@ -6364,8 +8120,14 @@ When users create specialized instances, suggest these configurations:
|
|
|
6364
8120
|
this.pendingModelSelects.delete(match[1]);
|
|
6365
8121
|
clearTimeout(pending.timer);
|
|
6366
8122
|
const model = match[2];
|
|
8123
|
+
// "More models…" is a navigation choice, not a model: swap the menu for the
|
|
8124
|
+
// full account catalog (live /v1/models with [1m] variants).
|
|
8125
|
+
if (model === "__more__") {
|
|
8126
|
+
await this.expandClaudeModelMenu(pending);
|
|
8127
|
+
return true;
|
|
8128
|
+
}
|
|
6367
8129
|
// Send immediate "⏳ Switching..." feedback, then apply in background.
|
|
6368
|
-
const progressText =
|
|
8130
|
+
const progressText = t("model.switching", pending.instanceName, model);
|
|
6369
8131
|
let progressMsgId;
|
|
6370
8132
|
if (pending.adapter && pending.adapterChatId) {
|
|
6371
8133
|
// TG path: turn the original menu into the progress message. This both
|
|
@@ -6402,7 +8164,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6402
8164
|
}
|
|
6403
8165
|
catch (err) {
|
|
6404
8166
|
this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
|
|
6405
|
-
result =
|
|
8167
|
+
result = t("model.switch_failed", model, err instanceof Error ? err.message : String(err));
|
|
6406
8168
|
}
|
|
6407
8169
|
if (pending.adapter && pending.adapterChatId) {
|
|
6408
8170
|
if (progressMsgId) {
|
|
@@ -6499,17 +8261,17 @@ When users create specialized instances, suggest these configurations:
|
|
|
6499
8261
|
}
|
|
6500
8262
|
catch { /* treated as unsupported below */ }
|
|
6501
8263
|
if (strategy === "unsupported" || supported.length === 0) {
|
|
6502
|
-
return
|
|
8264
|
+
return t("effort.unsupported", backendName);
|
|
6503
8265
|
}
|
|
6504
8266
|
if (!FleetManager.EFFORT_LEVELS.includes(level)) {
|
|
6505
|
-
return
|
|
8267
|
+
return t("effort.unknown", level, FleetManager.EFFORT_LEVELS.join(", "));
|
|
6506
8268
|
}
|
|
6507
8269
|
const applied = FleetManager.clampEffort(level, supported);
|
|
6508
8270
|
if (!applied)
|
|
6509
|
-
return
|
|
8271
|
+
return t("effort.no_canonical", backendName);
|
|
6510
8272
|
const warn = applied === level
|
|
6511
8273
|
? ""
|
|
6512
|
-
:
|
|
8274
|
+
: t("effort.clamped", applied, level, backendName);
|
|
6513
8275
|
// Persist either way: a runtime switch must survive the next respawn too,
|
|
6514
8276
|
// or the instance silently reverts on restart.
|
|
6515
8277
|
if (this.fleetConfig?.instances[instanceName]) {
|
|
@@ -6518,12 +8280,12 @@ When users create specialized instances, suggest these configurations:
|
|
|
6518
8280
|
}
|
|
6519
8281
|
if (strategy === "runtime") {
|
|
6520
8282
|
if (!this.instanceIpcClients.get(instanceName))
|
|
6521
|
-
return `${warn}
|
|
8283
|
+
return `${warn}${t("effort.not_running", instanceName)}`;
|
|
6522
8284
|
this.pasteRawToClassicInstance(instanceName, `/effort ${applied}`);
|
|
6523
|
-
return `${warn}
|
|
8285
|
+
return `${warn}${t("effort.runtime_success", instanceName, applied)}`;
|
|
6524
8286
|
}
|
|
6525
8287
|
await this.restartSingleInstance(instanceName);
|
|
6526
|
-
return `${warn}
|
|
8288
|
+
return `${warn}${t("effort.restart_success", instanceName, applied)}`;
|
|
6527
8289
|
}
|
|
6528
8290
|
async applyModel(instanceName, model) {
|
|
6529
8291
|
const backendName = this.backendNameForInstance(instanceName);
|
|
@@ -6532,9 +8294,9 @@ When users create specialized instances, suggest these configurations:
|
|
|
6532
8294
|
strategy = createBackend(backendName, this.getInstanceDir(instanceName)).getModelSwitchStrategy?.(model) ?? "restart";
|
|
6533
8295
|
}
|
|
6534
8296
|
catch { /* default restart */ }
|
|
6535
|
-
const warn = isModelCompatible(backendName, model) ? "" :
|
|
8297
|
+
const warn = isModelCompatible(backendName, model) ? "" : t("model.pattern_warning", model, backendName);
|
|
6536
8298
|
if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
|
|
6537
|
-
return `${warn}
|
|
8299
|
+
return `${warn}${t("effort.not_running", instanceName)}`;
|
|
6538
8300
|
}
|
|
6539
8301
|
// Persist either way: a runtime switch must survive the next respawn too,
|
|
6540
8302
|
// or the instance silently reverts to the CLI default after a fleet restart.
|
|
@@ -6548,13 +8310,13 @@ When users create specialized instances, suggest these configurations:
|
|
|
6548
8310
|
persisted = true;
|
|
6549
8311
|
}
|
|
6550
8312
|
if (!persisted)
|
|
6551
|
-
return `${warn}
|
|
8313
|
+
return `${warn}${t("model.persist_failed", instanceName)}`;
|
|
6552
8314
|
if (strategy === "runtime") {
|
|
6553
8315
|
this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
|
|
6554
|
-
return `${warn}
|
|
8316
|
+
return `${warn}${t("model.runtime_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
|
|
6555
8317
|
}
|
|
6556
8318
|
await this.restartSingleInstance(instanceName);
|
|
6557
|
-
return `${warn}
|
|
8319
|
+
return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
|
|
6558
8320
|
}
|
|
6559
8321
|
/**
|
|
6560
8322
|
* The trailing "Current effort: …" line for a /model reply.
|
|
@@ -6568,10 +8330,10 @@ When users create specialized instances, suggest these configurations:
|
|
|
6568
8330
|
return "";
|
|
6569
8331
|
const { effort, source } = this.resolveInstanceEffort(instanceName);
|
|
6570
8332
|
if (!effort)
|
|
6571
|
-
return "
|
|
8333
|
+
return `\n${t("effort.current_default")}`;
|
|
6572
8334
|
return source === "fleet-default"
|
|
6573
|
-
? `\
|
|
6574
|
-
: `\
|
|
8335
|
+
? `\n${t("effort.current_fleet", effort)}`
|
|
8336
|
+
: `\n${t("effort.current", effort)}`;
|
|
6575
8337
|
}
|
|
6576
8338
|
/** Read recent chat log for agent context */
|
|
6577
8339
|
getRecentChatLog(instanceName, maxLines = 10) {
|
|
@@ -6606,7 +8368,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6606
8368
|
/** Return a user-facing blocker without mutating ClassicBot state. */
|
|
6607
8369
|
validateClassicStart(channelId, userId, guildId, adapterId) {
|
|
6608
8370
|
if (!this.classicChannels)
|
|
6609
|
-
return "
|
|
8371
|
+
return t("classic.manager_unavailable");
|
|
6610
8372
|
if (guildId && !this.classicChannels.isGuildAllowed(guildId)) {
|
|
6611
8373
|
const generalId = this.findGeneralInstance(adapterId);
|
|
6612
8374
|
if (generalId)
|
|
@@ -6753,6 +8515,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6753
8515
|
return;
|
|
6754
8516
|
const workDir = join(getAgendHome(), "workspaces", instanceName);
|
|
6755
8517
|
ensureWorkspaceGit(workDir);
|
|
8518
|
+
const classicIdentity = this.classicChannels?.getAll().find(ch => ch.instanceName === instanceName);
|
|
6756
8519
|
const config = {
|
|
6757
8520
|
...DEFAULT_INSTANCE_CONFIG,
|
|
6758
8521
|
...this.fleetConfig?.defaults,
|
|
@@ -6760,6 +8523,8 @@ When users create specialized instances, suggest these configurations:
|
|
|
6760
8523
|
lightweight: true,
|
|
6761
8524
|
...(backend ? { backend } : {}),
|
|
6762
8525
|
...(model ? { model } : {}),
|
|
8526
|
+
...(classicIdentity?.displayName ? { display_name: classicIdentity.displayName } : {}),
|
|
8527
|
+
...(classicIdentity?.description ? { description: classicIdentity.description } : {}),
|
|
6763
8528
|
...(autoPauseAfter !== undefined ? { auto_pause_after: autoPauseAfter } : {}),
|
|
6764
8529
|
...(preTaskCommand ? { pre_task_command: preTaskCommand } : {}),
|
|
6765
8530
|
};
|
|
@@ -6773,7 +8538,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6773
8538
|
return blocker;
|
|
6774
8539
|
const classicChannels = this.classicChannels;
|
|
6775
8540
|
if (!classicChannels)
|
|
6776
|
-
return "
|
|
8541
|
+
return t("classic.manager_unavailable");
|
|
6777
8542
|
const instanceName = classicChannels.deriveInstanceName(channelName || channelId, channelId, adapterId);
|
|
6778
8543
|
clearPausedMarker(this.getInstanceDir(instanceName));
|
|
6779
8544
|
const selectedBackend = isSelectableClassicBackend(backend) ? backend : undefined;
|
|
@@ -6795,7 +8560,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6795
8560
|
/** Handle /stop slash command — unregister classic channel */
|
|
6796
8561
|
async handleClassicStop(channelId, adapterId) {
|
|
6797
8562
|
if (!this.classicChannels)
|
|
6798
|
-
return "
|
|
8563
|
+
return t("classic.manager_unavailable");
|
|
6799
8564
|
const ch = this.classicChannels.unregister(channelId, adapterId);
|
|
6800
8565
|
if (!ch)
|
|
6801
8566
|
return t("classic.no_agent");
|
|
@@ -6827,6 +8592,15 @@ When users create specialized instances, suggest these configurations:
|
|
|
6827
8592
|
// await in the same tick as the signal handler, so no event can slip in.
|
|
6828
8593
|
this.shuttingDown = true;
|
|
6829
8594
|
this.ipcStoppingInstances.add("__fleet_stopping__");
|
|
8595
|
+
// Release held delivery promises before awaiting daemon shutdown, then
|
|
8596
|
+
// reject spawn work which has not started. Otherwise a storm backoff could
|
|
8597
|
+
// make `agend stop` wait forever for its own queue.
|
|
8598
|
+
this.stormWindow.shutdown();
|
|
8599
|
+
this.spawnGate.shutdown();
|
|
8600
|
+
if (this.stormOpenNotifyTimer) {
|
|
8601
|
+
clearTimeout(this.stormOpenNotifyTimer);
|
|
8602
|
+
this.stormOpenNotifyTimer = null;
|
|
8603
|
+
}
|
|
6830
8604
|
sdNotifyBlocking("STOPPING=1");
|
|
6831
8605
|
if (this.watchdogTimer) {
|
|
6832
8606
|
clearInterval(this.watchdogTimer);
|
|
@@ -6842,6 +8616,8 @@ When users create specialized instances, suggest these configurations:
|
|
|
6842
8616
|
this.clearStatuslineWatchers();
|
|
6843
8617
|
this.costGuard?.stop();
|
|
6844
8618
|
this.dailySummary?.stop();
|
|
8619
|
+
this.dailyTipScheduler?.stop();
|
|
8620
|
+
this.dailyTipScheduler = null;
|
|
6845
8621
|
if (this.updateCheckTimer) {
|
|
6846
8622
|
clearTimeout(this.updateCheckTimer);
|
|
6847
8623
|
clearInterval(this.updateCheckTimer);
|
|
@@ -7059,7 +8835,8 @@ When users create specialized instances, suggest these configurations:
|
|
|
7059
8835
|
/**
|
|
7060
8836
|
* Hot-reload: re-read fleet.yaml and reconcile running instances.
|
|
7061
8837
|
* Starts new, stops removed, restarts modified instances.
|
|
7062
|
-
*
|
|
8838
|
+
* Whitelisted runtime fields are pushed into live daemons; all other instance
|
|
8839
|
+
* fields, plus cold fleet-level settings, retain restart semantics.
|
|
7063
8840
|
*/
|
|
7064
8841
|
async reconcileInstances() {
|
|
7065
8842
|
if (!this.configPath)
|
|
@@ -7101,11 +8878,11 @@ When users create specialized instances, suggest these configurations:
|
|
|
7101
8878
|
// possible outcome: they change fleet.yaml, send SIGHUP, and nothing happens
|
|
7102
8879
|
// with no explanation anywhere they are looking.
|
|
7103
8880
|
const why = !validation.valid
|
|
7104
|
-
?
|
|
8881
|
+
? t("fleet.reload_validation", validation.errors.map(e => `• ${e.path}: ${e.message}`).join("\n"))
|
|
7105
8882
|
: unsafeEmpty
|
|
7106
|
-
?
|
|
7107
|
-
:
|
|
7108
|
-
this.notifyFleetError(
|
|
8883
|
+
? t("fleet.reload_removed_all", oldCount)
|
|
8884
|
+
: t("fleet.reload_removed_half", oldCount, newCount);
|
|
8885
|
+
this.notifyFleetError(t("fleet.reload_rejected", why));
|
|
7109
8886
|
return;
|
|
7110
8887
|
}
|
|
7111
8888
|
this.routing.rebuild(this.fleetConfig);
|
|
@@ -7113,9 +8890,20 @@ When users create specialized instances, suggest these configurations:
|
|
|
7113
8890
|
this.scheduler?.reload();
|
|
7114
8891
|
const newInstances = this.fleetConfig.instances;
|
|
7115
8892
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
7116
|
-
// Detect fleet-level
|
|
7117
|
-
|
|
7118
|
-
const
|
|
8893
|
+
// Detect fleet-level changes which still need a restart. Hot defaults are
|
|
8894
|
+
// reconciled below and must not produce a misleading restart warning.
|
|
8895
|
+
const oldDefaultColdWithFleetFields = oldConfig?.defaults
|
|
8896
|
+
? splitHotColdConfig(oldConfig.defaults).cold
|
|
8897
|
+
: {};
|
|
8898
|
+
const newDefaultColdWithFleetFields = this.fleetConfig?.defaults
|
|
8899
|
+
? splitHotColdConfig(this.fleetConfig.defaults).cold
|
|
8900
|
+
: {};
|
|
8901
|
+
// `tips` is a fleet-owned hot switch; it is read at post time and never
|
|
8902
|
+
// belongs in a daemon config or a "restart required" warning.
|
|
8903
|
+
const { tips: _oldTips, ...oldDefaultCold } = oldDefaultColdWithFleetFields;
|
|
8904
|
+
const { tips: _newTips, ...newDefaultCold } = newDefaultColdWithFleetFields;
|
|
8905
|
+
const oldFleetLevel = JSON.stringify({ channel: oldConfig?.channel, defaults: oldDefaultCold });
|
|
8906
|
+
const newFleetLevel = JSON.stringify({ channel: this.fleetConfig?.channel, defaults: newDefaultCold });
|
|
7119
8907
|
if (oldFleetLevel !== newFleetLevel) {
|
|
7120
8908
|
this.logger.warn("Fleet-level config changed (channel/defaults) — use /restart for full effect");
|
|
7121
8909
|
}
|
|
@@ -7127,7 +8915,9 @@ When users create specialized instances, suggest these configurations:
|
|
|
7127
8915
|
await this.stopInstance(name).catch(err => this.logger.error({ err, name }, "Failed to stop removed instance"));
|
|
7128
8916
|
}
|
|
7129
8917
|
}
|
|
7130
|
-
// Start new +
|
|
8918
|
+
// Start new + reconcile modified instances. Hot values are always sent as a
|
|
8919
|
+
// complete snapshot: Settings mutates FleetManager's config before SIGHUP,
|
|
8920
|
+
// so an old/new diff alone can miss the live daemon's stale value.
|
|
7131
8921
|
for (const [name, config] of Object.entries(newInstances)) {
|
|
7132
8922
|
if (!this.daemons.has(name)) {
|
|
7133
8923
|
// New instance — startInstance already calls connectIpcToInstance
|
|
@@ -7135,14 +8925,33 @@ When users create specialized instances, suggest these configurations:
|
|
|
7135
8925
|
await this.startInstance(name, config, topicMode).catch(err => this.logger.error({ err, name }, "Failed to start new instance"));
|
|
7136
8926
|
}
|
|
7137
8927
|
else if (oldConfig?.instances[name]) {
|
|
7138
|
-
|
|
7139
|
-
|
|
8928
|
+
const daemon = this.daemons.get(name);
|
|
8929
|
+
const runtimeConfig = daemon.getConfigSnapshot?.() ?? oldConfig.instances[name];
|
|
8930
|
+
const oldParts = splitHotColdConfig(runtimeConfig);
|
|
8931
|
+
const newParts = splitHotColdConfig(config);
|
|
8932
|
+
// Every field not explicitly classified hot is cold by default.
|
|
8933
|
+
if (!isDeepStrictEqual(oldParts.cold, newParts.cold)) {
|
|
7140
8934
|
this.logger.info({ name }, "Instance config changed — restarting");
|
|
7141
8935
|
await this.stopInstance(name).catch(() => { });
|
|
7142
8936
|
await this.startInstance(name, config, topicMode).catch(err => this.logger.error({ err, name }, "Failed to restart modified instance"));
|
|
7143
8937
|
}
|
|
8938
|
+
else if (!isDeepStrictEqual(oldParts.hot, newParts.hot)) {
|
|
8939
|
+
const update = hotConfigUpdate(config);
|
|
8940
|
+
const ipc = this.instanceIpcClients.get(name);
|
|
8941
|
+
const sent = ipc?.connected === true && ipc.send({ type: "config_update", config: update });
|
|
8942
|
+
if (!sent) {
|
|
8943
|
+
// Daemon is in-process, so a reconnect gap must not leave runtime
|
|
8944
|
+
// state stale. Normal operation still uses the explicit IPC contract.
|
|
8945
|
+
daemon.applyConfigUpdate(update);
|
|
8946
|
+
this.logger.warn({ name }, "Config-update IPC unavailable — applied hot config in-process");
|
|
8947
|
+
}
|
|
8948
|
+
this.logger.info({ name, fields: [...HOT_INSTANCE_CONFIG_KEYS] }, "Instance hot config reloaded");
|
|
8949
|
+
}
|
|
7144
8950
|
}
|
|
7145
8951
|
}
|
|
8952
|
+
// warm_cap is fleet-owned; enforce the reloaded value immediately against
|
|
8953
|
+
// currently idle instances instead of waiting for a future state edge.
|
|
8954
|
+
this.enforceWarmCap();
|
|
7146
8955
|
this.logger.info({ running: this.daemons.size, configured: Object.keys(newInstances).length }, "Reconcile complete");
|
|
7147
8956
|
}
|
|
7148
8957
|
async restartInstances() {
|
|
@@ -7382,7 +9191,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
7382
9191
|
if (generalId) {
|
|
7383
9192
|
// No release URL — Discord's SuppressEmbeds proved unreliable and the
|
|
7384
9193
|
// link preview looked bad. Version + /update instruction is enough.
|
|
7385
|
-
this.notifyInstanceTopic(generalId, t("update.
|
|
9194
|
+
this.notifyInstanceTopic(generalId, t("update.available_current", `v${target}`, `v${currentVersion}`));
|
|
7386
9195
|
}
|
|
7387
9196
|
}
|
|
7388
9197
|
}
|
|
@@ -7539,9 +9348,10 @@ When users create specialized instances, suggest these configurations:
|
|
|
7539
9348
|
const lastActivity = inst.classic
|
|
7540
9349
|
? Math.max(persistedInboundAt ?? 0, readClassicLastActivityAt(this.dataDir, inst.name) ?? 0) || null
|
|
7541
9350
|
: (persistedInboundAt ?? this.lastActivityMs(inst.name)) || null;
|
|
7542
|
-
const backend = inst.
|
|
7543
|
-
|
|
7544
|
-
|
|
9351
|
+
const backend = this.backendNameForInstance(inst.name);
|
|
9352
|
+
const resolvedModel = this.resolveInstanceModel(inst.name);
|
|
9353
|
+
const effortStrategy = this.effortStrategyFor(inst.name);
|
|
9354
|
+
const resolvedEffort = this.resolveInstanceEffort(inst.name);
|
|
7545
9355
|
// Find claimed tasks for this instance
|
|
7546
9356
|
let currentTask = null;
|
|
7547
9357
|
try {
|
|
@@ -7556,6 +9366,14 @@ When users create specialized instances, suggest these configurations:
|
|
|
7556
9366
|
...inst,
|
|
7557
9367
|
description: config?.description ?? ("classicName" in inst ? inst.classicName : null),
|
|
7558
9368
|
backend,
|
|
9369
|
+
// Settings renders these runtime-effective values rather than the
|
|
9370
|
+
// sparse user-authored YAML. `auto` means the supported CLI is
|
|
9371
|
+
// using its own effort default; null is reserved for unsupported.
|
|
9372
|
+
model: resolvedModel.model,
|
|
9373
|
+
model_display: resolvedModel.display,
|
|
9374
|
+
model_source: resolvedModel.source,
|
|
9375
|
+
effort: effortStrategy === "unsupported" ? null : (resolvedEffort.effort ?? "auto"),
|
|
9376
|
+
effort_supported: effortStrategy !== "unsupported",
|
|
7559
9377
|
tool_set: config?.tool_set ?? "full",
|
|
7560
9378
|
general_topic: config?.general_topic ?? false,
|
|
7561
9379
|
// User activity is persisted by the daemon, so both the board and
|
|
@@ -7702,7 +9520,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
7702
9520
|
if (err.code === "EADDRINUSE") {
|
|
7703
9521
|
if (this.healthPortRetried) {
|
|
7704
9522
|
this.logger.error({ err, port }, "Health port still in use after takeover — dashboard disabled");
|
|
7705
|
-
this.notifyFleetError(
|
|
9523
|
+
this.notifyFleetError(t("dashboard.port_in_use", port));
|
|
7706
9524
|
return;
|
|
7707
9525
|
}
|
|
7708
9526
|
this.healthPortRetried = true;
|
|
@@ -7728,7 +9546,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
7728
9546
|
return;
|
|
7729
9547
|
}
|
|
7730
9548
|
this.logger.error({ err, port }, "Health server error");
|
|
7731
|
-
this.notifyFleetError(
|
|
9549
|
+
this.notifyFleetError(t("dashboard.server_failed", err.message));
|
|
7732
9550
|
});
|
|
7733
9551
|
this.healthServer.listen(port, "127.0.0.1", () => markListening());
|
|
7734
9552
|
}
|