@songsid/agend 2.2.0 → 2.2.5
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-cli-instructions.md +5 -2
- package/dist/agent-cli.js +284 -2
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +2 -0
- package/dist/agent-endpoint.js +29 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-file-read.d.ts +38 -0
- package/dist/agent-file-read.js +317 -0
- package/dist/agent-file-read.js.map +1 -0
- package/dist/agent-whats-new.d.ts +11 -0
- package/dist/agent-whats-new.js +34 -2
- package/dist/agent-whats-new.js.map +1 -1
- package/dist/atomic-write.js +9 -1
- package/dist/atomic-write.js.map +1 -1
- package/dist/auth-api.d.ts +30 -1
- package/dist/auth-api.js +61 -12
- package/dist/auth-api.js.map +1 -1
- package/dist/authority/authority-state.d.ts +38 -0
- package/dist/authority/authority-state.js +114 -0
- package/dist/authority/authority-state.js.map +1 -0
- package/dist/authority/session-authorizations.d.ts +43 -0
- package/dist/authority/session-authorizations.js +102 -0
- package/dist/authority/session-authorizations.js.map +1 -0
- package/dist/authority/web-authority.d.ts +120 -0
- package/dist/authority/web-authority.js +157 -0
- package/dist/authority/web-authority.js.map +1 -0
- package/dist/backend/antigravity.js +3 -3
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.d.ts +2 -2
- package/dist/backend/claude-code.js +10 -5
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/launch-effort.js +1 -1
- package/dist/backend/launch-effort.js.map +1 -1
- package/dist/backend/muse.js +10 -2
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +14 -0
- package/dist/backend/types.js +25 -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/bundled-skills.d.ts +14 -0
- package/dist/bundled-skills.js +29 -0
- package/dist/bundled-skills.js.map +1 -0
- package/dist/cache-analysis.d.ts +47 -1
- package/dist/cache-analysis.js +68 -8
- package/dist/cache-analysis.js.map +1 -1
- package/dist/cache-ledger.d.ts +16 -3
- package/dist/cache-ledger.js +33 -13
- package/dist/cache-ledger.js.map +1 -1
- package/dist/cache-report-tool.d.ts +17 -0
- package/dist/cache-report-tool.js +40 -0
- package/dist/cache-report-tool.js.map +1 -0
- package/dist/cache-service.d.ts +70 -3
- package/dist/cache-service.js +178 -17
- package/dist/cache-service.js.map +1 -1
- package/dist/channel/adapters/discord-client-retirement.d.ts +5 -0
- package/dist/channel/adapters/discord-client-retirement.js +57 -0
- package/dist/channel/adapters/discord-client-retirement.js.map +1 -0
- package/dist/channel/adapters/discord.d.ts +11 -0
- package/dist/channel/adapters/discord.js +66 -6
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +8 -1
- package/dist/channel/adapters/telegram.js +74 -4
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-tools.js +48 -2
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/types.d.ts +21 -0
- package/dist/classic-channel-manager.d.ts +10 -5
- package/dist/classic-channel-manager.js +12 -9
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +142 -110
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +119 -4
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.d.ts +10 -0
- package/dist/daemon.js +327 -31
- package/dist/daemon.js.map +1 -1
- package/dist/decision-list.d.ts +25 -0
- package/dist/decision-list.js +30 -0
- package/dist/decision-list.js.map +1 -0
- package/dist/delivery-outbox.d.ts +10 -0
- package/dist/delivery-outbox.js +12 -1
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-context.d.ts +15 -0
- package/dist/fleet-manager.d.ts +514 -12
- package/dist/fleet-manager.js +1885 -163
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-process-handlers.d.ts +18 -0
- package/dist/fleet-process-handlers.js +69 -0
- package/dist/fleet-process-handlers.js.map +1 -0
- package/dist/fleet-traffic.d.ts +74 -0
- package/dist/fleet-traffic.js +196 -0
- package/dist/fleet-traffic.js.map +1 -0
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +20 -5
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +6 -1
- package/dist/general-knowledge/skills/multi-channel/SKILL.md +3 -2
- package/dist/general-knowledge/skills/persona-emoji/SKILL.md +1 -1
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +492 -0
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +482 -0
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
- package/dist/human-delivery-receipts.d.ts +24 -1
- package/dist/human-delivery-receipts.js +64 -11
- package/dist/human-delivery-receipts.js.map +1 -1
- package/dist/instance-avatar.js +3 -0
- package/dist/instance-avatar.js.map +1 -1
- package/dist/instance-config-impact.js +6 -1
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +2 -0
- package/dist/instance-lifecycle.js +20 -10
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instance-names.d.ts +4 -0
- package/dist/instance-names.js +12 -0
- package/dist/instance-names.js.map +1 -0
- package/dist/instructions.d.ts +12 -1
- package/dist/instructions.js +13 -2
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +100 -8
- package/dist/locale.js.map +1 -1
- package/dist/ls-attach-resolve.d.ts +95 -0
- package/dist/ls-attach-resolve.js +174 -0
- package/dist/ls-attach-resolve.js.map +1 -0
- 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/needs-you-hub.d.ts +4 -0
- package/dist/needs-you-hub.js +31 -6
- package/dist/needs-you-hub.js.map +1 -1
- package/dist/needs-you.d.ts +1 -13
- package/dist/needs-you.js +3 -37
- package/dist/needs-you.js.map +1 -1
- package/dist/outbound-handlers.d.ts +15 -0
- package/dist/outbound-handlers.js +152 -11
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +228 -4
- package/dist/outbound-schemas.js +147 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +21 -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 +4 -0
- package/dist/reply-buttons-controller.js +2 -2
- package/dist/reply-buttons-controller.js.map +1 -1
- package/dist/repo-worktree-guard.d.ts +36 -0
- package/dist/repo-worktree-guard.js +143 -0
- package/dist/repo-worktree-guard.js.map +1 -0
- package/dist/restart-progress.d.ts +65 -1
- package/dist/restart-progress.js +236 -10
- package/dist/restart-progress.js.map +1 -1
- package/dist/rollout-index.d.ts +5 -0
- package/dist/rollout-index.js +26 -5
- package/dist/rollout-index.js.map +1 -1
- package/dist/scheduler/scheduler.d.ts +70 -1
- package/dist/scheduler/scheduler.js +256 -17
- package/dist/scheduler/scheduler.js.map +1 -1
- package/dist/service-installer.d.ts +2 -1
- package/dist/service-installer.js +18 -3
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.d.ts +23 -0
- package/dist/settings-api.js +233 -2
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +112 -0
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +32 -2
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.js +5 -0
- package/dist/settings-http-confirmation.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.d.ts +4 -6
- package/dist/tool-permissions.js +100 -2
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.d.ts +17 -7
- package/dist/topic-commands.js +39 -21
- package/dist/topic-commands.js.map +1 -1
- package/dist/topic-link.d.ts +128 -0
- package/dist/topic-link.js +269 -0
- package/dist/topic-link.js.map +1 -0
- package/dist/types.d.ts +61 -1
- package/dist/ui/app-effects.js +245 -0
- package/dist/ui/chat-ambient.js +307 -0
- package/dist/ui/chat-effect-posters.js +42 -0
- package/dist/ui/chat-effects.js +160 -0
- package/dist/ui/chat-live-replies.js +58 -0
- package/dist/ui/chat-render.js +25 -7
- package/dist/ui/chat-store.js +41 -13
- package/dist/ui/chat-strings.js +86 -2
- package/dist/ui/chat-thread.js +204 -37
- package/dist/ui/chat-tour.js +1 -1
- package/dist/ui/effect-trial-controller.js +124 -0
- package/dist/ui/effect-trial-offer.js +31 -0
- package/dist/ui/effect-trial-panel.js +40 -0
- package/dist/ui/fleet-cache.js +23 -1
- package/dist/ui/fleet-canvas-layout.js +111 -0
- package/dist/ui/fleet-canvas.js +525 -0
- package/dist/ui/fleet-org.js +27 -12
- package/dist/ui/fleet-traffic.js +343 -0
- package/dist/ui/fleet-usage.js +401 -0
- package/dist/ui/image-lightbox.js +1 -1
- package/dist/ui/panel-chat.js +252 -58
- package/dist/ui/panel-details.js +20 -6
- package/dist/ui/panel-fleet.js +45 -19
- package/dist/ui/panel-log.js +6 -6
- package/dist/ui/panel-needs.js +3 -3
- package/dist/ui/panel-settings.js +58 -13
- package/dist/ui/preview-panel.js +25 -9
- package/dist/ui/preview.js +3 -2
- package/dist/ui/settings-appearance.js +349 -0
- package/dist/ui/settings-catalog.js +92 -0
- package/dist/ui/settings-dialogs.js +40 -12
- package/dist/ui/settings-effects.js +202 -0
- package/dist/ui/settings-guests.js +117 -0
- package/dist/ui/settings-learn.js +25 -0
- package/dist/ui/settings-model.js +15 -6
- package/dist/ui/settings-mods.js +271 -0
- package/dist/ui/settings-owner-effects.js +80 -0
- package/dist/ui/settings-strings.js +335 -10
- package/dist/ui/settings-web-authority.js +91 -0
- package/dist/ui/shared/agend-auth.js +28 -4
- package/dist/ui/shared/agent-widget.js +248 -0
- package/dist/ui/shared/app-i18n.js +6 -2
- package/dist/ui/shared/app-prefs.js +34 -13
- package/dist/ui/shared/app-route.js +42 -2
- package/dist/ui/shared/app-session.js +3 -1
- package/dist/ui/shared/app-shell.js +119 -41
- package/dist/ui/shared/app-store.js +28 -1
- package/dist/ui/shared/app-stream.js +14 -7
- package/dist/ui/shared/app-themes.js +206 -0
- package/dist/ui/shared/app.css +566 -5
- package/dist/ui/shared/app.js +23 -10
- package/dist/ui/shared/card-strings.js +32 -0
- package/dist/ui/shared/effect-app-runtime.js +297 -0
- package/dist/ui/shared/effect-baseline.js +64 -0
- package/dist/ui/shared/effect-chat-runtime.js +167 -0
- package/dist/ui/shared/effect-cursor.js +125 -0
- package/dist/ui/shared/effect-frame.js +146 -0
- package/dist/ui/shared/effect-geometry.js +137 -0
- package/dist/ui/shared/effect-host-style.js +10 -0
- package/dist/ui/shared/effect-owner-regions.js +74 -0
- package/dist/ui/shared/effect-owner-source.js +122 -0
- package/dist/ui/shared/effect-policy.js +151 -0
- package/dist/ui/shared/effect-poster-reader.js +98 -0
- package/dist/ui/shared/effect-posters.js +117 -0
- package/dist/ui/shared/effect-profiler.js +184 -0
- package/dist/ui/shared/effect-profiles.js +65 -0
- package/dist/ui/shared/effect-registry.js +543 -0
- package/dist/ui/shared/effect-runtime-loader.js +234 -0
- package/dist/ui/shared/effect-safety.js +153 -0
- package/dist/ui/shared/effect-stock-snapshot.js +43 -0
- package/dist/ui/shared/effect-stock-trust.js +217 -0
- package/dist/ui/shared/effect-trial-shadow.js +62 -0
- package/dist/ui/shared/effect-trial-source.js +103 -0
- package/dist/ui/shared/effect-trial-stage.js +170 -0
- package/dist/ui/shared/effect-trial.css +40 -0
- package/dist/ui/shared/fx-motion.js +67 -0
- package/dist/ui/shared/header-tools.js +1 -1
- package/dist/ui/shared/identity-card.js +273 -0
- package/dist/ui/shared/identity-color.js +261 -0
- package/dist/ui/shared/instance-nav.js +215 -41
- package/dist/ui/shared/owner-css.js +250 -0
- package/dist/ui/shared/owner-effect-prefs.js +117 -0
- package/dist/ui/shared/panel-view.js +103 -10
- package/dist/ui/shared/signin.js +2 -0
- package/dist/ui/shared/tokens.css +9 -3
- package/dist/ui/shared/ui-dialog.js +1 -1
- package/dist/ui/shared/ui-icons.js +3 -0
- package/dist/ui/shared/ui-menu.js +1 -1
- package/dist/ui/shared/ui-toast.js +1 -1
- package/dist/ui/shared/usage-dialog.js +22 -2
- package/dist/ui/shared/view-strings.js +98 -2
- package/dist/ui/shared/web-authorize.js +151 -0
- package/dist/ui/signin.html +1 -0
- package/dist/ui/web-terminal/terminal.css +4 -4
- package/dist/usage/providers.d.ts +28 -0
- package/dist/usage/providers.js +126 -36
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage-report.d.ts +158 -0
- package/dist/usage-report.js +226 -0
- package/dist/usage-report.js.map +1 -0
- package/dist/usage-rollup.d.ts +51 -0
- package/dist/usage-rollup.js +168 -0
- package/dist/usage-rollup.js.map +1 -0
- package/dist/view-api.d.ts +68 -0
- package/dist/view-api.js +249 -8
- package/dist/view-api.js.map +1 -1
- package/dist/web-ambient.d.ts +72 -0
- package/dist/web-ambient.js +161 -0
- package/dist/web-ambient.js.map +1 -0
- package/dist/web-api.d.ts +117 -7
- package/dist/web-api.js +1283 -70
- package/dist/web-api.js.map +1 -1
- package/dist/web-appearance-limits.d.ts +82 -0
- package/dist/web-appearance-limits.js +83 -0
- package/dist/web-appearance-limits.js.map +1 -0
- package/dist/web-appearance-policy.d.ts +64 -0
- package/dist/web-appearance-policy.js +87 -0
- package/dist/web-appearance-policy.js.map +1 -0
- package/dist/web-appearance-tools.d.ts +53 -0
- package/dist/web-appearance-tools.js +124 -0
- package/dist/web-appearance-tools.js.map +1 -0
- package/dist/web-appearance-view.d.ts +124 -0
- package/dist/web-appearance-view.js +278 -0
- package/dist/web-appearance-view.js.map +1 -0
- package/dist/web-appearance.d.ts +93 -0
- package/dist/web-appearance.js +373 -0
- package/dist/web-appearance.js.map +1 -0
- package/dist/web-asset-parts.d.ts +93 -0
- package/dist/web-asset-parts.js +133 -0
- package/dist/web-asset-parts.js.map +1 -0
- package/dist/web-auth.js +20 -2
- package/dist/web-auth.js.map +1 -1
- package/dist/web-card.d.ts +110 -0
- package/dist/web-card.js +266 -0
- package/dist/web-card.js.map +1 -0
- 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 +63 -0
- package/dist/web-chat-history.js +109 -6
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-commands.d.ts +1 -0
- package/dist/web-effect-bootstrap.d.ts +12 -0
- package/dist/web-effect-bootstrap.js +49 -0
- package/dist/web-effect-bootstrap.js.map +1 -0
- package/dist/web-effect-bundle.d.ts +28 -0
- package/dist/web-effect-bundle.js +171 -0
- package/dist/web-effect-bundle.js.map +1 -0
- package/dist/web-effect-canonical.d.ts +8 -0
- package/dist/web-effect-canonical.js +27 -0
- package/dist/web-effect-canonical.js.map +1 -0
- package/dist/web-effect-library.d.ts +114 -0
- package/dist/web-effect-library.js +530 -0
- package/dist/web-effect-library.js.map +1 -0
- package/dist/web-effect-playback.d.ts +30 -0
- package/dist/web-effect-playback.js +71 -0
- package/dist/web-effect-playback.js.map +1 -0
- package/dist/web-effect-poster.d.ts +8 -0
- package/dist/web-effect-poster.js +52 -0
- package/dist/web-effect-poster.js.map +1 -0
- package/dist/web-effect-profiles.d.ts +118 -0
- package/dist/web-effect-profiles.js +180 -0
- package/dist/web-effect-profiles.js.map +1 -0
- package/dist/web-effect-service.d.ts +93 -0
- package/dist/web-effect-service.js +335 -0
- package/dist/web-effect-service.js.map +1 -0
- package/dist/web-effect-tools.d.ts +48 -0
- package/dist/web-effect-tools.js +129 -0
- package/dist/web-effect-tools.js.map +1 -0
- package/dist/web-effect-trials.d.ts +86 -0
- package/dist/web-effect-trials.js +204 -0
- package/dist/web-effect-trials.js.map +1 -0
- package/dist/web-effect-upload.d.ts +27 -0
- package/dist/web-effect-upload.js +185 -0
- package/dist/web-effect-upload.js.map +1 -0
- package/dist/web-effect-worker.d.ts +8 -0
- package/dist/web-effect-worker.js +170 -0
- package/dist/web-effect-worker.js.map +1 -0
- package/dist/web-effect.d.ts +103 -0
- package/dist/web-effect.js +153 -0
- package/dist/web-effect.js.map +1 -0
- package/dist/web-guest-access.d.ts +125 -0
- package/dist/web-guest-access.js +234 -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-color.d.ts +87 -0
- package/dist/web-identity-color.js +209 -0
- package/dist/web-identity-color.js.map +1 -0
- package/dist/web-identity-palette.d.ts +113 -0
- package/dist/web-identity-palette.js +84 -0
- package/dist/web-identity-palette.js.map +1 -0
- package/dist/web-image-meta.d.ts +38 -0
- package/dist/web-image-meta.js +262 -0
- package/dist/web-image-meta.js.map +1 -0
- 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 +50 -0
- package/dist/web-mod-agent.js +207 -0
- package/dist/web-mod-agent.js.map +1 -0
- package/dist/web-mod-bundle.d.ts +157 -0
- package/dist/web-mod-bundle.js +529 -0
- package/dist/web-mod-bundle.js.map +1 -0
- package/dist/web-mod-export.d.ts +68 -0
- package/dist/web-mod-export.js +100 -0
- package/dist/web-mod-export.js.map +1 -0
- package/dist/web-mod-store.d.ts +289 -0
- package/dist/web-mod-store.js +638 -0
- package/dist/web-mod-store.js.map +1 -0
- package/dist/web-org.d.ts +3 -3
- package/dist/web-org.js +11 -17
- package/dist/web-org.js.map +1 -1
- package/dist/web-owner-css-store.d.ts +222 -0
- package/dist/web-owner-css-store.js +440 -0
- package/dist/web-owner-css-store.js.map +1 -0
- package/dist/web-owner-css.d.ts +58 -0
- package/dist/web-owner-css.js +552 -0
- package/dist/web-owner-css.js.map +1 -0
- package/dist/web-prefs.d.ts +62 -0
- package/dist/web-prefs.js +232 -36
- package/dist/web-prefs.js.map +1 -1
- package/dist/web-preview.d.ts +15 -3
- package/dist/web-preview.js +28 -9
- package/dist/web-preview.js.map +1 -1
- package/dist/web-returning-device.d.ts +41 -0
- package/dist/web-returning-device.js +57 -5
- package/dist/web-returning-device.js.map +1 -1
- package/dist/web-session-authorization.d.ts +132 -0
- package/dist/web-session-authorization.js +193 -0
- package/dist/web-session-authorization.js.map +1 -0
- package/dist/web-session.d.ts +70 -10
- package/dist/web-session.js +137 -22
- package/dist/web-session.js.map +1 -1
- package/dist/web-settings-patch.d.ts +67 -2
- package/dist/web-settings-patch.js +196 -1
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-settings-tools.d.ts +89 -0
- package/dist/web-settings-tools.js +273 -0
- package/dist/web-settings-tools.js.map +1 -0
- package/dist/web-shell-routes.d.ts +2 -2
- package/dist/web-shell-routes.js +2 -2
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/web-theme-store.d.ts +257 -0
- package/dist/web-theme-store.js +627 -0
- package/dist/web-theme-store.js.map +1 -0
- package/dist/web-theme.d.ts +123 -0
- package/dist/web-theme.js +416 -0
- package/dist/web-theme.js.map +1 -0
- package/dist/web-upload.d.ts +13 -0
- package/dist/web-upload.js +39 -3
- package/dist/web-upload.js.map +1 -1
- package/dist/web-widget.d.ts +72 -0
- package/dist/web-widget.js +245 -0
- package/dist/web-widget.js.map +1 -0
- package/dist/workflow-templates/default.md +1 -1
- package/package.json +1 -1
- package/templates/systemd.service.ejs +8 -0
package/dist/fleet-manager.js
CHANGED
|
@@ -20,14 +20,14 @@ import { withinBudget } from "./monotonic-budget.js";
|
|
|
20
20
|
import { measureSyncWork } from "./sync-work-attribution.js";
|
|
21
21
|
import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
|
|
22
22
|
import { ProfileControlServer } from "./profile-control.js";
|
|
23
|
-
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, constants as fsConstants, closeSync } from "node:fs";
|
|
23
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, lstatSync, constants as fsConstants, closeSync } from "node:fs";
|
|
24
24
|
import { atomicWriteFileSync } from "./atomic-write.js";
|
|
25
25
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
26
26
|
import { freemem, totalmem, cpus, homedir } from "node:os";
|
|
27
27
|
import { createServer } from "node:http";
|
|
28
28
|
import { Worker } from "node:worker_threads";
|
|
29
29
|
import { ProbeWorkerPool } from "./probe-worker-pool.js";
|
|
30
|
-
import { join, dirname, basename, delimiter, isAbsolute, resolve as resolvePath } from "node:path";
|
|
30
|
+
import { join, dirname, basename, delimiter, isAbsolute, relative, resolve as resolvePath } from "node:path";
|
|
31
31
|
import { fileURLToPath } from "node:url";
|
|
32
32
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
33
33
|
import { fleetLabel } from "./fleet-label.js";
|
|
@@ -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,19 +135,36 @@ 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
|
-
import { WebPrefsStore } from "./web-prefs.js";
|
|
143
|
+
import { OPERATOR, WebPrefsStore } from "./web-prefs.js";
|
|
144
|
+
import { agendVersion } from "./agent-whats-new.js";
|
|
145
|
+
import { OwnerCssStore } from "./web-owner-css-store.js";
|
|
146
|
+
import { ThemeStore } from "./web-theme-store.js";
|
|
147
|
+
import { WebAppearance } from "./web-appearance.js";
|
|
148
|
+
import { WebEffectLibrary } from "./web-effect-library.js";
|
|
149
|
+
import { WebEffectTrials } from "./web-effect-trials.js";
|
|
150
|
+
import { WebEffectService } from "./web-effect-service.js";
|
|
151
|
+
import { WebEffectProfiles } from "./web-effect-profiles.js";
|
|
152
|
+
import { WebEffectTools, WEB_EFFECT_TOOLS } from "./web-effect-tools.js";
|
|
153
|
+
import { resolveEffectParams } from "./web-effect.js";
|
|
154
|
+
import { cw1EffectChecker } from "./web-mod-bundle.js";
|
|
155
|
+
import { effectiveAppearanceLimits } from "./web-appearance-limits.js";
|
|
143
156
|
import { WEB_NAME_FALLBACK, safeWebName, validateWebName, webNameInUse, webPersonKey } from "./web-profile.js";
|
|
144
157
|
import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
|
|
145
158
|
import { ReplyButtonsController } from "./reply-buttons-controller.js";
|
|
146
159
|
import { parsePoll } from "./reply-polls.js";
|
|
147
160
|
import { isUploadRequest, limitRequestReceive, publicAttachment, sweepOrphanedUploads, UPLOAD_REQUEST_TIMEOUT_MS, uploadLimits, WebFileLedger } from "./web-upload.js";
|
|
148
161
|
import { REPLY_FILES_MAX_BYTES, snapshotReplyFile, sweepReplySnapshots, webFilesDir } from "./web-reply-files.js";
|
|
149
|
-
import { handleViewRequest, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
|
|
162
|
+
import { cardPicture, handleViewRequest, humanCards, identityColors, instanceTeams, isViewPath, personBannerFocus, personIdentityColour, profileIdentities, resolveInstanceIdentity, setPersonBannerFocus, setPersonIdentityColour } from "./view-api.js";
|
|
163
|
+
import { autoIdentityColor } from "./web-identity-color.js";
|
|
164
|
+
import { AppearanceTools } from "./web-appearance-tools.js";
|
|
165
|
+
import { cardVersion, resolveCard } from "./web-card.js";
|
|
166
|
+
import { ModStore } from "./web-mod-store.js";
|
|
167
|
+
import { AgentMods } from "./web-mod-agent.js";
|
|
150
168
|
import { downloadStickerImage } from "./channel/sticker-download.js";
|
|
151
169
|
import { WebImageCache, imageMimeForFile, WEB_IMAGE_MAX_AGE_MS, WEB_IMAGE_MAX_BYTES, WEB_IMAGE_RETRY_MS } from "./web-images.js";
|
|
152
170
|
import { accentColor, avatarFor, ownBotConnections } from "./instance-avatar.js";
|
|
@@ -161,7 +179,11 @@ import { runOnLoginShell } from "./login-shell.js";
|
|
|
161
179
|
import { tightenInstanceDirs } from "./private-dir.js";
|
|
162
180
|
import { decideSlash } from "./slash-authz.js";
|
|
163
181
|
import { canonicalCommand, commandSpec, decideCommand, ruleFor } from "./command-table.js";
|
|
164
|
-
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 { Delegations, TrafficLog, trafficItem } from "./fleet-traffic.js";
|
|
165
187
|
import { installedChannel, updateNoticeKey } from "./update-check.js";
|
|
166
188
|
import { resolveInstalledAgend } from "./update-dispatch.js";
|
|
167
189
|
import { resolveUpdateLaunch, openUpdateLog, updateLogPath, watchUpdateLaunch } from "./update-launch.js";
|
|
@@ -173,7 +195,7 @@ import { setLocale, detectLocale, getLocale, t } from "./locale.js";
|
|
|
173
195
|
import { recentChatContext } from "./log-tail.js";
|
|
174
196
|
import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet-control-audit.js";
|
|
175
197
|
import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
|
|
176
|
-
import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
|
|
198
|
+
import { ClassicChannelManager, classicWebEchoOn, getClassicBackendChoices, inferClassicChannelType, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
|
|
177
199
|
import { assertExplicitInstanceRemoval } from "./instance-removal.js";
|
|
178
200
|
import { KNOWN_BACKENDS, validateFleetConfig } from "./config-validator.js";
|
|
179
201
|
import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
|
|
@@ -192,7 +214,9 @@ import { NeedsYouHub } from "./needs-you-hub.js";
|
|
|
192
214
|
import { WEB_CHAT_NOTICE, claimNotice, hasWebChat, releaseNotice, upgradeNoticesPath } from "./upgrade-notices.js";
|
|
193
215
|
import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
|
|
194
216
|
import { buildOrgChart } from "./web-org.js";
|
|
195
|
-
import {
|
|
217
|
+
import { topMemoryHogs } from "./memory-hogs.js";
|
|
218
|
+
import { GENERAL_POINTING_REFUSALS, SNOWFLAKE, hasPlaceRefs, placeRef, webRoute, webTextLinks, placeSuffix, renderPlaceRefs, stripPlaceRefs, topicUrl, worldPlaceOf } from "./topic-link.js";
|
|
219
|
+
import { CacheService, cacheWindow } from "./cache-service.js";
|
|
196
220
|
import { runWebCommand } from "./web-commands.js";
|
|
197
221
|
import { CompactWatch, launchIdentity } from "./compact-watch.js";
|
|
198
222
|
import { agyLegacyAgentsFile, danglingSymlink, migrateAgyAgentsFile } from "./backend/antigravity.js";
|
|
@@ -202,11 +226,17 @@ import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefu
|
|
|
202
226
|
import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
|
|
203
227
|
import { bypassesWebGate, handleAuthRequest, serveSigninPage } from "./auth-api.js";
|
|
204
228
|
import { isWebPageNavigation } from "./web-shell-routes.js";
|
|
205
|
-
import { localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
|
|
206
|
-
import {
|
|
229
|
+
import { guestSessionPolicy, localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
|
|
230
|
+
import { authorityNow, evaluateWebAuthority, VERIFIED_SESSION_HOURS, webAuthorityPolicy } from "./authority/web-authority.js";
|
|
231
|
+
import { AuthorityStateStore } from "./authority/authority-state.js";
|
|
232
|
+
import { SessionAuthorizations } from "./authority/session-authorizations.js";
|
|
233
|
+
import { SessionAuthorizationRequests } from "./web-session-authorization.js";
|
|
234
|
+
import { GuestLoginCodes, WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
|
|
235
|
+
import { decisionListAnswer } from "./decision-list.js";
|
|
207
236
|
import { CodeRequestLimiter, ReturningDevices } from "./web-returning-device.js";
|
|
208
|
-
import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
|
|
237
|
+
import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, LOOPBACK_HOST_NAMES, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
|
|
209
238
|
import { createPreviewListener, normalizeOrigin, previewAvailability, previewSettings } from "./web-preview.js";
|
|
239
|
+
import { createEffectBootstrap } from "./web-effect-bootstrap.js";
|
|
210
240
|
import { fleetLevelDifferences, fleetLevelSignature, appendedConnections } from "./fleet-level-config.js";
|
|
211
241
|
import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
|
|
212
242
|
import { SecretStore } from "./secret-store.js";
|
|
@@ -216,7 +246,7 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
|
|
|
216
246
|
/** A self-restart is a whole service restart; 120s is the apply budget, not this. */
|
|
217
247
|
const SELF_RESTART_DEADLINE_MS = 300_000;
|
|
218
248
|
import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
|
|
219
|
-
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
249
|
+
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileLogin, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
220
250
|
import { carryClaudeConversation } from "./backend/claude-carry.js";
|
|
221
251
|
import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
|
|
222
252
|
import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
|
|
@@ -265,6 +295,41 @@ export function selectLruEvictions(warm, cap, opts) {
|
|
|
265
295
|
* fleet.yaml. Keep in sync with `uniqueInstanceName` — every name it emits
|
|
266
296
|
* must match here, or a deleted instance's CLI is left running (#1305 P2-3).
|
|
267
297
|
*/
|
|
298
|
+
/**
|
|
299
|
+
* #2163: where `p` really is — symlinks resolved. A path that does not exist yet is resolved through its deepest
|
|
300
|
+
* existing ancestor (a missing child of a link elsewhere is elsewhere too).
|
|
301
|
+
*/
|
|
302
|
+
function realPathThroughAncestors(p) {
|
|
303
|
+
const missing = [];
|
|
304
|
+
let at = resolvePath(p);
|
|
305
|
+
for (;;) {
|
|
306
|
+
try {
|
|
307
|
+
return join(realpathSync(at), ...missing.reverse());
|
|
308
|
+
}
|
|
309
|
+
catch { /* not there yet: try its parent */ }
|
|
310
|
+
try {
|
|
311
|
+
lstatSync(at);
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
catch { /* truly absent — not a broken link whose target is unknown */ }
|
|
315
|
+
const up = dirname(at);
|
|
316
|
+
if (up === at)
|
|
317
|
+
return resolvePath(p);
|
|
318
|
+
missing.push(basename(at));
|
|
319
|
+
at = up;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* #2163: whether `dir` is inside AgEnD's own workspaces (<AGEND_HOME>/workspaces/<name>/…), compared after resolving
|
|
324
|
+
* symlinks on both sides — including a missing folder's existing ancestors — so a link elsewhere is not inside, nor is a broken link.
|
|
325
|
+
*/
|
|
326
|
+
export function insideManagedWorkspaces(dir, agendHome = getAgendHome()) {
|
|
327
|
+
const root = realPathThroughAncestors(join(agendHome, "workspaces")), real = realPathThroughAncestors(dir);
|
|
328
|
+
if (root === null || real === null)
|
|
329
|
+
return false;
|
|
330
|
+
const rel = relative(root, real);
|
|
331
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
332
|
+
}
|
|
268
333
|
export function isOrphanInstanceWindowName(name) {
|
|
269
334
|
return /-t\d+$/.test(name) || /^classic-/.test(name);
|
|
270
335
|
}
|
|
@@ -486,6 +551,8 @@ function emojiListFilter(opts) {
|
|
|
486
551
|
withImageUrls: flag(opts.with_image_urls),
|
|
487
552
|
};
|
|
488
553
|
}
|
|
554
|
+
/** #1920: a guest's scope until phase 2 derives one: nothing. */
|
|
555
|
+
const NO_GUEST_SCOPE = new Set();
|
|
489
556
|
export class FleetManager {
|
|
490
557
|
dataDir;
|
|
491
558
|
static signalTarget = null;
|
|
@@ -714,7 +781,10 @@ export class FleetManager {
|
|
|
714
781
|
// Adapter restart: prevents re-entrant restart attempts
|
|
715
782
|
adapterRestarting = new Set();
|
|
716
783
|
// Adapter isolation: track state per adapter for retry + visibility
|
|
784
|
+
/** #2175: this boot's progress message, whose completion may still be owed by its background retry. */
|
|
785
|
+
startupProgress = null;
|
|
717
786
|
adapterState = new Map();
|
|
787
|
+
adapterRetryInFlight = new Map();
|
|
718
788
|
/** Web Settings secret rotation is deliberately separate from reconnect
|
|
719
789
|
* recovery: a rotation must build a fresh provider client with the new token,
|
|
720
790
|
* and stale callbacks from the old client must not win. */
|
|
@@ -752,10 +822,16 @@ export class FleetManager {
|
|
|
752
822
|
collabInstances = new Set();
|
|
753
823
|
// Health endpoint
|
|
754
824
|
healthServer = null;
|
|
825
|
+
/** #2118: independent of HTML preview availability/port; fixed bootstrap only, no effect resources. */
|
|
826
|
+
effectBootstrap = createEffectBootstrap();
|
|
827
|
+
/** Fixed trusted document coherence, independent of the HTML preview listener; never a resource grant. */
|
|
828
|
+
get effectBoot() { return this.effectBootstrap.boot; }
|
|
755
829
|
healthServerDrain = null;
|
|
756
830
|
/** #1306: the preview listener (health_port + 1 by default), and whether it is listening. */
|
|
757
831
|
previewListener = null;
|
|
758
832
|
previewListening = false;
|
|
833
|
+
/** The preview listener could not open its port: "<port>: <error code>", for the page (#2061 follow-up); null otherwise. */
|
|
834
|
+
previewListenError = null;
|
|
759
835
|
/** The ports the preview listener was started for, and the inputs it was built from (a reload compares them). */
|
|
760
836
|
previewPorts = null;
|
|
761
837
|
previewInputs = "";
|
|
@@ -774,6 +850,9 @@ export class FleetManager {
|
|
|
774
850
|
/** Injectable only to keep the chat→CLI reload hand-off deterministic in tests. */
|
|
775
851
|
fullRestartLauncher = launchFullRestartHelper;
|
|
776
852
|
eventLogPruneTimer = null;
|
|
853
|
+
/** #1920 §3.2: the background reads of guests' ClassicBot room membership (a minute's tick; reads only what is due). */
|
|
854
|
+
guestRoomsTimer = null;
|
|
855
|
+
guestRoomMembership = null;
|
|
777
856
|
outboxPruneTimer = null;
|
|
778
857
|
logRotateTimer = null;
|
|
779
858
|
discordPresenceTimer = null;
|
|
@@ -788,6 +867,17 @@ export class FleetManager {
|
|
|
788
867
|
startedAt = 0;
|
|
789
868
|
stormOpenNotifyTimer = null;
|
|
790
869
|
memoryLogAt = null;
|
|
870
|
+
/**
|
|
871
|
+
* #2015: while host memory is elevated or critical, the processes holding the most in the fleet's service and whose
|
|
872
|
+
* they are (an instance, the fleet, tmux), for the pressure log, /health and the dashboard — never chat (#1712).
|
|
873
|
+
* Rescanned at most once a minute (async, one at a time); cleared when pressure ends.
|
|
874
|
+
*/
|
|
875
|
+
memoryTop = null;
|
|
876
|
+
memoryTopScan = { at: null, inFlight: false, loggedAt: null };
|
|
877
|
+
/** The scan (a seam for tests: they give it a fake /proc). */
|
|
878
|
+
memoryHogScan = topMemoryHogs;
|
|
879
|
+
/** The fleet's own tmux session exists (only then are its panes read: a fleet that never started has none). */
|
|
880
|
+
tmuxSessionEnsured = false;
|
|
791
881
|
memoryDebugAt = null;
|
|
792
882
|
memoryLogLevel = null;
|
|
793
883
|
/** #1685: the host_memory value the last status frame carried, so a change re-sends the frame. */
|
|
@@ -884,6 +974,214 @@ export class FleetManager {
|
|
|
884
974
|
return true;
|
|
885
975
|
return false;
|
|
886
976
|
}
|
|
977
|
+
themeStoreInstance;
|
|
978
|
+
webAppearanceService;
|
|
979
|
+
/** #1629: every instance's theme, placements, assets and lock (web-themes.json). */
|
|
980
|
+
get themeStore() {
|
|
981
|
+
// r4.1 Q3 (b): the card's limits are read from fleet.yaml at each upload (a change applies without a restart).
|
|
982
|
+
return (this.themeStoreInstance ??= new ThemeStore(this.dataDir, { limits: () => effectiveAppearanceLimits(this.fleetConfig?.web?.appearance).limits, effectsDropped: ids => this.effectLibrary.collectUnreferenced(ids) }));
|
|
983
|
+
}
|
|
984
|
+
/** #1629: the theme tools, a person's reset and lock, and the one appearance rate limiter (shared with effects). */
|
|
985
|
+
get webAppearance() {
|
|
986
|
+
return (this.webAppearanceService ??= new WebAppearance(this.themeStore, () => this.fleetConfig?.web ?? null, () => { try {
|
|
987
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
988
|
+
}
|
|
989
|
+
catch { /* the next frame carries the rev */ } }));
|
|
990
|
+
}
|
|
991
|
+
effectLibraryInstance;
|
|
992
|
+
effectTrialsInstance;
|
|
993
|
+
/** Lazy, single-owner storage. Register every host reference source before any operation can collect. */
|
|
994
|
+
get effectLibrary() {
|
|
995
|
+
if (!this.effectLibraryInstance) {
|
|
996
|
+
const library = new WebEffectLibrary(this.dataDir);
|
|
997
|
+
// Providers are lazy; publish only after every registration succeeds.
|
|
998
|
+
library.registerReferenceSource("themes", () => {
|
|
999
|
+
if (!this.themeStore.readable())
|
|
1000
|
+
throw new Error("themes_unreadable");
|
|
1001
|
+
return this.themeStore.placedEffects().flatMap(row => row.ids.map(id => ({ id, holder: `instance:${row.name}` })));
|
|
1002
|
+
});
|
|
1003
|
+
library.registerReferenceSource("prefs", () => this.webPrefs.ownerEffectReferences());
|
|
1004
|
+
library.registerReferenceSource("mods", () => {
|
|
1005
|
+
const mods = this.mods.list();
|
|
1006
|
+
if (mods.unreadable)
|
|
1007
|
+
throw new Error("mods_unreadable");
|
|
1008
|
+
return mods.mods.flatMap(mod => mod.effects.map(effect => ({ id: effect.id, holder: `mod:${mod.id}` })));
|
|
1009
|
+
});
|
|
1010
|
+
this.effectTrialsInstance = new WebEffectTrials({ library, known: name => this.isKnownInstance(name) });
|
|
1011
|
+
this.effectLibraryInstance = library;
|
|
1012
|
+
}
|
|
1013
|
+
return this.effectLibraryInstance;
|
|
1014
|
+
}
|
|
1015
|
+
/** Volatile Try offers and session-bound runs share this library. */
|
|
1016
|
+
get effectTrials() { if (!this.effectTrialsInstance)
|
|
1017
|
+
void this.effectLibrary; return this.effectTrialsInstance; }
|
|
1018
|
+
effectProfilesInstance;
|
|
1019
|
+
get effectProfiles() { return (this.effectProfilesInstance ??= new WebEffectProfiles(this.dataDir)); }
|
|
1020
|
+
effectServiceInstance;
|
|
1021
|
+
get effectService() {
|
|
1022
|
+
return (this.effectServiceInstance ??= new WebEffectService({ library: this.effectLibrary, themes: this.themeStore, profiles: this.effectProfiles, prefs: this.webPrefs, trials: this.effectTrials,
|
|
1023
|
+
known: name => this.isKnownInstance(name), changed: () => { try {
|
|
1024
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
1025
|
+
}
|
|
1026
|
+
catch { /* next status carries revision */ } } }));
|
|
1027
|
+
}
|
|
1028
|
+
effectToolsService;
|
|
1029
|
+
/** Caller identity is supplied only by the owned IPC connection / verified agent endpoint token. */
|
|
1030
|
+
get effectTools() {
|
|
1031
|
+
return (this.effectToolsService ??= new WebEffectTools({
|
|
1032
|
+
library: this.effectLibrary, limiter: this.webAppearance.limiter,
|
|
1033
|
+
authorize: (name, buckets) => !this.isKnownInstance(name) ? { error: "unknown_instance" } : this.webAppearance.authorizeEffect(name, buckets),
|
|
1034
|
+
placed: name => this.themeStore.get(name).placements.effects,
|
|
1035
|
+
place: (name, effects) => {
|
|
1036
|
+
const before = this.themeStore.get(name).placements.effects;
|
|
1037
|
+
const explicit = new Set(effects.filter(e => typeof e !== "string").map(e => e.id));
|
|
1038
|
+
const w = this.themeStore.setPlacements(name, { effects }, "agent", what => {
|
|
1039
|
+
// An unchanged human/private placement may be retained, never exposed or newly acquired by an agent.
|
|
1040
|
+
const r = explicit.has(what.id) ? this.effectLibrary.read(what.id, { kind: "agent", id: name })
|
|
1041
|
+
: this.effectLibrary.readReferenced(what.id, `instance:${name}`);
|
|
1042
|
+
if (!r.ok)
|
|
1043
|
+
return r.error;
|
|
1044
|
+
const params = explicit.has(what.id) ? what.params : before.find(e => e.id === what.id)?.params;
|
|
1045
|
+
return resolveEffectParams(r.entry.manifest, params ?? {}).ok ? null : "effect_params";
|
|
1046
|
+
});
|
|
1047
|
+
return w.ok ? { ok: true, rev: w.rev } : { ok: false, error: w.error };
|
|
1048
|
+
},
|
|
1049
|
+
changed: () => { try {
|
|
1050
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
1051
|
+
}
|
|
1052
|
+
catch { /* later status carries placements */ } },
|
|
1053
|
+
// A nonmutating own/shared offer is not an appearance write. Only an explicit non-public admin-session click may start it.
|
|
1054
|
+
preview: async (instance, id) => {
|
|
1055
|
+
const result = this.effectTrials.offer(instance, id);
|
|
1056
|
+
if (!result.ok)
|
|
1057
|
+
return { error: "not_found" };
|
|
1058
|
+
const present = this.webChatHistory.list(instance).some(row => row.effectTrial?.offer === result.offer.offer);
|
|
1059
|
+
if (!present && !this.publishEffectTrial(instance, result.offer))
|
|
1060
|
+
return { error: "trial_unavailable" };
|
|
1061
|
+
return { id, status: "offered", expiresAt: result.offer.expiresAt, message: "A trial card is in your web chat. A signed-in admin outside the public link can start it; nothing was applied." };
|
|
1062
|
+
},
|
|
1063
|
+
profiles: id => this.effectProfiles.read(id),
|
|
1064
|
+
profileSummary: id => this.effectService.profileSummary(id),
|
|
1065
|
+
profileSummaries: ids => this.effectService.profileSummaries(ids),
|
|
1066
|
+
playbackSummary: (name, id) => this.effectService.playbackSummary(name, id),
|
|
1067
|
+
}));
|
|
1068
|
+
}
|
|
1069
|
+
appearanceToolsService;
|
|
1070
|
+
/** #1629 r4 §1: get_web_appearance / preview_web_appearance — the caller's own look, resolved as the page resolves it. */
|
|
1071
|
+
get appearanceTools() {
|
|
1072
|
+
return (this.appearanceToolsService ??= new AppearanceTools({
|
|
1073
|
+
frame: (name) => (this.getUiStatus().instances.find(i => i.name === name) ?? { name }),
|
|
1074
|
+
theme: (name) => {
|
|
1075
|
+
const r = this.themeStore.get(name);
|
|
1076
|
+
return { theme: r.theme, locked: r.locked, widgets: r.placements.widgets, ambient: r.placements.ambient,
|
|
1077
|
+
glyphAsset: (id) => this.themeStore.asset(name, id) };
|
|
1078
|
+
},
|
|
1079
|
+
effects: name => this.themeStore.readable() ? this.themeStore.get(name).placements.effects.map(p => ({ id: p.id, by: p.by, ...(p.params ? { params: p.params } : {}), playback: this.effectService.playbackSummary(name, p.id) })) : [],
|
|
1080
|
+
card: (name) => resolveCard(name, humanCards(this.dataDir).get(name), this.themeStore.get(name).theme.card),
|
|
1081
|
+
// #1958: the host's identity-colour settings, as the page reads them (an unreadable prefs file: the defaults).
|
|
1082
|
+
colorPrefs: () => { try {
|
|
1083
|
+
const v = this.webPrefs.read(OPERATOR).values;
|
|
1084
|
+
return { show: v["color.show"], off: v["color.off"] === true };
|
|
1085
|
+
}
|
|
1086
|
+
catch {
|
|
1087
|
+
return {};
|
|
1088
|
+
} },
|
|
1089
|
+
cardPicture: (name, slot) => cardPicture(this, name, slot),
|
|
1090
|
+
themeAsset: (name, id) => this.themeStore.asset(name, id),
|
|
1091
|
+
profileAvatar: (name) => this.webImage("avatar", name),
|
|
1092
|
+
botAvatar: (_name, key) => this.webImage("bot", key),
|
|
1093
|
+
themeAnswer: (name) => this.webAppearance.getTheme(name),
|
|
1094
|
+
}));
|
|
1095
|
+
}
|
|
1096
|
+
/** #1958: a profile, card or colour write (view-api.ts) landed: every open page gets a fresh status frame. */
|
|
1097
|
+
identityChanged() {
|
|
1098
|
+
try {
|
|
1099
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
1100
|
+
}
|
|
1101
|
+
catch { /* the next frame carries it */ }
|
|
1102
|
+
}
|
|
1103
|
+
/** A fleet.yaml instance (own key) or a ClassicBot room, now (#1629: a person's reset/lock names one). */
|
|
1104
|
+
isKnownInstance(name) {
|
|
1105
|
+
return Object.hasOwn(this.fleetConfig?.instances ?? {}, name) || (this.classicChannels?.getAll() ?? []).some((ch) => ch.instanceName === name);
|
|
1106
|
+
}
|
|
1107
|
+
modStore;
|
|
1108
|
+
/**
|
|
1109
|
+
* #1629 r4-5 / B2: installed mods use the same validated library and scoped holder claims as direct uploads.
|
|
1110
|
+
*/
|
|
1111
|
+
get mods() {
|
|
1112
|
+
return (this.modStore ??= new ModStore(this.dataDir, {
|
|
1113
|
+
themes: this.themeStore,
|
|
1114
|
+
prefs: this.webPrefs,
|
|
1115
|
+
ownerCss: this.ownerCss,
|
|
1116
|
+
effects: this.effectLibrary,
|
|
1117
|
+
context: () => ({ agendVersion: agendVersion(), limits: this.themeStore.limits(), effects: cw1EffectChecker() }),
|
|
1118
|
+
isKnownInstance: (name) => this.isKnownInstance(name),
|
|
1119
|
+
prefsIdentity: OPERATOR,
|
|
1120
|
+
}));
|
|
1121
|
+
}
|
|
1122
|
+
agentModsService;
|
|
1123
|
+
/** #1629 r4-5b: export_web_mod / propose_web_mod — the caller's own look as a file, and a proposal for a person. */
|
|
1124
|
+
get agentMods() {
|
|
1125
|
+
return (this.agentModsService ??= new AgentMods({
|
|
1126
|
+
themes: this.themeStore, mods: this.mods, limiter: this.webAppearance.limiter,
|
|
1127
|
+
webConfig: () => this.fleetConfig?.web ?? null,
|
|
1128
|
+
// The mod store's own: a proposal is checked exactly as the person's import of it will be.
|
|
1129
|
+
context: () => this.mods.checkContext(),
|
|
1130
|
+
readOwnEffect: (instance, id) => { const r = this.effectLibrary.read(id, { kind: "agent", id: instance }); return r.ok ? { name: r.entry.manifest.name, files: r.entry.files } : null; },
|
|
1131
|
+
notice: (instance, text) => { try {
|
|
1132
|
+
this.emitSseEvent("message", { instance, sender: instance, role: "status", text, ts: new Date().toISOString() });
|
|
1133
|
+
}
|
|
1134
|
+
catch { /* the page reads it from Settings too */ } },
|
|
1135
|
+
changed: () => { try {
|
|
1136
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
1137
|
+
}
|
|
1138
|
+
catch { /* the next frame */ } },
|
|
1139
|
+
}));
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* #1629 r4-5c: an effect as this person may carry it away in an export — their own claim, or one a person shared —
|
|
1143
|
+
* read with their principal (Sol's ACL). Being able to view or run a placed effect never grants packaging someone
|
|
1144
|
+
* else's private claim: the library's reference read is runtime access, not an export path. Null: not theirs.
|
|
1145
|
+
*/
|
|
1146
|
+
modPlacedEffect(person, id) {
|
|
1147
|
+
const r = this.effectLibrary.read(id, { kind: "human", id: person });
|
|
1148
|
+
return r.ok ? { name: r.entry.manifest.name, files: r.entry.files } : null;
|
|
1149
|
+
}
|
|
1150
|
+
/** #1629 r4-5: an instance's card text as every viewer sees it (a person's fields over the agent's). */
|
|
1151
|
+
modCardText(name) {
|
|
1152
|
+
const card = resolveCard(name, humanCards(this.dataDir).get(name), this.themeStore.get(name).theme.card);
|
|
1153
|
+
return { ...(card.tagline ? { tagline: card.tagline.text } : {}), ...(card.bio ? { bio: card.bio.text } : {}) };
|
|
1154
|
+
}
|
|
1155
|
+
ownerCssStore;
|
|
1156
|
+
/** #1629 r4 §3: each person's own CSS; which version is in force is their `css.active` in the web preferences. */
|
|
1157
|
+
get ownerCss() {
|
|
1158
|
+
return (this.ownerCssStore ??= new OwnerCssStore(this.dataDir, this.webPrefs));
|
|
1159
|
+
}
|
|
1160
|
+
webSettingsToolsService;
|
|
1161
|
+
/**
|
|
1162
|
+
* #2056: Settings → Web appearance for a tool (General, a coordinator, full) — the page's own stores and checks, and the
|
|
1163
|
+
* page's announcement after a change (web-settings-tools.ts).
|
|
1164
|
+
*/
|
|
1165
|
+
get webSettings() {
|
|
1166
|
+
return (this.webSettingsToolsService ??= new WebSettingsTools({
|
|
1167
|
+
prefs: this.webPrefs, ownerCss: this.ownerCss, mods: this.mods, effects: this.effectService,
|
|
1168
|
+
instances: () => [...Object.keys(this.fleetConfig?.instances ?? {}),
|
|
1169
|
+
...(this.classicChannels?.getAll() ?? []).map((ch) => ch.instanceName).filter((n) => !Object.hasOwn(this.fleetConfig?.instances ?? {}, n))],
|
|
1170
|
+
colour: (name) => personIdentityColour(this.dataDir, name),
|
|
1171
|
+
setColour: (name, patch) => { const w = setPersonIdentityColour(this.dataDir, name, patch); if (w.ok)
|
|
1172
|
+
this.identityChanged(); return w; },
|
|
1173
|
+
bannerFocus: (name) => personBannerFocus(this.dataDir, name),
|
|
1174
|
+
setBannerFocus: (name, focus) => { const w = setPersonBannerFocus(this.dataDir, name, focus); if (w.ok)
|
|
1175
|
+
this.identityChanged(); return w; },
|
|
1176
|
+
placements: (name) => this.themeStore.get(name).placements.effects.map((e) => (typeof e === "string" ? { id: e } : e)),
|
|
1177
|
+
ambient: (name) => Object.fromEntries(Object.entries(this.themeStore.get(name).placements.ambient).flatMap(([k, a]) => a ? [[k, { spec: a.spec, by: a.by }]] : [])),
|
|
1178
|
+
setAmbient: (name, spec, which) => this.webAppearance.humanSetAmbient(name, spec, which),
|
|
1179
|
+
announce: () => { try {
|
|
1180
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
1181
|
+
}
|
|
1182
|
+
catch { /* the next status frame carries it */ } },
|
|
1183
|
+
}));
|
|
1184
|
+
}
|
|
887
1185
|
get webPrefs() {
|
|
888
1186
|
// #1664: a favorite must be an instance of this fleet now — fleet.yaml's (own keys) or a ClassicBot room's.
|
|
889
1187
|
return (this.webPrefsStore ??= new WebPrefsStore(this.dataDir, {
|
|
@@ -1154,9 +1452,26 @@ export class FleetManager {
|
|
|
1154
1452
|
sseHeartbeatMs = SSE_HEARTBEAT_MS;
|
|
1155
1453
|
/** The dashboard's login codes. Memory only: a code that outlives the process is a code nobody can prove was not copied. */
|
|
1156
1454
|
webLoginCodes = null;
|
|
1455
|
+
/**
|
|
1456
|
+
* #1920 step 4: guests' codes, apart from the admins' — one per account, so no guest's `/web` withdraws another's or
|
|
1457
|
+
* an admin's code (#2093 review), at most 32 at once. Local sign-in only for now (audience "local").
|
|
1458
|
+
*/
|
|
1459
|
+
webGuestLoginCodes = null;
|
|
1460
|
+
/** #1920 step 4: when each account last got a guest code (monotonic) — one a minute, so `/web` is no DM flood. */
|
|
1461
|
+
guestCodeAskedAt = new Map();
|
|
1462
|
+
/** #2093 review: an account's guest /web in progress — one at a time, so two at once cannot both pass the minute. */
|
|
1463
|
+
guestCodeInFlight = new Set();
|
|
1464
|
+
/**
|
|
1465
|
+
* #2093 review: revokes a guest /web in progress must see — every web session's (`/web revoke` from an admin) bumps
|
|
1466
|
+
* the global count, an account's own bumps its entry — checked before the code is issued and while it is delivered.
|
|
1467
|
+
*/
|
|
1468
|
+
guestRevokes = 0;
|
|
1469
|
+
guestAccountRevokes = new Map();
|
|
1157
1470
|
/** #1570: browsers that may ask the sign-in page for a fresh code, and how often they may. */
|
|
1158
1471
|
returningDevices = null;
|
|
1159
1472
|
codeRequestLimiter = new CodeRequestLimiter();
|
|
1473
|
+
/** #1920 step 4b: guests' device code requests, limited apart — guests never use up the admins' fleet-wide budget. */
|
|
1474
|
+
guestCodeRequestLimiter = new CodeRequestLimiter();
|
|
1160
1475
|
/**
|
|
1161
1476
|
* Set while a Settings apply job is driving the reconcile. The reconcile
|
|
1162
1477
|
* stays the single doer; it just says out loud what it is doing to whom, so
|
|
@@ -1304,6 +1619,7 @@ export class FleetManager {
|
|
|
1304
1619
|
meta: {
|
|
1305
1620
|
chat_id: set.chatId, message_id: messageId, user: by.username, user_id: by.userId, ts,
|
|
1306
1621
|
thread_id: set.threadId, adapter_id: inbound.adapterId, source: inbound.source,
|
|
1622
|
+
...(web && by.guest ? { web_guest: "1" } : {}), // #1920: "[user:… via web (guest), id:…]", as /ui/send
|
|
1307
1623
|
...reactions.meta,
|
|
1308
1624
|
},
|
|
1309
1625
|
}, by.humanOrigin, button.label)) !== false;
|
|
@@ -1324,6 +1640,9 @@ export class FleetManager {
|
|
|
1324
1640
|
this.emitSseEvent("message", {
|
|
1325
1641
|
instance: set.instance, sender: by.username, role: "user", text: content, ts, ...(web ? { messageId } : {}),
|
|
1326
1642
|
click: { label: button.label, of: set.id },
|
|
1643
|
+
// #1920: a platform click is its clicker's; it is the instance's channel's only when its buttons are there (the
|
|
1644
|
+
// same rule as a reply's: a set sent to another topic or a DM is not). A web click is the web's.
|
|
1645
|
+
...(web ? {} : { from: webChatFrom(set.adapterId, by.userId), ...(this.clickInOwnChannel(set) ? { channel: true } : {}) }),
|
|
1327
1646
|
});
|
|
1328
1647
|
return true;
|
|
1329
1648
|
}
|
|
@@ -1378,6 +1697,18 @@ export class FleetManager {
|
|
|
1378
1697
|
}
|
|
1379
1698
|
}
|
|
1380
1699
|
});
|
|
1700
|
+
// #1918: the org canvas's traffic — from the row this listener's events already name, never a probe of its own.
|
|
1701
|
+
// The row as it is now (it has already changed when the event comes): its state, not the event's, is the truth.
|
|
1702
|
+
outbox.on("state", (event) => {
|
|
1703
|
+
const row = event.deliveryId ? outbox.get(event.deliveryId) : undefined;
|
|
1704
|
+
if (row)
|
|
1705
|
+
this.publishTraffic(row);
|
|
1706
|
+
});
|
|
1707
|
+
outbox.on("consumed", (event) => {
|
|
1708
|
+
const row = event.deliveryId ? outbox.get(event.deliveryId) : undefined;
|
|
1709
|
+
if (row)
|
|
1710
|
+
this.publishTraffic(row, "consumed");
|
|
1711
|
+
});
|
|
1381
1712
|
outbox.on("generation_recovered", () => this.scheduleDeliveryOutboxPump());
|
|
1382
1713
|
this.replyObligationTimer = setInterval(() => measureSyncWork("fleet.replyObligationSweep", () => this.sweepReplyObligations()), REPLY_OBLIGATION_SWEEP_MS);
|
|
1383
1714
|
this.replyObligationTimer.unref?.();
|
|
@@ -1399,6 +1730,52 @@ export class FleetManager {
|
|
|
1399
1730
|
});
|
|
1400
1731
|
this.scheduleDeliveryOutboxPump();
|
|
1401
1732
|
}
|
|
1733
|
+
/** #1918: cross-instance traffic, as the org canvas draws it (fleet-traffic.ts). */
|
|
1734
|
+
trafficLog = new TrafficLog();
|
|
1735
|
+
delegations = new Delegations();
|
|
1736
|
+
trafficMemo = null;
|
|
1737
|
+
/**
|
|
1738
|
+
* One delivery row's traffic item, kept for the snapshot and sent to the open pages when it is news; and the
|
|
1739
|
+
* delegation it opens or closes (a task and its report), sent as "delegation".
|
|
1740
|
+
*/
|
|
1741
|
+
publishTraffic(row, state) {
|
|
1742
|
+
try {
|
|
1743
|
+
const item = trafficItem(row, this.fleetConfig, new Date().toISOString(), state);
|
|
1744
|
+
const news = item ? this.trafficLog.note(item) : null;
|
|
1745
|
+
const work = news ? this.delegations.note(row, news) : [];
|
|
1746
|
+
if (news || work.length)
|
|
1747
|
+
this.trafficMemo = null;
|
|
1748
|
+
if (news)
|
|
1749
|
+
this.emitSseEvent("traffic", news);
|
|
1750
|
+
// Each delegation as its two instances are seen now: one that expires or is pushed out may be older than a change
|
|
1751
|
+
// to their visibility (the incoming row's own item was checked; these can be anyone's).
|
|
1752
|
+
const visible = (name) => crossInstanceVisibility(this.fleetConfig, name) !== "hidden";
|
|
1753
|
+
for (const d of work)
|
|
1754
|
+
if (visible(d.from) && visible(d.to))
|
|
1755
|
+
this.emitSseEvent("delegation", d);
|
|
1756
|
+
}
|
|
1757
|
+
catch (err) {
|
|
1758
|
+
this.logger.debug({ err }, "traffic: an item was not published");
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* GET /ui/traffic: what is in flight now (the outbox's pending rows) and what finished in the last two minutes, at
|
|
1763
|
+
* most TRAFFIC_SNAPSHOT_MAX items. Worked out at most once every two seconds, so the public link's polling (or any
|
|
1764
|
+
* number of pages) costs one indexed read per two seconds. Visibility is applied as of now.
|
|
1765
|
+
*/
|
|
1766
|
+
trafficSnapshot() {
|
|
1767
|
+
const nowMs = performance.now();
|
|
1768
|
+
if (this.trafficMemo && nowMs - this.trafficMemo.atMs < 2_000)
|
|
1769
|
+
return this.trafficMemo.value;
|
|
1770
|
+
const now = new Date().toISOString();
|
|
1771
|
+
const pending = (this.deliveryOutbox?.listPending() ?? [])
|
|
1772
|
+
.map((r) => trafficItem(r, this.fleetConfig, r.createdAt ?? now)).filter((i) => i !== null);
|
|
1773
|
+
const visible = (name) => crossInstanceVisibility(this.fleetConfig, name) !== "hidden";
|
|
1774
|
+
const items = this.trafficLog.snapshot(pending).filter((i) => visible(i.from) && visible(i.to));
|
|
1775
|
+
const delegations = this.delegations.snapshot().filter((d) => visible(d.from) && visible(d.to));
|
|
1776
|
+
this.trafficMemo = { atMs: nowMs, value: { now, items, delegations } };
|
|
1777
|
+
return this.trafficMemo.value;
|
|
1778
|
+
}
|
|
1402
1779
|
/** Synchronous SQLite admission; the caller may acknowledge only after this returns. */
|
|
1403
1780
|
admitDurableDelivery(input) {
|
|
1404
1781
|
const outbox = this.deliveryOutbox;
|
|
@@ -1411,6 +1788,8 @@ export class FleetManager {
|
|
|
1411
1788
|
}
|
|
1412
1789
|
const sourceKey = `mcp:${input.sourceInstance}:${input.operationId}:${input.targetInstance}:${input.targetSession ?? ""}:${input.kind}`;
|
|
1413
1790
|
const admitted = outbox.admit({ ...input, sourceDaemonBootId, sourceKey });
|
|
1791
|
+
if (admitted.inserted)
|
|
1792
|
+
this.publishTraffic(admitted.delivery);
|
|
1414
1793
|
if (admitted.inserted)
|
|
1415
1794
|
this.logger.info({
|
|
1416
1795
|
deliveryId: admitted.delivery.deliveryId,
|
|
@@ -1433,10 +1812,18 @@ export class FleetManager {
|
|
|
1433
1812
|
queryDurableDeliveryStatus(callerInstance, selector) {
|
|
1434
1813
|
this.ensureDeliveryOutbox();
|
|
1435
1814
|
const page = this.deliveryOutbox.queryStatusForInstance(callerInstance, selector);
|
|
1815
|
+
// #1939 §10: a human receipt's authority as it reads now (switch, epoch, loopback_only).
|
|
1816
|
+
const state = page.items.some(item => item.authority) ? this.webAuthorityState() : null;
|
|
1817
|
+
const human = (item) => {
|
|
1818
|
+
if (!item.authority || !state)
|
|
1819
|
+
return item;
|
|
1820
|
+
const authority = authorityNow(item.authority, state);
|
|
1821
|
+
return { ...item, authority, ...(authority.downgraded ? { source_instance: "human:web:unknown" } : {}) };
|
|
1822
|
+
};
|
|
1436
1823
|
// #1641: a sender that sees only `queued` cannot tell "next in line" from "the target is stuck": say which.
|
|
1437
1824
|
return {
|
|
1438
1825
|
...page,
|
|
1439
|
-
items: page.items.map(item => (item.state === "queued" || item.state === "retry_wait")
|
|
1826
|
+
items: page.items.map(human).map(item => (item.state === "queued" || item.state === "retry_wait")
|
|
1440
1827
|
? { ...item, waiting_reason: this.deliveryWaitingReason(item.target_instance) }
|
|
1441
1828
|
: item),
|
|
1442
1829
|
};
|
|
@@ -1784,17 +2171,58 @@ export class FleetManager {
|
|
|
1784
2171
|
this.logger.info({ hostMemory: snapshot }, "Host memory recovered");
|
|
1785
2172
|
}
|
|
1786
2173
|
this.memoryLogLevel = snapshot.level;
|
|
2174
|
+
if (this.memoryTop) {
|
|
2175
|
+
this.memoryTop = null;
|
|
2176
|
+
this.memoryTopScan.loggedAt = null;
|
|
2177
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
2178
|
+
}
|
|
1787
2179
|
return;
|
|
1788
2180
|
}
|
|
2181
|
+
if (snapshot.level === "elevated" || snapshot.level === "critical")
|
|
2182
|
+
this.refreshMemoryTop(snapshot);
|
|
1789
2183
|
if (changed || this.memoryLogAt === null || now - this.memoryLogAt >= 10 * 60_000) {
|
|
1790
2184
|
this.memoryLogAt = now;
|
|
1791
|
-
this.logger.warn({ hostMemory: snapshot }, snapshot.level === "critical"
|
|
2185
|
+
this.logger.warn({ hostMemory: snapshot, ...(this.memoryTop ? { topProcesses: this.memoryTop.processes } : {}) }, snapshot.level === "critical"
|
|
1792
2186
|
? "Host memory critical — deferring new CLI spawns"
|
|
1793
2187
|
: snapshot.level === "elevated" ? "Host memory pressure — slowing new CLI spawns"
|
|
1794
2188
|
: "Host memory sample unavailable — slowing new CLI spawns");
|
|
1795
2189
|
}
|
|
1796
2190
|
this.memoryLogLevel = snapshot.level;
|
|
1797
2191
|
}
|
|
2192
|
+
/**
|
|
2193
|
+
* #2015: rescan the largest processes (at most once a minute, one at a time). The first result of an episode, and
|
|
2194
|
+
* then at most every 10 minutes, is logged with the pressure; /health and the dashboard read the latest. Never chat.
|
|
2195
|
+
*/
|
|
2196
|
+
refreshMemoryTop(snapshot) {
|
|
2197
|
+
const scan = this.memoryTopScan;
|
|
2198
|
+
const now = performance.now();
|
|
2199
|
+
if (scan.inFlight || (scan.at !== null && now - scan.at < 60_000))
|
|
2200
|
+
return;
|
|
2201
|
+
scan.inFlight = true;
|
|
2202
|
+
scan.at = now;
|
|
2203
|
+
void (async () => {
|
|
2204
|
+
const session = getTmuxSession();
|
|
2205
|
+
const [tmuxServerPid, panes] = this.tmuxSessionEnsured
|
|
2206
|
+
? await Promise.all([TmuxManager.getServerPid(session), TmuxManager.listPanePids(session)])
|
|
2207
|
+
: [null, []];
|
|
2208
|
+
const processes = await this.memoryHogScan({
|
|
2209
|
+
fleetPid: process.pid, tmuxServerPid, panes,
|
|
2210
|
+
isInstance: name => this.daemons.has(name) || Object.hasOwn(this.fleetConfig?.instances ?? {}, name),
|
|
2211
|
+
}, 3);
|
|
2212
|
+
// Pressure may have ended while the scan ran: nothing is kept then.
|
|
2213
|
+
if (this.shuttingDown || processes.length === 0 || this.hostMemoryIndicator() === null)
|
|
2214
|
+
return;
|
|
2215
|
+
const before = JSON.stringify(this.memoryTop?.processes ?? null);
|
|
2216
|
+
this.memoryTop = { at: new Date().toISOString(), processes };
|
|
2217
|
+
if (scan.loggedAt === null || performance.now() - scan.loggedAt >= 10 * 60_000) {
|
|
2218
|
+
scan.loggedAt = performance.now();
|
|
2219
|
+
this.logger.warn({ hostMemory: snapshot, topProcesses: processes }, "Host memory pressure — largest processes in the fleet");
|
|
2220
|
+
}
|
|
2221
|
+
if (JSON.stringify(processes) !== before)
|
|
2222
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
2223
|
+
})().catch(err => this.logger.debug({ err }, "Largest-process scan failed"))
|
|
2224
|
+
.finally(() => { scan.inFlight = false; });
|
|
2225
|
+
}
|
|
1798
2226
|
/** Wire the one fleet-wide storm into notification and recovery surfaces. */
|
|
1799
2227
|
bindStormWindowEvents() {
|
|
1800
2228
|
this.stormWindow.on("opened", (snapshot) => {
|
|
@@ -2116,6 +2544,7 @@ export class FleetManager {
|
|
|
2116
2544
|
this.fleetConfig = loaded;
|
|
2117
2545
|
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
2118
2546
|
this.warnAboutRemovedLoginMode(loaded);
|
|
2547
|
+
this.enforceGuestSwitch();
|
|
2119
2548
|
return this.fleetConfig;
|
|
2120
2549
|
}
|
|
2121
2550
|
relayLoginModeWarned = false;
|
|
@@ -3241,7 +3670,8 @@ export class FleetManager {
|
|
|
3241
3670
|
* for `instance` (#1320 part B). Per entry: the channel's own adapter, no
|
|
3242
3671
|
* thread, mention suppression on. Returns the number of channels posted
|
|
3243
3672
|
* to. Failures are per-entry warn-and-continue — a failed echo never fails
|
|
3244
|
-
* the web send.
|
|
3673
|
+
* the web send. Only entries classicWebEchoOn admits are touched (#2054: unset follows web.echo_to_channel; an explicit
|
|
3674
|
+
* `web_echo: false` never echoes).
|
|
3245
3675
|
*
|
|
3246
3676
|
* Every copy is fenced at start time: an entry whose explicit world is
|
|
3247
3677
|
* gone is skipped (only legacy adapterId-less entries fall back to the
|
|
@@ -3252,6 +3682,15 @@ export class FleetManager {
|
|
|
3252
3682
|
* already in flight may still land late — the contract permits that.
|
|
3253
3683
|
*/
|
|
3254
3684
|
/** `sender` (#1827): the web person's name, as the room log records them ("web-user" when none). */
|
|
3685
|
+
/**
|
|
3686
|
+
* #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
|
|
3687
|
+
* to the guests' streams only: an admin's page already has it, and nothing about it changed for them.
|
|
3688
|
+
*/
|
|
3689
|
+
webMessageInChannel(instance, messageId) {
|
|
3690
|
+
const m = this.webChatHistory.markChannel(instance, messageId);
|
|
3691
|
+
if (m)
|
|
3692
|
+
broadcastToGuests(this.sseClients, "message", m, (err) => this.logger.debug({ err }, "SSE client write failed; evicting"));
|
|
3693
|
+
}
|
|
3255
3694
|
async sendClassicWebEcho(instance, text, sender = WEB_NAME_FALLBACK) {
|
|
3256
3695
|
const guard = this.webChannelEchoGuards.get(instance);
|
|
3257
3696
|
const handled = new Set();
|
|
@@ -3268,7 +3707,7 @@ export class FleetManager {
|
|
|
3268
3707
|
}
|
|
3269
3708
|
}
|
|
3270
3709
|
const entry = (this.classicChannels?.getAll() ?? [])
|
|
3271
|
-
.find(candidate => candidate.instanceName === instance && candidate.webEcho
|
|
3710
|
+
.find(candidate => candidate.instanceName === instance && classicWebEchoOn(candidate.webEcho, this.fleetConfig?.web?.echo_to_channel)
|
|
3272
3711
|
&& !handled.has(`${candidate.channelId}#${candidate.adapterId ?? ""}`));
|
|
3273
3712
|
if (!entry)
|
|
3274
3713
|
break;
|
|
@@ -3468,12 +3907,31 @@ export class FleetManager {
|
|
|
3468
3907
|
}
|
|
3469
3908
|
/** Evidence-only admission. Bots/system/agent payloads have no trusted origin. */
|
|
3470
3909
|
humanReceiptPayload(instanceName, payload, origin, originalBody) {
|
|
3910
|
+
// #1939 S3: "via web (verified)" comes only from the receipt written here — never from a caller's meta.
|
|
3911
|
+
const given = payload.meta;
|
|
3912
|
+
if (given && "web_verified" in given) {
|
|
3913
|
+
const { web_verified: _claimed, ...rest } = given;
|
|
3914
|
+
payload = { ...payload, meta: rest };
|
|
3915
|
+
}
|
|
3471
3916
|
if (!origin || !this.deliveryOutbox)
|
|
3472
3917
|
return payload;
|
|
3473
3918
|
const row = this.deliveryOutbox.createHumanReceipt({ targetInstance: instanceName, originalBody,
|
|
3474
3919
|
origin: { ...origin }, inputKind: String(payload.type), managerBootId: this.managerBootId });
|
|
3920
|
+
// #1939 §7: each ordinary verified web message, for the S1 beta to watch. No text, cookie or session id: the handle.
|
|
3921
|
+
const authority = origin.authority;
|
|
3922
|
+
if (authority?.kind === "web-verified" && authority.actor && authority.session) {
|
|
3923
|
+
try {
|
|
3924
|
+
this.eventLog?.insert(instanceName, "web_authority", { receipt_id: row.receipt_id, session: authority.session.handle,
|
|
3925
|
+
peer: authority.session.peer, surface: "local", actor: `${authority.actor.adapter_id}:${authority.actor.user_id}`,
|
|
3926
|
+
input_kind: String(payload.type), outcome: "web-verified", authority_epoch: authority.authority_epoch });
|
|
3927
|
+
}
|
|
3928
|
+
catch (err) {
|
|
3929
|
+
this.logger.warn({ err, instanceName }, "Could not record web authority");
|
|
3930
|
+
}
|
|
3931
|
+
}
|
|
3932
|
+
const verified = origin.platform === "web" && authority?.kind === "web-verified";
|
|
3475
3933
|
return { ...payload, meta: { ...(payload.meta ?? {}),
|
|
3476
|
-
human_receipt_id: row.receipt_id, verification_id: row.message_id } };
|
|
3934
|
+
human_receipt_id: row.receipt_id, verification_id: row.message_id, ...(verified ? { web_verified: "1" } : {}) } };
|
|
3477
3935
|
}
|
|
3478
3936
|
/** Bind at the actual IPC send, after all wake/reconnect waits. No human retry lane. */
|
|
3479
3937
|
bindHumanReceiptPayload(instanceName, payload) {
|
|
@@ -3687,6 +4145,14 @@ export class FleetManager {
|
|
|
3687
4145
|
return;
|
|
3688
4146
|
}
|
|
3689
4147
|
const scope = door.scope;
|
|
4148
|
+
// #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)
|
|
4149
|
+
// — before the table, which keeps /web for fleet admins. Per account: it touches no instance binding.
|
|
4150
|
+
// Decided synchronously, so an admin's /web still reaches the table with no await after the door (#1399).
|
|
4151
|
+
if (data.command === "dashboard" && this.isGuestWebAsk(adapterId, data.userId)) {
|
|
4152
|
+
await this.guestWebCommand({ adapterId, userId: data.userId, username: data.username,
|
|
4153
|
+
arg: String(data.options?.action ?? "").trim().toLowerCase(), replyHere: text => data.respond(text), replyPrivately: text => data.respond(text) });
|
|
4154
|
+
return;
|
|
4155
|
+
}
|
|
3690
4156
|
const spec = commandSpec(data.command);
|
|
3691
4157
|
if (!spec) {
|
|
3692
4158
|
this.logger.info({ command: data.command, adapterId }, "Slash command is not in the command table");
|
|
@@ -3704,7 +4170,11 @@ export class FleetManager {
|
|
|
3704
4170
|
// A command off for want of fleet admins names where they go (`{0}` in its disabled reply).
|
|
3705
4171
|
if (decision.reply === spec.disabled)
|
|
3706
4172
|
args.push(this.adminListKey(adapterId));
|
|
3707
|
-
|
|
4173
|
+
// #1990: a refusal that sends the person to General links to it — not in a ClassicBot room, whose readers need
|
|
4174
|
+
// not be in the fleet's guild.
|
|
4175
|
+
const toGeneral = GENERAL_POINTING_REFUSALS.has(key) && scope !== "classic" && scope !== "none";
|
|
4176
|
+
const reply = toGeneral ? this.renderPlaces(adapterId, t(key, ...args) + placeSuffix({ kind: "general" }, "General")).text : t(key, ...args);
|
|
4177
|
+
await data.respond(reply).catch(() => { });
|
|
3708
4178
|
return;
|
|
3709
4179
|
}
|
|
3710
4180
|
if (data.command === "start") {
|
|
@@ -4317,6 +4787,22 @@ export class FleetManager {
|
|
|
4317
4787
|
this.logger.warn({ err, name, backend }, "Failed to sync worker skills — continuing startup");
|
|
4318
4788
|
}
|
|
4319
4789
|
}
|
|
4790
|
+
else if (kind === "classic") {
|
|
4791
|
+
// #2163: a Classic bot gets only the skills marked `classic` (its own look, the dashboard) — never the fleet's
|
|
4792
|
+
// coordination playbooks (#557 kept those out of end-user bots) — and only into a workspace AgEnD manages: the
|
|
4793
|
+
// room's own folder under <AGEND_HOME>/workspaces/. Anywhere else, nothing is written; `agend-agent skill <name>`
|
|
4794
|
+
// reads any bundled skill on demand.
|
|
4795
|
+
try {
|
|
4796
|
+
const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
|
|
4797
|
+
if (insideManagedWorkspaces(skillsWorkDir))
|
|
4798
|
+
measureSyncWork("fleet.classicSkills", () => this.syncRoleSkills(skillsWorkDir, backend, "classic"));
|
|
4799
|
+
else
|
|
4800
|
+
this.logger.debug({ name, skillsWorkDir }, "Classic workspace is not AgEnD-managed — no skills published");
|
|
4801
|
+
}
|
|
4802
|
+
catch (err) {
|
|
4803
|
+
this.logger.warn({ err, name, backend }, "Failed to sync classic skills — continuing startup");
|
|
4804
|
+
}
|
|
4805
|
+
}
|
|
4320
4806
|
const receipt = execution ? this.classicSettingsOwners.get(execution) : undefined;
|
|
4321
4807
|
const identity = { kind, backend, model: this.resolveInstanceModel(name).display };
|
|
4322
4808
|
if (receipt) {
|
|
@@ -5127,10 +5613,14 @@ export class FleetManager {
|
|
|
5127
5613
|
// #1620: fleet.yaml's web.session.local, read at every check — a Settings save or a reload applies at once
|
|
5128
5614
|
// (shortening ends the sessions it now outlives). The public link's policy stays fixed.
|
|
5129
5615
|
localPolicy: () => localSessionPolicy(this.fleetConfig?.web?.session?.local),
|
|
5616
|
+
// #1920 §5.4: a guest's, from web.guests.session (never longer than the surface's own).
|
|
5617
|
+
guestPolicy: () => guestSessionPolicy(this.fleetConfig?.web?.guests?.session),
|
|
5130
5618
|
});
|
|
5619
|
+
this.enforceGuestSwitch(); // #1920: guests off while the fleet was down
|
|
5131
5620
|
}
|
|
5132
5621
|
if (!this.returningDevices) {
|
|
5133
5622
|
this.returningDevices = new ReturningDevices({ dataDir: this.dataDir, onWarn: message => this.logger.warn(message) });
|
|
5623
|
+
this.enforceGuestSwitch(); // #1920: guests' devices too, if guests went off while down
|
|
5134
5624
|
}
|
|
5135
5625
|
if (!this.webLoginCodes) {
|
|
5136
5626
|
this.webLoginCodes = new WebLoginCodes({
|
|
@@ -5142,6 +5632,16 @@ export class FleetManager {
|
|
|
5142
5632
|
},
|
|
5143
5633
|
});
|
|
5144
5634
|
}
|
|
5635
|
+
if (!this.webGuestLoginCodes) {
|
|
5636
|
+
this.webGuestLoginCodes = new GuestLoginCodes({
|
|
5637
|
+
onEvent: event => {
|
|
5638
|
+
if (event === "burned")
|
|
5639
|
+
this.logger.warn("A guest web login code was used up by wrong attempts");
|
|
5640
|
+
else if (event === "breaker-open")
|
|
5641
|
+
this.logger.warn("Guest web sign-in paused: too many wrong login codes");
|
|
5642
|
+
},
|
|
5643
|
+
});
|
|
5644
|
+
}
|
|
5145
5645
|
}
|
|
5146
5646
|
/**
|
|
5147
5647
|
* A single-use login code for the dashboard, for a channel only the operator
|
|
@@ -5265,6 +5765,7 @@ export class FleetManager {
|
|
|
5265
5765
|
dispatch: (req, res) => this.dispatchWebHttp(req, res, this.fleetConfig?.health_port ?? 19280) }),
|
|
5266
5766
|
revoke: id => {
|
|
5267
5767
|
this.webLoginCodes?.revokeAudience(id);
|
|
5768
|
+
this.webGuestLoginCodes?.revokeAudience(id);
|
|
5268
5769
|
const result = this.webSessions?.revokeExposure(id);
|
|
5269
5770
|
if (result && !result.durable)
|
|
5270
5771
|
this.logger.warn("Public session revocation could not be saved; exposure remains closed");
|
|
@@ -5296,13 +5797,32 @@ export class FleetManager {
|
|
|
5296
5797
|
if (!current() || !this.publicOwnerCurrent(info.owner))
|
|
5297
5798
|
throw new Error("public owner changed");
|
|
5298
5799
|
}
|
|
5800
|
+
/**
|
|
5801
|
+
* #1920 §5.5 (step 4b): a guest signing in through the public link — said in the General of the admin who opened it
|
|
5802
|
+
* (who, how many agents), awaited as an admin's public sign-in is, while that link is the open one, its opener still
|
|
5803
|
+
* current, and the account still an admitted guest. A failure refuses the sign-in.
|
|
5804
|
+
*/
|
|
5805
|
+
async confirmGuestPublicWebLogin(info, current) {
|
|
5806
|
+
const admitted = () => {
|
|
5807
|
+
const open = this.publicWebLink?.openSignin();
|
|
5808
|
+
return current() && open && open.exposureId === info.exposureId && this.publicOwnerCurrent(open.owner) && this.guestSignInAllowed(info.account) ? open : null;
|
|
5809
|
+
};
|
|
5810
|
+
const open = admitted();
|
|
5811
|
+
if (!open)
|
|
5812
|
+
throw new Error("guest public sign-in unavailable");
|
|
5813
|
+
const adapter = open.owner.binding;
|
|
5814
|
+
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: [] } });
|
|
5815
|
+
if (!admitted())
|
|
5816
|
+
throw new Error("guest public sign-in changed");
|
|
5817
|
+
}
|
|
5299
5818
|
/** Telegram's typed command enters through the same strict owner and nonce path as Discord. */
|
|
5300
5819
|
async dashboardMenu(msg) {
|
|
5301
5820
|
const id = msg.adapterId;
|
|
5302
5821
|
const owner = id ? this.dashboardOwner(msg.userId, id, msg.chatId, msg.threadId, msg.username) : null;
|
|
5303
5822
|
if (!owner) {
|
|
5304
5823
|
const adapter = id ? this.adapters.get(id) : undefined;
|
|
5305
|
-
|
|
5824
|
+
const out = this.renderPlaces(id, t("dashboard.public_general") + placeSuffix({ kind: "general" }, "General"), { threadId: msg.threadId });
|
|
5825
|
+
await adapter?.sendText(msg.chatId, out.text, out.opts);
|
|
5306
5826
|
return;
|
|
5307
5827
|
}
|
|
5308
5828
|
if (!await this.showDashboardMenu(owner)) {
|
|
@@ -5618,16 +6138,197 @@ export class FleetManager {
|
|
|
5618
6138
|
this.initializeWebSessions();
|
|
5619
6139
|
void this.publicWebLink?.close("dashboard revoke");
|
|
5620
6140
|
this.webLoginCodes.revoke();
|
|
6141
|
+
this.webGuestLoginCodes.revokeAll();
|
|
6142
|
+
this.guestRevokes++; // and every guest /web still on its way
|
|
5621
6143
|
// #1570: no browser may ask the sign-in page for a code any more.
|
|
5622
6144
|
const devices = this.returningDevices.revokeAll();
|
|
5623
6145
|
const sessions = this.webSessions.revokeAll();
|
|
5624
|
-
|
|
6146
|
+
// #1939 §10: and every web authority given so far (its receipts read as none).
|
|
6147
|
+
const authority = this.revokeWebAuthority();
|
|
6148
|
+
const result = { count: sessions.count, durable: sessions.durable && devices.durable && authority.durable };
|
|
5625
6149
|
if (result.durable)
|
|
5626
6150
|
this.logger.info({ count: result.count, devices: devices.count }, "Web sessions revoked (all)");
|
|
5627
6151
|
else
|
|
5628
6152
|
this.logger.warn({ count: result.count }, "Web sessions revoked in memory only — the session file could not be updated or removed");
|
|
5629
6153
|
return result;
|
|
5630
6154
|
}
|
|
6155
|
+
/**
|
|
6156
|
+
* #1920 phase 3: Settings → Web sign-ins → Guests — the switches, each connection's access mode and the agents named for
|
|
6157
|
+
* an open one, and every guest account (signed in, with a returning device, or narrowed): who, its platform, what it
|
|
6158
|
+
* reaches now, its sessions and devices, and its narrowing. Ids are the accounts' own (an admin's page).
|
|
6159
|
+
*/
|
|
6160
|
+
guestOverview() {
|
|
6161
|
+
this.initializeWebSessions();
|
|
6162
|
+
const cfg = this.fleetConfig?.web?.guests;
|
|
6163
|
+
const own = (map, key) => (map && Object.hasOwn(map, key) ? map[key] : undefined);
|
|
6164
|
+
const connections = [...this.worlds.entries()].map(([id, w]) => ({ id, type: w.adapter.type, mode: (w.accessManager.getMode() === "open" ? "open" : "allowlist"),
|
|
6165
|
+
named: [...(own(cfg?.open_instances, id) ?? [])] }));
|
|
6166
|
+
const accounts = new Map();
|
|
6167
|
+
for (const a of this.webSessions.guestActivity())
|
|
6168
|
+
accounts.set(`${a.adapterId}\u0000${a.userId}`, a);
|
|
6169
|
+
const devices = this.returningDevices.guestCounts();
|
|
6170
|
+
for (const k of devices.keys())
|
|
6171
|
+
if (!accounts.has(k)) {
|
|
6172
|
+
const [adapterId, userId] = k.split("\u0000");
|
|
6173
|
+
accounts.set(k, { adapterId, userId, sessions: 0, lastSeen: null });
|
|
6174
|
+
}
|
|
6175
|
+
for (const key of Object.keys(cfg?.narrow ?? {})) {
|
|
6176
|
+
const at = key.indexOf(":"), adapterId = key.slice(0, at), userId = key.slice(at + 1), k = `${adapterId}\u0000${userId}`;
|
|
6177
|
+
if (at > 0 && !accounts.has(k))
|
|
6178
|
+
accounts.set(k, { adapterId, userId, sessions: 0, lastSeen: null });
|
|
6179
|
+
}
|
|
6180
|
+
const guests = [...accounts.entries()].map(([k, a]) => {
|
|
6181
|
+
const owner = { adapterId: a.adapterId, userId: a.userId };
|
|
6182
|
+
const badge = this.guestBadge(owner);
|
|
6183
|
+
// `reach`: before narrowing — what Settings offers to narrow to (#2104 review: never an agent it cannot reach).
|
|
6184
|
+
return { ...a, name: badge.name, platform: badge.platform, scope: [...this.guestScope(owner)].sort(), reach: [...this.guestReach(owner)].sort(),
|
|
6185
|
+
devices: devices.get(k) ?? 0, narrow: own(cfg?.narrow, `${a.adapterId}:${a.userId}`) ?? null };
|
|
6186
|
+
}).sort((x, y) => (y.lastSeen ?? -1) - (x.lastSeen ?? -1));
|
|
6187
|
+
return { enabled: cfg?.enabled === true, chat: cfg?.chat !== false, instances: Object.keys(this.fleetConfig?.instances ?? {}).sort(), connections, guests };
|
|
6188
|
+
}
|
|
6189
|
+
/** #1920 phase 3 (the app shell): a guest's chip — its web name, and its connection's platform (discord, telegram). */
|
|
6190
|
+
guestBadge(owner) {
|
|
6191
|
+
return { name: this.webPerson(owner).name, platform: this.worlds.get(owner.adapterId)?.adapter.type ?? owner.adapterId };
|
|
6192
|
+
}
|
|
6193
|
+
/** #1920 step 4: whether a `/web` is a guest's — guest sign-in on, and not from a fleet admin (theirs runs as before). */
|
|
6194
|
+
isGuestWebAsk(adapterId, userId) {
|
|
6195
|
+
return !!adapterId && this.fleetConfig?.web?.guests?.enabled === true && !this.isFleetAdmin(userId, adapterId);
|
|
6196
|
+
}
|
|
6197
|
+
/** #1920 step 4 (auth-api): a guest code's account may still sign in — guests on, and something in its scope now. */
|
|
6198
|
+
guestSignInAllowed(account) {
|
|
6199
|
+
return this.fleetConfig?.web?.guests?.enabled === true && !this.isFleetAdmin(account.userId, account.adapterId) && this.guestScope(account).size > 0;
|
|
6200
|
+
}
|
|
6201
|
+
/**
|
|
6202
|
+
* #1920 §5.4: a guest's own `/web revoke` — its sessions and its outstanding code; nobody else's (an admin's revokes
|
|
6203
|
+
* everyone's, as before).
|
|
6204
|
+
*/
|
|
6205
|
+
revokeGuestWebSessions(owner) {
|
|
6206
|
+
this.initializeWebSessions();
|
|
6207
|
+
const key = `${owner.adapterId}\u0000${owner.userId}`;
|
|
6208
|
+
this.guestAccountRevokes.set(key, (this.guestAccountRevokes.get(key) ?? 0) + 1); // its /web on its way, too
|
|
6209
|
+
while (this.guestAccountRevokes.size > 4096)
|
|
6210
|
+
this.guestAccountRevokes.delete(this.guestAccountRevokes.keys().next().value);
|
|
6211
|
+
this.webGuestLoginCodes.revokeRecipient(owner);
|
|
6212
|
+
// §5.4: its sessions and its returning devices (no browser of theirs may ask for a code any more).
|
|
6213
|
+
const devices = this.returningDevices.revokeGuest(owner);
|
|
6214
|
+
const ended = this.webSessions.revokeAll(r => r.tier === "guest" && r.owner?.adapterId === owner.adapterId && r.owner.userId === owner.userId);
|
|
6215
|
+
this.logger.info({ adapterId: owner.adapterId, count: ended.count, devices: devices.count, durable: ended.durable && devices.durable }, "Guest web sessions revoked by their account");
|
|
6216
|
+
return { count: ended.count, durable: ended.durable && devices.durable };
|
|
6217
|
+
}
|
|
6218
|
+
/**
|
|
6219
|
+
* #1920 §2.3 (step 4): `/web` from someone who is not a fleet admin, while guest sign-in is on. `revoke` ends their
|
|
6220
|
+
* own guest sessions. Otherwise a guest code (tier guest, bound to this account) goes privately — a DM, else the
|
|
6221
|
+
* slash command's ephemeral answer (`replyPrivately`) — once a minute at most, and only when their scope (from what
|
|
6222
|
+
* they may already use in chat, rooms read at sign-in) holds something. The code itself is never posted where asked.
|
|
6223
|
+
* Callers ask isGuestWebAsk first (synchronously); false here too when it is not a guest's.
|
|
6224
|
+
*/
|
|
6225
|
+
async guestWebCommand(ask) {
|
|
6226
|
+
if (!this.isGuestWebAsk(ask.adapterId, ask.userId))
|
|
6227
|
+
return false;
|
|
6228
|
+
const owner = { adapterId: ask.adapterId, userId: ask.userId };
|
|
6229
|
+
const say = (text) => ask.replyHere(text).catch(() => undefined);
|
|
6230
|
+
if (ask.arg === "revoke") {
|
|
6231
|
+
const ended = this.revokeGuestWebSessions(owner);
|
|
6232
|
+
await say(t(ended.durable ? "web.guest_revoked" : "web.guest_revoked_not_durable", ended.count));
|
|
6233
|
+
return true;
|
|
6234
|
+
}
|
|
6235
|
+
const token = this.webToken;
|
|
6236
|
+
const adapter = this.adapters.get(ask.adapterId);
|
|
6237
|
+
if (this.shuttingDown || !token || !adapter) {
|
|
6238
|
+
await say(t("dashboard.starting"));
|
|
6239
|
+
return true;
|
|
6240
|
+
}
|
|
6241
|
+
const key = `${owner.adapterId}\u0000${owner.userId}`, asked = this.guestCodeAskedAt.get(key);
|
|
6242
|
+
if (this.guestCodeInFlight.has(key) || (asked !== undefined && performance.now() - asked < 60_000)) {
|
|
6243
|
+
await say(t("web.guest_wait"));
|
|
6244
|
+
return true;
|
|
6245
|
+
}
|
|
6246
|
+
this.guestCodeInFlight.add(key);
|
|
6247
|
+
try {
|
|
6248
|
+
await this.issueGuestCode(ask, owner, key, adapter, token);
|
|
6249
|
+
}
|
|
6250
|
+
finally {
|
|
6251
|
+
this.guestCodeInFlight.delete(key);
|
|
6252
|
+
}
|
|
6253
|
+
return true;
|
|
6254
|
+
}
|
|
6255
|
+
/** guestWebCommand's issuance, one per account at a time; a revoke made meanwhile (either kind) ends it unissued. */
|
|
6256
|
+
async issueGuestCode(ask, owner, key, adapter, token) {
|
|
6257
|
+
const say = (text) => ask.replyHere(text).catch(() => undefined);
|
|
6258
|
+
const revokes = this.guestRevokes, accountRevokes = this.guestAccountRevokes.get(key) ?? 0;
|
|
6259
|
+
const unrevoked = () => this.guestRevokes === revokes && (this.guestAccountRevokes.get(key) ?? 0) === accountRevokes;
|
|
6260
|
+
// §3.2: its rooms are read at sign-in (bounded; never longer than the reads' own deadline).
|
|
6261
|
+
await withinBudget(this.refreshGuestRooms(owner, { mode: "force" }), performance.now() + 6_000).catch(() => undefined);
|
|
6262
|
+
if (!unrevoked())
|
|
6263
|
+
return; // revoked while its rooms were read: no code
|
|
6264
|
+
const scope = this.guestScope(owner);
|
|
6265
|
+
if (!scope.size) {
|
|
6266
|
+
const open = this.worlds.get(owner.adapterId)?.accessManager.getMode() === "open";
|
|
6267
|
+
await say(t(open ? "web.guest_not_set_up" : "web.guest_nothing"));
|
|
6268
|
+
return;
|
|
6269
|
+
}
|
|
6270
|
+
this.initializeWebSessions();
|
|
6271
|
+
const codes = this.webGuestLoginCodes;
|
|
6272
|
+
if (codes.pausedForMs() > 0) {
|
|
6273
|
+
await say(t("web.guest_paused"));
|
|
6274
|
+
return;
|
|
6275
|
+
}
|
|
6276
|
+
// §5.5 (step 4b): with the public link open, the code works through it too (an admin opened it; a guest is still a
|
|
6277
|
+
// guest there) — most guests are not on the host's network. Its closing withdraws that audience (revokeAudience).
|
|
6278
|
+
const open = this.publicWebLink?.openSignin() ?? null;
|
|
6279
|
+
// One code per account (a new one replaces only its own); at the cap, refused — never another guest's evicted.
|
|
6280
|
+
const login = codes.issue({ epoch: tokenEpoch(token), audiences: ["local", ...(open ? [open.exposureId] : [])],
|
|
6281
|
+
recipient: { adapterId: owner.adapterId, userId: owner.userId, ...(ask.username ? { username: ask.username } : {}) } });
|
|
6282
|
+
if (!login) {
|
|
6283
|
+
await say(t("web.guest_busy"));
|
|
6284
|
+
return;
|
|
6285
|
+
}
|
|
6286
|
+
this.guestCodeAskedAt.delete(key);
|
|
6287
|
+
this.guestCodeAskedAt.set(key, performance.now());
|
|
6288
|
+
while (this.guestCodeAskedAt.size > 256)
|
|
6289
|
+
this.guestCodeAskedAt.delete(this.guestCodeAskedAt.keys().next().value);
|
|
6290
|
+
const current = () => !this.shuttingDown && this.webToken === token && this.adapters.get(owner.adapterId) === adapter
|
|
6291
|
+
&& unrevoked() && this.guestSignInAllowed(owner);
|
|
6292
|
+
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))}`;
|
|
6293
|
+
const deadline = performance.now() + 10_000;
|
|
6294
|
+
try {
|
|
6295
|
+
let byDm = false;
|
|
6296
|
+
try {
|
|
6297
|
+
if (!adapter.sendDirect)
|
|
6298
|
+
throw new Error("DM unavailable");
|
|
6299
|
+
await withinBudget(adapter.sendDirect(owner.userId, `${privateText}\n${t("dashboard.code_next")}`, { disablePreview: true }), Math.min(deadline, performance.now() + 5_000));
|
|
6300
|
+
byDm = true;
|
|
6301
|
+
}
|
|
6302
|
+
catch (err) {
|
|
6303
|
+
this.logDashboardDmFailure(err);
|
|
6304
|
+
if (!ask.replyPrivately || !current())
|
|
6305
|
+
throw err;
|
|
6306
|
+
const id = await withinBudget(ask.replyPrivately(privateText), deadline); // a slash command's answer: ephemeral
|
|
6307
|
+
if (!id)
|
|
6308
|
+
throw new Error("private delivery unconfirmed");
|
|
6309
|
+
}
|
|
6310
|
+
if (!current())
|
|
6311
|
+
throw new Error("guest no longer admitted");
|
|
6312
|
+
if (byDm) {
|
|
6313
|
+
await this.sendCodeAlone((body, opts) => adapter.sendDirect(owner.userId, body, opts), adapter.type, login.display);
|
|
6314
|
+
if (!current())
|
|
6315
|
+
throw new Error("guest no longer admitted");
|
|
6316
|
+
await say(t("web.guest_sent"));
|
|
6317
|
+
}
|
|
6318
|
+
}
|
|
6319
|
+
catch (err) {
|
|
6320
|
+
codes.revokeIfCurrent(login.issuanceId); // this flow's code only, never a later one's
|
|
6321
|
+
this.logger.info({ err: err?.message, adapterId: owner.adapterId }, "Guest sign-in code was not delivered privately");
|
|
6322
|
+
if (!unrevoked())
|
|
6323
|
+
return; // revoked meanwhile: the revoke's own answer said so
|
|
6324
|
+
const start = adapter.type === "telegram" ? this.tgStartWebUrl(owner.adapterId) : null;
|
|
6325
|
+
// #1742 for guests (step 4b-3): its START button (a bare /start, or the link's /start web) then asks again, from
|
|
6326
|
+
// the DM — once, within the window.
|
|
6327
|
+
if (adapter.type === "telegram")
|
|
6328
|
+
this.openTgWebStartWindow(owner.adapterId, owner.userId);
|
|
6329
|
+
await say(start ? t("web.guest_dm_failed_link", start) : t("web.guest_dm_failed"));
|
|
6330
|
+
}
|
|
6331
|
+
}
|
|
5631
6332
|
/** #1570 (auth-api): this browser becomes a returning device of the code's owner. The cookie value, or null. */
|
|
5632
6333
|
rememberReturningDevice(owner, input) {
|
|
5633
6334
|
const token = this.webToken;
|
|
@@ -5655,6 +6356,12 @@ export class FleetManager {
|
|
|
5655
6356
|
if (!token || this.shuttingDown)
|
|
5656
6357
|
return { kind: "refused" };
|
|
5657
6358
|
this.initializeWebSessions();
|
|
6359
|
+
// #2095 review: verified before the request's body was read — a revoke (its own, or an admin's) made meanwhile
|
|
6360
|
+
// removed it, and nothing it asks for may then go out.
|
|
6361
|
+
if (!this.returningDevices.current(device, tokenEpoch(token)))
|
|
6362
|
+
return { kind: "refused" };
|
|
6363
|
+
if (device.tier === "guest")
|
|
6364
|
+
return this.requestGuestDeviceCode(device, input, token);
|
|
5658
6365
|
const paused = this.webLoginCodes.pausedForMs();
|
|
5659
6366
|
if (paused > 0)
|
|
5660
6367
|
return { kind: "limited", retryAfterMs: paused };
|
|
@@ -5686,7 +6393,7 @@ export class FleetManager {
|
|
|
5686
6393
|
this.logger.info({ err: err?.message, adapterId }, "Sign-in code request: the private message could not be sent");
|
|
5687
6394
|
return { kind: "refused" };
|
|
5688
6395
|
}
|
|
5689
|
-
if (this.webToken !== token || !this.publicOwnerCurrent(owner)) {
|
|
6396
|
+
if (this.webToken !== token || !this.publicOwnerCurrent(owner) || !this.returningDevices.current(device, tokenEpoch(token))) {
|
|
5690
6397
|
this.webLoginCodes.revokeIfCurrent(issued.issuanceId);
|
|
5691
6398
|
return { kind: "refused" };
|
|
5692
6399
|
}
|
|
@@ -5694,15 +6401,94 @@ export class FleetManager {
|
|
|
5694
6401
|
// Asked again after the follow-up's wait: the owner, the token, shutdown, and a public request's exposure.
|
|
5695
6402
|
const exposureGone = input.surface === "gateway"
|
|
5696
6403
|
&& (this.publicWebLink?.exposureId !== input.exposureId || this.publicWebLink?.status().state !== "open");
|
|
5697
|
-
if (this.shuttingDown || this.webToken !== token || !this.publicOwnerCurrent(owner) || exposureGone) {
|
|
6404
|
+
if (this.shuttingDown || this.webToken !== token || !this.publicOwnerCurrent(owner) || exposureGone || !this.returningDevices.current(device, tokenEpoch(token))) {
|
|
5698
6405
|
this.webLoginCodes.revokeIfCurrent(issued.issuanceId);
|
|
5699
6406
|
return { kind: "refused" };
|
|
5700
6407
|
}
|
|
5701
6408
|
this.logger.info({ adapterId, surface: input.surface }, "Web sign-in code sent privately to a returning device's owner");
|
|
5702
6409
|
return { kind: "sent" };
|
|
5703
6410
|
}
|
|
6411
|
+
/**
|
|
6412
|
+
* #1920 step 4b: a guest's returning device asks for a code — a guest code for that account, by DM only, under the
|
|
6413
|
+
* same rules as its /web (guests on, not an admin, something in scope now; one per account and the cap; one flow per
|
|
6414
|
+
* account at a time; a revoke meanwhile ends it), its own limiter, and the guests' breaker. Asked through the public
|
|
6415
|
+
* link: only while that exposure is the open one (its code then works through it alone), as an admin's device.
|
|
6416
|
+
*/
|
|
6417
|
+
async requestGuestDeviceCode(device, input, token) {
|
|
6418
|
+
const codes = this.webGuestLoginCodes;
|
|
6419
|
+
const account = { adapterId: device.owner.adapterId, userId: device.owner.userId };
|
|
6420
|
+
const key = `${account.adapterId}\u0000${account.userId}`;
|
|
6421
|
+
const adapter = this.adapters.get(account.adapterId);
|
|
6422
|
+
const viaLink = input.surface === "gateway";
|
|
6423
|
+
const linkOpen = () => !!input.exposureId && this.publicWebLink?.openSignin()?.exposureId === input.exposureId;
|
|
6424
|
+
if ((viaLink ? !linkOpen() : input.exposureId !== undefined) || !adapter?.sendDirect || !this.guestSignInAllowed(account)) {
|
|
6425
|
+
this.logger.info({ adapterId: account.adapterId, surface: input.surface }, "Guest sign-in code request refused");
|
|
6426
|
+
return { kind: "refused" };
|
|
6427
|
+
}
|
|
6428
|
+
const paused = codes.pausedForMs();
|
|
6429
|
+
if (paused > 0)
|
|
6430
|
+
return { kind: "limited", retryAfterMs: paused };
|
|
6431
|
+
const asked = this.guestCodeAskedAt.get(key);
|
|
6432
|
+
if (this.guestCodeInFlight.has(key))
|
|
6433
|
+
return { kind: "limited", retryAfterMs: 5_000 };
|
|
6434
|
+
if (asked !== undefined && performance.now() - asked < 60_000)
|
|
6435
|
+
return { kind: "limited", retryAfterMs: 60_000 - (performance.now() - asked) };
|
|
6436
|
+
const admitted = this.guestCodeRequestLimiter.admit(device.idHash);
|
|
6437
|
+
if (!admitted.ok)
|
|
6438
|
+
return { kind: "limited", retryAfterMs: admitted.retryAfterMs };
|
|
6439
|
+
const revokes = this.guestRevokes, accountRevokes = this.guestAccountRevokes.get(key) ?? 0;
|
|
6440
|
+
const current = () => !this.shuttingDown && this.webToken === token && this.adapters.get(account.adapterId) === adapter
|
|
6441
|
+
&& this.guestRevokes === revokes && (this.guestAccountRevokes.get(key) ?? 0) === accountRevokes && this.guestSignInAllowed(account)
|
|
6442
|
+
&& (!viaLink || linkOpen()) && this.returningDevices.current(device, tokenEpoch(token));
|
|
6443
|
+
const issued = codes.issue({ epoch: tokenEpoch(token), recipient: account, audiences: [viaLink ? input.exposureId : "local"] });
|
|
6444
|
+
if (!issued)
|
|
6445
|
+
return { kind: "limited", retryAfterMs: 60_000 }; // the guests' cap: never someone's evicted
|
|
6446
|
+
this.guestCodeInFlight.add(key);
|
|
6447
|
+
this.guestCodeAskedAt.delete(key);
|
|
6448
|
+
this.guestCodeAskedAt.set(key, performance.now());
|
|
6449
|
+
try {
|
|
6450
|
+
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"))
|
|
6451
|
+
+ "\n" + t("dashboard.code_next");
|
|
6452
|
+
try {
|
|
6453
|
+
await withinBudget(adapter.sendDirect(account.userId, text, { disablePreview: true }), performance.now() + 5_000);
|
|
6454
|
+
}
|
|
6455
|
+
catch (err) {
|
|
6456
|
+
codes.revokeIfCurrent(issued.issuanceId);
|
|
6457
|
+
this.logger.info({ err: err?.message, adapterId: account.adapterId }, "Guest sign-in code request: the private message could not be sent");
|
|
6458
|
+
return { kind: "refused" };
|
|
6459
|
+
}
|
|
6460
|
+
if (!current()) {
|
|
6461
|
+
codes.revokeIfCurrent(issued.issuanceId);
|
|
6462
|
+
return { kind: "refused" };
|
|
6463
|
+
}
|
|
6464
|
+
await this.sendCodeAlone((body, opts) => adapter.sendDirect(account.userId, body, opts), adapter.type, issued.display);
|
|
6465
|
+
if (!current()) {
|
|
6466
|
+
codes.revokeIfCurrent(issued.issuanceId);
|
|
6467
|
+
return { kind: "refused" };
|
|
6468
|
+
}
|
|
6469
|
+
this.logger.info({ adapterId: account.adapterId }, "Guest sign-in code sent privately to a returning device's account");
|
|
6470
|
+
return { kind: "sent" };
|
|
6471
|
+
}
|
|
6472
|
+
finally {
|
|
6473
|
+
this.guestCodeInFlight.delete(key);
|
|
6474
|
+
}
|
|
6475
|
+
}
|
|
6476
|
+
/** #1920 step 4b (auth-api): this browser becomes a guest's returning device. The cookie value, or null. */
|
|
6477
|
+
rememberGuestDevice(account, input) {
|
|
6478
|
+
const token = this.webToken;
|
|
6479
|
+
if (!token)
|
|
6480
|
+
return null;
|
|
6481
|
+
this.initializeWebSessions();
|
|
6482
|
+
return this.returningDevices.rememberGuest(account, { ...input, tokenEpoch: tokenEpoch(token), webToken: token });
|
|
6483
|
+
}
|
|
5704
6484
|
/** Called by the sign-in endpoint: a login the operator did not make should be visible to them. */
|
|
5705
6485
|
onWebLogin(info) {
|
|
6486
|
+
// #1920 §2.3: a guest's sign-in is always said in General (web.notify_login is the operator's own sign-ins): who,
|
|
6487
|
+
// and how many agents they now reach — admins stay aware of everyone who is not one of them.
|
|
6488
|
+
if (info.tier === "guest" && info.owner) {
|
|
6489
|
+
this.notifyFleetError(t("web.guest_login_notice", this.webPerson(info.owner).name, info.label, this.guestScope(info.owner).size, info.handle.slice(0, 8)));
|
|
6490
|
+
return;
|
|
6491
|
+
}
|
|
5706
6492
|
if (this.fleetConfig?.web?.notify_login === false)
|
|
5707
6493
|
return;
|
|
5708
6494
|
// The session handle makes each notice distinct: notifyFleetError throttles by text, and a second
|
|
@@ -5901,11 +6687,17 @@ export class FleetManager {
|
|
|
5901
6687
|
* landed mid-edit) must not be cleared or disturbed (Prism b).
|
|
5902
6688
|
*/
|
|
5903
6689
|
async appendCleanupWarning(target, watch, warning, cleanupCommand) {
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
|
|
5907
|
-
|
|
5908
|
-
|
|
6690
|
+
// #2175: one text per outcome, frozen at its first tick. The completion carries elapsed seconds, so a text made
|
|
6691
|
+
// anew each tick would never equal the one already handed over and landed (terminalLanded compares exactly).
|
|
6692
|
+
const key = JSON.stringify([warning, cleanupCommand ?? null]);
|
|
6693
|
+
if (watch.frozen?.key !== key) {
|
|
6694
|
+
watch.frozen = { key, text: formatRestartProgressCompletion("update", {
|
|
6695
|
+
...watch.summary,
|
|
6696
|
+
warningText: warning,
|
|
6697
|
+
warningCommand: cleanupCommand,
|
|
6698
|
+
}, watch.receipt.startedAt) };
|
|
6699
|
+
}
|
|
6700
|
+
const text = watch.frozen.text;
|
|
5909
6701
|
const stopWatching = () => {
|
|
5910
6702
|
this.updateCleanupWatch = null;
|
|
5911
6703
|
if (this.updateProgressTimer)
|
|
@@ -5913,10 +6705,22 @@ export class FleetManager {
|
|
|
5913
6705
|
this.updateProgressTimer = null;
|
|
5914
6706
|
};
|
|
5915
6707
|
if (text !== this.lastUpdateProgressText) {
|
|
5916
|
-
|
|
5917
|
-
|
|
5918
|
-
|
|
5919
|
-
|
|
6708
|
+
// #2175: while the completion is still owed by the background retry, that retry is the message's only
|
|
6709
|
+
// writer — this text (the completion plus the warning) goes to it rather than racing it. Handing it over is not
|
|
6710
|
+
// delivery: the receipt and this watch stay until the retry reports this exact text landed (a later tick).
|
|
6711
|
+
const progress = this.startupProgress;
|
|
6712
|
+
if (progress?.terminalLanded(text)) {
|
|
6713
|
+
// landed through the retry
|
|
6714
|
+
}
|
|
6715
|
+
else if (progress?.replaceTerminalText(text)) {
|
|
6716
|
+
return;
|
|
6717
|
+
}
|
|
6718
|
+
else {
|
|
6719
|
+
const adapter = this.adapters.get(target.adapterId);
|
|
6720
|
+
if (!adapter)
|
|
6721
|
+
return; // retry on a later tick
|
|
6722
|
+
await adapter.editMessage(target.chatId, target.messageId, text, target.threadId);
|
|
6723
|
+
}
|
|
5920
6724
|
this.lastUpdateProgressText = text;
|
|
5921
6725
|
}
|
|
5922
6726
|
const verdict = cleanupWatchVerdict(readUpdateProgress(this.dataDir), watch.receipt);
|
|
@@ -6066,6 +6870,9 @@ export class FleetManager {
|
|
|
6066
6870
|
const { getTmuxSocketName: getSocket } = await import("./paths.js");
|
|
6067
6871
|
TmuxManager.setSocketName(getSocket());
|
|
6068
6872
|
await TmuxManager.ensureSession(getTmuxSession());
|
|
6873
|
+
this.tmuxSessionEnsured = true;
|
|
6874
|
+
// #2072: what this host can run — probed in the background, published for every picker.
|
|
6875
|
+
this.startAvailabilityRefresh();
|
|
6069
6876
|
// Pre-flight (advisory, fire-and-forget): warm the per-backend auth cache
|
|
6070
6877
|
// before instances spawn, so a CLI that comes up on its sign-in screen gets
|
|
6071
6878
|
// an instant 🔑 verdict instead of decaying into crash/MCP-died noise.
|
|
@@ -6273,7 +7080,7 @@ export class FleetManager {
|
|
|
6273
7080
|
...DEFAULT_SCHEDULER_CONFIG,
|
|
6274
7081
|
...this.fleetConfig?.defaults.scheduler,
|
|
6275
7082
|
};
|
|
6276
|
-
this.scheduler = new Scheduler(join(this.dataDir, "scheduler.db"), (schedule, runId, retry) => this.handleScheduleTrigger(schedule, runId, retry), schedulerConfig, (name) => this.fleetConfig?.instances?.[name] != null || !!this.classicChannels?.getAll().some(ch => ch.instanceName === name), (schedule, retry, reason) => this.scheduleRetryDropped(schedule, retry, reason));
|
|
7083
|
+
this.scheduler = new Scheduler(join(this.dataDir, "scheduler.db"), (schedule, runId, retry) => this.handleScheduleTrigger(schedule, runId, retry), schedulerConfig, (name) => this.fleetConfig?.instances?.[name] != null || !!this.classicChannels?.getAll().some(ch => ch.instanceName === name), (schedule, retry, reason) => this.scheduleRetryDropped(schedule, retry, reason), this.logger);
|
|
6277
7084
|
this.scheduler.init();
|
|
6278
7085
|
this.logger.info("Scheduler initialized");
|
|
6279
7086
|
// Tips share the daily-report clock but remain an independent internal
|
|
@@ -6303,6 +7110,7 @@ export class FleetManager {
|
|
|
6303
7110
|
const generals = allEntries.filter(([_, cfg]) => cfg.general_topic);
|
|
6304
7111
|
const others = allEntries.filter(([_, cfg]) => !cfg.general_topic);
|
|
6305
7112
|
const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), pendingUpdateProgress?.startedAt ?? startupStartedAt, this.logger, { mode: pendingProgressOperation === "full-restart" ? "reload" : pendingUpdateProgress ? "update" : "restart" });
|
|
7113
|
+
this.startupProgress = startupProgress;
|
|
6306
7114
|
if (generals.length > 0) {
|
|
6307
7115
|
for (const [name, cfg] of generals) {
|
|
6308
7116
|
try {
|
|
@@ -6389,6 +7197,18 @@ export class FleetManager {
|
|
|
6389
7197
|
this.pruneEventLog();
|
|
6390
7198
|
this.eventLogPruneTimer = setInterval(() => this.pruneEventLog(), 24 * 60 * 60_000);
|
|
6391
7199
|
this.eventLogPruneTimer.unref?.();
|
|
7200
|
+
// #2122 §5.5: usage is collected from the start, not from the first time someone opens a report — the same bounded
|
|
7201
|
+
// background passes the cache report uses (16 MB a pass, then every 10 minutes).
|
|
7202
|
+
{
|
|
7203
|
+
const cache = this.cacheServiceFor();
|
|
7204
|
+
cache.start();
|
|
7205
|
+
void cache.kick();
|
|
7206
|
+
}
|
|
7207
|
+
// #1920 §3.2: guests' ClassicBot room membership, read in the background — now, then each key ten minutes after
|
|
7208
|
+
// its answer (a minute after a failed read; the tick only finds what is due). A request only reads the answers.
|
|
7209
|
+
void this.refreshAllGuestRooms();
|
|
7210
|
+
this.guestRoomsTimer = setInterval(() => { void this.refreshAllGuestRooms(); }, GUEST_ROOM_TICK_MS);
|
|
7211
|
+
this.guestRoomsTimer.unref?.();
|
|
6392
7212
|
// #1335: delivery-outbox.db and Task Board retention. Same once-at-startup
|
|
6393
7213
|
// + daily-timer pattern; chunked DELETEs so one run cannot hold the loop.
|
|
6394
7214
|
void this.pruneOutboxAndTasks();
|
|
@@ -6718,48 +7538,109 @@ export class FleetManager {
|
|
|
6718
7538
|
const err = results[i].reason;
|
|
6719
7539
|
this.logger.error({ adapterId, err: err?.message ?? err }, "Adapter startup failed — scheduling background retry");
|
|
6720
7540
|
this.adapterState.set(adapterId, { status: "retrying", retryCount: 0, lastError: err?.message ?? String(err) });
|
|
6721
|
-
this.scheduleAdapterRetry(adapterId
|
|
7541
|
+
this.scheduleAdapterRetry(adapterId);
|
|
6722
7542
|
// Notify admin via whichever adapter is already up
|
|
6723
7543
|
this.notifyAdapterFailure(adapterId, err?.message ?? String(err));
|
|
6724
7544
|
}
|
|
6725
7545
|
}
|
|
6726
7546
|
}
|
|
6727
|
-
/**
|
|
6728
|
-
scheduleAdapterRetry(adapterId
|
|
6729
|
-
const MAX_RETRIES = 10;
|
|
7547
|
+
/** Keep trying while the fleet runs, with a five-minute cap even after a long outage. */
|
|
7548
|
+
scheduleAdapterRetry(adapterId) {
|
|
6730
7549
|
const INITIAL_DELAY_MS = 5_000;
|
|
6731
7550
|
const MAX_DELAY_MS = 5 * 60_000;
|
|
6732
7551
|
const state = this.adapterState.get(adapterId);
|
|
6733
|
-
if (!state || state.
|
|
6734
|
-
if (state) {
|
|
6735
|
-
state.status = "failed";
|
|
6736
|
-
this.logger.error({ adapterId, retries: state.retryCount }, "Adapter retry exhausted — giving up");
|
|
6737
|
-
this.notifyAdapterFailure(adapterId, `Retry exhausted after ${state.retryCount} attempts. Check token/network and restart fleet.`);
|
|
6738
|
-
}
|
|
7552
|
+
if (this.shuttingDown || !state || state.retryTimer || this.adapterRetryInFlight.has(adapterId))
|
|
6739
7553
|
return;
|
|
6740
|
-
|
|
6741
|
-
const delay = Math.min(INITIAL_DELAY_MS * Math.pow(2, state.retryCount), MAX_DELAY_MS);
|
|
7554
|
+
const delay = Math.min(INITIAL_DELAY_MS * Math.pow(2, Math.min(state.retryCount, 6)), MAX_DELAY_MS);
|
|
6742
7555
|
this.logger.info({ adapterId, attempt: state.retryCount + 1, delay_ms: delay }, "Scheduling adapter retry");
|
|
6743
|
-
|
|
7556
|
+
const timer = setTimeout(() => {
|
|
7557
|
+
const current = this.adapterState.get(adapterId);
|
|
7558
|
+
if (this.shuttingDown || current?.retryTimer !== timer)
|
|
7559
|
+
return;
|
|
7560
|
+
current.retryTimer = undefined;
|
|
7561
|
+
void this.attemptAdapterRetry(adapterId);
|
|
7562
|
+
}, delay);
|
|
7563
|
+
state.retryTimer = timer;
|
|
7564
|
+
timer.unref?.();
|
|
7565
|
+
}
|
|
7566
|
+
/** One login per connection, shared by the background timer and the operator's Retry now. */
|
|
7567
|
+
attemptAdapterRetry(adapterId) {
|
|
7568
|
+
const pending = this.adapterRetryInFlight.get(adapterId);
|
|
7569
|
+
if (pending)
|
|
7570
|
+
return pending;
|
|
7571
|
+
if (this.shuttingDown || !this.secureConnectionChannel(adapterId))
|
|
7572
|
+
return Promise.resolve();
|
|
7573
|
+
const attempt = Promise.resolve().then(async () => {
|
|
7574
|
+
const channel = this.secureConnectionChannel(adapterId);
|
|
7575
|
+
if (this.shuttingDown || !channel)
|
|
7576
|
+
return;
|
|
7577
|
+
const generation = this.settingsGeneration;
|
|
7578
|
+
const binding = JSON.stringify([channel, this.getPrimaryAdapterId()]);
|
|
7579
|
+
const token = process.env[channel.bot_token_env];
|
|
7580
|
+
const operation = { owner: Symbol("adapter-retry"), assert: () => {
|
|
7581
|
+
if (this.shuttingDown || generation !== this.settingsGeneration
|
|
7582
|
+
|| JSON.stringify([this.secureConnectionChannel(adapterId), this.getPrimaryAdapterId()]) !== binding
|
|
7583
|
+
|| process.env[channel.bot_token_env] !== token)
|
|
7584
|
+
throw new Error("adapter retry superseded");
|
|
7585
|
+
} };
|
|
7586
|
+
const state = this.adapterState.get(adapterId) ?? { status: "retrying", retryCount: 0 };
|
|
7587
|
+
state.status = "retrying";
|
|
6744
7588
|
state.retryCount++;
|
|
7589
|
+
this.adapterState.set(adapterId, state);
|
|
6745
7590
|
try {
|
|
6746
|
-
if (
|
|
6747
|
-
await this.startSingleAdapter(
|
|
6748
|
-
|
|
6749
|
-
|
|
6750
|
-
|
|
6751
|
-
|
|
6752
|
-
|
|
6753
|
-
|
|
7591
|
+
if (this.getPrimaryAdapterId() === adapterId)
|
|
7592
|
+
await this.startSingleAdapter(this.fleetConfig, channel, undefined, operation);
|
|
7593
|
+
else
|
|
7594
|
+
await this.startAdditionalAdapter(channel, true, undefined, operation);
|
|
7595
|
+
if (this.shuttingDown || generation !== this.settingsGeneration)
|
|
7596
|
+
return;
|
|
7597
|
+
if (!this.adapters.has(adapterId))
|
|
7598
|
+
throw new Error("Adapter token is not configured");
|
|
7599
|
+
// Health events can replace the state object during login. Update its current owner.
|
|
7600
|
+
const current = this.adapterState.get(adapterId);
|
|
7601
|
+
current.status = "connected";
|
|
7602
|
+
current.lastError = undefined;
|
|
6754
7603
|
this.logger.info({ adapterId, attempts: state.retryCount }, "Adapter reconnected on retry");
|
|
6755
7604
|
this.notifyAdapterRecovery(adapterId, state.retryCount);
|
|
6756
7605
|
}
|
|
6757
7606
|
catch (err) {
|
|
6758
|
-
|
|
6759
|
-
|
|
6760
|
-
this.
|
|
7607
|
+
if (this.shuttingDown || generation !== this.settingsGeneration)
|
|
7608
|
+
return;
|
|
7609
|
+
const current = this.adapterState.get(adapterId);
|
|
7610
|
+
current.status = "retrying";
|
|
7611
|
+
current.lastError = err?.message ?? String(err);
|
|
7612
|
+
this.logger.warn({ adapterId, attempt: state.retryCount, err: current.lastError }, "Adapter retry failed");
|
|
6761
7613
|
}
|
|
6762
|
-
}
|
|
7614
|
+
}).finally(() => {
|
|
7615
|
+
if (this.adapterRetryInFlight.get(adapterId) !== attempt)
|
|
7616
|
+
return;
|
|
7617
|
+
this.adapterRetryInFlight.delete(adapterId);
|
|
7618
|
+
const channel = this.secureConnectionChannel(adapterId);
|
|
7619
|
+
if (!this.shuttingDown && channel && this.adapterState.get(adapterId)?.status !== "connected") {
|
|
7620
|
+
this.scheduleAdapterRetry(adapterId);
|
|
7621
|
+
}
|
|
7622
|
+
});
|
|
7623
|
+
this.adapterRetryInFlight.set(adapterId, attempt);
|
|
7624
|
+
return attempt;
|
|
7625
|
+
}
|
|
7626
|
+
retryAdapterNow(adapterId) {
|
|
7627
|
+
if (this.shuttingDown)
|
|
7628
|
+
throw new Error("The fleet is stopping");
|
|
7629
|
+
if (!this.secureConnectionChannel(adapterId))
|
|
7630
|
+
throw new Error("Connection not found");
|
|
7631
|
+
if (this.adapterRetryInFlight.has(adapterId) || this.adapterRestarting.has(adapterId))
|
|
7632
|
+
return { queued: true, already_running: true };
|
|
7633
|
+
const state = this.adapterState.get(adapterId);
|
|
7634
|
+
if (state?.retryTimer) {
|
|
7635
|
+
clearTimeout(state.retryTimer);
|
|
7636
|
+
state.retryTimer = undefined;
|
|
7637
|
+
}
|
|
7638
|
+
const adapter = this.adapters.get(adapterId);
|
|
7639
|
+
if (adapter && this.connectionStatus(adapterId, state, this.worlds.get(adapterId)).status === "connected")
|
|
7640
|
+
return { queued: false };
|
|
7641
|
+
const run = adapter ? this.restartAdapter(adapter, adapterId) : this.attemptAdapterRetry(adapterId);
|
|
7642
|
+
void run.catch(err => this.logger.warn({ adapterId, err }, "Requested adapter retry failed"));
|
|
7643
|
+
return { queued: true };
|
|
6763
7644
|
}
|
|
6764
7645
|
/** Notify admin about adapter failure (uses any available adapter). */
|
|
6765
7646
|
notifyAdapterFailure(adapterId, error) {
|
|
@@ -6814,6 +7695,7 @@ export class FleetManager {
|
|
|
6814
7695
|
: snapshot.status === "stopped" ? "failed"
|
|
6815
7696
|
: "retrying";
|
|
6816
7697
|
this.adapterState.set(adapterId, {
|
|
7698
|
+
...previous,
|
|
6817
7699
|
status,
|
|
6818
7700
|
retryCount: previous?.retryCount ?? 0,
|
|
6819
7701
|
lastError: status === "connected" ? undefined : snapshot.lastReconnectReason ?? previous?.lastError,
|
|
@@ -6889,7 +7771,8 @@ export class FleetManager {
|
|
|
6889
7771
|
adapters: { total: this.adapterState.size, connected, states, details },
|
|
6890
7772
|
startupComplete: this.startupComplete,
|
|
6891
7773
|
memory: readFleetMemory(),
|
|
6892
|
-
hostMemory
|
|
7774
|
+
hostMemory: this.memoryTop && (hostMemory.level === "elevated" || hostMemory.level === "critical")
|
|
7775
|
+
? { ...hostMemory, topProcesses: this.memoryTop.processes, topProcessesAt: this.memoryTop.at } : hostMemory,
|
|
6893
7776
|
problems,
|
|
6894
7777
|
};
|
|
6895
7778
|
}
|
|
@@ -7505,6 +8388,48 @@ export class FleetManager {
|
|
|
7505
8388
|
this.adapterRestarting.delete(id);
|
|
7506
8389
|
}
|
|
7507
8390
|
}
|
|
8391
|
+
/**
|
|
8392
|
+
* #1990: how a reader in world `worldId`'s group reaches a place token's target — undefined (plain text) for another
|
|
8393
|
+
* world's instance, an instance without a fleet topic (a ClassicBot room may be anywhere), or an id no link is built
|
|
8394
|
+
* from. Discord: the channel mention; Telegram: the topic URL (General's topic included).
|
|
8395
|
+
*/
|
|
8396
|
+
placeLink(worldId, target) {
|
|
8397
|
+
const world = this.worlds.get(worldId ?? "");
|
|
8398
|
+
if (!world)
|
|
8399
|
+
return undefined;
|
|
8400
|
+
let topic;
|
|
8401
|
+
if (target.kind === "general") {
|
|
8402
|
+
const notice = this.fleetNoticeTarget(world.id);
|
|
8403
|
+
if (!notice || notice.chatId !== world.groupId || notice.opts.threadId === undefined)
|
|
8404
|
+
return undefined;
|
|
8405
|
+
topic = String(notice.opts.threadId);
|
|
8406
|
+
}
|
|
8407
|
+
else {
|
|
8408
|
+
if (this.getInstanceAdapterId(target.name) !== world.id)
|
|
8409
|
+
return undefined;
|
|
8410
|
+
const topicId = this.fleetConfig?.instances[target.name]?.topic_id;
|
|
8411
|
+
if (topicId == null)
|
|
8412
|
+
return undefined;
|
|
8413
|
+
topic = String(topicId);
|
|
8414
|
+
}
|
|
8415
|
+
if (world.type === "discord")
|
|
8416
|
+
return SNOWFLAKE.test(topic) && SNOWFLAKE.test(world.groupId) ? { mention: `<#${topic}>` } : undefined;
|
|
8417
|
+
const url = topicUrl(worldPlaceOf(world), topic, { general: target.kind === "general" });
|
|
8418
|
+
return url ? { url } : undefined;
|
|
8419
|
+
}
|
|
8420
|
+
/** #1990: a message for world `worldId`'s group with its place tokens rendered (links as text or Telegram entities). */
|
|
8421
|
+
renderPlaces(worldId, text, opts) {
|
|
8422
|
+
if (!hasPlaceRefs(text))
|
|
8423
|
+
return { text, opts };
|
|
8424
|
+
const type = this.worlds.get(worldId ?? "")?.type;
|
|
8425
|
+
const surface = type === "discord" ? "discord" : type === "telegram" ? "telegram" : "plain";
|
|
8426
|
+
const out = renderPlaceRefs(text, surface, target => this.placeLink(worldId, target));
|
|
8427
|
+
return { text: out.text, opts: out.textLinks.length ? { ...opts, textLinks: out.textLinks } : opts };
|
|
8428
|
+
}
|
|
8429
|
+
/** #1990: renderPlaces for the world that owns `instanceName`'s topic. */
|
|
8430
|
+
renderPlacesFor(instanceName, text, opts) {
|
|
8431
|
+
return this.renderPlaces(this.getInstanceAdapterId(instanceName), text, opts);
|
|
8432
|
+
}
|
|
7508
8433
|
/** Handle inbound message — transcribe voice if present, then route */
|
|
7509
8434
|
findGeneralInstance(adapterId) {
|
|
7510
8435
|
if (!this.fleetConfig)
|
|
@@ -7998,6 +8923,13 @@ export class FleetManager {
|
|
|
7998
8923
|
if (!isPrivateChat && !cmdMatch && rawText.startsWith("/")) {
|
|
7999
8924
|
return; // bare slash in group — ignore silently
|
|
8000
8925
|
}
|
|
8926
|
+
// #1920 step 4b-3: /web typed in a ClassicBot room (a group: `/web@ThisBot`) by someone who is not a fleet admin,
|
|
8927
|
+
// while guest sign-in is on: their guest code, privately — as anywhere else. An admin's goes on as before.
|
|
8928
|
+
if (/^\/(?:web|dashboard)(?:\s|$)/i.test(text) && msgAdapter && this.isGuestWebAsk(msg.adapterId, msg.userId)) {
|
|
8929
|
+
await this.guestWebCommand({ adapterId: msg.adapterId, userId: msg.userId, username: msg.username,
|
|
8930
|
+
arg: text.replace(/^\/(?:web|dashboard)/i, "").trim().toLowerCase(), replyHere: body => msgAdapter.sendText(chatId, body) });
|
|
8931
|
+
return;
|
|
8932
|
+
}
|
|
8001
8933
|
// Handle /start command
|
|
8002
8934
|
if (text === "/start" || text.startsWith("/start ")) {
|
|
8003
8935
|
// #1686: intercept /start web before the ClassicBot admission flow runs.
|
|
@@ -8006,15 +8938,21 @@ export class FleetManager {
|
|
|
8006
8938
|
// #1742: a bare /start from a fleet admin whose /web just failed to reach them privately is that /start web
|
|
8007
8939
|
// (Telegram's START button sends it bare) — once, within the window; any other bare /start is ClassicBot's.
|
|
8008
8940
|
const admin = isPrivateChat && this.isFleetAdmin(msg.userId, msg.adapterId ?? "");
|
|
8941
|
+
// #1920 step 4b-3: a guest's too (guest sign-in on, not an admin) — its /web reached it no other way.
|
|
8942
|
+
const guest = isPrivateChat && !admin && this.isGuestWebAsk(msg.adapterId, msg.userId);
|
|
8009
8943
|
const webStart = isPrivateChat && (requestedBackend === "web"
|
|
8010
|
-
|| (text === "/start" && admin && this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId)));
|
|
8944
|
+
|| (text === "/start" && (admin || guest) && this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId)));
|
|
8011
8945
|
if (webStart) {
|
|
8012
8946
|
if (requestedBackend === "web")
|
|
8013
8947
|
this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId);
|
|
8014
8948
|
// #1742: the /web menu right here; the code comes to this DM.
|
|
8015
8949
|
if (admin)
|
|
8016
8950
|
await this.startWebInDm(msg, msgAdapter ?? undefined);
|
|
8017
|
-
//
|
|
8951
|
+
// A guest: its guest code, here (this DM is where it goes).
|
|
8952
|
+
else if (guest && msgAdapter)
|
|
8953
|
+
await this.guestWebCommand({ adapterId: msg.adapterId, userId: msg.userId, username: msg.username, arg: "",
|
|
8954
|
+
replyHere: body => msgAdapter.sendText(chatId, body) });
|
|
8955
|
+
// Anyone else: /start alone is enough to open the DM channel; no further action.
|
|
8018
8956
|
return;
|
|
8019
8957
|
}
|
|
8020
8958
|
// All new starts, including explicit backend and later chooser callbacks,
|
|
@@ -8318,10 +9256,7 @@ export class FleetManager {
|
|
|
8318
9256
|
this.afterUserInboundDelivered(generalInstance, msg);
|
|
8319
9257
|
this.logger.info(`${msg.username} → ${generalInstance}: ${(text ?? "").slice(0, 100)}`);
|
|
8320
9258
|
this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), generalInstance);
|
|
8321
|
-
this.
|
|
8322
|
-
instance: generalInstance, sender: msg.username, role: "user",
|
|
8323
|
-
text: (text ?? "").slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
|
|
8324
|
-
});
|
|
9259
|
+
this.emitPlatformInbound(generalInstance, msg, text ?? "");
|
|
8325
9260
|
}
|
|
8326
9261
|
catch (err) {
|
|
8327
9262
|
this.logger.warn({ err: err.message, instanceName: generalInstance }, "General wake/delivery failed");
|
|
@@ -8430,9 +9365,16 @@ export class FleetManager {
|
|
|
8430
9365
|
this.afterUserInboundDelivered(instanceName, msg);
|
|
8431
9366
|
this.logger.info(`${msg.username} → ${instanceName}: ${(text ?? "").slice(0, 100)}`);
|
|
8432
9367
|
this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), instanceName);
|
|
9368
|
+
this.emitPlatformInbound(instanceName, msg, text ?? "");
|
|
9369
|
+
}
|
|
9370
|
+
/**
|
|
9371
|
+
* A person's platform message as the web chat shows it — the user's own words, as a user (#1306), in the instance's
|
|
9372
|
+
* history (#1565). #1920 §5.2: said in the instance's topic, General or room, so it is that channel's, by that account.
|
|
9373
|
+
*/
|
|
9374
|
+
emitPlatformInbound(instance, msg, text) {
|
|
8433
9375
|
this.emitSseEvent("message", {
|
|
8434
|
-
instance
|
|
8435
|
-
|
|
9376
|
+
instance, sender: msg.username, role: "user", text: text.slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
|
|
9377
|
+
channel: true, from: webChatFrom(msg.adapterId, msg.userId),
|
|
8436
9378
|
});
|
|
8437
9379
|
}
|
|
8438
9380
|
/** Handle outbound tool calls from a daemon instance */
|
|
@@ -8579,6 +9521,16 @@ export class FleetManager {
|
|
|
8579
9521
|
respond(null, permitted.message);
|
|
8580
9522
|
return;
|
|
8581
9523
|
}
|
|
9524
|
+
// Fleet tools resolve their own targets. A missing chat world must not prevent local
|
|
9525
|
+
// delivery, inspection or receipt lookup; only channel tools need reply routing below.
|
|
9526
|
+
const handler = outboundHandlers.get(tool);
|
|
9527
|
+
if (handler && !usesChannelAddress(tool)) {
|
|
9528
|
+
this.eventLog?.logActivity("tool_call", senderSessionName ?? instanceName, this.summarizeToolCall(tool, args));
|
|
9529
|
+
await handler(this, args, respond, {
|
|
9530
|
+
instanceName, requestId, fleetRequestId, senderSessionName, operationId, sourceDaemonBootId,
|
|
9531
|
+
});
|
|
9532
|
+
return;
|
|
9533
|
+
}
|
|
8582
9534
|
// A fleet with no chat platform at all is driven from the web dashboard alone: a reply has nowhere
|
|
8583
9535
|
// else to go, and "retry shortly" would have the agent retry forever. It goes to the web chat.
|
|
8584
9536
|
const webOnlyReply = tool === "reply" && this.worlds.size === 0 && this.isWebOnlyFleet();
|
|
@@ -8734,7 +9686,14 @@ export class FleetManager {
|
|
|
8734
9686
|
// an agent cannot invent it to suppress the normal completion marker.
|
|
8735
9687
|
if (!error && result != null && msg.statusOnly !== true) {
|
|
8736
9688
|
try {
|
|
8737
|
-
|
|
9689
|
+
// #1920 §5.2: shown to the instance's guests only when it landed in the instance's own topic or room.
|
|
9690
|
+
let inChannel = false;
|
|
9691
|
+
try {
|
|
9692
|
+
inChannel = outAdapter !== WEB_ONLY_REPLY_SINK
|
|
9693
|
+
&& this.landedInOwnChannel(instanceName, contextAdapterId ?? this.getInstanceAdapterId(instanceName), result);
|
|
9694
|
+
}
|
|
9695
|
+
catch { /* not known: not the channel's (fail closed), and the bookkeeping below still runs */ }
|
|
9696
|
+
void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView, inChannel);
|
|
8738
9697
|
}
|
|
8739
9698
|
catch (err) {
|
|
8740
9699
|
this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
|
|
@@ -8763,19 +9722,14 @@ export class FleetManager {
|
|
|
8763
9722
|
if (routeToolCall(outAdapter, tool, args, threadId, respond)) {
|
|
8764
9723
|
return;
|
|
8765
9724
|
}
|
|
8766
|
-
// Log tool calls for activity visualization
|
|
8767
|
-
const senderLabel = senderSessionName ?? instanceName;
|
|
8768
|
-
this.eventLog?.logActivity("tool_call", senderLabel, this.summarizeToolCall(tool, args));
|
|
8769
|
-
// Dispatch fleet-specific tools via handler map
|
|
8770
|
-
const handler = outboundHandlers.get(tool);
|
|
8771
9725
|
if (handler) {
|
|
9726
|
+
this.eventLog?.logActivity("tool_call", senderSessionName ?? instanceName, this.summarizeToolCall(tool, args));
|
|
8772
9727
|
await handler(this, args, respond, {
|
|
8773
9728
|
instanceName, requestId, fleetRequestId, senderSessionName, operationId, sourceDaemonBootId,
|
|
8774
9729
|
});
|
|
8775
9730
|
}
|
|
8776
|
-
else
|
|
9731
|
+
else
|
|
8777
9732
|
respond(null, `Unknown tool: ${tool}`);
|
|
8778
|
-
}
|
|
8779
9733
|
}
|
|
8780
9734
|
/** Side effects of a routed reply: cancel-button lifecycle, logs, SSE, chat log. */
|
|
8781
9735
|
/** No chat platform is configured at all: the web dashboard is the fleet's only surface. */
|
|
@@ -8788,7 +9742,35 @@ export class FleetManager {
|
|
|
8788
9742
|
return this.worlds.size === 0 && this.isWebOnlyFleet() ? { adapter: WEB_ONLY_PROMPT_SINK, adapterId: "web", chatId: "web" } : null;
|
|
8789
9743
|
}
|
|
8790
9744
|
/** Resolves when the reply is in the web chat (#1770: after its files are copied); the caller need not wait. */
|
|
8791
|
-
|
|
9745
|
+
/**
|
|
9746
|
+
* #1920 §5.2: whether a platform send landed in the instance's own place — its ClassicBot room, or its topic in its
|
|
9747
|
+
* connection's group — so its channel's members saw it. Anything else (another chat, a DM, a context left from General,
|
|
9748
|
+
* an answer without the chat it went to) is not the channel's.
|
|
9749
|
+
*/
|
|
9750
|
+
landedInOwnChannel(instance, adapterId, sent) {
|
|
9751
|
+
const s = sent && typeof sent === "object" ? sent : {};
|
|
9752
|
+
if (!adapterId || typeof s.chatId !== "string" || !s.chatId)
|
|
9753
|
+
return false;
|
|
9754
|
+
const roomId = this.classicChannels?.getChannelIdByInstance(instance);
|
|
9755
|
+
if (roomId !== undefined)
|
|
9756
|
+
return (this.classicChannels?.getAdapterIdByInstance(instance) ?? this.getPrimaryAdapterId()) === adapterId && roomId === s.chatId;
|
|
9757
|
+
const cfg = this.fleetConfig?.instances && Object.hasOwn(this.fleetConfig.instances, instance) ? this.fleetConfig.instances[instance] : undefined;
|
|
9758
|
+
const world = this.worlds.get(adapterId);
|
|
9759
|
+
if (!cfg || this.getInstanceAdapterId(instance) !== adapterId || !world?.groupId || s.chatId !== world.groupId)
|
|
9760
|
+
return false;
|
|
9761
|
+
const thread = typeof s.threadId === "string" && s.threadId ? s.threadId : null;
|
|
9762
|
+
return cfg.topic_id == null ? thread === null : thread === String(cfg.topic_id);
|
|
9763
|
+
}
|
|
9764
|
+
/** #1920 §5.2 (#2085 review): a platform click's buttons are in the instance's own place — else not its channel's. */
|
|
9765
|
+
clickInOwnChannel(set) {
|
|
9766
|
+
try {
|
|
9767
|
+
return this.landedInOwnChannel(set.instance, set.adapterId, { chatId: set.chatId, threadId: set.threadId || undefined });
|
|
9768
|
+
}
|
|
9769
|
+
catch {
|
|
9770
|
+
return false;
|
|
9771
|
+
}
|
|
9772
|
+
}
|
|
9773
|
+
afterReplyRouted(instanceName, args, senderSessionName, buttons = null, inChannel = false) {
|
|
8792
9774
|
// A reply is NOT proof the turn is over (#410) — but it is not proof of
|
|
8793
9775
|
// more work either. Split the difference: an instance that is clearly
|
|
8794
9776
|
// idle loses the button now; one that looks busy keeps it (re-posted
|
|
@@ -8824,6 +9806,7 @@ export class FleetManager {
|
|
|
8824
9806
|
ts,
|
|
8825
9807
|
...(attachments.length ? { attachments } : {}),
|
|
8826
9808
|
...(buttons ? { buttons } : {}), // #1266
|
|
9809
|
+
...(inChannel ? { channel: true } : {}), // #1920 §5.2
|
|
8827
9810
|
});
|
|
8828
9811
|
};
|
|
8829
9812
|
const before = this.webReplyOrder.get(instanceName);
|
|
@@ -9177,6 +10160,19 @@ export class FleetManager {
|
|
|
9177
10160
|
}
|
|
9178
10161
|
dispatchTypedIpc(name, msg) {
|
|
9179
10162
|
const type = String(msg.type);
|
|
10163
|
+
if (type.startsWith("fleet_") && WEB_EFFECT_TOOLS.has(type.slice(6))) {
|
|
10164
|
+
const ipc = this.instanceIpcClients.get(name), fleetRequestId = msg.fleetRequestId;
|
|
10165
|
+
if (!ipc || typeof fleetRequestId !== "string")
|
|
10166
|
+
return;
|
|
10167
|
+
void this.handleWebThemeHttp(name, type.slice(6), (msg.payload ?? {})).then(result => {
|
|
10168
|
+
if (this.instanceIpcClients.get(name) === ipc)
|
|
10169
|
+
ipc.send({ type: "fleet_web_theme_response", fleetRequestId, result });
|
|
10170
|
+
}, err => {
|
|
10171
|
+
if (this.instanceIpcClients.get(name) === ipc)
|
|
10172
|
+
ipc.send({ type: "fleet_web_theme_response", fleetRequestId, error: err instanceof Error ? err.message : "Effect operation failed" });
|
|
10173
|
+
}).catch(err => this.logger.debug({ err }, "Effect tool response could not be written"));
|
|
10174
|
+
return;
|
|
10175
|
+
}
|
|
9180
10176
|
if (type.startsWith("fleet_schedule_")) {
|
|
9181
10177
|
this.handleScheduleCrud(name, msg);
|
|
9182
10178
|
return;
|
|
@@ -9202,6 +10198,37 @@ export class FleetManager {
|
|
|
9202
10198
|
this.handlePersonaEmoji(name, msg);
|
|
9203
10199
|
return;
|
|
9204
10200
|
}
|
|
10201
|
+
if (type === "fleet_get_web_theme" || type === "fleet_set_web_theme" || type === "fleet_set_web_theme_asset" || type === "fleet_reset_web_theme"
|
|
10202
|
+
|| type === "fleet_get_web_appearance" || type === "fleet_preview_web_appearance"
|
|
10203
|
+
|| type === "fleet_set_web_widget" || type === "fleet_remove_web_widget"
|
|
10204
|
+
|| type === "fleet_set_web_ambient" || type === "fleet_clear_web_ambient"
|
|
10205
|
+
|| type === "fleet_export_web_mod" || type === "fleet_propose_web_mod") {
|
|
10206
|
+
this.handleWebTheme(name, msg);
|
|
10207
|
+
return;
|
|
10208
|
+
}
|
|
10209
|
+
}
|
|
10210
|
+
/**
|
|
10211
|
+
* #1629: the caller's own web theme over IPC (the agent endpoint calls the same service). `name` is the instance this
|
|
10212
|
+
* IPC connection belongs to — never anything the message says. A refusal is a result the agent reads (its `error`
|
|
10213
|
+
* and what to do), not a transport error.
|
|
10214
|
+
*/
|
|
10215
|
+
handleWebTheme(instanceName, msg) {
|
|
10216
|
+
const fleetRequestId = msg.fleetRequestId;
|
|
10217
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
10218
|
+
if (!ipc)
|
|
10219
|
+
return;
|
|
10220
|
+
const tool = String(msg.type).replace(/^fleet_/, "");
|
|
10221
|
+
try {
|
|
10222
|
+
const args = (msg.payload ?? {});
|
|
10223
|
+
const result = tool === "get_web_appearance" || tool === "preview_web_appearance"
|
|
10224
|
+
? this.appearanceTools.handle(instanceName, tool, args)
|
|
10225
|
+
: tool === "export_web_mod" || tool === "propose_web_mod" ? this.agentMods.handle(instanceName, tool, args)
|
|
10226
|
+
: this.webAppearance.handleTool(instanceName, tool, args);
|
|
10227
|
+
ipc.send({ type: "fleet_web_theme_response", fleetRequestId, result });
|
|
10228
|
+
}
|
|
10229
|
+
catch (err) {
|
|
10230
|
+
ipc.send({ type: "fleet_web_theme_response", fleetRequestId, error: err.message });
|
|
10231
|
+
}
|
|
9205
10232
|
}
|
|
9206
10233
|
/** `list_emojis` / `set_persona_emoji` / `preview_emojis` / `list_stickers` / `preview_stickers` over IPC; the agent endpoint calls the same methods. */
|
|
9207
10234
|
handlePersonaEmoji(instanceName, msg) {
|
|
@@ -9289,10 +10316,11 @@ export class FleetManager {
|
|
|
9289
10316
|
}
|
|
9290
10317
|
case "fleet_decision_list":
|
|
9291
10318
|
db.pruneExpiredDecisions();
|
|
9292
|
-
|
|
10319
|
+
// #2152: titles by default; the full text by id, or all of it with full: true.
|
|
10320
|
+
result = decisionListAnswer(db.listDecisions(projectRoot, {
|
|
9293
10321
|
includeArchived: payload.include_archived,
|
|
9294
10322
|
tags: payload.tags,
|
|
9295
|
-
});
|
|
10323
|
+
}), { id: payload.id, full: payload.full });
|
|
9296
10324
|
break;
|
|
9297
10325
|
case "fleet_decision_update": {
|
|
9298
10326
|
const id = payload.id;
|
|
@@ -9485,10 +10513,16 @@ export class FleetManager {
|
|
|
9485
10513
|
created_by: instance,
|
|
9486
10514
|
});
|
|
9487
10515
|
}
|
|
9488
|
-
|
|
9489
|
-
|
|
9490
|
-
|
|
9491
|
-
|
|
10516
|
+
// #2152: titles by default (agend-agent decision-list); one decision's full text by id (decision-get <id>).
|
|
10517
|
+
// The CLI sends include_archived (snake case, as the MCP tool): both spellings are read.
|
|
10518
|
+
case "list":
|
|
10519
|
+
case "get":
|
|
10520
|
+
if (op === "get" && !asStr(args.id)?.trim())
|
|
10521
|
+
return { error: "id is required" };
|
|
10522
|
+
return decisionListAnswer(db.listDecisions(projectRoot, {
|
|
10523
|
+
includeArchived: args.includeArchived === true || args.include_archived === true,
|
|
10524
|
+
tags: asStrArr(args.tags),
|
|
10525
|
+
}), { id: op === "get" ? asStr(args.id) ?? "" : args.id, full: args.full });
|
|
9492
10526
|
case "update": {
|
|
9493
10527
|
const id = asStr(args.id);
|
|
9494
10528
|
if (!id)
|
|
@@ -9606,6 +10640,19 @@ export class FleetManager {
|
|
|
9606
10640
|
async handleSetPersonaEmojiHttp(instance, args) {
|
|
9607
10641
|
return this.setPersonaEmoji(instance, args);
|
|
9608
10642
|
}
|
|
10643
|
+
/** #1629: `agend-agent theme …` — the same service as the tools; `instance` is the token's. */
|
|
10644
|
+
async handleWebThemeHttp(instance, tool, args) {
|
|
10645
|
+
if (WEB_EFFECT_TOOLS.has(tool)) {
|
|
10646
|
+
if (!this.isKnownInstance(instance))
|
|
10647
|
+
return { error: "unknown_instance" };
|
|
10648
|
+
return this.effectTools.handle(instance, tool, args);
|
|
10649
|
+
}
|
|
10650
|
+
if (tool === "get_web_appearance" || tool === "preview_web_appearance")
|
|
10651
|
+
return this.appearanceTools.handle(instance, tool, args);
|
|
10652
|
+
if (tool === "export_web_mod" || tool === "propose_web_mod")
|
|
10653
|
+
return this.agentMods.handle(instance, tool, args);
|
|
10654
|
+
return this.webAppearance.handleTool(instance, tool, args);
|
|
10655
|
+
}
|
|
9609
10656
|
async handlePreviewEmojisHttp(instance, args) {
|
|
9610
10657
|
return this.previewEmojis(instance, args);
|
|
9611
10658
|
}
|
|
@@ -10021,6 +11068,7 @@ export class FleetManager {
|
|
|
10021
11068
|
*/
|
|
10022
11069
|
saveFleetConfig(explicitPatches = []) {
|
|
10023
11070
|
this.publicWebLink?.refresh();
|
|
11071
|
+
this.enforceGuestSwitch(); // #1920: a Settings save turning guests off ends their sessions
|
|
10024
11072
|
if (!this.fleetConfig || !this.configPath)
|
|
10025
11073
|
return;
|
|
10026
11074
|
if (!this.savedFleetConfigSnapshot)
|
|
@@ -10308,6 +11356,17 @@ export class FleetManager {
|
|
|
10308
11356
|
catch (err) {
|
|
10309
11357
|
this.logger.warn({ err: err.message, name }, "Dropping a removed instance from web favorites failed");
|
|
10310
11358
|
}
|
|
11359
|
+
try {
|
|
11360
|
+
this.themeStore.forget(name);
|
|
11361
|
+
this.webAppearance.forget(name);
|
|
11362
|
+
this.appearanceTools.forget(name);
|
|
11363
|
+
this.effectToolsService?.forget(name);
|
|
11364
|
+
this.mods.forgetInstance(name);
|
|
11365
|
+
this.agentMods.forget(name);
|
|
11366
|
+
}
|
|
11367
|
+
catch (err) {
|
|
11368
|
+
this.logger.warn({ err: err.message, name }, "Dropping a removed instance's web theme failed");
|
|
11369
|
+
}
|
|
10311
11370
|
// Clean up statusline watcher + instance directory
|
|
10312
11371
|
this.statuslineWatcher.unwatch(name);
|
|
10313
11372
|
// instanceDirToClean and effectiveBackend are captured before lifecycle.remove
|
|
@@ -10499,11 +11558,23 @@ export class FleetManager {
|
|
|
10499
11558
|
guildEmojiFetches = new Map();
|
|
10500
11559
|
// ── #1630: what stands for an instance in the web app, and Discord server emoji in web chat ──
|
|
10501
11560
|
webImageCaches = null;
|
|
11561
|
+
/** Server emoji whose picture fetch failure was already logged at warn (once each; bounded). */
|
|
11562
|
+
webEmojiFailures = new Set();
|
|
10502
11563
|
webImageCache(kind) {
|
|
10503
11564
|
const onError = (key, err) => this.logger.debug({ err: err?.message, key }, "web image fetch failed (kept what was cached)");
|
|
11565
|
+
// A server emoji's picture that cannot be fetched reads `:name:` in web chat (it used to be an empty box, the user's
|
|
11566
|
+
// "tofu"): say why once per emoji, where it can be seen, not on every render (its key is quiet for an hour anyway).
|
|
11567
|
+
const emojiError = (key, err) => {
|
|
11568
|
+
if (this.webEmojiFailures.has(key))
|
|
11569
|
+
return onError(key, err);
|
|
11570
|
+
this.webEmojiFailures.add(key);
|
|
11571
|
+
while (this.webEmojiFailures.size > 512)
|
|
11572
|
+
this.webEmojiFailures.delete(this.webEmojiFailures.values().next().value);
|
|
11573
|
+
this.logger.warn({ err: err?.message, key }, "Discord server emoji picture could not be fetched (web chat shows its :name:)");
|
|
11574
|
+
};
|
|
10504
11575
|
this.webImageCaches ??= {
|
|
10505
11576
|
avatars: new WebImageCache({ dir: join(this.dataDir, "cache", "avatars"), onError }),
|
|
10506
|
-
emoji: new WebImageCache({ dir: join(this.dataDir, "cache", "emoji"), onError }),
|
|
11577
|
+
emoji: new WebImageCache({ dir: join(this.dataDir, "cache", "emoji"), onError: emojiError }),
|
|
10507
11578
|
};
|
|
10508
11579
|
return this.webImageCaches[kind];
|
|
10509
11580
|
}
|
|
@@ -10596,10 +11667,25 @@ export class FleetManager {
|
|
|
10596
11667
|
const avatar = avatarFor({ name, displayName, profileVersion, bot: bot && connection ? { connection, version: bot.version } : null,
|
|
10597
11668
|
personaEmoji: cfg?.status_emojis?.delivered });
|
|
10598
11669
|
// The ring belongs to an own bot's face: only when that is what is shown (a shared bot's would ring everyone alike).
|
|
10599
|
-
|
|
11670
|
+
// #1958: the own bot's colour is also the identity colour's role tier — whatever face is shown (never a shared bot's).
|
|
11671
|
+
const roleColor = ownConnection && owner
|
|
10600
11672
|
? this.webAccent(ownConnection, owner, cfg ? (cfg.topic_id == null ? undefined : String(cfg.topic_id)) : this.classicChannels?.getChannelIdByInstance(name))
|
|
10601
11673
|
: null;
|
|
10602
|
-
|
|
11674
|
+
const accent = !profileVersion && bot && ownConnection && owner ? roleColor : null;
|
|
11675
|
+
// #1629 (first design §5, r2 (d)): the agent's theme may name its own picture and ring colour. Both come below a
|
|
11676
|
+
// person's upload (never when there is one) and ride beside the theme-free face: a viewer who turned agents'
|
|
11677
|
+
// themes off (fx.show), or a safe-mode load, shows `avatar` and `accent` as before.
|
|
11678
|
+
let theme = { v: 1 };
|
|
11679
|
+
try {
|
|
11680
|
+
theme = this.themeStore.get(name).theme;
|
|
11681
|
+
}
|
|
11682
|
+
catch { /* unreadable themes: none */ }
|
|
11683
|
+
const themeAvatar = !profileVersion && theme.avatar ? { kind: "image", src: `/ui/img/theme/${encodeURIComponent(name)}/${theme.avatar}` } : undefined;
|
|
11684
|
+
const themeAccent = !profileVersion && theme.accent ? theme.accent : undefined;
|
|
11685
|
+
return { avatar, ...(accent ? { accent } : {}), ...(themeAvatar ? { themeAvatar } : {}), ...(themeAccent ? { themeAccent } : {}),
|
|
11686
|
+
// #1958: the agent's own identity colour (theme.identity.color, else its accent) and where it would show it.
|
|
11687
|
+
...(roleColor ? { roleColor } : {}), ...(theme.identity?.color ?? theme.accent ? { agentColor: theme.identity?.color ?? theme.accent } : {}),
|
|
11688
|
+
...(theme.identity?.show ? { agentShow: theme.identity.show } : {}) };
|
|
10603
11689
|
}
|
|
10604
11690
|
/**
|
|
10605
11691
|
* GET /ui/img/bot|avatar/<key>: what is cached, never a fetch. Only a connection some instance or room speaks
|
|
@@ -11686,7 +12772,8 @@ export class FleetManager {
|
|
|
11686
12772
|
// Discord, so every such fallback failed inside the catch handler.
|
|
11687
12773
|
const target = this.fleetNoticeTarget();
|
|
11688
12774
|
if (this.adapter && target) {
|
|
11689
|
-
this.
|
|
12775
|
+
const out = this.renderPlaces(this.getPrimaryAdapterId(), body, target.opts);
|
|
12776
|
+
this.adapter.sendText(target.chatId, out.text, out.opts)
|
|
11690
12777
|
.catch(err => this.logger.warn({ err }, "Failed to send fleet error notification"));
|
|
11691
12778
|
dispatched = true;
|
|
11692
12779
|
}
|
|
@@ -11698,7 +12785,7 @@ export class FleetManager {
|
|
|
11698
12785
|
// also suppressed the next ten minutes of identical ones. Leaving the key
|
|
11699
12786
|
// unspent lets the next caller, once an adapter is up, actually get
|
|
11700
12787
|
// through. The counter is not reset either: nothing was shown.
|
|
11701
|
-
this.logger.warn({ text: body }, "Fleet error could not be delivered (no adapter or no target yet) — not consuming the throttle window");
|
|
12788
|
+
this.logger.warn({ text: stripPlaceRefs(body) }, "Fleet error could not be delivered (no adapter or no target yet) — not consuming the throttle window");
|
|
11702
12789
|
return false;
|
|
11703
12790
|
}
|
|
11704
12791
|
if (!throttled)
|
|
@@ -11736,24 +12823,28 @@ export class FleetManager {
|
|
|
11736
12823
|
const groupId = channelCfg?.group_id;
|
|
11737
12824
|
// Fleet topic instance
|
|
11738
12825
|
const threadId = this.fleetConfig?.instances[instanceName]?.topic_id;
|
|
12826
|
+
const worldId = this.getInstanceAdapterId(instanceName);
|
|
11739
12827
|
if (threadId != null && groupId) {
|
|
11740
|
-
|
|
12828
|
+
const out = this.renderPlaces(worldId, text, { threadId: String(threadId), ...extraOpts });
|
|
12829
|
+
adapter.sendText(String(groupId), out.text, out.opts)
|
|
11741
12830
|
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send instance topic notification"));
|
|
11742
12831
|
return true;
|
|
11743
12832
|
}
|
|
11744
12833
|
// Classic instance: find its channelId from the classic manager
|
|
11745
12834
|
const classicChatId = this.classicChannels?.getChannelIdByInstance(instanceName);
|
|
11746
12835
|
if (classicChatId) {
|
|
11747
|
-
|
|
12836
|
+
// #1990: a ClassicBot room's readers need not be in the fleet's group, so its notices name places as plain text.
|
|
12837
|
+
adapter.sendText(classicChatId, stripPlaceRefs(text), extraOpts)
|
|
11748
12838
|
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send classic notification"));
|
|
11749
12839
|
return true;
|
|
11750
12840
|
}
|
|
11751
12841
|
// Fallback: the instance has neither a topic nor a classic channel, so post
|
|
11752
12842
|
// where fleet-wide notices go. Not the bare group id: on Discord that is a
|
|
11753
12843
|
// guild, and the send fails inside the catch handler.
|
|
11754
|
-
const target = this.fleetNoticeTarget(
|
|
12844
|
+
const target = this.fleetNoticeTarget(worldId);
|
|
11755
12845
|
if (target) {
|
|
11756
|
-
|
|
12846
|
+
const out = this.renderPlaces(worldId, text, { ...target.opts, ...extraOpts });
|
|
12847
|
+
adapter.sendText(target.chatId, out.text, out.opts)
|
|
11757
12848
|
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
|
|
11758
12849
|
return true;
|
|
11759
12850
|
}
|
|
@@ -11824,12 +12915,14 @@ export class FleetManager {
|
|
|
11824
12915
|
this.pendingNonceButtons.set(nonce, entry);
|
|
11825
12916
|
try {
|
|
11826
12917
|
const choices = opts.choices.map(c => ({ id: `${opts.prefix}${nonce}:${c.action}`, label: c.label }));
|
|
12918
|
+
// #1990: place names link for the group the prompt is posted in; the dashboard's copy is plain (for now).
|
|
12919
|
+
const posted = this.renderPlaces(opts.adapterId, opts.message, opts.threadId ? { threadId: opts.threadId } : undefined);
|
|
11827
12920
|
const sent = opts.deliver ? await opts.deliver(choices) : await opts.adapter.notifyAlert(opts.chatId, {
|
|
11828
12921
|
type: opts.alertType,
|
|
11829
12922
|
instanceName: opts.instanceName,
|
|
11830
|
-
message:
|
|
12923
|
+
message: posted.text,
|
|
11831
12924
|
choices,
|
|
11832
|
-
},
|
|
12925
|
+
}, posted.opts);
|
|
11833
12926
|
// Bind the nonce to the provider's canonical delivery address, not the
|
|
11834
12927
|
// logical routing input. Telegram, for example, represents General as
|
|
11835
12928
|
// AgEnD topic "1" on input but omits message_thread_id on the wire and in
|
|
@@ -11847,8 +12940,15 @@ export class FleetManager {
|
|
|
11847
12940
|
// Offered on the dashboard only once it is live on the platform (a failed post is disarmed above),
|
|
11848
12941
|
// and only if nothing claimed or expired it meanwhile.
|
|
11849
12942
|
if (WEB_MIRRORED_PROMPT_PREFIXES.has(opts.prefix) && this.pendingNonceButtons.get(nonce) === entry) {
|
|
12943
|
+
// #1990: on the dashboard an instance's name opens its terminal View (where the prompt is answered by hand).
|
|
12944
|
+
const web = renderPlaceRefs(opts.message, "web", target => {
|
|
12945
|
+
const route = target.kind === "instance" && Object.hasOwn(this.fleetConfig?.instances ?? {}, target.name) ? webRoute("view", target.name) : undefined;
|
|
12946
|
+
return route ? { url: route } : undefined;
|
|
12947
|
+
});
|
|
12948
|
+
const links = webTextLinks(web.textLinks, web.text);
|
|
11850
12949
|
entry.web = {
|
|
11851
|
-
text:
|
|
12950
|
+
text: web.text,
|
|
12951
|
+
...(links ? { links } : {}),
|
|
11852
12952
|
actions: opts.choices.map(c => ({ id: c.action, label: c.label })),
|
|
11853
12953
|
expiresAt: Date.now() + (opts.timeoutMs ?? NONCE_BUTTON_TIMEOUT_MS),
|
|
11854
12954
|
};
|
|
@@ -12004,6 +13104,8 @@ export class FleetManager {
|
|
|
12004
13104
|
}
|
|
12005
13105
|
/** Routes a click to the handler that owns its prefix; false when none does. */
|
|
12006
13106
|
async dispatchAdapterCallback(data, adapterId, adapter) {
|
|
13107
|
+
if (await this.sessionAuthorizationRequests().handleCallback(data, adapterId))
|
|
13108
|
+
return true; // #1939 S2b
|
|
12007
13109
|
if (data.callbackData.startsWith(REPLY_BUTTON_PREFIX)) { // #1266
|
|
12008
13110
|
const buttons = this.replyButtons();
|
|
12009
13111
|
if (!buttons) {
|
|
@@ -12100,7 +13202,7 @@ export class FleetManager {
|
|
|
12100
13202
|
isClassic: name => classic.has(name),
|
|
12101
13203
|
place: name => {
|
|
12102
13204
|
const world = this.worlds.get(this.getInstanceAdapterId(name) ?? "");
|
|
12103
|
-
return world ?
|
|
13205
|
+
return world ? worldPlaceOf(world) : undefined;
|
|
12104
13206
|
},
|
|
12105
13207
|
// fleet.yaml's topic only. A ClassicBot room has none here, so no link: its channel may sit in any allowed
|
|
12106
13208
|
// guild, not necessarily its world's group.
|
|
@@ -12141,7 +13243,7 @@ export class FleetManager {
|
|
|
12141
13243
|
cacheInstances() {
|
|
12142
13244
|
const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
|
|
12143
13245
|
name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
|
|
12144
|
-
claudeConfigDir: this.claudeConfigDirOf(name),
|
|
13246
|
+
claudeConfigDir: this.claudeConfigDirOf(name), profile: this.usageProfileOf(name, this.backendNameOf(name)),
|
|
12145
13247
|
}));
|
|
12146
13248
|
const taken = new Set(fleet.map(i => i.name));
|
|
12147
13249
|
const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
|
|
@@ -12150,6 +13252,7 @@ export class FleetManager {
|
|
|
12150
13252
|
workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
|
|
12151
13253
|
ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
|
|
12152
13254
|
claudeConfigDir: this.claudeConfigDirOf(ch.instanceName),
|
|
13255
|
+
profile: this.usageProfileOf(ch.instanceName, this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code"),
|
|
12153
13256
|
}));
|
|
12154
13257
|
return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
|
|
12155
13258
|
}
|
|
@@ -12159,17 +13262,63 @@ export class FleetManager {
|
|
|
12159
13262
|
* (cache-service.ts) — never a vendor call, never a synchronous transcript read.
|
|
12160
13263
|
*/
|
|
12161
13264
|
cacheReport(window) {
|
|
12162
|
-
|
|
12163
|
-
|
|
13265
|
+
return this.cacheServiceFor().report(cacheWindow(window));
|
|
13266
|
+
}
|
|
13267
|
+
/**
|
|
13268
|
+
* #2122 §5.1: the profile the usage read now is tagged with — the claude profile whose config dir is read ("default"
|
|
13269
|
+
* without one); codex "unrecorded" (its rollouts don't say which login wrote them).
|
|
13270
|
+
*/
|
|
13271
|
+
usageProfileOf(name, backend) {
|
|
13272
|
+
if (backend === "claude-code")
|
|
13273
|
+
return this.credentialProfileOf(name, "claude-code") ?? "default";
|
|
13274
|
+
if (backend === "codex")
|
|
13275
|
+
return "unrecorded";
|
|
13276
|
+
return undefined;
|
|
13277
|
+
}
|
|
13278
|
+
/** #2122: the fleet's day boundary zone — defaults.scheduler.default_timezone, else this host's. */
|
|
13279
|
+
usageZone() {
|
|
13280
|
+
return this.fleetConfig?.defaults?.scheduler?.default_timezone?.trim() || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
13281
|
+
}
|
|
13282
|
+
/**
|
|
13283
|
+
* #2122: the usage report (usage-report.ts) — from the ledgers and rollups as they are now and the delivered-message
|
|
13284
|
+
* counts; never a transcript read. `only`: just that instance (an ordinary agent's own row).
|
|
13285
|
+
*/
|
|
13286
|
+
async usageReport(params) {
|
|
13287
|
+
const { buildUsageReport } = await import("./usage-report.js");
|
|
13288
|
+
const data = await this.cacheServiceFor().usageData();
|
|
13289
|
+
const team = new Map();
|
|
13290
|
+
for (const [t, cfg] of Object.entries(this.fleetConfig?.teams ?? {}))
|
|
13291
|
+
for (const m of cfg?.members ?? [])
|
|
13292
|
+
if (!team.has(m))
|
|
13293
|
+
team.set(m, t);
|
|
13294
|
+
const now = Date.now();
|
|
13295
|
+
this.ensureDeliveryOutbox?.();
|
|
13296
|
+
// Every hour of the kept history: the report places each on a local date and keeps the range's own.
|
|
13297
|
+
const messages = this.deliveryOutbox?.deliveredMessageHours(now - 401 * 86_400_000, now + 3_600_000) ?? [];
|
|
13298
|
+
const keep = (name) => params.only === undefined || name === params.only;
|
|
13299
|
+
return buildUsageReport({
|
|
13300
|
+
now, zone: this.usageZone(), range: params.range, group: params.group, scanning: data.scanning,
|
|
13301
|
+
instances: data.instances.filter(i => keep(i.inst.name)).map(i => ({ name: i.inst.name, backend: i.inst.backend, team: team.get(i.inst.name) ?? null,
|
|
13302
|
+
status: i.status, ...(i.with ? { with: i.with } : {}), ledger: i.ledger, days: i.days })),
|
|
13303
|
+
messages: messages.filter(m => keep(m.instance)),
|
|
13304
|
+
});
|
|
13305
|
+
}
|
|
13306
|
+
/** #2001: get_cache_report's — the same report, as last worked out (reused for a minute, until the next pass). */
|
|
13307
|
+
cacheReportPublished(window) {
|
|
13308
|
+
return this.cacheServiceFor().published(cacheWindow(window));
|
|
13309
|
+
}
|
|
13310
|
+
cacheServiceFor() {
|
|
13311
|
+
return this.cacheService ??= new CacheService({
|
|
12164
13312
|
instances: () => this.cacheInstances(),
|
|
12165
13313
|
claudeProjectsDir: () => join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects"),
|
|
12166
13314
|
claudeKey: claudeProjectKey,
|
|
12167
13315
|
codexSessionsDir: () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions"),
|
|
12168
13316
|
listRollouts: root => sharedRolloutIndex(root).list(),
|
|
13317
|
+
rolloutsUnreadable: root => sharedRolloutIndex(root).unreadable,
|
|
12169
13318
|
metaPath: join(this.dataDir, "cache-codex-rollouts.json"),
|
|
13319
|
+
zone: () => this.usageZone(),
|
|
12170
13320
|
log: (level, msg, extra) => this.logger[level](extra ?? {}, msg),
|
|
12171
13321
|
});
|
|
12172
|
-
return this.cacheService.report(w);
|
|
12173
13322
|
}
|
|
12174
13323
|
/** The web's Acknowledge (any item; a signed-in session is fleet-admin level). */
|
|
12175
13324
|
acknowledgeNeedsItem(id, principal) {
|
|
@@ -12265,9 +13414,7 @@ export class FleetManager {
|
|
|
12265
13414
|
finishedAt: Date.parse(d.finishedAt) || 0,
|
|
12266
13415
|
})),
|
|
12267
13416
|
ownerOf,
|
|
12268
|
-
worlds: () => [...this.worlds.values()].map((w) => ({
|
|
12269
|
-
id: w.id, adapter: w.adapter, place: { type: w.type, ...(w.groupId ? { groupId: w.groupId } : {}) },
|
|
12270
|
-
})),
|
|
13417
|
+
worlds: () => [...this.worlds.values()].map((w) => ({ id: w.id, adapter: w.adapter, place: worldPlaceOf(w) })),
|
|
12271
13418
|
noticeTarget: world => {
|
|
12272
13419
|
const target = this.fleetNoticeTarget(world);
|
|
12273
13420
|
return target ? { chatId: target.chatId, ...(target.opts.threadId !== undefined ? { threadId: String(target.opts.threadId) } : {}) } : null;
|
|
@@ -12681,6 +13828,198 @@ export class FleetManager {
|
|
|
12681
13828
|
webBackendTokens = new Map();
|
|
12682
13829
|
/** #1269: the web's /clear confirmations — one token per question, used once, for the instance as it was asked about. */
|
|
12683
13830
|
webClearTokens = new Map();
|
|
13831
|
+
/**
|
|
13832
|
+
* #1939 S1: the design's §3 for one web request (authority/web-authority.ts), with this fleet's admins, policy and
|
|
13833
|
+
* token; `instance` once the body has named it. Observe only: receipts record it, no agent acts on it yet.
|
|
13834
|
+
*/
|
|
13835
|
+
webAuthority(input) {
|
|
13836
|
+
const state = this.webAuthorityState();
|
|
13837
|
+
return evaluateWebAuthority({
|
|
13838
|
+
session: input.session, gateway: input.gateway, peer: input.peer,
|
|
13839
|
+
tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
|
|
13840
|
+
...(input.instance !== undefined ? { target: { adapterId: this.getInstanceAdapterId(input.instance) } } : {}),
|
|
13841
|
+
isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
|
|
13842
|
+
authorization: input.session ? this.sessionAuthorizationStore()?.get(input.session.handle) ?? null : null,
|
|
13843
|
+
policy: { ...webAuthorityPolicy(this.fleetConfig?.web), enabled: state.enabled, loopbackOnly: state.loopbackOnly }, epoch: state.epoch, now: Date.now(),
|
|
13844
|
+
});
|
|
13845
|
+
}
|
|
13846
|
+
sessionAuthorizations = null;
|
|
13847
|
+
sessionAuthorizationStore() {
|
|
13848
|
+
if (!this.dataDir)
|
|
13849
|
+
return null;
|
|
13850
|
+
this.sessionAuthorizations ??= new SessionAuthorizations(join(this.dataDir, "web-authorizations.json"), this.logger);
|
|
13851
|
+
return this.sessionAuthorizations;
|
|
13852
|
+
}
|
|
13853
|
+
/**
|
|
13854
|
+
* #1939 §0: record that a session's account has authorized it — called only once that account has confirmed on Discord
|
|
13855
|
+
* or Telegram (S2b). The session must qualify now (§3, as a message from it would be judged); then every message,
|
|
13856
|
+
* click, /steer and /btw from it is web-verified until max_session_hours after now, the session ends, or a revocation.
|
|
13857
|
+
* Ones no longer worth keeping (their session gone, an old epoch, run out) are dropped on the way.
|
|
13858
|
+
*/
|
|
13859
|
+
authorizeWebSession(session, peer, gateway) {
|
|
13860
|
+
const store = this.sessionAuthorizationStore();
|
|
13861
|
+
const state = this.webAuthorityState(), now = Date.now(), policy = webAuthorityPolicy(this.fleetConfig?.web);
|
|
13862
|
+
if (!store || !session.owner)
|
|
13863
|
+
return { ok: false, refusal: !store ? "disabled" : "no_account" };
|
|
13864
|
+
const check = evaluateWebAuthority({
|
|
13865
|
+
session, gateway, peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
|
|
13866
|
+
isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
|
|
13867
|
+
authorization: { handle: session.handle, owner: session.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch },
|
|
13868
|
+
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly }, epoch: state.epoch, now,
|
|
13869
|
+
});
|
|
13870
|
+
if (check.refusal)
|
|
13871
|
+
return { ok: false, refusal: check.refusal };
|
|
13872
|
+
const live = new Set(this.webSessions?.list(undefined, () => true).map(r => r.handle) ?? []);
|
|
13873
|
+
store.prune(a => a.epoch === state.epoch && now <= Math.min(a.until, a.authorizedAt + policy.maxSessionMs) && live.has(a.handle));
|
|
13874
|
+
return { ok: true, ...store.authorize(session.handle, session.owner, now, state.epoch, now + policy.maxSessionMs) };
|
|
13875
|
+
}
|
|
13876
|
+
authorityStateStore = null;
|
|
13877
|
+
/**
|
|
13878
|
+
* #1939 §10: the switch, "Only this computer can authorize" and the revocation epoch in force for the configuration as
|
|
13879
|
+
* it is now (a change since last seen moves the epoch first; authority-state.ts). No data directory: off.
|
|
13880
|
+
*/
|
|
13881
|
+
webAuthorityState() {
|
|
13882
|
+
if (!this.dataDir)
|
|
13883
|
+
return { enabled: false, loopbackOnly: true, epoch: 0 };
|
|
13884
|
+
this.authorityStateStore ??= new AuthorityStateStore(join(this.dataDir, "web-authority.json"), this.logger, undefined, undefined, () => { this.ensureDeliveryOutbox?.(); return this.deliveryOutbox?.maxWebAuthorityEpoch() ?? 0; });
|
|
13885
|
+
return this.authorityStateStore.current(this.fleetConfig?.web?.verified_authority);
|
|
13886
|
+
}
|
|
13887
|
+
/**
|
|
13888
|
+
* #1939 S2a: Settings → Web → Web authority — the state in force (the switch counts as off while its state cannot be
|
|
13889
|
+
* written), what fleet.yaml says, the hosts that make the dashboard reachable from elsewhere (the warning while
|
|
13890
|
+
* loopback_only is off), and the sessions authorized now (§0): each one's device, account, and when it runs out.
|
|
13891
|
+
*/
|
|
13892
|
+
webAuthorityOverview() {
|
|
13893
|
+
const configured = this.fleetConfig?.web?.verified_authority ?? {};
|
|
13894
|
+
const lanHosts = (this.fleetConfig?.web?.allowed_hosts ?? []).filter(h => { const name = hostnameOf(String(h)); return name !== null && !LOOPBACK_HOST_NAMES.includes(name); });
|
|
13895
|
+
const state = this.webAuthorityState(), maxMs = webAuthorityPolicy(this.fleetConfig?.web).maxSessionMs, now = Date.now();
|
|
13896
|
+
const sessions = new Map((this.webSessions?.list(undefined, () => true) ?? []).map(r => [r.handle, r]));
|
|
13897
|
+
const authorized = state.enabled ? (this.sessionAuthorizationStore()?.list() ?? [])
|
|
13898
|
+
.filter(a => a.epoch === state.epoch && sessions.has(a.handle) && now <= Math.min(a.until, a.authorizedAt + maxMs))
|
|
13899
|
+
.map(a => ({ handle: a.handle, label: sessions.get(a.handle).label, account: `${a.owner.adapterId}:${a.owner.userId}`, authorizedAt: a.authorizedAt,
|
|
13900
|
+
until: Math.min(a.until, a.authorizedAt + maxMs, sessions.get(a.handle).absoluteExpiry) })) : [];
|
|
13901
|
+
return { state, configured, hours: VERIFIED_SESSION_HOURS, lanHosts, authorized };
|
|
13902
|
+
}
|
|
13903
|
+
authorizationRequests = null;
|
|
13904
|
+
/**
|
|
13905
|
+
* #1939 S2b (§0.1): "Authorize this browser" — the Confirm card in the account's DM. Confirm runs §3 again at the tap
|
|
13906
|
+
* (authorizeWebSession on the session read again by its handle); Not me signs the session out.
|
|
13907
|
+
*/
|
|
13908
|
+
sessionAuthorizationRequests() {
|
|
13909
|
+
if (this.authorizationRequests)
|
|
13910
|
+
return this.authorizationRequests;
|
|
13911
|
+
const adapterOf = (adapterId) => this.adapters.get(adapterId) ?? this.worlds.get(adapterId)?.adapter;
|
|
13912
|
+
// The host's clock, with its zone: the person may read the DM on a phone in another one.
|
|
13913
|
+
const when = (ms) => new Date(ms).toLocaleString(undefined, { timeZoneName: "short" });
|
|
13914
|
+
const where = (peer) => t(peer === "loopback" ? "web_auth.peer_loopback" : "web_auth.peer_lan");
|
|
13915
|
+
return this.authorizationRequests = new SessionAuthorizationRequests({
|
|
13916
|
+
now: () => performance.now(), wallNow: () => Date.now(),
|
|
13917
|
+
maxSessionMs: () => webAuthorityPolicy(this.fleetConfig?.web).maxSessionMs,
|
|
13918
|
+
sendDirect: async (owner, text, choices) => {
|
|
13919
|
+
const adapter = adapterOf(owner.adapterId);
|
|
13920
|
+
if (!adapter?.sendDirect)
|
|
13921
|
+
return null;
|
|
13922
|
+
const sent = await adapter.sendDirect(owner.userId, text, { choices, allowedMentions: { parse: [] } });
|
|
13923
|
+
return sent?.messageId ? { chatId: sent.chatId, messageId: sent.messageId } : null;
|
|
13924
|
+
},
|
|
13925
|
+
sendGeneralHint: async (owner, text) => {
|
|
13926
|
+
const adapter = adapterOf(owner.adapterId), general = this.findGeneralInstance(owner.adapterId);
|
|
13927
|
+
const topic = general ? this.fleetConfig?.instances[general]?.topic_id : undefined;
|
|
13928
|
+
const group = general ? this.getGroupIdForInstance(general) : undefined;
|
|
13929
|
+
if (adapter && group)
|
|
13930
|
+
await adapter.sendText(String(group), text, { ...(topic != null ? { threadId: String(topic) } : {}), allowedMentions: { parse: [] } });
|
|
13931
|
+
},
|
|
13932
|
+
editCard: async (owner, at, text, choices) => {
|
|
13933
|
+
const adapter = adapterOf(owner.adapterId);
|
|
13934
|
+
if (!adapter)
|
|
13935
|
+
return;
|
|
13936
|
+
if (!choices.length && adapter.editMessageRemoveButtons) {
|
|
13937
|
+
await adapter.editMessageRemoveButtons(at.chatId, at.messageId, text);
|
|
13938
|
+
return;
|
|
13939
|
+
}
|
|
13940
|
+
await adapter.editMessage(at.chatId, at.messageId, text);
|
|
13941
|
+
if (adapter.replaceMessageButtons)
|
|
13942
|
+
await adapter.replaceMessageButtons(at.chatId, at.messageId, choices);
|
|
13943
|
+
},
|
|
13944
|
+
authorize: (handle, peer) => {
|
|
13945
|
+
const record = this.webSessions?.findByHandle(handle);
|
|
13946
|
+
if (!record)
|
|
13947
|
+
return { ok: false, refusal: "session_gone" };
|
|
13948
|
+
const r = this.authorizeWebSession(record, peer, false);
|
|
13949
|
+
// What the card says: the end of the authorization, or the session's own end when that comes first.
|
|
13950
|
+
return r.ok ? { ok: true, until: Math.min(r.authorization.until, record.absoluteExpiry) } : { ok: false, refusal: r.refusal };
|
|
13951
|
+
},
|
|
13952
|
+
signOut: (handle) => {
|
|
13953
|
+
this.webSessions?.revokeByHandle(handle);
|
|
13954
|
+
this.sessionAuthorizationStore()?.prune(a => a.handle !== handle);
|
|
13955
|
+
},
|
|
13956
|
+
event: (type, payload) => { try {
|
|
13957
|
+
this.eventLog?.insert("web", type, payload);
|
|
13958
|
+
}
|
|
13959
|
+
catch { /* the event log is best effort */ } },
|
|
13960
|
+
text: {
|
|
13961
|
+
ask: (c) => t("web_auth.card_ask", c.label, where(c.peer), when(c.askedAt), when(c.until)),
|
|
13962
|
+
confirmed: (c) => t("web_auth.card_confirmed", c.label, where(c.peer), when(c.until)),
|
|
13963
|
+
denied: (c) => t("web_auth.card_denied", c.label),
|
|
13964
|
+
expired: (c) => t("web_auth.card_expired", c.label),
|
|
13965
|
+
refused: (c) => t("web_auth.card_refused", c.label, t(`web_auth.refusal_${c.refusal}`)),
|
|
13966
|
+
revoked: (c) => t("web_auth.card_revoked", c.label),
|
|
13967
|
+
ended: (c) => t("web_auth.card_ended", c.label),
|
|
13968
|
+
generalHint: () => t("web_auth.general_hint"),
|
|
13969
|
+
confirmLabel: () => t("web_auth.confirm"),
|
|
13970
|
+
notMeLabel: () => t("web_auth.not_me"),
|
|
13971
|
+
notYours: () => t("web_auth.not_yours"),
|
|
13972
|
+
},
|
|
13973
|
+
log: this.logger,
|
|
13974
|
+
});
|
|
13975
|
+
}
|
|
13976
|
+
/** #1939 S2b: the web session asks to be authorized (the request judged by §3 first — as it will be again at the tap). */
|
|
13977
|
+
async requestSessionAuthorization(input) {
|
|
13978
|
+
const s = input.session;
|
|
13979
|
+
const gate = this.webAuthorityQualifies(input);
|
|
13980
|
+
if (gate)
|
|
13981
|
+
return { ok: false, refusal: gate };
|
|
13982
|
+
return this.sessionAuthorizationRequests().ask({ handle: s.handle, owner: s.owner, label: s.label, peer: input.peer, sessionEnd: s.absoluteExpiry });
|
|
13983
|
+
}
|
|
13984
|
+
/**
|
|
13985
|
+
* #1939 §3 without the authorization: would this request's session qualify to be authorized? null: yes; otherwise the
|
|
13986
|
+
* first check it fails (for the app to say why it can't be authorized here).
|
|
13987
|
+
*/
|
|
13988
|
+
webAuthorityQualifies(input) {
|
|
13989
|
+
const s = input.session, state = this.webAuthorityState(), now = Date.now(), policy = webAuthorityPolicy(this.fleetConfig?.web);
|
|
13990
|
+
const r = evaluateWebAuthority({
|
|
13991
|
+
session: s, gateway: input.gateway, peer: input.peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
|
|
13992
|
+
isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
|
|
13993
|
+
authorization: s?.owner ? { handle: s.handle, owner: s.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch } : null,
|
|
13994
|
+
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly }, epoch: state.epoch, now,
|
|
13995
|
+
});
|
|
13996
|
+
return r.refusal;
|
|
13997
|
+
}
|
|
13998
|
+
/** #1939 S2b: what the app shows for this browser — verified (until when), a card pending, or why not. */
|
|
13999
|
+
webSessionAuthorization(input) {
|
|
14000
|
+
const s = input.session;
|
|
14001
|
+
const verdict = this.webAuthority(input);
|
|
14002
|
+
if (!verdict.refusal && verdict.authority.session) {
|
|
14003
|
+
const a = this.sessionAuthorizationStore()?.get(s.handle);
|
|
14004
|
+
const max = webAuthorityPolicy(this.fleetConfig?.web).maxSessionMs;
|
|
14005
|
+
if (a)
|
|
14006
|
+
return { state: "verified", until: Math.min(a.until, a.authorizedAt + max, s.absoluteExpiry) };
|
|
14007
|
+
}
|
|
14008
|
+
const pending = s ? this.sessionAuthorizationRequests().pending(s.handle) : null;
|
|
14009
|
+
if (pending)
|
|
14010
|
+
return { state: "pending", expiresInMs: pending.expiresInMs };
|
|
14011
|
+
return { state: "none", reason: this.webAuthorityQualifies(input) ?? (verdict.refusal ?? "not_authorized") };
|
|
14012
|
+
}
|
|
14013
|
+
/** #1939 §10: revoke every web authority given so far (receipts read as none), without signing anyone out. */
|
|
14014
|
+
revokeWebAuthority() {
|
|
14015
|
+
this.webAuthorityState(); // any switch change first, then this revocation
|
|
14016
|
+
const result = this.authorityStateStore?.revoke() ?? { epoch: 0, durable: false };
|
|
14017
|
+
if (result.durable)
|
|
14018
|
+
this.logger.info({ epoch: result.epoch }, "Web authority revoked");
|
|
14019
|
+
else
|
|
14020
|
+
this.logger.warn({ epoch: result.epoch }, "Web authority revoked in memory only — web authority stays off until its state can be written; a restart before then undoes this revocation");
|
|
14021
|
+
return result;
|
|
14022
|
+
}
|
|
12684
14023
|
/** #1269: an instance's chat command from the web chat (web-commands.ts); `publicLink`: a gateway request. */
|
|
12685
14024
|
webCommand(input, opts) {
|
|
12686
14025
|
const webMeta = (instance) => {
|
|
@@ -12689,15 +14028,16 @@ export class FleetManager {
|
|
|
12689
14028
|
// #1827: a /steer or /btw reaches the agent as the web person who wrote it.
|
|
12690
14029
|
chatId: this.getGroupIdForInstance(instance) || "", messageId: newWebMessageId(),
|
|
12691
14030
|
username: opts.person?.name ?? WEB_NAME_FALLBACK, userId: opts.person?.userId ?? WEB_NAME_FALLBACK,
|
|
12692
|
-
threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web", humanOrigin: webHumanOrigin(true),
|
|
14031
|
+
threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web", humanOrigin: webHumanOrigin(true, opts.authority),
|
|
12693
14032
|
};
|
|
12694
14033
|
};
|
|
12695
14034
|
const choicesFromCache = (instance) => {
|
|
12696
|
-
const
|
|
14035
|
+
const backend = this.backendNameForInstance(instance);
|
|
14036
|
+
const cached = this.readCliEnv(backend, this.catalogProfile(backend, instance)); // the cached catalog only: never a probe here
|
|
12697
14037
|
if (!cached?.models.length)
|
|
12698
14038
|
return null;
|
|
12699
14039
|
const current = this.resolveInstanceModel(instance).model;
|
|
12700
|
-
return { current, options: cached.models.map(o => ({ id: o.id, label: this.modelChoiceLabel(o, current) })) };
|
|
14040
|
+
return { current, options: this.withCurrentModel(cached.models, current).map(o => ({ id: o.id, label: this.modelChoiceLabel(o, current) })) };
|
|
12701
14041
|
};
|
|
12702
14042
|
return runWebCommand({
|
|
12703
14043
|
// Own entries only: a name like "__proto__" or "constructor" must not be found on the prototype (#1476 review).
|
|
@@ -12744,7 +14084,7 @@ export class FleetManager {
|
|
|
12744
14084
|
return step.noop ? { text: step.text } : { error: step.text };
|
|
12745
14085
|
if (step.kind === "choose") {
|
|
12746
14086
|
const id = (token) => joinBackendArgs([...step.args, token]);
|
|
12747
|
-
return { choices: { prompt: step.text, current: step.current === null ? null : id(step.current), options: step.choices.map(c => ({ id: id(c.token), label: c.label })) } };
|
|
14087
|
+
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 } : {}) })) } };
|
|
12748
14088
|
}
|
|
12749
14089
|
const now = performance.now();
|
|
12750
14090
|
for (const [k, v] of this.webBackendTokens)
|
|
@@ -12919,12 +14259,12 @@ export class FleetManager {
|
|
|
12919
14259
|
* the operator's. Keep the instance notice passive and put the action in the
|
|
12920
14260
|
* same-world General topic where an administrator can make the choice. */
|
|
12921
14261
|
async notifyNormalExit(instanceName) {
|
|
12922
|
-
this.notifyInstanceTopic(instanceName, t("exit.instance_notice", instanceName));
|
|
14262
|
+
this.notifyInstanceTopic(instanceName, t("exit.instance_notice", instanceName) + placeSuffix({ kind: "general" }, "General"));
|
|
12923
14263
|
const web = this.webOnlyPromptPlace();
|
|
12924
14264
|
if (web) {
|
|
12925
14265
|
await this.postNonceButtonPrompt({
|
|
12926
14266
|
prefix: EXIT_RESTART_CALLBACK_PREFIX, alertType: "exit_restart", instanceName, ...web,
|
|
12927
|
-
message: t("exit.general_notice", instanceName),
|
|
14267
|
+
message: t("exit.general_notice", placeRef({ kind: "instance", name: instanceName }, instanceName)),
|
|
12928
14268
|
choices: [{ action: "restart", label: t("exit.restart") }, { action: "ignore", label: t("exit.ignore") }],
|
|
12929
14269
|
expiredText: t("exit.expired", instanceName),
|
|
12930
14270
|
});
|
|
@@ -12953,7 +14293,7 @@ export class FleetManager {
|
|
|
12953
14293
|
adapterId,
|
|
12954
14294
|
chatId,
|
|
12955
14295
|
threadId,
|
|
12956
|
-
message: t("exit.general_notice", instanceName),
|
|
14296
|
+
message: t("exit.general_notice", placeRef({ kind: "instance", name: instanceName }, instanceName)),
|
|
12957
14297
|
choices: [
|
|
12958
14298
|
{ action: "restart", label: t("exit.restart") },
|
|
12959
14299
|
{ action: "ignore", label: t("exit.ignore") },
|
|
@@ -13000,7 +14340,7 @@ export class FleetManager {
|
|
|
13000
14340
|
* contain usernames or secret-adjacent text and stays in daemon.log.
|
|
13001
14341
|
*/
|
|
13002
14342
|
async notifyInteractivePrompt(instanceName, kind) {
|
|
13003
|
-
this.notifyInstanceTopic(instanceName, t("interactive.instance_notice", instanceName));
|
|
14343
|
+
this.notifyInstanceTopic(instanceName, t("interactive.instance_notice", instanceName) + placeSuffix({ kind: "general" }, "General"));
|
|
13004
14344
|
// Multi-world fleets can have one General per bot/platform. Route the assist
|
|
13005
14345
|
// request to the General bound to the same adapter as the blocked instance.
|
|
13006
14346
|
const generalName = this.findGeneralInstance(this.getInstanceAdapterId(instanceName));
|
|
@@ -13011,7 +14351,7 @@ export class FleetManager {
|
|
|
13011
14351
|
const label = this.interactivePromptLabel(kind);
|
|
13012
14352
|
const prompt = {
|
|
13013
14353
|
prefix: INTERACTIVE_ASSIST_CALLBACK_PREFIX, alertType: "interactive_prompt", instanceName,
|
|
13014
|
-
message: t("interactive.general_notice", instanceName, label),
|
|
14354
|
+
message: t("interactive.general_notice", placeRef({ kind: "instance", name: instanceName }, instanceName), label),
|
|
13015
14355
|
choices: [{ action: "confirm", label: t("interactive.confirm") }, { action: "cancel", label: t("interactive.cancel") }],
|
|
13016
14356
|
expiredText: t("interactive.expired", instanceName),
|
|
13017
14357
|
extra: { generalName, promptKind: kind, assistFor: this.interactionWaitKey(instanceName) },
|
|
@@ -13882,7 +15222,12 @@ export class FleetManager {
|
|
|
13882
15222
|
fleetConfig: () => this.fleetConfig,
|
|
13883
15223
|
isFleetAdmin: (userId, adapterId) => this.isFleetAdmin(userId, adapterId),
|
|
13884
15224
|
eventLog: () => this.eventLog,
|
|
13885
|
-
recoverBackendInstances: (backend, profile) =>
|
|
15225
|
+
recoverBackendInstances: (backend, profile) => {
|
|
15226
|
+
// #2072: a sign-in just finished — the pickers learn it now, not at the next pass.
|
|
15227
|
+
if (!profile)
|
|
15228
|
+
void this.backendAvailability.refresh([backend], { force: true });
|
|
15229
|
+
return this.recoverBackendInstances(backend, undefined, profile ?? null);
|
|
15230
|
+
},
|
|
13886
15231
|
claimWindow: backend => this.loginWindow.tryClaim("web", backend),
|
|
13887
15232
|
releaseWindow: claim => { this.loginWindow.release(claim); },
|
|
13888
15233
|
isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
|
|
@@ -14401,6 +15746,8 @@ export class FleetManager {
|
|
|
14401
15746
|
// binary the same way, so a binary only a login shell could see was
|
|
14402
15747
|
// installed, reported as verified, and then offered nowhere.
|
|
14403
15748
|
this.adoptBinaryDirectory(installedAt, backend);
|
|
15749
|
+
// #2072: an install just finished — the pickers learn it now, not at the next pass.
|
|
15750
|
+
void this.backendAvailability.refresh([backend], { force: true });
|
|
14404
15751
|
if (!LOGIN_FLOWS[backend]) {
|
|
14405
15752
|
this.installHandoff = null;
|
|
14406
15753
|
await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
|
|
@@ -14610,6 +15957,14 @@ export class FleetManager {
|
|
|
14610
15957
|
const room = this.classicChannels?.getAll().find(c => c.instanceName === name);
|
|
14611
15958
|
return room ? `room:${room.channelId}:${room.adapterId ?? ""}` : null;
|
|
14612
15959
|
}
|
|
15960
|
+
/** The only Try-card publisher: no arbitrary SSE sender/role can manufacture this metadata. */
|
|
15961
|
+
publishEffectTrial(instance, offer) {
|
|
15962
|
+
const row = this.webChatHistory.recordEffectTrial(instance, offer, new Date().toISOString());
|
|
15963
|
+
if (!row)
|
|
15964
|
+
return false;
|
|
15965
|
+
broadcastSseEvent(this.sseClients, "message", row, err => this.logger.debug({ err }, "SSE client write failed; evicting"), this.webChatHistory.cursorOf(row));
|
|
15966
|
+
return true;
|
|
15967
|
+
}
|
|
14613
15968
|
/** Push an SSE event to all connected Web UI clients. */
|
|
14614
15969
|
emitSseEvent(event, data) {
|
|
14615
15970
|
// #1386: a prompt opened or closed, or an instance's state moved — "Needs you" may have changed.
|
|
@@ -14626,8 +15981,14 @@ export class FleetManager {
|
|
|
14626
15981
|
role: typeof m.role === "string" ? m.role : undefined,
|
|
14627
15982
|
buttons: m.buttons,
|
|
14628
15983
|
click: m.click,
|
|
15984
|
+
links: m.links,
|
|
15985
|
+
channel: m.channel,
|
|
15986
|
+
from: m.from,
|
|
14629
15987
|
});
|
|
14630
15988
|
broadcastSseEvent(this.sseClients, event, recorded, onError, this.webChatHistory.cursorOf(recorded));
|
|
15989
|
+
// #1629: live-only receipt after the recorded agent reply. Never persist/replay it or give it an SSE cursor.
|
|
15990
|
+
if (recorded.role === "agent")
|
|
15991
|
+
broadcastSseEvent(this.sseClients, "agent_reply_live", { v: 1, boot: recorded.boot, id: recorded.id, instance: recorded.instance }, onError);
|
|
14631
15992
|
return;
|
|
14632
15993
|
}
|
|
14633
15994
|
// #1266: a reply's buttons ended — the history shows it too (a later load, the public link's poll).
|
|
@@ -14852,7 +16213,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14852
16213
|
// can keep those copies current; other backends never had unmanaged
|
|
14853
16214
|
// AgEnD-published skills and must retain the normal user-ownership guard.
|
|
14854
16215
|
const adoptLegacyUnmanaged = backend === "kiro-cli" && role === "general";
|
|
14855
|
-
this.syncManagedSkills(join(workDir, ...skillSegments), join(root, "skills"), role, adoptLegacyUnmanaged
|
|
16216
|
+
this.syncManagedSkills(join(workDir, ...skillSegments), join(root, "skills"), role, adoptLegacyUnmanaged,
|
|
16217
|
+
// #2163: a Classic bot's skills folder (or a skill in it) may be a link out of its workspace; every path is
|
|
16218
|
+
// checked where it really lands, just before it is written.
|
|
16219
|
+
role === "classic" ? (path) => insideManagedWorkspaces(path) : undefined);
|
|
14856
16220
|
this.logger.debug({ workDir, backend, role }, "Synced role-based bundled skills");
|
|
14857
16221
|
}
|
|
14858
16222
|
/**
|
|
@@ -14938,11 +16302,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14938
16302
|
* migration exception is the pre-manifest Kiro General layout explicitly
|
|
14939
16303
|
* selected by adoptLegacyUnmanaged.
|
|
14940
16304
|
*/
|
|
14941
|
-
syncManagedSkills(destSkills, srcSkills, role, adoptLegacyUnmanaged = false) {
|
|
16305
|
+
syncManagedSkills(destSkills, srcSkills, role, adoptLegacyUnmanaged = false, confined) {
|
|
14942
16306
|
if (!existsSync(srcSkills))
|
|
14943
16307
|
return;
|
|
16308
|
+
const refuse = (path) => {
|
|
16309
|
+
if (!confined || confined(path))
|
|
16310
|
+
return false;
|
|
16311
|
+
this.logger.warn({ path, destSkills }, "Not publishing skills — the path leads out of the managed workspace");
|
|
16312
|
+
return true;
|
|
16313
|
+
};
|
|
16314
|
+
if (refuse(destSkills))
|
|
16315
|
+
return;
|
|
14944
16316
|
mkdirSync(destSkills, { recursive: true });
|
|
14945
16317
|
const manifestPath = join(destSkills, ".agend-managed-skills.json");
|
|
16318
|
+
// Its ownership claims decide what is replaced or removed below: one that lives elsewhere is never believed.
|
|
16319
|
+
if (refuse(manifestPath))
|
|
16320
|
+
return;
|
|
14946
16321
|
const hadManifest = existsSync(manifestPath);
|
|
14947
16322
|
let previouslyManaged = [];
|
|
14948
16323
|
try {
|
|
@@ -14971,9 +16346,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14971
16346
|
}
|
|
14972
16347
|
const eligible = bundled.filter(name => {
|
|
14973
16348
|
const roles = this.readManagedSkillRoles(join(srcSkills, name, "SKILL.md"));
|
|
14974
|
-
// General is the coordinator and receives
|
|
14975
|
-
//
|
|
14976
|
-
return role === "general" || roles.includes(
|
|
16349
|
+
// General is the coordinator and receives every playbook. Workers and Classic bots receive only skills
|
|
16350
|
+
// explicitly marked for their role (#2163).
|
|
16351
|
+
return role === "general" || roles.includes(role);
|
|
14977
16352
|
});
|
|
14978
16353
|
const managed = [];
|
|
14979
16354
|
for (const name of eligible) {
|
|
@@ -14990,6 +16365,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14990
16365
|
}
|
|
14991
16366
|
managed.push(name);
|
|
14992
16367
|
const newContent = readFileSync(join(srcSkills, name, "SKILL.md"), "utf-8");
|
|
16368
|
+
if (refuse(dest))
|
|
16369
|
+
continue;
|
|
14993
16370
|
try {
|
|
14994
16371
|
if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
|
|
14995
16372
|
continue;
|
|
@@ -15036,9 +16413,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15036
16413
|
if (!Array.isArray(frontmatter.roles))
|
|
15037
16414
|
throw new Error("roles must be an array");
|
|
15038
16415
|
const roles = [...new Set(frontmatter.roles)]
|
|
15039
|
-
.filter((value) => value === "general" || value === "worker");
|
|
16416
|
+
.filter((value) => value === "general" || value === "worker" || value === "classic");
|
|
15040
16417
|
if (roles.length !== frontmatter.roles.length || roles.length === 0) {
|
|
15041
|
-
throw new Error("roles must contain only general and/or
|
|
16418
|
+
throw new Error("roles must contain only general, worker and/or classic");
|
|
15042
16419
|
}
|
|
15043
16420
|
return roles;
|
|
15044
16421
|
}
|
|
@@ -15423,10 +16800,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15423
16800
|
this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
|
|
15424
16801
|
// #1592: the web chat shows what was asked, as for every other inbound — the user's own words (no chat-log
|
|
15425
16802
|
// context), as a user (#1306: never a role read from text), in the room's history (#1565).
|
|
15426
|
-
this.
|
|
15427
|
-
instance: instanceName, sender: msg.username, role: "user",
|
|
15428
|
-
text: text.slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
|
|
15429
|
-
});
|
|
16803
|
+
this.emitPlatformInbound(instanceName, msg, text);
|
|
15430
16804
|
}
|
|
15431
16805
|
/** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
|
|
15432
16806
|
/** #1690: our own runtime switches awaiting the daemon's outcome, by id. */
|
|
@@ -15464,7 +16838,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15464
16838
|
/** #1806: null on a switch INTO claude-code — the configured model then belongs to the backend being left. */
|
|
15465
16839
|
configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model) {
|
|
15466
16840
|
const aliases = ["default", "sonnet", "opus", "haiku", "opusplan", "Fable"];
|
|
15467
|
-
const api = (this.readCliEnv("claude-code")?.apiModels ?? []).map(m => m.id);
|
|
16841
|
+
const api = (this.readCliEnv("claude-code", this.catalogProfile("claude-code", instanceName))?.apiModels ?? []).map(m => m.id);
|
|
15468
16842
|
const lower = model.trim().toLowerCase();
|
|
15469
16843
|
const bare = lower.replace(/\[1m\]$/, "");
|
|
15470
16844
|
const known = aliases.some(a => a.toLowerCase() === bare)
|
|
@@ -15492,13 +16866,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15492
16866
|
: undefined;
|
|
15493
16867
|
return classic ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
15494
16868
|
}
|
|
15495
|
-
|
|
15496
|
-
|
|
16869
|
+
/**
|
|
16870
|
+
* #2072: a catalog is the account's, so a credential profile has its own. Only claude-code's probe reads an account
|
|
16871
|
+
* catalog through a profile's login (its `GET /v1/models` with the profile's token); other backends' probes read the
|
|
16872
|
+
* process environment, so their catalog stays the backend's own.
|
|
16873
|
+
*/
|
|
16874
|
+
catalogProfile(backend, instanceName) {
|
|
16875
|
+
return backend === "claude-code" && instanceName ? this.credentialProfileOf(instanceName, backend) : null;
|
|
15497
16876
|
}
|
|
15498
|
-
|
|
15499
|
-
|
|
16877
|
+
cliEnvPath(backend, profile = null) {
|
|
16878
|
+
const safe = (s) => s.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
16879
|
+
return join(getAgendHome(), "cli-env", profile ? `${safe(backend)}@${safe(profile)}.json` : `${safe(backend)}.json`);
|
|
16880
|
+
}
|
|
16881
|
+
/** Read the cached CLI env for a backend (a credential profile's own, when given), or null if missing/stale/unparseable. */
|
|
16882
|
+
readCliEnv(backend, profile = null) {
|
|
15500
16883
|
try {
|
|
15501
|
-
const env = JSON.parse(readFileSync(this.cliEnvPath(backend), "utf-8"));
|
|
16884
|
+
const env = JSON.parse(readFileSync(this.cliEnvPath(backend, profile), "utf-8"));
|
|
15502
16885
|
if (typeof env?.probedAt === "number" && Date.now() - env.probedAt < CLI_ENV_TTL_MS)
|
|
15503
16886
|
return env;
|
|
15504
16887
|
}
|
|
@@ -15541,10 +16924,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15541
16924
|
* previous list, never stall the command that asked for it.
|
|
15542
16925
|
*/
|
|
15543
16926
|
async probeBackendBounded(backend, opts = {}) {
|
|
15544
|
-
// #725: single-flight per backend. Explicit vendor refreshes remain a
|
|
16927
|
+
// #725: single-flight per backend (#2072: per backend and credential profile). Explicit vendor refreshes remain a
|
|
15545
16928
|
// separate flight from an ordinary probe, while sysinfo can join either.
|
|
15546
|
-
const
|
|
15547
|
-
const
|
|
16929
|
+
const profile = opts.profile ?? null;
|
|
16930
|
+
const key = profile ? `${backend}@${profile}` : backend;
|
|
16931
|
+
const regularFlight = this.pendingCliEnvProbes.get(key);
|
|
16932
|
+
const vendorFlight = this.pendingVendorCliEnvProbes.get(key);
|
|
15548
16933
|
if (opts.refreshVendorCatalog && vendorFlight)
|
|
15549
16934
|
return vendorFlight;
|
|
15550
16935
|
if (!opts.refreshVendorCatalog && (vendorFlight || regularFlight))
|
|
@@ -15555,25 +16940,26 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15555
16940
|
const epoch = this.cliEnvProbeEpoch;
|
|
15556
16941
|
const input = {
|
|
15557
16942
|
mode: "env", backend, instanceDir,
|
|
15558
|
-
config: { workingDirectory: "", instanceDir, instanceName: `probe-${backend}`, mcpServers: {}
|
|
16943
|
+
config: { workingDirectory: "", instanceDir, instanceName: `probe-${backend}`, mcpServers: {},
|
|
16944
|
+
...(profile ? { credentialStoreHome: credentialProfileStoreHome(getAgendHome(), backend, profile) } : {}) },
|
|
15559
16945
|
refreshVendorCatalog: opts.refreshVendorCatalog === true,
|
|
15560
16946
|
};
|
|
15561
|
-
const bounded = this.runBackendProbeWorker(`global:${
|
|
16947
|
+
const bounded = this.runBackendProbeWorker(`global:${key}`, input).then(probed => {
|
|
15562
16948
|
if (!probed || Array.isArray(probed) || this.shuttingDown || epoch !== this.cliEnvProbeEpoch)
|
|
15563
16949
|
return null;
|
|
15564
16950
|
try {
|
|
15565
|
-
return this.persistCliEnvProbeResult(backend, probed);
|
|
16951
|
+
return this.persistCliEnvProbeResult(backend, probed, profile);
|
|
15566
16952
|
}
|
|
15567
16953
|
catch (err) {
|
|
15568
|
-
this.logger.warn({ backend, err }, "CLI env cache write failed");
|
|
16954
|
+
this.logger.warn({ backend, profile, err }, "CLI env cache write failed");
|
|
15569
16955
|
return null;
|
|
15570
16956
|
}
|
|
15571
16957
|
}).finally(() => {
|
|
15572
16958
|
const flights = opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes;
|
|
15573
|
-
if (flights.get(
|
|
15574
|
-
flights.delete(
|
|
16959
|
+
if (flights.get(key) === bounded)
|
|
16960
|
+
flights.delete(key);
|
|
15575
16961
|
});
|
|
15576
|
-
(opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(
|
|
16962
|
+
(opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(key, bounded);
|
|
15577
16963
|
return bounded;
|
|
15578
16964
|
}
|
|
15579
16965
|
stopCliEnvProbes() {
|
|
@@ -15688,12 +17074,21 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15688
17074
|
modelDisplayForInstance(instanceName) {
|
|
15689
17075
|
return this.resolveInstanceModel(instanceName).display;
|
|
15690
17076
|
}
|
|
17077
|
+
/**
|
|
17078
|
+
* #2072: the model an instance runs is always among its choices — one its CLI's catalog does not list (an older
|
|
17079
|
+
* name, a custom provider's, a typed one) is shown first, marked, so the menu never hides what is running.
|
|
17080
|
+
*/
|
|
17081
|
+
withCurrentModel(options, currentModel) {
|
|
17082
|
+
if (!currentModel || currentModel === "default" || options.some(o => o.id === currentModel))
|
|
17083
|
+
return options;
|
|
17084
|
+
return [{ id: currentModel, label: currentModel, description: t("model.not_listed") }, ...options];
|
|
17085
|
+
}
|
|
15691
17086
|
modelChoiceLabel(option, currentModel) {
|
|
15692
17087
|
const label = option.description ? `${option.label} — ${option.description}` : option.label;
|
|
15693
17088
|
return option.id === currentModel ? `✓ ${label}` : label;
|
|
15694
17089
|
}
|
|
15695
17090
|
/** Preserve the existing cache merge rules for worker probes. */
|
|
15696
|
-
persistCliEnvProbeResult(backend, probed) {
|
|
17091
|
+
persistCliEnvProbeResult(backend, probed, profile = null) {
|
|
15697
17092
|
const env = { backend, probedAt: Date.now(), ...probed };
|
|
15698
17093
|
// An empty result must never overwrite a catalog we already have. Some
|
|
15699
17094
|
// probes hit the network (`agy models` fetches, 5s cap), so a slow moment
|
|
@@ -15702,14 +17097,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15702
17097
|
// antigravity cache replaced by an empty one. Keep the known models and
|
|
15703
17098
|
// let the fresher currentModel/version through.
|
|
15704
17099
|
if (!env.models?.length) {
|
|
15705
|
-
const previous = this.readCliEnv(backend);
|
|
17100
|
+
const previous = this.readCliEnv(backend, profile);
|
|
15706
17101
|
if (previous?.models?.length)
|
|
15707
17102
|
env.models = previous.models;
|
|
15708
17103
|
}
|
|
15709
17104
|
// Same protection for the extended catalog: one offline moment must not
|
|
15710
17105
|
// blank a good account list for the whole cache TTL.
|
|
15711
17106
|
if (!env.apiModels?.length) {
|
|
15712
|
-
const previous = this.readCliEnv(backend);
|
|
17107
|
+
const previous = this.readCliEnv(backend, profile);
|
|
15713
17108
|
if (previous?.apiModels?.length)
|
|
15714
17109
|
env.apiModels = previous.apiModels;
|
|
15715
17110
|
}
|
|
@@ -15717,12 +17112,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15717
17112
|
// keeps the cached levels only for that same binary: both versions known and equal, cache still valid. A help
|
|
15718
17113
|
// that was read and lists none ([]) is an answer and is written as is, so the fallback applies.
|
|
15719
17114
|
if (env.effortLevels === undefined) {
|
|
15720
|
-
const previous = this.readCliEnv(backend);
|
|
17115
|
+
const previous = this.readCliEnv(backend, profile);
|
|
15721
17116
|
if (previous?.effortLevels && previous.version && env.version && previous.version === env.version) {
|
|
15722
17117
|
env.effortLevels = previous.effortLevels;
|
|
15723
17118
|
}
|
|
15724
17119
|
}
|
|
15725
|
-
const path = this.cliEnvPath(backend);
|
|
17120
|
+
const path = this.cliEnvPath(backend, profile);
|
|
15726
17121
|
mkdirSync(dirname(path), { recursive: true });
|
|
15727
17122
|
writeFileSync(path, JSON.stringify(env, null, 2));
|
|
15728
17123
|
return env;
|
|
@@ -15744,6 +17139,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15744
17139
|
// indefinitely — each is wrapped by CLI_ENV_PROBE_DEADLINE_MS.
|
|
15745
17140
|
for (const b of backends)
|
|
15746
17141
|
void this.probeBackendBounded(b);
|
|
17142
|
+
// #2072: each Claude credential profile in use has its own account catalog. Only fleet.yaml's agents are walked
|
|
17143
|
+
// here; a ClassicBot room on a profile is probed on its first /model (catalogProfile knows rooms too).
|
|
17144
|
+
const profiles = new Set();
|
|
17145
|
+
for (const name of Object.keys(this.fleetConfig?.instances ?? {})) {
|
|
17146
|
+
if (this.backendNameForInstance(name) !== "claude-code")
|
|
17147
|
+
continue;
|
|
17148
|
+
const profile = this.catalogProfile("claude-code", name);
|
|
17149
|
+
if (profile)
|
|
17150
|
+
profiles.add(profile);
|
|
17151
|
+
}
|
|
17152
|
+
for (const profile of profiles)
|
|
17153
|
+
void this.probeBackendBounded("claude-code", { profile });
|
|
15747
17154
|
}
|
|
15748
17155
|
/** Best-effort model list for `/model`: cached CLI env first, else live probe. Never throws. */
|
|
15749
17156
|
async getModelOptions(instanceName, refresh = false, onLiveProbe) {
|
|
@@ -15757,7 +17164,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15757
17164
|
*/
|
|
15758
17165
|
async getModelOptionsWithSource(instanceName, opts = {}) {
|
|
15759
17166
|
const backendName = this.backendNameForInstance(instanceName);
|
|
15760
|
-
const
|
|
17167
|
+
const profile = this.catalogProfile(backendName, instanceName);
|
|
17168
|
+
const cached = this.readCliEnv(backendName, profile);
|
|
15761
17169
|
if (!opts.refresh && cached?.models.length && !this.cliEnvNeedsRefresh(cached)) {
|
|
15762
17170
|
return { models: cached.models, source: "cache" };
|
|
15763
17171
|
}
|
|
@@ -15768,7 +17176,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15768
17176
|
// Stale, missing, or a forced refresh → probe live (also refreshes the cache).
|
|
15769
17177
|
// A newly released model is invisible until this runs, which is why staleness
|
|
15770
17178
|
// triggers it rather than waiting for the 24h hard expiry or a cold start.
|
|
15771
|
-
const env = await this.probeBackendBounded(backendName, { refreshVendorCatalog: opts.refreshVendorCatalog });
|
|
17179
|
+
const env = await this.probeBackendBounded(backendName, { refreshVendorCatalog: opts.refreshVendorCatalog, profile });
|
|
15772
17180
|
if (env?.models.length)
|
|
15773
17181
|
return { models: env.models, source: "live" };
|
|
15774
17182
|
// Probe failed or timed out: the previous list is still the best answer.
|
|
@@ -15783,7 +17191,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15783
17191
|
modelMenuChoices(instanceName, nonce, options, currentModel) {
|
|
15784
17192
|
const isClaude = this.backendNameForInstance(instanceName) === "claude-code";
|
|
15785
17193
|
const choices = [{ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:__refresh__`, label: t("model.refresh") }];
|
|
15786
|
-
for (const o of options.slice(0, isClaude ? 23 : 24)) {
|
|
17194
|
+
for (const o of this.withCurrentModel(options, currentModel).slice(0, isClaude ? 23 : 24)) {
|
|
15787
17195
|
choices.push({ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`, label: this.modelChoiceLabel(o, currentModel) });
|
|
15788
17196
|
}
|
|
15789
17197
|
if (isClaude)
|
|
@@ -16190,16 +17598,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16190
17598
|
}
|
|
16191
17599
|
}
|
|
16192
17600
|
/** Cached-or-live account catalog behind "/model → more models" (claude). */
|
|
16193
|
-
async claudeApiModelOptions() {
|
|
16194
|
-
|
|
17601
|
+
async claudeApiModelOptions(instanceName) {
|
|
17602
|
+
// #2072: the account behind THIS instance — its credential profile's catalog, else the default login's.
|
|
17603
|
+
const profile = this.catalogProfile("claude-code", instanceName);
|
|
17604
|
+
const cached = this.readCliEnv("claude-code", profile);
|
|
16195
17605
|
if (cached?.apiModels?.length)
|
|
16196
17606
|
return cached.apiModels;
|
|
16197
|
-
const env = await this.probeBackendBounded("claude-code");
|
|
17607
|
+
const env = await this.probeBackendBounded("claude-code", { profile });
|
|
16198
17608
|
return env?.apiModels ?? [];
|
|
16199
17609
|
}
|
|
16200
17610
|
/** Replace a consumed "/model" menu with the full account catalog tier. */
|
|
16201
17611
|
async expandClaudeModelMenu(pending) {
|
|
16202
|
-
const expanded = await this.claudeApiModelOptions();
|
|
17612
|
+
const expanded = await this.claudeApiModelOptions(pending.instanceName);
|
|
16203
17613
|
if (!expanded.length) {
|
|
16204
17614
|
await pending.respond(t("model.more_unavailable")).catch(() => { });
|
|
16205
17615
|
return;
|
|
@@ -16627,7 +18037,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16627
18037
|
return {
|
|
16628
18038
|
currentBackend: (name) => this.backendNameForInstance(name),
|
|
16629
18039
|
currentDisplay: (name) => `${this.backendNameForInstance(name)} · ${this.resolveInstanceModel(name).display}`,
|
|
16630
|
-
|
|
18040
|
+
availability: () => this.backendAvailabilityFor(instanceName),
|
|
16631
18041
|
models: (backend) => this.backendModelOptions(backend, opts),
|
|
16632
18042
|
// #1808: the same rule update_instance_config's switch applies (backendModelVerdict).
|
|
16633
18043
|
modelKnown: (backend, model) => this.backendModelVerdict(instanceName, backend, model).ok,
|
|
@@ -16726,10 +18136,76 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16726
18136
|
// kiro-cli gates its own --effort by version at launch (a quiet console note otherwise); muse always passes it.
|
|
16727
18137
|
return () => true;
|
|
16728
18138
|
}
|
|
16729
|
-
/**
|
|
16730
|
-
|
|
16731
|
-
|
|
16732
|
-
|
|
18139
|
+
/**
|
|
18140
|
+
* #2072: what this host can run, from the published availability snapshot (backend-availability.ts). Read only: a
|
|
18141
|
+
* handler never starts a probe, not even in the background — launch, the timer, and /login install or sign-in do.
|
|
18142
|
+
*/
|
|
18143
|
+
backendAvailabilityRows() {
|
|
18144
|
+
return this.backendAvailability.snapshot();
|
|
18145
|
+
}
|
|
18146
|
+
/**
|
|
18147
|
+
* #2072 part 3: the models a Settings field offers for `backend` — the cached catalog (an instance's own credential
|
|
18148
|
+
* profile's, when it is named and has one), claude's account catalog included. Read only: never a probe.
|
|
18149
|
+
*/
|
|
18150
|
+
modelCatalogFor(backend, instance) {
|
|
18151
|
+
if (!KNOWN_BACKENDS.includes(backend) || backend === "mock")
|
|
18152
|
+
return [];
|
|
18153
|
+
const own = instance && Object.prototype.hasOwnProperty.call(this.fleetConfig?.instances ?? {}, instance) ? instance
|
|
18154
|
+
: instance && this.classicChannels?.getChannelIdByInstance(instance) !== undefined ? instance : null;
|
|
18155
|
+
const env = this.readCliEnv(backend, this.catalogProfile(backend, own));
|
|
18156
|
+
const seen = new Set(), out = [];
|
|
18157
|
+
for (const m of [...(env?.models ?? []), ...(env?.apiModels ?? [])])
|
|
18158
|
+
if (!seen.has(m.id)) {
|
|
18159
|
+
seen.add(m.id);
|
|
18160
|
+
out.push(m);
|
|
18161
|
+
}
|
|
18162
|
+
return out;
|
|
18163
|
+
}
|
|
18164
|
+
/**
|
|
18165
|
+
* #2072 (#2078 review): the snapshot as THIS instance would see it. Its sign-in on a backend is its own credential
|
|
18166
|
+
* profile's when it has one there — the host's default login says nothing about it. A profile's login is a file check
|
|
18167
|
+
* (credentialProfileLogin), no process.
|
|
18168
|
+
*/
|
|
18169
|
+
backendAvailabilityFor(instanceName) {
|
|
18170
|
+
return this.backendAvailabilityRows().map(row => {
|
|
18171
|
+
const profile = this.credentialProfileOf(instanceName, row.backend);
|
|
18172
|
+
if (!profile || (row.state !== "usable" && row.state !== "not_signed_in" && row.state !== "unknown") || !row.path)
|
|
18173
|
+
return row;
|
|
18174
|
+
let login = "unknown";
|
|
18175
|
+
try {
|
|
18176
|
+
login = credentialProfileLogin(this.dataDir, row.backend, profile).state;
|
|
18177
|
+
}
|
|
18178
|
+
catch { /* unknown */ }
|
|
18179
|
+
// Signed in there: usable for this instance. Signed out (or unknown): the host's default login says nothing about
|
|
18180
|
+
// it, so no "not signed in" claim from it — update_instance_config's profile gate refuses a signed-out profile
|
|
18181
|
+
// when applied, naming the profile and the command that signs it in.
|
|
18182
|
+
if (login === "logged-in")
|
|
18183
|
+
return { ...row, auth: "valid", state: "usable" };
|
|
18184
|
+
return row.state === "not_signed_in" ? { ...row, auth: "unknown", state: "unknown" } : row;
|
|
18185
|
+
});
|
|
18186
|
+
}
|
|
18187
|
+
/** The probes the snapshot runs (tests replace them: no CLI, no account, no network). */
|
|
18188
|
+
availabilityProbes = hostAvailabilityProbes;
|
|
18189
|
+
availabilityStore = null;
|
|
18190
|
+
availabilityTimer = null;
|
|
18191
|
+
get backendAvailability() {
|
|
18192
|
+
return this.availabilityStore ??= new BackendAvailabilityStore(KNOWN_BACKENDS.filter(b => b !== "mock" && !!BACKEND_INSTALLATION_INFO[b]), this.availabilityProbes, { onChange: () => this.noteAvailabilityPaths() });
|
|
18193
|
+
}
|
|
18194
|
+
/** A launch need not look the binary up again (a synchronous `which` on the loop) when the snapshot found it. */
|
|
18195
|
+
noteAvailabilityPaths() {
|
|
18196
|
+
for (const row of this.availabilityStore?.snapshot() ?? []) {
|
|
18197
|
+
const binary = BACKEND_INSTALLATION_INFO[row.backend]?.binary;
|
|
18198
|
+
if (binary)
|
|
18199
|
+
noteResolvedBinary(binary, row.state === "not_installed" ? null : row.path);
|
|
18200
|
+
}
|
|
18201
|
+
}
|
|
18202
|
+
/** Probe now and then every AVAILABILITY_STALE_MS; a pass that finds nothing changed starts no CLI. */
|
|
18203
|
+
startAvailabilityRefresh() {
|
|
18204
|
+
void this.backendAvailability.refresh();
|
|
18205
|
+
if (this.availabilityTimer)
|
|
18206
|
+
clearInterval(this.availabilityTimer);
|
|
18207
|
+
this.availabilityTimer = setInterval(() => { void this.backendAvailability.refresh(); }, AVAILABILITY_STALE_MS);
|
|
18208
|
+
this.availabilityTimer.unref?.();
|
|
16733
18209
|
}
|
|
16734
18210
|
/** The models `/model` would offer on a backend: the cached CLI env, else (live) a bounded probe. Never throws. */
|
|
16735
18211
|
async backendModelOptions(backend, opts) {
|
|
@@ -17071,9 +18547,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17071
18547
|
}
|
|
17072
18548
|
return undefined;
|
|
17073
18549
|
}
|
|
18550
|
+
/** #2072: from the published snapshot (no `which` on the loop); not checked yet counts as installed (no false alarm). */
|
|
17074
18551
|
isBackendInstalled(backend) {
|
|
17075
|
-
|
|
17076
|
-
|
|
18552
|
+
if (!BACKEND_INSTALLATION_INFO[backend])
|
|
18553
|
+
return false;
|
|
18554
|
+
return this.backendAvailability.get(backend)?.state !== "not_installed";
|
|
17077
18555
|
}
|
|
17078
18556
|
getMissingBackendWarning(backend) {
|
|
17079
18557
|
if (!backend)
|
|
@@ -17295,6 +18773,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17295
18773
|
catch (err) {
|
|
17296
18774
|
this.logger.warn({ err: err.message, instanceName: ch.instanceName }, "Dropping a removed room from web favorites failed");
|
|
17297
18775
|
}
|
|
18776
|
+
try {
|
|
18777
|
+
this.themeStore.forget(ch.instanceName);
|
|
18778
|
+
this.webAppearance.limiter.forget(ch.instanceName);
|
|
18779
|
+
this.effectToolsService?.forget(ch.instanceName);
|
|
18780
|
+
}
|
|
18781
|
+
catch (err) {
|
|
18782
|
+
this.logger.warn({ err: err.message, instanceName: ch.instanceName }, "Dropping a removed room's web theme failed");
|
|
18783
|
+
}
|
|
17298
18784
|
}
|
|
17299
18785
|
this.instanceWorldBinding.delete(ch.instanceName);
|
|
17300
18786
|
// #1716: say so in the reply when background work was ended with the stop.
|
|
@@ -17403,6 +18889,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17403
18889
|
clearInterval(this.eventLogPruneTimer);
|
|
17404
18890
|
this.eventLogPruneTimer = null;
|
|
17405
18891
|
}
|
|
18892
|
+
if (this.guestRoomsTimer) {
|
|
18893
|
+
clearInterval(this.guestRoomsTimer);
|
|
18894
|
+
this.guestRoomsTimer = null;
|
|
18895
|
+
}
|
|
17406
18896
|
this.logService?.unwatchAll(); // #1218: a Log's watch timers end with the fleet
|
|
17407
18897
|
this.compactWatchService?.stopAll();
|
|
17408
18898
|
if (this.outboxPruneTimer) {
|
|
@@ -17502,6 +18992,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17502
18992
|
this.adapters.clear();
|
|
17503
18993
|
this.controlClient?.stop();
|
|
17504
18994
|
this.controlClient = null;
|
|
18995
|
+
if (this.availabilityTimer) {
|
|
18996
|
+
clearInterval(this.availabilityTimer);
|
|
18997
|
+
this.availabilityTimer = null;
|
|
18998
|
+
}
|
|
17505
18999
|
if (this.healthServer) {
|
|
17506
19000
|
this.healthServerListening = false;
|
|
17507
19001
|
const server = this.healthServer;
|
|
@@ -19067,7 +20561,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19067
20561
|
all = false;
|
|
19068
20562
|
const message = err?.message ?? String(err);
|
|
19069
20563
|
this.adapterState.set(id, { status: "retrying", retryCount: 0, lastError: message });
|
|
19070
|
-
this.scheduleAdapterRetry(id
|
|
20564
|
+
this.scheduleAdapterRetry(id);
|
|
19071
20565
|
}
|
|
19072
20566
|
}
|
|
19073
20567
|
this.classicChannels?.configureAdapters(this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []));
|
|
@@ -19486,6 +20980,159 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19486
20980
|
this.rejectedHostsLogged.add(name);
|
|
19487
20981
|
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)");
|
|
19488
20982
|
}
|
|
20983
|
+
/**
|
|
20984
|
+
* #1920 §3: the instances a guest session may see — derived on every request from the account's chat access, never
|
|
20985
|
+
* stored; nothing while `web.guests.enabled` is not true.
|
|
20986
|
+
* - Its connection's fleet topics and General (what it may talk to in chat): when that connection's access lets the
|
|
20987
|
+
* account in (AccessManager.isAllowed — allowed_users or paired). An `open` connection lets everyone in, so it gives
|
|
20988
|
+
* only the instances an admin named for its guests (`web.guests.open_instances`; the user's decision: least
|
|
20989
|
+
* privilege, as an open group has no list of people).
|
|
20990
|
+
* - A Telegram private ClassicBot room that is the account's own (its chat id is the user id).
|
|
20991
|
+
* - A ClassicBot group or guild room on its connection: only while its membership is verified (§3.2 — a "member"
|
|
20992
|
+
* answer read in the background, at most 15 minutes old; unknown, failed or stale hides it).
|
|
20993
|
+
* - Then an admin's narrowing (`web.guests.narrow`), which only takes away.
|
|
20994
|
+
* A fleet admin is not a guest: an admin outside the connection's access gets nothing here (admins sign in as admins).
|
|
20995
|
+
*/
|
|
20996
|
+
guestScope(owner) {
|
|
20997
|
+
const guests = this.fleetConfig?.web?.guests;
|
|
20998
|
+
if (!owner || guests?.enabled !== true)
|
|
20999
|
+
return NO_GUEST_SCOPE;
|
|
21000
|
+
const scope = new Set(this.guestReach(owner));
|
|
21001
|
+
const narrow = guests.narrow && Object.hasOwn(guests.narrow, `${owner.adapterId}:${owner.userId}`) ? guests.narrow[`${owner.adapterId}:${owner.userId}`] : undefined;
|
|
21002
|
+
if (narrow) {
|
|
21003
|
+
const keep = new Set(narrow);
|
|
21004
|
+
for (const name of [...scope])
|
|
21005
|
+
if (!keep.has(name))
|
|
21006
|
+
scope.delete(name);
|
|
21007
|
+
}
|
|
21008
|
+
return scope.size ? scope : NO_GUEST_SCOPE;
|
|
21009
|
+
}
|
|
21010
|
+
/**
|
|
21011
|
+
* #1920 (#2104 review): what an account reaches before an admin's narrowing — its connection's topics (an open one's
|
|
21012
|
+
* named agents), its own Telegram private room, its verified group rooms. Narrowing can only take from this set, so it
|
|
21013
|
+
* is what Settings offers to narrow to. Empty while guest sign-in is off.
|
|
21014
|
+
*/
|
|
21015
|
+
guestReach(owner) {
|
|
21016
|
+
const guests = this.fleetConfig?.web?.guests;
|
|
21017
|
+
const scope = new Set();
|
|
21018
|
+
if (guests?.enabled !== true)
|
|
21019
|
+
return scope;
|
|
21020
|
+
const own = (map, key) => (map && Object.hasOwn(map, key) ? map[key] : undefined);
|
|
21021
|
+
const world = this.worlds.get(owner.adapterId);
|
|
21022
|
+
if (world && world.accessManager.isAllowed(owner.userId)) {
|
|
21023
|
+
const named = world.accessManager.getMode() === "open" ? new Set(own(guests.open_instances, owner.adapterId) ?? []) : null;
|
|
21024
|
+
for (const name of Object.keys(this.fleetConfig?.instances ?? {})) {
|
|
21025
|
+
if (this.getInstanceAdapterId(name) !== owner.adapterId || this.isClassicInstance(name))
|
|
21026
|
+
continue;
|
|
21027
|
+
if (!named || named.has(name))
|
|
21028
|
+
scope.add(name);
|
|
21029
|
+
}
|
|
21030
|
+
}
|
|
21031
|
+
const rooms = this.classicChannels?.getAll() ?? [];
|
|
21032
|
+
for (const room of rooms) {
|
|
21033
|
+
if (this.isOwnPrivateRoom(owner, room))
|
|
21034
|
+
scope.add(room.instanceName);
|
|
21035
|
+
}
|
|
21036
|
+
const members = this.guestRooms();
|
|
21037
|
+
for (const room of guestGroupRooms(rooms, owner)) {
|
|
21038
|
+
if (members.verified(room, owner.userId))
|
|
21039
|
+
scope.add(room.instance);
|
|
21040
|
+
}
|
|
21041
|
+
return scope;
|
|
21042
|
+
}
|
|
21043
|
+
/**
|
|
21044
|
+
* #1920 §3.5: a guest session still admitted — current, with something in scope. A scope that became empty (guest
|
|
21045
|
+
* sign-in off, the account removed from the connection's access, everything narrowed away) ends the session here,
|
|
21046
|
+
* so the next request and the open stream find it gone.
|
|
21047
|
+
*/
|
|
21048
|
+
guestStillAdmitted(record) {
|
|
21049
|
+
return this.guestAdmission(record) === "ok";
|
|
21050
|
+
}
|
|
21051
|
+
/**
|
|
21052
|
+
* The same, with a third answer (§3.2): "pending" — nothing in scope now, but a room's membership is not read yet
|
|
21053
|
+
* (a restart, a room just created). The request is refused and the session kept; its read starts in the background.
|
|
21054
|
+
*/
|
|
21055
|
+
guestAdmission(record) {
|
|
21056
|
+
if (!this.webSessions || !this.webToken || !this.webSessions.isCurrent(record, tokenEpoch(this.webToken)))
|
|
21057
|
+
return "gone";
|
|
21058
|
+
const owner = record.owner;
|
|
21059
|
+
if (this.guestScope(owner).size)
|
|
21060
|
+
return "ok";
|
|
21061
|
+
if (owner && this.fleetConfig?.web?.guests?.enabled === true) {
|
|
21062
|
+
const members = this.guestRooms(), rooms = guestGroupRooms(this.classicChannels?.getAll() ?? [], owner);
|
|
21063
|
+
if (rooms.some((room) => members.pending(room, owner.userId))) {
|
|
21064
|
+
void this.refreshGuestRooms(owner, { mode: "triggered" }); // rate-limited per room (30 s), single-flight
|
|
21065
|
+
return "pending";
|
|
21066
|
+
}
|
|
21067
|
+
}
|
|
21068
|
+
this.enforceGuestSwitch();
|
|
21069
|
+
const ended = this.webSessions.revokeByHandle(record.handle);
|
|
21070
|
+
if (ended.found)
|
|
21071
|
+
this.logger.info({ adapterId: record.owner?.adapterId, durable: ended.durable }, "Guest web session ended: nothing left in its scope");
|
|
21072
|
+
return "gone";
|
|
21073
|
+
}
|
|
21074
|
+
/** #1920 §3.2: the membership answers; the read goes to the room's own connection, never waits past its deadline. */
|
|
21075
|
+
guestRooms() {
|
|
21076
|
+
this.guestRoomMembership ??= new GuestRoomMembership({
|
|
21077
|
+
now: () => performance.now(),
|
|
21078
|
+
read: (room, userId) => {
|
|
21079
|
+
const adapter = this.worlds.get(room.adapterId)?.adapter;
|
|
21080
|
+
return room.kind === "telegram" ? adapter?.isGroupMember?.(room.channelId, userId) : adapter?.canViewChannel?.(room.channelId, userId);
|
|
21081
|
+
},
|
|
21082
|
+
// Said once per key while its reads keep failing (no user id: the room and connection are what an admin fixes).
|
|
21083
|
+
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"),
|
|
21084
|
+
onRecovered: (room) => this.logger.info({ adapterId: room.adapterId, instance: room.instance }, "Guest room membership reads again"),
|
|
21085
|
+
});
|
|
21086
|
+
return this.guestRoomMembership;
|
|
21087
|
+
}
|
|
21088
|
+
/** #1920 §3.2: read this guest's rooms that are due (GuestRoomMembership.refresh says when each mode reads). */
|
|
21089
|
+
async refreshGuestRooms(owner, opts = {}) {
|
|
21090
|
+
if (this.fleetConfig?.web?.guests?.enabled !== true)
|
|
21091
|
+
return;
|
|
21092
|
+
try {
|
|
21093
|
+
await this.guestRooms().refresh(owner, guestGroupRooms(this.classicChannels?.getAll() ?? [], owner), opts);
|
|
21094
|
+
}
|
|
21095
|
+
catch (err) {
|
|
21096
|
+
this.logger.warn({ err: err.message }, "Guest room membership could not be read");
|
|
21097
|
+
}
|
|
21098
|
+
}
|
|
21099
|
+
/** Every account with a live guest session, one after another (the guest caps bound them); one pass at a time. */
|
|
21100
|
+
async refreshAllGuestRooms() {
|
|
21101
|
+
if (this.guestRoomsPass || this.fleetConfig?.web?.guests?.enabled !== true || !this.webSessions)
|
|
21102
|
+
return;
|
|
21103
|
+
this.guestRoomsPass = true;
|
|
21104
|
+
try {
|
|
21105
|
+
for (const owner of this.webSessions.guestOwners())
|
|
21106
|
+
await this.refreshGuestRooms(owner);
|
|
21107
|
+
}
|
|
21108
|
+
finally {
|
|
21109
|
+
this.guestRoomsPass = false;
|
|
21110
|
+
}
|
|
21111
|
+
}
|
|
21112
|
+
guestRoomsPass = false;
|
|
21113
|
+
/**
|
|
21114
|
+
* #1920 §3.5: guest sign-in turned off ends every guest session — checked when the configuration is read or saved
|
|
21115
|
+
* and when the sessions are opened, so turning it back on never revives one.
|
|
21116
|
+
*/
|
|
21117
|
+
enforceGuestSwitch() {
|
|
21118
|
+
if (this.fleetConfig?.web?.guests?.enabled === true || !this.webSessions)
|
|
21119
|
+
return;
|
|
21120
|
+
const ended = this.webSessions.revokeAll((r) => r.tier === "guest");
|
|
21121
|
+
if (ended.count)
|
|
21122
|
+
this.logger.info({ count: ended.count, durable: ended.durable }, "Guest web sign-in is off: every guest session ended");
|
|
21123
|
+
// #1920 step 4b: and every guest's returning device — turning it back on lets no old browser ask for a code.
|
|
21124
|
+
const devices = this.returningDevices?.revokeGuest();
|
|
21125
|
+
if (devices?.count)
|
|
21126
|
+
this.logger.info({ count: devices.count, durable: devices.durable }, "Guest web sign-in is off: every guest's returning device forgotten");
|
|
21127
|
+
}
|
|
21128
|
+
/** #1920 §5.2: whether this instance is the account's own Telegram private ClassicBot room (its chat id is the user id). */
|
|
21129
|
+
guestOwnRoom(owner, instance) {
|
|
21130
|
+
return (this.classicChannels?.getAll() ?? []).some((room) => room.instanceName === instance && this.isOwnPrivateRoom(owner, room));
|
|
21131
|
+
}
|
|
21132
|
+
/** A private room's chat id is its one user's id (a group's is negative, so never equal); Telegram only. */
|
|
21133
|
+
isOwnPrivateRoom(owner, room) {
|
|
21134
|
+
return room.adapterId === owner.adapterId && room.channelId === owner.userId && inferClassicChannelType(room.channelId) === "telegram";
|
|
21135
|
+
}
|
|
19489
21136
|
/** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
|
|
19490
21137
|
dispatchWebHttp(req, res, port, closing = false) {
|
|
19491
21138
|
try {
|
|
@@ -19516,6 +21163,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19516
21163
|
return;
|
|
19517
21164
|
}
|
|
19518
21165
|
const requestPath = new URL(req.url ?? "/", `http://localhost:${port}`).pathname;
|
|
21166
|
+
if (this.effectBootstrap.handle(req, res))
|
|
21167
|
+
return;
|
|
19519
21168
|
// Branding is public, after Host admission: automatic favicon requests
|
|
19520
21169
|
// must not become noisy web-token 401s in the browser console.
|
|
19521
21170
|
if (handleWebIconRequest(req, res, requestPath))
|
|
@@ -19546,6 +21195,40 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19546
21195
|
res.end(JSON.stringify({ error: decision.message }));
|
|
19547
21196
|
return;
|
|
19548
21197
|
}
|
|
21198
|
+
// #1920: a guest reaches only GUEST_ROUTES, within the scope derived for it now — default deny, before any
|
|
21199
|
+
// handler, on this listener and the public link's gateway alike (both come through here).
|
|
21200
|
+
if (decision.via === "session" && decision.session.tier === "guest") {
|
|
21201
|
+
const owner = decision.session.owner;
|
|
21202
|
+
const admitted = decision.session;
|
|
21203
|
+
const admission = this.guestAdmission(admitted);
|
|
21204
|
+
if (admission === "pending") {
|
|
21205
|
+
// §3.2: a room's membership is being read — try again shortly; the session stays.
|
|
21206
|
+
res.writeHead(503, { "Retry-After": "5" });
|
|
21207
|
+
res.end(JSON.stringify({ error: "guest_scope_pending" }));
|
|
21208
|
+
return;
|
|
21209
|
+
}
|
|
21210
|
+
if (admission === "gone") {
|
|
21211
|
+
res.writeHead(401);
|
|
21212
|
+
res.end(JSON.stringify({ error: "guest_scope_empty" }));
|
|
21213
|
+
return;
|
|
21214
|
+
}
|
|
21215
|
+
const verdict = guestRouteDecision(req.method ?? "GET", parsedUrl, this.guestScope(owner));
|
|
21216
|
+
if (verdict.kind === "deny") {
|
|
21217
|
+
res.writeHead(verdict.status);
|
|
21218
|
+
res.end(JSON.stringify({ error: verdict.error }));
|
|
21219
|
+
return;
|
|
21220
|
+
}
|
|
21221
|
+
// Phase 1b: the handlers that answer per recipient (the stream, the poll, a file) know it is a guest's, and
|
|
21222
|
+
// ask its scope anew each time; an emptied scope ends the session (and closes its stream).
|
|
21223
|
+
bindGuestRequest(req, { owner, scope: () => this.guestScope(owner), current: () => this.guestStillAdmitted(admitted),
|
|
21224
|
+
// #1920 §5.2: its own Telegram private room shows it everything; the kept messages place ticks and buttons.
|
|
21225
|
+
ownRoom: (instance) => this.guestOwnRoom(owner, instance),
|
|
21226
|
+
// §3.2: a stream that connects reads its rooms again (in the background; at most once a minute).
|
|
21227
|
+
streamOpened: () => { void this.refreshGuestRooms(owner, { mode: "force" }); },
|
|
21228
|
+
history: () => this.webChatHistory,
|
|
21229
|
+
// #1920 §5.1: guests chat unless the host made them read-only (web.guests.chat: false).
|
|
21230
|
+
chat: () => this.fleetConfig?.web?.guests?.chat !== false });
|
|
21231
|
+
}
|
|
19549
21232
|
}
|
|
19550
21233
|
if (req.method === "GET" && req.url === "/health") {
|
|
19551
21234
|
const health = this.getFleetHealth();
|
|
@@ -19936,6 +21619,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19936
21619
|
this.logger.warn({ code: err.code, port: settings.port }, "Preview listener unavailable; HTML previews are off");
|
|
19937
21620
|
this.previewListening = false;
|
|
19938
21621
|
this.previewListener = null;
|
|
21622
|
+
this.previewListenError = `${settings.port}: ${err.code ?? err.message}`;
|
|
19939
21623
|
});
|
|
19940
21624
|
// An optional listener never takes the fleet down: a port Node refuses outright throws here, synchronously.
|
|
19941
21625
|
try {
|
|
@@ -19946,6 +21630,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19946
21630
|
this.logger.warn({ err: err.message, port: settings.port }, "Preview listener cannot listen; HTML previews are off");
|
|
19947
21631
|
this.previewListening = false;
|
|
19948
21632
|
this.previewListener = null;
|
|
21633
|
+
this.previewListenError = `${settings.port}: ${err.code ?? err.message}`;
|
|
19949
21634
|
}
|
|
19950
21635
|
}
|
|
19951
21636
|
/** What a reload may change hot: web.preview, and the dashboard names (the shim's allow-list and frame-ancestors). */
|
|
@@ -19966,6 +21651,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19966
21651
|
}
|
|
19967
21652
|
stopPreviewListener() {
|
|
19968
21653
|
this.previewListening = false;
|
|
21654
|
+
this.previewListenError = null;
|
|
19969
21655
|
this.previewListener?.close();
|
|
19970
21656
|
this.previewListener = null;
|
|
19971
21657
|
}
|
|
@@ -19973,10 +21659,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19973
21659
|
previewForUi(hostHeader, secure) {
|
|
19974
21660
|
const listener = this.previewListening ? this.previewListener : null;
|
|
19975
21661
|
const decided = previewAvailability(listener ? listener.settings : null, hostHeader, secure, listener ? listener.origins : undefined);
|
|
21662
|
+
// Previews are on, but no listener answers: say why — its port could not be opened (the error, never "turned off"),
|
|
21663
|
+
// or it is not listening yet.
|
|
19976
21664
|
if (!listener && this.fleetConfig?.web?.preview !== false)
|
|
19977
|
-
decided
|
|
21665
|
+
Object.assign(decided, this.previewNotListening());
|
|
19978
21666
|
return { ...decided, boot: listener ? listener.bootId : null };
|
|
19979
21667
|
}
|
|
21668
|
+
previewNotListening() {
|
|
21669
|
+
const failed = this.previewListenError;
|
|
21670
|
+
return failed
|
|
21671
|
+
? { 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.` }
|
|
21672
|
+
: { code: "notListening", reason: "Previews are not available on this fleet right now." };
|
|
21673
|
+
}
|
|
19980
21674
|
/**
|
|
19981
21675
|
* #1723: for one page of the public link (exposure `exposureId`, at `dashboardOrigin`): its own preview tunnel's
|
|
19982
21676
|
* origin once up, "starting" until then, why it failed if it did — or a configured web.preview_origin, used instead.
|
|
@@ -19991,8 +21685,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19991
21685
|
const configured = typeof web?.preview_origin === "string" ? normalizeOrigin(web.preview_origin) : null;
|
|
19992
21686
|
if (configured) {
|
|
19993
21687
|
const listener = this.previewListening ? this.previewListener : null;
|
|
19994
|
-
if (!listener)
|
|
19995
|
-
|
|
21688
|
+
if (!listener) {
|
|
21689
|
+
const why = this.previewNotListening();
|
|
21690
|
+
return off(why.code, why.reason, why.detail ? { detail: why.detail } : {});
|
|
21691
|
+
}
|
|
19996
21692
|
if (!configured.startsWith("https:"))
|
|
19997
21693
|
return off("needHttps", "Previews need an https web.preview_origin when the dashboard is reached over https.");
|
|
19998
21694
|
return { dashboardOrigin, previewOrigin: configured, reason: null, code: null, boot: listener.bootId, frameSrc: `${configured}/frame` };
|
|
@@ -20024,6 +21720,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20024
21720
|
// /api/profiles (resolveInstanceIdentity) — the sidebar groups, searches and labels with it on every page.
|
|
20025
21721
|
const classicRooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
|
|
20026
21722
|
const profiles = profileIdentities(this.dataDir);
|
|
21723
|
+
// #1629 r4 §1: the identity card's version per instance (the page reads the card when it moves).
|
|
21724
|
+
const cards = humanCards(this.dataDir);
|
|
21725
|
+
const colors = identityColors(this.dataDir);
|
|
20027
21726
|
// #1630: decorative — whatever goes wrong deciding an avatar never costs the frame (every instance gets its first character).
|
|
20028
21727
|
let bots = { own: new Map(), speaking: new Map() };
|
|
20029
21728
|
try {
|
|
@@ -20083,9 +21782,28 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20083
21782
|
display_name: identity.display_name || undefined,
|
|
20084
21783
|
avatar: image.avatar,
|
|
20085
21784
|
...(image.accent ? { accent: image.accent } : {}),
|
|
21785
|
+
// #1629: the agent's theme's picture and ring colour, for viewers who show agents' themes.
|
|
21786
|
+
...(image.themeAvatar ? { theme_avatar: image.themeAvatar } : {}),
|
|
21787
|
+
...(image.themeAccent ? { theme_accent: image.themeAccent } : {}),
|
|
21788
|
+
...(() => { try {
|
|
21789
|
+
const v = cardVersion(resolveCard(name, cards.get(name), this.themeStore.get(name).theme.card));
|
|
21790
|
+
return v ? { card_v: v } : {};
|
|
21791
|
+
}
|
|
21792
|
+
catch {
|
|
21793
|
+
return {};
|
|
21794
|
+
} })(),
|
|
21795
|
+
// #2118: only this Chat's placement content; guest projection drops global revisions.
|
|
21796
|
+
effect_chat_v: createHash("sha256").update(JSON.stringify(this.themeStore.get(name).placements.effects)).digest("hex").slice(0, 32),
|
|
21797
|
+
// #1958: the identity colour's tiers (the page resolves them: the agent's only while it shows agents' themes).
|
|
21798
|
+
color: { ...(colors.get(name)?.color ? { person: colors.get(name).color } : {}), ...(image.agentColor ? { agent: image.agentColor } : {}),
|
|
21799
|
+
...(image.roleColor ? { role: image.roleColor } : {}), auto: autoIdentityColor(name) },
|
|
21800
|
+
// #1958 decision 2: where it shows — a person's choice for this instance, the agent's; else the viewer's host default.
|
|
21801
|
+
color_show: { ...(colors.get(name)?.show ? { person: colors.get(name).show } : {}), ...(image.agentShow ? { agent: image.agentShow } : {}) },
|
|
20086
21802
|
description: identity.description || undefined,
|
|
20087
21803
|
role: identity.role || undefined,
|
|
20088
21804
|
tags: identity.tags,
|
|
21805
|
+
teams: instanceTeams(this.fleetConfig?.teams, name), // #2172: the sidebar groups a team's members under it
|
|
21806
|
+
last_reply: this.webChatHistory.lastAgentReply(name) ?? undefined, // #2179: its last reply to a person (ISO)
|
|
20089
21807
|
status: this.getInstanceStatus(name),
|
|
20090
21808
|
// `state` (presentation: may be awaiting_input) and `execution_state` (working / idle / stuck, or null —
|
|
20091
21809
|
// what the dashboard's activity events carry) come from instancePresentation (#1212).
|
|
@@ -20109,9 +21827,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20109
21827
|
connections: (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).length,
|
|
20110
21828
|
// #1665: the host preferences' revision — a page that has another re-reads /ui/prefs (no values in this frame).
|
|
20111
21829
|
prefs_rev: this.webPrefs.rev(),
|
|
21830
|
+
// #1629: the themes' revision — the page re-reads GET /ui/themes when it moves.
|
|
21831
|
+
themes_rev: this.themeStore.rev(),
|
|
20112
21832
|
// #1685: host memory pressure while it slows or holds new agents (the dashboard's indicator; macOS elevated is
|
|
20113
21833
|
// shown here rather than in chat). null when normal, unknown or advisory.
|
|
20114
21834
|
host_memory: this.hostMemoryIndicator(),
|
|
21835
|
+
// #2015: the largest processes while it lasts (name, pid, RSS, and whose: an instance, the fleet or tmux).
|
|
21836
|
+
...(this.hostMemoryIndicator() && this.memoryTop ? { host_memory_top: this.memoryTop.processes } : {}),
|
|
20115
21837
|
};
|
|
20116
21838
|
}
|
|
20117
21839
|
}
|