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