@songsid/agend 2.2.0-alpha.1 → 2.2.0-alpha.3
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 +6 -4
- package/README.zh-TW.md +6 -4
- package/dist/agent-cli.js +6 -1
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +4 -1
- package/dist/agent-endpoint.js +82 -14
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-token-header.d.ts +13 -0
- package/dist/agent-token-header.js +16 -0
- package/dist/agent-token-header.js.map +1 -0
- package/dist/atomic-write.d.ts +21 -0
- package/dist/atomic-write.js +65 -0
- package/dist/atomic-write.js.map +1 -0
- package/dist/auth-api.js +2 -0
- package/dist/auth-api.js.map +1 -1
- package/dist/backend/antigravity.js +4 -3
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/codex.d.ts +7 -0
- package/dist/backend/codex.js +7 -0
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/grok.js +1 -1
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro.d.ts +7 -0
- package/dist/backend/kiro.js +82 -33
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/muse.d.ts +7 -0
- package/dist/backend/muse.js +8 -1
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +12 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/binary-probe.d.ts +80 -0
- package/dist/binary-probe.js +182 -0
- package/dist/binary-probe.js.map +1 -0
- package/dist/cache-analysis.d.ts +92 -0
- package/dist/cache-analysis.js +147 -0
- package/dist/cache-analysis.js.map +1 -0
- package/dist/cache-ledger.d.ts +108 -0
- package/dist/cache-ledger.js +230 -0
- package/dist/cache-ledger.js.map +1 -0
- package/dist/cache-prices.d.ts +32 -0
- package/dist/cache-prices.js +73 -0
- package/dist/cache-prices.js.map +1 -0
- package/dist/cache-scan.d.ts +39 -0
- package/dist/cache-scan.js +115 -0
- package/dist/cache-scan.js.map +1 -0
- package/dist/cache-service.d.ts +131 -0
- package/dist/cache-service.js +452 -0
- package/dist/cache-service.js.map +1 -0
- package/dist/channel/adapters/discord.d.ts +5 -1
- package/dist/channel/adapters/discord.js +56 -11
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +38 -1
- package/dist/channel/adapters/telegram.js +82 -0
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-tools.js +1 -1
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/tool-router.d.ts +8 -1
- package/dist/channel/tool-router.js +4 -2
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +21 -0
- package/dist/cli-entry.d.ts +19 -0
- package/dist/cli-entry.js +36 -0
- package/dist/cli-entry.js.map +1 -0
- package/dist/cli.js +376 -54
- package/dist/cli.js.map +1 -1
- package/dist/connection-secrets.d.ts +5 -0
- package/dist/connection-secrets.js.map +1 -1
- package/dist/cpu-profile.js +50 -0
- package/dist/cpu-profile.js.map +1 -1
- package/dist/daemon.d.ts +18 -0
- package/dist/daemon.js +256 -63
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +1 -0
- package/dist/delivery-outbox.js +12 -0
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/discord-permissions.d.ts +33 -0
- package/dist/discord-permissions.js +42 -0
- package/dist/discord-permissions.js.map +1 -0
- package/dist/env-file.d.ts +11 -0
- package/dist/env-file.js +27 -0
- package/dist/env-file.js.map +1 -0
- package/dist/event-log.d.ts +4 -1
- package/dist/event-log.js +6 -3
- package/dist/event-log.js.map +1 -1
- package/dist/fleet-context.d.ts +5 -0
- package/dist/fleet-context.js.map +1 -1
- package/dist/fleet-control-audit.d.ts +5 -0
- package/dist/fleet-control-audit.js +7 -0
- package/dist/fleet-control-audit.js.map +1 -1
- package/dist/fleet-level-config.d.ts +8 -1
- package/dist/fleet-level-config.js +53 -0
- package/dist/fleet-level-config.js.map +1 -1
- package/dist/fleet-manager.d.ts +122 -7
- package/dist/fleet-manager.js +871 -152
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-stop-budget.d.ts +22 -0
- package/dist/fleet-stop-budget.js +95 -0
- package/dist/fleet-stop-budget.js.map +1 -0
- package/dist/full-restart.js +2 -3
- package/dist/full-restart.js.map +1 -1
- package/dist/general-knowledge/skills/persona-emoji/SKILL.md +3 -1
- package/dist/install-lock.d.ts +49 -0
- package/dist/install-lock.js +200 -0
- package/dist/install-lock.js.map +1 -0
- package/dist/instance-config-impact.js +3 -2
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/kiro-transcript-lane.d.ts +8 -4
- package/dist/kiro-transcript-lane.js +66 -44
- package/dist/kiro-transcript-lane.js.map +1 -1
- package/dist/locale.js +96 -0
- package/dist/locale.js.map +1 -1
- package/dist/next-steps.d.ts +5 -0
- package/dist/next-steps.js +26 -0
- package/dist/next-steps.js.map +1 -0
- package/dist/outbound-handlers.js +8 -0
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +4 -0
- package/dist/outbound-schemas.js +7 -2
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/outbox-open-error.d.ts +7 -0
- package/dist/outbox-open-error.js +15 -0
- package/dist/outbox-open-error.js.map +1 -0
- package/dist/package-preimage.d.ts +26 -0
- package/dist/package-preimage.js +96 -0
- package/dist/package-preimage.js.map +1 -0
- package/dist/pane-context-cache.d.ts +25 -0
- package/dist/pane-context-cache.js +73 -0
- package/dist/pane-context-cache.js.map +1 -0
- package/dist/paths.d.ts +0 -7
- package/dist/paths.js +8 -2
- package/dist/paths.js.map +1 -1
- package/dist/provider-probe.d.ts +15 -0
- package/dist/provider-probe.js +20 -0
- package/dist/provider-probe.js.map +1 -1
- package/dist/public-link-progress.d.ts +73 -0
- package/dist/public-link-progress.js +190 -0
- package/dist/public-link-progress.js.map +1 -0
- package/dist/public-web-gateway.js +2 -2
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/public-web-link.d.ts +6 -1
- package/dist/public-web-link.js +87 -15
- package/dist/public-web-link.js.map +1 -1
- package/dist/quickstart-api.d.ts +93 -20
- package/dist/quickstart-api.js +194 -62
- package/dist/quickstart-api.js.map +1 -1
- package/dist/quickstart.d.ts +15 -0
- package/dist/quickstart.js +110 -33
- package/dist/quickstart.js.map +1 -1
- package/dist/reply-buttons-controller.d.ts +98 -0
- package/dist/reply-buttons-controller.js +174 -0
- package/dist/reply-buttons-controller.js.map +1 -0
- package/dist/reply-buttons.d.ts +113 -0
- package/dist/reply-buttons.js +208 -0
- package/dist/reply-buttons.js.map +1 -0
- package/dist/reply-dedup.js +3 -1
- package/dist/reply-dedup.js.map +1 -1
- package/dist/restart-guard.d.ts +53 -0
- package/dist/restart-guard.js +114 -0
- package/dist/restart-guard.js.map +1 -0
- package/dist/scheduler/db.js +6 -2
- package/dist/scheduler/db.js.map +1 -1
- package/dist/service-activation.d.ts +62 -7
- package/dist/service-activation.js +237 -55
- package/dist/service-activation.js.map +1 -1
- package/dist/service-installer.d.ts +56 -1
- package/dist/service-installer.js +152 -12
- package/dist/service-installer.js.map +1 -1
- package/dist/service-plan.d.ts +65 -0
- package/dist/service-plan.js +108 -0
- package/dist/service-plan.js.map +1 -0
- package/dist/settings-api.js +92 -10
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-authority.d.ts +11 -0
- package/dist/settings-authority.js +128 -0
- package/dist/settings-authority.js.map +1 -0
- package/dist/settings-change.d.ts +5 -0
- package/dist/settings-change.js +37 -8
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-confirmation.d.ts +3 -0
- package/dist/settings-confirmation.js +7 -1
- package/dist/settings-confirmation.js.map +1 -1
- package/dist/settings-effect.js +19 -7
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.js +1 -1
- package/dist/settings-http-confirmation.js.map +1 -1
- package/dist/settings-redaction.d.ts +18 -0
- package/dist/settings-redaction.js +58 -0
- package/dist/settings-redaction.js.map +1 -0
- package/dist/setup-form.d.ts +9 -1
- package/dist/setup-form.js +25 -1
- package/dist/setup-form.js.map +1 -1
- package/dist/setup-host.js +15 -6
- package/dist/setup-host.js.map +1 -1
- package/dist/setup-wizard.js +27 -19
- package/dist/setup-wizard.js.map +1 -1
- package/dist/sqlite-open-errors.d.ts +12 -0
- package/dist/sqlite-open-errors.js +13 -0
- package/dist/sqlite-open-errors.js.map +1 -0
- package/dist/systemd-runtime.d.ts +15 -0
- package/dist/systemd-runtime.js +41 -0
- package/dist/systemd-runtime.js.map +1 -0
- package/dist/tmux-control.d.ts +44 -0
- package/dist/tmux-control.js +170 -21
- package/dist/tmux-control.js.map +1 -1
- package/dist/token-env-name.d.ts +37 -0
- package/dist/token-env-name.js +93 -0
- package/dist/token-env-name.js.map +1 -0
- package/dist/tool-permissions.js +5 -3
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.d.ts +7 -16
- package/dist/topic-commands.js +56 -85
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-monitor.js +3 -0
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +68 -0
- package/dist/transcript-sources.js +336 -29
- package/dist/transcript-sources.js.map +1 -1
- package/dist/transcript-turns.d.ts +39 -0
- package/dist/transcript-turns.js +58 -0
- package/dist/transcript-turns.js.map +1 -0
- package/dist/tunnel/cloudflared-install.d.ts +18 -0
- package/dist/tunnel/cloudflared-install.js +15 -1
- package/dist/tunnel/cloudflared-install.js.map +1 -1
- package/dist/turn-reply-guard.d.ts +3 -0
- package/dist/turn-reply-guard.js.map +1 -1
- package/dist/ui/chat-commands.js +43 -0
- package/dist/ui/chat-render.js +34 -8
- package/dist/ui/chat-store.js +51 -2
- package/dist/ui/chat-strings.js +30 -2
- package/dist/ui/chat-thread.js +141 -47
- package/dist/ui/first-run.js +64 -0
- package/dist/ui/fleet-cache.js +168 -0
- package/dist/ui/html-attachment.js +26 -0
- package/dist/ui/panel-chat.js +117 -23
- package/dist/ui/panel-fleet.js +31 -17
- package/dist/ui/panel-settings.js +29 -15
- package/dist/ui/preview-panel.js +237 -0
- package/dist/ui/preview.js +7 -3
- package/dist/ui/settings-apply.js +3 -1
- package/dist/ui/settings-dialogs.js +95 -25
- package/dist/ui/settings-discord.js +130 -0
- package/dist/ui/settings-model.js +21 -0
- package/dist/ui/settings-strings.js +50 -8
- package/dist/ui/settings-token.js +54 -0
- package/dist/ui/settings-wizard.js +63 -29
- package/dist/ui/shared/app-route.js +1 -1
- package/dist/ui/shared/app-shell.js +25 -37
- package/dist/ui/shared/app-store.js +5 -3
- package/dist/ui/shared/app-stream.js +2 -1
- package/dist/ui/shared/app.css +181 -5
- package/dist/ui/shared/app.js +2 -1
- package/dist/ui/shared/instance-nav.js +310 -0
- package/dist/ui/shared/panel-view.js +12 -190
- package/dist/ui/shared/signin.js +13 -1
- package/dist/ui/shared/ui-icons.js +2 -0
- package/dist/ui/shared/view-roster-store.js +5 -0
- package/dist/ui/shared/view-strings.js +12 -0
- package/dist/ui/signin.html +2 -1
- package/dist/update-dispatch.d.ts +28 -0
- package/dist/update-dispatch.js +73 -0
- package/dist/update-dispatch.js.map +1 -0
- package/dist/update-install.d.ts +78 -8
- package/dist/update-install.js +158 -30
- package/dist/update-install.js.map +1 -1
- package/dist/update-launch.d.ts +22 -0
- package/dist/update-launch.js +106 -0
- package/dist/update-launch.js.map +1 -0
- package/dist/update-marker.js +3 -8
- package/dist/update-marker.js.map +1 -1
- package/dist/usage/format-rich.js +7 -1
- package/dist/usage/format-rich.js.map +1 -1
- package/dist/usage/i18n-keys.d.ts +1 -1
- package/dist/usage/i18n-keys.js +1 -1
- package/dist/usage/i18n-keys.js.map +1 -1
- package/dist/usage/providers.d.ts +4 -0
- package/dist/usage/providers.js +2 -1
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.js +48 -2
- package/dist/usage/usage-api.js.map +1 -1
- package/dist/view-api.d.ts +40 -0
- package/dist/view-api.js +28 -10
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.d.ts +22 -0
- package/dist/web-api.js +115 -16
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.d.ts +8 -0
- package/dist/web-auth.js +34 -8
- package/dist/web-auth.js.map +1 -1
- package/dist/web-chat-history.d.ts +21 -0
- package/dist/web-chat-history.js +44 -0
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-commands.d.ts +77 -0
- package/dist/web-commands.js +81 -0
- package/dist/web-commands.js.map +1 -0
- package/dist/web-host-guard.d.ts +18 -2
- package/dist/web-host-guard.js +30 -5
- package/dist/web-host-guard.js.map +1 -1
- package/dist/web-session.d.ts +7 -1
- package/dist/web-session.js +23 -2
- package/dist/web-session.js.map +1 -1
- package/dist/web-shell-routes.d.ts +1 -1
- package/dist/web-shell-routes.js +1 -1
- package/dist/web-shell-routes.js.map +1 -1
- package/launcher/agend +110 -0
- package/launcher/agend-agent +110 -0
- package/launcher/agend-agent.cjs +4 -0
- package/launcher/agend.cjs +4 -0
- package/launcher/install-admission.cjs +105 -0
- package/launcher/launch.cjs +64 -0
- package/launcher/postinstall.cjs +118 -0
- package/launcher/run-in-process.cjs +13 -0
- package/launcher/runtime-platform.cjs +86 -0
- package/launcher/runtime-select.cjs +217 -0
- package/package.json +12 -4
- package/scripts/preinstall-guard.cjs +40 -21
- package/templates/launchd.plist.ejs +2 -2
- package/templates/systemd.service.ejs +4 -4
package/dist/fleet-manager.js
CHANGED
|
@@ -6,6 +6,7 @@ import { SettingsExecution, settingsRevision, noteSettingsWrite, settingsFileRes
|
|
|
6
6
|
import { performance } from "node:perf_hooks";
|
|
7
7
|
import { gatewayRequestContext } from "./web-request-context.js";
|
|
8
8
|
import { createPublicWebGateway } from "./public-web-gateway.js";
|
|
9
|
+
import { renderPublicLinkProgress, ThrottledMessageEditor } from "./public-link-progress.js";
|
|
9
10
|
import { PublicWebLink, publicLinkSettings } from "./public-web-link.js";
|
|
10
11
|
import { TunnelPurposeLane } from "./tunnel/purpose-lane.js";
|
|
11
12
|
import { ManagedTunnel } from "./tunnel/manager.js";
|
|
@@ -14,6 +15,7 @@ import { measureSyncWork } from "./sync-work-attribution.js";
|
|
|
14
15
|
import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
|
|
15
16
|
import { ProfileControlServer } from "./profile-control.js";
|
|
16
17
|
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, constants as fsConstants } from "node:fs";
|
|
18
|
+
import { atomicWriteFileSync } from "./atomic-write.js";
|
|
17
19
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
18
20
|
import { spawnSync } from "node:child_process";
|
|
19
21
|
import { freemem, totalmem, cpus, homedir } from "node:os";
|
|
@@ -39,6 +41,9 @@ import { STATUS_EMOJI_CONFIG_KEYS, STATUS_EMOJI_KEYS, STATUS_EMOJI_SUGGESTIONS,
|
|
|
39
41
|
import { isProbeableRouteTarget } from "./fleet-context.js";
|
|
40
42
|
import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
|
|
41
43
|
import { EventLog } from "./event-log.js";
|
|
44
|
+
import { binaryProbe } from "./binary-probe.js";
|
|
45
|
+
import { classifySqliteOpenError } from "./sqlite-open-errors.js";
|
|
46
|
+
import { OutboxOpenError } from "./outbox-open-error.js";
|
|
42
47
|
import { AdapterWorld } from "./adapter-world.js";
|
|
43
48
|
import { CostGuard, formatCents } from "./cost-guard.js";
|
|
44
49
|
import { startEventLoopWatch } from "./event-loop-watch.js";
|
|
@@ -120,8 +125,13 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
|
|
|
120
125
|
}
|
|
121
126
|
import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
|
|
122
127
|
import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
|
|
128
|
+
import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
|
|
129
|
+
import { ReplyButtonsController } from "./reply-buttons-controller.js";
|
|
123
130
|
import { publicAttachment, sweepOrphanedUploads, WebFileLedger } from "./web-upload.js";
|
|
124
|
-
import { handleViewRequest, isViewPath } from "./view-api.js";
|
|
131
|
+
import { handleViewRequest, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
|
|
132
|
+
import { envFileKeys } from "./token-env-name.js";
|
|
133
|
+
import { parseEnvText } from "./env-file.js";
|
|
134
|
+
import { isDisallowedIntentsError, isRejectedTokenError } from "./discord-permissions.js";
|
|
125
135
|
import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
|
|
126
136
|
import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
|
|
127
137
|
import { LoginSession } from "./login-manager.js";
|
|
@@ -129,7 +139,9 @@ import { tightenInstanceDirs } from "./private-dir.js";
|
|
|
129
139
|
import { decideSlash } from "./slash-authz.js";
|
|
130
140
|
import { commandSpec, decideCommand, ruleFor } from "./command-table.js";
|
|
131
141
|
import { runVisibilityCommand } from "./cross-instance-notice.js";
|
|
132
|
-
import {
|
|
142
|
+
import { installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
|
|
143
|
+
import { resolveInstalledAgend } from "./update-dispatch.js";
|
|
144
|
+
import { resolveUpdateLaunch, watchUpdateLaunch } from "./update-launch.js";
|
|
133
145
|
import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
|
|
134
146
|
import { runBeforeDeadline } from "./deadline.js";
|
|
135
147
|
import { LoginWindowLock } from "./login-window-lock.js";
|
|
@@ -156,6 +168,10 @@ import { NeedsYouHub } from "./needs-you-hub.js";
|
|
|
156
168
|
import { WEB_CHAT_NOTICE, WEB_REMOTE_DOCS_URL, claimNotice, hasWebChat, releaseNotice, upgradeNoticesPath } from "./upgrade-notices.js";
|
|
157
169
|
import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
|
|
158
170
|
import { buildOrgChart } from "./web-org.js";
|
|
171
|
+
import { CacheService, WINDOWS } from "./cache-service.js";
|
|
172
|
+
import { runWebCommand } from "./web-commands.js";
|
|
173
|
+
import { claudeProjectKey } from "./backend/claude-code.js";
|
|
174
|
+
import { sharedRolloutIndex } from "./rollout-index.js";
|
|
159
175
|
import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
|
|
160
176
|
import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
|
|
161
177
|
import { bypassesWebGate, handleAuthRequest, serveSigninPage } from "./auth-api.js";
|
|
@@ -164,7 +180,7 @@ import { tokenEpoch, WebSessionStore } from "./web-session.js";
|
|
|
164
180
|
import { WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
|
|
165
181
|
import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
|
|
166
182
|
import { createPreviewListener, previewAvailability, previewSettings } from "./web-preview.js";
|
|
167
|
-
import { fleetLevelDifferences, fleetLevelSignature } from "./fleet-level-config.js";
|
|
183
|
+
import { fleetLevelDifferences, fleetLevelSignature, appendedConnections } from "./fleet-level-config.js";
|
|
168
184
|
import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
|
|
169
185
|
import { SecretStore } from "./secret-store.js";
|
|
170
186
|
import { opaqueId, safeSecretError, SECRET_CHALLENGE_TTL_MS, } from "./connection-secrets.js";
|
|
@@ -334,6 +350,7 @@ const WEB_MIRRORED_PROMPT_PREFIXES = new Set([
|
|
|
334
350
|
*/
|
|
335
351
|
const WEB_ONLY_REPLY_SINK = {
|
|
336
352
|
type: "web",
|
|
353
|
+
supportsReplyButtons: true, // #1266: the web chat shows a reply's buttons itself
|
|
337
354
|
sendText: async () => ({ chatId: "web", messageId: newWebMessageId() }),
|
|
338
355
|
sendFile: async () => ({ chatId: "web", messageId: newWebMessageId() }),
|
|
339
356
|
};
|
|
@@ -437,6 +454,8 @@ export class FleetManager {
|
|
|
437
454
|
dataDir;
|
|
438
455
|
static signalTarget = null;
|
|
439
456
|
static sighupHandlerInstalled = false;
|
|
457
|
+
/** Test seam: inject a spy to verify saveFleetConfig calls fsync. Default: fsyncSync. */
|
|
458
|
+
fsyncForTest = undefined;
|
|
440
459
|
children = new Map();
|
|
441
460
|
lifecycle;
|
|
442
461
|
stormWindow;
|
|
@@ -833,21 +852,66 @@ export class FleetManager {
|
|
|
833
852
|
return false;
|
|
834
853
|
await settlement;
|
|
835
854
|
const job = this.providerSecretJobs.get(id) ?? this.connectionSecretJobs.get(id) ?? this.connectionBindingJobs.get(id);
|
|
836
|
-
|
|
855
|
+
// restart_required: the value is stored and committed (the receipt is checked above), only no adapter was running
|
|
856
|
+
// to take it — the change is done, and the page says it starts with the next restart (#1519 P1), never "failed".
|
|
857
|
+
return !!job && ["applied", "applied_next_use", "reloaded", "restart_required"].includes(job.result);
|
|
837
858
|
},
|
|
838
859
|
});
|
|
839
860
|
this.settingsConfirmation = gate;
|
|
840
861
|
return gate;
|
|
841
862
|
}
|
|
863
|
+
/** Exact configured effect owners; this does not depend on prompt location. */
|
|
864
|
+
settingsChatAuthority(store, id) {
|
|
865
|
+
const scope = store.authorityOf(id);
|
|
866
|
+
if (!scope || scope.unknown)
|
|
867
|
+
return null;
|
|
868
|
+
const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
869
|
+
const targets = new Set(scope.connections);
|
|
870
|
+
let primaryGeneral;
|
|
871
|
+
const selectPrimary = () => {
|
|
872
|
+
const generals = Object.entries(this.fleetConfig?.instances ?? {}).filter(([, cfg]) => cfg.general_topic === true);
|
|
873
|
+
const selected = generals.find(([name]) => this.getInstanceAdapterId(name) === this.getPrimaryAdapterId()) ?? generals[0];
|
|
874
|
+
const owner = selected && this.getInstanceAdapterId(selected[0]);
|
|
875
|
+
return selected && owner ? { name: selected[0], owner } : undefined;
|
|
876
|
+
};
|
|
877
|
+
if (scope.primaryGeneral) {
|
|
878
|
+
primaryGeneral = selectPrimary();
|
|
879
|
+
if (!primaryGeneral)
|
|
880
|
+
return null;
|
|
881
|
+
targets.add(primaryGeneral.owner);
|
|
882
|
+
}
|
|
883
|
+
const owners = [...targets].map(id => channels.filter(ch => (ch.id ?? ch.type) === id));
|
|
884
|
+
if (!owners.length || owners.some(matches => matches.length !== 1))
|
|
885
|
+
return null;
|
|
886
|
+
const platform = owners[0][0].type;
|
|
887
|
+
if ((platform !== "telegram" && platform !== "discord") || owners.some(matches => matches[0].type !== platform))
|
|
888
|
+
return null;
|
|
889
|
+
const identities = owners.map(matches => ({ id: matches[0].id ?? matches[0].type, type: matches[0].type }));
|
|
890
|
+
const generation = this.settingsGeneration;
|
|
891
|
+
const current = () => !this.shuttingDown && this.settingsGeneration === generation
|
|
892
|
+
&& identities.every(owner => {
|
|
893
|
+
const configs = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
894
|
+
const matches = configs.filter(ch => (ch.id ?? ch.type) === owner.id);
|
|
895
|
+
return matches.length === 1 && matches[0].type === owner.type;
|
|
896
|
+
})
|
|
897
|
+
&& (!primaryGeneral || selectPrimary()?.name === primaryGeneral.name && selectPrimary()?.owner === primaryGeneral.owner);
|
|
898
|
+
return { platform, current, admin: userId => current() && !!userId
|
|
899
|
+
&& identities.every(owner => this.isFleetAdmin(userId, owner.id)) };
|
|
900
|
+
}
|
|
842
901
|
async promptSettingsChange(store, view) {
|
|
843
902
|
const generation = this.settingsGeneration;
|
|
903
|
+
const authority = this.settingsChatAuthority(store, view.id);
|
|
904
|
+
if (!authority) {
|
|
905
|
+
this.logger.info({ id: view.id }, "Settings authority is unknown or spans platforms; host CLI confirmation required");
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
844
908
|
const excluded = new Set(store.affectedConnections(view.id));
|
|
845
909
|
for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
|
|
846
910
|
if (!config.general_topic)
|
|
847
911
|
continue;
|
|
848
912
|
const adapterId = this.getInstanceAdapterId(name), adapter = this.getAdapterForInstance(name);
|
|
849
913
|
const general = this.daemons.get(name);
|
|
850
|
-
if (!adapterId || !adapter || !general || excluded.has(adapterId) ||
|
|
914
|
+
if (!adapterId || !adapter || !general || excluded.has(adapterId) || adapter.type !== authority.platform)
|
|
851
915
|
continue;
|
|
852
916
|
const chatId = this.getGroupIdForInstance(name), topic = String(config.topic_id ?? "");
|
|
853
917
|
if (!chatId || !topic)
|
|
@@ -860,7 +924,7 @@ export class FleetManager {
|
|
|
860
924
|
&& this.adapterState.get(adapterId)?.status === "connected"
|
|
861
925
|
&& this.getInstanceAdapterId(name) === adapterId && this.getAdapterForInstance(name) === adapter
|
|
862
926
|
&& this.getGroupIdForInstance(name) === chatId && String(this.fleetConfig?.instances[name]?.topic_id ?? "") === topic
|
|
863
|
-
&&
|
|
927
|
+
&& authority.current();
|
|
864
928
|
if (!current())
|
|
865
929
|
continue;
|
|
866
930
|
try {
|
|
@@ -869,7 +933,7 @@ export class FleetManager {
|
|
|
869
933
|
message: `🔒 Settings change awaiting fleet-admin confirmation\nSource: ${view.source}\nRequester: ${view.requested_by}\n${view.summary.join("\n")}`,
|
|
870
934
|
choices: [{ action: "confirm", label: "Confirm" }, { action: "reject", label: "Reject" }],
|
|
871
935
|
expiredText: "Settings confirmation is no longer pending.", timeoutMs: view.remaining_ms,
|
|
872
|
-
extra: { pendingChangeId: view.id, confirmationCurrent: current },
|
|
936
|
+
extra: { pendingChangeId: view.id, confirmationCurrent: current, confirmationAdmin: userId => current() && authority.admin(userId) },
|
|
873
937
|
});
|
|
874
938
|
void posted.then(nonce => {
|
|
875
939
|
if (!retired)
|
|
@@ -907,7 +971,7 @@ export class FleetManager {
|
|
|
907
971
|
this.logger.info({ id: view.id }, "Settings change requires local `agend settings confirm <id>`");
|
|
908
972
|
}
|
|
909
973
|
async handleSettingsChangeCallback(data, adapterId, adapter) {
|
|
910
|
-
const result = this.consumeNonceCallback("settings-confirm:", /^settings-confirm:([0-9a-f]{32}):(confirm|reject)$/, data, adapterId, adapter);
|
|
974
|
+
const result = this.consumeNonceCallback("settings-confirm:", /^settings-confirm:([0-9a-f]{32}):(confirm|reject)$/, data, adapterId, adapter, undefined, (userId, entry) => entry.confirmationAdmin?.(userId) === true);
|
|
911
975
|
if (!result)
|
|
912
976
|
return false;
|
|
913
977
|
if (result === "consumed")
|
|
@@ -917,7 +981,7 @@ export class FleetManager {
|
|
|
917
981
|
const view = await this.settingsGate().store.decide(entry.pendingChangeId, action, {
|
|
918
982
|
label: `fleet admin ${data.userId}`,
|
|
919
983
|
current: () => entry.confirmationCurrent?.() === true && this.getAdapterForInstance(entry.instanceName) === entry.adapter
|
|
920
|
-
&& !!data.userId &&
|
|
984
|
+
&& !!data.userId && entry.confirmationAdmin?.(data.userId) === true,
|
|
921
985
|
});
|
|
922
986
|
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, view.outcome?.message ?? "Settings change is applying.");
|
|
923
987
|
}
|
|
@@ -990,16 +1054,111 @@ export class FleetManager {
|
|
|
990
1054
|
this.topicArchiver = new TopicArchiver(this);
|
|
991
1055
|
this.statuslineWatcher = new StatuslineWatcher(this);
|
|
992
1056
|
}
|
|
1057
|
+
/** #1266: a reply's buttons — the store (reply-buttons.db) and what a click does. Created on first use. */
|
|
1058
|
+
replyButtonsCtl = null;
|
|
1059
|
+
/** The store could not be opened in this process: buttons are offered as text until the next start. */
|
|
1060
|
+
replyButtonsUnavailable = false;
|
|
1061
|
+
/**
|
|
1062
|
+
* The controller, or null when reply-buttons.db cannot be opened (#1500 review). That file holds only open choices,
|
|
1063
|
+
* so it never stops AgEnD: the failure is logged and posted once, replies offer their choices as text, and a click on
|
|
1064
|
+
* an older button is answered "closed". The file is left where it is — an open error is not proof of corruption.
|
|
1065
|
+
*/
|
|
1066
|
+
replyButtons() {
|
|
1067
|
+
if (this.replyButtonsCtl)
|
|
1068
|
+
return this.replyButtonsCtl;
|
|
1069
|
+
if (this.replyButtonsUnavailable)
|
|
1070
|
+
return null;
|
|
1071
|
+
const path = join(this.dataDir, "reply-buttons.db");
|
|
1072
|
+
let store;
|
|
1073
|
+
try {
|
|
1074
|
+
store = new ReplyButtonStore(path);
|
|
1075
|
+
}
|
|
1076
|
+
catch (err) {
|
|
1077
|
+
this.replyButtonsUnavailable = true;
|
|
1078
|
+
this.logger.error({ err: err.message, path }, "Reply buttons unavailable: reply-buttons.db could not be opened — replies offer their choices as text");
|
|
1079
|
+
try {
|
|
1080
|
+
this.notifyFleetError(`⚠️ Reply buttons are off until AgEnD restarts: ${path} could not be opened (${err.message}). Replies offer their choices as text.`);
|
|
1081
|
+
}
|
|
1082
|
+
catch { /* the notice is best effort */ }
|
|
1083
|
+
return null;
|
|
1084
|
+
}
|
|
1085
|
+
this.replyButtonsCtl = new ReplyButtonsController({
|
|
1086
|
+
store,
|
|
1087
|
+
now: () => Date.now(),
|
|
1088
|
+
adapterFor: (adapterId) => this.worlds.get(adapterId)?.adapter ?? (adapterId === this.getPrimaryAdapterId() ? this.adapter ?? undefined : undefined),
|
|
1089
|
+
mayClick: (set, userId) => this.mayAnswerReplyButtons(set.instance, set.adapterId, userId),
|
|
1090
|
+
deliver: (set, button, by) => this.deliverReplyButtonChoice(set, button, by),
|
|
1091
|
+
publish: (instance, view) => this.emitSseEvent("reply_buttons", { instance, buttons: view }),
|
|
1092
|
+
logger: this.logger,
|
|
1093
|
+
setTimer: (fn, ms) => { const h = setTimeout(fn, ms); h.unref?.(); return h; },
|
|
1094
|
+
clearTimer: (h) => clearTimeout(h),
|
|
1095
|
+
});
|
|
1096
|
+
this.replyButtonsStore = store;
|
|
1097
|
+
return this.replyButtonsCtl;
|
|
1098
|
+
}
|
|
1099
|
+
replyButtonsStore = null;
|
|
1100
|
+
/**
|
|
1101
|
+
* #1266: who may answer a reply's buttons on a platform — whoever may message that instance there: in a ClassicBot
|
|
1102
|
+
* room anyone there (as a typed message), elsewhere someone the sending connection lets speak (#754/#1148), never a
|
|
1103
|
+
* fleet bot.
|
|
1104
|
+
*/
|
|
1105
|
+
mayAnswerReplyButtons(instance, adapterId, userId) {
|
|
1106
|
+
if ([...this.worlds.values()].some(w => w.botUserId && w.botUserId === userId))
|
|
1107
|
+
return false;
|
|
1108
|
+
if (this.classicChannels?.getChannelIdByInstance(instance) !== undefined)
|
|
1109
|
+
return true;
|
|
1110
|
+
const access = this.worlds.get(adapterId)?.accessManager ?? (adapterId === this.getPrimaryAdapterId() ? this.accessManager : null);
|
|
1111
|
+
return this.isFleetAdmin(userId, adapterId) || !!access?.isAllowed(userId);
|
|
1112
|
+
}
|
|
1113
|
+
/** #1266: the choice, delivered as an ordinary inbound message from whoever made it, and shown in the web chat. */
|
|
1114
|
+
async deliverReplyButtonChoice(set, button, by) {
|
|
1115
|
+
const content = replyButtonClickText(button);
|
|
1116
|
+
const web = by.source === "web";
|
|
1117
|
+
const ts = new Date().toISOString();
|
|
1118
|
+
const messageId = web ? newWebMessageId() : (set.messageId ?? "");
|
|
1119
|
+
const sent = await this.deliverToInstance(set.instance, {
|
|
1120
|
+
type: "fleet_inbound",
|
|
1121
|
+
content,
|
|
1122
|
+
targetSession: set.instance,
|
|
1123
|
+
meta: {
|
|
1124
|
+
chat_id: set.chatId, message_id: messageId, user: by.username, user_id: by.userId, ts,
|
|
1125
|
+
thread_id: set.threadId, adapter_id: set.adapterId === "web" ? undefined : set.adapterId,
|
|
1126
|
+
source: web ? "web" : (this.worlds.get(set.adapterId)?.adapter.type ?? "web"),
|
|
1127
|
+
},
|
|
1128
|
+
});
|
|
1129
|
+
if (sent === false)
|
|
1130
|
+
return false;
|
|
1131
|
+
this.lastInboundUser.set(set.instance, by.username);
|
|
1132
|
+
this.emitSseEvent("message", {
|
|
1133
|
+
instance: set.instance, sender: by.username, role: "user", text: content, ts, ...(web ? { messageId } : {}),
|
|
1134
|
+
});
|
|
1135
|
+
return true;
|
|
1136
|
+
}
|
|
993
1137
|
ensureDeliveryOutbox() {
|
|
994
1138
|
if (this.deliveryOutbox)
|
|
995
1139
|
return;
|
|
996
|
-
const
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
this.
|
|
1001
|
-
|
|
1140
|
+
const dbPath = join(this.dataDir, "delivery-outbox.db");
|
|
1141
|
+
let outbox;
|
|
1142
|
+
try {
|
|
1143
|
+
outbox = new DeliveryOutbox(dbPath, this.managerBootId);
|
|
1144
|
+
if (!this.deliveryOutboxRecovered) {
|
|
1145
|
+
const recovered = outbox.recoverForBoot(this.managerBootId);
|
|
1146
|
+
this.logger.info({ ...recovered }, "Recovered durable delivery outbox for this process boot");
|
|
1147
|
+
}
|
|
1002
1148
|
}
|
|
1149
|
+
catch (cause) {
|
|
1150
|
+
// Unlike events.db, these rows cannot be discarded: queued messages and uncertain submissions are authoritative.
|
|
1151
|
+
// Publish only a fully recovered store. A partial open/recovery must not authorize admission on a later call.
|
|
1152
|
+
try {
|
|
1153
|
+
outbox?.close();
|
|
1154
|
+
}
|
|
1155
|
+
catch { /* preserve the original failure */ }
|
|
1156
|
+
const error = new OutboxOpenError(dbPath, cause);
|
|
1157
|
+
this.logger.error({ err: cause, dbPath, kind: error.kind }, error.message);
|
|
1158
|
+
throw error;
|
|
1159
|
+
}
|
|
1160
|
+
this.deliveryOutbox = outbox;
|
|
1161
|
+
this.deliveryOutboxRecovered = true;
|
|
1003
1162
|
outbox.on("admitted", () => { this.scheduleDeliveryOutboxPump(); this.wakeCoordinator?.kick(); });
|
|
1004
1163
|
outbox.on("state", (event) => {
|
|
1005
1164
|
this.scheduleDeliveryOutboxPump();
|
|
@@ -1514,6 +1673,10 @@ export class FleetManager {
|
|
|
1514
1673
|
});
|
|
1515
1674
|
return caller;
|
|
1516
1675
|
}
|
|
1676
|
+
/** Pure cached admission for command continuations; no IO or update-progress lookup. */
|
|
1677
|
+
isFleetStopping() {
|
|
1678
|
+
return this.shuttingDown;
|
|
1679
|
+
}
|
|
1517
1680
|
/**
|
|
1518
1681
|
* Is the fleet going down (or coming back up) on purpose?
|
|
1519
1682
|
*
|
|
@@ -1575,6 +1738,16 @@ export class FleetManager {
|
|
|
1575
1738
|
lastActivityMs(name) {
|
|
1576
1739
|
return this.lastActivity.get(name) ?? 0;
|
|
1577
1740
|
}
|
|
1741
|
+
/** Capture the current daemon and delivery owner for a background context read. */
|
|
1742
|
+
getPaneContextSource(name) {
|
|
1743
|
+
const daemon = this.daemons.get(name);
|
|
1744
|
+
const source = daemon?.getPaneContextSource?.();
|
|
1745
|
+
if (!daemon || !source || this.shuttingDown)
|
|
1746
|
+
return null;
|
|
1747
|
+
const epoch = this.getDeliveryEpoch(name);
|
|
1748
|
+
return { ...source, generation: `${source.generation}:${epoch}`, isCurrent: () => !this.shuttingDown
|
|
1749
|
+
&& this.daemons.get(name) === daemon && this.getDeliveryEpoch(name) === epoch && source.isCurrent() };
|
|
1750
|
+
}
|
|
1578
1751
|
/**
|
|
1579
1752
|
* Is the instance between turns?
|
|
1580
1753
|
*
|
|
@@ -1850,7 +2023,7 @@ export class FleetManager {
|
|
|
1850
2023
|
await data.respond(t(`${action}.usage`));
|
|
1851
2024
|
return;
|
|
1852
2025
|
}
|
|
1853
|
-
if (!this.fleetConfig?.instances
|
|
2026
|
+
if (!Object.hasOwn(this.fleetConfig?.instances ?? {}, requested)) {
|
|
1854
2027
|
await data.respond(t("instance.not_found", requested));
|
|
1855
2028
|
return;
|
|
1856
2029
|
}
|
|
@@ -1898,14 +2071,27 @@ export class FleetManager {
|
|
|
1898
2071
|
const { spawn } = await import("node:child_process");
|
|
1899
2072
|
// Plain `agend update`: the CLI picks the channel from the version it is about to replace (a beta install
|
|
1900
2073
|
// stays on beta). Deciding here, from the package.json next to THIS code, read a source checkout's 1.22.0 as
|
|
1901
|
-
// "not a beta" and sent a beta install to @latest.
|
|
1902
|
-
const
|
|
2074
|
+
// "not a beta" and sent a beta install to @latest. #1450 C5: the INSTALLED agend, verified, by absolute path.
|
|
2075
|
+
const installed = await resolveInstalledAgend();
|
|
2076
|
+
if (!installed.ok) {
|
|
2077
|
+
this.failUpdateProgress(`/update cannot verify the installed AgEnD (${installed.reason}). Run \`agend update\` from a shell.`);
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
1903
2080
|
const origin = `slash /update by ${adapterId}:${data.userId}`;
|
|
1904
2081
|
recordInternalRequest(this.dataDir, "update", origin);
|
|
1905
|
-
const
|
|
2082
|
+
const launch = await resolveUpdateLaunch(installed.agend);
|
|
2083
|
+
if (!launch.ok) {
|
|
2084
|
+
this.failUpdateProgress(`/update cannot prepare an independent updater (${launch.reason}). Run ` + "`agend update` from a host shell.");
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
if (this.fleetAdminGate(data.userId, adapterId) !== "ok" || this.shuttingDown) {
|
|
2088
|
+
this.failUpdateProgress(t("not_authorized"));
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
const child = spawn(launch.command, launch.args, {
|
|
1906
2092
|
detached: true, stdio: "ignore", env: withOrigin(origin),
|
|
1907
2093
|
});
|
|
1908
|
-
child
|
|
2094
|
+
watchUpdateLaunch(child, message => this.failUpdateProgress(message));
|
|
1909
2095
|
child.unref();
|
|
1910
2096
|
}
|
|
1911
2097
|
async handleProfileSlash(data, adapterId) {
|
|
@@ -1927,7 +2113,7 @@ export class FleetManager {
|
|
|
1927
2113
|
await this.handleGeneralProfile(general, msg.userId, adapterId, seconds, async (text) => (await adapter.sendText(msg.chatId, text, { threadId: msg.threadId })).messageId);
|
|
1928
2114
|
}
|
|
1929
2115
|
async handleGeneralProfile(general, userId, ingressAdapterId, seconds, respond) {
|
|
1930
|
-
if (!general || !this.fleetConfig?.instances[general]?.general_topic) {
|
|
2116
|
+
if (!general || !Object.hasOwn(this.fleetConfig?.instances ?? {}, general) || !this.fleetConfig?.instances[general]?.general_topic) {
|
|
1931
2117
|
await respond(t("profile.general_only"));
|
|
1932
2118
|
return;
|
|
1933
2119
|
}
|
|
@@ -3173,7 +3359,9 @@ export class FleetManager {
|
|
|
3173
3359
|
await this.sendLocalDashboard(data, adapterId);
|
|
3174
3360
|
return;
|
|
3175
3361
|
}
|
|
3176
|
-
|
|
3362
|
+
// Discord shows a deferred reply that is never answered as "the application did not respond".
|
|
3363
|
+
if (!await this.showDashboardMenu(owner, data.respondButtons, data.retireButtons))
|
|
3364
|
+
await data.respond(t("dashboard.menu_failed"));
|
|
3177
3365
|
}
|
|
3178
3366
|
}
|
|
3179
3367
|
else if (data.command === "restart") {
|
|
@@ -3913,18 +4101,17 @@ export class FleetManager {
|
|
|
3913
4101
|
/**
|
|
3914
4102
|
* Probe the same executable set exposed by the web backend catalog.
|
|
3915
4103
|
*
|
|
3916
|
-
* This deliberately
|
|
4104
|
+
* This deliberately skips the cache: an install `/login` ran can add a
|
|
3917
4105
|
* binary to PATH while the fleet process remains alive, and the next bare
|
|
3918
4106
|
* `/login` must see it without requiring a restart or an explicit cache
|
|
3919
|
-
* invalidation call.
|
|
4107
|
+
* invalidation call. The fresh answers also refresh the shared cache that
|
|
4108
|
+
* `/ui/backends` reads. #1490: async and bounded, never `which` on the event loop; a probe with no answer in time
|
|
4109
|
+
* counts as not installed, as the old timeout did.
|
|
3920
4110
|
*/
|
|
3921
|
-
probeInstalledBackends() {
|
|
3922
|
-
const
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
installed.add(backend);
|
|
3926
|
-
}
|
|
3927
|
-
return installed;
|
|
4111
|
+
async probeInstalledBackends() {
|
|
4112
|
+
const entries = Object.entries(BACKEND_INSTALLATION_INFO);
|
|
4113
|
+
const found = await Promise.all(entries.map(([, info]) => binaryProbe.probe(info.binary, { fresh: true })));
|
|
4114
|
+
return new Set(entries.filter((_, i) => { const r = found[i]; return r.known && r.path !== null; }).map(([backend]) => backend));
|
|
3928
4115
|
}
|
|
3929
4116
|
/**
|
|
3930
4117
|
* One fleet-level notice per burst, not one per instance: a post-update herd
|
|
@@ -4283,24 +4470,10 @@ export class FleetManager {
|
|
|
4283
4470
|
if (!existsSync(envPath))
|
|
4284
4471
|
return;
|
|
4285
4472
|
assertSettingsLease(settingsFileResource(envPath), owner);
|
|
4286
|
-
|
|
4287
|
-
for
|
|
4288
|
-
|
|
4289
|
-
if (!trimmed || trimmed.startsWith("#"))
|
|
4290
|
-
continue;
|
|
4291
|
-
const eqIdx = trimmed.indexOf("=");
|
|
4292
|
-
if (eqIdx < 0)
|
|
4293
|
-
continue;
|
|
4294
|
-
// Accept `export KEY=value` — the shell-style form people paste from their
|
|
4295
|
-
// .bashrc. Without this the variable landed in process.env under the key
|
|
4296
|
-
// "export KEY" and silently did nothing.
|
|
4297
|
-
const key = trimmed.slice(0, eqIdx).replace(/^export\s+/, "").trim();
|
|
4298
|
-
const raw = trimmed.slice(eqIdx + 1);
|
|
4299
|
-
const value = raw.replace(/^["'](.*)["']$/, '$1');
|
|
4300
|
-
// .env file always wins over inherited shell env vars, so that
|
|
4301
|
-
// quickstart's newly written token overrides any stale value.
|
|
4473
|
+
// .env file always wins over inherited shell env vars, so that quickstart's newly written token overrides any stale
|
|
4474
|
+
// value. One parser for every reader of .env (env-file.ts): `export KEY=value` included.
|
|
4475
|
+
for (const [key, value] of parseEnvText(readFileSync(envPath, "utf-8")))
|
|
4302
4476
|
process.env[key] = value;
|
|
4303
|
-
}
|
|
4304
4477
|
}
|
|
4305
4478
|
/** Initialize auth before any adapter can answer /dashboard. */
|
|
4306
4479
|
initializeWebAuthTokens() {
|
|
@@ -4429,11 +4602,15 @@ export class FleetManager {
|
|
|
4429
4602
|
await adapter?.sendText(msg.chatId, t("dashboard.public_general"), { threadId: msg.threadId });
|
|
4430
4603
|
return;
|
|
4431
4604
|
}
|
|
4432
|
-
await this.showDashboardMenu(owner)
|
|
4605
|
+
if (!await this.showDashboardMenu(owner)) {
|
|
4606
|
+
await owner.binding.sendText(owner.chatId, t("dashboard.menu_failed"), { threadId: owner.threadId }).catch(() => undefined);
|
|
4607
|
+
}
|
|
4433
4608
|
}
|
|
4434
4609
|
async sendLocalDashboard(data, adapterId) {
|
|
4435
|
-
if (this.shuttingDown || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(data.userId, adapterId))
|
|
4610
|
+
if (this.shuttingDown || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(data.userId, adapterId)) {
|
|
4611
|
+
await data.respond(t("not_authorized"));
|
|
4436
4612
|
return;
|
|
4613
|
+
}
|
|
4437
4614
|
const text = this.topicCommands.getDashboardText();
|
|
4438
4615
|
const adapter = this.adapters.get(adapterId);
|
|
4439
4616
|
const token = this.webToken;
|
|
@@ -4455,7 +4632,8 @@ export class FleetManager {
|
|
|
4455
4632
|
await withinBudget(adapter.sendDirect(data.userId, privateText, { disablePreview: true }), Math.min(deadline, performance.now() + 5_000));
|
|
4456
4633
|
deliveredByDm = true;
|
|
4457
4634
|
}
|
|
4458
|
-
catch {
|
|
4635
|
+
catch (err) {
|
|
4636
|
+
this.logDashboardDmFailure(err);
|
|
4459
4637
|
if (!current())
|
|
4460
4638
|
throw new Error("owner changed");
|
|
4461
4639
|
const id = await withinBudget(data.respond(privateText), deadline); // native slash response is already ephemeral
|
|
@@ -4465,15 +4643,25 @@ export class FleetManager {
|
|
|
4465
4643
|
if (!current())
|
|
4466
4644
|
throw new Error("owner changed");
|
|
4467
4645
|
if (deliveredByDm)
|
|
4468
|
-
await withinBudget(data.respond(t("dashboard.
|
|
4646
|
+
await withinBudget(data.respond(t("dashboard.private_sent_dm")), deadline);
|
|
4469
4647
|
}
|
|
4470
|
-
catch {
|
|
4648
|
+
catch (err) {
|
|
4471
4649
|
this.webLoginCodes.revokeIfCurrent(login.issuanceId);
|
|
4650
|
+
this.logger.info({ err: err?.message }, "Dashboard private delivery was not confirmed");
|
|
4651
|
+
await data.respond(t("dashboard.private_failed")).catch(() => undefined);
|
|
4472
4652
|
}
|
|
4473
4653
|
}
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4654
|
+
/** Why the DM did not go, for the log only (Discord 50007 = the user does not accept DMs from this server's members). */
|
|
4655
|
+
logDashboardDmFailure(err) {
|
|
4656
|
+
const code = err?.code;
|
|
4657
|
+
this.logger.info({ code: typeof code === "string" || typeof code === "number" ? code : undefined, err: err?.message }, "Dashboard DM not delivered; answering privately where the command was used");
|
|
4658
|
+
}
|
|
4659
|
+
/** False when the menu was not shown (the caller still owes the user an answer). */
|
|
4660
|
+
async showDashboardMenu(owner, respondButtons, retireButtons) {
|
|
4661
|
+
if (!this.publicOwnerCurrent(owner)) {
|
|
4662
|
+
this.logger.info("Dashboard menu not shown: the General owner changed");
|
|
4663
|
+
return false;
|
|
4664
|
+
}
|
|
4477
4665
|
const adapter = owner.binding;
|
|
4478
4666
|
const status = this.getPublicWebStatus();
|
|
4479
4667
|
const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
|
|
@@ -4483,7 +4671,7 @@ export class FleetManager {
|
|
|
4483
4671
|
const choices = [{ action: "local", label: t("dashboard.local") },
|
|
4484
4672
|
...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
|
|
4485
4673
|
...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
|
|
4486
|
-
await this.
|
|
4674
|
+
const posted = await this.postNonceButtonPrompt({
|
|
4487
4675
|
prefix: "dashboard:", alertType: "login", instanceName: "dashboard", adapter, adapterId: owner.adapterId,
|
|
4488
4676
|
chatId: owner.chatId, threadId: owner.threadId, timeoutMs: 5 * 60_000,
|
|
4489
4677
|
message: menuText,
|
|
@@ -4492,9 +4680,11 @@ export class FleetManager {
|
|
|
4492
4680
|
const messageId = await respondButtons(menuText, c);
|
|
4493
4681
|
if (!messageId)
|
|
4494
4682
|
throw new Error("menu refused");
|
|
4495
|
-
|
|
4683
|
+
// The menu is the command's ephemeral reply: only that interaction can collapse it.
|
|
4684
|
+
return { chatId: owner.chatId, threadId: owner.threadId, messageId, ...(retireButtons ? { retire: retireButtons } : {}) };
|
|
4496
4685
|
} } : {}),
|
|
4497
4686
|
});
|
|
4687
|
+
return posted !== null;
|
|
4498
4688
|
}
|
|
4499
4689
|
async handleDashboardCallback(data, adapterId, adapter) {
|
|
4500
4690
|
const claimed = this.consumeNonceCallback("dashboard:", /^dashboard:([0-9a-f]{32}):(local|public|close)$/, data, adapterId, adapter);
|
|
@@ -4514,9 +4704,26 @@ export class FleetManager {
|
|
|
4514
4704
|
const result = await this.publicWebLink?.close("admin close", entry.publicExposureId);
|
|
4515
4705
|
if (result?.confirmed === false)
|
|
4516
4706
|
data.ack?.(t("dashboard.public_cleanup"));
|
|
4517
|
-
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("dashboard.public_closed"));
|
|
4707
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("dashboard.public_closed"), data.editClicked);
|
|
4518
4708
|
return true;
|
|
4519
4709
|
}
|
|
4710
|
+
// The click is claimed and its buttons are spent: say so on the menu now. A public link follows each of its steps
|
|
4711
|
+
// there (the first one installs cloudflared, which can take a minute): on Discord through the interaction, on
|
|
4712
|
+
// Telegram by editing the menu message itself. Only static words and numbers — the menu may be in General.
|
|
4713
|
+
const progressEdit = data.editClicked ?? entry.retire;
|
|
4714
|
+
const progress = progressEdit && action !== "public"
|
|
4715
|
+
? progressEdit(t("dashboard.private_sending")).catch(err => this.logger.debug({ err }, "Could not show the dashboard menu's progress"))
|
|
4716
|
+
: Promise.resolve();
|
|
4717
|
+
const stepsEdit = progressEdit ?? (entry.messageId && entry.adapter.editMessageRemoveButtons
|
|
4718
|
+
? (text) => entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, text, entry.threadId) : undefined);
|
|
4719
|
+
const steps = action === "public" && stepsEdit ? new ThrottledMessageEditor({
|
|
4720
|
+
edit: stepsEdit, now: () => performance.now(), heartbeatMs: 5_000,
|
|
4721
|
+
// Telegram counts edits against a group's ~20 messages a minute.
|
|
4722
|
+
minIntervalMs: owner.binding.type === "telegram" ? 3_000 : 2_000,
|
|
4723
|
+
onError: err => this.logger.debug({ err }, "Could not show the public link's progress"),
|
|
4724
|
+
}) : null;
|
|
4725
|
+
let lastSteps;
|
|
4726
|
+
let deliveredByDm = false;
|
|
4520
4727
|
const send = async (url, exposureId, expiresAt, current = () => this.publicOwnerCurrent(owner)) => {
|
|
4521
4728
|
const token = this.webToken;
|
|
4522
4729
|
if (!token || !current())
|
|
@@ -4531,9 +4738,12 @@ export class FleetManager {
|
|
|
4531
4738
|
try {
|
|
4532
4739
|
if (!direct.sendDirect)
|
|
4533
4740
|
throw new Error("DM unavailable");
|
|
4534
|
-
|
|
4741
|
+
const sent = await withinBudget(direct.sendDirect(owner.userId, text, { disablePreview: true, choices: c }), Math.min(deadline, performance.now() + 5_000));
|
|
4742
|
+
deliveredByDm = true;
|
|
4743
|
+
return sent;
|
|
4535
4744
|
}
|
|
4536
|
-
catch {
|
|
4745
|
+
catch (err) {
|
|
4746
|
+
this.logDashboardDmFailure(err);
|
|
4537
4747
|
if (direct.type !== "discord" || !data.respondPrivate || !current())
|
|
4538
4748
|
throw new Error("private delivery unavailable");
|
|
4539
4749
|
const sent = await withinBudget(data.respondPrivate(text, c), deadline);
|
|
@@ -4552,17 +4762,31 @@ export class FleetManager {
|
|
|
4552
4762
|
throw new Error("owner changed");
|
|
4553
4763
|
return true;
|
|
4554
4764
|
}
|
|
4555
|
-
catch {
|
|
4765
|
+
catch (err) {
|
|
4556
4766
|
this.webLoginCodes.revokeIfCurrent(issued.issuanceId);
|
|
4557
|
-
this.logger.info("Dashboard private delivery was not confirmed");
|
|
4767
|
+
this.logger.info({ err: err?.message }, "Dashboard private delivery was not confirmed");
|
|
4558
4768
|
return false;
|
|
4559
4769
|
}
|
|
4560
4770
|
};
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
|
|
4771
|
+
let ok = false;
|
|
4772
|
+
try {
|
|
4773
|
+
ok = action === "public"
|
|
4774
|
+
? await this.getPublicWebLink().deliver(owner, link => send(link.url, link.exposureId, link.expiresAt, link.isCurrent), p => {
|
|
4775
|
+
lastSteps = p;
|
|
4776
|
+
steps?.update(() => renderPublicLinkProgress(p, performance.now()));
|
|
4777
|
+
})
|
|
4778
|
+
: await send(`http://${this.fleetConfig?.hostname || "localhost"}:${this.fleetConfig?.health_port ?? 19280}/signin`);
|
|
4779
|
+
}
|
|
4780
|
+
catch (err) {
|
|
4781
|
+
// Still closes the step editor below and tells the clicker: a throw must not leave the menu refreshing.
|
|
4782
|
+
this.logger.warn({ err }, "Dashboard sign-in delivery failed");
|
|
4783
|
+
}
|
|
4564
4784
|
// Only safe, static words enter General; never link, code or platform error text.
|
|
4565
|
-
|
|
4785
|
+
const outcome = t(!ok ? "dashboard.private_failed"
|
|
4786
|
+
: owner.binding.type !== "discord" ? "dashboard.private_sent" : deliveredByDm ? "dashboard.private_sent_dm" : "dashboard.private_sent_here");
|
|
4787
|
+
await progress;
|
|
4788
|
+
await steps?.close(); // the last step edit has landed: the outcome cannot be overwritten by it
|
|
4789
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, lastSteps ? renderPublicLinkProgress(lastSteps, performance.now(), outcome) : outcome, data.editClicked);
|
|
4566
4790
|
return true;
|
|
4567
4791
|
}
|
|
4568
4792
|
/** `/dashboard revoke`: sign out every browser and withdraw any unused code. */
|
|
@@ -5274,6 +5498,8 @@ export class FleetManager {
|
|
|
5274
5498
|
}
|
|
5275
5499
|
// Health HTTP endpoint
|
|
5276
5500
|
this.startHealthServer(fleet.health_port ?? 19280);
|
|
5501
|
+
// #1266: buttons that ended while AgEnD was down (expired, or chosen before a restart) are shown as ended now.
|
|
5502
|
+
void this.replyButtons()?.sweep().catch(err => this.logger.warn({ err }, "Reply-button sweep failed"));
|
|
5277
5503
|
// Daily update check — first check after 1 hour, then every 24 hours
|
|
5278
5504
|
this.updateCheckTimer = setTimeout(() => {
|
|
5279
5505
|
this.checkForUpdates();
|
|
@@ -6427,26 +6653,64 @@ export class FleetManager {
|
|
|
6427
6653
|
return undefined;
|
|
6428
6654
|
}
|
|
6429
6655
|
/**
|
|
6430
|
-
*
|
|
6431
|
-
*
|
|
6432
|
-
*
|
|
6656
|
+
* A forum-root command has one configured General before any adapter claims
|
|
6657
|
+
* dedup. Bare commands prefer the primary adapter's General in this group;
|
|
6658
|
+
* explicit suffixes select their own bot. Runtime availability must not pick
|
|
6659
|
+
* another administrator, so stopped owners remain selected and every copy
|
|
6660
|
+
* silently refuses instead of falling back.
|
|
6661
|
+
* undefined = another ingress scope; null = root command with no proven target.
|
|
6433
6662
|
*/
|
|
6663
|
+
telegramRootCommandTarget(msg, threadId) {
|
|
6664
|
+
if (msg.source !== "telegram" || threadId !== undefined || !/^\/\w/.test(msg.text?.trim() ?? ""))
|
|
6665
|
+
return undefined;
|
|
6666
|
+
const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
6667
|
+
const receiving = channels.find(ch => (ch.id ?? ch.type) === msg.adapterId);
|
|
6668
|
+
if (receiving?.type !== "telegram" || receiving.group_id == null || String(receiving.group_id) !== msg.chatId)
|
|
6669
|
+
return undefined;
|
|
6670
|
+
const generals = Object.entries(this.fleetConfig?.instances ?? {}).filter(([, cfg]) => cfg.general_topic === true)
|
|
6671
|
+
.map(([name]) => {
|
|
6672
|
+
const owner = this.getInstanceAdapterId(name);
|
|
6673
|
+
return { name, owner, channel: channels.find(ch => (ch.id ?? ch.type) === owner) };
|
|
6674
|
+
});
|
|
6675
|
+
const candidates = generals.filter(entry => entry.owner && entry.channel?.type === "telegram"
|
|
6676
|
+
&& entry.channel.group_id != null && String(entry.channel.group_id) === msg.chatId);
|
|
6677
|
+
const suffix = msg.text?.trim().match(/^\/[\w-]+@(\S+)/)?.[1];
|
|
6678
|
+
let selected;
|
|
6679
|
+
if (suffix) {
|
|
6680
|
+
const matches = candidates.filter(entry => this.worlds.get(entry.owner)?.botUsername?.toLowerCase() === suffix.toLowerCase());
|
|
6681
|
+
const owners = new Set(matches.map(entry => entry.owner));
|
|
6682
|
+
if (owners.size !== 1)
|
|
6683
|
+
return null;
|
|
6684
|
+
selected = matches[0];
|
|
6685
|
+
}
|
|
6686
|
+
else {
|
|
6687
|
+
// An unresolvable General may be the intended primary; do not silently
|
|
6688
|
+
// widen authority to the next readable binding.
|
|
6689
|
+
if (generals.some(entry => !entry.channel || (entry.channel.type === "telegram" && entry.channel.group_id == null)))
|
|
6690
|
+
return null;
|
|
6691
|
+
selected = candidates.find(entry => entry.owner === this.getPrimaryAdapterId()) ?? candidates[0];
|
|
6692
|
+
}
|
|
6693
|
+
return selected?.owner ? { name: selected.name, owner: selected.owner } : null;
|
|
6694
|
+
}
|
|
6434
6695
|
/**
|
|
6435
6696
|
* #1346: whether this copy may claim the shared dedup key for
|
|
6436
6697
|
* command-like text. Fleet topics resolve an owning adapter and only its
|
|
6437
6698
|
* copy proceeds. Classic targets and unknown routing keep their existing
|
|
6438
|
-
* handling.
|
|
6439
|
-
* suffix requires a known matching username
|
|
6699
|
+
* handling. Telegram forum-root commands select a configured General before
|
|
6700
|
+
* dedup. An explicit suffix requires a known matching username; present-thread
|
|
6440
6701
|
* copies retain their permissive handling when that identity is unknown.
|
|
6441
6702
|
*/
|
|
6442
|
-
isOwnerCommandCopy(msg, threadId) {
|
|
6703
|
+
isOwnerCommandCopy(msg, threadId, rootCommand = this.telegramRootCommandTarget(msg, threadId)) {
|
|
6704
|
+
if (rootCommand !== undefined) {
|
|
6705
|
+
return rootCommand !== null && msg.adapterId === rootCommand.owner && this.daemons.has(rootCommand.name);
|
|
6706
|
+
}
|
|
6443
6707
|
if (!msg.adapterId)
|
|
6444
6708
|
return true;
|
|
6445
6709
|
const suffix = msg.text?.trim().match(/^\/[\w-]+@(\S+)/)?.[1];
|
|
6446
6710
|
if (threadId === undefined) {
|
|
6447
|
-
//
|
|
6448
|
-
// explicit
|
|
6449
|
-
//
|
|
6711
|
+
// Compatibility for a runtime world without configuration: preserve
|
|
6712
|
+
// explicit receiver proof; Classic/private/foreign-forum copies retain
|
|
6713
|
+
// their own dispatch rules.
|
|
6450
6714
|
const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
6451
6715
|
const channel = channels.find(ch => (ch.id ?? ch.type) === msg.adapterId)
|
|
6452
6716
|
?? this.worlds.get(msg.adapterId)?.channelConfig;
|
|
@@ -6476,6 +6740,9 @@ export class FleetManager {
|
|
|
6476
6740
|
}
|
|
6477
6741
|
return true;
|
|
6478
6742
|
}
|
|
6743
|
+
/**
|
|
6744
|
+
* #1085: coincident topic numbers must not route across Telegram groups.
|
|
6745
|
+
*/
|
|
6479
6746
|
resolveInboundTarget(msg, threadId) {
|
|
6480
6747
|
if (msg.source !== "telegram")
|
|
6481
6748
|
return this.routing.resolve(threadId);
|
|
@@ -6512,6 +6779,7 @@ export class FleetManager {
|
|
|
6512
6779
|
return;
|
|
6513
6780
|
}
|
|
6514
6781
|
const threadId = this.inboundRouteThreadId(msg);
|
|
6782
|
+
const rootCommand = this.telegramRootCommandTarget(msg, threadId);
|
|
6515
6783
|
this.logger.debug({ source: msg.source, chatId: msg.chatId, threadId, userId: msg.userId, isBotMessage: msg.isBotMessage, textLen: (msg.text ?? "").length, text: (msg.text ?? "").slice(0, 80) }, "handleInboundMessage entry");
|
|
6516
6784
|
// Ownership and per-adapter filtering both run before the dedup claim below.
|
|
6517
6785
|
// When several bots share a guild they all receive the same message; a copy
|
|
@@ -6572,7 +6840,7 @@ export class FleetManager {
|
|
|
6572
6840
|
// to flow — so only fleet targets are judged here; the in-handler gates
|
|
6573
6841
|
// (owner entry check, Discord ignore) cover classic instead.
|
|
6574
6842
|
if ((msg.messageId || (msg.source === "telegram" && threadId === undefined))
|
|
6575
|
-
&& /^\/\w/.test(msg.text?.trim() ?? "") && !this.isOwnerCommandCopy(msg, threadId)) {
|
|
6843
|
+
&& /^\/\w/.test(msg.text?.trim() ?? "") && !this.isOwnerCommandCopy(msg, threadId, rootCommand)) {
|
|
6576
6844
|
this.logger.debug({ adapterId: msg.adapterId, threadId }, "Non-owner command copy — skipping before dedup claim");
|
|
6577
6845
|
return;
|
|
6578
6846
|
}
|
|
@@ -6879,7 +7147,7 @@ export class FleetManager {
|
|
|
6879
7147
|
}
|
|
6880
7148
|
// General topic: Discord /xxx gets the system note first (never a
|
|
6881
7149
|
// command); other sources fall through to the handlers below.
|
|
6882
|
-
const generalInstance = this.findGeneralInstance(msg.adapterId);
|
|
7150
|
+
const generalInstance = rootCommand?.name ?? this.findGeneralInstance(msg.adapterId);
|
|
6883
7151
|
if (generalInstance && await this.replyDiscordNotACommand(msg, generalInstance))
|
|
6884
7152
|
return;
|
|
6885
7153
|
if (generalInstance && await this.topicCommands.handleInstanceCommand(msg, generalInstance))
|
|
@@ -7268,6 +7536,12 @@ export class FleetManager {
|
|
|
7268
7536
|
return;
|
|
7269
7537
|
}
|
|
7270
7538
|
}
|
|
7539
|
+
// #1266: buttons are checked before anything is sent, like stickers.
|
|
7540
|
+
const parsedButtons = parseReplyButtons(args.buttons, args);
|
|
7541
|
+
if (parsedButtons && "error" in parsedButtons) {
|
|
7542
|
+
respond(null, `reply: ${parsedButtons.error}`);
|
|
7543
|
+
return;
|
|
7544
|
+
}
|
|
7271
7545
|
// Stickers (#1226) are checked before anything is sent: a refused one is the reply's error, not a gap.
|
|
7272
7546
|
const stickerProblem = await this.replyStickerProblem(outAdapter, args, threadId, contextAdapterId ?? this.getInstanceAdapterId(senderInstanceName ?? instanceName));
|
|
7273
7547
|
if (stickerProblem) {
|
|
@@ -7280,8 +7554,33 @@ export class FleetManager {
|
|
|
7280
7554
|
ticket.subscribe(respond);
|
|
7281
7555
|
return;
|
|
7282
7556
|
}
|
|
7557
|
+
// #1266: the buttons' set, before the send (a click cannot match it until the message is known). Where buttons
|
|
7558
|
+
// cannot be shown, the choices go into the text instead.
|
|
7559
|
+
let replySet = null;
|
|
7560
|
+
if (parsedButtons && "buttons" in parsedButtons) {
|
|
7561
|
+
const buttons = outAdapter.supportsReplyButtons ? this.replyButtons() : null;
|
|
7562
|
+
if (buttons) {
|
|
7563
|
+
const outId = outAdapter === WEB_ONLY_REPLY_SINK ? "web" : outAdapter.id ?? contextAdapterId ?? "";
|
|
7564
|
+
replySet = buttons.prepare({ instance: instanceName, adapterId: outId, chatId: String(args.chat_id ?? ""), threadId }, parsedButtons.buttons);
|
|
7565
|
+
}
|
|
7566
|
+
else {
|
|
7567
|
+
args.text = `${String(args.text)}\n\n${replyButtonsFallbackText(parsedButtons.buttons)}`;
|
|
7568
|
+
}
|
|
7569
|
+
}
|
|
7283
7570
|
const original = respond;
|
|
7284
7571
|
const respondAndRecord = (result, error) => {
|
|
7572
|
+
let buttonsView = null;
|
|
7573
|
+
if (replySet) {
|
|
7574
|
+
const sent = result;
|
|
7575
|
+
const carrying = sent?.buttonsMessageId ?? sent?.messageId;
|
|
7576
|
+
if (!error && carrying) {
|
|
7577
|
+
this.replyButtonsCtl?.bind(replySet.id, carrying);
|
|
7578
|
+
buttonsView = this.replyButtonsCtl?.viewOf(replySet.id) ?? null;
|
|
7579
|
+
}
|
|
7580
|
+
else {
|
|
7581
|
+
this.replyButtonsCtl?.discard(replySet.id);
|
|
7582
|
+
}
|
|
7583
|
+
}
|
|
7285
7584
|
ticket.complete(result, error);
|
|
7286
7585
|
// Return the platform outcome first. Bookkeeping below must never turn
|
|
7287
7586
|
// a confirmed Discord/Telegram POST into a tool error if a secondary
|
|
@@ -7294,7 +7593,7 @@ export class FleetManager {
|
|
|
7294
7593
|
// an agent cannot invent it to suppress the normal completion marker.
|
|
7295
7594
|
if (!error && result != null && msg.statusOnly !== true) {
|
|
7296
7595
|
try {
|
|
7297
|
-
this.afterReplyRouted(instanceName, args, senderSessionName);
|
|
7596
|
+
this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
|
|
7298
7597
|
}
|
|
7299
7598
|
catch (err) {
|
|
7300
7599
|
this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
|
|
@@ -7309,9 +7608,11 @@ export class FleetManager {
|
|
|
7309
7608
|
});
|
|
7310
7609
|
}
|
|
7311
7610
|
};
|
|
7312
|
-
if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord)) {
|
|
7611
|
+
if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord, replySet ? { replyButtons: replySet.callbacks } : {})) {
|
|
7313
7612
|
return;
|
|
7314
7613
|
}
|
|
7614
|
+
if (replySet)
|
|
7615
|
+
this.replyButtonsCtl?.discard(replySet.id);
|
|
7315
7616
|
// routeToolCall knows "reply"; not handling it means the world changed.
|
|
7316
7617
|
ticket.complete(null, "reply not handled");
|
|
7317
7618
|
original(null, "reply not handled");
|
|
@@ -7345,7 +7646,7 @@ export class FleetManager {
|
|
|
7345
7646
|
webOnlyPromptPlace() {
|
|
7346
7647
|
return this.worlds.size === 0 && this.isWebOnlyFleet() ? { adapter: WEB_ONLY_PROMPT_SINK, adapterId: "web", chatId: "web" } : null;
|
|
7347
7648
|
}
|
|
7348
|
-
afterReplyRouted(instanceName, args, senderSessionName) {
|
|
7649
|
+
afterReplyRouted(instanceName, args, senderSessionName, buttons = null) {
|
|
7349
7650
|
// A reply is NOT proof the turn is over (#410) — but it is not proof of
|
|
7350
7651
|
// more work either. Split the difference: an instance that is clearly
|
|
7351
7652
|
// idle loses the button now; one that looks busy keeps it (re-posted
|
|
@@ -7376,6 +7677,7 @@ export class FleetManager {
|
|
|
7376
7677
|
text: (args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX),
|
|
7377
7678
|
ts: new Date().toISOString(),
|
|
7378
7679
|
...(attachments.length ? { attachments } : {}),
|
|
7680
|
+
...(buttons ? { buttons } : {}), // #1266
|
|
7379
7681
|
});
|
|
7380
7682
|
// Log bot reply to classic instance chat-log
|
|
7381
7683
|
const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
|
|
@@ -8579,15 +8881,48 @@ export class FleetManager {
|
|
|
8579
8881
|
const output = String(this.rawFleetDocument);
|
|
8580
8882
|
if (redundantPaths.length > 0)
|
|
8581
8883
|
this.writeFleetConfigBackup(source);
|
|
8582
|
-
|
|
8583
|
-
|
|
8584
|
-
|
|
8585
|
-
|
|
8586
|
-
|
|
8884
|
+
// Atomic write with fsync. The beforeRename hook runs after fsync and
|
|
8885
|
+
// before rename so validation failures are reported before the file is
|
|
8886
|
+
// replaced (#1056), and the temp file is cleaned up on any throw.
|
|
8887
|
+
atomicWriteFileSync(this.configPath, output, {
|
|
8888
|
+
mode: existsSync(this.configPath) ? statSync(this.configPath).mode : 0o644,
|
|
8889
|
+
fsync: this.fsyncForTest,
|
|
8890
|
+
beforeRename: (tempPath) => {
|
|
8891
|
+
// #1056 (Fable's 2.2 audit): the patcher writes leaves, not configs, so
|
|
8892
|
+
// a wrong patch can produce a file the validator — or the next start —
|
|
8893
|
+
// refuses. Load and validate what is about to replace fleet.yaml.
|
|
8894
|
+
const refusal = this.savedFleetConfigProblem(tempPath);
|
|
8895
|
+
if (refusal)
|
|
8896
|
+
throw new Error(refusal);
|
|
8897
|
+
if (existsSync(this.configPath))
|
|
8898
|
+
chmodSync(tempPath, statSync(this.configPath).mode);
|
|
8899
|
+
},
|
|
8900
|
+
});
|
|
8587
8901
|
this.rawFleetConfig = loadRawFleetConfig(this.configPath);
|
|
8588
8902
|
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
8589
8903
|
this.logger.info({ path: this.configPath, strippedDefaults: redundantPaths.length }, "Saved fleet config (lossless patch)");
|
|
8590
8904
|
}
|
|
8905
|
+
/** Why the config written to `candidate` must not replace fleet.yaml, or null: it does not load, or it adds errors. */
|
|
8906
|
+
savedFleetConfigProblem(candidate) {
|
|
8907
|
+
let after;
|
|
8908
|
+
try {
|
|
8909
|
+
after = validateFleetConfig(loadFleetConfig(candidate));
|
|
8910
|
+
}
|
|
8911
|
+
catch (err) {
|
|
8912
|
+
return `Refusing to save fleet.yaml: the result would not load (${err.message})`;
|
|
8913
|
+
}
|
|
8914
|
+
let had = new Set();
|
|
8915
|
+
try {
|
|
8916
|
+
if (this.configPath && existsSync(this.configPath)) {
|
|
8917
|
+
had = new Set(validateFleetConfig(loadFleetConfig(this.configPath)).errors.map(e => `${e.path}\u0000${e.message}`));
|
|
8918
|
+
}
|
|
8919
|
+
}
|
|
8920
|
+
catch { /* the current file does not load: every error of the result counts as new */ }
|
|
8921
|
+
const introduced = after.errors.filter(e => !had.has(`${e.path}\u0000${e.message}`));
|
|
8922
|
+
return introduced.length
|
|
8923
|
+
? `Refusing to save fleet.yaml: it would be invalid (${introduced.map(e => `${e.path}: ${e.message}`).join("; ")})`
|
|
8924
|
+
: null;
|
|
8925
|
+
}
|
|
8591
8926
|
/** One-time upgrade migration; invalid YAML is never rewritten. */
|
|
8592
8927
|
slimFleetConfigAtStartup() {
|
|
8593
8928
|
const redundantPaths = collectRedundantInstanceDefaultPaths(this.rawFleetConfig);
|
|
@@ -8646,6 +8981,20 @@ export class FleetManager {
|
|
|
8646
8981
|
}
|
|
8647
8982
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8648
8983
|
if (isRecord(before) && isRecord(after)) {
|
|
8984
|
+
// #1056: a legacy `channel` is being migrated (the caller removed it and kept `channels`). The file has no
|
|
8985
|
+
// `channels` for the leaf patch below to land on — the snapshot's list is only the normalized alias — so a bare
|
|
8986
|
+
// channels[0] holding just the changed field would be written while `channel` is deleted. Move the connection
|
|
8987
|
+
// as it is in the file NOW (a concurrent or manual edit, an option AgEnD does not know) into channels[0]
|
|
8988
|
+
// first; the patch then applies only this save's own changes on top, with the usual identity/order check.
|
|
8989
|
+
if (path.length === 0 && this.rawFleetConfig.channel && !this.rawFleetConfig.channels
|
|
8990
|
+
&& after.channel === undefined && Array.isArray(after.channels) && !document.hasIn(["channels"])) {
|
|
8991
|
+
const fresh = document.getIn(["channel"], true);
|
|
8992
|
+
if (isMap(fresh)) {
|
|
8993
|
+
const list = document.createNode([]);
|
|
8994
|
+
list.items.push(fresh.clone());
|
|
8995
|
+
document.setIn(["channels"], list);
|
|
8996
|
+
}
|
|
8997
|
+
}
|
|
8649
8998
|
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
8650
8999
|
for (const key of keys) {
|
|
8651
9000
|
// `channel` is a derived alias when the raw file uses `channels`.
|
|
@@ -8790,6 +9139,21 @@ export class FleetManager {
|
|
|
8790
9139
|
* to a plain add (never worse than the old behaviour).
|
|
8791
9140
|
*/
|
|
8792
9141
|
lastStatusEmoji = new Map();
|
|
9142
|
+
/**
|
|
9143
|
+
* #1056: every status emoji each bot really stamped, per `${adapterId}:${messageId}` (match keys). A connection's
|
|
9144
|
+
* status set can change while AgEnD runs, so "is this a stamp?" cannot be answered from the current set alone: a
|
|
9145
|
+
* bot's earlier stamp in the old emoji must still be plumbing, not an agent's reaction. Unbounded for the same
|
|
9146
|
+
* reason as lastStatusEmoji (a few short strings per stamped message).
|
|
9147
|
+
*/
|
|
9148
|
+
stampedStatus = new Map();
|
|
9149
|
+
noteStamped(adapter, messageId, emoji) {
|
|
9150
|
+
const adapterId = typeof adapter.id === "string" ? adapter.id : "?";
|
|
9151
|
+
const k = `${adapterId}:${messageId}`;
|
|
9152
|
+
let set = this.stampedStatus.get(k);
|
|
9153
|
+
if (!set)
|
|
9154
|
+
this.stampedStatus.set(k, set = new Set());
|
|
9155
|
+
set.add(statusMatchKey(emoji));
|
|
9156
|
+
}
|
|
8793
9157
|
/**
|
|
8794
9158
|
* One in-flight status update per bot+message: updates run strictly in call
|
|
8795
9159
|
* order, so a delayed ❌ add can never land after a newer ✅'s removal —
|
|
@@ -8812,7 +9176,7 @@ export class FleetManager {
|
|
|
8812
9176
|
*/
|
|
8813
9177
|
resolveStatusEmojisFor(instanceName, adapterId, adapter) {
|
|
8814
9178
|
const worldId = adapterId ?? this.getInstanceAdapterId(instanceName);
|
|
8815
|
-
const channel =
|
|
9179
|
+
const channel = this.statusEmojiChannel(worldId);
|
|
8816
9180
|
// The adapter that will react decides the vocabulary: a Telegram bot gets
|
|
8817
9181
|
// Telegram's reaction set even if the channel lookup fell back elsewhere.
|
|
8818
9182
|
const reacting = adapter ?? (worldId ? this.worlds.get(worldId)?.adapter : undefined) ?? this.adapter;
|
|
@@ -8833,6 +9197,22 @@ export class FleetManager {
|
|
|
8833
9197
|
return { ...resolved, platform };
|
|
8834
9198
|
}
|
|
8835
9199
|
warnedStatusEmojis = new Set();
|
|
9200
|
+
/**
|
|
9201
|
+
* The connection whose `options.status_emojis` an instance on `worldId` stamps with. #1056: read from the live config
|
|
9202
|
+
* first — a Settings save replaces `fleetConfig.channels`, and the running world keeps the object it started with —
|
|
9203
|
+
* so a change to the connection's emojis applies at the next stamp, as an instance's own override does. Only the
|
|
9204
|
+
* emoji options are taken from it: the platform is still the running adapter's (resolveStatusEmojisFor).
|
|
9205
|
+
*/
|
|
9206
|
+
statusEmojiChannel(worldId) {
|
|
9207
|
+
const started = (worldId ? this.worlds.get(worldId)?.channelConfig : undefined) ?? this.getChannelConfig(worldId ?? undefined);
|
|
9208
|
+
if (!worldId)
|
|
9209
|
+
return started;
|
|
9210
|
+
const list = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
9211
|
+
const live = list.find(ch => (ch.id ?? ch.type) === worldId);
|
|
9212
|
+
if (!live || !started)
|
|
9213
|
+
return live ?? started;
|
|
9214
|
+
return { ...started, options: { ...(started.options ?? {}), status_emojis: live.options?.status_emojis } };
|
|
9215
|
+
}
|
|
8836
9216
|
/**
|
|
8837
9217
|
* A Discord connection's server emojis for the Settings picker (#1005), from
|
|
8838
9218
|
* every server its bot can draw on (#1021): the primary `group_id` server,
|
|
@@ -8922,11 +9302,18 @@ export class FleetManager {
|
|
|
8922
9302
|
return { error: this.personaEmojiMissing(instanceName) };
|
|
8923
9303
|
const worldId = this.getInstanceAdapterId(instanceName);
|
|
8924
9304
|
const { platform } = this.resolveStatusEmojisFor(instanceName);
|
|
8925
|
-
const channel =
|
|
9305
|
+
const channel = this.statusEmojiChannel(worldId);
|
|
8926
9306
|
const current = previewStatusEmojis({ platform, platformConfig: channel?.options?.status_emojis, instanceConfig: self?.status_emojis });
|
|
8927
9307
|
const out = {
|
|
8928
9308
|
platform: platform ?? null,
|
|
8929
|
-
|
|
9309
|
+
// #1056: progress_prefix is the emoji at the start of the progress message, not a reaction stamp.
|
|
9310
|
+
statuses: current.entries.map(e => ({ status: e.key, value: e.value, source: e.source, kind: e.key === "progress_prefix" ? "text_prefix" : "reaction" })),
|
|
9311
|
+
// #1056: say inline why the lists differ by platform, so an agent need not know it already.
|
|
9312
|
+
platform_note: platform === "telegram"
|
|
9313
|
+
? "Telegram has no server custom emoji, so there is no server_emojis list: standard.reactions is the complete set a status reaction can use. progress_prefix is message text, not a reaction, so any single emoji works there."
|
|
9314
|
+
: platform === "discord"
|
|
9315
|
+
? "A status reaction can be any single emoji, or a server emoji from server_emojis as <:name:id> (one of the servers this bot is in). progress_prefix is message text, not a reaction."
|
|
9316
|
+
: "This instance has no chat connection, so no platform rules apply yet.",
|
|
8930
9317
|
standard: platform === "telegram"
|
|
8931
9318
|
? { reactions: [...TELEGRAM_REACTION_EMOJIS], note: "Telegram reacts only with these; progress_prefix may be any single emoji" }
|
|
8932
9319
|
: { suggestions: STATUS_EMOJI_SUGGESTIONS, note: "any single emoji works" },
|
|
@@ -9365,6 +9752,9 @@ export class FleetManager {
|
|
|
9365
9752
|
const reactorWorlds = new Set(worlds.filter(w => w.botUserId === r.userId).map(w => w.id));
|
|
9366
9753
|
if (reactorWorlds.size === 0)
|
|
9367
9754
|
return false; // a human, or a bot outside this fleet
|
|
9755
|
+
// #1056: what this bot really stamped on this message, whatever its status set says now.
|
|
9756
|
+
if ([...reactorWorlds].some(id => this.stampedStatus.get(`${id}:${r.messageId}`)?.has(key)))
|
|
9757
|
+
return true;
|
|
9368
9758
|
return [...names]
|
|
9369
9759
|
.filter(n => reactorWorlds.has(this.getInstanceAdapterId(n) ?? ""))
|
|
9370
9760
|
.some(n => statusMatchKeys(this.resolveStatusEmojisFor(n)).includes(key));
|
|
@@ -9372,6 +9762,8 @@ export class FleetManager {
|
|
|
9372
9762
|
// Fallback: the built-in ladder (as before #1005) plus every value an
|
|
9373
9763
|
// operator configured. Not the Telegram built-ins — 👎 is AgEnD's failed
|
|
9374
9764
|
// stamp there, but also far too common a human reaction to swallow blind.
|
|
9765
|
+
if (worlds.some(w => this.stampedStatus.get(`${w.id}:${r.messageId}`)?.has(key)))
|
|
9766
|
+
return true; // #1056, as above
|
|
9375
9767
|
const keys = new Set(statusMatchKeys(builtinStatusEmojis(undefined)));
|
|
9376
9768
|
for (const name of names) {
|
|
9377
9769
|
const resolved = this.resolveStatusEmojisFor(name);
|
|
@@ -9394,9 +9786,15 @@ export class FleetManager {
|
|
|
9394
9786
|
const emoji = this.receivedReactionFor(instanceName, adapter, msg.adapterId);
|
|
9395
9787
|
// Classic's system receipt is status-owned too. An ordinary react call
|
|
9396
9788
|
// would label it as an agent/other reaction and suppress later statuses.
|
|
9789
|
+
// #1056: recorded as this bot's stamp like the delivery statuses (provenance survives a status-set change) —
|
|
9790
|
+
// on Telegram once the adapter took the slot, on Discord before the add (its gateway can echo it first).
|
|
9397
9791
|
if (adapter instanceof TelegramAdapter) {
|
|
9398
|
-
return adapter.reactDeliveryStatus(msg.chatId, msg.messageId, emoji, msg.timestamp.getTime()).then(() => {
|
|
9792
|
+
return adapter.reactDeliveryStatus(msg.chatId, msg.messageId, emoji, msg.timestamp.getTime()).then((took) => {
|
|
9793
|
+
if (took)
|
|
9794
|
+
this.noteStamped(adapter, msg.messageId, emoji);
|
|
9795
|
+
});
|
|
9399
9796
|
}
|
|
9797
|
+
this.noteStamped(adapter, msg.messageId, emoji);
|
|
9400
9798
|
return adapter.react(msg.threadId ?? msg.chatId, msg.messageId, emoji);
|
|
9401
9799
|
}
|
|
9402
9800
|
reactClassicForwardedAttachment(instanceName, adapter, msg, kind) {
|
|
@@ -9482,8 +9880,10 @@ export class FleetManager {
|
|
|
9482
9880
|
if (await adapter.reactDeliveryStatus(chatId, messageId, emoji, receivedAt)) {
|
|
9483
9881
|
if (emoji == null)
|
|
9484
9882
|
this.lastStatusEmoji.delete(key);
|
|
9485
|
-
else
|
|
9883
|
+
else {
|
|
9486
9884
|
this.lastStatusEmoji.set(key, { emoji, status });
|
|
9885
|
+
this.noteStamped(adapter, messageId, emoji);
|
|
9886
|
+
}
|
|
9487
9887
|
}
|
|
9488
9888
|
return;
|
|
9489
9889
|
}
|
|
@@ -9507,6 +9907,8 @@ export class FleetManager {
|
|
|
9507
9907
|
// Best effort: a failed removal must not block the new status.
|
|
9508
9908
|
await adapter.unreact(target, messageId, last.emoji, threadId).catch(e => this.logger.debug({ err: e.message }, "Delivery status reaction removal failed"));
|
|
9509
9909
|
}
|
|
9910
|
+
// Recorded before the add: Discord can report the reaction back before react() resolves.
|
|
9911
|
+
this.noteStamped(adapter, messageId, emoji);
|
|
9510
9912
|
await adapter.react(target, messageId, emoji, threadId);
|
|
9511
9913
|
this.lastStatusEmoji.set(key, { emoji, status });
|
|
9512
9914
|
}
|
|
@@ -9694,13 +10096,28 @@ export class FleetManager {
|
|
|
9694
10096
|
wasDeliveryIdPrunedForCaller(deliveryId, callerInstance) {
|
|
9695
10097
|
return this.deliveryOutbox?.wasDeliveryIdPrunedForCaller(deliveryId, callerInstance) ?? false;
|
|
9696
10098
|
}
|
|
10099
|
+
/** How long opening events.db waits for another process's lock (a field so tests need not wait 5 s). */
|
|
10100
|
+
eventLogBusyTimeoutMs = 5000;
|
|
9697
10101
|
openEventLog() {
|
|
9698
10102
|
const dbPath = join(this.dataDir, "events.db");
|
|
9699
10103
|
try {
|
|
9700
|
-
return new EventLog(dbPath);
|
|
10104
|
+
return new EventLog(dbPath, { busyTimeoutMs: this.eventLogBusyTimeoutMs });
|
|
9701
10105
|
}
|
|
9702
10106
|
catch (err) {
|
|
9703
|
-
|
|
10107
|
+
// #1490: only a file SQLite proved corrupt is moved aside. A lock (it outlasted the busy timeout), a driver that
|
|
10108
|
+
// cannot load (ABI), or a permission/I/O problem says nothing against the file: it stays where it is, history
|
|
10109
|
+
// intact, and the fleet runs without event logging until it restarts.
|
|
10110
|
+
const kind = classifySqliteOpenError(err);
|
|
10111
|
+
if (kind !== "corrupt") {
|
|
10112
|
+
const key = kind === "busy" ? "eventlog.locked" : kind === "abi" ? "eventlog.abi" : "eventlog.unopenable";
|
|
10113
|
+
this.logger.error({ err, dbPath, kind }, `events.db not opened (${kind}) — left in place; continuing without event logging`);
|
|
10114
|
+
try {
|
|
10115
|
+
this.notifyFleetError(t(key));
|
|
10116
|
+
}
|
|
10117
|
+
catch { /* best effort: adapters may not be up yet; the log line stands */ }
|
|
10118
|
+
return null;
|
|
10119
|
+
}
|
|
10120
|
+
this.logger.error({ err, dbPath }, "events.db is corrupt — moving it aside and starting a fresh one");
|
|
9704
10121
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
9705
10122
|
for (const suffix of ["", "-wal", "-shm"]) {
|
|
9706
10123
|
try {
|
|
@@ -9709,7 +10126,7 @@ export class FleetManager {
|
|
|
9709
10126
|
catch { /* may not exist */ }
|
|
9710
10127
|
}
|
|
9711
10128
|
try {
|
|
9712
|
-
return new EventLog(dbPath);
|
|
10129
|
+
return new EventLog(dbPath, { busyTimeoutMs: this.eventLogBusyTimeoutMs });
|
|
9713
10130
|
}
|
|
9714
10131
|
catch (retryErr) {
|
|
9715
10132
|
// History is worth losing; a fleet that won't start is not.
|
|
@@ -9985,9 +10402,7 @@ export class FleetManager {
|
|
|
9985
10402
|
return;
|
|
9986
10403
|
this.pendingNonceButtons.delete(nonce);
|
|
9987
10404
|
this.webPromptGone(entry, entry.expiredText);
|
|
9988
|
-
|
|
9989
|
-
entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId).catch(err => this.logger.debug({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Failed to expire button prompt"));
|
|
9990
|
-
}
|
|
10405
|
+
this.collapseNoncePrompt(entry)?.catch(err => this.logger.debug({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Failed to expire button prompt"));
|
|
9991
10406
|
}, opts.timeoutMs ?? NONCE_BUTTON_TIMEOUT_MS);
|
|
9992
10407
|
entry.timer.unref?.();
|
|
9993
10408
|
this.pendingNonceButtons.set(nonce, entry);
|
|
@@ -10011,6 +10426,8 @@ export class FleetManager {
|
|
|
10011
10426
|
entry.threadId = sent.threadId;
|
|
10012
10427
|
}
|
|
10013
10428
|
entry.messageId = sent.messageId;
|
|
10429
|
+
if (sent.retire)
|
|
10430
|
+
entry.retire = sent.retire;
|
|
10014
10431
|
// Offered on the dashboard only once it is live on the platform (a failed post is disarmed above),
|
|
10015
10432
|
// and only if nothing claimed or expired it meanwhile.
|
|
10016
10433
|
if (WEB_MIRRORED_PROMPT_PREFIXES.has(opts.prefix) && this.pendingNonceButtons.get(nonce) === entry) {
|
|
@@ -10042,7 +10459,7 @@ export class FleetManager {
|
|
|
10042
10459
|
* longer parses) collapses the clicked message so the dead button stops
|
|
10043
10460
|
* inviting clicks — the same courtesy the cancel button extends.
|
|
10044
10461
|
*/
|
|
10045
|
-
consumeNonceCallback(prefix, actionRe, data, callbackAdapterId, receivingAdapter, staleHandling) {
|
|
10462
|
+
consumeNonceCallback(prefix, actionRe, data, callbackAdapterId, receivingAdapter, staleHandling, authorize) {
|
|
10046
10463
|
if (!data.callbackData.startsWith(prefix))
|
|
10047
10464
|
return null;
|
|
10048
10465
|
const match = data.callbackData.match(actionRe);
|
|
@@ -10061,6 +10478,9 @@ export class FleetManager {
|
|
|
10061
10478
|
if (staleHandling?.keepText && adapter?.removeMessageButtons) {
|
|
10062
10479
|
adapter.removeMessageButtons(data.chatId, data.messageId, data.threadId).catch(collapseFailed);
|
|
10063
10480
|
}
|
|
10481
|
+
else if (data.editClicked) {
|
|
10482
|
+
data.editClicked(t("buttons.stale")).catch(collapseFailed);
|
|
10483
|
+
}
|
|
10064
10484
|
else {
|
|
10065
10485
|
adapter?.editMessageRemoveButtons?.(data.chatId, data.messageId, t("buttons.stale"), data.threadId).catch(collapseFailed);
|
|
10066
10486
|
}
|
|
@@ -10079,11 +10499,13 @@ export class FleetManager {
|
|
|
10079
10499
|
const isAuthorized = fromWeb
|
|
10080
10500
|
? pending.web !== undefined
|
|
10081
10501
|
: data.userId
|
|
10082
|
-
?
|
|
10083
|
-
?
|
|
10084
|
-
: pending.
|
|
10085
|
-
?
|
|
10086
|
-
:
|
|
10502
|
+
? authorize
|
|
10503
|
+
? authorize(data.userId, pending)
|
|
10504
|
+
: pending.allowAnyUser
|
|
10505
|
+
? true
|
|
10506
|
+
: pending.authChannelId
|
|
10507
|
+
? this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
|
|
10508
|
+
: this.isFleetAdmin(data.userId, callbackAdapterId)
|
|
10087
10509
|
: false;
|
|
10088
10510
|
const mismatchedFields = [];
|
|
10089
10511
|
if (pending.adapterId !== callbackAdapterId)
|
|
@@ -10157,6 +10579,14 @@ export class FleetManager {
|
|
|
10157
10579
|
}
|
|
10158
10580
|
/** Routes a click to the handler that owns its prefix; false when none does. */
|
|
10159
10581
|
async dispatchAdapterCallback(data, adapterId, adapter) {
|
|
10582
|
+
if (data.callbackData.startsWith(REPLY_BUTTON_PREFIX)) { // #1266
|
|
10583
|
+
const buttons = this.replyButtons();
|
|
10584
|
+
if (!buttons) {
|
|
10585
|
+
data.ack?.(t("reply_buttons.closed"));
|
|
10586
|
+
return true;
|
|
10587
|
+
}
|
|
10588
|
+
return buttons.handleCallback(data, adapterId);
|
|
10589
|
+
}
|
|
10160
10590
|
if (this.needsYou?.handleCallback(data, adapterId))
|
|
10161
10591
|
return true;
|
|
10162
10592
|
if (await this.handleTipDismiss(data, adapterId, adapter))
|
|
@@ -10253,6 +10683,38 @@ export class FleetManager {
|
|
|
10253
10683
|
},
|
|
10254
10684
|
});
|
|
10255
10685
|
}
|
|
10686
|
+
cacheService = null;
|
|
10687
|
+
/**
|
|
10688
|
+
* #1468: the prompt-cache expiry analysis for the web, per instance, over `window`. Read from each instance's
|
|
10689
|
+
* ledger as it is now; the first request starts the bounded, persisted catch-up over the transcripts
|
|
10690
|
+
* (cache-service.ts) — never a vendor call, never a synchronous transcript read.
|
|
10691
|
+
*/
|
|
10692
|
+
cacheReport(window) {
|
|
10693
|
+
const w = Object.prototype.hasOwnProperty.call(WINDOWS, window) ? window : "7d";
|
|
10694
|
+
this.cacheService ??= new CacheService({
|
|
10695
|
+
// fleet.yaml's instances, then the ClassicBot rooms (their workspace under the AgEnD home, their own backend).
|
|
10696
|
+
instances: () => {
|
|
10697
|
+
const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
|
|
10698
|
+
name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
|
|
10699
|
+
}));
|
|
10700
|
+
const taken = new Set(fleet.map(i => i.name));
|
|
10701
|
+
const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
|
|
10702
|
+
name: ch.instanceName,
|
|
10703
|
+
backend: this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
|
|
10704
|
+
workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
|
|
10705
|
+
ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
|
|
10706
|
+
}));
|
|
10707
|
+
return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
|
|
10708
|
+
},
|
|
10709
|
+
claudeProjectsDir: () => join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects"),
|
|
10710
|
+
claudeKey: claudeProjectKey,
|
|
10711
|
+
codexSessionsDir: () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions"),
|
|
10712
|
+
listRollouts: root => sharedRolloutIndex(root).list(),
|
|
10713
|
+
metaPath: join(this.dataDir, "cache-codex-rollouts.json"),
|
|
10714
|
+
log: (level, msg, extra) => this.logger[level](extra ?? {}, msg),
|
|
10715
|
+
});
|
|
10716
|
+
return this.cacheService.report(w);
|
|
10717
|
+
}
|
|
10256
10718
|
/** The web's Acknowledge (any item; a signed-in session is fleet-admin level). */
|
|
10257
10719
|
acknowledgeNeedsItem(id, principal) {
|
|
10258
10720
|
if (!this.needsYou)
|
|
@@ -10395,6 +10857,11 @@ export class FleetManager {
|
|
|
10395
10857
|
* The caller has passed the /ui gate (session, same origin, CSRF). Here: the prompt must be one offered
|
|
10396
10858
|
* on the dashboard, about the instance the page named, and the action one of its own buttons.
|
|
10397
10859
|
*/
|
|
10860
|
+
/** #1266: a click on a reply's button in the web chat (the /ui gate is passed: a session, or the public link). */
|
|
10861
|
+
clickWebReplyButton(instance, id, index) {
|
|
10862
|
+
const buttons = this.replyButtons();
|
|
10863
|
+
return buttons ? buttons.clickWeb(instance, id, index) : Promise.resolve({ status: 409, error: t("reply_buttons.closed") });
|
|
10864
|
+
}
|
|
10398
10865
|
async clickWebPrompt(instance, nonce, action) {
|
|
10399
10866
|
if (!/^[0-9a-f]{32}$/.test(nonce) || !/^[a-z][a-z-]{0,23}$/.test(action))
|
|
10400
10867
|
return { status: 400, error: "Malformed prompt answer" };
|
|
@@ -10423,9 +10890,16 @@ export class FleetManager {
|
|
|
10423
10890
|
return { status: 200 };
|
|
10424
10891
|
return { status: 409, error: notice ?? "This prompt is no longer open" };
|
|
10425
10892
|
}
|
|
10426
|
-
async retireNonceButtons(pending, messageId, text
|
|
10893
|
+
async retireNonceButtons(pending, messageId, text,
|
|
10894
|
+
/** The click's own edit of a private prompt (AdapterCallbackData.editClicked); preferred over the prompt's. */
|
|
10895
|
+
editClicked) {
|
|
10427
10896
|
this.webPromptGone(pending, text);
|
|
10428
10897
|
try {
|
|
10898
|
+
const retire = editClicked ?? pending.retire;
|
|
10899
|
+
if (retire) {
|
|
10900
|
+
await retire(text);
|
|
10901
|
+
return;
|
|
10902
|
+
}
|
|
10429
10903
|
if (!pending.adapter.editMessageRemoveButtons)
|
|
10430
10904
|
throw new Error("adapter cannot remove prompt buttons");
|
|
10431
10905
|
await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
|
|
@@ -10680,29 +11154,18 @@ export class FleetManager {
|
|
|
10680
11154
|
});
|
|
10681
11155
|
return nonce ? null : t("clear.prompt_unavailable");
|
|
10682
11156
|
}
|
|
10683
|
-
/**
|
|
10684
|
-
|
|
10685
|
-
|
|
10686
|
-
|
|
10687
|
-
|
|
10688
|
-
|
|
10689
|
-
|
|
10690
|
-
const
|
|
10691
|
-
this.
|
|
10692
|
-
|
|
10693
|
-
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.cancelled", pending.instanceName));
|
|
10694
|
-
return true;
|
|
10695
|
-
}
|
|
10696
|
-
// The nonce was claimed synchronously. Keep the exact owner across platform
|
|
10697
|
-
// retirement awaits; a replacement with the same name is not this clear's
|
|
10698
|
-
// target, and a role granted at claim time may be revoked while editing.
|
|
10699
|
-
const ipc = this.instanceIpcClients.get(pending.instanceName);
|
|
10700
|
-
const daemon = this.daemons.get(pending.instanceName);
|
|
10701
|
-
const epoch = this.getDeliveryEpoch(pending.instanceName);
|
|
10702
|
-
const groupId = this.getChannelConfig(callbackAdapterId)?.group_id;
|
|
11157
|
+
/**
|
|
11158
|
+
* What a confirmed /clear must still find when it acts — shared by the platform's Confirm button and the web
|
|
11159
|
+
* (#1269): the same daemon and IPC client, the same interaction owner, lifecycle epoch and delivery epoch as when the
|
|
11160
|
+
* fence was taken. Null when there is no owner to pin (then nothing may be cleared). The reads are cached and
|
|
11161
|
+
* synchronous; they never probe the pane.
|
|
11162
|
+
*/
|
|
11163
|
+
clearTargetFence(instanceName) {
|
|
11164
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
11165
|
+
const daemon = this.daemons.get(instanceName);
|
|
11166
|
+
const epoch = this.getDeliveryEpoch(instanceName);
|
|
10703
11167
|
// Object identity survives a resident daemon's respawn/freeze, and stop
|
|
10704
11168
|
// invalidates its lifecycle epoch before the queued work replaces objects.
|
|
10705
|
-
// These reads are cached and synchronous; they never probe the pane.
|
|
10706
11169
|
const readOwner = () => {
|
|
10707
11170
|
try {
|
|
10708
11171
|
const owner = daemon?.getInteractionSnapshot?.()?.owner;
|
|
@@ -10718,7 +11181,7 @@ export class FleetManager {
|
|
|
10718
11181
|
};
|
|
10719
11182
|
const readLifecycleEpoch = () => {
|
|
10720
11183
|
try {
|
|
10721
|
-
const value = this.lifecycle?.epochOf(
|
|
11184
|
+
const value = this.lifecycle?.epochOf(instanceName);
|
|
10722
11185
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
10723
11186
|
}
|
|
10724
11187
|
catch {
|
|
@@ -10726,8 +11189,112 @@ export class FleetManager {
|
|
|
10726
11189
|
}
|
|
10727
11190
|
};
|
|
10728
11191
|
const owner = readOwner(), lifecycleEpoch = readLifecycleEpoch();
|
|
11192
|
+
if (owner === null || lifecycleEpoch === null)
|
|
11193
|
+
return null;
|
|
11194
|
+
return () => {
|
|
11195
|
+
try {
|
|
11196
|
+
const currentOwner = readOwner();
|
|
11197
|
+
return this.daemons.get(instanceName) === daemon
|
|
11198
|
+
&& this.instanceIpcClients.get(instanceName) === ipc
|
|
11199
|
+
&& currentOwner !== null && sameInteractionOwner(owner, currentOwner)
|
|
11200
|
+
&& readLifecycleEpoch() === lifecycleEpoch
|
|
11201
|
+
&& this.isDeliveryEpochCurrent(instanceName, epoch);
|
|
11202
|
+
}
|
|
11203
|
+
catch {
|
|
11204
|
+
return false;
|
|
11205
|
+
}
|
|
11206
|
+
};
|
|
11207
|
+
}
|
|
11208
|
+
/** #1269: the web's /clear confirmations — one token per question, used once, for the instance as it was asked about. */
|
|
11209
|
+
webClearTokens = new Map();
|
|
11210
|
+
/** #1269: an instance's chat command from the web chat (web-commands.ts); `publicLink`: a gateway request. */
|
|
11211
|
+
webCommand(input, opts) {
|
|
11212
|
+
const webMeta = (instance) => {
|
|
11213
|
+
const topicId = this.fleetConfig?.instances[instance]?.topic_id;
|
|
11214
|
+
return {
|
|
11215
|
+
chatId: this.getGroupIdForInstance(instance) || "", messageId: newWebMessageId(), username: "web-user", userId: "web-user",
|
|
11216
|
+
threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web",
|
|
11217
|
+
};
|
|
11218
|
+
};
|
|
11219
|
+
const choicesFromCache = (instance) => {
|
|
11220
|
+
const cached = this.readCliEnv(this.backendNameForInstance(instance)); // the cached catalog only: never a probe here
|
|
11221
|
+
if (!cached?.models.length)
|
|
11222
|
+
return null;
|
|
11223
|
+
const current = this.resolveInstanceModel(instance).model;
|
|
11224
|
+
return { current, options: cached.models.map(o => ({ id: o.id, label: this.modelChoiceLabel(o, current) })) };
|
|
11225
|
+
};
|
|
11226
|
+
return runWebCommand({
|
|
11227
|
+
// Own entries only: a name like "__proto__" or "constructor" must not be found on the prototype (#1476 review).
|
|
11228
|
+
scope: (instance) => {
|
|
11229
|
+
const instances = this.fleetConfig?.instances;
|
|
11230
|
+
if (instances && Object.prototype.hasOwnProperty.call(instances, instance))
|
|
11231
|
+
return isGeneralInstance(this.fleetConfig, instance) ? "general" : "fleet";
|
|
11232
|
+
return this.classicChannels?.getChannelIdByInstance(instance) !== undefined ? "classic" : null;
|
|
11233
|
+
},
|
|
11234
|
+
ctx: (instance) => this.topicCommands.getCtxText(instance),
|
|
11235
|
+
compact: (instance, instructions) => this.topicCommands.sendCompact(instance, instructions),
|
|
11236
|
+
applyModel: (instance, name) => this.applyModel(instance, name),
|
|
11237
|
+
modelChoices: choicesFromCache,
|
|
11238
|
+
applyEffort: (instance, level) => this.applyEffort(instance, level),
|
|
11239
|
+
effortChoices: (instance) => {
|
|
11240
|
+
const levels = this.effortLevelsFor(instance);
|
|
11241
|
+
if (!levels.length)
|
|
11242
|
+
return null;
|
|
11243
|
+
const current = this.resolveInstanceEffort(instance).effort;
|
|
11244
|
+
return { current, options: levels.map(l => ({ id: l, label: this.effortChoiceLabel(l, current) })) };
|
|
11245
|
+
},
|
|
11246
|
+
cancel: (instance) => this.cancelInstance(instance),
|
|
11247
|
+
steer: (instance, text) => this.topicCommands.sendSteer(instance, text, webMeta(instance)),
|
|
11248
|
+
btw: (instance, text) => this.topicCommands.sendBtw(instance, text, webMeta(instance)),
|
|
11249
|
+
pauseWake: (instance, action) => this.topicCommands.runPauseWake(instance, action),
|
|
11250
|
+
save: (instance, filename) => this.topicCommands.sendSave(instance, filename),
|
|
11251
|
+
clearAsk: (instance) => {
|
|
11252
|
+
if (!this.topicCommands.supportsClear(instance))
|
|
11253
|
+
return { refused: t("clear.unsupported") };
|
|
11254
|
+
const fence = this.clearTargetFence(instance);
|
|
11255
|
+
if (!fence)
|
|
11256
|
+
return { refused: t("clear.not_connected") };
|
|
11257
|
+
const now = performance.now();
|
|
11258
|
+
for (const [k, v] of this.webClearTokens)
|
|
11259
|
+
if (v.deadline < now)
|
|
11260
|
+
this.webClearTokens.delete(k);
|
|
11261
|
+
const token = randomBytes(16).toString("hex");
|
|
11262
|
+
this.webClearTokens.set(token, { instance, fence, deadline: now + CLEAR_CONFIRM_TIMEOUT_MS });
|
|
11263
|
+
return { token, message: t("clear.confirm_message", instance) };
|
|
11264
|
+
},
|
|
11265
|
+
clearConfirm: async (instance, token) => {
|
|
11266
|
+
const entry = this.webClearTokens.get(token);
|
|
11267
|
+
this.webClearTokens.delete(token); // used once, whatever happens next
|
|
11268
|
+
if (!entry || entry.instance !== instance || performance.now() > entry.deadline)
|
|
11269
|
+
return { status: 409, text: t("clear.expired", instance) };
|
|
11270
|
+
this.eventLog?.insert(instance, "clear_action", { action: "confirm", userId: "web" });
|
|
11271
|
+
// sendClear sends its first IPC synchronously: no await separates this check from the effect.
|
|
11272
|
+
if (this.shuttingDown || !entry.fence())
|
|
11273
|
+
return { status: 409, text: t("menu.click_stale") };
|
|
11274
|
+
return { status: 200, text: await this.topicCommands.sendClear(instance) };
|
|
11275
|
+
},
|
|
11276
|
+
t: (key, ...args) => t(key, ...args),
|
|
11277
|
+
}, input, opts);
|
|
11278
|
+
}
|
|
11279
|
+
/** Consume `/clear` Confirm/Cancel exactly once; only Confirm reaches IPC. */
|
|
11280
|
+
async handleClearConfirmation(data, callbackAdapterId, receivingAdapter) {
|
|
11281
|
+
const claimed = this.consumeNonceCallback(CLEAR_CONFIRM_CALLBACK_PREFIX, /^clear-confirm:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
|
|
11282
|
+
if (claimed === null)
|
|
11283
|
+
return false;
|
|
11284
|
+
if (claimed === "consumed")
|
|
11285
|
+
return true;
|
|
11286
|
+
const { entry: pending, action } = claimed;
|
|
11287
|
+
this.eventLog?.insert(pending.instanceName, "clear_action", { action, userId: data.userId });
|
|
11288
|
+
if (action === "cancel") {
|
|
11289
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.cancelled", pending.instanceName));
|
|
11290
|
+
return true;
|
|
11291
|
+
}
|
|
11292
|
+
// The nonce was claimed synchronously. Keep the exact owner across platform
|
|
11293
|
+
// retirement awaits; a replacement with the same name is not this clear's
|
|
11294
|
+
// target, and a role granted at claim time may be revoked while editing.
|
|
11295
|
+
const target = this.clearTargetFence(pending.instanceName);
|
|
11296
|
+
const groupId = this.getChannelConfig(callbackAdapterId)?.group_id;
|
|
10729
11297
|
const current = () => {
|
|
10730
|
-
const currentOwner = readOwner();
|
|
10731
11298
|
try {
|
|
10732
11299
|
return !this.shuttingDown
|
|
10733
11300
|
&& !!data.userId && !!pending.authChannelId
|
|
@@ -10739,11 +11306,7 @@ export class FleetManager {
|
|
|
10739
11306
|
? pending.adapter.type !== "telegram" || pending.chatId === pending.authChannelId
|
|
10740
11307
|
: String(this.getChannelConfig(callbackAdapterId)?.group_id ?? "") === pending.chatId)
|
|
10741
11308
|
&& this.getChannelConfig(callbackAdapterId)?.group_id === groupId
|
|
10742
|
-
&&
|
|
10743
|
-
&& this.instanceIpcClients.get(pending.instanceName) === ipc
|
|
10744
|
-
&& owner !== null && currentOwner !== null && sameInteractionOwner(owner, currentOwner)
|
|
10745
|
-
&& lifecycleEpoch !== null && readLifecycleEpoch() === lifecycleEpoch
|
|
10746
|
-
&& this.isDeliveryEpochCurrent(pending.instanceName, epoch);
|
|
11309
|
+
&& target !== null && target();
|
|
10747
11310
|
}
|
|
10748
11311
|
catch {
|
|
10749
11312
|
return false;
|
|
@@ -10794,8 +11357,8 @@ export class FleetManager {
|
|
|
10794
11357
|
this.webPromptGone(entry, entry.expiredText);
|
|
10795
11358
|
}
|
|
10796
11359
|
const collapses = entries
|
|
10797
|
-
.
|
|
10798
|
-
.
|
|
11360
|
+
.map(entry => this.collapseNoncePrompt(entry)?.catch(err => this.logger.debug({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Failed to retire button prompt during shutdown")))
|
|
11361
|
+
.filter(collapse => collapse !== undefined);
|
|
10799
11362
|
if (!collapses.length)
|
|
10800
11363
|
return;
|
|
10801
11364
|
await Promise.race([
|
|
@@ -10814,12 +11377,17 @@ export class FleetManager {
|
|
|
10814
11377
|
if (entry.timer)
|
|
10815
11378
|
clearTimeout(entry.timer);
|
|
10816
11379
|
this.webPromptGone(entry, entry.expiredText);
|
|
10817
|
-
|
|
10818
|
-
entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId)
|
|
10819
|
-
.catch(err => this.logger.debug({ err, instanceName, prefix: entry.prefix }, "Failed to collapse prompt during instance stop"));
|
|
10820
|
-
}
|
|
11380
|
+
this.collapseNoncePrompt(entry)?.catch(err => this.logger.debug({ err, instanceName, prefix: entry.prefix }, "Failed to collapse prompt during instance stop"));
|
|
10821
11381
|
}
|
|
10822
11382
|
}
|
|
11383
|
+
/** A lapsed prompt's buttons collapsed to its expired text — through its own interaction when it is private. */
|
|
11384
|
+
collapseNoncePrompt(entry) {
|
|
11385
|
+
if (entry.retire)
|
|
11386
|
+
return entry.retire(entry.expiredText);
|
|
11387
|
+
if (!entry.messageId || !entry.adapter.editMessageRemoveButtons)
|
|
11388
|
+
return undefined;
|
|
11389
|
+
return entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId);
|
|
11390
|
+
}
|
|
10823
11391
|
/** A clean exit is intentional from the CLI's perspective, but often not from
|
|
10824
11392
|
* the operator's. Keep the instance notice passive and put the action in the
|
|
10825
11393
|
* same-world General topic where an administrator can make the choice. */
|
|
@@ -11802,7 +12370,7 @@ export class FleetManager {
|
|
|
11802
12370
|
for (const name of this.configuredBackendInstanceNames()) {
|
|
11803
12371
|
configured.add(this.backendNameOf(name));
|
|
11804
12372
|
}
|
|
11805
|
-
const installed = this.probeInstalledBackends();
|
|
12373
|
+
const installed = await this.probeInstalledBackends();
|
|
11806
12374
|
// One entry point for "get this CLI working" (#1131): a backend that is
|
|
11807
12375
|
// not installed is offered too, and the click installs it first, then
|
|
11808
12376
|
// signs in (startLoginSession routes it).
|
|
@@ -12434,10 +13002,16 @@ export class FleetManager {
|
|
|
12434
13002
|
attachments: Array.isArray(m.attachments) ? m.attachments : undefined,
|
|
12435
13003
|
messageId: typeof m.messageId === "string" ? m.messageId : undefined,
|
|
12436
13004
|
role: typeof m.role === "string" ? m.role : undefined,
|
|
13005
|
+
buttons: m.buttons,
|
|
12437
13006
|
});
|
|
12438
13007
|
broadcastSseEvent(this.sseClients, event, recorded, onError, this.webChatHistory.cursorOf(recorded));
|
|
12439
13008
|
return;
|
|
12440
13009
|
}
|
|
13010
|
+
// #1266: a reply's buttons ended — the history shows it too (a later load, the public link's poll).
|
|
13011
|
+
if (event === "reply_buttons" && data && typeof data === "object") {
|
|
13012
|
+
const u = data;
|
|
13013
|
+
this.webChatHistory.updateButtons(String(u.instance ?? ""), u.buttons);
|
|
13014
|
+
}
|
|
12441
13015
|
broadcastSseEvent(this.sseClients, event, data, onError);
|
|
12442
13016
|
}
|
|
12443
13017
|
listClaimedTasks(assignee) {
|
|
@@ -14249,6 +14823,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14249
14823
|
return `${warn}${t("effort.restart_success", instanceName, applied)}`;
|
|
14250
14824
|
}
|
|
14251
14825
|
async applyModel(instanceName, model) {
|
|
14826
|
+
// Reject model names with newlines or control characters: they would be
|
|
14827
|
+
// persisted to fleet.yaml and pasted raw into the CLI (P3 from #1490 audit).
|
|
14828
|
+
if (/[\x00-\x1f\x7f]/.test(model)) {
|
|
14829
|
+
return t("model.invalid_chars");
|
|
14830
|
+
}
|
|
14252
14831
|
const backendName = this.backendNameForInstance(instanceName);
|
|
14253
14832
|
let strategy = "restart";
|
|
14254
14833
|
try {
|
|
@@ -14623,6 +15202,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14623
15202
|
// #1386: every live "Needs you" message says the fleet stopped (capabilities revoked first) — while the
|
|
14624
15203
|
// adapters can still edit. Bounded: a platform that does not answer must not hold the shutdown.
|
|
14625
15204
|
const needsStopped = this.stopNeedsYou();
|
|
15205
|
+
// #1490 P3: stop() now returns a Promise; await it so the final
|
|
15206
|
+
// ledger flush completes before process.exit(0) in doStopAll's caller.
|
|
15207
|
+
const cacheStopped = this.cacheService?.stop() ?? Promise.resolve();
|
|
14626
15208
|
const profileStopped = this.runtimeCpuProfiler?.shutdown("fleet shutdown");
|
|
14627
15209
|
this.ipcStoppingInstances.add("__fleet_stopping__");
|
|
14628
15210
|
// Release held delivery promises before awaiting daemon shutdown, then
|
|
@@ -14656,6 +15238,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14656
15238
|
await profileStopped;
|
|
14657
15239
|
await needsStopped;
|
|
14658
15240
|
await settingsControlStopped;
|
|
15241
|
+
await cacheStopped;
|
|
14659
15242
|
await this.cpuProfileControl?.close();
|
|
14660
15243
|
this.cpuProfileControl = null;
|
|
14661
15244
|
await this.shutdownLoginWindows();
|
|
@@ -14788,6 +15371,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14788
15371
|
// nor revives a session that was revoked while the disk was refusing writes.
|
|
14789
15372
|
this.webSessions?.flush();
|
|
14790
15373
|
this.eventLog?.close();
|
|
15374
|
+
this.replyButtonsCtl?.stop();
|
|
15375
|
+
this.replyButtonsStore?.close();
|
|
15376
|
+
this.replyButtonsCtl = null;
|
|
15377
|
+
this.replyButtonsStore = null;
|
|
14791
15378
|
const pidPath = join(this.dataDir, "fleet.pid");
|
|
14792
15379
|
try {
|
|
14793
15380
|
unlinkSync(pidPath);
|
|
@@ -15015,7 +15602,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15015
15602
|
// this process came up on: a Settings edit mutates this.fleetConfig in place
|
|
15016
15603
|
// before the reload, so comparing the pre-load copy sees nothing at all.
|
|
15017
15604
|
const newFleetLevel = this.fleetLevelSignature();
|
|
15018
|
-
|
|
15605
|
+
// #1519 P6: connections appended to a running fleet start now — no restart. Only once every one is running does
|
|
15606
|
+
// this process count the configuration as applied; otherwise the row says a restart is still needed.
|
|
15607
|
+
if (this.appliedFleetLevel !== null && this.appliedFleetLevel !== newFleetLevel && this.canHotAddConnections(this.fleetConfig)) {
|
|
15608
|
+
observe?.(APPLY_FLEET_TARGET, "hot", "running");
|
|
15609
|
+
if (await this.hotAddConnections(appendedConnections(this.startupFleetConfig, this.fleetConfig))) {
|
|
15610
|
+
this.startupFleetConfig = structuredClone(this.fleetConfig);
|
|
15611
|
+
this.appliedFleetLevel = newFleetLevel;
|
|
15612
|
+
observe?.(APPLY_FLEET_TARGET, "hot", "done");
|
|
15613
|
+
}
|
|
15614
|
+
else {
|
|
15615
|
+
observe?.(APPLY_FLEET_TARGET, "restart", "restart-required");
|
|
15616
|
+
}
|
|
15617
|
+
}
|
|
15618
|
+
else if (this.appliedFleetLevel !== null && this.appliedFleetLevel !== newFleetLevel) {
|
|
15019
15619
|
this.logger.warn({
|
|
15020
15620
|
keys: fleetLevelDifferences(this.startupFleetConfig, this.fleetConfig),
|
|
15021
15621
|
}, "Fleet-level config changed — restart AgEnD for it to take effect");
|
|
@@ -15349,6 +15949,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15349
15949
|
listSecureConnections() {
|
|
15350
15950
|
const channels = this.fleetConfig?.channels
|
|
15351
15951
|
?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
15952
|
+
// A token written from Settings is in .env before this process has it (#1519 P1: a new connection's token waits for
|
|
15953
|
+
// the restart that starts it) — stored is "set", not "missing". Names only; no value is read out.
|
|
15954
|
+
let stored;
|
|
15955
|
+
try {
|
|
15956
|
+
stored = envFileKeys(this.dataDir);
|
|
15957
|
+
}
|
|
15958
|
+
catch {
|
|
15959
|
+
stored = new Set();
|
|
15960
|
+
} // unreadable: only what this process has
|
|
15352
15961
|
return channels.map((channel, index) => {
|
|
15353
15962
|
const id = channel.id ?? channel.type ?? `channel-${index}`;
|
|
15354
15963
|
const world = this.worlds.get(id);
|
|
@@ -15357,16 +15966,44 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15357
15966
|
id,
|
|
15358
15967
|
type: channel.type,
|
|
15359
15968
|
token_env: channel.bot_token_env,
|
|
15360
|
-
token_present: !!process.env[channel.bot_token_env],
|
|
15969
|
+
token_present: !!process.env[channel.bot_token_env] || stored.has(channel.bot_token_env),
|
|
15361
15970
|
group_id: channel.group_id != null ? String(channel.group_id) : null,
|
|
15362
15971
|
general_channel_id: channel.options?.general_channel_id != null
|
|
15363
15972
|
? String(channel.options.general_channel_id)
|
|
15364
15973
|
: null,
|
|
15365
|
-
|
|
15974
|
+
...this.connectionStatus(id, state, world),
|
|
15366
15975
|
...(world ? { identity: { id: world.botUserId ?? null, username: world.botUsername ?? null } } : {}),
|
|
15367
15976
|
};
|
|
15368
15977
|
});
|
|
15369
15978
|
}
|
|
15979
|
+
/**
|
|
15980
|
+
* #1519 P6 (#1537 review): a connection's status from evidence, never from a start call that merely resolved. No
|
|
15981
|
+
* adapter object: not running (or retrying / failed, as recorded) — never "connected". An adapter that has not logged
|
|
15982
|
+
* in yet (no `started`: no bot id; for Telegram, polling not ready) is "starting". Named problems: the Message Content
|
|
15983
|
+
* intent refused (4014, P3), the token refused (an explicit auth error, or Telegram's own 401 flag) — the error text
|
|
15984
|
+
* itself is never passed on.
|
|
15985
|
+
*/
|
|
15986
|
+
connectionStatus(id, state, world) {
|
|
15987
|
+
const adapter = this.adapters.get(id);
|
|
15988
|
+
const evidence = adapter?.connectionEvidence?.();
|
|
15989
|
+
const recorded = state?.status;
|
|
15990
|
+
const problem = evidence?.authRejected || (recorded !== "connected" && isRejectedTokenError(state?.lastError)) ? "rejected"
|
|
15991
|
+
: recorded !== "connected" && isDisallowedIntentsError(state?.lastError) ? "missing_intent" : undefined;
|
|
15992
|
+
let status;
|
|
15993
|
+
if (!adapter || evidence?.stopped)
|
|
15994
|
+
status = recorded === "retrying" || recorded === "failed" ? recorded : "stopped"; // gone, or told to stop
|
|
15995
|
+
else if (recorded === "retrying" || recorded === "failed")
|
|
15996
|
+
status = recorded;
|
|
15997
|
+
else if (evidence?.authRejected)
|
|
15998
|
+
status = "failed";
|
|
15999
|
+
else if (!world?.botUserId)
|
|
16000
|
+
status = "starting"; // not logged in yet
|
|
16001
|
+
else if (evidence && !evidence.ready)
|
|
16002
|
+
status = "retrying"; // logged in once; its polling has failed since
|
|
16003
|
+
else
|
|
16004
|
+
status = "connected";
|
|
16005
|
+
return { status, ...(problem ? { problem } : {}) };
|
|
16006
|
+
}
|
|
15370
16007
|
secureConnectionChannel(connectionId) {
|
|
15371
16008
|
const channels = this.fleetConfig?.channels
|
|
15372
16009
|
?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
@@ -16155,8 +16792,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16155
16792
|
throw new Error("adapter operation superseded"); };
|
|
16156
16793
|
check();
|
|
16157
16794
|
const old = this.adapters.get(connectionId);
|
|
16158
|
-
|
|
16159
|
-
|
|
16795
|
+
// No adapter: a connection that is not the primary, in a fleet already running connections, starts now (#1519 P6);
|
|
16796
|
+
// otherwise the secret is valid on disk and the next start adopts it.
|
|
16797
|
+
if (!old && !force && (this.getPrimaryAdapterId() === connectionId || this.adapters.size === 0))
|
|
16798
|
+
return false;
|
|
16799
|
+
if (!old) {
|
|
16800
|
+
const pending = this.adapterState.get(connectionId)?.retryTimer;
|
|
16801
|
+
if (pending)
|
|
16802
|
+
clearTimeout(pending);
|
|
16803
|
+
}
|
|
16160
16804
|
const primary = this.getPrimaryAdapterId() === connectionId;
|
|
16161
16805
|
const previousState = this.adapterState.get(connectionId);
|
|
16162
16806
|
this.adapterState.set(connectionId, { status: "retrying", retryCount: previousState?.retryCount ?? 0 });
|
|
@@ -16225,6 +16869,74 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16225
16869
|
* the reconcile reports as it works. A target the forecast missed (a Classic
|
|
16226
16870
|
* instance inheriting a changed default) is added when it is first touched.
|
|
16227
16871
|
*/
|
|
16872
|
+
/**
|
|
16873
|
+
* #1519 P6: whether `next` differs from what this process started with only by connections appended to it, while it
|
|
16874
|
+
* runs connections already (a fleet's first connection becomes its primary: a fresh process sets that up).
|
|
16875
|
+
*/
|
|
16876
|
+
canHotAddConnections(next) {
|
|
16877
|
+
return this.adapters.size > 0 && appendedConnections(this.startupFleetConfig, next) !== null;
|
|
16878
|
+
}
|
|
16879
|
+
/**
|
|
16880
|
+
* #1519 P6: start connections appended to a running fleet. Each token is read from .env (that key only, under the
|
|
16881
|
+
* file's lease — Settings wrote it there, and this process read .env once at start); a connection already running (a
|
|
16882
|
+
* token Replace started it) is left alone. True only when every one is running; one that failed to start is retried in
|
|
16883
|
+
* the background like any other, and the Apply row says a restart is still needed.
|
|
16884
|
+
*/
|
|
16885
|
+
async hotAddConnections(added) {
|
|
16886
|
+
const envPath = join(this.dataDir, ".env");
|
|
16887
|
+
const lease = trySettingsLease([settingsFileResource(envPath)]);
|
|
16888
|
+
if (!lease)
|
|
16889
|
+
return false;
|
|
16890
|
+
try {
|
|
16891
|
+
this.loadEnvKeys(new Set(added.map(c => c.bot_token_env)), lease.owner);
|
|
16892
|
+
}
|
|
16893
|
+
finally {
|
|
16894
|
+
lease.release();
|
|
16895
|
+
}
|
|
16896
|
+
let all = true;
|
|
16897
|
+
for (const channel of added) {
|
|
16898
|
+
const id = channel.id ?? channel.type;
|
|
16899
|
+
if (this.adapters.has(id))
|
|
16900
|
+
continue;
|
|
16901
|
+
if (!process.env[channel.bot_token_env]) {
|
|
16902
|
+
all = false;
|
|
16903
|
+
continue;
|
|
16904
|
+
}
|
|
16905
|
+
const pending = this.adapterState.get(id)?.retryTimer;
|
|
16906
|
+
if (pending)
|
|
16907
|
+
clearTimeout(pending);
|
|
16908
|
+
try {
|
|
16909
|
+
await this.startAdditionalAdapter(channel);
|
|
16910
|
+
if (!this.adapters.has(id)) {
|
|
16911
|
+
all = false;
|
|
16912
|
+
continue;
|
|
16913
|
+
}
|
|
16914
|
+
this.adapterState.set(id, { status: "connected", retryCount: 0 });
|
|
16915
|
+
this.logger.info({ adapterId: id }, "Connection started without a restart");
|
|
16916
|
+
}
|
|
16917
|
+
catch (err) {
|
|
16918
|
+
all = false;
|
|
16919
|
+
const message = err?.message ?? String(err);
|
|
16920
|
+
this.adapterState.set(id, { status: "retrying", retryCount: 0, lastError: message });
|
|
16921
|
+
this.scheduleAdapterRetry(id, channel);
|
|
16922
|
+
}
|
|
16923
|
+
}
|
|
16924
|
+
this.classicChannels?.configureAdapters(this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []));
|
|
16925
|
+
this.routing.rebuild(this.fleetConfig);
|
|
16926
|
+
this.reregisterClassicChannels();
|
|
16927
|
+
return all;
|
|
16928
|
+
}
|
|
16929
|
+
/** Set the named keys of the data dir's .env into this process's environment (the file's lease held by `owner`). */
|
|
16930
|
+
loadEnvKeys(keys, owner) {
|
|
16931
|
+
const envPath = join(this.dataDir, ".env");
|
|
16932
|
+
if (!existsSync(envPath))
|
|
16933
|
+
return;
|
|
16934
|
+
assertSettingsLease(settingsFileResource(envPath), owner);
|
|
16935
|
+
// The same parser as the start's (env-file.ts): a token is read the same way at start and when hot-added.
|
|
16936
|
+
for (const [key, value] of parseEnvText(readFileSync(envPath, "utf-8")))
|
|
16937
|
+
if (keys.has(key))
|
|
16938
|
+
process.env[key] = value;
|
|
16939
|
+
}
|
|
16228
16940
|
planConfigApply() {
|
|
16229
16941
|
const rows = [];
|
|
16230
16942
|
const nextConfig = this.nextFleetConfig();
|
|
@@ -16248,7 +16960,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16248
16960
|
rows.push({ target: name, kind: "restart" });
|
|
16249
16961
|
}
|
|
16250
16962
|
if (this.appliedFleetLevel !== null && this.appliedFleetLevel !== this.fleetLevelSignature(nextConfig)) {
|
|
16251
|
-
|
|
16963
|
+
// #1519 P6: connections appended to a running fleet start now; anything else at fleet level waits for a restart.
|
|
16964
|
+
rows.push({ target: APPLY_FLEET_TARGET, kind: this.canHotAddConnections(nextConfig) ? "hot" : "restart" });
|
|
16252
16965
|
}
|
|
16253
16966
|
return rows;
|
|
16254
16967
|
}
|
|
@@ -16700,7 +17413,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16700
17413
|
const backend = this.fleetConfig?.instances[name]?.backend
|
|
16701
17414
|
?? this.fleetConfig?.defaults?.backend
|
|
16702
17415
|
?? "claude-code";
|
|
16703
|
-
const { context } = resolveInstanceContext(this.dataDir, name, backend);
|
|
17416
|
+
const { context } = resolveInstanceContext(this.dataDir, name, backend, { source: this.getPaneContextSource(name) });
|
|
16704
17417
|
return {
|
|
16705
17418
|
name,
|
|
16706
17419
|
status: this.getInstanceStatus(name),
|
|
@@ -17089,6 +17802,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17089
17802
|
.map(ch => ch.instanceName)
|
|
17090
17803
|
.filter(name => !fleetNames.includes(name));
|
|
17091
17804
|
const names = [...fleetNames, ...classicOnly];
|
|
17805
|
+
// The identity every page shows (alpha.2, N1): alias, description, role and tags resolved by the same rule as
|
|
17806
|
+
// /api/profiles (resolveInstanceIdentity) — the sidebar groups, searches and labels with it on every page.
|
|
17807
|
+
const classicRooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
|
|
17808
|
+
const profiles = profileIdentities(this.dataDir);
|
|
17092
17809
|
const instances = names.map(name => {
|
|
17093
17810
|
const statusFile = join(this.getInstanceDir(name), "statusline.json");
|
|
17094
17811
|
let cost = 0;
|
|
@@ -17106,7 +17823,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17106
17823
|
: (this.fleetConfig?.instances[name]?.backend
|
|
17107
17824
|
?? this.fleetConfig?.defaults?.backend
|
|
17108
17825
|
?? "claude-code");
|
|
17109
|
-
const { context } = resolveInstanceContext(this.dataDir, name, backend);
|
|
17826
|
+
const { context } = resolveInstanceContext(this.dataDir, name, backend, { source: this.getPaneContextSource(name) });
|
|
17110
17827
|
// context_pct: null when unavailable, not 0
|
|
17111
17828
|
const context_pct = context ?? null;
|
|
17112
17829
|
// Model: Only Claude Code has live statusline; others use the effective resolver.
|
|
@@ -17125,13 +17842,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17125
17842
|
// Only show effort if backend supports it and it's not antigravity
|
|
17126
17843
|
const effort = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.effort;
|
|
17127
17844
|
const effort_source = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.source;
|
|
17128
|
-
|
|
17129
|
-
const display_name = classic
|
|
17130
|
-
? this.classicChannels?.getAll().find(ch => ch.instanceName === name)?.displayName
|
|
17131
|
-
: this.fleetConfig?.instances[name]?.display_name;
|
|
17845
|
+
const identity = resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic: classicRooms.get(name), profile: profiles.get(name) });
|
|
17132
17846
|
return {
|
|
17133
17847
|
name,
|
|
17134
|
-
display_name: display_name || undefined,
|
|
17848
|
+
display_name: identity.display_name || undefined,
|
|
17849
|
+
description: identity.description || undefined,
|
|
17850
|
+
role: identity.role || undefined,
|
|
17851
|
+
tags: identity.tags,
|
|
17135
17852
|
status: this.getInstanceStatus(name),
|
|
17136
17853
|
// `state` (presentation: may be awaiting_input) and `execution_state` (working / idle / stuck, or null —
|
|
17137
17854
|
// what the dashboard's activity events carry) come from instancePresentation (#1212).
|
|
@@ -17149,6 +17866,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17149
17866
|
instances,
|
|
17150
17867
|
publicLink: this.getPublicWebStatus(),
|
|
17151
17868
|
uptime: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
17869
|
+
// #1519 P7: how many chat connections are configured — 0 is a web-only fleet, which gets the first-run card.
|
|
17870
|
+
connections: (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).length,
|
|
17152
17871
|
};
|
|
17153
17872
|
}
|
|
17154
17873
|
}
|