@songsid/agend 2.2.0-beta.1 → 2.2.0-beta.11
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 +1 -1
- package/README.zh-TW.md +1 -1
- package/dist/agent-cli-instructions.md +16 -9
- package/dist/agent-cli.js +15 -7
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.js +24 -9
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-whats-new.d.ts +47 -0
- package/dist/agent-whats-new.js +147 -0
- package/dist/agent-whats-new.js.map +1 -0
- package/dist/auth-api.d.ts +5 -3
- package/dist/auth-api.js +19 -3
- package/dist/auth-api.js.map +1 -1
- package/dist/backend/antigravity.d.ts +8 -0
- package/dist/backend/antigravity.js +32 -9
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-carry.d.ts +22 -0
- package/dist/backend/claude-carry.js +170 -0
- package/dist/backend/claude-carry.js.map +1 -0
- package/dist/backend/claude-code.d.ts +65 -2
- package/dist/backend/claude-code.js +226 -33
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex-metadata.d.ts +33 -0
- package/dist/backend/codex-metadata.js +122 -18
- package/dist/backend/codex-metadata.js.map +1 -1
- package/dist/backend/codex.d.ts +3 -0
- package/dist/backend/codex.js +17 -1
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/credential-profile.d.ts +40 -3
- package/dist/backend/credential-profile.js +113 -5
- package/dist/backend/credential-profile.js.map +1 -1
- package/dist/backend/effort-metadata.d.ts +37 -1
- package/dist/backend/effort-metadata.js +86 -15
- package/dist/backend/effort-metadata.js.map +1 -1
- package/dist/backend/grok.d.ts +4 -0
- package/dist/backend/grok.js +14 -0
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro-engine-ledger.d.ts +5 -0
- package/dist/backend/kiro-engine-ledger.js +20 -0
- package/dist/backend/kiro-engine-ledger.js.map +1 -1
- package/dist/backend/kiro-identity.d.ts +6 -0
- package/dist/backend/kiro-identity.js +19 -5
- package/dist/backend/kiro-identity.js.map +1 -1
- package/dist/backend/kiro.d.ts +39 -3
- package/dist/backend/kiro.js +143 -19
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/launch-effort.d.ts +61 -0
- package/dist/backend/launch-effort.js +170 -0
- package/dist/backend/launch-effort.js.map +1 -0
- package/dist/backend/muse.d.ts +84 -0
- package/dist/backend/muse.js +449 -53
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +47 -4
- package/dist/backend/types.js +12 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/backend-command.d.ts +91 -0
- package/dist/backend-command.js +122 -0
- package/dist/backend-command.js.map +1 -0
- package/dist/background-work-ended.d.ts +18 -0
- package/dist/background-work-ended.js +30 -0
- package/dist/background-work-ended.js.map +1 -0
- package/dist/cache-service.d.ts +2 -0
- package/dist/cache-service.js +2 -1
- package/dist/cache-service.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +36 -1
- package/dist/channel/adapters/discord.js +126 -4
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +42 -2
- package/dist/channel/adapters/telegram.js +142 -23
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-server.js +9 -1
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.d.ts +11 -0
- package/dist/channel/mcp-tools.js +26 -13
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/message-edit-errors.d.ts +5 -0
- package/dist/channel/message-edit-errors.js +24 -0
- package/dist/channel/message-edit-errors.js.map +1 -0
- package/dist/channel/telegram-markdown.d.ts +6 -0
- package/dist/channel/telegram-markdown.js +104 -0
- package/dist/channel/telegram-markdown.js.map +1 -0
- package/dist/channel/tool-router.d.ts +2 -0
- package/dist/channel/tool-router.js +10 -4
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +55 -3
- package/dist/cli.js +167 -39
- package/dist/cli.js.map +1 -1
- package/dist/command-table.d.ts +2 -2
- package/dist/command-table.js +6 -3
- package/dist/command-table.js.map +1 -1
- package/dist/config-validator.js +59 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/cross-instance-envelope.d.ts +15 -2
- package/dist/cross-instance-envelope.js +29 -5
- package/dist/cross-instance-envelope.js.map +1 -1
- package/dist/daemon.d.ts +156 -1
- package/dist/daemon.js +773 -89
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +70 -8
- package/dist/delivery-outbox.js +315 -24
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/delivery-reconciliation.d.ts +18 -1
- package/dist/delivery-reconciliation.js +67 -2
- package/dist/delivery-reconciliation.js.map +1 -1
- package/dist/fleet-context.d.ts +10 -1
- package/dist/fleet-manager.d.ts +404 -13
- package/dist/fleet-manager.js +2039 -210
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/backend-providers/SKILL.md +1 -1
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +92 -21
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +27 -3
- package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +8 -8
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +5 -5
- package/dist/general-knowledge/skills/fleet-health/SKILL.md +4 -4
- package/dist/general-knowledge/skills/fleet-restart/SKILL.md +13 -13
- package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +3 -2
- package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +6 -0
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +12 -9
- package/dist/general-knowledge/skills/multi-channel/SKILL.md +8 -2
- package/dist/general-knowledge/skills/scheduling/SKILL.md +5 -3
- package/dist/general-knowledge/skills/session-management/SKILL.md +6 -5
- package/dist/general-knowledge/skills/tui-effort/SKILL.md +2 -2
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +8 -3
- package/dist/general-knowledge/steering/core-rules.md +8 -7
- package/dist/host-memory.d.ts +10 -0
- package/dist/host-memory.js +33 -1
- package/dist/host-memory.js.map +1 -1
- package/dist/human-delivery-receipts.d.ts +83 -0
- package/dist/human-delivery-receipts.js +194 -0
- package/dist/human-delivery-receipts.js.map +1 -0
- package/dist/instance-avatar.d.ts +36 -0
- package/dist/instance-avatar.js +57 -0
- package/dist/instance-avatar.js.map +1 -0
- package/dist/instance-config-impact.js +6 -0
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +29 -6
- package/dist/instance-lifecycle.js +79 -28
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.d.ts +3 -1
- package/dist/instructions.js +11 -8
- package/dist/instructions.js.map +1 -1
- package/dist/kiro-log-gate.d.ts +42 -0
- package/dist/kiro-log-gate.js +39 -0
- package/dist/kiro-log-gate.js.map +1 -0
- package/dist/locale.js +226 -44
- package/dist/locale.js.map +1 -1
- package/dist/log-service.d.ts +99 -0
- package/dist/log-service.js +243 -0
- package/dist/log-service.js.map +1 -0
- package/dist/log-steps.d.ts +169 -0
- package/dist/log-steps.js +858 -0
- package/dist/log-steps.js.map +1 -0
- package/dist/login-controller.d.ts +20 -3
- package/dist/login-controller.js +71 -19
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.d.ts +5 -2
- package/dist/login-flows.js +5 -4
- package/dist/login-flows.js.map +1 -1
- package/dist/memory-pressure.d.ts +13 -1
- package/dist/memory-pressure.js +50 -10
- package/dist/memory-pressure.js.map +1 -1
- package/dist/needs-you-hub.js +2 -0
- package/dist/needs-you-hub.js.map +1 -1
- package/dist/needs-you-live.d.ts +6 -0
- package/dist/needs-you-live.js +23 -3
- package/dist/needs-you-live.js.map +1 -1
- package/dist/next-steps.js +1 -1
- package/dist/next-steps.js.map +1 -1
- package/dist/outbound-handlers.d.ts +100 -0
- package/dist/outbound-handlers.js +284 -97
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +38 -15
- package/dist/outbound-schemas.js +36 -8
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-link-progress.d.ts +1 -1
- package/dist/public-link-progress.js +8 -2
- package/dist/public-link-progress.js.map +1 -1
- package/dist/public-preview.d.ts +101 -0
- package/dist/public-preview.js +236 -0
- package/dist/public-preview.js.map +1 -0
- package/dist/public-web-gateway.js +14 -4
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/public-web-link.d.ts +24 -1
- package/dist/public-web-link.js +63 -9
- package/dist/public-web-link.js.map +1 -1
- package/dist/reply-buttons-controller.d.ts +89 -5
- package/dist/reply-buttons-controller.js +273 -8
- package/dist/reply-buttons-controller.js.map +1 -1
- package/dist/reply-buttons.d.ts +48 -3
- package/dist/reply-buttons.js +105 -10
- package/dist/reply-buttons.js.map +1 -1
- package/dist/reply-polls.d.ts +85 -0
- package/dist/reply-polls.js +154 -0
- package/dist/reply-polls.js.map +1 -0
- package/dist/restart-guard.d.ts +6 -3
- package/dist/restart-guard.js +6 -3
- package/dist/restart-guard.js.map +1 -1
- package/dist/restart-progress.d.ts +33 -0
- package/dist/restart-progress.js +30 -0
- package/dist/restart-progress.js.map +1 -1
- package/dist/service-activation.d.ts +15 -7
- package/dist/service-activation.js +46 -29
- package/dist/service-activation.js.map +1 -1
- package/dist/service-installer.d.ts +31 -0
- package/dist/service-installer.js +41 -2
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.js +85 -25
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +72 -1
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +28 -4
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.js +7 -0
- package/dist/settings-http-confirmation.js.map +1 -1
- package/dist/setup-form.d.ts +1 -1
- package/dist/setup-form.js +1 -1
- package/dist/spawn-gate.js +5 -1
- package/dist/spawn-gate.js.map +1 -1
- package/dist/spawn-hold-reply.d.ts +12 -0
- package/dist/spawn-hold-reply.js +39 -0
- package/dist/spawn-hold-reply.js.map +1 -0
- package/dist/status-emojis.d.ts +10 -3
- package/dist/status-emojis.js +11 -2
- package/dist/status-emojis.js.map +1 -1
- package/dist/systemd-peer-bus.d.ts +95 -0
- package/dist/systemd-peer-bus.js +430 -0
- package/dist/systemd-peer-bus.js.map +1 -0
- package/dist/systemd-runtime.d.ts +27 -1
- package/dist/systemd-runtime.js +69 -7
- package/dist/systemd-runtime.js.map +1 -1
- package/dist/tips.js +4 -4
- package/dist/tips.js.map +1 -1
- package/dist/tool-permissions.js +3 -3
- package/dist/tool-permissions.js.map +1 -1
- package/dist/tool-progress.js +3 -2
- package/dist/tool-progress.js.map +1 -1
- package/dist/topic-commands.d.ts +42 -2
- package/dist/topic-commands.js +101 -11
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-delta-reader.d.ts +3 -3
- package/dist/transcript-delta-reader.js +8 -8
- package/dist/transcript-delta-reader.js.map +1 -1
- package/dist/transcript-jsonl.d.ts +80 -3
- package/dist/transcript-jsonl.js +147 -10
- package/dist/transcript-jsonl.js.map +1 -1
- package/dist/transcript-monitor.d.ts +19 -0
- package/dist/transcript-monitor.js +87 -15
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +78 -0
- package/dist/transcript-sources.js +375 -64
- package/dist/transcript-sources.js.map +1 -1
- package/dist/transcript-turns.d.ts +15 -2
- package/dist/transcript-turns.js +15 -2
- package/dist/transcript-turns.js.map +1 -1
- package/dist/tunnel/cloudflared.d.ts +7 -15
- package/dist/tunnel/cloudflared.js +36 -1
- package/dist/tunnel/cloudflared.js.map +1 -1
- package/dist/tunnel/purpose-lane.d.ts +5 -1
- package/dist/tunnel/purpose-lane.js +14 -7
- package/dist/tunnel/purpose-lane.js.map +1 -1
- package/dist/tunnel/types.d.ts +5 -1
- package/dist/tunnel/types.js.map +1 -1
- package/dist/turn-resume.d.ts +2 -0
- package/dist/turn-resume.js +1 -1
- package/dist/turn-resume.js.map +1 -1
- package/dist/types.d.ts +25 -0
- package/dist/ui/chat-commands.js +1 -0
- package/dist/ui/chat-emojis.js +33 -0
- package/dist/ui/chat-mentions.js +63 -0
- package/dist/ui/chat-render.js +61 -4
- package/dist/ui/chat-store.js +37 -3
- package/dist/ui/chat-strings.js +40 -20
- package/dist/ui/chat-thread.js +111 -12
- package/dist/ui/chat-time.js +80 -0
- package/dist/ui/panel-chat.js +41 -10
- package/dist/ui/panel-details.js +2 -0
- package/dist/ui/panel-fleet.js +199 -57
- package/dist/ui/panel-log.js +428 -0
- package/dist/ui/panel-needs.js +6 -2
- package/dist/ui/panel-settings.js +184 -32
- package/dist/ui/preview-panel.js +55 -15
- package/dist/ui/preview-status.js +28 -0
- package/dist/ui/preview.js +44 -3
- package/dist/ui/settings-dialogs.js +7 -2
- package/dist/ui/settings-model.js +2 -1
- package/dist/ui/settings-strings.js +56 -4
- package/dist/ui/settings-token.js +1 -1
- package/dist/ui/shared/app-i18n.js +43 -17
- package/dist/ui/shared/app-prefs.js +250 -0
- package/dist/ui/shared/app-route.js +7 -2
- package/dist/ui/shared/app-shell.js +165 -8
- package/dist/ui/shared/app-store.js +5 -1
- package/dist/ui/shared/app-stream.js +26 -5
- package/dist/ui/shared/app.css +163 -22
- package/dist/ui/shared/app.js +11 -1
- package/dist/ui/shared/fonts/README.md +1 -1
- package/dist/ui/shared/header-tools.js +55 -5
- package/dist/ui/shared/instance-nav.js +241 -45
- package/dist/ui/shared/instance-switch.js +4 -4
- package/dist/ui/shared/panel-view.js +6 -0
- package/dist/ui/shared/signin.js +7 -7
- package/dist/ui/shared/theme.js +39 -7
- package/dist/ui/shared/tokens.css +1 -1
- package/dist/ui/shared/ui-icons.js +1 -0
- package/dist/ui/shared/usage-dialog.js +23 -5
- package/dist/ui/shared/view-strings.js +32 -4
- package/dist/ui/signin.html +1 -1
- package/dist/unrecognised-prompt.d.ts +23 -0
- package/dist/unrecognised-prompt.js +74 -0
- package/dist/unrecognised-prompt.js.map +1 -0
- package/dist/update-check.d.ts +31 -0
- package/dist/update-check.js +47 -0
- package/dist/update-check.js.map +1 -1
- package/dist/update-install.d.ts +28 -2
- package/dist/update-install.js +22 -4
- package/dist/update-install.js.map +1 -1
- package/dist/update-launch.d.ts +46 -1
- package/dist/update-launch.js +146 -3
- package/dist/update-launch.js.map +1 -1
- package/dist/update-marker.d.ts +51 -0
- package/dist/update-marker.js +61 -0
- package/dist/update-marker.js.map +1 -1
- package/dist/update-preflight.d.ts +100 -0
- package/dist/update-preflight.js +264 -0
- package/dist/update-preflight.js.map +1 -0
- package/dist/updater-caller.d.ts +31 -0
- package/dist/updater-caller.js +109 -0
- package/dist/updater-caller.js.map +1 -0
- package/dist/usage/i18n-keys.d.ts +1 -1
- package/dist/usage/i18n-keys.js +1 -1
- package/dist/usage/i18n-keys.js.map +1 -1
- package/dist/usage/providers.d.ts +34 -5
- package/dist/usage/providers.js +160 -27
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/statusline-usage.d.ts +6 -2
- package/dist/usage/statusline-usage.js +10 -4
- package/dist/usage/statusline-usage.js.map +1 -1
- package/dist/view-api.d.ts +1 -1
- package/dist/view-api.js +38 -8
- package/dist/view-api.js.map +1 -1
- package/dist/wake-coordinator.d.ts +11 -0
- package/dist/wake-coordinator.js +38 -4
- package/dist/wake-coordinator.js.map +1 -1
- package/dist/web-api.d.ts +89 -4
- package/dist/web-api.js +551 -45
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.d.ts +2 -2
- package/dist/web-auth.js +9 -4
- package/dist/web-auth.js.map +1 -1
- package/dist/web-chat-history.d.ts +12 -1
- package/dist/web-chat-history.js +29 -7
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-commands.d.ts +27 -4
- package/dist/web-commands.js +19 -4
- package/dist/web-commands.js.map +1 -1
- package/dist/web-host-guard.d.ts +3 -11
- package/dist/web-host-guard.js +3 -12
- package/dist/web-host-guard.js.map +1 -1
- package/dist/web-images.d.ts +47 -0
- package/dist/web-images.js +169 -0
- package/dist/web-images.js.map +1 -0
- package/dist/web-login.d.ts +14 -0
- package/dist/web-login.js +4 -2
- package/dist/web-login.js.map +1 -1
- package/dist/web-mentions.d.ts +44 -0
- package/dist/web-mentions.js +75 -0
- package/dist/web-mentions.js.map +1 -0
- package/dist/web-prefs.d.ts +81 -0
- package/dist/web-prefs.js +297 -0
- package/dist/web-prefs.js.map +1 -0
- package/dist/web-preview.d.ts +14 -1
- package/dist/web-preview.js +35 -13
- package/dist/web-preview.js.map +1 -1
- package/dist/web-profile.d.ts +32 -0
- package/dist/web-profile.js +132 -0
- package/dist/web-profile.js.map +1 -0
- package/dist/web-reply-files.d.ts +45 -0
- package/dist/web-reply-files.js +223 -0
- package/dist/web-reply-files.js.map +1 -0
- package/dist/web-schedules.d.ts +12 -0
- package/dist/web-schedules.js +34 -0
- package/dist/web-schedules.js.map +1 -0
- package/dist/web-server-drain.d.ts +10 -0
- package/dist/web-server-drain.js +50 -0
- package/dist/web-server-drain.js.map +1 -0
- package/dist/web-session.d.ts +50 -1
- package/dist/web-session.js +63 -6
- package/dist/web-session.js.map +1 -1
- package/dist/web-settings-patch.d.ts +23 -0
- package/dist/web-settings-patch.js +77 -0
- package/dist/web-settings-patch.js.map +1 -0
- package/dist/web-shell-routes.d.ts +5 -1
- package/dist/web-shell-routes.js +10 -1
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/web-upload.d.ts +17 -4
- package/dist/web-upload.js +64 -13
- package/dist/web-upload.js.map +1 -1
- package/dist/workflow-templates/default.md +1 -1
- package/package.json +1 -1
- package/templates/launchd.plist.ejs +3 -1
- package/templates/systemd.service.ejs +2 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
|
+
import { humanOwnerObjectId, webHumanOrigin } from "./human-delivery-receipts.js";
|
|
1
2
|
import { SettingsBaselines } from "./settings-baseline.js";
|
|
2
3
|
import { SettingsConfirmationStore } from "./settings-confirmation.js";
|
|
3
4
|
import { SettingsHttpConfirmation } from "./settings-http-confirmation.js";
|
|
4
5
|
import { SettingsControlServer } from "./settings-control.js";
|
|
5
6
|
import { SettingsExecution, settingsRevision, noteSettingsWrite, settingsFileResource, assertSettingsLease, trySettingsLease, waitSettingsLease, settingsUndo, undoSettingsPaths } from "./settings-transaction.js";
|
|
6
7
|
import { performance } from "node:perf_hooks";
|
|
8
|
+
import { withSpawnHoldReply } from "./spawn-hold-reply.js";
|
|
7
9
|
import { gatewayRequestContext } from "./web-request-context.js";
|
|
8
10
|
import { createPublicWebGateway } from "./public-web-gateway.js";
|
|
9
11
|
import { handleWebIconRequest } from "./web-icons.js";
|
|
12
|
+
import { trackWebServer } from "./web-server-drain.js";
|
|
10
13
|
import { renderPublicLinkProgress, ThrottledMessageEditor } from "./public-link-progress.js";
|
|
11
14
|
import { PublicWebLink, publicLinkSettings } from "./public-web-link.js";
|
|
15
|
+
import { PUBLIC_PREVIEW_FRAME_SRC, PUBLIC_PREVIEW_TUNNEL_DIR, PublicPreview, publicPreviewWanted } from "./public-preview.js";
|
|
16
|
+
import { CloudflaredProvider } from "./tunnel/cloudflared.js";
|
|
12
17
|
import { TunnelPurposeLane } from "./tunnel/purpose-lane.js";
|
|
13
18
|
import { ManagedTunnel } from "./tunnel/manager.js";
|
|
14
19
|
import { withinBudget } from "./monotonic-budget.js";
|
|
15
20
|
import { measureSyncWork } from "./sync-work-attribution.js";
|
|
16
21
|
import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
|
|
17
22
|
import { ProfileControlServer } from "./profile-control.js";
|
|
18
|
-
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, constants as fsConstants } from "node:fs";
|
|
23
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, constants as fsConstants, closeSync } from "node:fs";
|
|
19
24
|
import { atomicWriteFileSync } from "./atomic-write.js";
|
|
20
25
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
21
26
|
import { spawnSync } from "node:child_process";
|
|
@@ -27,20 +32,21 @@ import { join, dirname, basename, delimiter, isAbsolute, resolve as resolvePath
|
|
|
27
32
|
import { fileURLToPath } from "node:url";
|
|
28
33
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
29
34
|
import { fleetLabel } from "./fleet-label.js";
|
|
30
|
-
import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, updateProgressOperation, } from "./update-marker.js";
|
|
35
|
+
import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, sameUpdateMarker, setUpdateProgressStage, updateMarkerIdentity, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
|
|
31
36
|
import { formatUpdateProgress } from "./update-progress.js";
|
|
32
37
|
import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
|
|
33
38
|
import { readFleetMemory } from "./process-memory.js";
|
|
34
39
|
import { MemoryPressure } from "./memory-pressure.js";
|
|
35
40
|
import { replyDedupText, ReplyDeduper } from "./reply-dedup.js";
|
|
36
41
|
import { isMap, isScalar, parseDocument } from "yaml";
|
|
42
|
+
import { kiroLogGate } from "./kiro-log-gate.js";
|
|
37
43
|
const __filename = fileURLToPath(import.meta.url);
|
|
38
44
|
const __dirname = dirname(__filename);
|
|
39
45
|
/** Fallback access policy for a channel with no `access:` block — open (no gate). */
|
|
40
46
|
const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
|
|
41
47
|
import { STATUS_EMOJI_CONFIG_KEYS, STATUS_EMOJI_KEYS, STATUS_EMOJI_SUGGESTIONS, TELEGRAM_REACTION_EMOJIS, builtinStatusEmojis, customEmojiValue, emojiImageUrl, normalizeEmoji, previewStatusEmojis, reactionForm, reactionMatchKey, resolveStatusEmojis, statusAvoidList, statusEmojiProblem, statusMatchKey, statusMatchKeys, textForm, } from "./status-emojis.js";
|
|
42
48
|
import { isProbeableRouteTarget } from "./fleet-context.js";
|
|
43
|
-
import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
|
|
49
|
+
import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
|
|
44
50
|
import { EventLog } from "./event-log.js";
|
|
45
51
|
import { binaryProbe } from "./binary-probe.js";
|
|
46
52
|
import { classifySqliteOpenError } from "./sqlite-open-errors.js";
|
|
@@ -51,13 +57,16 @@ import { startEventLoopWatch } from "./event-loop-watch.js";
|
|
|
51
57
|
import { TmuxManager } from "./tmux-manager.js";
|
|
52
58
|
import { AccessManager } from "./channel/access-manager.js";
|
|
53
59
|
import { IpcClient } from "./channel/ipc-bridge.js";
|
|
60
|
+
import { usesChannelAddress } from "./channel/mcp-tools.js";
|
|
54
61
|
import { formatFleetTime } from "./tz-utils.js";
|
|
55
62
|
import { createAdapter } from "./channel/factory.js";
|
|
56
63
|
import { isWebChannelEcho, WEB_ECHO_PREFIX } from "./web-channel-echo.js";
|
|
57
64
|
import { TelegramAdapter } from "./channel/adapters/telegram.js";
|
|
58
65
|
import { createBackend } from "./backend/factory.js";
|
|
59
66
|
import { readEffortMetadata } from "./backend/effort-metadata.js";
|
|
60
|
-
import {
|
|
67
|
+
import { codexEffort, flagEffort } from "./backend/launch-effort.js";
|
|
68
|
+
import { codexMetadataHomes, readCodexModelEffortLevels, resolveCodexLaunchModel } from "./backend/codex-metadata.js";
|
|
69
|
+
import { CLI_ENV_TTL_MS, isModelCompatible, resolveBinary, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
|
|
61
70
|
import { createLogger, rotateLogIfNeeded } from "./logger.js";
|
|
62
71
|
import { processAttachments } from "./channel/attachment-handler.js";
|
|
63
72
|
import { routeToolCall } from "./channel/tool-router.js";
|
|
@@ -73,8 +82,9 @@ import { RoutingEngine } from "./routing-engine.js";
|
|
|
73
82
|
import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
|
|
74
83
|
import { TopicArchiver } from "./topic-archiver.js";
|
|
75
84
|
import { StatuslineWatcher } from "./statusline-watcher.js";
|
|
76
|
-
import { outboundHandlers } from "./outbound-handlers.js";
|
|
77
|
-
import {
|
|
85
|
+
import { applyInstanceConfigPatch, outboundHandlers } from "./outbound-handlers.js";
|
|
86
|
+
import { backendArgs, backendStep, joinBackendArgs, modelNameCarried, planDisplay } from "./backend-command.js";
|
|
87
|
+
import { DeliveryOutbox, DEFAULT_REPLY_OBLIGATION_TTL_MS, isSilentScheduleDelivery } from "./delivery-outbox.js";
|
|
78
88
|
// The target Daemon can legitimately wait 30 minutes for a busy pane before
|
|
79
89
|
// deciding to defer before submission. This is an alert threshold only: the
|
|
80
90
|
// active manager/target generation pair owns the lane until the daemon reports
|
|
@@ -126,12 +136,21 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
|
|
|
126
136
|
return tasks;
|
|
127
137
|
}
|
|
128
138
|
import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
|
|
139
|
+
import { LogService } from "./log-service.js";
|
|
129
140
|
import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
|
|
130
141
|
import { WebChatDiskStore } from "./web-chat-store.js";
|
|
142
|
+
import { SeenUsers, resolveMentions } from "./web-mentions.js";
|
|
143
|
+
import { WebPrefsStore } from "./web-prefs.js";
|
|
144
|
+
import { WEB_NAME_FALLBACK, safeWebName, validateWebName, webNameInUse, webPersonKey } from "./web-profile.js";
|
|
131
145
|
import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
|
|
132
146
|
import { ReplyButtonsController } from "./reply-buttons-controller.js";
|
|
147
|
+
import { parsePoll } from "./reply-polls.js";
|
|
133
148
|
import { publicAttachment, sweepOrphanedUploads, WebFileLedger } from "./web-upload.js";
|
|
149
|
+
import { REPLY_FILES_MAX_BYTES, snapshotReplyFile, sweepReplySnapshots, webFilesDir } from "./web-reply-files.js";
|
|
134
150
|
import { handleViewRequest, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
|
|
151
|
+
import { downloadStickerImage } from "./channel/sticker-download.js";
|
|
152
|
+
import { WebImageCache, imageMimeForFile, WEB_IMAGE_MAX_AGE_MS, WEB_IMAGE_MAX_BYTES, WEB_IMAGE_RETRY_MS } from "./web-images.js";
|
|
153
|
+
import { accentColor, avatarFor, ownBotConnections } from "./instance-avatar.js";
|
|
135
154
|
import { envFileKeys } from "./token-env-name.js";
|
|
136
155
|
import { discordGeneralChannelId } from "./discord-general.js";
|
|
137
156
|
import { parseEnvText } from "./env-file.js";
|
|
@@ -145,8 +164,8 @@ import { canonicalCommand, commandSpec, decideCommand, ruleFor } from "./command
|
|
|
145
164
|
import { runVisibilityCommand } from "./cross-instance-notice.js";
|
|
146
165
|
import { installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
|
|
147
166
|
import { resolveInstalledAgend } from "./update-dispatch.js";
|
|
148
|
-
import { resolveUpdateLaunch, watchUpdateLaunch } from "./update-launch.js";
|
|
149
|
-
import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
|
|
167
|
+
import { resolveUpdateLaunch, openUpdateLog, updateLogPath, watchUpdateLaunch } from "./update-launch.js";
|
|
168
|
+
import { admitProfileLogin, LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
|
|
150
169
|
import { runBeforeDeadline } from "./deadline.js";
|
|
151
170
|
import { LoginWindowLock } from "./login-window-lock.js";
|
|
152
171
|
import { handleSettingsRequest } from "./settings-api.js";
|
|
@@ -156,11 +175,12 @@ import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet
|
|
|
156
175
|
import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
|
|
157
176
|
import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
|
|
158
177
|
import { assertExplicitInstanceRemoval } from "./instance-removal.js";
|
|
159
|
-
import { validateFleetConfig } from "./config-validator.js";
|
|
178
|
+
import { KNOWN_BACKENDS, validateFleetConfig } from "./config-validator.js";
|
|
160
179
|
import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
|
|
161
180
|
import { presentationState, interactionSummary, sameInteractionOwner } from "./interaction-observation.js";
|
|
162
181
|
import { readLastInboundAt } from "./daemon.js";
|
|
163
182
|
import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
|
|
183
|
+
import { readBackgroundWorkEndedMarker } from "./background-work-ended.js";
|
|
164
184
|
import { DEFAULT_WARM_OVERFLOW, WakeCoordinator } from "./wake-coordinator.js";
|
|
165
185
|
import { TargetQueueWorker } from "./target-queue-worker.js";
|
|
166
186
|
import { resolveDeliveryWorkerMode } from "./types.js";
|
|
@@ -174,17 +194,17 @@ import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
|
|
|
174
194
|
import { buildOrgChart } from "./web-org.js";
|
|
175
195
|
import { CacheService, WINDOWS } from "./cache-service.js";
|
|
176
196
|
import { runWebCommand } from "./web-commands.js";
|
|
177
|
-
import { claudeProjectKey } from "./backend/claude-code.js";
|
|
197
|
+
import { claudeConfigDirFor, claudeProjectKey } from "./backend/claude-code.js";
|
|
178
198
|
import { sharedRolloutIndex } from "./rollout-index.js";
|
|
179
199
|
import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
|
|
180
200
|
import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
|
|
181
201
|
import { bypassesWebGate, handleAuthRequest, serveSigninPage } from "./auth-api.js";
|
|
182
202
|
import { isWebPageNavigation } from "./web-shell-routes.js";
|
|
183
|
-
import { tokenEpoch, WebSessionStore } from "./web-session.js";
|
|
203
|
+
import { localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
|
|
184
204
|
import { WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
|
|
185
205
|
import { CodeRequestLimiter, ReturningDevices } from "./web-returning-device.js";
|
|
186
206
|
import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
|
|
187
|
-
import { createPreviewListener, previewAvailability, previewSettings } from "./web-preview.js";
|
|
207
|
+
import { createPreviewListener, normalizeOrigin, previewAvailability, previewSettings } from "./web-preview.js";
|
|
188
208
|
import { fleetLevelDifferences, fleetLevelSignature, appendedConnections } from "./fleet-level-config.js";
|
|
189
209
|
import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
|
|
190
210
|
import { SecretStore } from "./secret-store.js";
|
|
@@ -194,10 +214,11 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
|
|
|
194
214
|
/** A self-restart is a whole service restart; 120s is the apply budget, not this. */
|
|
195
215
|
const SELF_RESTART_DEADLINE_MS = 300_000;
|
|
196
216
|
import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
|
|
197
|
-
import { credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
217
|
+
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
218
|
+
import { carryClaudeConversation } from "./backend/claude-carry.js";
|
|
198
219
|
import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
|
|
199
220
|
import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
|
|
200
|
-
import { formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
|
|
221
|
+
import { cleanupWatchVerdict, formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
|
|
201
222
|
import { launchFullRestartHelper } from "./full-restart.js";
|
|
202
223
|
import { SYSTEMD_RESTART_INDETERMINATE_EXIT_CODE } from "./service-restart-selection.js";
|
|
203
224
|
import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
|
|
@@ -248,6 +269,10 @@ export function isOrphanInstanceWindowName(name) {
|
|
|
248
269
|
/** Retry cadence for retiring a cancel button whose delete failed (e.g. a DC
|
|
249
270
|
* forum thread the bot momentarily can't reach). 3 retries × 5min = 15min. */
|
|
250
271
|
const CANCEL_BTN_RETRY_INTERVAL_MS = 5 * 60_000;
|
|
272
|
+
/** #1753: a carried conversation this large is logged as a warning (it is still carried). */
|
|
273
|
+
const CARRY_LARGE_BYTES = 256 * 1024 * 1024;
|
|
274
|
+
/** #1742: how long after a Telegram /web that could not reach an admin privately a bare /start counts as /start web. */
|
|
275
|
+
const TG_WEB_START_WINDOW_MS = 10 * 60_000;
|
|
251
276
|
const CANCEL_BTN_MAX_RETRIES = 3;
|
|
252
277
|
/** Backstop: every 5min, retire a button whose instance has gone idle. Catches
|
|
253
278
|
* buttons no clear trigger reached (e.g. a scheduled/HTTP turn that never called
|
|
@@ -333,6 +358,7 @@ const CLASSIC_BACKEND_SELECTION_TIMEOUT_MS = 60_000;
|
|
|
333
358
|
const CLASSIC_BACKEND_CALLBACK_PREFIX = "classic-backend:";
|
|
334
359
|
const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
|
|
335
360
|
const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
|
|
361
|
+
const BACKEND_SELECT_CALLBACK_PREFIX = "backend-select:";
|
|
336
362
|
const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
|
|
337
363
|
const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
|
|
338
364
|
/** #1386: how long a paused instance's marker reason is reused before it is read again (transitions drop it at once). */
|
|
@@ -356,6 +382,7 @@ const WEB_MIRRORED_PROMPT_PREFIXES = new Set([
|
|
|
356
382
|
const WEB_ONLY_REPLY_SINK = {
|
|
357
383
|
type: "web",
|
|
358
384
|
supportsReplyButtons: true, // #1266: the web chat shows a reply's buttons itself
|
|
385
|
+
supportsPolls: true, // #1823: …and a poll (its pages are told; there is nothing to edit)
|
|
359
386
|
sendText: async () => ({ chatId: "web", messageId: newWebMessageId() }),
|
|
360
387
|
sendFile: async () => ({ chatId: "web", messageId: newWebMessageId() }),
|
|
361
388
|
};
|
|
@@ -423,6 +450,8 @@ const CLI_ENV_FRESH_MS = 60 * 60 * 1000;
|
|
|
423
450
|
* and hides exactly the newly released model the user opened `/model` to find.
|
|
424
451
|
* The wait is announced before it starts, so it reads as progress, not a stall.
|
|
425
452
|
*/
|
|
453
|
+
/** #1690: how long `/model` waits for the daemon's word on our own runtime switch (its dialog wait plus settling). */
|
|
454
|
+
export const RUNTIME_SWITCH_RESULT_MS = 12_000;
|
|
426
455
|
export const CLI_ENV_PROBE_DEADLINE_MS = 16_000;
|
|
427
456
|
/**
|
|
428
457
|
* How many CLIs may cold-start at once, from BOTH memory and cores.
|
|
@@ -610,6 +639,11 @@ export class FleetManager {
|
|
|
610
639
|
webChannelEchoGuards = new Map();
|
|
611
640
|
/** instanceName → restart currently executing; concurrent callers join it. */
|
|
612
641
|
restartsInFlight = new Map();
|
|
642
|
+
/**
|
|
643
|
+
* #1753: each in-flight restart's stop → start gap — the conversation carry it will run there (its own, or one a later
|
|
644
|
+
* caller joined with before the gap), and whether the gap has been reached.
|
|
645
|
+
*/
|
|
646
|
+
restartGaps = new Map();
|
|
613
647
|
/**
|
|
614
648
|
* Delayed automatic retries for instances whose startup failed. Before this a
|
|
615
649
|
* failed start was logged once and the instance stayed `stopped` until an
|
|
@@ -645,6 +679,8 @@ export class FleetManager {
|
|
|
645
679
|
/** In-flight /effort selections, same coordinator shape as pendingModelSelects. */
|
|
646
680
|
pendingEffortSelects = new Map();
|
|
647
681
|
pendingModelSelects = new Map();
|
|
682
|
+
/** #1806: in-flight /backend steps, keyed by nonce (see handleBackendSelection). */
|
|
683
|
+
pendingBackendSelects = new Map();
|
|
648
684
|
/** nonce → pending button prompt (hang restart, interactive assist, clean-exit restart). */
|
|
649
685
|
pendingNonceButtons = new Map();
|
|
650
686
|
/** #1386 "Needs you": the list, its live chat messages and Acknowledge. Started with the fleet (finishStartup). */
|
|
@@ -714,6 +750,7 @@ export class FleetManager {
|
|
|
714
750
|
collabInstances = new Set();
|
|
715
751
|
// Health endpoint
|
|
716
752
|
healthServer = null;
|
|
753
|
+
healthServerDrain = null;
|
|
717
754
|
/** #1306: the preview listener (health_port + 1 by default), and whether it is listening. */
|
|
718
755
|
previewListener = null;
|
|
719
756
|
previewListening = false;
|
|
@@ -726,6 +763,13 @@ export class FleetManager {
|
|
|
726
763
|
updateProgressEditRunning = false;
|
|
727
764
|
lastUpdateProgressText = null;
|
|
728
765
|
updateCompletionTipText = null;
|
|
766
|
+
/**
|
|
767
|
+
* #1660: an adopted update whose retire outcome is still owed. The
|
|
768
|
+
* completion summary was (or will be) posted; this watch appends the
|
|
769
|
+
* cleanup outcome to the same receipt instead of letting stage edits
|
|
770
|
+
* clobber it — and refuses a receipt that no longer matches (checklist 3/5).
|
|
771
|
+
*/
|
|
772
|
+
updateCleanupWatch = null;
|
|
729
773
|
/** Injectable only to keep the chat→CLI reload hand-off deterministic in tests. */
|
|
730
774
|
fullRestartLauncher = launchFullRestartHelper;
|
|
731
775
|
eventLogPruneTimer = null;
|
|
@@ -745,8 +789,8 @@ export class FleetManager {
|
|
|
745
789
|
memoryLogAt = null;
|
|
746
790
|
memoryDebugAt = null;
|
|
747
791
|
memoryLogLevel = null;
|
|
748
|
-
|
|
749
|
-
|
|
792
|
+
/** #1685: the host_memory value the last status frame carried, so a change re-sends the frame. */
|
|
793
|
+
memoryIndicatorShown = null;
|
|
750
794
|
// Mirror topic: buffer cross-instance messages, flush every 3s
|
|
751
795
|
mirrorBuffer = [];
|
|
752
796
|
mirrorTimer = null;
|
|
@@ -758,6 +802,93 @@ export class FleetManager {
|
|
|
758
802
|
webChatStore = null;
|
|
759
803
|
/** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
|
|
760
804
|
webFiles = new WebFileLedger();
|
|
805
|
+
/** #1770: per instance, its last web reply still copying its files — the next one is shown after it, in order. */
|
|
806
|
+
webReplyOrder = new Map();
|
|
807
|
+
/** #1770: an instance's web-files byte cap (a field, so a test can make it small). */
|
|
808
|
+
webReplyMaxBytes = REPLY_FILES_MAX_BYTES;
|
|
809
|
+
/** #1682: the people seen on inbound messages (id → name), for the web chat's mention pills; bounded, in memory. */
|
|
810
|
+
seenUsers = new SeenUsers();
|
|
811
|
+
webPrefsStore;
|
|
812
|
+
/** #1665: the web preferences kept on the host (<home>/web-prefs.json), read on first use. */
|
|
813
|
+
/**
|
|
814
|
+
* #1827: a web person as agents and chats see them. Signed in as an account (a sign-in code DMed to it): that
|
|
815
|
+
* account's user id — still `via web`, and only for recognition — and the name they chose, else their account's name
|
|
816
|
+
* at sign-in (made safe). Otherwise (an `agend web` session): "web-user", as before, under a name the operator may set.
|
|
817
|
+
* Names are checked again now: one an instance or bot has taken since reads as the next choice down.
|
|
818
|
+
*/
|
|
819
|
+
webPerson(owner) {
|
|
820
|
+
const key = webPersonKey(owner);
|
|
821
|
+
let profile = null;
|
|
822
|
+
try {
|
|
823
|
+
profile = this.webPrefs.profile(key);
|
|
824
|
+
}
|
|
825
|
+
catch (err) {
|
|
826
|
+
this.logger.warn({ err: err.message }, "Web profile unavailable");
|
|
827
|
+
}
|
|
828
|
+
const reserved = (n) => this.webNameReserved(n);
|
|
829
|
+
const own = profile?.name !== undefined ? webNameInUse(profile.name, reserved) : WEB_NAME_FALLBACK;
|
|
830
|
+
const chosen = own !== WEB_NAME_FALLBACK;
|
|
831
|
+
const platformName = owner ? safeWebName(profile?.platformName, reserved) : undefined;
|
|
832
|
+
return {
|
|
833
|
+
key, bound: !!owner,
|
|
834
|
+
userId: owner ? owner.userId : WEB_NAME_FALLBACK,
|
|
835
|
+
// A poll ballot is the account's — the same key its votes on the platform have — or the operator's.
|
|
836
|
+
voterKey: owner ? `${owner.adapterId}:${owner.userId}` : "web:operator",
|
|
837
|
+
name: chosen ? own : platformName ?? WEB_NAME_FALLBACK,
|
|
838
|
+
set: chosen,
|
|
839
|
+
...(platformName !== undefined ? { platformName } : {}),
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
/** #1827: set (null: clear — back to their account's name) a web person's own name; validated in full. */
|
|
843
|
+
setWebPersonName(owner, raw) {
|
|
844
|
+
let name = null;
|
|
845
|
+
if (raw !== null) {
|
|
846
|
+
const v = validateWebName(raw, (n) => this.webNameReserved(n));
|
|
847
|
+
if (!v.ok)
|
|
848
|
+
return { ok: false, status: 400, error: v.error };
|
|
849
|
+
name = v.name;
|
|
850
|
+
}
|
|
851
|
+
const w = this.webPrefs.setProfileName(webPersonKey(owner), name);
|
|
852
|
+
return w.ok ? { ok: true, person: this.webPerson(owner) } : { ok: false, status: w.status, error: w.error };
|
|
853
|
+
}
|
|
854
|
+
/** #1827 (auth-api): a sign-in as this account — its display name then is the web name it starts with. */
|
|
855
|
+
noteWebSignIn(account) {
|
|
856
|
+
if (account.username)
|
|
857
|
+
this.webPrefs.setPlatformName(webPersonKey(account), account.username);
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* #1827: a name that belongs to something here — an instance (or ClassicBot room), its display name, a bot. On the web
|
|
861
|
+
* send path: it never throws (a lookup that fails reserves nothing; the name was checked in full when it was set).
|
|
862
|
+
*/
|
|
863
|
+
webNameReserved(lowered) {
|
|
864
|
+
try {
|
|
865
|
+
return this.webNameTaken(lowered);
|
|
866
|
+
}
|
|
867
|
+
catch {
|
|
868
|
+
return false;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
webNameTaken(lowered) {
|
|
872
|
+
const low = (s) => (typeof s === "string" ? s.normalize("NFC").toLocaleLowerCase("en-US") : "");
|
|
873
|
+
for (const [name, cfg] of Object.entries(this.fleetConfig?.instances ?? {})) {
|
|
874
|
+
if (low(name) === lowered || low(cfg.display_name) === lowered)
|
|
875
|
+
return true;
|
|
876
|
+
}
|
|
877
|
+
for (const ch of this.classicChannels?.getAll() ?? [])
|
|
878
|
+
if (low(ch.instanceName) === lowered || low(ch.displayName) === lowered)
|
|
879
|
+
return true;
|
|
880
|
+
for (const w of this.worlds.values())
|
|
881
|
+
if (w.botUsername && low(w.botUsername) === lowered)
|
|
882
|
+
return true;
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
get webPrefs() {
|
|
886
|
+
// #1664: a favorite must be an instance of this fleet now — fleet.yaml's (own keys) or a ClassicBot room's.
|
|
887
|
+
return (this.webPrefsStore ??= new WebPrefsStore(this.dataDir, {
|
|
888
|
+
exists: (name) => Object.hasOwn(this.fleetConfig?.instances ?? {}, name)
|
|
889
|
+
|| (this.classicChannels?.getAll() ?? []).some((ch) => ch.instanceName === name),
|
|
890
|
+
}));
|
|
891
|
+
}
|
|
761
892
|
/**
|
|
762
893
|
* Read from disk on every access rather than cached at startup: `agend
|
|
763
894
|
* web-token rotate` runs in a separate process, and a cached copy would keep
|
|
@@ -1099,6 +1230,8 @@ export class FleetManager {
|
|
|
1099
1230
|
mayClick: (set, userId) => this.mayAnswerReplyButtons(set.instance, set.adapterId, userId),
|
|
1100
1231
|
deliver: (set, button, by) => this.deliverReplyButtonChoice(set, button, by),
|
|
1101
1232
|
publish: (instance, view) => this.emitSseEvent("reply_buttons", { instance, buttons: view }),
|
|
1233
|
+
admitResults: (set, key, text) => this.admitPollResults(set, key, text),
|
|
1234
|
+
monotonic: () => performance.now(),
|
|
1102
1235
|
logger: this.logger,
|
|
1103
1236
|
setTimer: (fn, ms) => { const h = setTimeout(fn, ms); h.unref?.(); return h; },
|
|
1104
1237
|
clearTimer: (h) => clearTimeout(h),
|
|
@@ -1107,6 +1240,21 @@ export class FleetManager {
|
|
|
1107
1240
|
return this.replyButtonsCtl;
|
|
1108
1241
|
}
|
|
1109
1242
|
replyButtonsStore = null;
|
|
1243
|
+
/**
|
|
1244
|
+
* #1823: a closed poll's results go to its instance through the delivery outbox, as a system notice keyed by the
|
|
1245
|
+
* poll: durable (held until the instance can take it) and admitted once, however often this is asked. Addressed to
|
|
1246
|
+
* the poll's chat, so the instance's answer (the right option, the scores) goes there.
|
|
1247
|
+
*/
|
|
1248
|
+
admitPollResults(set, key, text) {
|
|
1249
|
+
this.ensureDeliveryOutbox();
|
|
1250
|
+
const outbox = this.deliveryOutbox;
|
|
1251
|
+
if (!outbox)
|
|
1252
|
+
return false;
|
|
1253
|
+
outbox.admitSystemNotice(set.instance, key, text, new Date(), {
|
|
1254
|
+
chatId: set.chatId, threadId: set.threadId || undefined, adapterId: set.adapterId === "web" ? undefined : set.adapterId,
|
|
1255
|
+
});
|
|
1256
|
+
return true;
|
|
1257
|
+
}
|
|
1110
1258
|
/**
|
|
1111
1259
|
* #1266: who may answer a reply's buttons on a platform — whoever may message that instance there: in a ClassicBot
|
|
1112
1260
|
* room anyone there (as a typed message), elsewhere someone the sending connection lets speak (#754/#1148), never a
|
|
@@ -1147,7 +1295,7 @@ export class FleetManager {
|
|
|
1147
1295
|
const reactions = this.pendingReactionsMeta(set.instance);
|
|
1148
1296
|
let accepted = false;
|
|
1149
1297
|
try {
|
|
1150
|
-
accepted = (await this.
|
|
1298
|
+
accepted = (await this.deliverHumanToInstance(set.instance, {
|
|
1151
1299
|
type: "fleet_inbound",
|
|
1152
1300
|
content,
|
|
1153
1301
|
targetSession: set.instance,
|
|
@@ -1156,7 +1304,7 @@ export class FleetManager {
|
|
|
1156
1304
|
thread_id: set.threadId, adapter_id: inbound.adapterId, source: inbound.source,
|
|
1157
1305
|
...reactions.meta,
|
|
1158
1306
|
},
|
|
1159
|
-
})) !== false;
|
|
1307
|
+
}, by.humanOrigin, button.label)) !== false;
|
|
1160
1308
|
}
|
|
1161
1309
|
finally {
|
|
1162
1310
|
// Both false and throw reopen the choice in the controller. Finish the
|
|
@@ -1215,8 +1363,13 @@ export class FleetManager {
|
|
|
1215
1363
|
if (event.deliveryId && (event.state === "failed" || event.state === "uncertain")) {
|
|
1216
1364
|
const row = outbox.get(event.deliveryId);
|
|
1217
1365
|
if (row && row.kind !== "delivery_outcome_notice" && row.kind !== "post_restart_outcome_notice") {
|
|
1218
|
-
|
|
1219
|
-
|
|
1366
|
+
// #1647: the cause goes in the log (the notice points there), and a silent schedule's paste says what it is.
|
|
1367
|
+
const silent = isSilentScheduleDelivery(row);
|
|
1368
|
+
const notice = silent
|
|
1369
|
+
? t(`delivery.fleet_${row.state}_silent_schedule`, row.targetInstance, row.operationId, row.deliveryId, row.lastError ?? "unknown")
|
|
1370
|
+
: t(`delivery.fleet_${row.state}`, row.targetInstance, row.operationId, row.deliveryId);
|
|
1371
|
+
this.logger.error({ deliveryId: row.deliveryId, operationId: row.operationId, target: row.targetInstance, state: row.state,
|
|
1372
|
+
cause: row.lastError, ...(silent ? { silentSchedule: true } : {}) }, notice);
|
|
1220
1373
|
this.notifyFleetError(notice);
|
|
1221
1374
|
}
|
|
1222
1375
|
}
|
|
@@ -1270,11 +1423,25 @@ export class FleetManager {
|
|
|
1270
1423
|
deliveryId: admitted.delivery.deliveryId,
|
|
1271
1424
|
state: admitted.delivery.state,
|
|
1272
1425
|
duplicate: !admitted.inserted,
|
|
1426
|
+
kind: admitted.delivery.kind,
|
|
1273
1427
|
};
|
|
1274
1428
|
}
|
|
1275
1429
|
queryDurableDeliveryStatus(callerInstance, selector) {
|
|
1276
1430
|
this.ensureDeliveryOutbox();
|
|
1277
|
-
|
|
1431
|
+
const page = this.deliveryOutbox.queryStatusForInstance(callerInstance, selector);
|
|
1432
|
+
// #1641: a sender that sees only `queued` cannot tell "next in line" from "the target is stuck": say which.
|
|
1433
|
+
return {
|
|
1434
|
+
...page,
|
|
1435
|
+
items: page.items.map(item => (item.state === "queued" || item.state === "retry_wait")
|
|
1436
|
+
? { ...item, waiting_reason: this.deliveryWaitingReason(item.target_instance) }
|
|
1437
|
+
: item),
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
/** #1641: why a target is not taking queued work right now, or null when nothing known holds it. */
|
|
1441
|
+
deliveryWaitingReason(target) {
|
|
1442
|
+
if (this.lifecycle.isPaused(target))
|
|
1443
|
+
return t("delivery.hold_state", "paused");
|
|
1444
|
+
return this.daemons.get(target)?.notAcceptingReason?.() ?? null;
|
|
1278
1445
|
}
|
|
1279
1446
|
getDaemonBootId(instanceName) {
|
|
1280
1447
|
return this.daemons.get(instanceName)?.bootId;
|
|
@@ -1298,7 +1465,9 @@ export class FleetManager {
|
|
|
1298
1465
|
// #926: a restart kills the turn that was working on a request, and a
|
|
1299
1466
|
// resumed CLI does not pick it back up. Remind the owner once per restart.
|
|
1300
1467
|
try {
|
|
1301
|
-
|
|
1468
|
+
const now = new Date();
|
|
1469
|
+
this.expireReplyObligations(now);
|
|
1470
|
+
outbox.remindReplyObligations(name, { reason: "restart", graceMs: 0, now, since: now, restartId: daemonBootId });
|
|
1302
1471
|
}
|
|
1303
1472
|
catch (err) {
|
|
1304
1473
|
this.logger.error({ err, owner: name }, "Could not persist the post-restart reply reminder");
|
|
@@ -1316,6 +1485,7 @@ export class FleetManager {
|
|
|
1316
1485
|
return;
|
|
1317
1486
|
const stateOf = (name) => this.daemons.get(name)?.getInstanceState();
|
|
1318
1487
|
try {
|
|
1488
|
+
this.expireReplyObligations(now);
|
|
1319
1489
|
for (const name of this.daemons.keys()) {
|
|
1320
1490
|
if (stateOf(name) === "idle")
|
|
1321
1491
|
outbox.remindReplyObligations(name, { now, graceMs: REPLY_REMINDER_GRACE_MS, reason: "turn-ended" });
|
|
@@ -1327,6 +1497,15 @@ export class FleetManager {
|
|
|
1327
1497
|
this.logger.error({ err }, "Reply-obligation sweep failed");
|
|
1328
1498
|
}
|
|
1329
1499
|
}
|
|
1500
|
+
expireReplyObligations(now) {
|
|
1501
|
+
const hours = this.fleetConfig?.defaults?.reply_obligation_ttl_hours;
|
|
1502
|
+
const duration = typeof hours === "number" && hours > 0 && Number.isFinite(hours * 3_600_000)
|
|
1503
|
+
? hours * 3_600_000 : DEFAULT_REPLY_OBLIGATION_TTL_MS;
|
|
1504
|
+
for (const row of this.deliveryOutbox.expireReplyObligations({ now, ttlMs: duration })) {
|
|
1505
|
+
this.logger.info({ owner: row.ownerInstance, requester: row.requesterInstance,
|
|
1506
|
+
correlationId: row.correlationId, lastAskedAt: row.lastAskedAt, expiredAt: row.expiredAt }, "Reply obligation expired");
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1330
1509
|
scheduleDeliveryOutboxPump(delayMs = 0) {
|
|
1331
1510
|
if (!this.deliveryOutbox || this.shuttingDown || !this.deliveryOutbox.isOpen)
|
|
1332
1511
|
return;
|
|
@@ -1566,6 +1745,14 @@ export class FleetManager {
|
|
|
1566
1745
|
return Math.max(1, Math.min(20, explicit));
|
|
1567
1746
|
return deriveSpawnConcurrency(Math.round(freemem() / (1024 * 1024)), cpus().length);
|
|
1568
1747
|
}
|
|
1748
|
+
/** #1685: "elevated" or "critical" while host memory pressure slows or holds new agents; otherwise null (an
|
|
1749
|
+
* unknown macOS reading — the advisory case — is unknown, so null too). */
|
|
1750
|
+
hostMemoryIndicator() {
|
|
1751
|
+
if (!this.memoryPressure)
|
|
1752
|
+
return null;
|
|
1753
|
+
const level = this.memoryPressure.snapshot().level;
|
|
1754
|
+
return level === "elevated" || level === "critical" ? level : null;
|
|
1755
|
+
}
|
|
1569
1756
|
/** Separate cooldown: changing measurements must not create new text-throttle keys. */
|
|
1570
1757
|
reportMemoryPressure(snapshot) {
|
|
1571
1758
|
if (this.shuttingDown)
|
|
@@ -1576,6 +1763,12 @@ export class FleetManager {
|
|
|
1576
1763
|
this.logger.debug({ hostMemory: snapshot }, "Host memory sample");
|
|
1577
1764
|
}
|
|
1578
1765
|
const changed = snapshot.level !== this.memoryLogLevel;
|
|
1766
|
+
// #1685: the dashboard's indicator (the status frame's host_memory) is re-sent when it changes.
|
|
1767
|
+
const indicator = this.hostMemoryIndicator();
|
|
1768
|
+
if (indicator !== this.memoryIndicatorShown) {
|
|
1769
|
+
this.memoryIndicatorShown = indicator;
|
|
1770
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
1771
|
+
}
|
|
1579
1772
|
if (this.memoryPressure.advisoryOnly()) {
|
|
1580
1773
|
// The sampler logs the unknown kernel value once per lifecycle; no warning/cooldown here.
|
|
1581
1774
|
this.memoryLogLevel = snapshot.level;
|
|
@@ -1596,20 +1789,6 @@ export class FleetManager {
|
|
|
1596
1789
|
: "Host memory sample unavailable — slowing new CLI spawns");
|
|
1597
1790
|
}
|
|
1598
1791
|
this.memoryLogLevel = snapshot.level;
|
|
1599
|
-
if (!snapshot.memory || snapshot.level === "unknown" || (!this.adapter && this.adapters.size === 0))
|
|
1600
|
-
return;
|
|
1601
|
-
const escalation = snapshot.level === "critical" && this.memoryNoticeLevel !== "critical";
|
|
1602
|
-
if (!escalation && this.memoryNoticeAt !== null && now - this.memoryNoticeAt < 10 * 60_000)
|
|
1603
|
-
return;
|
|
1604
|
-
const size = (bytes) => bytes === null ? t("memory.unknown") : `${Math.round(bytes / 1024 / 1024)} MiB`;
|
|
1605
|
-
const action = t(snapshot.level === "critical" ? "memory.holding" : "memory.slowing");
|
|
1606
|
-
const text = this.memoryPressure.platform === "darwin"
|
|
1607
|
-
? t("memory.kernel_pressure", snapshot.memory.darwinPressureLevel ?? t("memory.unknown"), size(snapshot.memory.availableBytes), size(snapshot.memory.swapFreeBytes), action)
|
|
1608
|
-
: t("memory.pressure", t(snapshot.memory.availableKind === "available" ? "memory.available" : "memory.free", size(snapshot.memory.availableBytes)), size(snapshot.memory.swapFreeBytes), action);
|
|
1609
|
-
if (this.notifyFleetError(text, { throttle: false })) {
|
|
1610
|
-
this.memoryNoticeAt = now;
|
|
1611
|
-
this.memoryNoticeLevel = snapshot.level;
|
|
1612
|
-
}
|
|
1613
1792
|
}
|
|
1614
1793
|
/** Wire the one fleet-wide storm into notification and recovery surfaces. */
|
|
1615
1794
|
bindStormWindowEvents() {
|
|
@@ -1730,7 +1909,7 @@ export class FleetManager {
|
|
|
1730
1909
|
isPlannedRestart() {
|
|
1731
1910
|
return this.shuttingDown || isUpdateInProgress(this.dataDir);
|
|
1732
1911
|
}
|
|
1733
|
-
finishStartup() {
|
|
1912
|
+
finishStartup(bootUpdateMarker = null) {
|
|
1734
1913
|
this.startupComplete = true;
|
|
1735
1914
|
this.startNeedsYou();
|
|
1736
1915
|
// Resolve whatever a previous run — or a setup host that crashed — left
|
|
@@ -1740,14 +1919,17 @@ export class FleetManager {
|
|
|
1740
1919
|
// the next tunnel and says so, it does not block the fleet.
|
|
1741
1920
|
this.announceToolPermissionsChange();
|
|
1742
1921
|
this.tightenInstanceDirectories();
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1922
|
+
// #1723: the public link's preview tunnel keeps its own lease beside it; a leftover one is reaped the same way.
|
|
1923
|
+
for (const leaseDir of [this.dataDir, join(this.dataDir, PUBLIC_PREVIEW_TUNNEL_DIR)]) {
|
|
1924
|
+
void reapStaleTunnel(leaseDir)
|
|
1925
|
+
.then(outcome => {
|
|
1926
|
+
if (outcome.kind === "manual")
|
|
1927
|
+
this.logger.warn({ tunnel: outcome }, manualCleanupMessage(outcome));
|
|
1928
|
+
else if (outcome.kind === "reaped")
|
|
1929
|
+
this.logger.info({ how: outcome.how, pid: outcome.pid }, "Reaped a leftover tunnel");
|
|
1930
|
+
})
|
|
1931
|
+
.catch(err => this.logger.warn({ err }, "Tunnel reaper failed"));
|
|
1932
|
+
}
|
|
1751
1933
|
// An existing installation has never written the setup marker — it predates
|
|
1752
1934
|
// it — so `agend setup` would open a pre-fleet form for a fleet that plainly
|
|
1753
1935
|
// exists. A fleet that just came up on a config with agents in it is proof
|
|
@@ -1767,14 +1949,33 @@ export class FleetManager {
|
|
|
1767
1949
|
for (const settled of this.applyJobs.settleAfterRestart()) {
|
|
1768
1950
|
this.logger.info({ jobId: settled.id }, "Settings apply job settled by fleet restart");
|
|
1769
1951
|
}
|
|
1770
|
-
|
|
1771
|
-
// it here (rather than in the update command, which exits before the new
|
|
1772
|
-
// fleet is up) is what keeps the quiet window from outliving the restart.
|
|
1773
|
-
clearUpdateMarker(this.dataDir);
|
|
1952
|
+
this.clearSettledUpdateMarker(bootUpdateMarker);
|
|
1774
1953
|
if (this.reloadPending)
|
|
1775
1954
|
this.scheduleReconcile();
|
|
1776
1955
|
void this.sweepOrphanedCancelButtons();
|
|
1777
1956
|
}
|
|
1957
|
+
/**
|
|
1958
|
+
* We are the post-update fleet: the update is over by definition. Clearing it here (rather than in the update
|
|
1959
|
+
* command, which exits before the new fleet is up) is what keeps the quiet window from outliving the restart.
|
|
1960
|
+
* #1660 exception: an update still owing its retire outcome keeps its receipt until the cleanup watch resolves it —
|
|
1961
|
+
* deleting it here is what lost the warning. Terminal stages are still cleared unconditionally.
|
|
1962
|
+
* #1735: and while the watch is armed it owns the marker — even if the outcome landed after the completion posted
|
|
1963
|
+
* (a launchd kickstart or a detached restart returns at once, so it can land at any point of the boot): the watch
|
|
1964
|
+
* appends it, then clears.
|
|
1965
|
+
* #1737 review: and only the marker this boot adopted. One written since — a `/restart full` or a new `/update`
|
|
1966
|
+
* landing while this boot's completion was still being edited — belongs to its writer and is left alone.
|
|
1967
|
+
*/
|
|
1968
|
+
clearSettledUpdateMarker(bootUpdateMarker) {
|
|
1969
|
+
if (this.updateCleanupWatch)
|
|
1970
|
+
return;
|
|
1971
|
+
const current = updateMarkerIdentity(this.dataDir);
|
|
1972
|
+
if (current && !sameUpdateMarker(current, bootUpdateMarker))
|
|
1973
|
+
return;
|
|
1974
|
+
const leftover = readUpdateProgress(this.dataDir)?.progress;
|
|
1975
|
+
if (leftover?.cleanup === "pending" && leftover.stage !== "failed" && leftover.stage !== "complete")
|
|
1976
|
+
return;
|
|
1977
|
+
clearUpdateMarker(this.dataDir);
|
|
1978
|
+
}
|
|
1778
1979
|
// ── ArchiverContext bridge ────────────────────────────────────────────
|
|
1779
1980
|
lastActivityMs(name) {
|
|
1780
1981
|
return this.lastActivity.get(name) ?? 0;
|
|
@@ -2129,10 +2330,15 @@ export class FleetManager {
|
|
|
2129
2330
|
this.failUpdateProgress(t("not_authorized"));
|
|
2130
2331
|
return;
|
|
2131
2332
|
}
|
|
2333
|
+
const logPath = updateLogPath(this.dataDir);
|
|
2334
|
+
const ts = new Date().toISOString();
|
|
2335
|
+
const logFd = openUpdateLog(logPath, `--- /update ${ts} ---`);
|
|
2132
2336
|
const child = spawn(launch.command, launch.args, {
|
|
2133
|
-
detached: true, stdio: "ignore", env: withOrigin(origin),
|
|
2337
|
+
detached: true, stdio: ["ignore", logFd ? logFd.fd : "ignore", logFd ? logFd.fd : "ignore"], env: withOrigin(origin),
|
|
2134
2338
|
});
|
|
2135
|
-
|
|
2339
|
+
if (logFd)
|
|
2340
|
+
closeSync(logFd.fd); // parent closes its copy; the child's fd stays open
|
|
2341
|
+
watchUpdateLaunch(child, message => this.failUpdateProgress(message), logFd ? logPath : undefined, logFd?.offset, installed.agend);
|
|
2136
2342
|
child.unref();
|
|
2137
2343
|
}
|
|
2138
2344
|
async handleProfileSlash(data, adapterId) {
|
|
@@ -2558,6 +2764,8 @@ export class FleetManager {
|
|
|
2558
2764
|
startsFresh: !backendChanged && credentialSwitchStartsFresh(backend),
|
|
2559
2765
|
from: profileKey(launched),
|
|
2560
2766
|
to: profileKey(next),
|
|
2767
|
+
...(!backendChanged && launched.state !== "invalid" && next.state !== "invalid"
|
|
2768
|
+
? { carry: { backend, from: profileName(launched), to: profileName(next) } } : {}),
|
|
2561
2769
|
};
|
|
2562
2770
|
}
|
|
2563
2771
|
/** `[instance, effective backend, credential profile]` for everything that is
|
|
@@ -2592,6 +2800,55 @@ export class FleetManager {
|
|
|
2592
2800
|
return this.classicChannels?.getAll().some(channel => channel.instanceName === name) ?? false;
|
|
2593
2801
|
}
|
|
2594
2802
|
/** #1523 N2: a ClassicBot room's own summary for its Details page (classicBot.yaml), or null. */
|
|
2803
|
+
/** #1218: the Log tab's on-demand service — made on first use; with `web.log_tab` off it answers off and does nothing. */
|
|
2804
|
+
logService = null;
|
|
2805
|
+
get log() {
|
|
2806
|
+
return this.logService ??= new LogService({
|
|
2807
|
+
enabled: () => this.fleetConfig?.web?.log_tab === true,
|
|
2808
|
+
known: (name) => Object.prototype.hasOwnProperty.call(this.fleetConfig?.instances ?? {}, name) || this.classicRoomFor(name) !== null,
|
|
2809
|
+
source: (name) => this.logSource(name),
|
|
2810
|
+
events: (name, limit) => (this.eventLog?.query({ instance: name, limit }) ?? []),
|
|
2811
|
+
emit: (event, data) => this.emitSseEvent(event, data),
|
|
2812
|
+
});
|
|
2813
|
+
}
|
|
2814
|
+
/** #1218: GET /ui/steps — an instance's steps, read on demand (null: no such instance). */
|
|
2815
|
+
logSteps(name, q) { return this.log.steps(name, q); }
|
|
2816
|
+
/**
|
|
2817
|
+
* #1218: where an instance's steps are read from, resolved only when its Log asks: the transcript its daemon follows
|
|
2818
|
+
* now; for claude-code, also the path its statusline last named (so a stopped instance's Log still shows its last
|
|
2819
|
+
* work). kiro: fleet events with the reason its history is not read (kiro-log-gate.ts); other backends have no
|
|
2820
|
+
* transcript AgEnD can read.
|
|
2821
|
+
*/
|
|
2822
|
+
logSource(name) {
|
|
2823
|
+
const instances = this.fleetConfig?.instances ?? {};
|
|
2824
|
+
const config = Object.prototype.hasOwnProperty.call(instances, name) ? instances[name] : undefined;
|
|
2825
|
+
const backend = config?.backend ?? (config ? null : this.classicRoomFor(name)?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
2826
|
+
const live = this.lifecycle.daemons.get(name)?.logTranscriptPath() ?? null;
|
|
2827
|
+
if (backend === "claude-code") {
|
|
2828
|
+
let path = live;
|
|
2829
|
+
if (!path) {
|
|
2830
|
+
try {
|
|
2831
|
+
path = JSON.parse(readFileSync(join(this.getInstanceDir(name), "statusline.json"), "utf-8")).transcript_path ?? null;
|
|
2832
|
+
}
|
|
2833
|
+
catch {
|
|
2834
|
+
path = null;
|
|
2835
|
+
}
|
|
2836
|
+
if (typeof path !== "string" || !path)
|
|
2837
|
+
path = null;
|
|
2838
|
+
}
|
|
2839
|
+
return { backend, path, format: "claude", ...(path ? {} : { reason: "no_transcript" }) };
|
|
2840
|
+
}
|
|
2841
|
+
if (backend === "codex")
|
|
2842
|
+
return { backend, path: live, format: "codex", ...(live ? {} : { reason: "not_running" }) };
|
|
2843
|
+
if (backend === "kiro-cli") {
|
|
2844
|
+
// #1218 PR4: events only until the launch's root and the current conversation are both proven (kiro-log-gate.ts);
|
|
2845
|
+
// the reason names which proof is missing. With today's proofs the gate never opens, and nothing is read for kiro:
|
|
2846
|
+
// the reader is bound to an admitted launch when the proofs arrive (#849 root, #1789 current conversation).
|
|
2847
|
+
const gate = kiroLogGate(this.lifecycle.daemons.get(name)?.logLaunch() ?? null);
|
|
2848
|
+
return { backend, path: null, format: null, reason: gate.open ? "kiro_session_unobservable" : gate.reason };
|
|
2849
|
+
}
|
|
2850
|
+
return { backend, path: null, format: null, reason: "unsupported" };
|
|
2851
|
+
}
|
|
2595
2852
|
classicRoomFor(name) {
|
|
2596
2853
|
const ch = this.classicChannels?.getAll().find(channel => channel.instanceName === name);
|
|
2597
2854
|
if (!ch)
|
|
@@ -2893,7 +3150,7 @@ export class FleetManager {
|
|
|
2893
3150
|
const queued = this.ipcWaitTails.get(instanceName);
|
|
2894
3151
|
if (!queued) {
|
|
2895
3152
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
2896
|
-
if (ipc?.connected && ipc.send(payload))
|
|
3153
|
+
if (ipc?.connected && ipc.send(this.bindHumanReceiptPayload(instanceName, payload)))
|
|
2897
3154
|
return true;
|
|
2898
3155
|
}
|
|
2899
3156
|
const attempt = (queued ?? Promise.resolve())
|
|
@@ -2928,7 +3185,7 @@ export class FleetManager {
|
|
|
2928
3185
|
// Re-read every round: a reconnect replaces the IpcClient object entirely,
|
|
2929
3186
|
// so a cached reference would stay dead forever.
|
|
2930
3187
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
2931
|
-
if (ipc?.connected && ipc.send(payload))
|
|
3188
|
+
if (ipc?.connected && ipc.send(this.bindHumanReceiptPayload(instanceName, payload)))
|
|
2932
3189
|
return true;
|
|
2933
3190
|
if (Date.now() >= deadline) {
|
|
2934
3191
|
throw new Error(`Instance '${instanceName}' IPC is unavailable`);
|
|
@@ -2963,7 +3220,8 @@ export class FleetManager {
|
|
|
2963
3220
|
* or a rebound adapter is never posted through a stale route. A copy
|
|
2964
3221
|
* already in flight may still land late — the contract permits that.
|
|
2965
3222
|
*/
|
|
2966
|
-
|
|
3223
|
+
/** `sender` (#1827): the web person's name, as the room log records them ("web-user" when none). */
|
|
3224
|
+
async sendClassicWebEcho(instance, text, sender = WEB_NAME_FALLBACK) {
|
|
2967
3225
|
const guard = this.webChannelEchoGuards.get(instance);
|
|
2968
3226
|
const handled = new Set();
|
|
2969
3227
|
let posted = 0;
|
|
@@ -2994,7 +3252,7 @@ export class FleetManager {
|
|
|
2994
3252
|
if (!adapter?.sendText)
|
|
2995
3253
|
continue;
|
|
2996
3254
|
await adapter.sendText(entry.channelId, text, { format: "text", allowedMentions: { parse: [] } });
|
|
2997
|
-
ClassicChannelManager.logMessage(instance,
|
|
3255
|
+
ClassicChannelManager.logMessage(instance, sender, text, new Date());
|
|
2998
3256
|
posted++;
|
|
2999
3257
|
}
|
|
3000
3258
|
catch (err) {
|
|
@@ -3003,13 +3261,38 @@ export class FleetManager {
|
|
|
3003
3261
|
}
|
|
3004
3262
|
return posted;
|
|
3005
3263
|
}
|
|
3006
|
-
|
|
3264
|
+
/**
|
|
3265
|
+
* #1666: a delivered web chat message starts the same cancel/progress lifecycle in the instance's topic as a platform
|
|
3266
|
+
* inbound (afterUserInboundDelivered): the same bubble, ticker and retire rules. Not its ✅ tracking — the web message
|
|
3267
|
+
* id is no platform message, and the echo is the fleet's own post. The web send calls this on the echo's lane.
|
|
3268
|
+
*/
|
|
3269
|
+
startWebChatProgress(instanceName) {
|
|
3270
|
+
if (this.shuttingDown)
|
|
3271
|
+
return Promise.resolve();
|
|
3272
|
+
return this.sendCancelButton(instanceName);
|
|
3273
|
+
}
|
|
3274
|
+
/**
|
|
3275
|
+
* `copy: false` (#1666/#1668): an entry that is not a copy of a message — the web send's Cancel/progress bubble. It
|
|
3276
|
+
* keeps its place in the order, but the lane's copy rules do not apply to it: its 5 s budget starts when its turn
|
|
3277
|
+
* starts (waiting behind a slow echo is not its delay), its own timeout only releases the lane (the copies queued
|
|
3278
|
+
* behind it are not "behind an ambiguous copy"), and another entry's timeout never drops it. A copy reserved while
|
|
3279
|
+
* any non-copy is still open starts its own budget when the last of them finishes: a slow bubble is no copy's delay,
|
|
3280
|
+
* however far back the copy is. And a timeout only ever drops copies reserved AFTER the entry that timed out — with
|
|
3281
|
+
* deferred budgets, deadlines no longer grow with the order, so that rule is stated, not implied (#1668 r3).
|
|
3282
|
+
*/
|
|
3283
|
+
reserveWebChannelEcho(instanceName, sendEcho, opts = {}) {
|
|
3284
|
+
const copy = opts.copy !== false;
|
|
3007
3285
|
const epoch = this.getDeliveryEpoch(instanceName);
|
|
3008
|
-
const
|
|
3286
|
+
const queued = this.webChannelEchoTails.get(instanceName);
|
|
3287
|
+
// The last open non-copy ahead of this copy: its budget starts when that one finishes.
|
|
3288
|
+
const nonCopyAhead = copy && (queued?.nonCopiesOpen ?? 0) > 0 ? queued?.lastNonCopyDone : undefined;
|
|
3289
|
+
let deadlineAt = copy && !nonCopyAhead ? performance.now() + 5_000 : Number.POSITIVE_INFINITY;
|
|
3009
3290
|
let decide;
|
|
3010
3291
|
const admission = new Promise(resolve => { decide = resolve; });
|
|
3011
|
-
const queue =
|
|
3292
|
+
const queue = queued
|
|
3012
3293
|
?? { tail: Promise.resolve(), pending: new Set() };
|
|
3294
|
+
const seq = queue.nextSeq ?? 0;
|
|
3295
|
+
queue.nextSeq = seq + 1;
|
|
3013
3296
|
const previous = queue.tail;
|
|
3014
3297
|
let resolveDone;
|
|
3015
3298
|
const tail = new Promise(resolve => { resolveDone = resolve; });
|
|
@@ -3020,6 +3303,8 @@ export class FleetManager {
|
|
|
3020
3303
|
if (done)
|
|
3021
3304
|
return;
|
|
3022
3305
|
done = true;
|
|
3306
|
+
if (!copy)
|
|
3307
|
+
queue.nonCopiesOpen = Math.max(0, (queue.nonCopiesOpen ?? 0) - 1);
|
|
3023
3308
|
clearTimeout(timer);
|
|
3024
3309
|
queue.pending.delete(entry);
|
|
3025
3310
|
if (guard && this.webChannelEchoGuards.get(instanceName) === guard) {
|
|
@@ -3027,7 +3312,7 @@ export class FleetManager {
|
|
|
3027
3312
|
}
|
|
3028
3313
|
resolveDone();
|
|
3029
3314
|
};
|
|
3030
|
-
const entry = { started: false, drop: () => {
|
|
3315
|
+
const entry = { seq, started: false, copy, drop: () => {
|
|
3031
3316
|
this.logger.warn({ instanceName }, "Queued web channel echo dropped after ordering timeout");
|
|
3032
3317
|
finish();
|
|
3033
3318
|
} };
|
|
@@ -3037,14 +3322,16 @@ export class FleetManager {
|
|
|
3037
3322
|
expired = true;
|
|
3038
3323
|
this.logger.warn({ instanceName, inFlight: entry.started }, "Web channel echo ordering timed out");
|
|
3039
3324
|
finish();
|
|
3040
|
-
// Do not post copies that were queued behind an ambiguous platform send.
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3325
|
+
// Do not post copies that were queued behind an ambiguous platform send. A non-copy that timed out was no copy
|
|
3326
|
+
// at all (nothing behind it is ambiguous), and a non-copy is never dropped as one.
|
|
3327
|
+
if (copy)
|
|
3328
|
+
for (const pending of queue.pending)
|
|
3329
|
+
if (pending.seq > seq && !pending.started && pending.copy)
|
|
3330
|
+
pending.drop();
|
|
3044
3331
|
};
|
|
3045
3332
|
const checkDeadline = () => {
|
|
3046
|
-
if (done)
|
|
3047
|
-
return;
|
|
3333
|
+
if (done || deadlineAt === Number.POSITIVE_INFINITY)
|
|
3334
|
+
return; // a non-copy's budget has not started yet
|
|
3048
3335
|
const remaining = deadlineAt - performance.now();
|
|
3049
3336
|
if (remaining <= 0)
|
|
3050
3337
|
expire();
|
|
@@ -3053,8 +3340,19 @@ export class FleetManager {
|
|
|
3053
3340
|
};
|
|
3054
3341
|
queue.pending.add(entry);
|
|
3055
3342
|
queue.tail = tail;
|
|
3343
|
+
if (!copy) {
|
|
3344
|
+
queue.nonCopiesOpen = (queue.nonCopiesOpen ?? 0) + 1;
|
|
3345
|
+
queue.lastNonCopyDone = tail;
|
|
3346
|
+
}
|
|
3056
3347
|
this.webChannelEchoTails.set(instanceName, queue);
|
|
3057
3348
|
checkDeadline();
|
|
3349
|
+
// Registered before the admission below (which waits on `previous`, itself after the non-copy ahead), so the budget
|
|
3350
|
+
// is set by the time that runs.
|
|
3351
|
+
if (nonCopyAhead)
|
|
3352
|
+
void nonCopyAhead.then(() => { if (!done) {
|
|
3353
|
+
deadlineAt = performance.now() + 5_000;
|
|
3354
|
+
checkDeadline();
|
|
3355
|
+
} });
|
|
3058
3356
|
void Promise.all([previous, admission]).then(([, accepted]) => {
|
|
3059
3357
|
if (done)
|
|
3060
3358
|
return;
|
|
@@ -3063,11 +3361,15 @@ export class FleetManager {
|
|
|
3063
3361
|
return;
|
|
3064
3362
|
}
|
|
3065
3363
|
// A delayed timer callback must not admit an already expired copy.
|
|
3066
|
-
if (performance.now() >= deadlineAt) {
|
|
3364
|
+
if (copy && performance.now() >= deadlineAt) {
|
|
3067
3365
|
expire();
|
|
3068
3366
|
return;
|
|
3069
3367
|
}
|
|
3070
3368
|
entry.started = true;
|
|
3369
|
+
if (!copy) {
|
|
3370
|
+
deadlineAt = performance.now() + 5_000;
|
|
3371
|
+
checkDeadline();
|
|
3372
|
+
}
|
|
3071
3373
|
guard = { epoch, deadlineAt };
|
|
3072
3374
|
this.webChannelEchoGuards.set(instanceName, guard);
|
|
3073
3375
|
let request;
|
|
@@ -3102,6 +3404,101 @@ export class FleetManager {
|
|
|
3102
3404
|
instanceLaunchSupportsSteer(instanceName) {
|
|
3103
3405
|
return this.daemons.get(instanceName)?.launchSupportsSteer();
|
|
3104
3406
|
}
|
|
3407
|
+
humanReceiptDaemons = new WeakSet();
|
|
3408
|
+
/** Cached FM + lifecycle + Daemon + tmux owner; unknown ownership grants nothing. */
|
|
3409
|
+
humanReceiptOwner(instanceName, daemon = this.daemons.get(instanceName)) {
|
|
3410
|
+
try {
|
|
3411
|
+
if (this.shuttingDown || !daemon || this.daemons.get(instanceName) !== daemon)
|
|
3412
|
+
return null;
|
|
3413
|
+
const epoch = this.lifecycle.epochOf(instanceName);
|
|
3414
|
+
const owner = daemon.captureHumanReceiptOwner();
|
|
3415
|
+
if (!Number.isSafeInteger(epoch) || !owner)
|
|
3416
|
+
return null;
|
|
3417
|
+
return JSON.stringify([this.managerBootId, epoch, this.getDeliveryEpoch(instanceName), humanOwnerObjectId(daemon), owner]);
|
|
3418
|
+
}
|
|
3419
|
+
catch {
|
|
3420
|
+
return null;
|
|
3421
|
+
}
|
|
3422
|
+
}
|
|
3423
|
+
listenForHumanReceipt(instanceName, daemon) {
|
|
3424
|
+
// Lazy initialization also supports narrow prototype-based integration fixtures.
|
|
3425
|
+
this.humanReceiptDaemons ??= new WeakSet();
|
|
3426
|
+
if (this.humanReceiptDaemons.has(daemon))
|
|
3427
|
+
return;
|
|
3428
|
+
this.humanReceiptDaemons.add(daemon);
|
|
3429
|
+
daemon.on("human_receipt_result", (result) => {
|
|
3430
|
+
try {
|
|
3431
|
+
this.deliveryOutbox?.settleHumanReceipt(result.receipt_id, instanceName, result.owner_key, result.proof, this.humanReceiptOwner(instanceName, daemon));
|
|
3432
|
+
}
|
|
3433
|
+
catch (err) {
|
|
3434
|
+
this.logger.error({ err, instanceName }, "Could not persist human delivery evidence");
|
|
3435
|
+
}
|
|
3436
|
+
});
|
|
3437
|
+
}
|
|
3438
|
+
/** Evidence-only admission. Bots/system/agent payloads have no trusted origin. */
|
|
3439
|
+
humanReceiptPayload(instanceName, payload, origin, originalBody) {
|
|
3440
|
+
if (!origin || !this.deliveryOutbox)
|
|
3441
|
+
return payload;
|
|
3442
|
+
const row = this.deliveryOutbox.createHumanReceipt({ targetInstance: instanceName, originalBody,
|
|
3443
|
+
origin: { ...origin }, inputKind: String(payload.type), managerBootId: this.managerBootId });
|
|
3444
|
+
return { ...payload, meta: { ...(payload.meta ?? {}),
|
|
3445
|
+
human_receipt_id: row.receipt_id, verification_id: row.message_id } };
|
|
3446
|
+
}
|
|
3447
|
+
/** Bind at the actual IPC send, after all wake/reconnect waits. No human retry lane. */
|
|
3448
|
+
bindHumanReceiptPayload(instanceName, payload) {
|
|
3449
|
+
const meta = payload.meta;
|
|
3450
|
+
if (!meta?.human_receipt_id || !this.deliveryOutbox)
|
|
3451
|
+
return payload;
|
|
3452
|
+
const daemon = this.daemons.get(instanceName);
|
|
3453
|
+
const owner = this.humanReceiptOwner(instanceName, daemon);
|
|
3454
|
+
if (!daemon || !owner) {
|
|
3455
|
+
this.deliveryOutbox.markHumanReceiptUncertain(meta.human_receipt_id, instanceName);
|
|
3456
|
+
return payload;
|
|
3457
|
+
}
|
|
3458
|
+
this.listenForHumanReceipt(instanceName, daemon);
|
|
3459
|
+
const daemonOwner = daemon.captureHumanReceiptOwner();
|
|
3460
|
+
const wire = daemon.humanReceiptWire(String(payload.content ?? ""), meta, String(payload.type));
|
|
3461
|
+
const row = this.deliveryOutbox.bindHumanReceipt(meta.human_receipt_id, instanceName, owner, wire, this.managerBootId);
|
|
3462
|
+
if (!daemonOwner || this.humanReceiptOwner(instanceName, daemon) !== owner)
|
|
3463
|
+
throw new Error("Human delivery ownership changed");
|
|
3464
|
+
// /raw retains exact bytes and never acquires a positive verification capability.
|
|
3465
|
+
if (payload.type === "raw_paste" || String(payload.content ?? "").startsWith("/raw ")) {
|
|
3466
|
+
this.deliveryOutbox.markHumanReceiptUncertain(row.receipt_id, instanceName);
|
|
3467
|
+
return payload;
|
|
3468
|
+
}
|
|
3469
|
+
return { ...payload, meta: { ...meta, human_owner_key: owner, human_daemon_owner: daemonOwner, human_wire_sha256: row.wire_sha256 } };
|
|
3470
|
+
}
|
|
3471
|
+
async deliverHumanToInstance(instanceName, payload, origin, originalBody, options) {
|
|
3472
|
+
const admitted = this.humanReceiptPayload(instanceName, payload, origin, originalBody);
|
|
3473
|
+
const id = admitted.meta?.human_receipt_id;
|
|
3474
|
+
let sent = false;
|
|
3475
|
+
try {
|
|
3476
|
+
const result = options ? await this.deliverToInstance(instanceName, admitted, options) : await this.deliverToInstance(instanceName, admitted);
|
|
3477
|
+
sent = result !== false;
|
|
3478
|
+
return result;
|
|
3479
|
+
}
|
|
3480
|
+
finally {
|
|
3481
|
+
if (!sent && id)
|
|
3482
|
+
this.deliveryOutbox?.markHumanReceiptUncertain(id, instanceName);
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
/** Synchronous steer/btw handoff, using the same evidence and owner binding. */
|
|
3486
|
+
sendHumanControl(instanceName, payload, origin, originalBody) {
|
|
3487
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
3488
|
+
if (this.shuttingDown || !ipc?.connected)
|
|
3489
|
+
return false;
|
|
3490
|
+
const admitted = this.humanReceiptPayload(instanceName, payload, origin, originalBody);
|
|
3491
|
+
const id = admitted.meta?.human_receipt_id;
|
|
3492
|
+
let sent = false;
|
|
3493
|
+
try {
|
|
3494
|
+
sent = ipc.send(this.bindHumanReceiptPayload(instanceName, admitted));
|
|
3495
|
+
return sent;
|
|
3496
|
+
}
|
|
3497
|
+
finally {
|
|
3498
|
+
if (!sent && id)
|
|
3499
|
+
this.deliveryOutbox?.markHumanReceiptUncertain(id, instanceName);
|
|
3500
|
+
}
|
|
3501
|
+
}
|
|
3105
3502
|
/** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
|
|
3106
3503
|
async deliverToInstance(instanceName, payload, options = {}) {
|
|
3107
3504
|
const deliveryEpoch = this.getDeliveryEpoch(instanceName);
|
|
@@ -3234,6 +3631,9 @@ export class FleetManager {
|
|
|
3234
3631
|
* the check and the act, so the channel's instance cannot be rebound to another bot in between.
|
|
3235
3632
|
*/
|
|
3236
3633
|
async dispatchSlash(slash, adapterId, adapter) {
|
|
3634
|
+
return withSpawnHoldReply(text => slash.respond(text), () => this.dispatchSlashCore(slash, adapterId, adapter));
|
|
3635
|
+
}
|
|
3636
|
+
async dispatchSlashCore(slash, adapterId, adapter) {
|
|
3237
3637
|
// #1569: an alias (/web) is its command (/dashboard) from here on — the door, the table and the handler see one name.
|
|
3238
3638
|
const data = { ...slash, command: canonicalCommand(slash.command) };
|
|
3239
3639
|
const door = this.slashDoor(data, adapterId);
|
|
@@ -3291,7 +3691,7 @@ export class FleetManager {
|
|
|
3291
3691
|
userId: data.userId,
|
|
3292
3692
|
username,
|
|
3293
3693
|
source: "discord",
|
|
3294
|
-
timestamp: new Date(),
|
|
3694
|
+
timestamp: new Date(), adapterId, humanOrigin: data.humanOrigin, humanOriginalBody: text,
|
|
3295
3695
|
});
|
|
3296
3696
|
}
|
|
3297
3697
|
else if (data.command === "save") {
|
|
@@ -3315,6 +3715,9 @@ export class FleetManager {
|
|
|
3315
3715
|
else if (data.command === "model") {
|
|
3316
3716
|
await this.handleModelSlash(data, adapterId);
|
|
3317
3717
|
}
|
|
3718
|
+
else if (data.command === "backend") {
|
|
3719
|
+
await this.handleBackendSlash(data, adapterId);
|
|
3720
|
+
}
|
|
3318
3721
|
else if (data.command === "clear") {
|
|
3319
3722
|
await this.handleClearSlash(data, adapterId);
|
|
3320
3723
|
}
|
|
@@ -3408,7 +3811,7 @@ export class FleetManager {
|
|
|
3408
3811
|
await data.respond(result.durable ? t("dashboard.revoked", result.count) : t("dashboard.revoked_not_durable", result.count));
|
|
3409
3812
|
}
|
|
3410
3813
|
else {
|
|
3411
|
-
const owner = this.dashboardOwner(data.userId, adapterId, data.channelId);
|
|
3814
|
+
const owner = this.dashboardOwner(data.userId, adapterId, data.channelId, undefined, data.username);
|
|
3412
3815
|
if (!owner) {
|
|
3413
3816
|
await this.sendLocalDashboard(data, adapterId);
|
|
3414
3817
|
return;
|
|
@@ -3436,7 +3839,8 @@ export class FleetManager {
|
|
|
3436
3839
|
await data.respond(t("classic.no_agent"));
|
|
3437
3840
|
return;
|
|
3438
3841
|
}
|
|
3439
|
-
const
|
|
3842
|
+
const originalSteerText = String(data.options?.message ?? "");
|
|
3843
|
+
const steerText = originalSteerText.trim();
|
|
3440
3844
|
if (!steerText) {
|
|
3441
3845
|
await data.respond(t("steer.usage"));
|
|
3442
3846
|
return;
|
|
@@ -3446,7 +3850,7 @@ export class FleetManager {
|
|
|
3446
3850
|
// rerouting the instance's replies to the slash context.
|
|
3447
3851
|
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
3448
3852
|
chatId: "", messageId: "", username: data.username ?? "user",
|
|
3449
|
-
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
3853
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord", humanOrigin: data.humanOrigin, humanOriginalBody: originalSteerText,
|
|
3450
3854
|
});
|
|
3451
3855
|
await data.respond(result);
|
|
3452
3856
|
}
|
|
@@ -3456,14 +3860,15 @@ export class FleetManager {
|
|
|
3456
3860
|
await data.respond(t("classic.no_agent"));
|
|
3457
3861
|
return;
|
|
3458
3862
|
}
|
|
3459
|
-
const
|
|
3863
|
+
const originalBtwText = String(data.options?.message ?? "");
|
|
3864
|
+
const btwText = originalBtwText.trim();
|
|
3460
3865
|
if (!btwText) {
|
|
3461
3866
|
await data.respond(t("btw.usage"));
|
|
3462
3867
|
return;
|
|
3463
3868
|
}
|
|
3464
3869
|
const result = this.topicCommands.sendBtw(name, btwText, {
|
|
3465
3870
|
chatId: "", messageId: "", username: data.username ?? "user",
|
|
3466
|
-
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
3871
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord", humanOrigin: data.humanOrigin, humanOriginalBody: originalBtwText,
|
|
3467
3872
|
});
|
|
3468
3873
|
await data.respond(result);
|
|
3469
3874
|
}
|
|
@@ -3763,12 +4168,16 @@ export class FleetManager {
|
|
|
3763
4168
|
const autoPauseChanged = oldAutoPause.get(ch.instanceName) !== newAutoPause;
|
|
3764
4169
|
// #1220: a running CLI keeps the login it was launched with, so a new
|
|
3765
4170
|
// credential_profile only takes effect through a restart.
|
|
3766
|
-
const { changed: profileChanged, startsFresh: profileSwitchStartsFresh, from: profileFrom, to: profileTo } = this.classicProfileChange(ch.instanceName, backendChanged);
|
|
4171
|
+
const { changed: profileChanged, startsFresh: profileSwitchStartsFresh, from: profileFrom, to: profileTo, carry: profileCarry } = this.classicProfileChange(ch.instanceName, backendChanged);
|
|
3767
4172
|
if (this.daemons.has(ch.instanceName) && (backendChanged || modelChanged || autoPauseChanged || profileChanged)) {
|
|
3768
4173
|
this.logger.info({ instanceName: ch.instanceName, backendFrom: oldBackends.get(ch.instanceName), backendTo: newBackend, modelFrom: oldModels.get(ch.instanceName), modelTo: newModel,
|
|
3769
4174
|
...(profileChanged ? { profileFrom, profileTo } : {}) }, "Backend/model/profile changed — restarting");
|
|
4175
|
+
const launchedIn = this.daemons.get(ch.instanceName)?.getConfigSnapshot?.()?.working_directory;
|
|
3770
4176
|
await this.stopInstance(ch.instanceName).catch(() => { });
|
|
3771
|
-
|
|
4177
|
+
// #1753: the conversation goes along where it can; otherwise a switch that loses it starts fresh.
|
|
4178
|
+
if (profileCarry)
|
|
4179
|
+
await this.switchConversation(ch.instanceName, { ...profileCarry, workingDirectory: launchedIn });
|
|
4180
|
+
else if (profileSwitchStartsFresh)
|
|
3772
4181
|
this.writeFreshStartMarker(ch.instanceName);
|
|
3773
4182
|
// Small delay to let tmux window clean up
|
|
3774
4183
|
await new Promise(r => setTimeout(r, 2000));
|
|
@@ -4152,6 +4561,33 @@ export class FleetManager {
|
|
|
4152
4561
|
names.add(channel.instanceName);
|
|
4153
4562
|
return [...names];
|
|
4154
4563
|
}
|
|
4564
|
+
/** #1624: migration cannot infer ownership from the running daemon roster.
|
|
4565
|
+
* Stopped/paused ClassicBot entries count too; their workspace is exactly
|
|
4566
|
+
* the one startClassicInstance builds, not this FleetManager's dataDir.
|
|
4567
|
+
*/
|
|
4568
|
+
kiroPeerWorkingDirectories(name) {
|
|
4569
|
+
if (!this.fleetConfig || !this.classicChannels)
|
|
4570
|
+
throw new Error("Kiro peer configuration is not loaded");
|
|
4571
|
+
const peers = [];
|
|
4572
|
+
for (const [peer, cfg] of Object.entries(this.fleetConfig.instances)) {
|
|
4573
|
+
if (peer === name || (cfg.backend ?? this.fleetConfig.defaults?.backend ?? "claude-code") !== "kiro-cli")
|
|
4574
|
+
continue;
|
|
4575
|
+
if (typeof cfg.working_directory !== "string" || !cfg.working_directory)
|
|
4576
|
+
throw new Error("Kiro peer cwd is unknown");
|
|
4577
|
+
peers.push(cfg.working_directory);
|
|
4578
|
+
}
|
|
4579
|
+
for (const ch of this.classicChannels.getAll()) {
|
|
4580
|
+
if (ch.instanceName === name) {
|
|
4581
|
+
if (Object.hasOwn(this.fleetConfig.instances, name))
|
|
4582
|
+
throw new Error("Kiro instance name has two configurations");
|
|
4583
|
+
continue;
|
|
4584
|
+
}
|
|
4585
|
+
if (this.classicChannels.getBackendByInstance(ch.instanceName, this.fleetConfig.defaults?.backend) === "kiro-cli") {
|
|
4586
|
+
peers.push(join(getAgendHome(), "workspaces", ch.instanceName));
|
|
4587
|
+
}
|
|
4588
|
+
}
|
|
4589
|
+
return peers;
|
|
4590
|
+
}
|
|
4155
4591
|
/**
|
|
4156
4592
|
* Probe the same executable set exposed by the web backend catalog.
|
|
4157
4593
|
*
|
|
@@ -4374,6 +4810,9 @@ export class FleetManager {
|
|
|
4374
4810
|
// Published so the outage hand-off can wait for an explicit stop that began
|
|
4375
4811
|
// BEFORE it (the generation fence alone only catches stops that begin after
|
|
4376
4812
|
// the hand-off snapshotted it).
|
|
4813
|
+
// #1716: scope the marker to this stop — a marker older than beganAt is
|
|
4814
|
+
// from an earlier stop and never reports.
|
|
4815
|
+
const beganAt = Date.now();
|
|
4377
4816
|
const run = (async () => {
|
|
4378
4817
|
try {
|
|
4379
4818
|
await this.lifecycle.stop(name, transition);
|
|
@@ -4381,6 +4820,10 @@ export class FleetManager {
|
|
|
4381
4820
|
finally {
|
|
4382
4821
|
this.clearNoncePromptsForInstance(name);
|
|
4383
4822
|
}
|
|
4823
|
+
const backgroundWorkEnded = readBackgroundWorkEndedMarker(this.getInstanceDir(name), beganAt) !== null;
|
|
4824
|
+
if (backgroundWorkEnded)
|
|
4825
|
+
this.notifyInstanceTopic(name, t("stop.background_work_ended", name));
|
|
4826
|
+
return { backgroundWorkEnded };
|
|
4384
4827
|
})().finally(() => { if (this.stopsInFlight.get(name) === run)
|
|
4385
4828
|
this.stopsInFlight.delete(name); });
|
|
4386
4829
|
this.stopsInFlight.set(name, run);
|
|
@@ -4395,6 +4838,16 @@ export class FleetManager {
|
|
|
4395
4838
|
const inFlight = this.restartsInFlight.get(name);
|
|
4396
4839
|
if (inFlight) {
|
|
4397
4840
|
this.logger.info({ name }, "restartSingleInstance: joining the restart already in flight");
|
|
4841
|
+
// #1753: a credential-profile switch joining it still needs its conversation carried in that restart's stop →
|
|
4842
|
+
// start gap: handed to the restart while it has not reached the gap (and carries nothing else); otherwise not
|
|
4843
|
+
// carried, and said so — the restart has already started, or will start, the CLI without it.
|
|
4844
|
+
if (opts?.carry) {
|
|
4845
|
+
const gap = this.restartGaps.get(name);
|
|
4846
|
+
if (gap && !gap.reached && !gap.carry)
|
|
4847
|
+
gap.carry = opts.carry;
|
|
4848
|
+
else
|
|
4849
|
+
opts.carry.result = { carried: false, reason: gap?.carry ? "a restart in flight carries another switch" : "joined a restart in flight past its stop" };
|
|
4850
|
+
}
|
|
4398
4851
|
return inFlight;
|
|
4399
4852
|
}
|
|
4400
4853
|
const workingDirectory = this.fleetConfig?.instances[name]?.working_directory || this.getInstanceDir(name);
|
|
@@ -4403,6 +4856,8 @@ export class FleetManager {
|
|
|
4403
4856
|
// spawn gate is taken inside the transition, never the other way round, so
|
|
4404
4857
|
// the start being waited for can still get its own gate slot.
|
|
4405
4858
|
this.lifecycle.invalidate(name);
|
|
4859
|
+
const gap = { carry: opts?.carry, reached: false };
|
|
4860
|
+
this.restartGaps.set(name, gap);
|
|
4406
4861
|
const run = this.lifecycle.runTransition(name, transition => this.spawnGate.run({
|
|
4407
4862
|
instanceName: name,
|
|
4408
4863
|
workingDirectory,
|
|
@@ -4411,6 +4866,8 @@ export class FleetManager {
|
|
|
4411
4866
|
}, () => this.doRestartSingleInstance(name, opts, transition)))
|
|
4412
4867
|
.finally(() => {
|
|
4413
4868
|
this.restartsInFlight.delete(name);
|
|
4869
|
+
if (this.restartGaps.get(name) === gap)
|
|
4870
|
+
this.restartGaps.delete(name);
|
|
4414
4871
|
this.scheduleDeliveryOutboxPump();
|
|
4415
4872
|
});
|
|
4416
4873
|
this.restartsInFlight.set(name, run);
|
|
@@ -4480,10 +4937,41 @@ export class FleetManager {
|
|
|
4480
4937
|
async doRestartWithSlot(name, opts, transition, config, wasPaused) {
|
|
4481
4938
|
const pausedAt = wasPaused ? (this.lifecycle.getLastPausedAt(name) ?? readPausedAt(this.getInstanceDir(name))) : null;
|
|
4482
4939
|
const pauseReason = wasPaused ? readPauseReason(this.getInstanceDir(name)) : null;
|
|
4940
|
+
// #1753: the directory the CLI being stopped was launched in — its conversation is under that directory's key.
|
|
4941
|
+
const launchedIn = this.daemons.get(name)?.getConfigSnapshot?.()?.working_directory;
|
|
4942
|
+
// Between the stop and the start: the old CLI's transcript is complete, the new one has not looked yet.
|
|
4943
|
+
/** `launching`: the login the start will use — read only when a switch is to be carried. */
|
|
4944
|
+
const betweenStopAndStart = async (launching) => {
|
|
4945
|
+
// #1753: this restart's own carry, or one a caller joined it with before this point (restartSingleInstance).
|
|
4946
|
+
const gap = this.restartGaps.get(name);
|
|
4947
|
+
const carry = gap?.carry ?? opts?.carry;
|
|
4948
|
+
if (gap)
|
|
4949
|
+
gap.reached = true;
|
|
4950
|
+
if (!carry) {
|
|
4951
|
+
if (opts?.freshStart)
|
|
4952
|
+
this.writeFreshStartMarker(name);
|
|
4953
|
+
return;
|
|
4954
|
+
}
|
|
4955
|
+
// Only into the login this start really launches: a restart that read its configuration before the switch was
|
|
4956
|
+
// saved (one a switch joined) launches the old one — a copy into the new would be reported as carried, falsely.
|
|
4957
|
+
const launch = launching();
|
|
4958
|
+
if (launch.backend !== carry.backend || launch.profile !== carry.to) {
|
|
4959
|
+
const login = (p) => (p ? `profile "${p}"` : "the default login");
|
|
4960
|
+
carry.result = { carried: false, reason: `the restart launches ${launch.backend !== carry.backend ? launch.backend : login(launch.profile)}, not ${login(carry.to)}` };
|
|
4961
|
+
this.logger.info({ name, launches: launch, carry: { backend: carry.backend, from: carry.from, to: carry.to } }, "Conversation not carried: the restart launches another login");
|
|
4962
|
+
// The old login resumes its own conversation; any other starts fresh, as an uncarried switch does.
|
|
4963
|
+
if (launch.backend !== carry.backend || launch.profile !== carry.from)
|
|
4964
|
+
this.writeFreshStartMarker(name);
|
|
4965
|
+
return;
|
|
4966
|
+
}
|
|
4967
|
+
carry.result = await this.switchConversation(name, { ...carry, workingDirectory: launchedIn });
|
|
4968
|
+
};
|
|
4483
4969
|
if (config) {
|
|
4484
4970
|
await this.stopInstance(name, transition);
|
|
4485
|
-
|
|
4486
|
-
this.
|
|
4971
|
+
await betweenStopAndStart(() => {
|
|
4972
|
+
const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
4973
|
+
return { backend, profile: credentialHomeSpec(backend) ? instanceCredentialProfile(config, this.fleetConfig?.defaults, backend) : null };
|
|
4974
|
+
});
|
|
4487
4975
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
4488
4976
|
await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => this.startInstance(name, config, topicMode ?? false, "fleet-topic", false, transition));
|
|
4489
4977
|
this.stormWindow.markRecovered(name);
|
|
@@ -4496,8 +4984,10 @@ export class FleetManager {
|
|
|
4496
4984
|
const adapterId = this.classicChannels.getAdapterIdByInstance(name);
|
|
4497
4985
|
await this.stopInstance(name, transition);
|
|
4498
4986
|
await new Promise(r => setTimeout(r, 1000)); // let tmux clean up
|
|
4499
|
-
|
|
4500
|
-
this.
|
|
4987
|
+
await betweenStopAndStart(() => {
|
|
4988
|
+
const backend = this.classicChannels.getBackendByInstance(name, fleetBackend);
|
|
4989
|
+
return { backend, profile: credentialHomeSpec(backend) ? profileName(this.classicChannels.getCredentialProfileByInstance(name, this.fleetConfig?.defaults)) : null };
|
|
4990
|
+
});
|
|
4501
4991
|
await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, fleetBackend), this.classicChannels.getPreTaskCommand(channelId, adapterId), this.classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channelId, adapterId, this.fleetConfig?.defaults?.auto_pause_after), transition));
|
|
4502
4992
|
this.stormWindow.markRecovered(name);
|
|
4503
4993
|
return;
|
|
@@ -4518,6 +5008,43 @@ export class FleetManager {
|
|
|
4518
5008
|
this.logger.warn({ err, name }, "freshStart: failed to write crash-state marker");
|
|
4519
5009
|
}
|
|
4520
5010
|
}
|
|
5011
|
+
/**
|
|
5012
|
+
* #1753: a credential-profile switch, between the old CLI's stop and the new one's start. Where the backend can take
|
|
5013
|
+
* the conversation along (claude-code), copy it into the new profile and make it the one `--continue` picks; when that
|
|
5014
|
+
* cannot be done, or the backend cannot, start fresh as before. Null when nothing was tried.
|
|
5015
|
+
*/
|
|
5016
|
+
async switchConversation(name, sw) {
|
|
5017
|
+
if (!credentialSwitchCarriesConversation(sw.backend)) {
|
|
5018
|
+
if (credentialSwitchStartsFresh(sw.backend))
|
|
5019
|
+
this.writeFreshStartMarker(name);
|
|
5020
|
+
return null;
|
|
5021
|
+
}
|
|
5022
|
+
let sessionId = null;
|
|
5023
|
+
try {
|
|
5024
|
+
sessionId = readFileSync(join(this.getInstanceDir(name), "session-id"), "utf-8");
|
|
5025
|
+
}
|
|
5026
|
+
catch { /* none: nothing to carry */ }
|
|
5027
|
+
const dir = (profile) => claudeConfigDirFor(profile ? credentialProfileHome(this.dataDir, sw.backend, profile) : null);
|
|
5028
|
+
let result;
|
|
5029
|
+
try {
|
|
5030
|
+
result = await carryClaudeConversation({ sessionId, workingDirectory: sw.workingDirectory ?? "", fromConfigDir: dir(sw.from), toConfigDir: dir(sw.to) });
|
|
5031
|
+
}
|
|
5032
|
+
catch (err) {
|
|
5033
|
+
result = { carried: false, reason: `copy failed: ${err.message}` };
|
|
5034
|
+
}
|
|
5035
|
+
if (result.carried) {
|
|
5036
|
+
const fields = { name, from: sw.from ?? "default", to: sw.to ?? "default", bytes: result.bytes, ms: result.ms, replacedOlderCopy: result.replacedOlderCopy };
|
|
5037
|
+
if (result.bytes > CARRY_LARGE_BYTES)
|
|
5038
|
+
this.logger.warn(fields, "Conversation carried to the new credential profile (a large transcript)");
|
|
5039
|
+
else
|
|
5040
|
+
this.logger.info(fields, "Conversation carried to the new credential profile");
|
|
5041
|
+
}
|
|
5042
|
+
else {
|
|
5043
|
+
this.logger.info({ name, from: sw.from ?? "default", to: sw.to ?? "default", reason: result.reason }, "Conversation not carried to the new credential profile; starting fresh");
|
|
5044
|
+
this.writeFreshStartMarker(name);
|
|
5045
|
+
}
|
|
5046
|
+
return result;
|
|
5047
|
+
}
|
|
4521
5048
|
/** Load .env file from data dir into process.env */
|
|
4522
5049
|
loadEnvFile(owner) {
|
|
4523
5050
|
const envPath = join(this.dataDir, ".env");
|
|
@@ -4549,6 +5076,9 @@ export class FleetManager {
|
|
|
4549
5076
|
this.webSessions = new WebSessionStore({
|
|
4550
5077
|
dataDir: this.dataDir,
|
|
4551
5078
|
onWarn: message => this.logger.warn(message),
|
|
5079
|
+
// #1620: fleet.yaml's web.session.local, read at every check — a Settings save or a reload applies at once
|
|
5080
|
+
// (shortening ends the sessions it now outlives). The public link's policy stays fixed.
|
|
5081
|
+
localPolicy: () => localSessionPolicy(this.fleetConfig?.web?.session?.local),
|
|
4552
5082
|
});
|
|
4553
5083
|
}
|
|
4554
5084
|
if (!this.returningDevices) {
|
|
@@ -4578,7 +5108,13 @@ export class FleetManager {
|
|
|
4578
5108
|
return { display: issued.display, expiresAt: issued.expiresAt, ttlMinutes: Math.round(LOGIN_CODE_TTL_MS / 60_000) };
|
|
4579
5109
|
}
|
|
4580
5110
|
tunnelPurposeLane = null;
|
|
5111
|
+
previewTunnel = null;
|
|
4581
5112
|
publicWebLink = null;
|
|
5113
|
+
/** #1723: the public link's preview tunnel — a manager and lease of its own, never the fleet's one tunnel lane. */
|
|
5114
|
+
getPreviewTunnel() {
|
|
5115
|
+
return this.previewTunnel ??= new ManagedTunnel({ dataDir: join(this.dataDir, PUBLIC_PREVIEW_TUNNEL_DIR),
|
|
5116
|
+
log: () => this.logger.warn("Public preview tunnel cleanup requires attention") });
|
|
5117
|
+
}
|
|
4582
5118
|
getTunnelLane() {
|
|
4583
5119
|
return this.tunnelPurposeLane ??= new TunnelPurposeLane(new ManagedTunnel({ dataDir: this.dataDir,
|
|
4584
5120
|
log: () => this.logger.warn("Managed tunnel cleanup requires attention") }));
|
|
@@ -4597,7 +5133,7 @@ export class FleetManager {
|
|
|
4597
5133
|
&& cfg.topic_id?.toString() === owner.threadId);
|
|
4598
5134
|
}
|
|
4599
5135
|
/** Exact owning General. No primary-adapter or name fallback participates in authorization. */
|
|
4600
|
-
dashboardOwner(userId, adapterId, address, threadId) {
|
|
5136
|
+
dashboardOwner(userId, adapterId, address, threadId, username) {
|
|
4601
5137
|
const world = this.worlds.get(adapterId);
|
|
4602
5138
|
if (!world || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(userId, adapterId))
|
|
4603
5139
|
return null;
|
|
@@ -4612,10 +5148,65 @@ export class FleetManager {
|
|
|
4612
5148
|
const matched = dc ? topic === address || (group === address && topic === threadId)
|
|
4613
5149
|
: group === address && ((topic === "1" && (threadId === undefined || threadId === "1")) || topic === threadId);
|
|
4614
5150
|
if (group && matched)
|
|
4615
|
-
return { userId, adapterId, chatId: group, threadId: topic, binding: world.adapter };
|
|
5151
|
+
return { userId, adapterId, chatId: group, threadId: topic, binding: world.adapter, ...(username ? { username } : {}) };
|
|
4616
5152
|
}
|
|
4617
5153
|
return null;
|
|
4618
5154
|
}
|
|
5155
|
+
/**
|
|
5156
|
+
* #1742: the General a fleet admin's DM speaks for (`/start web`): the same owner `/web` in that General would make
|
|
5157
|
+
* — this bot's configured group and its one General topic. No address to match in a DM, so it must be unambiguous:
|
|
5158
|
+
* no General, or more than one on this bot and group, and there is no owner (nothing is guessed).
|
|
5159
|
+
*/
|
|
5160
|
+
dmDashboardOwner(userId, adapterId, username) {
|
|
5161
|
+
const world = this.worlds.get(adapterId);
|
|
5162
|
+
if (!world || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(userId, adapterId))
|
|
5163
|
+
return null;
|
|
5164
|
+
const configured = (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).find(ch => (ch.id ?? ch.type) === adapterId);
|
|
5165
|
+
const group = String(configured?.group_id ?? "");
|
|
5166
|
+
if (!group)
|
|
5167
|
+
return null;
|
|
5168
|
+
const topics = Object.entries(this.fleetConfig?.instances ?? {})
|
|
5169
|
+
.filter(([name, cfg]) => cfg.general_topic && cfg.topic_id != null && this.getInstanceAdapterId(name) === adapterId
|
|
5170
|
+
&& String(this.getGroupIdForInstance(name) ?? "") === group)
|
|
5171
|
+
.map(([, cfg]) => cfg.topic_id.toString());
|
|
5172
|
+
if (topics.length !== 1)
|
|
5173
|
+
return null;
|
|
5174
|
+
const owner = { userId, adapterId, chatId: group, threadId: topics[0], binding: world.adapter, ...(username ? { username } : {}) };
|
|
5175
|
+
return this.publicOwnerCurrent(owner) ? owner : null;
|
|
5176
|
+
}
|
|
5177
|
+
/**
|
|
5178
|
+
* #1742: a fleet admin whose Telegram `/web` could not reach them privately may next open the bot's DM and press
|
|
5179
|
+
* Telegram's START, which sends a bare `/start` — read as `/start web` (the DM menu) for 10 minutes, once, for that
|
|
5180
|
+
* admin on that bot. Monotonic: a wall-clock jump neither opens nor shuts it.
|
|
5181
|
+
*/
|
|
5182
|
+
tgWebStartWindows = new Map();
|
|
5183
|
+
openTgWebStartWindow(adapterId, userId) {
|
|
5184
|
+
const now = performance.now();
|
|
5185
|
+
for (const [key, until] of this.tgWebStartWindows)
|
|
5186
|
+
if (until <= now)
|
|
5187
|
+
this.tgWebStartWindows.delete(key);
|
|
5188
|
+
this.tgWebStartWindows.set(`${adapterId}\0${userId}`, now + TG_WEB_START_WINDOW_MS);
|
|
5189
|
+
}
|
|
5190
|
+
/** Uses the window up: true when it was open. */
|
|
5191
|
+
takeTgWebStartWindow(adapterId, userId) {
|
|
5192
|
+
const key = `${adapterId}\0${userId}`;
|
|
5193
|
+
const until = this.tgWebStartWindows.get(key);
|
|
5194
|
+
this.tgWebStartWindows.delete(key);
|
|
5195
|
+
return until !== undefined && performance.now() < until;
|
|
5196
|
+
}
|
|
5197
|
+
/** #1742: `/start web` from a fleet admin: the /web menu right here in the DM, for the General this bot serves. */
|
|
5198
|
+
async startWebInDm(msg, adapter) {
|
|
5199
|
+
if (!adapter)
|
|
5200
|
+
return;
|
|
5201
|
+
const owner = msg.chatId === msg.userId ? this.dmDashboardOwner(msg.userId, msg.adapterId ?? "", msg.username) : null;
|
|
5202
|
+
if (!owner) {
|
|
5203
|
+
await adapter.sendText(msg.chatId, t("dashboard.start_web_ack"));
|
|
5204
|
+
return;
|
|
5205
|
+
}
|
|
5206
|
+
if (!await this.showDashboardMenu(owner, undefined, undefined, msg.chatId)) {
|
|
5207
|
+
await adapter.sendText(msg.chatId, t("dashboard.menu_failed")).catch(() => undefined);
|
|
5208
|
+
}
|
|
5209
|
+
}
|
|
4619
5210
|
getPublicWebLink() {
|
|
4620
5211
|
this.initializeWebSessions();
|
|
4621
5212
|
return this.publicWebLink ??= new PublicWebLink({
|
|
@@ -4630,7 +5221,14 @@ export class FleetManager {
|
|
|
4630
5221
|
if (result && !result.durable)
|
|
4631
5222
|
this.logger.warn("Public session revocation could not be saved; exposure remains closed");
|
|
4632
5223
|
},
|
|
4633
|
-
log: (event, exposureId) => this.logger.info({ event, exposureId }, "Public web link"),
|
|
5224
|
+
log: (event, exposureId, detail) => this.logger.info({ event, exposureId, ...(detail ? { detail } : {}) }, "Public web link"),
|
|
5225
|
+
createPreview: ({ id, expiresAt, binary, protocol, dashboardOrigin, isCurrent }) => {
|
|
5226
|
+
if (!publicPreviewWanted(this.fleetConfig?.web))
|
|
5227
|
+
return null;
|
|
5228
|
+
return new PublicPreview({ exposureId: id, expiresAt, dashboardOrigin, isCurrent, manager: this.getPreviewTunnel(),
|
|
5229
|
+
provider: () => new CloudflaredProvider({ binaryName: binary, protocol }),
|
|
5230
|
+
log: (event, detail) => this.logger.info({ event, exposureId: id, ...(detail ? { detail } : {}) }, "Public web link") });
|
|
5231
|
+
},
|
|
4634
5232
|
onCleanupUnconfirmed: (result, owner) => {
|
|
4635
5233
|
this.logger.warn({ pid: result.confirmed ? null : result.pid }, "Public web access closed; tunnel cleanup unconfirmed");
|
|
4636
5234
|
const world = this.worlds.get(owner.adapterId);
|
|
@@ -4653,7 +5251,7 @@ export class FleetManager {
|
|
|
4653
5251
|
/** Telegram's typed command enters through the same strict owner and nonce path as Discord. */
|
|
4654
5252
|
async dashboardMenu(msg) {
|
|
4655
5253
|
const id = msg.adapterId;
|
|
4656
|
-
const owner = id ? this.dashboardOwner(msg.userId, id, msg.chatId, msg.threadId) : null;
|
|
5254
|
+
const owner = id ? this.dashboardOwner(msg.userId, id, msg.chatId, msg.threadId, msg.username) : null;
|
|
4657
5255
|
if (!owner) {
|
|
4658
5256
|
const adapter = id ? this.adapters.get(id) : undefined;
|
|
4659
5257
|
await adapter?.sendText(msg.chatId, t("dashboard.public_general"), { threadId: msg.threadId });
|
|
@@ -4663,6 +5261,16 @@ export class FleetManager {
|
|
|
4663
5261
|
await owner.binding.sendText(owner.chatId, t("dashboard.menu_failed"), { threadId: owner.threadId }).catch(() => undefined);
|
|
4664
5262
|
}
|
|
4665
5263
|
}
|
|
5264
|
+
/**
|
|
5265
|
+
* #1686: build the Telegram deep-link that opens a DM with this bot pre-seeded with `/start web`.
|
|
5266
|
+
* Returns null when no botUsername is known or the username fails Telegram's format validation.
|
|
5267
|
+
*/
|
|
5268
|
+
tgStartWebUrl(adapterId) {
|
|
5269
|
+
const username = this.worlds.get(adapterId)?.botUsername;
|
|
5270
|
+
if (!username || !/^[A-Za-z0-9_]{5,32}$/.test(username))
|
|
5271
|
+
return null;
|
|
5272
|
+
return `https://t.me/${username}?start=web`;
|
|
5273
|
+
}
|
|
4666
5274
|
async sendLocalDashboard(data, adapterId) {
|
|
4667
5275
|
if (this.shuttingDown || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(data.userId, adapterId)) {
|
|
4668
5276
|
await data.respond(t("not_authorized"));
|
|
@@ -4676,7 +5284,9 @@ export class FleetManager {
|
|
|
4676
5284
|
return;
|
|
4677
5285
|
}
|
|
4678
5286
|
this.initializeWebSessions();
|
|
4679
|
-
|
|
5287
|
+
// #1827: the code goes privately to this account (a DM, or else this slash command's ephemeral answer): whoever
|
|
5288
|
+
// redeems it is recognised as that account. Recognition only — no owner (this is not an owning General's code).
|
|
5289
|
+
const login = this.webLoginCodes.issue({ epoch: tokenEpoch(token), recipient: { adapterId, userId: data.userId, ...(data.username ? { username: data.username } : {}) } });
|
|
4680
5290
|
const current = () => !this.shuttingDown && this.adapters.get(adapterId) === adapter
|
|
4681
5291
|
&& this.webToken === token && this.hasFleetAdmins(adapterId) && this.isFleetAdmin(data.userId, adapterId);
|
|
4682
5292
|
const privateText = `${text}\n${t("dashboard.code", login.display, Math.round(LOGIN_CODE_TTL_MS / 60_000))}`;
|
|
@@ -4776,7 +5386,11 @@ export class FleetManager {
|
|
|
4776
5386
|
this.logger.info({ code: typeof code === "string" || typeof code === "number" ? code : undefined, err: err?.message }, "Dashboard DM not delivered; answering privately where the command was used");
|
|
4777
5387
|
}
|
|
4778
5388
|
/** False when the menu was not shown (the caller still owes the user an answer). */
|
|
4779
|
-
|
|
5389
|
+
/**
|
|
5390
|
+
* `dmChatId` (#1742): show it in the owner's own private chat with the bot instead of the General — the owner, its
|
|
5391
|
+
* gates and the code's route are the same; only where the menu sits (and its clicks are good) changes.
|
|
5392
|
+
*/
|
|
5393
|
+
async showDashboardMenu(owner, respondButtons, retireButtons, dmChatId) {
|
|
4780
5394
|
if (!this.publicOwnerCurrent(owner)) {
|
|
4781
5395
|
this.logger.info("Dashboard menu not shown: the General owner changed");
|
|
4782
5396
|
return false;
|
|
@@ -4784,16 +5398,18 @@ export class FleetManager {
|
|
|
4784
5398
|
const adapter = owner.binding;
|
|
4785
5399
|
const status = this.getPublicWebStatus();
|
|
4786
5400
|
const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
|
|
5401
|
+
const tgHint = adapter.type === "telegram" && dmChatId === undefined ? this.tgStartWebUrl(owner.adapterId) : null;
|
|
4787
5402
|
const menuText = this.topicCommands.getDashboardText(false, !publicAllowed)
|
|
4788
5403
|
+ (publicAllowed ? "\n\n" + t("dashboard.public_risk") : "")
|
|
4789
5404
|
+ "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
|
|
4790
|
-
+ (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "")
|
|
5405
|
+
+ (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "")
|
|
5406
|
+
+ (tgHint ? "\n" + t("dashboard.menu_tg_hint", tgHint) : "");
|
|
4791
5407
|
const choices = [{ action: "local", label: t("dashboard.local") },
|
|
4792
5408
|
...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
|
|
4793
5409
|
...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
|
|
4794
5410
|
const posted = await this.postNonceButtonPrompt({
|
|
4795
5411
|
prefix: "dashboard:", alertType: "login", instanceName: "dashboard", adapter, adapterId: owner.adapterId,
|
|
4796
|
-
chatId: owner.chatId, threadId: owner.threadId, timeoutMs: 5 * 60_000,
|
|
5412
|
+
chatId: dmChatId ?? owner.chatId, threadId: dmChatId === undefined ? owner.threadId : undefined, timeoutMs: 5 * 60_000,
|
|
4797
5413
|
message: menuText,
|
|
4798
5414
|
choices, expiredText: t("buttons.stale"), extra: { requesterUserId: owner.userId, dashboardOwner: owner, publicExposureId: this.publicWebLink?.exposureId },
|
|
4799
5415
|
...(respondButtons ? { deliver: async (c) => {
|
|
@@ -4856,6 +5472,8 @@ export class FleetManager {
|
|
|
4856
5472
|
}) : null;
|
|
4857
5473
|
let lastSteps;
|
|
4858
5474
|
let deliveredByDm = false;
|
|
5475
|
+
// #1742: a DM was tried and did not go — only then is "open the private chat" the way out.
|
|
5476
|
+
let dmRefused = false;
|
|
4859
5477
|
const send = async (url, exposureId, expiresAt, current = () => this.publicOwnerCurrent(owner)) => {
|
|
4860
5478
|
const token = this.webToken;
|
|
4861
5479
|
if (!token || !current())
|
|
@@ -4881,6 +5499,7 @@ export class FleetManager {
|
|
|
4881
5499
|
}
|
|
4882
5500
|
catch (err) {
|
|
4883
5501
|
this.logDashboardDmFailure(err);
|
|
5502
|
+
dmRefused = true;
|
|
4884
5503
|
if (direct.type !== "discord" || !data.respondPrivate || !current())
|
|
4885
5504
|
throw new Error("private delivery unavailable");
|
|
4886
5505
|
const sent = await withinBudget(data.respondPrivate(text, c), deadline);
|
|
@@ -4926,11 +5545,23 @@ export class FleetManager {
|
|
|
4926
5545
|
this.logger.warn({ err }, "Dashboard sign-in delivery failed");
|
|
4927
5546
|
}
|
|
4928
5547
|
// Only safe, static words enter General; never link, code or platform error text.
|
|
4929
|
-
|
|
4930
|
-
|
|
5548
|
+
// #1742: Telegram's failure walks through opening the DM (never a bare /start, which starts a ClassicBot agent),
|
|
5549
|
+
// with the "先開私訊 →" button when the bot's username is known; and for a while a bare /start there counts as
|
|
5550
|
+
// /start web. A menu shown in the DM itself says the details are right below it. Only when a DM was tried and did
|
|
5551
|
+
// not go: a link that never got that far (the tunnel lane held, the owner gone) says so instead.
|
|
5552
|
+
const telegram = owner.binding.type === "telegram";
|
|
5553
|
+
const dmSteps = !ok && telegram && dmRefused;
|
|
5554
|
+
const tgLink = dmSteps ? this.tgStartWebUrl(adapterId) : null;
|
|
5555
|
+
if (dmSteps)
|
|
5556
|
+
this.openTgWebStartWindow(adapterId, owner.userId);
|
|
5557
|
+
const outcome = t(!ok ? (!telegram ? "dashboard.private_failed" : !dmSteps ? "dashboard.link_failed" : tgLink ? "dashboard.private_failed_tg" : "dashboard.private_failed_tg_nolink")
|
|
5558
|
+
: telegram && entry.chatId === owner.userId ? "dashboard.private_sent_below"
|
|
5559
|
+
: owner.binding.type !== "discord" ? "dashboard.private_sent" : deliveredByDm ? "dashboard.private_sent_dm" : "dashboard.private_sent_here");
|
|
4931
5560
|
await progress;
|
|
4932
5561
|
await steps?.close(); // the last step edit has landed: the outcome cannot be overwritten by it
|
|
4933
|
-
|
|
5562
|
+
// #1686: attach the deep-link button on the failure outcome (Telegram only; Discord and the editClicked path ignore urlButtons).
|
|
5563
|
+
const failUrlButtons = tgLink ? [{ label: t("dashboard.start_web_label"), url: tgLink }] : undefined;
|
|
5564
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, lastSteps ? renderPublicLinkProgress(lastSteps, performance.now(), outcome) : outcome, data.editClicked, failUrlButtons);
|
|
4934
5565
|
return true;
|
|
4935
5566
|
}
|
|
4936
5567
|
/** `/dashboard revoke`: sign out every browser and withdraw any unused code. */
|
|
@@ -5172,6 +5803,86 @@ export class FleetManager {
|
|
|
5172
5803
|
setUpdateProgressStage(this.dataDir, "failed", { error: message });
|
|
5173
5804
|
}
|
|
5174
5805
|
/** The old fleet edits CLI install stages; the new fleet adopts the same message below. */
|
|
5806
|
+
/**
|
|
5807
|
+
* #1660: arm the post-completion cleanup watch and make sure its monitor
|
|
5808
|
+
* actually runs. The new fleet adopts the receipt at boot but nothing else
|
|
5809
|
+
* starts this timer on that path (Prism a) — without this, the owed
|
|
5810
|
+
* outcome would sit in the marker unwatched. READY never waits for it.
|
|
5811
|
+
*/
|
|
5812
|
+
/**
|
|
5813
|
+
* #1735: what the completion of an adopted update does about its retire outcome, read from the marker as it is now.
|
|
5814
|
+
* Already landed with a warning (a detached restart returns before this fleet boots, so the updater retires first)
|
|
5815
|
+
* → render it in the completion. Still owed (`cleanup: "pending"` — a service restart waits for READY, which comes
|
|
5816
|
+
* after the completion) → watch for it. Anything else (no retire on this update, done silently, another receipt)
|
|
5817
|
+
* → nothing.
|
|
5818
|
+
*/
|
|
5819
|
+
/**
|
|
5820
|
+
* #1716: background work this restart/update ended, scoped to the operation
|
|
5821
|
+
* so an older stop never reports (`sinceAt` null = plain boot, report
|
|
5822
|
+
* nothing). A method — not a module function — because the #1737
|
|
5823
|
+
* extracted-completion harness runs this region verbatim with only
|
|
5824
|
+
* `this`-methods plus a fixed context.
|
|
5825
|
+
*/
|
|
5826
|
+
collectBackgroundWorkEndedNames(names, sinceAt) {
|
|
5827
|
+
if (sinceAt === null)
|
|
5828
|
+
return [];
|
|
5829
|
+
return names.filter(n => readBackgroundWorkEndedMarker(this.getInstanceDir(n), sinceAt) !== null);
|
|
5830
|
+
}
|
|
5831
|
+
updateCleanupAtCompletion(pending, operation) {
|
|
5832
|
+
if (!pending || operation !== "update")
|
|
5833
|
+
return null;
|
|
5834
|
+
const receipt = updateReceiptOf(pending);
|
|
5835
|
+
if (!receipt)
|
|
5836
|
+
return null;
|
|
5837
|
+
const current = readUpdateProgress(this.dataDir);
|
|
5838
|
+
const verdict = cleanupWatchVerdict(current, receipt);
|
|
5839
|
+
if (verdict.action === "append")
|
|
5840
|
+
return { render: { warning: verdict.warning, cleanupCommand: verdict.cleanupCommand } };
|
|
5841
|
+
if (verdict.action === "wait" && current?.progress.cleanup === "pending")
|
|
5842
|
+
return { watch: receipt };
|
|
5843
|
+
return null;
|
|
5844
|
+
}
|
|
5845
|
+
armCleanupWatch(receipt, summary) {
|
|
5846
|
+
this.updateCleanupWatch = { receipt, summary };
|
|
5847
|
+
if (!this.updateProgressTimer)
|
|
5848
|
+
this.startUpdateProgressMonitor();
|
|
5849
|
+
}
|
|
5850
|
+
/**
|
|
5851
|
+
* #1660: append a recorded cleanup outcome to the adopted update message,
|
|
5852
|
+
* then release the receipt — but only if this receipt still owns the
|
|
5853
|
+
* marker. The await yields, so a newer receipt (e.g. a full-restart that
|
|
5854
|
+
* landed mid-edit) must not be cleared or disturbed (Prism b).
|
|
5855
|
+
*/
|
|
5856
|
+
async appendCleanupWarning(target, watch, warning, cleanupCommand) {
|
|
5857
|
+
const text = formatRestartProgressCompletion("update", {
|
|
5858
|
+
...watch.summary,
|
|
5859
|
+
warningText: warning,
|
|
5860
|
+
warningCommand: cleanupCommand,
|
|
5861
|
+
}, watch.receipt.startedAt);
|
|
5862
|
+
const stopWatching = () => {
|
|
5863
|
+
this.updateCleanupWatch = null;
|
|
5864
|
+
if (this.updateProgressTimer)
|
|
5865
|
+
clearInterval(this.updateProgressTimer);
|
|
5866
|
+
this.updateProgressTimer = null;
|
|
5867
|
+
};
|
|
5868
|
+
if (text !== this.lastUpdateProgressText) {
|
|
5869
|
+
const adapter = this.adapters.get(target.adapterId);
|
|
5870
|
+
if (!adapter)
|
|
5871
|
+
return; // retry on a later tick
|
|
5872
|
+
await adapter.editMessage(target.chatId, target.messageId, text, target.threadId);
|
|
5873
|
+
this.lastUpdateProgressText = text;
|
|
5874
|
+
}
|
|
5875
|
+
const verdict = cleanupWatchVerdict(readUpdateProgress(this.dataDir), watch.receipt);
|
|
5876
|
+
if (verdict.action === "drop") {
|
|
5877
|
+
// Someone else owns the marker now — leave it entirely alone.
|
|
5878
|
+
this.updateCleanupWatch = null;
|
|
5879
|
+
return;
|
|
5880
|
+
}
|
|
5881
|
+
if (verdict.action === "wait")
|
|
5882
|
+
return; // not terminal; keep watching
|
|
5883
|
+
clearUpdateMarker(this.dataDir);
|
|
5884
|
+
stopWatching();
|
|
5885
|
+
}
|
|
5175
5886
|
startUpdateProgressMonitor(initialAdapter) {
|
|
5176
5887
|
if (this.updateProgressTimer)
|
|
5177
5888
|
clearInterval(this.updateProgressTimer);
|
|
@@ -5185,6 +5896,48 @@ export class FleetManager {
|
|
|
5185
5896
|
this.updateProgressTimer = null;
|
|
5186
5897
|
return;
|
|
5187
5898
|
}
|
|
5899
|
+
// #1660: the completion summary was already posted; only watch for
|
|
5900
|
+
// the retire outcome owed to this exact receipt. Never stage-edit
|
|
5901
|
+
// here (that would clobber the posted completion), and never touch a
|
|
5902
|
+
// receipt that no longer matches (checklist 3/5).
|
|
5903
|
+
const watch = this.updateCleanupWatch;
|
|
5904
|
+
if (watch) {
|
|
5905
|
+
const stopWatching = () => {
|
|
5906
|
+
this.updateCleanupWatch = null;
|
|
5907
|
+
if (this.updateProgressTimer)
|
|
5908
|
+
clearInterval(this.updateProgressTimer);
|
|
5909
|
+
this.updateProgressTimer = null;
|
|
5910
|
+
};
|
|
5911
|
+
const verdict = cleanupWatchVerdict(marker, watch.receipt);
|
|
5912
|
+
if (verdict.action === "drop") {
|
|
5913
|
+
// Receipt gone or replaced (e.g. a later full-restart adopted the
|
|
5914
|
+
// marker): not ours — leave it to its own lifecycle, drop the watch.
|
|
5915
|
+
this.logger.info("[update] cleanup watch: marker receipt changed, dropping watch");
|
|
5916
|
+
this.updateCleanupWatch = null;
|
|
5917
|
+
}
|
|
5918
|
+
else if (verdict.action === "release") {
|
|
5919
|
+
// Done silent, or the updater died past the max age: release it.
|
|
5920
|
+
clearUpdateMarker(this.dataDir);
|
|
5921
|
+
stopWatching();
|
|
5922
|
+
return;
|
|
5923
|
+
}
|
|
5924
|
+
else if (verdict.action === "wait") {
|
|
5925
|
+
return;
|
|
5926
|
+
}
|
|
5927
|
+
else {
|
|
5928
|
+
this.updateProgressEditRunning = true;
|
|
5929
|
+
try {
|
|
5930
|
+
await this.appendCleanupWarning(marker.progress.target, watch, verdict.warning, verdict.cleanupCommand);
|
|
5931
|
+
}
|
|
5932
|
+
catch (err) {
|
|
5933
|
+
this.logger.warn({ err }, "[update] cleanup watch: failed to append warning, will retry");
|
|
5934
|
+
}
|
|
5935
|
+
finally {
|
|
5936
|
+
this.updateProgressEditRunning = false;
|
|
5937
|
+
}
|
|
5938
|
+
return;
|
|
5939
|
+
}
|
|
5940
|
+
}
|
|
5188
5941
|
const target = marker.progress.target;
|
|
5189
5942
|
const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
|
|
5190
5943
|
if (!adapter)
|
|
@@ -5251,6 +6004,8 @@ export class FleetManager {
|
|
|
5251
6004
|
const fleet = this.loadConfig(configPath);
|
|
5252
6005
|
this.slimFleetConfigAtStartup();
|
|
5253
6006
|
setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
|
|
6007
|
+
// #1737 review: the marker this boot adopts — the startup clear may only ever clear this one.
|
|
6008
|
+
const bootUpdateMarker = updateMarkerIdentity(this.dataDir);
|
|
5254
6009
|
const savedUpdateProgress = readUpdateProgress(this.dataDir);
|
|
5255
6010
|
const pendingUpdateProgress = savedUpdateProgress
|
|
5256
6011
|
&& savedUpdateProgress.progress.stage !== "failed"
|
|
@@ -5275,7 +6030,11 @@ export class FleetManager {
|
|
|
5275
6030
|
// Pre-flight (advisory, fire-and-forget): warm the per-backend auth cache
|
|
5276
6031
|
// before instances spawn, so a CLI that comes up on its sign-in screen gets
|
|
5277
6032
|
// an instant 🔑 verdict instead of decaying into crash/MCP-died noise.
|
|
5278
|
-
this.lifecycle.primeAuthVerification(Object.values(fleet.instances).map(config =>
|
|
6033
|
+
this.lifecycle.primeAuthVerification(Object.values(fleet.instances).map(config => {
|
|
6034
|
+
const backend = config.backend ?? fleet.defaults?.backend ?? "claude-code";
|
|
6035
|
+
// #1699 follow-up: each instance's own login — a profile is probed with its own environment.
|
|
6036
|
+
return { backend, profile: credentialHomeSpec(backend) ? instanceCredentialProfile(config, fleet.defaults, backend) : null };
|
|
6037
|
+
}));
|
|
5279
6038
|
// Start tmux control mode client for idle detection
|
|
5280
6039
|
if (!this.controlClient) {
|
|
5281
6040
|
this.controlClient = new TmuxControlClient(getTmuxSession(), 2000, this.logger);
|
|
@@ -5680,19 +6439,37 @@ export class FleetManager {
|
|
|
5680
6439
|
const _require = createRequire(import.meta.url);
|
|
5681
6440
|
const agendVersion = _require("../package.json").version ?? "unknown";
|
|
5682
6441
|
await progressStart;
|
|
5683
|
-
|
|
6442
|
+
// #1660/#1735: the nvm route's retire outcome, read NOW (not from the boot snapshot). Either order happens:
|
|
6443
|
+
// a detached fleet's `agend restart` returns before this fleet boots, so the outcome may already be here —
|
|
6444
|
+
// render it in the completion itself; a service restart waits for READY (after this), so it may still be owed
|
|
6445
|
+
// — post without it and arm the watch, which appends it to the same receipt once it lands. READY never waits
|
|
6446
|
+
// for cleanup (that would deadlock with an updater that retires after its restart returns).
|
|
6447
|
+
const cleanupOutcome = this.updateCleanupAtCompletion(pendingUpdateProgress, pendingProgressOperation);
|
|
6448
|
+
// #1716: background work this restart/update ended, scoped to the
|
|
6449
|
+
// operation so an older stop never reports. No pending operation means
|
|
6450
|
+
// a plain boot — nothing was stopped by an operation, report nothing.
|
|
6451
|
+
const backgroundWorkEndedNames = this.collectBackgroundWorkEndedNames(configuredNames, pendingUpdateProgress?.startedAt ?? null);
|
|
6452
|
+
const completionSummary = {
|
|
5684
6453
|
running: started,
|
|
5685
6454
|
total,
|
|
5686
6455
|
version: agendVersion,
|
|
5687
6456
|
pausedNames,
|
|
5688
6457
|
failedNames,
|
|
6458
|
+
backgroundWorkEndedNames,
|
|
5689
6459
|
tipText: pendingProgressOperation === "update" && this.tipsEnabled()
|
|
5690
6460
|
? (() => {
|
|
5691
6461
|
const tip = this.pickAvailableTip();
|
|
5692
6462
|
return tip ? this.formatTip(tip) : undefined;
|
|
5693
6463
|
})()
|
|
5694
6464
|
: undefined,
|
|
5695
|
-
|
|
6465
|
+
...(cleanupOutcome && "render" in cleanupOutcome
|
|
6466
|
+
? { warningText: cleanupOutcome.render.warning, warningCommand: cleanupOutcome.render.cleanupCommand }
|
|
6467
|
+
: {}),
|
|
6468
|
+
};
|
|
6469
|
+
const progressCompleted = await startupProgress.finish(completionSummary);
|
|
6470
|
+
// Armed only after the completion posted: an append racing ahead of it would be overwritten by it.
|
|
6471
|
+
if (cleanupOutcome && "watch" in cleanupOutcome)
|
|
6472
|
+
this.armCleanupWatch(cleanupOutcome.watch, completionSummary);
|
|
5696
6473
|
if (!progressCompleted && fleet.channel?.group_id) {
|
|
5697
6474
|
let text;
|
|
5698
6475
|
if (pendingProgressOperation === "full-restart") {
|
|
@@ -5702,6 +6479,7 @@ export class FleetManager {
|
|
|
5702
6479
|
version: agendVersion,
|
|
5703
6480
|
pausedNames,
|
|
5704
6481
|
failedNames,
|
|
6482
|
+
backgroundWorkEndedNames,
|
|
5705
6483
|
}, pendingUpdateProgress.startedAt);
|
|
5706
6484
|
}
|
|
5707
6485
|
else if (failedNames.length === 0 && pausedNames.length === 0) {
|
|
@@ -5714,6 +6492,18 @@ export class FleetManager {
|
|
|
5714
6492
|
text = t("fleet.ready_with_failed", started, total, agendVersion, failedNames.join(", "))
|
|
5715
6493
|
+ (pausedNames.length > 0 ? `\n⏸ Paused: ${pausedNames.join(", ")}` : "");
|
|
5716
6494
|
}
|
|
6495
|
+
// #1716: the fallback carries the same background-work names as the
|
|
6496
|
+
// completion (inline: this block is executed verbatim by the #1737
|
|
6497
|
+
// extracted-completion harness, which resolves `this`-methods plus a
|
|
6498
|
+
// fixed context — no new bare module references here).
|
|
6499
|
+
if (backgroundWorkEndedNames.length) {
|
|
6500
|
+
text += `\n⚠️ Background work was running and was ended: ${backgroundWorkEndedNames.join(", ")}`;
|
|
6501
|
+
}
|
|
6502
|
+
// #1735: an outcome that already landed goes in this message too; one still owed is the watch's.
|
|
6503
|
+
if (cleanupOutcome && "render" in cleanupOutcome) {
|
|
6504
|
+
text += `\n\n⚠️ ${cleanupOutcome.render.warning}`
|
|
6505
|
+
+ (cleanupOutcome.render.cleanupCommand ? `\n\`$ ${cleanupOutcome.render.cleanupCommand}\`` : "");
|
|
6506
|
+
}
|
|
5717
6507
|
await this.sendFleetStartCompletionFallback(String(fleet.channel.group_id), text, generalThreadId != null ? String(generalThreadId) : undefined);
|
|
5718
6508
|
}
|
|
5719
6509
|
// After "fleet ready", so the notice is not the first thing people see of a restart.
|
|
@@ -5722,6 +6512,7 @@ export class FleetManager {
|
|
|
5722
6512
|
// Health HTTP endpoint
|
|
5723
6513
|
this.startHealthServer(fleet.health_port ?? 19280);
|
|
5724
6514
|
// #1266: buttons that ended while AgEnD was down (expired, or chosen before a restart) are shown as ended now.
|
|
6515
|
+
// #1823: and polls: a deadline that passed meanwhile closes now (results once); an open one's countdown is redrawn.
|
|
5725
6516
|
void this.replyButtons()?.sweep().catch(err => this.logger.warn({ err }, "Reply-button sweep failed"));
|
|
5726
6517
|
// Daily update check — first check after 1 hour, then every 24 hours
|
|
5727
6518
|
this.updateCheckTimer = setTimeout(() => {
|
|
@@ -5735,7 +6526,7 @@ export class FleetManager {
|
|
|
5735
6526
|
// A SIGHUP may arrive after the PID/general is available but before the
|
|
5736
6527
|
// rest of startup finishes. Replay one coalesced reload only after all
|
|
5737
6528
|
// startup-owned lifecycle work and signal handlers are in place.
|
|
5738
|
-
this.finishStartup();
|
|
6529
|
+
this.finishStartup(bootUpdateMarker);
|
|
5739
6530
|
// Tell systemd we are ready only now. This used to fire right after the
|
|
5740
6531
|
// generals started — before adapters, classic instances, topic creation and the
|
|
5741
6532
|
// health server — so `systemctl start` returned success while the fleet was
|
|
@@ -6463,6 +7254,14 @@ export class FleetManager {
|
|
|
6463
7254
|
});
|
|
6464
7255
|
}
|
|
6465
7256
|
}
|
|
7257
|
+
else if (msg.type === "runtime_switch_result") {
|
|
7258
|
+
// #1690: what our own runtime /model came to (the daemon answered claude's "Switch model?" or saw the result).
|
|
7259
|
+
const waiter = typeof msg.id === "string" ? this.runtimeSwitchWaiters.get(msg.id) : undefined;
|
|
7260
|
+
if (waiter) {
|
|
7261
|
+
this.runtimeSwitchWaiters.delete(msg.id);
|
|
7262
|
+
waiter(msg);
|
|
7263
|
+
}
|
|
7264
|
+
}
|
|
6466
7265
|
else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
|
|
6467
7266
|
this.cacheInstanceExecutionState(name, msg);
|
|
6468
7267
|
if (msg.type === "instance_state_response") {
|
|
@@ -6975,6 +7774,24 @@ export class FleetManager {
|
|
|
6975
7774
|
});
|
|
6976
7775
|
}
|
|
6977
7776
|
async handleInboundMessage(msg) {
|
|
7777
|
+
msg = { ...msg, humanOriginalBody: msg.humanOriginalBody ?? msg.text, humanOrigin: msg.isBotMessage ? undefined : msg.humanOrigin && { ...msg.humanOrigin } };
|
|
7778
|
+
if (msg.isBotMessage)
|
|
7779
|
+
return withSpawnHoldReply(undefined, () => this.handleInboundMessageCore(msg));
|
|
7780
|
+
return withSpawnHoldReply(text => this.inboundNoticeAdapter(msg)?.sendText(msg.chatId, text, { threadId: msg.threadId }) ?? Promise.resolve(), () => this.handleInboundMessageCore(msg));
|
|
7781
|
+
}
|
|
7782
|
+
/**
|
|
7783
|
+
* #1752: the bot that speaks back about an inbound message — in a fleet topic the owning instance's adapter, never
|
|
7784
|
+
* the sibling that happened to win the dedup race; elsewhere (ClassicBot rooms, no routed instance) the receiver.
|
|
7785
|
+
*/
|
|
7786
|
+
inboundNoticeAdapter(msg) {
|
|
7787
|
+
const id = this.governingAccess(msg, this.inboundRouteThreadId(msg)).adapterId ?? "";
|
|
7788
|
+
return this.worlds.get(id)?.adapter ?? this.adapters.get(id) ?? this.adapter;
|
|
7789
|
+
}
|
|
7790
|
+
async handleInboundMessageCore(msg) {
|
|
7791
|
+
// #1682: who wrote, for the web chat's mention pills (a person, never one of the fleet's bots: those are named by
|
|
7792
|
+
// the instance they belong to).
|
|
7793
|
+
if (!msg.isBotMessage && msg.userId && msg.username)
|
|
7794
|
+
this.seenUsers.record(msg.userId, msg.username);
|
|
6978
7795
|
// Platform author identity is the authority. This runs before routing,
|
|
6979
7796
|
// collab/commands/access/dedup and does not depend on any echo setting/ACK.
|
|
6980
7797
|
const fleetBotIds = new Set();
|
|
@@ -7002,6 +7819,15 @@ export class FleetManager {
|
|
|
7002
7819
|
return;
|
|
7003
7820
|
}
|
|
7004
7821
|
const threadId = this.inboundRouteThreadId(msg);
|
|
7822
|
+
// #1752: a post by one of this fleet's own bots is never input to a fleet topic's instance. They speak there only
|
|
7823
|
+
// for an instance (its replies, notices, warnings) — taking that as a user turn makes one bot's notice another
|
|
7824
|
+
// agent's "user" message, and a loop. Fleet /collab and open mode admit OTHER bots and webhooks; they never meant
|
|
7825
|
+
// our own. Exempt: ClassicBot rooms, where bots this fleet runs deliberately talk to each other by @mention
|
|
7826
|
+
// (same-channel multi-bot collab), and no-thread Telegram (classic candidates; Telegram bots never see each other).
|
|
7827
|
+
if (msg.userId && fleetBotIds.has(msg.userId) && threadId && !this.classicChannels?.hasChannel(threadId)) {
|
|
7828
|
+
this.logger.debug({ adapterId: msg.adapterId, threadId, messageId: msg.messageId, author: msg.userId }, "Inbound dropped: authored by one of this fleet's own bots in a fleet topic");
|
|
7829
|
+
return;
|
|
7830
|
+
}
|
|
7005
7831
|
const rootCommand = this.telegramRootCommandTarget(msg, threadId);
|
|
7006
7832
|
this.logger.debug({ source: msg.source, chatId: msg.chatId, threadId, userId: msg.userId, isBotMessage: msg.isBotMessage, textLen: (msg.text ?? "").length, text: (msg.text ?? "").slice(0, 80) }, "handleInboundMessage entry");
|
|
7007
7833
|
// Ownership and per-adapter filtering both run before the dedup claim below.
|
|
@@ -7130,6 +7956,23 @@ export class FleetManager {
|
|
|
7130
7956
|
}
|
|
7131
7957
|
// Handle /start command
|
|
7132
7958
|
if (text === "/start" || text.startsWith("/start ")) {
|
|
7959
|
+
// #1686: intercept /start web before the ClassicBot admission flow runs.
|
|
7960
|
+
// /start alone already opens the DM for future bot-to-user messages — no further action needed.
|
|
7961
|
+
const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
|
|
7962
|
+
// #1742: a bare /start from a fleet admin whose /web just failed to reach them privately is that /start web
|
|
7963
|
+
// (Telegram's START button sends it bare) — once, within the window; any other bare /start is ClassicBot's.
|
|
7964
|
+
const admin = isPrivateChat && this.isFleetAdmin(msg.userId, msg.adapterId ?? "");
|
|
7965
|
+
const webStart = isPrivateChat && (requestedBackend === "web"
|
|
7966
|
+
|| (text === "/start" && admin && this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId)));
|
|
7967
|
+
if (webStart) {
|
|
7968
|
+
if (requestedBackend === "web")
|
|
7969
|
+
this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId);
|
|
7970
|
+
// #1742: the /web menu right here; the code comes to this DM.
|
|
7971
|
+
if (admin)
|
|
7972
|
+
await this.startWebInDm(msg, msgAdapter ?? undefined);
|
|
7973
|
+
// Non-fleet-admins: /start alone is enough to open the DM channel; no further action.
|
|
7974
|
+
return;
|
|
7975
|
+
}
|
|
7133
7976
|
// All new starts, including explicit backend and later chooser callbacks,
|
|
7134
7977
|
// share validateClassicStart. Existing channels are recognized first.
|
|
7135
7978
|
const blocker = this.validateClassicStart(chatId, msg.userId, undefined, msg.adapterId);
|
|
@@ -7138,7 +7981,6 @@ export class FleetManager {
|
|
|
7138
7981
|
return;
|
|
7139
7982
|
}
|
|
7140
7983
|
const channelName = msg.username || chatId;
|
|
7141
|
-
const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
|
|
7142
7984
|
if (requestedBackend) {
|
|
7143
7985
|
// handleClassicStart binds the instance to this adapter authoritatively.
|
|
7144
7986
|
const reply = await this.handleClassicStart(chatId, channelName, msg.userId, undefined, msg.adapterId, requestedBackend);
|
|
@@ -7206,6 +8048,21 @@ export class FleetManager {
|
|
|
7206
8048
|
}
|
|
7207
8049
|
return;
|
|
7208
8050
|
}
|
|
8051
|
+
// #1806: /backend — channel admin, as /model. A ClassicBot agent is not switched by it (it says so).
|
|
8052
|
+
if (text === "/backend" || text.startsWith("/backend ") || text.startsWith("/backend@")) {
|
|
8053
|
+
if (!this.isModelAdmin(msg.userId, chatId, msg.adapterId)) {
|
|
8054
|
+
await msgAdapter?.sendText(chatId, t("permission.denied"));
|
|
8055
|
+
return;
|
|
8056
|
+
}
|
|
8057
|
+
const backendInstance = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
8058
|
+
if (!backendInstance) {
|
|
8059
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
8060
|
+
return;
|
|
8061
|
+
}
|
|
8062
|
+
if (msgAdapter)
|
|
8063
|
+
await this.runBackendCommand(backendInstance, text, msg.userId, chatId, msgAdapter, chatId, undefined, msg.adapterId);
|
|
8064
|
+
return;
|
|
8065
|
+
}
|
|
7209
8066
|
// Handle /compact command (admin only)
|
|
7210
8067
|
const classicCompact = parseCompactCommand(text);
|
|
7211
8068
|
if (classicCompact) {
|
|
@@ -7391,7 +8248,7 @@ export class FleetManager {
|
|
|
7391
8248
|
const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, generalInstance);
|
|
7392
8249
|
const generalReactions = this.pendingReactionsMeta(generalInstance);
|
|
7393
8250
|
try {
|
|
7394
|
-
await this.
|
|
8251
|
+
await this.deliverHumanToInstance(generalInstance, {
|
|
7395
8252
|
type: "fleet_inbound",
|
|
7396
8253
|
content: text,
|
|
7397
8254
|
targetSession: generalInstance,
|
|
@@ -7412,7 +8269,7 @@ export class FleetManager {
|
|
|
7412
8269
|
...generalReactions.meta,
|
|
7413
8270
|
...extraMeta,
|
|
7414
8271
|
},
|
|
7415
|
-
});
|
|
8272
|
+
}, msg.humanOrigin, msg.humanOriginalBody ?? msg.text);
|
|
7416
8273
|
generalReactions.consume();
|
|
7417
8274
|
this.afterUserInboundDelivered(generalInstance, msg);
|
|
7418
8275
|
this.logger.info(`${msg.username} → ${generalInstance}: ${(text ?? "").slice(0, 100)}`);
|
|
@@ -7492,7 +8349,7 @@ export class FleetManager {
|
|
|
7492
8349
|
const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, instanceName);
|
|
7493
8350
|
const reactions = this.pendingReactionsMeta(instanceName);
|
|
7494
8351
|
try {
|
|
7495
|
-
await this.
|
|
8352
|
+
await this.deliverHumanToInstance(instanceName, {
|
|
7496
8353
|
type: "fleet_inbound",
|
|
7497
8354
|
content: text,
|
|
7498
8355
|
targetSession: instanceName, // Channel messages → instance's own session
|
|
@@ -7511,7 +8368,7 @@ export class FleetManager {
|
|
|
7511
8368
|
...reactions.meta,
|
|
7512
8369
|
...extraMeta,
|
|
7513
8370
|
},
|
|
7514
|
-
});
|
|
8371
|
+
}, msg.humanOrigin, msg.humanOriginalBody ?? msg.text);
|
|
7515
8372
|
// Only after the message actually went out. A failed delivery keeps the
|
|
7516
8373
|
// reactions queued for the retry / the next message.
|
|
7517
8374
|
reactions.consume();
|
|
@@ -7554,9 +8411,14 @@ export class FleetManager {
|
|
|
7554
8411
|
if (Date.now() - lastWarn < 30 * 60_000)
|
|
7555
8412
|
return;
|
|
7556
8413
|
this.rateLimitWarnedAt.set(instanceName, Date.now());
|
|
7557
|
-
|
|
7558
|
-
|
|
7559
|
-
|
|
8414
|
+
// #1752: the instance's own bot, in the instance's own topic. `msg.adapterId` is whichever sibling bot won the
|
|
8415
|
+
// inbound dedup race; a warning from that bot is a stranger's post in this topic (and, to the owning adapter, a
|
|
8416
|
+
// bot message it would take as input).
|
|
8417
|
+
const warnAdapter = this.getAdapterForInstance(instanceName);
|
|
8418
|
+
const dest = this.cancelButtonDestination(instanceName);
|
|
8419
|
+
const { chatId, threadId } = dest?.threadId ? dest : { chatId: msg.chatId, threadId: msg.threadId ?? undefined };
|
|
8420
|
+
if (warnAdapter && chatId) {
|
|
8421
|
+
warnAdapter.sendText(chatId, warning, { threadId }).catch(() => { });
|
|
7560
8422
|
}
|
|
7561
8423
|
}
|
|
7562
8424
|
/** Handle outbound tool calls from a daemon instance */
|
|
@@ -7710,10 +8572,12 @@ export class FleetManager {
|
|
|
7710
8572
|
respond(null, "No adapter available");
|
|
7711
8573
|
return;
|
|
7712
8574
|
}
|
|
8575
|
+
// Only channel-addressing schemas receive ClassicBot routing metadata.
|
|
8576
|
+
// Non-channel calls (notably strict delivery_status) retain their arguments.
|
|
7713
8577
|
// For classic instances: force chat_id to channelId and clear thread_id
|
|
7714
8578
|
// (daemon may have set chat_id to guild_id which is wrong for DC; TG may have set thread_id which causes 'thread not found')
|
|
7715
8579
|
const classicChannelId = this.classicChannels?.getChannelIdByInstance(senderInstanceName ?? instanceName);
|
|
7716
|
-
if (classicChannelId) {
|
|
8580
|
+
if (classicChannelId && usesChannelAddress(tool)) {
|
|
7717
8581
|
args.chat_id = classicChannelId;
|
|
7718
8582
|
// #1085: a Telegram forum ClassicBot replies in the topic it was asked
|
|
7719
8583
|
// in. Any other thread_id (threadless Telegram, where it echoes the chat
|
|
@@ -7761,6 +8625,17 @@ export class FleetManager {
|
|
|
7761
8625
|
respond(null, `reply: ${parsedButtons.error}`);
|
|
7762
8626
|
return;
|
|
7763
8627
|
}
|
|
8628
|
+
// #1823: a poll is checked with its buttons. Where it cannot be shown it is refused, never sent as text: text
|
|
8629
|
+
// options would never come back as results.
|
|
8630
|
+
const parsedPoll = parsePoll(args.poll, parsedButtons && "buttons" in parsedButtons ? parsedButtons.buttons : null, args.text);
|
|
8631
|
+
if (parsedPoll && "error" in parsedPoll) {
|
|
8632
|
+
respond(null, `reply: ${parsedPoll.error}`);
|
|
8633
|
+
return;
|
|
8634
|
+
}
|
|
8635
|
+
if (parsedPoll && (!outAdapter.supportsPolls || !this.replyButtons())) {
|
|
8636
|
+
respond(null, `reply: a poll cannot be shown here${outAdapter.supportsPolls ? " (reply buttons are unavailable until AgEnD restarts)" : " — polls work in Discord and Telegram chats and the web chat"}; send the question without poll`);
|
|
8637
|
+
return;
|
|
8638
|
+
}
|
|
7764
8639
|
// Stickers (#1226) are checked before anything is sent: a refused one is the reply's error, not a gap.
|
|
7765
8640
|
const stickerProblem = await this.replyStickerProblem(outAdapter, args, threadId, contextAdapterId ?? this.getInstanceAdapterId(senderInstanceName ?? instanceName));
|
|
7766
8641
|
if (stickerProblem) {
|
|
@@ -7780,7 +8655,7 @@ export class FleetManager {
|
|
|
7780
8655
|
const buttons = outAdapter.supportsReplyButtons ? this.replyButtons() : null;
|
|
7781
8656
|
if (buttons) {
|
|
7782
8657
|
const outId = outAdapter === WEB_ONLY_REPLY_SINK ? "web" : outAdapter.id ?? contextAdapterId ?? "";
|
|
7783
|
-
replySet = buttons.prepare({ instance: instanceName, adapterId: outId, chatId: String(args.chat_id ?? ""), threadId }, parsedButtons.buttons);
|
|
8658
|
+
replySet = buttons.prepare({ instance: instanceName, adapterId: outId, chatId: String(args.chat_id ?? ""), threadId }, parsedButtons.buttons, parsedPoll && "poll" in parsedPoll ? parsedPoll.poll : null);
|
|
7784
8659
|
}
|
|
7785
8660
|
else {
|
|
7786
8661
|
args.text = `${String(args.text)}\n\n${replyButtonsFallbackText(parsedButtons.buttons)}`;
|
|
@@ -7800,6 +8675,9 @@ export class FleetManager {
|
|
|
7800
8675
|
this.replyButtonsCtl?.discard(replySet.id);
|
|
7801
8676
|
}
|
|
7802
8677
|
}
|
|
8678
|
+
// #1823: the agent learns its poll's id (the results name it).
|
|
8679
|
+
if (replySet?.poll && !error && result && typeof result === "object")
|
|
8680
|
+
result = { ...result, poll_id: replySet.id };
|
|
7803
8681
|
ticket.complete(result, error);
|
|
7804
8682
|
// Return the platform outcome first. Bookkeeping below must never turn
|
|
7805
8683
|
// a confirmed Discord/Telegram POST into a tool error if a secondary
|
|
@@ -7812,7 +8690,7 @@ export class FleetManager {
|
|
|
7812
8690
|
// an agent cannot invent it to suppress the normal completion marker.
|
|
7813
8691
|
if (!error && result != null && msg.statusOnly !== true) {
|
|
7814
8692
|
try {
|
|
7815
|
-
this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
|
|
8693
|
+
void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
|
|
7816
8694
|
}
|
|
7817
8695
|
catch (err) {
|
|
7818
8696
|
this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
|
|
@@ -7827,7 +8705,7 @@ export class FleetManager {
|
|
|
7827
8705
|
});
|
|
7828
8706
|
}
|
|
7829
8707
|
};
|
|
7830
|
-
if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord, replySet ? { replyButtons: replySet.callbacks } : {})) {
|
|
8708
|
+
if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord, replySet ? { replyButtons: replySet.callbacks, ...(replySet.poll ? { replyPoll: replySet.poll } : {}) } : {})) {
|
|
7831
8709
|
return;
|
|
7832
8710
|
}
|
|
7833
8711
|
if (replySet)
|
|
@@ -7865,6 +8743,7 @@ export class FleetManager {
|
|
|
7865
8743
|
webOnlyPromptPlace() {
|
|
7866
8744
|
return this.worlds.size === 0 && this.isWebOnlyFleet() ? { adapter: WEB_ONLY_PROMPT_SINK, adapterId: "web", chatId: "web" } : null;
|
|
7867
8745
|
}
|
|
8746
|
+
/** Resolves when the reply is in the web chat (#1770: after its files are copied); the caller need not wait. */
|
|
7868
8747
|
afterReplyRouted(instanceName, args, senderSessionName, buttons = null) {
|
|
7869
8748
|
// A reply is NOT proof the turn is over (#410) — but it is not proof of
|
|
7870
8749
|
// more work either. Split the difference: an instance that is clearly
|
|
@@ -7882,27 +8761,74 @@ export class FleetManager {
|
|
|
7882
8761
|
this.reactDone(instanceName);
|
|
7883
8762
|
const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
|
|
7884
8763
|
this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
|
|
7885
|
-
// Files the agent attached are shown in the web chat too: registered for fetching by id (the path
|
|
7886
|
-
// already passed the reply tool's sendability check, and is resolved once more here), never by path.
|
|
7887
|
-
const replyFiles = Array.isArray(args.files) ? args.files.filter((f) => typeof f === "string") : [];
|
|
7888
|
-
const attachments = replyFiles
|
|
7889
|
-
.map(path => this.webFiles.registerServed({ path, instance: instanceName }))
|
|
7890
|
-
.filter((f) => f !== null)
|
|
7891
|
-
.map(publicAttachment);
|
|
7892
|
-
// The one place a delivered agent reply reaches the web chat: the server marks it `agent` (#1306) — the only
|
|
7893
|
-
// role that may get HTML preview cards. Never inferred from the sender name or the text.
|
|
7894
|
-
this.emitSseEvent("message", {
|
|
7895
|
-
instance: instanceName, sender: senderSessionName ?? instanceName, role: "agent",
|
|
7896
|
-
text: (args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX),
|
|
7897
|
-
ts: new Date().toISOString(),
|
|
7898
|
-
...(attachments.length ? { attachments } : {}),
|
|
7899
|
-
...(buttons ? { buttons } : {}), // #1266
|
|
7900
|
-
});
|
|
7901
8764
|
// Log bot reply to classic instance chat-log
|
|
7902
8765
|
const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
|
|
7903
8766
|
if (isClassic) {
|
|
7904
8767
|
ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
|
|
7905
8768
|
}
|
|
8769
|
+
// Files the agent attached are shown in the web chat too, fetched by id, never by path. #1770: each is copied
|
|
8770
|
+
// first (web-reply-files.ts), and the copy is what the chat serves and keeps — so the message is shown once the
|
|
8771
|
+
// copies are done, and an instance's replies wait for the one before them: they reach the chat in order.
|
|
8772
|
+
const replyFiles = Array.isArray(args.files) ? args.files.filter((f) => typeof f === "string") : [];
|
|
8773
|
+
const ts = new Date().toISOString();
|
|
8774
|
+
const show = (attachments) => {
|
|
8775
|
+
// The one place a delivered agent reply reaches the web chat: the server marks it `agent` (#1306) — the only
|
|
8776
|
+
// role that may get HTML preview cards. Never inferred from the sender name or the text.
|
|
8777
|
+
this.emitSseEvent("message", {
|
|
8778
|
+
instance: instanceName, sender: senderSessionName ?? instanceName, role: "agent",
|
|
8779
|
+
text: (args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX),
|
|
8780
|
+
ts,
|
|
8781
|
+
...(attachments.length ? { attachments } : {}),
|
|
8782
|
+
...(buttons ? { buttons } : {}), // #1266
|
|
8783
|
+
});
|
|
8784
|
+
};
|
|
8785
|
+
const before = this.webReplyOrder.get(instanceName);
|
|
8786
|
+
if (!replyFiles.length && !before) {
|
|
8787
|
+
show([]);
|
|
8788
|
+
return Promise.resolve();
|
|
8789
|
+
}
|
|
8790
|
+
const done = (before ?? Promise.resolve())
|
|
8791
|
+
.then(() => this.webReplyAttachments(instanceName, replyFiles))
|
|
8792
|
+
.catch((err) => { this.logger.warn({ err, instanceName }, "Reply files could not be prepared for the web chat"); return []; })
|
|
8793
|
+
.then((attachments) => { try {
|
|
8794
|
+
show(attachments);
|
|
8795
|
+
}
|
|
8796
|
+
catch (err) {
|
|
8797
|
+
this.logger.warn({ err, instanceName }, "Reply could not be shown in the web chat");
|
|
8798
|
+
} })
|
|
8799
|
+
.finally(() => { if (this.webReplyOrder.get(instanceName) === done)
|
|
8800
|
+
this.webReplyOrder.delete(instanceName); });
|
|
8801
|
+
this.webReplyOrder.set(instanceName, done);
|
|
8802
|
+
return done;
|
|
8803
|
+
}
|
|
8804
|
+
/**
|
|
8805
|
+
* #1770: a reply's files as the web chat shows them. Each is copied into the instance's web-files/ and served from
|
|
8806
|
+
* the copy. One the copy refuses (a symlink, not a regular file, over 50 MB, a state file, missing) is not shown;
|
|
8807
|
+
* one that could not be copied (I/O) is served from the agent's own path, as before — until the fleet restarts.
|
|
8808
|
+
*/
|
|
8809
|
+
async webReplyAttachments(instanceName, paths) {
|
|
8810
|
+
const home = getAgendHome();
|
|
8811
|
+
const dir = webFilesDir(home, instanceName);
|
|
8812
|
+
const out = [];
|
|
8813
|
+
for (const path of paths) {
|
|
8814
|
+
const id = randomBytes(16).toString("hex");
|
|
8815
|
+
const got = dir ? await snapshotReplyFile(path, dir, id, { stateDir: home, maxDirBytes: this.webReplyMaxBytes }) : null;
|
|
8816
|
+
if (got && "evicted" in got && got.evicted.length)
|
|
8817
|
+
this.webFiles.dropServed(got.evicted);
|
|
8818
|
+
let file = null;
|
|
8819
|
+
if (got?.ok)
|
|
8820
|
+
file = this.webFiles.registerServed({ id, path: got.path, name: basename(path), instance: instanceName, snapshot: true });
|
|
8821
|
+
else if (got?.refused)
|
|
8822
|
+
this.logger.info({ instanceName, file: basename(path), reason: got.reason }, "Reply file not shown in the web chat");
|
|
8823
|
+
else {
|
|
8824
|
+
if (got)
|
|
8825
|
+
this.logger.warn({ instanceName, file: basename(path), reason: got.reason }, "Reply file could not be copied for the web chat; served from its own path until a restart");
|
|
8826
|
+
file = this.webFiles.registerServed({ path, instance: instanceName });
|
|
8827
|
+
}
|
|
8828
|
+
if (file)
|
|
8829
|
+
out.push(publicAttachment(file));
|
|
8830
|
+
}
|
|
8831
|
+
return out;
|
|
7906
8832
|
}
|
|
7907
8833
|
// ===================== Scheduler =====================
|
|
7908
8834
|
async handleScheduleTrigger(schedule, stableRunId = randomUUID(), retry) {
|
|
@@ -9301,7 +10227,9 @@ export class FleetManager {
|
|
|
9301
10227
|
else
|
|
9302
10228
|
await this.lifecycle.remove(name, authorization);
|
|
9303
10229
|
// A later same-name registration owns its files and caches.
|
|
9304
|
-
|
|
10230
|
+
// Own keys only: an instance named "constructor" is removed as any other (its inherited namesake is not a
|
|
10231
|
+
// re-registration) — #1677 review.
|
|
10232
|
+
if (Object.hasOwn(this.fleetConfig?.instances ?? {}, name) || this.daemons.has(name))
|
|
9305
10233
|
return;
|
|
9306
10234
|
execution?.assert();
|
|
9307
10235
|
// Clean up per-instance tracking maps so they don't grow unbounded
|
|
@@ -9313,6 +10241,13 @@ export class FleetManager {
|
|
|
9313
10241
|
// must not be shown the old one's conversation, and deleted names must not pile up.
|
|
9314
10242
|
this.webChatHistory.forget(name);
|
|
9315
10243
|
this.webFiles.forget(name);
|
|
10244
|
+
// #1664: and it is nobody's favorite any more (every device sees that at its next status frame).
|
|
10245
|
+
try {
|
|
10246
|
+
this.webPrefs.forgetInstance(name);
|
|
10247
|
+
}
|
|
10248
|
+
catch (err) {
|
|
10249
|
+
this.logger.warn({ err: err.message, name }, "Dropping a removed instance from web favorites failed");
|
|
10250
|
+
}
|
|
9316
10251
|
// Clean up statusline watcher + instance directory
|
|
9317
10252
|
this.statuslineWatcher.unwatch(name);
|
|
9318
10253
|
// instanceDirToClean and effectiveBackend are captured before lifecycle.remove
|
|
@@ -9482,26 +10417,210 @@ export class FleetManager {
|
|
|
9482
10417
|
this.guildEmojiFetches.set(key, pending);
|
|
9483
10418
|
}
|
|
9484
10419
|
try {
|
|
9485
|
-
return { ...g, ...(await pending) };
|
|
9486
|
-
}
|
|
9487
|
-
catch (e) {
|
|
9488
|
-
return { ...g, error: `Discord refused the emoji list: ${e.message}` };
|
|
10420
|
+
return { ...g, ...(await pending) };
|
|
10421
|
+
}
|
|
10422
|
+
catch (e) {
|
|
10423
|
+
return { ...g, error: `Discord refused the emoji list: ${e.message}` };
|
|
10424
|
+
}
|
|
10425
|
+
}));
|
|
10426
|
+
const listed = guilds.filter((g) => !!g.emojis);
|
|
10427
|
+
if (!listed.length)
|
|
10428
|
+
return { ok: false, error: guilds[0]?.error ?? "no server to list emojis from" };
|
|
10429
|
+
return {
|
|
10430
|
+
ok: true,
|
|
10431
|
+
fetched_at: Math.min(...listed.map(g => g.fetched_at)),
|
|
10432
|
+
emojis: listed.flatMap(g => g.emojis),
|
|
10433
|
+
guilds,
|
|
10434
|
+
};
|
|
10435
|
+
}
|
|
10436
|
+
static GUILD_EMOJI_TTL_MS = 10 * 60_000;
|
|
10437
|
+
/** Keyed `<connection>\0<server id>`. */
|
|
10438
|
+
guildEmojiCache = new Map();
|
|
10439
|
+
guildEmojiFetches = new Map();
|
|
10440
|
+
// ── #1630: what stands for an instance in the web app, and Discord server emoji in web chat ──
|
|
10441
|
+
webImageCaches = null;
|
|
10442
|
+
webImageCache(kind) {
|
|
10443
|
+
const onError = (key, err) => this.logger.debug({ err: err?.message, key }, "web image fetch failed (kept what was cached)");
|
|
10444
|
+
this.webImageCaches ??= {
|
|
10445
|
+
avatars: new WebImageCache({ dir: join(this.dataDir, "cache", "avatars"), onError }),
|
|
10446
|
+
emoji: new WebImageCache({ dir: join(this.dataDir, "cache", "emoji"), onError }),
|
|
10447
|
+
};
|
|
10448
|
+
return this.webImageCaches[kind];
|
|
10449
|
+
}
|
|
10450
|
+
/**
|
|
10451
|
+
* instance → the connection that is its own bot: configured now (a connection removed from fleet.yaml is nobody's,
|
|
10452
|
+
* whatever an instance still names — #1635 review), not the primary, and answering for it alone.
|
|
10453
|
+
*/
|
|
10454
|
+
ownBots() {
|
|
10455
|
+
const instances = this.fleetConfig?.instances ?? {};
|
|
10456
|
+
const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
10457
|
+
const configured = new Set(channels.map(ch => ch.id ?? ch.type).filter((id) => typeof id === "string"));
|
|
10458
|
+
const assigned = (name) => { const c = this.getInstanceAdapterId(name); return c && configured.has(c) ? c : undefined; };
|
|
10459
|
+
const assignments = Object.keys(instances).map(n => [n, assigned(n)]);
|
|
10460
|
+
for (const room of this.classicChannels?.getAll() ?? []) {
|
|
10461
|
+
if (!Object.hasOwn(instances, room.instanceName))
|
|
10462
|
+
assignments.push([room.instanceName, assigned(room.instanceName)]);
|
|
10463
|
+
}
|
|
10464
|
+
return ownBotConnections(this.getPrimaryAdapterId(), assignments);
|
|
10465
|
+
}
|
|
10466
|
+
/**
|
|
10467
|
+
* The bot a connection is now: its running adapter and that bot's own user id. Everything cached for an own bot is
|
|
10468
|
+
* keyed by both, so a bot put in its place (a token replaced under the same connection id) never shows the old one's
|
|
10469
|
+
* face or colour, and the old one's late answers land under the old key (#1635 review). null while it has no id.
|
|
10470
|
+
*/
|
|
10471
|
+
botOwner(connection) {
|
|
10472
|
+
const adapter = this.adapters.get(connection), id = adapter?.getBotUserId?.();
|
|
10473
|
+
return adapter && id ? { adapter, id } : null;
|
|
10474
|
+
}
|
|
10475
|
+
/** Still the same bot behind `connection` (after an await): the same adapter object and the same bot id. */
|
|
10476
|
+
botStillOwns(connection, owner) {
|
|
10477
|
+
return this.adapters.get(connection) === owner.adapter && owner.adapter.getBotUserId?.() === owner.id;
|
|
10478
|
+
}
|
|
10479
|
+
/** keyed `<connection>\0<bot id>\0<channel>`: the bot's colour there, refreshed at most daily (monotonic), one read at a time. */
|
|
10480
|
+
webAccents = new Map();
|
|
10481
|
+
webAccentReads = new Set();
|
|
10482
|
+
webAccent(connection, owner, channelId) {
|
|
10483
|
+
const adapter = owner.adapter;
|
|
10484
|
+
if (!adapter.botAccentColor)
|
|
10485
|
+
return null;
|
|
10486
|
+
const key = `${connection}\0${owner.id}\0${channelId ?? ""}`, now = performance.now(), cached = this.webAccents.get(key);
|
|
10487
|
+
if ((!cached || now >= cached.until) && !this.webAccentReads.has(key)) {
|
|
10488
|
+
this.webAccentReads.add(key);
|
|
10489
|
+
adapter.botAccentColor(channelId)
|
|
10490
|
+
// Keyed by the bot's id: a replaced bot's late answer lands under its own key, which the new bot never reads.
|
|
10491
|
+
.then(value => { this.webAccents.set(key, { value: accentColor(value), until: performance.now() + WEB_IMAGE_MAX_AGE_MS }); }, () => { this.webAccents.set(key, { value: cached?.value ?? null, until: performance.now() + WEB_IMAGE_RETRY_MS }); })
|
|
10492
|
+
.finally(() => this.webAccentReads.delete(key));
|
|
10493
|
+
}
|
|
10494
|
+
return cached?.value ?? null;
|
|
10495
|
+
}
|
|
10496
|
+
/** The cache key of an own bot's avatar, and its loader (which refuses an answer from a bot no longer there). */
|
|
10497
|
+
botAvatarSource(connection, owner) {
|
|
10498
|
+
return {
|
|
10499
|
+
key: `bot:${connection}:${owner.id}`,
|
|
10500
|
+
load: async () => {
|
|
10501
|
+
const got = await owner.adapter.fetchBotAvatar();
|
|
10502
|
+
if (!this.botStillOwns(connection, owner))
|
|
10503
|
+
throw new Error("the bot was replaced while its avatar was fetched");
|
|
10504
|
+
return got;
|
|
10505
|
+
},
|
|
10506
|
+
};
|
|
10507
|
+
}
|
|
10508
|
+
/** One instance's avatar and ring for the status frame. Synchronous: what is cached now; fetches run behind. */
|
|
10509
|
+
webIdentityImage(name, displayName, profileAvatar, own) {
|
|
10510
|
+
let profileVersion = null;
|
|
10511
|
+
if (profileAvatar && imageMimeForFile(profileAvatar)) {
|
|
10512
|
+
try {
|
|
10513
|
+
profileVersion = Math.floor(statSync(join(this.dataDir, "avatars", basename(profileAvatar))).mtimeMs).toString(36);
|
|
10514
|
+
}
|
|
10515
|
+
catch { /* removed */ }
|
|
10516
|
+
}
|
|
10517
|
+
const connection = own.get(name), owner = connection ? this.botOwner(connection) : null;
|
|
10518
|
+
const source = connection && owner?.adapter.fetchBotAvatar ? this.botAvatarSource(connection, owner) : null;
|
|
10519
|
+
const bot = source ? this.webImageCache("avatars").peek(source.key, source.load) : null;
|
|
10520
|
+
const cfg = this.ownInstanceConfig(name);
|
|
10521
|
+
const avatar = avatarFor({ name, displayName, profileVersion, bot: bot && connection ? { connection, version: bot.version } : null,
|
|
10522
|
+
personaEmoji: cfg?.status_emojis?.delivered });
|
|
10523
|
+
// The ring belongs to the bot's own face: only when that is what is shown.
|
|
10524
|
+
const accent = !profileVersion && bot && connection && owner
|
|
10525
|
+
? this.webAccent(connection, owner, cfg ? (cfg.topic_id == null ? undefined : String(cfg.topic_id)) : this.classicChannels?.getChannelIdByInstance(name))
|
|
10526
|
+
: null;
|
|
10527
|
+
return accent ? { avatar, accent } : { avatar };
|
|
10528
|
+
}
|
|
10529
|
+
/** GET /ui/img/bot|avatar/<key>: what is cached, never a fetch. Only an own-bot connection, or a known instance's upload. */
|
|
10530
|
+
webImage(kind, key) {
|
|
10531
|
+
if (kind === "bot") {
|
|
10532
|
+
if (![...this.ownBots().values()].includes(key))
|
|
10533
|
+
return null;
|
|
10534
|
+
const owner = this.botOwner(key);
|
|
10535
|
+
return owner ? this.webImageCache("avatars").read(this.botAvatarSource(key, owner).key) : null;
|
|
10536
|
+
}
|
|
10537
|
+
const known = Object.hasOwn(this.fleetConfig?.instances ?? {}, key) || this.classicChannels?.getChannelIdByInstance(key) !== undefined;
|
|
10538
|
+
if (!known)
|
|
10539
|
+
return null;
|
|
10540
|
+
const file = profileIdentities(this.dataDir).get(key)?.avatar_path;
|
|
10541
|
+
const mime = file ? imageMimeForFile(file) : null;
|
|
10542
|
+
if (!file || !mime)
|
|
10543
|
+
return null;
|
|
10544
|
+
try {
|
|
10545
|
+
const path = join(this.dataDir, "avatars", basename(file));
|
|
10546
|
+
return { bytes: readFileSync(path), mime, version: Math.floor(statSync(path).mtimeMs).toString(36) };
|
|
10547
|
+
}
|
|
10548
|
+
catch {
|
|
10549
|
+
return null;
|
|
10550
|
+
}
|
|
10551
|
+
}
|
|
10552
|
+
/**
|
|
10553
|
+
* #1682 GET /ui/mentions: names for the mention ids a page found in what it rendered — never a Discord request; in order:
|
|
10554
|
+
* the fleet's bots (an instance's own bot is that instance's label: fleet.yaml, and the View profiles read from
|
|
10555
|
+
* profiles.db, as the status frame does), the people seen on inbound messages, and the Discord adapters' caches
|
|
10556
|
+
* (members, users, roles, channels).
|
|
10557
|
+
*/
|
|
10558
|
+
webMentionNames(query) {
|
|
10559
|
+
const bots = (q) => {
|
|
10560
|
+
const u = Object.create(null);
|
|
10561
|
+
if (!q.u.length)
|
|
10562
|
+
return { u };
|
|
10563
|
+
let own = new Map();
|
|
10564
|
+
try {
|
|
10565
|
+
own = this.ownBots();
|
|
10566
|
+
}
|
|
10567
|
+
catch { /* none known */ }
|
|
10568
|
+
const profiles = profileIdentities(this.dataDir);
|
|
10569
|
+
const rooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
|
|
10570
|
+
for (const [instance, connection] of own) {
|
|
10571
|
+
const id = this.adapters.get(connection)?.getBotUserId?.();
|
|
10572
|
+
if (!id || !q.u.includes(id))
|
|
10573
|
+
continue;
|
|
10574
|
+
const cfg = Object.hasOwn(this.fleetConfig?.instances ?? {}, instance) ? this.fleetConfig.instances[instance] : undefined;
|
|
10575
|
+
u[id] = resolveInstanceIdentity({ cfg, classic: rooms.get(instance), profile: profiles.get(instance) }).display_name || instance;
|
|
9489
10576
|
}
|
|
9490
|
-
|
|
9491
|
-
|
|
9492
|
-
|
|
9493
|
-
|
|
9494
|
-
|
|
9495
|
-
|
|
9496
|
-
|
|
9497
|
-
|
|
9498
|
-
|
|
10577
|
+
return { u };
|
|
10578
|
+
};
|
|
10579
|
+
const seen = (q) => {
|
|
10580
|
+
const u = Object.create(null);
|
|
10581
|
+
for (const id of q.u) {
|
|
10582
|
+
const name = this.seenUsers.get(id);
|
|
10583
|
+
if (name)
|
|
10584
|
+
u[id] = name;
|
|
10585
|
+
}
|
|
10586
|
+
return { u };
|
|
9499
10587
|
};
|
|
10588
|
+
const caches = [...this.adapters.values()].filter(a => typeof a.mentionNames === "function").map(a => (q) => a.mentionNames(q));
|
|
10589
|
+
return resolveMentions(query, [bots, seen, ...caches]);
|
|
10590
|
+
}
|
|
10591
|
+
/** GET /ui/emojis: the custom emoji of every server the fleet's Discord bots are in (the Settings picker's cache). */
|
|
10592
|
+
async webEmojiList() {
|
|
10593
|
+
const adopted = [];
|
|
10594
|
+
for (const [id, world] of [...this.worlds]) {
|
|
10595
|
+
if (world.type !== "discord")
|
|
10596
|
+
continue;
|
|
10597
|
+
const listed = await this.listGuildEmojis(id);
|
|
10598
|
+
if (listed.ok)
|
|
10599
|
+
adopted.push({ id, world, emojis: listed.emojis });
|
|
10600
|
+
}
|
|
10601
|
+
// After the LAST await (#1635 review r2): only the lists whose world is still the one that was read — a connection
|
|
10602
|
+
// removed or replaced while a later list was awaited takes its servers along, whenever it went.
|
|
10603
|
+
const out = new Map();
|
|
10604
|
+
for (const a of adopted) {
|
|
10605
|
+
if (this.worlds.get(a.id) !== a.world)
|
|
10606
|
+
continue;
|
|
10607
|
+
for (const e of a.emojis)
|
|
10608
|
+
if (!out.has(e.id))
|
|
10609
|
+
out.set(e.id, { id: e.id, name: e.name, animated: e.animated });
|
|
10610
|
+
}
|
|
10611
|
+
return [...out.values()];
|
|
10612
|
+
}
|
|
10613
|
+
/** GET /ui/img/emoji/<id>: only an emoji of those servers; fetched once from Discord's CDN, then from the host cache. */
|
|
10614
|
+
async webEmoji(id) {
|
|
10615
|
+
if (!/^\d{15,25}$/.test(id))
|
|
10616
|
+
return null;
|
|
10617
|
+
const emoji = (await this.webEmojiList()).find(e => e.id === id);
|
|
10618
|
+
if (!emoji)
|
|
10619
|
+
return null;
|
|
10620
|
+
const image = await this.webImageCache("emoji").get(`emoji:${id}`, () => downloadStickerImage(`https://cdn.discordapp.com/emojis/${id}.${emoji.animated ? "gif" : "png"}?size=64`, { maxBytes: WEB_IMAGE_MAX_BYTES }));
|
|
10621
|
+
// Still an emoji of the fleet's servers once it is here (a connection removed meanwhile takes its servers along).
|
|
10622
|
+
return image && (await this.webEmojiList()).some(e => e.id === id) ? image : null;
|
|
9500
10623
|
}
|
|
9501
|
-
static GUILD_EMOJI_TTL_MS = 10 * 60_000;
|
|
9502
|
-
/** Keyed `<connection>\0<server id>`. */
|
|
9503
|
-
guildEmojiCache = new Map();
|
|
9504
|
-
guildEmojiFetches = new Map();
|
|
9505
10624
|
/**
|
|
9506
10625
|
* What `instanceName` may pick as its persona emoji (the `list_emojis`
|
|
9507
10626
|
* tool): its current status set with where each value comes from, the
|
|
@@ -10693,7 +11812,8 @@ export class FleetManager {
|
|
|
10693
11812
|
// on the sign-in DM, armed by the first tap). A double tap, or a late second one from an old keyboard, lands
|
|
10694
11813
|
// here with the spent nonce — and collapsing would wipe the message's text and the live buttons. The clicker is
|
|
10695
11814
|
// told; the message is left alone.
|
|
10696
|
-
|
|
11815
|
+
// #1617: and of the same bot — two bots' DM message ids can collide; another bot's live prompt says nothing here.
|
|
11816
|
+
if ([...this.pendingNonceButtons.values()].some(e => e.prefix === prefix && e.adapterId === callbackAdapterId && e.chatId === data.chatId && e.messageId === data.messageId)) {
|
|
10697
11817
|
data.ack?.(t("buttons.stale_notice"));
|
|
10698
11818
|
return "consumed";
|
|
10699
11819
|
}
|
|
@@ -10846,6 +11966,8 @@ export class FleetManager {
|
|
|
10846
11966
|
return true;
|
|
10847
11967
|
if (await this.handleEffortSelection(data, adapterId))
|
|
10848
11968
|
return true;
|
|
11969
|
+
if (await this.handleBackendSelection(data, adapterId))
|
|
11970
|
+
return true;
|
|
10849
11971
|
if (await this.handleHangPrompt(data, adapterId, adapter))
|
|
10850
11972
|
return true;
|
|
10851
11973
|
if (data.callbackData.startsWith("cancel:")) {
|
|
@@ -10911,6 +12033,48 @@ export class FleetManager {
|
|
|
10911
12033
|
});
|
|
10912
12034
|
}
|
|
10913
12035
|
cacheService = null;
|
|
12036
|
+
/**
|
|
12037
|
+
* #1699: the credential profile an instance's CLI signs in with on `backend` — fleet.yaml's (with its defaults), or a
|
|
12038
|
+
* ClassicBot room's launched or would-be login when the room runs that backend. Null: the backend's default login
|
|
12039
|
+
* (also for a backend that has no profiles, where the option is ignored).
|
|
12040
|
+
*/
|
|
12041
|
+
credentialProfileOf(name, backend) {
|
|
12042
|
+
if (!credentialHomeSpec(backend))
|
|
12043
|
+
return null;
|
|
12044
|
+
const cfg = this.fleetConfig?.instances?.[name];
|
|
12045
|
+
if (cfg)
|
|
12046
|
+
return instanceCredentialProfile(cfg, this.fleetConfig?.defaults, backend);
|
|
12047
|
+
const launched = this.daemons.get(name)?.getConfigSnapshot?.();
|
|
12048
|
+
if (launched)
|
|
12049
|
+
return (launched.backend ?? "claude-code") === backend ? profileName(profileOf(launched.backend_options, backend)) : null;
|
|
12050
|
+
if (!this.classicChannels || this.classicChannels.getBackendByInstance(name, this.fleetConfig?.defaults?.backend) !== backend)
|
|
12051
|
+
return null;
|
|
12052
|
+
return profileName(this.classicChannels.getCredentialProfileByInstance(name, this.fleetConfig?.defaults));
|
|
12053
|
+
}
|
|
12054
|
+
/**
|
|
12055
|
+
* #1699: the claude config dir an instance's CLI uses — its credential profile's, else exactly as before
|
|
12056
|
+
* (CLAUDE_CONFIG_DIR or ~/.claude).
|
|
12057
|
+
*/
|
|
12058
|
+
claudeConfigDirOf(name) {
|
|
12059
|
+
const profile = this.credentialProfileOf(name, "claude-code");
|
|
12060
|
+
return claudeConfigDirFor(profile ? credentialProfileHome(getAgendHome(), "claude-code", profile) : null);
|
|
12061
|
+
}
|
|
12062
|
+
/** The instances the cache analysis reads: fleet.yaml's, then the ClassicBot rooms (their workspace under the AgEnD home, their own backend). */
|
|
12063
|
+
cacheInstances() {
|
|
12064
|
+
const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
|
|
12065
|
+
name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
|
|
12066
|
+
claudeConfigDir: this.claudeConfigDirOf(name),
|
|
12067
|
+
}));
|
|
12068
|
+
const taken = new Set(fleet.map(i => i.name));
|
|
12069
|
+
const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
|
|
12070
|
+
name: ch.instanceName,
|
|
12071
|
+
backend: this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
|
|
12072
|
+
workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
|
|
12073
|
+
ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
|
|
12074
|
+
claudeConfigDir: this.claudeConfigDirOf(ch.instanceName),
|
|
12075
|
+
}));
|
|
12076
|
+
return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
|
|
12077
|
+
}
|
|
10914
12078
|
/**
|
|
10915
12079
|
* #1468: the prompt-cache expiry analysis for the web, per instance, over `window`. Read from each instance's
|
|
10916
12080
|
* ledger as it is now; the first request starts the bounded, persisted catch-up over the transcripts
|
|
@@ -10919,20 +12083,7 @@ export class FleetManager {
|
|
|
10919
12083
|
cacheReport(window) {
|
|
10920
12084
|
const w = Object.prototype.hasOwnProperty.call(WINDOWS, window) ? window : "7d";
|
|
10921
12085
|
this.cacheService ??= new CacheService({
|
|
10922
|
-
|
|
10923
|
-
instances: () => {
|
|
10924
|
-
const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
|
|
10925
|
-
name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
|
|
10926
|
-
}));
|
|
10927
|
-
const taken = new Set(fleet.map(i => i.name));
|
|
10928
|
-
const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
|
|
10929
|
-
name: ch.instanceName,
|
|
10930
|
-
backend: this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
|
|
10931
|
-
workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
|
|
10932
|
-
ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
|
|
10933
|
-
}));
|
|
10934
|
-
return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
|
|
10935
|
-
},
|
|
12086
|
+
instances: () => this.cacheInstances(),
|
|
10936
12087
|
claudeProjectsDir: () => join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects"),
|
|
10937
12088
|
claudeKey: claudeProjectKey,
|
|
10938
12089
|
codexSessionsDir: () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions"),
|
|
@@ -11084,10 +12235,18 @@ export class FleetManager {
|
|
|
11084
12235
|
* The caller has passed the /ui gate (session, same origin, CSRF). Here: the prompt must be one offered
|
|
11085
12236
|
* on the dashboard, about the instance the page named, and the action one of its own buttons.
|
|
11086
12237
|
*/
|
|
11087
|
-
/** #
|
|
11088
|
-
|
|
12238
|
+
/** #1823: close_poll — `instance` closes one of its own polls before the deadline. */
|
|
12239
|
+
closePollEarly(instance, id) {
|
|
11089
12240
|
const buttons = this.replyButtons();
|
|
11090
|
-
return buttons ? buttons.
|
|
12241
|
+
return buttons ? buttons.closeEarly(instance, id) : Promise.resolve({ ok: false, error: "polls are unavailable until AgEnD restarts" });
|
|
12242
|
+
}
|
|
12243
|
+
/**
|
|
12244
|
+
* #1266: a click on a reply's button in the web chat (the /ui gate is passed: a session, or the public link). On a
|
|
12245
|
+
* poll (#1823) it is `voter`'s vote: the page's session.
|
|
12246
|
+
*/
|
|
12247
|
+
clickWebReplyButton(instance, id, index, voter) {
|
|
12248
|
+
const buttons = this.replyButtons();
|
|
12249
|
+
return buttons ? buttons.clickWeb(instance, id, index, voter) : Promise.resolve({ status: 409, error: t("reply_buttons.closed") });
|
|
11091
12250
|
}
|
|
11092
12251
|
async clickWebPrompt(instance, nonce, action) {
|
|
11093
12252
|
if (!/^[0-9a-f]{32}$/.test(nonce) || !/^[a-z][a-z-]{0,23}$/.test(action))
|
|
@@ -11119,7 +12278,9 @@ export class FleetManager {
|
|
|
11119
12278
|
}
|
|
11120
12279
|
async retireNonceButtons(pending, messageId, text,
|
|
11121
12280
|
/** The click's own edit of a private prompt (AdapterCallbackData.editClicked); preferred over the prompt's. */
|
|
11122
|
-
editClicked
|
|
12281
|
+
editClicked,
|
|
12282
|
+
/** #1686: URL buttons to attach when editing the outcome (Telegram only; ignored when editClicked or retire is set). */
|
|
12283
|
+
urlButtons) {
|
|
11123
12284
|
this.webPromptGone(pending, text);
|
|
11124
12285
|
try {
|
|
11125
12286
|
const retire = editClicked ?? pending.retire;
|
|
@@ -11129,7 +12290,12 @@ export class FleetManager {
|
|
|
11129
12290
|
}
|
|
11130
12291
|
if (!pending.adapter.editMessageRemoveButtons)
|
|
11131
12292
|
throw new Error("adapter cannot remove prompt buttons");
|
|
11132
|
-
|
|
12293
|
+
if (urlButtons) {
|
|
12294
|
+
await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId, urlButtons);
|
|
12295
|
+
}
|
|
12296
|
+
else {
|
|
12297
|
+
await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
|
|
12298
|
+
}
|
|
11133
12299
|
}
|
|
11134
12300
|
catch (err) {
|
|
11135
12301
|
// The action was already atomically consumed. An edit failure must not
|
|
@@ -11137,7 +12303,8 @@ export class FleetManager {
|
|
|
11137
12303
|
// only place some outcomes are told (cancel, later, a resend's result),
|
|
11138
12304
|
// so the text goes out as a message instead (#1133).
|
|
11139
12305
|
this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons; posting the outcome instead");
|
|
11140
|
-
|
|
12306
|
+
// #1742: with its link button, if it had one — the outcome may be the only way to the DM.
|
|
12307
|
+
await pending.adapter.sendText(pending.chatId, text, { threadId: pending.threadId, ...(urlButtons ? { urlButtons } : {}) })
|
|
11141
12308
|
.catch(sendErr => this.logger.warn({ err: sendErr, instanceName: pending.instanceName }, "Could not post a prompt's outcome"));
|
|
11142
12309
|
}
|
|
11143
12310
|
}
|
|
@@ -11432,6 +12599,8 @@ export class FleetManager {
|
|
|
11432
12599
|
}
|
|
11433
12600
|
};
|
|
11434
12601
|
}
|
|
12602
|
+
/** #1806: the web's /backend confirmations — one per question, used once, for the arguments and backend asked about. */
|
|
12603
|
+
webBackendTokens = new Map();
|
|
11435
12604
|
/** #1269: the web's /clear confirmations — one token per question, used once, for the instance as it was asked about. */
|
|
11436
12605
|
webClearTokens = new Map();
|
|
11437
12606
|
/** #1269: an instance's chat command from the web chat (web-commands.ts); `publicLink`: a gateway request. */
|
|
@@ -11439,8 +12608,10 @@ export class FleetManager {
|
|
|
11439
12608
|
const webMeta = (instance) => {
|
|
11440
12609
|
const topicId = this.fleetConfig?.instances[instance]?.topic_id;
|
|
11441
12610
|
return {
|
|
11442
|
-
|
|
11443
|
-
|
|
12611
|
+
// #1827: a /steer or /btw reaches the agent as the web person who wrote it.
|
|
12612
|
+
chatId: this.getGroupIdForInstance(instance) || "", messageId: newWebMessageId(),
|
|
12613
|
+
username: opts.person?.name ?? WEB_NAME_FALLBACK, userId: opts.person?.userId ?? WEB_NAME_FALLBACK,
|
|
12614
|
+
threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web", humanOrigin: webHumanOrigin(true),
|
|
11444
12615
|
};
|
|
11445
12616
|
};
|
|
11446
12617
|
const choicesFromCache = (instance) => {
|
|
@@ -11470,9 +12641,44 @@ export class FleetManager {
|
|
|
11470
12641
|
const current = this.resolveInstanceEffort(instance).effort;
|
|
11471
12642
|
return { current, options: levels.map(l => ({ id: l, label: this.effortChoiceLabel(l, current) })) };
|
|
11472
12643
|
},
|
|
12644
|
+
backendStep: async (instance, args) => {
|
|
12645
|
+
const refusal = this.backendCommandRefusal(instance);
|
|
12646
|
+
if (refusal)
|
|
12647
|
+
return { error: refusal };
|
|
12648
|
+
// The cached model lists only, as the web's /model: never a live probe on this path.
|
|
12649
|
+
const step = await backendStep(this.backendCommandDeps(instance, { live: false }), instance, backendArgs(args));
|
|
12650
|
+
if (step.kind === "refuse")
|
|
12651
|
+
return step.noop ? { text: step.text } : { error: step.text };
|
|
12652
|
+
if (step.kind === "choose") {
|
|
12653
|
+
const id = (token) => joinBackendArgs([...step.args, token]);
|
|
12654
|
+
return { choices: { prompt: step.text, current: step.current === null ? null : id(step.current), options: step.choices.map(c => ({ id: id(c.token), label: c.label })) } };
|
|
12655
|
+
}
|
|
12656
|
+
const now = performance.now();
|
|
12657
|
+
for (const [k, v] of this.webBackendTokens)
|
|
12658
|
+
if (v.deadline < now)
|
|
12659
|
+
this.webBackendTokens.delete(k);
|
|
12660
|
+
const token = randomBytes(16).toString("hex");
|
|
12661
|
+
this.webBackendTokens.set(token, {
|
|
12662
|
+
instance, asked: joinBackendArgs(backendArgs(args)), args: step.args, from: step.from,
|
|
12663
|
+
target: this.fleetConfig.instances[instance], deadline: now + CLASSIC_BACKEND_SELECTION_TIMEOUT_MS,
|
|
12664
|
+
});
|
|
12665
|
+
return { confirm: { token, message: step.text } };
|
|
12666
|
+
},
|
|
12667
|
+
backendConfirm: async (instance, args, token) => {
|
|
12668
|
+
const entry = this.webBackendTokens.get(token);
|
|
12669
|
+
this.webBackendTokens.delete(token); // used once, whatever happens next
|
|
12670
|
+
if (!entry || entry.instance !== instance || entry.asked !== joinBackendArgs(backendArgs(args)) || performance.now() > entry.deadline) {
|
|
12671
|
+
return { status: 409, text: t("backend.selection_expired") };
|
|
12672
|
+
}
|
|
12673
|
+
// The instance it was asked about: one deleted and created again under the name, stopped or not, is another.
|
|
12674
|
+
const stillCurrent = () => this.backendTargetIs(instance, entry.target);
|
|
12675
|
+
if (!stillCurrent())
|
|
12676
|
+
return { status: 409, text: t("menu.click_stale") };
|
|
12677
|
+
return { status: 200, text: await this.applyBackendSwitch(instance, entry.args, entry.from, stillCurrent) };
|
|
12678
|
+
},
|
|
11473
12679
|
cancel: (instance) => this.cancelInstance(instance),
|
|
11474
|
-
steer: (instance, text) => this.topicCommands.sendSteer(instance, text, webMeta(instance)),
|
|
11475
|
-
btw: (instance, text) => this.topicCommands.sendBtw(instance, text, webMeta(instance)),
|
|
12680
|
+
steer: (instance, text, originalBody) => this.topicCommands.sendSteer(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
|
|
12681
|
+
btw: (instance, text, originalBody) => this.topicCommands.sendBtw(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
|
|
11476
12682
|
pauseWake: (instance, action) => this.topicCommands.runPauseWake(instance, action),
|
|
11477
12683
|
save: (instance, filename) => this.topicCommands.sendSave(instance, filename),
|
|
11478
12684
|
clearAsk: (instance) => {
|
|
@@ -11743,9 +12949,12 @@ export class FleetManager {
|
|
|
11743
12949
|
* Backends with no remote login flow (opencode logs in from a terminal) get
|
|
11744
12950
|
* no button — the alert's own wording already tells them what to run.
|
|
11745
12951
|
*/
|
|
11746
|
-
async offerBackendLogin(targetInstance, backend) {
|
|
12952
|
+
async offerBackendLogin(targetInstance, backend, profile) {
|
|
11747
12953
|
if (!LOGIN_FLOWS[backend])
|
|
11748
12954
|
return;
|
|
12955
|
+
if (profile && !CHAT_PROFILE_LOGIN_BACKENDS.has(backend))
|
|
12956
|
+
return; // that profile signs in on the host
|
|
12957
|
+
const shown = profile ? t("login.profile_label", backend, profile) : backend;
|
|
11749
12958
|
const adapterId = this.getInstanceAdapterId(targetInstance);
|
|
11750
12959
|
const adapter = this.getAdapterForInstance(targetInstance);
|
|
11751
12960
|
const chatId = this.getGroupIdForInstance(targetInstance);
|
|
@@ -11763,9 +12972,10 @@ export class FleetManager {
|
|
|
11763
12972
|
adapterId,
|
|
11764
12973
|
chatId,
|
|
11765
12974
|
threadId,
|
|
11766
|
-
message: t("login.offer",
|
|
11767
|
-
choices: [{ action: backend, label: t("login.offer_action",
|
|
12975
|
+
message: t("login.offer", shown),
|
|
12976
|
+
choices: [{ action: backend, label: t("login.offer_action", shown) }],
|
|
11768
12977
|
expiredText: t("buttons.stale"),
|
|
12978
|
+
...(profile ? { extra: { loginProfile: profile } } : {}),
|
|
11769
12979
|
});
|
|
11770
12980
|
}
|
|
11771
12981
|
/** Consume a General assist button exactly once. */
|
|
@@ -12578,7 +13788,7 @@ export class FleetManager {
|
|
|
12578
13788
|
fleetConfig: () => this.fleetConfig,
|
|
12579
13789
|
isFleetAdmin: (userId, adapterId) => this.isFleetAdmin(userId, adapterId),
|
|
12580
13790
|
eventLog: () => this.eventLog,
|
|
12581
|
-
recoverBackendInstances: backend => this.recoverBackendInstances(backend),
|
|
13791
|
+
recoverBackendInstances: (backend, profile) => this.recoverBackendInstances(backend, undefined, profile ?? null),
|
|
12582
13792
|
claimWindow: backend => this.loginWindow.tryClaim("web", backend),
|
|
12583
13793
|
releaseWindow: claim => { this.loginWindow.release(claim); },
|
|
12584
13794
|
isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
|
|
@@ -12587,11 +13797,12 @@ export class FleetManager {
|
|
|
12587
13797
|
reserveTunnel: owner => this.getTunnelLane().reserve("login", owner),
|
|
12588
13798
|
// Throws when the prompt cannot be posted: the controller tells the
|
|
12589
13799
|
// user so instead of leaving "Starting…" as the last word (#1133).
|
|
12590
|
-
postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText }) => {
|
|
13800
|
+
postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText, profile }) => {
|
|
12591
13801
|
await this.postNonceButtonPromptOrThrow({
|
|
12592
13802
|
prefix, alertType: "login", instanceName,
|
|
12593
13803
|
adapter: chat.adapter, adapterId: chat.adapterId, chatId: chat.chatId, threadId: chat.threadId,
|
|
12594
13804
|
message, choices, expiredText,
|
|
13805
|
+
...(profile ? { extra: { loginProfile: profile } } : {}),
|
|
12595
13806
|
});
|
|
12596
13807
|
},
|
|
12597
13808
|
});
|
|
@@ -12805,10 +14016,20 @@ export class FleetManager {
|
|
|
12805
14016
|
// A removed backend (#1280) is neither installed nor signed into: say what replaces it.
|
|
12806
14017
|
if (isRemovedBackend(wanted))
|
|
12807
14018
|
return removedBackendMessage(wanted);
|
|
14019
|
+
// #1699 part 2: a profile is admitted before anything is installed for it — a name that is not one, or a backend
|
|
14020
|
+
// whose profiles sign in on the host, is refused here, not after an install it did not need.
|
|
14021
|
+
if (opts.profile !== undefined) {
|
|
14022
|
+
const admitted = admitProfileLogin(wanted, opts.profile);
|
|
14023
|
+
if ("refusal" in admitted)
|
|
14024
|
+
return admitted.refusal;
|
|
14025
|
+
opts = { ...opts, profile: admitted.profile };
|
|
14026
|
+
}
|
|
12808
14027
|
if (BACKEND_INSTALLATION_INFO[wanted] && !this.isCliInstalled(wanted)) {
|
|
12809
14028
|
const flow = LOGIN_FLOWS[wanted];
|
|
12810
14029
|
this.recordLoginFlow(wanted, flow && flow.remoteLogin !== "unsupported" ? "install_then_login" : "install_only", chat.userId);
|
|
12811
|
-
return
|
|
14030
|
+
return opts.profile !== undefined
|
|
14031
|
+
? this.startInstallSession(wanted, chat, { profile: opts.profile })
|
|
14032
|
+
: this.startInstallSession(wanted, chat);
|
|
12812
14033
|
}
|
|
12813
14034
|
this.recordLoginFlow(wanted, "login", chat.userId);
|
|
12814
14035
|
return this.launchSignIn(backendArg, chat, opts);
|
|
@@ -12897,7 +14118,9 @@ export class FleetManager {
|
|
|
12897
14118
|
* cancelled: they are still coming back, and are reported as pending so the
|
|
12898
14119
|
* message can say so instead of implying failure.
|
|
12899
14120
|
*/
|
|
12900
|
-
async recoverBackendInstances(backend, deadlineMs = POST_LOGIN_RECOVERY_DEADLINE_MS
|
|
14121
|
+
async recoverBackendInstances(backend, deadlineMs = POST_LOGIN_RECOVERY_DEADLINE_MS,
|
|
14122
|
+
/** #1699 part 2: the login just signed in — a profile, or null for the backend's default one. */
|
|
14123
|
+
profile = null) {
|
|
12901
14124
|
const woken = [];
|
|
12902
14125
|
const restarted = [];
|
|
12903
14126
|
const pending = [];
|
|
@@ -12905,6 +14128,9 @@ export class FleetManager {
|
|
|
12905
14128
|
for (const name of this.configuredBackendInstanceNames()) {
|
|
12906
14129
|
if (this.backendNameOf(name) !== backend)
|
|
12907
14130
|
continue;
|
|
14131
|
+
// Only the instances on the login that changed: another profile's (or the default's) credentials did not.
|
|
14132
|
+
if (this.credentialProfileOf(name, backend) !== profile)
|
|
14133
|
+
continue;
|
|
12908
14134
|
const status = this.getInstanceStatus(name);
|
|
12909
14135
|
if (status !== "paused" && status !== "running")
|
|
12910
14136
|
continue;
|
|
@@ -12942,14 +14168,16 @@ export class FleetManager {
|
|
|
12942
14168
|
if (claimed === "consumed")
|
|
12943
14169
|
return true;
|
|
12944
14170
|
const { entry, action: backend } = claimed;
|
|
12945
|
-
|
|
12946
|
-
|
|
14171
|
+
const profile = entry.loginProfile;
|
|
14172
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", profile ? t("login.profile_label", backend, profile) : backend));
|
|
14173
|
+
const chat = {
|
|
12947
14174
|
adapter: entry.adapter,
|
|
12948
14175
|
adapterId: entry.adapterId,
|
|
12949
14176
|
chatId: entry.chatId,
|
|
12950
14177
|
threadId: entry.threadId,
|
|
12951
14178
|
userId: data.userId,
|
|
12952
|
-
}
|
|
14179
|
+
};
|
|
14180
|
+
const text = profile ? await this.startLoginSession(backend, chat, { profile }) : await this.startLoginSession(backend, chat);
|
|
12953
14181
|
if (text)
|
|
12954
14182
|
await this.postPromptOutcome(entry, text);
|
|
12955
14183
|
return true;
|
|
@@ -12963,11 +14191,13 @@ export class FleetManager {
|
|
|
12963
14191
|
return true;
|
|
12964
14192
|
const { entry, action } = claimed;
|
|
12965
14193
|
const backend = entry.instanceName;
|
|
14194
|
+
const profile = entry.loginProfile;
|
|
14195
|
+
const shown = profile ? t("login.profile_label", backend, profile) : backend;
|
|
12966
14196
|
if (action === "cancel") {
|
|
12967
|
-
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled",
|
|
14197
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled", shown));
|
|
12968
14198
|
return true;
|
|
12969
14199
|
}
|
|
12970
|
-
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend",
|
|
14200
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", shown));
|
|
12971
14201
|
const text = await this.startLoginSession(backend, {
|
|
12972
14202
|
adapter: entry.adapter,
|
|
12973
14203
|
adapterId: entry.adapterId,
|
|
@@ -12979,6 +14209,7 @@ export class FleetManager {
|
|
|
12979
14209
|
tokenPresent: action === "go-relogin" || action === "go-relogin-tunnel",
|
|
12980
14210
|
// The consent is the button itself: only the explicit "Open public link" actions carry it.
|
|
12981
14211
|
tunnel: action === "go-tunnel" || action === "go-relogin-tunnel",
|
|
14212
|
+
...(profile ? { profile } : {}),
|
|
12982
14213
|
});
|
|
12983
14214
|
if (text)
|
|
12984
14215
|
await this.postPromptOutcome(entry, text);
|
|
@@ -13006,7 +14237,7 @@ export class FleetManager {
|
|
|
13006
14237
|
/** An install that succeeded, between its success line and the sign-in it hands over to (#1131). */
|
|
13007
14238
|
installHandoff = null;
|
|
13008
14239
|
/** Start a CLI install session. Caller enforces admin. */
|
|
13009
|
-
async startInstallSession(backendArg, chat) {
|
|
14240
|
+
async startInstallSession(backendArg, chat, signIn = {}) {
|
|
13010
14241
|
const backend = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
|
|
13011
14242
|
const info = BACKEND_INSTALLATION_INFO[backend];
|
|
13012
14243
|
if (!info)
|
|
@@ -13090,7 +14321,8 @@ export class FleetManager {
|
|
|
13090
14321
|
// without a click.
|
|
13091
14322
|
// launchSignIn, not startLoginSession: if the new binary were still
|
|
13092
14323
|
// not visible, startLoginSession would route straight back to an install.
|
|
13093
|
-
|
|
14324
|
+
// A `/login <backend> <profile>` that had to install first signs that profile in, not the default login.
|
|
14325
|
+
const next = await this.launchSignIn(backend, chat, signIn.profile !== undefined ? { profile: signIn.profile } : {}).catch((err) => t("login.failed", backend, err?.message ?? String(err)));
|
|
13094
14326
|
if (next) {
|
|
13095
14327
|
await chat.adapter.sendText(chat.chatId, next, { threadId: chat.threadId })
|
|
13096
14328
|
.catch(err => this.logger.warn({ err, backend }, "Could not post the sign-in step after an install"));
|
|
@@ -13198,8 +14430,16 @@ export class FleetManager {
|
|
|
13198
14430
|
return;
|
|
13199
14431
|
}
|
|
13200
14432
|
const backend = String(data.options?.backend ?? "").trim();
|
|
14433
|
+
// #1699 part 2: a supplied profile is never dropped — a blank one is refused by the admission like any bad name,
|
|
14434
|
+
// and one without a backend is refused rather than becoming the default login's picker.
|
|
14435
|
+
const profileSupplied = data.options?.profile !== undefined && data.options?.profile !== null;
|
|
14436
|
+
const profile = profileSupplied ? String(data.options?.profile).trim() : undefined;
|
|
14437
|
+
if (profileSupplied && !backend) {
|
|
14438
|
+
await data.respond(t("login.profile_needs_backend"));
|
|
14439
|
+
return;
|
|
14440
|
+
}
|
|
13201
14441
|
if (backend) {
|
|
13202
|
-
const text = await this.startLoginSession(backend, chat);
|
|
14442
|
+
const text = profile !== undefined ? await this.startLoginSession(backend, chat, { profile }) : await this.startLoginSession(backend, chat);
|
|
13203
14443
|
await data.respond(text ?? t("login.confirm_posted"));
|
|
13204
14444
|
return;
|
|
13205
14445
|
}
|
|
@@ -13234,16 +14474,26 @@ export class FleetManager {
|
|
|
13234
14474
|
try {
|
|
13235
14475
|
const store = new WebChatDiskStore({ home: getAgendHome(), logger: this.logger, ownerOf: (name) => this.webChatOwner(name) });
|
|
13236
14476
|
const names = new Set([...Object.keys(fleet.instances ?? {}), ...(this.classicChannels?.getAll().map(c => c.instanceName) ?? [])]);
|
|
13237
|
-
// A kept message's files are served again under their ids only from the instance's own inbox
|
|
13238
|
-
// checks (web-upload.ts restoreServed); they leave the ledger with their message.
|
|
14477
|
+
// A kept message's files are served again under their ids only from the instance's own inbox or (#1770) its reply
|
|
14478
|
+
// snapshots, by the ledger's checks (web-upload.ts restoreServed); they leave the ledger with their message.
|
|
13239
14479
|
this.webChatHistory.setFiles({
|
|
13240
14480
|
storablePath: (id) => this.webFiles.storablePath(id),
|
|
13241
|
-
restore: (entry, instance) => this.webFiles.restoreServed(entry, instance, join(getAgendHome(), "workspaces", instance, "inbox")),
|
|
14481
|
+
restore: (entry, instance) => this.webFiles.restoreServed(entry, instance, join(getAgendHome(), "workspaces", instance, "inbox"), webFilesDir(getAgendHome(), instance) ?? undefined),
|
|
13242
14482
|
drop: (ids) => this.webFiles.dropServed(ids),
|
|
13243
14483
|
});
|
|
13244
14484
|
let restored = 0;
|
|
13245
14485
|
for (const name of names)
|
|
13246
14486
|
restored += this.webChatHistory.restore(name, store.load(name));
|
|
14487
|
+
// #1770: before any instance replies, the snapshots no restored message holds (it left the history while the
|
|
14488
|
+
// fleet was down, or a copy stopped midway) are removed.
|
|
14489
|
+
let swept = 0;
|
|
14490
|
+
for (const name of names) {
|
|
14491
|
+
const dir = webFilesDir(getAgendHome(), name);
|
|
14492
|
+
if (dir)
|
|
14493
|
+
swept += sweepReplySnapshots(dir, this.webFiles.heldSnapshots(name));
|
|
14494
|
+
}
|
|
14495
|
+
if (swept)
|
|
14496
|
+
this.logger.info({ swept }, "Removed web reply snapshots no message holds");
|
|
13247
14497
|
this.webChatStore = store;
|
|
13248
14498
|
this.webChatHistory.setStore(store);
|
|
13249
14499
|
if (restored)
|
|
@@ -14034,12 +15284,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14034
15284
|
const reactions = this.pendingReactionsMeta(instanceName);
|
|
14035
15285
|
Object.assign(meta, reactions.meta);
|
|
14036
15286
|
try {
|
|
14037
|
-
await this.
|
|
15287
|
+
await this.deliverHumanToInstance(instanceName, {
|
|
14038
15288
|
type: "fleet_inbound",
|
|
14039
15289
|
content: fullText,
|
|
14040
15290
|
targetSession: instanceName,
|
|
14041
15291
|
meta,
|
|
14042
|
-
});
|
|
15292
|
+
}, msg.humanOrigin, msg.humanOriginalBody ?? text);
|
|
14043
15293
|
reactions.consume();
|
|
14044
15294
|
}
|
|
14045
15295
|
catch (err) {
|
|
@@ -14056,6 +15306,50 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14056
15306
|
});
|
|
14057
15307
|
}
|
|
14058
15308
|
/** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
|
|
15309
|
+
/** #1690: our own runtime switches awaiting the daemon's outcome, by id. */
|
|
15310
|
+
runtimeSwitchWaiters = new Map();
|
|
15311
|
+
/**
|
|
15312
|
+
* #1690: type `/model <model>` and wait for what it came to: the daemon answers claude's "Switch model?" for us (Yes
|
|
15313
|
+
* when it names this model, No otherwise) or sees claude's own result. Null when it could not be sent; a timeout is
|
|
15314
|
+
* "not confirmed".
|
|
15315
|
+
*/
|
|
15316
|
+
runRuntimeModelSwitch(instanceName, model) {
|
|
15317
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
15318
|
+
if (!ipc)
|
|
15319
|
+
return Promise.resolve({ outcome: "not_sent" });
|
|
15320
|
+
const id = `switch-${randomUUID()}`;
|
|
15321
|
+
return new Promise(resolve => {
|
|
15322
|
+
const timer = setTimeout(() => { this.runtimeSwitchWaiters.delete(id); resolve({ outcome: "not_confirmed" }); }, RUNTIME_SWITCH_RESULT_MS);
|
|
15323
|
+
timer.unref?.();
|
|
15324
|
+
this.runtimeSwitchWaiters.set(id, result => { clearTimeout(timer); resolve(result); });
|
|
15325
|
+
const sent = ipc.send({ type: "raw_paste", content: `/model ${model}`, runtime_switch: { id, kind: "model", requested: model } });
|
|
15326
|
+
if (!sent) {
|
|
15327
|
+
clearTimeout(timer);
|
|
15328
|
+
this.runtimeSwitchWaiters.delete(id);
|
|
15329
|
+
resolve({ outcome: "not_sent" });
|
|
15330
|
+
return;
|
|
15331
|
+
}
|
|
15332
|
+
this.logger.info({ instanceName, model }, "Runtime model switch sent");
|
|
15333
|
+
});
|
|
15334
|
+
}
|
|
15335
|
+
/**
|
|
15336
|
+
* #1690: the names claude-code is known to accept, for checking `/model <name>` BEFORE it is typed — claude itself
|
|
15337
|
+
* accepts any string ("Set model to opsu"), and its "Switch model?" then names the typo. Static aliases (with [1m]),
|
|
15338
|
+
* the cached account catalog, the configured model; without a cached catalog, any `claude-…` id too (cannot check).
|
|
15339
|
+
*/
|
|
15340
|
+
claudeModelKnown(instanceName, model,
|
|
15341
|
+
/** #1806: null on a switch INTO claude-code — the configured model then belongs to the backend being left. */
|
|
15342
|
+
configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model) {
|
|
15343
|
+
const aliases = ["default", "sonnet", "opus", "haiku", "opusplan", "Fable"];
|
|
15344
|
+
const api = (this.readCliEnv("claude-code")?.apiModels ?? []).map(m => m.id);
|
|
15345
|
+
const lower = model.trim().toLowerCase();
|
|
15346
|
+
const bare = lower.replace(/\[1m\]$/, "");
|
|
15347
|
+
const known = aliases.some(a => a.toLowerCase() === bare)
|
|
15348
|
+
|| api.some(id => id.toLowerCase() === lower || `${id.toLowerCase()}[1m]` === lower)
|
|
15349
|
+
|| (typeof configured === "string" && configured.toLowerCase() === lower)
|
|
15350
|
+
|| (api.length === 0 && /^claude-[a-z0-9][a-z0-9.-]*(\[1m\])?$/.test(lower));
|
|
15351
|
+
return { known, names: [...aliases, ...api.filter(id => !id.endsWith("[1m]")).slice(0, 12)] };
|
|
15352
|
+
}
|
|
14059
15353
|
pasteRawToClassicInstance(instanceName, text) {
|
|
14060
15354
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
14061
15355
|
if (!ipc) {
|
|
@@ -14655,7 +15949,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14655
15949
|
try {
|
|
14656
15950
|
// Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
|
|
14657
15951
|
result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
|
|
14658
|
-
? await
|
|
15952
|
+
? await withSpawnHoldReply(async (text) => {
|
|
15953
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
15954
|
+
if (progressMsgId)
|
|
15955
|
+
await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
|
|
15956
|
+
else
|
|
15957
|
+
await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
|
|
15958
|
+
}
|
|
15959
|
+
else
|
|
15960
|
+
await pending.respond(text);
|
|
15961
|
+
}, () => this.applyEffort(pending.instanceName, level)) : t("menu.click_stale");
|
|
14659
15962
|
}
|
|
14660
15963
|
catch (err) {
|
|
14661
15964
|
this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
|
|
@@ -14980,7 +16283,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14980
16283
|
try {
|
|
14981
16284
|
// Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
|
|
14982
16285
|
result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
|
|
14983
|
-
? await
|
|
16286
|
+
? await withSpawnHoldReply(async (text) => {
|
|
16287
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
16288
|
+
if (progressMsgId)
|
|
16289
|
+
await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
|
|
16290
|
+
else
|
|
16291
|
+
await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
|
|
16292
|
+
}
|
|
16293
|
+
else
|
|
16294
|
+
await pending.respond(text);
|
|
16295
|
+
}, () => this.applyModel(pending.instanceName, model)) : t("menu.click_stale");
|
|
14984
16296
|
}
|
|
14985
16297
|
catch (err) {
|
|
14986
16298
|
this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
|
|
@@ -15008,7 +16320,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15008
16320
|
/** How this instance's backend applies an effort change. */
|
|
15009
16321
|
effortStrategyFor(instanceName) {
|
|
15010
16322
|
try {
|
|
15011
|
-
const { strategy, levels } =
|
|
16323
|
+
const { strategy, levels } = this.instanceEffortMetadata(instanceName);
|
|
15012
16324
|
return strategy !== "unsupported" && levels.length > 0 ? strategy : "unsupported";
|
|
15013
16325
|
}
|
|
15014
16326
|
catch {
|
|
@@ -15018,13 +16330,23 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15018
16330
|
/** Effort levels this instance's backend actually accepts (empty = unsupported). */
|
|
15019
16331
|
effortLevelsFor(instanceName) {
|
|
15020
16332
|
try {
|
|
15021
|
-
const { strategy, levels } =
|
|
16333
|
+
const { strategy, levels } = this.instanceEffortMetadata(instanceName);
|
|
15022
16334
|
return strategy === "unsupported" ? [] : levels;
|
|
15023
16335
|
}
|
|
15024
16336
|
catch {
|
|
15025
16337
|
return [];
|
|
15026
16338
|
}
|
|
15027
16339
|
}
|
|
16340
|
+
/** The instance's backend's effort metadata; agy's levels are its model's (#1825), the one the instance runs. */
|
|
16341
|
+
instanceEffortMetadata(instanceName) {
|
|
16342
|
+
const backend = this.backendNameForInstance(instanceName);
|
|
16343
|
+
let model = null;
|
|
16344
|
+
if (backend === "antigravity") {
|
|
16345
|
+
const resolved = this.resolveInstanceModel(instanceName);
|
|
16346
|
+
model = resolved.source === "unresolved" ? null : resolved.model;
|
|
16347
|
+
}
|
|
16348
|
+
return readEffortMetadata(backend, this.getInstanceDir(instanceName), model);
|
|
16349
|
+
}
|
|
15028
16350
|
/** Configured effort for an instance: per-instance, else fleet default, else none. */
|
|
15029
16351
|
resolveInstanceEffort(instanceName) {
|
|
15030
16352
|
const own = this.fleetConfig?.instances[instanceName]?.effort;
|
|
@@ -15114,21 +16436,34 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15114
16436
|
}
|
|
15115
16437
|
catch { /* default restart */ }
|
|
15116
16438
|
const warn = isModelCompatible(backendName, model) ? "" : t("model.pattern_warning", model, backendName);
|
|
16439
|
+
// #1690: claude takes any name; a mistyped one would be "switched" to and fail the next turn. Checked first —
|
|
16440
|
+
// nothing is typed or saved for an unknown one.
|
|
16441
|
+
if (backendName === "claude-code") {
|
|
16442
|
+
const check = this.claudeModelKnown(instanceName, model);
|
|
16443
|
+
if (!check.known)
|
|
16444
|
+
return t("model.unknown_name", model, check.names.join(", "));
|
|
16445
|
+
}
|
|
15117
16446
|
if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
|
|
15118
16447
|
return `${warn}${t("effort.not_running", instanceName)}`;
|
|
15119
16448
|
}
|
|
16449
|
+
// #1690: claude-code's runtime switch reports what it came to; the model is saved unless claude was told No.
|
|
16450
|
+
if (strategy === "runtime" && backendName === "claude-code") {
|
|
16451
|
+
const result = await this.runRuntimeModelSwitch(instanceName, model);
|
|
16452
|
+
if (result.outcome === "mismatch")
|
|
16453
|
+
return `${warn}${t("model.switch_mismatch", instanceName, result.shown, model)}`;
|
|
16454
|
+
if (result.outcome === "not_sent")
|
|
16455
|
+
return `${warn}${t("model.switch_not_sent", instanceName, model)}`;
|
|
16456
|
+
const persistedNow = this.persistInstanceModel(instanceName, model);
|
|
16457
|
+
if (!persistedNow)
|
|
16458
|
+
return `${warn}${t("model.persist_failed", instanceName)}`;
|
|
16459
|
+
const head = result.outcome === "switched"
|
|
16460
|
+
? (result.confirmed ? t("model.switch_confirmed", instanceName, model) : t("model.runtime_success", instanceName, model))
|
|
16461
|
+
: t("model.switch_unconfirmed", instanceName, model, Math.round(RUNTIME_SWITCH_RESULT_MS / 1000));
|
|
16462
|
+
return `${warn}${head}${this.effortSuffix(instanceName)}`;
|
|
16463
|
+
}
|
|
15120
16464
|
// Persist either way: a runtime switch must survive the next respawn too,
|
|
15121
16465
|
// or the instance silently reverts to the CLI default after a fleet restart.
|
|
15122
|
-
|
|
15123
|
-
if (this.fleetConfig?.instances[instanceName]) {
|
|
15124
|
-
this.fleetConfig.instances[instanceName].model = model;
|
|
15125
|
-
this.saveFleetConfig();
|
|
15126
|
-
persisted = true;
|
|
15127
|
-
}
|
|
15128
|
-
else if (this.classicChannels?.setModelByInstance(instanceName, model)) {
|
|
15129
|
-
persisted = true;
|
|
15130
|
-
}
|
|
15131
|
-
if (!persisted)
|
|
16466
|
+
if (!this.persistInstanceModel(instanceName, model))
|
|
15132
16467
|
return `${warn}${t("model.persist_failed", instanceName)}`;
|
|
15133
16468
|
if (strategy === "runtime") {
|
|
15134
16469
|
this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
|
|
@@ -15137,6 +16472,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15137
16472
|
await this.restartSingleInstance(instanceName);
|
|
15138
16473
|
return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
|
|
15139
16474
|
}
|
|
16475
|
+
/** Save an instance's model (fleet.yaml, or its ClassicBot channel). False when neither holds it. */
|
|
16476
|
+
persistInstanceModel(instanceName, model) {
|
|
16477
|
+
if (this.fleetConfig?.instances[instanceName]) {
|
|
16478
|
+
this.fleetConfig.instances[instanceName].model = model;
|
|
16479
|
+
this.saveFleetConfig();
|
|
16480
|
+
return true;
|
|
16481
|
+
}
|
|
16482
|
+
return this.classicChannels?.setModelByInstance(instanceName, model) === true;
|
|
16483
|
+
}
|
|
15140
16484
|
/**
|
|
15141
16485
|
* The trailing "Current effort: …" line for a /model reply.
|
|
15142
16486
|
*
|
|
@@ -15154,6 +16498,377 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15154
16498
|
? `\n${t("effort.current_fleet", effort)}`
|
|
15155
16499
|
: `\n${t("effort.current", effort)}`;
|
|
15156
16500
|
}
|
|
16501
|
+
// ── #1806: /backend — another CLI backend, with its model and effort, in one write and one relaunch ──
|
|
16502
|
+
/** The steps' view of this fleet (backend-command.ts). `live`: a missing or stale model list may be probed (not on the web). */
|
|
16503
|
+
backendCommandDeps(instanceName, opts) {
|
|
16504
|
+
return {
|
|
16505
|
+
currentBackend: (name) => this.backendNameForInstance(name),
|
|
16506
|
+
currentDisplay: (name) => `${this.backendNameForInstance(name)} · ${this.resolveInstanceModel(name).display}`,
|
|
16507
|
+
installedBackends: () => KNOWN_BACKENDS.filter(b => b !== "mock" && this.backendInstalledHere(b)),
|
|
16508
|
+
models: (backend) => this.backendModelOptions(backend, opts),
|
|
16509
|
+
// #1808: the same rule update_instance_config's switch applies (backendModelVerdict).
|
|
16510
|
+
modelKnown: (backend, model) => this.backendModelVerdict(instanceName, backend, model).ok,
|
|
16511
|
+
effortLevels: (backend, model) => this.backendEffortLevels(instanceName, backend, model),
|
|
16512
|
+
inherited: (key) => {
|
|
16513
|
+
const value = getEffectiveInstanceDefaults(this.fleetConfig?.defaults)[key];
|
|
16514
|
+
return typeof value === "string" && value ? value : undefined;
|
|
16515
|
+
},
|
|
16516
|
+
modelCompatible: (backend, model) => isModelCompatible(backend, model),
|
|
16517
|
+
state: (name) => {
|
|
16518
|
+
const status = this.getInstanceStatus(name);
|
|
16519
|
+
return { running: status === "running", busy: status === "running" && !this.getInstanceIdle(name), status };
|
|
16520
|
+
},
|
|
16521
|
+
t: (key, ...args) => t(key, ...args),
|
|
16522
|
+
};
|
|
16523
|
+
}
|
|
16524
|
+
/**
|
|
16525
|
+
* `/effort`'s levels for a backend this instance may move to, for the model that will run there (codex's levels are
|
|
16526
|
+
* per model; null: its configured default). Empty: it has no effort setting. A file read, never a CLI.
|
|
16527
|
+
*/
|
|
16528
|
+
backendEffortLevels(instanceName, backend, model = null) {
|
|
16529
|
+
// codex: the model the NEXT launch runs, for the levels and the gate alike (#1807 review r2).
|
|
16530
|
+
let runs = model;
|
|
16531
|
+
if (backend === "codex") {
|
|
16532
|
+
const next = this.prospectiveCodexModel(instanceName, model);
|
|
16533
|
+
if (!next.proven)
|
|
16534
|
+
return [];
|
|
16535
|
+
runs = next.model;
|
|
16536
|
+
}
|
|
16537
|
+
let levels;
|
|
16538
|
+
try {
|
|
16539
|
+
const metadata = readEffortMetadata(backend, this.getInstanceDir(instanceName), runs);
|
|
16540
|
+
levels = metadata.strategy === "unsupported" ? [] : metadata.levels;
|
|
16541
|
+
}
|
|
16542
|
+
catch {
|
|
16543
|
+
return [];
|
|
16544
|
+
}
|
|
16545
|
+
// #1811: only the levels this host's launch of that backend will pass (launch-effort.ts, the gate the launch
|
|
16546
|
+
// itself applies) — an effort chosen here reaches the new CLI with the one relaunch, or is not offered.
|
|
16547
|
+
const takes = this.launchEffortGate(instanceName, backend, runs);
|
|
16548
|
+
return levels.filter(takes);
|
|
16549
|
+
}
|
|
16550
|
+
/**
|
|
16551
|
+
* #1808: whether a backend takes this model name — the one rule for /backend and update_instance_config's switch.
|
|
16552
|
+
* The name must be one its launch line can carry (modelNameCarried); then its offered list (the cached CLI env), and
|
|
16553
|
+
* claude-code's known names (without this instance's configured model, which belongs to the backend being left).
|
|
16554
|
+
* With no list known, the backend's usual name pattern decides.
|
|
16555
|
+
*/
|
|
16556
|
+
backendModelVerdict(instanceName, backend, model,
|
|
16557
|
+
/** The provider the backend will run on (default: this instance's own, else the fleet's). */
|
|
16558
|
+
opts = { provider: this.customProviderFor(instanceName, backend) }) {
|
|
16559
|
+
// A custom provider has its own catalog, which the CLI reads at launch and AgEnD cannot see before one (#1815
|
|
16560
|
+
// review): only the launch-line rule applies — never the account's list or the default provider's naming pattern.
|
|
16561
|
+
if (opts.provider)
|
|
16562
|
+
return modelNameCarried(backend, model) ? { ok: true } : { ok: false, choices: [] };
|
|
16563
|
+
const listed = this.readCliEnv(backend)?.models ?? [];
|
|
16564
|
+
const choices = listed.slice(0, 24).map(o => o.id);
|
|
16565
|
+
if (!modelNameCarried(backend, model))
|
|
16566
|
+
return { ok: false, choices };
|
|
16567
|
+
if (listed.some(o => o.id === model))
|
|
16568
|
+
return { ok: true };
|
|
16569
|
+
if (backend === "claude-code") {
|
|
16570
|
+
const check = this.claudeModelKnown(instanceName, model, null);
|
|
16571
|
+
return check.known ? { ok: true } : { ok: false, choices: check.names };
|
|
16572
|
+
}
|
|
16573
|
+
return listed.length === 0 && isModelCompatible(backend, model) ? { ok: true } : { ok: false, choices };
|
|
16574
|
+
}
|
|
16575
|
+
/**
|
|
16576
|
+
* #1807 review r2: the model codex's next launch of this instance runs. Every launch's writeConfig copies the SHARED
|
|
16577
|
+
* config.toml into the instance's codex home first, so that source decides — not the home's copy from an earlier
|
|
16578
|
+
* launch. Read-only; a model chosen here (`-c model=…`) outranks it; a project file that could change it, unproven.
|
|
16579
|
+
*/
|
|
16580
|
+
prospectiveCodexModel(instanceName, model) {
|
|
16581
|
+
const { sharedHome } = codexMetadataHomes(this.getInstanceDir(instanceName));
|
|
16582
|
+
return resolveCodexLaunchModel({
|
|
16583
|
+
isolatedHome: sharedHome, sharedHome, model,
|
|
16584
|
+
workingDirectory: this.fleetConfig?.instances[instanceName]?.working_directory,
|
|
16585
|
+
});
|
|
16586
|
+
}
|
|
16587
|
+
/** #1811: whether a launch of `backend` here would pass a level: the same gate its builder applies. */
|
|
16588
|
+
launchEffortGate(instanceName, backend, model) {
|
|
16589
|
+
if (backend === "claude-code" || backend === "grok" || backend === "antigravity") {
|
|
16590
|
+
const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
|
|
16591
|
+
if (!binary)
|
|
16592
|
+
return () => false;
|
|
16593
|
+
const path = resolveBinary(binary);
|
|
16594
|
+
return level => flagEffort(backend, level, path).arg !== null;
|
|
16595
|
+
}
|
|
16596
|
+
if (backend === "codex") {
|
|
16597
|
+
// `model` is the next launch's, proven by the caller (prospectiveCodexModel); the catalog is codex's own cache.
|
|
16598
|
+
if (!model)
|
|
16599
|
+
return () => false;
|
|
16600
|
+
const catalog = readCodexModelEffortLevels({ ...codexMetadataHomes(this.getInstanceDir(instanceName)), model });
|
|
16601
|
+
return level => codexEffort(level, catalog).arg !== null;
|
|
16602
|
+
}
|
|
16603
|
+
// kiro-cli gates its own --effort by version at launch (a quiet console note otherwise); muse always passes it.
|
|
16604
|
+
return () => true;
|
|
16605
|
+
}
|
|
16606
|
+
/** Installed the way the launch finds a CLI (resolveBinary): on PATH or in a known install directory. */
|
|
16607
|
+
backendInstalledHere(backend) {
|
|
16608
|
+
const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
|
|
16609
|
+
return !!binary && resolveBinary(binary) !== binary;
|
|
16610
|
+
}
|
|
16611
|
+
/** The models `/model` would offer on a backend: the cached CLI env, else (live) a bounded probe. Never throws. */
|
|
16612
|
+
async backendModelOptions(backend, opts) {
|
|
16613
|
+
const cached = this.readCliEnv(backend);
|
|
16614
|
+
if (!opts.live || (cached?.models.length && !this.cliEnvNeedsRefresh(cached)))
|
|
16615
|
+
return cached?.models ?? [];
|
|
16616
|
+
opts.onLiveProbe?.();
|
|
16617
|
+
const env = await this.probeBackendBounded(backend).catch(() => null);
|
|
16618
|
+
return env?.models.length ? env.models : cached?.models ?? [];
|
|
16619
|
+
}
|
|
16620
|
+
/**
|
|
16621
|
+
* #1806 review: the instance a `/backend` was asked about is still this config entry — not one deleted and created
|
|
16622
|
+
* again under the same name, or replaced by a Settings edit (both put a new object there), stopped or not.
|
|
16623
|
+
*/
|
|
16624
|
+
backendTargetIs(instanceName, target) {
|
|
16625
|
+
const instances = this.fleetConfig?.instances;
|
|
16626
|
+
return !!target && !!instances && Object.prototype.hasOwnProperty.call(instances, instanceName) && instances[instanceName] === target;
|
|
16627
|
+
}
|
|
16628
|
+
/** `/backend` is for fleet.yaml instances: update_instance_config does not manage a ClassicBot agent. */
|
|
16629
|
+
backendCommandRefusal(instanceName) {
|
|
16630
|
+
return this.fleetConfig?.instances && Object.prototype.hasOwnProperty.call(this.fleetConfig.instances, instanceName)
|
|
16631
|
+
? null : t("backend.classic_unsupported", instanceName);
|
|
16632
|
+
}
|
|
16633
|
+
/**
|
|
16634
|
+
* Apply a confirmed `/backend`: judged again first (a menu lives a minute — the backend may have moved, a CLI been
|
|
16635
|
+
* removed), then update_instance_config's write: backend, model and effort at once, refused and rolled back whole
|
|
16636
|
+
* (its credential-profile login gate included), then one fresh relaunch of a running instance.
|
|
16637
|
+
*/
|
|
16638
|
+
async applyBackendSwitch(instanceName, args, from, stillCurrent) {
|
|
16639
|
+
if (this.backendNameForInstance(instanceName) !== from)
|
|
16640
|
+
return t("backend.changed", instanceName, from);
|
|
16641
|
+
if (!stillCurrent())
|
|
16642
|
+
return t("menu.click_stale");
|
|
16643
|
+
const deps = this.backendCommandDeps(instanceName, { live: false });
|
|
16644
|
+
const step = await backendStep(deps, instanceName, args);
|
|
16645
|
+
if (step.kind !== "confirm")
|
|
16646
|
+
return step.kind === "refuse" ? step.text : t("backend.usage");
|
|
16647
|
+
// Asked again after the await, with nothing awaited between here and the write (applyInstanceConfigPatch writes
|
|
16648
|
+
// synchronously): two switches confirmed together, or an instance changed meanwhile, never both succeed.
|
|
16649
|
+
if (this.backendNameForInstance(instanceName) !== from)
|
|
16650
|
+
return t("backend.changed", instanceName, from);
|
|
16651
|
+
if (!stillCurrent())
|
|
16652
|
+
return t("menu.click_stale");
|
|
16653
|
+
const { plan } = step;
|
|
16654
|
+
const shown = planDisplay(deps, plan);
|
|
16655
|
+
const { value, error } = await new Promise(resolve => {
|
|
16656
|
+
applyInstanceConfigPatch(this, instanceName, { backend: plan.backend, model: plan.model, effort: plan.effort }, (value, error) => resolve({ value, error }));
|
|
16657
|
+
});
|
|
16658
|
+
if (error)
|
|
16659
|
+
return t("backend.refused", instanceName, error);
|
|
16660
|
+
const result = (value ?? {});
|
|
16661
|
+
if (result.warning)
|
|
16662
|
+
return t("backend.restart_failed", instanceName, plan.backend, result.warning);
|
|
16663
|
+
if (result.restarted)
|
|
16664
|
+
return t("backend.switched", instanceName, plan.backend, shown.model, shown.effort);
|
|
16665
|
+
return t("backend.switched_not_running", instanceName, plan.backend, shown.model, shown.effort, this.getInstanceStatus(instanceName));
|
|
16666
|
+
}
|
|
16667
|
+
/** A step's buttons: index callbacks (a model id can be longer than Telegram's 64-byte callback data allows). */
|
|
16668
|
+
backendStepButtons(instanceName, nonce, step) {
|
|
16669
|
+
if (step.kind === "choose")
|
|
16670
|
+
return step.choices.map((c, i) => ({ id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:${i}`, label: c.label }));
|
|
16671
|
+
if (step.kind !== "confirm")
|
|
16672
|
+
return [];
|
|
16673
|
+
const running = this.getInstanceStatus(instanceName) === "running";
|
|
16674
|
+
return [
|
|
16675
|
+
{ id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:0`, label: t(running ? "backend.confirm_button" : "backend.confirm_button_stopped") },
|
|
16676
|
+
{ id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:1`, label: t("backend.cancel_button") },
|
|
16677
|
+
];
|
|
16678
|
+
}
|
|
16679
|
+
/**
|
|
16680
|
+
* Show a `/backend` step (a choice, or the confirmation) where the command was given: Discord edits its interaction
|
|
16681
|
+
* reply; Telegram retires the previous step's keyboard and posts the next. One pending entry per step, under a fresh
|
|
16682
|
+
* nonce: a click on an older step finds nothing.
|
|
16683
|
+
*/
|
|
16684
|
+
async presentBackendStep(place, step) {
|
|
16685
|
+
const nonce = randomBytes(6).toString("hex");
|
|
16686
|
+
const buttons = this.backendStepButtons(place.instanceName, nonce, step);
|
|
16687
|
+
const timer = setTimeout(() => {
|
|
16688
|
+
const p = this.pendingBackendSelects.get(nonce);
|
|
16689
|
+
if (!p)
|
|
16690
|
+
return;
|
|
16691
|
+
this.pendingBackendSelects.delete(nonce);
|
|
16692
|
+
void this.endBackendMenu(p, t("backend.selection_expired")).catch(() => { });
|
|
16693
|
+
}, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
16694
|
+
timer.unref?.();
|
|
16695
|
+
const pending = { ...place, step, timer };
|
|
16696
|
+
this.pendingBackendSelects.set(nonce, pending);
|
|
16697
|
+
try {
|
|
16698
|
+
if (step.kind === "confirm" && place.respondButtons) {
|
|
16699
|
+
await place.respondButtons(step.text, buttons);
|
|
16700
|
+
return;
|
|
16701
|
+
}
|
|
16702
|
+
if (place.respondChoices) {
|
|
16703
|
+
await place.respondChoices(step.text, buttons);
|
|
16704
|
+
return;
|
|
16705
|
+
}
|
|
16706
|
+
if (!place.adapter || !place.adapterChatId)
|
|
16707
|
+
throw new Error("no place to show the menu");
|
|
16708
|
+
if (place.menuMessageId && place.adapter.editMessageRemoveButtons) {
|
|
16709
|
+
const chosen = t("backend.chosen", joinBackendArgs(step.args));
|
|
16710
|
+
await place.adapter.editMessageRemoveButtons(place.adapterChatId, place.menuMessageId, chosen, place.adapterThreadId).catch(() => { });
|
|
16711
|
+
}
|
|
16712
|
+
const menuMessageId = await place.adapter.promptUser(place.adapterChatId, step.text, buttons, { threadId: place.adapterThreadId });
|
|
16713
|
+
const fresh = this.pendingBackendSelects.get(nonce);
|
|
16714
|
+
if (fresh)
|
|
16715
|
+
fresh.menuMessageId = menuMessageId;
|
|
16716
|
+
}
|
|
16717
|
+
catch (err) {
|
|
16718
|
+
this.pendingBackendSelects.delete(nonce);
|
|
16719
|
+
clearTimeout(timer);
|
|
16720
|
+
this.logger.warn({ err, instanceName: place.instanceName }, "backend menu failed");
|
|
16721
|
+
await place.respond(t("backend.usage")).catch(() => { });
|
|
16722
|
+
}
|
|
16723
|
+
}
|
|
16724
|
+
/** Close a `/backend` menu with a last word: its buttons go (Telegram edits them away), the text stays. Returns where it was said. */
|
|
16725
|
+
async endBackendMenu(pending, text) {
|
|
16726
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
16727
|
+
if (pending.menuMessageId && pending.adapter.editMessageRemoveButtons) {
|
|
16728
|
+
try {
|
|
16729
|
+
await pending.adapter.editMessageRemoveButtons(pending.adapterChatId, pending.menuMessageId, text, pending.adapterThreadId);
|
|
16730
|
+
return pending.menuMessageId;
|
|
16731
|
+
}
|
|
16732
|
+
catch { /* say it in a new message */ }
|
|
16733
|
+
}
|
|
16734
|
+
return (await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId })).messageId;
|
|
16735
|
+
}
|
|
16736
|
+
if (pending.retireButtons)
|
|
16737
|
+
await pending.retireButtons(text);
|
|
16738
|
+
else
|
|
16739
|
+
await pending.respond(text);
|
|
16740
|
+
return undefined;
|
|
16741
|
+
}
|
|
16742
|
+
/** Start `/backend` for a fleet instance from Discord or Telegram; the caller has checked the channel admin. */
|
|
16743
|
+
async beginBackendCommand(place, args) {
|
|
16744
|
+
const refusal = this.backendCommandRefusal(place.instanceName);
|
|
16745
|
+
if (refusal) {
|
|
16746
|
+
await place.respond(refusal);
|
|
16747
|
+
return;
|
|
16748
|
+
}
|
|
16749
|
+
if (!place.respondChoices && !(place.adapter && place.adapterChatId)) {
|
|
16750
|
+
await place.respond(t("backend.usage"));
|
|
16751
|
+
return;
|
|
16752
|
+
}
|
|
16753
|
+
place = { ...place, target: this.fleetConfig.instances[place.instanceName] };
|
|
16754
|
+
const deps = this.backendCommandDeps(place.instanceName, {
|
|
16755
|
+
live: true,
|
|
16756
|
+
onLiveProbe: () => { void place.respond(t("model.refreshing")).catch(() => { }); },
|
|
16757
|
+
});
|
|
16758
|
+
const step = await backendStep(deps, place.instanceName, args);
|
|
16759
|
+
if (step.kind === "refuse") {
|
|
16760
|
+
await place.respond(step.text);
|
|
16761
|
+
return;
|
|
16762
|
+
}
|
|
16763
|
+
await this.presentBackendStep(place, step);
|
|
16764
|
+
}
|
|
16765
|
+
/** `/backend` slash handler: channel admin, as `/model`. Options or typed text give the direct form. */
|
|
16766
|
+
async handleBackendSlash(data, adapterId) {
|
|
16767
|
+
if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
|
|
16768
|
+
await data.respond(t("permission.denied"));
|
|
16769
|
+
return;
|
|
16770
|
+
}
|
|
16771
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
16772
|
+
if (!name) {
|
|
16773
|
+
await data.respond(t("classic.no_agent"));
|
|
16774
|
+
return;
|
|
16775
|
+
}
|
|
16776
|
+
const option = (key) => typeof data.options?.[key] === "string" ? data.options[key].trim() : "";
|
|
16777
|
+
const given = [option("backend"), option("model"), option("effort")];
|
|
16778
|
+
// A later option without the one before it says nothing that can be judged in order.
|
|
16779
|
+
if (given.some((value, i) => value && given.slice(0, i).some(before => !before))) {
|
|
16780
|
+
await data.respond(t("backend.usage"));
|
|
16781
|
+
return;
|
|
16782
|
+
}
|
|
16783
|
+
const args = given[0] ? given.filter(Boolean) : backendArgs(data.text ?? "");
|
|
16784
|
+
await this.beginBackendCommand({ instanceName: name, userId: data.userId, channelId: data.channelId, adapterId, respond: data.respond, respondChoices: data.respondChoices,
|
|
16785
|
+
respondButtons: data.respondButtons, retireButtons: data.retireButtons }, args);
|
|
16786
|
+
}
|
|
16787
|
+
/** Telegram `/backend` in a fleet topic (topic-commands) or a ClassicBot chat; the caller has checked the admin. */
|
|
16788
|
+
async runBackendCommand(instanceName, text, userId, channelId, adapter, chatId, threadId, adapterId) {
|
|
16789
|
+
const respond = async (reply) => (await adapter.sendText(chatId, reply, { threadId })).messageId;
|
|
16790
|
+
await this.beginBackendCommand({ instanceName, userId, channelId, adapterId, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId }, backendArgs(text.replace(/^\/backend(@\S+)?/, "")));
|
|
16791
|
+
}
|
|
16792
|
+
/** Consume a `/backend` click: the next step, or on the confirmation the switch itself. True for every backend-select id. */
|
|
16793
|
+
async handleBackendSelection(data, adapterId) {
|
|
16794
|
+
if (!data.callbackData.startsWith(BACKEND_SELECT_CALLBACK_PREFIX))
|
|
16795
|
+
return false;
|
|
16796
|
+
const match = data.callbackData.match(/^backend-select:([0-9a-f]+):(\d{1,2})$/);
|
|
16797
|
+
if (!match)
|
|
16798
|
+
return true;
|
|
16799
|
+
const pending = this.pendingBackendSelects.get(match[1]);
|
|
16800
|
+
if (!pending)
|
|
16801
|
+
return true;
|
|
16802
|
+
// As a /model click (#754 audit): the admin who opened it, through the same bot, in the same channel, still admin.
|
|
16803
|
+
const cbChannel = data.threadId ?? data.chatId;
|
|
16804
|
+
if (!data.userId || data.userId !== pending.userId
|
|
16805
|
+
|| (pending.adapterId !== undefined && pending.adapterId !== adapterId)
|
|
16806
|
+
|| (cbChannel !== pending.channelId && data.chatId !== pending.channelId)
|
|
16807
|
+
|| !this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)) {
|
|
16808
|
+
data.ack?.(t("buttons.admin_only"));
|
|
16809
|
+
return true;
|
|
16810
|
+
}
|
|
16811
|
+
this.pendingBackendSelects.delete(match[1]);
|
|
16812
|
+
clearTimeout(pending.timer);
|
|
16813
|
+
if (!pending.menuMessageId && data.messageId)
|
|
16814
|
+
pending.menuMessageId = data.messageId;
|
|
16815
|
+
const index = Number(match[2]);
|
|
16816
|
+
const { step } = pending;
|
|
16817
|
+
if (step.kind === "choose") {
|
|
16818
|
+
const choice = step.choices[index];
|
|
16819
|
+
if (!choice)
|
|
16820
|
+
return true;
|
|
16821
|
+
if (!this.backendTargetIs(pending.instanceName, pending.target)) {
|
|
16822
|
+
await this.endBackendMenu(pending, t("menu.click_stale")).catch(() => { });
|
|
16823
|
+
return true;
|
|
16824
|
+
}
|
|
16825
|
+
const next = await backendStep(this.backendCommandDeps(pending.instanceName, { live: true }), pending.instanceName, [...step.args, choice.token]);
|
|
16826
|
+
if (next.kind === "refuse") {
|
|
16827
|
+
await this.endBackendMenu(pending, next.text).catch(() => { });
|
|
16828
|
+
return true;
|
|
16829
|
+
}
|
|
16830
|
+
await this.presentBackendStep(pending, next);
|
|
16831
|
+
return true;
|
|
16832
|
+
}
|
|
16833
|
+
if (index === 1) {
|
|
16834
|
+
await this.endBackendMenu(pending, t("backend.cancelled")).catch(() => { });
|
|
16835
|
+
return true;
|
|
16836
|
+
}
|
|
16837
|
+
if (index !== 0)
|
|
16838
|
+
return true;
|
|
16839
|
+
// Confirmed: say so at once, then switch in the background (the relaunch takes a while) and say how it went.
|
|
16840
|
+
const progressId = await this.endBackendMenu(pending, t("backend.switching", pending.instanceName, step.plan.backend)).catch(() => undefined);
|
|
16841
|
+
const say = async (text) => {
|
|
16842
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
16843
|
+
if (progressId)
|
|
16844
|
+
await pending.adapter.editMessage(pending.adapterChatId, progressId, text, pending.adapterThreadId);
|
|
16845
|
+
else
|
|
16846
|
+
await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
|
|
16847
|
+
}
|
|
16848
|
+
else
|
|
16849
|
+
await pending.respond(text);
|
|
16850
|
+
};
|
|
16851
|
+
void (async () => {
|
|
16852
|
+
let result;
|
|
16853
|
+
try {
|
|
16854
|
+
// Asked again after the awaits above: the instance may have moved, or the admin lost admin.
|
|
16855
|
+
const stillCurrent = () => this.backendTargetIs(pending.instanceName, pending.target)
|
|
16856
|
+
&& this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId);
|
|
16857
|
+
result = stillCurrent()
|
|
16858
|
+
? await withSpawnHoldReply(say, () => this.applyBackendSwitch(pending.instanceName, step.args, step.from, stillCurrent))
|
|
16859
|
+
: t("menu.click_stale");
|
|
16860
|
+
}
|
|
16861
|
+
catch (err) {
|
|
16862
|
+
this.logger.error({ err, instance: pending.instanceName, backend: step.plan.backend }, "Backend switch failed");
|
|
16863
|
+
result = t("backend.failed", pending.instanceName, step.plan.backend, err instanceof Error ? err.message : String(err));
|
|
16864
|
+
}
|
|
16865
|
+
await say(result).catch(() => {
|
|
16866
|
+
if (pending.adapter && pending.adapterChatId)
|
|
16867
|
+
pending.adapter.sendText(pending.adapterChatId, result, { threadId: pending.adapterThreadId }).catch(() => { });
|
|
16868
|
+
});
|
|
16869
|
+
})();
|
|
16870
|
+
return true;
|
|
16871
|
+
}
|
|
15157
16872
|
/** Read recent chat log for agent context */
|
|
15158
16873
|
getRecentChatLog(instanceName, maxLines = 10) {
|
|
15159
16874
|
const logDir = ClassicChannelManager.chatLogDir(instanceName);
|
|
@@ -15247,6 +16962,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15247
16962
|
}
|
|
15248
16963
|
/** Handle Discord's required static slash choice, warning before a likely startup failure. */
|
|
15249
16964
|
async handleClassicStartSlash(data, adapterId) {
|
|
16965
|
+
return withSpawnHoldReply(text => data.respond(text), () => this.handleClassicStartSlashCore(data, adapterId));
|
|
16966
|
+
}
|
|
16967
|
+
async handleClassicStartSlashCore(data, adapterId) {
|
|
15250
16968
|
const requestedBackend = typeof data.options?.backend === "string" ? data.options.backend : undefined;
|
|
15251
16969
|
if (!requestedBackend) {
|
|
15252
16970
|
// beta.31 made this option required. Discord can briefly retain the old
|
|
@@ -15363,7 +17081,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15363
17081
|
// failure. The selected backend is still attempted as requested.
|
|
15364
17082
|
if (warning)
|
|
15365
17083
|
await pending.complete(warning, pending.messageId);
|
|
15366
|
-
const reply = await this.handleClassicStart(pending.channelId, pending.channelName, pending.userId, pending.guildId, pending.adapterId, selectedBackend);
|
|
17084
|
+
const reply = await withSpawnHoldReply(text => pending.complete(text, pending.messageId), () => this.handleClassicStart(pending.channelId, pending.channelName, pending.userId, pending.guildId, pending.adapterId, selectedBackend));
|
|
15367
17085
|
await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
|
|
15368
17086
|
}
|
|
15369
17087
|
/** Start a classic channel instance with lightweight config */
|
|
@@ -15444,12 +17162,29 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15444
17162
|
const ch = this.classicChannels.unregister(channelId, adapterId);
|
|
15445
17163
|
if (!ch)
|
|
15446
17164
|
return t("classic.no_agent");
|
|
17165
|
+
// #1664: the room is gone (not paused or stopped): nobody's favorite any more — unless the name is still an
|
|
17166
|
+
// instance (fleet.yaml's, or another room's), so a later room of the same name does not inherit an old star.
|
|
17167
|
+
if (!Object.hasOwn(this.fleetConfig?.instances ?? {}, ch.instanceName)
|
|
17168
|
+
&& !this.classicChannels.getAll().some((c) => c.instanceName === ch.instanceName)) {
|
|
17169
|
+
try {
|
|
17170
|
+
this.webPrefs.forgetInstance(ch.instanceName);
|
|
17171
|
+
}
|
|
17172
|
+
catch (err) {
|
|
17173
|
+
this.logger.warn({ err: err.message, instanceName: ch.instanceName }, "Dropping a removed room from web favorites failed");
|
|
17174
|
+
}
|
|
17175
|
+
}
|
|
15447
17176
|
this.instanceWorldBinding.delete(ch.instanceName);
|
|
15448
|
-
|
|
17177
|
+
// #1716: say so in the reply when background work was ended with the stop.
|
|
17178
|
+
// Null-safe: a foreign stopInstance stub may resolve nothing at all.
|
|
17179
|
+
const stopOutcome = await this.stopInstance(ch.instanceName).catch(err => {
|
|
17180
|
+
this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance");
|
|
17181
|
+
return { backgroundWorkEnded: false };
|
|
17182
|
+
});
|
|
15449
17183
|
clearPausedMarker(this.getInstanceDir(ch.instanceName));
|
|
15450
17184
|
this.reregisterClassicChannels();
|
|
15451
17185
|
this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
|
|
15452
|
-
return t("classic.stopped")
|
|
17186
|
+
return t("classic.stopped")
|
|
17187
|
+
+ (stopOutcome?.backgroundWorkEnded ? `\n${t("classic.stopped_bg_work")}` : "");
|
|
15453
17188
|
}
|
|
15454
17189
|
/**
|
|
15455
17190
|
* Idempotent while in flight: SIGINT and SIGTERM share one handler and the
|
|
@@ -15545,6 +17280,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15545
17280
|
clearInterval(this.eventLogPruneTimer);
|
|
15546
17281
|
this.eventLogPruneTimer = null;
|
|
15547
17282
|
}
|
|
17283
|
+
this.logService?.unwatchAll(); // #1218: a Log's watch timers end with the fleet
|
|
15548
17284
|
if (this.outboxPruneTimer) {
|
|
15549
17285
|
clearInterval(this.outboxPruneTimer);
|
|
15550
17286
|
this.outboxPruneTimer = null;
|
|
@@ -15644,8 +17380,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15644
17380
|
this.controlClient = null;
|
|
15645
17381
|
if (this.healthServer) {
|
|
15646
17382
|
this.healthServerListening = false;
|
|
15647
|
-
this.healthServer
|
|
15648
|
-
this.
|
|
17383
|
+
const server = this.healthServer;
|
|
17384
|
+
const drain = this.healthServerDrain ?? trackWebServer(server);
|
|
17385
|
+
await drain.close(this.sseClients);
|
|
17386
|
+
if (this.healthServer === server) {
|
|
17387
|
+
this.healthServer = null;
|
|
17388
|
+
this.healthServerDrain = null;
|
|
17389
|
+
}
|
|
15649
17390
|
}
|
|
15650
17391
|
this.stopPreviewListener();
|
|
15651
17392
|
// The store writes lastSeen at most once a minute; what that debounce is still holding — and any
|
|
@@ -15974,8 +17715,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15974
17715
|
if (old.backend !== backend || old.model !== model || old.autoPauseAfter !== autoPauseAfter || profile.changed) {
|
|
15975
17716
|
this.logger.info({ instanceName: ch.instanceName, ...(profile.changed ? { profileFrom: profile.from, profileTo: profile.to } : {}) }, "Classic cold config changed — restarting");
|
|
15976
17717
|
observe?.(ch.instanceName, "restart", "running");
|
|
17718
|
+
const launchedIn = this.daemons.get(ch.instanceName)?.getConfigSnapshot?.()?.working_directory;
|
|
15977
17719
|
await this.stopInstance(ch.instanceName).catch(() => { });
|
|
15978
|
-
|
|
17720
|
+
// #1753: the conversation goes along where it can; otherwise a switch that loses it starts fresh.
|
|
17721
|
+
if (profile.carry)
|
|
17722
|
+
await this.switchConversation(ch.instanceName, { ...profile.carry, workingDirectory: launchedIn });
|
|
17723
|
+
else if (profile.startsFresh)
|
|
15979
17724
|
this.writeFreshStartMarker(ch.instanceName);
|
|
15980
17725
|
await this.startClassicInstanceUnattended(ch, "classic instance after fleet reload");
|
|
15981
17726
|
observe?.(ch.instanceName, "restart", "done");
|
|
@@ -17614,7 +19359,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17614
19359
|
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)");
|
|
17615
19360
|
}
|
|
17616
19361
|
/** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
|
|
17617
|
-
dispatchWebHttp(req, res, port) {
|
|
19362
|
+
dispatchWebHttp(req, res, port, closing = false) {
|
|
17618
19363
|
try {
|
|
17619
19364
|
res.setHeader("Content-Type", "application/json");
|
|
17620
19365
|
// No Referer to a tunnel host, an upstream proxy, or any page linked from
|
|
@@ -17633,6 +19378,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17633
19378
|
res.end(JSON.stringify({ error: WEB_HOST_REJECTED_MESSAGE }));
|
|
17634
19379
|
return;
|
|
17635
19380
|
}
|
|
19381
|
+
if (closing) {
|
|
19382
|
+
res.writeHead(503, { Connection: "close", "Retry-After": "10" });
|
|
19383
|
+
res.end(JSON.stringify({ error: "fleet stopping" }));
|
|
19384
|
+
return;
|
|
19385
|
+
}
|
|
17636
19386
|
const requestPath = new URL(req.url ?? "/", `http://localhost:${port}`).pathname;
|
|
17637
19387
|
// Branding is public, after Host admission: automatic favicon requests
|
|
17638
19388
|
// must not become noisy web-token 401s in the browser console.
|
|
@@ -17818,14 +19568,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17818
19568
|
return;
|
|
17819
19569
|
}
|
|
17820
19570
|
(async () => {
|
|
19571
|
+
let held = false;
|
|
17821
19572
|
try {
|
|
17822
19573
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
17823
|
-
await
|
|
19574
|
+
await withSpawnHoldReply(async (message) => {
|
|
19575
|
+
held = true;
|
|
19576
|
+
res.writeHead(202, { "Content-Type": "application/json" });
|
|
19577
|
+
res.end(JSON.stringify({ held: true, message, instance: name }));
|
|
19578
|
+
}, () => this.startInstance(name, config, topicMode ?? false, "fleet-topic", true));
|
|
17824
19579
|
this.emitSseEvent("status", this.getUiStatus());
|
|
19580
|
+
if (held)
|
|
19581
|
+
return;
|
|
17825
19582
|
res.writeHead(200);
|
|
17826
19583
|
res.end(JSON.stringify({ ok: true }));
|
|
17827
19584
|
}
|
|
17828
19585
|
catch (err) {
|
|
19586
|
+
if (held) {
|
|
19587
|
+
this.logger.error({ err, name }, "Held start failed");
|
|
19588
|
+
return;
|
|
19589
|
+
}
|
|
17829
19590
|
res.writeHead(500);
|
|
17830
19591
|
res.end(JSON.stringify({ error: `Start failed: ${err.message}` }));
|
|
17831
19592
|
}
|
|
@@ -17840,15 +19601,24 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17840
19601
|
const name = decodeURIComponent(req.url.slice("/restart/".length));
|
|
17841
19602
|
this.logger.info({ name }, "Instance restart requested via HTTP");
|
|
17842
19603
|
(async () => {
|
|
19604
|
+
let held = false;
|
|
17843
19605
|
try {
|
|
17844
|
-
await
|
|
19606
|
+
await withSpawnHoldReply(async (message) => {
|
|
19607
|
+
held = true;
|
|
19608
|
+
res.writeHead(202, { "Content-Type": "application/json" });
|
|
19609
|
+
res.end(JSON.stringify({ held: true, message, instance: name }));
|
|
19610
|
+
}, () => this.restartSingleInstance(name, { explicit: true }));
|
|
17845
19611
|
this.logger.info({ name }, "Instance restarted");
|
|
17846
19612
|
this.emitSseEvent("status", this.getUiStatus());
|
|
19613
|
+
if (held)
|
|
19614
|
+
return;
|
|
17847
19615
|
res.writeHead(200);
|
|
17848
19616
|
res.end(JSON.stringify({ restarted: name }));
|
|
17849
19617
|
}
|
|
17850
19618
|
catch (err) {
|
|
17851
19619
|
this.logger.error({ err, name }, "Instance restart failed");
|
|
19620
|
+
if (held)
|
|
19621
|
+
return;
|
|
17852
19622
|
const status = err.message.includes("not found") ? 404 : 500;
|
|
17853
19623
|
res.writeHead(status);
|
|
17854
19624
|
res.end(JSON.stringify({ error: `Restart failed: ${err.message}` }));
|
|
@@ -17931,7 +19701,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17931
19701
|
// Defensive for direct/unit callers; normal startup initializes these before adapters.
|
|
17932
19702
|
if (!this.webToken || !this.webSessions)
|
|
17933
19703
|
this.initializeWebAuthTokens();
|
|
17934
|
-
|
|
19704
|
+
const server = createServer((req, res) => this.dispatchWebHttp(req, res, port, drain.closing));
|
|
19705
|
+
const drain = trackWebServer(server);
|
|
19706
|
+
this.healthServer = server;
|
|
19707
|
+
this.healthServerDrain = drain;
|
|
17935
19708
|
const markListening = (afterTakeover = false) => {
|
|
17936
19709
|
this.healthServerListening = true;
|
|
17937
19710
|
this.logger.info({ port }, afterTakeover
|
|
@@ -17940,7 +19713,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17940
19713
|
// Never the token: fleet.log is readable by anything that can read the
|
|
17941
19714
|
// data dir, is copied into bug reports, and is tailed in shared terminals.
|
|
17942
19715
|
// `/dashboard` and `agend web` are the ways to get an authorized link.
|
|
17943
|
-
this.logger.info({ url: `http://localhost:${port}/ui` }, "Web UI available (open it with /
|
|
19716
|
+
this.logger.info({ url: `http://localhost:${port}/ui` }, "Web UI available (open it with /web or `agend web`)");
|
|
17944
19717
|
this.logger.info({ url: `http://localhost:${port}/view` }, "Web View available");
|
|
17945
19718
|
// #1306: the preview listener starts once the web listener is bound — its frame-ancestors name the real port.
|
|
17946
19719
|
const bound = this.healthServer?.address();
|
|
@@ -18017,7 +19790,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18017
19790
|
this.logger.warn({ health_port: requestedPort }, "No port for the HTML preview listener (health_port is the highest port) — set web.preview_port; previews are off");
|
|
18018
19791
|
return;
|
|
18019
19792
|
}
|
|
18020
|
-
const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig
|
|
19793
|
+
const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig,
|
|
19794
|
+
// #1723: with a web.preview_origin, the open public link's dashboard frames this listener's preview too.
|
|
19795
|
+
extraOrigins: () => { const o = settings.origin ? this.publicWebLink?.openDashboardOrigin() : null; return o ? [o] : []; } });
|
|
18021
19796
|
this.previewListener = listener;
|
|
18022
19797
|
listener.server.on("error", (err) => {
|
|
18023
19798
|
if (this.previewListener !== listener)
|
|
@@ -18066,6 +19841,37 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18066
19841
|
decided.reason = "Previews are not available on this fleet right now.";
|
|
18067
19842
|
return { ...decided, boot: listener ? listener.bootId : null };
|
|
18068
19843
|
}
|
|
19844
|
+
/**
|
|
19845
|
+
* #1723: for one page of the public link (exposure `exposureId`, at `dashboardOrigin`): its own preview tunnel's
|
|
19846
|
+
* origin once up, "starting" until then, why it failed if it did — or a configured web.preview_origin, used instead.
|
|
19847
|
+
*/
|
|
19848
|
+
publicPreviewForUi(exposureId, dashboardOrigin) {
|
|
19849
|
+
const web = this.fleetConfig?.web;
|
|
19850
|
+
const off = (code, reason, extra = {}) => ({ dashboardOrigin, previewOrigin: null, reason, code, boot: null, frameSrc: null, ...extra });
|
|
19851
|
+
if (web?.preview === false)
|
|
19852
|
+
return off("fleetOff", "Previews are turned off for this fleet (web.preview: false).");
|
|
19853
|
+
if (web?.preview_on_public_link === false)
|
|
19854
|
+
return off("publicLink", "Previews are off on the public link (web.preview_on_public_link: false).");
|
|
19855
|
+
const configured = typeof web?.preview_origin === "string" ? normalizeOrigin(web.preview_origin) : null;
|
|
19856
|
+
if (configured) {
|
|
19857
|
+
const listener = this.previewListening ? this.previewListener : null;
|
|
19858
|
+
if (!listener)
|
|
19859
|
+
return off("publicLink", "Previews are not available on this fleet right now.");
|
|
19860
|
+
if (!configured.startsWith("https:"))
|
|
19861
|
+
return off("needHttps", "Previews need an https web.preview_origin when the dashboard is reached over https.");
|
|
19862
|
+
return { dashboardOrigin, previewOrigin: configured, reason: null, code: null, boot: listener.bootId, frameSrc: `${configured}/frame` };
|
|
19863
|
+
}
|
|
19864
|
+
const status = this.publicWebLink?.previewStatus(exposureId) ?? null;
|
|
19865
|
+
if (!status)
|
|
19866
|
+
return off("publicLink", "Previews are not offered on this public link.");
|
|
19867
|
+
// The page may load before the preview tunnel has a host: it frames the quick-tunnel wildcard, and only ever the
|
|
19868
|
+
// origin /ui/preview-status names (the preview side's exact frame-ancestors is the boundary).
|
|
19869
|
+
if (status.state === "starting")
|
|
19870
|
+
return off("publicLinkStarting", "Preview is starting…", { frameSrc: PUBLIC_PREVIEW_FRAME_SRC });
|
|
19871
|
+
if (status.state === "failed")
|
|
19872
|
+
return off("publicLinkPreviewFailed", `Previews are unavailable on this public link (${status.reason}).`, { detail: status.reason });
|
|
19873
|
+
return { dashboardOrigin, previewOrigin: status.origin, reason: null, code: null, boot: status.boot, frameSrc: PUBLIC_PREVIEW_FRAME_SRC };
|
|
19874
|
+
}
|
|
18069
19875
|
getUiStatus() {
|
|
18070
19876
|
return measureSyncWork("fleet.uiStatus", () => this.getUiStatusSync());
|
|
18071
19877
|
}
|
|
@@ -18082,6 +19888,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18082
19888
|
// /api/profiles (resolveInstanceIdentity) — the sidebar groups, searches and labels with it on every page.
|
|
18083
19889
|
const classicRooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
|
|
18084
19890
|
const profiles = profileIdentities(this.dataDir);
|
|
19891
|
+
// #1630: decorative — whatever goes wrong deciding an avatar never costs the frame (every instance gets its first character).
|
|
19892
|
+
let ownBots = new Map();
|
|
19893
|
+
try {
|
|
19894
|
+
ownBots = this.ownBots();
|
|
19895
|
+
}
|
|
19896
|
+
catch (err) {
|
|
19897
|
+
this.logger.debug({ err }, "own bots unreadable (getUiStatus)");
|
|
19898
|
+
}
|
|
18085
19899
|
const instances = names.map(name => {
|
|
18086
19900
|
const statusFile = join(this.getInstanceDir(name), "statusline.json");
|
|
18087
19901
|
let cost = 0;
|
|
@@ -18119,9 +19933,19 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18119
19933
|
const effort = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.effort;
|
|
18120
19934
|
const effort_source = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.source;
|
|
18121
19935
|
const identity = resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic: classicRooms.get(name), profile: profiles.get(name) });
|
|
19936
|
+
let image;
|
|
19937
|
+
try {
|
|
19938
|
+
image = this.webIdentityImage(name, identity.display_name, profiles.get(name)?.avatar_path, ownBots);
|
|
19939
|
+
}
|
|
19940
|
+
catch (err) {
|
|
19941
|
+
this.logger.debug({ err, name }, "avatar unreadable (getUiStatus)");
|
|
19942
|
+
image = { avatar: avatarFor({ name, displayName: identity.display_name }) };
|
|
19943
|
+
}
|
|
18122
19944
|
return {
|
|
18123
19945
|
name,
|
|
18124
19946
|
display_name: identity.display_name || undefined,
|
|
19947
|
+
avatar: image.avatar,
|
|
19948
|
+
...(image.accent ? { accent: image.accent } : {}),
|
|
18125
19949
|
description: identity.description || undefined,
|
|
18126
19950
|
role: identity.role || undefined,
|
|
18127
19951
|
tags: identity.tags,
|
|
@@ -18144,6 +19968,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18144
19968
|
uptime: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
18145
19969
|
// #1519 P7: how many chat connections are configured — 0 is a web-only fleet, which gets the first-run card.
|
|
18146
19970
|
connections: (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).length,
|
|
19971
|
+
// #1665: the host preferences' revision — a page that has another re-reads /ui/prefs (no values in this frame).
|
|
19972
|
+
prefs_rev: this.webPrefs.rev(),
|
|
19973
|
+
// #1685: host memory pressure while it slows or holds new agents (the dashboard's indicator; macOS elevated is
|
|
19974
|
+
// shown here rather than in chat). null when normal, unknown or advisory.
|
|
19975
|
+
host_memory: this.hostMemoryIndicator(),
|
|
18147
19976
|
};
|
|
18148
19977
|
}
|
|
18149
19978
|
}
|