@songsid/agend 2.3.0-beta.2 → 2.3.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/README.zh-TW.md +7 -1
- package/dist/agent-cli-instructions.md +9 -2
- package/dist/agent-cli.js +63 -2
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.js +4 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-file-read.d.ts +4 -1
- package/dist/agent-file-read.js +189 -16
- package/dist/agent-file-read.js.map +1 -1
- package/dist/agent-whats-new.d.ts +11 -0
- package/dist/agent-whats-new.js +29 -5
- package/dist/agent-whats-new.js.map +1 -1
- package/dist/auth-api.d.ts +30 -1
- package/dist/auth-api.js +45 -13
- 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/claude-code.d.ts +2 -2
- package/dist/backend/claude-code.js +18 -8
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex-metadata.d.ts +8 -0
- package/dist/backend/codex-metadata.js +74 -17
- package/dist/backend/codex-metadata.js.map +1 -1
- package/dist/backend/codex.js +15 -2
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/credential-profile.d.ts +2 -0
- package/dist/backend/credential-profile.js +2 -0
- package/dist/backend/credential-profile.js.map +1 -1
- package/dist/backend/types.d.ts +6 -0
- package/dist/backend/types.js +15 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/backend-availability.d.ts +120 -0
- package/dist/backend-availability.js +396 -0
- package/dist/backend-availability.js.map +1 -0
- package/dist/backend-command.d.ts +23 -3
- package/dist/backend-command.js +25 -4
- package/dist/backend-command.js.map +1 -1
- package/dist/bug-report-cli.d.ts +30 -0
- package/dist/bug-report-cli.js +138 -0
- package/dist/bug-report-cli.js.map +1 -0
- package/dist/bug-report.d.ts +47 -0
- package/dist/bug-report.js +136 -0
- package/dist/bug-report.js.map +1 -0
- 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-ledger.d.ts +16 -3
- package/dist/cache-ledger.js +33 -13
- package/dist/cache-ledger.js.map +1 -1
- package/dist/cache-service.d.ts +55 -3
- package/dist/cache-service.js +152 -16
- 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 +9 -0
- package/dist/channel/adapters/discord.js +49 -4
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-tools.js +22 -11
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/types.d.ts +6 -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 +224 -111
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.d.ts +6 -0
- package/dist/config-validator.js +129 -3
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +13 -1
- package/dist/config.js +14 -5
- package/dist/config.js.map +1 -1
- package/dist/cross-instance-notice.d.ts +7 -0
- package/dist/cross-instance-notice.js +10 -0
- package/dist/cross-instance-notice.js.map +1 -1
- package/dist/daemon.d.ts +24 -1
- package/dist/daemon.js +151 -19
- 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 +17 -1
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/diagnostic-redaction.d.ts +10 -0
- package/dist/diagnostic-redaction.js +167 -0
- package/dist/diagnostic-redaction.js.map +1 -0
- package/dist/fleet-context.d.ts +10 -0
- package/dist/fleet-manager.d.ts +505 -15
- package/dist/fleet-manager.js +1870 -204
- 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/bug-report/SKILL.md +31 -0
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +17 -6
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +6 -1
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +2 -0
- 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/scheduling/SKILL.md +4 -0
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +190 -47
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +545 -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-config-impact.js +7 -1
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +10 -1
- package/dist/instance-lifecycle.js +79 -18
- 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 +173 -12
- package/dist/locale.js.map +1 -1
- package/dist/log-steps.d.ts +1 -0
- package/dist/log-steps.js +15 -0
- package/dist/log-steps.js.map +1 -1
- package/dist/login-controller.d.ts +45 -0
- package/dist/login-controller.js +101 -5
- package/dist/login-controller.js.map +1 -1
- package/dist/ls-attach-resolve.d.ts +96 -0
- package/dist/ls-attach-resolve.js +180 -0
- package/dist/ls-attach-resolve.js.map +1 -0
- package/dist/ls-rows.d.ts +12 -0
- package/dist/ls-rows.js +82 -3
- package/dist/ls-rows.js.map +1 -1
- package/dist/memory-hogs.d.ts +27 -0
- package/dist/memory-hogs.js +124 -0
- package/dist/memory-hogs.js.map +1 -0
- package/dist/needs-you.d.ts +10 -0
- package/dist/needs-you.js +2 -1
- package/dist/needs-you.js.map +1 -1
- package/dist/outbound-handlers.d.ts +11 -0
- package/dist/outbound-handlers.js +139 -11
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +200 -4
- package/dist/outbound-schemas.js +124 -11
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +17 -4
- 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 +19 -1
- package/dist/quickstart-api.js +94 -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/restart-progress.d.ts +80 -1
- package/dist/restart-progress.js +306 -21
- 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/db.d.ts +40 -13
- package/dist/scheduler/db.js +123 -89
- package/dist/scheduler/db.js.map +1 -1
- package/dist/scheduler/task-board-text.d.ts +26 -0
- package/dist/scheduler/task-board-text.js +135 -0
- package/dist/scheduler/task-board-text.js.map +1 -0
- package/dist/scheduler/task-board.d.ts +650 -0
- package/dist/scheduler/task-board.js +1938 -0
- package/dist/scheduler/task-board.js.map +1 -0
- package/dist/scheduler/types.d.ts +6 -1
- package/dist/search/search-docs.d.ts +58 -0
- package/dist/search/search-docs.js +63 -0
- package/dist/search/search-docs.js.map +1 -0
- package/dist/search/search-index.d.ts +141 -0
- package/dist/search/search-index.js +440 -0
- package/dist/search/search-index.js.map +1 -0
- package/dist/search/search-store.d.ts +122 -0
- package/dist/search/search-store.js +236 -0
- package/dist/search/search-store.js.map +1 -0
- package/dist/search/search-worker.d.ts +1 -0
- package/dist/search/search-worker.js +163 -0
- package/dist/search/search-worker.js.map +1 -0
- package/dist/service-installer.js +12 -0
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.d.ts +23 -0
- package/dist/settings-api.js +164 -3
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +104 -0
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-confirmation.d.ts +3 -1
- package/dist/settings-confirmation.js +2 -0
- package/dist/settings-confirmation.js.map +1 -1
- package/dist/settings-effect.js +34 -3
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.d.ts +11 -0
- package/dist/settings-http-confirmation.js +10 -0
- package/dist/settings-http-confirmation.js.map +1 -1
- package/dist/setup-form.d.ts +1 -1
- package/dist/setup-form.js +23 -7
- package/dist/setup-form.js.map +1 -1
- package/dist/spawn-gate.d.ts +1 -0
- package/dist/spawn-gate.js +13 -3
- package/dist/spawn-gate.js.map +1 -1
- package/dist/task-board-tool.d.ts +25 -0
- package/dist/task-board-tool.js +154 -0
- package/dist/task-board-tool.js.map +1 -0
- package/dist/task-board-waker.d.ts +44 -0
- package/dist/task-board-waker.js +128 -0
- package/dist/task-board-waker.js.map +1 -0
- package/dist/tmux-exec-helper.d.ts +94 -0
- package/dist/tmux-exec-helper.js +407 -0
- package/dist/tmux-exec-helper.js.map +1 -0
- package/dist/tmux-manager.d.ts +5 -0
- package/dist/tmux-manager.js +42 -11
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tool-permissions.d.ts +0 -7
- package/dist/tool-permissions.js +44 -5
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.d.ts +17 -1
- package/dist/topic-commands.js +45 -19
- package/dist/topic-commands.js.map +1 -1
- package/dist/tunnel/cloudflared.js +37 -2
- package/dist/tunnel/cloudflared.js.map +1 -1
- package/dist/tunnel/types.d.ts +3 -1
- package/dist/tunnel/types.js.map +1 -1
- package/dist/types.d.ts +63 -1
- package/dist/ui/app-effects.js +90 -32
- package/dist/ui/backend-signin.js +297 -0
- package/dist/ui/chat-ambient.js +307 -0
- package/dist/ui/chat-effect-posters.js +8 -8
- package/dist/ui/chat-effects.js +110 -15
- package/dist/ui/chat-msg-actions.js +176 -0
- package/dist/ui/chat-render.js +21 -3
- package/dist/ui/chat-store.js +64 -12
- package/dist/ui/chat-strings.js +85 -8
- package/dist/ui/chat-thread.js +204 -21
- package/dist/ui/effect-trial-controller.js +13 -8
- package/dist/ui/effect-trial-panel.js +19 -16
- package/dist/ui/effects-waker.js +70 -0
- package/dist/ui/fleet-canvas-layout.js +117 -0
- package/dist/ui/fleet-canvas.js +555 -0
- package/dist/ui/fleet-org.js +27 -12
- package/dist/ui/fleet-route-scene.js +207 -0
- package/dist/ui/fleet-route-worker.js +21 -0
- package/dist/ui/fleet-route.js +88 -0
- package/dist/ui/fleet-task-card.js +436 -0
- package/dist/ui/fleet-task-common.js +448 -0
- package/dist/ui/fleet-tasks.js +535 -0
- package/dist/ui/fleet-traffic.js +359 -0
- package/dist/ui/fleet-usage.js +435 -0
- package/dist/ui/panel-chat.js +264 -66
- package/dist/ui/panel-details.js +2 -1
- package/dist/ui/panel-fleet.js +65 -83
- package/dist/ui/panel-needs.js +4 -2
- package/dist/ui/panel-settings.js +56 -11
- package/dist/ui/preview.js +3 -2
- package/dist/ui/search-dialog.js +349 -0
- package/dist/ui/settings-appearance.js +44 -19
- package/dist/ui/settings-apply.js +3 -3
- package/dist/ui/settings-catalog.js +92 -0
- package/dist/ui/settings-confirm.js +7 -2
- package/dist/ui/settings-dialogs.js +40 -12
- package/dist/ui/settings-effects.js +4 -3
- 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 +23 -9
- package/dist/ui/settings-owner-effects.js +15 -13
- package/dist/ui/settings-strings.js +122 -42
- package/dist/ui/settings-web-authority.js +91 -0
- package/dist/ui/shared/agend-auth.js +1 -1
- package/dist/ui/shared/app-i18n.js +11 -2
- package/dist/ui/shared/app-prefs.js +17 -4
- package/dist/ui/shared/app-route.js +19 -1
- package/dist/ui/shared/app-session.js +2 -0
- package/dist/ui/shared/app-shell.js +174 -30
- package/dist/ui/shared/app-store.js +26 -1
- package/dist/ui/shared/app-stream.js +5 -1
- package/dist/ui/shared/app-themes.js +140 -7
- package/dist/ui/shared/app.css +705 -20
- package/dist/ui/shared/app.js +26 -15
- package/dist/ui/shared/card-strings.js +8 -0
- package/dist/ui/shared/effect-app-runtime.js +68 -19
- package/dist/ui/shared/effect-chat-runtime.js +16 -3
- package/dist/ui/shared/effect-frame.js +26 -7
- package/dist/ui/shared/effect-geometry.js +24 -9
- package/dist/ui/shared/effect-owner-source.js +8 -1
- package/dist/ui/shared/effect-policy.js +21 -7
- package/dist/ui/shared/effect-profiler.js +31 -9
- package/dist/ui/shared/effect-registry.js +97 -50
- package/dist/ui/shared/effect-stock-snapshot.js +9 -0
- package/dist/ui/shared/effect-stock-trust.js +62 -10
- package/dist/ui/shared/effect-trial-shadow.js +62 -0
- package/dist/ui/shared/effect-trial-source.js +1 -1
- package/dist/ui/shared/effect-trial-stage.js +54 -31
- package/dist/ui/shared/effect-trial.css +40 -0
- package/dist/ui/shared/fx-motion.js +67 -0
- package/dist/ui/shared/identity-card.js +100 -11
- package/dist/ui/shared/identity-color.js +14 -10
- package/dist/ui/shared/instance-nav.js +179 -23
- package/dist/ui/shared/owner-css.js +30 -1
- package/dist/ui/shared/panel-view.js +24 -4
- package/dist/ui/shared/tokens.css +9 -3
- package/dist/ui/shared/ui-icons.js +2 -0
- package/dist/ui/shared/ui-menu.js +20 -7
- package/dist/ui/shared/ui-toast.js +16 -3
- package/dist/ui/shared/usage-dialog.js +34 -3
- package/dist/ui/shared/view-strings.js +72 -0
- package/dist/ui/shared/web-authorize.js +198 -0
- package/dist/ui/web-terminal/terminal.css +4 -4
- package/dist/ui/web-terminal/terminal.html +1 -1
- package/dist/update-launch.d.ts +9 -0
- package/dist/update-launch.js +38 -12
- package/dist/update-launch.js.map +1 -1
- package/dist/usage/format-rich.js +6 -3
- package/dist/usage/format-rich.js.map +1 -1
- package/dist/usage/i18n-keys.d.ts +1 -1
- package/dist/usage/i18n-keys.js +2 -1
- package/dist/usage/i18n-keys.js.map +1 -1
- package/dist/usage/i18n.d.ts +13 -1
- package/dist/usage/i18n.js +29 -0
- package/dist/usage/i18n.js.map +1 -1
- package/dist/usage/providers.d.ts +81 -2
- package/dist/usage/providers.js +240 -38
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.d.ts +5 -1
- package/dist/usage/usage-api.js +184 -58
- package/dist/usage/usage-api.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/user-systemd.d.ts +24 -0
- package/dist/user-systemd.js +75 -0
- package/dist/user-systemd.js.map +1 -0
- package/dist/view-api.d.ts +39 -0
- package/dist/view-api.js +69 -6
- 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 +98 -14
- package/dist/web-api.js +1027 -83
- package/dist/web-api.js.map +1 -1
- package/dist/web-appearance-policy.d.ts +4 -6
- package/dist/web-appearance-policy.js +5 -12
- package/dist/web-appearance-policy.js.map +1 -1
- package/dist/web-appearance-tools.d.ts +3 -0
- package/dist/web-appearance-tools.js +22 -3
- package/dist/web-appearance-tools.js.map +1 -1
- package/dist/web-appearance-view.d.ts +27 -2
- package/dist/web-appearance-view.js +129 -14
- package/dist/web-appearance-view.js.map +1 -1
- package/dist/web-appearance.d.ts +17 -3
- package/dist/web-appearance.js +85 -8
- package/dist/web-appearance.js.map +1 -1
- package/dist/web-asset-parts.d.ts +2 -2
- package/dist/web-asset-parts.js +3 -3
- package/dist/web-asset-parts.js.map +1 -1
- package/dist/web-auth.js +3 -1
- package/dist/web-auth.js.map +1 -1
- package/dist/web-backend-login.d.ts +130 -0
- package/dist/web-backend-login.js +225 -0
- package/dist/web-backend-login.js.map +1 -0
- package/dist/web-card.d.ts +14 -0
- package/dist/web-card.js +40 -6
- package/dist/web-card.js.map +1 -1
- package/dist/web-channel-echo.d.ts +7 -1
- package/dist/web-channel-echo.js +6 -2
- package/dist/web-channel-echo.js.map +1 -1
- package/dist/web-chat-history.d.ts +83 -0
- package/dist/web-chat-history.js +137 -5
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-commands.d.ts +6 -1
- package/dist/web-commands.js +1 -1
- package/dist/web-commands.js.map +1 -1
- 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.js +46 -1
- package/dist/web-effect-bundle.js.map +1 -1
- 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-profiles.d.ts +4 -4
- package/dist/web-effect-service.d.ts +11 -4
- package/dist/web-effect-service.js +25 -4
- package/dist/web-effect-service.js.map +1 -1
- package/dist/web-effect-tools.d.ts +1 -0
- package/dist/web-effect-tools.js +4 -4
- package/dist/web-effect-tools.js.map +1 -1
- package/dist/web-effect-trials.d.ts +2 -2
- package/dist/web-effect-trials.js +3 -3
- package/dist/web-effect-trials.js.map +1 -1
- package/dist/web-effect-upload.js +1 -1
- package/dist/web-effect-upload.js.map +1 -1
- package/dist/web-effect-worker.d.ts +1 -1
- package/dist/web-effect-worker.js +2 -2
- package/dist/web-effect-worker.js.map +1 -1
- package/dist/web-effect.d.ts +1 -1
- package/dist/web-guest-access.d.ts +126 -0
- package/dist/web-guest-access.js +238 -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-host-guard.d.ts +11 -0
- package/dist/web-host-guard.js +11 -0
- package/dist/web-host-guard.js.map +1 -1
- package/dist/web-identity-color.d.ts +1 -1
- package/dist/web-identity-color.js +2 -2
- package/dist/web-identity-color.js.map +1 -1
- package/dist/web-identity-palette.d.ts +1 -1
- package/dist/web-identity-palette.js +2 -2
- package/dist/web-identity-palette.js.map +1 -1
- package/dist/web-image-meta.d.ts +13 -0
- package/dist/web-image-meta.js +62 -0
- package/dist/web-image-meta.js.map +1 -1
- package/dist/web-libavoid.d.ts +14 -0
- package/dist/web-libavoid.js +46 -0
- package/dist/web-libavoid.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 +1 -1
- package/dist/web-mod-agent.js +7 -5
- package/dist/web-mod-agent.js.map +1 -1
- package/dist/web-mod-bundle.d.ts +9 -1
- package/dist/web-mod-bundle.js +62 -3
- package/dist/web-mod-bundle.js.map +1 -1
- package/dist/web-mod-export.d.ts +9 -0
- package/dist/web-mod-export.js +21 -0
- package/dist/web-mod-export.js.map +1 -1
- package/dist/web-mod-store.d.ts +7 -1
- package/dist/web-mod-store.js +19 -6
- package/dist/web-mod-store.js.map +1 -1
- package/dist/web-owner-css-store.d.ts +44 -1
- package/dist/web-owner-css-store.js +70 -9
- package/dist/web-owner-css-store.js.map +1 -1
- package/dist/web-prefs.d.ts +14 -0
- package/dist/web-prefs.js +37 -4
- package/dist/web-prefs.js.map +1 -1
- package/dist/web-preview.d.ts +6 -2
- package/dist/web-preview.js +4 -2
- package/dist/web-preview.js.map +1 -1
- package/dist/web-returning-device.d.ts +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 +49 -2
- package/dist/web-settings-patch.js +145 -1
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-settings-tools.d.ts +94 -0
- package/dist/web-settings-tools.js +301 -0
- package/dist/web-settings-tools.js.map +1 -0
- package/dist/web-shell-routes.d.ts +1 -1
- package/dist/web-shell-routes.js +1 -1
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/web-terminal-http.js +15 -2
- package/dist/web-terminal-http.js.map +1 -1
- package/dist/web-theme-store.d.ts +39 -0
- package/dist/web-theme-store.js +122 -11
- package/dist/web-theme-store.js.map +1 -1
- package/dist/web-theme.d.ts +31 -0
- package/dist/web-theme.js +99 -2
- package/dist/web-theme.js.map +1 -1
- 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/windows-shortcuts.d.ts +78 -0
- package/dist/windows-shortcuts.js +203 -0
- package/dist/windows-shortcuts.js.map +1 -0
- package/dist/workflow-templates/default.md +4 -1
- package/dist/yaml-shared-nodes.d.ts +25 -0
- package/dist/yaml-shared-nodes.js +185 -0
- package/dist/yaml-shared-nodes.js.map +1 -0
- package/launcher/agend-install.cjs +553 -0
- package/launcher/install-admission.cjs +4 -39
- package/launcher/postinstall.cjs +14 -12
- package/package.json +4 -2
- package/scripts/preinstall-guard.cjs +2 -5
- package/templates/windows-shortcuts.ps1 +181 -0
- package/launcher/old-updater-note.cjs +0 -48
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";
|
|
@@ -38,6 +38,8 @@ import { readFleetMemory } from "./process-memory.js";
|
|
|
38
38
|
import { MemoryPressure } from "./memory-pressure.js";
|
|
39
39
|
import { replyDedupText, ReplyDeduper } from "./reply-dedup.js";
|
|
40
40
|
import { isMap, isScalar, parseDocument } from "yaml";
|
|
41
|
+
import { changedOutside, detachForWrite } from "./yaml-shared-nodes.js";
|
|
42
|
+
import { tmuxExecHelper } from "./tmux-exec-helper.js";
|
|
41
43
|
import { kiroLogGate } from "./kiro-log-gate.js";
|
|
42
44
|
const __filename = fileURLToPath(import.meta.url);
|
|
43
45
|
const __dirname = dirname(__filename);
|
|
@@ -48,6 +50,7 @@ import { isProbeableRouteTarget } from "./fleet-context.js";
|
|
|
48
50
|
import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
|
|
49
51
|
import { EventLog } from "./event-log.js";
|
|
50
52
|
import { binaryProbe } from "./binary-probe.js";
|
|
53
|
+
import { AVAILABILITY_STALE_MS, BackendAvailabilityStore, hostAvailabilityProbes } from "./backend-availability.js";
|
|
51
54
|
import { classifySqliteOpenError } from "./sqlite-open-errors.js";
|
|
52
55
|
import { OutboxOpenError } from "./outbox-open-error.js";
|
|
53
56
|
import { AdapterWorld } from "./adapter-world.js";
|
|
@@ -65,14 +68,16 @@ import { createBackend } from "./backend/factory.js";
|
|
|
65
68
|
import { readEffortMetadata } from "./backend/effort-metadata.js";
|
|
66
69
|
import { codexEffort, flagEffort } from "./backend/launch-effort.js";
|
|
67
70
|
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";
|
|
71
|
+
import { CLI_ENV_TTL_MS, isModelCompatible, noteResolvedBinary, resolveBinary, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
|
|
69
72
|
import { createLogger, rotateLogIfNeeded } from "./logger.js";
|
|
70
73
|
import { processAttachments } from "./channel/attachment-handler.js";
|
|
71
74
|
import { routeToolCall } from "./channel/tool-router.js";
|
|
72
75
|
import { Scheduler } from "./scheduler/index.js";
|
|
76
|
+
import { boardActivity, boardCardLimit, boardToolAction } from "./task-board-tool.js";
|
|
77
|
+
import { TaskBoardWaker } from "./task-board-waker.js";
|
|
73
78
|
import { escapeTelegramHtml, scheduleClock, scheduleRetryLabel } from "./scheduler/retry-label.js";
|
|
74
79
|
import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
|
|
75
|
-
import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, parseCompactCommand, SAVE_FILENAME_RE, resolveInstanceContext, forgetInstanceContext,
|
|
80
|
+
import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, parseCompactCommand, SAVE_FILENAME_RE, resolveInstanceContext, forgetInstanceContext, readStatuslineJson, statuslineModel } from "./topic-commands.js";
|
|
76
81
|
import { DailySummary } from "./daily-summary.js";
|
|
77
82
|
import { WebhookEmitter } from "./webhook-emitter.js";
|
|
78
83
|
import { TmuxControlClient } from "./tmux-control.js";
|
|
@@ -100,7 +105,8 @@ export const TASK_LIST_CAP = 100;
|
|
|
100
105
|
* #1336: the non-terminal statuses a bare `list` returns by default. `done`
|
|
101
106
|
* and `cancelled` are excluded unless the caller passes `filter_status`.
|
|
102
107
|
*/
|
|
103
|
-
|
|
108
|
+
/** Not finished: `review` is done work waiting for the person (the task board), still live. */
|
|
109
|
+
export const LIVE_TASK_STATUSES = ["open", "claimed", "blocked", "review"];
|
|
104
110
|
/**
|
|
105
111
|
* #1336 P2: shared normalization for filter_status across both task handlers
|
|
106
112
|
* and the cap helper. Trims whitespace before dropping empties, so
|
|
@@ -134,9 +140,9 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
|
|
|
134
140
|
}
|
|
135
141
|
return tasks;
|
|
136
142
|
}
|
|
137
|
-
import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
|
|
143
|
+
import { handleWebRequest, broadcastSseEvent, broadcastToGuests, SSE_HEARTBEAT_MS } from "./web-api.js";
|
|
138
144
|
import { LogService } from "./log-service.js";
|
|
139
|
-
import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
|
|
145
|
+
import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId, webChatFrom } from "./web-chat-history.js";
|
|
140
146
|
import { WebChatDiskStore } from "./web-chat-store.js";
|
|
141
147
|
import { SeenUsers, resolveMentions } from "./web-mentions.js";
|
|
142
148
|
import { OPERATOR, WebPrefsStore } from "./web-prefs.js";
|
|
@@ -158,7 +164,7 @@ import { ReplyButtonsController } from "./reply-buttons-controller.js";
|
|
|
158
164
|
import { parsePoll } from "./reply-polls.js";
|
|
159
165
|
import { isUploadRequest, limitRequestReceive, publicAttachment, sweepOrphanedUploads, UPLOAD_REQUEST_TIMEOUT_MS, uploadLimits, WebFileLedger } from "./web-upload.js";
|
|
160
166
|
import { REPLY_FILES_MAX_BYTES, snapshotReplyFile, sweepReplySnapshots, webFilesDir } from "./web-reply-files.js";
|
|
161
|
-
import { cardPicture, handleViewRequest, humanCards, identityColors, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
|
|
167
|
+
import { cardPicture, handleViewRequest, humanCards, identityColors, instanceTeams, isViewPath, personBannerFocus, personIdentityColour, profileIdentities, resolveInstanceIdentity, setPersonBannerFocus, setPersonIdentityColour } from "./view-api.js";
|
|
162
168
|
import { autoIdentityColor } from "./web-identity-color.js";
|
|
163
169
|
import { AppearanceTools } from "./web-appearance-tools.js";
|
|
164
170
|
import { cardVersion, resolveCard } from "./web-card.js";
|
|
@@ -178,11 +184,18 @@ import { runOnLoginShell } from "./login-shell.js";
|
|
|
178
184
|
import { tightenInstanceDirs } from "./private-dir.js";
|
|
179
185
|
import { decideSlash } from "./slash-authz.js";
|
|
180
186
|
import { canonicalCommand, commandSpec, decideCommand, ruleFor } from "./command-table.js";
|
|
181
|
-
import { runVisibilityCommand } from "./cross-instance-notice.js";
|
|
187
|
+
import { crossInstanceVisibility, runVisibilityCommand, xmsgVisibility } from "./cross-instance-notice.js";
|
|
188
|
+
import { SEARCH_RETENTION_DAYS, SearchIndex } from "./search/search-index.js";
|
|
189
|
+
import { chatDoc, taskDoc, xmsgDoc } from "./search/search-docs.js";
|
|
190
|
+
import { WebSettingsTools } from "./web-settings-tools.js";
|
|
191
|
+
import { bindGuestRequest, guestRouteDecision } from "./web-guest-access.js";
|
|
192
|
+
import { GUEST_ROOM_TICK_MS, GuestRoomMembership, guestGroupRooms } from "./web-guest-rooms.js";
|
|
193
|
+
import { Delegations, TrafficLog, trafficItem } from "./fleet-traffic.js";
|
|
182
194
|
import { installedChannel, updateNoticeKey } from "./update-check.js";
|
|
183
195
|
import { resolveInstalledAgend } from "./update-dispatch.js";
|
|
184
196
|
import { resolveUpdateLaunch, openUpdateLog, updateLogPath, watchUpdateLaunch } from "./update-launch.js";
|
|
185
197
|
import { admitProfileLogin, LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
|
|
198
|
+
import { WebBackendLogins, dashboardSignInSupported } from "./web-backend-login.js";
|
|
186
199
|
import { runBeforeDeadline } from "./deadline.js";
|
|
187
200
|
import { LoginWindowLock } from "./login-window-lock.js";
|
|
188
201
|
import { handleSettingsRequest } from "./settings-api.js";
|
|
@@ -190,7 +203,7 @@ import { setLocale, detectLocale, getLocale, t } from "./locale.js";
|
|
|
190
203
|
import { recentChatContext } from "./log-tail.js";
|
|
191
204
|
import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet-control-audit.js";
|
|
192
205
|
import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
|
|
193
|
-
import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
|
|
206
|
+
import { ClassicChannelManager, classicWebEchoOn, getClassicBackendChoices, inferClassicChannelType, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
|
|
194
207
|
import { assertExplicitInstanceRemoval } from "./instance-removal.js";
|
|
195
208
|
import { KNOWN_BACKENDS, validateFleetConfig } from "./config-validator.js";
|
|
196
209
|
import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
|
|
@@ -209,6 +222,7 @@ import { NeedsYouHub } from "./needs-you-hub.js";
|
|
|
209
222
|
import { WEB_CHAT_NOTICE, claimNotice, hasWebChat, releaseNotice, upgradeNoticesPath } from "./upgrade-notices.js";
|
|
210
223
|
import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
|
|
211
224
|
import { buildOrgChart } from "./web-org.js";
|
|
225
|
+
import { topMemoryHogs } from "./memory-hogs.js";
|
|
212
226
|
import { GENERAL_POINTING_REFUSALS, SNOWFLAKE, hasPlaceRefs, placeRef, webRoute, webTextLinks, placeSuffix, renderPlaceRefs, stripPlaceRefs, topicUrl, worldPlaceOf } from "./topic-link.js";
|
|
213
227
|
import { CacheService, cacheWindow } from "./cache-service.js";
|
|
214
228
|
import { runWebCommand } from "./web-commands.js";
|
|
@@ -220,11 +234,17 @@ import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefu
|
|
|
220
234
|
import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
|
|
221
235
|
import { bypassesWebGate, handleAuthRequest, serveSigninPage } from "./auth-api.js";
|
|
222
236
|
import { isWebPageNavigation } from "./web-shell-routes.js";
|
|
223
|
-
import { localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
|
|
224
|
-
import {
|
|
237
|
+
import { guestSessionPolicy, localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
|
|
238
|
+
import { authorityNow, classifyWebPeer, evaluateWebAuthority, VERIFIED_SESSION_HOURS, webAuthorityPolicy } from "./authority/web-authority.js";
|
|
239
|
+
import { AuthorityStateStore } from "./authority/authority-state.js";
|
|
240
|
+
import { SessionAuthorizations } from "./authority/session-authorizations.js";
|
|
241
|
+
import { SessionAuthorizationRequests } from "./web-session-authorization.js";
|
|
242
|
+
import { GuestLoginCodes, WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
|
|
243
|
+
import { decisionListAnswer } from "./decision-list.js";
|
|
225
244
|
import { CodeRequestLimiter, ReturningDevices } from "./web-returning-device.js";
|
|
226
|
-
import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
|
|
245
|
+
import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, LOOPBACK_HOST_NAMES, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
|
|
227
246
|
import { createPreviewListener, normalizeOrigin, previewAvailability, previewSettings } from "./web-preview.js";
|
|
247
|
+
import { createEffectBootstrap } from "./web-effect-bootstrap.js";
|
|
228
248
|
import { fleetLevelDifferences, fleetLevelSignature, appendedConnections } from "./fleet-level-config.js";
|
|
229
249
|
import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
|
|
230
250
|
import { SecretStore } from "./secret-store.js";
|
|
@@ -234,11 +254,11 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
|
|
|
234
254
|
/** A self-restart is a whole service restart; 120s is the apply budget, not this. */
|
|
235
255
|
const SELF_RESTART_DEADLINE_MS = 300_000;
|
|
236
256
|
import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
|
|
237
|
-
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
257
|
+
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileLogin, credentialProfileLoginCommand, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
238
258
|
import { carryClaudeConversation } from "./backend/claude-carry.js";
|
|
239
259
|
import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
|
|
240
260
|
import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
|
|
241
|
-
import { cleanupWatchVerdict, formatRestartProgressCompletion,
|
|
261
|
+
import { cleanupWatchVerdict, formatRestartProgressCompletion, RestartProgress, } from "./restart-progress.js";
|
|
242
262
|
import { launchFullRestartHelper } from "./full-restart.js";
|
|
243
263
|
import { SYSTEMD_RESTART_INDETERMINATE_EXIT_CODE } from "./service-restart-selection.js";
|
|
244
264
|
import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
|
|
@@ -247,6 +267,14 @@ import { SpawnGate } from "./spawn-gate.js";
|
|
|
247
267
|
import { BackendOutageTracker } from "./backend-outage.js";
|
|
248
268
|
import { canUnlockAdvancedTips, DailyTipScheduler, selectTip, visibleTipLevels, } from "./tips.js";
|
|
249
269
|
import { getTmuxSession } from "./config.js";
|
|
270
|
+
// Closed migration cohort: these 11 names existed immediately before the first
|
|
271
|
+
// managed-skill publisher (a1ac499f; parent e267e684). Never grow this set when
|
|
272
|
+
// adding a bundled skill: its new name is not proof of old AgEnD ownership.
|
|
273
|
+
const PRE_MANIFEST_KIRO_SKILLS = new Set([
|
|
274
|
+
"backend-providers", "cross-instance-messaging", "fleet-config", "fleet-health",
|
|
275
|
+
"fleet-restart", "instance-lifecycle", "model-discovery", "multi-channel",
|
|
276
|
+
"scheduling", "session-management", "tui-effort",
|
|
277
|
+
]);
|
|
250
278
|
export function resolveReplyThreadId(argsThreadId, instanceConfig) {
|
|
251
279
|
if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
|
|
252
280
|
return argsThreadId;
|
|
@@ -283,6 +311,41 @@ export function selectLruEvictions(warm, cap, opts) {
|
|
|
283
311
|
* fleet.yaml. Keep in sync with `uniqueInstanceName` — every name it emits
|
|
284
312
|
* must match here, or a deleted instance's CLI is left running (#1305 P2-3).
|
|
285
313
|
*/
|
|
314
|
+
/**
|
|
315
|
+
* #2163: where `p` really is — symlinks resolved. A path that does not exist yet is resolved through its deepest
|
|
316
|
+
* existing ancestor (a missing child of a link elsewhere is elsewhere too).
|
|
317
|
+
*/
|
|
318
|
+
function realPathThroughAncestors(p) {
|
|
319
|
+
const missing = [];
|
|
320
|
+
let at = resolvePath(p);
|
|
321
|
+
for (;;) {
|
|
322
|
+
try {
|
|
323
|
+
return join(realpathSync(at), ...missing.reverse());
|
|
324
|
+
}
|
|
325
|
+
catch { /* not there yet: try its parent */ }
|
|
326
|
+
try {
|
|
327
|
+
lstatSync(at);
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
catch { /* truly absent — not a broken link whose target is unknown */ }
|
|
331
|
+
const up = dirname(at);
|
|
332
|
+
if (up === at)
|
|
333
|
+
return resolvePath(p);
|
|
334
|
+
missing.push(basename(at));
|
|
335
|
+
at = up;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* #2163: whether `dir` is inside AgEnD's own workspaces (<AGEND_HOME>/workspaces/<name>/…), compared after resolving
|
|
340
|
+
* symlinks on both sides — including a missing folder's existing ancestors — so a link elsewhere is not inside, nor is a broken link.
|
|
341
|
+
*/
|
|
342
|
+
export function insideManagedWorkspaces(dir, agendHome = getAgendHome()) {
|
|
343
|
+
const root = realPathThroughAncestors(join(agendHome, "workspaces")), real = realPathThroughAncestors(dir);
|
|
344
|
+
if (root === null || real === null)
|
|
345
|
+
return false;
|
|
346
|
+
const rel = relative(root, real);
|
|
347
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
348
|
+
}
|
|
286
349
|
export function isOrphanInstanceWindowName(name) {
|
|
287
350
|
return /-t\d+$/.test(name) || /^classic-/.test(name);
|
|
288
351
|
}
|
|
@@ -504,6 +567,8 @@ function emojiListFilter(opts) {
|
|
|
504
567
|
withImageUrls: flag(opts.with_image_urls),
|
|
505
568
|
};
|
|
506
569
|
}
|
|
570
|
+
/** #1920: a guest's scope until phase 2 derives one: nothing. */
|
|
571
|
+
const NO_GUEST_SCOPE = new Set();
|
|
507
572
|
export class FleetManager {
|
|
508
573
|
dataDir;
|
|
509
574
|
static signalTarget = null;
|
|
@@ -540,6 +605,8 @@ export class FleetManager {
|
|
|
540
605
|
fleetConfig = null;
|
|
541
606
|
rawFleetConfig = {};
|
|
542
607
|
rawFleetDocument = null;
|
|
608
|
+
/** #2208: the paths a saveFleetConfig() wrote, for its check that nothing else changed. */
|
|
609
|
+
fleetDocumentWrites = null;
|
|
543
610
|
savedFleetConfigSnapshot = null;
|
|
544
611
|
adapter = null;
|
|
545
612
|
worlds = new Map();
|
|
@@ -604,6 +671,8 @@ export class FleetManager {
|
|
|
604
671
|
statuslineWatcher;
|
|
605
672
|
dailySummary = null;
|
|
606
673
|
dailyTipScheduler = null;
|
|
674
|
+
/** The task board's wakes (step 2): an instance hears when someone else changes a card it owns or handed out. */
|
|
675
|
+
taskBoardWaker = null;
|
|
607
676
|
webhookEmitter = null;
|
|
608
677
|
// Topic icon + auto-archive state
|
|
609
678
|
topicIcons = {};
|
|
@@ -732,7 +801,10 @@ export class FleetManager {
|
|
|
732
801
|
// Adapter restart: prevents re-entrant restart attempts
|
|
733
802
|
adapterRestarting = new Set();
|
|
734
803
|
// Adapter isolation: track state per adapter for retry + visibility
|
|
804
|
+
/** #2175: this boot's progress message, whose completion may still be owed by its background retry. */
|
|
805
|
+
startupProgress = null;
|
|
735
806
|
adapterState = new Map();
|
|
807
|
+
adapterRetryInFlight = new Map();
|
|
736
808
|
/** Web Settings secret rotation is deliberately separate from reconnect
|
|
737
809
|
* recovery: a rotation must build a fresh provider client with the new token,
|
|
738
810
|
* and stale callbacks from the old client must not win. */
|
|
@@ -770,10 +842,16 @@ export class FleetManager {
|
|
|
770
842
|
collabInstances = new Set();
|
|
771
843
|
// Health endpoint
|
|
772
844
|
healthServer = null;
|
|
845
|
+
/** #2118: independent of HTML preview availability/port; fixed bootstrap only, no effect resources. */
|
|
846
|
+
effectBootstrap = createEffectBootstrap();
|
|
847
|
+
/** Fixed trusted document coherence, independent of the HTML preview listener; never a resource grant. */
|
|
848
|
+
get effectBoot() { return this.effectBootstrap.boot; }
|
|
773
849
|
healthServerDrain = null;
|
|
774
850
|
/** #1306: the preview listener (health_port + 1 by default), and whether it is listening. */
|
|
775
851
|
previewListener = null;
|
|
776
852
|
previewListening = false;
|
|
853
|
+
/** The preview listener could not open its port: "<port>: <error code>", for the page (#2061 follow-up); null otherwise. */
|
|
854
|
+
previewListenError = null;
|
|
777
855
|
/** The ports the preview listener was started for, and the inputs it was built from (a reload compares them). */
|
|
778
856
|
previewPorts = null;
|
|
779
857
|
previewInputs = "";
|
|
@@ -792,6 +870,9 @@ export class FleetManager {
|
|
|
792
870
|
/** Injectable only to keep the chat→CLI reload hand-off deterministic in tests. */
|
|
793
871
|
fullRestartLauncher = launchFullRestartHelper;
|
|
794
872
|
eventLogPruneTimer = null;
|
|
873
|
+
/** #1920 §3.2: the background reads of guests' ClassicBot room membership (a minute's tick; reads only what is due). */
|
|
874
|
+
guestRoomsTimer = null;
|
|
875
|
+
guestRoomMembership = null;
|
|
795
876
|
outboxPruneTimer = null;
|
|
796
877
|
logRotateTimer = null;
|
|
797
878
|
discordPresenceTimer = null;
|
|
@@ -806,6 +887,17 @@ export class FleetManager {
|
|
|
806
887
|
startedAt = 0;
|
|
807
888
|
stormOpenNotifyTimer = null;
|
|
808
889
|
memoryLogAt = null;
|
|
890
|
+
/**
|
|
891
|
+
* #2015: while host memory is elevated or critical, the processes holding the most in the fleet's service and whose
|
|
892
|
+
* they are (an instance, the fleet, tmux), for the pressure log, /health and the dashboard — never chat (#1712).
|
|
893
|
+
* Rescanned at most once a minute (async, one at a time); cleared when pressure ends.
|
|
894
|
+
*/
|
|
895
|
+
memoryTop = null;
|
|
896
|
+
memoryTopScan = { at: null, inFlight: false, loggedAt: null };
|
|
897
|
+
/** The scan (a seam for tests: they give it a fake /proc). */
|
|
898
|
+
memoryHogScan = topMemoryHogs;
|
|
899
|
+
/** The fleet's own tmux session exists (only then are its panes read: a fleet that never started has none). */
|
|
900
|
+
tmuxSessionEnsured = false;
|
|
809
901
|
memoryDebugAt = null;
|
|
810
902
|
memoryLogLevel = null;
|
|
811
903
|
/** #1685: the host_memory value the last status frame carried, so a change re-sends the frame. */
|
|
@@ -819,6 +911,8 @@ export class FleetManager {
|
|
|
819
911
|
webChatHistory = new WebChatHistory();
|
|
820
912
|
/** #1565: the web chat history's files (set at fleet start, once the home and the instances are known). */
|
|
821
913
|
webChatStore = null;
|
|
914
|
+
/** #2293: the dashboard's global search (null: web.search.enabled is false, or the fleet has not started). */
|
|
915
|
+
searchIndex = null;
|
|
822
916
|
/** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
|
|
823
917
|
// #1937: the upload limits are the config's, read on each use (agend reload applies a change to the next upload).
|
|
824
918
|
webFiles = new WebFileLedger({ limits: () => uploadLimits(this.fleetConfig?.web) });
|
|
@@ -978,7 +1072,7 @@ export class FleetManager {
|
|
|
978
1072
|
this.emitSseEvent("status", this.getUiStatus());
|
|
979
1073
|
}
|
|
980
1074
|
catch { /* later status carries placements */ } },
|
|
981
|
-
// A nonmutating own/shared offer is not an appearance write. Only an explicit
|
|
1075
|
+
// A nonmutating own/shared offer is not an appearance write. Only an explicit non-public admin-session click may start it.
|
|
982
1076
|
preview: async (instance, id) => {
|
|
983
1077
|
const result = this.effectTrials.offer(instance, id);
|
|
984
1078
|
if (!result.ok)
|
|
@@ -986,11 +1080,12 @@ export class FleetManager {
|
|
|
986
1080
|
const present = this.webChatHistory.list(instance).some(row => row.effectTrial?.offer === result.offer.offer);
|
|
987
1081
|
if (!present && !this.publishEffectTrial(instance, result.offer))
|
|
988
1082
|
return { error: "trial_unavailable" };
|
|
989
|
-
return { id, status: "offered", expiresAt: result.offer.expiresAt, message: "A trial card is in your web chat. A signed-in
|
|
1083
|
+
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." };
|
|
990
1084
|
},
|
|
991
1085
|
profiles: id => this.effectProfiles.read(id),
|
|
992
1086
|
profileSummary: id => this.effectService.profileSummary(id),
|
|
993
1087
|
profileSummaries: ids => this.effectService.profileSummaries(ids),
|
|
1088
|
+
playbackSummary: (name, id) => this.effectService.playbackSummary(name, id),
|
|
994
1089
|
}));
|
|
995
1090
|
}
|
|
996
1091
|
appearanceToolsService;
|
|
@@ -998,7 +1093,12 @@ export class FleetManager {
|
|
|
998
1093
|
get appearanceTools() {
|
|
999
1094
|
return (this.appearanceToolsService ??= new AppearanceTools({
|
|
1000
1095
|
frame: (name) => (this.getUiStatus().instances.find(i => i.name === name) ?? { name }),
|
|
1001
|
-
theme: (name) => {
|
|
1096
|
+
theme: (name) => {
|
|
1097
|
+
const r = this.themeStore.get(name);
|
|
1098
|
+
return { theme: r.theme, locked: r.locked, widgets: r.placements.widgets, ambient: r.placements.ambient,
|
|
1099
|
+
glyphAsset: (id) => this.themeStore.asset(name, id) };
|
|
1100
|
+
},
|
|
1101
|
+
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) })) : [],
|
|
1002
1102
|
card: (name) => resolveCard(name, humanCards(this.dataDir).get(name), this.themeStore.get(name).theme.card),
|
|
1003
1103
|
// #1958: the host's identity-colour settings, as the page reads them (an unreadable prefs file: the defaults).
|
|
1004
1104
|
colorPrefs: () => { try {
|
|
@@ -1079,6 +1179,31 @@ export class FleetManager {
|
|
|
1079
1179
|
get ownerCss() {
|
|
1080
1180
|
return (this.ownerCssStore ??= new OwnerCssStore(this.dataDir, this.webPrefs));
|
|
1081
1181
|
}
|
|
1182
|
+
webSettingsToolsService;
|
|
1183
|
+
/**
|
|
1184
|
+
* #2056: Settings → Web appearance for a tool (General, a coordinator, full) — the page's own stores and checks, and the
|
|
1185
|
+
* page's announcement after a change (web-settings-tools.ts).
|
|
1186
|
+
*/
|
|
1187
|
+
get webSettings() {
|
|
1188
|
+
return (this.webSettingsToolsService ??= new WebSettingsTools({
|
|
1189
|
+
prefs: this.webPrefs, ownerCss: this.ownerCss, mods: this.mods, effects: this.effectService,
|
|
1190
|
+
instances: () => [...Object.keys(this.fleetConfig?.instances ?? {}),
|
|
1191
|
+
...(this.classicChannels?.getAll() ?? []).map((ch) => ch.instanceName).filter((n) => !Object.hasOwn(this.fleetConfig?.instances ?? {}, n))],
|
|
1192
|
+
colour: (name) => personIdentityColour(this.dataDir, name),
|
|
1193
|
+
setColour: (name, patch) => { const w = setPersonIdentityColour(this.dataDir, name, patch); if (w.ok)
|
|
1194
|
+
this.identityChanged(); return w; },
|
|
1195
|
+
bannerFocus: (name) => personBannerFocus(this.dataDir, name),
|
|
1196
|
+
setBannerFocus: (name, focus) => { const w = setPersonBannerFocus(this.dataDir, name, focus); if (w.ok)
|
|
1197
|
+
this.identityChanged(); return w; },
|
|
1198
|
+
placements: (name) => this.themeStore.get(name).placements.effects.map((e) => (typeof e === "string" ? { id: e } : e)),
|
|
1199
|
+
ambient: (name) => Object.fromEntries(Object.entries(this.themeStore.get(name).placements.ambient).flatMap(([k, a]) => a ? [[k, { spec: a.spec, by: a.by }]] : [])),
|
|
1200
|
+
setAmbient: (name, spec, which) => this.webAppearance.humanSetAmbient(name, spec, which),
|
|
1201
|
+
announce: () => { try {
|
|
1202
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
1203
|
+
}
|
|
1204
|
+
catch { /* the next status frame carries it */ } },
|
|
1205
|
+
}));
|
|
1206
|
+
}
|
|
1082
1207
|
get webPrefs() {
|
|
1083
1208
|
// #1664: a favorite must be an instance of this fleet now — fleet.yaml's (own keys) or a ClassicBot room's.
|
|
1084
1209
|
return (this.webPrefsStore ??= new WebPrefsStore(this.dataDir, {
|
|
@@ -1181,6 +1306,7 @@ export class FleetManager {
|
|
|
1181
1306
|
current: () => current() && sessions === this.webSessions && !!this.webToken
|
|
1182
1307
|
&& sessions.isCurrent(record, tokenEpoch(this.webToken)) && (!exposure || exposure.isCurrent()) };
|
|
1183
1308
|
}, baseline: () => baselines.read(), snapshot: () => baselines.snapshot(),
|
|
1309
|
+
selfConfirm: (req, id) => this.settingsSelfConfirmation(store, req, id),
|
|
1184
1310
|
job: async (id) => {
|
|
1185
1311
|
const settlement = this.settingsJobSettlements.get(id);
|
|
1186
1312
|
if (!settlement)
|
|
@@ -1195,6 +1321,86 @@ export class FleetManager {
|
|
|
1195
1321
|
this.settingsConfirmation = gate;
|
|
1196
1322
|
return gate;
|
|
1197
1323
|
}
|
|
1324
|
+
/**
|
|
1325
|
+
* #2170: a held change confirmed by the request itself — only when ALL hold, else the chat card or the host CLI, as
|
|
1326
|
+
* before:
|
|
1327
|
+
* - the session is web-verified (#1939: its account confirmed this browser on Discord or Telegram), asked now;
|
|
1328
|
+
* - the change's effect owners are known and on one platform (settingsChatAuthority — the chat card's own rule), the
|
|
1329
|
+
* verified account is on that platform and an admin of every one of them;
|
|
1330
|
+
* - a chat card could have been asked: a connected General on that platform the change does not affect (else the
|
|
1331
|
+
* change goes to the host CLI, as before) — the General that then hears of it;
|
|
1332
|
+
* - and, at execution (store.decide asks `current()` again): still web-verified under the same session and account
|
|
1333
|
+
* (not expired, revoked, signed out or the switch turned off), still those owners' admin, still a General a card
|
|
1334
|
+
* could go to, the fleet unchanged.
|
|
1335
|
+
* What it rests on: the account's one confirmation of this browser (#1939), kept for the session until it is revoked,
|
|
1336
|
+
* expires or signs out — not a fresh confirmation per Apply. Whoever uses that verified session (a browser left signed
|
|
1337
|
+
* in, its cookie) confirms the same way. A public link never qualifies (evaluateWebAuthority refuses it). General
|
|
1338
|
+
* still hears it: a notice, not a step.
|
|
1339
|
+
*/
|
|
1340
|
+
settingsSelfConfirmation(store, req, id) {
|
|
1341
|
+
const auth = authorizeSession(req, this.webToken, this.webSessions, { touch: false });
|
|
1342
|
+
if (auth.kind !== "ok")
|
|
1343
|
+
return null;
|
|
1344
|
+
const record = auth.session, gateway = !!gatewayRequestContext(req), peer = classifyWebPeer(req);
|
|
1345
|
+
const verified = () => {
|
|
1346
|
+
const now = this.webAuthority({ session: record, gateway, peer });
|
|
1347
|
+
// A refusal carries no authority (kind "none"); the handle pins the session, and with it its one account.
|
|
1348
|
+
return now.authority.kind === "web-verified" && now.authority.actor && now.authority.session?.handle === record.handle ? now.authority.actor : null;
|
|
1349
|
+
};
|
|
1350
|
+
const actor = verified();
|
|
1351
|
+
if (!actor)
|
|
1352
|
+
return null;
|
|
1353
|
+
const chat = this.settingsChatAuthority(store, id);
|
|
1354
|
+
if (!chat)
|
|
1355
|
+
return null;
|
|
1356
|
+
const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
1357
|
+
const own = channels.filter(ch => (ch.id ?? ch.type) === actor.adapter_id);
|
|
1358
|
+
if (own.length !== 1 || own[0].type !== chat.platform || !chat.admin(actor.user_id))
|
|
1359
|
+
return null;
|
|
1360
|
+
const affected = [...store.affectedConnections(id)]; // read now: a finished change no longer has them
|
|
1361
|
+
if (!this.settingsNoticeGenerals(affected, chat.platform).length)
|
|
1362
|
+
return null;
|
|
1363
|
+
const name = this.webPerson({ adapterId: actor.adapter_id, userId: actor.user_id }).name;
|
|
1364
|
+
return {
|
|
1365
|
+
label: `${name} (web-verified)`,
|
|
1366
|
+
// admin() asks chat.current() too; a General a card could go to must still be there (else the host CLI's).
|
|
1367
|
+
current: () => !!verified() && chat.admin(actor.user_id) && this.settingsNoticeGenerals(affected, chat.platform).length > 0,
|
|
1368
|
+
done: view => this.settingsWebConfirmedNotice(view, affected, name, chat.platform),
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
/** #2170: the Generals a chat card could be posted in (promptSettingsChange's rule): connected, on the platform, unaffected. */
|
|
1372
|
+
settingsNoticeGenerals(affected, platform) {
|
|
1373
|
+
const excluded = new Set(affected), found = [];
|
|
1374
|
+
for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
|
|
1375
|
+
if (!config.general_topic || !this.daemons.get(name))
|
|
1376
|
+
continue;
|
|
1377
|
+
const adapterId = this.getInstanceAdapterId(name), adapter = this.getAdapterForInstance(name);
|
|
1378
|
+
if (!adapterId || !adapter || excluded.has(adapterId) || adapter.type !== platform || this.adapterState.get(adapterId)?.status !== "connected")
|
|
1379
|
+
continue;
|
|
1380
|
+
const chatId = this.getGroupIdForInstance(name), topic = String(config.topic_id ?? "");
|
|
1381
|
+
if (chatId && topic)
|
|
1382
|
+
found.push({ adapterId, adapter, chatId, topic });
|
|
1383
|
+
}
|
|
1384
|
+
return found;
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* #2170: General's notice of a change confirmed from the web — the first General a chat card could have been posted
|
|
1388
|
+
* in, plain text, no mentions. Information only: nothing waits on it.
|
|
1389
|
+
*/
|
|
1390
|
+
async settingsWebConfirmedNotice(view, affected, who, platform) {
|
|
1391
|
+
const outcome = view.outcome ? view.outcome.message : view.state;
|
|
1392
|
+
const text = `✅ Settings change confirmed from the web by ${who} (web-verified)\nSource: ${view.source}\nRequester: ${view.requested_by}\n${view.summary.join("\n")}\nOutcome: ${outcome}`;
|
|
1393
|
+
for (const { adapterId, adapter, chatId, topic } of this.settingsNoticeGenerals(affected, platform)) {
|
|
1394
|
+
try {
|
|
1395
|
+
await adapter.sendText(chatId, text, { threadId: topic, allowedMentions: { parse: [] } });
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
catch {
|
|
1399
|
+
this.logger.info({ id: view.id, adapterId }, "Settings web-confirmation notice unavailable; trying another General");
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
this.logger.info({ id: view.id }, "Settings change confirmed from the web: no General reachable for the notice");
|
|
1403
|
+
}
|
|
1198
1404
|
/** Exact configured effect owners; this does not depend on prompt location. */
|
|
1199
1405
|
settingsChatAuthority(store, id) {
|
|
1200
1406
|
const scope = store.authorityOf(id);
|
|
@@ -1349,9 +1555,26 @@ export class FleetManager {
|
|
|
1349
1555
|
sseHeartbeatMs = SSE_HEARTBEAT_MS;
|
|
1350
1556
|
/** The dashboard's login codes. Memory only: a code that outlives the process is a code nobody can prove was not copied. */
|
|
1351
1557
|
webLoginCodes = null;
|
|
1558
|
+
/**
|
|
1559
|
+
* #1920 step 4: guests' codes, apart from the admins' — one per account, so no guest's `/web` withdraws another's or
|
|
1560
|
+
* an admin's code (#2093 review), at most 32 at once. Local sign-in only for now (audience "local").
|
|
1561
|
+
*/
|
|
1562
|
+
webGuestLoginCodes = null;
|
|
1563
|
+
/** #1920 step 4: when each account last got a guest code (monotonic) — one a minute, so `/web` is no DM flood. */
|
|
1564
|
+
guestCodeAskedAt = new Map();
|
|
1565
|
+
/** #2093 review: an account's guest /web in progress — one at a time, so two at once cannot both pass the minute. */
|
|
1566
|
+
guestCodeInFlight = new Set();
|
|
1567
|
+
/**
|
|
1568
|
+
* #2093 review: revokes a guest /web in progress must see — every web session's (`/web revoke` from an admin) bumps
|
|
1569
|
+
* the global count, an account's own bumps its entry — checked before the code is issued and while it is delivered.
|
|
1570
|
+
*/
|
|
1571
|
+
guestRevokes = 0;
|
|
1572
|
+
guestAccountRevokes = new Map();
|
|
1352
1573
|
/** #1570: browsers that may ask the sign-in page for a fresh code, and how often they may. */
|
|
1353
1574
|
returningDevices = null;
|
|
1354
1575
|
codeRequestLimiter = new CodeRequestLimiter();
|
|
1576
|
+
/** #1920 step 4b: guests' device code requests, limited apart — guests never use up the admins' fleet-wide budget. */
|
|
1577
|
+
guestCodeRequestLimiter = new CodeRequestLimiter();
|
|
1355
1578
|
/**
|
|
1356
1579
|
* Set while a Settings apply job is driving the reconcile. The reconcile
|
|
1357
1580
|
* stays the single doer; it just says out loud what it is doing to whom, so
|
|
@@ -1499,6 +1722,7 @@ export class FleetManager {
|
|
|
1499
1722
|
meta: {
|
|
1500
1723
|
chat_id: set.chatId, message_id: messageId, user: by.username, user_id: by.userId, ts,
|
|
1501
1724
|
thread_id: set.threadId, adapter_id: inbound.adapterId, source: inbound.source,
|
|
1725
|
+
...(web && by.guest ? { web_guest: "1" } : {}), // #1920: "[user:… via web (guest), id:…]", as /ui/send
|
|
1502
1726
|
...reactions.meta,
|
|
1503
1727
|
},
|
|
1504
1728
|
}, by.humanOrigin, button.label)) !== false;
|
|
@@ -1519,6 +1743,9 @@ export class FleetManager {
|
|
|
1519
1743
|
this.emitSseEvent("message", {
|
|
1520
1744
|
instance: set.instance, sender: by.username, role: "user", text: content, ts, ...(web ? { messageId } : {}),
|
|
1521
1745
|
click: { label: button.label, of: set.id },
|
|
1746
|
+
// #1920: a platform click is its clicker's; it is the instance's channel's only when its buttons are there (the
|
|
1747
|
+
// same rule as a reply's: a set sent to another topic or a DM is not). A web click is the web's.
|
|
1748
|
+
...(web ? {} : { from: webChatFrom(set.adapterId, by.userId), ...(this.clickInOwnChannel(set) ? { channel: true } : {}) }),
|
|
1522
1749
|
});
|
|
1523
1750
|
return true;
|
|
1524
1751
|
}
|
|
@@ -1569,10 +1796,25 @@ export class FleetManager {
|
|
|
1569
1796
|
: t(`delivery.fleet_${row.state}`, row.targetInstance, row.operationId, row.deliveryId);
|
|
1570
1797
|
this.logger.error({ deliveryId: row.deliveryId, operationId: row.operationId, target: row.targetInstance, state: row.state,
|
|
1571
1798
|
cause: row.lastError, ...(silent ? { silentSchedule: true } : {}) }, notice);
|
|
1572
|
-
|
|
1799
|
+
// A self-scheduled silent heartbeat stays log-only, including the
|
|
1800
|
+
// operator alert; another source still receives the normal notice.
|
|
1801
|
+
if (!silent || row.sourceInstance !== row.targetInstance)
|
|
1802
|
+
this.notifyFleetError(notice);
|
|
1573
1803
|
}
|
|
1574
1804
|
}
|
|
1575
1805
|
});
|
|
1806
|
+
// #1918: the org canvas's traffic — from the row this listener's events already name, never a probe of its own.
|
|
1807
|
+
// The row as it is now (it has already changed when the event comes): its state, not the event's, is the truth.
|
|
1808
|
+
outbox.on("state", (event) => {
|
|
1809
|
+
const row = event.deliveryId ? outbox.get(event.deliveryId) : undefined;
|
|
1810
|
+
if (row)
|
|
1811
|
+
this.publishTraffic(row);
|
|
1812
|
+
});
|
|
1813
|
+
outbox.on("consumed", (event) => {
|
|
1814
|
+
const row = event.deliveryId ? outbox.get(event.deliveryId) : undefined;
|
|
1815
|
+
if (row)
|
|
1816
|
+
this.publishTraffic(row, "consumed");
|
|
1817
|
+
});
|
|
1576
1818
|
outbox.on("generation_recovered", () => this.scheduleDeliveryOutboxPump());
|
|
1577
1819
|
this.replyObligationTimer = setInterval(() => measureSyncWork("fleet.replyObligationSweep", () => this.sweepReplyObligations()), REPLY_OBLIGATION_SWEEP_MS);
|
|
1578
1820
|
this.replyObligationTimer.unref?.();
|
|
@@ -1594,6 +1836,52 @@ export class FleetManager {
|
|
|
1594
1836
|
});
|
|
1595
1837
|
this.scheduleDeliveryOutboxPump();
|
|
1596
1838
|
}
|
|
1839
|
+
/** #1918: cross-instance traffic, as the org canvas draws it (fleet-traffic.ts). */
|
|
1840
|
+
trafficLog = new TrafficLog();
|
|
1841
|
+
delegations = new Delegations();
|
|
1842
|
+
trafficMemo = null;
|
|
1843
|
+
/**
|
|
1844
|
+
* One delivery row's traffic item, kept for the snapshot and sent to the open pages when it is news; and the
|
|
1845
|
+
* delegation it opens or closes (a task and its report), sent as "delegation".
|
|
1846
|
+
*/
|
|
1847
|
+
publishTraffic(row, state) {
|
|
1848
|
+
try {
|
|
1849
|
+
const item = trafficItem(row, this.fleetConfig, new Date().toISOString(), state);
|
|
1850
|
+
const news = item ? this.trafficLog.note(item) : null;
|
|
1851
|
+
const work = news ? this.delegations.note(row, news) : [];
|
|
1852
|
+
if (news || work.length)
|
|
1853
|
+
this.trafficMemo = null;
|
|
1854
|
+
if (news)
|
|
1855
|
+
this.emitSseEvent("traffic", news);
|
|
1856
|
+
// Each delegation as its two instances are seen now: one that expires or is pushed out may be older than a change
|
|
1857
|
+
// to their visibility (the incoming row's own item was checked; these can be anyone's).
|
|
1858
|
+
const visible = (name) => crossInstanceVisibility(this.fleetConfig, name) !== "hidden";
|
|
1859
|
+
for (const d of work)
|
|
1860
|
+
if (visible(d.from) && visible(d.to))
|
|
1861
|
+
this.emitSseEvent("delegation", d);
|
|
1862
|
+
}
|
|
1863
|
+
catch (err) {
|
|
1864
|
+
this.logger.debug({ err }, "traffic: an item was not published");
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
/**
|
|
1868
|
+
* GET /ui/traffic: what is in flight now (the outbox's pending rows) and what finished in the last two minutes, at
|
|
1869
|
+
* most TRAFFIC_SNAPSHOT_MAX items. Worked out at most once every two seconds, so the public link's polling (or any
|
|
1870
|
+
* number of pages) costs one indexed read per two seconds. Visibility is applied as of now.
|
|
1871
|
+
*/
|
|
1872
|
+
trafficSnapshot() {
|
|
1873
|
+
const nowMs = performance.now();
|
|
1874
|
+
if (this.trafficMemo && nowMs - this.trafficMemo.atMs < 2_000)
|
|
1875
|
+
return this.trafficMemo.value;
|
|
1876
|
+
const now = new Date().toISOString();
|
|
1877
|
+
const pending = (this.deliveryOutbox?.listPending() ?? [])
|
|
1878
|
+
.map((r) => trafficItem(r, this.fleetConfig, r.createdAt ?? now)).filter((i) => i !== null);
|
|
1879
|
+
const visible = (name) => crossInstanceVisibility(this.fleetConfig, name) !== "hidden";
|
|
1880
|
+
const items = this.trafficLog.snapshot(pending).filter((i) => visible(i.from) && visible(i.to));
|
|
1881
|
+
const delegations = this.delegations.snapshot().filter((d) => visible(d.from) && visible(d.to));
|
|
1882
|
+
this.trafficMemo = { atMs: nowMs, value: { now, items, delegations } };
|
|
1883
|
+
return this.trafficMemo.value;
|
|
1884
|
+
}
|
|
1597
1885
|
/** Synchronous SQLite admission; the caller may acknowledge only after this returns. */
|
|
1598
1886
|
admitDurableDelivery(input) {
|
|
1599
1887
|
const outbox = this.deliveryOutbox;
|
|
@@ -1606,6 +1894,8 @@ export class FleetManager {
|
|
|
1606
1894
|
}
|
|
1607
1895
|
const sourceKey = `mcp:${input.sourceInstance}:${input.operationId}:${input.targetInstance}:${input.targetSession ?? ""}:${input.kind}`;
|
|
1608
1896
|
const admitted = outbox.admit({ ...input, sourceDaemonBootId, sourceKey });
|
|
1897
|
+
if (admitted.inserted)
|
|
1898
|
+
this.publishTraffic(admitted.delivery);
|
|
1609
1899
|
if (admitted.inserted)
|
|
1610
1900
|
this.logger.info({
|
|
1611
1901
|
deliveryId: admitted.delivery.deliveryId,
|
|
@@ -1628,10 +1918,18 @@ export class FleetManager {
|
|
|
1628
1918
|
queryDurableDeliveryStatus(callerInstance, selector) {
|
|
1629
1919
|
this.ensureDeliveryOutbox();
|
|
1630
1920
|
const page = this.deliveryOutbox.queryStatusForInstance(callerInstance, selector);
|
|
1921
|
+
// #1939 §10: a human receipt's authority as it reads now (switch, epoch, loopback_only).
|
|
1922
|
+
const state = page.items.some(item => item.authority) ? this.webAuthorityState() : null;
|
|
1923
|
+
const human = (item) => {
|
|
1924
|
+
if (!item.authority || !state)
|
|
1925
|
+
return item;
|
|
1926
|
+
const authority = authorityNow(item.authority, state);
|
|
1927
|
+
return { ...item, authority, ...(authority.downgraded ? { source_instance: "human:web:unknown" } : {}) };
|
|
1928
|
+
};
|
|
1631
1929
|
// #1641: a sender that sees only `queued` cannot tell "next in line" from "the target is stuck": say which.
|
|
1632
1930
|
return {
|
|
1633
1931
|
...page,
|
|
1634
|
-
items: page.items.map(item => (item.state === "queued" || item.state === "retry_wait")
|
|
1932
|
+
items: page.items.map(human).map(item => (item.state === "queued" || item.state === "retry_wait")
|
|
1635
1933
|
? { ...item, waiting_reason: this.deliveryWaitingReason(item.target_instance) }
|
|
1636
1934
|
: item),
|
|
1637
1935
|
};
|
|
@@ -1979,17 +2277,58 @@ export class FleetManager {
|
|
|
1979
2277
|
this.logger.info({ hostMemory: snapshot }, "Host memory recovered");
|
|
1980
2278
|
}
|
|
1981
2279
|
this.memoryLogLevel = snapshot.level;
|
|
2280
|
+
if (this.memoryTop) {
|
|
2281
|
+
this.memoryTop = null;
|
|
2282
|
+
this.memoryTopScan.loggedAt = null;
|
|
2283
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
2284
|
+
}
|
|
1982
2285
|
return;
|
|
1983
2286
|
}
|
|
2287
|
+
if (snapshot.level === "elevated" || snapshot.level === "critical")
|
|
2288
|
+
this.refreshMemoryTop(snapshot);
|
|
1984
2289
|
if (changed || this.memoryLogAt === null || now - this.memoryLogAt >= 10 * 60_000) {
|
|
1985
2290
|
this.memoryLogAt = now;
|
|
1986
|
-
this.logger.warn({ hostMemory: snapshot }, snapshot.level === "critical"
|
|
2291
|
+
this.logger.warn({ hostMemory: snapshot, ...(this.memoryTop ? { topProcesses: this.memoryTop.processes } : {}) }, snapshot.level === "critical"
|
|
1987
2292
|
? "Host memory critical — deferring new CLI spawns"
|
|
1988
2293
|
: snapshot.level === "elevated" ? "Host memory pressure — slowing new CLI spawns"
|
|
1989
2294
|
: "Host memory sample unavailable — slowing new CLI spawns");
|
|
1990
2295
|
}
|
|
1991
2296
|
this.memoryLogLevel = snapshot.level;
|
|
1992
2297
|
}
|
|
2298
|
+
/**
|
|
2299
|
+
* #2015: rescan the largest processes (at most once a minute, one at a time). The first result of an episode, and
|
|
2300
|
+
* then at most every 10 minutes, is logged with the pressure; /health and the dashboard read the latest. Never chat.
|
|
2301
|
+
*/
|
|
2302
|
+
refreshMemoryTop(snapshot) {
|
|
2303
|
+
const scan = this.memoryTopScan;
|
|
2304
|
+
const now = performance.now();
|
|
2305
|
+
if (scan.inFlight || (scan.at !== null && now - scan.at < 60_000))
|
|
2306
|
+
return;
|
|
2307
|
+
scan.inFlight = true;
|
|
2308
|
+
scan.at = now;
|
|
2309
|
+
void (async () => {
|
|
2310
|
+
const session = getTmuxSession();
|
|
2311
|
+
const [tmuxServerPid, panes] = this.tmuxSessionEnsured
|
|
2312
|
+
? await Promise.all([TmuxManager.getServerPid(session), TmuxManager.listPanePids(session)])
|
|
2313
|
+
: [null, []];
|
|
2314
|
+
const processes = await this.memoryHogScan({
|
|
2315
|
+
fleetPid: process.pid, tmuxServerPid, panes,
|
|
2316
|
+
isInstance: name => this.daemons.has(name) || Object.hasOwn(this.fleetConfig?.instances ?? {}, name),
|
|
2317
|
+
}, 3);
|
|
2318
|
+
// Pressure may have ended while the scan ran: nothing is kept then.
|
|
2319
|
+
if (this.shuttingDown || processes.length === 0 || this.hostMemoryIndicator() === null)
|
|
2320
|
+
return;
|
|
2321
|
+
const before = JSON.stringify(this.memoryTop?.processes ?? null);
|
|
2322
|
+
this.memoryTop = { at: new Date().toISOString(), processes };
|
|
2323
|
+
if (scan.loggedAt === null || performance.now() - scan.loggedAt >= 10 * 60_000) {
|
|
2324
|
+
scan.loggedAt = performance.now();
|
|
2325
|
+
this.logger.warn({ hostMemory: snapshot, topProcesses: processes }, "Host memory pressure — largest processes in the fleet");
|
|
2326
|
+
}
|
|
2327
|
+
if (JSON.stringify(processes) !== before)
|
|
2328
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
2329
|
+
})().catch(err => this.logger.debug({ err }, "Largest-process scan failed"))
|
|
2330
|
+
.finally(() => { scan.inFlight = false; });
|
|
2331
|
+
}
|
|
1993
2332
|
/** Wire the one fleet-wide storm into notification and recovery surfaces. */
|
|
1994
2333
|
bindStormWindowEvents() {
|
|
1995
2334
|
this.stormWindow.on("opened", (snapshot) => {
|
|
@@ -2311,6 +2650,7 @@ export class FleetManager {
|
|
|
2311
2650
|
this.fleetConfig = loaded;
|
|
2312
2651
|
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
2313
2652
|
this.warnAboutRemovedLoginMode(loaded);
|
|
2653
|
+
this.enforceGuestSwitch();
|
|
2314
2654
|
return this.fleetConfig;
|
|
2315
2655
|
}
|
|
2316
2656
|
relayLoginModeWarned = false;
|
|
@@ -3436,7 +3776,8 @@ export class FleetManager {
|
|
|
3436
3776
|
* for `instance` (#1320 part B). Per entry: the channel's own adapter, no
|
|
3437
3777
|
* thread, mention suppression on. Returns the number of channels posted
|
|
3438
3778
|
* to. Failures are per-entry warn-and-continue — a failed echo never fails
|
|
3439
|
-
* the web send.
|
|
3779
|
+
* the web send. Only entries classicWebEchoOn admits are touched (#2054: unset follows web.echo_to_channel; an explicit
|
|
3780
|
+
* `web_echo: false` never echoes).
|
|
3440
3781
|
*
|
|
3441
3782
|
* Every copy is fenced at start time: an entry whose explicit world is
|
|
3442
3783
|
* gone is skipped (only legacy adapterId-less entries fall back to the
|
|
@@ -3447,6 +3788,15 @@ export class FleetManager {
|
|
|
3447
3788
|
* already in flight may still land late — the contract permits that.
|
|
3448
3789
|
*/
|
|
3449
3790
|
/** `sender` (#1827): the web person's name, as the room log records them ("web-user" when none). */
|
|
3791
|
+
/**
|
|
3792
|
+
* #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
|
|
3793
|
+
* to the guests' streams only: an admin's page already has it, and nothing about it changed for them.
|
|
3794
|
+
*/
|
|
3795
|
+
webMessageInChannel(instance, messageId) {
|
|
3796
|
+
const m = this.webChatHistory.markChannel(instance, messageId);
|
|
3797
|
+
if (m)
|
|
3798
|
+
broadcastToGuests(this.sseClients, "message", m, (err) => this.logger.debug({ err }, "SSE client write failed; evicting"));
|
|
3799
|
+
}
|
|
3450
3800
|
async sendClassicWebEcho(instance, text, sender = WEB_NAME_FALLBACK) {
|
|
3451
3801
|
const guard = this.webChannelEchoGuards.get(instance);
|
|
3452
3802
|
const handled = new Set();
|
|
@@ -3463,7 +3813,7 @@ export class FleetManager {
|
|
|
3463
3813
|
}
|
|
3464
3814
|
}
|
|
3465
3815
|
const entry = (this.classicChannels?.getAll() ?? [])
|
|
3466
|
-
.find(candidate => candidate.instanceName === instance && candidate.webEcho
|
|
3816
|
+
.find(candidate => candidate.instanceName === instance && classicWebEchoOn(candidate.webEcho, this.fleetConfig?.web?.echo_to_channel)
|
|
3467
3817
|
&& !handled.has(`${candidate.channelId}#${candidate.adapterId ?? ""}`));
|
|
3468
3818
|
if (!entry)
|
|
3469
3819
|
break;
|
|
@@ -3663,12 +4013,31 @@ export class FleetManager {
|
|
|
3663
4013
|
}
|
|
3664
4014
|
/** Evidence-only admission. Bots/system/agent payloads have no trusted origin. */
|
|
3665
4015
|
humanReceiptPayload(instanceName, payload, origin, originalBody) {
|
|
4016
|
+
// #1939 S3: "via web (verified)" comes only from the receipt written here — never from a caller's meta.
|
|
4017
|
+
const given = payload.meta;
|
|
4018
|
+
if (given && "web_verified" in given) {
|
|
4019
|
+
const { web_verified: _claimed, ...rest } = given;
|
|
4020
|
+
payload = { ...payload, meta: rest };
|
|
4021
|
+
}
|
|
3666
4022
|
if (!origin || !this.deliveryOutbox)
|
|
3667
4023
|
return payload;
|
|
3668
4024
|
const row = this.deliveryOutbox.createHumanReceipt({ targetInstance: instanceName, originalBody,
|
|
3669
4025
|
origin: { ...origin }, inputKind: String(payload.type), managerBootId: this.managerBootId });
|
|
4026
|
+
// #1939 §7: each ordinary verified web message, for the S1 beta to watch. No text, cookie or session id: the handle.
|
|
4027
|
+
const authority = origin.authority;
|
|
4028
|
+
if (authority?.kind === "web-verified" && authority.actor && authority.session) {
|
|
4029
|
+
try {
|
|
4030
|
+
this.eventLog?.insert(instanceName, "web_authority", { receipt_id: row.receipt_id, session: authority.session.handle,
|
|
4031
|
+
peer: authority.session.peer, surface: "local", actor: `${authority.actor.adapter_id}:${authority.actor.user_id}`,
|
|
4032
|
+
input_kind: String(payload.type), outcome: "web-verified", authority_epoch: authority.authority_epoch });
|
|
4033
|
+
}
|
|
4034
|
+
catch (err) {
|
|
4035
|
+
this.logger.warn({ err, instanceName }, "Could not record web authority");
|
|
4036
|
+
}
|
|
4037
|
+
}
|
|
4038
|
+
const verified = origin.platform === "web" && authority?.kind === "web-verified";
|
|
3670
4039
|
return { ...payload, meta: { ...(payload.meta ?? {}),
|
|
3671
|
-
human_receipt_id: row.receipt_id, verification_id: row.message_id } };
|
|
4040
|
+
human_receipt_id: row.receipt_id, verification_id: row.message_id, ...(verified ? { web_verified: "1" } : {}) } };
|
|
3672
4041
|
}
|
|
3673
4042
|
/** Bind at the actual IPC send, after all wake/reconnect waits. No human retry lane. */
|
|
3674
4043
|
bindHumanReceiptPayload(instanceName, payload) {
|
|
@@ -3882,6 +4251,14 @@ export class FleetManager {
|
|
|
3882
4251
|
return;
|
|
3883
4252
|
}
|
|
3884
4253
|
const scope = door.scope;
|
|
4254
|
+
// #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)
|
|
4255
|
+
// — before the table, which keeps /web for fleet admins. Per account: it touches no instance binding.
|
|
4256
|
+
// Decided synchronously, so an admin's /web still reaches the table with no await after the door (#1399).
|
|
4257
|
+
if (data.command === "dashboard" && this.isGuestWebAsk(adapterId, data.userId)) {
|
|
4258
|
+
await this.guestWebCommand({ adapterId, userId: data.userId, username: data.username,
|
|
4259
|
+
arg: String(data.options?.action ?? "").trim().toLowerCase(), replyHere: text => data.respond(text), replyPrivately: text => data.respond(text) });
|
|
4260
|
+
return;
|
|
4261
|
+
}
|
|
3885
4262
|
const spec = commandSpec(data.command);
|
|
3886
4263
|
if (!spec) {
|
|
3887
4264
|
this.logger.info({ command: data.command, adapterId }, "Slash command is not in the command table");
|
|
@@ -4516,6 +4893,22 @@ export class FleetManager {
|
|
|
4516
4893
|
this.logger.warn({ err, name, backend }, "Failed to sync worker skills — continuing startup");
|
|
4517
4894
|
}
|
|
4518
4895
|
}
|
|
4896
|
+
else if (kind === "classic") {
|
|
4897
|
+
// #2163: a Classic bot gets only the skills marked `classic` (its own look, the dashboard) — never the fleet's
|
|
4898
|
+
// coordination playbooks (#557 kept those out of end-user bots) — and only into a workspace AgEnD manages: the
|
|
4899
|
+
// room's own folder under <AGEND_HOME>/workspaces/. Anywhere else, nothing is written; `agend-agent skill <name>`
|
|
4900
|
+
// reads any bundled skill on demand.
|
|
4901
|
+
try {
|
|
4902
|
+
const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
|
|
4903
|
+
if (insideManagedWorkspaces(skillsWorkDir))
|
|
4904
|
+
measureSyncWork("fleet.classicSkills", () => this.syncRoleSkills(skillsWorkDir, backend, "classic"));
|
|
4905
|
+
else
|
|
4906
|
+
this.logger.debug({ name, skillsWorkDir }, "Classic workspace is not AgEnD-managed — no skills published");
|
|
4907
|
+
}
|
|
4908
|
+
catch (err) {
|
|
4909
|
+
this.logger.warn({ err, name, backend }, "Failed to sync classic skills — continuing startup");
|
|
4910
|
+
}
|
|
4911
|
+
}
|
|
4519
4912
|
const receipt = execution ? this.classicSettingsOwners.get(execution) : undefined;
|
|
4520
4913
|
const identity = { kind, backend, model: this.resolveInstanceModel(name).display };
|
|
4521
4914
|
if (receipt) {
|
|
@@ -4554,8 +4947,8 @@ export class FleetManager {
|
|
|
4554
4947
|
}
|
|
4555
4948
|
/**
|
|
4556
4949
|
* Start instances with configurable concurrency and stagger delay.
|
|
4557
|
-
* Instances sharing the same working_directory are serialized
|
|
4558
|
-
*
|
|
4950
|
+
* Instances sharing the same working_directory are serialized to avoid
|
|
4951
|
+
* config file races. Stagger applies between outer lifecycle admissions.
|
|
4559
4952
|
* TODO: per-instance startup timeout (existing issue, not introduced here)
|
|
4560
4953
|
*/
|
|
4561
4954
|
async startInstancesWithConcurrency(entries, topicMode, onReady) {
|
|
@@ -4577,15 +4970,30 @@ export class FleetManager {
|
|
|
4577
4970
|
totalInstances: runnableEntries.length,
|
|
4578
4971
|
}, "Adaptive startup concurrency");
|
|
4579
4972
|
}
|
|
4973
|
+
const startedAt = performance.now();
|
|
4974
|
+
let active = 0, peakConcurrentStarts = 0, readyInstances = 0;
|
|
4580
4975
|
await Promise.all(runnableEntries.map(([name, config]) => this.spawnGate.run({
|
|
4581
4976
|
instanceName: name,
|
|
4582
4977
|
workingDirectory: config.working_directory,
|
|
4583
4978
|
reason: "startup",
|
|
4584
4979
|
stage: "lifecycle",
|
|
4585
4980
|
}, async () => {
|
|
4586
|
-
|
|
4587
|
-
|
|
4981
|
+
peakConcurrentStarts = Math.max(peakConcurrentStarts, ++active);
|
|
4982
|
+
try {
|
|
4983
|
+
if (await this.startInstanceUnattended(name, config, topicMode, "instance")) {
|
|
4984
|
+
readyInstances++;
|
|
4985
|
+
onReady?.(name);
|
|
4986
|
+
}
|
|
4987
|
+
}
|
|
4988
|
+
finally {
|
|
4989
|
+
active--;
|
|
4990
|
+
}
|
|
4588
4991
|
})));
|
|
4992
|
+
this.logger.info({
|
|
4993
|
+
totalInstances: runnableEntries.length, readyInstances,
|
|
4994
|
+
elapsedSeconds: Math.round((performance.now() - startedAt) / 100) / 10,
|
|
4995
|
+
peakConcurrentStarts,
|
|
4996
|
+
}, "Fleet startup batch completed");
|
|
4589
4997
|
}
|
|
4590
4998
|
/**
|
|
4591
4999
|
* Start from an UNATTENDED path (fleet startup, full restart, config
|
|
@@ -5011,34 +5419,18 @@ export class FleetManager {
|
|
|
5011
5419
|
return this.adapterState.get(adapterId)?.status === "connected" ? adapter : undefined;
|
|
5012
5420
|
}
|
|
5013
5421
|
/** Last-resort completion after RestartProgress could not deliver to its
|
|
5014
|
-
* adopted target
|
|
5422
|
+
* adopted target (or had no message: a small fleet, a failed first post). It
|
|
5423
|
+
* never wakes a not-ready gateway. #2213: an outage at fleet start no longer
|
|
5424
|
+
* loses it — bounded at startup, then retried in the background once the
|
|
5425
|
+
* adapter is ready again, never posted twice after an uncertain attempt. */
|
|
5015
5426
|
async sendFleetStartCompletionFallback(chatId, text, threadId) {
|
|
5016
5427
|
const adapterId = this.getPrimaryAdapterId();
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
this.logger.error({ adapterId }, "Fleet start completion fallback skipped because the primary adapter is not ready");
|
|
5428
|
+
if (!adapterId) {
|
|
5429
|
+
this.logger.error({ adapterId }, "Fleet start completion fallback skipped because there is no primary adapter");
|
|
5020
5430
|
return false;
|
|
5021
5431
|
}
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
timer = setTimeout(() => resolve({ status: "timeout" }), RESTART_PROGRESS_TERMINAL_TIMEOUT_MS);
|
|
5025
|
-
timer.unref?.();
|
|
5026
|
-
});
|
|
5027
|
-
const delivery = Promise.resolve()
|
|
5028
|
-
.then(() => adapter.sendText(chatId, text, { threadId }))
|
|
5029
|
-
.then(() => ({ status: "sent" }), err => ({ status: "failed", err }));
|
|
5030
|
-
const result = await Promise.race([delivery, timeout]);
|
|
5031
|
-
if (timer)
|
|
5032
|
-
clearTimeout(timer);
|
|
5033
|
-
if (result.status === "sent")
|
|
5034
|
-
return true;
|
|
5035
|
-
if (result.status === "failed") {
|
|
5036
|
-
this.logger.error({ err: result.err }, "Failed to send fleet start completion fallback");
|
|
5037
|
-
}
|
|
5038
|
-
else {
|
|
5039
|
-
this.logger.error({ timeout_ms: RESTART_PROGRESS_TERMINAL_TIMEOUT_MS }, "Timed out sending fleet start completion fallback");
|
|
5040
|
-
}
|
|
5041
|
-
return false;
|
|
5432
|
+
const poster = new RestartProgress(0, Date.now(), this.logger);
|
|
5433
|
+
return poster.postCompletion({ resolveAdapter: () => this.readyProgressAdapter(adapterId), chatId, threadId }, text);
|
|
5042
5434
|
}
|
|
5043
5435
|
async stopInstance(name, transition) {
|
|
5044
5436
|
this.explicitStopGeneration.set(name, (this.explicitStopGeneration.get(name) ?? 0) + 1);
|
|
@@ -5326,10 +5718,14 @@ export class FleetManager {
|
|
|
5326
5718
|
// #1620: fleet.yaml's web.session.local, read at every check — a Settings save or a reload applies at once
|
|
5327
5719
|
// (shortening ends the sessions it now outlives). The public link's policy stays fixed.
|
|
5328
5720
|
localPolicy: () => localSessionPolicy(this.fleetConfig?.web?.session?.local),
|
|
5721
|
+
// #1920 §5.4: a guest's, from web.guests.session (never longer than the surface's own).
|
|
5722
|
+
guestPolicy: () => guestSessionPolicy(this.fleetConfig?.web?.guests?.session),
|
|
5329
5723
|
});
|
|
5724
|
+
this.enforceGuestSwitch(); // #1920: guests off while the fleet was down
|
|
5330
5725
|
}
|
|
5331
5726
|
if (!this.returningDevices) {
|
|
5332
5727
|
this.returningDevices = new ReturningDevices({ dataDir: this.dataDir, onWarn: message => this.logger.warn(message) });
|
|
5728
|
+
this.enforceGuestSwitch(); // #1920: guests' devices too, if guests went off while down
|
|
5333
5729
|
}
|
|
5334
5730
|
if (!this.webLoginCodes) {
|
|
5335
5731
|
this.webLoginCodes = new WebLoginCodes({
|
|
@@ -5341,6 +5737,16 @@ export class FleetManager {
|
|
|
5341
5737
|
},
|
|
5342
5738
|
});
|
|
5343
5739
|
}
|
|
5740
|
+
if (!this.webGuestLoginCodes) {
|
|
5741
|
+
this.webGuestLoginCodes = new GuestLoginCodes({
|
|
5742
|
+
onEvent: event => {
|
|
5743
|
+
if (event === "burned")
|
|
5744
|
+
this.logger.warn("A guest web login code was used up by wrong attempts");
|
|
5745
|
+
else if (event === "breaker-open")
|
|
5746
|
+
this.logger.warn("Guest web sign-in paused: too many wrong login codes");
|
|
5747
|
+
},
|
|
5748
|
+
});
|
|
5749
|
+
}
|
|
5344
5750
|
}
|
|
5345
5751
|
/**
|
|
5346
5752
|
* A single-use login code for the dashboard, for a channel only the operator
|
|
@@ -5464,6 +5870,7 @@ export class FleetManager {
|
|
|
5464
5870
|
dispatch: (req, res) => this.dispatchWebHttp(req, res, this.fleetConfig?.health_port ?? 19280) }),
|
|
5465
5871
|
revoke: id => {
|
|
5466
5872
|
this.webLoginCodes?.revokeAudience(id);
|
|
5873
|
+
this.webGuestLoginCodes?.revokeAudience(id);
|
|
5467
5874
|
const result = this.webSessions?.revokeExposure(id);
|
|
5468
5875
|
if (result && !result.durable)
|
|
5469
5876
|
this.logger.warn("Public session revocation could not be saved; exposure remains closed");
|
|
@@ -5495,6 +5902,24 @@ export class FleetManager {
|
|
|
5495
5902
|
if (!current() || !this.publicOwnerCurrent(info.owner))
|
|
5496
5903
|
throw new Error("public owner changed");
|
|
5497
5904
|
}
|
|
5905
|
+
/**
|
|
5906
|
+
* #1920 §5.5 (step 4b): a guest signing in through the public link — said in the General of the admin who opened it
|
|
5907
|
+
* (who, how many agents), awaited as an admin's public sign-in is, while that link is the open one, its opener still
|
|
5908
|
+
* current, and the account still an admitted guest. A failure refuses the sign-in.
|
|
5909
|
+
*/
|
|
5910
|
+
async confirmGuestPublicWebLogin(info, current) {
|
|
5911
|
+
const admitted = () => {
|
|
5912
|
+
const open = this.publicWebLink?.openSignin();
|
|
5913
|
+
return current() && open && open.exposureId === info.exposureId && this.publicOwnerCurrent(open.owner) && this.guestSignInAllowed(info.account) ? open : null;
|
|
5914
|
+
};
|
|
5915
|
+
const open = admitted();
|
|
5916
|
+
if (!open)
|
|
5917
|
+
throw new Error("guest public sign-in unavailable");
|
|
5918
|
+
const adapter = open.owner.binding;
|
|
5919
|
+
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: [] } });
|
|
5920
|
+
if (!admitted())
|
|
5921
|
+
throw new Error("guest public sign-in changed");
|
|
5922
|
+
}
|
|
5498
5923
|
/** Telegram's typed command enters through the same strict owner and nonce path as Discord. */
|
|
5499
5924
|
async dashboardMenu(msg) {
|
|
5500
5925
|
const id = msg.adapterId;
|
|
@@ -5818,16 +6243,197 @@ export class FleetManager {
|
|
|
5818
6243
|
this.initializeWebSessions();
|
|
5819
6244
|
void this.publicWebLink?.close("dashboard revoke");
|
|
5820
6245
|
this.webLoginCodes.revoke();
|
|
6246
|
+
this.webGuestLoginCodes.revokeAll();
|
|
6247
|
+
this.guestRevokes++; // and every guest /web still on its way
|
|
5821
6248
|
// #1570: no browser may ask the sign-in page for a code any more.
|
|
5822
6249
|
const devices = this.returningDevices.revokeAll();
|
|
5823
6250
|
const sessions = this.webSessions.revokeAll();
|
|
5824
|
-
|
|
6251
|
+
// #1939 §10: and every web authority given so far (its receipts read as none).
|
|
6252
|
+
const authority = this.revokeWebAuthority();
|
|
6253
|
+
const result = { count: sessions.count, durable: sessions.durable && devices.durable && authority.durable };
|
|
5825
6254
|
if (result.durable)
|
|
5826
6255
|
this.logger.info({ count: result.count, devices: devices.count }, "Web sessions revoked (all)");
|
|
5827
6256
|
else
|
|
5828
6257
|
this.logger.warn({ count: result.count }, "Web sessions revoked in memory only — the session file could not be updated or removed");
|
|
5829
6258
|
return result;
|
|
5830
6259
|
}
|
|
6260
|
+
/**
|
|
6261
|
+
* #1920 phase 3: Settings → Web sign-ins → Guests — the switches, each connection's access mode and the agents named for
|
|
6262
|
+
* an open one, and every guest account (signed in, with a returning device, or narrowed): who, its platform, what it
|
|
6263
|
+
* reaches now, its sessions and devices, and its narrowing. Ids are the accounts' own (an admin's page).
|
|
6264
|
+
*/
|
|
6265
|
+
guestOverview() {
|
|
6266
|
+
this.initializeWebSessions();
|
|
6267
|
+
const cfg = this.fleetConfig?.web?.guests;
|
|
6268
|
+
const own = (map, key) => (map && Object.hasOwn(map, key) ? map[key] : undefined);
|
|
6269
|
+
const connections = [...this.worlds.entries()].map(([id, w]) => ({ id, type: w.adapter.type, mode: (w.accessManager.getMode() === "open" ? "open" : "allowlist"),
|
|
6270
|
+
named: [...(own(cfg?.open_instances, id) ?? [])] }));
|
|
6271
|
+
const accounts = new Map();
|
|
6272
|
+
for (const a of this.webSessions.guestActivity())
|
|
6273
|
+
accounts.set(`${a.adapterId}\u0000${a.userId}`, a);
|
|
6274
|
+
const devices = this.returningDevices.guestCounts();
|
|
6275
|
+
for (const k of devices.keys())
|
|
6276
|
+
if (!accounts.has(k)) {
|
|
6277
|
+
const [adapterId, userId] = k.split("\u0000");
|
|
6278
|
+
accounts.set(k, { adapterId, userId, sessions: 0, lastSeen: null });
|
|
6279
|
+
}
|
|
6280
|
+
for (const key of Object.keys(cfg?.narrow ?? {})) {
|
|
6281
|
+
const at = key.indexOf(":"), adapterId = key.slice(0, at), userId = key.slice(at + 1), k = `${adapterId}\u0000${userId}`;
|
|
6282
|
+
if (at > 0 && !accounts.has(k))
|
|
6283
|
+
accounts.set(k, { adapterId, userId, sessions: 0, lastSeen: null });
|
|
6284
|
+
}
|
|
6285
|
+
const guests = [...accounts.entries()].map(([k, a]) => {
|
|
6286
|
+
const owner = { adapterId: a.adapterId, userId: a.userId };
|
|
6287
|
+
const badge = this.guestBadge(owner);
|
|
6288
|
+
// `reach`: before narrowing — what Settings offers to narrow to (#2104 review: never an agent it cannot reach).
|
|
6289
|
+
return { ...a, name: badge.name, platform: badge.platform, scope: [...this.guestScope(owner)].sort(), reach: [...this.guestReach(owner)].sort(),
|
|
6290
|
+
devices: devices.get(k) ?? 0, narrow: own(cfg?.narrow, `${a.adapterId}:${a.userId}`) ?? null };
|
|
6291
|
+
}).sort((x, y) => (y.lastSeen ?? -1) - (x.lastSeen ?? -1));
|
|
6292
|
+
return { enabled: cfg?.enabled === true, chat: cfg?.chat !== false, instances: Object.keys(this.fleetConfig?.instances ?? {}).sort(), connections, guests };
|
|
6293
|
+
}
|
|
6294
|
+
/** #1920 phase 3 (the app shell): a guest's chip — its web name, and its connection's platform (discord, telegram). */
|
|
6295
|
+
guestBadge(owner) {
|
|
6296
|
+
return { name: this.webPerson(owner).name, platform: this.worlds.get(owner.adapterId)?.adapter.type ?? owner.adapterId };
|
|
6297
|
+
}
|
|
6298
|
+
/** #1920 step 4: whether a `/web` is a guest's — guest sign-in on, and not from a fleet admin (theirs runs as before). */
|
|
6299
|
+
isGuestWebAsk(adapterId, userId) {
|
|
6300
|
+
return !!adapterId && this.fleetConfig?.web?.guests?.enabled === true && !this.isFleetAdmin(userId, adapterId);
|
|
6301
|
+
}
|
|
6302
|
+
/** #1920 step 4 (auth-api): a guest code's account may still sign in — guests on, and something in its scope now. */
|
|
6303
|
+
guestSignInAllowed(account) {
|
|
6304
|
+
return this.fleetConfig?.web?.guests?.enabled === true && !this.isFleetAdmin(account.userId, account.adapterId) && this.guestScope(account).size > 0;
|
|
6305
|
+
}
|
|
6306
|
+
/**
|
|
6307
|
+
* #1920 §5.4: a guest's own `/web revoke` — its sessions and its outstanding code; nobody else's (an admin's revokes
|
|
6308
|
+
* everyone's, as before).
|
|
6309
|
+
*/
|
|
6310
|
+
revokeGuestWebSessions(owner) {
|
|
6311
|
+
this.initializeWebSessions();
|
|
6312
|
+
const key = `${owner.adapterId}\u0000${owner.userId}`;
|
|
6313
|
+
this.guestAccountRevokes.set(key, (this.guestAccountRevokes.get(key) ?? 0) + 1); // its /web on its way, too
|
|
6314
|
+
while (this.guestAccountRevokes.size > 4096)
|
|
6315
|
+
this.guestAccountRevokes.delete(this.guestAccountRevokes.keys().next().value);
|
|
6316
|
+
this.webGuestLoginCodes.revokeRecipient(owner);
|
|
6317
|
+
// §5.4: its sessions and its returning devices (no browser of theirs may ask for a code any more).
|
|
6318
|
+
const devices = this.returningDevices.revokeGuest(owner);
|
|
6319
|
+
const ended = this.webSessions.revokeAll(r => r.tier === "guest" && r.owner?.adapterId === owner.adapterId && r.owner.userId === owner.userId);
|
|
6320
|
+
this.logger.info({ adapterId: owner.adapterId, count: ended.count, devices: devices.count, durable: ended.durable && devices.durable }, "Guest web sessions revoked by their account");
|
|
6321
|
+
return { count: ended.count, durable: ended.durable && devices.durable };
|
|
6322
|
+
}
|
|
6323
|
+
/**
|
|
6324
|
+
* #1920 §2.3 (step 4): `/web` from someone who is not a fleet admin, while guest sign-in is on. `revoke` ends their
|
|
6325
|
+
* own guest sessions. Otherwise a guest code (tier guest, bound to this account) goes privately — a DM, else the
|
|
6326
|
+
* slash command's ephemeral answer (`replyPrivately`) — once a minute at most, and only when their scope (from what
|
|
6327
|
+
* they may already use in chat, rooms read at sign-in) holds something. The code itself is never posted where asked.
|
|
6328
|
+
* Callers ask isGuestWebAsk first (synchronously); false here too when it is not a guest's.
|
|
6329
|
+
*/
|
|
6330
|
+
async guestWebCommand(ask) {
|
|
6331
|
+
if (!this.isGuestWebAsk(ask.adapterId, ask.userId))
|
|
6332
|
+
return false;
|
|
6333
|
+
const owner = { adapterId: ask.adapterId, userId: ask.userId };
|
|
6334
|
+
const say = (text) => ask.replyHere(text).catch(() => undefined);
|
|
6335
|
+
if (ask.arg === "revoke") {
|
|
6336
|
+
const ended = this.revokeGuestWebSessions(owner);
|
|
6337
|
+
await say(t(ended.durable ? "web.guest_revoked" : "web.guest_revoked_not_durable", ended.count));
|
|
6338
|
+
return true;
|
|
6339
|
+
}
|
|
6340
|
+
const token = this.webToken;
|
|
6341
|
+
const adapter = this.adapters.get(ask.adapterId);
|
|
6342
|
+
if (this.shuttingDown || !token || !adapter) {
|
|
6343
|
+
await say(t("dashboard.starting"));
|
|
6344
|
+
return true;
|
|
6345
|
+
}
|
|
6346
|
+
const key = `${owner.adapterId}\u0000${owner.userId}`, asked = this.guestCodeAskedAt.get(key);
|
|
6347
|
+
if (this.guestCodeInFlight.has(key) || (asked !== undefined && performance.now() - asked < 60_000)) {
|
|
6348
|
+
await say(t("web.guest_wait"));
|
|
6349
|
+
return true;
|
|
6350
|
+
}
|
|
6351
|
+
this.guestCodeInFlight.add(key);
|
|
6352
|
+
try {
|
|
6353
|
+
await this.issueGuestCode(ask, owner, key, adapter, token);
|
|
6354
|
+
}
|
|
6355
|
+
finally {
|
|
6356
|
+
this.guestCodeInFlight.delete(key);
|
|
6357
|
+
}
|
|
6358
|
+
return true;
|
|
6359
|
+
}
|
|
6360
|
+
/** guestWebCommand's issuance, one per account at a time; a revoke made meanwhile (either kind) ends it unissued. */
|
|
6361
|
+
async issueGuestCode(ask, owner, key, adapter, token) {
|
|
6362
|
+
const say = (text) => ask.replyHere(text).catch(() => undefined);
|
|
6363
|
+
const revokes = this.guestRevokes, accountRevokes = this.guestAccountRevokes.get(key) ?? 0;
|
|
6364
|
+
const unrevoked = () => this.guestRevokes === revokes && (this.guestAccountRevokes.get(key) ?? 0) === accountRevokes;
|
|
6365
|
+
// §3.2: its rooms are read at sign-in (bounded; never longer than the reads' own deadline).
|
|
6366
|
+
await withinBudget(this.refreshGuestRooms(owner, { mode: "force" }), performance.now() + 6_000).catch(() => undefined);
|
|
6367
|
+
if (!unrevoked())
|
|
6368
|
+
return; // revoked while its rooms were read: no code
|
|
6369
|
+
const scope = this.guestScope(owner);
|
|
6370
|
+
if (!scope.size) {
|
|
6371
|
+
const open = this.worlds.get(owner.adapterId)?.accessManager.getMode() === "open";
|
|
6372
|
+
await say(t(open ? "web.guest_not_set_up" : "web.guest_nothing"));
|
|
6373
|
+
return;
|
|
6374
|
+
}
|
|
6375
|
+
this.initializeWebSessions();
|
|
6376
|
+
const codes = this.webGuestLoginCodes;
|
|
6377
|
+
if (codes.pausedForMs() > 0) {
|
|
6378
|
+
await say(t("web.guest_paused"));
|
|
6379
|
+
return;
|
|
6380
|
+
}
|
|
6381
|
+
// §5.5 (step 4b): with the public link open, the code works through it too (an admin opened it; a guest is still a
|
|
6382
|
+
// guest there) — most guests are not on the host's network. Its closing withdraws that audience (revokeAudience).
|
|
6383
|
+
const open = this.publicWebLink?.openSignin() ?? null;
|
|
6384
|
+
// One code per account (a new one replaces only its own); at the cap, refused — never another guest's evicted.
|
|
6385
|
+
const login = codes.issue({ epoch: tokenEpoch(token), audiences: ["local", ...(open ? [open.exposureId] : [])],
|
|
6386
|
+
recipient: { adapterId: owner.adapterId, userId: owner.userId, ...(ask.username ? { username: ask.username } : {}) } });
|
|
6387
|
+
if (!login) {
|
|
6388
|
+
await say(t("web.guest_busy"));
|
|
6389
|
+
return;
|
|
6390
|
+
}
|
|
6391
|
+
this.guestCodeAskedAt.delete(key);
|
|
6392
|
+
this.guestCodeAskedAt.set(key, performance.now());
|
|
6393
|
+
while (this.guestCodeAskedAt.size > 256)
|
|
6394
|
+
this.guestCodeAskedAt.delete(this.guestCodeAskedAt.keys().next().value);
|
|
6395
|
+
const current = () => !this.shuttingDown && this.webToken === token && this.adapters.get(owner.adapterId) === adapter
|
|
6396
|
+
&& unrevoked() && this.guestSignInAllowed(owner);
|
|
6397
|
+
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))}`;
|
|
6398
|
+
const deadline = performance.now() + 10_000;
|
|
6399
|
+
try {
|
|
6400
|
+
let byDm = false;
|
|
6401
|
+
try {
|
|
6402
|
+
if (!adapter.sendDirect)
|
|
6403
|
+
throw new Error("DM unavailable");
|
|
6404
|
+
await withinBudget(adapter.sendDirect(owner.userId, `${privateText}\n${t("dashboard.code_next")}`, { disablePreview: true }), Math.min(deadline, performance.now() + 5_000));
|
|
6405
|
+
byDm = true;
|
|
6406
|
+
}
|
|
6407
|
+
catch (err) {
|
|
6408
|
+
this.logDashboardDmFailure(err);
|
|
6409
|
+
if (!ask.replyPrivately || !current())
|
|
6410
|
+
throw err;
|
|
6411
|
+
const id = await withinBudget(ask.replyPrivately(privateText), deadline); // a slash command's answer: ephemeral
|
|
6412
|
+
if (!id)
|
|
6413
|
+
throw new Error("private delivery unconfirmed");
|
|
6414
|
+
}
|
|
6415
|
+
if (!current())
|
|
6416
|
+
throw new Error("guest no longer admitted");
|
|
6417
|
+
if (byDm) {
|
|
6418
|
+
await this.sendCodeAlone((body, opts) => adapter.sendDirect(owner.userId, body, opts), adapter.type, login.display);
|
|
6419
|
+
if (!current())
|
|
6420
|
+
throw new Error("guest no longer admitted");
|
|
6421
|
+
await say(t("web.guest_sent"));
|
|
6422
|
+
}
|
|
6423
|
+
}
|
|
6424
|
+
catch (err) {
|
|
6425
|
+
codes.revokeIfCurrent(login.issuanceId); // this flow's code only, never a later one's
|
|
6426
|
+
this.logger.info({ err: err?.message, adapterId: owner.adapterId }, "Guest sign-in code was not delivered privately");
|
|
6427
|
+
if (!unrevoked())
|
|
6428
|
+
return; // revoked meanwhile: the revoke's own answer said so
|
|
6429
|
+
const start = adapter.type === "telegram" ? this.tgStartWebUrl(owner.adapterId) : null;
|
|
6430
|
+
// #1742 for guests (step 4b-3): its START button (a bare /start, or the link's /start web) then asks again, from
|
|
6431
|
+
// the DM — once, within the window.
|
|
6432
|
+
if (adapter.type === "telegram")
|
|
6433
|
+
this.openTgWebStartWindow(owner.adapterId, owner.userId);
|
|
6434
|
+
await say(start ? t("web.guest_dm_failed_link", start) : t("web.guest_dm_failed"));
|
|
6435
|
+
}
|
|
6436
|
+
}
|
|
5831
6437
|
/** #1570 (auth-api): this browser becomes a returning device of the code's owner. The cookie value, or null. */
|
|
5832
6438
|
rememberReturningDevice(owner, input) {
|
|
5833
6439
|
const token = this.webToken;
|
|
@@ -5855,6 +6461,12 @@ export class FleetManager {
|
|
|
5855
6461
|
if (!token || this.shuttingDown)
|
|
5856
6462
|
return { kind: "refused" };
|
|
5857
6463
|
this.initializeWebSessions();
|
|
6464
|
+
// #2095 review: verified before the request's body was read — a revoke (its own, or an admin's) made meanwhile
|
|
6465
|
+
// removed it, and nothing it asks for may then go out.
|
|
6466
|
+
if (!this.returningDevices.current(device, tokenEpoch(token)))
|
|
6467
|
+
return { kind: "refused" };
|
|
6468
|
+
if (device.tier === "guest")
|
|
6469
|
+
return this.requestGuestDeviceCode(device, input, token);
|
|
5858
6470
|
const paused = this.webLoginCodes.pausedForMs();
|
|
5859
6471
|
if (paused > 0)
|
|
5860
6472
|
return { kind: "limited", retryAfterMs: paused };
|
|
@@ -5886,7 +6498,7 @@ export class FleetManager {
|
|
|
5886
6498
|
this.logger.info({ err: err?.message, adapterId }, "Sign-in code request: the private message could not be sent");
|
|
5887
6499
|
return { kind: "refused" };
|
|
5888
6500
|
}
|
|
5889
|
-
if (this.webToken !== token || !this.publicOwnerCurrent(owner)) {
|
|
6501
|
+
if (this.webToken !== token || !this.publicOwnerCurrent(owner) || !this.returningDevices.current(device, tokenEpoch(token))) {
|
|
5890
6502
|
this.webLoginCodes.revokeIfCurrent(issued.issuanceId);
|
|
5891
6503
|
return { kind: "refused" };
|
|
5892
6504
|
}
|
|
@@ -5894,15 +6506,94 @@ export class FleetManager {
|
|
|
5894
6506
|
// Asked again after the follow-up's wait: the owner, the token, shutdown, and a public request's exposure.
|
|
5895
6507
|
const exposureGone = input.surface === "gateway"
|
|
5896
6508
|
&& (this.publicWebLink?.exposureId !== input.exposureId || this.publicWebLink?.status().state !== "open");
|
|
5897
|
-
if (this.shuttingDown || this.webToken !== token || !this.publicOwnerCurrent(owner) || exposureGone) {
|
|
6509
|
+
if (this.shuttingDown || this.webToken !== token || !this.publicOwnerCurrent(owner) || exposureGone || !this.returningDevices.current(device, tokenEpoch(token))) {
|
|
5898
6510
|
this.webLoginCodes.revokeIfCurrent(issued.issuanceId);
|
|
5899
6511
|
return { kind: "refused" };
|
|
5900
6512
|
}
|
|
5901
6513
|
this.logger.info({ adapterId, surface: input.surface }, "Web sign-in code sent privately to a returning device's owner");
|
|
5902
6514
|
return { kind: "sent" };
|
|
5903
6515
|
}
|
|
6516
|
+
/**
|
|
6517
|
+
* #1920 step 4b: a guest's returning device asks for a code — a guest code for that account, by DM only, under the
|
|
6518
|
+
* same rules as its /web (guests on, not an admin, something in scope now; one per account and the cap; one flow per
|
|
6519
|
+
* account at a time; a revoke meanwhile ends it), its own limiter, and the guests' breaker. Asked through the public
|
|
6520
|
+
* link: only while that exposure is the open one (its code then works through it alone), as an admin's device.
|
|
6521
|
+
*/
|
|
6522
|
+
async requestGuestDeviceCode(device, input, token) {
|
|
6523
|
+
const codes = this.webGuestLoginCodes;
|
|
6524
|
+
const account = { adapterId: device.owner.adapterId, userId: device.owner.userId };
|
|
6525
|
+
const key = `${account.adapterId}\u0000${account.userId}`;
|
|
6526
|
+
const adapter = this.adapters.get(account.adapterId);
|
|
6527
|
+
const viaLink = input.surface === "gateway";
|
|
6528
|
+
const linkOpen = () => !!input.exposureId && this.publicWebLink?.openSignin()?.exposureId === input.exposureId;
|
|
6529
|
+
if ((viaLink ? !linkOpen() : input.exposureId !== undefined) || !adapter?.sendDirect || !this.guestSignInAllowed(account)) {
|
|
6530
|
+
this.logger.info({ adapterId: account.adapterId, surface: input.surface }, "Guest sign-in code request refused");
|
|
6531
|
+
return { kind: "refused" };
|
|
6532
|
+
}
|
|
6533
|
+
const paused = codes.pausedForMs();
|
|
6534
|
+
if (paused > 0)
|
|
6535
|
+
return { kind: "limited", retryAfterMs: paused };
|
|
6536
|
+
const asked = this.guestCodeAskedAt.get(key);
|
|
6537
|
+
if (this.guestCodeInFlight.has(key))
|
|
6538
|
+
return { kind: "limited", retryAfterMs: 5_000 };
|
|
6539
|
+
if (asked !== undefined && performance.now() - asked < 60_000)
|
|
6540
|
+
return { kind: "limited", retryAfterMs: 60_000 - (performance.now() - asked) };
|
|
6541
|
+
const admitted = this.guestCodeRequestLimiter.admit(device.idHash);
|
|
6542
|
+
if (!admitted.ok)
|
|
6543
|
+
return { kind: "limited", retryAfterMs: admitted.retryAfterMs };
|
|
6544
|
+
const revokes = this.guestRevokes, accountRevokes = this.guestAccountRevokes.get(key) ?? 0;
|
|
6545
|
+
const current = () => !this.shuttingDown && this.webToken === token && this.adapters.get(account.adapterId) === adapter
|
|
6546
|
+
&& this.guestRevokes === revokes && (this.guestAccountRevokes.get(key) ?? 0) === accountRevokes && this.guestSignInAllowed(account)
|
|
6547
|
+
&& (!viaLink || linkOpen()) && this.returningDevices.current(device, tokenEpoch(token));
|
|
6548
|
+
const issued = codes.issue({ epoch: tokenEpoch(token), recipient: account, audiences: [viaLink ? input.exposureId : "local"] });
|
|
6549
|
+
if (!issued)
|
|
6550
|
+
return { kind: "limited", retryAfterMs: 60_000 }; // the guests' cap: never someone's evicted
|
|
6551
|
+
this.guestCodeInFlight.add(key);
|
|
6552
|
+
this.guestCodeAskedAt.delete(key);
|
|
6553
|
+
this.guestCodeAskedAt.set(key, performance.now());
|
|
6554
|
+
try {
|
|
6555
|
+
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"))
|
|
6556
|
+
+ "\n" + t("dashboard.code_next");
|
|
6557
|
+
try {
|
|
6558
|
+
await withinBudget(adapter.sendDirect(account.userId, text, { disablePreview: true }), performance.now() + 5_000);
|
|
6559
|
+
}
|
|
6560
|
+
catch (err) {
|
|
6561
|
+
codes.revokeIfCurrent(issued.issuanceId);
|
|
6562
|
+
this.logger.info({ err: err?.message, adapterId: account.adapterId }, "Guest sign-in code request: the private message could not be sent");
|
|
6563
|
+
return { kind: "refused" };
|
|
6564
|
+
}
|
|
6565
|
+
if (!current()) {
|
|
6566
|
+
codes.revokeIfCurrent(issued.issuanceId);
|
|
6567
|
+
return { kind: "refused" };
|
|
6568
|
+
}
|
|
6569
|
+
await this.sendCodeAlone((body, opts) => adapter.sendDirect(account.userId, body, opts), adapter.type, issued.display);
|
|
6570
|
+
if (!current()) {
|
|
6571
|
+
codes.revokeIfCurrent(issued.issuanceId);
|
|
6572
|
+
return { kind: "refused" };
|
|
6573
|
+
}
|
|
6574
|
+
this.logger.info({ adapterId: account.adapterId }, "Guest sign-in code sent privately to a returning device's account");
|
|
6575
|
+
return { kind: "sent" };
|
|
6576
|
+
}
|
|
6577
|
+
finally {
|
|
6578
|
+
this.guestCodeInFlight.delete(key);
|
|
6579
|
+
}
|
|
6580
|
+
}
|
|
6581
|
+
/** #1920 step 4b (auth-api): this browser becomes a guest's returning device. The cookie value, or null. */
|
|
6582
|
+
rememberGuestDevice(account, input) {
|
|
6583
|
+
const token = this.webToken;
|
|
6584
|
+
if (!token)
|
|
6585
|
+
return null;
|
|
6586
|
+
this.initializeWebSessions();
|
|
6587
|
+
return this.returningDevices.rememberGuest(account, { ...input, tokenEpoch: tokenEpoch(token), webToken: token });
|
|
6588
|
+
}
|
|
5904
6589
|
/** Called by the sign-in endpoint: a login the operator did not make should be visible to them. */
|
|
5905
6590
|
onWebLogin(info) {
|
|
6591
|
+
// #1920 §2.3: a guest's sign-in is always said in General (web.notify_login is the operator's own sign-ins): who,
|
|
6592
|
+
// and how many agents they now reach — admins stay aware of everyone who is not one of them.
|
|
6593
|
+
if (info.tier === "guest" && info.owner) {
|
|
6594
|
+
this.notifyFleetError(t("web.guest_login_notice", this.webPerson(info.owner).name, info.label, this.guestScope(info.owner).size, info.handle.slice(0, 8)));
|
|
6595
|
+
return;
|
|
6596
|
+
}
|
|
5906
6597
|
if (this.fleetConfig?.web?.notify_login === false)
|
|
5907
6598
|
return;
|
|
5908
6599
|
// The session handle makes each notice distinct: notifyFleetError throttles by text, and a second
|
|
@@ -6101,11 +6792,17 @@ export class FleetManager {
|
|
|
6101
6792
|
* landed mid-edit) must not be cleared or disturbed (Prism b).
|
|
6102
6793
|
*/
|
|
6103
6794
|
async appendCleanupWarning(target, watch, warning, cleanupCommand) {
|
|
6104
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6795
|
+
// #2175: one text per outcome, frozen at its first tick. The completion carries elapsed seconds, so a text made
|
|
6796
|
+
// anew each tick would never equal the one already handed over and landed (terminalLanded compares exactly).
|
|
6797
|
+
const key = JSON.stringify([warning, cleanupCommand ?? null]);
|
|
6798
|
+
if (watch.frozen?.key !== key) {
|
|
6799
|
+
watch.frozen = { key, text: formatRestartProgressCompletion("update", {
|
|
6800
|
+
...watch.summary,
|
|
6801
|
+
warningText: warning,
|
|
6802
|
+
warningCommand: cleanupCommand,
|
|
6803
|
+
}, watch.receipt.startedAt) };
|
|
6804
|
+
}
|
|
6805
|
+
const text = watch.frozen.text;
|
|
6109
6806
|
const stopWatching = () => {
|
|
6110
6807
|
this.updateCleanupWatch = null;
|
|
6111
6808
|
if (this.updateProgressTimer)
|
|
@@ -6113,10 +6810,22 @@ export class FleetManager {
|
|
|
6113
6810
|
this.updateProgressTimer = null;
|
|
6114
6811
|
};
|
|
6115
6812
|
if (text !== this.lastUpdateProgressText) {
|
|
6116
|
-
|
|
6117
|
-
|
|
6118
|
-
|
|
6119
|
-
|
|
6813
|
+
// #2175: while the completion is still owed by the background retry, that retry is the message's only
|
|
6814
|
+
// writer — this text (the completion plus the warning) goes to it rather than racing it. Handing it over is not
|
|
6815
|
+
// delivery: the receipt and this watch stay until the retry reports this exact text landed (a later tick).
|
|
6816
|
+
const progress = this.startupProgress;
|
|
6817
|
+
if (progress?.terminalLanded(text)) {
|
|
6818
|
+
// landed through the retry
|
|
6819
|
+
}
|
|
6820
|
+
else if (progress?.replaceTerminalText(text)) {
|
|
6821
|
+
return;
|
|
6822
|
+
}
|
|
6823
|
+
else {
|
|
6824
|
+
const adapter = this.adapters.get(target.adapterId);
|
|
6825
|
+
if (!adapter)
|
|
6826
|
+
return; // retry on a later tick
|
|
6827
|
+
await adapter.editMessage(target.chatId, target.messageId, text, target.threadId);
|
|
6828
|
+
}
|
|
6120
6829
|
this.lastUpdateProgressText = text;
|
|
6121
6830
|
}
|
|
6122
6831
|
const verdict = cleanupWatchVerdict(readUpdateProgress(this.dataDir), watch.receipt);
|
|
@@ -6238,6 +6947,12 @@ export class FleetManager {
|
|
|
6238
6947
|
envLease.release();
|
|
6239
6948
|
}
|
|
6240
6949
|
this.ensureDeliveryOutbox();
|
|
6950
|
+
// #1956: the spawn helper the fleet's tmux/stty commands are started by (its fork is cheap; the fleet's is not).
|
|
6951
|
+
// Started here and by the fleet's own timer — never by a tool handler — and stopped with the fleet.
|
|
6952
|
+
tmuxExecHelper.reopen();
|
|
6953
|
+
tmuxExecHelper.start();
|
|
6954
|
+
this.tmuxExecHelperTimer ??= setInterval(() => tmuxExecHelper.maintain(), 5_000);
|
|
6955
|
+
this.tmuxExecHelperTimer.unref?.();
|
|
6241
6956
|
this.rotateFleetLogs();
|
|
6242
6957
|
const fleet = this.loadConfig(configPath);
|
|
6243
6958
|
this.slimFleetConfigAtStartup();
|
|
@@ -6266,6 +6981,9 @@ export class FleetManager {
|
|
|
6266
6981
|
const { getTmuxSocketName: getSocket } = await import("./paths.js");
|
|
6267
6982
|
TmuxManager.setSocketName(getSocket());
|
|
6268
6983
|
await TmuxManager.ensureSession(getTmuxSession());
|
|
6984
|
+
this.tmuxSessionEnsured = true;
|
|
6985
|
+
// #2072: what this host can run — probed in the background, published for every picker.
|
|
6986
|
+
this.startAvailabilityRefresh();
|
|
6269
6987
|
// Pre-flight (advisory, fire-and-forget): warm the per-backend auth cache
|
|
6270
6988
|
// before instances spawn, so a CLI that comes up on its sign-in screen gets
|
|
6271
6989
|
// an instant 🔑 verdict instead of decaying into crash/MCP-died noise.
|
|
@@ -6331,6 +7049,7 @@ export class FleetManager {
|
|
|
6331
7049
|
// #1565: the web chat as it was before this process — the configured instances' only (fleet.yaml and ClassicBot
|
|
6332
7050
|
// rooms), before the dashboard serves anything; from now on every change is written back (off the loop).
|
|
6333
7051
|
this.restoreWebChat(fleet);
|
|
7052
|
+
this.startSearchIndex(fleet);
|
|
6334
7053
|
// Poll classicBot.yaml for external changes every 30s
|
|
6335
7054
|
this.classicReloadTimer = setInterval(() => {
|
|
6336
7055
|
// Attributes the synchronous part, up to the reload's first await (#1235).
|
|
@@ -6475,7 +7194,9 @@ export class FleetManager {
|
|
|
6475
7194
|
};
|
|
6476
7195
|
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);
|
|
6477
7196
|
this.scheduler.init();
|
|
7197
|
+
this.scheduler.db.onTaskChange = (task) => this.searchIndex?.record(taskDoc(task));
|
|
6478
7198
|
this.logger.info("Scheduler initialized");
|
|
7199
|
+
this.attachTaskBoard(this.scheduler.db);
|
|
6479
7200
|
// Tips share the daily-report clock but remain an independent internal
|
|
6480
7201
|
// job: disabling daily_summary must not disable tips (and vice versa).
|
|
6481
7202
|
// The callback checks defaults.tips at fire time, so /tips on|off is hot.
|
|
@@ -6503,6 +7224,7 @@ export class FleetManager {
|
|
|
6503
7224
|
const generals = allEntries.filter(([_, cfg]) => cfg.general_topic);
|
|
6504
7225
|
const others = allEntries.filter(([_, cfg]) => !cfg.general_topic);
|
|
6505
7226
|
const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), pendingUpdateProgress?.startedAt ?? startupStartedAt, this.logger, { mode: pendingProgressOperation === "full-restart" ? "reload" : pendingUpdateProgress ? "update" : "restart" });
|
|
7227
|
+
this.startupProgress = startupProgress;
|
|
6506
7228
|
if (generals.length > 0) {
|
|
6507
7229
|
for (const [name, cfg] of generals) {
|
|
6508
7230
|
try {
|
|
@@ -6589,6 +7311,18 @@ export class FleetManager {
|
|
|
6589
7311
|
this.pruneEventLog();
|
|
6590
7312
|
this.eventLogPruneTimer = setInterval(() => this.pruneEventLog(), 24 * 60 * 60_000);
|
|
6591
7313
|
this.eventLogPruneTimer.unref?.();
|
|
7314
|
+
// #2122 §5.5: usage is collected from the start, not from the first time someone opens a report — the same bounded
|
|
7315
|
+
// background passes the cache report uses (16 MB a pass, then every 10 minutes).
|
|
7316
|
+
{
|
|
7317
|
+
const cache = this.cacheServiceFor();
|
|
7318
|
+
cache.start();
|
|
7319
|
+
void cache.kick();
|
|
7320
|
+
}
|
|
7321
|
+
// #1920 §3.2: guests' ClassicBot room membership, read in the background — now, then each key ten minutes after
|
|
7322
|
+
// its answer (a minute after a failed read; the tick only finds what is due). A request only reads the answers.
|
|
7323
|
+
void this.refreshAllGuestRooms();
|
|
7324
|
+
this.guestRoomsTimer = setInterval(() => { void this.refreshAllGuestRooms(); }, GUEST_ROOM_TICK_MS);
|
|
7325
|
+
this.guestRoomsTimer.unref?.();
|
|
6592
7326
|
// #1335: delivery-outbox.db and Task Board retention. Same once-at-startup
|
|
6593
7327
|
// + daily-timer pattern; chunked DELETEs so one run cannot hold the loop.
|
|
6594
7328
|
void this.pruneOutboxAndTasks();
|
|
@@ -6689,6 +7423,10 @@ export class FleetManager {
|
|
|
6689
7423
|
// operation so an older stop never reports. No pending operation means
|
|
6690
7424
|
// a plain boot — nothing was stopped by an operation, report nothing.
|
|
6691
7425
|
const backgroundWorkEndedNames = this.collectBackgroundWorkEndedNames(configuredNames, pendingUpdateProgress?.startedAt ?? null);
|
|
7426
|
+
// #2264: an update whose fleet comes back on another version did not land — say so in its final message (the
|
|
7427
|
+
// version it aimed for, the one running, and the one repair), never just "updated".
|
|
7428
|
+
const aimedAt = pendingProgressOperation === "update" ? pendingUpdateProgress?.progress.version : undefined;
|
|
7429
|
+
const notLanded = aimedAt && agendVersion !== "unknown" && aimedAt !== agendVersion ? t("update.not_landed", aimedAt, agendVersion) : null;
|
|
6692
7430
|
const completionSummary = {
|
|
6693
7431
|
running: started,
|
|
6694
7432
|
total,
|
|
@@ -6698,7 +7436,7 @@ export class FleetManager {
|
|
|
6698
7436
|
backgroundWorkEndedNames,
|
|
6699
7437
|
...(cleanupOutcome && "render" in cleanupOutcome
|
|
6700
7438
|
? { warningText: cleanupOutcome.render.warning, warningCommand: cleanupOutcome.render.cleanupCommand }
|
|
6701
|
-
: {}),
|
|
7439
|
+
: notLanded ? { warningText: notLanded } : {}),
|
|
6702
7440
|
};
|
|
6703
7441
|
const progressCompleted = await startupProgress.finish(completionSummary);
|
|
6704
7442
|
// Armed only after the completion posted: an append racing ahead of it would be overwritten by it.
|
|
@@ -6918,48 +7656,109 @@ export class FleetManager {
|
|
|
6918
7656
|
const err = results[i].reason;
|
|
6919
7657
|
this.logger.error({ adapterId, err: err?.message ?? err }, "Adapter startup failed — scheduling background retry");
|
|
6920
7658
|
this.adapterState.set(adapterId, { status: "retrying", retryCount: 0, lastError: err?.message ?? String(err) });
|
|
6921
|
-
this.scheduleAdapterRetry(adapterId
|
|
7659
|
+
this.scheduleAdapterRetry(adapterId);
|
|
6922
7660
|
// Notify admin via whichever adapter is already up
|
|
6923
7661
|
this.notifyAdapterFailure(adapterId, err?.message ?? String(err));
|
|
6924
7662
|
}
|
|
6925
7663
|
}
|
|
6926
7664
|
}
|
|
6927
|
-
/**
|
|
6928
|
-
scheduleAdapterRetry(adapterId
|
|
6929
|
-
const MAX_RETRIES = 10;
|
|
7665
|
+
/** Keep trying while the fleet runs, with a five-minute cap even after a long outage. */
|
|
7666
|
+
scheduleAdapterRetry(adapterId) {
|
|
6930
7667
|
const INITIAL_DELAY_MS = 5_000;
|
|
6931
7668
|
const MAX_DELAY_MS = 5 * 60_000;
|
|
6932
7669
|
const state = this.adapterState.get(adapterId);
|
|
6933
|
-
if (!state || state.
|
|
6934
|
-
if (state) {
|
|
6935
|
-
state.status = "failed";
|
|
6936
|
-
this.logger.error({ adapterId, retries: state.retryCount }, "Adapter retry exhausted — giving up");
|
|
6937
|
-
this.notifyAdapterFailure(adapterId, `Retry exhausted after ${state.retryCount} attempts. Check token/network and restart fleet.`);
|
|
6938
|
-
}
|
|
7670
|
+
if (this.shuttingDown || !state || state.retryTimer || this.adapterRetryInFlight.has(adapterId))
|
|
6939
7671
|
return;
|
|
6940
|
-
|
|
6941
|
-
const delay = Math.min(INITIAL_DELAY_MS * Math.pow(2, state.retryCount), MAX_DELAY_MS);
|
|
7672
|
+
const delay = Math.min(INITIAL_DELAY_MS * Math.pow(2, Math.min(state.retryCount, 6)), MAX_DELAY_MS);
|
|
6942
7673
|
this.logger.info({ adapterId, attempt: state.retryCount + 1, delay_ms: delay }, "Scheduling adapter retry");
|
|
6943
|
-
|
|
7674
|
+
const timer = setTimeout(() => {
|
|
7675
|
+
const current = this.adapterState.get(adapterId);
|
|
7676
|
+
if (this.shuttingDown || current?.retryTimer !== timer)
|
|
7677
|
+
return;
|
|
7678
|
+
current.retryTimer = undefined;
|
|
7679
|
+
void this.attemptAdapterRetry(adapterId);
|
|
7680
|
+
}, delay);
|
|
7681
|
+
state.retryTimer = timer;
|
|
7682
|
+
timer.unref?.();
|
|
7683
|
+
}
|
|
7684
|
+
/** One login per connection, shared by the background timer and the operator's Retry now. */
|
|
7685
|
+
attemptAdapterRetry(adapterId) {
|
|
7686
|
+
const pending = this.adapterRetryInFlight.get(adapterId);
|
|
7687
|
+
if (pending)
|
|
7688
|
+
return pending;
|
|
7689
|
+
if (this.shuttingDown || !this.secureConnectionChannel(adapterId))
|
|
7690
|
+
return Promise.resolve();
|
|
7691
|
+
const attempt = Promise.resolve().then(async () => {
|
|
7692
|
+
const channel = this.secureConnectionChannel(adapterId);
|
|
7693
|
+
if (this.shuttingDown || !channel)
|
|
7694
|
+
return;
|
|
7695
|
+
const generation = this.settingsGeneration;
|
|
7696
|
+
const binding = JSON.stringify([channel, this.getPrimaryAdapterId()]);
|
|
7697
|
+
const token = process.env[channel.bot_token_env];
|
|
7698
|
+
const operation = { owner: Symbol("adapter-retry"), assert: () => {
|
|
7699
|
+
if (this.shuttingDown || generation !== this.settingsGeneration
|
|
7700
|
+
|| JSON.stringify([this.secureConnectionChannel(adapterId), this.getPrimaryAdapterId()]) !== binding
|
|
7701
|
+
|| process.env[channel.bot_token_env] !== token)
|
|
7702
|
+
throw new Error("adapter retry superseded");
|
|
7703
|
+
} };
|
|
7704
|
+
const state = this.adapterState.get(adapterId) ?? { status: "retrying", retryCount: 0 };
|
|
7705
|
+
state.status = "retrying";
|
|
6944
7706
|
state.retryCount++;
|
|
7707
|
+
this.adapterState.set(adapterId, state);
|
|
6945
7708
|
try {
|
|
6946
|
-
if (
|
|
6947
|
-
await this.startSingleAdapter(
|
|
6948
|
-
|
|
6949
|
-
|
|
6950
|
-
|
|
6951
|
-
|
|
6952
|
-
|
|
6953
|
-
|
|
7709
|
+
if (this.getPrimaryAdapterId() === adapterId)
|
|
7710
|
+
await this.startSingleAdapter(this.fleetConfig, channel, undefined, operation);
|
|
7711
|
+
else
|
|
7712
|
+
await this.startAdditionalAdapter(channel, true, undefined, operation);
|
|
7713
|
+
if (this.shuttingDown || generation !== this.settingsGeneration)
|
|
7714
|
+
return;
|
|
7715
|
+
if (!this.adapters.has(adapterId))
|
|
7716
|
+
throw new Error("Adapter token is not configured");
|
|
7717
|
+
// Health events can replace the state object during login. Update its current owner.
|
|
7718
|
+
const current = this.adapterState.get(adapterId);
|
|
7719
|
+
current.status = "connected";
|
|
7720
|
+
current.lastError = undefined;
|
|
6954
7721
|
this.logger.info({ adapterId, attempts: state.retryCount }, "Adapter reconnected on retry");
|
|
6955
7722
|
this.notifyAdapterRecovery(adapterId, state.retryCount);
|
|
6956
7723
|
}
|
|
6957
7724
|
catch (err) {
|
|
6958
|
-
|
|
6959
|
-
|
|
6960
|
-
this.
|
|
7725
|
+
if (this.shuttingDown || generation !== this.settingsGeneration)
|
|
7726
|
+
return;
|
|
7727
|
+
const current = this.adapterState.get(adapterId);
|
|
7728
|
+
current.status = "retrying";
|
|
7729
|
+
current.lastError = err?.message ?? String(err);
|
|
7730
|
+
this.logger.warn({ adapterId, attempt: state.retryCount, err: current.lastError }, "Adapter retry failed");
|
|
6961
7731
|
}
|
|
6962
|
-
}
|
|
7732
|
+
}).finally(() => {
|
|
7733
|
+
if (this.adapterRetryInFlight.get(adapterId) !== attempt)
|
|
7734
|
+
return;
|
|
7735
|
+
this.adapterRetryInFlight.delete(adapterId);
|
|
7736
|
+
const channel = this.secureConnectionChannel(adapterId);
|
|
7737
|
+
if (!this.shuttingDown && channel && this.adapterState.get(adapterId)?.status !== "connected") {
|
|
7738
|
+
this.scheduleAdapterRetry(adapterId);
|
|
7739
|
+
}
|
|
7740
|
+
});
|
|
7741
|
+
this.adapterRetryInFlight.set(adapterId, attempt);
|
|
7742
|
+
return attempt;
|
|
7743
|
+
}
|
|
7744
|
+
retryAdapterNow(adapterId) {
|
|
7745
|
+
if (this.shuttingDown)
|
|
7746
|
+
throw new Error("The fleet is stopping");
|
|
7747
|
+
if (!this.secureConnectionChannel(adapterId))
|
|
7748
|
+
throw new Error("Connection not found");
|
|
7749
|
+
if (this.adapterRetryInFlight.has(adapterId) || this.adapterRestarting.has(adapterId))
|
|
7750
|
+
return { queued: true, already_running: true };
|
|
7751
|
+
const state = this.adapterState.get(adapterId);
|
|
7752
|
+
if (state?.retryTimer) {
|
|
7753
|
+
clearTimeout(state.retryTimer);
|
|
7754
|
+
state.retryTimer = undefined;
|
|
7755
|
+
}
|
|
7756
|
+
const adapter = this.adapters.get(adapterId);
|
|
7757
|
+
if (adapter && this.connectionStatus(adapterId, state, this.worlds.get(adapterId)).status === "connected")
|
|
7758
|
+
return { queued: false };
|
|
7759
|
+
const run = adapter ? this.restartAdapter(adapter, adapterId) : this.attemptAdapterRetry(adapterId);
|
|
7760
|
+
void run.catch(err => this.logger.warn({ adapterId, err }, "Requested adapter retry failed"));
|
|
7761
|
+
return { queued: true };
|
|
6963
7762
|
}
|
|
6964
7763
|
/** Notify admin about adapter failure (uses any available adapter). */
|
|
6965
7764
|
notifyAdapterFailure(adapterId, error) {
|
|
@@ -7014,6 +7813,7 @@ export class FleetManager {
|
|
|
7014
7813
|
: snapshot.status === "stopped" ? "failed"
|
|
7015
7814
|
: "retrying";
|
|
7016
7815
|
this.adapterState.set(adapterId, {
|
|
7816
|
+
...previous,
|
|
7017
7817
|
status,
|
|
7018
7818
|
retryCount: previous?.retryCount ?? 0,
|
|
7019
7819
|
lastError: status === "connected" ? undefined : snapshot.lastReconnectReason ?? previous?.lastError,
|
|
@@ -7089,7 +7889,8 @@ export class FleetManager {
|
|
|
7089
7889
|
adapters: { total: this.adapterState.size, connected, states, details },
|
|
7090
7890
|
startupComplete: this.startupComplete,
|
|
7091
7891
|
memory: readFleetMemory(),
|
|
7092
|
-
hostMemory
|
|
7892
|
+
hostMemory: this.memoryTop && (hostMemory.level === "elevated" || hostMemory.level === "critical")
|
|
7893
|
+
? { ...hostMemory, topProcesses: this.memoryTop.processes, topProcessesAt: this.memoryTop.at } : hostMemory,
|
|
7093
7894
|
problems,
|
|
7094
7895
|
};
|
|
7095
7896
|
}
|
|
@@ -8240,6 +9041,13 @@ export class FleetManager {
|
|
|
8240
9041
|
if (!isPrivateChat && !cmdMatch && rawText.startsWith("/")) {
|
|
8241
9042
|
return; // bare slash in group — ignore silently
|
|
8242
9043
|
}
|
|
9044
|
+
// #1920 step 4b-3: /web typed in a ClassicBot room (a group: `/web@ThisBot`) by someone who is not a fleet admin,
|
|
9045
|
+
// while guest sign-in is on: their guest code, privately — as anywhere else. An admin's goes on as before.
|
|
9046
|
+
if (/^\/(?:web|dashboard)(?:\s|$)/i.test(text) && msgAdapter && this.isGuestWebAsk(msg.adapterId, msg.userId)) {
|
|
9047
|
+
await this.guestWebCommand({ adapterId: msg.adapterId, userId: msg.userId, username: msg.username,
|
|
9048
|
+
arg: text.replace(/^\/(?:web|dashboard)/i, "").trim().toLowerCase(), replyHere: body => msgAdapter.sendText(chatId, body) });
|
|
9049
|
+
return;
|
|
9050
|
+
}
|
|
8243
9051
|
// Handle /start command
|
|
8244
9052
|
if (text === "/start" || text.startsWith("/start ")) {
|
|
8245
9053
|
// #1686: intercept /start web before the ClassicBot admission flow runs.
|
|
@@ -8248,15 +9056,21 @@ export class FleetManager {
|
|
|
8248
9056
|
// #1742: a bare /start from a fleet admin whose /web just failed to reach them privately is that /start web
|
|
8249
9057
|
// (Telegram's START button sends it bare) — once, within the window; any other bare /start is ClassicBot's.
|
|
8250
9058
|
const admin = isPrivateChat && this.isFleetAdmin(msg.userId, msg.adapterId ?? "");
|
|
9059
|
+
// #1920 step 4b-3: a guest's too (guest sign-in on, not an admin) — its /web reached it no other way.
|
|
9060
|
+
const guest = isPrivateChat && !admin && this.isGuestWebAsk(msg.adapterId, msg.userId);
|
|
8251
9061
|
const webStart = isPrivateChat && (requestedBackend === "web"
|
|
8252
|
-
|| (text === "/start" && admin && this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId)));
|
|
9062
|
+
|| (text === "/start" && (admin || guest) && this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId)));
|
|
8253
9063
|
if (webStart) {
|
|
8254
9064
|
if (requestedBackend === "web")
|
|
8255
9065
|
this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId);
|
|
8256
9066
|
// #1742: the /web menu right here; the code comes to this DM.
|
|
8257
9067
|
if (admin)
|
|
8258
9068
|
await this.startWebInDm(msg, msgAdapter ?? undefined);
|
|
8259
|
-
//
|
|
9069
|
+
// A guest: its guest code, here (this DM is where it goes).
|
|
9070
|
+
else if (guest && msgAdapter)
|
|
9071
|
+
await this.guestWebCommand({ adapterId: msg.adapterId, userId: msg.userId, username: msg.username, arg: "",
|
|
9072
|
+
replyHere: body => msgAdapter.sendText(chatId, body) });
|
|
9073
|
+
// Anyone else: /start alone is enough to open the DM channel; no further action.
|
|
8260
9074
|
return;
|
|
8261
9075
|
}
|
|
8262
9076
|
// All new starts, including explicit backend and later chooser callbacks,
|
|
@@ -8560,10 +9374,7 @@ export class FleetManager {
|
|
|
8560
9374
|
this.afterUserInboundDelivered(generalInstance, msg);
|
|
8561
9375
|
this.logger.info(`${msg.username} → ${generalInstance}: ${(text ?? "").slice(0, 100)}`);
|
|
8562
9376
|
this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), generalInstance);
|
|
8563
|
-
this.
|
|
8564
|
-
instance: generalInstance, sender: msg.username, role: "user",
|
|
8565
|
-
text: (text ?? "").slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
|
|
8566
|
-
});
|
|
9377
|
+
this.emitPlatformInbound(generalInstance, msg, text ?? "");
|
|
8567
9378
|
}
|
|
8568
9379
|
catch (err) {
|
|
8569
9380
|
this.logger.warn({ err: err.message, instanceName: generalInstance }, "General wake/delivery failed");
|
|
@@ -8672,9 +9483,16 @@ export class FleetManager {
|
|
|
8672
9483
|
this.afterUserInboundDelivered(instanceName, msg);
|
|
8673
9484
|
this.logger.info(`${msg.username} → ${instanceName}: ${(text ?? "").slice(0, 100)}`);
|
|
8674
9485
|
this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), instanceName);
|
|
9486
|
+
this.emitPlatformInbound(instanceName, msg, text ?? "");
|
|
9487
|
+
}
|
|
9488
|
+
/**
|
|
9489
|
+
* A person's platform message as the web chat shows it — the user's own words, as a user (#1306), in the instance's
|
|
9490
|
+
* history (#1565). #1920 §5.2: said in the instance's topic, General or room, so it is that channel's, by that account.
|
|
9491
|
+
*/
|
|
9492
|
+
emitPlatformInbound(instance, msg, text) {
|
|
8675
9493
|
this.emitSseEvent("message", {
|
|
8676
|
-
instance
|
|
8677
|
-
|
|
9494
|
+
instance, sender: msg.username, role: "user", text: text.slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
|
|
9495
|
+
channel: true, from: webChatFrom(msg.adapterId, msg.userId),
|
|
8678
9496
|
});
|
|
8679
9497
|
}
|
|
8680
9498
|
/** Handle outbound tool calls from a daemon instance */
|
|
@@ -8821,6 +9639,16 @@ export class FleetManager {
|
|
|
8821
9639
|
respond(null, permitted.message);
|
|
8822
9640
|
return;
|
|
8823
9641
|
}
|
|
9642
|
+
// Fleet tools resolve their own targets. A missing chat world must not prevent local
|
|
9643
|
+
// delivery, inspection or receipt lookup; only channel tools need reply routing below.
|
|
9644
|
+
const handler = outboundHandlers.get(tool);
|
|
9645
|
+
if (handler && !usesChannelAddress(tool)) {
|
|
9646
|
+
this.eventLog?.logActivity("tool_call", senderSessionName ?? instanceName, this.summarizeToolCall(tool, args));
|
|
9647
|
+
await handler(this, args, respond, {
|
|
9648
|
+
instanceName, requestId, fleetRequestId, senderSessionName, operationId, sourceDaemonBootId,
|
|
9649
|
+
});
|
|
9650
|
+
return;
|
|
9651
|
+
}
|
|
8824
9652
|
// A fleet with no chat platform at all is driven from the web dashboard alone: a reply has nowhere
|
|
8825
9653
|
// else to go, and "retry shortly" would have the agent retry forever. It goes to the web chat.
|
|
8826
9654
|
const webOnlyReply = tool === "reply" && this.worlds.size === 0 && this.isWebOnlyFleet();
|
|
@@ -8976,7 +9804,14 @@ export class FleetManager {
|
|
|
8976
9804
|
// an agent cannot invent it to suppress the normal completion marker.
|
|
8977
9805
|
if (!error && result != null && msg.statusOnly !== true) {
|
|
8978
9806
|
try {
|
|
8979
|
-
|
|
9807
|
+
// #1920 §5.2: shown to the instance's guests only when it landed in the instance's own topic or room.
|
|
9808
|
+
let inChannel = false;
|
|
9809
|
+
try {
|
|
9810
|
+
inChannel = outAdapter !== WEB_ONLY_REPLY_SINK
|
|
9811
|
+
&& this.landedInOwnChannel(instanceName, contextAdapterId ?? this.getInstanceAdapterId(instanceName), result);
|
|
9812
|
+
}
|
|
9813
|
+
catch { /* not known: not the channel's (fail closed), and the bookkeeping below still runs */ }
|
|
9814
|
+
void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView, inChannel);
|
|
8980
9815
|
}
|
|
8981
9816
|
catch (err) {
|
|
8982
9817
|
this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
|
|
@@ -9005,19 +9840,14 @@ export class FleetManager {
|
|
|
9005
9840
|
if (routeToolCall(outAdapter, tool, args, threadId, respond)) {
|
|
9006
9841
|
return;
|
|
9007
9842
|
}
|
|
9008
|
-
// Log tool calls for activity visualization
|
|
9009
|
-
const senderLabel = senderSessionName ?? instanceName;
|
|
9010
|
-
this.eventLog?.logActivity("tool_call", senderLabel, this.summarizeToolCall(tool, args));
|
|
9011
|
-
// Dispatch fleet-specific tools via handler map
|
|
9012
|
-
const handler = outboundHandlers.get(tool);
|
|
9013
9843
|
if (handler) {
|
|
9844
|
+
this.eventLog?.logActivity("tool_call", senderSessionName ?? instanceName, this.summarizeToolCall(tool, args));
|
|
9014
9845
|
await handler(this, args, respond, {
|
|
9015
9846
|
instanceName, requestId, fleetRequestId, senderSessionName, operationId, sourceDaemonBootId,
|
|
9016
9847
|
});
|
|
9017
9848
|
}
|
|
9018
|
-
else
|
|
9849
|
+
else
|
|
9019
9850
|
respond(null, `Unknown tool: ${tool}`);
|
|
9020
|
-
}
|
|
9021
9851
|
}
|
|
9022
9852
|
/** Side effects of a routed reply: cancel-button lifecycle, logs, SSE, chat log. */
|
|
9023
9853
|
/** No chat platform is configured at all: the web dashboard is the fleet's only surface. */
|
|
@@ -9030,7 +9860,35 @@ export class FleetManager {
|
|
|
9030
9860
|
return this.worlds.size === 0 && this.isWebOnlyFleet() ? { adapter: WEB_ONLY_PROMPT_SINK, adapterId: "web", chatId: "web" } : null;
|
|
9031
9861
|
}
|
|
9032
9862
|
/** Resolves when the reply is in the web chat (#1770: after its files are copied); the caller need not wait. */
|
|
9033
|
-
|
|
9863
|
+
/**
|
|
9864
|
+
* #1920 §5.2: whether a platform send landed in the instance's own place — its ClassicBot room, or its topic in its
|
|
9865
|
+
* connection's group — so its channel's members saw it. Anything else (another chat, a DM, a context left from General,
|
|
9866
|
+
* an answer without the chat it went to) is not the channel's.
|
|
9867
|
+
*/
|
|
9868
|
+
landedInOwnChannel(instance, adapterId, sent) {
|
|
9869
|
+
const s = sent && typeof sent === "object" ? sent : {};
|
|
9870
|
+
if (!adapterId || typeof s.chatId !== "string" || !s.chatId)
|
|
9871
|
+
return false;
|
|
9872
|
+
const roomId = this.classicChannels?.getChannelIdByInstance(instance);
|
|
9873
|
+
if (roomId !== undefined)
|
|
9874
|
+
return (this.classicChannels?.getAdapterIdByInstance(instance) ?? this.getPrimaryAdapterId()) === adapterId && roomId === s.chatId;
|
|
9875
|
+
const cfg = this.fleetConfig?.instances && Object.hasOwn(this.fleetConfig.instances, instance) ? this.fleetConfig.instances[instance] : undefined;
|
|
9876
|
+
const world = this.worlds.get(adapterId);
|
|
9877
|
+
if (!cfg || this.getInstanceAdapterId(instance) !== adapterId || !world?.groupId || s.chatId !== world.groupId)
|
|
9878
|
+
return false;
|
|
9879
|
+
const thread = typeof s.threadId === "string" && s.threadId ? s.threadId : null;
|
|
9880
|
+
return cfg.topic_id == null ? thread === null : thread === String(cfg.topic_id);
|
|
9881
|
+
}
|
|
9882
|
+
/** #1920 §5.2 (#2085 review): a platform click's buttons are in the instance's own place — else not its channel's. */
|
|
9883
|
+
clickInOwnChannel(set) {
|
|
9884
|
+
try {
|
|
9885
|
+
return this.landedInOwnChannel(set.instance, set.adapterId, { chatId: set.chatId, threadId: set.threadId || undefined });
|
|
9886
|
+
}
|
|
9887
|
+
catch {
|
|
9888
|
+
return false;
|
|
9889
|
+
}
|
|
9890
|
+
}
|
|
9891
|
+
afterReplyRouted(instanceName, args, senderSessionName, buttons = null, inChannel = false) {
|
|
9034
9892
|
// A reply is NOT proof the turn is over (#410) — but it is not proof of
|
|
9035
9893
|
// more work either. Split the difference: an instance that is clearly
|
|
9036
9894
|
// idle loses the button now; one that looks busy keeps it (re-posted
|
|
@@ -9066,6 +9924,7 @@ export class FleetManager {
|
|
|
9066
9924
|
ts,
|
|
9067
9925
|
...(attachments.length ? { attachments } : {}),
|
|
9068
9926
|
...(buttons ? { buttons } : {}), // #1266
|
|
9927
|
+
...(inChannel ? { channel: true } : {}), // #1920 §5.2
|
|
9069
9928
|
});
|
|
9070
9929
|
};
|
|
9071
9930
|
const before = this.webReplyOrder.get(instanceName);
|
|
@@ -9460,6 +10319,7 @@ export class FleetManager {
|
|
|
9460
10319
|
if (type === "fleet_get_web_theme" || type === "fleet_set_web_theme" || type === "fleet_set_web_theme_asset" || type === "fleet_reset_web_theme"
|
|
9461
10320
|
|| type === "fleet_get_web_appearance" || type === "fleet_preview_web_appearance"
|
|
9462
10321
|
|| type === "fleet_set_web_widget" || type === "fleet_remove_web_widget"
|
|
10322
|
+
|| type === "fleet_set_web_ambient" || type === "fleet_clear_web_ambient"
|
|
9463
10323
|
|| type === "fleet_export_web_mod" || type === "fleet_propose_web_mod") {
|
|
9464
10324
|
this.handleWebTheme(name, msg);
|
|
9465
10325
|
return;
|
|
@@ -9574,10 +10434,11 @@ export class FleetManager {
|
|
|
9574
10434
|
}
|
|
9575
10435
|
case "fleet_decision_list":
|
|
9576
10436
|
db.pruneExpiredDecisions();
|
|
9577
|
-
|
|
10437
|
+
// #2152: titles by default; the full text by id, or all of it with full: true.
|
|
10438
|
+
result = decisionListAnswer(db.listDecisions(projectRoot, {
|
|
9578
10439
|
includeArchived: payload.include_archived,
|
|
9579
10440
|
tags: payload.tags,
|
|
9580
|
-
});
|
|
10441
|
+
}), { id: payload.id, full: payload.full });
|
|
9581
10442
|
break;
|
|
9582
10443
|
case "fleet_decision_update": {
|
|
9583
10444
|
const id = payload.id;
|
|
@@ -9770,10 +10631,16 @@ export class FleetManager {
|
|
|
9770
10631
|
created_by: instance,
|
|
9771
10632
|
});
|
|
9772
10633
|
}
|
|
9773
|
-
|
|
9774
|
-
|
|
9775
|
-
|
|
9776
|
-
|
|
10634
|
+
// #2152: titles by default (agend-agent decision-list); one decision's full text by id (decision-get <id>).
|
|
10635
|
+
// The CLI sends include_archived (snake case, as the MCP tool): both spellings are read.
|
|
10636
|
+
case "list":
|
|
10637
|
+
case "get":
|
|
10638
|
+
if (op === "get" && !asStr(args.id)?.trim())
|
|
10639
|
+
return { error: "id is required" };
|
|
10640
|
+
return decisionListAnswer(db.listDecisions(projectRoot, {
|
|
10641
|
+
includeArchived: args.includeArchived === true || args.include_archived === true,
|
|
10642
|
+
tags: asStrArr(args.tags),
|
|
10643
|
+
}), { id: op === "get" ? asStr(args.id) ?? "" : args.id, full: args.full });
|
|
9777
10644
|
case "update": {
|
|
9778
10645
|
const id = asStr(args.id);
|
|
9779
10646
|
if (!id)
|
|
@@ -9798,14 +10665,20 @@ export class FleetManager {
|
|
|
9798
10665
|
return (v === "low" || v === "normal" || v === "high" || v === "urgent") ? v : undefined;
|
|
9799
10666
|
};
|
|
9800
10667
|
const asStatus = (v) => {
|
|
9801
|
-
return (v === "open" || v === "claimed" || v === "done" || v === "blocked" || v === "cancelled") ? v : undefined;
|
|
10668
|
+
return (v === "open" || v === "claimed" || v === "review" || v === "done" || v === "blocked" || v === "cancelled") ? v : undefined;
|
|
9802
10669
|
};
|
|
9803
10670
|
// #1336 P2: use the shared normalizer — trims whitespace AND drops empties,
|
|
9804
10671
|
// so " \t " / [" ","\t"] are treated identically to undefined.
|
|
9805
10672
|
const asStatusFilter = normalizeStatusFilter;
|
|
9806
10673
|
// #1336: small write ack — identifying fields only.
|
|
9807
10674
|
const ack = (t) => ({ id: t.id, status: t.status, updated_at: t.updated_at });
|
|
10675
|
+
// The board's notes for the write (a send-back reason, a comment the assignee had not seen): with the ack when any.
|
|
10676
|
+
const noted = (a) => { const notes = db.board.takeNotes(); return notes.length ? { ...a, notes } : a; };
|
|
9808
10677
|
try {
|
|
10678
|
+
// The task board's own actions and fields (task-board-tool.ts); the rest is the tool's plain task, below.
|
|
10679
|
+
const onBoard = boardToolAction(db, instance, args);
|
|
10680
|
+
if (onBoard !== undefined)
|
|
10681
|
+
return onBoard;
|
|
9809
10682
|
switch (action) {
|
|
9810
10683
|
case "create": {
|
|
9811
10684
|
const title = asStr(args.title);
|
|
@@ -9844,24 +10717,24 @@ export class FleetManager {
|
|
|
9844
10717
|
const id = asStr(args.id);
|
|
9845
10718
|
if (!id)
|
|
9846
10719
|
return { error: "id is required" };
|
|
9847
|
-
return ack(db.claimTask(db.getTaskByPrefix(id).id, instance));
|
|
10720
|
+
return noted(ack(db.claimTask(db.getTaskByPrefix(id).id, instance, { kind: "instance", name: instance })));
|
|
9848
10721
|
}
|
|
9849
10722
|
case "done": {
|
|
9850
10723
|
const id = asStr(args.id);
|
|
9851
10724
|
if (!id)
|
|
9852
10725
|
return { error: "id is required" };
|
|
9853
|
-
return ack(db.completeTask(db.getTaskByPrefix(id).id, asStr(args.result)));
|
|
10726
|
+
return noted(ack(db.completeTask(db.getTaskByPrefix(id).id, asStr(args.result), { kind: "instance", name: instance })));
|
|
9854
10727
|
}
|
|
9855
10728
|
case "update": {
|
|
9856
10729
|
const id = asStr(args.id);
|
|
9857
10730
|
if (!id)
|
|
9858
10731
|
return { error: "id is required" };
|
|
9859
|
-
return ack(db.updateTask(db.getTaskByPrefix(id).id, {
|
|
10732
|
+
return noted(ack(db.updateTask(db.getTaskByPrefix(id).id, {
|
|
9860
10733
|
status: asStatus(args.status),
|
|
9861
10734
|
assignee: asStr(args.assignee),
|
|
9862
10735
|
result: asStr(args.result),
|
|
9863
10736
|
priority: asPriority(args.priority),
|
|
9864
|
-
}));
|
|
10737
|
+
}, { kind: "instance", name: instance })));
|
|
9865
10738
|
}
|
|
9866
10739
|
default: return { error: `Unknown task action: ${action}` };
|
|
9867
10740
|
}
|
|
@@ -9955,7 +10828,18 @@ export class FleetManager {
|
|
|
9955
10828
|
// so " \t " / [" ","\t"] are treated identically to undefined.
|
|
9956
10829
|
const asStatusFilter = normalizeStatusFilter;
|
|
9957
10830
|
const ack = (t) => ({ id: t.id, status: t.status, updated_at: t.updated_at });
|
|
10831
|
+
const noted = (a) => { const notes = db.board.takeNotes(); return notes.length ? { ...a, notes } : a; };
|
|
9958
10832
|
try {
|
|
10833
|
+
// The task board's own actions and fields (task-board-tool.ts); the rest is the tool's plain task, below.
|
|
10834
|
+
const onBoard = boardToolAction(db, meta.instance_name || instanceName, payload);
|
|
10835
|
+
if (onBoard !== undefined) {
|
|
10836
|
+
ipc.send({ type: "fleet_task_response", fleetRequestId, result: onBoard });
|
|
10837
|
+
// The activity log hears of board work as it does of plain tasks.
|
|
10838
|
+
const what = boardActivity(payload);
|
|
10839
|
+
if (what)
|
|
10840
|
+
this.eventLog?.logActivity("task_update", instanceName, what);
|
|
10841
|
+
return;
|
|
10842
|
+
}
|
|
9959
10843
|
let result;
|
|
9960
10844
|
// Full task kept for activity logging; the IPC reply uses the compact ack.
|
|
9961
10845
|
let logTask;
|
|
@@ -9991,12 +10875,12 @@ export class FleetManager {
|
|
|
9991
10875
|
result = db.getTaskByPrefix(payload.id);
|
|
9992
10876
|
break;
|
|
9993
10877
|
case "claim":
|
|
9994
|
-
logTask = db.claimTask(db.getTaskByPrefix(payload.id).id, meta.instance_name || instanceName);
|
|
9995
|
-
result = ack(logTask);
|
|
10878
|
+
logTask = db.claimTask(db.getTaskByPrefix(payload.id).id, meta.instance_name || instanceName, { kind: "instance", name: meta.instance_name || instanceName });
|
|
10879
|
+
result = noted(ack(logTask));
|
|
9996
10880
|
break;
|
|
9997
10881
|
case "done":
|
|
9998
|
-
logTask = db.completeTask(db.getTaskByPrefix(payload.id).id, payload.result);
|
|
9999
|
-
result = ack(logTask);
|
|
10882
|
+
logTask = db.completeTask(db.getTaskByPrefix(payload.id).id, payload.result, { kind: "instance", name: meta.instance_name || instanceName });
|
|
10883
|
+
result = noted(ack(logTask));
|
|
10000
10884
|
break;
|
|
10001
10885
|
case "update":
|
|
10002
10886
|
logTask = db.updateTask(db.getTaskByPrefix(payload.id).id, {
|
|
@@ -10004,8 +10888,8 @@ export class FleetManager {
|
|
|
10004
10888
|
assignee: payload.assignee,
|
|
10005
10889
|
result: payload.result,
|
|
10006
10890
|
priority: payload.priority,
|
|
10007
|
-
});
|
|
10008
|
-
result = ack(logTask);
|
|
10891
|
+
}, { kind: "instance", name: meta.instance_name || instanceName });
|
|
10892
|
+
result = noted(ack(logTask));
|
|
10009
10893
|
break;
|
|
10010
10894
|
default:
|
|
10011
10895
|
throw new Error(`Unknown task action: ${action}`);
|
|
@@ -10319,6 +11203,7 @@ export class FleetManager {
|
|
|
10319
11203
|
*/
|
|
10320
11204
|
saveFleetConfig(explicitPatches = []) {
|
|
10321
11205
|
this.publicWebLink?.refresh();
|
|
11206
|
+
this.enforceGuestSwitch(); // #1920: a Settings save turning guests off ends their sessions
|
|
10322
11207
|
if (!this.fleetConfig || !this.configPath)
|
|
10323
11208
|
return;
|
|
10324
11209
|
if (!this.savedFleetConfigSnapshot)
|
|
@@ -10331,6 +11216,10 @@ export class FleetManager {
|
|
|
10331
11216
|
throw new Error(`Refusing to overwrite invalid fleet.yaml: ${this.rawFleetDocument.errors[0].message}`);
|
|
10332
11217
|
}
|
|
10333
11218
|
this.rawFleetConfig = loadRawFleetConfig(this.configPath);
|
|
11219
|
+
// #2208: what the file says before this save, aliases expanded — every write below is recorded, and anything that
|
|
11220
|
+
// changes outside them (a place sharing a node with one written) refuses the save.
|
|
11221
|
+
const fileBefore = this.rawFleetDocument.toJS({ maxAliasCount: -1 });
|
|
11222
|
+
const writes = this.fleetDocumentWrites = []; // a throw below leaves it to the next save
|
|
10334
11223
|
this.patchFleetDocument(this.rawFleetDocument, [], this.savedFleetConfigSnapshot, this.fleetConfig);
|
|
10335
11224
|
// Settings edits are expressed against the raw config, so apply them before
|
|
10336
11225
|
// canonicalization. A non-identity value equal to its inherited default is
|
|
@@ -10341,6 +11230,7 @@ export class FleetManager {
|
|
|
10341
11230
|
// (or a legacy scalar occupies that parent). Removing an override which
|
|
10342
11231
|
// is already absent is an idempotent no-op, not a failed Settings save.
|
|
10343
11232
|
if (this.rawFleetDocument.hasIn(patch.path)) {
|
|
11233
|
+
this.writingFleetDocument(this.rawFleetDocument, patch.path);
|
|
10344
11234
|
this.rawFleetDocument.deleteIn(patch.path);
|
|
10345
11235
|
}
|
|
10346
11236
|
}
|
|
@@ -10352,8 +11242,10 @@ export class FleetManager {
|
|
|
10352
11242
|
const rawAfterPatches = this.rawFleetDocument.toJS();
|
|
10353
11243
|
const redundantPaths = collectRedundantInstanceDefaultPaths(rawAfterPatches);
|
|
10354
11244
|
for (const path of redundantPaths) {
|
|
10355
|
-
if (this.rawFleetDocument.hasIn(path))
|
|
11245
|
+
if (this.rawFleetDocument.hasIn(path)) {
|
|
11246
|
+
this.writingFleetDocument(this.rawFleetDocument, path);
|
|
10356
11247
|
this.rawFleetDocument.deleteIn(path);
|
|
11248
|
+
}
|
|
10357
11249
|
// Avoid leaving empty operational maps such as `terminal: {}` while
|
|
10358
11250
|
// preserving the instance mapping itself and all surrounding comments.
|
|
10359
11251
|
for (let depth = path.length - 1; depth > 2; depth--) {
|
|
@@ -10361,9 +11253,14 @@ export class FleetManager {
|
|
|
10361
11253
|
const parent = this.rawFleetDocument.getIn(parentPath, true);
|
|
10362
11254
|
if (!isMap(parent) || parent.items.length > 0)
|
|
10363
11255
|
break;
|
|
11256
|
+
this.writingFleetDocument(this.rawFleetDocument, parentPath);
|
|
10364
11257
|
this.rawFleetDocument.deleteIn(parentPath);
|
|
10365
11258
|
}
|
|
10366
11259
|
}
|
|
11260
|
+
this.fleetDocumentWrites = null;
|
|
11261
|
+
const stray = changedOutside(fileBefore, this.rawFleetDocument.toJS({ maxAliasCount: -1 }), writes);
|
|
11262
|
+
if (stray)
|
|
11263
|
+
throw new Error(`Refusing to save fleet.yaml: this change would also change ${stray}, which shares a value with what it edits. Give each its own value in fleet.yaml.`);
|
|
10367
11264
|
noteSettingsWrite(this.configPath, this.savedFleetConfigSnapshot, this.fleetConfig);
|
|
10368
11265
|
const output = String(this.rawFleetDocument);
|
|
10369
11266
|
if (redundantPaths.length > 0)
|
|
@@ -10474,11 +11371,15 @@ export class FleetManager {
|
|
|
10474
11371
|
// Remove from the end so YAML sequence indexes do not shift underneath us.
|
|
10475
11372
|
for (let i = before.length - 1; i >= after.length; i--) {
|
|
10476
11373
|
const itemPath = [...path, i];
|
|
10477
|
-
if (document.hasIn(itemPath))
|
|
11374
|
+
if (document.hasIn(itemPath)) {
|
|
11375
|
+
this.writingFleetDocument(document, itemPath);
|
|
10478
11376
|
document.deleteIn(itemPath);
|
|
11377
|
+
}
|
|
10479
11378
|
}
|
|
10480
|
-
for (let i = shared; i < after.length; i++)
|
|
11379
|
+
for (let i = shared; i < after.length; i++) {
|
|
11380
|
+
this.writingFleetDocument(document, [...path, i]);
|
|
10481
11381
|
document.setIn([...path, i], after[i]);
|
|
11382
|
+
}
|
|
10482
11383
|
return;
|
|
10483
11384
|
}
|
|
10484
11385
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -10494,6 +11395,7 @@ export class FleetManager {
|
|
|
10494
11395
|
if (isMap(fresh)) {
|
|
10495
11396
|
const list = document.createNode([]);
|
|
10496
11397
|
list.items.push(fresh.clone());
|
|
11398
|
+
this.writingFleetDocument(document, ["channels"]);
|
|
10497
11399
|
document.setIn(["channels"], list);
|
|
10498
11400
|
}
|
|
10499
11401
|
}
|
|
@@ -10515,13 +11417,17 @@ export class FleetManager {
|
|
|
10515
11417
|
// Effective config contains inherited objects that may not exist in the
|
|
10516
11418
|
// raw YAML at all. yaml.deleteIn() is not idempotent for a missing nested
|
|
10517
11419
|
// parent, so guard it explicitly.
|
|
10518
|
-
if (document.hasIn(path))
|
|
11420
|
+
if (document.hasIn(path)) {
|
|
11421
|
+
this.writingFleetDocument(document, path);
|
|
10519
11422
|
document.deleteIn(path);
|
|
11423
|
+
}
|
|
10520
11424
|
}
|
|
10521
11425
|
else if (path.length === 0) {
|
|
11426
|
+
this.writingFleetDocument(document, path);
|
|
10522
11427
|
document.contents = document.createNode(after);
|
|
10523
11428
|
}
|
|
10524
11429
|
else {
|
|
11430
|
+
this.writingFleetDocument(document, path);
|
|
10525
11431
|
const currentNode = document.getIn(path, true);
|
|
10526
11432
|
if (isScalar(currentNode) && (after === null || typeof after !== "object")) {
|
|
10527
11433
|
currentNode.value = after;
|
|
@@ -10536,6 +11442,15 @@ export class FleetManager {
|
|
|
10536
11442
|
}
|
|
10537
11443
|
}
|
|
10538
11444
|
}
|
|
11445
|
+
/**
|
|
11446
|
+
* #2208: before every write to the raw document — the place is unshared first (an alias on the way becomes its own
|
|
11447
|
+
* copy; an anchor's other aliases get theirs), so the write changes that place only; and it is recorded for the
|
|
11448
|
+
* save's check that nothing else changed.
|
|
11449
|
+
*/
|
|
11450
|
+
writingFleetDocument(document, path) {
|
|
11451
|
+
detachForWrite(document, path);
|
|
11452
|
+
this.fleetDocumentWrites?.push([...path]);
|
|
11453
|
+
}
|
|
10539
11454
|
getSettingsConfigPath() { return this.configPath; }
|
|
10540
11455
|
async removeInstance(name, authorization, execution) {
|
|
10541
11456
|
if (execution)
|
|
@@ -10597,6 +11512,7 @@ export class FleetManager {
|
|
|
10597
11512
|
// Its web chat goes with it (only after the removal succeeded): a later instance of the same name
|
|
10598
11513
|
// must not be shown the old one's conversation, and deleted names must not pile up.
|
|
10599
11514
|
this.webChatHistory.forget(name);
|
|
11515
|
+
this.searchIndex?.removeInstance(name);
|
|
10600
11516
|
this.webFiles.forget(name);
|
|
10601
11517
|
this.compactWatchService?.forget(name);
|
|
10602
11518
|
// #1664: and it is nobody's favorite any more (every device sees that at its next status frame).
|
|
@@ -10808,11 +11724,23 @@ export class FleetManager {
|
|
|
10808
11724
|
guildEmojiFetches = new Map();
|
|
10809
11725
|
// ── #1630: what stands for an instance in the web app, and Discord server emoji in web chat ──
|
|
10810
11726
|
webImageCaches = null;
|
|
11727
|
+
/** Server emoji whose picture fetch failure was already logged at warn (once each; bounded). */
|
|
11728
|
+
webEmojiFailures = new Set();
|
|
10811
11729
|
webImageCache(kind) {
|
|
10812
11730
|
const onError = (key, err) => this.logger.debug({ err: err?.message, key }, "web image fetch failed (kept what was cached)");
|
|
11731
|
+
// A server emoji's picture that cannot be fetched reads `:name:` in web chat (it used to be an empty box, the user's
|
|
11732
|
+
// "tofu"): say why once per emoji, where it can be seen, not on every render (its key is quiet for an hour anyway).
|
|
11733
|
+
const emojiError = (key, err) => {
|
|
11734
|
+
if (this.webEmojiFailures.has(key))
|
|
11735
|
+
return onError(key, err);
|
|
11736
|
+
this.webEmojiFailures.add(key);
|
|
11737
|
+
while (this.webEmojiFailures.size > 512)
|
|
11738
|
+
this.webEmojiFailures.delete(this.webEmojiFailures.values().next().value);
|
|
11739
|
+
this.logger.warn({ err: err?.message, key }, "Discord server emoji picture could not be fetched (web chat shows its :name:)");
|
|
11740
|
+
};
|
|
10813
11741
|
this.webImageCaches ??= {
|
|
10814
11742
|
avatars: new WebImageCache({ dir: join(this.dataDir, "cache", "avatars"), onError }),
|
|
10815
|
-
emoji: new WebImageCache({ dir: join(this.dataDir, "cache", "emoji"), onError }),
|
|
11743
|
+
emoji: new WebImageCache({ dir: join(this.dataDir, "cache", "emoji"), onError: emojiError }),
|
|
10816
11744
|
};
|
|
10817
11745
|
return this.webImageCaches[kind];
|
|
10818
11746
|
}
|
|
@@ -12342,6 +13270,8 @@ export class FleetManager {
|
|
|
12342
13270
|
}
|
|
12343
13271
|
/** Routes a click to the handler that owns its prefix; false when none does. */
|
|
12344
13272
|
async dispatchAdapterCallback(data, adapterId, adapter) {
|
|
13273
|
+
if (await this.sessionAuthorizationRequests().handleCallback(data, adapterId))
|
|
13274
|
+
return true; // #1939 S2b
|
|
12345
13275
|
if (data.callbackData.startsWith(REPLY_BUTTON_PREFIX)) { // #1266
|
|
12346
13276
|
const buttons = this.replyButtons();
|
|
12347
13277
|
if (!buttons) {
|
|
@@ -12479,7 +13409,7 @@ export class FleetManager {
|
|
|
12479
13409
|
cacheInstances() {
|
|
12480
13410
|
const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
|
|
12481
13411
|
name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
|
|
12482
|
-
claudeConfigDir: this.claudeConfigDirOf(name),
|
|
13412
|
+
claudeConfigDir: this.claudeConfigDirOf(name), profile: this.usageProfileOf(name, this.backendNameOf(name)),
|
|
12483
13413
|
}));
|
|
12484
13414
|
const taken = new Set(fleet.map(i => i.name));
|
|
12485
13415
|
const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
|
|
@@ -12488,6 +13418,7 @@ export class FleetManager {
|
|
|
12488
13418
|
workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
|
|
12489
13419
|
ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
|
|
12490
13420
|
claudeConfigDir: this.claudeConfigDirOf(ch.instanceName),
|
|
13421
|
+
profile: this.usageProfileOf(ch.instanceName, this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code"),
|
|
12491
13422
|
}));
|
|
12492
13423
|
return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
|
|
12493
13424
|
}
|
|
@@ -12499,6 +13430,45 @@ export class FleetManager {
|
|
|
12499
13430
|
cacheReport(window) {
|
|
12500
13431
|
return this.cacheServiceFor().report(cacheWindow(window));
|
|
12501
13432
|
}
|
|
13433
|
+
/**
|
|
13434
|
+
* #2122 §5.1: the profile the usage read now is tagged with — the claude profile whose config dir is read ("default"
|
|
13435
|
+
* without one); codex "unrecorded" (its rollouts don't say which login wrote them).
|
|
13436
|
+
*/
|
|
13437
|
+
usageProfileOf(name, backend) {
|
|
13438
|
+
if (backend === "claude-code")
|
|
13439
|
+
return this.credentialProfileOf(name, "claude-code") ?? "default";
|
|
13440
|
+
if (backend === "codex")
|
|
13441
|
+
return "unrecorded";
|
|
13442
|
+
return undefined;
|
|
13443
|
+
}
|
|
13444
|
+
/** #2122: the fleet's day boundary zone — defaults.scheduler.default_timezone, else this host's. */
|
|
13445
|
+
usageZone() {
|
|
13446
|
+
return this.fleetConfig?.defaults?.scheduler?.default_timezone?.trim() || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
13447
|
+
}
|
|
13448
|
+
/**
|
|
13449
|
+
* #2122: the usage report (usage-report.ts) — from the ledgers and rollups as they are now and the delivered-message
|
|
13450
|
+
* counts; never a transcript read. `only`: just that instance (an ordinary agent's own row).
|
|
13451
|
+
*/
|
|
13452
|
+
async usageReport(params) {
|
|
13453
|
+
const { buildUsageReport } = await import("./usage-report.js");
|
|
13454
|
+
const data = await this.cacheServiceFor().usageData();
|
|
13455
|
+
const team = new Map();
|
|
13456
|
+
for (const [t, cfg] of Object.entries(this.fleetConfig?.teams ?? {}))
|
|
13457
|
+
for (const m of cfg?.members ?? [])
|
|
13458
|
+
if (!team.has(m))
|
|
13459
|
+
team.set(m, t);
|
|
13460
|
+
const now = Date.now();
|
|
13461
|
+
this.ensureDeliveryOutbox?.();
|
|
13462
|
+
// Every hour of the kept history: the report places each on a local date and keeps the range's own.
|
|
13463
|
+
const messages = this.deliveryOutbox?.deliveredMessageHours(now - 401 * 86_400_000, now + 3_600_000) ?? [];
|
|
13464
|
+
const keep = (name) => params.only === undefined || name === params.only;
|
|
13465
|
+
return buildUsageReport({
|
|
13466
|
+
now, zone: this.usageZone(), range: params.range, group: params.group, scanning: data.scanning,
|
|
13467
|
+
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,
|
|
13468
|
+
status: i.status, ...(i.with ? { with: i.with } : {}), ledger: i.ledger, days: i.days })),
|
|
13469
|
+
messages: messages.filter(m => keep(m.instance)),
|
|
13470
|
+
});
|
|
13471
|
+
}
|
|
12502
13472
|
/** #2001: get_cache_report's — the same report, as last worked out (reused for a minute, until the next pass). */
|
|
12503
13473
|
cacheReportPublished(window) {
|
|
12504
13474
|
return this.cacheServiceFor().published(cacheWindow(window));
|
|
@@ -12510,7 +13480,9 @@ export class FleetManager {
|
|
|
12510
13480
|
claudeKey: claudeProjectKey,
|
|
12511
13481
|
codexSessionsDir: () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions"),
|
|
12512
13482
|
listRollouts: root => sharedRolloutIndex(root).list(),
|
|
13483
|
+
rolloutsUnreadable: root => sharedRolloutIndex(root).unreadable,
|
|
12513
13484
|
metaPath: join(this.dataDir, "cache-codex-rollouts.json"),
|
|
13485
|
+
zone: () => this.usageZone(),
|
|
12514
13486
|
log: (level, msg, extra) => this.logger[level](extra ?? {}, msg),
|
|
12515
13487
|
});
|
|
12516
13488
|
}
|
|
@@ -12558,6 +13530,7 @@ export class FleetManager {
|
|
|
12558
13530
|
interactionSummary: p.interaction_summary ?? null,
|
|
12559
13531
|
pauseReason, pausedAt,
|
|
12560
13532
|
crashedAt: crashed ? (this.needsCrashedAt.get(name) ?? null) : null,
|
|
13533
|
+
signIn: pauseReason === "auth" ? this.signInFor(name) : null,
|
|
12561
13534
|
});
|
|
12562
13535
|
}
|
|
12563
13536
|
return out;
|
|
@@ -13022,6 +13995,198 @@ export class FleetManager {
|
|
|
13022
13995
|
webBackendTokens = new Map();
|
|
13023
13996
|
/** #1269: the web's /clear confirmations — one token per question, used once, for the instance as it was asked about. */
|
|
13024
13997
|
webClearTokens = new Map();
|
|
13998
|
+
/**
|
|
13999
|
+
* #1939 S1: the design's §3 for one web request (authority/web-authority.ts), with this fleet's admins, policy and
|
|
14000
|
+
* token; `instance` once the body has named it. Observe only: receipts record it, no agent acts on it yet.
|
|
14001
|
+
*/
|
|
14002
|
+
webAuthority(input) {
|
|
14003
|
+
const state = this.webAuthorityState();
|
|
14004
|
+
return evaluateWebAuthority({
|
|
14005
|
+
session: input.session, gateway: input.gateway, peer: input.peer,
|
|
14006
|
+
tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
|
|
14007
|
+
...(input.instance !== undefined ? { target: { adapterId: this.getInstanceAdapterId(input.instance) } } : {}),
|
|
14008
|
+
isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
|
|
14009
|
+
authorization: input.session ? this.sessionAuthorizationStore()?.get(input.session.handle) ?? null : null,
|
|
14010
|
+
policy: { ...webAuthorityPolicy(this.fleetConfig?.web), enabled: state.enabled, loopbackOnly: state.loopbackOnly }, epoch: state.epoch, now: Date.now(),
|
|
14011
|
+
});
|
|
14012
|
+
}
|
|
14013
|
+
sessionAuthorizations = null;
|
|
14014
|
+
sessionAuthorizationStore() {
|
|
14015
|
+
if (!this.dataDir)
|
|
14016
|
+
return null;
|
|
14017
|
+
this.sessionAuthorizations ??= new SessionAuthorizations(join(this.dataDir, "web-authorizations.json"), this.logger);
|
|
14018
|
+
return this.sessionAuthorizations;
|
|
14019
|
+
}
|
|
14020
|
+
/**
|
|
14021
|
+
* #1939 §0: record that a session's account has authorized it — called only once that account has confirmed on Discord
|
|
14022
|
+
* or Telegram (S2b). The session must qualify now (§3, as a message from it would be judged); then every message,
|
|
14023
|
+
* click, /steer and /btw from it is web-verified until max_session_hours after now, the session ends, or a revocation.
|
|
14024
|
+
* Ones no longer worth keeping (their session gone, an old epoch, run out) are dropped on the way.
|
|
14025
|
+
*/
|
|
14026
|
+
authorizeWebSession(session, peer, gateway) {
|
|
14027
|
+
const store = this.sessionAuthorizationStore();
|
|
14028
|
+
const state = this.webAuthorityState(), now = Date.now(), policy = webAuthorityPolicy(this.fleetConfig?.web);
|
|
14029
|
+
if (!store || !session.owner)
|
|
14030
|
+
return { ok: false, refusal: !store ? "disabled" : "no_account" };
|
|
14031
|
+
const check = evaluateWebAuthority({
|
|
14032
|
+
session, gateway, peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
|
|
14033
|
+
isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
|
|
14034
|
+
authorization: { handle: session.handle, owner: session.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch },
|
|
14035
|
+
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly }, epoch: state.epoch, now,
|
|
14036
|
+
});
|
|
14037
|
+
if (check.refusal)
|
|
14038
|
+
return { ok: false, refusal: check.refusal };
|
|
14039
|
+
const live = new Set(this.webSessions?.list(undefined, () => true).map(r => r.handle) ?? []);
|
|
14040
|
+
store.prune(a => a.epoch === state.epoch && now <= Math.min(a.until, a.authorizedAt + policy.maxSessionMs) && live.has(a.handle));
|
|
14041
|
+
return { ok: true, ...store.authorize(session.handle, session.owner, now, state.epoch, now + policy.maxSessionMs) };
|
|
14042
|
+
}
|
|
14043
|
+
authorityStateStore = null;
|
|
14044
|
+
/**
|
|
14045
|
+
* #1939 §10: the switch, "Only this computer can authorize" and the revocation epoch in force for the configuration as
|
|
14046
|
+
* it is now (a change since last seen moves the epoch first; authority-state.ts). No data directory: off.
|
|
14047
|
+
*/
|
|
14048
|
+
webAuthorityState() {
|
|
14049
|
+
if (!this.dataDir)
|
|
14050
|
+
return { enabled: false, loopbackOnly: true, epoch: 0 };
|
|
14051
|
+
this.authorityStateStore ??= new AuthorityStateStore(join(this.dataDir, "web-authority.json"), this.logger, undefined, undefined, () => { this.ensureDeliveryOutbox?.(); return this.deliveryOutbox?.maxWebAuthorityEpoch() ?? 0; });
|
|
14052
|
+
return this.authorityStateStore.current(this.fleetConfig?.web?.verified_authority);
|
|
14053
|
+
}
|
|
14054
|
+
/**
|
|
14055
|
+
* #1939 S2a: Settings → Web → Web authority — the state in force (the switch counts as off while its state cannot be
|
|
14056
|
+
* written), what fleet.yaml says, the hosts that make the dashboard reachable from elsewhere (the warning while
|
|
14057
|
+
* loopback_only is off), and the sessions authorized now (§0): each one's device, account, and when it runs out.
|
|
14058
|
+
*/
|
|
14059
|
+
webAuthorityOverview() {
|
|
14060
|
+
const configured = this.fleetConfig?.web?.verified_authority ?? {};
|
|
14061
|
+
const lanHosts = (this.fleetConfig?.web?.allowed_hosts ?? []).filter(h => { const name = hostnameOf(String(h)); return name !== null && !LOOPBACK_HOST_NAMES.includes(name); });
|
|
14062
|
+
const state = this.webAuthorityState(), maxMs = webAuthorityPolicy(this.fleetConfig?.web).maxSessionMs, now = Date.now();
|
|
14063
|
+
const sessions = new Map((this.webSessions?.list(undefined, () => true) ?? []).map(r => [r.handle, r]));
|
|
14064
|
+
const authorized = state.enabled ? (this.sessionAuthorizationStore()?.list() ?? [])
|
|
14065
|
+
.filter(a => a.epoch === state.epoch && sessions.has(a.handle) && now <= Math.min(a.until, a.authorizedAt + maxMs))
|
|
14066
|
+
.map(a => ({ handle: a.handle, label: sessions.get(a.handle).label, account: `${a.owner.adapterId}:${a.owner.userId}`, authorizedAt: a.authorizedAt,
|
|
14067
|
+
until: Math.min(a.until, a.authorizedAt + maxMs, sessions.get(a.handle).absoluteExpiry) })) : [];
|
|
14068
|
+
return { state, configured, hours: VERIFIED_SESSION_HOURS, lanHosts, authorized };
|
|
14069
|
+
}
|
|
14070
|
+
authorizationRequests = null;
|
|
14071
|
+
/**
|
|
14072
|
+
* #1939 S2b (§0.1): "Authorize this browser" — the Confirm card in the account's DM. Confirm runs §3 again at the tap
|
|
14073
|
+
* (authorizeWebSession on the session read again by its handle); Not me signs the session out.
|
|
14074
|
+
*/
|
|
14075
|
+
sessionAuthorizationRequests() {
|
|
14076
|
+
if (this.authorizationRequests)
|
|
14077
|
+
return this.authorizationRequests;
|
|
14078
|
+
const adapterOf = (adapterId) => this.adapters.get(adapterId) ?? this.worlds.get(adapterId)?.adapter;
|
|
14079
|
+
// The host's clock, with its zone: the person may read the DM on a phone in another one.
|
|
14080
|
+
const when = (ms) => new Date(ms).toLocaleString(undefined, { timeZoneName: "short" });
|
|
14081
|
+
const where = (peer) => t(peer === "loopback" ? "web_auth.peer_loopback" : "web_auth.peer_lan");
|
|
14082
|
+
return this.authorizationRequests = new SessionAuthorizationRequests({
|
|
14083
|
+
now: () => performance.now(), wallNow: () => Date.now(),
|
|
14084
|
+
maxSessionMs: () => webAuthorityPolicy(this.fleetConfig?.web).maxSessionMs,
|
|
14085
|
+
sendDirect: async (owner, text, choices) => {
|
|
14086
|
+
const adapter = adapterOf(owner.adapterId);
|
|
14087
|
+
if (!adapter?.sendDirect)
|
|
14088
|
+
return null;
|
|
14089
|
+
const sent = await adapter.sendDirect(owner.userId, text, { choices, allowedMentions: { parse: [] } });
|
|
14090
|
+
return sent?.messageId ? { chatId: sent.chatId, messageId: sent.messageId } : null;
|
|
14091
|
+
},
|
|
14092
|
+
sendGeneralHint: async (owner, text) => {
|
|
14093
|
+
const adapter = adapterOf(owner.adapterId), general = this.findGeneralInstance(owner.adapterId);
|
|
14094
|
+
const topic = general ? this.fleetConfig?.instances[general]?.topic_id : undefined;
|
|
14095
|
+
const group = general ? this.getGroupIdForInstance(general) : undefined;
|
|
14096
|
+
if (adapter && group)
|
|
14097
|
+
await adapter.sendText(String(group), text, { ...(topic != null ? { threadId: String(topic) } : {}), allowedMentions: { parse: [] } });
|
|
14098
|
+
},
|
|
14099
|
+
editCard: async (owner, at, text, choices) => {
|
|
14100
|
+
const adapter = adapterOf(owner.adapterId);
|
|
14101
|
+
if (!adapter)
|
|
14102
|
+
return;
|
|
14103
|
+
if (!choices.length && adapter.editMessageRemoveButtons) {
|
|
14104
|
+
await adapter.editMessageRemoveButtons(at.chatId, at.messageId, text);
|
|
14105
|
+
return;
|
|
14106
|
+
}
|
|
14107
|
+
await adapter.editMessage(at.chatId, at.messageId, text);
|
|
14108
|
+
if (adapter.replaceMessageButtons)
|
|
14109
|
+
await adapter.replaceMessageButtons(at.chatId, at.messageId, choices);
|
|
14110
|
+
},
|
|
14111
|
+
authorize: (handle, peer) => {
|
|
14112
|
+
const record = this.webSessions?.findByHandle(handle);
|
|
14113
|
+
if (!record)
|
|
14114
|
+
return { ok: false, refusal: "session_gone" };
|
|
14115
|
+
const r = this.authorizeWebSession(record, peer, false);
|
|
14116
|
+
// What the card says: the end of the authorization, or the session's own end when that comes first.
|
|
14117
|
+
return r.ok ? { ok: true, until: Math.min(r.authorization.until, record.absoluteExpiry) } : { ok: false, refusal: r.refusal };
|
|
14118
|
+
},
|
|
14119
|
+
signOut: (handle) => {
|
|
14120
|
+
this.webSessions?.revokeByHandle(handle);
|
|
14121
|
+
this.sessionAuthorizationStore()?.prune(a => a.handle !== handle);
|
|
14122
|
+
},
|
|
14123
|
+
event: (type, payload) => { try {
|
|
14124
|
+
this.eventLog?.insert("web", type, payload);
|
|
14125
|
+
}
|
|
14126
|
+
catch { /* the event log is best effort */ } },
|
|
14127
|
+
text: {
|
|
14128
|
+
ask: (c) => t("web_auth.card_ask", c.label, where(c.peer), when(c.askedAt), when(c.until)),
|
|
14129
|
+
confirmed: (c) => t("web_auth.card_confirmed", c.label, where(c.peer), when(c.until)),
|
|
14130
|
+
denied: (c) => t("web_auth.card_denied", c.label),
|
|
14131
|
+
expired: (c) => t("web_auth.card_expired", c.label),
|
|
14132
|
+
refused: (c) => t("web_auth.card_refused", c.label, t(`web_auth.refusal_${c.refusal}`)),
|
|
14133
|
+
revoked: (c) => t("web_auth.card_revoked", c.label),
|
|
14134
|
+
ended: (c) => t("web_auth.card_ended", c.label),
|
|
14135
|
+
generalHint: () => t("web_auth.general_hint"),
|
|
14136
|
+
confirmLabel: () => t("web_auth.confirm"),
|
|
14137
|
+
notMeLabel: () => t("web_auth.not_me"),
|
|
14138
|
+
notYours: () => t("web_auth.not_yours"),
|
|
14139
|
+
},
|
|
14140
|
+
log: this.logger,
|
|
14141
|
+
});
|
|
14142
|
+
}
|
|
14143
|
+
/** #1939 S2b: the web session asks to be authorized (the request judged by §3 first — as it will be again at the tap). */
|
|
14144
|
+
async requestSessionAuthorization(input) {
|
|
14145
|
+
const s = input.session;
|
|
14146
|
+
const gate = this.webAuthorityQualifies(input);
|
|
14147
|
+
if (gate)
|
|
14148
|
+
return { ok: false, refusal: gate };
|
|
14149
|
+
return this.sessionAuthorizationRequests().ask({ handle: s.handle, owner: s.owner, label: s.label, peer: input.peer, sessionEnd: s.absoluteExpiry });
|
|
14150
|
+
}
|
|
14151
|
+
/**
|
|
14152
|
+
* #1939 §3 without the authorization: would this request's session qualify to be authorized? null: yes; otherwise the
|
|
14153
|
+
* first check it fails (for the app to say why it can't be authorized here).
|
|
14154
|
+
*/
|
|
14155
|
+
webAuthorityQualifies(input) {
|
|
14156
|
+
const s = input.session, state = this.webAuthorityState(), now = Date.now(), policy = webAuthorityPolicy(this.fleetConfig?.web);
|
|
14157
|
+
const r = evaluateWebAuthority({
|
|
14158
|
+
session: s, gateway: input.gateway, peer: input.peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
|
|
14159
|
+
isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
|
|
14160
|
+
authorization: s?.owner ? { handle: s.handle, owner: s.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch } : null,
|
|
14161
|
+
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly }, epoch: state.epoch, now,
|
|
14162
|
+
});
|
|
14163
|
+
return r.refusal;
|
|
14164
|
+
}
|
|
14165
|
+
/** #1939 S2b: what the app shows for this browser — verified (until when), a card pending, or why not. */
|
|
14166
|
+
webSessionAuthorization(input) {
|
|
14167
|
+
const s = input.session;
|
|
14168
|
+
const verdict = this.webAuthority(input);
|
|
14169
|
+
if (!verdict.refusal && verdict.authority.session) {
|
|
14170
|
+
const a = this.sessionAuthorizationStore()?.get(s.handle);
|
|
14171
|
+
const max = webAuthorityPolicy(this.fleetConfig?.web).maxSessionMs;
|
|
14172
|
+
if (a)
|
|
14173
|
+
return { state: "verified", until: Math.min(a.until, a.authorizedAt + max, s.absoluteExpiry) };
|
|
14174
|
+
}
|
|
14175
|
+
const pending = s ? this.sessionAuthorizationRequests().pending(s.handle) : null;
|
|
14176
|
+
if (pending)
|
|
14177
|
+
return { state: "pending", expiresInMs: pending.expiresInMs };
|
|
14178
|
+
return { state: "none", reason: this.webAuthorityQualifies(input) ?? (verdict.refusal ?? "not_authorized") };
|
|
14179
|
+
}
|
|
14180
|
+
/** #1939 §10: revoke every web authority given so far (receipts read as none), without signing anyone out. */
|
|
14181
|
+
revokeWebAuthority() {
|
|
14182
|
+
this.webAuthorityState(); // any switch change first, then this revocation
|
|
14183
|
+
const result = this.authorityStateStore?.revoke() ?? { epoch: 0, durable: false };
|
|
14184
|
+
if (result.durable)
|
|
14185
|
+
this.logger.info({ epoch: result.epoch }, "Web authority revoked");
|
|
14186
|
+
else
|
|
14187
|
+
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");
|
|
14188
|
+
return result;
|
|
14189
|
+
}
|
|
13025
14190
|
/** #1269: an instance's chat command from the web chat (web-commands.ts); `publicLink`: a gateway request. */
|
|
13026
14191
|
webCommand(input, opts) {
|
|
13027
14192
|
const webMeta = (instance) => {
|
|
@@ -13030,15 +14195,16 @@ export class FleetManager {
|
|
|
13030
14195
|
// #1827: a /steer or /btw reaches the agent as the web person who wrote it.
|
|
13031
14196
|
chatId: this.getGroupIdForInstance(instance) || "", messageId: newWebMessageId(),
|
|
13032
14197
|
username: opts.person?.name ?? WEB_NAME_FALLBACK, userId: opts.person?.userId ?? WEB_NAME_FALLBACK,
|
|
13033
|
-
threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web", humanOrigin: webHumanOrigin(true),
|
|
14198
|
+
threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web", humanOrigin: webHumanOrigin(true, opts.authority),
|
|
13034
14199
|
};
|
|
13035
14200
|
};
|
|
13036
14201
|
const choicesFromCache = (instance) => {
|
|
13037
|
-
const
|
|
14202
|
+
const backend = this.backendNameForInstance(instance);
|
|
14203
|
+
const cached = this.readCliEnv(backend, this.catalogProfile(backend, instance)); // the cached catalog only: never a probe here
|
|
13038
14204
|
if (!cached?.models.length)
|
|
13039
14205
|
return null;
|
|
13040
14206
|
const current = this.resolveInstanceModel(instance).model;
|
|
13041
|
-
return { current, options: cached.models.map(o => ({ id: o.id, label: this.modelChoiceLabel(o, current) })) };
|
|
14207
|
+
return { current, options: this.withCurrentModel(cached.models, current).map(o => ({ id: o.id, label: this.modelChoiceLabel(o, current) })) };
|
|
13042
14208
|
};
|
|
13043
14209
|
return runWebCommand({
|
|
13044
14210
|
// Own entries only: a name like "__proto__" or "constructor" must not be found on the prototype (#1476 review).
|
|
@@ -13075,7 +14241,7 @@ export class FleetManager {
|
|
|
13075
14241
|
const current = this.resolveInstanceEffort(instance).effort;
|
|
13076
14242
|
return { current, options: levels.map(l => ({ id: l, label: this.effortChoiceLabel(l, current) })) };
|
|
13077
14243
|
},
|
|
13078
|
-
backendStep: async (instance, args) => {
|
|
14244
|
+
backendStep: async (instance, args, stepOpts) => {
|
|
13079
14245
|
const refusal = this.backendCommandRefusal(instance);
|
|
13080
14246
|
if (refusal)
|
|
13081
14247
|
return { error: refusal };
|
|
@@ -13085,7 +14251,13 @@ export class FleetManager {
|
|
|
13085
14251
|
return step.noop ? { text: step.text } : { error: step.text };
|
|
13086
14252
|
if (step.kind === "choose") {
|
|
13087
14253
|
const id = (token) => joinBackendArgs([...step.args, token]);
|
|
13088
|
-
return { choices: { prompt: step.text, current: step.current === null ? null : id(step.current), options: step.choices.map(c =>
|
|
14254
|
+
return { choices: { prompt: step.text, current: step.current === null ? null : id(step.current), options: step.choices.map(c => {
|
|
14255
|
+
// #2233: the web offers Sign in beside it, so its reason is no longer "use /login".
|
|
14256
|
+
// Not over the public link: Sign in is refused there, so the choice keeps its "(use /login)" (#2262 review).
|
|
14257
|
+
const signIn = c.signIn && !stepOpts?.publicLink && dashboardSignInSupported(c.signIn) ? c.signIn : null;
|
|
14258
|
+
const disabled = signIn ? t("backend.reason_not_signed_in_web") : c.disabled;
|
|
14259
|
+
return { id: id(c.token), label: signIn ? `${c.token} — ${disabled}` : c.label, ...(disabled ? { disabled } : {}), ...(signIn ? { signIn } : {}) };
|
|
14260
|
+
}) } };
|
|
13089
14261
|
}
|
|
13090
14262
|
const now = performance.now();
|
|
13091
14263
|
for (const [k, v] of this.webBackendTokens)
|
|
@@ -14223,7 +15395,12 @@ export class FleetManager {
|
|
|
14223
15395
|
fleetConfig: () => this.fleetConfig,
|
|
14224
15396
|
isFleetAdmin: (userId, adapterId) => this.isFleetAdmin(userId, adapterId),
|
|
14225
15397
|
eventLog: () => this.eventLog,
|
|
14226
|
-
recoverBackendInstances: (backend, profile) =>
|
|
15398
|
+
recoverBackendInstances: (backend, profile) => {
|
|
15399
|
+
// #2072: a sign-in just finished — the pickers learn it now, not at the next pass.
|
|
15400
|
+
if (!profile)
|
|
15401
|
+
void this.backendAvailability.refresh([backend], { force: true });
|
|
15402
|
+
return this.recoverBackendInstances(backend, undefined, profile ?? null);
|
|
15403
|
+
},
|
|
14227
15404
|
claimWindow: backend => this.loginWindow.tryClaim("web", backend),
|
|
14228
15405
|
releaseWindow: claim => { this.loginWindow.release(claim); },
|
|
14229
15406
|
isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
|
|
@@ -14244,6 +15421,86 @@ export class FleetManager {
|
|
|
14244
15421
|
}
|
|
14245
15422
|
return this.loginController;
|
|
14246
15423
|
}
|
|
15424
|
+
/** #2233: sign-ins started from the web dashboard (local admin sessions), on the same controller as chat's. */
|
|
15425
|
+
webBackendLoginsInstance = null;
|
|
15426
|
+
get webBackendLogins() {
|
|
15427
|
+
this.webBackendLoginsInstance ??= new WebBackendLogins({
|
|
15428
|
+
isLocalAdmin: handle => this.isLocalAdminSession(handle),
|
|
15429
|
+
start: (backend, chat, opts) => this.startWebDashboardLogin(backend, chat, opts),
|
|
15430
|
+
accessToken: sink => this.loginController?.webAccessToken(sink) ?? null,
|
|
15431
|
+
isActiveFor: sink => this.loginController?.isActiveFor(sink) ?? false,
|
|
15432
|
+
cancelFor: sink => this.loginController?.cancelFor(sink) ?? Promise.resolve(false),
|
|
15433
|
+
cancel: () => this.cancelLoginSession(),
|
|
15434
|
+
notice: ({ shown, label, handle }) => this.notifyFleetError(t("login.web_dashboard_notice", shown, label, handle.slice(0, 8))),
|
|
15435
|
+
// No details in the event: each page reads its own view (GET /ui/backend-login), as its session may see it.
|
|
15436
|
+
changed: () => broadcastSseEvent(this.sseClients, "backend_login", {}, err => this.logger.debug({ err }, "backend_login event failed")),
|
|
15437
|
+
});
|
|
15438
|
+
return this.webBackendLoginsInstance;
|
|
15439
|
+
}
|
|
15440
|
+
/** #2233: the login a paused-for-sign-in instance needs, when the dashboard can sign it in. */
|
|
15441
|
+
signInFor(name) {
|
|
15442
|
+
const backend = this.backendNameForInstance(name);
|
|
15443
|
+
const profile = this.credentialProfileOf(name, backend);
|
|
15444
|
+
return dashboardSignInSupported(backend, profile) ? { backend, ...(profile ? { profile } : {}) } : null;
|
|
15445
|
+
}
|
|
15446
|
+
/**
|
|
15447
|
+
* #2233: Settings → Agents' sign-in list — every backend's default login as the availability snapshot has it, then
|
|
15448
|
+
* each credential profile an agent uses (its own login state, read offline).
|
|
15449
|
+
*/
|
|
15450
|
+
backendSignInTargets() {
|
|
15451
|
+
const out = [];
|
|
15452
|
+
for (const row of this.backendAvailabilityRows()) {
|
|
15453
|
+
const flow = LOGIN_FLOWS[row.backend];
|
|
15454
|
+
const install = BACKEND_INSTALLATION_INFO[row.backend]?.install;
|
|
15455
|
+
out.push({
|
|
15456
|
+
// Not installed or too old: signing in would not help — the row says how to install (or update) it instead.
|
|
15457
|
+
backend: row.backend, state: row.state, signIn: row.state !== "not_installed" && row.state !== "too_old" && dashboardSignInSupported(row.backend),
|
|
15458
|
+
...(flow?.remoteLogin === "unsupported" ? { hostCommand: flow.command } : {}),
|
|
15459
|
+
...(row.state === "not_installed" && install ? { install } : {}),
|
|
15460
|
+
...(row.state === "too_old" && install ? { update: install } : {}),
|
|
15461
|
+
});
|
|
15462
|
+
}
|
|
15463
|
+
const seen = new Set();
|
|
15464
|
+
for (const name of Object.keys(this.fleetConfig?.instances ?? {}).sort()) {
|
|
15465
|
+
const backend = this.backendNameForInstance(name);
|
|
15466
|
+
const profile = this.credentialProfileOf(name, backend);
|
|
15467
|
+
if (!profile || seen.has(`${backend}\0${profile}`))
|
|
15468
|
+
continue;
|
|
15469
|
+
seen.add(`${backend}\0${profile}`);
|
|
15470
|
+
let login = "unknown";
|
|
15471
|
+
try {
|
|
15472
|
+
login = credentialProfileLogin(this.dataDir, backend, profile).state;
|
|
15473
|
+
}
|
|
15474
|
+
catch { /* unknown */ }
|
|
15475
|
+
const signIn = dashboardSignInSupported(backend, profile);
|
|
15476
|
+
const hostCommand = signIn ? null : credentialProfileLoginCommand(this.dataDir, backend, profile);
|
|
15477
|
+
out.push({ backend, profile, state: login === "logged-in" ? "usable" : login === "signed-out" ? "not_signed_in" : "unknown", signIn,
|
|
15478
|
+
...(hostCommand ? { hostCommand } : {}) });
|
|
15479
|
+
}
|
|
15480
|
+
return out;
|
|
15481
|
+
}
|
|
15482
|
+
/** #2233: the session with this handle is live, admin, local and of the current dashboard token. */
|
|
15483
|
+
isLocalAdminSession(handle) {
|
|
15484
|
+
const sessions = this.webSessions, token = this.webToken;
|
|
15485
|
+
const record = sessions?.findByHandle(handle);
|
|
15486
|
+
return !!record && !!token && record.tier === "admin" && record.surface === "local" && sessions.isCurrent(record, tokenEpoch(token));
|
|
15487
|
+
}
|
|
15488
|
+
/**
|
|
15489
|
+
* #2233: `/login` for a dashboard requester. The same controller and checks; a CLI that is not installed is refused
|
|
15490
|
+
* with its install command (chat's install-first runs a terminal of its own, which the dashboard does not offer).
|
|
15491
|
+
*/
|
|
15492
|
+
startWebDashboardLogin(backendArg, chat, opts) {
|
|
15493
|
+
const wanted = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
|
|
15494
|
+
if (isRemovedBackend(wanted))
|
|
15495
|
+
return Promise.resolve(removedBackendMessage(wanted));
|
|
15496
|
+
const info = BACKEND_INSTALLATION_INFO[wanted];
|
|
15497
|
+
if (info && LOGIN_FLOWS[wanted]?.remoteLogin !== "unsupported" && !this.isCliInstalled(wanted)) {
|
|
15498
|
+
return Promise.resolve(t("login.web_dashboard_not_installed", wanted, info.install));
|
|
15499
|
+
}
|
|
15500
|
+
if (!opts.skipAuthCheck)
|
|
15501
|
+
this.recordLoginFlow(wanted, "login", chat.userId);
|
|
15502
|
+
return this.webLogin.start(backendArg, chat, opts);
|
|
15503
|
+
}
|
|
14247
15504
|
/**
|
|
14248
15505
|
* Post the backend chooser for a bare `/login`. Caller enforces admin.
|
|
14249
15506
|
* Returns why the chooser could not be posted, for the caller to tell the
|
|
@@ -14742,6 +15999,8 @@ export class FleetManager {
|
|
|
14742
15999
|
// binary the same way, so a binary only a login shell could see was
|
|
14743
16000
|
// installed, reported as verified, and then offered nowhere.
|
|
14744
16001
|
this.adoptBinaryDirectory(installedAt, backend);
|
|
16002
|
+
// #2072: an install just finished — the pickers learn it now, not at the next pass.
|
|
16003
|
+
void this.backendAvailability.refresh([backend], { force: true });
|
|
14745
16004
|
if (!LOGIN_FLOWS[backend]) {
|
|
14746
16005
|
this.installHandoff = null;
|
|
14747
16006
|
await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
|
|
@@ -14884,6 +16143,36 @@ export class FleetManager {
|
|
|
14884
16143
|
const failure = await this.promptLoginBackends(chat);
|
|
14885
16144
|
await data.respond(failure ?? t("login.chooser_posted"));
|
|
14886
16145
|
}
|
|
16146
|
+
/** #2293: whose cross-instance messages search leaves out now (web-api /ui/search). */
|
|
16147
|
+
searchVisibility() { return xmsgVisibility(this.fleetConfig); }
|
|
16148
|
+
/** #2293: a cross-instance message for the search index (outbound-handlers, after it was admitted). */
|
|
16149
|
+
indexCrossInstance(record) {
|
|
16150
|
+
this.searchIndex?.record(xmsgDoc(record));
|
|
16151
|
+
}
|
|
16152
|
+
/**
|
|
16153
|
+
* #2293: the global search index, at fleet start — after the web chat is restored (the backfill reads it). Its worker
|
|
16154
|
+
* owns search.db; see search-index.ts. Off with web.search.enabled: false.
|
|
16155
|
+
*/
|
|
16156
|
+
startSearchIndex(fleet) {
|
|
16157
|
+
const config = fleet.web?.search;
|
|
16158
|
+
if (config?.enabled === false || this.searchIndex)
|
|
16159
|
+
return;
|
|
16160
|
+
const history = this.webChatHistory;
|
|
16161
|
+
this.searchIndex = new SearchIndex({
|
|
16162
|
+
path: join(this.dataDir, "search.db"),
|
|
16163
|
+
retentionDays: config?.retention_days ?? SEARCH_RETENTION_DAYS,
|
|
16164
|
+
outboxPath: join(this.dataDir, "delivery-outbox.db"),
|
|
16165
|
+
schedulerPath: join(this.dataDir, "scheduler.db"),
|
|
16166
|
+
chatDocs: function* () { for (const name of history.instances())
|
|
16167
|
+
for (const m of history.list(name)) {
|
|
16168
|
+
const doc = chatDoc(m);
|
|
16169
|
+
if (doc)
|
|
16170
|
+
yield doc;
|
|
16171
|
+
} },
|
|
16172
|
+
log: (level, msg) => this.logger[level](msg),
|
|
16173
|
+
});
|
|
16174
|
+
this.searchIndex.start();
|
|
16175
|
+
}
|
|
14887
16176
|
queueMirrorMessage(text) {
|
|
14888
16177
|
const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
|
|
14889
16178
|
if (mirrorTopicId == null || !this.adapter)
|
|
@@ -14976,8 +16265,12 @@ export class FleetManager {
|
|
|
14976
16265
|
buttons: m.buttons,
|
|
14977
16266
|
click: m.click,
|
|
14978
16267
|
links: m.links,
|
|
16268
|
+
channel: m.channel,
|
|
16269
|
+
from: m.from,
|
|
16270
|
+
replyTo: m.replyTo,
|
|
14979
16271
|
});
|
|
14980
16272
|
broadcastSseEvent(this.sseClients, event, recorded, onError, this.webChatHistory.cursorOf(recorded));
|
|
16273
|
+
this.searchIndex?.record(chatDoc(recorded));
|
|
14981
16274
|
// #1629: live-only receipt after the recorded agent reply. Never persist/replay it or give it an SSE cursor.
|
|
14982
16275
|
if (recorded.role === "agent")
|
|
14983
16276
|
broadcastSseEvent(this.sseClients, "agent_reply_live", { v: 1, boot: recorded.boot, id: recorded.id, instance: recorded.instance }, onError);
|
|
@@ -14990,6 +16283,38 @@ export class FleetManager {
|
|
|
14990
16283
|
}
|
|
14991
16284
|
broadcastSseEvent(this.sseClients, event, data, onError);
|
|
14992
16285
|
}
|
|
16286
|
+
/**
|
|
16287
|
+
* The task board's live side: every committed change goes to the open pages (never to a guest) — the board's new
|
|
16288
|
+
* revision and the card numbers it touched; not who touched them (the page does not need it, and a public-link page
|
|
16289
|
+
* must not learn a hidden instance's name from it). Its news goes to the instances whose cards someone else changed:
|
|
16290
|
+
* one "[Task board]" notice per instance, gathered for 10 s, held by the delivery outbox until it is taken
|
|
16291
|
+
* (task-board-waker.ts).
|
|
16292
|
+
*/
|
|
16293
|
+
attachTaskBoard(db) {
|
|
16294
|
+
this.taskBoardWaker?.stop();
|
|
16295
|
+
// #2303: each instance's cards at once, read at every claim (a fleet.yaml edit applies to the next one).
|
|
16296
|
+
db.board.setCardLimit((name) => boardCardLimit(this.fleetConfig, name));
|
|
16297
|
+
const waker = this.taskBoardWaker = new TaskBoardWaker({
|
|
16298
|
+
deliver: (instance, message, key) => this.admitTaskBoardNotice(instance, message, key),
|
|
16299
|
+
isInstance: (name) => this.fleetConfig?.instances?.[name] != null,
|
|
16300
|
+
holds: (wake) => db.board.wakeHolds(wake),
|
|
16301
|
+
card: (uuid) => db.board.findCard(uuid),
|
|
16302
|
+
log: (level, message, extra) => this.logger[level](extra ?? {}, message),
|
|
16303
|
+
});
|
|
16304
|
+
db.board.onChange(({ rev, ids, projects, wakes }) => {
|
|
16305
|
+
this.emitSseEvent("tasks", { rev, ids, projects });
|
|
16306
|
+
waker.note(wakes);
|
|
16307
|
+
});
|
|
16308
|
+
}
|
|
16309
|
+
/** A task board notice, as AgEnD's own (not a person's words): through the delivery outbox, held until delivered. */
|
|
16310
|
+
admitTaskBoardNotice(instance, message, key) {
|
|
16311
|
+
this.ensureDeliveryOutbox();
|
|
16312
|
+
const outbox = this.deliveryOutbox;
|
|
16313
|
+
if (!outbox)
|
|
16314
|
+
return false;
|
|
16315
|
+
outbox.admitSystemNotice(instance, key, message, new Date());
|
|
16316
|
+
return true;
|
|
16317
|
+
}
|
|
14993
16318
|
listClaimedTasks(assignee) {
|
|
14994
16319
|
try {
|
|
14995
16320
|
return this.scheduler?.db.listTasks({ assignee, status: "claimed" }) ?? [];
|
|
@@ -15205,7 +16530,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15205
16530
|
// can keep those copies current; other backends never had unmanaged
|
|
15206
16531
|
// AgEnD-published skills and must retain the normal user-ownership guard.
|
|
15207
16532
|
const adoptLegacyUnmanaged = backend === "kiro-cli" && role === "general";
|
|
15208
|
-
this.syncManagedSkills(join(workDir, ...skillSegments), join(root, "skills"), role, adoptLegacyUnmanaged
|
|
16533
|
+
this.syncManagedSkills(join(workDir, ...skillSegments), join(root, "skills"), role, adoptLegacyUnmanaged,
|
|
16534
|
+
// #2163: a Classic bot's skills folder (or a skill in it) may be a link out of its workspace; every path is
|
|
16535
|
+
// checked where it really lands, just before it is written.
|
|
16536
|
+
role === "classic" ? (path) => insideManagedWorkspaces(path) : undefined);
|
|
15209
16537
|
this.logger.debug({ workDir, backend, role }, "Synced role-based bundled skills");
|
|
15210
16538
|
}
|
|
15211
16539
|
/**
|
|
@@ -15291,11 +16619,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15291
16619
|
* migration exception is the pre-manifest Kiro General layout explicitly
|
|
15292
16620
|
* selected by adoptLegacyUnmanaged.
|
|
15293
16621
|
*/
|
|
15294
|
-
syncManagedSkills(destSkills, srcSkills, role, adoptLegacyUnmanaged = false) {
|
|
16622
|
+
syncManagedSkills(destSkills, srcSkills, role, adoptLegacyUnmanaged = false, confined) {
|
|
15295
16623
|
if (!existsSync(srcSkills))
|
|
15296
16624
|
return;
|
|
16625
|
+
const refuse = (path) => {
|
|
16626
|
+
if (!confined || confined(path))
|
|
16627
|
+
return false;
|
|
16628
|
+
this.logger.warn({ path, destSkills }, "Not publishing skills — the path leads out of the managed workspace");
|
|
16629
|
+
return true;
|
|
16630
|
+
};
|
|
16631
|
+
if (refuse(destSkills))
|
|
16632
|
+
return;
|
|
15297
16633
|
mkdirSync(destSkills, { recursive: true });
|
|
15298
16634
|
const manifestPath = join(destSkills, ".agend-managed-skills.json");
|
|
16635
|
+
// Its ownership claims decide what is replaced or removed below: one that lives elsewhere is never believed.
|
|
16636
|
+
if (refuse(manifestPath))
|
|
16637
|
+
return;
|
|
15299
16638
|
const hadManifest = existsSync(manifestPath);
|
|
15300
16639
|
let previouslyManaged = [];
|
|
15301
16640
|
try {
|
|
@@ -15308,25 +16647,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15308
16647
|
.filter(name => existsSync(join(srcSkills, name, "SKILL.md")))
|
|
15309
16648
|
.sort();
|
|
15310
16649
|
// Pre-manifest Kiro General copied bundled skills directly into
|
|
15311
|
-
// .kiro/skills. If every existing skill is still
|
|
15312
|
-
// the
|
|
15313
|
-
// the ownership manifest. Any extra skill name keeps the directory fully
|
|
16650
|
+
// .kiro/skills. If every existing skill is still bundled AND belongs to
|
|
16651
|
+
// the closed pre-manifest cohort, adopt it once, update it below, and
|
|
16652
|
+
// write the ownership manifest. Any extra skill name keeps the directory fully
|
|
15314
16653
|
// on the user-owned/collision path.
|
|
15315
16654
|
if (!hadManifest && adoptLegacyUnmanaged) {
|
|
15316
16655
|
const existing = readdirSync(destSkills, { withFileTypes: true })
|
|
15317
16656
|
.filter(entry => entry.isDirectory() && existsSync(join(destSkills, entry.name, "SKILL.md")))
|
|
15318
16657
|
.map(entry => entry.name)
|
|
15319
16658
|
.sort();
|
|
15320
|
-
if (existing.length > 0 && existing.every(name => bundled.includes(name))) {
|
|
16659
|
+
if (existing.length > 0 && existing.every(name => bundled.includes(name) && PRE_MANIFEST_KIRO_SKILLS.has(name))) {
|
|
15321
16660
|
previouslyManaged = existing;
|
|
15322
16661
|
this.logger.info({ skills: existing, destSkills }, "Adopted legacy AgEnD skills into managed ownership");
|
|
15323
16662
|
}
|
|
15324
16663
|
}
|
|
15325
16664
|
const eligible = bundled.filter(name => {
|
|
15326
16665
|
const roles = this.readManagedSkillRoles(join(srcSkills, name, "SKILL.md"));
|
|
15327
|
-
// General is the coordinator and receives
|
|
15328
|
-
//
|
|
15329
|
-
return role === "general" || roles.includes(
|
|
16666
|
+
// General is the coordinator and receives every playbook. Workers and Classic bots receive only skills
|
|
16667
|
+
// explicitly marked for their role (#2163).
|
|
16668
|
+
return role === "general" || roles.includes(role);
|
|
15330
16669
|
});
|
|
15331
16670
|
const managed = [];
|
|
15332
16671
|
for (const name of eligible) {
|
|
@@ -15343,6 +16682,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15343
16682
|
}
|
|
15344
16683
|
managed.push(name);
|
|
15345
16684
|
const newContent = readFileSync(join(srcSkills, name, "SKILL.md"), "utf-8");
|
|
16685
|
+
if (refuse(dest))
|
|
16686
|
+
continue;
|
|
15346
16687
|
try {
|
|
15347
16688
|
if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
|
|
15348
16689
|
continue;
|
|
@@ -15389,9 +16730,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15389
16730
|
if (!Array.isArray(frontmatter.roles))
|
|
15390
16731
|
throw new Error("roles must be an array");
|
|
15391
16732
|
const roles = [...new Set(frontmatter.roles)]
|
|
15392
|
-
.filter((value) => value === "general" || value === "worker");
|
|
16733
|
+
.filter((value) => value === "general" || value === "worker" || value === "classic");
|
|
15393
16734
|
if (roles.length !== frontmatter.roles.length || roles.length === 0) {
|
|
15394
|
-
throw new Error("roles must contain only general and/or
|
|
16735
|
+
throw new Error("roles must contain only general, worker and/or classic");
|
|
15395
16736
|
}
|
|
15396
16737
|
return roles;
|
|
15397
16738
|
}
|
|
@@ -15776,10 +17117,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15776
17117
|
this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
|
|
15777
17118
|
// #1592: the web chat shows what was asked, as for every other inbound — the user's own words (no chat-log
|
|
15778
17119
|
// context), as a user (#1306: never a role read from text), in the room's history (#1565).
|
|
15779
|
-
this.
|
|
15780
|
-
instance: instanceName, sender: msg.username, role: "user",
|
|
15781
|
-
text: text.slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
|
|
15782
|
-
});
|
|
17120
|
+
this.emitPlatformInbound(instanceName, msg, text);
|
|
15783
17121
|
}
|
|
15784
17122
|
/** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
|
|
15785
17123
|
/** #1690: our own runtime switches awaiting the daemon's outcome, by id. */
|
|
@@ -15817,7 +17155,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15817
17155
|
/** #1806: null on a switch INTO claude-code — the configured model then belongs to the backend being left. */
|
|
15818
17156
|
configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model) {
|
|
15819
17157
|
const aliases = ["default", "sonnet", "opus", "haiku", "opusplan", "Fable"];
|
|
15820
|
-
const api = (this.readCliEnv("claude-code")?.apiModels ?? []).map(m => m.id);
|
|
17158
|
+
const api = (this.readCliEnv("claude-code", this.catalogProfile("claude-code", instanceName))?.apiModels ?? []).map(m => m.id);
|
|
15821
17159
|
const lower = model.trim().toLowerCase();
|
|
15822
17160
|
const bare = lower.replace(/\[1m\]$/, "");
|
|
15823
17161
|
const known = aliases.some(a => a.toLowerCase() === bare)
|
|
@@ -15845,13 +17183,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15845
17183
|
: undefined;
|
|
15846
17184
|
return classic ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
15847
17185
|
}
|
|
15848
|
-
|
|
15849
|
-
|
|
17186
|
+
/**
|
|
17187
|
+
* #2072: a catalog is the account's, so a credential profile has its own. Only claude-code's probe reads an account
|
|
17188
|
+
* catalog through a profile's login (its `GET /v1/models` with the profile's token); other backends' probes read the
|
|
17189
|
+
* process environment, so their catalog stays the backend's own.
|
|
17190
|
+
*/
|
|
17191
|
+
catalogProfile(backend, instanceName) {
|
|
17192
|
+
return backend === "claude-code" && instanceName ? this.credentialProfileOf(instanceName, backend) : null;
|
|
17193
|
+
}
|
|
17194
|
+
cliEnvPath(backend, profile = null) {
|
|
17195
|
+
const safe = (s) => s.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
17196
|
+
return join(getAgendHome(), "cli-env", profile ? `${safe(backend)}@${safe(profile)}.json` : `${safe(backend)}.json`);
|
|
15850
17197
|
}
|
|
15851
|
-
/** Read the cached CLI env for a backend, or null if missing/stale/unparseable. */
|
|
15852
|
-
readCliEnv(backend) {
|
|
17198
|
+
/** Read the cached CLI env for a backend (a credential profile's own, when given), or null if missing/stale/unparseable. */
|
|
17199
|
+
readCliEnv(backend, profile = null) {
|
|
15853
17200
|
try {
|
|
15854
|
-
const env = JSON.parse(readFileSync(this.cliEnvPath(backend), "utf-8"));
|
|
17201
|
+
const env = JSON.parse(readFileSync(this.cliEnvPath(backend, profile), "utf-8"));
|
|
15855
17202
|
if (typeof env?.probedAt === "number" && Date.now() - env.probedAt < CLI_ENV_TTL_MS)
|
|
15856
17203
|
return env;
|
|
15857
17204
|
}
|
|
@@ -15894,10 +17241,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15894
17241
|
* previous list, never stall the command that asked for it.
|
|
15895
17242
|
*/
|
|
15896
17243
|
async probeBackendBounded(backend, opts = {}) {
|
|
15897
|
-
// #725: single-flight per backend. Explicit vendor refreshes remain a
|
|
17244
|
+
// #725: single-flight per backend (#2072: per backend and credential profile). Explicit vendor refreshes remain a
|
|
15898
17245
|
// separate flight from an ordinary probe, while sysinfo can join either.
|
|
15899
|
-
const
|
|
15900
|
-
const
|
|
17246
|
+
const profile = opts.profile ?? null;
|
|
17247
|
+
const key = profile ? `${backend}@${profile}` : backend;
|
|
17248
|
+
const regularFlight = this.pendingCliEnvProbes.get(key);
|
|
17249
|
+
const vendorFlight = this.pendingVendorCliEnvProbes.get(key);
|
|
15901
17250
|
if (opts.refreshVendorCatalog && vendorFlight)
|
|
15902
17251
|
return vendorFlight;
|
|
15903
17252
|
if (!opts.refreshVendorCatalog && (vendorFlight || regularFlight))
|
|
@@ -15908,25 +17257,26 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15908
17257
|
const epoch = this.cliEnvProbeEpoch;
|
|
15909
17258
|
const input = {
|
|
15910
17259
|
mode: "env", backend, instanceDir,
|
|
15911
|
-
config: { workingDirectory: "", instanceDir, instanceName: `probe-${backend}`, mcpServers: {}
|
|
17260
|
+
config: { workingDirectory: "", instanceDir, instanceName: `probe-${backend}`, mcpServers: {},
|
|
17261
|
+
...(profile ? { credentialStoreHome: credentialProfileStoreHome(getAgendHome(), backend, profile) } : {}) },
|
|
15912
17262
|
refreshVendorCatalog: opts.refreshVendorCatalog === true,
|
|
15913
17263
|
};
|
|
15914
|
-
const bounded = this.runBackendProbeWorker(`global:${
|
|
17264
|
+
const bounded = this.runBackendProbeWorker(`global:${key}`, input).then(probed => {
|
|
15915
17265
|
if (!probed || Array.isArray(probed) || this.shuttingDown || epoch !== this.cliEnvProbeEpoch)
|
|
15916
17266
|
return null;
|
|
15917
17267
|
try {
|
|
15918
|
-
return this.persistCliEnvProbeResult(backend, probed);
|
|
17268
|
+
return this.persistCliEnvProbeResult(backend, probed, profile);
|
|
15919
17269
|
}
|
|
15920
17270
|
catch (err) {
|
|
15921
|
-
this.logger.warn({ backend, err }, "CLI env cache write failed");
|
|
17271
|
+
this.logger.warn({ backend, profile, err }, "CLI env cache write failed");
|
|
15922
17272
|
return null;
|
|
15923
17273
|
}
|
|
15924
17274
|
}).finally(() => {
|
|
15925
17275
|
const flights = opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes;
|
|
15926
|
-
if (flights.get(
|
|
15927
|
-
flights.delete(
|
|
17276
|
+
if (flights.get(key) === bounded)
|
|
17277
|
+
flights.delete(key);
|
|
15928
17278
|
});
|
|
15929
|
-
(opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(
|
|
17279
|
+
(opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(key, bounded);
|
|
15930
17280
|
return bounded;
|
|
15931
17281
|
}
|
|
15932
17282
|
stopCliEnvProbes() {
|
|
@@ -16041,12 +17391,21 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16041
17391
|
modelDisplayForInstance(instanceName) {
|
|
16042
17392
|
return this.resolveInstanceModel(instanceName).display;
|
|
16043
17393
|
}
|
|
17394
|
+
/**
|
|
17395
|
+
* #2072: the model an instance runs is always among its choices — one its CLI's catalog does not list (an older
|
|
17396
|
+
* name, a custom provider's, a typed one) is shown first, marked, so the menu never hides what is running.
|
|
17397
|
+
*/
|
|
17398
|
+
withCurrentModel(options, currentModel) {
|
|
17399
|
+
if (!currentModel || currentModel === "default" || options.some(o => o.id === currentModel))
|
|
17400
|
+
return options;
|
|
17401
|
+
return [{ id: currentModel, label: currentModel, description: t("model.not_listed") }, ...options];
|
|
17402
|
+
}
|
|
16044
17403
|
modelChoiceLabel(option, currentModel) {
|
|
16045
17404
|
const label = option.description ? `${option.label} — ${option.description}` : option.label;
|
|
16046
17405
|
return option.id === currentModel ? `✓ ${label}` : label;
|
|
16047
17406
|
}
|
|
16048
17407
|
/** Preserve the existing cache merge rules for worker probes. */
|
|
16049
|
-
persistCliEnvProbeResult(backend, probed) {
|
|
17408
|
+
persistCliEnvProbeResult(backend, probed, profile = null) {
|
|
16050
17409
|
const env = { backend, probedAt: Date.now(), ...probed };
|
|
16051
17410
|
// An empty result must never overwrite a catalog we already have. Some
|
|
16052
17411
|
// probes hit the network (`agy models` fetches, 5s cap), so a slow moment
|
|
@@ -16055,14 +17414,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16055
17414
|
// antigravity cache replaced by an empty one. Keep the known models and
|
|
16056
17415
|
// let the fresher currentModel/version through.
|
|
16057
17416
|
if (!env.models?.length) {
|
|
16058
|
-
const previous = this.readCliEnv(backend);
|
|
17417
|
+
const previous = this.readCliEnv(backend, profile);
|
|
16059
17418
|
if (previous?.models?.length)
|
|
16060
17419
|
env.models = previous.models;
|
|
16061
17420
|
}
|
|
16062
17421
|
// Same protection for the extended catalog: one offline moment must not
|
|
16063
17422
|
// blank a good account list for the whole cache TTL.
|
|
16064
17423
|
if (!env.apiModels?.length) {
|
|
16065
|
-
const previous = this.readCliEnv(backend);
|
|
17424
|
+
const previous = this.readCliEnv(backend, profile);
|
|
16066
17425
|
if (previous?.apiModels?.length)
|
|
16067
17426
|
env.apiModels = previous.apiModels;
|
|
16068
17427
|
}
|
|
@@ -16070,12 +17429,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16070
17429
|
// keeps the cached levels only for that same binary: both versions known and equal, cache still valid. A help
|
|
16071
17430
|
// that was read and lists none ([]) is an answer and is written as is, so the fallback applies.
|
|
16072
17431
|
if (env.effortLevels === undefined) {
|
|
16073
|
-
const previous = this.readCliEnv(backend);
|
|
17432
|
+
const previous = this.readCliEnv(backend, profile);
|
|
16074
17433
|
if (previous?.effortLevels && previous.version && env.version && previous.version === env.version) {
|
|
16075
17434
|
env.effortLevels = previous.effortLevels;
|
|
16076
17435
|
}
|
|
16077
17436
|
}
|
|
16078
|
-
const path = this.cliEnvPath(backend);
|
|
17437
|
+
const path = this.cliEnvPath(backend, profile);
|
|
16079
17438
|
mkdirSync(dirname(path), { recursive: true });
|
|
16080
17439
|
writeFileSync(path, JSON.stringify(env, null, 2));
|
|
16081
17440
|
return env;
|
|
@@ -16097,6 +17456,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16097
17456
|
// indefinitely — each is wrapped by CLI_ENV_PROBE_DEADLINE_MS.
|
|
16098
17457
|
for (const b of backends)
|
|
16099
17458
|
void this.probeBackendBounded(b);
|
|
17459
|
+
// #2072: each Claude credential profile in use has its own account catalog. Only fleet.yaml's agents are walked
|
|
17460
|
+
// here; a ClassicBot room on a profile is probed on its first /model (catalogProfile knows rooms too).
|
|
17461
|
+
const profiles = new Set();
|
|
17462
|
+
for (const name of Object.keys(this.fleetConfig?.instances ?? {})) {
|
|
17463
|
+
if (this.backendNameForInstance(name) !== "claude-code")
|
|
17464
|
+
continue;
|
|
17465
|
+
const profile = this.catalogProfile("claude-code", name);
|
|
17466
|
+
if (profile)
|
|
17467
|
+
profiles.add(profile);
|
|
17468
|
+
}
|
|
17469
|
+
for (const profile of profiles)
|
|
17470
|
+
void this.probeBackendBounded("claude-code", { profile });
|
|
16100
17471
|
}
|
|
16101
17472
|
/** Best-effort model list for `/model`: cached CLI env first, else live probe. Never throws. */
|
|
16102
17473
|
async getModelOptions(instanceName, refresh = false, onLiveProbe) {
|
|
@@ -16110,7 +17481,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16110
17481
|
*/
|
|
16111
17482
|
async getModelOptionsWithSource(instanceName, opts = {}) {
|
|
16112
17483
|
const backendName = this.backendNameForInstance(instanceName);
|
|
16113
|
-
const
|
|
17484
|
+
const profile = this.catalogProfile(backendName, instanceName);
|
|
17485
|
+
const cached = this.readCliEnv(backendName, profile);
|
|
16114
17486
|
if (!opts.refresh && cached?.models.length && !this.cliEnvNeedsRefresh(cached)) {
|
|
16115
17487
|
return { models: cached.models, source: "cache" };
|
|
16116
17488
|
}
|
|
@@ -16121,7 +17493,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16121
17493
|
// Stale, missing, or a forced refresh → probe live (also refreshes the cache).
|
|
16122
17494
|
// A newly released model is invisible until this runs, which is why staleness
|
|
16123
17495
|
// triggers it rather than waiting for the 24h hard expiry or a cold start.
|
|
16124
|
-
const env = await this.probeBackendBounded(backendName, { refreshVendorCatalog: opts.refreshVendorCatalog });
|
|
17496
|
+
const env = await this.probeBackendBounded(backendName, { refreshVendorCatalog: opts.refreshVendorCatalog, profile });
|
|
16125
17497
|
if (env?.models.length)
|
|
16126
17498
|
return { models: env.models, source: "live" };
|
|
16127
17499
|
// Probe failed or timed out: the previous list is still the best answer.
|
|
@@ -16136,7 +17508,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16136
17508
|
modelMenuChoices(instanceName, nonce, options, currentModel) {
|
|
16137
17509
|
const isClaude = this.backendNameForInstance(instanceName) === "claude-code";
|
|
16138
17510
|
const choices = [{ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:__refresh__`, label: t("model.refresh") }];
|
|
16139
|
-
for (const o of options.slice(0, isClaude ? 23 : 24)) {
|
|
17511
|
+
for (const o of this.withCurrentModel(options, currentModel).slice(0, isClaude ? 23 : 24)) {
|
|
16140
17512
|
choices.push({ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`, label: this.modelChoiceLabel(o, currentModel) });
|
|
16141
17513
|
}
|
|
16142
17514
|
if (isClaude)
|
|
@@ -16543,16 +17915,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16543
17915
|
}
|
|
16544
17916
|
}
|
|
16545
17917
|
/** Cached-or-live account catalog behind "/model → more models" (claude). */
|
|
16546
|
-
async claudeApiModelOptions() {
|
|
16547
|
-
|
|
17918
|
+
async claudeApiModelOptions(instanceName) {
|
|
17919
|
+
// #2072: the account behind THIS instance — its credential profile's catalog, else the default login's.
|
|
17920
|
+
const profile = this.catalogProfile("claude-code", instanceName);
|
|
17921
|
+
const cached = this.readCliEnv("claude-code", profile);
|
|
16548
17922
|
if (cached?.apiModels?.length)
|
|
16549
17923
|
return cached.apiModels;
|
|
16550
|
-
const env = await this.probeBackendBounded("claude-code");
|
|
17924
|
+
const env = await this.probeBackendBounded("claude-code", { profile });
|
|
16551
17925
|
return env?.apiModels ?? [];
|
|
16552
17926
|
}
|
|
16553
17927
|
/** Replace a consumed "/model" menu with the full account catalog tier. */
|
|
16554
17928
|
async expandClaudeModelMenu(pending) {
|
|
16555
|
-
const expanded = await this.claudeApiModelOptions();
|
|
17929
|
+
const expanded = await this.claudeApiModelOptions(pending.instanceName);
|
|
16556
17930
|
if (!expanded.length) {
|
|
16557
17931
|
await pending.respond(t("model.more_unavailable")).catch(() => { });
|
|
16558
17932
|
return;
|
|
@@ -16980,7 +18354,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16980
18354
|
return {
|
|
16981
18355
|
currentBackend: (name) => this.backendNameForInstance(name),
|
|
16982
18356
|
currentDisplay: (name) => `${this.backendNameForInstance(name)} · ${this.resolveInstanceModel(name).display}`,
|
|
16983
|
-
|
|
18357
|
+
availability: () => this.backendAvailabilityFor(instanceName),
|
|
16984
18358
|
models: (backend) => this.backendModelOptions(backend, opts),
|
|
16985
18359
|
// #1808: the same rule update_instance_config's switch applies (backendModelVerdict).
|
|
16986
18360
|
modelKnown: (backend, model) => this.backendModelVerdict(instanceName, backend, model).ok,
|
|
@@ -17079,10 +18453,76 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17079
18453
|
// kiro-cli gates its own --effort by version at launch (a quiet console note otherwise); muse always passes it.
|
|
17080
18454
|
return () => true;
|
|
17081
18455
|
}
|
|
17082
|
-
/**
|
|
17083
|
-
|
|
17084
|
-
|
|
17085
|
-
|
|
18456
|
+
/**
|
|
18457
|
+
* #2072: what this host can run, from the published availability snapshot (backend-availability.ts). Read only: a
|
|
18458
|
+
* handler never starts a probe, not even in the background — launch, the timer, and /login install or sign-in do.
|
|
18459
|
+
*/
|
|
18460
|
+
backendAvailabilityRows() {
|
|
18461
|
+
return this.backendAvailability.snapshot();
|
|
18462
|
+
}
|
|
18463
|
+
/**
|
|
18464
|
+
* #2072 part 3: the models a Settings field offers for `backend` — the cached catalog (an instance's own credential
|
|
18465
|
+
* profile's, when it is named and has one), claude's account catalog included. Read only: never a probe.
|
|
18466
|
+
*/
|
|
18467
|
+
modelCatalogFor(backend, instance) {
|
|
18468
|
+
if (!KNOWN_BACKENDS.includes(backend) || backend === "mock")
|
|
18469
|
+
return [];
|
|
18470
|
+
const own = instance && Object.prototype.hasOwnProperty.call(this.fleetConfig?.instances ?? {}, instance) ? instance
|
|
18471
|
+
: instance && this.classicChannels?.getChannelIdByInstance(instance) !== undefined ? instance : null;
|
|
18472
|
+
const env = this.readCliEnv(backend, this.catalogProfile(backend, own));
|
|
18473
|
+
const seen = new Set(), out = [];
|
|
18474
|
+
for (const m of [...(env?.models ?? []), ...(env?.apiModels ?? [])])
|
|
18475
|
+
if (!seen.has(m.id)) {
|
|
18476
|
+
seen.add(m.id);
|
|
18477
|
+
out.push(m);
|
|
18478
|
+
}
|
|
18479
|
+
return out;
|
|
18480
|
+
}
|
|
18481
|
+
/**
|
|
18482
|
+
* #2072 (#2078 review): the snapshot as THIS instance would see it. Its sign-in on a backend is its own credential
|
|
18483
|
+
* profile's when it has one there — the host's default login says nothing about it. A profile's login is a file check
|
|
18484
|
+
* (credentialProfileLogin), no process.
|
|
18485
|
+
*/
|
|
18486
|
+
backendAvailabilityFor(instanceName) {
|
|
18487
|
+
return this.backendAvailabilityRows().map(row => {
|
|
18488
|
+
const profile = this.credentialProfileOf(instanceName, row.backend);
|
|
18489
|
+
if (!profile || (row.state !== "usable" && row.state !== "not_signed_in" && row.state !== "unknown") || !row.path)
|
|
18490
|
+
return row;
|
|
18491
|
+
let login = "unknown";
|
|
18492
|
+
try {
|
|
18493
|
+
login = credentialProfileLogin(this.dataDir, row.backend, profile).state;
|
|
18494
|
+
}
|
|
18495
|
+
catch { /* unknown */ }
|
|
18496
|
+
// Signed in there: usable for this instance. Signed out (or unknown): the host's default login says nothing about
|
|
18497
|
+
// it, so no "not signed in" claim from it — update_instance_config's profile gate refuses a signed-out profile
|
|
18498
|
+
// when applied, naming the profile and the command that signs it in.
|
|
18499
|
+
if (login === "logged-in")
|
|
18500
|
+
return { ...row, auth: "valid", state: "usable" };
|
|
18501
|
+
return row.state === "not_signed_in" ? { ...row, auth: "unknown", state: "unknown" } : row;
|
|
18502
|
+
});
|
|
18503
|
+
}
|
|
18504
|
+
/** The probes the snapshot runs (tests replace them: no CLI, no account, no network). */
|
|
18505
|
+
availabilityProbes = hostAvailabilityProbes;
|
|
18506
|
+
availabilityStore = null;
|
|
18507
|
+
availabilityTimer = null;
|
|
18508
|
+
get backendAvailability() {
|
|
18509
|
+
return this.availabilityStore ??= new BackendAvailabilityStore(KNOWN_BACKENDS.filter(b => b !== "mock" && !!BACKEND_INSTALLATION_INFO[b]), this.availabilityProbes, { onChange: () => this.noteAvailabilityPaths() });
|
|
18510
|
+
}
|
|
18511
|
+
/** A launch need not look the binary up again (a synchronous `which` on the loop) when the snapshot found it. */
|
|
18512
|
+
noteAvailabilityPaths() {
|
|
18513
|
+
for (const row of this.availabilityStore?.snapshot() ?? []) {
|
|
18514
|
+
const binary = BACKEND_INSTALLATION_INFO[row.backend]?.binary;
|
|
18515
|
+
if (binary)
|
|
18516
|
+
noteResolvedBinary(binary, row.state === "not_installed" ? null : row.path);
|
|
18517
|
+
}
|
|
18518
|
+
}
|
|
18519
|
+
/** Probe now and then every AVAILABILITY_STALE_MS; a pass that finds nothing changed starts no CLI. */
|
|
18520
|
+
startAvailabilityRefresh() {
|
|
18521
|
+
void this.backendAvailability.refresh();
|
|
18522
|
+
if (this.availabilityTimer)
|
|
18523
|
+
clearInterval(this.availabilityTimer);
|
|
18524
|
+
this.availabilityTimer = setInterval(() => { void this.backendAvailability.refresh(); }, AVAILABILITY_STALE_MS);
|
|
18525
|
+
this.availabilityTimer.unref?.();
|
|
17086
18526
|
}
|
|
17087
18527
|
/** The models `/model` would offer on a backend: the cached CLI env, else (live) a bounded probe. Never throws. */
|
|
17088
18528
|
async backendModelOptions(backend, opts) {
|
|
@@ -17424,9 +18864,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17424
18864
|
}
|
|
17425
18865
|
return undefined;
|
|
17426
18866
|
}
|
|
18867
|
+
/** #2072: from the published snapshot (no `which` on the loop); not checked yet counts as installed (no false alarm). */
|
|
17427
18868
|
isBackendInstalled(backend) {
|
|
17428
|
-
|
|
17429
|
-
|
|
18869
|
+
if (!BACKEND_INSTALLATION_INFO[backend])
|
|
18870
|
+
return false;
|
|
18871
|
+
return this.backendAvailability.get(backend)?.state !== "not_installed";
|
|
17430
18872
|
}
|
|
17431
18873
|
getMissingBackendWarning(backend) {
|
|
17432
18874
|
if (!backend)
|
|
@@ -17679,9 +19121,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17679
19121
|
* settles, so a later genuine stop (after a restart) still does the work.
|
|
17680
19122
|
*/
|
|
17681
19123
|
stopAll() {
|
|
17682
|
-
this.stopAllInFlight ??= this.doStopAll().finally(() => {
|
|
19124
|
+
this.stopAllInFlight ??= this.doStopAll().finally(() => {
|
|
19125
|
+
this.stopAllInFlight = null;
|
|
19126
|
+
// #1956: after the stop's own tmux work; anything later goes direct. A later startAll starts it again.
|
|
19127
|
+
if (this.tmuxExecHelperTimer) {
|
|
19128
|
+
clearInterval(this.tmuxExecHelperTimer);
|
|
19129
|
+
this.tmuxExecHelperTimer = null;
|
|
19130
|
+
}
|
|
19131
|
+
tmuxExecHelper.stop();
|
|
19132
|
+
});
|
|
17683
19133
|
return this.stopAllInFlight;
|
|
17684
19134
|
}
|
|
19135
|
+
tmuxExecHelperTimer = null;
|
|
17685
19136
|
stopAllInFlight = null;
|
|
17686
19137
|
async doStopAll() {
|
|
17687
19138
|
this.startupComplete = false;
|
|
@@ -17764,6 +19215,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17764
19215
|
clearInterval(this.eventLogPruneTimer);
|
|
17765
19216
|
this.eventLogPruneTimer = null;
|
|
17766
19217
|
}
|
|
19218
|
+
if (this.guestRoomsTimer) {
|
|
19219
|
+
clearInterval(this.guestRoomsTimer);
|
|
19220
|
+
this.guestRoomsTimer = null;
|
|
19221
|
+
}
|
|
17767
19222
|
this.logService?.unwatchAll(); // #1218: a Log's watch timers end with the fleet
|
|
17768
19223
|
this.compactWatchService?.stopAll();
|
|
17769
19224
|
if (this.outboxPruneTimer) {
|
|
@@ -17829,7 +19284,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17829
19284
|
// this is the last moment the prompts can be collapsed.
|
|
17830
19285
|
await this.retirePendingNoncePrompts();
|
|
17831
19286
|
this.topicArchiver.stop();
|
|
19287
|
+
this.taskBoardWaker?.stop();
|
|
17832
19288
|
this.scheduler?.shutdown();
|
|
19289
|
+
// What it was sent is written first (bounded: search-index.ts close()).
|
|
19290
|
+
const search = this.searchIndex;
|
|
19291
|
+
this.searchIndex = null;
|
|
19292
|
+
await search?.close();
|
|
17833
19293
|
// Stop instances in parallel batches to avoid long sequential waits.
|
|
17834
19294
|
// Concurrency scales with fleet size — larger fleets tolerate more parallel
|
|
17835
19295
|
// tmux ops, while small fleets stay conservative to avoid overwhelming the
|
|
@@ -17863,6 +19323,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17863
19323
|
this.adapters.clear();
|
|
17864
19324
|
this.controlClient?.stop();
|
|
17865
19325
|
this.controlClient = null;
|
|
19326
|
+
if (this.availabilityTimer) {
|
|
19327
|
+
clearInterval(this.availabilityTimer);
|
|
19328
|
+
this.availabilityTimer = null;
|
|
19329
|
+
}
|
|
17866
19330
|
if (this.healthServer) {
|
|
17867
19331
|
this.healthServerListening = false;
|
|
17868
19332
|
const server = this.healthServer;
|
|
@@ -19428,7 +20892,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19428
20892
|
all = false;
|
|
19429
20893
|
const message = err?.message ?? String(err);
|
|
19430
20894
|
this.adapterState.set(id, { status: "retrying", retryCount: 0, lastError: message });
|
|
19431
|
-
this.scheduleAdapterRetry(id
|
|
20895
|
+
this.scheduleAdapterRetry(id);
|
|
19432
20896
|
}
|
|
19433
20897
|
}
|
|
19434
20898
|
this.classicChannels?.configureAdapters(this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []));
|
|
@@ -19847,6 +21311,159 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19847
21311
|
this.rejectedHostsLogged.add(name);
|
|
19848
21312
|
this.logger.warn({ host: name }, "Web request refused: Host is not allowed (add it to web.allowed_hosts if this is a proxy you run)");
|
|
19849
21313
|
}
|
|
21314
|
+
/**
|
|
21315
|
+
* #1920 §3: the instances a guest session may see — derived on every request from the account's chat access, never
|
|
21316
|
+
* stored; nothing while `web.guests.enabled` is not true.
|
|
21317
|
+
* - Its connection's fleet topics and General (what it may talk to in chat): when that connection's access lets the
|
|
21318
|
+
* account in (AccessManager.isAllowed — allowed_users or paired). An `open` connection lets everyone in, so it gives
|
|
21319
|
+
* only the instances an admin named for its guests (`web.guests.open_instances`; the user's decision: least
|
|
21320
|
+
* privilege, as an open group has no list of people).
|
|
21321
|
+
* - A Telegram private ClassicBot room that is the account's own (its chat id is the user id).
|
|
21322
|
+
* - A ClassicBot group or guild room on its connection: only while its membership is verified (§3.2 — a "member"
|
|
21323
|
+
* answer read in the background, at most 15 minutes old; unknown, failed or stale hides it).
|
|
21324
|
+
* - Then an admin's narrowing (`web.guests.narrow`), which only takes away.
|
|
21325
|
+
* A fleet admin is not a guest: an admin outside the connection's access gets nothing here (admins sign in as admins).
|
|
21326
|
+
*/
|
|
21327
|
+
guestScope(owner) {
|
|
21328
|
+
const guests = this.fleetConfig?.web?.guests;
|
|
21329
|
+
if (!owner || guests?.enabled !== true)
|
|
21330
|
+
return NO_GUEST_SCOPE;
|
|
21331
|
+
const scope = new Set(this.guestReach(owner));
|
|
21332
|
+
const narrow = guests.narrow && Object.hasOwn(guests.narrow, `${owner.adapterId}:${owner.userId}`) ? guests.narrow[`${owner.adapterId}:${owner.userId}`] : undefined;
|
|
21333
|
+
if (narrow) {
|
|
21334
|
+
const keep = new Set(narrow);
|
|
21335
|
+
for (const name of [...scope])
|
|
21336
|
+
if (!keep.has(name))
|
|
21337
|
+
scope.delete(name);
|
|
21338
|
+
}
|
|
21339
|
+
return scope.size ? scope : NO_GUEST_SCOPE;
|
|
21340
|
+
}
|
|
21341
|
+
/**
|
|
21342
|
+
* #1920 (#2104 review): what an account reaches before an admin's narrowing — its connection's topics (an open one's
|
|
21343
|
+
* named agents), its own Telegram private room, its verified group rooms. Narrowing can only take from this set, so it
|
|
21344
|
+
* is what Settings offers to narrow to. Empty while guest sign-in is off.
|
|
21345
|
+
*/
|
|
21346
|
+
guestReach(owner) {
|
|
21347
|
+
const guests = this.fleetConfig?.web?.guests;
|
|
21348
|
+
const scope = new Set();
|
|
21349
|
+
if (guests?.enabled !== true)
|
|
21350
|
+
return scope;
|
|
21351
|
+
const own = (map, key) => (map && Object.hasOwn(map, key) ? map[key] : undefined);
|
|
21352
|
+
const world = this.worlds.get(owner.adapterId);
|
|
21353
|
+
if (world && world.accessManager.isAllowed(owner.userId)) {
|
|
21354
|
+
const named = world.accessManager.getMode() === "open" ? new Set(own(guests.open_instances, owner.adapterId) ?? []) : null;
|
|
21355
|
+
for (const name of Object.keys(this.fleetConfig?.instances ?? {})) {
|
|
21356
|
+
if (this.getInstanceAdapterId(name) !== owner.adapterId || this.isClassicInstance(name))
|
|
21357
|
+
continue;
|
|
21358
|
+
if (!named || named.has(name))
|
|
21359
|
+
scope.add(name);
|
|
21360
|
+
}
|
|
21361
|
+
}
|
|
21362
|
+
const rooms = this.classicChannels?.getAll() ?? [];
|
|
21363
|
+
for (const room of rooms) {
|
|
21364
|
+
if (this.isOwnPrivateRoom(owner, room))
|
|
21365
|
+
scope.add(room.instanceName);
|
|
21366
|
+
}
|
|
21367
|
+
const members = this.guestRooms();
|
|
21368
|
+
for (const room of guestGroupRooms(rooms, owner)) {
|
|
21369
|
+
if (members.verified(room, owner.userId))
|
|
21370
|
+
scope.add(room.instance);
|
|
21371
|
+
}
|
|
21372
|
+
return scope;
|
|
21373
|
+
}
|
|
21374
|
+
/**
|
|
21375
|
+
* #1920 §3.5: a guest session still admitted — current, with something in scope. A scope that became empty (guest
|
|
21376
|
+
* sign-in off, the account removed from the connection's access, everything narrowed away) ends the session here,
|
|
21377
|
+
* so the next request and the open stream find it gone.
|
|
21378
|
+
*/
|
|
21379
|
+
guestStillAdmitted(record) {
|
|
21380
|
+
return this.guestAdmission(record) === "ok";
|
|
21381
|
+
}
|
|
21382
|
+
/**
|
|
21383
|
+
* The same, with a third answer (§3.2): "pending" — nothing in scope now, but a room's membership is not read yet
|
|
21384
|
+
* (a restart, a room just created). The request is refused and the session kept; its read starts in the background.
|
|
21385
|
+
*/
|
|
21386
|
+
guestAdmission(record) {
|
|
21387
|
+
if (!this.webSessions || !this.webToken || !this.webSessions.isCurrent(record, tokenEpoch(this.webToken)))
|
|
21388
|
+
return "gone";
|
|
21389
|
+
const owner = record.owner;
|
|
21390
|
+
if (this.guestScope(owner).size)
|
|
21391
|
+
return "ok";
|
|
21392
|
+
if (owner && this.fleetConfig?.web?.guests?.enabled === true) {
|
|
21393
|
+
const members = this.guestRooms(), rooms = guestGroupRooms(this.classicChannels?.getAll() ?? [], owner);
|
|
21394
|
+
if (rooms.some((room) => members.pending(room, owner.userId))) {
|
|
21395
|
+
void this.refreshGuestRooms(owner, { mode: "triggered" }); // rate-limited per room (30 s), single-flight
|
|
21396
|
+
return "pending";
|
|
21397
|
+
}
|
|
21398
|
+
}
|
|
21399
|
+
this.enforceGuestSwitch();
|
|
21400
|
+
const ended = this.webSessions.revokeByHandle(record.handle);
|
|
21401
|
+
if (ended.found)
|
|
21402
|
+
this.logger.info({ adapterId: record.owner?.adapterId, durable: ended.durable }, "Guest web session ended: nothing left in its scope");
|
|
21403
|
+
return "gone";
|
|
21404
|
+
}
|
|
21405
|
+
/** #1920 §3.2: the membership answers; the read goes to the room's own connection, never waits past its deadline. */
|
|
21406
|
+
guestRooms() {
|
|
21407
|
+
this.guestRoomMembership ??= new GuestRoomMembership({
|
|
21408
|
+
now: () => performance.now(),
|
|
21409
|
+
read: (room, userId) => {
|
|
21410
|
+
const adapter = this.worlds.get(room.adapterId)?.adapter;
|
|
21411
|
+
return room.kind === "telegram" ? adapter?.isGroupMember?.(room.channelId, userId) : adapter?.canViewChannel?.(room.channelId, userId);
|
|
21412
|
+
},
|
|
21413
|
+
// Said once per key while its reads keep failing (no user id: the room and connection are what an admin fixes).
|
|
21414
|
+
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"),
|
|
21415
|
+
onRecovered: (room) => this.logger.info({ adapterId: room.adapterId, instance: room.instance }, "Guest room membership reads again"),
|
|
21416
|
+
});
|
|
21417
|
+
return this.guestRoomMembership;
|
|
21418
|
+
}
|
|
21419
|
+
/** #1920 §3.2: read this guest's rooms that are due (GuestRoomMembership.refresh says when each mode reads). */
|
|
21420
|
+
async refreshGuestRooms(owner, opts = {}) {
|
|
21421
|
+
if (this.fleetConfig?.web?.guests?.enabled !== true)
|
|
21422
|
+
return;
|
|
21423
|
+
try {
|
|
21424
|
+
await this.guestRooms().refresh(owner, guestGroupRooms(this.classicChannels?.getAll() ?? [], owner), opts);
|
|
21425
|
+
}
|
|
21426
|
+
catch (err) {
|
|
21427
|
+
this.logger.warn({ err: err.message }, "Guest room membership could not be read");
|
|
21428
|
+
}
|
|
21429
|
+
}
|
|
21430
|
+
/** Every account with a live guest session, one after another (the guest caps bound them); one pass at a time. */
|
|
21431
|
+
async refreshAllGuestRooms() {
|
|
21432
|
+
if (this.guestRoomsPass || this.fleetConfig?.web?.guests?.enabled !== true || !this.webSessions)
|
|
21433
|
+
return;
|
|
21434
|
+
this.guestRoomsPass = true;
|
|
21435
|
+
try {
|
|
21436
|
+
for (const owner of this.webSessions.guestOwners())
|
|
21437
|
+
await this.refreshGuestRooms(owner);
|
|
21438
|
+
}
|
|
21439
|
+
finally {
|
|
21440
|
+
this.guestRoomsPass = false;
|
|
21441
|
+
}
|
|
21442
|
+
}
|
|
21443
|
+
guestRoomsPass = false;
|
|
21444
|
+
/**
|
|
21445
|
+
* #1920 §3.5: guest sign-in turned off ends every guest session — checked when the configuration is read or saved
|
|
21446
|
+
* and when the sessions are opened, so turning it back on never revives one.
|
|
21447
|
+
*/
|
|
21448
|
+
enforceGuestSwitch() {
|
|
21449
|
+
if (this.fleetConfig?.web?.guests?.enabled === true || !this.webSessions)
|
|
21450
|
+
return;
|
|
21451
|
+
const ended = this.webSessions.revokeAll((r) => r.tier === "guest");
|
|
21452
|
+
if (ended.count)
|
|
21453
|
+
this.logger.info({ count: ended.count, durable: ended.durable }, "Guest web sign-in is off: every guest session ended");
|
|
21454
|
+
// #1920 step 4b: and every guest's returning device — turning it back on lets no old browser ask for a code.
|
|
21455
|
+
const devices = this.returningDevices?.revokeGuest();
|
|
21456
|
+
if (devices?.count)
|
|
21457
|
+
this.logger.info({ count: devices.count, durable: devices.durable }, "Guest web sign-in is off: every guest's returning device forgotten");
|
|
21458
|
+
}
|
|
21459
|
+
/** #1920 §5.2: whether this instance is the account's own Telegram private ClassicBot room (its chat id is the user id). */
|
|
21460
|
+
guestOwnRoom(owner, instance) {
|
|
21461
|
+
return (this.classicChannels?.getAll() ?? []).some((room) => room.instanceName === instance && this.isOwnPrivateRoom(owner, room));
|
|
21462
|
+
}
|
|
21463
|
+
/** A private room's chat id is its one user's id (a group's is negative, so never equal); Telegram only. */
|
|
21464
|
+
isOwnPrivateRoom(owner, room) {
|
|
21465
|
+
return room.adapterId === owner.adapterId && room.channelId === owner.userId && inferClassicChannelType(room.channelId) === "telegram";
|
|
21466
|
+
}
|
|
19850
21467
|
/** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
|
|
19851
21468
|
dispatchWebHttp(req, res, port, closing = false) {
|
|
19852
21469
|
try {
|
|
@@ -19877,6 +21494,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19877
21494
|
return;
|
|
19878
21495
|
}
|
|
19879
21496
|
const requestPath = new URL(req.url ?? "/", `http://localhost:${port}`).pathname;
|
|
21497
|
+
if (this.effectBootstrap.handle(req, res))
|
|
21498
|
+
return;
|
|
19880
21499
|
// Branding is public, after Host admission: automatic favicon requests
|
|
19881
21500
|
// must not become noisy web-token 401s in the browser console.
|
|
19882
21501
|
if (handleWebIconRequest(req, res, requestPath))
|
|
@@ -19907,6 +21526,40 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19907
21526
|
res.end(JSON.stringify({ error: decision.message }));
|
|
19908
21527
|
return;
|
|
19909
21528
|
}
|
|
21529
|
+
// #1920: a guest reaches only GUEST_ROUTES, within the scope derived for it now — default deny, before any
|
|
21530
|
+
// handler, on this listener and the public link's gateway alike (both come through here).
|
|
21531
|
+
if (decision.via === "session" && decision.session.tier === "guest") {
|
|
21532
|
+
const owner = decision.session.owner;
|
|
21533
|
+
const admitted = decision.session;
|
|
21534
|
+
const admission = this.guestAdmission(admitted);
|
|
21535
|
+
if (admission === "pending") {
|
|
21536
|
+
// §3.2: a room's membership is being read — try again shortly; the session stays.
|
|
21537
|
+
res.writeHead(503, { "Retry-After": "5" });
|
|
21538
|
+
res.end(JSON.stringify({ error: "guest_scope_pending" }));
|
|
21539
|
+
return;
|
|
21540
|
+
}
|
|
21541
|
+
if (admission === "gone") {
|
|
21542
|
+
res.writeHead(401);
|
|
21543
|
+
res.end(JSON.stringify({ error: "guest_scope_empty" }));
|
|
21544
|
+
return;
|
|
21545
|
+
}
|
|
21546
|
+
const verdict = guestRouteDecision(req.method ?? "GET", parsedUrl, this.guestScope(owner));
|
|
21547
|
+
if (verdict.kind === "deny") {
|
|
21548
|
+
res.writeHead(verdict.status);
|
|
21549
|
+
res.end(JSON.stringify({ error: verdict.error }));
|
|
21550
|
+
return;
|
|
21551
|
+
}
|
|
21552
|
+
// Phase 1b: the handlers that answer per recipient (the stream, the poll, a file) know it is a guest's, and
|
|
21553
|
+
// ask its scope anew each time; an emptied scope ends the session (and closes its stream).
|
|
21554
|
+
bindGuestRequest(req, { owner, scope: () => this.guestScope(owner), current: () => this.guestStillAdmitted(admitted),
|
|
21555
|
+
// #1920 §5.2: its own Telegram private room shows it everything; the kept messages place ticks and buttons.
|
|
21556
|
+
ownRoom: (instance) => this.guestOwnRoom(owner, instance),
|
|
21557
|
+
// §3.2: a stream that connects reads its rooms again (in the background; at most once a minute).
|
|
21558
|
+
streamOpened: () => { void this.refreshGuestRooms(owner, { mode: "force" }); },
|
|
21559
|
+
history: () => this.webChatHistory,
|
|
21560
|
+
// #1920 §5.1: guests chat unless the host made them read-only (web.guests.chat: false).
|
|
21561
|
+
chat: () => this.fleetConfig?.web?.guests?.chat !== false });
|
|
21562
|
+
}
|
|
19910
21563
|
}
|
|
19911
21564
|
if (req.method === "GET" && req.url === "/health") {
|
|
19912
21565
|
const health = this.getFleetHealth();
|
|
@@ -20297,6 +21950,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20297
21950
|
this.logger.warn({ code: err.code, port: settings.port }, "Preview listener unavailable; HTML previews are off");
|
|
20298
21951
|
this.previewListening = false;
|
|
20299
21952
|
this.previewListener = null;
|
|
21953
|
+
this.previewListenError = `${settings.port}: ${err.code ?? err.message}`;
|
|
20300
21954
|
});
|
|
20301
21955
|
// An optional listener never takes the fleet down: a port Node refuses outright throws here, synchronously.
|
|
20302
21956
|
try {
|
|
@@ -20307,6 +21961,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20307
21961
|
this.logger.warn({ err: err.message, port: settings.port }, "Preview listener cannot listen; HTML previews are off");
|
|
20308
21962
|
this.previewListening = false;
|
|
20309
21963
|
this.previewListener = null;
|
|
21964
|
+
this.previewListenError = `${settings.port}: ${err.code ?? err.message}`;
|
|
20310
21965
|
}
|
|
20311
21966
|
}
|
|
20312
21967
|
/** What a reload may change hot: web.preview, and the dashboard names (the shim's allow-list and frame-ancestors). */
|
|
@@ -20327,6 +21982,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20327
21982
|
}
|
|
20328
21983
|
stopPreviewListener() {
|
|
20329
21984
|
this.previewListening = false;
|
|
21985
|
+
this.previewListenError = null;
|
|
20330
21986
|
this.previewListener?.close();
|
|
20331
21987
|
this.previewListener = null;
|
|
20332
21988
|
}
|
|
@@ -20334,10 +21990,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20334
21990
|
previewForUi(hostHeader, secure) {
|
|
20335
21991
|
const listener = this.previewListening ? this.previewListener : null;
|
|
20336
21992
|
const decided = previewAvailability(listener ? listener.settings : null, hostHeader, secure, listener ? listener.origins : undefined);
|
|
21993
|
+
// Previews are on, but no listener answers: say why — its port could not be opened (the error, never "turned off"),
|
|
21994
|
+
// or it is not listening yet.
|
|
20337
21995
|
if (!listener && this.fleetConfig?.web?.preview !== false)
|
|
20338
|
-
decided
|
|
21996
|
+
Object.assign(decided, this.previewNotListening());
|
|
20339
21997
|
return { ...decided, boot: listener ? listener.bootId : null };
|
|
20340
21998
|
}
|
|
21999
|
+
previewNotListening() {
|
|
22000
|
+
const failed = this.previewListenError;
|
|
22001
|
+
return failed
|
|
22002
|
+
? { 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.` }
|
|
22003
|
+
: { code: "notListening", reason: "Previews are not available on this fleet right now." };
|
|
22004
|
+
}
|
|
20341
22005
|
/**
|
|
20342
22006
|
* #1723: for one page of the public link (exposure `exposureId`, at `dashboardOrigin`): its own preview tunnel's
|
|
20343
22007
|
* origin once up, "starting" until then, why it failed if it did — or a configured web.preview_origin, used instead.
|
|
@@ -20352,8 +22016,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20352
22016
|
const configured = typeof web?.preview_origin === "string" ? normalizeOrigin(web.preview_origin) : null;
|
|
20353
22017
|
if (configured) {
|
|
20354
22018
|
const listener = this.previewListening ? this.previewListener : null;
|
|
20355
|
-
if (!listener)
|
|
20356
|
-
|
|
22019
|
+
if (!listener) {
|
|
22020
|
+
const why = this.previewNotListening();
|
|
22021
|
+
return off(why.code, why.reason, why.detail ? { detail: why.detail } : {});
|
|
22022
|
+
}
|
|
20357
22023
|
if (!configured.startsWith("https:"))
|
|
20358
22024
|
return off("needHttps", "Previews need an https web.preview_origin when the dashboard is reached over https.");
|
|
20359
22025
|
return { dashboardOrigin, previewOrigin: configured, reason: null, code: null, boot: listener.bootId, frameSrc: `${configured}/frame` };
|
|
@@ -20397,15 +22063,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20397
22063
|
this.logger.debug({ err }, "bots unreadable (getUiStatus)");
|
|
20398
22064
|
}
|
|
20399
22065
|
const instances = names.map(name => {
|
|
20400
|
-
|
|
20401
|
-
|
|
20402
|
-
|
|
20403
|
-
const data = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
20404
|
-
cost = data.cost?.total_cost_usd ?? 0;
|
|
20405
|
-
}
|
|
20406
|
-
catch (err) {
|
|
20407
|
-
this.logger.debug({ err, name }, "statusline.json read failed (getUiStatus)");
|
|
20408
|
-
}
|
|
22066
|
+
// #1956: read once for this instance; its cost, context and model come from it.
|
|
22067
|
+
const statusline = readStatuslineJson(this.dataDir, name);
|
|
22068
|
+
const cost = statusline?.cost?.total_cost_usd ?? 0;
|
|
20409
22069
|
// Align with /ctx: statusline for claude-code, pane scrape for kiro/grok/codex.
|
|
20410
22070
|
const classic = classicOnly.includes(name);
|
|
20411
22071
|
const backend = classic
|
|
@@ -20413,14 +22073,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20413
22073
|
: (this.fleetConfig?.instances[name]?.backend
|
|
20414
22074
|
?? this.fleetConfig?.defaults?.backend
|
|
20415
22075
|
?? "claude-code");
|
|
20416
|
-
const { context } = resolveInstanceContext(this.dataDir, name, backend, { source: this.getPaneContextSource(name) });
|
|
22076
|
+
const { context } = resolveInstanceContext(this.dataDir, name, backend, { source: this.getPaneContextSource(name), statusline });
|
|
20417
22077
|
// context_pct: null when unavailable, not 0
|
|
20418
22078
|
const context_pct = context ?? null;
|
|
20419
22079
|
// Model: Only Claude Code has live statusline; others use the effective resolver.
|
|
20420
|
-
//
|
|
22080
|
+
// statuslineModel gives the /ctx-aligned display_name+id combo for Claude (readStatuslineModel's rule).
|
|
20421
22081
|
// Non-Claude backends must NOT read statusline.json model (may be stale from previous Claude run).
|
|
20422
22082
|
const resolved = this.resolveInstanceModel(name);
|
|
20423
|
-
const liveModel = backend === "claude-code" ?
|
|
22083
|
+
const liveModel = backend === "claude-code" ? statuslineModel(statusline) : null;
|
|
20424
22084
|
// Display value: live model for Claude, resolved.display for others (includes "auto (default)" for Kiro)
|
|
20425
22085
|
const model = liveModel ?? resolved.display;
|
|
20426
22086
|
// model_source: "live" when Claude statusline succeeded, else the resolver's source
|
|
@@ -20457,6 +22117,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20457
22117
|
catch {
|
|
20458
22118
|
return {};
|
|
20459
22119
|
} })(),
|
|
22120
|
+
// #2118: only this Chat's placement content; guest projection drops global revisions.
|
|
22121
|
+
effect_chat_v: createHash("sha256").update(JSON.stringify(this.themeStore.get(name).placements.effects)).digest("hex").slice(0, 32),
|
|
20460
22122
|
// #1958: the identity colour's tiers (the page resolves them: the agent's only while it shows agents' themes).
|
|
20461
22123
|
color: { ...(colors.get(name)?.color ? { person: colors.get(name).color } : {}), ...(image.agentColor ? { agent: image.agentColor } : {}),
|
|
20462
22124
|
...(image.roleColor ? { role: image.roleColor } : {}), auto: autoIdentityColor(name) },
|
|
@@ -20465,6 +22127,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20465
22127
|
description: identity.description || undefined,
|
|
20466
22128
|
role: identity.role || undefined,
|
|
20467
22129
|
tags: identity.tags,
|
|
22130
|
+
teams: instanceTeams(this.fleetConfig?.teams, name), // #2172: the sidebar groups a team's members under it
|
|
22131
|
+
last_reply: this.webChatHistory.lastAgentReply(name) ?? undefined, // #2179: its last reply to a person (ISO)
|
|
20468
22132
|
status: this.getInstanceStatus(name),
|
|
20469
22133
|
// `state` (presentation: may be awaiting_input) and `execution_state` (working / idle / stuck, or null —
|
|
20470
22134
|
// what the dashboard's activity events carry) come from instancePresentation (#1212).
|
|
@@ -20493,6 +22157,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20493
22157
|
// #1685: host memory pressure while it slows or holds new agents (the dashboard's indicator; macOS elevated is
|
|
20494
22158
|
// shown here rather than in chat). null when normal, unknown or advisory.
|
|
20495
22159
|
host_memory: this.hostMemoryIndicator(),
|
|
22160
|
+
// #2015: the largest processes while it lasts (name, pid, RSS, and whose: an instance, the fleet or tmux).
|
|
22161
|
+
...(this.hostMemoryIndicator() && this.memoryTop ? { host_memory_top: this.memoryTop.processes } : {}),
|
|
20496
22162
|
};
|
|
20497
22163
|
}
|
|
20498
22164
|
}
|