@songsid/agend 2.1.10 → 2.1.11-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backend/antigravity.js +3 -2
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.d.ts +7 -0
- package/dist/backend/claude-code.js +107 -3
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/cli-env-probe-worker.js +2 -10
- package/dist/backend/cli-env-probe-worker.js.map +1 -1
- package/dist/backend/cli-env-probe.d.ts +14 -0
- package/dist/backend/cli-env-probe.js +13 -0
- package/dist/backend/cli-env-probe.js.map +1 -0
- package/dist/backend/codex-metadata.d.ts +14 -0
- package/dist/backend/codex-metadata.js +70 -0
- package/dist/backend/codex-metadata.js.map +1 -0
- package/dist/backend/codex.d.ts +1 -2
- package/dist/backend/codex.js +112 -65
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/effort-metadata.d.ts +32 -0
- package/dist/backend/effort-metadata.js +21 -0
- package/dist/backend/effort-metadata.js.map +1 -0
- package/dist/backend/grok.js +3 -2
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro.d.ts +3 -0
- package/dist/backend/kiro.js +19 -4
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/muse.js +3 -2
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/opencode.d.ts +31 -4
- package/dist/backend/opencode.js +66 -23
- package/dist/backend/opencode.js.map +1 -1
- package/dist/backend/types.d.ts +14 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.js +10 -2
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/cli.js +48 -6
- package/dist/cli.js.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +24 -0
- package/dist/daemon.js +109 -12
- package/dist/daemon.js.map +1 -1
- package/dist/doctor.d.ts +3 -1
- package/dist/doctor.js +11 -0
- package/dist/doctor.js.map +1 -1
- package/dist/fleet-context.d.ts +2 -2
- package/dist/fleet-control-audit.d.ts +83 -0
- package/dist/fleet-control-audit.js +195 -0
- package/dist/fleet-control-audit.js.map +1 -0
- package/dist/fleet-manager.d.ts +45 -35
- package/dist/fleet-manager.js +327 -384
- package/dist/fleet-manager.js.map +1 -1
- package/dist/full-restart.js +2 -1
- package/dist/full-restart.js.map +1 -1
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +3 -2
- package/dist/host-memory.d.ts +16 -0
- package/dist/host-memory.js +32 -0
- package/dist/host-memory.js.map +1 -0
- package/dist/instance-lifecycle.js +4 -1
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +62 -16
- package/dist/locale.js.map +1 -1
- package/dist/log-tail.d.ts +34 -0
- package/dist/log-tail.js +66 -0
- package/dist/log-tail.js.map +1 -0
- package/dist/logger.d.ts +15 -0
- package/dist/logger.js +89 -1
- package/dist/logger.js.map +1 -1
- package/dist/login-controller.d.ts +1 -3
- package/dist/login-controller.js +2 -6
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.d.ts +0 -8
- package/dist/login-flows.js +1 -11
- package/dist/login-flows.js.map +1 -1
- package/dist/login-manager.d.ts +5 -20
- package/dist/login-manager.js +0 -53
- package/dist/login-manager.js.map +1 -1
- package/dist/login-window-lock.d.ts +1 -1
- package/dist/login-window-lock.js +1 -1
- package/dist/memory-pressure.d.ts +41 -0
- package/dist/memory-pressure.js +117 -0
- package/dist/memory-pressure.js.map +1 -0
- package/dist/probe-worker-pool.d.ts +28 -0
- package/dist/probe-worker-pool.js +129 -0
- package/dist/probe-worker-pool.js.map +1 -0
- package/dist/resource-report.d.ts +31 -0
- package/dist/resource-report.js +209 -0
- package/dist/resource-report.js.map +1 -0
- package/dist/rollout-index.d.ts +66 -0
- package/dist/rollout-index.js +124 -0
- package/dist/rollout-index.js.map +1 -0
- package/dist/slash-authz.d.ts +50 -0
- package/dist/slash-authz.js +19 -0
- package/dist/slash-authz.js.map +1 -0
- package/dist/spawn-gate.d.ts +13 -1
- package/dist/spawn-gate.js +108 -39
- package/dist/spawn-gate.js.map +1 -1
- package/dist/storm-window.d.ts +42 -0
- package/dist/storm-window.js +84 -1
- package/dist/storm-window.js.map +1 -1
- package/dist/topic-commands.d.ts +29 -2
- package/dist/topic-commands.js +77 -21
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-sources.d.ts +21 -4
- package/dist/transcript-sources.js +55 -41
- package/dist/transcript-sources.js.map +1 -1
- package/dist/types.d.ts +5 -1
- package/dist/ui/settings.html +14 -13
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -4,6 +4,7 @@ import { spawnSync } from "node:child_process";
|
|
|
4
4
|
import { freemem, totalmem, cpus, homedir } from "node:os";
|
|
5
5
|
import { createServer } from "node:http";
|
|
6
6
|
import { Worker } from "node:worker_threads";
|
|
7
|
+
import { ProbeWorkerPool } from "./probe-worker-pool.js";
|
|
7
8
|
import { join, dirname, basename, delimiter, isAbsolute } from "node:path";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
9
10
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
@@ -12,6 +13,7 @@ import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProg
|
|
|
12
13
|
import { formatUpdateProgress } from "./update-progress.js";
|
|
13
14
|
import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
|
|
14
15
|
import { readFleetMemory } from "./process-memory.js";
|
|
16
|
+
import { MemoryPressure } from "./memory-pressure.js";
|
|
15
17
|
import { ReplyDeduper } from "./reply-dedup.js";
|
|
16
18
|
import { isMap, isScalar, parseDocument } from "yaml";
|
|
17
19
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -30,13 +32,14 @@ import { IpcClient } from "./channel/ipc-bridge.js";
|
|
|
30
32
|
import { createAdapter } from "./channel/factory.js";
|
|
31
33
|
import { TelegramAdapter } from "./channel/adapters/telegram.js";
|
|
32
34
|
import { createBackend } from "./backend/factory.js";
|
|
35
|
+
import { readEffortMetadata } from "./backend/effort-metadata.js";
|
|
33
36
|
import { isModelCompatible, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
|
|
34
37
|
import { createLogger, rotateLogIfNeeded } from "./logger.js";
|
|
35
38
|
import { processAttachments } from "./channel/attachment-handler.js";
|
|
36
39
|
import { routeToolCall } from "./channel/tool-router.js";
|
|
37
40
|
import { Scheduler } from "./scheduler/index.js";
|
|
38
41
|
import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
|
|
39
|
-
import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, resolveInstanceContext, forgetInstanceContext, readStatuslineModel } from "./topic-commands.js";
|
|
42
|
+
import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, parseCompactCommand, SAVE_FILENAME_RE, resolveInstanceContext, forgetInstanceContext, readStatuslineModel } from "./topic-commands.js";
|
|
40
43
|
import { DailySummary } from "./daily-summary.js";
|
|
41
44
|
import { WebhookEmitter } from "./webhook-emitter.js";
|
|
42
45
|
import { TmuxControlClient } from "./tmux-control.js";
|
|
@@ -55,14 +58,17 @@ export const DURABLE_DELIVERY_LANE_ALERT_MS = 35 * 60_000;
|
|
|
55
58
|
import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
|
|
56
59
|
import { handleViewRequest, isViewPath } from "./view-api.js";
|
|
57
60
|
import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
|
|
58
|
-
import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES
|
|
61
|
+
import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
|
|
59
62
|
import { LoginSession } from "./login-manager.js";
|
|
60
63
|
import { tightenInstanceDirs } from "./private-dir.js";
|
|
61
|
-
import {
|
|
64
|
+
import { decideSlash } from "./slash-authz.js";
|
|
65
|
+
import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
|
|
62
66
|
import { runBeforeDeadline } from "./deadline.js";
|
|
63
67
|
import { LoginWindowLock } from "./login-window-lock.js";
|
|
64
68
|
import { handleSettingsRequest } from "./settings-api.js";
|
|
65
69
|
import { setLocale, detectLocale, getLocale, t } from "./locale.js";
|
|
70
|
+
import { recentChatContext } from "./log-tail.js";
|
|
71
|
+
import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet-control-audit.js";
|
|
66
72
|
import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
|
|
67
73
|
import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
|
|
68
74
|
import { assertExplicitInstanceRemoval } from "./instance-removal.js";
|
|
@@ -223,12 +229,17 @@ const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
|
|
|
223
229
|
const TIP_DISMISS_CALLBACK_PREFIX = "tip-dismiss:";
|
|
224
230
|
const TIP_UNLOCK_CALLBACK_PREFIX = "tip-unlock:";
|
|
225
231
|
export const LOGIN_CALLBACK_PREFIX = "login:";
|
|
226
|
-
/**
|
|
227
|
-
|
|
232
|
+
/**
|
|
233
|
+
* Prompt kinds that no longer exist: `/install-cli` (#1131) and the relay-mode login's
|
|
234
|
+
* provider picker (#1139). Their buttons may still be on screen after an upgrade.
|
|
235
|
+
*/
|
|
236
|
+
const RETIRED_PROMPT_PREFIXES = ["install-select:", "install-login:", "login-menu:"];
|
|
228
237
|
const NEVER_MATCHES = /(?!)/;
|
|
229
238
|
const CLASSIC_APPROVE_CALLBACK_PREFIX = "classic-approve:";
|
|
230
|
-
const LOGIN_MENU_CALLBACK_PREFIX = "login-menu:";
|
|
231
239
|
const LOGIN_CONFIRM_CALLBACK_PREFIX = "login-confirm:";
|
|
240
|
+
/** How much of the end of a Classic chat log is read for context, and the most it grows to. */
|
|
241
|
+
const CHAT_LOG_TAIL_BYTES = 64 * 1024;
|
|
242
|
+
const CHAT_LOG_TAIL_MAX_BYTES = 4 * 1024 * 1024;
|
|
232
243
|
const CLEAR_CONFIRM_TIMEOUT_MS = 15_000;
|
|
233
244
|
/** Default lifetime for long-lived nonce prompts (clear overrides this to 15s). */
|
|
234
245
|
const NONCE_BUTTON_TIMEOUT_MS = 15 * 60_000;
|
|
@@ -292,6 +303,7 @@ export class FleetManager {
|
|
|
292
303
|
lifecycle;
|
|
293
304
|
stormWindow;
|
|
294
305
|
spawnGate;
|
|
306
|
+
memoryPressure;
|
|
295
307
|
/** Fleet-level backend reachability memory (fed by pty_error / startup panes). */
|
|
296
308
|
backendOutage = new BackendOutageTracker();
|
|
297
309
|
/** Stable for this FleetManager OS-process lifetime; Daemon objects have their own boot IDs. */
|
|
@@ -537,6 +549,11 @@ export class FleetManager {
|
|
|
537
549
|
watchdogTimer = null;
|
|
538
550
|
startedAt = 0;
|
|
539
551
|
stormOpenNotifyTimer = null;
|
|
552
|
+
memoryLogAt = null;
|
|
553
|
+
memoryDebugAt = null;
|
|
554
|
+
memoryLogLevel = null;
|
|
555
|
+
memoryNoticeAt = null;
|
|
556
|
+
memoryNoticeLevel = null;
|
|
540
557
|
// Mirror topic: buffer cross-instance messages, flush every 3s
|
|
541
558
|
mirrorBuffer = [];
|
|
542
559
|
mirrorTimer = null;
|
|
@@ -575,8 +592,10 @@ export class FleetManager {
|
|
|
575
592
|
FleetManager.sighupHandlerInstalled = true;
|
|
576
593
|
}
|
|
577
594
|
this.stormWindow = new StormWindow();
|
|
595
|
+
this.memoryPressure = new MemoryPressure({ onSample: snapshot => this.reportMemoryPressure(snapshot) });
|
|
578
596
|
this.spawnGate = new SpawnGate({
|
|
579
597
|
storm: this.stormWindow,
|
|
598
|
+
memoryPressure: this.memoryPressure,
|
|
580
599
|
concurrency: () => this.spawnConcurrency(),
|
|
581
600
|
staggerMs: () => this.fleetConfig?.defaults?.startup?.stagger_delay_ms ?? 500,
|
|
582
601
|
});
|
|
@@ -956,9 +975,54 @@ export class FleetManager {
|
|
|
956
975
|
return Math.max(1, Math.min(20, explicit));
|
|
957
976
|
return deriveSpawnConcurrency(Math.round(freemem() / (1024 * 1024)), cpus().length);
|
|
958
977
|
}
|
|
978
|
+
/** Separate cooldown: changing measurements must not create new text-throttle keys. */
|
|
979
|
+
reportMemoryPressure(snapshot) {
|
|
980
|
+
if (this.shuttingDown)
|
|
981
|
+
return;
|
|
982
|
+
const now = Date.now();
|
|
983
|
+
if (this.memoryDebugAt === null || now - this.memoryDebugAt >= 30_000) {
|
|
984
|
+
this.memoryDebugAt = now;
|
|
985
|
+
this.logger.debug({ hostMemory: snapshot }, "Host memory sample");
|
|
986
|
+
}
|
|
987
|
+
const changed = snapshot.level !== this.memoryLogLevel;
|
|
988
|
+
if (snapshot.level === "normal") {
|
|
989
|
+
if (this.memoryLogLevel === "critical" || this.memoryLogLevel === "elevated") {
|
|
990
|
+
this.logger.info({ hostMemory: snapshot }, "Host memory recovered");
|
|
991
|
+
}
|
|
992
|
+
this.memoryLogLevel = snapshot.level;
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
if (changed || this.memoryLogAt === null || now - this.memoryLogAt >= 10 * 60_000) {
|
|
996
|
+
this.memoryLogAt = now;
|
|
997
|
+
this.logger.warn({ hostMemory: snapshot }, snapshot.level === "critical"
|
|
998
|
+
? "Host memory critical — deferring new CLI spawns"
|
|
999
|
+
: snapshot.level === "elevated" ? "Host memory pressure — slowing new CLI spawns"
|
|
1000
|
+
: "Host memory sample unavailable — slowing new CLI spawns");
|
|
1001
|
+
}
|
|
1002
|
+
this.memoryLogLevel = snapshot.level;
|
|
1003
|
+
if (!snapshot.memory || snapshot.level === "unknown" || (!this.adapter && this.adapters.size === 0))
|
|
1004
|
+
return;
|
|
1005
|
+
const escalation = snapshot.level === "critical" && this.memoryNoticeLevel !== "critical";
|
|
1006
|
+
if (!escalation && this.memoryNoticeAt !== null && now - this.memoryNoticeAt < 10 * 60_000)
|
|
1007
|
+
return;
|
|
1008
|
+
const size = (bytes) => bytes === null ? t("memory.unknown") : `${Math.round(bytes / 1024 / 1024)} MiB`;
|
|
1009
|
+
const text = t("memory.pressure", t(snapshot.memory.availableKind === "available" ? "memory.available" : "memory.free", size(snapshot.memory.availableBytes)), size(snapshot.memory.swapFreeBytes), t(snapshot.level === "critical" ? "memory.holding" : "memory.slowing"));
|
|
1010
|
+
if (this.notifyFleetError(text, { throttle: false })) {
|
|
1011
|
+
this.memoryNoticeAt = now;
|
|
1012
|
+
this.memoryNoticeLevel = snapshot.level;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
959
1015
|
/** Wire the one fleet-wide storm into notification and recovery surfaces. */
|
|
960
1016
|
bindStormWindowEvents() {
|
|
961
1017
|
this.stormWindow.on("opened", (snapshot) => {
|
|
1018
|
+
if (snapshot.kind === "window_loss") {
|
|
1019
|
+
// The server is fine and nothing is held: only the instances that lost
|
|
1020
|
+
// their window are affected, they respawn at the storm rate, and their
|
|
1021
|
+
// per-instance incident notices fold into this one (#1127).
|
|
1022
|
+
this.logger.error({ ...snapshot }, "several instances lost their tmux window at once — recovering at a reduced rate");
|
|
1023
|
+
this.notifyFleetError(t("storm.window_loss", snapshot.affected.length, Math.round(this.stormWindow.windowLossWindowMs / 1000), this.stormWindowLossRecoveryConcurrency()));
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
962
1026
|
for (const [name, daemon] of this.daemons) {
|
|
963
1027
|
if (!daemon.isPaused)
|
|
964
1028
|
this.stormWindow.addAffected(name);
|
|
@@ -986,6 +1050,10 @@ export class FleetManager {
|
|
|
986
1050
|
this.notifyFleetError(t(reason === "timeout" ? "storm.timed_out" : "storm.recovered", snapshot.recovered.length, snapshot.affected.length, unresolved.length > 0 ? unresolved.join(", ") : t("storm.none")));
|
|
987
1051
|
});
|
|
988
1052
|
}
|
|
1053
|
+
/** What the gate allows while any storm window is open (see SpawnGate.pump). */
|
|
1054
|
+
stormWindowLossRecoveryConcurrency() {
|
|
1055
|
+
return Math.min(4, this.spawnConcurrency());
|
|
1056
|
+
}
|
|
989
1057
|
formatStormDelay(ms) {
|
|
990
1058
|
if (ms >= 60_000)
|
|
991
1059
|
return `${Math.round(ms / 60_000)}m`;
|
|
@@ -1226,8 +1294,17 @@ export class FleetManager {
|
|
|
1226
1294
|
this.rawFleetConfig = raw;
|
|
1227
1295
|
this.fleetConfig = loaded;
|
|
1228
1296
|
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
1297
|
+
this.warnAboutRemovedLoginMode(loaded);
|
|
1229
1298
|
return this.fleetConfig;
|
|
1230
1299
|
}
|
|
1300
|
+
relayLoginModeWarned = false;
|
|
1301
|
+
/** `login.mode: relay` was removed (#1139). Still accepted so an upgrade cannot stop a fleet from starting. */
|
|
1302
|
+
warnAboutRemovedLoginMode(config) {
|
|
1303
|
+
if (config.login?.mode !== "relay" || this.relayLoginModeWarned)
|
|
1304
|
+
return;
|
|
1305
|
+
this.relayLoginModeWarned = true;
|
|
1306
|
+
this.logger.warn("fleet.yaml sets login.mode: relay — the chat-relay login was removed; /login always uses the web terminal. Remove the setting.");
|
|
1307
|
+
}
|
|
1231
1308
|
/**
|
|
1232
1309
|
* A channel's configurable bot_token_env is an env-key writer too. Refuse
|
|
1233
1310
|
* an overlap with a registry API key (or a process-reserved key) before the
|
|
@@ -1380,10 +1457,30 @@ export class FleetManager {
|
|
|
1380
1457
|
}
|
|
1381
1458
|
await data.respond(await this.topicCommands.runPauseWake(target, action));
|
|
1382
1459
|
}
|
|
1460
|
+
/** SIGUSR2: restart the instances inside this process. Names who asked (#1120). */
|
|
1461
|
+
onGracefulRestartSignal(rearm) {
|
|
1462
|
+
this.logger.info(`Received SIGUSR2, initiating graceful restart... ${describeSignalSource(this.dataDir, "SIGUSR2")}`);
|
|
1463
|
+
this.restartInstances()
|
|
1464
|
+
.catch(err => this.logger.error({ err }, "Graceful restart failed"))
|
|
1465
|
+
.finally(rearm);
|
|
1466
|
+
}
|
|
1467
|
+
/** SIGUSR1: full process reload. Names who asked (#1120). */
|
|
1468
|
+
onFullRestartSignal() {
|
|
1469
|
+
this.logger.info(`Received SIGUSR1, initiating full restart (process reload)... ${describeSignalSource(this.dataDir, "SIGUSR1")}`);
|
|
1470
|
+
this.gracefulShutdownForReload()
|
|
1471
|
+
.then(() => {
|
|
1472
|
+
this.logger.info("Full restart: shutdown complete, exiting for reload");
|
|
1473
|
+
process.exit(0);
|
|
1474
|
+
})
|
|
1475
|
+
.catch(err => {
|
|
1476
|
+
this.logger.error({ err }, "Full restart: graceful shutdown failed");
|
|
1477
|
+
process.exit(1);
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1383
1480
|
async handleUpdateSlash(data, adapterId) {
|
|
1384
|
-
const
|
|
1385
|
-
if (
|
|
1386
|
-
await data.respond(t("not_authorized"));
|
|
1481
|
+
const gate = this.fleetAdminGate(data.userId, adapterId);
|
|
1482
|
+
if (gate !== "ok") {
|
|
1483
|
+
await data.respond(t(gate === "disabled" ? "update.disabled" : "not_authorized"));
|
|
1387
1484
|
return;
|
|
1388
1485
|
}
|
|
1389
1486
|
const messageId = await data.respond(t("update.progress.preparing", 0));
|
|
@@ -1395,7 +1492,11 @@ export class FleetManager {
|
|
|
1395
1492
|
const { spawn } = await import("node:child_process");
|
|
1396
1493
|
const currentVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
|
|
1397
1494
|
const command = currentVersion.includes("beta") ? "agend update --beta" : "agend update";
|
|
1398
|
-
const
|
|
1495
|
+
const origin = `slash /update by ${adapterId}:${data.userId}`;
|
|
1496
|
+
recordInternalRequest(this.dataDir, "update", origin);
|
|
1497
|
+
const child = spawn("sh", ["-c", `sleep 2 && ${command}`], {
|
|
1498
|
+
detached: true, stdio: "ignore", env: withOrigin(origin),
|
|
1499
|
+
});
|
|
1399
1500
|
child.once("error", err => this.failUpdateProgress(err.message));
|
|
1400
1501
|
child.unref();
|
|
1401
1502
|
}
|
|
@@ -1404,6 +1505,7 @@ export class FleetManager {
|
|
|
1404
1505
|
await data.respond(t("not_authorized"));
|
|
1405
1506
|
return;
|
|
1406
1507
|
}
|
|
1508
|
+
recordInternalRequest(this.dataDir, "restart", `slash /restart ${String(data.options?.mode ?? "graceful")} by ${adapterId}:${data.userId}`);
|
|
1407
1509
|
if (data.options?.mode !== "full") {
|
|
1408
1510
|
await data.respond(t("restart.graceful"));
|
|
1409
1511
|
process.kill(process.pid, "SIGUSR2");
|
|
@@ -2102,6 +2204,62 @@ export class FleetManager {
|
|
|
2102
2204
|
const allowed = this.getChannelConfig(adapterId)?.access?.allowed_users ?? [];
|
|
2103
2205
|
return allowed.some(entry => String(entry) === String(userId));
|
|
2104
2206
|
}
|
|
2207
|
+
/** Whether this adapter has any fleet admin at all (an empty allowlist means the admin commands are off). */
|
|
2208
|
+
hasFleetAdmins(adapterId) {
|
|
2209
|
+
return (this.getChannelConfig(adapterId)?.access?.allowed_users ?? []).length > 0;
|
|
2210
|
+
}
|
|
2211
|
+
/**
|
|
2212
|
+
* The one question the privileged commands (/update, /doctor, /dashboard, /collab) ask: may THIS caller,
|
|
2213
|
+
* through THIS adapter, run it. `disabled` is "nobody can" — an empty list — and is told apart from
|
|
2214
|
+
* `denied` only so the reply can say so. An empty list never means "everyone": these commands used to read
|
|
2215
|
+
* the primary channel's list and treat empty as open, so on a fleet that had not set one anyone who could
|
|
2216
|
+
* type a slash command could `/update` the host.
|
|
2217
|
+
*/
|
|
2218
|
+
fleetAdminGate(userId, adapterId) {
|
|
2219
|
+
if (!this.hasFleetAdmins(adapterId))
|
|
2220
|
+
return "disabled";
|
|
2221
|
+
return this.isFleetAdmin(userId, adapterId) ? "ok" : "denied";
|
|
2222
|
+
}
|
|
2223
|
+
/**
|
|
2224
|
+
* The door every Discord slash command goes through (src/slash-authz.ts has the rule and the reasoning).
|
|
2225
|
+
* Answers the caller itself when it refuses, so a refused command costs one reply and does nothing else.
|
|
2226
|
+
*/
|
|
2227
|
+
async authorizeSlash(data, adapterId) {
|
|
2228
|
+
const channelId = data.channelId;
|
|
2229
|
+
const classic = !!this.classicChannels?.isClassicChannel(channelId, adapterId);
|
|
2230
|
+
const fleetTarget = classic ? undefined : this.routing.resolve(channelId);
|
|
2231
|
+
const scope = classic ? "classic" : fleetTarget ? "fleet" : "none";
|
|
2232
|
+
let speaker = "denied";
|
|
2233
|
+
if (scope !== "classic") {
|
|
2234
|
+
// The same world the typed-message path would consult: the adapter that owns the channel's instance,
|
|
2235
|
+
// else the one the command arrived on.
|
|
2236
|
+
const ownerId = fleetTarget ? this.getInstanceAdapterId(fleetTarget.name) : undefined;
|
|
2237
|
+
const ownerWorld = ownerId ? this.worlds.get(ownerId) : undefined;
|
|
2238
|
+
if (ownerId && !ownerWorld) {
|
|
2239
|
+
speaker = "owner-not-running";
|
|
2240
|
+
}
|
|
2241
|
+
else {
|
|
2242
|
+
const am = ownerWorld?.accessManager ?? this.worlds.get(adapterId)?.accessManager ?? this.accessManager;
|
|
2243
|
+
// An explicit fleet admin always speaks, even if the access state file disagrees with the config.
|
|
2244
|
+
speaker = this.isFleetAdmin(data.userId, adapterId) || (am?.isAllowed(data.userId) ?? false) ? "allowed" : "denied";
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
const facts = {
|
|
2248
|
+
command: data.command,
|
|
2249
|
+
guildId: data.guildId,
|
|
2250
|
+
primaryGuildId: String(this.getChannelConfig(adapterId)?.group_id ?? ""),
|
|
2251
|
+
scope,
|
|
2252
|
+
speaker,
|
|
2253
|
+
};
|
|
2254
|
+
const decision = decideSlash(facts);
|
|
2255
|
+
if (decision.allow)
|
|
2256
|
+
return true;
|
|
2257
|
+
this.logger.info({ command: data.command, reason: decision.reason, adapterId, guildId: data.guildId ?? null, channelId, scope }, "Slash command refused");
|
|
2258
|
+
await data.respond(t(decision.reason === "dm" ? "slash.dm_unsupported"
|
|
2259
|
+
: decision.reason === "wrong-guild" ? "slash.wrong_server"
|
|
2260
|
+
: "not_authorized")).catch(() => { });
|
|
2261
|
+
return false;
|
|
2262
|
+
}
|
|
2105
2263
|
/** Phase 2: delivery_worker for a target (instance override → fleet default → wake_only). */
|
|
2106
2264
|
deliveryWorkerMode(target) {
|
|
2107
2265
|
return resolveDeliveryWorkerMode(this.fleetConfig, target);
|
|
@@ -2334,6 +2492,10 @@ export class FleetManager {
|
|
|
2334
2492
|
resumePaused = false,
|
|
2335
2493
|
/** Phase 2a: the caller's transition, for a start made from inside a restart or wake. */
|
|
2336
2494
|
transition) {
|
|
2495
|
+
// CLI single-instance cold starts bypass startAll. Start diagnostics before
|
|
2496
|
+
// any lifecycle/wake work; a shutdown must not revive the stopped sampler.
|
|
2497
|
+
if (!this.shuttingDown)
|
|
2498
|
+
this.memoryPressure?.start();
|
|
2337
2499
|
// Any start supersedes a pending automatic retry (it would otherwise fire
|
|
2338
2500
|
// into a running instance — harmless, but noisy — or race this start).
|
|
2339
2501
|
this.cancelStartupRetry(name);
|
|
@@ -2448,6 +2610,7 @@ export class FleetManager {
|
|
|
2448
2610
|
instanceName: name,
|
|
2449
2611
|
workingDirectory: config.working_directory,
|
|
2450
2612
|
reason: "startup",
|
|
2613
|
+
stage: "lifecycle",
|
|
2451
2614
|
}, async () => {
|
|
2452
2615
|
if (await this.startInstanceUnattended(name, config, topicMode, "instance"))
|
|
2453
2616
|
onReady?.(name);
|
|
@@ -2587,6 +2750,7 @@ export class FleetManager {
|
|
|
2587
2750
|
instanceName: name,
|
|
2588
2751
|
workingDirectory: this.fleetConfig?.instances[name]?.working_directory || this.getInstanceDir(name),
|
|
2589
2752
|
reason: "recovery",
|
|
2753
|
+
stage: "lifecycle",
|
|
2590
2754
|
}, () => this.startConfiguredInstance(name, topicMode));
|
|
2591
2755
|
if (this.daemons.has(name)) {
|
|
2592
2756
|
this.logger.info({ name, attempt: attempt + 1 }, "Automatic startup retry succeeded");
|
|
@@ -2879,6 +3043,7 @@ export class FleetManager {
|
|
|
2879
3043
|
instanceName: name,
|
|
2880
3044
|
workingDirectory,
|
|
2881
3045
|
reason: "restart",
|
|
3046
|
+
stage: "lifecycle",
|
|
2882
3047
|
}, () => this.doRestartSingleInstance(name, opts, transition)))
|
|
2883
3048
|
.finally(() => {
|
|
2884
3049
|
this.restartsInFlight.delete(name);
|
|
@@ -3254,6 +3419,9 @@ export class FleetManager {
|
|
|
3254
3419
|
setUpdateProgressStage(this.dataDir, "starting", { version: pendingUpdateProgress.progress.version });
|
|
3255
3420
|
}
|
|
3256
3421
|
this.initializeWebAuthTokens();
|
|
3422
|
+
// Must run before the first General/CLI spawn: pressure can hold startup.
|
|
3423
|
+
// Logs remain available even before the adapters and health listener exist.
|
|
3424
|
+
this.memoryPressure.start();
|
|
3257
3425
|
const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
|
|
3258
3426
|
// Set tmux socket isolation for custom AGEND_HOME
|
|
3259
3427
|
const { getTmuxSocketName: getSocket } = await import("./paths.js");
|
|
@@ -3696,27 +3864,10 @@ export class FleetManager {
|
|
|
3696
3864
|
this.checkForUpdates();
|
|
3697
3865
|
this.updateCheckTimer = setInterval(() => this.checkForUpdates(), 24 * 60 * 60 * 1000);
|
|
3698
3866
|
}, 60 * 60 * 1000);
|
|
3699
|
-
const onRestart = () =>
|
|
3700
|
-
this.logger.info("Received SIGUSR2, initiating graceful restart...");
|
|
3701
|
-
this.restartInstances()
|
|
3702
|
-
.catch(err => this.logger.error({ err }, "Graceful restart failed"))
|
|
3703
|
-
.finally(() => process.once("SIGUSR2", onRestart));
|
|
3704
|
-
};
|
|
3867
|
+
const onRestart = () => this.onGracefulRestartSignal(() => process.once("SIGUSR2", onRestart));
|
|
3705
3868
|
process.once("SIGUSR2", onRestart);
|
|
3706
3869
|
// SIGUSR1: full process reload (graceful stop → exit → CLI restarts)
|
|
3707
|
-
|
|
3708
|
-
this.logger.info("Received SIGUSR1, initiating full restart (process reload)...");
|
|
3709
|
-
this.gracefulShutdownForReload()
|
|
3710
|
-
.then(() => {
|
|
3711
|
-
this.logger.info("Full restart: shutdown complete, exiting for reload");
|
|
3712
|
-
process.exit(0);
|
|
3713
|
-
})
|
|
3714
|
-
.catch(err => {
|
|
3715
|
-
this.logger.error({ err }, "Full restart: graceful shutdown failed");
|
|
3716
|
-
process.exit(1);
|
|
3717
|
-
});
|
|
3718
|
-
};
|
|
3719
|
-
process.once("SIGUSR1", onFullRestart);
|
|
3870
|
+
process.once("SIGUSR1", () => this.onFullRestartSignal());
|
|
3720
3871
|
// A SIGHUP may arrive after the PID/general is available but before the
|
|
3721
3872
|
// rest of startup finishes. Replay one coalesced reload only after all
|
|
3722
3873
|
// startup-owned lifecycle work and signal handlers are in place.
|
|
@@ -4002,6 +4153,10 @@ export class FleetManager {
|
|
|
4002
4153
|
connected++;
|
|
4003
4154
|
}
|
|
4004
4155
|
const problems = [];
|
|
4156
|
+
const hostMemory = this.memoryPressure.snapshot();
|
|
4157
|
+
if (hostMemory.level === "critical" || hostMemory.level === "elevated") {
|
|
4158
|
+
problems.push(`host memory pressure is ${hostMemory.level}`);
|
|
4159
|
+
}
|
|
4005
4160
|
if (this.adapterState.size > 0 && connected === 0)
|
|
4006
4161
|
problems.push("no channel adapter is connected");
|
|
4007
4162
|
if (counts.crashed > 0)
|
|
@@ -4024,6 +4179,7 @@ export class FleetManager {
|
|
|
4024
4179
|
adapters: { total: this.adapterState.size, connected, states, details },
|
|
4025
4180
|
startupComplete: this.startupComplete,
|
|
4026
4181
|
memory: readFleetMemory(),
|
|
4182
|
+
hostMemory,
|
|
4027
4183
|
problems,
|
|
4028
4184
|
};
|
|
4029
4185
|
}
|
|
@@ -4074,6 +4230,8 @@ export class FleetManager {
|
|
|
4074
4230
|
this.adapter.on("slash_command", safeHandler(async (data) => {
|
|
4075
4231
|
if (!isCurrentAdapter())
|
|
4076
4232
|
return;
|
|
4233
|
+
if (!(await this.authorizeSlash(data, adapterId)))
|
|
4234
|
+
return;
|
|
4077
4235
|
if (data.command === "start") {
|
|
4078
4236
|
await this.handleClassicStartSlash(data, adapterId);
|
|
4079
4237
|
}
|
|
@@ -4095,7 +4253,8 @@ export class FleetManager {
|
|
|
4095
4253
|
await data.respond(t("classic.no_agent_start"));
|
|
4096
4254
|
return;
|
|
4097
4255
|
}
|
|
4098
|
-
const
|
|
4256
|
+
const status = this.resolveStatusEmojisFor(name, adapterId, adapter);
|
|
4257
|
+
const replyMsgId = await data.respond(textForm(status.platform, status.received));
|
|
4099
4258
|
const username = data.username ?? data.userId;
|
|
4100
4259
|
ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
|
|
4101
4260
|
await this.forwardToClassicInstance(name, text, {
|
|
@@ -4136,7 +4295,7 @@ export class FleetManager {
|
|
|
4136
4295
|
await data.respond(t("classic.no_agent"));
|
|
4137
4296
|
return;
|
|
4138
4297
|
}
|
|
4139
|
-
const result = await this.topicCommands.sendCompact(name);
|
|
4298
|
+
const result = await this.topicCommands.sendCompact(name, String(data.options?.instructions ?? ""));
|
|
4140
4299
|
await data.respond(result);
|
|
4141
4300
|
}
|
|
4142
4301
|
else if (data.command === "steer") {
|
|
@@ -4211,8 +4370,7 @@ export class FleetManager {
|
|
|
4211
4370
|
// always a fleet-topic instance.
|
|
4212
4371
|
const collabTarget = this.routing.resolve(data.channelId);
|
|
4213
4372
|
if (collabTarget) {
|
|
4214
|
-
|
|
4215
|
-
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
4373
|
+
if (this.fleetAdminGate(data.userId, adapterId) !== "ok") {
|
|
4216
4374
|
await data.respond(t("not_authorized"));
|
|
4217
4375
|
return;
|
|
4218
4376
|
}
|
|
@@ -4237,8 +4395,7 @@ export class FleetManager {
|
|
|
4237
4395
|
await this.handleUpdateSlash(data, adapterId);
|
|
4238
4396
|
}
|
|
4239
4397
|
else if (data.command === "doctor") {
|
|
4240
|
-
|
|
4241
|
-
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
4398
|
+
if (this.fleetAdminGate(data.userId, adapterId) !== "ok") {
|
|
4242
4399
|
await data.respond(t("not_authorized"));
|
|
4243
4400
|
return;
|
|
4244
4401
|
}
|
|
@@ -4278,13 +4435,9 @@ export class FleetManager {
|
|
|
4278
4435
|
else if (data.command === "dashboard") {
|
|
4279
4436
|
// Reply is ephemeral (adapter defers non-chat commands ephemerally), so
|
|
4280
4437
|
// the web-token-bearing URLs are only visible to the caller.
|
|
4281
|
-
const
|
|
4282
|
-
if (
|
|
4283
|
-
await data.respond(t("dashboard.disabled"));
|
|
4284
|
-
return;
|
|
4285
|
-
}
|
|
4286
|
-
if (!allowed.some(u => String(u) === String(data.userId))) {
|
|
4287
|
-
await data.respond(t("not_authorized"));
|
|
4438
|
+
const gate = this.fleetAdminGate(data.userId, adapterId);
|
|
4439
|
+
if (gate !== "ok") {
|
|
4440
|
+
await data.respond(t(gate === "disabled" ? "dashboard.disabled" : "not_authorized"));
|
|
4288
4441
|
return;
|
|
4289
4442
|
}
|
|
4290
4443
|
await data.respond(this.topicCommands.getDashboardText());
|
|
@@ -4298,7 +4451,7 @@ export class FleetManager {
|
|
|
4298
4451
|
await data.respond(t("classic.no_agent"));
|
|
4299
4452
|
return;
|
|
4300
4453
|
}
|
|
4301
|
-
const result = await this.topicCommands.sendCompact(name);
|
|
4454
|
+
const result = await this.topicCommands.sendCompact(name, String(data.options?.instructions ?? ""));
|
|
4302
4455
|
await data.respond(result);
|
|
4303
4456
|
}
|
|
4304
4457
|
else if (data.command === "steer") {
|
|
@@ -4444,6 +4597,8 @@ export class FleetManager {
|
|
|
4444
4597
|
adapter.on("slash_command", safeHandler(async (data) => {
|
|
4445
4598
|
if (!isCurrentAdapter())
|
|
4446
4599
|
return;
|
|
4600
|
+
if (!(await this.authorizeSlash(data, adapterId)))
|
|
4601
|
+
return;
|
|
4447
4602
|
if (data.command === "start") {
|
|
4448
4603
|
await this.handleClassicStartSlash(data, adapterId);
|
|
4449
4604
|
}
|
|
@@ -4465,7 +4620,8 @@ export class FleetManager {
|
|
|
4465
4620
|
await data.respond(t("classic.no_agent_start"));
|
|
4466
4621
|
return;
|
|
4467
4622
|
}
|
|
4468
|
-
const
|
|
4623
|
+
const status = this.resolveStatusEmojisFor(name, adapterId, adapter);
|
|
4624
|
+
const replyMsgId = await data.respond(textForm(status.platform, status.received));
|
|
4469
4625
|
const username = data.username ?? data.userId;
|
|
4470
4626
|
ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
|
|
4471
4627
|
await this.forwardToClassicInstance(name, text, {
|
|
@@ -4532,8 +4688,7 @@ export class FleetManager {
|
|
|
4532
4688
|
// always a fleet-topic instance.
|
|
4533
4689
|
const collabTarget2 = this.routing.resolve(data.channelId);
|
|
4534
4690
|
if (collabTarget2) {
|
|
4535
|
-
|
|
4536
|
-
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
4691
|
+
if (this.fleetAdminGate(data.userId, adapterId) !== "ok") {
|
|
4537
4692
|
await data.respond(t("not_authorized"));
|
|
4538
4693
|
return;
|
|
4539
4694
|
}
|
|
@@ -4558,8 +4713,7 @@ export class FleetManager {
|
|
|
4558
4713
|
await this.handleUpdateSlash(data, adapterId);
|
|
4559
4714
|
}
|
|
4560
4715
|
else if (data.command === "doctor") {
|
|
4561
|
-
|
|
4562
|
-
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
4716
|
+
if (this.fleetAdminGate(data.userId, adapterId) !== "ok") {
|
|
4563
4717
|
await data.respond(t("not_authorized"));
|
|
4564
4718
|
return;
|
|
4565
4719
|
}
|
|
@@ -4599,13 +4753,9 @@ export class FleetManager {
|
|
|
4599
4753
|
else if (data.command === "dashboard") {
|
|
4600
4754
|
// Reply is ephemeral (adapter defers non-chat commands ephemerally), so
|
|
4601
4755
|
// the web-token-bearing URLs are only visible to the caller.
|
|
4602
|
-
const
|
|
4603
|
-
if (
|
|
4604
|
-
await data.respond(t("dashboard.disabled"));
|
|
4605
|
-
return;
|
|
4606
|
-
}
|
|
4607
|
-
if (!allowed.some(u => String(u) === String(data.userId))) {
|
|
4608
|
-
await data.respond(t("not_authorized"));
|
|
4756
|
+
const gate = this.fleetAdminGate(data.userId, adapterId);
|
|
4757
|
+
if (gate !== "ok") {
|
|
4758
|
+
await data.respond(t(gate === "disabled" ? "dashboard.disabled" : "not_authorized"));
|
|
4609
4759
|
return;
|
|
4610
4760
|
}
|
|
4611
4761
|
await data.respond(this.topicCommands.getDashboardText());
|
|
@@ -4619,7 +4769,7 @@ export class FleetManager {
|
|
|
4619
4769
|
await data.respond(t("classic.no_agent"));
|
|
4620
4770
|
return;
|
|
4621
4771
|
}
|
|
4622
|
-
const result = await this.topicCommands.sendCompact(name);
|
|
4772
|
+
const result = await this.topicCommands.sendCompact(name, String(data.options?.instructions ?? ""));
|
|
4623
4773
|
await data.respond(result);
|
|
4624
4774
|
}
|
|
4625
4775
|
else if (data.command === "steer") {
|
|
@@ -5273,7 +5423,14 @@ export class FleetManager {
|
|
|
5273
5423
|
// Key the dedup per-adapter there so a sibling bot's copy isn't dropped.
|
|
5274
5424
|
if (msg.messageId) {
|
|
5275
5425
|
const classicCid = threadId || msg.chatId;
|
|
5276
|
-
|
|
5426
|
+
// First /start has no registration yet. A sibling can receive the same
|
|
5427
|
+
// targeted command first, then reject its @suffix below; it must not
|
|
5428
|
+
// consume the intended bot's key. Use the same Classic candidate scope
|
|
5429
|
+
// as routing (including foreign forum topics normalized above, #1085).
|
|
5430
|
+
const isTelegramClassicCandidate = msg.source === "telegram" && !threadId
|
|
5431
|
+
&& msg.chatId !== String(this.getChannelConfig(msg.adapterId)?.group_id ?? "");
|
|
5432
|
+
const isClassicMsg = !!this.classicChannels
|
|
5433
|
+
&& (this.classicChannels.hasChannel(classicCid) || isTelegramClassicCandidate);
|
|
5277
5434
|
const dedupKey = isClassicMsg
|
|
5278
5435
|
? `${msg.source}:${msg.chatId}:${msg.messageId}:${msg.adapterId ?? ""}`
|
|
5279
5436
|
: `${msg.source}:${msg.chatId}:${msg.messageId}`;
|
|
@@ -5432,7 +5589,8 @@ export class FleetManager {
|
|
|
5432
5589
|
return;
|
|
5433
5590
|
}
|
|
5434
5591
|
// Handle /compact command (admin only)
|
|
5435
|
-
|
|
5592
|
+
const classicCompact = parseCompactCommand(text);
|
|
5593
|
+
if (classicCompact) {
|
|
5436
5594
|
if (!this.classicChannels.isAdmin(msg.userId)) {
|
|
5437
5595
|
await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/compact"));
|
|
5438
5596
|
return;
|
|
@@ -5442,7 +5600,7 @@ export class FleetManager {
|
|
|
5442
5600
|
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
5443
5601
|
return;
|
|
5444
5602
|
}
|
|
5445
|
-
const result = await this.topicCommands.sendCompact(compactName);
|
|
5603
|
+
const result = await this.topicCommands.sendCompact(compactName, classicCompact.instructions);
|
|
5446
5604
|
await msgAdapter?.sendText(chatId, result);
|
|
5447
5605
|
return;
|
|
5448
5606
|
}
|
|
@@ -7230,6 +7388,9 @@ export class FleetManager {
|
|
|
7230
7388
|
pendingCliEnvProbes = new Map();
|
|
7231
7389
|
/** Forced vendor catalog refreshes are separate flights, but sysinfo joins either flight. */
|
|
7232
7390
|
pendingVendorCliEnvProbes = new Map();
|
|
7391
|
+
pendingInstanceModelProbes = new Map();
|
|
7392
|
+
cliEnvProbePool = new ProbeWorkerPool();
|
|
7393
|
+
cliEnvProbeEpoch = 0;
|
|
7233
7394
|
/**
|
|
7234
7395
|
* The status emojis for an instance (#1005): its own `status_emojis`, then
|
|
7235
7396
|
* its channel's `options.status_emojis`, then the platform built-in. An
|
|
@@ -7558,10 +7719,18 @@ export class FleetManager {
|
|
|
7558
7719
|
return statusAvoidList(this.resolveStatusEmojisFor(instanceName));
|
|
7559
7720
|
}
|
|
7560
7721
|
/** The progress bubble's leading emoji, in the form the channel renders. */
|
|
7561
|
-
progressPrefixFor(instanceName) {
|
|
7722
|
+
progressPrefixFor(instanceName, elapsedMs = 0, minElapsedMs = this.progressMinElapsedMs()) {
|
|
7562
7723
|
const r = this.resolveStatusEmojisFor(instanceName);
|
|
7563
7724
|
const builtin = builtinStatusEmojis(r.platform).progress_prefix;
|
|
7564
|
-
|
|
7725
|
+
// Keep the built-in short/elapsed phases, but resolve their status values
|
|
7726
|
+
// too. A custom progress prefix takes precedence in both phases.
|
|
7727
|
+
// Telegram's built-in queued reaction is 👀; its elapsed *text* has always
|
|
7728
|
+
// used ⏳ (text is not limited to Telegram's reaction vocabulary).
|
|
7729
|
+
const queued = r.platform === "telegram" && r.queued === builtinStatusEmojis(r.platform).queued
|
|
7730
|
+
? builtinStatusEmojis(undefined).queued : r.queued;
|
|
7731
|
+
const value = r.progress_prefix !== builtin ? r.progress_prefix
|
|
7732
|
+
: elapsedMs < minElapsedMs ? r.processing : queued;
|
|
7733
|
+
return textForm(r.platform, value);
|
|
7565
7734
|
}
|
|
7566
7735
|
/**
|
|
7567
7736
|
* Is this inbound reaction one of the fleet's own delivery-status stamps?
|
|
@@ -8260,7 +8429,7 @@ export class FleetManager {
|
|
|
8260
8429
|
}
|
|
8261
8430
|
/**
|
|
8262
8431
|
* A button from a prompt kind that no longer exists (#1131: `/install-cli`'s
|
|
8263
|
-
* picker and its "sign in now?" prompt). Still on screen after an upgrade;
|
|
8432
|
+
* picker and its "sign in now?" prompt; #1139: the relay login's provider picker). Still on screen after an upgrade;
|
|
8264
8433
|
* a click gets the expired-prompt treatment — a private notice, the buttons
|
|
8265
8434
|
* collapsed — instead of nothing.
|
|
8266
8435
|
*/
|
|
@@ -8281,8 +8450,6 @@ export class FleetManager {
|
|
|
8281
8450
|
return true;
|
|
8282
8451
|
if (await this.handleClassicApproval(data, adapterId, adapter))
|
|
8283
8452
|
return true;
|
|
8284
|
-
if (await this.handleLoginMenuSelect(data, adapterId, adapter))
|
|
8285
|
-
return true;
|
|
8286
8453
|
if (this.handleRetiredPromptButton(data, adapterId, adapter))
|
|
8287
8454
|
return true;
|
|
8288
8455
|
if (await this.handleLoginConfirm(data, adapterId, adapter))
|
|
@@ -8999,7 +9166,7 @@ export class FleetManager {
|
|
|
8999
9166
|
return;
|
|
9000
9167
|
}
|
|
9001
9168
|
const publication = this.beginCancelButtonPublication(instanceName, correlationId);
|
|
9002
|
-
const initialProgressText = FleetManager.progressText(0, null, 1, this.progressPrefixFor(instanceName));
|
|
9169
|
+
const initialProgressText = FleetManager.progressText(0, null, 1, this.progressPrefixFor(instanceName, 0, 1));
|
|
9003
9170
|
try {
|
|
9004
9171
|
const sent = await adapter.notifyAlert(chatId, {
|
|
9005
9172
|
type: "cancel",
|
|
@@ -9215,7 +9382,8 @@ export class FleetManager {
|
|
|
9215
9382
|
* wiped by the next elapsed tick (#528 trap 2).
|
|
9216
9383
|
*/
|
|
9217
9384
|
composeBubbleText(entry) {
|
|
9218
|
-
|
|
9385
|
+
const elapsedMs = Date.now() - (entry.startedAt ?? Date.now());
|
|
9386
|
+
return FleetManager.bubbleText(elapsedMs, this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs(), entry.toolProgress, this.progressPrefixFor(entry.instanceName, elapsedMs));
|
|
9219
9387
|
}
|
|
9220
9388
|
/** Pure composition of header + tool list, exposed for tests. */
|
|
9221
9389
|
static bubbleText(elapsedMs, activity, minElapsedMs, progress, prefix) {
|
|
@@ -9521,16 +9689,11 @@ export class FleetManager {
|
|
|
9521
9689
|
// shared ~/.codex; the other CLIs use one real home), so a single sign-in
|
|
9522
9690
|
// repairs every instance of that backend, and instance delivery, pane-state
|
|
9523
9691
|
// detection, tool progress, and mcp_proxy_reply never observe login output.
|
|
9524
|
-
|
|
9525
|
-
/**
|
|
9526
|
-
* Web-terminal login (v2.1.5 default, `login.mode: web`). The relay code
|
|
9527
|
-
* below (activeLogin/LoginSession) is `login.mode: relay`, kept for one
|
|
9528
|
-
* release as the rollback lever and removed in 2.1.6.
|
|
9529
|
-
*/
|
|
9692
|
+
/** Web-terminal login: the only sign-in path (the chat-relay mode was removed, #1139). */
|
|
9530
9693
|
loginController = null;
|
|
9531
9694
|
/**
|
|
9532
9695
|
* One login/install window fleet-wide. Claimed synchronously before the
|
|
9533
|
-
* first await by web login
|
|
9696
|
+
* first await by web login and install alike (sol B1).
|
|
9534
9697
|
*/
|
|
9535
9698
|
loginWindow = new LoginWindowLock();
|
|
9536
9699
|
get webLogin() {
|
|
@@ -9766,162 +9929,11 @@ export class FleetManager {
|
|
|
9766
9929
|
this.logger.debug({ err, backend, flow }, "Could not record the /login flow");
|
|
9767
9930
|
}
|
|
9768
9931
|
}
|
|
9769
|
-
/** The sign-in itself
|
|
9932
|
+
/** The sign-in itself, in the web terminal, for a CLI that is installed. */
|
|
9770
9933
|
async launchSignIn(backendArg, chat, opts = {}) {
|
|
9771
|
-
|
|
9772
|
-
return this.webLogin.start(backendArg, chat, opts);
|
|
9773
|
-
// ── legacy relay mode (login.mode: relay) — removed in 2.1.6 ──
|
|
9774
|
-
const backend = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
|
|
9775
|
-
const flow = LOGIN_FLOWS[backend];
|
|
9776
|
-
if (!flow)
|
|
9777
|
-
return t("login.unsupported", backendArg);
|
|
9778
|
-
// Declined in every mode — never a silent relay fallback (e.g. Antigravity).
|
|
9779
|
-
if (flow.remoteLogin === "unsupported")
|
|
9780
|
-
return t("login.remote_unsupported_agent_cli", backend, flow.command);
|
|
9781
|
-
// Reserve the window before the pre-check await. The claim is owned by this
|
|
9782
|
-
// region until it is transferred to launchLoginSession; any other exit
|
|
9783
|
-
// (buttons only, throw, shutdown) releases it.
|
|
9784
|
-
const claim = this.loginWindow.tryClaim("relay", backend);
|
|
9785
|
-
if (!claim)
|
|
9786
|
-
return this.loginWindow.busyMessage();
|
|
9787
|
-
let transferred = false;
|
|
9788
|
-
try {
|
|
9789
|
-
return await this.startRelayClaimed(flow, backend, chat, opts, claim, () => { transferred = true; });
|
|
9790
|
-
}
|
|
9791
|
-
finally {
|
|
9792
|
-
if (!transferred)
|
|
9793
|
-
this.loginWindow.release(claim);
|
|
9794
|
-
}
|
|
9934
|
+
return this.webLogin.start(backendArg, chat, opts);
|
|
9795
9935
|
}
|
|
9796
|
-
|
|
9797
|
-
// Token-free pre-check (5s cap): re-login while auth still works is
|
|
9798
|
-
// usually a mistake, so it needs a confirmed click. An invalid OR
|
|
9799
|
-
// uncertain result (timeout, missing binary) proceeds straight to login —
|
|
9800
|
-
// an unreliable probe must never block the re-login the admin asked for.
|
|
9801
|
-
if (!opts.skipAuthCheck && flow.authCheck) {
|
|
9802
|
-
const status = await checkAuthStatus(flow.authCheck);
|
|
9803
|
-
if (!this.loginWindow.isCurrent(claim))
|
|
9804
|
-
return t("login.web_shutting_down"); // fleet shut down while we probed
|
|
9805
|
-
if (status === "valid") {
|
|
9806
|
-
const failure = await this.postChooser({
|
|
9807
|
-
prefix: LOGIN_CONFIRM_CALLBACK_PREFIX,
|
|
9808
|
-
alertType: "login",
|
|
9809
|
-
instanceName: backend,
|
|
9810
|
-
adapter: chat.adapter,
|
|
9811
|
-
adapterId: chat.adapterId,
|
|
9812
|
-
chatId: chat.chatId,
|
|
9813
|
-
threadId: chat.threadId,
|
|
9814
|
-
message: t("login.still_valid", backend),
|
|
9815
|
-
choices: [
|
|
9816
|
-
{ action: "go", label: t("login.relogin_go") },
|
|
9817
|
-
{ action: "cancel", label: t("login.relogin_cancel") },
|
|
9818
|
-
],
|
|
9819
|
-
expiredText: t("buttons.stale"),
|
|
9820
|
-
});
|
|
9821
|
-
return failure ?? null;
|
|
9822
|
-
}
|
|
9823
|
-
}
|
|
9824
|
-
markTransferred(); // launchLoginSession owns the claim from here
|
|
9825
|
-
return this.launchLoginSession(flow, backend, chat, claim);
|
|
9826
|
-
}
|
|
9827
|
-
/** Create the login window and session (pre-check already settled). */
|
|
9828
|
-
async launchLoginSession(flow, backend, chat, claim) {
|
|
9829
|
-
// Owns `claim`: released on any failure before the session is published,
|
|
9830
|
-
// and by onDone afterwards. A shutdown during ensureSession stops us.
|
|
9831
|
-
let tmux;
|
|
9832
|
-
try {
|
|
9833
|
-
const sessionName = getTmuxSession();
|
|
9834
|
-
await TmuxManager.ensureSession(sessionName);
|
|
9835
|
-
if (!this.loginWindow.isCurrent(claim)) {
|
|
9836
|
-
this.loginWindow.release(claim);
|
|
9837
|
-
return t("login.web_shutting_down");
|
|
9838
|
-
}
|
|
9839
|
-
tmux = new TmuxManager(sessionName, "");
|
|
9840
|
-
}
|
|
9841
|
-
catch (err) {
|
|
9842
|
-
this.loginWindow.release(claim);
|
|
9843
|
-
return t("login.failed", backend, err.message);
|
|
9844
|
-
}
|
|
9845
|
-
const session = new LoginSession(flow, tmux, {
|
|
9846
|
-
onMenu: async (options) => {
|
|
9847
|
-
const failure = await this.postChooser({
|
|
9848
|
-
prefix: LOGIN_MENU_CALLBACK_PREFIX,
|
|
9849
|
-
alertType: "login",
|
|
9850
|
-
instanceName: backend,
|
|
9851
|
-
adapter: chat.adapter,
|
|
9852
|
-
adapterId: chat.adapterId,
|
|
9853
|
-
chatId: chat.chatId,
|
|
9854
|
-
threadId: chat.threadId,
|
|
9855
|
-
message: t("login.choose_provider"),
|
|
9856
|
-
choices: options.map((label, index) => ({ action: String(index), label })),
|
|
9857
|
-
expiredText: t("buttons.stale"),
|
|
9858
|
-
});
|
|
9859
|
-
if (failure) {
|
|
9860
|
-
await chat.adapter.sendText(chat.chatId, failure, { threadId: chat.threadId })
|
|
9861
|
-
.catch(err => this.logger.warn({ err, backend }, "Could not report a login menu that failed to post"));
|
|
9862
|
-
}
|
|
9863
|
-
},
|
|
9864
|
-
onAuthHint: async (url, code) => {
|
|
9865
|
-
await this.sendLoginSecret(chat, backend, url, code);
|
|
9866
|
-
},
|
|
9867
|
-
onNeedInput: async (promptExcerpt) => {
|
|
9868
|
-
await chat.adapter.sendText(chat.chatId, t("login.need_input", backend, promptExcerpt), { threadId: chat.threadId }).catch(() => { });
|
|
9869
|
-
},
|
|
9870
|
-
onDone: async ({ ok, detail, cleanupFailed }) => {
|
|
9871
|
-
this.activeLogin = null;
|
|
9872
|
-
this.loginWindow.release(claim);
|
|
9873
|
-
if (cleanupFailed) {
|
|
9874
|
-
await chat.adapter.sendText(chat.chatId, t("login.web_cleanup_failed", backend), { threadId: chat.threadId }).catch(() => { });
|
|
9875
|
-
}
|
|
9876
|
-
const send = (text) => chat.adapter.sendText(chat.chatId, text, { threadId: chat.threadId }).catch(() => { });
|
|
9877
|
-
let text;
|
|
9878
|
-
if (ok) {
|
|
9879
|
-
// Same order as the web-login path: the login result goes out first,
|
|
9880
|
-
// then the recovery reports its own outcome. Waiting for recovery to
|
|
9881
|
-
// build this message is what made a successful login look hung.
|
|
9882
|
-
await send(t("login.completed", backend));
|
|
9883
|
-
await announcePostLoginRecovery(backend, () => this.recoverBackendInstances(backend), send);
|
|
9884
|
-
return;
|
|
9885
|
-
}
|
|
9886
|
-
else if (detail === "cancelled") {
|
|
9887
|
-
// The cancel command's own reply already announced this — a second
|
|
9888
|
-
// message here was a duplicate.
|
|
9889
|
-
return;
|
|
9890
|
-
}
|
|
9891
|
-
else {
|
|
9892
|
-
text = t("login.failed", backend, detail);
|
|
9893
|
-
}
|
|
9894
|
-
await chat.adapter.sendText(chat.chatId, text, { threadId: chat.threadId }).catch(() => { });
|
|
9895
|
-
},
|
|
9896
|
-
}, this.logger);
|
|
9897
|
-
// Claim the slot before the first await so two admins racing /login cannot
|
|
9898
|
-
// both create windows; release on startup failure.
|
|
9899
|
-
this.activeLogin = { session, backend, chat };
|
|
9900
|
-
try {
|
|
9901
|
-
await session.start();
|
|
9902
|
-
}
|
|
9903
|
-
catch (err) {
|
|
9904
|
-
this.activeLogin = null;
|
|
9905
|
-
this.loginWindow.release(claim);
|
|
9906
|
-
const text = t("login.failed", backend, err.message);
|
|
9907
|
-
return err.cleanupFailed ? `${text}\n${t("login.web_cleanup_failed", backend)}` : text;
|
|
9908
|
-
}
|
|
9909
|
-
if (session.state === "done") {
|
|
9910
|
-
// Cancelled (user or shutdown) while starting: start() joined the
|
|
9911
|
-
// teardown, so nothing is left. onDone already released the claim.
|
|
9912
|
-
this.activeLogin = null;
|
|
9913
|
-
this.loginWindow.release(claim);
|
|
9914
|
-
return this.loginWindow.isClosed ? t("login.web_shutting_down") : t("login.cancelled", backend);
|
|
9915
|
-
}
|
|
9916
|
-
if (!this.loginWindow.isCurrent(claim)) {
|
|
9917
|
-
await session.cancel("cancelled").catch(() => { });
|
|
9918
|
-
this.activeLogin = null;
|
|
9919
|
-
this.loginWindow.release(claim);
|
|
9920
|
-
return t("login.web_shutting_down");
|
|
9921
|
-
}
|
|
9922
|
-
return t("login.started", backend);
|
|
9923
|
-
}
|
|
9924
|
-
/** Fleet shutdown: end any web/relay login or install window and wait for its confirmed teardown. */
|
|
9936
|
+
/** Fleet shutdown: end any web login or install window and wait for its confirmed teardown. */
|
|
9925
9937
|
async shutdownLoginWindows() {
|
|
9926
9938
|
// Close the lock FIRST: in-flight starts parked in a pre-check or
|
|
9927
9939
|
// ensureSession observe !isCurrent when they resume and stop; no new
|
|
@@ -9956,19 +9968,9 @@ export class FleetManager {
|
|
|
9956
9968
|
// a stopping fleet must not announce "login failed — fleet shutdown".
|
|
9957
9969
|
await Promise.all([
|
|
9958
9970
|
bounded(this.loginController?.shutdown(), "web-login"),
|
|
9959
|
-
bounded(this.activeLogin?.session.cancel("cancelled"), "relay-login"),
|
|
9960
9971
|
bounded(this.activeInstall?.session.cancel("cancelled"), "install"),
|
|
9961
9972
|
]);
|
|
9962
9973
|
}
|
|
9963
|
-
/** `/login code <text>` — paste admin-supplied text into the login window. */
|
|
9964
|
-
async loginSubmitInput(text) {
|
|
9965
|
-
if (this.loginController?.isActive())
|
|
9966
|
-
return t("login.web_code_not_needed");
|
|
9967
|
-
if (!this.activeLogin)
|
|
9968
|
-
return t("login.no_session");
|
|
9969
|
-
const ok = await this.activeLogin.session.submitInput(text);
|
|
9970
|
-
return ok ? t("login.input_sent") : t("login.input_failed");
|
|
9971
|
-
}
|
|
9972
9974
|
/** `/login cancel` — abort the active session and remove its window. */
|
|
9973
9975
|
async cancelLoginSession() {
|
|
9974
9976
|
// `/login cancel` also stops an install that `/login` started — and the
|
|
@@ -9983,31 +9985,7 @@ export class FleetManager {
|
|
|
9983
9985
|
}
|
|
9984
9986
|
if (this.loginController?.isActive())
|
|
9985
9987
|
return this.loginController.cancel();
|
|
9986
|
-
|
|
9987
|
-
return t("login.no_session");
|
|
9988
|
-
const backend = this.activeLogin.backend;
|
|
9989
|
-
await this.activeLogin.session.cancel();
|
|
9990
|
-
return t("login.cancelled", backend);
|
|
9991
|
-
}
|
|
9992
|
-
/**
|
|
9993
|
-
* The URL (+ code) is a live credential: whoever completes it binds THEIR
|
|
9994
|
-
* account to this fleet's CLI. Telegram gets an HTML spoiler (same treatment
|
|
9995
|
-
* as the dashboard token); other adapters get plain text with the warning.
|
|
9996
|
-
*/
|
|
9997
|
-
async sendLoginSecret(chat, backend, url, code) {
|
|
9998
|
-
const codeLine = code ? `\n${t("login.auth_code", code)}` : "";
|
|
9999
|
-
try {
|
|
10000
|
-
if (chat.adapter.type === "telegram") {
|
|
10001
|
-
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
10002
|
-
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" });
|
|
10003
|
-
}
|
|
10004
|
-
else {
|
|
10005
|
-
await chat.adapter.sendText(chat.chatId, `${t("login.auth_hint", backend)}\n${url}${codeLine}`, { threadId: chat.threadId });
|
|
10006
|
-
}
|
|
10007
|
-
}
|
|
10008
|
-
catch (err) {
|
|
10009
|
-
this.logger.warn({ err: err.message, backend }, "Failed to deliver login URL");
|
|
10010
|
-
}
|
|
9988
|
+
return t("login.no_session");
|
|
10011
9989
|
}
|
|
10012
9990
|
/**
|
|
10013
9991
|
* After a successful login: wake the paused instances of that backend AND
|
|
@@ -10125,20 +10103,6 @@ export class FleetManager {
|
|
|
10125
10103
|
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, text);
|
|
10126
10104
|
return true;
|
|
10127
10105
|
}
|
|
10128
|
-
/** Kiro provider button → drive the CLI's arrow-key selector. */
|
|
10129
|
-
async handleLoginMenuSelect(data, callbackAdapterId, receivingAdapter) {
|
|
10130
|
-
const claimed = this.consumeNonceCallback(LOGIN_MENU_CALLBACK_PREFIX, /^login-menu:([0-9a-f]+):(\d)$/, data, callbackAdapterId, receivingAdapter);
|
|
10131
|
-
if (claimed === null)
|
|
10132
|
-
return false;
|
|
10133
|
-
if (claimed === "consumed")
|
|
10134
|
-
return true;
|
|
10135
|
-
const { entry, action } = claimed;
|
|
10136
|
-
const index = Number(action);
|
|
10137
|
-
const label = this.activeLogin?.session.flow.menu?.options[index] ?? action;
|
|
10138
|
-
const ok = await this.activeLogin?.session.selectMenuOption(index) ?? false;
|
|
10139
|
-
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, ok ? t("login.provider_selected", label) : t("login.no_session"));
|
|
10140
|
-
return true;
|
|
10141
|
-
}
|
|
10142
10106
|
// ── Remote CLI install (run by `/login` for a CLI that is missing) ──────────────────────────────────
|
|
10143
10107
|
//
|
|
10144
10108
|
// Same dedicated-window model as /login (and the same LoginSession state
|
|
@@ -10156,7 +10120,7 @@ export class FleetManager {
|
|
|
10156
10120
|
return t("install.unsupported", backendArg);
|
|
10157
10121
|
if (checkBinaryInstalled(info.binary))
|
|
10158
10122
|
return t("install.already", backend, info.binary);
|
|
10159
|
-
// Reserve the fleet-wide window before the first await (shared with web
|
|
10123
|
+
// Reserve the fleet-wide window before the first await (shared with web
|
|
10160
10124
|
// login). Owned by this method until the session is published.
|
|
10161
10125
|
const claim = this.loginWindow.tryClaim("install", backend);
|
|
10162
10126
|
if (!claim)
|
|
@@ -10175,10 +10139,9 @@ export class FleetManager {
|
|
|
10175
10139
|
this.loginWindow.release(claim);
|
|
10176
10140
|
return t("install.failed", backend, err.message);
|
|
10177
10141
|
}
|
|
10178
|
-
// A synthetic
|
|
10179
|
-
// (LoginSession treats a clean exit as success), never by pane text
|
|
10180
|
-
//
|
|
10181
|
-
// an auth hint, hence the no-op events below.
|
|
10142
|
+
// A synthetic flow: success is decided by the installer's exit code
|
|
10143
|
+
// (LoginSession treats a clean exit as success), never by pane text. The
|
|
10144
|
+
// session only judges that verdict; it does not read the output for URLs or prompts.
|
|
10182
10145
|
const flow = {
|
|
10183
10146
|
backend,
|
|
10184
10147
|
command: info.install,
|
|
@@ -10186,9 +10149,6 @@ export class FleetManager {
|
|
|
10186
10149
|
timeoutMs: 10 * 60 * 1000,
|
|
10187
10150
|
};
|
|
10188
10151
|
const session = new LoginSession(flow, tmux, {
|
|
10189
|
-
onMenu: () => { },
|
|
10190
|
-
onAuthHint: () => { },
|
|
10191
|
-
onNeedInput: () => { },
|
|
10192
10152
|
onDone: async ({ ok, detail, cleanupFailed }) => {
|
|
10193
10153
|
this.activeInstall = null;
|
|
10194
10154
|
this.loginWindow.release(claim);
|
|
@@ -11145,7 +11105,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11145
11105
|
for (const backend of SYSINFO_BACKEND_IDS) {
|
|
11146
11106
|
if (!this.cliEnvNeedsRefresh(this.readCliEnv(backend)))
|
|
11147
11107
|
continue;
|
|
11148
|
-
void this.probeBackendBounded(backend
|
|
11108
|
+
void this.probeBackendBounded(backend).catch(err => {
|
|
11149
11109
|
this.logger.debug({ err, backend }, "Background CLI version refresh failed");
|
|
11150
11110
|
});
|
|
11151
11111
|
}
|
|
@@ -11168,19 +11128,26 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11168
11128
|
return vendorFlight;
|
|
11169
11129
|
if (!opts.refreshVendorCatalog && (vendorFlight || regularFlight))
|
|
11170
11130
|
return vendorFlight ?? regularFlight;
|
|
11171
|
-
|
|
11172
|
-
|
|
11173
|
-
|
|
11174
|
-
|
|
11175
|
-
const
|
|
11176
|
-
|
|
11177
|
-
|
|
11178
|
-
|
|
11179
|
-
|
|
11180
|
-
|
|
11181
|
-
|
|
11182
|
-
|
|
11183
|
-
|
|
11131
|
+
if (this.shuttingDown)
|
|
11132
|
+
return null;
|
|
11133
|
+
const instanceDir = join(getAgendHome(), "cli-env");
|
|
11134
|
+
const epoch = this.cliEnvProbeEpoch;
|
|
11135
|
+
const input = {
|
|
11136
|
+
mode: "env", backend, instanceDir,
|
|
11137
|
+
config: { workingDirectory: "", instanceDir, instanceName: `probe-${backend}`, mcpServers: {} },
|
|
11138
|
+
refreshVendorCatalog: opts.refreshVendorCatalog === true,
|
|
11139
|
+
};
|
|
11140
|
+
const bounded = this.runBackendProbeWorker(`global:${backend}`, input).then(probed => {
|
|
11141
|
+
if (!probed || Array.isArray(probed) || this.shuttingDown || epoch !== this.cliEnvProbeEpoch)
|
|
11142
|
+
return null;
|
|
11143
|
+
try {
|
|
11144
|
+
return this.persistCliEnvProbeResult(backend, probed);
|
|
11145
|
+
}
|
|
11146
|
+
catch (err) {
|
|
11147
|
+
this.logger.warn({ backend, err }, "CLI env cache write failed");
|
|
11148
|
+
return null;
|
|
11149
|
+
}
|
|
11150
|
+
}).finally(() => {
|
|
11184
11151
|
const flights = opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes;
|
|
11185
11152
|
if (flights.get(backend) === bounded)
|
|
11186
11153
|
flights.delete(backend);
|
|
@@ -11188,18 +11155,26 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11188
11155
|
(opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(backend, bounded);
|
|
11189
11156
|
return bounded;
|
|
11190
11157
|
}
|
|
11191
|
-
|
|
11192
|
-
|
|
11193
|
-
|
|
11194
|
-
|
|
11195
|
-
|
|
11196
|
-
|
|
11197
|
-
|
|
11198
|
-
|
|
11199
|
-
|
|
11200
|
-
|
|
11201
|
-
|
|
11202
|
-
|
|
11158
|
+
stopCliEnvProbes() {
|
|
11159
|
+
this.cliEnvProbeEpoch++;
|
|
11160
|
+
this.cliEnvProbePool.close();
|
|
11161
|
+
this.pendingCliEnvProbes.clear();
|
|
11162
|
+
this.pendingVendorCliEnvProbes.clear();
|
|
11163
|
+
this.pendingInstanceModelProbes.clear();
|
|
11164
|
+
}
|
|
11165
|
+
runBackendProbeWorker(key, input) {
|
|
11166
|
+
if (this.shuttingDown)
|
|
11167
|
+
return Promise.resolve(null);
|
|
11168
|
+
this.cliEnvProbePool.reopen();
|
|
11169
|
+
return this.cliEnvProbePool.run(key, () => this.startCliEnvProbeWorker(input), {
|
|
11170
|
+
deadlineMs: CLI_ENV_PROBE_DEADLINE_MS,
|
|
11171
|
+
onTimeout: () => this.logger.warn({ backend: input.backend, deadlineMs: CLI_ENV_PROBE_DEADLINE_MS }, "CLI env live probe exceeded its deadline — serving the cached model list"),
|
|
11172
|
+
onError: err => this.logger.warn({ backend: input.backend, err }, "CLI env worker failed to start or stop"),
|
|
11173
|
+
});
|
|
11174
|
+
}
|
|
11175
|
+
startCliEnvProbeWorker(input) {
|
|
11176
|
+
const { backend } = input;
|
|
11177
|
+
const workerData = input;
|
|
11203
11178
|
// `tsx src/cli.ts` needs its TS module resolver inside the separate isolate.
|
|
11204
11179
|
// Published builds load the compiled worker and require no tsx dependency.
|
|
11205
11180
|
const worker = import.meta.url.endsWith(".ts")
|
|
@@ -11207,35 +11182,31 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11207
11182
|
: new Worker(new URL("./backend/cli-env-probe-worker.js", import.meta.url), { execArgv: [], workerData });
|
|
11208
11183
|
let settled = false;
|
|
11209
11184
|
let resolveProbe;
|
|
11185
|
+
let markStopped;
|
|
11186
|
+
const stopped = new Promise(resolve => { markStopped = resolve; });
|
|
11210
11187
|
const promise = new Promise(resolve => { resolveProbe = resolve; });
|
|
11211
11188
|
const finish = (value) => {
|
|
11212
11189
|
if (settled)
|
|
11213
11190
|
return;
|
|
11214
11191
|
settled = true;
|
|
11215
11192
|
resolveProbe(value);
|
|
11216
|
-
|
|
11193
|
+
// Admission stays occupied until terminate resolves or an exit is seen.
|
|
11194
|
+
// A rejected termination does not prove the isolate has stopped.
|
|
11195
|
+
try {
|
|
11196
|
+
void worker.terminate().then(markStopped).catch(err => {
|
|
11197
|
+
this.logger.debug({ backend, err }, "CLI env worker termination failed");
|
|
11198
|
+
});
|
|
11199
|
+
}
|
|
11200
|
+
catch (err) {
|
|
11217
11201
|
this.logger.debug({ backend, err }, "CLI env worker termination failed");
|
|
11218
|
-
}
|
|
11202
|
+
}
|
|
11219
11203
|
};
|
|
11220
11204
|
worker.once("message", (message) => {
|
|
11221
11205
|
if (settled)
|
|
11222
11206
|
return;
|
|
11223
|
-
if (!message.ok)
|
|
11207
|
+
if (!message.ok)
|
|
11224
11208
|
this.logger.warn({ backend, error: message.error }, "CLI env worker failed");
|
|
11225
|
-
|
|
11226
|
-
}
|
|
11227
|
-
else if (!message.result) {
|
|
11228
|
-
finish(null);
|
|
11229
|
-
}
|
|
11230
|
-
else {
|
|
11231
|
-
try {
|
|
11232
|
-
finish(this.persistCliEnvProbeResult(backend, message.result));
|
|
11233
|
-
}
|
|
11234
|
-
catch (err) {
|
|
11235
|
-
this.logger.warn({ backend, err }, "CLI env cache write failed");
|
|
11236
|
-
finish(null);
|
|
11237
|
-
}
|
|
11238
|
-
}
|
|
11209
|
+
finish(message.ok ? message.result ?? null : null);
|
|
11239
11210
|
});
|
|
11240
11211
|
worker.once("error", err => {
|
|
11241
11212
|
if (settled)
|
|
@@ -11244,12 +11215,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11244
11215
|
finish(null);
|
|
11245
11216
|
});
|
|
11246
11217
|
worker.once("exit", code => {
|
|
11218
|
+
markStopped();
|
|
11247
11219
|
if (settled)
|
|
11248
11220
|
return;
|
|
11249
11221
|
this.logger.warn({ backend, code }, "CLI env worker exited without a result");
|
|
11250
11222
|
finish(null);
|
|
11251
11223
|
});
|
|
11252
|
-
return { promise, terminate: () => { finish(null); } };
|
|
11224
|
+
return { promise, stopped, terminate: () => { finish(null); } };
|
|
11253
11225
|
}
|
|
11254
11226
|
/**
|
|
11255
11227
|
* Resolve the effective model for a fleet or ClassicBot instance, plus where it
|
|
@@ -11299,32 +11271,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11299
11271
|
const label = option.description ? `${option.label} — ${option.description}` : option.label;
|
|
11300
11272
|
return option.id === currentModel ? `✓ ${label}` : label;
|
|
11301
11273
|
}
|
|
11302
|
-
/**
|
|
11303
|
-
* Probe one backend's CLI env and cache it. Best-effort; never throws.
|
|
11304
|
-
*
|
|
11305
|
-
* `refreshVendorCatalog` is the "🔄 Refresh models" path only (#886): first
|
|
11306
|
-
* ask the CLI to refetch its own catalog, for backends whose probe merely
|
|
11307
|
-
* reads a file the CLI maintains. Every other caller leaves it off, so the
|
|
11308
|
-
* startup and /model probes behave exactly as before. A failed vendor
|
|
11309
|
-
* refresh fails the probe (null), which the menu reports instead of passing
|
|
11310
|
-
* the old list off as fresh.
|
|
11311
|
-
*/
|
|
11312
|
-
async probeBackend(backend, opts = {}) {
|
|
11313
|
-
try {
|
|
11314
|
-
const be = createBackend(backend, join(getAgendHome(), "cli-env"));
|
|
11315
|
-
if (!be.probeCLIEnv)
|
|
11316
|
-
return null;
|
|
11317
|
-
if (opts.refreshVendorCatalog && be.refreshModelCatalog)
|
|
11318
|
-
await be.refreshModelCatalog();
|
|
11319
|
-
const probed = await be.probeCLIEnv({ workingDirectory: "", instanceDir: join(getAgendHome(), "cli-env"), instanceName: `probe-${backend}`, mcpServers: {} });
|
|
11320
|
-
return this.persistCliEnvProbeResult(backend, probed);
|
|
11321
|
-
}
|
|
11322
|
-
catch (err) {
|
|
11323
|
-
this.logger.warn({ err, backend }, "CLI env probe failed");
|
|
11324
|
-
return null;
|
|
11325
|
-
}
|
|
11326
|
-
}
|
|
11327
|
-
/** Preserve the existing cache merge rules for both in-process and worker probes. */
|
|
11274
|
+
/** Preserve the existing cache merge rules for worker probes. */
|
|
11328
11275
|
persistCliEnvProbeResult(backend, probed) {
|
|
11329
11276
|
const env = { backend, probedAt: Date.now(), ...probed };
|
|
11330
11277
|
// An empty result must never overwrite a catalog we already have. Some
|
|
@@ -11471,17 +11418,30 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11471
11418
|
try {
|
|
11472
11419
|
const inst = this.fleetConfig?.instances?.[instanceName];
|
|
11473
11420
|
const instanceDir = this.getInstanceDir(instanceName);
|
|
11474
|
-
const
|
|
11475
|
-
if (!be.listModels)
|
|
11476
|
-
return [];
|
|
11477
|
-
return await be.listModels({
|
|
11421
|
+
const config = structuredClone({
|
|
11478
11422
|
workingDirectory: inst?.working_directory ?? "",
|
|
11479
11423
|
instanceDir,
|
|
11480
11424
|
instanceName,
|
|
11481
11425
|
mcpServers: {},
|
|
11482
11426
|
model: inst?.model,
|
|
11483
11427
|
backendOptions: inst?.backend_options?.[backend] ?? this.fleetConfig?.defaults?.backend_options?.[backend],
|
|
11484
|
-
})
|
|
11428
|
+
});
|
|
11429
|
+
// Include the config snapshot, so a changed provider/profile cannot join
|
|
11430
|
+
// a still-running probe for the previous instance configuration.
|
|
11431
|
+
const key = JSON.stringify({ backend, instanceDir, config });
|
|
11432
|
+
const pending = this.pendingInstanceModelProbes.get(key);
|
|
11433
|
+
if (pending)
|
|
11434
|
+
return pending;
|
|
11435
|
+
const epoch = this.cliEnvProbeEpoch;
|
|
11436
|
+
const bounded = this.runBackendProbeWorker(`instance:${backend}:${instanceDir}`, {
|
|
11437
|
+
mode: "models", backend, instanceDir, config,
|
|
11438
|
+
}).then(result => Array.isArray(result) && !this.shuttingDown && epoch === this.cliEnvProbeEpoch ? result : [])
|
|
11439
|
+
.finally(() => {
|
|
11440
|
+
if (this.pendingInstanceModelProbes.get(key) === bounded)
|
|
11441
|
+
this.pendingInstanceModelProbes.delete(key);
|
|
11442
|
+
});
|
|
11443
|
+
this.pendingInstanceModelProbes.set(key, bounded);
|
|
11444
|
+
return bounded;
|
|
11485
11445
|
}
|
|
11486
11446
|
catch {
|
|
11487
11447
|
// listModels is documented never to throw, but a backend constructor can
|
|
@@ -11990,12 +11950,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11990
11950
|
/** How this instance's backend applies an effort change. */
|
|
11991
11951
|
effortStrategyFor(instanceName) {
|
|
11992
11952
|
try {
|
|
11993
|
-
const
|
|
11994
|
-
|
|
11995
|
-
// A backend claiming support but listing no levels is unusable either way.
|
|
11996
|
-
return strategy !== "unsupported" && (backend.getEffortLevels?.() ?? []).length > 0
|
|
11997
|
-
? strategy
|
|
11998
|
-
: "unsupported";
|
|
11953
|
+
const { strategy, levels } = readEffortMetadata(this.backendNameForInstance(instanceName), this.getInstanceDir(instanceName));
|
|
11954
|
+
return strategy !== "unsupported" && levels.length > 0 ? strategy : "unsupported";
|
|
11999
11955
|
}
|
|
12000
11956
|
catch {
|
|
12001
11957
|
return "unsupported";
|
|
@@ -12004,10 +11960,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12004
11960
|
/** Effort levels this instance's backend actually accepts (empty = unsupported). */
|
|
12005
11961
|
effortLevelsFor(instanceName) {
|
|
12006
11962
|
try {
|
|
12007
|
-
const
|
|
12008
|
-
|
|
12009
|
-
return [];
|
|
12010
|
-
return backend.getEffortLevels?.() ?? [];
|
|
11963
|
+
const { strategy, levels } = readEffortMetadata(this.backendNameForInstance(instanceName), this.getInstanceDir(instanceName));
|
|
11964
|
+
return strategy === "unsupported" ? [] : levels;
|
|
12011
11965
|
}
|
|
12012
11966
|
catch {
|
|
12013
11967
|
return [];
|
|
@@ -12147,21 +12101,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12147
12101
|
try {
|
|
12148
12102
|
if (!existsSync(logFile))
|
|
12149
12103
|
return undefined;
|
|
12150
|
-
|
|
12151
|
-
|
|
12152
|
-
// and is included separately under [User message]. Exclude that newest
|
|
12153
|
-
// log entry so the agent does not receive the same message twice. A chat
|
|
12154
|
-
// message may span physical lines, so remove from its timestamped entry
|
|
12155
|
-
// header rather than blindly dropping only the final continuation line.
|
|
12156
|
-
const entryHeader = /^\[\d{4}-\d{2}-\d{2}T[^\]]+\] <.*> /;
|
|
12157
|
-
let currentEntryStart = lines.length - 1;
|
|
12158
|
-
while (currentEntryStart > 0 && !entryHeader.test(lines[currentEntryStart])) {
|
|
12159
|
-
currentEntryStart--;
|
|
12160
|
-
}
|
|
12161
|
-
lines.splice(currentEntryStart);
|
|
12162
|
-
if (lines.length === 0 || maxLines <= 0)
|
|
12163
|
-
return undefined;
|
|
12164
|
-
return lines.slice(-maxLines).join("\n") || undefined;
|
|
12104
|
+
// Only the end of today's log is needed (#1161): a day's log used to be read and split whole for every message.
|
|
12105
|
+
return recentChatContext(logFile, maxLines, CHAT_LOG_TAIL_BYTES, CHAT_LOG_TAIL_MAX_BYTES);
|
|
12165
12106
|
}
|
|
12166
12107
|
catch {
|
|
12167
12108
|
return undefined;
|
|
@@ -12417,6 +12358,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12417
12358
|
// make `agend stop` wait forever for its own queue.
|
|
12418
12359
|
this.stormWindow.shutdown();
|
|
12419
12360
|
this.spawnGate.shutdown();
|
|
12361
|
+
this.memoryPressure?.stop();
|
|
12362
|
+
this.stopCliEnvProbes();
|
|
12420
12363
|
for (const pending of this.startupRetries.values())
|
|
12421
12364
|
clearTimeout(pending.timer);
|
|
12422
12365
|
this.startupRetries.clear();
|