@songsid/agend 2.2.0-beta.9 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -24
- package/README.zh-TW.md +40 -24
- package/dist/agent-endpoint.js +3 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-whats-new.js +7 -1
- package/dist/agent-whats-new.js.map +1 -1
- package/dist/auth-api.d.ts +3 -1
- package/dist/auth-api.js +15 -1
- package/dist/auth-api.js.map +1 -1
- package/dist/backend/antigravity.d.ts +33 -0
- package/dist/backend/antigravity.js +126 -18
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.d.ts +3 -0
- package/dist/backend/claude-code.js +9 -0
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex-metadata.d.ts +33 -0
- package/dist/backend/codex-metadata.js +122 -18
- package/dist/backend/codex-metadata.js.map +1 -1
- package/dist/backend/codex.d.ts +3 -0
- package/dist/backend/codex.js +17 -1
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/effort-metadata.d.ts +37 -1
- package/dist/backend/effort-metadata.js +86 -15
- package/dist/backend/effort-metadata.js.map +1 -1
- package/dist/backend/grok.d.ts +3 -0
- package/dist/backend/grok.js +9 -0
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro.d.ts +3 -1
- package/dist/backend/kiro.js +7 -3
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/launch-effort.d.ts +61 -0
- package/dist/backend/launch-effort.js +170 -0
- package/dist/backend/launch-effort.js.map +1 -0
- package/dist/backend/types.d.ts +24 -0
- package/dist/backend/types.js +12 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/backend-command.d.ts +91 -0
- package/dist/backend-command.js +122 -0
- package/dist/backend-command.js.map +1 -0
- package/dist/channel/adapters/discord.d.ts +12 -0
- package/dist/channel/adapters/discord.js +49 -2
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +18 -0
- package/dist/channel/adapters/telegram.js +24 -3
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-tools.js +2 -0
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/tool-router.d.ts +2 -0
- package/dist/channel/tool-router.js +1 -1
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +16 -0
- package/dist/cli.js +124 -24
- package/dist/cli.js.map +1 -1
- package/dist/command-table.d.ts +2 -2
- package/dist/command-table.js +5 -2
- package/dist/command-table.js.map +1 -1
- package/dist/compact-watch.d.ts +86 -0
- package/dist/compact-watch.js +288 -0
- package/dist/compact-watch.js.map +1 -0
- package/dist/config-validator.js +39 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +2 -1
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +9 -0
- package/dist/daemon.js +150 -9
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +8 -1
- package/dist/delivery-outbox.js +6 -2
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/doctor.js +7 -0
- package/dist/doctor.js.map +1 -1
- package/dist/fleet-context.d.ts +7 -0
- package/dist/fleet-manager.d.ts +158 -9
- package/dist/fleet-manager.js +827 -118
- package/dist/fleet-manager.js.map +1 -1
- package/dist/forged-envelope-scan.d.ts +4 -3
- package/dist/forged-envelope-scan.js +4 -3
- package/dist/forged-envelope-scan.js.map +1 -1
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +4 -1
- package/dist/install-lock.d.ts +18 -1
- package/dist/install-lock.js +30 -3
- package/dist/install-lock.js.map +1 -1
- package/dist/instance-avatar.d.ts +5 -4
- package/dist/instance-avatar.js +12 -8
- package/dist/instance-avatar.js.map +1 -1
- package/dist/instructions.js +2 -2
- package/dist/instructions.js.map +1 -1
- package/dist/kiro-compact-pane.d.ts +38 -0
- package/dist/kiro-compact-pane.js +57 -0
- package/dist/kiro-compact-pane.js.map +1 -0
- package/dist/launchd-handoff.d.ts +43 -0
- package/dist/launchd-handoff.js +56 -0
- package/dist/launchd-handoff.js.map +1 -0
- package/dist/locale.js +202 -30
- package/dist/locale.js.map +1 -1
- package/dist/log-service.js +10 -2
- package/dist/log-service.js.map +1 -1
- package/dist/login-shell.d.ts +21 -0
- package/dist/login-shell.js +98 -0
- package/dist/login-shell.js.map +1 -0
- package/dist/outbound-handlers.d.ts +27 -0
- package/dist/outbound-handlers.js +120 -36
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +12 -0
- package/dist/outbound-schemas.js +15 -4
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/provider-probe.d.ts +29 -0
- package/dist/provider-probe.js +198 -2
- package/dist/provider-probe.js.map +1 -1
- package/dist/public-web-gateway.d.ts +2 -0
- package/dist/public-web-gateway.js +11 -1
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/quickstart-api.d.ts +1 -1
- package/dist/quickstart-api.js +8 -6
- package/dist/quickstart-api.js.map +1 -1
- package/dist/quickstart.d.ts +12 -0
- package/dist/quickstart.js +36 -6
- package/dist/quickstart.js.map +1 -1
- package/dist/reply-buttons-controller.d.ts +86 -5
- package/dist/reply-buttons-controller.js +271 -7
- package/dist/reply-buttons-controller.js.map +1 -1
- package/dist/reply-buttons.d.ts +48 -3
- package/dist/reply-buttons.js +105 -10
- package/dist/reply-buttons.js.map +1 -1
- package/dist/reply-polls.d.ts +85 -0
- package/dist/reply-polls.js +154 -0
- package/dist/reply-polls.js.map +1 -0
- package/dist/restart-guard.d.ts +6 -3
- package/dist/restart-guard.js +6 -3
- package/dist/restart-guard.js.map +1 -1
- package/dist/restart-progress.d.ts +0 -2
- package/dist/restart-progress.js +0 -2
- package/dist/restart-progress.js.map +1 -1
- package/dist/sd-notify.d.ts +22 -1
- package/dist/sd-notify.js +63 -10
- package/dist/sd-notify.js.map +1 -1
- package/dist/service-activation.d.ts +15 -7
- package/dist/service-activation.js +42 -26
- package/dist/service-activation.js.map +1 -1
- package/dist/service-installer.d.ts +24 -0
- package/dist/service-installer.js +49 -1
- package/dist/service-installer.js.map +1 -1
- package/dist/service-plan.d.ts +11 -0
- package/dist/service-plan.js +23 -0
- package/dist/service-plan.js.map +1 -1
- package/dist/systemd-peer-bus.d.ts +95 -0
- package/dist/systemd-peer-bus.js +430 -0
- package/dist/systemd-peer-bus.js.map +1 -0
- package/dist/systemd-runtime.d.ts +27 -1
- package/dist/systemd-runtime.js +69 -7
- package/dist/systemd-runtime.js.map +1 -1
- package/dist/tool-permissions.js +3 -3
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.d.ts +68 -2
- package/dist/topic-commands.js +141 -27
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-jsonl.d.ts +7 -2
- package/dist/transcript-jsonl.js +13 -7
- package/dist/transcript-jsonl.js.map +1 -1
- package/dist/transcript-sources.js +6 -1
- package/dist/transcript-sources.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/ui/chat-commands.js +1 -0
- package/dist/ui/chat-render.js +55 -10
- package/dist/ui/chat-store.js +69 -5
- package/dist/ui/chat-strings.js +28 -16
- package/dist/ui/chat-thread.js +132 -14
- package/dist/ui/chat-tour.js +17 -14
- package/dist/ui/panel-chat.js +16 -10
- package/dist/ui/panel-fleet.js +15 -2
- package/dist/ui/panel-log.js +4 -4
- package/dist/ui/panel-needs.js +10 -7
- package/dist/ui/panel-settings.js +58 -7
- package/dist/ui/settings-dialogs.js +11 -6
- package/dist/ui/settings-model.js +3 -1
- package/dist/ui/settings-strings.js +24 -10
- package/dist/ui/settings-token.js +7 -0
- package/dist/ui/settings-wizard.js +5 -3
- package/dist/ui/shared/app-i18n.js +1 -1
- package/dist/ui/shared/app-stream.js +4 -1
- package/dist/ui/shared/app.css +52 -10
- package/dist/ui/shared/header-tools.js +34 -14
- package/dist/ui/shared/instance-switch.js +5 -2
- package/dist/ui/shared/panel-view.js +15 -4
- package/dist/ui/shared/signin.js +15 -9
- package/dist/ui/shared/ui-icons.js +1 -0
- package/dist/ui/shared/view-strings.js +10 -2
- package/dist/ui/signin.html +8 -3
- package/dist/update-check.d.ts +31 -0
- package/dist/update-check.js +47 -0
- package/dist/update-check.js.map +1 -1
- package/dist/update-launch.d.ts +48 -1
- package/dist/update-launch.js +159 -5
- package/dist/update-launch.js.map +1 -1
- package/dist/usage/providers.d.ts +28 -8
- package/dist/usage/providers.js +96 -41
- package/dist/usage/providers.js.map +1 -1
- package/dist/view-api.js +26 -9
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.d.ts +44 -2
- package/dist/web-api.js +265 -57
- package/dist/web-api.js.map +1 -1
- package/dist/web-chat-history.d.ts +26 -1
- package/dist/web-chat-history.js +47 -8
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-commands.d.ts +39 -3
- package/dist/web-commands.js +22 -5
- package/dist/web-commands.js.map +1 -1
- package/dist/web-login.d.ts +14 -0
- package/dist/web-login.js +4 -2
- package/dist/web-login.js.map +1 -1
- package/dist/web-prefs.d.ts +18 -0
- package/dist/web-prefs.js +36 -4
- package/dist/web-prefs.js.map +1 -1
- package/dist/web-profile.d.ts +32 -0
- package/dist/web-profile.js +132 -0
- package/dist/web-profile.js.map +1 -0
- package/dist/web-session.d.ts +14 -0
- package/dist/web-session.js +10 -0
- package/dist/web-session.js.map +1 -1
- package/dist/web-upload.d.ts +119 -4
- package/dist/web-upload.js +252 -39
- package/dist/web-upload.js.map +1 -1
- package/launcher/install-admission.cjs +61 -6
- package/package.json +3 -3
package/dist/fleet-manager.js
CHANGED
|
@@ -7,7 +7,7 @@ import { SettingsExecution, settingsRevision, noteSettingsWrite, settingsFileRes
|
|
|
7
7
|
import { performance } from "node:perf_hooks";
|
|
8
8
|
import { withSpawnHoldReply } from "./spawn-hold-reply.js";
|
|
9
9
|
import { gatewayRequestContext } from "./web-request-context.js";
|
|
10
|
-
import { createPublicWebGateway } from "./public-web-gateway.js";
|
|
10
|
+
import { createPublicWebGateway, PUBLIC_GATEWAY_RECEIVE_MS } from "./public-web-gateway.js";
|
|
11
11
|
import { handleWebIconRequest } from "./web-icons.js";
|
|
12
12
|
import { trackWebServer } from "./web-server-drain.js";
|
|
13
13
|
import { renderPublicLinkProgress, ThrottledMessageEditor } from "./public-link-progress.js";
|
|
@@ -20,10 +20,9 @@ import { withinBudget } from "./monotonic-budget.js";
|
|
|
20
20
|
import { measureSyncWork } from "./sync-work-attribution.js";
|
|
21
21
|
import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
|
|
22
22
|
import { ProfileControlServer } from "./profile-control.js";
|
|
23
|
-
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, constants as fsConstants } from "node:fs";
|
|
23
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, constants as fsConstants, closeSync } from "node:fs";
|
|
24
24
|
import { atomicWriteFileSync } from "./atomic-write.js";
|
|
25
25
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
26
|
-
import { spawnSync } from "node:child_process";
|
|
27
26
|
import { freemem, totalmem, cpus, homedir } from "node:os";
|
|
28
27
|
import { createServer } from "node:http";
|
|
29
28
|
import { Worker } from "node:worker_threads";
|
|
@@ -34,7 +33,7 @@ import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
|
34
33
|
import { fleetLabel } from "./fleet-label.js";
|
|
35
34
|
import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, sameUpdateMarker, setUpdateProgressStage, updateMarkerIdentity, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
|
|
36
35
|
import { formatUpdateProgress } from "./update-progress.js";
|
|
37
|
-
import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
|
|
36
|
+
import { sdNotify, sdNotifyBlocking, setSdNotifyWarn } from "./sd-notify.js";
|
|
38
37
|
import { readFleetMemory } from "./process-memory.js";
|
|
39
38
|
import { MemoryPressure } from "./memory-pressure.js";
|
|
40
39
|
import { replyDedupText, ReplyDeduper } from "./reply-dedup.js";
|
|
@@ -46,7 +45,7 @@ const __dirname = dirname(__filename);
|
|
|
46
45
|
const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
|
|
47
46
|
import { STATUS_EMOJI_CONFIG_KEYS, STATUS_EMOJI_KEYS, STATUS_EMOJI_SUGGESTIONS, TELEGRAM_REACTION_EMOJIS, builtinStatusEmojis, customEmojiValue, emojiImageUrl, normalizeEmoji, previewStatusEmojis, reactionForm, reactionMatchKey, resolveStatusEmojis, statusAvoidList, statusEmojiProblem, statusMatchKey, statusMatchKeys, textForm, } from "./status-emojis.js";
|
|
48
47
|
import { isProbeableRouteTarget } from "./fleet-context.js";
|
|
49
|
-
import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
|
|
48
|
+
import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
|
|
50
49
|
import { EventLog } from "./event-log.js";
|
|
51
50
|
import { binaryProbe } from "./binary-probe.js";
|
|
52
51
|
import { classifySqliteOpenError } from "./sqlite-open-errors.js";
|
|
@@ -64,7 +63,9 @@ import { isWebChannelEcho, WEB_ECHO_PREFIX } from "./web-channel-echo.js";
|
|
|
64
63
|
import { TelegramAdapter } from "./channel/adapters/telegram.js";
|
|
65
64
|
import { createBackend } from "./backend/factory.js";
|
|
66
65
|
import { readEffortMetadata } from "./backend/effort-metadata.js";
|
|
67
|
-
import {
|
|
66
|
+
import { codexEffort, flagEffort } from "./backend/launch-effort.js";
|
|
67
|
+
import { codexMetadataHomes, readCodexModelEffortLevels, resolveCodexLaunchModel } from "./backend/codex-metadata.js";
|
|
68
|
+
import { CLI_ENV_TTL_MS, isModelCompatible, resolveBinary, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
|
|
68
69
|
import { createLogger, rotateLogIfNeeded } from "./logger.js";
|
|
69
70
|
import { processAttachments } from "./channel/attachment-handler.js";
|
|
70
71
|
import { routeToolCall } from "./channel/tool-router.js";
|
|
@@ -80,7 +81,8 @@ import { RoutingEngine } from "./routing-engine.js";
|
|
|
80
81
|
import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
|
|
81
82
|
import { TopicArchiver } from "./topic-archiver.js";
|
|
82
83
|
import { StatuslineWatcher } from "./statusline-watcher.js";
|
|
83
|
-
import { outboundHandlers } from "./outbound-handlers.js";
|
|
84
|
+
import { applyInstanceConfigPatch, outboundHandlers } from "./outbound-handlers.js";
|
|
85
|
+
import { backendArgs, backendStep, joinBackendArgs, modelNameCarried, planDisplay } from "./backend-command.js";
|
|
84
86
|
import { DeliveryOutbox, DEFAULT_REPLY_OBLIGATION_TTL_MS, isSilentScheduleDelivery } from "./delivery-outbox.js";
|
|
85
87
|
// The target Daemon can legitimately wait 30 minutes for a busy pane before
|
|
86
88
|
// deciding to defer before submission. This is an alert threshold only: the
|
|
@@ -138,9 +140,11 @@ import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } fr
|
|
|
138
140
|
import { WebChatDiskStore } from "./web-chat-store.js";
|
|
139
141
|
import { SeenUsers, resolveMentions } from "./web-mentions.js";
|
|
140
142
|
import { WebPrefsStore } from "./web-prefs.js";
|
|
143
|
+
import { WEB_NAME_FALLBACK, safeWebName, validateWebName, webNameInUse, webPersonKey } from "./web-profile.js";
|
|
141
144
|
import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
|
|
142
145
|
import { ReplyButtonsController } from "./reply-buttons-controller.js";
|
|
143
|
-
import {
|
|
146
|
+
import { parsePoll } from "./reply-polls.js";
|
|
147
|
+
import { isUploadRequest, limitRequestReceive, publicAttachment, sweepOrphanedUploads, UPLOAD_REQUEST_TIMEOUT_MS, uploadLimits, WebFileLedger } from "./web-upload.js";
|
|
144
148
|
import { REPLY_FILES_MAX_BYTES, snapshotReplyFile, sweepReplySnapshots, webFilesDir } from "./web-reply-files.js";
|
|
145
149
|
import { handleViewRequest, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
|
|
146
150
|
import { downloadStickerImage } from "./channel/sticker-download.js";
|
|
@@ -153,13 +157,14 @@ import { isDisallowedIntentsError, isRejectedTokenError } from "./discord-permis
|
|
|
153
157
|
import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
|
|
154
158
|
import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
|
|
155
159
|
import { LoginSession } from "./login-manager.js";
|
|
160
|
+
import { runOnLoginShell } from "./login-shell.js";
|
|
156
161
|
import { tightenInstanceDirs } from "./private-dir.js";
|
|
157
162
|
import { decideSlash } from "./slash-authz.js";
|
|
158
163
|
import { canonicalCommand, commandSpec, decideCommand, ruleFor } from "./command-table.js";
|
|
159
164
|
import { runVisibilityCommand } from "./cross-instance-notice.js";
|
|
160
|
-
import { installedChannel,
|
|
165
|
+
import { installedChannel, updateNoticeKey } from "./update-check.js";
|
|
161
166
|
import { resolveInstalledAgend } from "./update-dispatch.js";
|
|
162
|
-
import { resolveUpdateLaunch, watchUpdateLaunch } from "./update-launch.js";
|
|
167
|
+
import { resolveUpdateLaunch, openUpdateLog, updateLogPath, watchUpdateLaunch } from "./update-launch.js";
|
|
163
168
|
import { admitProfileLogin, LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
|
|
164
169
|
import { runBeforeDeadline } from "./deadline.js";
|
|
165
170
|
import { LoginWindowLock } from "./login-window-lock.js";
|
|
@@ -170,10 +175,10 @@ import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet
|
|
|
170
175
|
import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
|
|
171
176
|
import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
|
|
172
177
|
import { assertExplicitInstanceRemoval } from "./instance-removal.js";
|
|
173
|
-
import { validateFleetConfig } from "./config-validator.js";
|
|
178
|
+
import { KNOWN_BACKENDS, validateFleetConfig } from "./config-validator.js";
|
|
174
179
|
import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
|
|
175
180
|
import { presentationState, interactionSummary, sameInteractionOwner } from "./interaction-observation.js";
|
|
176
|
-
import { readLastInboundAt } from "./daemon.js";
|
|
181
|
+
import { DEFAULT_STUCK_TIMEOUT_MS, readLastInboundAt } from "./daemon.js";
|
|
177
182
|
import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
|
|
178
183
|
import { readBackgroundWorkEndedMarker } from "./background-work-ended.js";
|
|
179
184
|
import { DEFAULT_WARM_OVERFLOW, WakeCoordinator } from "./wake-coordinator.js";
|
|
@@ -184,11 +189,13 @@ import { isSetupComplete, markSetupComplete } from "./setup-marker.js";
|
|
|
184
189
|
import { manualCleanupMessage, reapStaleTunnel } from "./tunnel/lease.js";
|
|
185
190
|
import { buildToolPermissionsNotice } from "./tool-permissions-notice.js";
|
|
186
191
|
import { NeedsYouHub } from "./needs-you-hub.js";
|
|
187
|
-
import { WEB_CHAT_NOTICE,
|
|
192
|
+
import { WEB_CHAT_NOTICE, claimNotice, hasWebChat, releaseNotice, upgradeNoticesPath } from "./upgrade-notices.js";
|
|
188
193
|
import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
|
|
189
194
|
import { buildOrgChart } from "./web-org.js";
|
|
190
195
|
import { CacheService, WINDOWS } from "./cache-service.js";
|
|
191
196
|
import { runWebCommand } from "./web-commands.js";
|
|
197
|
+
import { CompactWatch, launchIdentity } from "./compact-watch.js";
|
|
198
|
+
import { agyLegacyAgentsFile, danglingSymlink, migrateAgyAgentsFile } from "./backend/antigravity.js";
|
|
192
199
|
import { claudeConfigDirFor, claudeProjectKey } from "./backend/claude-code.js";
|
|
193
200
|
import { sharedRolloutIndex } from "./rollout-index.js";
|
|
194
201
|
import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
|
|
@@ -353,6 +360,7 @@ const CLASSIC_BACKEND_SELECTION_TIMEOUT_MS = 60_000;
|
|
|
353
360
|
const CLASSIC_BACKEND_CALLBACK_PREFIX = "classic-backend:";
|
|
354
361
|
const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
|
|
355
362
|
const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
|
|
363
|
+
const BACKEND_SELECT_CALLBACK_PREFIX = "backend-select:";
|
|
356
364
|
const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
|
|
357
365
|
const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
|
|
358
366
|
/** #1386: how long a paused instance's marker reason is reused before it is read again (transitions drop it at once). */
|
|
@@ -376,6 +384,7 @@ const WEB_MIRRORED_PROMPT_PREFIXES = new Set([
|
|
|
376
384
|
const WEB_ONLY_REPLY_SINK = {
|
|
377
385
|
type: "web",
|
|
378
386
|
supportsReplyButtons: true, // #1266: the web chat shows a reply's buttons itself
|
|
387
|
+
supportsPolls: true, // #1823: …and a poll (its pages are told; there is nothing to edit)
|
|
379
388
|
sendText: async () => ({ chatId: "web", messageId: newWebMessageId() }),
|
|
380
389
|
sendFile: async () => ({ chatId: "web", messageId: newWebMessageId() }),
|
|
381
390
|
};
|
|
@@ -672,6 +681,8 @@ export class FleetManager {
|
|
|
672
681
|
/** In-flight /effort selections, same coordinator shape as pendingModelSelects. */
|
|
673
682
|
pendingEffortSelects = new Map();
|
|
674
683
|
pendingModelSelects = new Map();
|
|
684
|
+
/** #1806: in-flight /backend steps, keyed by nonce (see handleBackendSelection). */
|
|
685
|
+
pendingBackendSelects = new Map();
|
|
675
686
|
/** nonce → pending button prompt (hang restart, interactive assist, clean-exit restart). */
|
|
676
687
|
pendingNonceButtons = new Map();
|
|
677
688
|
/** #1386 "Needs you": the list, its live chat messages and Acknowledge. Started with the fleet (finishStartup). */
|
|
@@ -753,7 +764,6 @@ export class FleetManager {
|
|
|
753
764
|
updateProgressTimer = null;
|
|
754
765
|
updateProgressEditRunning = false;
|
|
755
766
|
lastUpdateProgressText = null;
|
|
756
|
-
updateCompletionTipText = null;
|
|
757
767
|
/**
|
|
758
768
|
* #1660: an adopted update whose retire outcome is still owed. The
|
|
759
769
|
* completion summary was (or will be) posted; this watch appends the
|
|
@@ -792,7 +802,8 @@ export class FleetManager {
|
|
|
792
802
|
/** #1565: the web chat history's files (set at fleet start, once the home and the instances are known). */
|
|
793
803
|
webChatStore = null;
|
|
794
804
|
/** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
|
|
795
|
-
|
|
805
|
+
// #1937: the upload limits are the config's, read on each use (agend reload applies a change to the next upload).
|
|
806
|
+
webFiles = new WebFileLedger({ limits: () => uploadLimits(this.fleetConfig?.web) });
|
|
796
807
|
/** #1770: per instance, its last web reply still copying its files — the next one is shown after it, in order. */
|
|
797
808
|
webReplyOrder = new Map();
|
|
798
809
|
/** #1770: an instance's web-files byte cap (a field, so a test can make it small). */
|
|
@@ -801,6 +812,78 @@ export class FleetManager {
|
|
|
801
812
|
seenUsers = new SeenUsers();
|
|
802
813
|
webPrefsStore;
|
|
803
814
|
/** #1665: the web preferences kept on the host (<home>/web-prefs.json), read on first use. */
|
|
815
|
+
/**
|
|
816
|
+
* #1827: a web person as agents and chats see them. Signed in as an account (a sign-in code DMed to it): that
|
|
817
|
+
* account's user id — still `via web`, and only for recognition — and the name they chose, else their account's name
|
|
818
|
+
* at sign-in (made safe). Otherwise (an `agend web` session): "web-user", as before, under a name the operator may set.
|
|
819
|
+
* Names are checked again now: one an instance or bot has taken since reads as the next choice down.
|
|
820
|
+
*/
|
|
821
|
+
webPerson(owner) {
|
|
822
|
+
const key = webPersonKey(owner);
|
|
823
|
+
let profile = null;
|
|
824
|
+
try {
|
|
825
|
+
profile = this.webPrefs.profile(key);
|
|
826
|
+
}
|
|
827
|
+
catch (err) {
|
|
828
|
+
this.logger.warn({ err: err.message }, "Web profile unavailable");
|
|
829
|
+
}
|
|
830
|
+
const reserved = (n) => this.webNameReserved(n);
|
|
831
|
+
const own = profile?.name !== undefined ? webNameInUse(profile.name, reserved) : WEB_NAME_FALLBACK;
|
|
832
|
+
const chosen = own !== WEB_NAME_FALLBACK;
|
|
833
|
+
const platformName = owner ? safeWebName(profile?.platformName, reserved) : undefined;
|
|
834
|
+
return {
|
|
835
|
+
key, bound: !!owner,
|
|
836
|
+
userId: owner ? owner.userId : WEB_NAME_FALLBACK,
|
|
837
|
+
// A poll ballot is the account's — the same key its votes on the platform have — or the operator's.
|
|
838
|
+
voterKey: owner ? `${owner.adapterId}:${owner.userId}` : "web:operator",
|
|
839
|
+
name: chosen ? own : platformName ?? WEB_NAME_FALLBACK,
|
|
840
|
+
set: chosen,
|
|
841
|
+
...(platformName !== undefined ? { platformName } : {}),
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
/** #1827: set (null: clear — back to their account's name) a web person's own name; validated in full. */
|
|
845
|
+
setWebPersonName(owner, raw) {
|
|
846
|
+
let name = null;
|
|
847
|
+
if (raw !== null) {
|
|
848
|
+
const v = validateWebName(raw, (n) => this.webNameReserved(n));
|
|
849
|
+
if (!v.ok)
|
|
850
|
+
return { ok: false, status: 400, error: v.error };
|
|
851
|
+
name = v.name;
|
|
852
|
+
}
|
|
853
|
+
const w = this.webPrefs.setProfileName(webPersonKey(owner), name);
|
|
854
|
+
return w.ok ? { ok: true, person: this.webPerson(owner) } : { ok: false, status: w.status, error: w.error };
|
|
855
|
+
}
|
|
856
|
+
/** #1827 (auth-api): a sign-in as this account — its display name then is the web name it starts with. */
|
|
857
|
+
noteWebSignIn(account) {
|
|
858
|
+
if (account.username)
|
|
859
|
+
this.webPrefs.setPlatformName(webPersonKey(account), account.username);
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* #1827: a name that belongs to something here — an instance (or ClassicBot room), its display name, a bot. On the web
|
|
863
|
+
* send path: it never throws (a lookup that fails reserves nothing; the name was checked in full when it was set).
|
|
864
|
+
*/
|
|
865
|
+
webNameReserved(lowered) {
|
|
866
|
+
try {
|
|
867
|
+
return this.webNameTaken(lowered);
|
|
868
|
+
}
|
|
869
|
+
catch {
|
|
870
|
+
return false;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
webNameTaken(lowered) {
|
|
874
|
+
const low = (s) => (typeof s === "string" ? s.normalize("NFC").toLocaleLowerCase("en-US") : "");
|
|
875
|
+
for (const [name, cfg] of Object.entries(this.fleetConfig?.instances ?? {})) {
|
|
876
|
+
if (low(name) === lowered || low(cfg.display_name) === lowered)
|
|
877
|
+
return true;
|
|
878
|
+
}
|
|
879
|
+
for (const ch of this.classicChannels?.getAll() ?? [])
|
|
880
|
+
if (low(ch.instanceName) === lowered || low(ch.displayName) === lowered)
|
|
881
|
+
return true;
|
|
882
|
+
for (const w of this.worlds.values())
|
|
883
|
+
if (w.botUsername && low(w.botUsername) === lowered)
|
|
884
|
+
return true;
|
|
885
|
+
return false;
|
|
886
|
+
}
|
|
804
887
|
get webPrefs() {
|
|
805
888
|
// #1664: a favorite must be an instance of this fleet now — fleet.yaml's (own keys) or a ClassicBot room's.
|
|
806
889
|
return (this.webPrefsStore ??= new WebPrefsStore(this.dataDir, {
|
|
@@ -1149,6 +1232,8 @@ export class FleetManager {
|
|
|
1149
1232
|
mayClick: (set, userId) => this.mayAnswerReplyButtons(set.instance, set.adapterId, userId),
|
|
1150
1233
|
deliver: (set, button, by) => this.deliverReplyButtonChoice(set, button, by),
|
|
1151
1234
|
publish: (instance, view) => this.emitSseEvent("reply_buttons", { instance, buttons: view }),
|
|
1235
|
+
admitResults: (set, key, text) => this.admitPollResults(set, key, text),
|
|
1236
|
+
monotonic: () => performance.now(),
|
|
1152
1237
|
logger: this.logger,
|
|
1153
1238
|
setTimer: (fn, ms) => { const h = setTimeout(fn, ms); h.unref?.(); return h; },
|
|
1154
1239
|
clearTimer: (h) => clearTimeout(h),
|
|
@@ -1157,6 +1242,21 @@ export class FleetManager {
|
|
|
1157
1242
|
return this.replyButtonsCtl;
|
|
1158
1243
|
}
|
|
1159
1244
|
replyButtonsStore = null;
|
|
1245
|
+
/**
|
|
1246
|
+
* #1823: a closed poll's results go to its instance through the delivery outbox, as a system notice keyed by the
|
|
1247
|
+
* poll: durable (held until the instance can take it) and admitted once, however often this is asked. Addressed to
|
|
1248
|
+
* the poll's chat, so the instance's answer (the right option, the scores) goes there.
|
|
1249
|
+
*/
|
|
1250
|
+
admitPollResults(set, key, text) {
|
|
1251
|
+
this.ensureDeliveryOutbox();
|
|
1252
|
+
const outbox = this.deliveryOutbox;
|
|
1253
|
+
if (!outbox)
|
|
1254
|
+
return false;
|
|
1255
|
+
outbox.admitSystemNotice(set.instance, key, text, new Date(), {
|
|
1256
|
+
chatId: set.chatId, threadId: set.threadId || undefined, adapterId: set.adapterId === "web" ? undefined : set.adapterId,
|
|
1257
|
+
});
|
|
1258
|
+
return true;
|
|
1259
|
+
}
|
|
1160
1260
|
/**
|
|
1161
1261
|
* #1266: who may answer a reply's buttons on a platform — whoever may message that instance there: in a ClassicBot
|
|
1162
1262
|
* room anyone there (as a typed message), elsewhere someone the sending connection lets speak (#754/#1148), never a
|
|
@@ -1220,8 +1320,10 @@ export class FleetManager {
|
|
|
1220
1320
|
return false;
|
|
1221
1321
|
reactions.consume();
|
|
1222
1322
|
this.afterUserInboundDelivered(set.instance, inbound, deliveryEpoch);
|
|
1323
|
+
// The web chat shows the click as its button's label (a chip); `text` stays what the agent was given.
|
|
1223
1324
|
this.emitSseEvent("message", {
|
|
1224
1325
|
instance: set.instance, sender: by.username, role: "user", text: content, ts, ...(web ? { messageId } : {}),
|
|
1326
|
+
click: { label: button.label, of: set.id },
|
|
1225
1327
|
});
|
|
1226
1328
|
return true;
|
|
1227
1329
|
}
|
|
@@ -1564,6 +1666,7 @@ export class FleetManager {
|
|
|
1564
1666
|
content: payload.content,
|
|
1565
1667
|
delivery_id: claimed.deliveryId,
|
|
1566
1668
|
delivery_attempt: String(claimed.attemptNo),
|
|
1669
|
+
...(isSilentScheduleDelivery(claimed) ? { silent_schedule: true } : {}),
|
|
1567
1670
|
}, { waitForIdle: false, noInlineWake: this.deliveryWorkerMode(target) !== "off" });
|
|
1568
1671
|
}
|
|
1569
1672
|
else {
|
|
@@ -2203,7 +2306,7 @@ export class FleetManager {
|
|
|
2203
2306
|
async handleUpdateSlash(data, adapterId) {
|
|
2204
2307
|
const gate = this.fleetAdminGate(data.userId, adapterId);
|
|
2205
2308
|
if (gate !== "ok") {
|
|
2206
|
-
await data.respond(
|
|
2309
|
+
await data.respond(gate === "disabled" ? t("update.disabled", this.adminListKey(adapterId)) : t("not_authorized"));
|
|
2207
2310
|
return;
|
|
2208
2311
|
}
|
|
2209
2312
|
const messageId = await data.respond(t("update.progress.preparing", 0));
|
|
@@ -2218,24 +2321,29 @@ export class FleetManager {
|
|
|
2218
2321
|
// "not a beta" and sent a beta install to @latest. #1450 C5: the INSTALLED agend, verified, by absolute path.
|
|
2219
2322
|
const installed = await resolveInstalledAgend();
|
|
2220
2323
|
if (!installed.ok) {
|
|
2221
|
-
this.failUpdateProgress(
|
|
2324
|
+
this.failUpdateProgress(`${t("update.failure.shell")} ${t("update.failure.installed", installed.reason)}`);
|
|
2222
2325
|
return;
|
|
2223
2326
|
}
|
|
2224
2327
|
const origin = `slash /update by ${adapterId}:${data.userId}`;
|
|
2225
2328
|
recordInternalRequest(this.dataDir, "update", origin);
|
|
2226
2329
|
const launch = await resolveUpdateLaunch(installed.agend);
|
|
2227
2330
|
if (!launch.ok) {
|
|
2228
|
-
this.failUpdateProgress(
|
|
2331
|
+
this.failUpdateProgress(`${t("update.failure.shell")} ${t("update.failure.independent", launch.reason)}`);
|
|
2229
2332
|
return;
|
|
2230
2333
|
}
|
|
2231
2334
|
if (this.fleetAdminGate(data.userId, adapterId) !== "ok" || this.shuttingDown) {
|
|
2232
2335
|
this.failUpdateProgress(t("not_authorized"));
|
|
2233
2336
|
return;
|
|
2234
2337
|
}
|
|
2338
|
+
const logPath = updateLogPath(this.dataDir);
|
|
2339
|
+
const ts = new Date().toISOString();
|
|
2340
|
+
const logFd = openUpdateLog(logPath, `--- /update ${ts} ---`);
|
|
2235
2341
|
const child = spawn(launch.command, launch.args, {
|
|
2236
|
-
detached: true, stdio: "ignore", env: withOrigin(origin),
|
|
2342
|
+
detached: true, stdio: ["ignore", logFd ? logFd.fd : "ignore", logFd ? logFd.fd : "ignore"], env: withOrigin(origin),
|
|
2237
2343
|
});
|
|
2238
|
-
|
|
2344
|
+
if (logFd)
|
|
2345
|
+
closeSync(logFd.fd); // parent closes its copy; the child's fd stays open
|
|
2346
|
+
watchUpdateLaunch(child, message => this.failUpdateProgress(message), logFd ? logPath : undefined, logFd?.offset, installed.agend);
|
|
2239
2347
|
child.unref();
|
|
2240
2348
|
}
|
|
2241
2349
|
async handleProfileSlash(data, adapterId) {
|
|
@@ -2708,6 +2816,32 @@ export class FleetManager {
|
|
|
2708
2816
|
emit: (event, data) => this.emitSseEvent(event, data),
|
|
2709
2817
|
});
|
|
2710
2818
|
}
|
|
2819
|
+
/** Board 003648ac: a web /compact followed to its end, from the transcript the Log reads (none: nothing to follow). */
|
|
2820
|
+
compactWatchService = null;
|
|
2821
|
+
get compactWatch() {
|
|
2822
|
+
return this.compactWatchService ??= new CompactWatch({
|
|
2823
|
+
source: (name) => { const src = this.logSource(name); return { path: src.path, format: src.format }; },
|
|
2824
|
+
// The launch that runs it now (the same value the daemon sends with raw_paste_written).
|
|
2825
|
+
owner: (name) => {
|
|
2826
|
+
const daemon = this.lifecycle.daemons.get(name);
|
|
2827
|
+
try {
|
|
2828
|
+
return daemon ? launchIdentity(daemon.getInteractionSnapshot().owner) : null;
|
|
2829
|
+
}
|
|
2830
|
+
catch {
|
|
2831
|
+
return null;
|
|
2832
|
+
}
|
|
2833
|
+
},
|
|
2834
|
+
paneFollow: (name) => {
|
|
2835
|
+
try {
|
|
2836
|
+
return this.lifecycle.daemons.get(name)?.compactPaneUi() != null;
|
|
2837
|
+
}
|
|
2838
|
+
catch {
|
|
2839
|
+
return false;
|
|
2840
|
+
}
|
|
2841
|
+
},
|
|
2842
|
+
changed: () => this.emitSseEvent("status", this.getUiStatus()),
|
|
2843
|
+
});
|
|
2844
|
+
}
|
|
2711
2845
|
/** #1218: GET /ui/steps — an instance's steps, read on demand (null: no such instance). */
|
|
2712
2846
|
logSteps(name, q) { return this.log.steps(name, q); }
|
|
2713
2847
|
/**
|
|
@@ -3117,7 +3251,8 @@ export class FleetManager {
|
|
|
3117
3251
|
* or a rebound adapter is never posted through a stale route. A copy
|
|
3118
3252
|
* already in flight may still land late — the contract permits that.
|
|
3119
3253
|
*/
|
|
3120
|
-
|
|
3254
|
+
/** `sender` (#1827): the web person's name, as the room log records them ("web-user" when none). */
|
|
3255
|
+
async sendClassicWebEcho(instance, text, sender = WEB_NAME_FALLBACK) {
|
|
3121
3256
|
const guard = this.webChannelEchoGuards.get(instance);
|
|
3122
3257
|
const handled = new Set();
|
|
3123
3258
|
let posted = 0;
|
|
@@ -3148,7 +3283,7 @@ export class FleetManager {
|
|
|
3148
3283
|
if (!adapter?.sendText)
|
|
3149
3284
|
continue;
|
|
3150
3285
|
await adapter.sendText(entry.channelId, text, { format: "text", allowedMentions: { parse: [] } });
|
|
3151
|
-
ClassicChannelManager.logMessage(instance,
|
|
3286
|
+
ClassicChannelManager.logMessage(instance, sender, text, new Date());
|
|
3152
3287
|
posted++;
|
|
3153
3288
|
}
|
|
3154
3289
|
catch (err) {
|
|
@@ -3475,6 +3610,20 @@ export class FleetManager {
|
|
|
3475
3610
|
hasFleetAdmins(adapterId) {
|
|
3476
3611
|
return (this.adminListOf(adapterId)?.length ?? 0) > 0;
|
|
3477
3612
|
}
|
|
3613
|
+
/**
|
|
3614
|
+
* Where this adapter's fleet-admin list is in fleet.yaml, as the operator wrote it, for the "disabled" replies:
|
|
3615
|
+
* `channel.access.allowed_users` (the single-channel form), else the `channels:` entry — by its `id:`, or its
|
|
3616
|
+
* `type:` when it has none. Read from the raw file: loading turns `channel:` into `channels` and fills in ids.
|
|
3617
|
+
*/
|
|
3618
|
+
adminListKey(adapterId) {
|
|
3619
|
+
const raw = this.rawFleetConfig;
|
|
3620
|
+
if (!Array.isArray(raw.channels) && raw.channel)
|
|
3621
|
+
return "channel.access.allowed_users";
|
|
3622
|
+
const channels = Array.isArray(raw.channels) ? raw.channels : this.fleetConfig?.channels ?? [];
|
|
3623
|
+
const id = adapterId ?? this.getPrimaryAdapterId();
|
|
3624
|
+
const entry = channels.find(ch => (ch.id ?? ch.type) === id);
|
|
3625
|
+
return `channels[${entry?.id ? `id: ${entry.id}` : `type: ${entry?.type ?? id}`}].access.allowed_users`;
|
|
3626
|
+
}
|
|
3478
3627
|
/**
|
|
3479
3628
|
* The one question the privileged commands (/update, /doctor, /dashboard, /collab) ask: may THIS caller,
|
|
3480
3629
|
* through THIS adapter, run it. `disabled` is "nobody can" — an empty list — and is told apart from
|
|
@@ -3552,6 +3701,9 @@ export class FleetManager {
|
|
|
3552
3701
|
if (!decision.allow) {
|
|
3553
3702
|
this.logger.info({ command: data.command, adapterId, scope, reply: decision.reply[0] }, "Slash command refused by the command table");
|
|
3554
3703
|
const [key, ...args] = decision.reply;
|
|
3704
|
+
// A command off for want of fleet admins names where they go (`{0}` in its disabled reply).
|
|
3705
|
+
if (decision.reply === spec.disabled)
|
|
3706
|
+
args.push(this.adminListKey(adapterId));
|
|
3555
3707
|
await data.respond(t(key, ...args)).catch(() => { });
|
|
3556
3708
|
return;
|
|
3557
3709
|
}
|
|
@@ -3611,6 +3763,9 @@ export class FleetManager {
|
|
|
3611
3763
|
else if (data.command === "model") {
|
|
3612
3764
|
await this.handleModelSlash(data, adapterId);
|
|
3613
3765
|
}
|
|
3766
|
+
else if (data.command === "backend") {
|
|
3767
|
+
await this.handleBackendSlash(data, adapterId);
|
|
3768
|
+
}
|
|
3614
3769
|
else if (data.command === "clear") {
|
|
3615
3770
|
await this.handleClearSlash(data, adapterId);
|
|
3616
3771
|
}
|
|
@@ -3704,7 +3859,7 @@ export class FleetManager {
|
|
|
3704
3859
|
await data.respond(result.durable ? t("dashboard.revoked", result.count) : t("dashboard.revoked_not_durable", result.count));
|
|
3705
3860
|
}
|
|
3706
3861
|
else {
|
|
3707
|
-
const owner = this.dashboardOwner(data.userId, adapterId, data.channelId);
|
|
3862
|
+
const owner = this.dashboardOwner(data.userId, adapterId, data.channelId, undefined, data.username);
|
|
3708
3863
|
if (!owner) {
|
|
3709
3864
|
await this.sendLocalDashboard(data, adapterId);
|
|
3710
3865
|
return;
|
|
@@ -5026,7 +5181,7 @@ export class FleetManager {
|
|
|
5026
5181
|
&& cfg.topic_id?.toString() === owner.threadId);
|
|
5027
5182
|
}
|
|
5028
5183
|
/** Exact owning General. No primary-adapter or name fallback participates in authorization. */
|
|
5029
|
-
dashboardOwner(userId, adapterId, address, threadId) {
|
|
5184
|
+
dashboardOwner(userId, adapterId, address, threadId, username) {
|
|
5030
5185
|
const world = this.worlds.get(adapterId);
|
|
5031
5186
|
if (!world || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(userId, adapterId))
|
|
5032
5187
|
return null;
|
|
@@ -5041,7 +5196,7 @@ export class FleetManager {
|
|
|
5041
5196
|
const matched = dc ? topic === address || (group === address && topic === threadId)
|
|
5042
5197
|
: group === address && ((topic === "1" && (threadId === undefined || threadId === "1")) || topic === threadId);
|
|
5043
5198
|
if (group && matched)
|
|
5044
|
-
return { userId, adapterId, chatId: group, threadId: topic, binding: world.adapter };
|
|
5199
|
+
return { userId, adapterId, chatId: group, threadId: topic, binding: world.adapter, ...(username ? { username } : {}) };
|
|
5045
5200
|
}
|
|
5046
5201
|
return null;
|
|
5047
5202
|
}
|
|
@@ -5050,7 +5205,7 @@ export class FleetManager {
|
|
|
5050
5205
|
* — this bot's configured group and its one General topic. No address to match in a DM, so it must be unambiguous:
|
|
5051
5206
|
* no General, or more than one on this bot and group, and there is no owner (nothing is guessed).
|
|
5052
5207
|
*/
|
|
5053
|
-
dmDashboardOwner(userId, adapterId) {
|
|
5208
|
+
dmDashboardOwner(userId, adapterId, username) {
|
|
5054
5209
|
const world = this.worlds.get(adapterId);
|
|
5055
5210
|
if (!world || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(userId, adapterId))
|
|
5056
5211
|
return null;
|
|
@@ -5064,7 +5219,7 @@ export class FleetManager {
|
|
|
5064
5219
|
.map(([, cfg]) => cfg.topic_id.toString());
|
|
5065
5220
|
if (topics.length !== 1)
|
|
5066
5221
|
return null;
|
|
5067
|
-
const owner = { userId, adapterId, chatId: group, threadId: topics[0], binding: world.adapter };
|
|
5222
|
+
const owner = { userId, adapterId, chatId: group, threadId: topics[0], binding: world.adapter, ...(username ? { username } : {}) };
|
|
5068
5223
|
return this.publicOwnerCurrent(owner) ? owner : null;
|
|
5069
5224
|
}
|
|
5070
5225
|
/**
|
|
@@ -5091,7 +5246,7 @@ export class FleetManager {
|
|
|
5091
5246
|
async startWebInDm(msg, adapter) {
|
|
5092
5247
|
if (!adapter)
|
|
5093
5248
|
return;
|
|
5094
|
-
const owner = msg.chatId === msg.userId ? this.dmDashboardOwner(msg.userId, msg.adapterId ?? "") : null;
|
|
5249
|
+
const owner = msg.chatId === msg.userId ? this.dmDashboardOwner(msg.userId, msg.adapterId ?? "", msg.username) : null;
|
|
5095
5250
|
if (!owner) {
|
|
5096
5251
|
await adapter.sendText(msg.chatId, t("dashboard.start_web_ack"));
|
|
5097
5252
|
return;
|
|
@@ -5144,7 +5299,7 @@ export class FleetManager {
|
|
|
5144
5299
|
/** Telegram's typed command enters through the same strict owner and nonce path as Discord. */
|
|
5145
5300
|
async dashboardMenu(msg) {
|
|
5146
5301
|
const id = msg.adapterId;
|
|
5147
|
-
const owner = id ? this.dashboardOwner(msg.userId, id, msg.chatId, msg.threadId) : null;
|
|
5302
|
+
const owner = id ? this.dashboardOwner(msg.userId, id, msg.chatId, msg.threadId, msg.username) : null;
|
|
5148
5303
|
if (!owner) {
|
|
5149
5304
|
const adapter = id ? this.adapters.get(id) : undefined;
|
|
5150
5305
|
await adapter?.sendText(msg.chatId, t("dashboard.public_general"), { threadId: msg.threadId });
|
|
@@ -5177,7 +5332,9 @@ export class FleetManager {
|
|
|
5177
5332
|
return;
|
|
5178
5333
|
}
|
|
5179
5334
|
this.initializeWebSessions();
|
|
5180
|
-
|
|
5335
|
+
// #1827: the code goes privately to this account (a DM, or else this slash command's ephemeral answer): whoever
|
|
5336
|
+
// redeems it is recognised as that account. Recognition only — no owner (this is not an owning General's code).
|
|
5337
|
+
const login = this.webLoginCodes.issue({ epoch: tokenEpoch(token), recipient: { adapterId, userId: data.userId, ...(data.username ? { username: data.username } : {}) } });
|
|
5181
5338
|
const current = () => !this.shuttingDown && this.adapters.get(adapterId) === adapter
|
|
5182
5339
|
&& this.webToken === token && this.hasFleetAdmins(adapterId) && this.isFleetAdmin(data.userId, adapterId);
|
|
5183
5340
|
const privateText = `${text}\n${t("dashboard.code", login.display, Math.round(LOGIN_CODE_TTL_MS / 60_000))}`;
|
|
@@ -5289,13 +5446,12 @@ export class FleetManager {
|
|
|
5289
5446
|
const adapter = owner.binding;
|
|
5290
5447
|
const status = this.getPublicWebStatus();
|
|
5291
5448
|
const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
|
|
5292
|
-
|
|
5293
|
-
const menuText = this.topicCommands.
|
|
5294
|
-
+ (publicAllowed ? "\n
|
|
5295
|
-
+ "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
const choices = [{ action: "local", label: t("dashboard.local") },
|
|
5449
|
+
// Show the action before any link exists. TG's Start instructions belong to confirmed DM refusal below.
|
|
5450
|
+
const menuText = this.topicCommands.getDashboardMenuText()
|
|
5451
|
+
+ (publicAllowed ? "\n" + t("dashboard.public_risk") : "")
|
|
5452
|
+
+ (status.state !== "closed" ? "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
|
|
5453
|
+
+ (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "") : "");
|
|
5454
|
+
const choices = [{ action: "local", label: t("dashboard.local_signin") },
|
|
5299
5455
|
...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
|
|
5300
5456
|
...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
|
|
5301
5457
|
const posted = await this.postNonceButtonPrompt({
|
|
@@ -5371,9 +5527,11 @@ export class FleetManager {
|
|
|
5371
5527
|
return false;
|
|
5372
5528
|
this.initializeWebSessions();
|
|
5373
5529
|
const issued = this.webLoginCodes.issue({ epoch: tokenEpoch(token), audience: exposureId, owner });
|
|
5374
|
-
//
|
|
5375
|
-
const
|
|
5376
|
-
|
|
5530
|
+
// Only a public link has a link expiry; the code's five-minute lifetime applies to either packet.
|
|
5531
|
+
const minutes = Math.round(LOGIN_CODE_TTL_MS / 60_000);
|
|
5532
|
+
const text = expiresAt !== undefined
|
|
5533
|
+
? t("dashboard.private_public", url, issued.display, minutes, formatFleetTime(expiresAt))
|
|
5534
|
+
: t("dashboard.private_local", url, issued.display, minutes);
|
|
5377
5535
|
const deadline = performance.now() + 10_000;
|
|
5378
5536
|
const direct = owner.binding;
|
|
5379
5537
|
// The route the sign-in message took: the code-only follow-up takes the same one (#1586).
|
|
@@ -5562,7 +5720,6 @@ export class FleetManager {
|
|
|
5562
5720
|
messageId,
|
|
5563
5721
|
});
|
|
5564
5722
|
this.lastUpdateProgressText = null;
|
|
5565
|
-
this.updateCompletionTipText = null;
|
|
5566
5723
|
this.startUpdateProgressMonitor(adapter);
|
|
5567
5724
|
}
|
|
5568
5725
|
/** Persist the public response, wait for idle, then start the canonical service restart. */
|
|
@@ -5593,7 +5750,6 @@ export class FleetManager {
|
|
|
5593
5750
|
return false;
|
|
5594
5751
|
}
|
|
5595
5752
|
this.lastUpdateProgressText = null;
|
|
5596
|
-
this.updateCompletionTipText = null;
|
|
5597
5753
|
this.startUpdateProgressMonitor(adapter);
|
|
5598
5754
|
await this.waitForFullRestartIdleGrace();
|
|
5599
5755
|
// An update can begin while the idle wait yields. Never launch a second
|
|
@@ -5833,17 +5989,8 @@ export class FleetManager {
|
|
|
5833
5989
|
const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
|
|
5834
5990
|
if (!adapter)
|
|
5835
5991
|
return;
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
// reach RestartProgress.finish(). Append the same optional tip here.
|
|
5839
|
-
if (marker.progress.stage === "complete" && this.tipsEnabled()) {
|
|
5840
|
-
if (this.updateCompletionTipText === null) {
|
|
5841
|
-
const tip = this.pickAvailableTip();
|
|
5842
|
-
this.updateCompletionTipText = tip ? this.formatTip(tip) : "";
|
|
5843
|
-
}
|
|
5844
|
-
if (this.updateCompletionTipText)
|
|
5845
|
-
text += `\n\n${this.updateCompletionTipText}`;
|
|
5846
|
-
}
|
|
5992
|
+
// #1854: no tip on an update's completion (either path); tips come on their own schedule and from /tips.
|
|
5993
|
+
const text = formatUpdateProgress(marker);
|
|
5847
5994
|
if (text === this.lastUpdateProgressText)
|
|
5848
5995
|
return;
|
|
5849
5996
|
this.updateProgressEditRunning = true;
|
|
@@ -5894,6 +6041,7 @@ export class FleetManager {
|
|
|
5894
6041
|
this.rotateFleetLogs();
|
|
5895
6042
|
const fleet = this.loadConfig(configPath);
|
|
5896
6043
|
this.slimFleetConfigAtStartup();
|
|
6044
|
+
this.logConfigWarningsAtStartup();
|
|
5897
6045
|
setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
|
|
5898
6046
|
// #1737 review: the marker this boot adopts — the startup clear may only ever clear this one.
|
|
5899
6047
|
const bootUpdateMarker = updateMarkerIdentity(this.dataDir);
|
|
@@ -6229,6 +6377,7 @@ export class FleetManager {
|
|
|
6229
6377
|
// into the same broken state, and a user who has legitimately stopped every
|
|
6230
6378
|
// instance would get a restart loop. Those conditions surface through /health
|
|
6231
6379
|
// (which now returns 503) and through the General-topic notifications instead.
|
|
6380
|
+
setSdNotifyWarn((fields, message) => this.logger.warn(fields, message));
|
|
6232
6381
|
this.watchdogTimer = setInterval(() => sdNotify("WATCHDOG=1"), 30_000);
|
|
6233
6382
|
// #1231: a stalled loop is what turns a slash command into "did not respond";
|
|
6234
6383
|
// put each long stall in the log instead of leaving it to be guessed at.
|
|
@@ -6347,12 +6496,6 @@ export class FleetManager {
|
|
|
6347
6496
|
pausedNames,
|
|
6348
6497
|
failedNames,
|
|
6349
6498
|
backgroundWorkEndedNames,
|
|
6350
|
-
tipText: pendingProgressOperation === "update" && this.tipsEnabled()
|
|
6351
|
-
? (() => {
|
|
6352
|
-
const tip = this.pickAvailableTip();
|
|
6353
|
-
return tip ? this.formatTip(tip) : undefined;
|
|
6354
|
-
})()
|
|
6355
|
-
: undefined,
|
|
6356
6499
|
...(cleanupOutcome && "render" in cleanupOutcome
|
|
6357
6500
|
? { warningText: cleanupOutcome.render.warning, warningCommand: cleanupOutcome.render.cleanupCommand }
|
|
6358
6501
|
: {}),
|
|
@@ -6403,6 +6546,7 @@ export class FleetManager {
|
|
|
6403
6546
|
// Health HTTP endpoint
|
|
6404
6547
|
this.startHealthServer(fleet.health_port ?? 19280);
|
|
6405
6548
|
// #1266: buttons that ended while AgEnD was down (expired, or chosen before a restart) are shown as ended now.
|
|
6549
|
+
// #1823: and polls: a deadline that passed meanwhile closes now (results once); an open one's countdown is redrawn.
|
|
6406
6550
|
void this.replyButtons()?.sweep().catch(err => this.logger.warn({ err }, "Reply-button sweep failed"));
|
|
6407
6551
|
// Daily update check — first check after 1 hour, then every 24 hours
|
|
6408
6552
|
this.updateCheckTimer = setTimeout(() => {
|
|
@@ -7144,6 +7288,16 @@ export class FleetManager {
|
|
|
7144
7288
|
});
|
|
7145
7289
|
}
|
|
7146
7290
|
}
|
|
7291
|
+
else if (msg.type === "raw_paste_written") {
|
|
7292
|
+
// Board 003648ac: a web /compact's paste reached the pane (or never will): its watch starts, or ends as lost.
|
|
7293
|
+
if (typeof msg.id === "string")
|
|
7294
|
+
this.compactWatchService?.pasted(name, msg.id, msg.outcome === "written", { owner: msg.owner, checkpoint: msg.checkpoint, pane: msg.pane });
|
|
7295
|
+
}
|
|
7296
|
+
else if (msg.type === "compact_pane_result") {
|
|
7297
|
+
// Board 907c7899: kiro's pane said how that /compact ended.
|
|
7298
|
+
if (typeof msg.id === "string")
|
|
7299
|
+
this.compactWatchService?.paneResult(name, msg.id, msg.owner, msg.outcome);
|
|
7300
|
+
}
|
|
7147
7301
|
else if (msg.type === "runtime_switch_result") {
|
|
7148
7302
|
// #1690: what our own runtime /model came to (the daemon answered claude's "Switch model?" or saw the result).
|
|
7149
7303
|
const waiter = typeof msg.id === "string" ? this.runtimeSwitchWaiters.get(msg.id) : undefined;
|
|
@@ -7938,6 +8092,21 @@ export class FleetManager {
|
|
|
7938
8092
|
}
|
|
7939
8093
|
return;
|
|
7940
8094
|
}
|
|
8095
|
+
// #1806: /backend — channel admin, as /model. A ClassicBot agent is not switched by it (it says so).
|
|
8096
|
+
if (text === "/backend" || text.startsWith("/backend ") || text.startsWith("/backend@")) {
|
|
8097
|
+
if (!this.isModelAdmin(msg.userId, chatId, msg.adapterId)) {
|
|
8098
|
+
await msgAdapter?.sendText(chatId, t("permission.denied"));
|
|
8099
|
+
return;
|
|
8100
|
+
}
|
|
8101
|
+
const backendInstance = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
8102
|
+
if (!backendInstance) {
|
|
8103
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
8104
|
+
return;
|
|
8105
|
+
}
|
|
8106
|
+
if (msgAdapter)
|
|
8107
|
+
await this.runBackendCommand(backendInstance, text, msg.userId, chatId, msgAdapter, chatId, undefined, msg.adapterId);
|
|
8108
|
+
return;
|
|
8109
|
+
}
|
|
7941
8110
|
// Handle /compact command (admin only)
|
|
7942
8111
|
const classicCompact = parseCompactCommand(text);
|
|
7943
8112
|
if (classicCompact) {
|
|
@@ -8337,7 +8506,7 @@ export class FleetManager {
|
|
|
8337
8506
|
if (!claimNotice(path, WEB_CHAT_NOTICE, adapterId))
|
|
8338
8507
|
continue;
|
|
8339
8508
|
try {
|
|
8340
|
-
await world.adapter.sendText(target.chatId, t("upgrade.web_chat"
|
|
8509
|
+
await world.adapter.sendText(target.chatId, t("upgrade.web_chat"), target.opts);
|
|
8341
8510
|
this.logger.info({ adapterId, notice: WEB_CHAT_NOTICE }, "Announced web chat in General");
|
|
8342
8511
|
}
|
|
8343
8512
|
catch (err) {
|
|
@@ -8500,6 +8669,17 @@ export class FleetManager {
|
|
|
8500
8669
|
respond(null, `reply: ${parsedButtons.error}`);
|
|
8501
8670
|
return;
|
|
8502
8671
|
}
|
|
8672
|
+
// #1823: a poll is checked with its buttons. Where it cannot be shown it is refused, never sent as text: text
|
|
8673
|
+
// options would never come back as results.
|
|
8674
|
+
const parsedPoll = parsePoll(args.poll, parsedButtons && "buttons" in parsedButtons ? parsedButtons.buttons : null, args.text);
|
|
8675
|
+
if (parsedPoll && "error" in parsedPoll) {
|
|
8676
|
+
respond(null, `reply: ${parsedPoll.error}`);
|
|
8677
|
+
return;
|
|
8678
|
+
}
|
|
8679
|
+
if (parsedPoll && (!outAdapter.supportsPolls || !this.replyButtons())) {
|
|
8680
|
+
respond(null, `reply: a poll cannot be shown here${outAdapter.supportsPolls ? " (reply buttons are unavailable until AgEnD restarts)" : " — polls work in Discord and Telegram chats and the web chat"}; send the question without poll`);
|
|
8681
|
+
return;
|
|
8682
|
+
}
|
|
8503
8683
|
// Stickers (#1226) are checked before anything is sent: a refused one is the reply's error, not a gap.
|
|
8504
8684
|
const stickerProblem = await this.replyStickerProblem(outAdapter, args, threadId, contextAdapterId ?? this.getInstanceAdapterId(senderInstanceName ?? instanceName));
|
|
8505
8685
|
if (stickerProblem) {
|
|
@@ -8519,7 +8699,7 @@ export class FleetManager {
|
|
|
8519
8699
|
const buttons = outAdapter.supportsReplyButtons ? this.replyButtons() : null;
|
|
8520
8700
|
if (buttons) {
|
|
8521
8701
|
const outId = outAdapter === WEB_ONLY_REPLY_SINK ? "web" : outAdapter.id ?? contextAdapterId ?? "";
|
|
8522
|
-
replySet = buttons.prepare({ instance: instanceName, adapterId: outId, chatId: String(args.chat_id ?? ""), threadId }, parsedButtons.buttons);
|
|
8702
|
+
replySet = buttons.prepare({ instance: instanceName, adapterId: outId, chatId: String(args.chat_id ?? ""), threadId }, parsedButtons.buttons, parsedPoll && "poll" in parsedPoll ? parsedPoll.poll : null);
|
|
8523
8703
|
}
|
|
8524
8704
|
else {
|
|
8525
8705
|
args.text = `${String(args.text)}\n\n${replyButtonsFallbackText(parsedButtons.buttons)}`;
|
|
@@ -8539,6 +8719,9 @@ export class FleetManager {
|
|
|
8539
8719
|
this.replyButtonsCtl?.discard(replySet.id);
|
|
8540
8720
|
}
|
|
8541
8721
|
}
|
|
8722
|
+
// #1823: the agent learns its poll's id (the results name it).
|
|
8723
|
+
if (replySet?.poll && !error && result && typeof result === "object")
|
|
8724
|
+
result = { ...result, poll_id: replySet.id };
|
|
8542
8725
|
ticket.complete(result, error);
|
|
8543
8726
|
// Return the platform outcome first. Bookkeeping below must never turn
|
|
8544
8727
|
// a confirmed Discord/Telegram POST into a tool error if a secondary
|
|
@@ -8566,7 +8749,7 @@ export class FleetManager {
|
|
|
8566
8749
|
});
|
|
8567
8750
|
}
|
|
8568
8751
|
};
|
|
8569
|
-
if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord, replySet ? { replyButtons: replySet.callbacks } : {})) {
|
|
8752
|
+
if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord, replySet ? { replyButtons: replySet.callbacks, ...(replySet.poll ? { replyPoll: replySet.poll } : {}) } : {})) {
|
|
8570
8753
|
return;
|
|
8571
8754
|
}
|
|
8572
8755
|
if (replySet)
|
|
@@ -9929,6 +10112,21 @@ export class FleetManager {
|
|
|
9929
10112
|
? `Refusing to save fleet.yaml: it would be invalid (${introduced.map(e => `${e.path}: ${e.message}`).join("; ")})`
|
|
9930
10113
|
: null;
|
|
9931
10114
|
}
|
|
10115
|
+
/**
|
|
10116
|
+
* #1874 follow-up: the validator's warnings, once per start, in daemon.log — a setting fleet.yaml has that AgEnD
|
|
10117
|
+
* ignores (e.g. `web.access`) is otherwise silent unless someone runs `agend validate`.
|
|
10118
|
+
*/
|
|
10119
|
+
logConfigWarningsAtStartup() {
|
|
10120
|
+
let warnings = [];
|
|
10121
|
+
try {
|
|
10122
|
+
warnings = validateFleetConfig(this.rawFleetConfig).warnings;
|
|
10123
|
+
}
|
|
10124
|
+
catch (err) {
|
|
10125
|
+
this.logger.debug({ err }, "fleet.yaml validation failed (startup warnings)");
|
|
10126
|
+
}
|
|
10127
|
+
for (const w of warnings)
|
|
10128
|
+
this.logger.warn({ path: w.path }, `fleet.yaml: ${w.path ? `${w.path}: ` : ""}${w.message}`);
|
|
10129
|
+
}
|
|
9932
10130
|
/** One-time upgrade migration; invalid YAML is never rewritten. */
|
|
9933
10131
|
slimFleetConfigAtStartup() {
|
|
9934
10132
|
const redundantPaths = collectRedundantInstanceDefaultPaths(this.rawFleetConfig);
|
|
@@ -10102,6 +10300,7 @@ export class FleetManager {
|
|
|
10102
10300
|
// must not be shown the old one's conversation, and deleted names must not pile up.
|
|
10103
10301
|
this.webChatHistory.forget(name);
|
|
10104
10302
|
this.webFiles.forget(name);
|
|
10303
|
+
this.compactWatchService?.forget(name);
|
|
10105
10304
|
// #1664: and it is nobody's favorite any more (every device sees that at its next status frame).
|
|
10106
10305
|
try {
|
|
10107
10306
|
this.webPrefs.forgetInstance(name);
|
|
@@ -10309,10 +10508,12 @@ export class FleetManager {
|
|
|
10309
10508
|
return this.webImageCaches[kind];
|
|
10310
10509
|
}
|
|
10311
10510
|
/**
|
|
10312
|
-
*
|
|
10313
|
-
* whatever an instance still names — #1635 review)
|
|
10511
|
+
* The bots instances and ClassicBot rooms speak through, by connections configured now (a connection removed from
|
|
10512
|
+
* fleet.yaml is nobody's, whatever an instance still names — #1635 review). `own`: instance → its own bot (answering
|
|
10513
|
+
* for it alone; the primary too, when it serves one instance only). `speaking` (#1867): instance → the bot it speaks through — its own, a shared one,
|
|
10514
|
+
* or, with none configured for it, the primary.
|
|
10314
10515
|
*/
|
|
10315
|
-
|
|
10516
|
+
webBots() {
|
|
10316
10517
|
const instances = this.fleetConfig?.instances ?? {};
|
|
10317
10518
|
const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
10318
10519
|
const configured = new Set(channels.map(ch => ch.id ?? ch.type).filter((id) => typeof id === "string"));
|
|
@@ -10322,8 +10523,17 @@ export class FleetManager {
|
|
|
10322
10523
|
if (!Object.hasOwn(instances, room.instanceName))
|
|
10323
10524
|
assignments.push([room.instanceName, assigned(room.instanceName)]);
|
|
10324
10525
|
}
|
|
10325
|
-
|
|
10526
|
+
const primary = this.getPrimaryAdapterId(), fallback = primary && configured.has(primary) ? primary : undefined;
|
|
10527
|
+
const speaking = new Map();
|
|
10528
|
+
for (const [instance, connection] of assignments) {
|
|
10529
|
+
const c = connection ?? fallback;
|
|
10530
|
+
if (c)
|
|
10531
|
+
speaking.set(instance, c);
|
|
10532
|
+
}
|
|
10533
|
+
return { own: ownBotConnections(assignments), speaking };
|
|
10326
10534
|
}
|
|
10535
|
+
/** instance → the connection that is its own bot (see webBots). */
|
|
10536
|
+
ownBots() { return this.webBots().own; }
|
|
10327
10537
|
/**
|
|
10328
10538
|
* The bot a connection is now: its running adapter and that bot's own user id. Everything cached for an own bot is
|
|
10329
10539
|
* keyed by both, so a bot put in its place (a token replaced under the same connection id) never shows the old one's
|
|
@@ -10366,8 +10576,11 @@ export class FleetManager {
|
|
|
10366
10576
|
},
|
|
10367
10577
|
};
|
|
10368
10578
|
}
|
|
10369
|
-
/**
|
|
10370
|
-
|
|
10579
|
+
/**
|
|
10580
|
+
* One instance's avatar and ring for the status frame. Synchronous: what is cached now; fetches run behind. The bot
|
|
10581
|
+
* is its own, else the one it speaks through (#1867) — one cache entry per bot, so a shared bot is fetched once.
|
|
10582
|
+
*/
|
|
10583
|
+
webIdentityImage(name, displayName, profileAvatar, bots) {
|
|
10371
10584
|
let profileVersion = null;
|
|
10372
10585
|
if (profileAvatar && imageMimeForFile(profileAvatar)) {
|
|
10373
10586
|
try {
|
|
@@ -10375,22 +10588,26 @@ export class FleetManager {
|
|
|
10375
10588
|
}
|
|
10376
10589
|
catch { /* removed */ }
|
|
10377
10590
|
}
|
|
10378
|
-
const
|
|
10591
|
+
const ownConnection = bots.own.get(name), connection = ownConnection ?? bots.speaking.get(name);
|
|
10592
|
+
const owner = connection ? this.botOwner(connection) : null;
|
|
10379
10593
|
const source = connection && owner?.adapter.fetchBotAvatar ? this.botAvatarSource(connection, owner) : null;
|
|
10380
10594
|
const bot = source ? this.webImageCache("avatars").peek(source.key, source.load) : null;
|
|
10381
10595
|
const cfg = this.ownInstanceConfig(name);
|
|
10382
10596
|
const avatar = avatarFor({ name, displayName, profileVersion, bot: bot && connection ? { connection, version: bot.version } : null,
|
|
10383
10597
|
personaEmoji: cfg?.status_emojis?.delivered });
|
|
10384
|
-
// The ring belongs to
|
|
10385
|
-
const accent = !profileVersion && bot &&
|
|
10386
|
-
? this.webAccent(
|
|
10598
|
+
// The ring belongs to an own bot's face: only when that is what is shown (a shared bot's would ring everyone alike).
|
|
10599
|
+
const accent = !profileVersion && bot && ownConnection && owner
|
|
10600
|
+
? this.webAccent(ownConnection, owner, cfg ? (cfg.topic_id == null ? undefined : String(cfg.topic_id)) : this.classicChannels?.getChannelIdByInstance(name))
|
|
10387
10601
|
: null;
|
|
10388
10602
|
return accent ? { avatar, accent } : { avatar };
|
|
10389
10603
|
}
|
|
10390
|
-
/**
|
|
10604
|
+
/**
|
|
10605
|
+
* GET /ui/img/bot|avatar/<key>: what is cached, never a fetch. Only a connection some instance or room speaks
|
|
10606
|
+
* through (#1867), or a known instance's upload.
|
|
10607
|
+
*/
|
|
10391
10608
|
webImage(kind, key) {
|
|
10392
10609
|
if (kind === "bot") {
|
|
10393
|
-
if (![...this.
|
|
10610
|
+
if (![...this.webBots().speaking.values()].includes(key))
|
|
10394
10611
|
return null;
|
|
10395
10612
|
const owner = this.botOwner(key);
|
|
10396
10613
|
return owner ? this.webImageCache("avatars").read(this.botAvatarSource(key, owner).key) : null;
|
|
@@ -11827,6 +12044,8 @@ export class FleetManager {
|
|
|
11827
12044
|
return true;
|
|
11828
12045
|
if (await this.handleEffortSelection(data, adapterId))
|
|
11829
12046
|
return true;
|
|
12047
|
+
if (await this.handleBackendSelection(data, adapterId))
|
|
12048
|
+
return true;
|
|
11830
12049
|
if (await this.handleHangPrompt(data, adapterId, adapter))
|
|
11831
12050
|
return true;
|
|
11832
12051
|
if (data.callbackData.startsWith("cancel:")) {
|
|
@@ -12094,10 +12313,18 @@ export class FleetManager {
|
|
|
12094
12313
|
* The caller has passed the /ui gate (session, same origin, CSRF). Here: the prompt must be one offered
|
|
12095
12314
|
* on the dashboard, about the instance the page named, and the action one of its own buttons.
|
|
12096
12315
|
*/
|
|
12097
|
-
/** #
|
|
12098
|
-
|
|
12316
|
+
/** #1823: close_poll — `instance` closes one of its own polls before the deadline. */
|
|
12317
|
+
closePollEarly(instance, id) {
|
|
12099
12318
|
const buttons = this.replyButtons();
|
|
12100
|
-
return buttons ? buttons.
|
|
12319
|
+
return buttons ? buttons.closeEarly(instance, id) : Promise.resolve({ ok: false, error: "polls are unavailable until AgEnD restarts" });
|
|
12320
|
+
}
|
|
12321
|
+
/**
|
|
12322
|
+
* #1266: a click on a reply's button in the web chat (the /ui gate is passed: a session, or the public link). On a
|
|
12323
|
+
* poll (#1823) it is `voter`'s vote: the page's session.
|
|
12324
|
+
*/
|
|
12325
|
+
clickWebReplyButton(instance, id, index, voter) {
|
|
12326
|
+
const buttons = this.replyButtons();
|
|
12327
|
+
return buttons ? buttons.clickWeb(instance, id, index, voter) : Promise.resolve({ status: 409, error: t("reply_buttons.closed") });
|
|
12101
12328
|
}
|
|
12102
12329
|
async clickWebPrompt(instance, nonce, action) {
|
|
12103
12330
|
if (!/^[0-9a-f]{32}$/.test(nonce) || !/^[a-z][a-z-]{0,23}$/.test(action))
|
|
@@ -12450,6 +12677,8 @@ export class FleetManager {
|
|
|
12450
12677
|
}
|
|
12451
12678
|
};
|
|
12452
12679
|
}
|
|
12680
|
+
/** #1806: the web's /backend confirmations — one per question, used once, for the arguments and backend asked about. */
|
|
12681
|
+
webBackendTokens = new Map();
|
|
12453
12682
|
/** #1269: the web's /clear confirmations — one token per question, used once, for the instance as it was asked about. */
|
|
12454
12683
|
webClearTokens = new Map();
|
|
12455
12684
|
/** #1269: an instance's chat command from the web chat (web-commands.ts); `publicLink`: a gateway request. */
|
|
@@ -12457,7 +12686,9 @@ export class FleetManager {
|
|
|
12457
12686
|
const webMeta = (instance) => {
|
|
12458
12687
|
const topicId = this.fleetConfig?.instances[instance]?.topic_id;
|
|
12459
12688
|
return {
|
|
12460
|
-
|
|
12689
|
+
// #1827: a /steer or /btw reaches the agent as the web person who wrote it.
|
|
12690
|
+
chatId: this.getGroupIdForInstance(instance) || "", messageId: newWebMessageId(),
|
|
12691
|
+
username: opts.person?.name ?? WEB_NAME_FALLBACK, userId: opts.person?.userId ?? WEB_NAME_FALLBACK,
|
|
12461
12692
|
threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web", humanOrigin: webHumanOrigin(true),
|
|
12462
12693
|
};
|
|
12463
12694
|
};
|
|
@@ -12477,7 +12708,22 @@ export class FleetManager {
|
|
|
12477
12708
|
return this.classicChannels?.getChannelIdByInstance(instance) !== undefined ? "classic" : null;
|
|
12478
12709
|
},
|
|
12479
12710
|
ctx: (instance) => this.topicCommands.getCtxText(instance),
|
|
12480
|
-
|
|
12711
|
+
// Board 003648ac: a watch for the paste, armed when the daemon says it was written (raw_paste_written); one
|
|
12712
|
+
// for a command that was not handed over is dropped.
|
|
12713
|
+
compact: async (instance, instructions) => {
|
|
12714
|
+
let watch = null;
|
|
12715
|
+
try {
|
|
12716
|
+
const r = this.topicCommands.compactInstance(instance, instructions, () => (watch = this.compactWatch.begin(instance)));
|
|
12717
|
+
if (!r.sent && watch)
|
|
12718
|
+
this.compactWatch.cancel(instance, watch);
|
|
12719
|
+
return { ...r, watch: r.sent ? watch : null };
|
|
12720
|
+
}
|
|
12721
|
+
catch (err) {
|
|
12722
|
+
if (watch)
|
|
12723
|
+
this.compactWatch.cancel(instance, watch);
|
|
12724
|
+
throw err;
|
|
12725
|
+
}
|
|
12726
|
+
},
|
|
12481
12727
|
applyModel: (instance, name) => this.applyModel(instance, name),
|
|
12482
12728
|
modelChoices: choicesFromCache,
|
|
12483
12729
|
applyEffort: (instance, level) => this.applyEffort(instance, level),
|
|
@@ -12488,6 +12734,41 @@ export class FleetManager {
|
|
|
12488
12734
|
const current = this.resolveInstanceEffort(instance).effort;
|
|
12489
12735
|
return { current, options: levels.map(l => ({ id: l, label: this.effortChoiceLabel(l, current) })) };
|
|
12490
12736
|
},
|
|
12737
|
+
backendStep: async (instance, args) => {
|
|
12738
|
+
const refusal = this.backendCommandRefusal(instance);
|
|
12739
|
+
if (refusal)
|
|
12740
|
+
return { error: refusal };
|
|
12741
|
+
// The cached model lists only, as the web's /model: never a live probe on this path.
|
|
12742
|
+
const step = await backendStep(this.backendCommandDeps(instance, { live: false }), instance, backendArgs(args));
|
|
12743
|
+
if (step.kind === "refuse")
|
|
12744
|
+
return step.noop ? { text: step.text } : { error: step.text };
|
|
12745
|
+
if (step.kind === "choose") {
|
|
12746
|
+
const id = (token) => joinBackendArgs([...step.args, token]);
|
|
12747
|
+
return { choices: { prompt: step.text, current: step.current === null ? null : id(step.current), options: step.choices.map(c => ({ id: id(c.token), label: c.label })) } };
|
|
12748
|
+
}
|
|
12749
|
+
const now = performance.now();
|
|
12750
|
+
for (const [k, v] of this.webBackendTokens)
|
|
12751
|
+
if (v.deadline < now)
|
|
12752
|
+
this.webBackendTokens.delete(k);
|
|
12753
|
+
const token = randomBytes(16).toString("hex");
|
|
12754
|
+
this.webBackendTokens.set(token, {
|
|
12755
|
+
instance, asked: joinBackendArgs(backendArgs(args)), args: step.args, from: step.from,
|
|
12756
|
+
target: this.fleetConfig.instances[instance], deadline: now + CLASSIC_BACKEND_SELECTION_TIMEOUT_MS,
|
|
12757
|
+
});
|
|
12758
|
+
return { confirm: { token, message: step.text } };
|
|
12759
|
+
},
|
|
12760
|
+
backendConfirm: async (instance, args, token) => {
|
|
12761
|
+
const entry = this.webBackendTokens.get(token);
|
|
12762
|
+
this.webBackendTokens.delete(token); // used once, whatever happens next
|
|
12763
|
+
if (!entry || entry.instance !== instance || entry.asked !== joinBackendArgs(backendArgs(args)) || performance.now() > entry.deadline) {
|
|
12764
|
+
return { status: 409, text: t("backend.selection_expired") };
|
|
12765
|
+
}
|
|
12766
|
+
// The instance it was asked about: one deleted and created again under the name, stopped or not, is another.
|
|
12767
|
+
const stillCurrent = () => this.backendTargetIs(instance, entry.target);
|
|
12768
|
+
if (!stillCurrent())
|
|
12769
|
+
return { status: 409, text: t("menu.click_stale") };
|
|
12770
|
+
return { status: 200, text: await this.applyBackendSwitch(instance, entry.args, entry.from, stillCurrent) };
|
|
12771
|
+
},
|
|
12491
12772
|
cancel: (instance) => this.cancelInstance(instance),
|
|
12492
12773
|
steer: (instance, text, originalBody) => this.topicCommands.sendSteer(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
|
|
12493
12774
|
btw: (instance, text, originalBody) => this.topicCommands.sendBtw(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
|
|
@@ -12516,7 +12797,8 @@ export class FleetManager {
|
|
|
12516
12797
|
// sendClear sends its first IPC synchronously: no await separates this check from the effect.
|
|
12517
12798
|
if (this.shuttingDown || !entry.fence())
|
|
12518
12799
|
return { status: 409, text: t("menu.click_stale") };
|
|
12519
|
-
|
|
12800
|
+
const r = this.topicCommands.clearInstance(instance);
|
|
12801
|
+
return r.sent ? { status: 200, text: r.text, sent: true } : { status: 409, text: r.text };
|
|
12520
12802
|
},
|
|
12521
12803
|
t: (key, ...args) => t(key, ...args),
|
|
12522
12804
|
}, input, opts);
|
|
@@ -14099,10 +14381,18 @@ export class FleetManager {
|
|
|
14099
14381
|
}
|
|
14100
14382
|
return;
|
|
14101
14383
|
}
|
|
14384
|
+
// Between the install ending and the sign-in starting there is no
|
|
14385
|
+
// session to cancel; this hand-off is what `/login cancel` stops — from
|
|
14386
|
+
// here on, as the login-shell lookup below is an await.
|
|
14387
|
+
const handoff = { backend, cancelled: false };
|
|
14388
|
+
this.installHandoff = handoff;
|
|
14102
14389
|
// The installer may only have added the binary to a profile PATH; a
|
|
14103
14390
|
// fresh login shell sees that, the fleet process's PATH may not.
|
|
14104
|
-
const installedAt = this.locateBinaryOnLoginShell(info.binary) ?? this.locateInInstallerBinDirs(info);
|
|
14391
|
+
const installedAt = (await this.locateBinaryOnLoginShell(info.binary)) ?? this.locateInInstallerBinDirs(info);
|
|
14392
|
+
if (handoff.cancelled || this.installHandoff !== handoff)
|
|
14393
|
+
return;
|
|
14105
14394
|
if (!installedAt) {
|
|
14395
|
+
this.installHandoff = null;
|
|
14106
14396
|
await chat.adapter.sendText(chat.chatId, t("install.verify_failed", backend, info.binary), { threadId: chat.threadId }).catch(() => { });
|
|
14107
14397
|
return;
|
|
14108
14398
|
}
|
|
@@ -14112,6 +14402,7 @@ export class FleetManager {
|
|
|
14112
14402
|
// installed, reported as verified, and then offered nowhere.
|
|
14113
14403
|
this.adoptBinaryDirectory(installedAt, backend);
|
|
14114
14404
|
if (!LOGIN_FLOWS[backend]) {
|
|
14405
|
+
this.installHandoff = null;
|
|
14115
14406
|
await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
|
|
14116
14407
|
return;
|
|
14117
14408
|
}
|
|
@@ -14120,10 +14411,6 @@ export class FleetManager {
|
|
|
14120
14411
|
// publish a durable completion line first so a successful install can
|
|
14121
14412
|
// never look like it silently disappeared. Include the binary that
|
|
14122
14413
|
// passed the fresh-login-shell verification and the exact next step.
|
|
14123
|
-
// Between the install ending and the sign-in starting there is no
|
|
14124
|
-
// session to cancel; this hand-off is what `/login cancel` stops.
|
|
14125
|
-
const handoff = { backend, cancelled: false };
|
|
14126
|
-
this.installHandoff = handoff;
|
|
14127
14414
|
await chat.adapter.sendText(chat.chatId, t("install.success", backend, info.binary), { threadId: chat.threadId }).catch(err => this.logger.warn({ err, backend }, "Failed to send durable install success notification"));
|
|
14128
14415
|
if (handoff.cancelled || this.installHandoff !== handoff)
|
|
14129
14416
|
return;
|
|
@@ -14182,16 +14469,14 @@ export class FleetManager {
|
|
|
14182
14469
|
* The absolute path a fresh login shell resolves `binary` to, or null. Only
|
|
14183
14470
|
* a real executable file counts: `command -v` also answers with an alias
|
|
14184
14471
|
* definition or a function name, neither of which a spawn could run.
|
|
14472
|
+
* Async (runOnLoginShell): a login file that backgrounds a process holding the shell's output once froze the fleet loop here.
|
|
14185
14473
|
*/
|
|
14186
|
-
locateBinaryOnLoginShell(binary) {
|
|
14187
|
-
return measureSyncWork("fleet.installLookup", () => this.locateBinaryOnLoginShellSync(binary));
|
|
14188
|
-
}
|
|
14189
|
-
locateBinaryOnLoginShellSync(binary) {
|
|
14474
|
+
async locateBinaryOnLoginShell(binary) {
|
|
14190
14475
|
try {
|
|
14191
|
-
const result =
|
|
14476
|
+
const result = await runOnLoginShell(`command -v ${binary}`);
|
|
14192
14477
|
if (result.status !== 0)
|
|
14193
14478
|
return null;
|
|
14194
|
-
const path =
|
|
14479
|
+
const path = result.stdout.trim().split("\n").pop()?.trim() ?? "";
|
|
14195
14480
|
if (!isAbsolute(path))
|
|
14196
14481
|
return null;
|
|
14197
14482
|
accessSync(path, fsConstants.X_OK);
|
|
@@ -14340,6 +14625,7 @@ export class FleetManager {
|
|
|
14340
14625
|
messageId: typeof m.messageId === "string" ? m.messageId : undefined,
|
|
14341
14626
|
role: typeof m.role === "string" ? m.role : undefined,
|
|
14342
14627
|
buttons: m.buttons,
|
|
14628
|
+
click: m.click,
|
|
14343
14629
|
});
|
|
14344
14630
|
broadcastSseEvent(this.sseClients, event, recorded, onError, this.webChatHistory.cursorOf(recorded));
|
|
14345
14631
|
return;
|
|
@@ -14391,7 +14677,7 @@ export class FleetManager {
|
|
|
14391
14677
|
const instanceHangConfig = this.fleetConfig?.instances[instanceName]?.hang_detector;
|
|
14392
14678
|
const configuredMinutes = instanceHangConfig?.timeout_minutes
|
|
14393
14679
|
?? this.fleetConfig?.defaults?.hang_detector?.timeout_minutes
|
|
14394
|
-
?? 15
|
|
14680
|
+
?? DEFAULT_STUCK_TIMEOUT_MS / 60_000; // #1854: the detector's own default (10 min), not a stale 15
|
|
14395
14681
|
const unchangedMinutes = unchangedForMs == null
|
|
14396
14682
|
? configuredMinutes
|
|
14397
14683
|
: Math.max(1, Math.floor(unchangedForMs / 60_000));
|
|
@@ -14449,12 +14735,12 @@ export class FleetManager {
|
|
|
14449
14735
|
"codex": "AGENTS.md",
|
|
14450
14736
|
"opencode": "AGENTS.md",
|
|
14451
14737
|
"kiro-cli": ".kiro/steering/project.md",
|
|
14452
|
-
// Grok reads AGENTS.md project docs; agy reads .agents/
|
|
14738
|
+
// Grok reads AGENTS.md project docs; agy reads .agents/AGENTS.md (board 0340ec89: not the lowercase name) — the
|
|
14453
14739
|
// same files their writeConfig() appends fleet instructions to.
|
|
14454
14740
|
"grok": "AGENTS.md",
|
|
14455
14741
|
// muse init scaffolds AGENTS.md and the binary reads it as project rules.
|
|
14456
14742
|
"muse": "AGENTS.md",
|
|
14457
|
-
"antigravity": ".agents/
|
|
14743
|
+
"antigravity": ".agents/AGENTS.md",
|
|
14458
14744
|
"mock": "CLAUDE.md",
|
|
14459
14745
|
};
|
|
14460
14746
|
static GENERAL_INSTRUCTIONS = `# Fleet Coordinator
|
|
@@ -14494,10 +14780,17 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14494
14780
|
ensureGeneralInstructionsSync(workDir, backendName, instanceName) {
|
|
14495
14781
|
const backend = backendName ?? "claude-code";
|
|
14496
14782
|
workDir = this.resolveKnowledgeWorkDir(workDir, backend, instanceName);
|
|
14783
|
+
// Board 0340ec89: General's file from before, under the lowercase name agy never read, becomes the one it reads.
|
|
14784
|
+
if (backend === "antigravity")
|
|
14785
|
+
migrateAgyAgentsFile(workDir);
|
|
14497
14786
|
const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
|
|
14498
14787
|
const filePath = join(workDir, filename);
|
|
14499
14788
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
14500
|
-
if (
|
|
14789
|
+
if (backend === "antigravity" && danglingSymlink(filePath)) {
|
|
14790
|
+
// #1914 review: a person's dangling symlink there is kept and not followed (nothing created where it points).
|
|
14791
|
+
this.logger.warn({ filePath }, "General's agy instructions file is a dangling symlink; left as it is, not written");
|
|
14792
|
+
}
|
|
14793
|
+
else if (!existsSync(filePath)) {
|
|
14501
14794
|
writeFileSync(filePath, FleetManager.GENERAL_INSTRUCTIONS, "utf-8");
|
|
14502
14795
|
this.logger.info({ filePath }, "Created general instance instructions file");
|
|
14503
14796
|
}
|
|
@@ -14594,6 +14887,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14594
14887
|
}
|
|
14595
14888
|
const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
|
|
14596
14889
|
const instructionsPath = join(workDir, filename);
|
|
14890
|
+
if (backend === "antigravity" && danglingSymlink(instructionsPath))
|
|
14891
|
+
return; // kept, not followed (#1914 review; logged above)
|
|
14597
14892
|
const body = files.map(f => readFileSync(join(srcSteering, f), "utf-8").trim()).join("\n\n");
|
|
14598
14893
|
const block = `${FleetManager.STEERING_BLOCK_BEGIN}\n${body}\n${FleetManager.STEERING_BLOCK_END}`;
|
|
14599
14894
|
let existing = "";
|
|
@@ -14614,6 +14909,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14614
14909
|
mkdirSync(dirname(instructionsPath), { recursive: true });
|
|
14615
14910
|
writeFileSync(instructionsPath, next);
|
|
14616
14911
|
}
|
|
14912
|
+
// Board 0340ec89: a legacy agy file that stays beside the new one (a person's own) loses only AgEnD's block.
|
|
14913
|
+
const legacy = backend === "antigravity" ? agyLegacyAgentsFile(workDir) : null;
|
|
14914
|
+
if (legacy) {
|
|
14915
|
+
try {
|
|
14916
|
+
const old = readFileSync(legacy, "utf-8");
|
|
14917
|
+
const b = old.indexOf(FleetManager.STEERING_BLOCK_BEGIN), e = old.indexOf(FleetManager.STEERING_BLOCK_END);
|
|
14918
|
+
if (b !== -1 && e > b) {
|
|
14919
|
+
const rest = (old.slice(0, b).trimEnd() + "\n" + old.slice(e + FleetManager.STEERING_BLOCK_END.length).trimStart()).trim();
|
|
14920
|
+
if (rest)
|
|
14921
|
+
writeFileSync(legacy, rest + "\n");
|
|
14922
|
+
else
|
|
14923
|
+
rmSync(legacy, { force: true });
|
|
14924
|
+
}
|
|
14925
|
+
}
|
|
14926
|
+
catch { /* best effort: the new file already has the block */ }
|
|
14927
|
+
}
|
|
14617
14928
|
}
|
|
14618
14929
|
static STEERING_BLOCK_BEGIN = "<!-- >>> agend:core-rules — managed by AgEnD; edits inside this block are overwritten -->";
|
|
14619
14930
|
static STEERING_BLOCK_END = "<!-- <<< agend:core-rules -->";
|
|
@@ -15149,10 +15460,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15149
15460
|
* accepts any string ("Set model to opsu"), and its "Switch model?" then names the typo. Static aliases (with [1m]),
|
|
15150
15461
|
* the cached account catalog, the configured model; without a cached catalog, any `claude-…` id too (cannot check).
|
|
15151
15462
|
*/
|
|
15152
|
-
claudeModelKnown(instanceName, model
|
|
15463
|
+
claudeModelKnown(instanceName, model,
|
|
15464
|
+
/** #1806: null on a switch INTO claude-code — the configured model then belongs to the backend being left. */
|
|
15465
|
+
configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model) {
|
|
15153
15466
|
const aliases = ["default", "sonnet", "opus", "haiku", "opusplan", "Fable"];
|
|
15154
15467
|
const api = (this.readCliEnv("claude-code")?.apiModels ?? []).map(m => m.id);
|
|
15155
|
-
const configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model;
|
|
15156
15468
|
const lower = model.trim().toLowerCase();
|
|
15157
15469
|
const bare = lower.replace(/\[1m\]$/, "");
|
|
15158
15470
|
const known = aliases.some(a => a.toLowerCase() === bare)
|
|
@@ -16131,7 +16443,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16131
16443
|
/** How this instance's backend applies an effort change. */
|
|
16132
16444
|
effortStrategyFor(instanceName) {
|
|
16133
16445
|
try {
|
|
16134
|
-
const { strategy, levels } =
|
|
16446
|
+
const { strategy, levels } = this.instanceEffortMetadata(instanceName);
|
|
16135
16447
|
return strategy !== "unsupported" && levels.length > 0 ? strategy : "unsupported";
|
|
16136
16448
|
}
|
|
16137
16449
|
catch {
|
|
@@ -16141,13 +16453,23 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16141
16453
|
/** Effort levels this instance's backend actually accepts (empty = unsupported). */
|
|
16142
16454
|
effortLevelsFor(instanceName) {
|
|
16143
16455
|
try {
|
|
16144
|
-
const { strategy, levels } =
|
|
16456
|
+
const { strategy, levels } = this.instanceEffortMetadata(instanceName);
|
|
16145
16457
|
return strategy === "unsupported" ? [] : levels;
|
|
16146
16458
|
}
|
|
16147
16459
|
catch {
|
|
16148
16460
|
return [];
|
|
16149
16461
|
}
|
|
16150
16462
|
}
|
|
16463
|
+
/** The instance's backend's effort metadata; agy's levels are its model's (#1825), the one the instance runs. */
|
|
16464
|
+
instanceEffortMetadata(instanceName) {
|
|
16465
|
+
const backend = this.backendNameForInstance(instanceName);
|
|
16466
|
+
let model = null;
|
|
16467
|
+
if (backend === "antigravity") {
|
|
16468
|
+
const resolved = this.resolveInstanceModel(instanceName);
|
|
16469
|
+
model = resolved.source === "unresolved" ? null : resolved.model;
|
|
16470
|
+
}
|
|
16471
|
+
return readEffortMetadata(backend, this.getInstanceDir(instanceName), model);
|
|
16472
|
+
}
|
|
16151
16473
|
/** Configured effort for an instance: per-instance, else fleet default, else none. */
|
|
16152
16474
|
resolveInstanceEffort(instanceName) {
|
|
16153
16475
|
const own = this.fleetConfig?.instances[instanceName]?.effort;
|
|
@@ -16299,6 +16621,377 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16299
16621
|
? `\n${t("effort.current_fleet", effort)}`
|
|
16300
16622
|
: `\n${t("effort.current", effort)}`;
|
|
16301
16623
|
}
|
|
16624
|
+
// ── #1806: /backend — another CLI backend, with its model and effort, in one write and one relaunch ──
|
|
16625
|
+
/** The steps' view of this fleet (backend-command.ts). `live`: a missing or stale model list may be probed (not on the web). */
|
|
16626
|
+
backendCommandDeps(instanceName, opts) {
|
|
16627
|
+
return {
|
|
16628
|
+
currentBackend: (name) => this.backendNameForInstance(name),
|
|
16629
|
+
currentDisplay: (name) => `${this.backendNameForInstance(name)} · ${this.resolveInstanceModel(name).display}`,
|
|
16630
|
+
installedBackends: () => KNOWN_BACKENDS.filter(b => b !== "mock" && this.backendInstalledHere(b)),
|
|
16631
|
+
models: (backend) => this.backendModelOptions(backend, opts),
|
|
16632
|
+
// #1808: the same rule update_instance_config's switch applies (backendModelVerdict).
|
|
16633
|
+
modelKnown: (backend, model) => this.backendModelVerdict(instanceName, backend, model).ok,
|
|
16634
|
+
effortLevels: (backend, model) => this.backendEffortLevels(instanceName, backend, model),
|
|
16635
|
+
inherited: (key) => {
|
|
16636
|
+
const value = getEffectiveInstanceDefaults(this.fleetConfig?.defaults)[key];
|
|
16637
|
+
return typeof value === "string" && value ? value : undefined;
|
|
16638
|
+
},
|
|
16639
|
+
modelCompatible: (backend, model) => isModelCompatible(backend, model),
|
|
16640
|
+
state: (name) => {
|
|
16641
|
+
const status = this.getInstanceStatus(name);
|
|
16642
|
+
return { running: status === "running", busy: status === "running" && !this.getInstanceIdle(name), status };
|
|
16643
|
+
},
|
|
16644
|
+
t: (key, ...args) => t(key, ...args),
|
|
16645
|
+
};
|
|
16646
|
+
}
|
|
16647
|
+
/**
|
|
16648
|
+
* `/effort`'s levels for a backend this instance may move to, for the model that will run there (codex's levels are
|
|
16649
|
+
* per model; null: its configured default). Empty: it has no effort setting. A file read, never a CLI.
|
|
16650
|
+
*/
|
|
16651
|
+
backendEffortLevels(instanceName, backend, model = null) {
|
|
16652
|
+
// codex: the model the NEXT launch runs, for the levels and the gate alike (#1807 review r2).
|
|
16653
|
+
let runs = model;
|
|
16654
|
+
if (backend === "codex") {
|
|
16655
|
+
const next = this.prospectiveCodexModel(instanceName, model);
|
|
16656
|
+
if (!next.proven)
|
|
16657
|
+
return [];
|
|
16658
|
+
runs = next.model;
|
|
16659
|
+
}
|
|
16660
|
+
let levels;
|
|
16661
|
+
try {
|
|
16662
|
+
const metadata = readEffortMetadata(backend, this.getInstanceDir(instanceName), runs);
|
|
16663
|
+
levels = metadata.strategy === "unsupported" ? [] : metadata.levels;
|
|
16664
|
+
}
|
|
16665
|
+
catch {
|
|
16666
|
+
return [];
|
|
16667
|
+
}
|
|
16668
|
+
// #1811: only the levels this host's launch of that backend will pass (launch-effort.ts, the gate the launch
|
|
16669
|
+
// itself applies) — an effort chosen here reaches the new CLI with the one relaunch, or is not offered.
|
|
16670
|
+
const takes = this.launchEffortGate(instanceName, backend, runs);
|
|
16671
|
+
return levels.filter(takes);
|
|
16672
|
+
}
|
|
16673
|
+
/**
|
|
16674
|
+
* #1808: whether a backend takes this model name — the one rule for /backend and update_instance_config's switch.
|
|
16675
|
+
* The name must be one its launch line can carry (modelNameCarried); then its offered list (the cached CLI env), and
|
|
16676
|
+
* claude-code's known names (without this instance's configured model, which belongs to the backend being left).
|
|
16677
|
+
* With no list known, the backend's usual name pattern decides.
|
|
16678
|
+
*/
|
|
16679
|
+
backendModelVerdict(instanceName, backend, model,
|
|
16680
|
+
/** The provider the backend will run on (default: this instance's own, else the fleet's). */
|
|
16681
|
+
opts = { provider: this.customProviderFor(instanceName, backend) }) {
|
|
16682
|
+
// A custom provider has its own catalog, which the CLI reads at launch and AgEnD cannot see before one (#1815
|
|
16683
|
+
// review): only the launch-line rule applies — never the account's list or the default provider's naming pattern.
|
|
16684
|
+
if (opts.provider)
|
|
16685
|
+
return modelNameCarried(backend, model) ? { ok: true } : { ok: false, choices: [] };
|
|
16686
|
+
const listed = this.readCliEnv(backend)?.models ?? [];
|
|
16687
|
+
const choices = listed.slice(0, 24).map(o => o.id);
|
|
16688
|
+
if (!modelNameCarried(backend, model))
|
|
16689
|
+
return { ok: false, choices };
|
|
16690
|
+
if (listed.some(o => o.id === model))
|
|
16691
|
+
return { ok: true };
|
|
16692
|
+
if (backend === "claude-code") {
|
|
16693
|
+
const check = this.claudeModelKnown(instanceName, model, null);
|
|
16694
|
+
return check.known ? { ok: true } : { ok: false, choices: check.names };
|
|
16695
|
+
}
|
|
16696
|
+
return listed.length === 0 && isModelCompatible(backend, model) ? { ok: true } : { ok: false, choices };
|
|
16697
|
+
}
|
|
16698
|
+
/**
|
|
16699
|
+
* #1807 review r2: the model codex's next launch of this instance runs. Every launch's writeConfig copies the SHARED
|
|
16700
|
+
* config.toml into the instance's codex home first, so that source decides — not the home's copy from an earlier
|
|
16701
|
+
* launch. Read-only; a model chosen here (`-c model=…`) outranks it; a project file that could change it, unproven.
|
|
16702
|
+
*/
|
|
16703
|
+
prospectiveCodexModel(instanceName, model) {
|
|
16704
|
+
const { sharedHome } = codexMetadataHomes(this.getInstanceDir(instanceName));
|
|
16705
|
+
return resolveCodexLaunchModel({
|
|
16706
|
+
isolatedHome: sharedHome, sharedHome, model,
|
|
16707
|
+
workingDirectory: this.fleetConfig?.instances[instanceName]?.working_directory,
|
|
16708
|
+
});
|
|
16709
|
+
}
|
|
16710
|
+
/** #1811: whether a launch of `backend` here would pass a level: the same gate its builder applies. */
|
|
16711
|
+
launchEffortGate(instanceName, backend, model) {
|
|
16712
|
+
if (backend === "claude-code" || backend === "grok" || backend === "antigravity") {
|
|
16713
|
+
const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
|
|
16714
|
+
if (!binary)
|
|
16715
|
+
return () => false;
|
|
16716
|
+
const path = resolveBinary(binary);
|
|
16717
|
+
return level => flagEffort(backend, level, path).arg !== null;
|
|
16718
|
+
}
|
|
16719
|
+
if (backend === "codex") {
|
|
16720
|
+
// `model` is the next launch's, proven by the caller (prospectiveCodexModel); the catalog is codex's own cache.
|
|
16721
|
+
if (!model)
|
|
16722
|
+
return () => false;
|
|
16723
|
+
const catalog = readCodexModelEffortLevels({ ...codexMetadataHomes(this.getInstanceDir(instanceName)), model });
|
|
16724
|
+
return level => codexEffort(level, catalog).arg !== null;
|
|
16725
|
+
}
|
|
16726
|
+
// kiro-cli gates its own --effort by version at launch (a quiet console note otherwise); muse always passes it.
|
|
16727
|
+
return () => true;
|
|
16728
|
+
}
|
|
16729
|
+
/** Installed the way the launch finds a CLI (resolveBinary): on PATH or in a known install directory. */
|
|
16730
|
+
backendInstalledHere(backend) {
|
|
16731
|
+
const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
|
|
16732
|
+
return !!binary && resolveBinary(binary) !== binary;
|
|
16733
|
+
}
|
|
16734
|
+
/** The models `/model` would offer on a backend: the cached CLI env, else (live) a bounded probe. Never throws. */
|
|
16735
|
+
async backendModelOptions(backend, opts) {
|
|
16736
|
+
const cached = this.readCliEnv(backend);
|
|
16737
|
+
if (!opts.live || (cached?.models.length && !this.cliEnvNeedsRefresh(cached)))
|
|
16738
|
+
return cached?.models ?? [];
|
|
16739
|
+
opts.onLiveProbe?.();
|
|
16740
|
+
const env = await this.probeBackendBounded(backend).catch(() => null);
|
|
16741
|
+
return env?.models.length ? env.models : cached?.models ?? [];
|
|
16742
|
+
}
|
|
16743
|
+
/**
|
|
16744
|
+
* #1806 review: the instance a `/backend` was asked about is still this config entry — not one deleted and created
|
|
16745
|
+
* again under the same name, or replaced by a Settings edit (both put a new object there), stopped or not.
|
|
16746
|
+
*/
|
|
16747
|
+
backendTargetIs(instanceName, target) {
|
|
16748
|
+
const instances = this.fleetConfig?.instances;
|
|
16749
|
+
return !!target && !!instances && Object.prototype.hasOwnProperty.call(instances, instanceName) && instances[instanceName] === target;
|
|
16750
|
+
}
|
|
16751
|
+
/** `/backend` is for fleet.yaml instances: update_instance_config does not manage a ClassicBot agent. */
|
|
16752
|
+
backendCommandRefusal(instanceName) {
|
|
16753
|
+
return this.fleetConfig?.instances && Object.prototype.hasOwnProperty.call(this.fleetConfig.instances, instanceName)
|
|
16754
|
+
? null : t("backend.classic_unsupported", instanceName);
|
|
16755
|
+
}
|
|
16756
|
+
/**
|
|
16757
|
+
* Apply a confirmed `/backend`: judged again first (a menu lives a minute — the backend may have moved, a CLI been
|
|
16758
|
+
* removed), then update_instance_config's write: backend, model and effort at once, refused and rolled back whole
|
|
16759
|
+
* (its credential-profile login gate included), then one fresh relaunch of a running instance.
|
|
16760
|
+
*/
|
|
16761
|
+
async applyBackendSwitch(instanceName, args, from, stillCurrent) {
|
|
16762
|
+
if (this.backendNameForInstance(instanceName) !== from)
|
|
16763
|
+
return t("backend.changed", instanceName, from);
|
|
16764
|
+
if (!stillCurrent())
|
|
16765
|
+
return t("menu.click_stale");
|
|
16766
|
+
const deps = this.backendCommandDeps(instanceName, { live: false });
|
|
16767
|
+
const step = await backendStep(deps, instanceName, args);
|
|
16768
|
+
if (step.kind !== "confirm")
|
|
16769
|
+
return step.kind === "refuse" ? step.text : t("backend.usage");
|
|
16770
|
+
// Asked again after the await, with nothing awaited between here and the write (applyInstanceConfigPatch writes
|
|
16771
|
+
// synchronously): two switches confirmed together, or an instance changed meanwhile, never both succeed.
|
|
16772
|
+
if (this.backendNameForInstance(instanceName) !== from)
|
|
16773
|
+
return t("backend.changed", instanceName, from);
|
|
16774
|
+
if (!stillCurrent())
|
|
16775
|
+
return t("menu.click_stale");
|
|
16776
|
+
const { plan } = step;
|
|
16777
|
+
const shown = planDisplay(deps, plan);
|
|
16778
|
+
const { value, error } = await new Promise(resolve => {
|
|
16779
|
+
applyInstanceConfigPatch(this, instanceName, { backend: plan.backend, model: plan.model, effort: plan.effort }, (value, error) => resolve({ value, error }));
|
|
16780
|
+
});
|
|
16781
|
+
if (error)
|
|
16782
|
+
return t("backend.refused", instanceName, error);
|
|
16783
|
+
const result = (value ?? {});
|
|
16784
|
+
if (result.warning)
|
|
16785
|
+
return t("backend.restart_failed", instanceName, plan.backend, result.warning);
|
|
16786
|
+
if (result.restarted)
|
|
16787
|
+
return t("backend.switched", instanceName, plan.backend, shown.model, shown.effort);
|
|
16788
|
+
return t("backend.switched_not_running", instanceName, plan.backend, shown.model, shown.effort, this.getInstanceStatus(instanceName));
|
|
16789
|
+
}
|
|
16790
|
+
/** A step's buttons: index callbacks (a model id can be longer than Telegram's 64-byte callback data allows). */
|
|
16791
|
+
backendStepButtons(instanceName, nonce, step) {
|
|
16792
|
+
if (step.kind === "choose")
|
|
16793
|
+
return step.choices.map((c, i) => ({ id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:${i}`, label: c.label }));
|
|
16794
|
+
if (step.kind !== "confirm")
|
|
16795
|
+
return [];
|
|
16796
|
+
const running = this.getInstanceStatus(instanceName) === "running";
|
|
16797
|
+
return [
|
|
16798
|
+
{ id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:0`, label: t(running ? "backend.confirm_button" : "backend.confirm_button_stopped") },
|
|
16799
|
+
{ id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:1`, label: t("backend.cancel_button") },
|
|
16800
|
+
];
|
|
16801
|
+
}
|
|
16802
|
+
/**
|
|
16803
|
+
* Show a `/backend` step (a choice, or the confirmation) where the command was given: Discord edits its interaction
|
|
16804
|
+
* reply; Telegram retires the previous step's keyboard and posts the next. One pending entry per step, under a fresh
|
|
16805
|
+
* nonce: a click on an older step finds nothing.
|
|
16806
|
+
*/
|
|
16807
|
+
async presentBackendStep(place, step) {
|
|
16808
|
+
const nonce = randomBytes(6).toString("hex");
|
|
16809
|
+
const buttons = this.backendStepButtons(place.instanceName, nonce, step);
|
|
16810
|
+
const timer = setTimeout(() => {
|
|
16811
|
+
const p = this.pendingBackendSelects.get(nonce);
|
|
16812
|
+
if (!p)
|
|
16813
|
+
return;
|
|
16814
|
+
this.pendingBackendSelects.delete(nonce);
|
|
16815
|
+
void this.endBackendMenu(p, t("backend.selection_expired")).catch(() => { });
|
|
16816
|
+
}, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
16817
|
+
timer.unref?.();
|
|
16818
|
+
const pending = { ...place, step, timer };
|
|
16819
|
+
this.pendingBackendSelects.set(nonce, pending);
|
|
16820
|
+
try {
|
|
16821
|
+
if (step.kind === "confirm" && place.respondButtons) {
|
|
16822
|
+
await place.respondButtons(step.text, buttons);
|
|
16823
|
+
return;
|
|
16824
|
+
}
|
|
16825
|
+
if (place.respondChoices) {
|
|
16826
|
+
await place.respondChoices(step.text, buttons);
|
|
16827
|
+
return;
|
|
16828
|
+
}
|
|
16829
|
+
if (!place.adapter || !place.adapterChatId)
|
|
16830
|
+
throw new Error("no place to show the menu");
|
|
16831
|
+
if (place.menuMessageId && place.adapter.editMessageRemoveButtons) {
|
|
16832
|
+
const chosen = t("backend.chosen", joinBackendArgs(step.args));
|
|
16833
|
+
await place.adapter.editMessageRemoveButtons(place.adapterChatId, place.menuMessageId, chosen, place.adapterThreadId).catch(() => { });
|
|
16834
|
+
}
|
|
16835
|
+
const menuMessageId = await place.adapter.promptUser(place.adapterChatId, step.text, buttons, { threadId: place.adapterThreadId });
|
|
16836
|
+
const fresh = this.pendingBackendSelects.get(nonce);
|
|
16837
|
+
if (fresh)
|
|
16838
|
+
fresh.menuMessageId = menuMessageId;
|
|
16839
|
+
}
|
|
16840
|
+
catch (err) {
|
|
16841
|
+
this.pendingBackendSelects.delete(nonce);
|
|
16842
|
+
clearTimeout(timer);
|
|
16843
|
+
this.logger.warn({ err, instanceName: place.instanceName }, "backend menu failed");
|
|
16844
|
+
await place.respond(t("backend.usage")).catch(() => { });
|
|
16845
|
+
}
|
|
16846
|
+
}
|
|
16847
|
+
/** Close a `/backend` menu with a last word: its buttons go (Telegram edits them away), the text stays. Returns where it was said. */
|
|
16848
|
+
async endBackendMenu(pending, text) {
|
|
16849
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
16850
|
+
if (pending.menuMessageId && pending.adapter.editMessageRemoveButtons) {
|
|
16851
|
+
try {
|
|
16852
|
+
await pending.adapter.editMessageRemoveButtons(pending.adapterChatId, pending.menuMessageId, text, pending.adapterThreadId);
|
|
16853
|
+
return pending.menuMessageId;
|
|
16854
|
+
}
|
|
16855
|
+
catch { /* say it in a new message */ }
|
|
16856
|
+
}
|
|
16857
|
+
return (await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId })).messageId;
|
|
16858
|
+
}
|
|
16859
|
+
if (pending.retireButtons)
|
|
16860
|
+
await pending.retireButtons(text);
|
|
16861
|
+
else
|
|
16862
|
+
await pending.respond(text);
|
|
16863
|
+
return undefined;
|
|
16864
|
+
}
|
|
16865
|
+
/** Start `/backend` for a fleet instance from Discord or Telegram; the caller has checked the channel admin. */
|
|
16866
|
+
async beginBackendCommand(place, args) {
|
|
16867
|
+
const refusal = this.backendCommandRefusal(place.instanceName);
|
|
16868
|
+
if (refusal) {
|
|
16869
|
+
await place.respond(refusal);
|
|
16870
|
+
return;
|
|
16871
|
+
}
|
|
16872
|
+
if (!place.respondChoices && !(place.adapter && place.adapterChatId)) {
|
|
16873
|
+
await place.respond(t("backend.usage"));
|
|
16874
|
+
return;
|
|
16875
|
+
}
|
|
16876
|
+
place = { ...place, target: this.fleetConfig.instances[place.instanceName] };
|
|
16877
|
+
const deps = this.backendCommandDeps(place.instanceName, {
|
|
16878
|
+
live: true,
|
|
16879
|
+
onLiveProbe: () => { void place.respond(t("model.refreshing")).catch(() => { }); },
|
|
16880
|
+
});
|
|
16881
|
+
const step = await backendStep(deps, place.instanceName, args);
|
|
16882
|
+
if (step.kind === "refuse") {
|
|
16883
|
+
await place.respond(step.text);
|
|
16884
|
+
return;
|
|
16885
|
+
}
|
|
16886
|
+
await this.presentBackendStep(place, step);
|
|
16887
|
+
}
|
|
16888
|
+
/** `/backend` slash handler: channel admin, as `/model`. Options or typed text give the direct form. */
|
|
16889
|
+
async handleBackendSlash(data, adapterId) {
|
|
16890
|
+
if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
|
|
16891
|
+
await data.respond(t("permission.denied"));
|
|
16892
|
+
return;
|
|
16893
|
+
}
|
|
16894
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
16895
|
+
if (!name) {
|
|
16896
|
+
await data.respond(t("classic.no_agent"));
|
|
16897
|
+
return;
|
|
16898
|
+
}
|
|
16899
|
+
const option = (key) => typeof data.options?.[key] === "string" ? data.options[key].trim() : "";
|
|
16900
|
+
const given = [option("backend"), option("model"), option("effort")];
|
|
16901
|
+
// A later option without the one before it says nothing that can be judged in order.
|
|
16902
|
+
if (given.some((value, i) => value && given.slice(0, i).some(before => !before))) {
|
|
16903
|
+
await data.respond(t("backend.usage"));
|
|
16904
|
+
return;
|
|
16905
|
+
}
|
|
16906
|
+
const args = given[0] ? given.filter(Boolean) : backendArgs(data.text ?? "");
|
|
16907
|
+
await this.beginBackendCommand({ instanceName: name, userId: data.userId, channelId: data.channelId, adapterId, respond: data.respond, respondChoices: data.respondChoices,
|
|
16908
|
+
respondButtons: data.respondButtons, retireButtons: data.retireButtons }, args);
|
|
16909
|
+
}
|
|
16910
|
+
/** Telegram `/backend` in a fleet topic (topic-commands) or a ClassicBot chat; the caller has checked the admin. */
|
|
16911
|
+
async runBackendCommand(instanceName, text, userId, channelId, adapter, chatId, threadId, adapterId) {
|
|
16912
|
+
const respond = async (reply) => (await adapter.sendText(chatId, reply, { threadId })).messageId;
|
|
16913
|
+
await this.beginBackendCommand({ instanceName, userId, channelId, adapterId, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId }, backendArgs(text.replace(/^\/backend(@\S+)?/, "")));
|
|
16914
|
+
}
|
|
16915
|
+
/** Consume a `/backend` click: the next step, or on the confirmation the switch itself. True for every backend-select id. */
|
|
16916
|
+
async handleBackendSelection(data, adapterId) {
|
|
16917
|
+
if (!data.callbackData.startsWith(BACKEND_SELECT_CALLBACK_PREFIX))
|
|
16918
|
+
return false;
|
|
16919
|
+
const match = data.callbackData.match(/^backend-select:([0-9a-f]+):(\d{1,2})$/);
|
|
16920
|
+
if (!match)
|
|
16921
|
+
return true;
|
|
16922
|
+
const pending = this.pendingBackendSelects.get(match[1]);
|
|
16923
|
+
if (!pending)
|
|
16924
|
+
return true;
|
|
16925
|
+
// As a /model click (#754 audit): the admin who opened it, through the same bot, in the same channel, still admin.
|
|
16926
|
+
const cbChannel = data.threadId ?? data.chatId;
|
|
16927
|
+
if (!data.userId || data.userId !== pending.userId
|
|
16928
|
+
|| (pending.adapterId !== undefined && pending.adapterId !== adapterId)
|
|
16929
|
+
|| (cbChannel !== pending.channelId && data.chatId !== pending.channelId)
|
|
16930
|
+
|| !this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)) {
|
|
16931
|
+
data.ack?.(t("buttons.admin_only"));
|
|
16932
|
+
return true;
|
|
16933
|
+
}
|
|
16934
|
+
this.pendingBackendSelects.delete(match[1]);
|
|
16935
|
+
clearTimeout(pending.timer);
|
|
16936
|
+
if (!pending.menuMessageId && data.messageId)
|
|
16937
|
+
pending.menuMessageId = data.messageId;
|
|
16938
|
+
const index = Number(match[2]);
|
|
16939
|
+
const { step } = pending;
|
|
16940
|
+
if (step.kind === "choose") {
|
|
16941
|
+
const choice = step.choices[index];
|
|
16942
|
+
if (!choice)
|
|
16943
|
+
return true;
|
|
16944
|
+
if (!this.backendTargetIs(pending.instanceName, pending.target)) {
|
|
16945
|
+
await this.endBackendMenu(pending, t("menu.click_stale")).catch(() => { });
|
|
16946
|
+
return true;
|
|
16947
|
+
}
|
|
16948
|
+
const next = await backendStep(this.backendCommandDeps(pending.instanceName, { live: true }), pending.instanceName, [...step.args, choice.token]);
|
|
16949
|
+
if (next.kind === "refuse") {
|
|
16950
|
+
await this.endBackendMenu(pending, next.text).catch(() => { });
|
|
16951
|
+
return true;
|
|
16952
|
+
}
|
|
16953
|
+
await this.presentBackendStep(pending, next);
|
|
16954
|
+
return true;
|
|
16955
|
+
}
|
|
16956
|
+
if (index === 1) {
|
|
16957
|
+
await this.endBackendMenu(pending, t("backend.cancelled")).catch(() => { });
|
|
16958
|
+
return true;
|
|
16959
|
+
}
|
|
16960
|
+
if (index !== 0)
|
|
16961
|
+
return true;
|
|
16962
|
+
// Confirmed: say so at once, then switch in the background (the relaunch takes a while) and say how it went.
|
|
16963
|
+
const progressId = await this.endBackendMenu(pending, t("backend.switching", pending.instanceName, step.plan.backend)).catch(() => undefined);
|
|
16964
|
+
const say = async (text) => {
|
|
16965
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
16966
|
+
if (progressId)
|
|
16967
|
+
await pending.adapter.editMessage(pending.adapterChatId, progressId, text, pending.adapterThreadId);
|
|
16968
|
+
else
|
|
16969
|
+
await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
|
|
16970
|
+
}
|
|
16971
|
+
else
|
|
16972
|
+
await pending.respond(text);
|
|
16973
|
+
};
|
|
16974
|
+
void (async () => {
|
|
16975
|
+
let result;
|
|
16976
|
+
try {
|
|
16977
|
+
// Asked again after the awaits above: the instance may have moved, or the admin lost admin.
|
|
16978
|
+
const stillCurrent = () => this.backendTargetIs(pending.instanceName, pending.target)
|
|
16979
|
+
&& this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId);
|
|
16980
|
+
result = stillCurrent()
|
|
16981
|
+
? await withSpawnHoldReply(say, () => this.applyBackendSwitch(pending.instanceName, step.args, step.from, stillCurrent))
|
|
16982
|
+
: t("menu.click_stale");
|
|
16983
|
+
}
|
|
16984
|
+
catch (err) {
|
|
16985
|
+
this.logger.error({ err, instance: pending.instanceName, backend: step.plan.backend }, "Backend switch failed");
|
|
16986
|
+
result = t("backend.failed", pending.instanceName, step.plan.backend, err instanceof Error ? err.message : String(err));
|
|
16987
|
+
}
|
|
16988
|
+
await say(result).catch(() => {
|
|
16989
|
+
if (pending.adapter && pending.adapterChatId)
|
|
16990
|
+
pending.adapter.sendText(pending.adapterChatId, result, { threadId: pending.adapterThreadId }).catch(() => { });
|
|
16991
|
+
});
|
|
16992
|
+
})();
|
|
16993
|
+
return true;
|
|
16994
|
+
}
|
|
16302
16995
|
/** Read recent chat log for agent context */
|
|
16303
16996
|
getRecentChatLog(instanceName, maxLines = 10) {
|
|
16304
16997
|
const logDir = ClassicChannelManager.chatLogDir(instanceName);
|
|
@@ -16711,6 +17404,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16711
17404
|
this.eventLogPruneTimer = null;
|
|
16712
17405
|
}
|
|
16713
17406
|
this.logService?.unwatchAll(); // #1218: a Log's watch timers end with the fleet
|
|
17407
|
+
this.compactWatchService?.stopAll();
|
|
16714
17408
|
if (this.outboxPruneTimer) {
|
|
16715
17409
|
clearInterval(this.outboxPruneTimer);
|
|
16716
17410
|
this.outboxPruneTimer = null;
|
|
@@ -18694,37 +19388,41 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18694
19388
|
const currentVersion = JSON.parse(readFileSync(pkgPath, "utf-8")).version ?? "0.0.0";
|
|
18695
19389
|
const latest = await npmVersion("@songsid/agend");
|
|
18696
19390
|
let target = latest;
|
|
19391
|
+
let fromChannel = false;
|
|
18697
19392
|
// The same channel rule as `agend update` (#1259): an alpha follows @alpha, any other prerelease @beta.
|
|
18698
19393
|
const channel = installedChannel(currentVersion);
|
|
18699
19394
|
if (channel !== "latest") {
|
|
18700
|
-
// Prerelease users track their own channel (never fall back to @latest,
|
|
18701
|
-
// which is older, and an alpha is never offered @beta), but should also
|
|
18702
|
-
// hear when a newer STABLE ships — pick whichever is the newest.
|
|
18703
19395
|
let pre = "";
|
|
18704
19396
|
try {
|
|
18705
19397
|
pre = await npmVersion(`@songsid/agend@${channel}`);
|
|
18706
19398
|
}
|
|
18707
19399
|
catch { /* no such tag */ }
|
|
18708
|
-
|
|
18709
|
-
|
|
19400
|
+
// #1926: a beta install hears about @beta only: beta users stay on the beta line after a stable ships, so
|
|
19401
|
+
// @latest is not their target. Taking the higher of the two made a same-core stable (2.2.0 over beta.16) the
|
|
19402
|
+
// target, which the same-core guard then dropped, so the newer beta.17 was never announced.
|
|
19403
|
+
target = pre;
|
|
19404
|
+
fromChannel = true;
|
|
19405
|
+
// An alpha is still told about a stable that passed its own minor (with --stable, #1259): only a stable of
|
|
19406
|
+
// another core that is newer than its channel's release. A same-core @latest (2.2.0 for 2.2.0-alpha.3) is not
|
|
19407
|
+
// taken from @latest and no longer hides a newer alpha; once the release points @alpha at it, it comes from
|
|
19408
|
+
// @alpha like any channel release.
|
|
19409
|
+
const core = (v) => v.replace(/^v/, "").split("-")[0];
|
|
19410
|
+
if (channel === "alpha" && latest && core(latest) !== core(currentVersion) && (!pre || this.semverGt(latest, pre))) {
|
|
18710
19411
|
target = latest;
|
|
19412
|
+
fromChannel = false;
|
|
19413
|
+
}
|
|
18711
19414
|
}
|
|
18712
|
-
// A beta already at/ahead of its matching stable must NOT be told to
|
|
18713
|
-
// "update" to that stable — e.g. 2.0.11-beta.41 already contains everything
|
|
18714
|
-
// in stable 2.0.11, so semverGt(2.0.11, 2.0.11-beta.41) being true (a stable
|
|
18715
|
-
// outranks a prerelease of the same core) is a false positive here. Suppress
|
|
18716
|
-
// only that same-core stable-vs-my-beta case; a higher stable core (2.0.12)
|
|
18717
|
-
// or a newer beta (2.0.11-beta.50) still notifies via semverGt below.
|
|
18718
|
-
const core = (v) => v.replace(/^v/, "").split("-")[0];
|
|
18719
|
-
const betaSupersedesStable = isPrereleaseVersion(currentVersion) && !isPrereleaseVersion(target) && core(target) === core(currentVersion);
|
|
18720
19415
|
// Only notify when target is genuinely newer (semver), so a beta user on
|
|
18721
19416
|
// 2.0.8-beta.16 is never told that stable 2.0.7 is "available".
|
|
18722
|
-
if (target &&
|
|
19417
|
+
if (target && this.semverGt(target, currentVersion)) {
|
|
18723
19418
|
const generalId = this.findGeneralInstance();
|
|
18724
19419
|
if (generalId) {
|
|
18725
19420
|
// No release URL — Discord's SuppressEmbeds proved unreliable and the
|
|
18726
19421
|
// link preview looked bad. Version + /update instruction is enough.
|
|
18727
|
-
|
|
19422
|
+
// From the install's own channel tag, this is what /update installs — even a stable the release pointed the
|
|
19423
|
+
// tag at (2.2.0 on @alpha / @beta): the plain /update text, never "/update stays on … use --stable" (#1928).
|
|
19424
|
+
const key = fromChannel ? "update.available_current" : updateNoticeKey(currentVersion, target);
|
|
19425
|
+
this.notifyInstanceTopic(generalId, t(key, `v${target}`, `v${currentVersion}`));
|
|
18728
19426
|
}
|
|
18729
19427
|
}
|
|
18730
19428
|
}
|
|
@@ -18791,6 +19489,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18791
19489
|
/** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
|
|
18792
19490
|
dispatchWebHttp(req, res, port, closing = false) {
|
|
18793
19491
|
try {
|
|
19492
|
+
// #1937: the listeners allow an upload long enough for a slow link; every other request keeps its old bound on
|
|
19493
|
+
// arriving whole — 10 s through the public link's gateway, Node's 300 s here.
|
|
19494
|
+
if (!isUploadRequest(req))
|
|
19495
|
+
limitRequestReceive(req, gatewayRequestContext(req) ? PUBLIC_GATEWAY_RECEIVE_MS : 300_000);
|
|
18794
19496
|
res.setHeader("Content-Type", "application/json");
|
|
18795
19497
|
// No Referer to a tunnel host, an upstream proxy, or any page linked from
|
|
18796
19498
|
// the panel — the dashboard URL is itself a credential-bearing address.
|
|
@@ -19132,6 +19834,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19132
19834
|
if (!this.webToken || !this.webSessions)
|
|
19133
19835
|
this.initializeWebAuthTokens();
|
|
19134
19836
|
const server = createServer((req, res) => this.dispatchWebHttp(req, res, port, drain.closing));
|
|
19837
|
+
// #1937: Node's default (300 s to receive a request) would cut a large upload on a slow link. The listener allows
|
|
19838
|
+
// more; the upload route keeps its own clocks (no byte for a minute, or 30 minutes in all), and every other request
|
|
19839
|
+
// keeps the old 300 s (limitRequestReceive in dispatchWebHttp).
|
|
19840
|
+
server.requestTimeout = UPLOAD_REQUEST_TIMEOUT_MS;
|
|
19135
19841
|
const drain = trackWebServer(server);
|
|
19136
19842
|
this.healthServer = server;
|
|
19137
19843
|
this.healthServerDrain = drain;
|
|
@@ -19319,12 +20025,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19319
20025
|
const classicRooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
|
|
19320
20026
|
const profiles = profileIdentities(this.dataDir);
|
|
19321
20027
|
// #1630: decorative — whatever goes wrong deciding an avatar never costs the frame (every instance gets its first character).
|
|
19322
|
-
let
|
|
20028
|
+
let bots = { own: new Map(), speaking: new Map() };
|
|
19323
20029
|
try {
|
|
19324
|
-
|
|
20030
|
+
bots = this.webBots();
|
|
19325
20031
|
}
|
|
19326
20032
|
catch (err) {
|
|
19327
|
-
this.logger.debug({ err }, "
|
|
20033
|
+
this.logger.debug({ err }, "bots unreadable (getUiStatus)");
|
|
19328
20034
|
}
|
|
19329
20035
|
const instances = names.map(name => {
|
|
19330
20036
|
const statusFile = join(this.getInstanceDir(name), "statusline.json");
|
|
@@ -19363,9 +20069,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19363
20069
|
const effort = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.effort;
|
|
19364
20070
|
const effort_source = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.source;
|
|
19365
20071
|
const identity = resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic: classicRooms.get(name), profile: profiles.get(name) });
|
|
20072
|
+
const compact = this.compactWatchService?.view(name) ?? null;
|
|
19366
20073
|
let image;
|
|
19367
20074
|
try {
|
|
19368
|
-
image = this.webIdentityImage(name, identity.display_name, profiles.get(name)?.avatar_path,
|
|
20075
|
+
image = this.webIdentityImage(name, identity.display_name, profiles.get(name)?.avatar_path, bots);
|
|
19369
20076
|
}
|
|
19370
20077
|
catch (err) {
|
|
19371
20078
|
this.logger.debug({ err, name }, "avatar unreadable (getUiStatus)");
|
|
@@ -19390,6 +20097,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19390
20097
|
effort,
|
|
19391
20098
|
effort_source,
|
|
19392
20099
|
...this.instancePresentation(name),
|
|
20100
|
+
// Board 003648ac: the last web /compact, while it runs and briefly after (the chat's card follows it).
|
|
20101
|
+
...(compact ? { compact } : {}),
|
|
19393
20102
|
};
|
|
19394
20103
|
});
|
|
19395
20104
|
return {
|