@songsid/agend 2.2.0-alpha.2 → 2.2.0-alpha.4
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 +4 -4
- package/README.zh-TW.md +4 -4
- package/dist/agent-endpoint.d.ts +4 -1
- package/dist/agent-endpoint.js +14 -2
- package/dist/agent-endpoint.js.map +1 -1
- 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/codex.d.ts +7 -0
- package/dist/backend/codex.js +7 -0
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/muse.d.ts +7 -0
- package/dist/backend/muse.js +7 -0
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +6 -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-service.d.ts +17 -1
- package/dist/cache-service.js +39 -4
- package/dist/cache-service.js.map +1 -1
- 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.js +133 -38
- 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 +12 -0
- package/dist/daemon.js +166 -26
- 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-general.d.ts +15 -0
- package/dist/discord-general.js +24 -0
- package/dist/discord-general.js.map +1 -0
- 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/fleet-context.d.ts +5 -0
- package/dist/fleet-context.js.map +1 -1
- package/dist/fleet-level-config.d.ts +8 -1
- package/dist/fleet-level-config.js +31 -0
- package/dist/fleet-level-config.js.map +1 -1
- package/dist/fleet-manager.d.ts +76 -7
- package/dist/fleet-manager.js +610 -142
- 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/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 +88 -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 +6 -1
- 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/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 +75 -0
- package/dist/public-link-progress.js +204 -0
- package/dist/public-link-progress.js.map +1 -0
- package/dist/public-web-gateway.js +4 -1
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/public-web-link.d.ts +9 -2
- package/dist/public-web-link.js +89 -15
- package/dist/public-web-link.js.map +1 -1
- package/dist/quickstart-api.d.ts +93 -20
- package/dist/quickstart-api.js +198 -63
- package/dist/quickstart-api.js.map +1 -1
- package/dist/quickstart.d.ts +15 -0
- package/dist/quickstart.js +100 -29
- 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 +1 -0
- package/dist/restart-guard.js +8 -3
- package/dist/restart-guard.js.map +1 -1
- package/dist/service-activation.d.ts +15 -2
- package/dist/service-activation.js +81 -4
- package/dist/service-activation.js.map +1 -1
- package/dist/service-installer.js +19 -1
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.js +53 -12
- 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 +4 -3
- 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 +13 -3
- package/dist/setup-host.js.map +1 -1
- package/dist/setup-wizard.js +26 -19
- package/dist/setup-wizard.js.map +1 -1
- 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 +22 -0
- package/dist/tmux-control.js +82 -17
- 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 +48 -84
- 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 +42 -0
- package/dist/tunnel/cloudflared-install.js +194 -9
- package/dist/tunnel/cloudflared-install.js.map +1 -1
- package/dist/tunnel/deb-extract.d.ts +9 -0
- package/dist/tunnel/deb-extract.js +94 -0
- package/dist/tunnel/deb-extract.js.map +1 -0
- package/dist/turn-reply-guard.d.ts +3 -0
- package/dist/turn-reply-guard.js.map +1 -1
- package/dist/ui/app.html +3 -0
- package/dist/ui/chat-render.js +32 -6
- package/dist/ui/chat-store.js +21 -1
- package/dist/ui/chat-strings.js +45 -0
- package/dist/ui/chat-thread.js +109 -52
- package/dist/ui/first-run.js +64 -0
- package/dist/ui/html-attachment.js +26 -0
- package/dist/ui/icons/apple-touch-icon.png +0 -0
- package/dist/ui/icons/favicon.ico +0 -0
- package/dist/ui/icons/favicon.svg +8 -0
- package/dist/ui/panel-chat.js +16 -7
- package/dist/ui/panel-fleet.js +5 -5
- package/dist/ui/panel-settings.js +18 -8
- package/dist/ui/preview-panel.js +47 -7
- package/dist/ui/preview.js +36 -13
- 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 +46 -6
- package/dist/ui/settings-token.js +54 -0
- package/dist/ui/settings-wizard.js +63 -29
- package/dist/ui/shared/app-shell.js +19 -33
- package/dist/ui/shared/app-store.js +5 -3
- package/dist/ui/shared/app-stream.js +2 -1
- package/dist/ui/shared/app.css +34 -2
- 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/view-roster-store.js +5 -0
- package/dist/ui/shared/view-strings.js +12 -0
- package/dist/ui/signin.html +3 -0
- package/dist/update-dispatch.d.ts +1 -1
- package/dist/update-dispatch.js +1 -1
- package/dist/update-install.d.ts +15 -3
- package/dist/update-install.js +41 -13
- 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 +8 -0
- package/dist/web-api.js +53 -17
- 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-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-icons.d.ts +21 -0
- package/dist/web-icons.js +38 -0
- package/dist/web-icons.js.map +1 -0
- package/dist/web-preview.d.ts +9 -0
- package/dist/web-preview.js +15 -9
- package/dist/web-preview.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/launcher/postinstall.cjs +8 -4
- package/package.json +1 -1
- package/templates/systemd.service.ejs +3 -3
package/dist/fleet-manager.js
CHANGED
|
@@ -6,6 +6,8 @@ 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 { handleWebIconRequest } from "./web-icons.js";
|
|
10
|
+
import { renderPublicLinkProgress, ThrottledMessageEditor } from "./public-link-progress.js";
|
|
9
11
|
import { PublicWebLink, publicLinkSettings } from "./public-web-link.js";
|
|
10
12
|
import { TunnelPurposeLane } from "./tunnel/purpose-lane.js";
|
|
11
13
|
import { ManagedTunnel } from "./tunnel/manager.js";
|
|
@@ -14,6 +16,7 @@ import { measureSyncWork } from "./sync-work-attribution.js";
|
|
|
14
16
|
import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
|
|
15
17
|
import { ProfileControlServer } from "./profile-control.js";
|
|
16
18
|
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, constants as fsConstants } from "node:fs";
|
|
19
|
+
import { atomicWriteFileSync } from "./atomic-write.js";
|
|
17
20
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
18
21
|
import { spawnSync } from "node:child_process";
|
|
19
22
|
import { freemem, totalmem, cpus, homedir } from "node:os";
|
|
@@ -39,7 +42,9 @@ import { STATUS_EMOJI_CONFIG_KEYS, STATUS_EMOJI_KEYS, STATUS_EMOJI_SUGGESTIONS,
|
|
|
39
42
|
import { isProbeableRouteTarget } from "./fleet-context.js";
|
|
40
43
|
import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
|
|
41
44
|
import { EventLog } from "./event-log.js";
|
|
45
|
+
import { binaryProbe } from "./binary-probe.js";
|
|
42
46
|
import { classifySqliteOpenError } from "./sqlite-open-errors.js";
|
|
47
|
+
import { OutboxOpenError } from "./outbox-open-error.js";
|
|
43
48
|
import { AdapterWorld } from "./adapter-world.js";
|
|
44
49
|
import { CostGuard, formatCents } from "./cost-guard.js";
|
|
45
50
|
import { startEventLoopWatch } from "./event-loop-watch.js";
|
|
@@ -121,8 +126,14 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
|
|
|
121
126
|
}
|
|
122
127
|
import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
|
|
123
128
|
import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
|
|
129
|
+
import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
|
|
130
|
+
import { ReplyButtonsController } from "./reply-buttons-controller.js";
|
|
124
131
|
import { publicAttachment, sweepOrphanedUploads, WebFileLedger } from "./web-upload.js";
|
|
125
|
-
import { handleViewRequest, isViewPath } from "./view-api.js";
|
|
132
|
+
import { handleViewRequest, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
|
|
133
|
+
import { envFileKeys } from "./token-env-name.js";
|
|
134
|
+
import { discordGeneralChannelId } from "./discord-general.js";
|
|
135
|
+
import { parseEnvText } from "./env-file.js";
|
|
136
|
+
import { isDisallowedIntentsError, isRejectedTokenError } from "./discord-permissions.js";
|
|
126
137
|
import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
|
|
127
138
|
import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
|
|
128
139
|
import { LoginSession } from "./login-manager.js";
|
|
@@ -131,7 +142,8 @@ import { decideSlash } from "./slash-authz.js";
|
|
|
131
142
|
import { commandSpec, decideCommand, ruleFor } from "./command-table.js";
|
|
132
143
|
import { runVisibilityCommand } from "./cross-instance-notice.js";
|
|
133
144
|
import { installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
|
|
134
|
-
import { resolveInstalledAgend
|
|
145
|
+
import { resolveInstalledAgend } from "./update-dispatch.js";
|
|
146
|
+
import { resolveUpdateLaunch, watchUpdateLaunch } from "./update-launch.js";
|
|
135
147
|
import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
|
|
136
148
|
import { runBeforeDeadline } from "./deadline.js";
|
|
137
149
|
import { LoginWindowLock } from "./login-window-lock.js";
|
|
@@ -170,7 +182,7 @@ import { tokenEpoch, WebSessionStore } from "./web-session.js";
|
|
|
170
182
|
import { WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
|
|
171
183
|
import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
|
|
172
184
|
import { createPreviewListener, previewAvailability, previewSettings } from "./web-preview.js";
|
|
173
|
-
import { fleetLevelDifferences, fleetLevelSignature } from "./fleet-level-config.js";
|
|
185
|
+
import { fleetLevelDifferences, fleetLevelSignature, appendedConnections } from "./fleet-level-config.js";
|
|
174
186
|
import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
|
|
175
187
|
import { SecretStore } from "./secret-store.js";
|
|
176
188
|
import { opaqueId, safeSecretError, SECRET_CHALLENGE_TTL_MS, } from "./connection-secrets.js";
|
|
@@ -340,6 +352,7 @@ const WEB_MIRRORED_PROMPT_PREFIXES = new Set([
|
|
|
340
352
|
*/
|
|
341
353
|
const WEB_ONLY_REPLY_SINK = {
|
|
342
354
|
type: "web",
|
|
355
|
+
supportsReplyButtons: true, // #1266: the web chat shows a reply's buttons itself
|
|
343
356
|
sendText: async () => ({ chatId: "web", messageId: newWebMessageId() }),
|
|
344
357
|
sendFile: async () => ({ chatId: "web", messageId: newWebMessageId() }),
|
|
345
358
|
};
|
|
@@ -443,6 +456,8 @@ export class FleetManager {
|
|
|
443
456
|
dataDir;
|
|
444
457
|
static signalTarget = null;
|
|
445
458
|
static sighupHandlerInstalled = false;
|
|
459
|
+
/** Test seam: inject a spy to verify saveFleetConfig calls fsync. Default: fsyncSync. */
|
|
460
|
+
fsyncForTest = undefined;
|
|
446
461
|
children = new Map();
|
|
447
462
|
lifecycle;
|
|
448
463
|
stormWindow;
|
|
@@ -839,21 +854,66 @@ export class FleetManager {
|
|
|
839
854
|
return false;
|
|
840
855
|
await settlement;
|
|
841
856
|
const job = this.providerSecretJobs.get(id) ?? this.connectionSecretJobs.get(id) ?? this.connectionBindingJobs.get(id);
|
|
842
|
-
|
|
857
|
+
// restart_required: the value is stored and committed (the receipt is checked above), only no adapter was running
|
|
858
|
+
// to take it — the change is done, and the page says it starts with the next restart (#1519 P1), never "failed".
|
|
859
|
+
return !!job && ["applied", "applied_next_use", "reloaded", "restart_required"].includes(job.result);
|
|
843
860
|
},
|
|
844
861
|
});
|
|
845
862
|
this.settingsConfirmation = gate;
|
|
846
863
|
return gate;
|
|
847
864
|
}
|
|
865
|
+
/** Exact configured effect owners; this does not depend on prompt location. */
|
|
866
|
+
settingsChatAuthority(store, id) {
|
|
867
|
+
const scope = store.authorityOf(id);
|
|
868
|
+
if (!scope || scope.unknown)
|
|
869
|
+
return null;
|
|
870
|
+
const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
871
|
+
const targets = new Set(scope.connections);
|
|
872
|
+
let primaryGeneral;
|
|
873
|
+
const selectPrimary = () => {
|
|
874
|
+
const generals = Object.entries(this.fleetConfig?.instances ?? {}).filter(([, cfg]) => cfg.general_topic === true);
|
|
875
|
+
const selected = generals.find(([name]) => this.getInstanceAdapterId(name) === this.getPrimaryAdapterId()) ?? generals[0];
|
|
876
|
+
const owner = selected && this.getInstanceAdapterId(selected[0]);
|
|
877
|
+
return selected && owner ? { name: selected[0], owner } : undefined;
|
|
878
|
+
};
|
|
879
|
+
if (scope.primaryGeneral) {
|
|
880
|
+
primaryGeneral = selectPrimary();
|
|
881
|
+
if (!primaryGeneral)
|
|
882
|
+
return null;
|
|
883
|
+
targets.add(primaryGeneral.owner);
|
|
884
|
+
}
|
|
885
|
+
const owners = [...targets].map(id => channels.filter(ch => (ch.id ?? ch.type) === id));
|
|
886
|
+
if (!owners.length || owners.some(matches => matches.length !== 1))
|
|
887
|
+
return null;
|
|
888
|
+
const platform = owners[0][0].type;
|
|
889
|
+
if ((platform !== "telegram" && platform !== "discord") || owners.some(matches => matches[0].type !== platform))
|
|
890
|
+
return null;
|
|
891
|
+
const identities = owners.map(matches => ({ id: matches[0].id ?? matches[0].type, type: matches[0].type }));
|
|
892
|
+
const generation = this.settingsGeneration;
|
|
893
|
+
const current = () => !this.shuttingDown && this.settingsGeneration === generation
|
|
894
|
+
&& identities.every(owner => {
|
|
895
|
+
const configs = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
896
|
+
const matches = configs.filter(ch => (ch.id ?? ch.type) === owner.id);
|
|
897
|
+
return matches.length === 1 && matches[0].type === owner.type;
|
|
898
|
+
})
|
|
899
|
+
&& (!primaryGeneral || selectPrimary()?.name === primaryGeneral.name && selectPrimary()?.owner === primaryGeneral.owner);
|
|
900
|
+
return { platform, current, admin: userId => current() && !!userId
|
|
901
|
+
&& identities.every(owner => this.isFleetAdmin(userId, owner.id)) };
|
|
902
|
+
}
|
|
848
903
|
async promptSettingsChange(store, view) {
|
|
849
904
|
const generation = this.settingsGeneration;
|
|
905
|
+
const authority = this.settingsChatAuthority(store, view.id);
|
|
906
|
+
if (!authority) {
|
|
907
|
+
this.logger.info({ id: view.id }, "Settings authority is unknown or spans platforms; host CLI confirmation required");
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
850
910
|
const excluded = new Set(store.affectedConnections(view.id));
|
|
851
911
|
for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
|
|
852
912
|
if (!config.general_topic)
|
|
853
913
|
continue;
|
|
854
914
|
const adapterId = this.getInstanceAdapterId(name), adapter = this.getAdapterForInstance(name);
|
|
855
915
|
const general = this.daemons.get(name);
|
|
856
|
-
if (!adapterId || !adapter || !general || excluded.has(adapterId) ||
|
|
916
|
+
if (!adapterId || !adapter || !general || excluded.has(adapterId) || adapter.type !== authority.platform)
|
|
857
917
|
continue;
|
|
858
918
|
const chatId = this.getGroupIdForInstance(name), topic = String(config.topic_id ?? "");
|
|
859
919
|
if (!chatId || !topic)
|
|
@@ -866,7 +926,7 @@ export class FleetManager {
|
|
|
866
926
|
&& this.adapterState.get(adapterId)?.status === "connected"
|
|
867
927
|
&& this.getInstanceAdapterId(name) === adapterId && this.getAdapterForInstance(name) === adapter
|
|
868
928
|
&& this.getGroupIdForInstance(name) === chatId && String(this.fleetConfig?.instances[name]?.topic_id ?? "") === topic
|
|
869
|
-
&&
|
|
929
|
+
&& authority.current();
|
|
870
930
|
if (!current())
|
|
871
931
|
continue;
|
|
872
932
|
try {
|
|
@@ -875,7 +935,7 @@ export class FleetManager {
|
|
|
875
935
|
message: `🔒 Settings change awaiting fleet-admin confirmation\nSource: ${view.source}\nRequester: ${view.requested_by}\n${view.summary.join("\n")}`,
|
|
876
936
|
choices: [{ action: "confirm", label: "Confirm" }, { action: "reject", label: "Reject" }],
|
|
877
937
|
expiredText: "Settings confirmation is no longer pending.", timeoutMs: view.remaining_ms,
|
|
878
|
-
extra: { pendingChangeId: view.id, confirmationCurrent: current },
|
|
938
|
+
extra: { pendingChangeId: view.id, confirmationCurrent: current, confirmationAdmin: userId => current() && authority.admin(userId) },
|
|
879
939
|
});
|
|
880
940
|
void posted.then(nonce => {
|
|
881
941
|
if (!retired)
|
|
@@ -913,7 +973,7 @@ export class FleetManager {
|
|
|
913
973
|
this.logger.info({ id: view.id }, "Settings change requires local `agend settings confirm <id>`");
|
|
914
974
|
}
|
|
915
975
|
async handleSettingsChangeCallback(data, adapterId, adapter) {
|
|
916
|
-
const result = this.consumeNonceCallback("settings-confirm:", /^settings-confirm:([0-9a-f]{32}):(confirm|reject)$/, data, adapterId, adapter);
|
|
976
|
+
const result = this.consumeNonceCallback("settings-confirm:", /^settings-confirm:([0-9a-f]{32}):(confirm|reject)$/, data, adapterId, adapter, undefined, (userId, entry) => entry.confirmationAdmin?.(userId) === true);
|
|
917
977
|
if (!result)
|
|
918
978
|
return false;
|
|
919
979
|
if (result === "consumed")
|
|
@@ -923,7 +983,7 @@ export class FleetManager {
|
|
|
923
983
|
const view = await this.settingsGate().store.decide(entry.pendingChangeId, action, {
|
|
924
984
|
label: `fleet admin ${data.userId}`,
|
|
925
985
|
current: () => entry.confirmationCurrent?.() === true && this.getAdapterForInstance(entry.instanceName) === entry.adapter
|
|
926
|
-
&& !!data.userId &&
|
|
986
|
+
&& !!data.userId && entry.confirmationAdmin?.(data.userId) === true,
|
|
927
987
|
});
|
|
928
988
|
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, view.outcome?.message ?? "Settings change is applying.");
|
|
929
989
|
}
|
|
@@ -996,16 +1056,111 @@ export class FleetManager {
|
|
|
996
1056
|
this.topicArchiver = new TopicArchiver(this);
|
|
997
1057
|
this.statuslineWatcher = new StatuslineWatcher(this);
|
|
998
1058
|
}
|
|
1059
|
+
/** #1266: a reply's buttons — the store (reply-buttons.db) and what a click does. Created on first use. */
|
|
1060
|
+
replyButtonsCtl = null;
|
|
1061
|
+
/** The store could not be opened in this process: buttons are offered as text until the next start. */
|
|
1062
|
+
replyButtonsUnavailable = false;
|
|
1063
|
+
/**
|
|
1064
|
+
* The controller, or null when reply-buttons.db cannot be opened (#1500 review). That file holds only open choices,
|
|
1065
|
+
* so it never stops AgEnD: the failure is logged and posted once, replies offer their choices as text, and a click on
|
|
1066
|
+
* an older button is answered "closed". The file is left where it is — an open error is not proof of corruption.
|
|
1067
|
+
*/
|
|
1068
|
+
replyButtons() {
|
|
1069
|
+
if (this.replyButtonsCtl)
|
|
1070
|
+
return this.replyButtonsCtl;
|
|
1071
|
+
if (this.replyButtonsUnavailable)
|
|
1072
|
+
return null;
|
|
1073
|
+
const path = join(this.dataDir, "reply-buttons.db");
|
|
1074
|
+
let store;
|
|
1075
|
+
try {
|
|
1076
|
+
store = new ReplyButtonStore(path);
|
|
1077
|
+
}
|
|
1078
|
+
catch (err) {
|
|
1079
|
+
this.replyButtonsUnavailable = true;
|
|
1080
|
+
this.logger.error({ err: err.message, path }, "Reply buttons unavailable: reply-buttons.db could not be opened — replies offer their choices as text");
|
|
1081
|
+
try {
|
|
1082
|
+
this.notifyFleetError(`⚠️ Reply buttons are off until AgEnD restarts: ${path} could not be opened (${err.message}). Replies offer their choices as text.`);
|
|
1083
|
+
}
|
|
1084
|
+
catch { /* the notice is best effort */ }
|
|
1085
|
+
return null;
|
|
1086
|
+
}
|
|
1087
|
+
this.replyButtonsCtl = new ReplyButtonsController({
|
|
1088
|
+
store,
|
|
1089
|
+
now: () => Date.now(),
|
|
1090
|
+
adapterFor: (adapterId) => this.worlds.get(adapterId)?.adapter ?? (adapterId === this.getPrimaryAdapterId() ? this.adapter ?? undefined : undefined),
|
|
1091
|
+
mayClick: (set, userId) => this.mayAnswerReplyButtons(set.instance, set.adapterId, userId),
|
|
1092
|
+
deliver: (set, button, by) => this.deliverReplyButtonChoice(set, button, by),
|
|
1093
|
+
publish: (instance, view) => this.emitSseEvent("reply_buttons", { instance, buttons: view }),
|
|
1094
|
+
logger: this.logger,
|
|
1095
|
+
setTimer: (fn, ms) => { const h = setTimeout(fn, ms); h.unref?.(); return h; },
|
|
1096
|
+
clearTimer: (h) => clearTimeout(h),
|
|
1097
|
+
});
|
|
1098
|
+
this.replyButtonsStore = store;
|
|
1099
|
+
return this.replyButtonsCtl;
|
|
1100
|
+
}
|
|
1101
|
+
replyButtonsStore = null;
|
|
1102
|
+
/**
|
|
1103
|
+
* #1266: who may answer a reply's buttons on a platform — whoever may message that instance there: in a ClassicBot
|
|
1104
|
+
* room anyone there (as a typed message), elsewhere someone the sending connection lets speak (#754/#1148), never a
|
|
1105
|
+
* fleet bot.
|
|
1106
|
+
*/
|
|
1107
|
+
mayAnswerReplyButtons(instance, adapterId, userId) {
|
|
1108
|
+
if ([...this.worlds.values()].some(w => w.botUserId && w.botUserId === userId))
|
|
1109
|
+
return false;
|
|
1110
|
+
if (this.classicChannels?.getChannelIdByInstance(instance) !== undefined)
|
|
1111
|
+
return true;
|
|
1112
|
+
const access = this.worlds.get(adapterId)?.accessManager ?? (adapterId === this.getPrimaryAdapterId() ? this.accessManager : null);
|
|
1113
|
+
return this.isFleetAdmin(userId, adapterId) || !!access?.isAllowed(userId);
|
|
1114
|
+
}
|
|
1115
|
+
/** #1266: the choice, delivered as an ordinary inbound message from whoever made it, and shown in the web chat. */
|
|
1116
|
+
async deliverReplyButtonChoice(set, button, by) {
|
|
1117
|
+
const content = replyButtonClickText(button);
|
|
1118
|
+
const web = by.source === "web";
|
|
1119
|
+
const ts = new Date().toISOString();
|
|
1120
|
+
const messageId = web ? newWebMessageId() : (set.messageId ?? "");
|
|
1121
|
+
const sent = await this.deliverToInstance(set.instance, {
|
|
1122
|
+
type: "fleet_inbound",
|
|
1123
|
+
content,
|
|
1124
|
+
targetSession: set.instance,
|
|
1125
|
+
meta: {
|
|
1126
|
+
chat_id: set.chatId, message_id: messageId, user: by.username, user_id: by.userId, ts,
|
|
1127
|
+
thread_id: set.threadId, adapter_id: set.adapterId === "web" ? undefined : set.adapterId,
|
|
1128
|
+
source: web ? "web" : (this.worlds.get(set.adapterId)?.adapter.type ?? "web"),
|
|
1129
|
+
},
|
|
1130
|
+
});
|
|
1131
|
+
if (sent === false)
|
|
1132
|
+
return false;
|
|
1133
|
+
this.lastInboundUser.set(set.instance, by.username);
|
|
1134
|
+
this.emitSseEvent("message", {
|
|
1135
|
+
instance: set.instance, sender: by.username, role: "user", text: content, ts, ...(web ? { messageId } : {}),
|
|
1136
|
+
});
|
|
1137
|
+
return true;
|
|
1138
|
+
}
|
|
999
1139
|
ensureDeliveryOutbox() {
|
|
1000
1140
|
if (this.deliveryOutbox)
|
|
1001
1141
|
return;
|
|
1002
|
-
const
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
this.
|
|
1007
|
-
|
|
1142
|
+
const dbPath = join(this.dataDir, "delivery-outbox.db");
|
|
1143
|
+
let outbox;
|
|
1144
|
+
try {
|
|
1145
|
+
outbox = new DeliveryOutbox(dbPath, this.managerBootId);
|
|
1146
|
+
if (!this.deliveryOutboxRecovered) {
|
|
1147
|
+
const recovered = outbox.recoverForBoot(this.managerBootId);
|
|
1148
|
+
this.logger.info({ ...recovered }, "Recovered durable delivery outbox for this process boot");
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
catch (cause) {
|
|
1152
|
+
// Unlike events.db, these rows cannot be discarded: queued messages and uncertain submissions are authoritative.
|
|
1153
|
+
// Publish only a fully recovered store. A partial open/recovery must not authorize admission on a later call.
|
|
1154
|
+
try {
|
|
1155
|
+
outbox?.close();
|
|
1156
|
+
}
|
|
1157
|
+
catch { /* preserve the original failure */ }
|
|
1158
|
+
const error = new OutboxOpenError(dbPath, cause);
|
|
1159
|
+
this.logger.error({ err: cause, dbPath, kind: error.kind }, error.message);
|
|
1160
|
+
throw error;
|
|
1008
1161
|
}
|
|
1162
|
+
this.deliveryOutbox = outbox;
|
|
1163
|
+
this.deliveryOutboxRecovered = true;
|
|
1009
1164
|
outbox.on("admitted", () => { this.scheduleDeliveryOutboxPump(); this.wakeCoordinator?.kick(); });
|
|
1010
1165
|
outbox.on("state", (event) => {
|
|
1011
1166
|
this.scheduleDeliveryOutboxPump();
|
|
@@ -1520,6 +1675,10 @@ export class FleetManager {
|
|
|
1520
1675
|
});
|
|
1521
1676
|
return caller;
|
|
1522
1677
|
}
|
|
1678
|
+
/** Pure cached admission for command continuations; no IO or update-progress lookup. */
|
|
1679
|
+
isFleetStopping() {
|
|
1680
|
+
return this.shuttingDown;
|
|
1681
|
+
}
|
|
1523
1682
|
/**
|
|
1524
1683
|
* Is the fleet going down (or coming back up) on purpose?
|
|
1525
1684
|
*
|
|
@@ -1581,6 +1740,16 @@ export class FleetManager {
|
|
|
1581
1740
|
lastActivityMs(name) {
|
|
1582
1741
|
return this.lastActivity.get(name) ?? 0;
|
|
1583
1742
|
}
|
|
1743
|
+
/** Capture the current daemon and delivery owner for a background context read. */
|
|
1744
|
+
getPaneContextSource(name) {
|
|
1745
|
+
const daemon = this.daemons.get(name);
|
|
1746
|
+
const source = daemon?.getPaneContextSource?.();
|
|
1747
|
+
if (!daemon || !source || this.shuttingDown)
|
|
1748
|
+
return null;
|
|
1749
|
+
const epoch = this.getDeliveryEpoch(name);
|
|
1750
|
+
return { ...source, generation: `${source.generation}:${epoch}`, isCurrent: () => !this.shuttingDown
|
|
1751
|
+
&& this.daemons.get(name) === daemon && this.getDeliveryEpoch(name) === epoch && source.isCurrent() };
|
|
1752
|
+
}
|
|
1584
1753
|
/**
|
|
1585
1754
|
* Is the instance between turns?
|
|
1586
1755
|
*
|
|
@@ -1856,7 +2025,7 @@ export class FleetManager {
|
|
|
1856
2025
|
await data.respond(t(`${action}.usage`));
|
|
1857
2026
|
return;
|
|
1858
2027
|
}
|
|
1859
|
-
if (!this.fleetConfig?.instances
|
|
2028
|
+
if (!Object.hasOwn(this.fleetConfig?.instances ?? {}, requested)) {
|
|
1860
2029
|
await data.respond(t("instance.not_found", requested));
|
|
1861
2030
|
return;
|
|
1862
2031
|
}
|
|
@@ -1912,11 +2081,19 @@ export class FleetManager {
|
|
|
1912
2081
|
}
|
|
1913
2082
|
const origin = `slash /update by ${adapterId}:${data.userId}`;
|
|
1914
2083
|
recordInternalRequest(this.dataDir, "update", origin);
|
|
1915
|
-
const
|
|
1916
|
-
|
|
2084
|
+
const launch = await resolveUpdateLaunch(installed.agend);
|
|
2085
|
+
if (!launch.ok) {
|
|
2086
|
+
this.failUpdateProgress(`/update cannot prepare an independent updater (${launch.reason}). Run ` + "`agend update` from a host shell.");
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
if (this.fleetAdminGate(data.userId, adapterId) !== "ok" || this.shuttingDown) {
|
|
2090
|
+
this.failUpdateProgress(t("not_authorized"));
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
const child = spawn(launch.command, launch.args, {
|
|
1917
2094
|
detached: true, stdio: "ignore", env: withOrigin(origin),
|
|
1918
2095
|
});
|
|
1919
|
-
child
|
|
2096
|
+
watchUpdateLaunch(child, message => this.failUpdateProgress(message));
|
|
1920
2097
|
child.unref();
|
|
1921
2098
|
}
|
|
1922
2099
|
async handleProfileSlash(data, adapterId) {
|
|
@@ -1938,7 +2115,7 @@ export class FleetManager {
|
|
|
1938
2115
|
await this.handleGeneralProfile(general, msg.userId, adapterId, seconds, async (text) => (await adapter.sendText(msg.chatId, text, { threadId: msg.threadId })).messageId);
|
|
1939
2116
|
}
|
|
1940
2117
|
async handleGeneralProfile(general, userId, ingressAdapterId, seconds, respond) {
|
|
1941
|
-
if (!general || !this.fleetConfig?.instances[general]?.general_topic) {
|
|
2118
|
+
if (!general || !Object.hasOwn(this.fleetConfig?.instances ?? {}, general) || !this.fleetConfig?.instances[general]?.general_topic) {
|
|
1942
2119
|
await respond(t("profile.general_only"));
|
|
1943
2120
|
return;
|
|
1944
2121
|
}
|
|
@@ -3184,7 +3361,9 @@ export class FleetManager {
|
|
|
3184
3361
|
await this.sendLocalDashboard(data, adapterId);
|
|
3185
3362
|
return;
|
|
3186
3363
|
}
|
|
3187
|
-
|
|
3364
|
+
// Discord shows a deferred reply that is never answered as "the application did not respond".
|
|
3365
|
+
if (!await this.showDashboardMenu(owner, data.respondButtons, data.retireButtons))
|
|
3366
|
+
await data.respond(t("dashboard.menu_failed"));
|
|
3188
3367
|
}
|
|
3189
3368
|
}
|
|
3190
3369
|
else if (data.command === "restart") {
|
|
@@ -3924,18 +4103,17 @@ export class FleetManager {
|
|
|
3924
4103
|
/**
|
|
3925
4104
|
* Probe the same executable set exposed by the web backend catalog.
|
|
3926
4105
|
*
|
|
3927
|
-
* This deliberately
|
|
4106
|
+
* This deliberately skips the cache: an install `/login` ran can add a
|
|
3928
4107
|
* binary to PATH while the fleet process remains alive, and the next bare
|
|
3929
4108
|
* `/login` must see it without requiring a restart or an explicit cache
|
|
3930
|
-
* invalidation call.
|
|
4109
|
+
* invalidation call. The fresh answers also refresh the shared cache that
|
|
4110
|
+
* `/ui/backends` reads. #1490: async and bounded, never `which` on the event loop; a probe with no answer in time
|
|
4111
|
+
* counts as not installed, as the old timeout did.
|
|
3931
4112
|
*/
|
|
3932
|
-
probeInstalledBackends() {
|
|
3933
|
-
const
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
installed.add(backend);
|
|
3937
|
-
}
|
|
3938
|
-
return installed;
|
|
4113
|
+
async probeInstalledBackends() {
|
|
4114
|
+
const entries = Object.entries(BACKEND_INSTALLATION_INFO);
|
|
4115
|
+
const found = await Promise.all(entries.map(([, info]) => binaryProbe.probe(info.binary, { fresh: true })));
|
|
4116
|
+
return new Set(entries.filter((_, i) => { const r = found[i]; return r.known && r.path !== null; }).map(([backend]) => backend));
|
|
3939
4117
|
}
|
|
3940
4118
|
/**
|
|
3941
4119
|
* One fleet-level notice per burst, not one per instance: a post-update herd
|
|
@@ -4294,24 +4472,10 @@ export class FleetManager {
|
|
|
4294
4472
|
if (!existsSync(envPath))
|
|
4295
4473
|
return;
|
|
4296
4474
|
assertSettingsLease(settingsFileResource(envPath), owner);
|
|
4297
|
-
|
|
4298
|
-
for
|
|
4299
|
-
|
|
4300
|
-
if (!trimmed || trimmed.startsWith("#"))
|
|
4301
|
-
continue;
|
|
4302
|
-
const eqIdx = trimmed.indexOf("=");
|
|
4303
|
-
if (eqIdx < 0)
|
|
4304
|
-
continue;
|
|
4305
|
-
// Accept `export KEY=value` — the shell-style form people paste from their
|
|
4306
|
-
// .bashrc. Without this the variable landed in process.env under the key
|
|
4307
|
-
// "export KEY" and silently did nothing.
|
|
4308
|
-
const key = trimmed.slice(0, eqIdx).replace(/^export\s+/, "").trim();
|
|
4309
|
-
const raw = trimmed.slice(eqIdx + 1);
|
|
4310
|
-
const value = raw.replace(/^["'](.*)["']$/, '$1');
|
|
4311
|
-
// .env file always wins over inherited shell env vars, so that
|
|
4312
|
-
// quickstart's newly written token overrides any stale value.
|
|
4475
|
+
// .env file always wins over inherited shell env vars, so that quickstart's newly written token overrides any stale
|
|
4476
|
+
// value. One parser for every reader of .env (env-file.ts): `export KEY=value` included.
|
|
4477
|
+
for (const [key, value] of parseEnvText(readFileSync(envPath, "utf-8")))
|
|
4313
4478
|
process.env[key] = value;
|
|
4314
|
-
}
|
|
4315
4479
|
}
|
|
4316
4480
|
/** Initialize auth before any adapter can answer /dashboard. */
|
|
4317
4481
|
initializeWebAuthTokens() {
|
|
@@ -4440,11 +4604,15 @@ export class FleetManager {
|
|
|
4440
4604
|
await adapter?.sendText(msg.chatId, t("dashboard.public_general"), { threadId: msg.threadId });
|
|
4441
4605
|
return;
|
|
4442
4606
|
}
|
|
4443
|
-
await this.showDashboardMenu(owner)
|
|
4607
|
+
if (!await this.showDashboardMenu(owner)) {
|
|
4608
|
+
await owner.binding.sendText(owner.chatId, t("dashboard.menu_failed"), { threadId: owner.threadId }).catch(() => undefined);
|
|
4609
|
+
}
|
|
4444
4610
|
}
|
|
4445
4611
|
async sendLocalDashboard(data, adapterId) {
|
|
4446
|
-
if (this.shuttingDown || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(data.userId, adapterId))
|
|
4612
|
+
if (this.shuttingDown || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(data.userId, adapterId)) {
|
|
4613
|
+
await data.respond(t("not_authorized"));
|
|
4447
4614
|
return;
|
|
4615
|
+
}
|
|
4448
4616
|
const text = this.topicCommands.getDashboardText();
|
|
4449
4617
|
const adapter = this.adapters.get(adapterId);
|
|
4450
4618
|
const token = this.webToken;
|
|
@@ -4466,7 +4634,8 @@ export class FleetManager {
|
|
|
4466
4634
|
await withinBudget(adapter.sendDirect(data.userId, privateText, { disablePreview: true }), Math.min(deadline, performance.now() + 5_000));
|
|
4467
4635
|
deliveredByDm = true;
|
|
4468
4636
|
}
|
|
4469
|
-
catch {
|
|
4637
|
+
catch (err) {
|
|
4638
|
+
this.logDashboardDmFailure(err);
|
|
4470
4639
|
if (!current())
|
|
4471
4640
|
throw new Error("owner changed");
|
|
4472
4641
|
const id = await withinBudget(data.respond(privateText), deadline); // native slash response is already ephemeral
|
|
@@ -4476,15 +4645,25 @@ export class FleetManager {
|
|
|
4476
4645
|
if (!current())
|
|
4477
4646
|
throw new Error("owner changed");
|
|
4478
4647
|
if (deliveredByDm)
|
|
4479
|
-
await withinBudget(data.respond(t("dashboard.
|
|
4648
|
+
await withinBudget(data.respond(t("dashboard.private_sent_dm")), deadline);
|
|
4480
4649
|
}
|
|
4481
|
-
catch {
|
|
4650
|
+
catch (err) {
|
|
4482
4651
|
this.webLoginCodes.revokeIfCurrent(login.issuanceId);
|
|
4652
|
+
this.logger.info({ err: err?.message }, "Dashboard private delivery was not confirmed");
|
|
4653
|
+
await data.respond(t("dashboard.private_failed")).catch(() => undefined);
|
|
4483
4654
|
}
|
|
4484
4655
|
}
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4656
|
+
/** Why the DM did not go, for the log only (Discord 50007 = the user does not accept DMs from this server's members). */
|
|
4657
|
+
logDashboardDmFailure(err) {
|
|
4658
|
+
const code = err?.code;
|
|
4659
|
+
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");
|
|
4660
|
+
}
|
|
4661
|
+
/** False when the menu was not shown (the caller still owes the user an answer). */
|
|
4662
|
+
async showDashboardMenu(owner, respondButtons, retireButtons) {
|
|
4663
|
+
if (!this.publicOwnerCurrent(owner)) {
|
|
4664
|
+
this.logger.info("Dashboard menu not shown: the General owner changed");
|
|
4665
|
+
return false;
|
|
4666
|
+
}
|
|
4488
4667
|
const adapter = owner.binding;
|
|
4489
4668
|
const status = this.getPublicWebStatus();
|
|
4490
4669
|
const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
|
|
@@ -4494,7 +4673,7 @@ export class FleetManager {
|
|
|
4494
4673
|
const choices = [{ action: "local", label: t("dashboard.local") },
|
|
4495
4674
|
...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
|
|
4496
4675
|
...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
|
|
4497
|
-
await this.
|
|
4676
|
+
const posted = await this.postNonceButtonPrompt({
|
|
4498
4677
|
prefix: "dashboard:", alertType: "login", instanceName: "dashboard", adapter, adapterId: owner.adapterId,
|
|
4499
4678
|
chatId: owner.chatId, threadId: owner.threadId, timeoutMs: 5 * 60_000,
|
|
4500
4679
|
message: menuText,
|
|
@@ -4503,9 +4682,11 @@ export class FleetManager {
|
|
|
4503
4682
|
const messageId = await respondButtons(menuText, c);
|
|
4504
4683
|
if (!messageId)
|
|
4505
4684
|
throw new Error("menu refused");
|
|
4506
|
-
|
|
4685
|
+
// The menu is the command's ephemeral reply: only that interaction can collapse it.
|
|
4686
|
+
return { chatId: owner.chatId, threadId: owner.threadId, messageId, ...(retireButtons ? { retire: retireButtons } : {}) };
|
|
4507
4687
|
} } : {}),
|
|
4508
4688
|
});
|
|
4689
|
+
return posted !== null;
|
|
4509
4690
|
}
|
|
4510
4691
|
async handleDashboardCallback(data, adapterId, adapter) {
|
|
4511
4692
|
const claimed = this.consumeNonceCallback("dashboard:", /^dashboard:([0-9a-f]{32}):(local|public|close)$/, data, adapterId, adapter);
|
|
@@ -4525,9 +4706,26 @@ export class FleetManager {
|
|
|
4525
4706
|
const result = await this.publicWebLink?.close("admin close", entry.publicExposureId);
|
|
4526
4707
|
if (result?.confirmed === false)
|
|
4527
4708
|
data.ack?.(t("dashboard.public_cleanup"));
|
|
4528
|
-
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("dashboard.public_closed"));
|
|
4709
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("dashboard.public_closed"), data.editClicked);
|
|
4529
4710
|
return true;
|
|
4530
4711
|
}
|
|
4712
|
+
// The click is claimed and its buttons are spent: say so on the menu now. A public link follows each of its steps
|
|
4713
|
+
// there (the first one installs cloudflared, which can take a minute): on Discord through the interaction, on
|
|
4714
|
+
// Telegram by editing the menu message itself. Only static words and numbers — the menu may be in General.
|
|
4715
|
+
const progressEdit = data.editClicked ?? entry.retire;
|
|
4716
|
+
const progress = progressEdit && action !== "public"
|
|
4717
|
+
? progressEdit(t("dashboard.private_sending")).catch(err => this.logger.debug({ err }, "Could not show the dashboard menu's progress"))
|
|
4718
|
+
: Promise.resolve();
|
|
4719
|
+
const stepsEdit = progressEdit ?? (entry.messageId && entry.adapter.editMessageRemoveButtons
|
|
4720
|
+
? (text) => entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, text, entry.threadId) : undefined);
|
|
4721
|
+
const steps = action === "public" && stepsEdit ? new ThrottledMessageEditor({
|
|
4722
|
+
edit: stepsEdit, now: () => performance.now(), heartbeatMs: 5_000,
|
|
4723
|
+
// Telegram counts edits against a group's ~20 messages a minute.
|
|
4724
|
+
minIntervalMs: owner.binding.type === "telegram" ? 3_000 : 2_000,
|
|
4725
|
+
onError: err => this.logger.debug({ err }, "Could not show the public link's progress"),
|
|
4726
|
+
}) : null;
|
|
4727
|
+
let lastSteps;
|
|
4728
|
+
let deliveredByDm = false;
|
|
4531
4729
|
const send = async (url, exposureId, expiresAt, current = () => this.publicOwnerCurrent(owner)) => {
|
|
4532
4730
|
const token = this.webToken;
|
|
4533
4731
|
if (!token || !current())
|
|
@@ -4542,9 +4740,12 @@ export class FleetManager {
|
|
|
4542
4740
|
try {
|
|
4543
4741
|
if (!direct.sendDirect)
|
|
4544
4742
|
throw new Error("DM unavailable");
|
|
4545
|
-
|
|
4743
|
+
const sent = await withinBudget(direct.sendDirect(owner.userId, text, { disablePreview: true, choices: c }), Math.min(deadline, performance.now() + 5_000));
|
|
4744
|
+
deliveredByDm = true;
|
|
4745
|
+
return sent;
|
|
4546
4746
|
}
|
|
4547
|
-
catch {
|
|
4747
|
+
catch (err) {
|
|
4748
|
+
this.logDashboardDmFailure(err);
|
|
4548
4749
|
if (direct.type !== "discord" || !data.respondPrivate || !current())
|
|
4549
4750
|
throw new Error("private delivery unavailable");
|
|
4550
4751
|
const sent = await withinBudget(data.respondPrivate(text, c), deadline);
|
|
@@ -4563,17 +4764,31 @@ export class FleetManager {
|
|
|
4563
4764
|
throw new Error("owner changed");
|
|
4564
4765
|
return true;
|
|
4565
4766
|
}
|
|
4566
|
-
catch {
|
|
4767
|
+
catch (err) {
|
|
4567
4768
|
this.webLoginCodes.revokeIfCurrent(issued.issuanceId);
|
|
4568
|
-
this.logger.info("Dashboard private delivery was not confirmed");
|
|
4769
|
+
this.logger.info({ err: err?.message }, "Dashboard private delivery was not confirmed");
|
|
4569
4770
|
return false;
|
|
4570
4771
|
}
|
|
4571
4772
|
};
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4773
|
+
let ok = false;
|
|
4774
|
+
try {
|
|
4775
|
+
ok = action === "public"
|
|
4776
|
+
? await this.getPublicWebLink().deliver(owner, link => send(link.url, link.exposureId, link.expiresAt, link.isCurrent), p => {
|
|
4777
|
+
lastSteps = p;
|
|
4778
|
+
steps?.update(() => renderPublicLinkProgress(p, performance.now()));
|
|
4779
|
+
})
|
|
4780
|
+
: await send(`http://${this.fleetConfig?.hostname || "localhost"}:${this.fleetConfig?.health_port ?? 19280}/signin`);
|
|
4781
|
+
}
|
|
4782
|
+
catch (err) {
|
|
4783
|
+
// Still closes the step editor below and tells the clicker: a throw must not leave the menu refreshing.
|
|
4784
|
+
this.logger.warn({ err }, "Dashboard sign-in delivery failed");
|
|
4785
|
+
}
|
|
4575
4786
|
// Only safe, static words enter General; never link, code or platform error text.
|
|
4576
|
-
|
|
4787
|
+
const outcome = t(!ok ? "dashboard.private_failed"
|
|
4788
|
+
: owner.binding.type !== "discord" ? "dashboard.private_sent" : deliveredByDm ? "dashboard.private_sent_dm" : "dashboard.private_sent_here");
|
|
4789
|
+
await progress;
|
|
4790
|
+
await steps?.close(); // the last step edit has landed: the outcome cannot be overwritten by it
|
|
4791
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, lastSteps ? renderPublicLinkProgress(lastSteps, performance.now(), outcome) : outcome, data.editClicked);
|
|
4577
4792
|
return true;
|
|
4578
4793
|
}
|
|
4579
4794
|
/** `/dashboard revoke`: sign out every browser and withdraw any unused code. */
|
|
@@ -4986,8 +5201,8 @@ export class FleetManager {
|
|
|
4986
5201
|
for (const need of [...needsGeneral]) {
|
|
4987
5202
|
const matchIdx = unboundGenerals.findIndex(([, inst]) => {
|
|
4988
5203
|
const topicId = String(inst.topic_id ?? "");
|
|
4989
|
-
if (need.ch.type === "discord" && need.ch
|
|
4990
|
-
return topicId ===
|
|
5204
|
+
if (need.ch.type === "discord" && discordGeneralChannelId(need.ch)) {
|
|
5205
|
+
return topicId === discordGeneralChannelId(need.ch);
|
|
4991
5206
|
}
|
|
4992
5207
|
if (need.ch.type === "telegram") {
|
|
4993
5208
|
return topicId === "1" || topicId === "";
|
|
@@ -5285,6 +5500,8 @@ export class FleetManager {
|
|
|
5285
5500
|
}
|
|
5286
5501
|
// Health HTTP endpoint
|
|
5287
5502
|
this.startHealthServer(fleet.health_port ?? 19280);
|
|
5503
|
+
// #1266: buttons that ended while AgEnD was down (expired, or chosen before a restart) are shown as ended now.
|
|
5504
|
+
void this.replyButtons()?.sweep().catch(err => this.logger.warn({ err }, "Reply-button sweep failed"));
|
|
5288
5505
|
// Daily update check — first check after 1 hour, then every 24 hours
|
|
5289
5506
|
this.updateCheckTimer = setTimeout(() => {
|
|
5290
5507
|
this.checkForUpdates();
|
|
@@ -6438,26 +6655,64 @@ export class FleetManager {
|
|
|
6438
6655
|
return undefined;
|
|
6439
6656
|
}
|
|
6440
6657
|
/**
|
|
6441
|
-
*
|
|
6442
|
-
*
|
|
6443
|
-
*
|
|
6658
|
+
* A forum-root command has one configured General before any adapter claims
|
|
6659
|
+
* dedup. Bare commands prefer the primary adapter's General in this group;
|
|
6660
|
+
* explicit suffixes select their own bot. Runtime availability must not pick
|
|
6661
|
+
* another administrator, so stopped owners remain selected and every copy
|
|
6662
|
+
* silently refuses instead of falling back.
|
|
6663
|
+
* undefined = another ingress scope; null = root command with no proven target.
|
|
6444
6664
|
*/
|
|
6665
|
+
telegramRootCommandTarget(msg, threadId) {
|
|
6666
|
+
if (msg.source !== "telegram" || threadId !== undefined || !/^\/\w/.test(msg.text?.trim() ?? ""))
|
|
6667
|
+
return undefined;
|
|
6668
|
+
const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
6669
|
+
const receiving = channels.find(ch => (ch.id ?? ch.type) === msg.adapterId);
|
|
6670
|
+
if (receiving?.type !== "telegram" || receiving.group_id == null || String(receiving.group_id) !== msg.chatId)
|
|
6671
|
+
return undefined;
|
|
6672
|
+
const generals = Object.entries(this.fleetConfig?.instances ?? {}).filter(([, cfg]) => cfg.general_topic === true)
|
|
6673
|
+
.map(([name]) => {
|
|
6674
|
+
const owner = this.getInstanceAdapterId(name);
|
|
6675
|
+
return { name, owner, channel: channels.find(ch => (ch.id ?? ch.type) === owner) };
|
|
6676
|
+
});
|
|
6677
|
+
const candidates = generals.filter(entry => entry.owner && entry.channel?.type === "telegram"
|
|
6678
|
+
&& entry.channel.group_id != null && String(entry.channel.group_id) === msg.chatId);
|
|
6679
|
+
const suffix = msg.text?.trim().match(/^\/[\w-]+@(\S+)/)?.[1];
|
|
6680
|
+
let selected;
|
|
6681
|
+
if (suffix) {
|
|
6682
|
+
const matches = candidates.filter(entry => this.worlds.get(entry.owner)?.botUsername?.toLowerCase() === suffix.toLowerCase());
|
|
6683
|
+
const owners = new Set(matches.map(entry => entry.owner));
|
|
6684
|
+
if (owners.size !== 1)
|
|
6685
|
+
return null;
|
|
6686
|
+
selected = matches[0];
|
|
6687
|
+
}
|
|
6688
|
+
else {
|
|
6689
|
+
// An unresolvable General may be the intended primary; do not silently
|
|
6690
|
+
// widen authority to the next readable binding.
|
|
6691
|
+
if (generals.some(entry => !entry.channel || (entry.channel.type === "telegram" && entry.channel.group_id == null)))
|
|
6692
|
+
return null;
|
|
6693
|
+
selected = candidates.find(entry => entry.owner === this.getPrimaryAdapterId()) ?? candidates[0];
|
|
6694
|
+
}
|
|
6695
|
+
return selected?.owner ? { name: selected.name, owner: selected.owner } : null;
|
|
6696
|
+
}
|
|
6445
6697
|
/**
|
|
6446
6698
|
* #1346: whether this copy may claim the shared dedup key for
|
|
6447
6699
|
* command-like text. Fleet topics resolve an owning adapter and only its
|
|
6448
6700
|
* copy proceeds. Classic targets and unknown routing keep their existing
|
|
6449
|
-
* handling.
|
|
6450
|
-
* suffix requires a known matching username
|
|
6701
|
+
* handling. Telegram forum-root commands select a configured General before
|
|
6702
|
+
* dedup. An explicit suffix requires a known matching username; present-thread
|
|
6451
6703
|
* copies retain their permissive handling when that identity is unknown.
|
|
6452
6704
|
*/
|
|
6453
|
-
isOwnerCommandCopy(msg, threadId) {
|
|
6705
|
+
isOwnerCommandCopy(msg, threadId, rootCommand = this.telegramRootCommandTarget(msg, threadId)) {
|
|
6706
|
+
if (rootCommand !== undefined) {
|
|
6707
|
+
return rootCommand !== null && msg.adapterId === rootCommand.owner && this.daemons.has(rootCommand.name);
|
|
6708
|
+
}
|
|
6454
6709
|
if (!msg.adapterId)
|
|
6455
6710
|
return true;
|
|
6456
6711
|
const suffix = msg.text?.trim().match(/^\/[\w-]+@(\S+)/)?.[1];
|
|
6457
6712
|
if (threadId === undefined) {
|
|
6458
|
-
//
|
|
6459
|
-
// explicit
|
|
6460
|
-
//
|
|
6713
|
+
// Compatibility for a runtime world without configuration: preserve
|
|
6714
|
+
// explicit receiver proof; Classic/private/foreign-forum copies retain
|
|
6715
|
+
// their own dispatch rules.
|
|
6461
6716
|
const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
6462
6717
|
const channel = channels.find(ch => (ch.id ?? ch.type) === msg.adapterId)
|
|
6463
6718
|
?? this.worlds.get(msg.adapterId)?.channelConfig;
|
|
@@ -6487,6 +6742,9 @@ export class FleetManager {
|
|
|
6487
6742
|
}
|
|
6488
6743
|
return true;
|
|
6489
6744
|
}
|
|
6745
|
+
/**
|
|
6746
|
+
* #1085: coincident topic numbers must not route across Telegram groups.
|
|
6747
|
+
*/
|
|
6490
6748
|
resolveInboundTarget(msg, threadId) {
|
|
6491
6749
|
if (msg.source !== "telegram")
|
|
6492
6750
|
return this.routing.resolve(threadId);
|
|
@@ -6523,6 +6781,7 @@ export class FleetManager {
|
|
|
6523
6781
|
return;
|
|
6524
6782
|
}
|
|
6525
6783
|
const threadId = this.inboundRouteThreadId(msg);
|
|
6784
|
+
const rootCommand = this.telegramRootCommandTarget(msg, threadId);
|
|
6526
6785
|
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");
|
|
6527
6786
|
// Ownership and per-adapter filtering both run before the dedup claim below.
|
|
6528
6787
|
// When several bots share a guild they all receive the same message; a copy
|
|
@@ -6583,7 +6842,7 @@ export class FleetManager {
|
|
|
6583
6842
|
// to flow — so only fleet targets are judged here; the in-handler gates
|
|
6584
6843
|
// (owner entry check, Discord ignore) cover classic instead.
|
|
6585
6844
|
if ((msg.messageId || (msg.source === "telegram" && threadId === undefined))
|
|
6586
|
-
&& /^\/\w/.test(msg.text?.trim() ?? "") && !this.isOwnerCommandCopy(msg, threadId)) {
|
|
6845
|
+
&& /^\/\w/.test(msg.text?.trim() ?? "") && !this.isOwnerCommandCopy(msg, threadId, rootCommand)) {
|
|
6587
6846
|
this.logger.debug({ adapterId: msg.adapterId, threadId }, "Non-owner command copy — skipping before dedup claim");
|
|
6588
6847
|
return;
|
|
6589
6848
|
}
|
|
@@ -6890,7 +7149,7 @@ export class FleetManager {
|
|
|
6890
7149
|
}
|
|
6891
7150
|
// General topic: Discord /xxx gets the system note first (never a
|
|
6892
7151
|
// command); other sources fall through to the handlers below.
|
|
6893
|
-
const generalInstance = this.findGeneralInstance(msg.adapterId);
|
|
7152
|
+
const generalInstance = rootCommand?.name ?? this.findGeneralInstance(msg.adapterId);
|
|
6894
7153
|
if (generalInstance && await this.replyDiscordNotACommand(msg, generalInstance))
|
|
6895
7154
|
return;
|
|
6896
7155
|
if (generalInstance && await this.topicCommands.handleInstanceCommand(msg, generalInstance))
|
|
@@ -7279,6 +7538,12 @@ export class FleetManager {
|
|
|
7279
7538
|
return;
|
|
7280
7539
|
}
|
|
7281
7540
|
}
|
|
7541
|
+
// #1266: buttons are checked before anything is sent, like stickers.
|
|
7542
|
+
const parsedButtons = parseReplyButtons(args.buttons, args);
|
|
7543
|
+
if (parsedButtons && "error" in parsedButtons) {
|
|
7544
|
+
respond(null, `reply: ${parsedButtons.error}`);
|
|
7545
|
+
return;
|
|
7546
|
+
}
|
|
7282
7547
|
// Stickers (#1226) are checked before anything is sent: a refused one is the reply's error, not a gap.
|
|
7283
7548
|
const stickerProblem = await this.replyStickerProblem(outAdapter, args, threadId, contextAdapterId ?? this.getInstanceAdapterId(senderInstanceName ?? instanceName));
|
|
7284
7549
|
if (stickerProblem) {
|
|
@@ -7291,8 +7556,33 @@ export class FleetManager {
|
|
|
7291
7556
|
ticket.subscribe(respond);
|
|
7292
7557
|
return;
|
|
7293
7558
|
}
|
|
7559
|
+
// #1266: the buttons' set, before the send (a click cannot match it until the message is known). Where buttons
|
|
7560
|
+
// cannot be shown, the choices go into the text instead.
|
|
7561
|
+
let replySet = null;
|
|
7562
|
+
if (parsedButtons && "buttons" in parsedButtons) {
|
|
7563
|
+
const buttons = outAdapter.supportsReplyButtons ? this.replyButtons() : null;
|
|
7564
|
+
if (buttons) {
|
|
7565
|
+
const outId = outAdapter === WEB_ONLY_REPLY_SINK ? "web" : outAdapter.id ?? contextAdapterId ?? "";
|
|
7566
|
+
replySet = buttons.prepare({ instance: instanceName, adapterId: outId, chatId: String(args.chat_id ?? ""), threadId }, parsedButtons.buttons);
|
|
7567
|
+
}
|
|
7568
|
+
else {
|
|
7569
|
+
args.text = `${String(args.text)}\n\n${replyButtonsFallbackText(parsedButtons.buttons)}`;
|
|
7570
|
+
}
|
|
7571
|
+
}
|
|
7294
7572
|
const original = respond;
|
|
7295
7573
|
const respondAndRecord = (result, error) => {
|
|
7574
|
+
let buttonsView = null;
|
|
7575
|
+
if (replySet) {
|
|
7576
|
+
const sent = result;
|
|
7577
|
+
const carrying = sent?.buttonsMessageId ?? sent?.messageId;
|
|
7578
|
+
if (!error && carrying) {
|
|
7579
|
+
this.replyButtonsCtl?.bind(replySet.id, carrying);
|
|
7580
|
+
buttonsView = this.replyButtonsCtl?.viewOf(replySet.id) ?? null;
|
|
7581
|
+
}
|
|
7582
|
+
else {
|
|
7583
|
+
this.replyButtonsCtl?.discard(replySet.id);
|
|
7584
|
+
}
|
|
7585
|
+
}
|
|
7296
7586
|
ticket.complete(result, error);
|
|
7297
7587
|
// Return the platform outcome first. Bookkeeping below must never turn
|
|
7298
7588
|
// a confirmed Discord/Telegram POST into a tool error if a secondary
|
|
@@ -7305,7 +7595,7 @@ export class FleetManager {
|
|
|
7305
7595
|
// an agent cannot invent it to suppress the normal completion marker.
|
|
7306
7596
|
if (!error && result != null && msg.statusOnly !== true) {
|
|
7307
7597
|
try {
|
|
7308
|
-
this.afterReplyRouted(instanceName, args, senderSessionName);
|
|
7598
|
+
this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
|
|
7309
7599
|
}
|
|
7310
7600
|
catch (err) {
|
|
7311
7601
|
this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
|
|
@@ -7320,9 +7610,11 @@ export class FleetManager {
|
|
|
7320
7610
|
});
|
|
7321
7611
|
}
|
|
7322
7612
|
};
|
|
7323
|
-
if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord)) {
|
|
7613
|
+
if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord, replySet ? { replyButtons: replySet.callbacks } : {})) {
|
|
7324
7614
|
return;
|
|
7325
7615
|
}
|
|
7616
|
+
if (replySet)
|
|
7617
|
+
this.replyButtonsCtl?.discard(replySet.id);
|
|
7326
7618
|
// routeToolCall knows "reply"; not handling it means the world changed.
|
|
7327
7619
|
ticket.complete(null, "reply not handled");
|
|
7328
7620
|
original(null, "reply not handled");
|
|
@@ -7356,7 +7648,7 @@ export class FleetManager {
|
|
|
7356
7648
|
webOnlyPromptPlace() {
|
|
7357
7649
|
return this.worlds.size === 0 && this.isWebOnlyFleet() ? { adapter: WEB_ONLY_PROMPT_SINK, adapterId: "web", chatId: "web" } : null;
|
|
7358
7650
|
}
|
|
7359
|
-
afterReplyRouted(instanceName, args, senderSessionName) {
|
|
7651
|
+
afterReplyRouted(instanceName, args, senderSessionName, buttons = null) {
|
|
7360
7652
|
// A reply is NOT proof the turn is over (#410) — but it is not proof of
|
|
7361
7653
|
// more work either. Split the difference: an instance that is clearly
|
|
7362
7654
|
// idle loses the button now; one that looks busy keeps it (re-posted
|
|
@@ -7387,6 +7679,7 @@ export class FleetManager {
|
|
|
7387
7679
|
text: (args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX),
|
|
7388
7680
|
ts: new Date().toISOString(),
|
|
7389
7681
|
...(attachments.length ? { attachments } : {}),
|
|
7682
|
+
...(buttons ? { buttons } : {}), // #1266
|
|
7390
7683
|
});
|
|
7391
7684
|
// Log bot reply to classic instance chat-log
|
|
7392
7685
|
const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
|
|
@@ -8590,22 +8883,23 @@ export class FleetManager {
|
|
|
8590
8883
|
const output = String(this.rawFleetDocument);
|
|
8591
8884
|
if (redundantPaths.length > 0)
|
|
8592
8885
|
this.writeFleetConfigBackup(source);
|
|
8593
|
-
|
|
8594
|
-
|
|
8595
|
-
// #1056
|
|
8596
|
-
|
|
8597
|
-
|
|
8598
|
-
|
|
8599
|
-
|
|
8600
|
-
|
|
8601
|
-
|
|
8602
|
-
|
|
8603
|
-
|
|
8604
|
-
|
|
8605
|
-
|
|
8606
|
-
|
|
8607
|
-
|
|
8608
|
-
|
|
8886
|
+
// Atomic write with fsync. The beforeRename hook runs after fsync and
|
|
8887
|
+
// before rename so validation failures are reported before the file is
|
|
8888
|
+
// replaced (#1056), and the temp file is cleaned up on any throw.
|
|
8889
|
+
atomicWriteFileSync(this.configPath, output, {
|
|
8890
|
+
mode: existsSync(this.configPath) ? statSync(this.configPath).mode : 0o644,
|
|
8891
|
+
fsync: this.fsyncForTest,
|
|
8892
|
+
beforeRename: (tempPath) => {
|
|
8893
|
+
// #1056 (Fable's 2.2 audit): the patcher writes leaves, not configs, so
|
|
8894
|
+
// a wrong patch can produce a file the validator — or the next start —
|
|
8895
|
+
// refuses. Load and validate what is about to replace fleet.yaml.
|
|
8896
|
+
const refusal = this.savedFleetConfigProblem(tempPath);
|
|
8897
|
+
if (refusal)
|
|
8898
|
+
throw new Error(refusal);
|
|
8899
|
+
if (existsSync(this.configPath))
|
|
8900
|
+
chmodSync(tempPath, statSync(this.configPath).mode);
|
|
8901
|
+
},
|
|
8902
|
+
});
|
|
8609
8903
|
this.rawFleetConfig = loadRawFleetConfig(this.configPath);
|
|
8610
8904
|
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
8611
8905
|
this.logger.info({ path: this.configPath, strippedDefaults: redundantPaths.length }, "Saved fleet config (lossless patch)");
|
|
@@ -9916,9 +10210,9 @@ export class FleetManager {
|
|
|
9916
10210
|
if (generalTopic != null)
|
|
9917
10211
|
return { chatId, opts: { threadId: String(generalTopic) } };
|
|
9918
10212
|
if (cfg?.type === "discord") {
|
|
9919
|
-
const configured = cfg
|
|
9920
|
-
if (configured
|
|
9921
|
-
return { chatId, opts: { threadId:
|
|
10213
|
+
const configured = discordGeneralChannelId(cfg);
|
|
10214
|
+
if (configured) {
|
|
10215
|
+
return { chatId, opts: { threadId: configured } };
|
|
9922
10216
|
}
|
|
9923
10217
|
return null; // a guild id is not a channel; sending would always fail
|
|
9924
10218
|
}
|
|
@@ -10110,9 +10404,7 @@ export class FleetManager {
|
|
|
10110
10404
|
return;
|
|
10111
10405
|
this.pendingNonceButtons.delete(nonce);
|
|
10112
10406
|
this.webPromptGone(entry, entry.expiredText);
|
|
10113
|
-
|
|
10114
|
-
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"));
|
|
10115
|
-
}
|
|
10407
|
+
this.collapseNoncePrompt(entry)?.catch(err => this.logger.debug({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Failed to expire button prompt"));
|
|
10116
10408
|
}, opts.timeoutMs ?? NONCE_BUTTON_TIMEOUT_MS);
|
|
10117
10409
|
entry.timer.unref?.();
|
|
10118
10410
|
this.pendingNonceButtons.set(nonce, entry);
|
|
@@ -10136,6 +10428,8 @@ export class FleetManager {
|
|
|
10136
10428
|
entry.threadId = sent.threadId;
|
|
10137
10429
|
}
|
|
10138
10430
|
entry.messageId = sent.messageId;
|
|
10431
|
+
if (sent.retire)
|
|
10432
|
+
entry.retire = sent.retire;
|
|
10139
10433
|
// Offered on the dashboard only once it is live on the platform (a failed post is disarmed above),
|
|
10140
10434
|
// and only if nothing claimed or expired it meanwhile.
|
|
10141
10435
|
if (WEB_MIRRORED_PROMPT_PREFIXES.has(opts.prefix) && this.pendingNonceButtons.get(nonce) === entry) {
|
|
@@ -10167,7 +10461,7 @@ export class FleetManager {
|
|
|
10167
10461
|
* longer parses) collapses the clicked message so the dead button stops
|
|
10168
10462
|
* inviting clicks — the same courtesy the cancel button extends.
|
|
10169
10463
|
*/
|
|
10170
|
-
consumeNonceCallback(prefix, actionRe, data, callbackAdapterId, receivingAdapter, staleHandling) {
|
|
10464
|
+
consumeNonceCallback(prefix, actionRe, data, callbackAdapterId, receivingAdapter, staleHandling, authorize) {
|
|
10171
10465
|
if (!data.callbackData.startsWith(prefix))
|
|
10172
10466
|
return null;
|
|
10173
10467
|
const match = data.callbackData.match(actionRe);
|
|
@@ -10186,6 +10480,9 @@ export class FleetManager {
|
|
|
10186
10480
|
if (staleHandling?.keepText && adapter?.removeMessageButtons) {
|
|
10187
10481
|
adapter.removeMessageButtons(data.chatId, data.messageId, data.threadId).catch(collapseFailed);
|
|
10188
10482
|
}
|
|
10483
|
+
else if (data.editClicked) {
|
|
10484
|
+
data.editClicked(t("buttons.stale")).catch(collapseFailed);
|
|
10485
|
+
}
|
|
10189
10486
|
else {
|
|
10190
10487
|
adapter?.editMessageRemoveButtons?.(data.chatId, data.messageId, t("buttons.stale"), data.threadId).catch(collapseFailed);
|
|
10191
10488
|
}
|
|
@@ -10204,11 +10501,13 @@ export class FleetManager {
|
|
|
10204
10501
|
const isAuthorized = fromWeb
|
|
10205
10502
|
? pending.web !== undefined
|
|
10206
10503
|
: data.userId
|
|
10207
|
-
?
|
|
10208
|
-
?
|
|
10209
|
-
: pending.
|
|
10210
|
-
?
|
|
10211
|
-
:
|
|
10504
|
+
? authorize
|
|
10505
|
+
? authorize(data.userId, pending)
|
|
10506
|
+
: pending.allowAnyUser
|
|
10507
|
+
? true
|
|
10508
|
+
: pending.authChannelId
|
|
10509
|
+
? this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
|
|
10510
|
+
: this.isFleetAdmin(data.userId, callbackAdapterId)
|
|
10212
10511
|
: false;
|
|
10213
10512
|
const mismatchedFields = [];
|
|
10214
10513
|
if (pending.adapterId !== callbackAdapterId)
|
|
@@ -10282,6 +10581,14 @@ export class FleetManager {
|
|
|
10282
10581
|
}
|
|
10283
10582
|
/** Routes a click to the handler that owns its prefix; false when none does. */
|
|
10284
10583
|
async dispatchAdapterCallback(data, adapterId, adapter) {
|
|
10584
|
+
if (data.callbackData.startsWith(REPLY_BUTTON_PREFIX)) { // #1266
|
|
10585
|
+
const buttons = this.replyButtons();
|
|
10586
|
+
if (!buttons) {
|
|
10587
|
+
data.ack?.(t("reply_buttons.closed"));
|
|
10588
|
+
return true;
|
|
10589
|
+
}
|
|
10590
|
+
return buttons.handleCallback(data, adapterId);
|
|
10591
|
+
}
|
|
10285
10592
|
if (this.needsYou?.handleCallback(data, adapterId))
|
|
10286
10593
|
return true;
|
|
10287
10594
|
if (await this.handleTipDismiss(data, adapterId, adapter))
|
|
@@ -10552,6 +10859,11 @@ export class FleetManager {
|
|
|
10552
10859
|
* The caller has passed the /ui gate (session, same origin, CSRF). Here: the prompt must be one offered
|
|
10553
10860
|
* on the dashboard, about the instance the page named, and the action one of its own buttons.
|
|
10554
10861
|
*/
|
|
10862
|
+
/** #1266: a click on a reply's button in the web chat (the /ui gate is passed: a session, or the public link). */
|
|
10863
|
+
clickWebReplyButton(instance, id, index) {
|
|
10864
|
+
const buttons = this.replyButtons();
|
|
10865
|
+
return buttons ? buttons.clickWeb(instance, id, index) : Promise.resolve({ status: 409, error: t("reply_buttons.closed") });
|
|
10866
|
+
}
|
|
10555
10867
|
async clickWebPrompt(instance, nonce, action) {
|
|
10556
10868
|
if (!/^[0-9a-f]{32}$/.test(nonce) || !/^[a-z][a-z-]{0,23}$/.test(action))
|
|
10557
10869
|
return { status: 400, error: "Malformed prompt answer" };
|
|
@@ -10580,9 +10892,16 @@ export class FleetManager {
|
|
|
10580
10892
|
return { status: 200 };
|
|
10581
10893
|
return { status: 409, error: notice ?? "This prompt is no longer open" };
|
|
10582
10894
|
}
|
|
10583
|
-
async retireNonceButtons(pending, messageId, text
|
|
10895
|
+
async retireNonceButtons(pending, messageId, text,
|
|
10896
|
+
/** The click's own edit of a private prompt (AdapterCallbackData.editClicked); preferred over the prompt's. */
|
|
10897
|
+
editClicked) {
|
|
10584
10898
|
this.webPromptGone(pending, text);
|
|
10585
10899
|
try {
|
|
10900
|
+
const retire = editClicked ?? pending.retire;
|
|
10901
|
+
if (retire) {
|
|
10902
|
+
await retire(text);
|
|
10903
|
+
return;
|
|
10904
|
+
}
|
|
10586
10905
|
if (!pending.adapter.editMessageRemoveButtons)
|
|
10587
10906
|
throw new Error("adapter cannot remove prompt buttons");
|
|
10588
10907
|
await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
|
|
@@ -11040,8 +11359,8 @@ export class FleetManager {
|
|
|
11040
11359
|
this.webPromptGone(entry, entry.expiredText);
|
|
11041
11360
|
}
|
|
11042
11361
|
const collapses = entries
|
|
11043
|
-
.
|
|
11044
|
-
.
|
|
11362
|
+
.map(entry => this.collapseNoncePrompt(entry)?.catch(err => this.logger.debug({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Failed to retire button prompt during shutdown")))
|
|
11363
|
+
.filter(collapse => collapse !== undefined);
|
|
11045
11364
|
if (!collapses.length)
|
|
11046
11365
|
return;
|
|
11047
11366
|
await Promise.race([
|
|
@@ -11060,12 +11379,17 @@ export class FleetManager {
|
|
|
11060
11379
|
if (entry.timer)
|
|
11061
11380
|
clearTimeout(entry.timer);
|
|
11062
11381
|
this.webPromptGone(entry, entry.expiredText);
|
|
11063
|
-
|
|
11064
|
-
entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId)
|
|
11065
|
-
.catch(err => this.logger.debug({ err, instanceName, prefix: entry.prefix }, "Failed to collapse prompt during instance stop"));
|
|
11066
|
-
}
|
|
11382
|
+
this.collapseNoncePrompt(entry)?.catch(err => this.logger.debug({ err, instanceName, prefix: entry.prefix }, "Failed to collapse prompt during instance stop"));
|
|
11067
11383
|
}
|
|
11068
11384
|
}
|
|
11385
|
+
/** A lapsed prompt's buttons collapsed to its expired text — through its own interaction when it is private. */
|
|
11386
|
+
collapseNoncePrompt(entry) {
|
|
11387
|
+
if (entry.retire)
|
|
11388
|
+
return entry.retire(entry.expiredText);
|
|
11389
|
+
if (!entry.messageId || !entry.adapter.editMessageRemoveButtons)
|
|
11390
|
+
return undefined;
|
|
11391
|
+
return entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId);
|
|
11392
|
+
}
|
|
11069
11393
|
/** A clean exit is intentional from the CLI's perspective, but often not from
|
|
11070
11394
|
* the operator's. Keep the instance notice passive and put the action in the
|
|
11071
11395
|
* same-world General topic where an administrator can make the choice. */
|
|
@@ -12048,7 +12372,7 @@ export class FleetManager {
|
|
|
12048
12372
|
for (const name of this.configuredBackendInstanceNames()) {
|
|
12049
12373
|
configured.add(this.backendNameOf(name));
|
|
12050
12374
|
}
|
|
12051
|
-
const installed = this.probeInstalledBackends();
|
|
12375
|
+
const installed = await this.probeInstalledBackends();
|
|
12052
12376
|
// One entry point for "get this CLI working" (#1131): a backend that is
|
|
12053
12377
|
// not installed is offered too, and the click installs it first, then
|
|
12054
12378
|
// signs in (startLoginSession routes it).
|
|
@@ -12680,10 +13004,16 @@ export class FleetManager {
|
|
|
12680
13004
|
attachments: Array.isArray(m.attachments) ? m.attachments : undefined,
|
|
12681
13005
|
messageId: typeof m.messageId === "string" ? m.messageId : undefined,
|
|
12682
13006
|
role: typeof m.role === "string" ? m.role : undefined,
|
|
13007
|
+
buttons: m.buttons,
|
|
12683
13008
|
});
|
|
12684
13009
|
broadcastSseEvent(this.sseClients, event, recorded, onError, this.webChatHistory.cursorOf(recorded));
|
|
12685
13010
|
return;
|
|
12686
13011
|
}
|
|
13012
|
+
// #1266: a reply's buttons ended — the history shows it too (a later load, the public link's poll).
|
|
13013
|
+
if (event === "reply_buttons" && data && typeof data === "object") {
|
|
13014
|
+
const u = data;
|
|
13015
|
+
this.webChatHistory.updateButtons(String(u.instance ?? ""), u.buttons);
|
|
13016
|
+
}
|
|
12687
13017
|
broadcastSseEvent(this.sseClients, event, data, onError);
|
|
12688
13018
|
}
|
|
12689
13019
|
listClaimedTasks(assignee) {
|
|
@@ -14495,6 +14825,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14495
14825
|
return `${warn}${t("effort.restart_success", instanceName, applied)}`;
|
|
14496
14826
|
}
|
|
14497
14827
|
async applyModel(instanceName, model) {
|
|
14828
|
+
// Reject model names with newlines or control characters: they would be
|
|
14829
|
+
// persisted to fleet.yaml and pasted raw into the CLI (P3 from #1490 audit).
|
|
14830
|
+
if (/[\x00-\x1f\x7f]/.test(model)) {
|
|
14831
|
+
return t("model.invalid_chars");
|
|
14832
|
+
}
|
|
14498
14833
|
const backendName = this.backendNameForInstance(instanceName);
|
|
14499
14834
|
let strategy = "restart";
|
|
14500
14835
|
try {
|
|
@@ -14869,7 +15204,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14869
15204
|
// #1386: every live "Needs you" message says the fleet stopped (capabilities revoked first) — while the
|
|
14870
15205
|
// adapters can still edit. Bounded: a platform that does not answer must not hold the shutdown.
|
|
14871
15206
|
const needsStopped = this.stopNeedsYou();
|
|
14872
|
-
|
|
15207
|
+
// #1490 P3: stop() now returns a Promise; await it so the final
|
|
15208
|
+
// ledger flush completes before process.exit(0) in doStopAll's caller.
|
|
15209
|
+
const cacheStopped = this.cacheService?.stop() ?? Promise.resolve();
|
|
14873
15210
|
const profileStopped = this.runtimeCpuProfiler?.shutdown("fleet shutdown");
|
|
14874
15211
|
this.ipcStoppingInstances.add("__fleet_stopping__");
|
|
14875
15212
|
// Release held delivery promises before awaiting daemon shutdown, then
|
|
@@ -14903,6 +15240,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14903
15240
|
await profileStopped;
|
|
14904
15241
|
await needsStopped;
|
|
14905
15242
|
await settingsControlStopped;
|
|
15243
|
+
await cacheStopped;
|
|
14906
15244
|
await this.cpuProfileControl?.close();
|
|
14907
15245
|
this.cpuProfileControl = null;
|
|
14908
15246
|
await this.shutdownLoginWindows();
|
|
@@ -15035,6 +15373,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15035
15373
|
// nor revives a session that was revoked while the disk was refusing writes.
|
|
15036
15374
|
this.webSessions?.flush();
|
|
15037
15375
|
this.eventLog?.close();
|
|
15376
|
+
this.replyButtonsCtl?.stop();
|
|
15377
|
+
this.replyButtonsStore?.close();
|
|
15378
|
+
this.replyButtonsCtl = null;
|
|
15379
|
+
this.replyButtonsStore = null;
|
|
15038
15380
|
const pidPath = join(this.dataDir, "fleet.pid");
|
|
15039
15381
|
try {
|
|
15040
15382
|
unlinkSync(pidPath);
|
|
@@ -15262,7 +15604,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15262
15604
|
// this process came up on: a Settings edit mutates this.fleetConfig in place
|
|
15263
15605
|
// before the reload, so comparing the pre-load copy sees nothing at all.
|
|
15264
15606
|
const newFleetLevel = this.fleetLevelSignature();
|
|
15265
|
-
|
|
15607
|
+
// #1519 P6: connections appended to a running fleet start now — no restart. Only once every one is running does
|
|
15608
|
+
// this process count the configuration as applied; otherwise the row says a restart is still needed.
|
|
15609
|
+
if (this.appliedFleetLevel !== null && this.appliedFleetLevel !== newFleetLevel && this.canHotAddConnections(this.fleetConfig)) {
|
|
15610
|
+
observe?.(APPLY_FLEET_TARGET, "hot", "running");
|
|
15611
|
+
if (await this.hotAddConnections(appendedConnections(this.startupFleetConfig, this.fleetConfig))) {
|
|
15612
|
+
this.startupFleetConfig = structuredClone(this.fleetConfig);
|
|
15613
|
+
this.appliedFleetLevel = newFleetLevel;
|
|
15614
|
+
observe?.(APPLY_FLEET_TARGET, "hot", "done");
|
|
15615
|
+
}
|
|
15616
|
+
else {
|
|
15617
|
+
observe?.(APPLY_FLEET_TARGET, "restart", "restart-required");
|
|
15618
|
+
}
|
|
15619
|
+
}
|
|
15620
|
+
else if (this.appliedFleetLevel !== null && this.appliedFleetLevel !== newFleetLevel) {
|
|
15266
15621
|
this.logger.warn({
|
|
15267
15622
|
keys: fleetLevelDifferences(this.startupFleetConfig, this.fleetConfig),
|
|
15268
15623
|
}, "Fleet-level config changed — restart AgEnD for it to take effect");
|
|
@@ -15596,6 +15951,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15596
15951
|
listSecureConnections() {
|
|
15597
15952
|
const channels = this.fleetConfig?.channels
|
|
15598
15953
|
?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
15954
|
+
// A token written from Settings is in .env before this process has it (#1519 P1: a new connection's token waits for
|
|
15955
|
+
// the restart that starts it) — stored is "set", not "missing". Names only; no value is read out.
|
|
15956
|
+
let stored;
|
|
15957
|
+
try {
|
|
15958
|
+
stored = envFileKeys(this.dataDir);
|
|
15959
|
+
}
|
|
15960
|
+
catch {
|
|
15961
|
+
stored = new Set();
|
|
15962
|
+
} // unreadable: only what this process has
|
|
15599
15963
|
return channels.map((channel, index) => {
|
|
15600
15964
|
const id = channel.id ?? channel.type ?? `channel-${index}`;
|
|
15601
15965
|
const world = this.worlds.get(id);
|
|
@@ -15604,16 +15968,42 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15604
15968
|
id,
|
|
15605
15969
|
type: channel.type,
|
|
15606
15970
|
token_env: channel.bot_token_env,
|
|
15607
|
-
token_present: !!process.env[channel.bot_token_env],
|
|
15971
|
+
token_present: !!process.env[channel.bot_token_env] || stored.has(channel.bot_token_env),
|
|
15608
15972
|
group_id: channel.group_id != null ? String(channel.group_id) : null,
|
|
15609
|
-
general_channel_id: channel
|
|
15610
|
-
|
|
15611
|
-
: null,
|
|
15612
|
-
status: state?.status ?? (world ? "starting" : "stopped"),
|
|
15973
|
+
general_channel_id: discordGeneralChannelId(channel),
|
|
15974
|
+
...this.connectionStatus(id, state, world),
|
|
15613
15975
|
...(world ? { identity: { id: world.botUserId ?? null, username: world.botUsername ?? null } } : {}),
|
|
15614
15976
|
};
|
|
15615
15977
|
});
|
|
15616
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
|
+
}
|
|
15617
16007
|
secureConnectionChannel(connectionId) {
|
|
15618
16008
|
const channels = this.fleetConfig?.channels
|
|
15619
16009
|
?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
@@ -16402,8 +16792,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16402
16792
|
throw new Error("adapter operation superseded"); };
|
|
16403
16793
|
check();
|
|
16404
16794
|
const old = this.adapters.get(connectionId);
|
|
16405
|
-
|
|
16406
|
-
|
|
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
|
+
}
|
|
16407
16804
|
const primary = this.getPrimaryAdapterId() === connectionId;
|
|
16408
16805
|
const previousState = this.adapterState.get(connectionId);
|
|
16409
16806
|
this.adapterState.set(connectionId, { status: "retrying", retryCount: previousState?.retryCount ?? 0 });
|
|
@@ -16472,6 +16869,74 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16472
16869
|
* the reconcile reports as it works. A target the forecast missed (a Classic
|
|
16473
16870
|
* instance inheriting a changed default) is added when it is first touched.
|
|
16474
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
|
+
}
|
|
16475
16940
|
planConfigApply() {
|
|
16476
16941
|
const rows = [];
|
|
16477
16942
|
const nextConfig = this.nextFleetConfig();
|
|
@@ -16495,7 +16960,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16495
16960
|
rows.push({ target: name, kind: "restart" });
|
|
16496
16961
|
}
|
|
16497
16962
|
if (this.appliedFleetLevel !== null && this.appliedFleetLevel !== this.fleetLevelSignature(nextConfig)) {
|
|
16498
|
-
|
|
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" });
|
|
16499
16965
|
}
|
|
16500
16966
|
return rows;
|
|
16501
16967
|
}
|
|
@@ -16888,14 +17354,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16888
17354
|
return;
|
|
16889
17355
|
}
|
|
16890
17356
|
const requestPath = new URL(req.url ?? "/", `http://localhost:${port}`).pathname;
|
|
16891
|
-
//
|
|
16892
|
-
//
|
|
16893
|
-
|
|
16894
|
-
if (req.method === "GET" && requestPath === "/favicon.ico") {
|
|
16895
|
-
res.writeHead(204);
|
|
16896
|
-
res.end();
|
|
17357
|
+
// Branding is public, after Host admission: automatic favicon requests
|
|
17358
|
+
// must not become noisy web-token 401s in the browser console.
|
|
17359
|
+
if (handleWebIconRequest(req, res, requestPath))
|
|
16897
17360
|
return;
|
|
16898
|
-
}
|
|
16899
17361
|
// Public: the health probe, /agent (instance-token auth of its own), the
|
|
16900
17362
|
// sign-in surface, and /view's reads unless web.view_access says otherwise.
|
|
16901
17363
|
if (bypassesWebGate(req, requestPath, this.fleetConfig, p => !gatewayRequestContext(req) && (isViewPath(p) || isUsagePath(p)))) {
|
|
@@ -16947,7 +17409,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16947
17409
|
const backend = this.fleetConfig?.instances[name]?.backend
|
|
16948
17410
|
?? this.fleetConfig?.defaults?.backend
|
|
16949
17411
|
?? "claude-code";
|
|
16950
|
-
const { context } = resolveInstanceContext(this.dataDir, name, backend);
|
|
17412
|
+
const { context } = resolveInstanceContext(this.dataDir, name, backend, { source: this.getPaneContextSource(name) });
|
|
16951
17413
|
return {
|
|
16952
17414
|
name,
|
|
16953
17415
|
status: this.getInstanceStatus(name),
|
|
@@ -17336,6 +17798,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17336
17798
|
.map(ch => ch.instanceName)
|
|
17337
17799
|
.filter(name => !fleetNames.includes(name));
|
|
17338
17800
|
const names = [...fleetNames, ...classicOnly];
|
|
17801
|
+
// The identity every page shows (alpha.2, N1): alias, description, role and tags resolved by the same rule as
|
|
17802
|
+
// /api/profiles (resolveInstanceIdentity) — the sidebar groups, searches and labels with it on every page.
|
|
17803
|
+
const classicRooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
|
|
17804
|
+
const profiles = profileIdentities(this.dataDir);
|
|
17339
17805
|
const instances = names.map(name => {
|
|
17340
17806
|
const statusFile = join(this.getInstanceDir(name), "statusline.json");
|
|
17341
17807
|
let cost = 0;
|
|
@@ -17353,7 +17819,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17353
17819
|
: (this.fleetConfig?.instances[name]?.backend
|
|
17354
17820
|
?? this.fleetConfig?.defaults?.backend
|
|
17355
17821
|
?? "claude-code");
|
|
17356
|
-
const { context } = resolveInstanceContext(this.dataDir, name, backend);
|
|
17822
|
+
const { context } = resolveInstanceContext(this.dataDir, name, backend, { source: this.getPaneContextSource(name) });
|
|
17357
17823
|
// context_pct: null when unavailable, not 0
|
|
17358
17824
|
const context_pct = context ?? null;
|
|
17359
17825
|
// Model: Only Claude Code has live statusline; others use the effective resolver.
|
|
@@ -17372,13 +17838,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17372
17838
|
// Only show effort if backend supports it and it's not antigravity
|
|
17373
17839
|
const effort = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.effort;
|
|
17374
17840
|
const effort_source = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.source;
|
|
17375
|
-
|
|
17376
|
-
const display_name = classic
|
|
17377
|
-
? this.classicChannels?.getAll().find(ch => ch.instanceName === name)?.displayName
|
|
17378
|
-
: this.fleetConfig?.instances[name]?.display_name;
|
|
17841
|
+
const identity = resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic: classicRooms.get(name), profile: profiles.get(name) });
|
|
17379
17842
|
return {
|
|
17380
17843
|
name,
|
|
17381
|
-
display_name: display_name || undefined,
|
|
17844
|
+
display_name: identity.display_name || undefined,
|
|
17845
|
+
description: identity.description || undefined,
|
|
17846
|
+
role: identity.role || undefined,
|
|
17847
|
+
tags: identity.tags,
|
|
17382
17848
|
status: this.getInstanceStatus(name),
|
|
17383
17849
|
// `state` (presentation: may be awaiting_input) and `execution_state` (working / idle / stuck, or null —
|
|
17384
17850
|
// what the dashboard's activity events carry) come from instancePresentation (#1212).
|
|
@@ -17396,6 +17862,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17396
17862
|
instances,
|
|
17397
17863
|
publicLink: this.getPublicWebStatus(),
|
|
17398
17864
|
uptime: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
17865
|
+
// #1519 P7: how many chat connections are configured — 0 is a web-only fleet, which gets the first-run card.
|
|
17866
|
+
connections: (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).length,
|
|
17399
17867
|
};
|
|
17400
17868
|
}
|
|
17401
17869
|
}
|