@songsid/agend 2.1.10 → 2.1.11-beta.2
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-engine-ledger.d.ts +36 -0
- package/dist/backend/kiro-engine-ledger.js +113 -0
- package/dist/backend/kiro-engine-ledger.js.map +1 -0
- package/dist/backend/kiro-kas-store.d.ts +11 -0
- package/dist/backend/kiro-kas-store.js +67 -0
- package/dist/backend/kiro-kas-store.js.map +1 -0
- package/dist/backend/kiro-v3-identity.d.ts +28 -0
- package/dist/backend/kiro-v3-identity.js +213 -0
- package/dist/backend/kiro-v3-identity.js.map +1 -0
- package/dist/backend/kiro.d.ts +10 -0
- package/dist/backend/kiro.js +53 -5
- 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-permission.d.ts +49 -0
- package/dist/backend/opencode-permission.js +181 -0
- package/dist/backend/opencode-permission.js.map +1 -0
- package/dist/backend/opencode.d.ts +37 -4
- package/dist/backend/opencode.js +112 -25
- package/dist/backend/opencode.js.map +1 -1
- package/dist/backend/types.d.ts +29 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.js +35 -26
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/ipc-bridge.d.ts +1 -0
- package/dist/channel/ipc-bridge.js +1 -1
- package/dist/channel/ipc-bridge.js.map +1 -1
- package/dist/channel/mcp-tools.js +2 -0
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/cli.js +48 -6
- package/dist/cli.js.map +1 -1
- package/dist/command-table.d.ts +99 -0
- package/dist/command-table.js +125 -0
- package/dist/command-table.js.map +1 -0
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +60 -0
- package/dist/daemon.js +274 -14
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +16 -0
- package/dist/delivery-outbox.js +55 -6
- package/dist/delivery-outbox.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/e2e-isolation.d.ts +122 -0
- package/dist/e2e-isolation.js +386 -0
- package/dist/e2e-isolation.js.map +1 -0
- 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 +69 -35
- package/dist/fleet-manager.js +596 -845
- 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/general-knowledge/skills/kiro-engine-migration/SKILL.md +56 -0
- 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.d.ts +2 -0
- package/dist/instance-lifecycle.js +18 -1
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/kiro-engine-status.d.ts +71 -0
- package/dist/kiro-engine-status.js +68 -0
- package/dist/kiro-engine-status.js.map +1 -0
- package/dist/locale.js +72 -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 +11 -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/outbound-handlers.js +16 -1
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +3 -0
- package/dist/outbound-schemas.js +3 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/paths.d.ts +19 -0
- package/dist/paths.js +18 -5
- package/dist/paths.js.map +1 -1
- 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/tool-permissions.js +4 -2
- package/dist/tool-permissions.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/dist/ui/web-terminal/terminal-input.js +69 -0
- package/dist/ui/web-terminal/terminal.css +6 -0
- package/dist/ui/web-terminal/terminal.html +9 -1
- package/dist/ui/web-terminal/terminal.js +31 -2
- package/dist/web-terminal-http.js +1 -0
- package/dist/web-terminal-http.js.map +1 -1
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, constants as fsConstants } from "node:fs";
|
|
2
|
-
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
3
|
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,18 @@ 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 { commandSpec, decideCommand } from "./command-table.js";
|
|
66
|
+
import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
|
|
62
67
|
import { runBeforeDeadline } from "./deadline.js";
|
|
63
68
|
import { LoginWindowLock } from "./login-window-lock.js";
|
|
64
69
|
import { handleSettingsRequest } from "./settings-api.js";
|
|
65
70
|
import { setLocale, detectLocale, getLocale, t } from "./locale.js";
|
|
71
|
+
import { recentChatContext } from "./log-tail.js";
|
|
72
|
+
import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet-control-audit.js";
|
|
66
73
|
import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
|
|
67
74
|
import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
|
|
68
75
|
import { assertExplicitInstanceRemoval } from "./instance-removal.js";
|
|
@@ -223,12 +230,17 @@ const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
|
|
|
223
230
|
const TIP_DISMISS_CALLBACK_PREFIX = "tip-dismiss:";
|
|
224
231
|
const TIP_UNLOCK_CALLBACK_PREFIX = "tip-unlock:";
|
|
225
232
|
export const LOGIN_CALLBACK_PREFIX = "login:";
|
|
226
|
-
/**
|
|
227
|
-
|
|
233
|
+
/**
|
|
234
|
+
* Prompt kinds that no longer exist: `/install-cli` (#1131) and the relay-mode login's
|
|
235
|
+
* provider picker (#1139). Their buttons may still be on screen after an upgrade.
|
|
236
|
+
*/
|
|
237
|
+
const RETIRED_PROMPT_PREFIXES = ["install-select:", "install-login:", "login-menu:"];
|
|
228
238
|
const NEVER_MATCHES = /(?!)/;
|
|
229
239
|
const CLASSIC_APPROVE_CALLBACK_PREFIX = "classic-approve:";
|
|
230
|
-
const LOGIN_MENU_CALLBACK_PREFIX = "login-menu:";
|
|
231
240
|
const LOGIN_CONFIRM_CALLBACK_PREFIX = "login-confirm:";
|
|
241
|
+
/** How much of the end of a Classic chat log is read for context, and the most it grows to. */
|
|
242
|
+
const CHAT_LOG_TAIL_BYTES = 64 * 1024;
|
|
243
|
+
const CHAT_LOG_TAIL_MAX_BYTES = 4 * 1024 * 1024;
|
|
232
244
|
const CLEAR_CONFIRM_TIMEOUT_MS = 15_000;
|
|
233
245
|
/** Default lifetime for long-lived nonce prompts (clear overrides this to 15s). */
|
|
234
246
|
const NONCE_BUTTON_TIMEOUT_MS = 15 * 60_000;
|
|
@@ -292,6 +304,7 @@ export class FleetManager {
|
|
|
292
304
|
lifecycle;
|
|
293
305
|
stormWindow;
|
|
294
306
|
spawnGate;
|
|
307
|
+
memoryPressure;
|
|
295
308
|
/** Fleet-level backend reachability memory (fed by pty_error / startup panes). */
|
|
296
309
|
backendOutage = new BackendOutageTracker();
|
|
297
310
|
/** Stable for this FleetManager OS-process lifetime; Daemon objects have their own boot IDs. */
|
|
@@ -445,6 +458,7 @@ export class FleetManager {
|
|
|
445
458
|
startupRetryNotices = new Map();
|
|
446
459
|
/** Unsupported-CLI refusals waiting to go out, grouped by reason (#1109). */
|
|
447
460
|
unsupportedCliNotices = new Map();
|
|
461
|
+
kiroIncompatNotices = new Map();
|
|
448
462
|
/** Backoff between automatic startup retries; the last step repeats while the backend is down. */
|
|
449
463
|
static STARTUP_RETRY_BACKOFF_MS = [60_000, 5 * 60_000, 15 * 60_000];
|
|
450
464
|
/** Hard cap on automatic startup retries (3 backoff steps + up to 3 more during a backend outage). */
|
|
@@ -537,6 +551,11 @@ export class FleetManager {
|
|
|
537
551
|
watchdogTimer = null;
|
|
538
552
|
startedAt = 0;
|
|
539
553
|
stormOpenNotifyTimer = null;
|
|
554
|
+
memoryLogAt = null;
|
|
555
|
+
memoryDebugAt = null;
|
|
556
|
+
memoryLogLevel = null;
|
|
557
|
+
memoryNoticeAt = null;
|
|
558
|
+
memoryNoticeLevel = null;
|
|
540
559
|
// Mirror topic: buffer cross-instance messages, flush every 3s
|
|
541
560
|
mirrorBuffer = [];
|
|
542
561
|
mirrorTimer = null;
|
|
@@ -575,8 +594,10 @@ export class FleetManager {
|
|
|
575
594
|
FleetManager.sighupHandlerInstalled = true;
|
|
576
595
|
}
|
|
577
596
|
this.stormWindow = new StormWindow();
|
|
597
|
+
this.memoryPressure = new MemoryPressure({ onSample: snapshot => this.reportMemoryPressure(snapshot) });
|
|
578
598
|
this.spawnGate = new SpawnGate({
|
|
579
599
|
storm: this.stormWindow,
|
|
600
|
+
memoryPressure: this.memoryPressure,
|
|
580
601
|
concurrency: () => this.spawnConcurrency(),
|
|
581
602
|
staggerMs: () => this.fleetConfig?.defaults?.startup?.stagger_delay_ms ?? 500,
|
|
582
603
|
});
|
|
@@ -956,9 +977,54 @@ export class FleetManager {
|
|
|
956
977
|
return Math.max(1, Math.min(20, explicit));
|
|
957
978
|
return deriveSpawnConcurrency(Math.round(freemem() / (1024 * 1024)), cpus().length);
|
|
958
979
|
}
|
|
980
|
+
/** Separate cooldown: changing measurements must not create new text-throttle keys. */
|
|
981
|
+
reportMemoryPressure(snapshot) {
|
|
982
|
+
if (this.shuttingDown)
|
|
983
|
+
return;
|
|
984
|
+
const now = Date.now();
|
|
985
|
+
if (this.memoryDebugAt === null || now - this.memoryDebugAt >= 30_000) {
|
|
986
|
+
this.memoryDebugAt = now;
|
|
987
|
+
this.logger.debug({ hostMemory: snapshot }, "Host memory sample");
|
|
988
|
+
}
|
|
989
|
+
const changed = snapshot.level !== this.memoryLogLevel;
|
|
990
|
+
if (snapshot.level === "normal") {
|
|
991
|
+
if (this.memoryLogLevel === "critical" || this.memoryLogLevel === "elevated") {
|
|
992
|
+
this.logger.info({ hostMemory: snapshot }, "Host memory recovered");
|
|
993
|
+
}
|
|
994
|
+
this.memoryLogLevel = snapshot.level;
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
if (changed || this.memoryLogAt === null || now - this.memoryLogAt >= 10 * 60_000) {
|
|
998
|
+
this.memoryLogAt = now;
|
|
999
|
+
this.logger.warn({ hostMemory: snapshot }, snapshot.level === "critical"
|
|
1000
|
+
? "Host memory critical — deferring new CLI spawns"
|
|
1001
|
+
: snapshot.level === "elevated" ? "Host memory pressure — slowing new CLI spawns"
|
|
1002
|
+
: "Host memory sample unavailable — slowing new CLI spawns");
|
|
1003
|
+
}
|
|
1004
|
+
this.memoryLogLevel = snapshot.level;
|
|
1005
|
+
if (!snapshot.memory || snapshot.level === "unknown" || (!this.adapter && this.adapters.size === 0))
|
|
1006
|
+
return;
|
|
1007
|
+
const escalation = snapshot.level === "critical" && this.memoryNoticeLevel !== "critical";
|
|
1008
|
+
if (!escalation && this.memoryNoticeAt !== null && now - this.memoryNoticeAt < 10 * 60_000)
|
|
1009
|
+
return;
|
|
1010
|
+
const size = (bytes) => bytes === null ? t("memory.unknown") : `${Math.round(bytes / 1024 / 1024)} MiB`;
|
|
1011
|
+
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"));
|
|
1012
|
+
if (this.notifyFleetError(text, { throttle: false })) {
|
|
1013
|
+
this.memoryNoticeAt = now;
|
|
1014
|
+
this.memoryNoticeLevel = snapshot.level;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
959
1017
|
/** Wire the one fleet-wide storm into notification and recovery surfaces. */
|
|
960
1018
|
bindStormWindowEvents() {
|
|
961
1019
|
this.stormWindow.on("opened", (snapshot) => {
|
|
1020
|
+
if (snapshot.kind === "window_loss") {
|
|
1021
|
+
// The server is fine and nothing is held: only the instances that lost
|
|
1022
|
+
// their window are affected, they respawn at the storm rate, and their
|
|
1023
|
+
// per-instance incident notices fold into this one (#1127).
|
|
1024
|
+
this.logger.error({ ...snapshot }, "several instances lost their tmux window at once — recovering at a reduced rate");
|
|
1025
|
+
this.notifyFleetError(t("storm.window_loss", snapshot.affected.length, Math.round(this.stormWindow.windowLossWindowMs / 1000), this.stormWindowLossRecoveryConcurrency()));
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
962
1028
|
for (const [name, daemon] of this.daemons) {
|
|
963
1029
|
if (!daemon.isPaused)
|
|
964
1030
|
this.stormWindow.addAffected(name);
|
|
@@ -986,6 +1052,10 @@ export class FleetManager {
|
|
|
986
1052
|
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
1053
|
});
|
|
988
1054
|
}
|
|
1055
|
+
/** What the gate allows while any storm window is open (see SpawnGate.pump). */
|
|
1056
|
+
stormWindowLossRecoveryConcurrency() {
|
|
1057
|
+
return Math.min(4, this.spawnConcurrency());
|
|
1058
|
+
}
|
|
989
1059
|
formatStormDelay(ms) {
|
|
990
1060
|
if (ms >= 60_000)
|
|
991
1061
|
return `${Math.round(ms / 60_000)}m`;
|
|
@@ -1226,8 +1296,17 @@ export class FleetManager {
|
|
|
1226
1296
|
this.rawFleetConfig = raw;
|
|
1227
1297
|
this.fleetConfig = loaded;
|
|
1228
1298
|
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
1299
|
+
this.warnAboutRemovedLoginMode(loaded);
|
|
1229
1300
|
return this.fleetConfig;
|
|
1230
1301
|
}
|
|
1302
|
+
relayLoginModeWarned = false;
|
|
1303
|
+
/** `login.mode: relay` was removed (#1139). Still accepted so an upgrade cannot stop a fleet from starting. */
|
|
1304
|
+
warnAboutRemovedLoginMode(config) {
|
|
1305
|
+
if (config.login?.mode !== "relay" || this.relayLoginModeWarned)
|
|
1306
|
+
return;
|
|
1307
|
+
this.relayLoginModeWarned = true;
|
|
1308
|
+
this.logger.warn("fleet.yaml sets login.mode: relay — the chat-relay login was removed; /login always uses the web terminal. Remove the setting.");
|
|
1309
|
+
}
|
|
1231
1310
|
/**
|
|
1232
1311
|
* A channel's configurable bot_token_env is an env-key writer too. Refuse
|
|
1233
1312
|
* an overlap with a registry API key (or a process-reserved key) before the
|
|
@@ -1349,7 +1428,7 @@ export class FleetManager {
|
|
|
1349
1428
|
const action = data.command;
|
|
1350
1429
|
const classicName = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
1351
1430
|
if (classicName) {
|
|
1352
|
-
if (!this.
|
|
1431
|
+
if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
|
|
1353
1432
|
await data.respond(t("permission.denied"));
|
|
1354
1433
|
return;
|
|
1355
1434
|
}
|
|
@@ -1380,10 +1459,30 @@ export class FleetManager {
|
|
|
1380
1459
|
}
|
|
1381
1460
|
await data.respond(await this.topicCommands.runPauseWake(target, action));
|
|
1382
1461
|
}
|
|
1462
|
+
/** SIGUSR2: restart the instances inside this process. Names who asked (#1120). */
|
|
1463
|
+
onGracefulRestartSignal(rearm) {
|
|
1464
|
+
this.logger.info(`Received SIGUSR2, initiating graceful restart... ${describeSignalSource(this.dataDir, "SIGUSR2")}`);
|
|
1465
|
+
this.restartInstances()
|
|
1466
|
+
.catch(err => this.logger.error({ err }, "Graceful restart failed"))
|
|
1467
|
+
.finally(rearm);
|
|
1468
|
+
}
|
|
1469
|
+
/** SIGUSR1: full process reload. Names who asked (#1120). */
|
|
1470
|
+
onFullRestartSignal() {
|
|
1471
|
+
this.logger.info(`Received SIGUSR1, initiating full restart (process reload)... ${describeSignalSource(this.dataDir, "SIGUSR1")}`);
|
|
1472
|
+
this.gracefulShutdownForReload()
|
|
1473
|
+
.then(() => {
|
|
1474
|
+
this.logger.info("Full restart: shutdown complete, exiting for reload");
|
|
1475
|
+
process.exit(0);
|
|
1476
|
+
})
|
|
1477
|
+
.catch(err => {
|
|
1478
|
+
this.logger.error({ err }, "Full restart: graceful shutdown failed");
|
|
1479
|
+
process.exit(1);
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1383
1482
|
async handleUpdateSlash(data, adapterId) {
|
|
1384
|
-
const
|
|
1385
|
-
if (
|
|
1386
|
-
await data.respond(t("not_authorized"));
|
|
1483
|
+
const gate = this.fleetAdminGate(data.userId, adapterId);
|
|
1484
|
+
if (gate !== "ok") {
|
|
1485
|
+
await data.respond(t(gate === "disabled" ? "update.disabled" : "not_authorized"));
|
|
1387
1486
|
return;
|
|
1388
1487
|
}
|
|
1389
1488
|
const messageId = await data.respond(t("update.progress.preparing", 0));
|
|
@@ -1395,7 +1494,11 @@ export class FleetManager {
|
|
|
1395
1494
|
const { spawn } = await import("node:child_process");
|
|
1396
1495
|
const currentVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
|
|
1397
1496
|
const command = currentVersion.includes("beta") ? "agend update --beta" : "agend update";
|
|
1398
|
-
const
|
|
1497
|
+
const origin = `slash /update by ${adapterId}:${data.userId}`;
|
|
1498
|
+
recordInternalRequest(this.dataDir, "update", origin);
|
|
1499
|
+
const child = spawn("sh", ["-c", `sleep 2 && ${command}`], {
|
|
1500
|
+
detached: true, stdio: "ignore", env: withOrigin(origin),
|
|
1501
|
+
});
|
|
1399
1502
|
child.once("error", err => this.failUpdateProgress(err.message));
|
|
1400
1503
|
child.unref();
|
|
1401
1504
|
}
|
|
@@ -1404,6 +1507,7 @@ export class FleetManager {
|
|
|
1404
1507
|
await data.respond(t("not_authorized"));
|
|
1405
1508
|
return;
|
|
1406
1509
|
}
|
|
1510
|
+
recordInternalRequest(this.dataDir, "restart", `slash /restart ${String(data.options?.mode ?? "graceful")} by ${adapterId}:${data.userId}`);
|
|
1407
1511
|
if (data.options?.mode !== "full") {
|
|
1408
1512
|
await data.respond(t("restart.graceful"));
|
|
1409
1513
|
process.kill(process.pid, "SIGUSR2");
|
|
@@ -2102,6 +2206,288 @@ export class FleetManager {
|
|
|
2102
2206
|
const allowed = this.getChannelConfig(adapterId)?.access?.allowed_users ?? [];
|
|
2103
2207
|
return allowed.some(entry => String(entry) === String(userId));
|
|
2104
2208
|
}
|
|
2209
|
+
/** Whether this adapter has any fleet admin at all (an empty allowlist means the admin commands are off). */
|
|
2210
|
+
hasFleetAdmins(adapterId) {
|
|
2211
|
+
return (this.getChannelConfig(adapterId)?.access?.allowed_users ?? []).length > 0;
|
|
2212
|
+
}
|
|
2213
|
+
/**
|
|
2214
|
+
* The one question the privileged commands (/update, /doctor, /dashboard, /collab) ask: may THIS caller,
|
|
2215
|
+
* through THIS adapter, run it. `disabled` is "nobody can" — an empty list — and is told apart from
|
|
2216
|
+
* `denied` only so the reply can say so. An empty list never means "everyone": these commands used to read
|
|
2217
|
+
* the primary channel's list and treat empty as open, so on a fleet that had not set one anyone who could
|
|
2218
|
+
* type a slash command could `/update` the host.
|
|
2219
|
+
*/
|
|
2220
|
+
fleetAdminGate(userId, adapterId) {
|
|
2221
|
+
if (!this.hasFleetAdmins(adapterId))
|
|
2222
|
+
return "disabled";
|
|
2223
|
+
return this.isFleetAdmin(userId, adapterId) ? "ok" : "denied";
|
|
2224
|
+
}
|
|
2225
|
+
/**
|
|
2226
|
+
* Every Discord slash command, for every adapter, in one place (it used to be two copies that could drift).
|
|
2227
|
+
*
|
|
2228
|
+
* Two checks before any command's own code runs, and the order matters: the DOOR (src/slash-authz.ts — may
|
|
2229
|
+
* this caller speak here at all) and then the COMMAND TABLE (src/command-table.ts — does this command apply
|
|
2230
|
+
* in this kind of channel, and which kind of admin does it need). The table can only narrow what the door
|
|
2231
|
+
* let through. A command nobody registered is answered, not left to time out.
|
|
2232
|
+
*/
|
|
2233
|
+
async dispatchSlash(data, adapterId, adapter) {
|
|
2234
|
+
const scope = await this.authorizeSlash(data, adapterId);
|
|
2235
|
+
if (!scope)
|
|
2236
|
+
return;
|
|
2237
|
+
const spec = commandSpec(data.command);
|
|
2238
|
+
if (!spec) {
|
|
2239
|
+
this.logger.info({ command: data.command, adapterId }, "Slash command is not in the command table");
|
|
2240
|
+
await data.respond(t("slash.unknown_command")).catch(() => { });
|
|
2241
|
+
return;
|
|
2242
|
+
}
|
|
2243
|
+
const decision = decideCommand(spec, scope, {
|
|
2244
|
+
fleetAdmin: () => this.fleetAdminGate(data.userId, adapterId),
|
|
2245
|
+
channelAdmin: () => this.isModelAdmin(data.userId, data.channelId, adapterId),
|
|
2246
|
+
classicAdmin: () => !!this.classicChannels?.isAdmin(data.userId),
|
|
2247
|
+
});
|
|
2248
|
+
if (!decision.allow) {
|
|
2249
|
+
this.logger.info({ command: data.command, adapterId, scope, reply: decision.reply[0] }, "Slash command refused by the command table");
|
|
2250
|
+
const [key, ...args] = decision.reply;
|
|
2251
|
+
await data.respond(t(key, ...args)).catch(() => { });
|
|
2252
|
+
return;
|
|
2253
|
+
}
|
|
2254
|
+
if (data.command === "start") {
|
|
2255
|
+
await this.handleClassicStartSlash(data, adapterId);
|
|
2256
|
+
}
|
|
2257
|
+
else if (data.command === "stop") {
|
|
2258
|
+
const reply = await this.handleClassicStop(data.channelId, adapterId);
|
|
2259
|
+
await data.respond(reply);
|
|
2260
|
+
}
|
|
2261
|
+
else if (data.command === "pause" || data.command === "wake") {
|
|
2262
|
+
await this.handlePauseWakeSlash(data, adapterId);
|
|
2263
|
+
}
|
|
2264
|
+
else if (data.command === "chat") {
|
|
2265
|
+
const text = data.text ?? "";
|
|
2266
|
+
if (!text) {
|
|
2267
|
+
await data.respond(t("chat.usage"));
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
2270
|
+
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
2271
|
+
if (!name) {
|
|
2272
|
+
await data.respond(t("classic.no_agent_start"));
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
const status = this.resolveStatusEmojisFor(name, adapterId, adapter);
|
|
2276
|
+
const replyMsgId = await data.respond(textForm(status.platform, status.received));
|
|
2277
|
+
const username = data.username ?? data.userId;
|
|
2278
|
+
ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
|
|
2279
|
+
await this.forwardToClassicInstance(name, text, {
|
|
2280
|
+
chatId: data.channelId,
|
|
2281
|
+
threadId: data.channelId,
|
|
2282
|
+
messageId: replyMsgId ?? "",
|
|
2283
|
+
userId: data.userId,
|
|
2284
|
+
username,
|
|
2285
|
+
source: "discord",
|
|
2286
|
+
timestamp: new Date(),
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
else if (data.command === "save") {
|
|
2290
|
+
await this.handleSlashSave(data, adapterId);
|
|
2291
|
+
}
|
|
2292
|
+
else if (data.command === "load") {
|
|
2293
|
+
// load is kiro-cli/classic only — no claude-code equivalent. (Classic admins only: the command table.)
|
|
2294
|
+
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
2295
|
+
if (!name) {
|
|
2296
|
+
await data.respond(t("classic.no_agent_start"));
|
|
2297
|
+
return;
|
|
2298
|
+
}
|
|
2299
|
+
const filename = data.options?.filename;
|
|
2300
|
+
if (!SAVE_FILENAME_RE.test(filename ?? "")) {
|
|
2301
|
+
await data.respond(t("filename.invalid"));
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
|
|
2305
|
+
await data.respond(t("save.sent", `/chat load ${filename}`, name));
|
|
2306
|
+
}
|
|
2307
|
+
else if (data.command === "model") {
|
|
2308
|
+
await this.handleModelSlash(data, adapterId);
|
|
2309
|
+
}
|
|
2310
|
+
else if (data.command === "clear") {
|
|
2311
|
+
await this.handleClearSlash(data, adapterId);
|
|
2312
|
+
}
|
|
2313
|
+
else if (data.command === "effort") {
|
|
2314
|
+
await this.handleEffortSlash(data, adapterId);
|
|
2315
|
+
}
|
|
2316
|
+
else if (data.command === "cancel") {
|
|
2317
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2318
|
+
if (!name) {
|
|
2319
|
+
await data.respond(t("classic.no_agent"));
|
|
2320
|
+
return;
|
|
2321
|
+
}
|
|
2322
|
+
const ok = this.cancelInstance(name);
|
|
2323
|
+
await data.respond(ok ? t("cancel.sent", name) : t("cancel.not_running", name));
|
|
2324
|
+
}
|
|
2325
|
+
else if (data.command === "ctx") {
|
|
2326
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2327
|
+
if (!name) {
|
|
2328
|
+
await data.respond(t("classic.no_agent"));
|
|
2329
|
+
return;
|
|
2330
|
+
}
|
|
2331
|
+
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
2332
|
+
await data.respond(await this.topicCommands.getCtxText(name));
|
|
2333
|
+
}
|
|
2334
|
+
else if (data.command === "collab") {
|
|
2335
|
+
// The scope the door and the table authorized is the scope this acts in. A channel can be in both the
|
|
2336
|
+
// ClassicBot registry and the routing table (two configurations that each validate); the door and
|
|
2337
|
+
// `isModelAdmin` read the registry first, so a ClassicBot admin was judged for the ClassicBot channel
|
|
2338
|
+
// here and must not be handed the fleet instance's collab switch by a second, routing-first lookup.
|
|
2339
|
+
const collabTarget2 = scope === "classic" ? undefined : this.routing.resolve(data.channelId);
|
|
2340
|
+
if (collabTarget2) {
|
|
2341
|
+
const isCollab = this.toggleFleetCollab(collabTarget2.name);
|
|
2342
|
+
await data.respond(isCollab ? t("collab.on") : t("collab.off"));
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
if (!this.classicChannels?.isClassicChannel(data.channelId, adapterId)) {
|
|
2346
|
+
await data.respond(t("classic.no_agent_start"));
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
|
|
2350
|
+
await data.respond(newState
|
|
2351
|
+
? t("collab.on.classic")
|
|
2352
|
+
: t("collab.off.classic"));
|
|
2353
|
+
}
|
|
2354
|
+
else if (data.command === "update") {
|
|
2355
|
+
await this.handleUpdateSlash(data, adapterId);
|
|
2356
|
+
}
|
|
2357
|
+
else if (data.command === "doctor") {
|
|
2358
|
+
await data.respond(await this.runBackendDoctor());
|
|
2359
|
+
}
|
|
2360
|
+
else if (data.command === "usage") {
|
|
2361
|
+
// Same permission level as /ctx (none). The reply is still ephemeral —
|
|
2362
|
+
// the adapter defers non-chat commands that way — so it never spams the
|
|
2363
|
+
// channel either way.
|
|
2364
|
+
try {
|
|
2365
|
+
const { getUsageSnapshot } = await import("./usage/usage-api.js");
|
|
2366
|
+
const { renderUsageMarkdown } = await import("./usage/format-rich.js");
|
|
2367
|
+
// slash_command is Discord-only; editReply renders Markdown natively.
|
|
2368
|
+
await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
|
|
2369
|
+
}
|
|
2370
|
+
catch (err) {
|
|
2371
|
+
await data.respond(t("usage.failed", err.message));
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
else if (data.command === "tips") {
|
|
2375
|
+
await this.handleTipsSlash(data, adapterId);
|
|
2376
|
+
}
|
|
2377
|
+
else if (data.command === "status") {
|
|
2378
|
+
// Admin-gated (like the topic path): the merged table shows every
|
|
2379
|
+
// instance's cost and IPC health.
|
|
2380
|
+
if (!this.isFleetAdmin(data.userId, adapterId)) {
|
|
2381
|
+
await data.respond(t("cmd.admin_required", "/status"));
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
const text = await this.topicCommands.getStatusText();
|
|
2385
|
+
await data.respond(text);
|
|
2386
|
+
}
|
|
2387
|
+
else if (data.command === "sysinfo") {
|
|
2388
|
+
// Slash commands are Discord-only; use plain lines (no markdown table)
|
|
2389
|
+
await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
|
|
2390
|
+
}
|
|
2391
|
+
else if (data.command === "dashboard") {
|
|
2392
|
+
// Reply is ephemeral (adapter defers non-chat commands ephemerally), so
|
|
2393
|
+
// the web-token-bearing URLs are only visible to the caller.
|
|
2394
|
+
await data.respond(this.topicCommands.getDashboardText());
|
|
2395
|
+
}
|
|
2396
|
+
else if (data.command === "restart") {
|
|
2397
|
+
await this.handleRestartSlash(data, adapterId);
|
|
2398
|
+
}
|
|
2399
|
+
else if (data.command === "compact") {
|
|
2400
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2401
|
+
if (!name) {
|
|
2402
|
+
await data.respond(t("classic.no_agent"));
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
2405
|
+
const result = await this.topicCommands.sendCompact(name, String(data.options?.instructions ?? ""));
|
|
2406
|
+
await data.respond(result);
|
|
2407
|
+
}
|
|
2408
|
+
else if (data.command === "steer") {
|
|
2409
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2410
|
+
if (!name) {
|
|
2411
|
+
await data.respond(t("classic.no_agent"));
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
const steerText = String(data.options?.message ?? "").trim();
|
|
2415
|
+
if (!steerText) {
|
|
2416
|
+
await data.respond(t("steer.usage"));
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
// chat_id/message_id stay empty: a slash interaction has no channel
|
|
2420
|
+
// message to react to, and an empty chat_id keeps updateLastChat from
|
|
2421
|
+
// rerouting the instance's replies to the slash context.
|
|
2422
|
+
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
2423
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2424
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2425
|
+
});
|
|
2426
|
+
await data.respond(result);
|
|
2427
|
+
}
|
|
2428
|
+
else if (data.command === "btw") {
|
|
2429
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2430
|
+
if (!name) {
|
|
2431
|
+
await data.respond(t("classic.no_agent"));
|
|
2432
|
+
return;
|
|
2433
|
+
}
|
|
2434
|
+
const btwText = String(data.options?.message ?? "").trim();
|
|
2435
|
+
if (!btwText) {
|
|
2436
|
+
await data.respond(t("btw.usage"));
|
|
2437
|
+
return;
|
|
2438
|
+
}
|
|
2439
|
+
const result = this.topicCommands.sendBtw(name, btwText, {
|
|
2440
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2441
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2442
|
+
});
|
|
2443
|
+
await data.respond(result);
|
|
2444
|
+
}
|
|
2445
|
+
else if (data.command === "login") {
|
|
2446
|
+
await this.handleLoginSlash(data, adapterId, adapter);
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
/**
|
|
2450
|
+
* The door every Discord slash command goes through (src/slash-authz.ts has the rule and the reasoning).
|
|
2451
|
+
* Answers the caller itself when it refuses, so a refused command costs one reply and does nothing else.
|
|
2452
|
+
*/
|
|
2453
|
+
async authorizeSlash(data, adapterId) {
|
|
2454
|
+
const channelId = data.channelId;
|
|
2455
|
+
const classic = !!this.classicChannels?.isClassicChannel(channelId, adapterId);
|
|
2456
|
+
const fleetTarget = classic ? undefined : this.routing.resolve(channelId);
|
|
2457
|
+
const scope = classic ? "classic" : fleetTarget ? "fleet" : "none";
|
|
2458
|
+
// The table tells the General dispatcher from an instance's own channel; the door does not need to.
|
|
2459
|
+
const commandScope = classic ? "classic" : fleetTarget ? (fleetTarget.kind === "general" ? "general" : "fleet") : "none";
|
|
2460
|
+
let speaker = "denied";
|
|
2461
|
+
if (scope !== "classic") {
|
|
2462
|
+
// The same world the typed-message path would consult: the adapter that owns the channel's instance,
|
|
2463
|
+
// else the one the command arrived on.
|
|
2464
|
+
const ownerId = fleetTarget ? this.getInstanceAdapterId(fleetTarget.name) : undefined;
|
|
2465
|
+
const ownerWorld = ownerId ? this.worlds.get(ownerId) : undefined;
|
|
2466
|
+
if (ownerId && !ownerWorld) {
|
|
2467
|
+
speaker = "owner-not-running";
|
|
2468
|
+
}
|
|
2469
|
+
else {
|
|
2470
|
+
const am = ownerWorld?.accessManager ?? this.worlds.get(adapterId)?.accessManager ?? this.accessManager;
|
|
2471
|
+
// An explicit fleet admin always speaks, even if the access state file disagrees with the config.
|
|
2472
|
+
speaker = this.isFleetAdmin(data.userId, adapterId) || (am?.isAllowed(data.userId) ?? false) ? "allowed" : "denied";
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
const facts = {
|
|
2476
|
+
command: data.command,
|
|
2477
|
+
guildId: data.guildId,
|
|
2478
|
+
primaryGuildId: String(this.getChannelConfig(adapterId)?.group_id ?? ""),
|
|
2479
|
+
scope,
|
|
2480
|
+
speaker,
|
|
2481
|
+
};
|
|
2482
|
+
const decision = decideSlash(facts);
|
|
2483
|
+
if (decision.allow)
|
|
2484
|
+
return commandScope;
|
|
2485
|
+
this.logger.info({ command: data.command, reason: decision.reason, adapterId, guildId: data.guildId ?? null, channelId, scope }, "Slash command refused");
|
|
2486
|
+
await data.respond(t(decision.reason === "dm" ? "slash.dm_unsupported"
|
|
2487
|
+
: decision.reason === "wrong-guild" ? "slash.wrong_server"
|
|
2488
|
+
: "not_authorized")).catch(() => { });
|
|
2489
|
+
return null;
|
|
2490
|
+
}
|
|
2105
2491
|
/** Phase 2: delivery_worker for a target (instance override → fleet default → wake_only). */
|
|
2106
2492
|
deliveryWorkerMode(target) {
|
|
2107
2493
|
return resolveDeliveryWorkerMode(this.fleetConfig, target);
|
|
@@ -2334,6 +2720,10 @@ export class FleetManager {
|
|
|
2334
2720
|
resumePaused = false,
|
|
2335
2721
|
/** Phase 2a: the caller's transition, for a start made from inside a restart or wake. */
|
|
2336
2722
|
transition) {
|
|
2723
|
+
// CLI single-instance cold starts bypass startAll. Start diagnostics before
|
|
2724
|
+
// any lifecycle/wake work; a shutdown must not revive the stopped sampler.
|
|
2725
|
+
if (!this.shuttingDown)
|
|
2726
|
+
this.memoryPressure?.start();
|
|
2337
2727
|
// Any start supersedes a pending automatic retry (it would otherwise fire
|
|
2338
2728
|
// into a running instance — harmless, but noisy — or race this start).
|
|
2339
2729
|
this.cancelStartupRetry(name);
|
|
@@ -2448,6 +2838,7 @@ export class FleetManager {
|
|
|
2448
2838
|
instanceName: name,
|
|
2449
2839
|
workingDirectory: config.working_directory,
|
|
2450
2840
|
reason: "startup",
|
|
2841
|
+
stage: "lifecycle",
|
|
2451
2842
|
}, async () => {
|
|
2452
2843
|
if (await this.startInstanceUnattended(name, config, topicMode, "instance"))
|
|
2453
2844
|
onReady?.(name);
|
|
@@ -2587,6 +2978,7 @@ export class FleetManager {
|
|
|
2587
2978
|
instanceName: name,
|
|
2588
2979
|
workingDirectory: this.fleetConfig?.instances[name]?.working_directory || this.getInstanceDir(name),
|
|
2589
2980
|
reason: "recovery",
|
|
2981
|
+
stage: "lifecycle",
|
|
2590
2982
|
}, () => this.startConfiguredInstance(name, topicMode));
|
|
2591
2983
|
if (this.daemons.has(name)) {
|
|
2592
2984
|
this.logger.info({ name, attempt: attempt + 1 }, "Automatic startup retry succeeded");
|
|
@@ -2704,8 +3096,58 @@ export class FleetManager {
|
|
|
2704
3096
|
return false;
|
|
2705
3097
|
this.logger.error({ name, reason: err.message }, `Not starting ${what}: the installed CLI cannot run it as configured`);
|
|
2706
3098
|
this.queueUnsupportedCliNotice(name, err.message);
|
|
3099
|
+
this.queueKiroIncompatNotice(name, err.message);
|
|
2707
3100
|
return true;
|
|
2708
3101
|
}
|
|
3102
|
+
/**
|
|
3103
|
+
* LifecycleContext: a kiro instance the installed kiro-cli cannot run as
|
|
3104
|
+
* configured (#1109) — at start, or at a respawn after kiro-cli replaced
|
|
3105
|
+
* itself. The operator already gets the plain notice; this tells every
|
|
3106
|
+
* General, as an agent, so it can explain the kiro engine move when asked.
|
|
3107
|
+
* One notice per refusal reason, like the operator's.
|
|
3108
|
+
*/
|
|
3109
|
+
queueKiroIncompatNotice(name, reason) {
|
|
3110
|
+
// The effective backend, ClassicBot channels included: they are not in fleetConfig.instances.
|
|
3111
|
+
if (this.backendNameForInstance(name) !== "kiro-cli")
|
|
3112
|
+
return;
|
|
3113
|
+
const pending = this.kiroIncompatNotices.get(reason);
|
|
3114
|
+
if (pending) {
|
|
3115
|
+
if (!pending.names.includes(name))
|
|
3116
|
+
pending.names.push(name);
|
|
3117
|
+
return;
|
|
3118
|
+
}
|
|
3119
|
+
const timer = setTimeout(() => {
|
|
3120
|
+
const entry = this.kiroIncompatNotices.get(reason);
|
|
3121
|
+
this.kiroIncompatNotices.delete(reason);
|
|
3122
|
+
if (entry)
|
|
3123
|
+
this.sendKiroIncompatToGenerals(entry.names, reason);
|
|
3124
|
+
}, FleetManager.STARTUP_RETRY_NOTICE_AGGREGATE_MS);
|
|
3125
|
+
timer.unref?.();
|
|
3126
|
+
this.kiroIncompatNotices.set(reason, { names: [name], timer });
|
|
3127
|
+
}
|
|
3128
|
+
/**
|
|
3129
|
+
* Held in the delivery outbox until each General takes it: the same kiro-cli
|
|
3130
|
+
* may have stopped the General too, and it should still hear once it is back.
|
|
3131
|
+
* Admitted once per day per refusal (reason + names) and General.
|
|
3132
|
+
*/
|
|
3133
|
+
sendKiroIncompatToGenerals(names, reason, now = new Date()) {
|
|
3134
|
+
const generals = Object.entries(this.fleetConfig?.instances ?? {})
|
|
3135
|
+
.filter(([, config]) => config.general_topic === true).map(([general]) => general);
|
|
3136
|
+
if (generals.length === 0)
|
|
3137
|
+
return;
|
|
3138
|
+
try {
|
|
3139
|
+
this.ensureDeliveryOutbox();
|
|
3140
|
+
const sorted = [...names].sort();
|
|
3141
|
+
const digest = createHash("sha256").update(`${reason}\u0000${sorted.join(",")}`).digest("hex").slice(0, 16);
|
|
3142
|
+
const content = t("fleet.kiro_incompat_general", sorted.join(", "), reason);
|
|
3143
|
+
for (const general of generals) {
|
|
3144
|
+
this.deliveryOutbox.admitSystemNotice(general, `kiro-incompat:${now.toISOString().slice(0, 10)}:${digest}:${general}`, content, now);
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
catch (err) {
|
|
3148
|
+
this.logger.warn({ err, names }, "Could not queue the kiro incompatibility notice for General");
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
2709
3151
|
/**
|
|
2710
3152
|
* One notice per refusal reason: a kiro-cli that dropped the legacy UI
|
|
2711
3153
|
* refuses every kiro instance at once, and that is one message, not ten.
|
|
@@ -2879,6 +3321,7 @@ export class FleetManager {
|
|
|
2879
3321
|
instanceName: name,
|
|
2880
3322
|
workingDirectory,
|
|
2881
3323
|
reason: "restart",
|
|
3324
|
+
stage: "lifecycle",
|
|
2882
3325
|
}, () => this.doRestartSingleInstance(name, opts, transition)))
|
|
2883
3326
|
.finally(() => {
|
|
2884
3327
|
this.restartsInFlight.delete(name);
|
|
@@ -3254,6 +3697,9 @@ export class FleetManager {
|
|
|
3254
3697
|
setUpdateProgressStage(this.dataDir, "starting", { version: pendingUpdateProgress.progress.version });
|
|
3255
3698
|
}
|
|
3256
3699
|
this.initializeWebAuthTokens();
|
|
3700
|
+
// Must run before the first General/CLI spawn: pressure can hold startup.
|
|
3701
|
+
// Logs remain available even before the adapters and health listener exist.
|
|
3702
|
+
this.memoryPressure.start();
|
|
3257
3703
|
const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
|
|
3258
3704
|
// Set tmux socket isolation for custom AGEND_HOME
|
|
3259
3705
|
const { getTmuxSocketName: getSocket } = await import("./paths.js");
|
|
@@ -3696,27 +4142,10 @@ export class FleetManager {
|
|
|
3696
4142
|
this.checkForUpdates();
|
|
3697
4143
|
this.updateCheckTimer = setInterval(() => this.checkForUpdates(), 24 * 60 * 60 * 1000);
|
|
3698
4144
|
}, 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
|
-
};
|
|
4145
|
+
const onRestart = () => this.onGracefulRestartSignal(() => process.once("SIGUSR2", onRestart));
|
|
3705
4146
|
process.once("SIGUSR2", onRestart);
|
|
3706
4147
|
// 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);
|
|
4148
|
+
process.once("SIGUSR1", () => this.onFullRestartSignal());
|
|
3720
4149
|
// A SIGHUP may arrive after the PID/general is available but before the
|
|
3721
4150
|
// rest of startup finishes. Replay one coalesced reload only after all
|
|
3722
4151
|
// startup-owned lifecycle work and signal handlers are in place.
|
|
@@ -4002,6 +4431,10 @@ export class FleetManager {
|
|
|
4002
4431
|
connected++;
|
|
4003
4432
|
}
|
|
4004
4433
|
const problems = [];
|
|
4434
|
+
const hostMemory = this.memoryPressure.snapshot();
|
|
4435
|
+
if (hostMemory.level === "critical" || hostMemory.level === "elevated") {
|
|
4436
|
+
problems.push(`host memory pressure is ${hostMemory.level}`);
|
|
4437
|
+
}
|
|
4005
4438
|
if (this.adapterState.size > 0 && connected === 0)
|
|
4006
4439
|
problems.push("no channel adapter is connected");
|
|
4007
4440
|
if (counts.crashed > 0)
|
|
@@ -4024,6 +4457,7 @@ export class FleetManager {
|
|
|
4024
4457
|
adapters: { total: this.adapterState.size, connected, states, details },
|
|
4025
4458
|
startupComplete: this.startupComplete,
|
|
4026
4459
|
memory: readFleetMemory(),
|
|
4460
|
+
hostMemory,
|
|
4027
4461
|
problems,
|
|
4028
4462
|
};
|
|
4029
4463
|
}
|
|
@@ -4074,273 +4508,7 @@ export class FleetManager {
|
|
|
4074
4508
|
this.adapter.on("slash_command", safeHandler(async (data) => {
|
|
4075
4509
|
if (!isCurrentAdapter())
|
|
4076
4510
|
return;
|
|
4077
|
-
|
|
4078
|
-
await this.handleClassicStartSlash(data, adapterId);
|
|
4079
|
-
}
|
|
4080
|
-
else if (data.command === "stop") {
|
|
4081
|
-
const reply = await this.handleClassicStop(data.channelId, adapterId);
|
|
4082
|
-
await data.respond(reply);
|
|
4083
|
-
}
|
|
4084
|
-
else if (data.command === "pause" || data.command === "wake") {
|
|
4085
|
-
await this.handlePauseWakeSlash(data, adapterId);
|
|
4086
|
-
}
|
|
4087
|
-
else if (data.command === "chat") {
|
|
4088
|
-
const text = data.text ?? "";
|
|
4089
|
-
if (!text) {
|
|
4090
|
-
await data.respond(t("chat.usage"));
|
|
4091
|
-
return;
|
|
4092
|
-
}
|
|
4093
|
-
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
4094
|
-
if (!name) {
|
|
4095
|
-
await data.respond(t("classic.no_agent_start"));
|
|
4096
|
-
return;
|
|
4097
|
-
}
|
|
4098
|
-
const replyMsgId = await data.respond("👀");
|
|
4099
|
-
const username = data.username ?? data.userId;
|
|
4100
|
-
ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
|
|
4101
|
-
await this.forwardToClassicInstance(name, text, {
|
|
4102
|
-
chatId: data.channelId,
|
|
4103
|
-
threadId: data.channelId,
|
|
4104
|
-
messageId: replyMsgId ?? "",
|
|
4105
|
-
userId: data.userId,
|
|
4106
|
-
username,
|
|
4107
|
-
source: "discord",
|
|
4108
|
-
timestamp: new Date(),
|
|
4109
|
-
});
|
|
4110
|
-
}
|
|
4111
|
-
else if (data.command === "save") {
|
|
4112
|
-
await this.handleSlashSave(data, adapterId);
|
|
4113
|
-
}
|
|
4114
|
-
else if (data.command === "load") {
|
|
4115
|
-
// load is kiro-cli/classic only — no claude-code equivalent.
|
|
4116
|
-
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
4117
|
-
await data.respond(t("admin.required"));
|
|
4118
|
-
return;
|
|
4119
|
-
}
|
|
4120
|
-
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
4121
|
-
if (!name) {
|
|
4122
|
-
await data.respond(t("classic.no_agent_start"));
|
|
4123
|
-
return;
|
|
4124
|
-
}
|
|
4125
|
-
const filename = data.options?.filename;
|
|
4126
|
-
if (!SAVE_FILENAME_RE.test(filename ?? "")) {
|
|
4127
|
-
await data.respond(t("filename.invalid"));
|
|
4128
|
-
return;
|
|
4129
|
-
}
|
|
4130
|
-
this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
|
|
4131
|
-
await data.respond(t("save.sent", `/chat load ${filename}`, name));
|
|
4132
|
-
}
|
|
4133
|
-
else if (data.command === "compact") {
|
|
4134
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4135
|
-
if (!name) {
|
|
4136
|
-
await data.respond(t("classic.no_agent"));
|
|
4137
|
-
return;
|
|
4138
|
-
}
|
|
4139
|
-
const result = await this.topicCommands.sendCompact(name);
|
|
4140
|
-
await data.respond(result);
|
|
4141
|
-
}
|
|
4142
|
-
else if (data.command === "steer") {
|
|
4143
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4144
|
-
if (!name) {
|
|
4145
|
-
await data.respond(t("classic.no_agent"));
|
|
4146
|
-
return;
|
|
4147
|
-
}
|
|
4148
|
-
const steerText = String(data.options?.message ?? "").trim();
|
|
4149
|
-
if (!steerText) {
|
|
4150
|
-
await data.respond(t("steer.usage"));
|
|
4151
|
-
return;
|
|
4152
|
-
}
|
|
4153
|
-
// chat_id/message_id stay empty: a slash interaction has no channel
|
|
4154
|
-
// message to react to, and an empty chat_id keeps updateLastChat from
|
|
4155
|
-
// rerouting the instance's replies to the slash context.
|
|
4156
|
-
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
4157
|
-
chatId: "", messageId: "", username: data.username ?? "user",
|
|
4158
|
-
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
4159
|
-
});
|
|
4160
|
-
await data.respond(result);
|
|
4161
|
-
}
|
|
4162
|
-
else if (data.command === "btw") {
|
|
4163
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4164
|
-
if (!name) {
|
|
4165
|
-
await data.respond(t("classic.no_agent"));
|
|
4166
|
-
return;
|
|
4167
|
-
}
|
|
4168
|
-
const btwText = String(data.options?.message ?? "").trim();
|
|
4169
|
-
if (!btwText) {
|
|
4170
|
-
await data.respond(t("btw.usage"));
|
|
4171
|
-
return;
|
|
4172
|
-
}
|
|
4173
|
-
const result = this.topicCommands.sendBtw(name, btwText, {
|
|
4174
|
-
chatId: "", messageId: "", username: data.username ?? "user",
|
|
4175
|
-
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
4176
|
-
});
|
|
4177
|
-
await data.respond(result);
|
|
4178
|
-
}
|
|
4179
|
-
else if (data.command === "login") {
|
|
4180
|
-
await this.handleLoginSlash(data, adapterId, this.adapter);
|
|
4181
|
-
}
|
|
4182
|
-
else if (data.command === "clear") {
|
|
4183
|
-
await this.handleClearSlash(data, adapterId);
|
|
4184
|
-
}
|
|
4185
|
-
else if (data.command === "model") {
|
|
4186
|
-
await this.handleModelSlash(data, adapterId);
|
|
4187
|
-
}
|
|
4188
|
-
else if (data.command === "effort") {
|
|
4189
|
-
await this.handleEffortSlash(data, adapterId);
|
|
4190
|
-
}
|
|
4191
|
-
else if (data.command === "cancel") {
|
|
4192
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4193
|
-
if (!name) {
|
|
4194
|
-
await data.respond(t("classic.no_agent"));
|
|
4195
|
-
return;
|
|
4196
|
-
}
|
|
4197
|
-
const ok = this.cancelInstance(name);
|
|
4198
|
-
await data.respond(ok ? t("cancel.sent", name) : t("cancel.not_running", name));
|
|
4199
|
-
}
|
|
4200
|
-
else if (data.command === "ctx") {
|
|
4201
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4202
|
-
if (!name) {
|
|
4203
|
-
await data.respond(t("classic.no_agent"));
|
|
4204
|
-
return;
|
|
4205
|
-
}
|
|
4206
|
-
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
4207
|
-
await data.respond(await this.topicCommands.getCtxText(name));
|
|
4208
|
-
}
|
|
4209
|
-
else if (data.command === "collab") {
|
|
4210
|
-
// Classic no longer lives in the routing engine, so a routing hit here is
|
|
4211
|
-
// always a fleet-topic instance.
|
|
4212
|
-
const collabTarget = this.routing.resolve(data.channelId);
|
|
4213
|
-
if (collabTarget) {
|
|
4214
|
-
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
4215
|
-
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
4216
|
-
await data.respond(t("not_authorized"));
|
|
4217
|
-
return;
|
|
4218
|
-
}
|
|
4219
|
-
const isCollab = this.toggleFleetCollab(collabTarget.name);
|
|
4220
|
-
await data.respond(isCollab ? t("collab.on") : t("collab.off"));
|
|
4221
|
-
return;
|
|
4222
|
-
}
|
|
4223
|
-
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
4224
|
-
await data.respond(t("admin.required"));
|
|
4225
|
-
return;
|
|
4226
|
-
}
|
|
4227
|
-
if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
|
|
4228
|
-
await data.respond(t("classic.no_agent_start"));
|
|
4229
|
-
return;
|
|
4230
|
-
}
|
|
4231
|
-
const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
|
|
4232
|
-
await data.respond(newState
|
|
4233
|
-
? t("collab.on.classic")
|
|
4234
|
-
: t("collab.off.classic"));
|
|
4235
|
-
}
|
|
4236
|
-
else if (data.command === "update") {
|
|
4237
|
-
await this.handleUpdateSlash(data, adapterId);
|
|
4238
|
-
}
|
|
4239
|
-
else if (data.command === "doctor") {
|
|
4240
|
-
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
4241
|
-
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
4242
|
-
await data.respond(t("not_authorized"));
|
|
4243
|
-
return;
|
|
4244
|
-
}
|
|
4245
|
-
await data.respond(await this.runBackendDoctor());
|
|
4246
|
-
}
|
|
4247
|
-
else if (data.command === "usage") {
|
|
4248
|
-
// Same permission level as /ctx (none). The reply is still ephemeral —
|
|
4249
|
-
// the adapter defers non-chat commands that way — so it never spams the
|
|
4250
|
-
// channel either way.
|
|
4251
|
-
try {
|
|
4252
|
-
const { getUsageSnapshot } = await import("./usage/usage-api.js");
|
|
4253
|
-
const { renderUsageMarkdown } = await import("./usage/format-rich.js");
|
|
4254
|
-
// slash_command is Discord-only; editReply renders Markdown natively.
|
|
4255
|
-
await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
|
|
4256
|
-
}
|
|
4257
|
-
catch (err) {
|
|
4258
|
-
await data.respond(t("usage.failed", err.message));
|
|
4259
|
-
}
|
|
4260
|
-
}
|
|
4261
|
-
else if (data.command === "tips") {
|
|
4262
|
-
await this.handleTipsSlash(data, adapterId);
|
|
4263
|
-
}
|
|
4264
|
-
else if (data.command === "status") {
|
|
4265
|
-
// Admin-gated (like the topic path): the merged table shows every
|
|
4266
|
-
// instance's cost and IPC health.
|
|
4267
|
-
if (!this.isFleetAdmin(data.userId, adapterId)) {
|
|
4268
|
-
await data.respond(t("cmd.admin_required", "/status"));
|
|
4269
|
-
return;
|
|
4270
|
-
}
|
|
4271
|
-
const text = await this.topicCommands.getStatusText();
|
|
4272
|
-
await data.respond(text);
|
|
4273
|
-
}
|
|
4274
|
-
else if (data.command === "sysinfo") {
|
|
4275
|
-
// Slash commands are Discord-only; use plain lines (no markdown table)
|
|
4276
|
-
await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
|
|
4277
|
-
}
|
|
4278
|
-
else if (data.command === "dashboard") {
|
|
4279
|
-
// Reply is ephemeral (adapter defers non-chat commands ephemerally), so
|
|
4280
|
-
// the web-token-bearing URLs are only visible to the caller.
|
|
4281
|
-
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
4282
|
-
if (allowed.length === 0) {
|
|
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"));
|
|
4288
|
-
return;
|
|
4289
|
-
}
|
|
4290
|
-
await data.respond(this.topicCommands.getDashboardText());
|
|
4291
|
-
}
|
|
4292
|
-
else if (data.command === "restart") {
|
|
4293
|
-
await this.handleRestartSlash(data, adapterId);
|
|
4294
|
-
}
|
|
4295
|
-
else if (data.command === "compact") {
|
|
4296
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4297
|
-
if (!name) {
|
|
4298
|
-
await data.respond(t("classic.no_agent"));
|
|
4299
|
-
return;
|
|
4300
|
-
}
|
|
4301
|
-
const result = await this.topicCommands.sendCompact(name);
|
|
4302
|
-
await data.respond(result);
|
|
4303
|
-
}
|
|
4304
|
-
else if (data.command === "steer") {
|
|
4305
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4306
|
-
if (!name) {
|
|
4307
|
-
await data.respond(t("classic.no_agent"));
|
|
4308
|
-
return;
|
|
4309
|
-
}
|
|
4310
|
-
const steerText = String(data.options?.message ?? "").trim();
|
|
4311
|
-
if (!steerText) {
|
|
4312
|
-
await data.respond(t("steer.usage"));
|
|
4313
|
-
return;
|
|
4314
|
-
}
|
|
4315
|
-
// chat_id/message_id stay empty: a slash interaction has no channel
|
|
4316
|
-
// message to react to, and an empty chat_id keeps updateLastChat from
|
|
4317
|
-
// rerouting the instance's replies to the slash context.
|
|
4318
|
-
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
4319
|
-
chatId: "", messageId: "", username: data.username ?? "user",
|
|
4320
|
-
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
4321
|
-
});
|
|
4322
|
-
await data.respond(result);
|
|
4323
|
-
}
|
|
4324
|
-
else if (data.command === "btw") {
|
|
4325
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4326
|
-
if (!name) {
|
|
4327
|
-
await data.respond(t("classic.no_agent"));
|
|
4328
|
-
return;
|
|
4329
|
-
}
|
|
4330
|
-
const btwText = String(data.options?.message ?? "").trim();
|
|
4331
|
-
if (!btwText) {
|
|
4332
|
-
await data.respond(t("btw.usage"));
|
|
4333
|
-
return;
|
|
4334
|
-
}
|
|
4335
|
-
const result = this.topicCommands.sendBtw(name, btwText, {
|
|
4336
|
-
chatId: "", messageId: "", username: data.username ?? "user",
|
|
4337
|
-
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
4338
|
-
});
|
|
4339
|
-
await data.respond(result);
|
|
4340
|
-
}
|
|
4341
|
-
else if (data.command === "login") {
|
|
4342
|
-
await this.handleLoginSlash(data, adapterId, this.adapter);
|
|
4343
|
-
}
|
|
4511
|
+
await this.dispatchSlash(data, adapterId, adapter);
|
|
4344
4512
|
}, this.logger, "adapter.slash_command"));
|
|
4345
4513
|
await this.topicCommands.registerBotCommands().catch(e => this.logger.warn({ err: e }, "registerBotCommands failed (non-fatal)"));
|
|
4346
4514
|
// Background-probe each backend's CLI env (version/models) → cli-env cache.
|
|
@@ -4444,224 +4612,7 @@ export class FleetManager {
|
|
|
4444
4612
|
adapter.on("slash_command", safeHandler(async (data) => {
|
|
4445
4613
|
if (!isCurrentAdapter())
|
|
4446
4614
|
return;
|
|
4447
|
-
|
|
4448
|
-
await this.handleClassicStartSlash(data, adapterId);
|
|
4449
|
-
}
|
|
4450
|
-
else if (data.command === "stop") {
|
|
4451
|
-
const reply = await this.handleClassicStop(data.channelId, adapterId);
|
|
4452
|
-
await data.respond(reply);
|
|
4453
|
-
}
|
|
4454
|
-
else if (data.command === "pause" || data.command === "wake") {
|
|
4455
|
-
await this.handlePauseWakeSlash(data, adapterId);
|
|
4456
|
-
}
|
|
4457
|
-
else if (data.command === "chat") {
|
|
4458
|
-
const text = data.text ?? "";
|
|
4459
|
-
if (!text) {
|
|
4460
|
-
await data.respond(t("chat.usage"));
|
|
4461
|
-
return;
|
|
4462
|
-
}
|
|
4463
|
-
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
4464
|
-
if (!name) {
|
|
4465
|
-
await data.respond(t("classic.no_agent_start"));
|
|
4466
|
-
return;
|
|
4467
|
-
}
|
|
4468
|
-
const replyMsgId = await data.respond("👀");
|
|
4469
|
-
const username = data.username ?? data.userId;
|
|
4470
|
-
ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
|
|
4471
|
-
await this.forwardToClassicInstance(name, text, {
|
|
4472
|
-
chatId: data.channelId,
|
|
4473
|
-
threadId: data.channelId,
|
|
4474
|
-
messageId: replyMsgId ?? "",
|
|
4475
|
-
userId: data.userId,
|
|
4476
|
-
username,
|
|
4477
|
-
source: channelConfig.type,
|
|
4478
|
-
timestamp: new Date(),
|
|
4479
|
-
});
|
|
4480
|
-
}
|
|
4481
|
-
else if (data.command === "save") {
|
|
4482
|
-
await this.handleSlashSave(data, adapterId);
|
|
4483
|
-
}
|
|
4484
|
-
else if (data.command === "load") {
|
|
4485
|
-
// load is kiro-cli/classic only — no claude-code equivalent.
|
|
4486
|
-
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
4487
|
-
await data.respond(t("admin.required"));
|
|
4488
|
-
return;
|
|
4489
|
-
}
|
|
4490
|
-
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
4491
|
-
if (!name) {
|
|
4492
|
-
await data.respond(t("classic.no_agent_start"));
|
|
4493
|
-
return;
|
|
4494
|
-
}
|
|
4495
|
-
const filename = data.options?.filename;
|
|
4496
|
-
if (!SAVE_FILENAME_RE.test(filename ?? "")) {
|
|
4497
|
-
await data.respond(t("filename.invalid"));
|
|
4498
|
-
return;
|
|
4499
|
-
}
|
|
4500
|
-
this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
|
|
4501
|
-
await data.respond(t("save.sent", `/chat load ${filename}`, name));
|
|
4502
|
-
}
|
|
4503
|
-
else if (data.command === "model") {
|
|
4504
|
-
await this.handleModelSlash(data, adapterId);
|
|
4505
|
-
}
|
|
4506
|
-
else if (data.command === "clear") {
|
|
4507
|
-
await this.handleClearSlash(data, adapterId);
|
|
4508
|
-
}
|
|
4509
|
-
else if (data.command === "effort") {
|
|
4510
|
-
await this.handleEffortSlash(data, adapterId);
|
|
4511
|
-
}
|
|
4512
|
-
else if (data.command === "cancel") {
|
|
4513
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4514
|
-
if (!name) {
|
|
4515
|
-
await data.respond(t("classic.no_agent"));
|
|
4516
|
-
return;
|
|
4517
|
-
}
|
|
4518
|
-
const ok = this.cancelInstance(name);
|
|
4519
|
-
await data.respond(ok ? t("cancel.sent", name) : t("cancel.not_running", name));
|
|
4520
|
-
}
|
|
4521
|
-
else if (data.command === "ctx") {
|
|
4522
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4523
|
-
if (!name) {
|
|
4524
|
-
await data.respond(t("classic.no_agent"));
|
|
4525
|
-
return;
|
|
4526
|
-
}
|
|
4527
|
-
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
4528
|
-
await data.respond(await this.topicCommands.getCtxText(name));
|
|
4529
|
-
}
|
|
4530
|
-
else if (data.command === "collab") {
|
|
4531
|
-
// Classic no longer lives in the routing engine, so a routing hit here is
|
|
4532
|
-
// always a fleet-topic instance.
|
|
4533
|
-
const collabTarget2 = this.routing.resolve(data.channelId);
|
|
4534
|
-
if (collabTarget2) {
|
|
4535
|
-
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
4536
|
-
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
4537
|
-
await data.respond(t("not_authorized"));
|
|
4538
|
-
return;
|
|
4539
|
-
}
|
|
4540
|
-
const isCollab = this.toggleFleetCollab(collabTarget2.name);
|
|
4541
|
-
await data.respond(isCollab ? t("collab.on") : t("collab.off"));
|
|
4542
|
-
return;
|
|
4543
|
-
}
|
|
4544
|
-
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
4545
|
-
await data.respond(t("admin.required"));
|
|
4546
|
-
return;
|
|
4547
|
-
}
|
|
4548
|
-
if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
|
|
4549
|
-
await data.respond(t("classic.no_agent_start"));
|
|
4550
|
-
return;
|
|
4551
|
-
}
|
|
4552
|
-
const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
|
|
4553
|
-
await data.respond(newState
|
|
4554
|
-
? t("collab.on.classic")
|
|
4555
|
-
: t("collab.off.classic"));
|
|
4556
|
-
}
|
|
4557
|
-
else if (data.command === "update") {
|
|
4558
|
-
await this.handleUpdateSlash(data, adapterId);
|
|
4559
|
-
}
|
|
4560
|
-
else if (data.command === "doctor") {
|
|
4561
|
-
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
4562
|
-
if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
|
|
4563
|
-
await data.respond(t("not_authorized"));
|
|
4564
|
-
return;
|
|
4565
|
-
}
|
|
4566
|
-
await data.respond(await this.runBackendDoctor());
|
|
4567
|
-
}
|
|
4568
|
-
else if (data.command === "usage") {
|
|
4569
|
-
// Same permission level as /ctx (none). The reply is still ephemeral —
|
|
4570
|
-
// the adapter defers non-chat commands that way — so it never spams the
|
|
4571
|
-
// channel either way.
|
|
4572
|
-
try {
|
|
4573
|
-
const { getUsageSnapshot } = await import("./usage/usage-api.js");
|
|
4574
|
-
const { renderUsageMarkdown } = await import("./usage/format-rich.js");
|
|
4575
|
-
// slash_command is Discord-only; editReply renders Markdown natively.
|
|
4576
|
-
await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
|
|
4577
|
-
}
|
|
4578
|
-
catch (err) {
|
|
4579
|
-
await data.respond(t("usage.failed", err.message));
|
|
4580
|
-
}
|
|
4581
|
-
}
|
|
4582
|
-
else if (data.command === "tips") {
|
|
4583
|
-
await this.handleTipsSlash(data, adapterId);
|
|
4584
|
-
}
|
|
4585
|
-
else if (data.command === "status") {
|
|
4586
|
-
// Admin-gated (like the topic path): the merged table shows every
|
|
4587
|
-
// instance's cost and IPC health.
|
|
4588
|
-
if (!this.isFleetAdmin(data.userId, adapterId)) {
|
|
4589
|
-
await data.respond(t("cmd.admin_required", "/status"));
|
|
4590
|
-
return;
|
|
4591
|
-
}
|
|
4592
|
-
const text = await this.topicCommands.getStatusText();
|
|
4593
|
-
await data.respond(text);
|
|
4594
|
-
}
|
|
4595
|
-
else if (data.command === "sysinfo") {
|
|
4596
|
-
// Slash commands are Discord-only; use plain lines (no markdown table)
|
|
4597
|
-
await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
|
|
4598
|
-
}
|
|
4599
|
-
else if (data.command === "dashboard") {
|
|
4600
|
-
// Reply is ephemeral (adapter defers non-chat commands ephemerally), so
|
|
4601
|
-
// the web-token-bearing URLs are only visible to the caller.
|
|
4602
|
-
const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
|
|
4603
|
-
if (allowed.length === 0) {
|
|
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"));
|
|
4609
|
-
return;
|
|
4610
|
-
}
|
|
4611
|
-
await data.respond(this.topicCommands.getDashboardText());
|
|
4612
|
-
}
|
|
4613
|
-
else if (data.command === "restart") {
|
|
4614
|
-
await this.handleRestartSlash(data, adapterId);
|
|
4615
|
-
}
|
|
4616
|
-
else if (data.command === "compact") {
|
|
4617
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4618
|
-
if (!name) {
|
|
4619
|
-
await data.respond(t("classic.no_agent"));
|
|
4620
|
-
return;
|
|
4621
|
-
}
|
|
4622
|
-
const result = await this.topicCommands.sendCompact(name);
|
|
4623
|
-
await data.respond(result);
|
|
4624
|
-
}
|
|
4625
|
-
else if (data.command === "steer") {
|
|
4626
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4627
|
-
if (!name) {
|
|
4628
|
-
await data.respond(t("classic.no_agent"));
|
|
4629
|
-
return;
|
|
4630
|
-
}
|
|
4631
|
-
const steerText = String(data.options?.message ?? "").trim();
|
|
4632
|
-
if (!steerText) {
|
|
4633
|
-
await data.respond(t("steer.usage"));
|
|
4634
|
-
return;
|
|
4635
|
-
}
|
|
4636
|
-
// chat_id/message_id stay empty: a slash interaction has no channel
|
|
4637
|
-
// message to react to, and an empty chat_id keeps updateLastChat from
|
|
4638
|
-
// rerouting the instance's replies to the slash context.
|
|
4639
|
-
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
4640
|
-
chatId: "", messageId: "", username: data.username ?? "user",
|
|
4641
|
-
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
4642
|
-
});
|
|
4643
|
-
await data.respond(result);
|
|
4644
|
-
}
|
|
4645
|
-
else if (data.command === "btw") {
|
|
4646
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4647
|
-
if (!name) {
|
|
4648
|
-
await data.respond(t("classic.no_agent"));
|
|
4649
|
-
return;
|
|
4650
|
-
}
|
|
4651
|
-
const btwText = String(data.options?.message ?? "").trim();
|
|
4652
|
-
if (!btwText) {
|
|
4653
|
-
await data.respond(t("btw.usage"));
|
|
4654
|
-
return;
|
|
4655
|
-
}
|
|
4656
|
-
const result = this.topicCommands.sendBtw(name, btwText, {
|
|
4657
|
-
chatId: "", messageId: "", username: data.username ?? "user",
|
|
4658
|
-
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
4659
|
-
});
|
|
4660
|
-
await data.respond(result);
|
|
4661
|
-
}
|
|
4662
|
-
else if (data.command === "login") {
|
|
4663
|
-
await this.handleLoginSlash(data, adapterId, adapter);
|
|
4664
|
-
}
|
|
4615
|
+
await this.dispatchSlash(data, adapterId, adapter);
|
|
4665
4616
|
}, this.logger, `adapter[${adapterId}].slash_command`));
|
|
4666
4617
|
adapter.on("started", safeHandler((username, userId) => {
|
|
4667
4618
|
if (!isCurrentAdapter())
|
|
@@ -5273,7 +5224,14 @@ export class FleetManager {
|
|
|
5273
5224
|
// Key the dedup per-adapter there so a sibling bot's copy isn't dropped.
|
|
5274
5225
|
if (msg.messageId) {
|
|
5275
5226
|
const classicCid = threadId || msg.chatId;
|
|
5276
|
-
|
|
5227
|
+
// First /start has no registration yet. A sibling can receive the same
|
|
5228
|
+
// targeted command first, then reject its @suffix below; it must not
|
|
5229
|
+
// consume the intended bot's key. Use the same Classic candidate scope
|
|
5230
|
+
// as routing (including foreign forum topics normalized above, #1085).
|
|
5231
|
+
const isTelegramClassicCandidate = msg.source === "telegram" && !threadId
|
|
5232
|
+
&& msg.chatId !== String(this.getChannelConfig(msg.adapterId)?.group_id ?? "");
|
|
5233
|
+
const isClassicMsg = !!this.classicChannels
|
|
5234
|
+
&& (this.classicChannels.hasChannel(classicCid) || isTelegramClassicCandidate);
|
|
5277
5235
|
const dedupKey = isClassicMsg
|
|
5278
5236
|
? `${msg.source}:${msg.chatId}:${msg.messageId}:${msg.adapterId ?? ""}`
|
|
5279
5237
|
: `${msg.source}:${msg.chatId}:${msg.messageId}`;
|
|
@@ -5432,7 +5390,8 @@ export class FleetManager {
|
|
|
5432
5390
|
return;
|
|
5433
5391
|
}
|
|
5434
5392
|
// Handle /compact command (admin only)
|
|
5435
|
-
|
|
5393
|
+
const classicCompact = parseCompactCommand(text);
|
|
5394
|
+
if (classicCompact) {
|
|
5436
5395
|
if (!this.classicChannels.isAdmin(msg.userId)) {
|
|
5437
5396
|
await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/compact"));
|
|
5438
5397
|
return;
|
|
@@ -5442,7 +5401,7 @@ export class FleetManager {
|
|
|
5442
5401
|
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
5443
5402
|
return;
|
|
5444
5403
|
}
|
|
5445
|
-
const result = await this.topicCommands.sendCompact(compactName);
|
|
5404
|
+
const result = await this.topicCommands.sendCompact(compactName, classicCompact.instructions);
|
|
5446
5405
|
await msgAdapter?.sendText(chatId, result);
|
|
5447
5406
|
return;
|
|
5448
5407
|
}
|
|
@@ -7230,6 +7189,9 @@ export class FleetManager {
|
|
|
7230
7189
|
pendingCliEnvProbes = new Map();
|
|
7231
7190
|
/** Forced vendor catalog refreshes are separate flights, but sysinfo joins either flight. */
|
|
7232
7191
|
pendingVendorCliEnvProbes = new Map();
|
|
7192
|
+
pendingInstanceModelProbes = new Map();
|
|
7193
|
+
cliEnvProbePool = new ProbeWorkerPool();
|
|
7194
|
+
cliEnvProbeEpoch = 0;
|
|
7233
7195
|
/**
|
|
7234
7196
|
* The status emojis for an instance (#1005): its own `status_emojis`, then
|
|
7235
7197
|
* its channel's `options.status_emojis`, then the platform built-in. An
|
|
@@ -7558,10 +7520,18 @@ export class FleetManager {
|
|
|
7558
7520
|
return statusAvoidList(this.resolveStatusEmojisFor(instanceName));
|
|
7559
7521
|
}
|
|
7560
7522
|
/** The progress bubble's leading emoji, in the form the channel renders. */
|
|
7561
|
-
progressPrefixFor(instanceName) {
|
|
7523
|
+
progressPrefixFor(instanceName, elapsedMs = 0, minElapsedMs = this.progressMinElapsedMs()) {
|
|
7562
7524
|
const r = this.resolveStatusEmojisFor(instanceName);
|
|
7563
7525
|
const builtin = builtinStatusEmojis(r.platform).progress_prefix;
|
|
7564
|
-
|
|
7526
|
+
// Keep the built-in short/elapsed phases, but resolve their status values
|
|
7527
|
+
// too. A custom progress prefix takes precedence in both phases.
|
|
7528
|
+
// Telegram's built-in queued reaction is 👀; its elapsed *text* has always
|
|
7529
|
+
// used ⏳ (text is not limited to Telegram's reaction vocabulary).
|
|
7530
|
+
const queued = r.platform === "telegram" && r.queued === builtinStatusEmojis(r.platform).queued
|
|
7531
|
+
? builtinStatusEmojis(undefined).queued : r.queued;
|
|
7532
|
+
const value = r.progress_prefix !== builtin ? r.progress_prefix
|
|
7533
|
+
: elapsedMs < minElapsedMs ? r.processing : queued;
|
|
7534
|
+
return textForm(r.platform, value);
|
|
7565
7535
|
}
|
|
7566
7536
|
/**
|
|
7567
7537
|
* Is this inbound reaction one of the fleet's own delivery-status stamps?
|
|
@@ -8260,7 +8230,7 @@ export class FleetManager {
|
|
|
8260
8230
|
}
|
|
8261
8231
|
/**
|
|
8262
8232
|
* 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;
|
|
8233
|
+
* picker and its "sign in now?" prompt; #1139: the relay login's provider picker). Still on screen after an upgrade;
|
|
8264
8234
|
* a click gets the expired-prompt treatment — a private notice, the buttons
|
|
8265
8235
|
* collapsed — instead of nothing.
|
|
8266
8236
|
*/
|
|
@@ -8281,8 +8251,6 @@ export class FleetManager {
|
|
|
8281
8251
|
return true;
|
|
8282
8252
|
if (await this.handleClassicApproval(data, adapterId, adapter))
|
|
8283
8253
|
return true;
|
|
8284
|
-
if (await this.handleLoginMenuSelect(data, adapterId, adapter))
|
|
8285
|
-
return true;
|
|
8286
8254
|
if (this.handleRetiredPromptButton(data, adapterId, adapter))
|
|
8287
8255
|
return true;
|
|
8288
8256
|
if (await this.handleLoginConfirm(data, adapterId, adapter))
|
|
@@ -8858,10 +8826,17 @@ export class FleetManager {
|
|
|
8858
8826
|
* unsupported backends get a clear error. Routes via classic paste or fleet IPC.
|
|
8859
8827
|
*/
|
|
8860
8828
|
async handleSlashSave(data, adapterId) {
|
|
8861
|
-
|
|
8829
|
+
// The admin of the channel's own kind (a fleet admin in a fleet channel). It used to ask for a ClassicBot
|
|
8830
|
+
// admin everywhere, so a fleet admin was refused in their own channel and a ClassicBot admin could paste
|
|
8831
|
+
// into a fleet instance.
|
|
8832
|
+
if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
|
|
8862
8833
|
await data.respond(t("admin.required"));
|
|
8863
8834
|
return;
|
|
8864
8835
|
}
|
|
8836
|
+
if (!this.classicChannels) {
|
|
8837
|
+
await data.respond(t("classic.no_agent_start"));
|
|
8838
|
+
return;
|
|
8839
|
+
}
|
|
8865
8840
|
// Classic resolves per-bot (same-channel multi-bot); otherwise a fleet topic.
|
|
8866
8841
|
const classicName = this.classicChannels.getInstanceByChannel(data.channelId, adapterId);
|
|
8867
8842
|
const target = classicName
|
|
@@ -8999,7 +8974,7 @@ export class FleetManager {
|
|
|
8999
8974
|
return;
|
|
9000
8975
|
}
|
|
9001
8976
|
const publication = this.beginCancelButtonPublication(instanceName, correlationId);
|
|
9002
|
-
const initialProgressText = FleetManager.progressText(0, null, 1, this.progressPrefixFor(instanceName));
|
|
8977
|
+
const initialProgressText = FleetManager.progressText(0, null, 1, this.progressPrefixFor(instanceName, 0, 1));
|
|
9003
8978
|
try {
|
|
9004
8979
|
const sent = await adapter.notifyAlert(chatId, {
|
|
9005
8980
|
type: "cancel",
|
|
@@ -9215,7 +9190,8 @@ export class FleetManager {
|
|
|
9215
9190
|
* wiped by the next elapsed tick (#528 trap 2).
|
|
9216
9191
|
*/
|
|
9217
9192
|
composeBubbleText(entry) {
|
|
9218
|
-
|
|
9193
|
+
const elapsedMs = Date.now() - (entry.startedAt ?? Date.now());
|
|
9194
|
+
return FleetManager.bubbleText(elapsedMs, this.instanceActivity.get(entry.instanceName), this.progressMinElapsedMs(), entry.toolProgress, this.progressPrefixFor(entry.instanceName, elapsedMs));
|
|
9219
9195
|
}
|
|
9220
9196
|
/** Pure composition of header + tool list, exposed for tests. */
|
|
9221
9197
|
static bubbleText(elapsedMs, activity, minElapsedMs, progress, prefix) {
|
|
@@ -9521,16 +9497,11 @@ export class FleetManager {
|
|
|
9521
9497
|
// shared ~/.codex; the other CLIs use one real home), so a single sign-in
|
|
9522
9498
|
// repairs every instance of that backend, and instance delivery, pane-state
|
|
9523
9499
|
// 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
|
-
*/
|
|
9500
|
+
/** Web-terminal login: the only sign-in path (the chat-relay mode was removed, #1139). */
|
|
9530
9501
|
loginController = null;
|
|
9531
9502
|
/**
|
|
9532
9503
|
* One login/install window fleet-wide. Claimed synchronously before the
|
|
9533
|
-
* first await by web login
|
|
9504
|
+
* first await by web login and install alike (sol B1).
|
|
9534
9505
|
*/
|
|
9535
9506
|
loginWindow = new LoginWindowLock();
|
|
9536
9507
|
get webLogin() {
|
|
@@ -9766,162 +9737,11 @@ export class FleetManager {
|
|
|
9766
9737
|
this.logger.debug({ err, backend, flow }, "Could not record the /login flow");
|
|
9767
9738
|
}
|
|
9768
9739
|
}
|
|
9769
|
-
/** The sign-in itself
|
|
9740
|
+
/** The sign-in itself, in the web terminal, for a CLI that is installed. */
|
|
9770
9741
|
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
|
-
}
|
|
9795
|
-
}
|
|
9796
|
-
async startRelayClaimed(flow, backend, chat, opts, claim, markTransferred) {
|
|
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);
|
|
9742
|
+
return this.webLogin.start(backendArg, chat, opts);
|
|
9923
9743
|
}
|
|
9924
|
-
/** Fleet shutdown: end any web
|
|
9744
|
+
/** Fleet shutdown: end any web login or install window and wait for its confirmed teardown. */
|
|
9925
9745
|
async shutdownLoginWindows() {
|
|
9926
9746
|
// Close the lock FIRST: in-flight starts parked in a pre-check or
|
|
9927
9747
|
// ensureSession observe !isCurrent when they resume and stop; no new
|
|
@@ -9956,19 +9776,9 @@ export class FleetManager {
|
|
|
9956
9776
|
// a stopping fleet must not announce "login failed — fleet shutdown".
|
|
9957
9777
|
await Promise.all([
|
|
9958
9778
|
bounded(this.loginController?.shutdown(), "web-login"),
|
|
9959
|
-
bounded(this.activeLogin?.session.cancel("cancelled"), "relay-login"),
|
|
9960
9779
|
bounded(this.activeInstall?.session.cancel("cancelled"), "install"),
|
|
9961
9780
|
]);
|
|
9962
9781
|
}
|
|
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
9782
|
/** `/login cancel` — abort the active session and remove its window. */
|
|
9973
9783
|
async cancelLoginSession() {
|
|
9974
9784
|
// `/login cancel` also stops an install that `/login` started — and the
|
|
@@ -9983,31 +9793,7 @@ export class FleetManager {
|
|
|
9983
9793
|
}
|
|
9984
9794
|
if (this.loginController?.isActive())
|
|
9985
9795
|
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
|
-
}
|
|
9796
|
+
return t("login.no_session");
|
|
10011
9797
|
}
|
|
10012
9798
|
/**
|
|
10013
9799
|
* After a successful login: wake the paused instances of that backend AND
|
|
@@ -10125,20 +9911,6 @@ export class FleetManager {
|
|
|
10125
9911
|
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, text);
|
|
10126
9912
|
return true;
|
|
10127
9913
|
}
|
|
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
9914
|
// ── Remote CLI install (run by `/login` for a CLI that is missing) ──────────────────────────────────
|
|
10143
9915
|
//
|
|
10144
9916
|
// Same dedicated-window model as /login (and the same LoginSession state
|
|
@@ -10156,7 +9928,7 @@ export class FleetManager {
|
|
|
10156
9928
|
return t("install.unsupported", backendArg);
|
|
10157
9929
|
if (checkBinaryInstalled(info.binary))
|
|
10158
9930
|
return t("install.already", backend, info.binary);
|
|
10159
|
-
// Reserve the fleet-wide window before the first await (shared with web
|
|
9931
|
+
// Reserve the fleet-wide window before the first await (shared with web
|
|
10160
9932
|
// login). Owned by this method until the session is published.
|
|
10161
9933
|
const claim = this.loginWindow.tryClaim("install", backend);
|
|
10162
9934
|
if (!claim)
|
|
@@ -10175,10 +9947,9 @@ export class FleetManager {
|
|
|
10175
9947
|
this.loginWindow.release(claim);
|
|
10176
9948
|
return t("install.failed", backend, err.message);
|
|
10177
9949
|
}
|
|
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.
|
|
9950
|
+
// A synthetic flow: success is decided by the installer's exit code
|
|
9951
|
+
// (LoginSession treats a clean exit as success), never by pane text. The
|
|
9952
|
+
// session only judges that verdict; it does not read the output for URLs or prompts.
|
|
10182
9953
|
const flow = {
|
|
10183
9954
|
backend,
|
|
10184
9955
|
command: info.install,
|
|
@@ -10186,9 +9957,6 @@ export class FleetManager {
|
|
|
10186
9957
|
timeoutMs: 10 * 60 * 1000,
|
|
10187
9958
|
};
|
|
10188
9959
|
const session = new LoginSession(flow, tmux, {
|
|
10189
|
-
onMenu: () => { },
|
|
10190
|
-
onAuthHint: () => { },
|
|
10191
|
-
onNeedInput: () => { },
|
|
10192
9960
|
onDone: async ({ ok, detail, cleanupFailed }) => {
|
|
10193
9961
|
this.activeInstall = null;
|
|
10194
9962
|
this.loginWindow.release(claim);
|
|
@@ -11145,7 +10913,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11145
10913
|
for (const backend of SYSINFO_BACKEND_IDS) {
|
|
11146
10914
|
if (!this.cliEnvNeedsRefresh(this.readCliEnv(backend)))
|
|
11147
10915
|
continue;
|
|
11148
|
-
void this.probeBackendBounded(backend
|
|
10916
|
+
void this.probeBackendBounded(backend).catch(err => {
|
|
11149
10917
|
this.logger.debug({ err, backend }, "Background CLI version refresh failed");
|
|
11150
10918
|
});
|
|
11151
10919
|
}
|
|
@@ -11168,19 +10936,26 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11168
10936
|
return vendorFlight;
|
|
11169
10937
|
if (!opts.refreshVendorCatalog && (vendorFlight || regularFlight))
|
|
11170
10938
|
return vendorFlight ?? regularFlight;
|
|
11171
|
-
|
|
11172
|
-
|
|
11173
|
-
|
|
11174
|
-
|
|
11175
|
-
const
|
|
11176
|
-
|
|
11177
|
-
|
|
11178
|
-
|
|
11179
|
-
|
|
11180
|
-
|
|
11181
|
-
|
|
11182
|
-
|
|
11183
|
-
|
|
10939
|
+
if (this.shuttingDown)
|
|
10940
|
+
return null;
|
|
10941
|
+
const instanceDir = join(getAgendHome(), "cli-env");
|
|
10942
|
+
const epoch = this.cliEnvProbeEpoch;
|
|
10943
|
+
const input = {
|
|
10944
|
+
mode: "env", backend, instanceDir,
|
|
10945
|
+
config: { workingDirectory: "", instanceDir, instanceName: `probe-${backend}`, mcpServers: {} },
|
|
10946
|
+
refreshVendorCatalog: opts.refreshVendorCatalog === true,
|
|
10947
|
+
};
|
|
10948
|
+
const bounded = this.runBackendProbeWorker(`global:${backend}`, input).then(probed => {
|
|
10949
|
+
if (!probed || Array.isArray(probed) || this.shuttingDown || epoch !== this.cliEnvProbeEpoch)
|
|
10950
|
+
return null;
|
|
10951
|
+
try {
|
|
10952
|
+
return this.persistCliEnvProbeResult(backend, probed);
|
|
10953
|
+
}
|
|
10954
|
+
catch (err) {
|
|
10955
|
+
this.logger.warn({ backend, err }, "CLI env cache write failed");
|
|
10956
|
+
return null;
|
|
10957
|
+
}
|
|
10958
|
+
}).finally(() => {
|
|
11184
10959
|
const flights = opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes;
|
|
11185
10960
|
if (flights.get(backend) === bounded)
|
|
11186
10961
|
flights.delete(backend);
|
|
@@ -11188,18 +10963,26 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11188
10963
|
(opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(backend, bounded);
|
|
11189
10964
|
return bounded;
|
|
11190
10965
|
}
|
|
11191
|
-
|
|
11192
|
-
|
|
11193
|
-
|
|
11194
|
-
|
|
11195
|
-
|
|
11196
|
-
|
|
11197
|
-
|
|
11198
|
-
|
|
11199
|
-
|
|
11200
|
-
|
|
11201
|
-
|
|
11202
|
-
|
|
10966
|
+
stopCliEnvProbes() {
|
|
10967
|
+
this.cliEnvProbeEpoch++;
|
|
10968
|
+
this.cliEnvProbePool.close();
|
|
10969
|
+
this.pendingCliEnvProbes.clear();
|
|
10970
|
+
this.pendingVendorCliEnvProbes.clear();
|
|
10971
|
+
this.pendingInstanceModelProbes.clear();
|
|
10972
|
+
}
|
|
10973
|
+
runBackendProbeWorker(key, input) {
|
|
10974
|
+
if (this.shuttingDown)
|
|
10975
|
+
return Promise.resolve(null);
|
|
10976
|
+
this.cliEnvProbePool.reopen();
|
|
10977
|
+
return this.cliEnvProbePool.run(key, () => this.startCliEnvProbeWorker(input), {
|
|
10978
|
+
deadlineMs: CLI_ENV_PROBE_DEADLINE_MS,
|
|
10979
|
+
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"),
|
|
10980
|
+
onError: err => this.logger.warn({ backend: input.backend, err }, "CLI env worker failed to start or stop"),
|
|
10981
|
+
});
|
|
10982
|
+
}
|
|
10983
|
+
startCliEnvProbeWorker(input) {
|
|
10984
|
+
const { backend } = input;
|
|
10985
|
+
const workerData = input;
|
|
11203
10986
|
// `tsx src/cli.ts` needs its TS module resolver inside the separate isolate.
|
|
11204
10987
|
// Published builds load the compiled worker and require no tsx dependency.
|
|
11205
10988
|
const worker = import.meta.url.endsWith(".ts")
|
|
@@ -11207,35 +10990,31 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11207
10990
|
: new Worker(new URL("./backend/cli-env-probe-worker.js", import.meta.url), { execArgv: [], workerData });
|
|
11208
10991
|
let settled = false;
|
|
11209
10992
|
let resolveProbe;
|
|
10993
|
+
let markStopped;
|
|
10994
|
+
const stopped = new Promise(resolve => { markStopped = resolve; });
|
|
11210
10995
|
const promise = new Promise(resolve => { resolveProbe = resolve; });
|
|
11211
10996
|
const finish = (value) => {
|
|
11212
10997
|
if (settled)
|
|
11213
10998
|
return;
|
|
11214
10999
|
settled = true;
|
|
11215
11000
|
resolveProbe(value);
|
|
11216
|
-
|
|
11001
|
+
// Admission stays occupied until terminate resolves or an exit is seen.
|
|
11002
|
+
// A rejected termination does not prove the isolate has stopped.
|
|
11003
|
+
try {
|
|
11004
|
+
void worker.terminate().then(markStopped).catch(err => {
|
|
11005
|
+
this.logger.debug({ backend, err }, "CLI env worker termination failed");
|
|
11006
|
+
});
|
|
11007
|
+
}
|
|
11008
|
+
catch (err) {
|
|
11217
11009
|
this.logger.debug({ backend, err }, "CLI env worker termination failed");
|
|
11218
|
-
}
|
|
11010
|
+
}
|
|
11219
11011
|
};
|
|
11220
11012
|
worker.once("message", (message) => {
|
|
11221
11013
|
if (settled)
|
|
11222
11014
|
return;
|
|
11223
|
-
if (!message.ok)
|
|
11015
|
+
if (!message.ok)
|
|
11224
11016
|
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
|
-
}
|
|
11017
|
+
finish(message.ok ? message.result ?? null : null);
|
|
11239
11018
|
});
|
|
11240
11019
|
worker.once("error", err => {
|
|
11241
11020
|
if (settled)
|
|
@@ -11244,12 +11023,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11244
11023
|
finish(null);
|
|
11245
11024
|
});
|
|
11246
11025
|
worker.once("exit", code => {
|
|
11026
|
+
markStopped();
|
|
11247
11027
|
if (settled)
|
|
11248
11028
|
return;
|
|
11249
11029
|
this.logger.warn({ backend, code }, "CLI env worker exited without a result");
|
|
11250
11030
|
finish(null);
|
|
11251
11031
|
});
|
|
11252
|
-
return { promise, terminate: () => { finish(null); } };
|
|
11032
|
+
return { promise, stopped, terminate: () => { finish(null); } };
|
|
11253
11033
|
}
|
|
11254
11034
|
/**
|
|
11255
11035
|
* Resolve the effective model for a fleet or ClassicBot instance, plus where it
|
|
@@ -11299,32 +11079,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11299
11079
|
const label = option.description ? `${option.label} — ${option.description}` : option.label;
|
|
11300
11080
|
return option.id === currentModel ? `✓ ${label}` : label;
|
|
11301
11081
|
}
|
|
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. */
|
|
11082
|
+
/** Preserve the existing cache merge rules for worker probes. */
|
|
11328
11083
|
persistCliEnvProbeResult(backend, probed) {
|
|
11329
11084
|
const env = { backend, probedAt: Date.now(), ...probed };
|
|
11330
11085
|
// An empty result must never overwrite a catalog we already have. Some
|
|
@@ -11471,17 +11226,30 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11471
11226
|
try {
|
|
11472
11227
|
const inst = this.fleetConfig?.instances?.[instanceName];
|
|
11473
11228
|
const instanceDir = this.getInstanceDir(instanceName);
|
|
11474
|
-
const
|
|
11475
|
-
if (!be.listModels)
|
|
11476
|
-
return [];
|
|
11477
|
-
return await be.listModels({
|
|
11229
|
+
const config = structuredClone({
|
|
11478
11230
|
workingDirectory: inst?.working_directory ?? "",
|
|
11479
11231
|
instanceDir,
|
|
11480
11232
|
instanceName,
|
|
11481
11233
|
mcpServers: {},
|
|
11482
11234
|
model: inst?.model,
|
|
11483
11235
|
backendOptions: inst?.backend_options?.[backend] ?? this.fleetConfig?.defaults?.backend_options?.[backend],
|
|
11484
|
-
})
|
|
11236
|
+
});
|
|
11237
|
+
// Include the config snapshot, so a changed provider/profile cannot join
|
|
11238
|
+
// a still-running probe for the previous instance configuration.
|
|
11239
|
+
const key = JSON.stringify({ backend, instanceDir, config });
|
|
11240
|
+
const pending = this.pendingInstanceModelProbes.get(key);
|
|
11241
|
+
if (pending)
|
|
11242
|
+
return pending;
|
|
11243
|
+
const epoch = this.cliEnvProbeEpoch;
|
|
11244
|
+
const bounded = this.runBackendProbeWorker(`instance:${backend}:${instanceDir}`, {
|
|
11245
|
+
mode: "models", backend, instanceDir, config,
|
|
11246
|
+
}).then(result => Array.isArray(result) && !this.shuttingDown && epoch === this.cliEnvProbeEpoch ? result : [])
|
|
11247
|
+
.finally(() => {
|
|
11248
|
+
if (this.pendingInstanceModelProbes.get(key) === bounded)
|
|
11249
|
+
this.pendingInstanceModelProbes.delete(key);
|
|
11250
|
+
});
|
|
11251
|
+
this.pendingInstanceModelProbes.set(key, bounded);
|
|
11252
|
+
return bounded;
|
|
11485
11253
|
}
|
|
11486
11254
|
catch {
|
|
11487
11255
|
// listModels is documented never to throw, but a backend constructor can
|
|
@@ -11990,12 +11758,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11990
11758
|
/** How this instance's backend applies an effort change. */
|
|
11991
11759
|
effortStrategyFor(instanceName) {
|
|
11992
11760
|
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";
|
|
11761
|
+
const { strategy, levels } = readEffortMetadata(this.backendNameForInstance(instanceName), this.getInstanceDir(instanceName));
|
|
11762
|
+
return strategy !== "unsupported" && levels.length > 0 ? strategy : "unsupported";
|
|
11999
11763
|
}
|
|
12000
11764
|
catch {
|
|
12001
11765
|
return "unsupported";
|
|
@@ -12004,10 +11768,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12004
11768
|
/** Effort levels this instance's backend actually accepts (empty = unsupported). */
|
|
12005
11769
|
effortLevelsFor(instanceName) {
|
|
12006
11770
|
try {
|
|
12007
|
-
const
|
|
12008
|
-
|
|
12009
|
-
return [];
|
|
12010
|
-
return backend.getEffortLevels?.() ?? [];
|
|
11771
|
+
const { strategy, levels } = readEffortMetadata(this.backendNameForInstance(instanceName), this.getInstanceDir(instanceName));
|
|
11772
|
+
return strategy === "unsupported" ? [] : levels;
|
|
12011
11773
|
}
|
|
12012
11774
|
catch {
|
|
12013
11775
|
return [];
|
|
@@ -12147,21 +11909,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12147
11909
|
try {
|
|
12148
11910
|
if (!existsSync(logFile))
|
|
12149
11911
|
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;
|
|
11912
|
+
// Only the end of today's log is needed (#1161): a day's log used to be read and split whole for every message.
|
|
11913
|
+
return recentChatContext(logFile, maxLines, CHAT_LOG_TAIL_BYTES, CHAT_LOG_TAIL_MAX_BYTES);
|
|
12165
11914
|
}
|
|
12166
11915
|
catch {
|
|
12167
11916
|
return undefined;
|
|
@@ -12417,6 +12166,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12417
12166
|
// make `agend stop` wait forever for its own queue.
|
|
12418
12167
|
this.stormWindow.shutdown();
|
|
12419
12168
|
this.spawnGate.shutdown();
|
|
12169
|
+
this.memoryPressure?.stop();
|
|
12170
|
+
this.stopCliEnvProbes();
|
|
12420
12171
|
for (const pending of this.startupRetries.values())
|
|
12421
12172
|
clearTimeout(pending.timer);
|
|
12422
12173
|
this.startupRetries.clear();
|