@songsid/agend 2.3.0-beta.2 → 2.3.0-beta.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/dist/agent-whats-new.js +2 -0
- package/dist/agent-whats-new.js.map +1 -1
- package/dist/auth-api.d.ts +30 -1
- package/dist/auth-api.js +42 -12
- package/dist/auth-api.js.map +1 -1
- package/dist/backend/claude-code.d.ts +2 -2
- package/dist/backend/claude-code.js +5 -4
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/types.d.ts +6 -0
- package/dist/backend/types.js +15 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/backend-availability.d.ts +120 -0
- package/dist/backend-availability.js +396 -0
- package/dist/backend-availability.js.map +1 -0
- package/dist/backend-command.d.ts +21 -3
- package/dist/backend-command.js +23 -4
- package/dist/backend-command.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +7 -0
- package/dist/channel/adapters/discord.js +35 -0
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-tools.js +8 -4
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/types.d.ts +6 -0
- package/dist/classic-channel-manager.d.ts +8 -3
- package/dist/classic-channel-manager.js +10 -3
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/config-validator.js +73 -2
- package/dist/config-validator.js.map +1 -1
- package/dist/delivery-outbox.js +5 -0
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-context.d.ts +10 -0
- package/dist/fleet-manager.d.ts +238 -5
- package/dist/fleet-manager.js +878 -71
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-traffic.d.ts +38 -0
- package/dist/fleet-traffic.js +96 -0
- package/dist/fleet-traffic.js.map +1 -0
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +13 -5
- package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -1
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +56 -11
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +398 -0
- package/dist/human-delivery-receipts.js +2 -1
- package/dist/human-delivery-receipts.js.map +1 -1
- package/dist/locale.js +48 -8
- package/dist/locale.js.map +1 -1
- package/dist/memory-hogs.d.ts +27 -0
- package/dist/memory-hogs.js +124 -0
- package/dist/memory-hogs.js.map +1 -0
- package/dist/outbound-handlers.d.ts +2 -0
- package/dist/outbound-handlers.js +40 -1
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +92 -0
- package/dist/outbound-schemas.js +55 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +1 -1
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/public-web-link.d.ts +9 -0
- package/dist/public-web-link.js +8 -0
- package/dist/public-web-link.js.map +1 -1
- package/dist/quickstart-api.d.ts +10 -1
- package/dist/quickstart-api.js +12 -4
- package/dist/quickstart-api.js.map +1 -1
- package/dist/reply-buttons-controller.d.ts +2 -0
- package/dist/reply-buttons-controller.js +1 -1
- package/dist/reply-buttons-controller.js.map +1 -1
- package/dist/tmux-manager.d.ts +5 -0
- package/dist/tmux-manager.js +16 -0
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tool-permissions.js +4 -0
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.d.ts +5 -0
- package/dist/topic-commands.js +16 -0
- package/dist/topic-commands.js.map +1 -1
- package/dist/types.d.ts +25 -1
- package/dist/ui/app-effects.js +33 -9
- package/dist/ui/chat-effects.js +62 -4
- package/dist/ui/chat-strings.js +40 -4
- package/dist/ui/chat-thread.js +17 -6
- package/dist/ui/effect-trial-panel.js +19 -16
- package/dist/ui/fleet-canvas-layout.js +111 -0
- package/dist/ui/fleet-canvas.js +489 -0
- package/dist/ui/fleet-org.js +27 -12
- package/dist/ui/fleet-traffic.js +224 -0
- package/dist/ui/panel-chat.js +12 -5
- package/dist/ui/panel-details.js +2 -1
- package/dist/ui/panel-fleet.js +32 -11
- package/dist/ui/panel-settings.js +6 -4
- package/dist/ui/preview.js +3 -2
- package/dist/ui/settings-appearance.js +34 -18
- package/dist/ui/settings-catalog.js +89 -0
- package/dist/ui/settings-dialogs.js +40 -12
- package/dist/ui/settings-effects.js +4 -3
- package/dist/ui/settings-learn.js +25 -0
- package/dist/ui/settings-model.js +6 -2
- package/dist/ui/settings-mods.js +4 -3
- package/dist/ui/settings-owner-effects.js +8 -8
- package/dist/ui/settings-strings.js +40 -38
- package/dist/ui/shared/app-i18n.js +2 -2
- package/dist/ui/shared/app-prefs.js +1 -1
- package/dist/ui/shared/app-shell.js +23 -1
- package/dist/ui/shared/app-store.js +12 -1
- package/dist/ui/shared/app-stream.js +2 -1
- package/dist/ui/shared/app.css +186 -11
- package/dist/ui/shared/card-strings.js +4 -0
- package/dist/ui/shared/effect-app-runtime.js +1 -1
- package/dist/ui/shared/effect-frame.js +10 -5
- package/dist/ui/shared/effect-geometry.js +24 -9
- package/dist/ui/shared/effect-policy.js +2 -1
- package/dist/ui/shared/effect-registry.js +71 -17
- package/dist/ui/shared/effect-stock-trust.js +23 -2
- package/dist/ui/shared/effect-trial-shadow.js +62 -0
- package/dist/ui/shared/effect-trial-stage.js +15 -9
- package/dist/ui/shared/effect-trial.css +40 -0
- package/dist/ui/shared/identity-card.js +75 -4
- package/dist/ui/shared/identity-color.js +3 -3
- package/dist/ui/shared/instance-nav.js +20 -4
- package/dist/ui/shared/panel-view.js +24 -4
- package/dist/ui/shared/view-strings.js +22 -0
- package/dist/view-api.d.ts +32 -0
- package/dist/view-api.js +55 -4
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.d.ts +19 -4
- package/dist/web-api.js +178 -20
- package/dist/web-api.js.map +1 -1
- package/dist/web-appearance-policy.d.ts +4 -6
- package/dist/web-appearance-policy.js +5 -12
- package/dist/web-appearance-policy.js.map +1 -1
- package/dist/web-appearance-tools.js +5 -1
- package/dist/web-appearance-tools.js.map +1 -1
- package/dist/web-appearance-view.d.ts +14 -1
- package/dist/web-appearance-view.js +98 -10
- package/dist/web-appearance-view.js.map +1 -1
- package/dist/web-appearance.d.ts +2 -2
- package/dist/web-appearance.js +5 -6
- package/dist/web-appearance.js.map +1 -1
- package/dist/web-auth.js +1 -1
- package/dist/web-auth.js.map +1 -1
- package/dist/web-card.d.ts +14 -0
- package/dist/web-card.js +40 -6
- package/dist/web-card.js.map +1 -1
- package/dist/web-channel-echo.d.ts +7 -1
- package/dist/web-channel-echo.js +6 -2
- package/dist/web-channel-echo.js.map +1 -1
- package/dist/web-chat-history.d.ts +40 -0
- package/dist/web-chat-history.js +66 -3
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-commands.d.ts +1 -0
- package/dist/web-effect-tools.js +2 -3
- package/dist/web-effect-tools.js.map +1 -1
- package/dist/web-effect-trials.d.ts +2 -2
- package/dist/web-effect-trials.js +3 -3
- package/dist/web-effect-trials.js.map +1 -1
- package/dist/web-guest-access.d.ts +125 -0
- package/dist/web-guest-access.js +232 -0
- package/dist/web-guest-access.js.map +1 -0
- package/dist/web-guest-rooms.d.ts +82 -0
- package/dist/web-guest-rooms.js +135 -0
- package/dist/web-guest-rooms.js.map +1 -0
- package/dist/web-identity-palette.d.ts +1 -1
- package/dist/web-identity-palette.js +2 -2
- package/dist/web-identity-palette.js.map +1 -1
- package/dist/web-login.d.ts +47 -0
- package/dist/web-login.js +115 -0
- package/dist/web-login.js.map +1 -1
- package/dist/web-mod-agent.d.ts +1 -1
- package/dist/web-mod-agent.js +2 -4
- package/dist/web-mod-agent.js.map +1 -1
- package/dist/web-mod-store.d.ts +5 -0
- package/dist/web-mod-store.js +6 -0
- package/dist/web-mod-store.js.map +1 -1
- package/dist/web-prefs.d.ts +8 -0
- package/dist/web-prefs.js +9 -0
- package/dist/web-prefs.js.map +1 -1
- package/dist/web-preview.d.ts +6 -2
- package/dist/web-preview.js +4 -2
- package/dist/web-preview.js.map +1 -1
- package/dist/web-returning-device.d.ts +39 -0
- package/dist/web-returning-device.js +46 -5
- package/dist/web-returning-device.js.map +1 -1
- package/dist/web-session.d.ts +58 -10
- package/dist/web-session.js +104 -22
- package/dist/web-session.js.map +1 -1
- package/dist/web-settings-tools.d.ts +81 -0
- package/dist/web-settings-tools.js +257 -0
- package/dist/web-settings-tools.js.map +1 -0
- package/dist/web-theme.d.ts +2 -0
- package/dist/web-theme.js +10 -1
- package/dist/web-theme.js.map +1 -1
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -48,6 +48,7 @@ import { isProbeableRouteTarget } from "./fleet-context.js";
|
|
|
48
48
|
import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
|
|
49
49
|
import { EventLog } from "./event-log.js";
|
|
50
50
|
import { binaryProbe } from "./binary-probe.js";
|
|
51
|
+
import { AVAILABILITY_STALE_MS, BackendAvailabilityStore, hostAvailabilityProbes } from "./backend-availability.js";
|
|
51
52
|
import { classifySqliteOpenError } from "./sqlite-open-errors.js";
|
|
52
53
|
import { OutboxOpenError } from "./outbox-open-error.js";
|
|
53
54
|
import { AdapterWorld } from "./adapter-world.js";
|
|
@@ -65,7 +66,7 @@ import { createBackend } from "./backend/factory.js";
|
|
|
65
66
|
import { readEffortMetadata } from "./backend/effort-metadata.js";
|
|
66
67
|
import { codexEffort, flagEffort } from "./backend/launch-effort.js";
|
|
67
68
|
import { codexMetadataHomes, readCodexModelEffortLevels, resolveCodexLaunchModel } from "./backend/codex-metadata.js";
|
|
68
|
-
import { CLI_ENV_TTL_MS, isModelCompatible, resolveBinary, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
|
|
69
|
+
import { CLI_ENV_TTL_MS, isModelCompatible, noteResolvedBinary, resolveBinary, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
|
|
69
70
|
import { createLogger, rotateLogIfNeeded } from "./logger.js";
|
|
70
71
|
import { processAttachments } from "./channel/attachment-handler.js";
|
|
71
72
|
import { routeToolCall } from "./channel/tool-router.js";
|
|
@@ -134,9 +135,9 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
|
|
|
134
135
|
}
|
|
135
136
|
return tasks;
|
|
136
137
|
}
|
|
137
|
-
import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
|
|
138
|
+
import { handleWebRequest, broadcastSseEvent, broadcastToGuests, SSE_HEARTBEAT_MS } from "./web-api.js";
|
|
138
139
|
import { LogService } from "./log-service.js";
|
|
139
|
-
import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
|
|
140
|
+
import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId, webChatFrom } from "./web-chat-history.js";
|
|
140
141
|
import { WebChatDiskStore } from "./web-chat-store.js";
|
|
141
142
|
import { SeenUsers, resolveMentions } from "./web-mentions.js";
|
|
142
143
|
import { OPERATOR, WebPrefsStore } from "./web-prefs.js";
|
|
@@ -158,7 +159,7 @@ import { ReplyButtonsController } from "./reply-buttons-controller.js";
|
|
|
158
159
|
import { parsePoll } from "./reply-polls.js";
|
|
159
160
|
import { isUploadRequest, limitRequestReceive, publicAttachment, sweepOrphanedUploads, UPLOAD_REQUEST_TIMEOUT_MS, uploadLimits, WebFileLedger } from "./web-upload.js";
|
|
160
161
|
import { REPLY_FILES_MAX_BYTES, snapshotReplyFile, sweepReplySnapshots, webFilesDir } from "./web-reply-files.js";
|
|
161
|
-
import { cardPicture, handleViewRequest, humanCards, identityColors, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
|
|
162
|
+
import { cardPicture, handleViewRequest, humanCards, identityColors, isViewPath, personBannerFocus, personIdentityColour, profileIdentities, resolveInstanceIdentity, setPersonBannerFocus, setPersonIdentityColour } from "./view-api.js";
|
|
162
163
|
import { autoIdentityColor } from "./web-identity-color.js";
|
|
163
164
|
import { AppearanceTools } from "./web-appearance-tools.js";
|
|
164
165
|
import { cardVersion, resolveCard } from "./web-card.js";
|
|
@@ -178,7 +179,11 @@ import { runOnLoginShell } from "./login-shell.js";
|
|
|
178
179
|
import { tightenInstanceDirs } from "./private-dir.js";
|
|
179
180
|
import { decideSlash } from "./slash-authz.js";
|
|
180
181
|
import { canonicalCommand, commandSpec, decideCommand, ruleFor } from "./command-table.js";
|
|
181
|
-
import { runVisibilityCommand } from "./cross-instance-notice.js";
|
|
182
|
+
import { crossInstanceVisibility, runVisibilityCommand } from "./cross-instance-notice.js";
|
|
183
|
+
import { WebSettingsTools } from "./web-settings-tools.js";
|
|
184
|
+
import { bindGuestRequest, guestRouteDecision } from "./web-guest-access.js";
|
|
185
|
+
import { GUEST_ROOM_TICK_MS, GuestRoomMembership, guestGroupRooms } from "./web-guest-rooms.js";
|
|
186
|
+
import { TrafficLog, trafficItem } from "./fleet-traffic.js";
|
|
182
187
|
import { installedChannel, updateNoticeKey } from "./update-check.js";
|
|
183
188
|
import { resolveInstalledAgend } from "./update-dispatch.js";
|
|
184
189
|
import { resolveUpdateLaunch, openUpdateLog, updateLogPath, watchUpdateLaunch } from "./update-launch.js";
|
|
@@ -190,7 +195,7 @@ import { setLocale, detectLocale, getLocale, t } from "./locale.js";
|
|
|
190
195
|
import { recentChatContext } from "./log-tail.js";
|
|
191
196
|
import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet-control-audit.js";
|
|
192
197
|
import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
|
|
193
|
-
import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
|
|
198
|
+
import { ClassicChannelManager, classicWebEchoOn, getClassicBackendChoices, inferClassicChannelType, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
|
|
194
199
|
import { assertExplicitInstanceRemoval } from "./instance-removal.js";
|
|
195
200
|
import { KNOWN_BACKENDS, validateFleetConfig } from "./config-validator.js";
|
|
196
201
|
import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
|
|
@@ -209,6 +214,7 @@ import { NeedsYouHub } from "./needs-you-hub.js";
|
|
|
209
214
|
import { WEB_CHAT_NOTICE, claimNotice, hasWebChat, releaseNotice, upgradeNoticesPath } from "./upgrade-notices.js";
|
|
210
215
|
import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
|
|
211
216
|
import { buildOrgChart } from "./web-org.js";
|
|
217
|
+
import { topMemoryHogs } from "./memory-hogs.js";
|
|
212
218
|
import { GENERAL_POINTING_REFUSALS, SNOWFLAKE, hasPlaceRefs, placeRef, webRoute, webTextLinks, placeSuffix, renderPlaceRefs, stripPlaceRefs, topicUrl, worldPlaceOf } from "./topic-link.js";
|
|
213
219
|
import { CacheService, cacheWindow } from "./cache-service.js";
|
|
214
220
|
import { runWebCommand } from "./web-commands.js";
|
|
@@ -220,8 +226,8 @@ import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefu
|
|
|
220
226
|
import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
|
|
221
227
|
import { bypassesWebGate, handleAuthRequest, serveSigninPage } from "./auth-api.js";
|
|
222
228
|
import { isWebPageNavigation } from "./web-shell-routes.js";
|
|
223
|
-
import { localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
|
|
224
|
-
import { WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
|
|
229
|
+
import { guestSessionPolicy, localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
|
|
230
|
+
import { GuestLoginCodes, WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
|
|
225
231
|
import { CodeRequestLimiter, ReturningDevices } from "./web-returning-device.js";
|
|
226
232
|
import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
|
|
227
233
|
import { createPreviewListener, normalizeOrigin, previewAvailability, previewSettings } from "./web-preview.js";
|
|
@@ -234,7 +240,7 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
|
|
|
234
240
|
/** A self-restart is a whole service restart; 120s is the apply budget, not this. */
|
|
235
241
|
const SELF_RESTART_DEADLINE_MS = 300_000;
|
|
236
242
|
import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
|
|
237
|
-
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
243
|
+
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileLogin, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
238
244
|
import { carryClaudeConversation } from "./backend/claude-carry.js";
|
|
239
245
|
import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
|
|
240
246
|
import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
|
|
@@ -504,6 +510,8 @@ function emojiListFilter(opts) {
|
|
|
504
510
|
withImageUrls: flag(opts.with_image_urls),
|
|
505
511
|
};
|
|
506
512
|
}
|
|
513
|
+
/** #1920: a guest's scope until phase 2 derives one: nothing. */
|
|
514
|
+
const NO_GUEST_SCOPE = new Set();
|
|
507
515
|
export class FleetManager {
|
|
508
516
|
dataDir;
|
|
509
517
|
static signalTarget = null;
|
|
@@ -774,6 +782,8 @@ export class FleetManager {
|
|
|
774
782
|
/** #1306: the preview listener (health_port + 1 by default), and whether it is listening. */
|
|
775
783
|
previewListener = null;
|
|
776
784
|
previewListening = false;
|
|
785
|
+
/** The preview listener could not open its port: "<port>: <error code>", for the page (#2061 follow-up); null otherwise. */
|
|
786
|
+
previewListenError = null;
|
|
777
787
|
/** The ports the preview listener was started for, and the inputs it was built from (a reload compares them). */
|
|
778
788
|
previewPorts = null;
|
|
779
789
|
previewInputs = "";
|
|
@@ -792,6 +802,9 @@ export class FleetManager {
|
|
|
792
802
|
/** Injectable only to keep the chat→CLI reload hand-off deterministic in tests. */
|
|
793
803
|
fullRestartLauncher = launchFullRestartHelper;
|
|
794
804
|
eventLogPruneTimer = null;
|
|
805
|
+
/** #1920 §3.2: the background reads of guests' ClassicBot room membership (a minute's tick; reads only what is due). */
|
|
806
|
+
guestRoomsTimer = null;
|
|
807
|
+
guestRoomMembership = null;
|
|
795
808
|
outboxPruneTimer = null;
|
|
796
809
|
logRotateTimer = null;
|
|
797
810
|
discordPresenceTimer = null;
|
|
@@ -806,6 +819,17 @@ export class FleetManager {
|
|
|
806
819
|
startedAt = 0;
|
|
807
820
|
stormOpenNotifyTimer = null;
|
|
808
821
|
memoryLogAt = null;
|
|
822
|
+
/**
|
|
823
|
+
* #2015: while host memory is elevated or critical, the processes holding the most in the fleet's service and whose
|
|
824
|
+
* they are (an instance, the fleet, tmux), for the pressure log, /health and the dashboard — never chat (#1712).
|
|
825
|
+
* Rescanned at most once a minute (async, one at a time); cleared when pressure ends.
|
|
826
|
+
*/
|
|
827
|
+
memoryTop = null;
|
|
828
|
+
memoryTopScan = { at: null, inFlight: false, loggedAt: null };
|
|
829
|
+
/** The scan (a seam for tests: they give it a fake /proc). */
|
|
830
|
+
memoryHogScan = topMemoryHogs;
|
|
831
|
+
/** The fleet's own tmux session exists (only then are its panes read: a fleet that never started has none). */
|
|
832
|
+
tmuxSessionEnsured = false;
|
|
809
833
|
memoryDebugAt = null;
|
|
810
834
|
memoryLogLevel = null;
|
|
811
835
|
/** #1685: the host_memory value the last status frame carried, so a change re-sends the frame. */
|
|
@@ -1079,6 +1103,29 @@ export class FleetManager {
|
|
|
1079
1103
|
get ownerCss() {
|
|
1080
1104
|
return (this.ownerCssStore ??= new OwnerCssStore(this.dataDir, this.webPrefs));
|
|
1081
1105
|
}
|
|
1106
|
+
webSettingsToolsService;
|
|
1107
|
+
/**
|
|
1108
|
+
* #2056: Settings → Web appearance for a tool (General, a coordinator, full) — the page's own stores and checks, and the
|
|
1109
|
+
* page's announcement after a change (web-settings-tools.ts).
|
|
1110
|
+
*/
|
|
1111
|
+
get webSettings() {
|
|
1112
|
+
return (this.webSettingsToolsService ??= new WebSettingsTools({
|
|
1113
|
+
prefs: this.webPrefs, ownerCss: this.ownerCss, mods: this.mods, effects: this.effectService,
|
|
1114
|
+
instances: () => [...Object.keys(this.fleetConfig?.instances ?? {}),
|
|
1115
|
+
...(this.classicChannels?.getAll() ?? []).map((ch) => ch.instanceName).filter((n) => !Object.hasOwn(this.fleetConfig?.instances ?? {}, n))],
|
|
1116
|
+
colour: (name) => personIdentityColour(this.dataDir, name),
|
|
1117
|
+
setColour: (name, patch) => { const w = setPersonIdentityColour(this.dataDir, name, patch); if (w.ok)
|
|
1118
|
+
this.identityChanged(); return w; },
|
|
1119
|
+
bannerFocus: (name) => personBannerFocus(this.dataDir, name),
|
|
1120
|
+
setBannerFocus: (name, focus) => { const w = setPersonBannerFocus(this.dataDir, name, focus); if (w.ok)
|
|
1121
|
+
this.identityChanged(); return w; },
|
|
1122
|
+
placements: (name) => this.themeStore.get(name).placements.effects.map((e) => (typeof e === "string" ? { id: e } : e)),
|
|
1123
|
+
announce: () => { try {
|
|
1124
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
1125
|
+
}
|
|
1126
|
+
catch { /* the next status frame carries it */ } },
|
|
1127
|
+
}));
|
|
1128
|
+
}
|
|
1082
1129
|
get webPrefs() {
|
|
1083
1130
|
// #1664: a favorite must be an instance of this fleet now — fleet.yaml's (own keys) or a ClassicBot room's.
|
|
1084
1131
|
return (this.webPrefsStore ??= new WebPrefsStore(this.dataDir, {
|
|
@@ -1349,9 +1396,26 @@ export class FleetManager {
|
|
|
1349
1396
|
sseHeartbeatMs = SSE_HEARTBEAT_MS;
|
|
1350
1397
|
/** The dashboard's login codes. Memory only: a code that outlives the process is a code nobody can prove was not copied. */
|
|
1351
1398
|
webLoginCodes = null;
|
|
1399
|
+
/**
|
|
1400
|
+
* #1920 step 4: guests' codes, apart from the admins' — one per account, so no guest's `/web` withdraws another's or
|
|
1401
|
+
* an admin's code (#2093 review), at most 32 at once. Local sign-in only for now (audience "local").
|
|
1402
|
+
*/
|
|
1403
|
+
webGuestLoginCodes = null;
|
|
1404
|
+
/** #1920 step 4: when each account last got a guest code (monotonic) — one a minute, so `/web` is no DM flood. */
|
|
1405
|
+
guestCodeAskedAt = new Map();
|
|
1406
|
+
/** #2093 review: an account's guest /web in progress — one at a time, so two at once cannot both pass the minute. */
|
|
1407
|
+
guestCodeInFlight = new Set();
|
|
1408
|
+
/**
|
|
1409
|
+
* #2093 review: revokes a guest /web in progress must see — every web session's (`/web revoke` from an admin) bumps
|
|
1410
|
+
* the global count, an account's own bumps its entry — checked before the code is issued and while it is delivered.
|
|
1411
|
+
*/
|
|
1412
|
+
guestRevokes = 0;
|
|
1413
|
+
guestAccountRevokes = new Map();
|
|
1352
1414
|
/** #1570: browsers that may ask the sign-in page for a fresh code, and how often they may. */
|
|
1353
1415
|
returningDevices = null;
|
|
1354
1416
|
codeRequestLimiter = new CodeRequestLimiter();
|
|
1417
|
+
/** #1920 step 4b: guests' device code requests, limited apart — guests never use up the admins' fleet-wide budget. */
|
|
1418
|
+
guestCodeRequestLimiter = new CodeRequestLimiter();
|
|
1355
1419
|
/**
|
|
1356
1420
|
* Set while a Settings apply job is driving the reconcile. The reconcile
|
|
1357
1421
|
* stays the single doer; it just says out loud what it is doing to whom, so
|
|
@@ -1499,6 +1563,7 @@ export class FleetManager {
|
|
|
1499
1563
|
meta: {
|
|
1500
1564
|
chat_id: set.chatId, message_id: messageId, user: by.username, user_id: by.userId, ts,
|
|
1501
1565
|
thread_id: set.threadId, adapter_id: inbound.adapterId, source: inbound.source,
|
|
1566
|
+
...(web && by.guest ? { web_guest: "1" } : {}), // #1920: "[user:… via web (guest), id:…]", as /ui/send
|
|
1502
1567
|
...reactions.meta,
|
|
1503
1568
|
},
|
|
1504
1569
|
}, by.humanOrigin, button.label)) !== false;
|
|
@@ -1519,6 +1584,9 @@ export class FleetManager {
|
|
|
1519
1584
|
this.emitSseEvent("message", {
|
|
1520
1585
|
instance: set.instance, sender: by.username, role: "user", text: content, ts, ...(web ? { messageId } : {}),
|
|
1521
1586
|
click: { label: button.label, of: set.id },
|
|
1587
|
+
// #1920: a platform click is its clicker's; it is the instance's channel's only when its buttons are there (the
|
|
1588
|
+
// same rule as a reply's: a set sent to another topic or a DM is not). A web click is the web's.
|
|
1589
|
+
...(web ? {} : { from: webChatFrom(set.adapterId, by.userId), ...(this.clickInOwnChannel(set) ? { channel: true } : {}) }),
|
|
1522
1590
|
});
|
|
1523
1591
|
return true;
|
|
1524
1592
|
}
|
|
@@ -1573,6 +1641,18 @@ export class FleetManager {
|
|
|
1573
1641
|
}
|
|
1574
1642
|
}
|
|
1575
1643
|
});
|
|
1644
|
+
// #1918: the org canvas's traffic — from the row this listener's events already name, never a probe of its own.
|
|
1645
|
+
// The row as it is now (it has already changed when the event comes): its state, not the event's, is the truth.
|
|
1646
|
+
outbox.on("state", (event) => {
|
|
1647
|
+
const row = event.deliveryId ? outbox.get(event.deliveryId) : undefined;
|
|
1648
|
+
if (row)
|
|
1649
|
+
this.publishTraffic(row);
|
|
1650
|
+
});
|
|
1651
|
+
outbox.on("consumed", (event) => {
|
|
1652
|
+
const row = event.deliveryId ? outbox.get(event.deliveryId) : undefined;
|
|
1653
|
+
if (row)
|
|
1654
|
+
this.publishTraffic(row, "consumed");
|
|
1655
|
+
});
|
|
1576
1656
|
outbox.on("generation_recovered", () => this.scheduleDeliveryOutboxPump());
|
|
1577
1657
|
this.replyObligationTimer = setInterval(() => measureSyncWork("fleet.replyObligationSweep", () => this.sweepReplyObligations()), REPLY_OBLIGATION_SWEEP_MS);
|
|
1578
1658
|
this.replyObligationTimer.unref?.();
|
|
@@ -1594,6 +1674,40 @@ export class FleetManager {
|
|
|
1594
1674
|
});
|
|
1595
1675
|
this.scheduleDeliveryOutboxPump();
|
|
1596
1676
|
}
|
|
1677
|
+
/** #1918: cross-instance traffic, as the org canvas draws it (fleet-traffic.ts). */
|
|
1678
|
+
trafficLog = new TrafficLog();
|
|
1679
|
+
trafficMemo = null;
|
|
1680
|
+
/** One delivery row's traffic item, kept for the snapshot and sent to the open pages when it is news. */
|
|
1681
|
+
publishTraffic(row, state) {
|
|
1682
|
+
try {
|
|
1683
|
+
const item = trafficItem(row, this.fleetConfig, new Date().toISOString(), state);
|
|
1684
|
+
const news = item ? this.trafficLog.note(item) : null;
|
|
1685
|
+
if (news) {
|
|
1686
|
+
this.trafficMemo = null;
|
|
1687
|
+
this.emitSseEvent("traffic", news);
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
catch (err) {
|
|
1691
|
+
this.logger.debug({ err }, "traffic: an item was not published");
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* GET /ui/traffic: what is in flight now (the outbox's pending rows) and what finished in the last two minutes, at
|
|
1696
|
+
* most TRAFFIC_SNAPSHOT_MAX items. Worked out at most once every two seconds, so the public link's polling (or any
|
|
1697
|
+
* number of pages) costs one indexed read per two seconds. Visibility is applied as of now.
|
|
1698
|
+
*/
|
|
1699
|
+
trafficSnapshot() {
|
|
1700
|
+
const nowMs = performance.now();
|
|
1701
|
+
if (this.trafficMemo && nowMs - this.trafficMemo.atMs < 2_000)
|
|
1702
|
+
return this.trafficMemo.value;
|
|
1703
|
+
const now = new Date().toISOString();
|
|
1704
|
+
const pending = (this.deliveryOutbox?.listPending() ?? [])
|
|
1705
|
+
.map((r) => trafficItem(r, this.fleetConfig, r.createdAt ?? now)).filter((i) => i !== null);
|
|
1706
|
+
const visible = (name) => crossInstanceVisibility(this.fleetConfig, name) !== "hidden";
|
|
1707
|
+
const items = this.trafficLog.snapshot(pending).filter((i) => visible(i.from) && visible(i.to));
|
|
1708
|
+
this.trafficMemo = { atMs: nowMs, value: { now, items } };
|
|
1709
|
+
return this.trafficMemo.value;
|
|
1710
|
+
}
|
|
1597
1711
|
/** Synchronous SQLite admission; the caller may acknowledge only after this returns. */
|
|
1598
1712
|
admitDurableDelivery(input) {
|
|
1599
1713
|
const outbox = this.deliveryOutbox;
|
|
@@ -1606,6 +1720,8 @@ export class FleetManager {
|
|
|
1606
1720
|
}
|
|
1607
1721
|
const sourceKey = `mcp:${input.sourceInstance}:${input.operationId}:${input.targetInstance}:${input.targetSession ?? ""}:${input.kind}`;
|
|
1608
1722
|
const admitted = outbox.admit({ ...input, sourceDaemonBootId, sourceKey });
|
|
1723
|
+
if (admitted.inserted)
|
|
1724
|
+
this.publishTraffic(admitted.delivery);
|
|
1609
1725
|
if (admitted.inserted)
|
|
1610
1726
|
this.logger.info({
|
|
1611
1727
|
deliveryId: admitted.delivery.deliveryId,
|
|
@@ -1979,17 +2095,58 @@ export class FleetManager {
|
|
|
1979
2095
|
this.logger.info({ hostMemory: snapshot }, "Host memory recovered");
|
|
1980
2096
|
}
|
|
1981
2097
|
this.memoryLogLevel = snapshot.level;
|
|
2098
|
+
if (this.memoryTop) {
|
|
2099
|
+
this.memoryTop = null;
|
|
2100
|
+
this.memoryTopScan.loggedAt = null;
|
|
2101
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
2102
|
+
}
|
|
1982
2103
|
return;
|
|
1983
2104
|
}
|
|
2105
|
+
if (snapshot.level === "elevated" || snapshot.level === "critical")
|
|
2106
|
+
this.refreshMemoryTop(snapshot);
|
|
1984
2107
|
if (changed || this.memoryLogAt === null || now - this.memoryLogAt >= 10 * 60_000) {
|
|
1985
2108
|
this.memoryLogAt = now;
|
|
1986
|
-
this.logger.warn({ hostMemory: snapshot }, snapshot.level === "critical"
|
|
2109
|
+
this.logger.warn({ hostMemory: snapshot, ...(this.memoryTop ? { topProcesses: this.memoryTop.processes } : {}) }, snapshot.level === "critical"
|
|
1987
2110
|
? "Host memory critical — deferring new CLI spawns"
|
|
1988
2111
|
: snapshot.level === "elevated" ? "Host memory pressure — slowing new CLI spawns"
|
|
1989
2112
|
: "Host memory sample unavailable — slowing new CLI spawns");
|
|
1990
2113
|
}
|
|
1991
2114
|
this.memoryLogLevel = snapshot.level;
|
|
1992
2115
|
}
|
|
2116
|
+
/**
|
|
2117
|
+
* #2015: rescan the largest processes (at most once a minute, one at a time). The first result of an episode, and
|
|
2118
|
+
* then at most every 10 minutes, is logged with the pressure; /health and the dashboard read the latest. Never chat.
|
|
2119
|
+
*/
|
|
2120
|
+
refreshMemoryTop(snapshot) {
|
|
2121
|
+
const scan = this.memoryTopScan;
|
|
2122
|
+
const now = performance.now();
|
|
2123
|
+
if (scan.inFlight || (scan.at !== null && now - scan.at < 60_000))
|
|
2124
|
+
return;
|
|
2125
|
+
scan.inFlight = true;
|
|
2126
|
+
scan.at = now;
|
|
2127
|
+
void (async () => {
|
|
2128
|
+
const session = getTmuxSession();
|
|
2129
|
+
const [tmuxServerPid, panes] = this.tmuxSessionEnsured
|
|
2130
|
+
? await Promise.all([TmuxManager.getServerPid(session), TmuxManager.listPanePids(session)])
|
|
2131
|
+
: [null, []];
|
|
2132
|
+
const processes = await this.memoryHogScan({
|
|
2133
|
+
fleetPid: process.pid, tmuxServerPid, panes,
|
|
2134
|
+
isInstance: name => this.daemons.has(name) || Object.hasOwn(this.fleetConfig?.instances ?? {}, name),
|
|
2135
|
+
}, 3);
|
|
2136
|
+
// Pressure may have ended while the scan ran: nothing is kept then.
|
|
2137
|
+
if (this.shuttingDown || processes.length === 0 || this.hostMemoryIndicator() === null)
|
|
2138
|
+
return;
|
|
2139
|
+
const before = JSON.stringify(this.memoryTop?.processes ?? null);
|
|
2140
|
+
this.memoryTop = { at: new Date().toISOString(), processes };
|
|
2141
|
+
if (scan.loggedAt === null || performance.now() - scan.loggedAt >= 10 * 60_000) {
|
|
2142
|
+
scan.loggedAt = performance.now();
|
|
2143
|
+
this.logger.warn({ hostMemory: snapshot, topProcesses: processes }, "Host memory pressure — largest processes in the fleet");
|
|
2144
|
+
}
|
|
2145
|
+
if (JSON.stringify(processes) !== before)
|
|
2146
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
2147
|
+
})().catch(err => this.logger.debug({ err }, "Largest-process scan failed"))
|
|
2148
|
+
.finally(() => { scan.inFlight = false; });
|
|
2149
|
+
}
|
|
1993
2150
|
/** Wire the one fleet-wide storm into notification and recovery surfaces. */
|
|
1994
2151
|
bindStormWindowEvents() {
|
|
1995
2152
|
this.stormWindow.on("opened", (snapshot) => {
|
|
@@ -2311,6 +2468,7 @@ export class FleetManager {
|
|
|
2311
2468
|
this.fleetConfig = loaded;
|
|
2312
2469
|
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
2313
2470
|
this.warnAboutRemovedLoginMode(loaded);
|
|
2471
|
+
this.enforceGuestSwitch();
|
|
2314
2472
|
return this.fleetConfig;
|
|
2315
2473
|
}
|
|
2316
2474
|
relayLoginModeWarned = false;
|
|
@@ -3436,7 +3594,8 @@ export class FleetManager {
|
|
|
3436
3594
|
* for `instance` (#1320 part B). Per entry: the channel's own adapter, no
|
|
3437
3595
|
* thread, mention suppression on. Returns the number of channels posted
|
|
3438
3596
|
* to. Failures are per-entry warn-and-continue — a failed echo never fails
|
|
3439
|
-
* the web send.
|
|
3597
|
+
* the web send. Only entries classicWebEchoOn admits are touched (#2054: unset follows web.echo_to_channel; an explicit
|
|
3598
|
+
* `web_echo: false` never echoes).
|
|
3440
3599
|
*
|
|
3441
3600
|
* Every copy is fenced at start time: an entry whose explicit world is
|
|
3442
3601
|
* gone is skipped (only legacy adapterId-less entries fall back to the
|
|
@@ -3447,6 +3606,15 @@ export class FleetManager {
|
|
|
3447
3606
|
* already in flight may still land late — the contract permits that.
|
|
3448
3607
|
*/
|
|
3449
3608
|
/** `sender` (#1827): the web person's name, as the room log records them ("web-user" when none). */
|
|
3609
|
+
/**
|
|
3610
|
+
* #1920 §5.2: a web message's channel copy was sent — it is now what the channel saw. Kept so (and on disk), and sent
|
|
3611
|
+
* to the guests' streams only: an admin's page already has it, and nothing about it changed for them.
|
|
3612
|
+
*/
|
|
3613
|
+
webMessageInChannel(instance, messageId) {
|
|
3614
|
+
const m = this.webChatHistory.markChannel(instance, messageId);
|
|
3615
|
+
if (m)
|
|
3616
|
+
broadcastToGuests(this.sseClients, "message", m, (err) => this.logger.debug({ err }, "SSE client write failed; evicting"));
|
|
3617
|
+
}
|
|
3450
3618
|
async sendClassicWebEcho(instance, text, sender = WEB_NAME_FALLBACK) {
|
|
3451
3619
|
const guard = this.webChannelEchoGuards.get(instance);
|
|
3452
3620
|
const handled = new Set();
|
|
@@ -3463,7 +3631,7 @@ export class FleetManager {
|
|
|
3463
3631
|
}
|
|
3464
3632
|
}
|
|
3465
3633
|
const entry = (this.classicChannels?.getAll() ?? [])
|
|
3466
|
-
.find(candidate => candidate.instanceName === instance && candidate.webEcho
|
|
3634
|
+
.find(candidate => candidate.instanceName === instance && classicWebEchoOn(candidate.webEcho, this.fleetConfig?.web?.echo_to_channel)
|
|
3467
3635
|
&& !handled.has(`${candidate.channelId}#${candidate.adapterId ?? ""}`));
|
|
3468
3636
|
if (!entry)
|
|
3469
3637
|
break;
|
|
@@ -3882,6 +4050,14 @@ export class FleetManager {
|
|
|
3882
4050
|
return;
|
|
3883
4051
|
}
|
|
3884
4052
|
const scope = door.scope;
|
|
4053
|
+
// #1920 step 4: a non-admin's /web while guest sign-in is on is theirs to sign in as a guest (or revoke their own)
|
|
4054
|
+
// — before the table, which keeps /web for fleet admins. Per account: it touches no instance binding.
|
|
4055
|
+
// Decided synchronously, so an admin's /web still reaches the table with no await after the door (#1399).
|
|
4056
|
+
if (data.command === "dashboard" && this.isGuestWebAsk(adapterId, data.userId)) {
|
|
4057
|
+
await this.guestWebCommand({ adapterId, userId: data.userId, username: data.username,
|
|
4058
|
+
arg: String(data.options?.action ?? "").trim().toLowerCase(), replyHere: text => data.respond(text), replyPrivately: text => data.respond(text) });
|
|
4059
|
+
return;
|
|
4060
|
+
}
|
|
3885
4061
|
const spec = commandSpec(data.command);
|
|
3886
4062
|
if (!spec) {
|
|
3887
4063
|
this.logger.info({ command: data.command, adapterId }, "Slash command is not in the command table");
|
|
@@ -5326,10 +5502,14 @@ export class FleetManager {
|
|
|
5326
5502
|
// #1620: fleet.yaml's web.session.local, read at every check — a Settings save or a reload applies at once
|
|
5327
5503
|
// (shortening ends the sessions it now outlives). The public link's policy stays fixed.
|
|
5328
5504
|
localPolicy: () => localSessionPolicy(this.fleetConfig?.web?.session?.local),
|
|
5505
|
+
// #1920 §5.4: a guest's, from web.guests.session (never longer than the surface's own).
|
|
5506
|
+
guestPolicy: () => guestSessionPolicy(this.fleetConfig?.web?.guests?.session),
|
|
5329
5507
|
});
|
|
5508
|
+
this.enforceGuestSwitch(); // #1920: guests off while the fleet was down
|
|
5330
5509
|
}
|
|
5331
5510
|
if (!this.returningDevices) {
|
|
5332
5511
|
this.returningDevices = new ReturningDevices({ dataDir: this.dataDir, onWarn: message => this.logger.warn(message) });
|
|
5512
|
+
this.enforceGuestSwitch(); // #1920: guests' devices too, if guests went off while down
|
|
5333
5513
|
}
|
|
5334
5514
|
if (!this.webLoginCodes) {
|
|
5335
5515
|
this.webLoginCodes = new WebLoginCodes({
|
|
@@ -5341,6 +5521,16 @@ export class FleetManager {
|
|
|
5341
5521
|
},
|
|
5342
5522
|
});
|
|
5343
5523
|
}
|
|
5524
|
+
if (!this.webGuestLoginCodes) {
|
|
5525
|
+
this.webGuestLoginCodes = new GuestLoginCodes({
|
|
5526
|
+
onEvent: event => {
|
|
5527
|
+
if (event === "burned")
|
|
5528
|
+
this.logger.warn("A guest web login code was used up by wrong attempts");
|
|
5529
|
+
else if (event === "breaker-open")
|
|
5530
|
+
this.logger.warn("Guest web sign-in paused: too many wrong login codes");
|
|
5531
|
+
},
|
|
5532
|
+
});
|
|
5533
|
+
}
|
|
5344
5534
|
}
|
|
5345
5535
|
/**
|
|
5346
5536
|
* A single-use login code for the dashboard, for a channel only the operator
|
|
@@ -5464,6 +5654,7 @@ export class FleetManager {
|
|
|
5464
5654
|
dispatch: (req, res) => this.dispatchWebHttp(req, res, this.fleetConfig?.health_port ?? 19280) }),
|
|
5465
5655
|
revoke: id => {
|
|
5466
5656
|
this.webLoginCodes?.revokeAudience(id);
|
|
5657
|
+
this.webGuestLoginCodes?.revokeAudience(id);
|
|
5467
5658
|
const result = this.webSessions?.revokeExposure(id);
|
|
5468
5659
|
if (result && !result.durable)
|
|
5469
5660
|
this.logger.warn("Public session revocation could not be saved; exposure remains closed");
|
|
@@ -5495,6 +5686,24 @@ export class FleetManager {
|
|
|
5495
5686
|
if (!current() || !this.publicOwnerCurrent(info.owner))
|
|
5496
5687
|
throw new Error("public owner changed");
|
|
5497
5688
|
}
|
|
5689
|
+
/**
|
|
5690
|
+
* #1920 §5.5 (step 4b): a guest signing in through the public link — said in the General of the admin who opened it
|
|
5691
|
+
* (who, how many agents), awaited as an admin's public sign-in is, while that link is the open one, its opener still
|
|
5692
|
+
* current, and the account still an admitted guest. A failure refuses the sign-in.
|
|
5693
|
+
*/
|
|
5694
|
+
async confirmGuestPublicWebLogin(info, current) {
|
|
5695
|
+
const admitted = () => {
|
|
5696
|
+
const open = this.publicWebLink?.openSignin();
|
|
5697
|
+
return current() && open && open.exposureId === info.exposureId && this.publicOwnerCurrent(open.owner) && this.guestSignInAllowed(info.account) ? open : null;
|
|
5698
|
+
};
|
|
5699
|
+
const open = admitted();
|
|
5700
|
+
if (!open)
|
|
5701
|
+
throw new Error("guest public sign-in unavailable");
|
|
5702
|
+
const adapter = open.owner.binding;
|
|
5703
|
+
await adapter.sendText(open.owner.chatId, t("web.guest_public_login_notice", this.webPerson(info.account).name, info.label, this.guestScope(info.account).size, info.handle.slice(0, 8)), { threadId: open.owner.threadId, allowedMentions: { parse: [] } });
|
|
5704
|
+
if (!admitted())
|
|
5705
|
+
throw new Error("guest public sign-in changed");
|
|
5706
|
+
}
|
|
5498
5707
|
/** Telegram's typed command enters through the same strict owner and nonce path as Discord. */
|
|
5499
5708
|
async dashboardMenu(msg) {
|
|
5500
5709
|
const id = msg.adapterId;
|
|
@@ -5818,6 +6027,8 @@ export class FleetManager {
|
|
|
5818
6027
|
this.initializeWebSessions();
|
|
5819
6028
|
void this.publicWebLink?.close("dashboard revoke");
|
|
5820
6029
|
this.webLoginCodes.revoke();
|
|
6030
|
+
this.webGuestLoginCodes.revokeAll();
|
|
6031
|
+
this.guestRevokes++; // and every guest /web still on its way
|
|
5821
6032
|
// #1570: no browser may ask the sign-in page for a code any more.
|
|
5822
6033
|
const devices = this.returningDevices.revokeAll();
|
|
5823
6034
|
const sessions = this.webSessions.revokeAll();
|
|
@@ -5828,6 +6039,145 @@ export class FleetManager {
|
|
|
5828
6039
|
this.logger.warn({ count: result.count }, "Web sessions revoked in memory only — the session file could not be updated or removed");
|
|
5829
6040
|
return result;
|
|
5830
6041
|
}
|
|
6042
|
+
/** #1920 step 4: whether a `/web` is a guest's — guest sign-in on, and not from a fleet admin (theirs runs as before). */
|
|
6043
|
+
isGuestWebAsk(adapterId, userId) {
|
|
6044
|
+
return !!adapterId && this.fleetConfig?.web?.guests?.enabled === true && !this.isFleetAdmin(userId, adapterId);
|
|
6045
|
+
}
|
|
6046
|
+
/** #1920 step 4 (auth-api): a guest code's account may still sign in — guests on, and something in its scope now. */
|
|
6047
|
+
guestSignInAllowed(account) {
|
|
6048
|
+
return this.fleetConfig?.web?.guests?.enabled === true && !this.isFleetAdmin(account.userId, account.adapterId) && this.guestScope(account).size > 0;
|
|
6049
|
+
}
|
|
6050
|
+
/**
|
|
6051
|
+
* #1920 §5.4: a guest's own `/web revoke` — its sessions and its outstanding code; nobody else's (an admin's revokes
|
|
6052
|
+
* everyone's, as before).
|
|
6053
|
+
*/
|
|
6054
|
+
revokeGuestWebSessions(owner) {
|
|
6055
|
+
this.initializeWebSessions();
|
|
6056
|
+
const key = `${owner.adapterId}\u0000${owner.userId}`;
|
|
6057
|
+
this.guestAccountRevokes.set(key, (this.guestAccountRevokes.get(key) ?? 0) + 1); // its /web on its way, too
|
|
6058
|
+
while (this.guestAccountRevokes.size > 4096)
|
|
6059
|
+
this.guestAccountRevokes.delete(this.guestAccountRevokes.keys().next().value);
|
|
6060
|
+
this.webGuestLoginCodes.revokeRecipient(owner);
|
|
6061
|
+
// §5.4: its sessions and its returning devices (no browser of theirs may ask for a code any more).
|
|
6062
|
+
const devices = this.returningDevices.revokeGuest(owner);
|
|
6063
|
+
const ended = this.webSessions.revokeAll(r => r.tier === "guest" && r.owner?.adapterId === owner.adapterId && r.owner.userId === owner.userId);
|
|
6064
|
+
this.logger.info({ adapterId: owner.adapterId, count: ended.count, devices: devices.count, durable: ended.durable && devices.durable }, "Guest web sessions revoked by their account");
|
|
6065
|
+
return { count: ended.count, durable: ended.durable && devices.durable };
|
|
6066
|
+
}
|
|
6067
|
+
/**
|
|
6068
|
+
* #1920 §2.3 (step 4): `/web` from someone who is not a fleet admin, while guest sign-in is on. `revoke` ends their
|
|
6069
|
+
* own guest sessions. Otherwise a guest code (tier guest, bound to this account) goes privately — a DM, else the
|
|
6070
|
+
* slash command's ephemeral answer (`replyPrivately`) — once a minute at most, and only when their scope (from what
|
|
6071
|
+
* they may already use in chat, rooms read at sign-in) holds something. The code itself is never posted where asked.
|
|
6072
|
+
* Callers ask isGuestWebAsk first (synchronously); false here too when it is not a guest's.
|
|
6073
|
+
*/
|
|
6074
|
+
async guestWebCommand(ask) {
|
|
6075
|
+
if (!this.isGuestWebAsk(ask.adapterId, ask.userId))
|
|
6076
|
+
return false;
|
|
6077
|
+
const owner = { adapterId: ask.adapterId, userId: ask.userId };
|
|
6078
|
+
const say = (text) => ask.replyHere(text).catch(() => undefined);
|
|
6079
|
+
if (ask.arg === "revoke") {
|
|
6080
|
+
const ended = this.revokeGuestWebSessions(owner);
|
|
6081
|
+
await say(t(ended.durable ? "web.guest_revoked" : "web.guest_revoked_not_durable", ended.count));
|
|
6082
|
+
return true;
|
|
6083
|
+
}
|
|
6084
|
+
const token = this.webToken;
|
|
6085
|
+
const adapter = this.adapters.get(ask.adapterId);
|
|
6086
|
+
if (this.shuttingDown || !token || !adapter) {
|
|
6087
|
+
await say(t("dashboard.starting"));
|
|
6088
|
+
return true;
|
|
6089
|
+
}
|
|
6090
|
+
const key = `${owner.adapterId}\u0000${owner.userId}`, asked = this.guestCodeAskedAt.get(key);
|
|
6091
|
+
if (this.guestCodeInFlight.has(key) || (asked !== undefined && performance.now() - asked < 60_000)) {
|
|
6092
|
+
await say(t("web.guest_wait"));
|
|
6093
|
+
return true;
|
|
6094
|
+
}
|
|
6095
|
+
this.guestCodeInFlight.add(key);
|
|
6096
|
+
try {
|
|
6097
|
+
await this.issueGuestCode(ask, owner, key, adapter, token);
|
|
6098
|
+
}
|
|
6099
|
+
finally {
|
|
6100
|
+
this.guestCodeInFlight.delete(key);
|
|
6101
|
+
}
|
|
6102
|
+
return true;
|
|
6103
|
+
}
|
|
6104
|
+
/** guestWebCommand's issuance, one per account at a time; a revoke made meanwhile (either kind) ends it unissued. */
|
|
6105
|
+
async issueGuestCode(ask, owner, key, adapter, token) {
|
|
6106
|
+
const say = (text) => ask.replyHere(text).catch(() => undefined);
|
|
6107
|
+
const revokes = this.guestRevokes, accountRevokes = this.guestAccountRevokes.get(key) ?? 0;
|
|
6108
|
+
const unrevoked = () => this.guestRevokes === revokes && (this.guestAccountRevokes.get(key) ?? 0) === accountRevokes;
|
|
6109
|
+
// §3.2: its rooms are read at sign-in (bounded; never longer than the reads' own deadline).
|
|
6110
|
+
await withinBudget(this.refreshGuestRooms(owner, { mode: "force" }), performance.now() + 6_000).catch(() => undefined);
|
|
6111
|
+
if (!unrevoked())
|
|
6112
|
+
return; // revoked while its rooms were read: no code
|
|
6113
|
+
const scope = this.guestScope(owner);
|
|
6114
|
+
if (!scope.size) {
|
|
6115
|
+
const open = this.worlds.get(owner.adapterId)?.accessManager.getMode() === "open";
|
|
6116
|
+
await say(t(open ? "web.guest_not_set_up" : "web.guest_nothing"));
|
|
6117
|
+
return;
|
|
6118
|
+
}
|
|
6119
|
+
this.initializeWebSessions();
|
|
6120
|
+
const codes = this.webGuestLoginCodes;
|
|
6121
|
+
if (codes.pausedForMs() > 0) {
|
|
6122
|
+
await say(t("web.guest_paused"));
|
|
6123
|
+
return;
|
|
6124
|
+
}
|
|
6125
|
+
// §5.5 (step 4b): with the public link open, the code works through it too (an admin opened it; a guest is still a
|
|
6126
|
+
// guest there) — most guests are not on the host's network. Its closing withdraws that audience (revokeAudience).
|
|
6127
|
+
const open = this.publicWebLink?.openSignin() ?? null;
|
|
6128
|
+
// One code per account (a new one replaces only its own); at the cap, refused — never another guest's evicted.
|
|
6129
|
+
const login = codes.issue({ epoch: tokenEpoch(token), audiences: ["local", ...(open ? [open.exposureId] : [])],
|
|
6130
|
+
recipient: { adapterId: owner.adapterId, userId: owner.userId, ...(ask.username ? { username: ask.username } : {}) } });
|
|
6131
|
+
if (!login) {
|
|
6132
|
+
await say(t("web.guest_busy"));
|
|
6133
|
+
return;
|
|
6134
|
+
}
|
|
6135
|
+
this.guestCodeAskedAt.delete(key);
|
|
6136
|
+
this.guestCodeAskedAt.set(key, performance.now());
|
|
6137
|
+
while (this.guestCodeAskedAt.size > 256)
|
|
6138
|
+
this.guestCodeAskedAt.delete(this.guestCodeAskedAt.keys().next().value);
|
|
6139
|
+
const current = () => !this.shuttingDown && this.webToken === token && this.adapters.get(owner.adapterId) === adapter
|
|
6140
|
+
&& unrevoked() && this.guestSignInAllowed(owner);
|
|
6141
|
+
const privateText = `${t("web.guest_intro", scope.size)}\n${this.topicCommands.getDashboardText()}${open ? `\n${t("web.guest_public", open.url)}` : ""}\n${t("dashboard.code", login.display, Math.round(LOGIN_CODE_TTL_MS / 60_000))}`;
|
|
6142
|
+
const deadline = performance.now() + 10_000;
|
|
6143
|
+
try {
|
|
6144
|
+
let byDm = false;
|
|
6145
|
+
try {
|
|
6146
|
+
if (!adapter.sendDirect)
|
|
6147
|
+
throw new Error("DM unavailable");
|
|
6148
|
+
await withinBudget(adapter.sendDirect(owner.userId, `${privateText}\n${t("dashboard.code_next")}`, { disablePreview: true }), Math.min(deadline, performance.now() + 5_000));
|
|
6149
|
+
byDm = true;
|
|
6150
|
+
}
|
|
6151
|
+
catch (err) {
|
|
6152
|
+
this.logDashboardDmFailure(err);
|
|
6153
|
+
if (!ask.replyPrivately || !current())
|
|
6154
|
+
throw err;
|
|
6155
|
+
const id = await withinBudget(ask.replyPrivately(privateText), deadline); // a slash command's answer: ephemeral
|
|
6156
|
+
if (!id)
|
|
6157
|
+
throw new Error("private delivery unconfirmed");
|
|
6158
|
+
}
|
|
6159
|
+
if (!current())
|
|
6160
|
+
throw new Error("guest no longer admitted");
|
|
6161
|
+
if (byDm) {
|
|
6162
|
+
await this.sendCodeAlone((body, opts) => adapter.sendDirect(owner.userId, body, opts), adapter.type, login.display);
|
|
6163
|
+
if (!current())
|
|
6164
|
+
throw new Error("guest no longer admitted");
|
|
6165
|
+
await say(t("web.guest_sent"));
|
|
6166
|
+
}
|
|
6167
|
+
}
|
|
6168
|
+
catch (err) {
|
|
6169
|
+
codes.revokeIfCurrent(login.issuanceId); // this flow's code only, never a later one's
|
|
6170
|
+
this.logger.info({ err: err?.message, adapterId: owner.adapterId }, "Guest sign-in code was not delivered privately");
|
|
6171
|
+
if (!unrevoked())
|
|
6172
|
+
return; // revoked meanwhile: the revoke's own answer said so
|
|
6173
|
+
const start = adapter.type === "telegram" ? this.tgStartWebUrl(owner.adapterId) : null;
|
|
6174
|
+
// #1742 for guests (step 4b-3): its START button (a bare /start, or the link's /start web) then asks again, from
|
|
6175
|
+
// the DM — once, within the window.
|
|
6176
|
+
if (adapter.type === "telegram")
|
|
6177
|
+
this.openTgWebStartWindow(owner.adapterId, owner.userId);
|
|
6178
|
+
await say(start ? t("web.guest_dm_failed_link", start) : t("web.guest_dm_failed"));
|
|
6179
|
+
}
|
|
6180
|
+
}
|
|
5831
6181
|
/** #1570 (auth-api): this browser becomes a returning device of the code's owner. The cookie value, or null. */
|
|
5832
6182
|
rememberReturningDevice(owner, input) {
|
|
5833
6183
|
const token = this.webToken;
|
|
@@ -5855,6 +6205,12 @@ export class FleetManager {
|
|
|
5855
6205
|
if (!token || this.shuttingDown)
|
|
5856
6206
|
return { kind: "refused" };
|
|
5857
6207
|
this.initializeWebSessions();
|
|
6208
|
+
// #2095 review: verified before the request's body was read — a revoke (its own, or an admin's) made meanwhile
|
|
6209
|
+
// removed it, and nothing it asks for may then go out.
|
|
6210
|
+
if (!this.returningDevices.current(device, tokenEpoch(token)))
|
|
6211
|
+
return { kind: "refused" };
|
|
6212
|
+
if (device.tier === "guest")
|
|
6213
|
+
return this.requestGuestDeviceCode(device, input, token);
|
|
5858
6214
|
const paused = this.webLoginCodes.pausedForMs();
|
|
5859
6215
|
if (paused > 0)
|
|
5860
6216
|
return { kind: "limited", retryAfterMs: paused };
|
|
@@ -5886,7 +6242,7 @@ export class FleetManager {
|
|
|
5886
6242
|
this.logger.info({ err: err?.message, adapterId }, "Sign-in code request: the private message could not be sent");
|
|
5887
6243
|
return { kind: "refused" };
|
|
5888
6244
|
}
|
|
5889
|
-
if (this.webToken !== token || !this.publicOwnerCurrent(owner)) {
|
|
6245
|
+
if (this.webToken !== token || !this.publicOwnerCurrent(owner) || !this.returningDevices.current(device, tokenEpoch(token))) {
|
|
5890
6246
|
this.webLoginCodes.revokeIfCurrent(issued.issuanceId);
|
|
5891
6247
|
return { kind: "refused" };
|
|
5892
6248
|
}
|
|
@@ -5894,15 +6250,94 @@ export class FleetManager {
|
|
|
5894
6250
|
// Asked again after the follow-up's wait: the owner, the token, shutdown, and a public request's exposure.
|
|
5895
6251
|
const exposureGone = input.surface === "gateway"
|
|
5896
6252
|
&& (this.publicWebLink?.exposureId !== input.exposureId || this.publicWebLink?.status().state !== "open");
|
|
5897
|
-
if (this.shuttingDown || this.webToken !== token || !this.publicOwnerCurrent(owner) || exposureGone) {
|
|
6253
|
+
if (this.shuttingDown || this.webToken !== token || !this.publicOwnerCurrent(owner) || exposureGone || !this.returningDevices.current(device, tokenEpoch(token))) {
|
|
5898
6254
|
this.webLoginCodes.revokeIfCurrent(issued.issuanceId);
|
|
5899
6255
|
return { kind: "refused" };
|
|
5900
6256
|
}
|
|
5901
6257
|
this.logger.info({ adapterId, surface: input.surface }, "Web sign-in code sent privately to a returning device's owner");
|
|
5902
6258
|
return { kind: "sent" };
|
|
5903
6259
|
}
|
|
6260
|
+
/**
|
|
6261
|
+
* #1920 step 4b: a guest's returning device asks for a code — a guest code for that account, by DM only, under the
|
|
6262
|
+
* same rules as its /web (guests on, not an admin, something in scope now; one per account and the cap; one flow per
|
|
6263
|
+
* account at a time; a revoke meanwhile ends it), its own limiter, and the guests' breaker. Asked through the public
|
|
6264
|
+
* link: only while that exposure is the open one (its code then works through it alone), as an admin's device.
|
|
6265
|
+
*/
|
|
6266
|
+
async requestGuestDeviceCode(device, input, token) {
|
|
6267
|
+
const codes = this.webGuestLoginCodes;
|
|
6268
|
+
const account = { adapterId: device.owner.adapterId, userId: device.owner.userId };
|
|
6269
|
+
const key = `${account.adapterId}\u0000${account.userId}`;
|
|
6270
|
+
const adapter = this.adapters.get(account.adapterId);
|
|
6271
|
+
const viaLink = input.surface === "gateway";
|
|
6272
|
+
const linkOpen = () => !!input.exposureId && this.publicWebLink?.openSignin()?.exposureId === input.exposureId;
|
|
6273
|
+
if ((viaLink ? !linkOpen() : input.exposureId !== undefined) || !adapter?.sendDirect || !this.guestSignInAllowed(account)) {
|
|
6274
|
+
this.logger.info({ adapterId: account.adapterId, surface: input.surface }, "Guest sign-in code request refused");
|
|
6275
|
+
return { kind: "refused" };
|
|
6276
|
+
}
|
|
6277
|
+
const paused = codes.pausedForMs();
|
|
6278
|
+
if (paused > 0)
|
|
6279
|
+
return { kind: "limited", retryAfterMs: paused };
|
|
6280
|
+
const asked = this.guestCodeAskedAt.get(key);
|
|
6281
|
+
if (this.guestCodeInFlight.has(key))
|
|
6282
|
+
return { kind: "limited", retryAfterMs: 5_000 };
|
|
6283
|
+
if (asked !== undefined && performance.now() - asked < 60_000)
|
|
6284
|
+
return { kind: "limited", retryAfterMs: 60_000 - (performance.now() - asked) };
|
|
6285
|
+
const admitted = this.guestCodeRequestLimiter.admit(device.idHash);
|
|
6286
|
+
if (!admitted.ok)
|
|
6287
|
+
return { kind: "limited", retryAfterMs: admitted.retryAfterMs };
|
|
6288
|
+
const revokes = this.guestRevokes, accountRevokes = this.guestAccountRevokes.get(key) ?? 0;
|
|
6289
|
+
const current = () => !this.shuttingDown && this.webToken === token && this.adapters.get(account.adapterId) === adapter
|
|
6290
|
+
&& this.guestRevokes === revokes && (this.guestAccountRevokes.get(key) ?? 0) === accountRevokes && this.guestSignInAllowed(account)
|
|
6291
|
+
&& (!viaLink || linkOpen()) && this.returningDevices.current(device, tokenEpoch(token));
|
|
6292
|
+
const issued = codes.issue({ epoch: tokenEpoch(token), recipient: account, audiences: [viaLink ? input.exposureId : "local"] });
|
|
6293
|
+
if (!issued)
|
|
6294
|
+
return { kind: "limited", retryAfterMs: 60_000 }; // the guests' cap: never someone's evicted
|
|
6295
|
+
this.guestCodeInFlight.add(key);
|
|
6296
|
+
this.guestCodeAskedAt.delete(key);
|
|
6297
|
+
this.guestCodeAskedAt.set(key, performance.now());
|
|
6298
|
+
try {
|
|
6299
|
+
const text = t("web.resend_code", issued.display, Math.round(LOGIN_CODE_TTL_MS / 60_000), input.label || t("web.resend_unknown_browser"), t(viaLink ? "web.resend_public" : "web.resend_local"))
|
|
6300
|
+
+ "\n" + t("dashboard.code_next");
|
|
6301
|
+
try {
|
|
6302
|
+
await withinBudget(adapter.sendDirect(account.userId, text, { disablePreview: true }), performance.now() + 5_000);
|
|
6303
|
+
}
|
|
6304
|
+
catch (err) {
|
|
6305
|
+
codes.revokeIfCurrent(issued.issuanceId);
|
|
6306
|
+
this.logger.info({ err: err?.message, adapterId: account.adapterId }, "Guest sign-in code request: the private message could not be sent");
|
|
6307
|
+
return { kind: "refused" };
|
|
6308
|
+
}
|
|
6309
|
+
if (!current()) {
|
|
6310
|
+
codes.revokeIfCurrent(issued.issuanceId);
|
|
6311
|
+
return { kind: "refused" };
|
|
6312
|
+
}
|
|
6313
|
+
await this.sendCodeAlone((body, opts) => adapter.sendDirect(account.userId, body, opts), adapter.type, issued.display);
|
|
6314
|
+
if (!current()) {
|
|
6315
|
+
codes.revokeIfCurrent(issued.issuanceId);
|
|
6316
|
+
return { kind: "refused" };
|
|
6317
|
+
}
|
|
6318
|
+
this.logger.info({ adapterId: account.adapterId }, "Guest sign-in code sent privately to a returning device's account");
|
|
6319
|
+
return { kind: "sent" };
|
|
6320
|
+
}
|
|
6321
|
+
finally {
|
|
6322
|
+
this.guestCodeInFlight.delete(key);
|
|
6323
|
+
}
|
|
6324
|
+
}
|
|
6325
|
+
/** #1920 step 4b (auth-api): this browser becomes a guest's returning device. The cookie value, or null. */
|
|
6326
|
+
rememberGuestDevice(account, input) {
|
|
6327
|
+
const token = this.webToken;
|
|
6328
|
+
if (!token)
|
|
6329
|
+
return null;
|
|
6330
|
+
this.initializeWebSessions();
|
|
6331
|
+
return this.returningDevices.rememberGuest(account, { ...input, tokenEpoch: tokenEpoch(token), webToken: token });
|
|
6332
|
+
}
|
|
5904
6333
|
/** Called by the sign-in endpoint: a login the operator did not make should be visible to them. */
|
|
5905
6334
|
onWebLogin(info) {
|
|
6335
|
+
// #1920 §2.3: a guest's sign-in is always said in General (web.notify_login is the operator's own sign-ins): who,
|
|
6336
|
+
// and how many agents they now reach — admins stay aware of everyone who is not one of them.
|
|
6337
|
+
if (info.tier === "guest" && info.owner) {
|
|
6338
|
+
this.notifyFleetError(t("web.guest_login_notice", this.webPerson(info.owner).name, info.label, this.guestScope(info.owner).size, info.handle.slice(0, 8)));
|
|
6339
|
+
return;
|
|
6340
|
+
}
|
|
5906
6341
|
if (this.fleetConfig?.web?.notify_login === false)
|
|
5907
6342
|
return;
|
|
5908
6343
|
// The session handle makes each notice distinct: notifyFleetError throttles by text, and a second
|
|
@@ -6266,6 +6701,9 @@ export class FleetManager {
|
|
|
6266
6701
|
const { getTmuxSocketName: getSocket } = await import("./paths.js");
|
|
6267
6702
|
TmuxManager.setSocketName(getSocket());
|
|
6268
6703
|
await TmuxManager.ensureSession(getTmuxSession());
|
|
6704
|
+
this.tmuxSessionEnsured = true;
|
|
6705
|
+
// #2072: what this host can run — probed in the background, published for every picker.
|
|
6706
|
+
this.startAvailabilityRefresh();
|
|
6269
6707
|
// Pre-flight (advisory, fire-and-forget): warm the per-backend auth cache
|
|
6270
6708
|
// before instances spawn, so a CLI that comes up on its sign-in screen gets
|
|
6271
6709
|
// an instant 🔑 verdict instead of decaying into crash/MCP-died noise.
|
|
@@ -6589,6 +7027,11 @@ export class FleetManager {
|
|
|
6589
7027
|
this.pruneEventLog();
|
|
6590
7028
|
this.eventLogPruneTimer = setInterval(() => this.pruneEventLog(), 24 * 60 * 60_000);
|
|
6591
7029
|
this.eventLogPruneTimer.unref?.();
|
|
7030
|
+
// #1920 §3.2: guests' ClassicBot room membership, read in the background — now, then each key ten minutes after
|
|
7031
|
+
// its answer (a minute after a failed read; the tick only finds what is due). A request only reads the answers.
|
|
7032
|
+
void this.refreshAllGuestRooms();
|
|
7033
|
+
this.guestRoomsTimer = setInterval(() => { void this.refreshAllGuestRooms(); }, GUEST_ROOM_TICK_MS);
|
|
7034
|
+
this.guestRoomsTimer.unref?.();
|
|
6592
7035
|
// #1335: delivery-outbox.db and Task Board retention. Same once-at-startup
|
|
6593
7036
|
// + daily-timer pattern; chunked DELETEs so one run cannot hold the loop.
|
|
6594
7037
|
void this.pruneOutboxAndTasks();
|
|
@@ -7089,7 +7532,8 @@ export class FleetManager {
|
|
|
7089
7532
|
adapters: { total: this.adapterState.size, connected, states, details },
|
|
7090
7533
|
startupComplete: this.startupComplete,
|
|
7091
7534
|
memory: readFleetMemory(),
|
|
7092
|
-
hostMemory
|
|
7535
|
+
hostMemory: this.memoryTop && (hostMemory.level === "elevated" || hostMemory.level === "critical")
|
|
7536
|
+
? { ...hostMemory, topProcesses: this.memoryTop.processes, topProcessesAt: this.memoryTop.at } : hostMemory,
|
|
7093
7537
|
problems,
|
|
7094
7538
|
};
|
|
7095
7539
|
}
|
|
@@ -8240,6 +8684,13 @@ export class FleetManager {
|
|
|
8240
8684
|
if (!isPrivateChat && !cmdMatch && rawText.startsWith("/")) {
|
|
8241
8685
|
return; // bare slash in group — ignore silently
|
|
8242
8686
|
}
|
|
8687
|
+
// #1920 step 4b-3: /web typed in a ClassicBot room (a group: `/web@ThisBot`) by someone who is not a fleet admin,
|
|
8688
|
+
// while guest sign-in is on: their guest code, privately — as anywhere else. An admin's goes on as before.
|
|
8689
|
+
if (/^\/(?:web|dashboard)(?:\s|$)/i.test(text) && msgAdapter && this.isGuestWebAsk(msg.adapterId, msg.userId)) {
|
|
8690
|
+
await this.guestWebCommand({ adapterId: msg.adapterId, userId: msg.userId, username: msg.username,
|
|
8691
|
+
arg: text.replace(/^\/(?:web|dashboard)/i, "").trim().toLowerCase(), replyHere: body => msgAdapter.sendText(chatId, body) });
|
|
8692
|
+
return;
|
|
8693
|
+
}
|
|
8243
8694
|
// Handle /start command
|
|
8244
8695
|
if (text === "/start" || text.startsWith("/start ")) {
|
|
8245
8696
|
// #1686: intercept /start web before the ClassicBot admission flow runs.
|
|
@@ -8248,15 +8699,21 @@ export class FleetManager {
|
|
|
8248
8699
|
// #1742: a bare /start from a fleet admin whose /web just failed to reach them privately is that /start web
|
|
8249
8700
|
// (Telegram's START button sends it bare) — once, within the window; any other bare /start is ClassicBot's.
|
|
8250
8701
|
const admin = isPrivateChat && this.isFleetAdmin(msg.userId, msg.adapterId ?? "");
|
|
8702
|
+
// #1920 step 4b-3: a guest's too (guest sign-in on, not an admin) — its /web reached it no other way.
|
|
8703
|
+
const guest = isPrivateChat && !admin && this.isGuestWebAsk(msg.adapterId, msg.userId);
|
|
8251
8704
|
const webStart = isPrivateChat && (requestedBackend === "web"
|
|
8252
|
-
|| (text === "/start" && admin && this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId)));
|
|
8705
|
+
|| (text === "/start" && (admin || guest) && this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId)));
|
|
8253
8706
|
if (webStart) {
|
|
8254
8707
|
if (requestedBackend === "web")
|
|
8255
8708
|
this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId);
|
|
8256
8709
|
// #1742: the /web menu right here; the code comes to this DM.
|
|
8257
8710
|
if (admin)
|
|
8258
8711
|
await this.startWebInDm(msg, msgAdapter ?? undefined);
|
|
8259
|
-
//
|
|
8712
|
+
// A guest: its guest code, here (this DM is where it goes).
|
|
8713
|
+
else if (guest && msgAdapter)
|
|
8714
|
+
await this.guestWebCommand({ adapterId: msg.adapterId, userId: msg.userId, username: msg.username, arg: "",
|
|
8715
|
+
replyHere: body => msgAdapter.sendText(chatId, body) });
|
|
8716
|
+
// Anyone else: /start alone is enough to open the DM channel; no further action.
|
|
8260
8717
|
return;
|
|
8261
8718
|
}
|
|
8262
8719
|
// All new starts, including explicit backend and later chooser callbacks,
|
|
@@ -8560,10 +9017,7 @@ export class FleetManager {
|
|
|
8560
9017
|
this.afterUserInboundDelivered(generalInstance, msg);
|
|
8561
9018
|
this.logger.info(`${msg.username} → ${generalInstance}: ${(text ?? "").slice(0, 100)}`);
|
|
8562
9019
|
this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), generalInstance);
|
|
8563
|
-
this.
|
|
8564
|
-
instance: generalInstance, sender: msg.username, role: "user",
|
|
8565
|
-
text: (text ?? "").slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
|
|
8566
|
-
});
|
|
9020
|
+
this.emitPlatformInbound(generalInstance, msg, text ?? "");
|
|
8567
9021
|
}
|
|
8568
9022
|
catch (err) {
|
|
8569
9023
|
this.logger.warn({ err: err.message, instanceName: generalInstance }, "General wake/delivery failed");
|
|
@@ -8672,9 +9126,16 @@ export class FleetManager {
|
|
|
8672
9126
|
this.afterUserInboundDelivered(instanceName, msg);
|
|
8673
9127
|
this.logger.info(`${msg.username} → ${instanceName}: ${(text ?? "").slice(0, 100)}`);
|
|
8674
9128
|
this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), instanceName);
|
|
9129
|
+
this.emitPlatformInbound(instanceName, msg, text ?? "");
|
|
9130
|
+
}
|
|
9131
|
+
/**
|
|
9132
|
+
* A person's platform message as the web chat shows it — the user's own words, as a user (#1306), in the instance's
|
|
9133
|
+
* history (#1565). #1920 §5.2: said in the instance's topic, General or room, so it is that channel's, by that account.
|
|
9134
|
+
*/
|
|
9135
|
+
emitPlatformInbound(instance, msg, text) {
|
|
8675
9136
|
this.emitSseEvent("message", {
|
|
8676
|
-
instance
|
|
8677
|
-
|
|
9137
|
+
instance, sender: msg.username, role: "user", text: text.slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
|
|
9138
|
+
channel: true, from: webChatFrom(msg.adapterId, msg.userId),
|
|
8678
9139
|
});
|
|
8679
9140
|
}
|
|
8680
9141
|
/** Handle outbound tool calls from a daemon instance */
|
|
@@ -8976,7 +9437,14 @@ export class FleetManager {
|
|
|
8976
9437
|
// an agent cannot invent it to suppress the normal completion marker.
|
|
8977
9438
|
if (!error && result != null && msg.statusOnly !== true) {
|
|
8978
9439
|
try {
|
|
8979
|
-
|
|
9440
|
+
// #1920 §5.2: shown to the instance's guests only when it landed in the instance's own topic or room.
|
|
9441
|
+
let inChannel = false;
|
|
9442
|
+
try {
|
|
9443
|
+
inChannel = outAdapter !== WEB_ONLY_REPLY_SINK
|
|
9444
|
+
&& this.landedInOwnChannel(instanceName, contextAdapterId ?? this.getInstanceAdapterId(instanceName), result);
|
|
9445
|
+
}
|
|
9446
|
+
catch { /* not known: not the channel's (fail closed), and the bookkeeping below still runs */ }
|
|
9447
|
+
void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView, inChannel);
|
|
8980
9448
|
}
|
|
8981
9449
|
catch (err) {
|
|
8982
9450
|
this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
|
|
@@ -9030,7 +9498,35 @@ export class FleetManager {
|
|
|
9030
9498
|
return this.worlds.size === 0 && this.isWebOnlyFleet() ? { adapter: WEB_ONLY_PROMPT_SINK, adapterId: "web", chatId: "web" } : null;
|
|
9031
9499
|
}
|
|
9032
9500
|
/** Resolves when the reply is in the web chat (#1770: after its files are copied); the caller need not wait. */
|
|
9033
|
-
|
|
9501
|
+
/**
|
|
9502
|
+
* #1920 §5.2: whether a platform send landed in the instance's own place — its ClassicBot room, or its topic in its
|
|
9503
|
+
* connection's group — so its channel's members saw it. Anything else (another chat, a DM, a context left from General,
|
|
9504
|
+
* an answer without the chat it went to) is not the channel's.
|
|
9505
|
+
*/
|
|
9506
|
+
landedInOwnChannel(instance, adapterId, sent) {
|
|
9507
|
+
const s = sent && typeof sent === "object" ? sent : {};
|
|
9508
|
+
if (!adapterId || typeof s.chatId !== "string" || !s.chatId)
|
|
9509
|
+
return false;
|
|
9510
|
+
const roomId = this.classicChannels?.getChannelIdByInstance(instance);
|
|
9511
|
+
if (roomId !== undefined)
|
|
9512
|
+
return (this.classicChannels?.getAdapterIdByInstance(instance) ?? this.getPrimaryAdapterId()) === adapterId && roomId === s.chatId;
|
|
9513
|
+
const cfg = this.fleetConfig?.instances && Object.hasOwn(this.fleetConfig.instances, instance) ? this.fleetConfig.instances[instance] : undefined;
|
|
9514
|
+
const world = this.worlds.get(adapterId);
|
|
9515
|
+
if (!cfg || this.getInstanceAdapterId(instance) !== adapterId || !world?.groupId || s.chatId !== world.groupId)
|
|
9516
|
+
return false;
|
|
9517
|
+
const thread = typeof s.threadId === "string" && s.threadId ? s.threadId : null;
|
|
9518
|
+
return cfg.topic_id == null ? thread === null : thread === String(cfg.topic_id);
|
|
9519
|
+
}
|
|
9520
|
+
/** #1920 §5.2 (#2085 review): a platform click's buttons are in the instance's own place — else not its channel's. */
|
|
9521
|
+
clickInOwnChannel(set) {
|
|
9522
|
+
try {
|
|
9523
|
+
return this.landedInOwnChannel(set.instance, set.adapterId, { chatId: set.chatId, threadId: set.threadId || undefined });
|
|
9524
|
+
}
|
|
9525
|
+
catch {
|
|
9526
|
+
return false;
|
|
9527
|
+
}
|
|
9528
|
+
}
|
|
9529
|
+
afterReplyRouted(instanceName, args, senderSessionName, buttons = null, inChannel = false) {
|
|
9034
9530
|
// A reply is NOT proof the turn is over (#410) — but it is not proof of
|
|
9035
9531
|
// more work either. Split the difference: an instance that is clearly
|
|
9036
9532
|
// idle loses the button now; one that looks busy keeps it (re-posted
|
|
@@ -9066,6 +9562,7 @@ export class FleetManager {
|
|
|
9066
9562
|
ts,
|
|
9067
9563
|
...(attachments.length ? { attachments } : {}),
|
|
9068
9564
|
...(buttons ? { buttons } : {}), // #1266
|
|
9565
|
+
...(inChannel ? { channel: true } : {}), // #1920 §5.2
|
|
9069
9566
|
});
|
|
9070
9567
|
};
|
|
9071
9568
|
const before = this.webReplyOrder.get(instanceName);
|
|
@@ -10319,6 +10816,7 @@ export class FleetManager {
|
|
|
10319
10816
|
*/
|
|
10320
10817
|
saveFleetConfig(explicitPatches = []) {
|
|
10321
10818
|
this.publicWebLink?.refresh();
|
|
10819
|
+
this.enforceGuestSwitch(); // #1920: a Settings save turning guests off ends their sessions
|
|
10322
10820
|
if (!this.fleetConfig || !this.configPath)
|
|
10323
10821
|
return;
|
|
10324
10822
|
if (!this.savedFleetConfigSnapshot)
|
|
@@ -13034,11 +13532,12 @@ export class FleetManager {
|
|
|
13034
13532
|
};
|
|
13035
13533
|
};
|
|
13036
13534
|
const choicesFromCache = (instance) => {
|
|
13037
|
-
const
|
|
13535
|
+
const backend = this.backendNameForInstance(instance);
|
|
13536
|
+
const cached = this.readCliEnv(backend, this.catalogProfile(backend, instance)); // the cached catalog only: never a probe here
|
|
13038
13537
|
if (!cached?.models.length)
|
|
13039
13538
|
return null;
|
|
13040
13539
|
const current = this.resolveInstanceModel(instance).model;
|
|
13041
|
-
return { current, options: cached.models.map(o => ({ id: o.id, label: this.modelChoiceLabel(o, current) })) };
|
|
13540
|
+
return { current, options: this.withCurrentModel(cached.models, current).map(o => ({ id: o.id, label: this.modelChoiceLabel(o, current) })) };
|
|
13042
13541
|
};
|
|
13043
13542
|
return runWebCommand({
|
|
13044
13543
|
// Own entries only: a name like "__proto__" or "constructor" must not be found on the prototype (#1476 review).
|
|
@@ -13085,7 +13584,7 @@ export class FleetManager {
|
|
|
13085
13584
|
return step.noop ? { text: step.text } : { error: step.text };
|
|
13086
13585
|
if (step.kind === "choose") {
|
|
13087
13586
|
const id = (token) => joinBackendArgs([...step.args, token]);
|
|
13088
|
-
return { choices: { prompt: step.text, current: step.current === null ? null : id(step.current), options: step.choices.map(c => ({ id: id(c.token), label: c.label })) } };
|
|
13587
|
+
return { choices: { prompt: step.text, current: step.current === null ? null : id(step.current), options: step.choices.map(c => ({ id: id(c.token), label: c.label, ...(c.disabled ? { disabled: c.disabled } : {}) })) } };
|
|
13089
13588
|
}
|
|
13090
13589
|
const now = performance.now();
|
|
13091
13590
|
for (const [k, v] of this.webBackendTokens)
|
|
@@ -14223,7 +14722,12 @@ export class FleetManager {
|
|
|
14223
14722
|
fleetConfig: () => this.fleetConfig,
|
|
14224
14723
|
isFleetAdmin: (userId, adapterId) => this.isFleetAdmin(userId, adapterId),
|
|
14225
14724
|
eventLog: () => this.eventLog,
|
|
14226
|
-
recoverBackendInstances: (backend, profile) =>
|
|
14725
|
+
recoverBackendInstances: (backend, profile) => {
|
|
14726
|
+
// #2072: a sign-in just finished — the pickers learn it now, not at the next pass.
|
|
14727
|
+
if (!profile)
|
|
14728
|
+
void this.backendAvailability.refresh([backend], { force: true });
|
|
14729
|
+
return this.recoverBackendInstances(backend, undefined, profile ?? null);
|
|
14730
|
+
},
|
|
14227
14731
|
claimWindow: backend => this.loginWindow.tryClaim("web", backend),
|
|
14228
14732
|
releaseWindow: claim => { this.loginWindow.release(claim); },
|
|
14229
14733
|
isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
|
|
@@ -14742,6 +15246,8 @@ export class FleetManager {
|
|
|
14742
15246
|
// binary the same way, so a binary only a login shell could see was
|
|
14743
15247
|
// installed, reported as verified, and then offered nowhere.
|
|
14744
15248
|
this.adoptBinaryDirectory(installedAt, backend);
|
|
15249
|
+
// #2072: an install just finished — the pickers learn it now, not at the next pass.
|
|
15250
|
+
void this.backendAvailability.refresh([backend], { force: true });
|
|
14745
15251
|
if (!LOGIN_FLOWS[backend]) {
|
|
14746
15252
|
this.installHandoff = null;
|
|
14747
15253
|
await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
|
|
@@ -14976,6 +15482,8 @@ export class FleetManager {
|
|
|
14976
15482
|
buttons: m.buttons,
|
|
14977
15483
|
click: m.click,
|
|
14978
15484
|
links: m.links,
|
|
15485
|
+
channel: m.channel,
|
|
15486
|
+
from: m.from,
|
|
14979
15487
|
});
|
|
14980
15488
|
broadcastSseEvent(this.sseClients, event, recorded, onError, this.webChatHistory.cursorOf(recorded));
|
|
14981
15489
|
// #1629: live-only receipt after the recorded agent reply. Never persist/replay it or give it an SSE cursor.
|
|
@@ -15776,10 +16284,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15776
16284
|
this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
|
|
15777
16285
|
// #1592: the web chat shows what was asked, as for every other inbound — the user's own words (no chat-log
|
|
15778
16286
|
// context), as a user (#1306: never a role read from text), in the room's history (#1565).
|
|
15779
|
-
this.
|
|
15780
|
-
instance: instanceName, sender: msg.username, role: "user",
|
|
15781
|
-
text: text.slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
|
|
15782
|
-
});
|
|
16287
|
+
this.emitPlatformInbound(instanceName, msg, text);
|
|
15783
16288
|
}
|
|
15784
16289
|
/** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
|
|
15785
16290
|
/** #1690: our own runtime switches awaiting the daemon's outcome, by id. */
|
|
@@ -15817,7 +16322,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15817
16322
|
/** #1806: null on a switch INTO claude-code — the configured model then belongs to the backend being left. */
|
|
15818
16323
|
configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model) {
|
|
15819
16324
|
const aliases = ["default", "sonnet", "opus", "haiku", "opusplan", "Fable"];
|
|
15820
|
-
const api = (this.readCliEnv("claude-code")?.apiModels ?? []).map(m => m.id);
|
|
16325
|
+
const api = (this.readCliEnv("claude-code", this.catalogProfile("claude-code", instanceName))?.apiModels ?? []).map(m => m.id);
|
|
15821
16326
|
const lower = model.trim().toLowerCase();
|
|
15822
16327
|
const bare = lower.replace(/\[1m\]$/, "");
|
|
15823
16328
|
const known = aliases.some(a => a.toLowerCase() === bare)
|
|
@@ -15845,13 +16350,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15845
16350
|
: undefined;
|
|
15846
16351
|
return classic ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
15847
16352
|
}
|
|
15848
|
-
|
|
15849
|
-
|
|
16353
|
+
/**
|
|
16354
|
+
* #2072: a catalog is the account's, so a credential profile has its own. Only claude-code's probe reads an account
|
|
16355
|
+
* catalog through a profile's login (its `GET /v1/models` with the profile's token); other backends' probes read the
|
|
16356
|
+
* process environment, so their catalog stays the backend's own.
|
|
16357
|
+
*/
|
|
16358
|
+
catalogProfile(backend, instanceName) {
|
|
16359
|
+
return backend === "claude-code" && instanceName ? this.credentialProfileOf(instanceName, backend) : null;
|
|
16360
|
+
}
|
|
16361
|
+
cliEnvPath(backend, profile = null) {
|
|
16362
|
+
const safe = (s) => s.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
16363
|
+
return join(getAgendHome(), "cli-env", profile ? `${safe(backend)}@${safe(profile)}.json` : `${safe(backend)}.json`);
|
|
15850
16364
|
}
|
|
15851
|
-
/** Read the cached CLI env for a backend, or null if missing/stale/unparseable. */
|
|
15852
|
-
readCliEnv(backend) {
|
|
16365
|
+
/** Read the cached CLI env for a backend (a credential profile's own, when given), or null if missing/stale/unparseable. */
|
|
16366
|
+
readCliEnv(backend, profile = null) {
|
|
15853
16367
|
try {
|
|
15854
|
-
const env = JSON.parse(readFileSync(this.cliEnvPath(backend), "utf-8"));
|
|
16368
|
+
const env = JSON.parse(readFileSync(this.cliEnvPath(backend, profile), "utf-8"));
|
|
15855
16369
|
if (typeof env?.probedAt === "number" && Date.now() - env.probedAt < CLI_ENV_TTL_MS)
|
|
15856
16370
|
return env;
|
|
15857
16371
|
}
|
|
@@ -15894,10 +16408,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15894
16408
|
* previous list, never stall the command that asked for it.
|
|
15895
16409
|
*/
|
|
15896
16410
|
async probeBackendBounded(backend, opts = {}) {
|
|
15897
|
-
// #725: single-flight per backend. Explicit vendor refreshes remain a
|
|
16411
|
+
// #725: single-flight per backend (#2072: per backend and credential profile). Explicit vendor refreshes remain a
|
|
15898
16412
|
// separate flight from an ordinary probe, while sysinfo can join either.
|
|
15899
|
-
const
|
|
15900
|
-
const
|
|
16413
|
+
const profile = opts.profile ?? null;
|
|
16414
|
+
const key = profile ? `${backend}@${profile}` : backend;
|
|
16415
|
+
const regularFlight = this.pendingCliEnvProbes.get(key);
|
|
16416
|
+
const vendorFlight = this.pendingVendorCliEnvProbes.get(key);
|
|
15901
16417
|
if (opts.refreshVendorCatalog && vendorFlight)
|
|
15902
16418
|
return vendorFlight;
|
|
15903
16419
|
if (!opts.refreshVendorCatalog && (vendorFlight || regularFlight))
|
|
@@ -15908,25 +16424,26 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15908
16424
|
const epoch = this.cliEnvProbeEpoch;
|
|
15909
16425
|
const input = {
|
|
15910
16426
|
mode: "env", backend, instanceDir,
|
|
15911
|
-
config: { workingDirectory: "", instanceDir, instanceName: `probe-${backend}`, mcpServers: {}
|
|
16427
|
+
config: { workingDirectory: "", instanceDir, instanceName: `probe-${backend}`, mcpServers: {},
|
|
16428
|
+
...(profile ? { credentialStoreHome: credentialProfileStoreHome(getAgendHome(), backend, profile) } : {}) },
|
|
15912
16429
|
refreshVendorCatalog: opts.refreshVendorCatalog === true,
|
|
15913
16430
|
};
|
|
15914
|
-
const bounded = this.runBackendProbeWorker(`global:${
|
|
16431
|
+
const bounded = this.runBackendProbeWorker(`global:${key}`, input).then(probed => {
|
|
15915
16432
|
if (!probed || Array.isArray(probed) || this.shuttingDown || epoch !== this.cliEnvProbeEpoch)
|
|
15916
16433
|
return null;
|
|
15917
16434
|
try {
|
|
15918
|
-
return this.persistCliEnvProbeResult(backend, probed);
|
|
16435
|
+
return this.persistCliEnvProbeResult(backend, probed, profile);
|
|
15919
16436
|
}
|
|
15920
16437
|
catch (err) {
|
|
15921
|
-
this.logger.warn({ backend, err }, "CLI env cache write failed");
|
|
16438
|
+
this.logger.warn({ backend, profile, err }, "CLI env cache write failed");
|
|
15922
16439
|
return null;
|
|
15923
16440
|
}
|
|
15924
16441
|
}).finally(() => {
|
|
15925
16442
|
const flights = opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes;
|
|
15926
|
-
if (flights.get(
|
|
15927
|
-
flights.delete(
|
|
16443
|
+
if (flights.get(key) === bounded)
|
|
16444
|
+
flights.delete(key);
|
|
15928
16445
|
});
|
|
15929
|
-
(opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(
|
|
16446
|
+
(opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(key, bounded);
|
|
15930
16447
|
return bounded;
|
|
15931
16448
|
}
|
|
15932
16449
|
stopCliEnvProbes() {
|
|
@@ -16041,12 +16558,21 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16041
16558
|
modelDisplayForInstance(instanceName) {
|
|
16042
16559
|
return this.resolveInstanceModel(instanceName).display;
|
|
16043
16560
|
}
|
|
16561
|
+
/**
|
|
16562
|
+
* #2072: the model an instance runs is always among its choices — one its CLI's catalog does not list (an older
|
|
16563
|
+
* name, a custom provider's, a typed one) is shown first, marked, so the menu never hides what is running.
|
|
16564
|
+
*/
|
|
16565
|
+
withCurrentModel(options, currentModel) {
|
|
16566
|
+
if (!currentModel || currentModel === "default" || options.some(o => o.id === currentModel))
|
|
16567
|
+
return options;
|
|
16568
|
+
return [{ id: currentModel, label: currentModel, description: t("model.not_listed") }, ...options];
|
|
16569
|
+
}
|
|
16044
16570
|
modelChoiceLabel(option, currentModel) {
|
|
16045
16571
|
const label = option.description ? `${option.label} — ${option.description}` : option.label;
|
|
16046
16572
|
return option.id === currentModel ? `✓ ${label}` : label;
|
|
16047
16573
|
}
|
|
16048
16574
|
/** Preserve the existing cache merge rules for worker probes. */
|
|
16049
|
-
persistCliEnvProbeResult(backend, probed) {
|
|
16575
|
+
persistCliEnvProbeResult(backend, probed, profile = null) {
|
|
16050
16576
|
const env = { backend, probedAt: Date.now(), ...probed };
|
|
16051
16577
|
// An empty result must never overwrite a catalog we already have. Some
|
|
16052
16578
|
// probes hit the network (`agy models` fetches, 5s cap), so a slow moment
|
|
@@ -16055,14 +16581,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16055
16581
|
// antigravity cache replaced by an empty one. Keep the known models and
|
|
16056
16582
|
// let the fresher currentModel/version through.
|
|
16057
16583
|
if (!env.models?.length) {
|
|
16058
|
-
const previous = this.readCliEnv(backend);
|
|
16584
|
+
const previous = this.readCliEnv(backend, profile);
|
|
16059
16585
|
if (previous?.models?.length)
|
|
16060
16586
|
env.models = previous.models;
|
|
16061
16587
|
}
|
|
16062
16588
|
// Same protection for the extended catalog: one offline moment must not
|
|
16063
16589
|
// blank a good account list for the whole cache TTL.
|
|
16064
16590
|
if (!env.apiModels?.length) {
|
|
16065
|
-
const previous = this.readCliEnv(backend);
|
|
16591
|
+
const previous = this.readCliEnv(backend, profile);
|
|
16066
16592
|
if (previous?.apiModels?.length)
|
|
16067
16593
|
env.apiModels = previous.apiModels;
|
|
16068
16594
|
}
|
|
@@ -16070,12 +16596,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16070
16596
|
// keeps the cached levels only for that same binary: both versions known and equal, cache still valid. A help
|
|
16071
16597
|
// that was read and lists none ([]) is an answer and is written as is, so the fallback applies.
|
|
16072
16598
|
if (env.effortLevels === undefined) {
|
|
16073
|
-
const previous = this.readCliEnv(backend);
|
|
16599
|
+
const previous = this.readCliEnv(backend, profile);
|
|
16074
16600
|
if (previous?.effortLevels && previous.version && env.version && previous.version === env.version) {
|
|
16075
16601
|
env.effortLevels = previous.effortLevels;
|
|
16076
16602
|
}
|
|
16077
16603
|
}
|
|
16078
|
-
const path = this.cliEnvPath(backend);
|
|
16604
|
+
const path = this.cliEnvPath(backend, profile);
|
|
16079
16605
|
mkdirSync(dirname(path), { recursive: true });
|
|
16080
16606
|
writeFileSync(path, JSON.stringify(env, null, 2));
|
|
16081
16607
|
return env;
|
|
@@ -16097,6 +16623,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16097
16623
|
// indefinitely — each is wrapped by CLI_ENV_PROBE_DEADLINE_MS.
|
|
16098
16624
|
for (const b of backends)
|
|
16099
16625
|
void this.probeBackendBounded(b);
|
|
16626
|
+
// #2072: each Claude credential profile in use has its own account catalog. Only fleet.yaml's agents are walked
|
|
16627
|
+
// here; a ClassicBot room on a profile is probed on its first /model (catalogProfile knows rooms too).
|
|
16628
|
+
const profiles = new Set();
|
|
16629
|
+
for (const name of Object.keys(this.fleetConfig?.instances ?? {})) {
|
|
16630
|
+
if (this.backendNameForInstance(name) !== "claude-code")
|
|
16631
|
+
continue;
|
|
16632
|
+
const profile = this.catalogProfile("claude-code", name);
|
|
16633
|
+
if (profile)
|
|
16634
|
+
profiles.add(profile);
|
|
16635
|
+
}
|
|
16636
|
+
for (const profile of profiles)
|
|
16637
|
+
void this.probeBackendBounded("claude-code", { profile });
|
|
16100
16638
|
}
|
|
16101
16639
|
/** Best-effort model list for `/model`: cached CLI env first, else live probe. Never throws. */
|
|
16102
16640
|
async getModelOptions(instanceName, refresh = false, onLiveProbe) {
|
|
@@ -16110,7 +16648,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16110
16648
|
*/
|
|
16111
16649
|
async getModelOptionsWithSource(instanceName, opts = {}) {
|
|
16112
16650
|
const backendName = this.backendNameForInstance(instanceName);
|
|
16113
|
-
const
|
|
16651
|
+
const profile = this.catalogProfile(backendName, instanceName);
|
|
16652
|
+
const cached = this.readCliEnv(backendName, profile);
|
|
16114
16653
|
if (!opts.refresh && cached?.models.length && !this.cliEnvNeedsRefresh(cached)) {
|
|
16115
16654
|
return { models: cached.models, source: "cache" };
|
|
16116
16655
|
}
|
|
@@ -16121,7 +16660,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16121
16660
|
// Stale, missing, or a forced refresh → probe live (also refreshes the cache).
|
|
16122
16661
|
// A newly released model is invisible until this runs, which is why staleness
|
|
16123
16662
|
// triggers it rather than waiting for the 24h hard expiry or a cold start.
|
|
16124
|
-
const env = await this.probeBackendBounded(backendName, { refreshVendorCatalog: opts.refreshVendorCatalog });
|
|
16663
|
+
const env = await this.probeBackendBounded(backendName, { refreshVendorCatalog: opts.refreshVendorCatalog, profile });
|
|
16125
16664
|
if (env?.models.length)
|
|
16126
16665
|
return { models: env.models, source: "live" };
|
|
16127
16666
|
// Probe failed or timed out: the previous list is still the best answer.
|
|
@@ -16136,7 +16675,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16136
16675
|
modelMenuChoices(instanceName, nonce, options, currentModel) {
|
|
16137
16676
|
const isClaude = this.backendNameForInstance(instanceName) === "claude-code";
|
|
16138
16677
|
const choices = [{ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:__refresh__`, label: t("model.refresh") }];
|
|
16139
|
-
for (const o of options.slice(0, isClaude ? 23 : 24)) {
|
|
16678
|
+
for (const o of this.withCurrentModel(options, currentModel).slice(0, isClaude ? 23 : 24)) {
|
|
16140
16679
|
choices.push({ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`, label: this.modelChoiceLabel(o, currentModel) });
|
|
16141
16680
|
}
|
|
16142
16681
|
if (isClaude)
|
|
@@ -16543,16 +17082,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16543
17082
|
}
|
|
16544
17083
|
}
|
|
16545
17084
|
/** Cached-or-live account catalog behind "/model → more models" (claude). */
|
|
16546
|
-
async claudeApiModelOptions() {
|
|
16547
|
-
|
|
17085
|
+
async claudeApiModelOptions(instanceName) {
|
|
17086
|
+
// #2072: the account behind THIS instance — its credential profile's catalog, else the default login's.
|
|
17087
|
+
const profile = this.catalogProfile("claude-code", instanceName);
|
|
17088
|
+
const cached = this.readCliEnv("claude-code", profile);
|
|
16548
17089
|
if (cached?.apiModels?.length)
|
|
16549
17090
|
return cached.apiModels;
|
|
16550
|
-
const env = await this.probeBackendBounded("claude-code");
|
|
17091
|
+
const env = await this.probeBackendBounded("claude-code", { profile });
|
|
16551
17092
|
return env?.apiModels ?? [];
|
|
16552
17093
|
}
|
|
16553
17094
|
/** Replace a consumed "/model" menu with the full account catalog tier. */
|
|
16554
17095
|
async expandClaudeModelMenu(pending) {
|
|
16555
|
-
const expanded = await this.claudeApiModelOptions();
|
|
17096
|
+
const expanded = await this.claudeApiModelOptions(pending.instanceName);
|
|
16556
17097
|
if (!expanded.length) {
|
|
16557
17098
|
await pending.respond(t("model.more_unavailable")).catch(() => { });
|
|
16558
17099
|
return;
|
|
@@ -16980,7 +17521,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16980
17521
|
return {
|
|
16981
17522
|
currentBackend: (name) => this.backendNameForInstance(name),
|
|
16982
17523
|
currentDisplay: (name) => `${this.backendNameForInstance(name)} · ${this.resolveInstanceModel(name).display}`,
|
|
16983
|
-
|
|
17524
|
+
availability: () => this.backendAvailabilityFor(instanceName),
|
|
16984
17525
|
models: (backend) => this.backendModelOptions(backend, opts),
|
|
16985
17526
|
// #1808: the same rule update_instance_config's switch applies (backendModelVerdict).
|
|
16986
17527
|
modelKnown: (backend, model) => this.backendModelVerdict(instanceName, backend, model).ok,
|
|
@@ -17079,10 +17620,76 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17079
17620
|
// kiro-cli gates its own --effort by version at launch (a quiet console note otherwise); muse always passes it.
|
|
17080
17621
|
return () => true;
|
|
17081
17622
|
}
|
|
17082
|
-
/**
|
|
17083
|
-
|
|
17084
|
-
|
|
17085
|
-
|
|
17623
|
+
/**
|
|
17624
|
+
* #2072: what this host can run, from the published availability snapshot (backend-availability.ts). Read only: a
|
|
17625
|
+
* handler never starts a probe, not even in the background — launch, the timer, and /login install or sign-in do.
|
|
17626
|
+
*/
|
|
17627
|
+
backendAvailabilityRows() {
|
|
17628
|
+
return this.backendAvailability.snapshot();
|
|
17629
|
+
}
|
|
17630
|
+
/**
|
|
17631
|
+
* #2072 part 3: the models a Settings field offers for `backend` — the cached catalog (an instance's own credential
|
|
17632
|
+
* profile's, when it is named and has one), claude's account catalog included. Read only: never a probe.
|
|
17633
|
+
*/
|
|
17634
|
+
modelCatalogFor(backend, instance) {
|
|
17635
|
+
if (!KNOWN_BACKENDS.includes(backend) || backend === "mock")
|
|
17636
|
+
return [];
|
|
17637
|
+
const own = instance && Object.prototype.hasOwnProperty.call(this.fleetConfig?.instances ?? {}, instance) ? instance
|
|
17638
|
+
: instance && this.classicChannels?.getChannelIdByInstance(instance) !== undefined ? instance : null;
|
|
17639
|
+
const env = this.readCliEnv(backend, this.catalogProfile(backend, own));
|
|
17640
|
+
const seen = new Set(), out = [];
|
|
17641
|
+
for (const m of [...(env?.models ?? []), ...(env?.apiModels ?? [])])
|
|
17642
|
+
if (!seen.has(m.id)) {
|
|
17643
|
+
seen.add(m.id);
|
|
17644
|
+
out.push(m);
|
|
17645
|
+
}
|
|
17646
|
+
return out;
|
|
17647
|
+
}
|
|
17648
|
+
/**
|
|
17649
|
+
* #2072 (#2078 review): the snapshot as THIS instance would see it. Its sign-in on a backend is its own credential
|
|
17650
|
+
* profile's when it has one there — the host's default login says nothing about it. A profile's login is a file check
|
|
17651
|
+
* (credentialProfileLogin), no process.
|
|
17652
|
+
*/
|
|
17653
|
+
backendAvailabilityFor(instanceName) {
|
|
17654
|
+
return this.backendAvailabilityRows().map(row => {
|
|
17655
|
+
const profile = this.credentialProfileOf(instanceName, row.backend);
|
|
17656
|
+
if (!profile || (row.state !== "usable" && row.state !== "not_signed_in" && row.state !== "unknown") || !row.path)
|
|
17657
|
+
return row;
|
|
17658
|
+
let login = "unknown";
|
|
17659
|
+
try {
|
|
17660
|
+
login = credentialProfileLogin(this.dataDir, row.backend, profile).state;
|
|
17661
|
+
}
|
|
17662
|
+
catch { /* unknown */ }
|
|
17663
|
+
// Signed in there: usable for this instance. Signed out (or unknown): the host's default login says nothing about
|
|
17664
|
+
// it, so no "not signed in" claim from it — update_instance_config's profile gate refuses a signed-out profile
|
|
17665
|
+
// when applied, naming the profile and the command that signs it in.
|
|
17666
|
+
if (login === "logged-in")
|
|
17667
|
+
return { ...row, auth: "valid", state: "usable" };
|
|
17668
|
+
return row.state === "not_signed_in" ? { ...row, auth: "unknown", state: "unknown" } : row;
|
|
17669
|
+
});
|
|
17670
|
+
}
|
|
17671
|
+
/** The probes the snapshot runs (tests replace them: no CLI, no account, no network). */
|
|
17672
|
+
availabilityProbes = hostAvailabilityProbes;
|
|
17673
|
+
availabilityStore = null;
|
|
17674
|
+
availabilityTimer = null;
|
|
17675
|
+
get backendAvailability() {
|
|
17676
|
+
return this.availabilityStore ??= new BackendAvailabilityStore(KNOWN_BACKENDS.filter(b => b !== "mock" && !!BACKEND_INSTALLATION_INFO[b]), this.availabilityProbes, { onChange: () => this.noteAvailabilityPaths() });
|
|
17677
|
+
}
|
|
17678
|
+
/** A launch need not look the binary up again (a synchronous `which` on the loop) when the snapshot found it. */
|
|
17679
|
+
noteAvailabilityPaths() {
|
|
17680
|
+
for (const row of this.availabilityStore?.snapshot() ?? []) {
|
|
17681
|
+
const binary = BACKEND_INSTALLATION_INFO[row.backend]?.binary;
|
|
17682
|
+
if (binary)
|
|
17683
|
+
noteResolvedBinary(binary, row.state === "not_installed" ? null : row.path);
|
|
17684
|
+
}
|
|
17685
|
+
}
|
|
17686
|
+
/** Probe now and then every AVAILABILITY_STALE_MS; a pass that finds nothing changed starts no CLI. */
|
|
17687
|
+
startAvailabilityRefresh() {
|
|
17688
|
+
void this.backendAvailability.refresh();
|
|
17689
|
+
if (this.availabilityTimer)
|
|
17690
|
+
clearInterval(this.availabilityTimer);
|
|
17691
|
+
this.availabilityTimer = setInterval(() => { void this.backendAvailability.refresh(); }, AVAILABILITY_STALE_MS);
|
|
17692
|
+
this.availabilityTimer.unref?.();
|
|
17086
17693
|
}
|
|
17087
17694
|
/** The models `/model` would offer on a backend: the cached CLI env, else (live) a bounded probe. Never throws. */
|
|
17088
17695
|
async backendModelOptions(backend, opts) {
|
|
@@ -17424,9 +18031,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17424
18031
|
}
|
|
17425
18032
|
return undefined;
|
|
17426
18033
|
}
|
|
18034
|
+
/** #2072: from the published snapshot (no `which` on the loop); not checked yet counts as installed (no false alarm). */
|
|
17427
18035
|
isBackendInstalled(backend) {
|
|
17428
|
-
|
|
17429
|
-
|
|
18036
|
+
if (!BACKEND_INSTALLATION_INFO[backend])
|
|
18037
|
+
return false;
|
|
18038
|
+
return this.backendAvailability.get(backend)?.state !== "not_installed";
|
|
17430
18039
|
}
|
|
17431
18040
|
getMissingBackendWarning(backend) {
|
|
17432
18041
|
if (!backend)
|
|
@@ -17764,6 +18373,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17764
18373
|
clearInterval(this.eventLogPruneTimer);
|
|
17765
18374
|
this.eventLogPruneTimer = null;
|
|
17766
18375
|
}
|
|
18376
|
+
if (this.guestRoomsTimer) {
|
|
18377
|
+
clearInterval(this.guestRoomsTimer);
|
|
18378
|
+
this.guestRoomsTimer = null;
|
|
18379
|
+
}
|
|
17767
18380
|
this.logService?.unwatchAll(); // #1218: a Log's watch timers end with the fleet
|
|
17768
18381
|
this.compactWatchService?.stopAll();
|
|
17769
18382
|
if (this.outboxPruneTimer) {
|
|
@@ -17863,6 +18476,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17863
18476
|
this.adapters.clear();
|
|
17864
18477
|
this.controlClient?.stop();
|
|
17865
18478
|
this.controlClient = null;
|
|
18479
|
+
if (this.availabilityTimer) {
|
|
18480
|
+
clearInterval(this.availabilityTimer);
|
|
18481
|
+
this.availabilityTimer = null;
|
|
18482
|
+
}
|
|
17866
18483
|
if (this.healthServer) {
|
|
17867
18484
|
this.healthServerListening = false;
|
|
17868
18485
|
const server = this.healthServer;
|
|
@@ -19847,6 +20464,147 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19847
20464
|
this.rejectedHostsLogged.add(name);
|
|
19848
20465
|
this.logger.warn({ host: name }, "Web request refused: Host is not allowed (add it to web.allowed_hosts if this is a proxy you run)");
|
|
19849
20466
|
}
|
|
20467
|
+
/**
|
|
20468
|
+
* #1920 §3: the instances a guest session may see — derived on every request from the account's chat access, never
|
|
20469
|
+
* stored; nothing while `web.guests.enabled` is not true.
|
|
20470
|
+
* - Its connection's fleet topics and General (what it may talk to in chat): when that connection's access lets the
|
|
20471
|
+
* account in (AccessManager.isAllowed — allowed_users or paired). An `open` connection lets everyone in, so it gives
|
|
20472
|
+
* only the instances an admin named for its guests (`web.guests.open_instances`; the user's decision: least
|
|
20473
|
+
* privilege, as an open group has no list of people).
|
|
20474
|
+
* - A Telegram private ClassicBot room that is the account's own (its chat id is the user id).
|
|
20475
|
+
* - A ClassicBot group or guild room on its connection: only while its membership is verified (§3.2 — a "member"
|
|
20476
|
+
* answer read in the background, at most 15 minutes old; unknown, failed or stale hides it).
|
|
20477
|
+
* - Then an admin's narrowing (`web.guests.narrow`), which only takes away.
|
|
20478
|
+
* A fleet admin is not a guest: an admin outside the connection's access gets nothing here (admins sign in as admins).
|
|
20479
|
+
*/
|
|
20480
|
+
guestScope(owner) {
|
|
20481
|
+
const guests = this.fleetConfig?.web?.guests;
|
|
20482
|
+
if (!owner || guests?.enabled !== true)
|
|
20483
|
+
return NO_GUEST_SCOPE;
|
|
20484
|
+
const own = (map, key) => (map && Object.hasOwn(map, key) ? map[key] : undefined);
|
|
20485
|
+
const scope = new Set();
|
|
20486
|
+
const world = this.worlds.get(owner.adapterId);
|
|
20487
|
+
if (world && world.accessManager.isAllowed(owner.userId)) {
|
|
20488
|
+
const named = world.accessManager.getMode() === "open" ? new Set(own(guests.open_instances, owner.adapterId) ?? []) : null;
|
|
20489
|
+
for (const name of Object.keys(this.fleetConfig?.instances ?? {})) {
|
|
20490
|
+
if (this.getInstanceAdapterId(name) !== owner.adapterId || this.isClassicInstance(name))
|
|
20491
|
+
continue;
|
|
20492
|
+
if (!named || named.has(name))
|
|
20493
|
+
scope.add(name);
|
|
20494
|
+
}
|
|
20495
|
+
}
|
|
20496
|
+
const rooms = this.classicChannels?.getAll() ?? [];
|
|
20497
|
+
for (const room of rooms) {
|
|
20498
|
+
if (this.isOwnPrivateRoom(owner, room))
|
|
20499
|
+
scope.add(room.instanceName);
|
|
20500
|
+
}
|
|
20501
|
+
const members = this.guestRooms();
|
|
20502
|
+
for (const room of guestGroupRooms(rooms, owner)) {
|
|
20503
|
+
if (members.verified(room, owner.userId))
|
|
20504
|
+
scope.add(room.instance);
|
|
20505
|
+
}
|
|
20506
|
+
const narrow = own(guests.narrow, `${owner.adapterId}:${owner.userId}`);
|
|
20507
|
+
if (narrow) {
|
|
20508
|
+
const keep = new Set(narrow);
|
|
20509
|
+
for (const name of [...scope])
|
|
20510
|
+
if (!keep.has(name))
|
|
20511
|
+
scope.delete(name);
|
|
20512
|
+
}
|
|
20513
|
+
return scope.size ? scope : NO_GUEST_SCOPE;
|
|
20514
|
+
}
|
|
20515
|
+
/**
|
|
20516
|
+
* #1920 §3.5: a guest session still admitted — current, with something in scope. A scope that became empty (guest
|
|
20517
|
+
* sign-in off, the account removed from the connection's access, everything narrowed away) ends the session here,
|
|
20518
|
+
* so the next request and the open stream find it gone.
|
|
20519
|
+
*/
|
|
20520
|
+
guestStillAdmitted(record) {
|
|
20521
|
+
return this.guestAdmission(record) === "ok";
|
|
20522
|
+
}
|
|
20523
|
+
/**
|
|
20524
|
+
* The same, with a third answer (§3.2): "pending" — nothing in scope now, but a room's membership is not read yet
|
|
20525
|
+
* (a restart, a room just created). The request is refused and the session kept; its read starts in the background.
|
|
20526
|
+
*/
|
|
20527
|
+
guestAdmission(record) {
|
|
20528
|
+
if (!this.webSessions || !this.webToken || !this.webSessions.isCurrent(record, tokenEpoch(this.webToken)))
|
|
20529
|
+
return "gone";
|
|
20530
|
+
const owner = record.owner;
|
|
20531
|
+
if (this.guestScope(owner).size)
|
|
20532
|
+
return "ok";
|
|
20533
|
+
if (owner && this.fleetConfig?.web?.guests?.enabled === true) {
|
|
20534
|
+
const members = this.guestRooms(), rooms = guestGroupRooms(this.classicChannels?.getAll() ?? [], owner);
|
|
20535
|
+
if (rooms.some((room) => members.pending(room, owner.userId))) {
|
|
20536
|
+
void this.refreshGuestRooms(owner, { mode: "triggered" }); // rate-limited per room (30 s), single-flight
|
|
20537
|
+
return "pending";
|
|
20538
|
+
}
|
|
20539
|
+
}
|
|
20540
|
+
this.enforceGuestSwitch();
|
|
20541
|
+
const ended = this.webSessions.revokeByHandle(record.handle);
|
|
20542
|
+
if (ended.found)
|
|
20543
|
+
this.logger.info({ adapterId: record.owner?.adapterId, durable: ended.durable }, "Guest web session ended: nothing left in its scope");
|
|
20544
|
+
return "gone";
|
|
20545
|
+
}
|
|
20546
|
+
/** #1920 §3.2: the membership answers; the read goes to the room's own connection, never waits past its deadline. */
|
|
20547
|
+
guestRooms() {
|
|
20548
|
+
this.guestRoomMembership ??= new GuestRoomMembership({
|
|
20549
|
+
now: () => performance.now(),
|
|
20550
|
+
read: (room, userId) => {
|
|
20551
|
+
const adapter = this.worlds.get(room.adapterId)?.adapter;
|
|
20552
|
+
return room.kind === "telegram" ? adapter?.isGroupMember?.(room.channelId, userId) : adapter?.canViewChannel?.(room.channelId, userId);
|
|
20553
|
+
},
|
|
20554
|
+
// Said once per key while its reads keep failing (no user id: the room and connection are what an admin fixes).
|
|
20555
|
+
onPersistentFailure: (room, failures) => this.logger.warn({ adapterId: room.adapterId, instance: room.instance, failures }, "Guest room membership keeps failing to read (the bot may lack the permission, or was removed); retrying with backoff up to 10 minutes"),
|
|
20556
|
+
onRecovered: (room) => this.logger.info({ adapterId: room.adapterId, instance: room.instance }, "Guest room membership reads again"),
|
|
20557
|
+
});
|
|
20558
|
+
return this.guestRoomMembership;
|
|
20559
|
+
}
|
|
20560
|
+
/** #1920 §3.2: read this guest's rooms that are due (GuestRoomMembership.refresh says when each mode reads). */
|
|
20561
|
+
async refreshGuestRooms(owner, opts = {}) {
|
|
20562
|
+
if (this.fleetConfig?.web?.guests?.enabled !== true)
|
|
20563
|
+
return;
|
|
20564
|
+
try {
|
|
20565
|
+
await this.guestRooms().refresh(owner, guestGroupRooms(this.classicChannels?.getAll() ?? [], owner), opts);
|
|
20566
|
+
}
|
|
20567
|
+
catch (err) {
|
|
20568
|
+
this.logger.warn({ err: err.message }, "Guest room membership could not be read");
|
|
20569
|
+
}
|
|
20570
|
+
}
|
|
20571
|
+
/** Every account with a live guest session, one after another (the guest caps bound them); one pass at a time. */
|
|
20572
|
+
async refreshAllGuestRooms() {
|
|
20573
|
+
if (this.guestRoomsPass || this.fleetConfig?.web?.guests?.enabled !== true || !this.webSessions)
|
|
20574
|
+
return;
|
|
20575
|
+
this.guestRoomsPass = true;
|
|
20576
|
+
try {
|
|
20577
|
+
for (const owner of this.webSessions.guestOwners())
|
|
20578
|
+
await this.refreshGuestRooms(owner);
|
|
20579
|
+
}
|
|
20580
|
+
finally {
|
|
20581
|
+
this.guestRoomsPass = false;
|
|
20582
|
+
}
|
|
20583
|
+
}
|
|
20584
|
+
guestRoomsPass = false;
|
|
20585
|
+
/**
|
|
20586
|
+
* #1920 §3.5: guest sign-in turned off ends every guest session — checked when the configuration is read or saved
|
|
20587
|
+
* and when the sessions are opened, so turning it back on never revives one.
|
|
20588
|
+
*/
|
|
20589
|
+
enforceGuestSwitch() {
|
|
20590
|
+
if (this.fleetConfig?.web?.guests?.enabled === true || !this.webSessions)
|
|
20591
|
+
return;
|
|
20592
|
+
const ended = this.webSessions.revokeAll((r) => r.tier === "guest");
|
|
20593
|
+
if (ended.count)
|
|
20594
|
+
this.logger.info({ count: ended.count, durable: ended.durable }, "Guest web sign-in is off: every guest session ended");
|
|
20595
|
+
// #1920 step 4b: and every guest's returning device — turning it back on lets no old browser ask for a code.
|
|
20596
|
+
const devices = this.returningDevices?.revokeGuest();
|
|
20597
|
+
if (devices?.count)
|
|
20598
|
+
this.logger.info({ count: devices.count, durable: devices.durable }, "Guest web sign-in is off: every guest's returning device forgotten");
|
|
20599
|
+
}
|
|
20600
|
+
/** #1920 §5.2: whether this instance is the account's own Telegram private ClassicBot room (its chat id is the user id). */
|
|
20601
|
+
guestOwnRoom(owner, instance) {
|
|
20602
|
+
return (this.classicChannels?.getAll() ?? []).some((room) => room.instanceName === instance && this.isOwnPrivateRoom(owner, room));
|
|
20603
|
+
}
|
|
20604
|
+
/** A private room's chat id is its one user's id (a group's is negative, so never equal); Telegram only. */
|
|
20605
|
+
isOwnPrivateRoom(owner, room) {
|
|
20606
|
+
return room.adapterId === owner.adapterId && room.channelId === owner.userId && inferClassicChannelType(room.channelId) === "telegram";
|
|
20607
|
+
}
|
|
19850
20608
|
/** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
|
|
19851
20609
|
dispatchWebHttp(req, res, port, closing = false) {
|
|
19852
20610
|
try {
|
|
@@ -19907,6 +20665,40 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19907
20665
|
res.end(JSON.stringify({ error: decision.message }));
|
|
19908
20666
|
return;
|
|
19909
20667
|
}
|
|
20668
|
+
// #1920: a guest reaches only GUEST_ROUTES, within the scope derived for it now — default deny, before any
|
|
20669
|
+
// handler, on this listener and the public link's gateway alike (both come through here).
|
|
20670
|
+
if (decision.via === "session" && decision.session.tier === "guest") {
|
|
20671
|
+
const owner = decision.session.owner;
|
|
20672
|
+
const admitted = decision.session;
|
|
20673
|
+
const admission = this.guestAdmission(admitted);
|
|
20674
|
+
if (admission === "pending") {
|
|
20675
|
+
// §3.2: a room's membership is being read — try again shortly; the session stays.
|
|
20676
|
+
res.writeHead(503, { "Retry-After": "5" });
|
|
20677
|
+
res.end(JSON.stringify({ error: "guest_scope_pending" }));
|
|
20678
|
+
return;
|
|
20679
|
+
}
|
|
20680
|
+
if (admission === "gone") {
|
|
20681
|
+
res.writeHead(401);
|
|
20682
|
+
res.end(JSON.stringify({ error: "guest_scope_empty" }));
|
|
20683
|
+
return;
|
|
20684
|
+
}
|
|
20685
|
+
const verdict = guestRouteDecision(req.method ?? "GET", parsedUrl, this.guestScope(owner));
|
|
20686
|
+
if (verdict.kind === "deny") {
|
|
20687
|
+
res.writeHead(verdict.status);
|
|
20688
|
+
res.end(JSON.stringify({ error: verdict.error }));
|
|
20689
|
+
return;
|
|
20690
|
+
}
|
|
20691
|
+
// Phase 1b: the handlers that answer per recipient (the stream, the poll, a file) know it is a guest's, and
|
|
20692
|
+
// ask its scope anew each time; an emptied scope ends the session (and closes its stream).
|
|
20693
|
+
bindGuestRequest(req, { owner, scope: () => this.guestScope(owner), current: () => this.guestStillAdmitted(admitted),
|
|
20694
|
+
// #1920 §5.2: its own Telegram private room shows it everything; the kept messages place ticks and buttons.
|
|
20695
|
+
ownRoom: (instance) => this.guestOwnRoom(owner, instance),
|
|
20696
|
+
// §3.2: a stream that connects reads its rooms again (in the background; at most once a minute).
|
|
20697
|
+
streamOpened: () => { void this.refreshGuestRooms(owner, { mode: "force" }); },
|
|
20698
|
+
history: () => this.webChatHistory,
|
|
20699
|
+
// #1920 §5.1: guests chat unless the host made them read-only (web.guests.chat: false).
|
|
20700
|
+
chat: () => this.fleetConfig?.web?.guests?.chat !== false });
|
|
20701
|
+
}
|
|
19910
20702
|
}
|
|
19911
20703
|
if (req.method === "GET" && req.url === "/health") {
|
|
19912
20704
|
const health = this.getFleetHealth();
|
|
@@ -20297,6 +21089,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20297
21089
|
this.logger.warn({ code: err.code, port: settings.port }, "Preview listener unavailable; HTML previews are off");
|
|
20298
21090
|
this.previewListening = false;
|
|
20299
21091
|
this.previewListener = null;
|
|
21092
|
+
this.previewListenError = `${settings.port}: ${err.code ?? err.message}`;
|
|
20300
21093
|
});
|
|
20301
21094
|
// An optional listener never takes the fleet down: a port Node refuses outright throws here, synchronously.
|
|
20302
21095
|
try {
|
|
@@ -20307,6 +21100,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20307
21100
|
this.logger.warn({ err: err.message, port: settings.port }, "Preview listener cannot listen; HTML previews are off");
|
|
20308
21101
|
this.previewListening = false;
|
|
20309
21102
|
this.previewListener = null;
|
|
21103
|
+
this.previewListenError = `${settings.port}: ${err.code ?? err.message}`;
|
|
20310
21104
|
}
|
|
20311
21105
|
}
|
|
20312
21106
|
/** What a reload may change hot: web.preview, and the dashboard names (the shim's allow-list and frame-ancestors). */
|
|
@@ -20327,6 +21121,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20327
21121
|
}
|
|
20328
21122
|
stopPreviewListener() {
|
|
20329
21123
|
this.previewListening = false;
|
|
21124
|
+
this.previewListenError = null;
|
|
20330
21125
|
this.previewListener?.close();
|
|
20331
21126
|
this.previewListener = null;
|
|
20332
21127
|
}
|
|
@@ -20334,10 +21129,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20334
21129
|
previewForUi(hostHeader, secure) {
|
|
20335
21130
|
const listener = this.previewListening ? this.previewListener : null;
|
|
20336
21131
|
const decided = previewAvailability(listener ? listener.settings : null, hostHeader, secure, listener ? listener.origins : undefined);
|
|
21132
|
+
// Previews are on, but no listener answers: say why — its port could not be opened (the error, never "turned off"),
|
|
21133
|
+
// or it is not listening yet.
|
|
20337
21134
|
if (!listener && this.fleetConfig?.web?.preview !== false)
|
|
20338
|
-
decided
|
|
21135
|
+
Object.assign(decided, this.previewNotListening());
|
|
20339
21136
|
return { ...decided, boot: listener ? listener.bootId : null };
|
|
20340
21137
|
}
|
|
21138
|
+
previewNotListening() {
|
|
21139
|
+
const failed = this.previewListenError;
|
|
21140
|
+
return failed
|
|
21141
|
+
? { code: "listenFailed", detail: failed, reason: `Previews are unavailable: the fleet could not open its preview port (${failed}). Another program may be using it — set web.preview_port, then restart AgEnD.` }
|
|
21142
|
+
: { code: "notListening", reason: "Previews are not available on this fleet right now." };
|
|
21143
|
+
}
|
|
20341
21144
|
/**
|
|
20342
21145
|
* #1723: for one page of the public link (exposure `exposureId`, at `dashboardOrigin`): its own preview tunnel's
|
|
20343
21146
|
* origin once up, "starting" until then, why it failed if it did — or a configured web.preview_origin, used instead.
|
|
@@ -20352,8 +21155,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20352
21155
|
const configured = typeof web?.preview_origin === "string" ? normalizeOrigin(web.preview_origin) : null;
|
|
20353
21156
|
if (configured) {
|
|
20354
21157
|
const listener = this.previewListening ? this.previewListener : null;
|
|
20355
|
-
if (!listener)
|
|
20356
|
-
|
|
21158
|
+
if (!listener) {
|
|
21159
|
+
const why = this.previewNotListening();
|
|
21160
|
+
return off(why.code, why.reason, why.detail ? { detail: why.detail } : {});
|
|
21161
|
+
}
|
|
20357
21162
|
if (!configured.startsWith("https:"))
|
|
20358
21163
|
return off("needHttps", "Previews need an https web.preview_origin when the dashboard is reached over https.");
|
|
20359
21164
|
return { dashboardOrigin, previewOrigin: configured, reason: null, code: null, boot: listener.bootId, frameSrc: `${configured}/frame` };
|
|
@@ -20493,6 +21298,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20493
21298
|
// #1685: host memory pressure while it slows or holds new agents (the dashboard's indicator; macOS elevated is
|
|
20494
21299
|
// shown here rather than in chat). null when normal, unknown or advisory.
|
|
20495
21300
|
host_memory: this.hostMemoryIndicator(),
|
|
21301
|
+
// #2015: the largest processes while it lasts (name, pid, RSS, and whose: an instance, the fleet or tmux).
|
|
21302
|
+
...(this.hostMemoryIndicator() && this.memoryTop ? { host_memory_top: this.memoryTop.processes } : {}),
|
|
20496
21303
|
};
|
|
20497
21304
|
}
|
|
20498
21305
|
}
|