@songsid/agend 2.2.0-beta.2 → 2.2.0-beta.20
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 +40 -24
- package/README.zh-TW.md +40 -24
- package/dist/agent-cli-instructions.md +6 -2
- package/dist/agent-cli.js +15 -7
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.js +3 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-whats-new.js +16 -4
- package/dist/agent-whats-new.js.map +1 -1
- package/dist/auth-api.d.ts +3 -1
- package/dist/auth-api.js +17 -1
- package/dist/auth-api.js.map +1 -1
- package/dist/backend/antigravity.d.ts +33 -0
- package/dist/backend/antigravity.js +126 -18
- 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.d.ts +27 -1
- package/dist/backend/kiro.js +63 -10
- 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 +48 -2
- 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 +26 -1
- package/dist/channel/adapters/discord.js +103 -3
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +35 -3
- package/dist/channel/adapters/telegram.js +122 -23
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-tools.js +5 -3
- 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 +6 -5
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +46 -4
- package/dist/cli.js +184 -34
- package/dist/cli.js.map +1 -1
- package/dist/command-table.d.ts +2 -2
- package/dist/command-table.js +5 -2
- package/dist/command-table.js.map +1 -1
- package/dist/compact-watch.d.ts +86 -0
- package/dist/compact-watch.js +288 -0
- package/dist/compact-watch.js.map +1 -0
- package/dist/config-validator.js +55 -5
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +2 -1
- package/dist/config.js.map +1 -1
- package/dist/cross-instance-envelope.js +2 -0
- package/dist/cross-instance-envelope.js.map +1 -1
- package/dist/daemon.d.ts +129 -1
- package/dist/daemon.js +754 -94
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +64 -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/doctor.js +7 -0
- package/dist/doctor.js.map +1 -1
- package/dist/fleet-context.d.ts +12 -1
- package/dist/fleet-manager.d.ts +392 -19
- package/dist/fleet-manager.js +2030 -275
- package/dist/fleet-manager.js.map +1 -1
- package/dist/forged-envelope-scan.d.ts +4 -3
- package/dist/forged-envelope-scan.js +4 -3
- package/dist/forged-envelope-scan.js.map +1 -1
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +84 -16
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +16 -1
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +4 -1
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +4 -0
- 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/install-lock.d.ts +18 -1
- package/dist/install-lock.js +30 -3
- package/dist/install-lock.js.map +1 -1
- package/dist/instance-avatar.d.ts +5 -4
- package/dist/instance-avatar.js +12 -8
- package/dist/instance-avatar.js.map +1 -1
- package/dist/instance-config-impact.js +1 -0
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +25 -6
- package/dist/instance-lifecycle.js +76 -27
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.d.ts +1 -1
- package/dist/instructions.js +3 -3
- package/dist/instructions.js.map +1 -1
- package/dist/kiro-compact-pane.d.ts +38 -0
- package/dist/kiro-compact-pane.js +57 -0
- package/dist/kiro-compact-pane.js.map +1 -0
- 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/launchd-handoff.d.ts +43 -0
- package/dist/launchd-handoff.js +56 -0
- package/dist/launchd-handoff.js.map +1 -0
- package/dist/locale.js +268 -42
- 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/login-shell.d.ts +21 -0
- package/dist/login-shell.js +98 -0
- package/dist/login-shell.js.map +1 -0
- 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/outbound-handlers.d.ts +100 -0
- package/dist/outbound-handlers.js +277 -93
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +30 -15
- package/dist/outbound-schemas.js +23 -5
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/provider-probe.d.ts +29 -0
- package/dist/provider-probe.js +198 -2
- package/dist/provider-probe.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.d.ts +2 -0
- package/dist/public-web-gateway.js +20 -6
- 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/quickstart-api.d.ts +1 -1
- package/dist/quickstart-api.js +8 -6
- package/dist/quickstart-api.js.map +1 -1
- package/dist/quickstart.d.ts +12 -0
- package/dist/quickstart.js +36 -6
- package/dist/quickstart.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 -2
- package/dist/restart-progress.js +30 -2
- package/dist/restart-progress.js.map +1 -1
- package/dist/sd-notify.d.ts +22 -1
- package/dist/sd-notify.js +63 -10
- package/dist/sd-notify.js.map +1 -1
- package/dist/service-activation.d.ts +15 -7
- package/dist/service-activation.js +42 -26
- package/dist/service-activation.js.map +1 -1
- package/dist/service-installer.d.ts +55 -0
- package/dist/service-installer.js +90 -3
- package/dist/service-installer.js.map +1 -1
- package/dist/service-plan.d.ts +11 -0
- package/dist/service-plan.js +23 -0
- package/dist/service-plan.js.map +1 -1
- package/dist/settings-api.js +14 -4
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +4 -2
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +2 -0
- package/dist/settings-effect.js.map +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/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 +1 -1
- 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 +71 -4
- package/dist/topic-commands.js +167 -34
- 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 +85 -3
- package/dist/transcript-jsonl.js +156 -13
- package/dist/transcript-jsonl.js.map +1 -1
- package/dist/transcript-monitor.d.ts +11 -0
- package/dist/transcript-monitor.js +61 -13
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +24 -0
- package/dist/transcript-sources.js +211 -56
- package/dist/transcript-sources.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 +22 -0
- package/dist/ui/chat-commands.js +1 -0
- package/dist/ui/chat-mentions.js +63 -0
- package/dist/ui/chat-render.js +80 -10
- package/dist/ui/chat-store.js +70 -6
- package/dist/ui/chat-strings.js +62 -32
- package/dist/ui/chat-thread.js +199 -24
- package/dist/ui/chat-time.js +80 -0
- package/dist/ui/chat-tour.js +17 -14
- package/dist/ui/panel-chat.js +45 -21
- package/dist/ui/panel-details.js +2 -0
- package/dist/ui/panel-fleet.js +213 -58
- package/dist/ui/panel-log.js +428 -0
- package/dist/ui/panel-needs.js +14 -10
- package/dist/ui/panel-settings.js +82 -17
- 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 +11 -6
- package/dist/ui/settings-model.js +3 -1
- package/dist/ui/settings-strings.js +32 -12
- package/dist/ui/settings-token.js +8 -1
- package/dist/ui/settings-wizard.js +5 -3
- package/dist/ui/shared/app-i18n.js +41 -15
- package/dist/ui/shared/app-prefs.js +250 -0
- package/dist/ui/shared/app-route.js +6 -1
- 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 +170 -30
- package/dist/ui/shared/app.js +11 -1
- package/dist/ui/shared/fonts/README.md +1 -1
- package/dist/ui/shared/header-tools.js +83 -13
- package/dist/ui/shared/instance-nav.js +178 -51
- package/dist/ui/shared/instance-switch.js +8 -5
- package/dist/ui/shared/panel-view.js +21 -4
- package/dist/ui/shared/signin.js +15 -9
- package/dist/ui/shared/theme.js +39 -7
- package/dist/ui/shared/tokens.css +1 -1
- package/dist/ui/shared/ui-icons.js +2 -0
- package/dist/ui/shared/usage-dialog.js +23 -5
- package/dist/ui/shared/view-strings.js +40 -4
- package/dist/ui/signin.html +8 -3
- 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 +13 -1
- package/dist/update-install.js +16 -4
- package/dist/update-install.js.map +1 -1
- package/dist/update-launch.d.ts +48 -1
- package/dist/update-launch.js +159 -5
- 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/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 +62 -13
- package/dist/usage/providers.js +256 -68
- 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.js +31 -9
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.d.ts +82 -4
- package/dist/web-api.js +632 -88
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.js +5 -2
- package/dist/web-auth.js.map +1 -1
- package/dist/web-chat-history.d.ts +26 -1
- package/dist/web-chat-history.js +47 -8
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-commands.d.ts +41 -5
- package/dist/web-commands.js +25 -7
- package/dist/web-commands.js.map +1 -1
- 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 +14 -0
- package/dist/web-session.js +11 -1
- package/dist/web-session.js.map +1 -1
- package/dist/web-settings-patch.d.ts +3 -1
- package/dist/web-settings-patch.js +4 -1
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-shell-routes.d.ts +4 -0
- package/dist/web-shell-routes.js +9 -0
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/web-upload.d.ts +135 -7
- package/dist/web-upload.js +315 -51
- package/dist/web-upload.js.map +1 -1
- package/launcher/install-admission.cjs +61 -6
- package/package.json +3 -3
- package/templates/launchd.plist.ejs +3 -1
- package/templates/systemd.service.ejs +2 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -1,24 +1,28 @@
|
|
|
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
|
-
import { createPublicWebGateway } from "./public-web-gateway.js";
|
|
10
|
+
import { createPublicWebGateway, PUBLIC_GATEWAY_RECEIVE_MS } 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
|
-
import { spawnSync } from "node:child_process";
|
|
22
26
|
import { freemem, totalmem, cpus, homedir } from "node:os";
|
|
23
27
|
import { createServer } from "node:http";
|
|
24
28
|
import { Worker } from "node:worker_threads";
|
|
@@ -27,20 +31,21 @@ import { join, dirname, basename, delimiter, isAbsolute, resolve as resolvePath
|
|
|
27
31
|
import { fileURLToPath } from "node:url";
|
|
28
32
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
29
33
|
import { fleetLabel } from "./fleet-label.js";
|
|
30
|
-
import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, updateProgressOperation, } from "./update-marker.js";
|
|
34
|
+
import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, sameUpdateMarker, setUpdateProgressStage, updateMarkerIdentity, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
|
|
31
35
|
import { formatUpdateProgress } from "./update-progress.js";
|
|
32
|
-
import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
|
|
36
|
+
import { sdNotify, sdNotifyBlocking, setSdNotifyWarn } from "./sd-notify.js";
|
|
33
37
|
import { readFleetMemory } from "./process-memory.js";
|
|
34
38
|
import { MemoryPressure } from "./memory-pressure.js";
|
|
35
39
|
import { replyDedupText, ReplyDeduper } from "./reply-dedup.js";
|
|
36
40
|
import { isMap, isScalar, parseDocument } from "yaml";
|
|
41
|
+
import { kiroLogGate } from "./kiro-log-gate.js";
|
|
37
42
|
const __filename = fileURLToPath(import.meta.url);
|
|
38
43
|
const __dirname = dirname(__filename);
|
|
39
44
|
/** Fallback access policy for a channel with no `access:` block — open (no gate). */
|
|
40
45
|
const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
|
|
41
46
|
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
47
|
import { isProbeableRouteTarget } from "./fleet-context.js";
|
|
43
|
-
import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
|
|
48
|
+
import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
|
|
44
49
|
import { EventLog } from "./event-log.js";
|
|
45
50
|
import { binaryProbe } from "./binary-probe.js";
|
|
46
51
|
import { classifySqliteOpenError } from "./sqlite-open-errors.js";
|
|
@@ -58,7 +63,9 @@ import { isWebChannelEcho, WEB_ECHO_PREFIX } from "./web-channel-echo.js";
|
|
|
58
63
|
import { TelegramAdapter } from "./channel/adapters/telegram.js";
|
|
59
64
|
import { createBackend } from "./backend/factory.js";
|
|
60
65
|
import { readEffortMetadata } from "./backend/effort-metadata.js";
|
|
61
|
-
import {
|
|
66
|
+
import { codexEffort, flagEffort } from "./backend/launch-effort.js";
|
|
67
|
+
import { codexMetadataHomes, readCodexModelEffortLevels, resolveCodexLaunchModel } from "./backend/codex-metadata.js";
|
|
68
|
+
import { CLI_ENV_TTL_MS, isModelCompatible, resolveBinary, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
|
|
62
69
|
import { createLogger, rotateLogIfNeeded } from "./logger.js";
|
|
63
70
|
import { processAttachments } from "./channel/attachment-handler.js";
|
|
64
71
|
import { routeToolCall } from "./channel/tool-router.js";
|
|
@@ -74,8 +81,9 @@ import { RoutingEngine } from "./routing-engine.js";
|
|
|
74
81
|
import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
|
|
75
82
|
import { TopicArchiver } from "./topic-archiver.js";
|
|
76
83
|
import { StatuslineWatcher } from "./statusline-watcher.js";
|
|
77
|
-
import { outboundHandlers } from "./outbound-handlers.js";
|
|
78
|
-
import {
|
|
84
|
+
import { applyInstanceConfigPatch, outboundHandlers } from "./outbound-handlers.js";
|
|
85
|
+
import { backendArgs, backendStep, joinBackendArgs, modelNameCarried, planDisplay } from "./backend-command.js";
|
|
86
|
+
import { DeliveryOutbox, DEFAULT_REPLY_OBLIGATION_TTL_MS, isSilentScheduleDelivery } from "./delivery-outbox.js";
|
|
79
87
|
// The target Daemon can legitimately wait 30 minutes for a busy pane before
|
|
80
88
|
// deciding to defer before submission. This is an alert threshold only: the
|
|
81
89
|
// active manager/target generation pair owns the lane until the daemon reports
|
|
@@ -127,11 +135,17 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
|
|
|
127
135
|
return tasks;
|
|
128
136
|
}
|
|
129
137
|
import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
|
|
138
|
+
import { LogService } from "./log-service.js";
|
|
130
139
|
import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
|
|
131
140
|
import { WebChatDiskStore } from "./web-chat-store.js";
|
|
141
|
+
import { SeenUsers, resolveMentions } from "./web-mentions.js";
|
|
142
|
+
import { WebPrefsStore } from "./web-prefs.js";
|
|
143
|
+
import { WEB_NAME_FALLBACK, safeWebName, validateWebName, webNameInUse, webPersonKey } from "./web-profile.js";
|
|
132
144
|
import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
|
|
133
145
|
import { ReplyButtonsController } from "./reply-buttons-controller.js";
|
|
134
|
-
import {
|
|
146
|
+
import { parsePoll } from "./reply-polls.js";
|
|
147
|
+
import { isUploadRequest, limitRequestReceive, publicAttachment, sweepOrphanedUploads, UPLOAD_REQUEST_TIMEOUT_MS, uploadLimits, WebFileLedger } from "./web-upload.js";
|
|
148
|
+
import { REPLY_FILES_MAX_BYTES, snapshotReplyFile, sweepReplySnapshots, webFilesDir } from "./web-reply-files.js";
|
|
135
149
|
import { handleViewRequest, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
|
|
136
150
|
import { downloadStickerImage } from "./channel/sticker-download.js";
|
|
137
151
|
import { WebImageCache, imageMimeForFile, WEB_IMAGE_MAX_AGE_MS, WEB_IMAGE_MAX_BYTES, WEB_IMAGE_RETRY_MS } from "./web-images.js";
|
|
@@ -143,14 +157,15 @@ import { isDisallowedIntentsError, isRejectedTokenError } from "./discord-permis
|
|
|
143
157
|
import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
|
|
144
158
|
import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
|
|
145
159
|
import { LoginSession } from "./login-manager.js";
|
|
160
|
+
import { runOnLoginShell } from "./login-shell.js";
|
|
146
161
|
import { tightenInstanceDirs } from "./private-dir.js";
|
|
147
162
|
import { decideSlash } from "./slash-authz.js";
|
|
148
163
|
import { canonicalCommand, commandSpec, decideCommand, ruleFor } from "./command-table.js";
|
|
149
164
|
import { runVisibilityCommand } from "./cross-instance-notice.js";
|
|
150
|
-
import { installedChannel,
|
|
165
|
+
import { installedChannel, updateNoticeKey } from "./update-check.js";
|
|
151
166
|
import { resolveInstalledAgend } from "./update-dispatch.js";
|
|
152
|
-
import { resolveUpdateLaunch, watchUpdateLaunch } from "./update-launch.js";
|
|
153
|
-
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";
|
|
154
169
|
import { runBeforeDeadline } from "./deadline.js";
|
|
155
170
|
import { LoginWindowLock } from "./login-window-lock.js";
|
|
156
171
|
import { handleSettingsRequest } from "./settings-api.js";
|
|
@@ -160,11 +175,12 @@ import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet
|
|
|
160
175
|
import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
|
|
161
176
|
import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
|
|
162
177
|
import { assertExplicitInstanceRemoval } from "./instance-removal.js";
|
|
163
|
-
import { validateFleetConfig } from "./config-validator.js";
|
|
178
|
+
import { KNOWN_BACKENDS, validateFleetConfig } from "./config-validator.js";
|
|
164
179
|
import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
|
|
165
180
|
import { presentationState, interactionSummary, sameInteractionOwner } from "./interaction-observation.js";
|
|
166
|
-
import { readLastInboundAt } from "./daemon.js";
|
|
181
|
+
import { DEFAULT_STUCK_TIMEOUT_MS, readLastInboundAt } from "./daemon.js";
|
|
167
182
|
import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
|
|
183
|
+
import { readBackgroundWorkEndedMarker } from "./background-work-ended.js";
|
|
168
184
|
import { DEFAULT_WARM_OVERFLOW, WakeCoordinator } from "./wake-coordinator.js";
|
|
169
185
|
import { TargetQueueWorker } from "./target-queue-worker.js";
|
|
170
186
|
import { resolveDeliveryWorkerMode } from "./types.js";
|
|
@@ -173,12 +189,14 @@ import { isSetupComplete, markSetupComplete } from "./setup-marker.js";
|
|
|
173
189
|
import { manualCleanupMessage, reapStaleTunnel } from "./tunnel/lease.js";
|
|
174
190
|
import { buildToolPermissionsNotice } from "./tool-permissions-notice.js";
|
|
175
191
|
import { NeedsYouHub } from "./needs-you-hub.js";
|
|
176
|
-
import { WEB_CHAT_NOTICE,
|
|
192
|
+
import { WEB_CHAT_NOTICE, claimNotice, hasWebChat, releaseNotice, upgradeNoticesPath } from "./upgrade-notices.js";
|
|
177
193
|
import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
|
|
178
194
|
import { buildOrgChart } from "./web-org.js";
|
|
179
195
|
import { CacheService, WINDOWS } from "./cache-service.js";
|
|
180
196
|
import { runWebCommand } from "./web-commands.js";
|
|
181
|
-
import {
|
|
197
|
+
import { CompactWatch, launchIdentity } from "./compact-watch.js";
|
|
198
|
+
import { agyLegacyAgentsFile, danglingSymlink, migrateAgyAgentsFile } from "./backend/antigravity.js";
|
|
199
|
+
import { claudeConfigDirFor, claudeProjectKey } from "./backend/claude-code.js";
|
|
182
200
|
import { sharedRolloutIndex } from "./rollout-index.js";
|
|
183
201
|
import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
|
|
184
202
|
import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
|
|
@@ -188,7 +206,7 @@ import { localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.j
|
|
|
188
206
|
import { WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
|
|
189
207
|
import { CodeRequestLimiter, ReturningDevices } from "./web-returning-device.js";
|
|
190
208
|
import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
|
|
191
|
-
import { createPreviewListener, previewAvailability, previewSettings } from "./web-preview.js";
|
|
209
|
+
import { createPreviewListener, normalizeOrigin, previewAvailability, previewSettings } from "./web-preview.js";
|
|
192
210
|
import { fleetLevelDifferences, fleetLevelSignature, appendedConnections } from "./fleet-level-config.js";
|
|
193
211
|
import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
|
|
194
212
|
import { SecretStore } from "./secret-store.js";
|
|
@@ -198,10 +216,11 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
|
|
|
198
216
|
/** A self-restart is a whole service restart; 120s is the apply budget, not this. */
|
|
199
217
|
const SELF_RESTART_DEADLINE_MS = 300_000;
|
|
200
218
|
import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
|
|
201
|
-
import { credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
219
|
+
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
220
|
+
import { carryClaudeConversation } from "./backend/claude-carry.js";
|
|
202
221
|
import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
|
|
203
222
|
import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
|
|
204
|
-
import { formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
|
|
223
|
+
import { cleanupWatchVerdict, formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
|
|
205
224
|
import { launchFullRestartHelper } from "./full-restart.js";
|
|
206
225
|
import { SYSTEMD_RESTART_INDETERMINATE_EXIT_CODE } from "./service-restart-selection.js";
|
|
207
226
|
import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
|
|
@@ -252,6 +271,10 @@ export function isOrphanInstanceWindowName(name) {
|
|
|
252
271
|
/** Retry cadence for retiring a cancel button whose delete failed (e.g. a DC
|
|
253
272
|
* forum thread the bot momentarily can't reach). 3 retries × 5min = 15min. */
|
|
254
273
|
const CANCEL_BTN_RETRY_INTERVAL_MS = 5 * 60_000;
|
|
274
|
+
/** #1753: a carried conversation this large is logged as a warning (it is still carried). */
|
|
275
|
+
const CARRY_LARGE_BYTES = 256 * 1024 * 1024;
|
|
276
|
+
/** #1742: how long after a Telegram /web that could not reach an admin privately a bare /start counts as /start web. */
|
|
277
|
+
const TG_WEB_START_WINDOW_MS = 10 * 60_000;
|
|
255
278
|
const CANCEL_BTN_MAX_RETRIES = 3;
|
|
256
279
|
/** Backstop: every 5min, retire a button whose instance has gone idle. Catches
|
|
257
280
|
* buttons no clear trigger reached (e.g. a scheduled/HTTP turn that never called
|
|
@@ -337,6 +360,7 @@ const CLASSIC_BACKEND_SELECTION_TIMEOUT_MS = 60_000;
|
|
|
337
360
|
const CLASSIC_BACKEND_CALLBACK_PREFIX = "classic-backend:";
|
|
338
361
|
const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
|
|
339
362
|
const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
|
|
363
|
+
const BACKEND_SELECT_CALLBACK_PREFIX = "backend-select:";
|
|
340
364
|
const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
|
|
341
365
|
const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
|
|
342
366
|
/** #1386: how long a paused instance's marker reason is reused before it is read again (transitions drop it at once). */
|
|
@@ -360,6 +384,7 @@ const WEB_MIRRORED_PROMPT_PREFIXES = new Set([
|
|
|
360
384
|
const WEB_ONLY_REPLY_SINK = {
|
|
361
385
|
type: "web",
|
|
362
386
|
supportsReplyButtons: true, // #1266: the web chat shows a reply's buttons itself
|
|
387
|
+
supportsPolls: true, // #1823: …and a poll (its pages are told; there is nothing to edit)
|
|
363
388
|
sendText: async () => ({ chatId: "web", messageId: newWebMessageId() }),
|
|
364
389
|
sendFile: async () => ({ chatId: "web", messageId: newWebMessageId() }),
|
|
365
390
|
};
|
|
@@ -427,6 +452,8 @@ const CLI_ENV_FRESH_MS = 60 * 60 * 1000;
|
|
|
427
452
|
* and hides exactly the newly released model the user opened `/model` to find.
|
|
428
453
|
* The wait is announced before it starts, so it reads as progress, not a stall.
|
|
429
454
|
*/
|
|
455
|
+
/** #1690: how long `/model` waits for the daemon's word on our own runtime switch (its dialog wait plus settling). */
|
|
456
|
+
export const RUNTIME_SWITCH_RESULT_MS = 12_000;
|
|
430
457
|
export const CLI_ENV_PROBE_DEADLINE_MS = 16_000;
|
|
431
458
|
/**
|
|
432
459
|
* How many CLIs may cold-start at once, from BOTH memory and cores.
|
|
@@ -614,6 +641,11 @@ export class FleetManager {
|
|
|
614
641
|
webChannelEchoGuards = new Map();
|
|
615
642
|
/** instanceName → restart currently executing; concurrent callers join it. */
|
|
616
643
|
restartsInFlight = new Map();
|
|
644
|
+
/**
|
|
645
|
+
* #1753: each in-flight restart's stop → start gap — the conversation carry it will run there (its own, or one a later
|
|
646
|
+
* caller joined with before the gap), and whether the gap has been reached.
|
|
647
|
+
*/
|
|
648
|
+
restartGaps = new Map();
|
|
617
649
|
/**
|
|
618
650
|
* Delayed automatic retries for instances whose startup failed. Before this a
|
|
619
651
|
* failed start was logged once and the instance stayed `stopped` until an
|
|
@@ -649,6 +681,8 @@ export class FleetManager {
|
|
|
649
681
|
/** In-flight /effort selections, same coordinator shape as pendingModelSelects. */
|
|
650
682
|
pendingEffortSelects = new Map();
|
|
651
683
|
pendingModelSelects = new Map();
|
|
684
|
+
/** #1806: in-flight /backend steps, keyed by nonce (see handleBackendSelection). */
|
|
685
|
+
pendingBackendSelects = new Map();
|
|
652
686
|
/** nonce → pending button prompt (hang restart, interactive assist, clean-exit restart). */
|
|
653
687
|
pendingNonceButtons = new Map();
|
|
654
688
|
/** #1386 "Needs you": the list, its live chat messages and Acknowledge. Started with the fleet (finishStartup). */
|
|
@@ -718,6 +752,7 @@ export class FleetManager {
|
|
|
718
752
|
collabInstances = new Set();
|
|
719
753
|
// Health endpoint
|
|
720
754
|
healthServer = null;
|
|
755
|
+
healthServerDrain = null;
|
|
721
756
|
/** #1306: the preview listener (health_port + 1 by default), and whether it is listening. */
|
|
722
757
|
previewListener = null;
|
|
723
758
|
previewListening = false;
|
|
@@ -729,7 +764,13 @@ export class FleetManager {
|
|
|
729
764
|
updateProgressTimer = null;
|
|
730
765
|
updateProgressEditRunning = false;
|
|
731
766
|
lastUpdateProgressText = null;
|
|
732
|
-
|
|
767
|
+
/**
|
|
768
|
+
* #1660: an adopted update whose retire outcome is still owed. The
|
|
769
|
+
* completion summary was (or will be) posted; this watch appends the
|
|
770
|
+
* cleanup outcome to the same receipt instead of letting stage edits
|
|
771
|
+
* clobber it — and refuses a receipt that no longer matches (checklist 3/5).
|
|
772
|
+
*/
|
|
773
|
+
updateCleanupWatch = null;
|
|
733
774
|
/** Injectable only to keep the chat→CLI reload hand-off deterministic in tests. */
|
|
734
775
|
fullRestartLauncher = launchFullRestartHelper;
|
|
735
776
|
eventLogPruneTimer = null;
|
|
@@ -749,8 +790,8 @@ export class FleetManager {
|
|
|
749
790
|
memoryLogAt = null;
|
|
750
791
|
memoryDebugAt = null;
|
|
751
792
|
memoryLogLevel = null;
|
|
752
|
-
|
|
753
|
-
|
|
793
|
+
/** #1685: the host_memory value the last status frame carried, so a change re-sends the frame. */
|
|
794
|
+
memoryIndicatorShown = null;
|
|
754
795
|
// Mirror topic: buffer cross-instance messages, flush every 3s
|
|
755
796
|
mirrorBuffer = [];
|
|
756
797
|
mirrorTimer = null;
|
|
@@ -761,7 +802,95 @@ export class FleetManager {
|
|
|
761
802
|
/** #1565: the web chat history's files (set at fleet start, once the home and the instances are known). */
|
|
762
803
|
webChatStore = null;
|
|
763
804
|
/** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
|
|
764
|
-
|
|
805
|
+
// #1937: the upload limits are the config's, read on each use (agend reload applies a change to the next upload).
|
|
806
|
+
webFiles = new WebFileLedger({ limits: () => uploadLimits(this.fleetConfig?.web) });
|
|
807
|
+
/** #1770: per instance, its last web reply still copying its files — the next one is shown after it, in order. */
|
|
808
|
+
webReplyOrder = new Map();
|
|
809
|
+
/** #1770: an instance's web-files byte cap (a field, so a test can make it small). */
|
|
810
|
+
webReplyMaxBytes = REPLY_FILES_MAX_BYTES;
|
|
811
|
+
/** #1682: the people seen on inbound messages (id → name), for the web chat's mention pills; bounded, in memory. */
|
|
812
|
+
seenUsers = new SeenUsers();
|
|
813
|
+
webPrefsStore;
|
|
814
|
+
/** #1665: the web preferences kept on the host (<home>/web-prefs.json), read on first use. */
|
|
815
|
+
/**
|
|
816
|
+
* #1827: a web person as agents and chats see them. Signed in as an account (a sign-in code DMed to it): that
|
|
817
|
+
* account's user id — still `via web`, and only for recognition — and the name they chose, else their account's name
|
|
818
|
+
* at sign-in (made safe). Otherwise (an `agend web` session): "web-user", as before, under a name the operator may set.
|
|
819
|
+
* Names are checked again now: one an instance or bot has taken since reads as the next choice down.
|
|
820
|
+
*/
|
|
821
|
+
webPerson(owner) {
|
|
822
|
+
const key = webPersonKey(owner);
|
|
823
|
+
let profile = null;
|
|
824
|
+
try {
|
|
825
|
+
profile = this.webPrefs.profile(key);
|
|
826
|
+
}
|
|
827
|
+
catch (err) {
|
|
828
|
+
this.logger.warn({ err: err.message }, "Web profile unavailable");
|
|
829
|
+
}
|
|
830
|
+
const reserved = (n) => this.webNameReserved(n);
|
|
831
|
+
const own = profile?.name !== undefined ? webNameInUse(profile.name, reserved) : WEB_NAME_FALLBACK;
|
|
832
|
+
const chosen = own !== WEB_NAME_FALLBACK;
|
|
833
|
+
const platformName = owner ? safeWebName(profile?.platformName, reserved) : undefined;
|
|
834
|
+
return {
|
|
835
|
+
key, bound: !!owner,
|
|
836
|
+
userId: owner ? owner.userId : WEB_NAME_FALLBACK,
|
|
837
|
+
// A poll ballot is the account's — the same key its votes on the platform have — or the operator's.
|
|
838
|
+
voterKey: owner ? `${owner.adapterId}:${owner.userId}` : "web:operator",
|
|
839
|
+
name: chosen ? own : platformName ?? WEB_NAME_FALLBACK,
|
|
840
|
+
set: chosen,
|
|
841
|
+
...(platformName !== undefined ? { platformName } : {}),
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
/** #1827: set (null: clear — back to their account's name) a web person's own name; validated in full. */
|
|
845
|
+
setWebPersonName(owner, raw) {
|
|
846
|
+
let name = null;
|
|
847
|
+
if (raw !== null) {
|
|
848
|
+
const v = validateWebName(raw, (n) => this.webNameReserved(n));
|
|
849
|
+
if (!v.ok)
|
|
850
|
+
return { ok: false, status: 400, error: v.error };
|
|
851
|
+
name = v.name;
|
|
852
|
+
}
|
|
853
|
+
const w = this.webPrefs.setProfileName(webPersonKey(owner), name);
|
|
854
|
+
return w.ok ? { ok: true, person: this.webPerson(owner) } : { ok: false, status: w.status, error: w.error };
|
|
855
|
+
}
|
|
856
|
+
/** #1827 (auth-api): a sign-in as this account — its display name then is the web name it starts with. */
|
|
857
|
+
noteWebSignIn(account) {
|
|
858
|
+
if (account.username)
|
|
859
|
+
this.webPrefs.setPlatformName(webPersonKey(account), account.username);
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* #1827: a name that belongs to something here — an instance (or ClassicBot room), its display name, a bot. On the web
|
|
863
|
+
* send path: it never throws (a lookup that fails reserves nothing; the name was checked in full when it was set).
|
|
864
|
+
*/
|
|
865
|
+
webNameReserved(lowered) {
|
|
866
|
+
try {
|
|
867
|
+
return this.webNameTaken(lowered);
|
|
868
|
+
}
|
|
869
|
+
catch {
|
|
870
|
+
return false;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
webNameTaken(lowered) {
|
|
874
|
+
const low = (s) => (typeof s === "string" ? s.normalize("NFC").toLocaleLowerCase("en-US") : "");
|
|
875
|
+
for (const [name, cfg] of Object.entries(this.fleetConfig?.instances ?? {})) {
|
|
876
|
+
if (low(name) === lowered || low(cfg.display_name) === lowered)
|
|
877
|
+
return true;
|
|
878
|
+
}
|
|
879
|
+
for (const ch of this.classicChannels?.getAll() ?? [])
|
|
880
|
+
if (low(ch.instanceName) === lowered || low(ch.displayName) === lowered)
|
|
881
|
+
return true;
|
|
882
|
+
for (const w of this.worlds.values())
|
|
883
|
+
if (w.botUsername && low(w.botUsername) === lowered)
|
|
884
|
+
return true;
|
|
885
|
+
return false;
|
|
886
|
+
}
|
|
887
|
+
get webPrefs() {
|
|
888
|
+
// #1664: a favorite must be an instance of this fleet now — fleet.yaml's (own keys) or a ClassicBot room's.
|
|
889
|
+
return (this.webPrefsStore ??= new WebPrefsStore(this.dataDir, {
|
|
890
|
+
exists: (name) => Object.hasOwn(this.fleetConfig?.instances ?? {}, name)
|
|
891
|
+
|| (this.classicChannels?.getAll() ?? []).some((ch) => ch.instanceName === name),
|
|
892
|
+
}));
|
|
893
|
+
}
|
|
765
894
|
/**
|
|
766
895
|
* Read from disk on every access rather than cached at startup: `agend
|
|
767
896
|
* web-token rotate` runs in a separate process, and a cached copy would keep
|
|
@@ -1103,6 +1232,8 @@ export class FleetManager {
|
|
|
1103
1232
|
mayClick: (set, userId) => this.mayAnswerReplyButtons(set.instance, set.adapterId, userId),
|
|
1104
1233
|
deliver: (set, button, by) => this.deliverReplyButtonChoice(set, button, by),
|
|
1105
1234
|
publish: (instance, view) => this.emitSseEvent("reply_buttons", { instance, buttons: view }),
|
|
1235
|
+
admitResults: (set, key, text) => this.admitPollResults(set, key, text),
|
|
1236
|
+
monotonic: () => performance.now(),
|
|
1106
1237
|
logger: this.logger,
|
|
1107
1238
|
setTimer: (fn, ms) => { const h = setTimeout(fn, ms); h.unref?.(); return h; },
|
|
1108
1239
|
clearTimer: (h) => clearTimeout(h),
|
|
@@ -1111,6 +1242,21 @@ export class FleetManager {
|
|
|
1111
1242
|
return this.replyButtonsCtl;
|
|
1112
1243
|
}
|
|
1113
1244
|
replyButtonsStore = null;
|
|
1245
|
+
/**
|
|
1246
|
+
* #1823: a closed poll's results go to its instance through the delivery outbox, as a system notice keyed by the
|
|
1247
|
+
* poll: durable (held until the instance can take it) and admitted once, however often this is asked. Addressed to
|
|
1248
|
+
* the poll's chat, so the instance's answer (the right option, the scores) goes there.
|
|
1249
|
+
*/
|
|
1250
|
+
admitPollResults(set, key, text) {
|
|
1251
|
+
this.ensureDeliveryOutbox();
|
|
1252
|
+
const outbox = this.deliveryOutbox;
|
|
1253
|
+
if (!outbox)
|
|
1254
|
+
return false;
|
|
1255
|
+
outbox.admitSystemNotice(set.instance, key, text, new Date(), {
|
|
1256
|
+
chatId: set.chatId, threadId: set.threadId || undefined, adapterId: set.adapterId === "web" ? undefined : set.adapterId,
|
|
1257
|
+
});
|
|
1258
|
+
return true;
|
|
1259
|
+
}
|
|
1114
1260
|
/**
|
|
1115
1261
|
* #1266: who may answer a reply's buttons on a platform — whoever may message that instance there: in a ClassicBot
|
|
1116
1262
|
* room anyone there (as a typed message), elsewhere someone the sending connection lets speak (#754/#1148), never a
|
|
@@ -1151,7 +1297,7 @@ export class FleetManager {
|
|
|
1151
1297
|
const reactions = this.pendingReactionsMeta(set.instance);
|
|
1152
1298
|
let accepted = false;
|
|
1153
1299
|
try {
|
|
1154
|
-
accepted = (await this.
|
|
1300
|
+
accepted = (await this.deliverHumanToInstance(set.instance, {
|
|
1155
1301
|
type: "fleet_inbound",
|
|
1156
1302
|
content,
|
|
1157
1303
|
targetSession: set.instance,
|
|
@@ -1160,7 +1306,7 @@ export class FleetManager {
|
|
|
1160
1306
|
thread_id: set.threadId, adapter_id: inbound.adapterId, source: inbound.source,
|
|
1161
1307
|
...reactions.meta,
|
|
1162
1308
|
},
|
|
1163
|
-
})) !== false;
|
|
1309
|
+
}, by.humanOrigin, button.label)) !== false;
|
|
1164
1310
|
}
|
|
1165
1311
|
finally {
|
|
1166
1312
|
// Both false and throw reopen the choice in the controller. Finish the
|
|
@@ -1174,8 +1320,10 @@ export class FleetManager {
|
|
|
1174
1320
|
return false;
|
|
1175
1321
|
reactions.consume();
|
|
1176
1322
|
this.afterUserInboundDelivered(set.instance, inbound, deliveryEpoch);
|
|
1323
|
+
// The web chat shows the click as its button's label (a chip); `text` stays what the agent was given.
|
|
1177
1324
|
this.emitSseEvent("message", {
|
|
1178
1325
|
instance: set.instance, sender: by.username, role: "user", text: content, ts, ...(web ? { messageId } : {}),
|
|
1326
|
+
click: { label: button.label, of: set.id },
|
|
1179
1327
|
});
|
|
1180
1328
|
return true;
|
|
1181
1329
|
}
|
|
@@ -1219,8 +1367,13 @@ export class FleetManager {
|
|
|
1219
1367
|
if (event.deliveryId && (event.state === "failed" || event.state === "uncertain")) {
|
|
1220
1368
|
const row = outbox.get(event.deliveryId);
|
|
1221
1369
|
if (row && row.kind !== "delivery_outcome_notice" && row.kind !== "post_restart_outcome_notice") {
|
|
1222
|
-
|
|
1223
|
-
|
|
1370
|
+
// #1647: the cause goes in the log (the notice points there), and a silent schedule's paste says what it is.
|
|
1371
|
+
const silent = isSilentScheduleDelivery(row);
|
|
1372
|
+
const notice = silent
|
|
1373
|
+
? t(`delivery.fleet_${row.state}_silent_schedule`, row.targetInstance, row.operationId, row.deliveryId, row.lastError ?? "unknown")
|
|
1374
|
+
: t(`delivery.fleet_${row.state}`, row.targetInstance, row.operationId, row.deliveryId);
|
|
1375
|
+
this.logger.error({ deliveryId: row.deliveryId, operationId: row.operationId, target: row.targetInstance, state: row.state,
|
|
1376
|
+
cause: row.lastError, ...(silent ? { silentSchedule: true } : {}) }, notice);
|
|
1224
1377
|
this.notifyFleetError(notice);
|
|
1225
1378
|
}
|
|
1226
1379
|
}
|
|
@@ -1274,6 +1427,7 @@ export class FleetManager {
|
|
|
1274
1427
|
deliveryId: admitted.delivery.deliveryId,
|
|
1275
1428
|
state: admitted.delivery.state,
|
|
1276
1429
|
duplicate: !admitted.inserted,
|
|
1430
|
+
kind: admitted.delivery.kind,
|
|
1277
1431
|
};
|
|
1278
1432
|
}
|
|
1279
1433
|
queryDurableDeliveryStatus(callerInstance, selector) {
|
|
@@ -1315,7 +1469,9 @@ export class FleetManager {
|
|
|
1315
1469
|
// #926: a restart kills the turn that was working on a request, and a
|
|
1316
1470
|
// resumed CLI does not pick it back up. Remind the owner once per restart.
|
|
1317
1471
|
try {
|
|
1318
|
-
|
|
1472
|
+
const now = new Date();
|
|
1473
|
+
this.expireReplyObligations(now);
|
|
1474
|
+
outbox.remindReplyObligations(name, { reason: "restart", graceMs: 0, now, since: now, restartId: daemonBootId });
|
|
1319
1475
|
}
|
|
1320
1476
|
catch (err) {
|
|
1321
1477
|
this.logger.error({ err, owner: name }, "Could not persist the post-restart reply reminder");
|
|
@@ -1333,6 +1489,7 @@ export class FleetManager {
|
|
|
1333
1489
|
return;
|
|
1334
1490
|
const stateOf = (name) => this.daemons.get(name)?.getInstanceState();
|
|
1335
1491
|
try {
|
|
1492
|
+
this.expireReplyObligations(now);
|
|
1336
1493
|
for (const name of this.daemons.keys()) {
|
|
1337
1494
|
if (stateOf(name) === "idle")
|
|
1338
1495
|
outbox.remindReplyObligations(name, { now, graceMs: REPLY_REMINDER_GRACE_MS, reason: "turn-ended" });
|
|
@@ -1344,6 +1501,15 @@ export class FleetManager {
|
|
|
1344
1501
|
this.logger.error({ err }, "Reply-obligation sweep failed");
|
|
1345
1502
|
}
|
|
1346
1503
|
}
|
|
1504
|
+
expireReplyObligations(now) {
|
|
1505
|
+
const hours = this.fleetConfig?.defaults?.reply_obligation_ttl_hours;
|
|
1506
|
+
const duration = typeof hours === "number" && hours > 0 && Number.isFinite(hours * 3_600_000)
|
|
1507
|
+
? hours * 3_600_000 : DEFAULT_REPLY_OBLIGATION_TTL_MS;
|
|
1508
|
+
for (const row of this.deliveryOutbox.expireReplyObligations({ now, ttlMs: duration })) {
|
|
1509
|
+
this.logger.info({ owner: row.ownerInstance, requester: row.requesterInstance,
|
|
1510
|
+
correlationId: row.correlationId, lastAskedAt: row.lastAskedAt, expiredAt: row.expiredAt }, "Reply obligation expired");
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1347
1513
|
scheduleDeliveryOutboxPump(delayMs = 0) {
|
|
1348
1514
|
if (!this.deliveryOutbox || this.shuttingDown || !this.deliveryOutbox.isOpen)
|
|
1349
1515
|
return;
|
|
@@ -1500,6 +1666,7 @@ export class FleetManager {
|
|
|
1500
1666
|
content: payload.content,
|
|
1501
1667
|
delivery_id: claimed.deliveryId,
|
|
1502
1668
|
delivery_attempt: String(claimed.attemptNo),
|
|
1669
|
+
...(isSilentScheduleDelivery(claimed) ? { silent_schedule: true } : {}),
|
|
1503
1670
|
}, { waitForIdle: false, noInlineWake: this.deliveryWorkerMode(target) !== "off" });
|
|
1504
1671
|
}
|
|
1505
1672
|
else {
|
|
@@ -1583,6 +1750,14 @@ export class FleetManager {
|
|
|
1583
1750
|
return Math.max(1, Math.min(20, explicit));
|
|
1584
1751
|
return deriveSpawnConcurrency(Math.round(freemem() / (1024 * 1024)), cpus().length);
|
|
1585
1752
|
}
|
|
1753
|
+
/** #1685: "elevated" or "critical" while host memory pressure slows or holds new agents; otherwise null (an
|
|
1754
|
+
* unknown macOS reading — the advisory case — is unknown, so null too). */
|
|
1755
|
+
hostMemoryIndicator() {
|
|
1756
|
+
if (!this.memoryPressure)
|
|
1757
|
+
return null;
|
|
1758
|
+
const level = this.memoryPressure.snapshot().level;
|
|
1759
|
+
return level === "elevated" || level === "critical" ? level : null;
|
|
1760
|
+
}
|
|
1586
1761
|
/** Separate cooldown: changing measurements must not create new text-throttle keys. */
|
|
1587
1762
|
reportMemoryPressure(snapshot) {
|
|
1588
1763
|
if (this.shuttingDown)
|
|
@@ -1593,6 +1768,12 @@ export class FleetManager {
|
|
|
1593
1768
|
this.logger.debug({ hostMemory: snapshot }, "Host memory sample");
|
|
1594
1769
|
}
|
|
1595
1770
|
const changed = snapshot.level !== this.memoryLogLevel;
|
|
1771
|
+
// #1685: the dashboard's indicator (the status frame's host_memory) is re-sent when it changes.
|
|
1772
|
+
const indicator = this.hostMemoryIndicator();
|
|
1773
|
+
if (indicator !== this.memoryIndicatorShown) {
|
|
1774
|
+
this.memoryIndicatorShown = indicator;
|
|
1775
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
1776
|
+
}
|
|
1596
1777
|
if (this.memoryPressure.advisoryOnly()) {
|
|
1597
1778
|
// The sampler logs the unknown kernel value once per lifecycle; no warning/cooldown here.
|
|
1598
1779
|
this.memoryLogLevel = snapshot.level;
|
|
@@ -1613,20 +1794,6 @@ export class FleetManager {
|
|
|
1613
1794
|
: "Host memory sample unavailable — slowing new CLI spawns");
|
|
1614
1795
|
}
|
|
1615
1796
|
this.memoryLogLevel = snapshot.level;
|
|
1616
|
-
if (!snapshot.memory || snapshot.level === "unknown" || (!this.adapter && this.adapters.size === 0))
|
|
1617
|
-
return;
|
|
1618
|
-
const escalation = snapshot.level === "critical" && this.memoryNoticeLevel !== "critical";
|
|
1619
|
-
if (!escalation && this.memoryNoticeAt !== null && now - this.memoryNoticeAt < 10 * 60_000)
|
|
1620
|
-
return;
|
|
1621
|
-
const size = (bytes) => bytes === null ? t("memory.unknown") : `${Math.round(bytes / 1024 / 1024)} MiB`;
|
|
1622
|
-
const action = t(snapshot.level === "critical" ? "memory.holding" : "memory.slowing");
|
|
1623
|
-
const text = this.memoryPressure.platform === "darwin"
|
|
1624
|
-
? t("memory.kernel_pressure", snapshot.memory.darwinPressureLevel ?? t("memory.unknown"), size(snapshot.memory.availableBytes), size(snapshot.memory.swapFreeBytes), action)
|
|
1625
|
-
: t("memory.pressure", t(snapshot.memory.availableKind === "available" ? "memory.available" : "memory.free", size(snapshot.memory.availableBytes)), size(snapshot.memory.swapFreeBytes), action);
|
|
1626
|
-
if (this.notifyFleetError(text, { throttle: false })) {
|
|
1627
|
-
this.memoryNoticeAt = now;
|
|
1628
|
-
this.memoryNoticeLevel = snapshot.level;
|
|
1629
|
-
}
|
|
1630
1797
|
}
|
|
1631
1798
|
/** Wire the one fleet-wide storm into notification and recovery surfaces. */
|
|
1632
1799
|
bindStormWindowEvents() {
|
|
@@ -1747,7 +1914,7 @@ export class FleetManager {
|
|
|
1747
1914
|
isPlannedRestart() {
|
|
1748
1915
|
return this.shuttingDown || isUpdateInProgress(this.dataDir);
|
|
1749
1916
|
}
|
|
1750
|
-
finishStartup() {
|
|
1917
|
+
finishStartup(bootUpdateMarker = null) {
|
|
1751
1918
|
this.startupComplete = true;
|
|
1752
1919
|
this.startNeedsYou();
|
|
1753
1920
|
// Resolve whatever a previous run — or a setup host that crashed — left
|
|
@@ -1757,14 +1924,17 @@ export class FleetManager {
|
|
|
1757
1924
|
// the next tunnel and says so, it does not block the fleet.
|
|
1758
1925
|
this.announceToolPermissionsChange();
|
|
1759
1926
|
this.tightenInstanceDirectories();
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1927
|
+
// #1723: the public link's preview tunnel keeps its own lease beside it; a leftover one is reaped the same way.
|
|
1928
|
+
for (const leaseDir of [this.dataDir, join(this.dataDir, PUBLIC_PREVIEW_TUNNEL_DIR)]) {
|
|
1929
|
+
void reapStaleTunnel(leaseDir)
|
|
1930
|
+
.then(outcome => {
|
|
1931
|
+
if (outcome.kind === "manual")
|
|
1932
|
+
this.logger.warn({ tunnel: outcome }, manualCleanupMessage(outcome));
|
|
1933
|
+
else if (outcome.kind === "reaped")
|
|
1934
|
+
this.logger.info({ how: outcome.how, pid: outcome.pid }, "Reaped a leftover tunnel");
|
|
1935
|
+
})
|
|
1936
|
+
.catch(err => this.logger.warn({ err }, "Tunnel reaper failed"));
|
|
1937
|
+
}
|
|
1768
1938
|
// An existing installation has never written the setup marker — it predates
|
|
1769
1939
|
// it — so `agend setup` would open a pre-fleet form for a fleet that plainly
|
|
1770
1940
|
// exists. A fleet that just came up on a config with agents in it is proof
|
|
@@ -1784,14 +1954,33 @@ export class FleetManager {
|
|
|
1784
1954
|
for (const settled of this.applyJobs.settleAfterRestart()) {
|
|
1785
1955
|
this.logger.info({ jobId: settled.id }, "Settings apply job settled by fleet restart");
|
|
1786
1956
|
}
|
|
1787
|
-
|
|
1788
|
-
// it here (rather than in the update command, which exits before the new
|
|
1789
|
-
// fleet is up) is what keeps the quiet window from outliving the restart.
|
|
1790
|
-
clearUpdateMarker(this.dataDir);
|
|
1957
|
+
this.clearSettledUpdateMarker(bootUpdateMarker);
|
|
1791
1958
|
if (this.reloadPending)
|
|
1792
1959
|
this.scheduleReconcile();
|
|
1793
1960
|
void this.sweepOrphanedCancelButtons();
|
|
1794
1961
|
}
|
|
1962
|
+
/**
|
|
1963
|
+
* We are the post-update fleet: the update is over by definition. Clearing it here (rather than in the update
|
|
1964
|
+
* command, which exits before the new fleet is up) is what keeps the quiet window from outliving the restart.
|
|
1965
|
+
* #1660 exception: an update still owing its retire outcome keeps its receipt until the cleanup watch resolves it —
|
|
1966
|
+
* deleting it here is what lost the warning. Terminal stages are still cleared unconditionally.
|
|
1967
|
+
* #1735: and while the watch is armed it owns the marker — even if the outcome landed after the completion posted
|
|
1968
|
+
* (a launchd kickstart or a detached restart returns at once, so it can land at any point of the boot): the watch
|
|
1969
|
+
* appends it, then clears.
|
|
1970
|
+
* #1737 review: and only the marker this boot adopted. One written since — a `/restart full` or a new `/update`
|
|
1971
|
+
* landing while this boot's completion was still being edited — belongs to its writer and is left alone.
|
|
1972
|
+
*/
|
|
1973
|
+
clearSettledUpdateMarker(bootUpdateMarker) {
|
|
1974
|
+
if (this.updateCleanupWatch)
|
|
1975
|
+
return;
|
|
1976
|
+
const current = updateMarkerIdentity(this.dataDir);
|
|
1977
|
+
if (current && !sameUpdateMarker(current, bootUpdateMarker))
|
|
1978
|
+
return;
|
|
1979
|
+
const leftover = readUpdateProgress(this.dataDir)?.progress;
|
|
1980
|
+
if (leftover?.cleanup === "pending" && leftover.stage !== "failed" && leftover.stage !== "complete")
|
|
1981
|
+
return;
|
|
1982
|
+
clearUpdateMarker(this.dataDir);
|
|
1983
|
+
}
|
|
1795
1984
|
// ── ArchiverContext bridge ────────────────────────────────────────────
|
|
1796
1985
|
lastActivityMs(name) {
|
|
1797
1986
|
return this.lastActivity.get(name) ?? 0;
|
|
@@ -2117,7 +2306,7 @@ export class FleetManager {
|
|
|
2117
2306
|
async handleUpdateSlash(data, adapterId) {
|
|
2118
2307
|
const gate = this.fleetAdminGate(data.userId, adapterId);
|
|
2119
2308
|
if (gate !== "ok") {
|
|
2120
|
-
await data.respond(
|
|
2309
|
+
await data.respond(gate === "disabled" ? t("update.disabled", this.adminListKey(adapterId)) : t("not_authorized"));
|
|
2121
2310
|
return;
|
|
2122
2311
|
}
|
|
2123
2312
|
const messageId = await data.respond(t("update.progress.preparing", 0));
|
|
@@ -2132,24 +2321,29 @@ export class FleetManager {
|
|
|
2132
2321
|
// "not a beta" and sent a beta install to @latest. #1450 C5: the INSTALLED agend, verified, by absolute path.
|
|
2133
2322
|
const installed = await resolveInstalledAgend();
|
|
2134
2323
|
if (!installed.ok) {
|
|
2135
|
-
this.failUpdateProgress(
|
|
2324
|
+
this.failUpdateProgress(`${t("update.failure.shell")} ${t("update.failure.installed", installed.reason)}`);
|
|
2136
2325
|
return;
|
|
2137
2326
|
}
|
|
2138
2327
|
const origin = `slash /update by ${adapterId}:${data.userId}`;
|
|
2139
2328
|
recordInternalRequest(this.dataDir, "update", origin);
|
|
2140
2329
|
const launch = await resolveUpdateLaunch(installed.agend);
|
|
2141
2330
|
if (!launch.ok) {
|
|
2142
|
-
this.failUpdateProgress(
|
|
2331
|
+
this.failUpdateProgress(`${t("update.failure.shell")} ${t("update.failure.independent", launch.reason)}`);
|
|
2143
2332
|
return;
|
|
2144
2333
|
}
|
|
2145
2334
|
if (this.fleetAdminGate(data.userId, adapterId) !== "ok" || this.shuttingDown) {
|
|
2146
2335
|
this.failUpdateProgress(t("not_authorized"));
|
|
2147
2336
|
return;
|
|
2148
2337
|
}
|
|
2338
|
+
const logPath = updateLogPath(this.dataDir);
|
|
2339
|
+
const ts = new Date().toISOString();
|
|
2340
|
+
const logFd = openUpdateLog(logPath, `--- /update ${ts} ---`);
|
|
2149
2341
|
const child = spawn(launch.command, launch.args, {
|
|
2150
|
-
detached: true, stdio: "ignore", env: withOrigin(origin),
|
|
2342
|
+
detached: true, stdio: ["ignore", logFd ? logFd.fd : "ignore", logFd ? logFd.fd : "ignore"], env: withOrigin(origin),
|
|
2151
2343
|
});
|
|
2152
|
-
|
|
2344
|
+
if (logFd)
|
|
2345
|
+
closeSync(logFd.fd); // parent closes its copy; the child's fd stays open
|
|
2346
|
+
watchUpdateLaunch(child, message => this.failUpdateProgress(message), logFd ? logPath : undefined, logFd?.offset, installed.agend);
|
|
2153
2347
|
child.unref();
|
|
2154
2348
|
}
|
|
2155
2349
|
async handleProfileSlash(data, adapterId) {
|
|
@@ -2575,6 +2769,8 @@ export class FleetManager {
|
|
|
2575
2769
|
startsFresh: !backendChanged && credentialSwitchStartsFresh(backend),
|
|
2576
2770
|
from: profileKey(launched),
|
|
2577
2771
|
to: profileKey(next),
|
|
2772
|
+
...(!backendChanged && launched.state !== "invalid" && next.state !== "invalid"
|
|
2773
|
+
? { carry: { backend, from: profileName(launched), to: profileName(next) } } : {}),
|
|
2578
2774
|
};
|
|
2579
2775
|
}
|
|
2580
2776
|
/** `[instance, effective backend, credential profile]` for everything that is
|
|
@@ -2609,6 +2805,81 @@ export class FleetManager {
|
|
|
2609
2805
|
return this.classicChannels?.getAll().some(channel => channel.instanceName === name) ?? false;
|
|
2610
2806
|
}
|
|
2611
2807
|
/** #1523 N2: a ClassicBot room's own summary for its Details page (classicBot.yaml), or null. */
|
|
2808
|
+
/** #1218: the Log tab's on-demand service — made on first use; with `web.log_tab` off it answers off and does nothing. */
|
|
2809
|
+
logService = null;
|
|
2810
|
+
get log() {
|
|
2811
|
+
return this.logService ??= new LogService({
|
|
2812
|
+
enabled: () => this.fleetConfig?.web?.log_tab === true,
|
|
2813
|
+
known: (name) => Object.prototype.hasOwnProperty.call(this.fleetConfig?.instances ?? {}, name) || this.classicRoomFor(name) !== null,
|
|
2814
|
+
source: (name) => this.logSource(name),
|
|
2815
|
+
events: (name, limit) => (this.eventLog?.query({ instance: name, limit }) ?? []),
|
|
2816
|
+
emit: (event, data) => this.emitSseEvent(event, data),
|
|
2817
|
+
});
|
|
2818
|
+
}
|
|
2819
|
+
/** Board 003648ac: a web /compact followed to its end, from the transcript the Log reads (none: nothing to follow). */
|
|
2820
|
+
compactWatchService = null;
|
|
2821
|
+
get compactWatch() {
|
|
2822
|
+
return this.compactWatchService ??= new CompactWatch({
|
|
2823
|
+
source: (name) => { const src = this.logSource(name); return { path: src.path, format: src.format }; },
|
|
2824
|
+
// The launch that runs it now (the same value the daemon sends with raw_paste_written).
|
|
2825
|
+
owner: (name) => {
|
|
2826
|
+
const daemon = this.lifecycle.daemons.get(name);
|
|
2827
|
+
try {
|
|
2828
|
+
return daemon ? launchIdentity(daemon.getInteractionSnapshot().owner) : null;
|
|
2829
|
+
}
|
|
2830
|
+
catch {
|
|
2831
|
+
return null;
|
|
2832
|
+
}
|
|
2833
|
+
},
|
|
2834
|
+
paneFollow: (name) => {
|
|
2835
|
+
try {
|
|
2836
|
+
return this.lifecycle.daemons.get(name)?.compactPaneUi() != null;
|
|
2837
|
+
}
|
|
2838
|
+
catch {
|
|
2839
|
+
return false;
|
|
2840
|
+
}
|
|
2841
|
+
},
|
|
2842
|
+
changed: () => this.emitSseEvent("status", this.getUiStatus()),
|
|
2843
|
+
});
|
|
2844
|
+
}
|
|
2845
|
+
/** #1218: GET /ui/steps — an instance's steps, read on demand (null: no such instance). */
|
|
2846
|
+
logSteps(name, q) { return this.log.steps(name, q); }
|
|
2847
|
+
/**
|
|
2848
|
+
* #1218: where an instance's steps are read from, resolved only when its Log asks: the transcript its daemon follows
|
|
2849
|
+
* now; for claude-code, also the path its statusline last named (so a stopped instance's Log still shows its last
|
|
2850
|
+
* work). kiro: fleet events with the reason its history is not read (kiro-log-gate.ts); other backends have no
|
|
2851
|
+
* transcript AgEnD can read.
|
|
2852
|
+
*/
|
|
2853
|
+
logSource(name) {
|
|
2854
|
+
const instances = this.fleetConfig?.instances ?? {};
|
|
2855
|
+
const config = Object.prototype.hasOwnProperty.call(instances, name) ? instances[name] : undefined;
|
|
2856
|
+
const backend = config?.backend ?? (config ? null : this.classicRoomFor(name)?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
2857
|
+
const live = this.lifecycle.daemons.get(name)?.logTranscriptPath() ?? null;
|
|
2858
|
+
if (backend === "claude-code") {
|
|
2859
|
+
let path = live;
|
|
2860
|
+
if (!path) {
|
|
2861
|
+
try {
|
|
2862
|
+
path = JSON.parse(readFileSync(join(this.getInstanceDir(name), "statusline.json"), "utf-8")).transcript_path ?? null;
|
|
2863
|
+
}
|
|
2864
|
+
catch {
|
|
2865
|
+
path = null;
|
|
2866
|
+
}
|
|
2867
|
+
if (typeof path !== "string" || !path)
|
|
2868
|
+
path = null;
|
|
2869
|
+
}
|
|
2870
|
+
return { backend, path, format: "claude", ...(path ? {} : { reason: "no_transcript" }) };
|
|
2871
|
+
}
|
|
2872
|
+
if (backend === "codex")
|
|
2873
|
+
return { backend, path: live, format: "codex", ...(live ? {} : { reason: "not_running" }) };
|
|
2874
|
+
if (backend === "kiro-cli") {
|
|
2875
|
+
// #1218 PR4: events only until the launch's root and the current conversation are both proven (kiro-log-gate.ts);
|
|
2876
|
+
// the reason names which proof is missing. With today's proofs the gate never opens, and nothing is read for kiro:
|
|
2877
|
+
// the reader is bound to an admitted launch when the proofs arrive (#849 root, #1789 current conversation).
|
|
2878
|
+
const gate = kiroLogGate(this.lifecycle.daemons.get(name)?.logLaunch() ?? null);
|
|
2879
|
+
return { backend, path: null, format: null, reason: gate.open ? "kiro_session_unobservable" : gate.reason };
|
|
2880
|
+
}
|
|
2881
|
+
return { backend, path: null, format: null, reason: "unsupported" };
|
|
2882
|
+
}
|
|
2612
2883
|
classicRoomFor(name) {
|
|
2613
2884
|
const ch = this.classicChannels?.getAll().find(channel => channel.instanceName === name);
|
|
2614
2885
|
if (!ch)
|
|
@@ -2910,7 +3181,7 @@ export class FleetManager {
|
|
|
2910
3181
|
const queued = this.ipcWaitTails.get(instanceName);
|
|
2911
3182
|
if (!queued) {
|
|
2912
3183
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
2913
|
-
if (ipc?.connected && ipc.send(payload))
|
|
3184
|
+
if (ipc?.connected && ipc.send(this.bindHumanReceiptPayload(instanceName, payload)))
|
|
2914
3185
|
return true;
|
|
2915
3186
|
}
|
|
2916
3187
|
const attempt = (queued ?? Promise.resolve())
|
|
@@ -2945,7 +3216,7 @@ export class FleetManager {
|
|
|
2945
3216
|
// Re-read every round: a reconnect replaces the IpcClient object entirely,
|
|
2946
3217
|
// so a cached reference would stay dead forever.
|
|
2947
3218
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
2948
|
-
if (ipc?.connected && ipc.send(payload))
|
|
3219
|
+
if (ipc?.connected && ipc.send(this.bindHumanReceiptPayload(instanceName, payload)))
|
|
2949
3220
|
return true;
|
|
2950
3221
|
if (Date.now() >= deadline) {
|
|
2951
3222
|
throw new Error(`Instance '${instanceName}' IPC is unavailable`);
|
|
@@ -2980,7 +3251,8 @@ export class FleetManager {
|
|
|
2980
3251
|
* or a rebound adapter is never posted through a stale route. A copy
|
|
2981
3252
|
* already in flight may still land late — the contract permits that.
|
|
2982
3253
|
*/
|
|
2983
|
-
|
|
3254
|
+
/** `sender` (#1827): the web person's name, as the room log records them ("web-user" when none). */
|
|
3255
|
+
async sendClassicWebEcho(instance, text, sender = WEB_NAME_FALLBACK) {
|
|
2984
3256
|
const guard = this.webChannelEchoGuards.get(instance);
|
|
2985
3257
|
const handled = new Set();
|
|
2986
3258
|
let posted = 0;
|
|
@@ -3011,7 +3283,7 @@ export class FleetManager {
|
|
|
3011
3283
|
if (!adapter?.sendText)
|
|
3012
3284
|
continue;
|
|
3013
3285
|
await adapter.sendText(entry.channelId, text, { format: "text", allowedMentions: { parse: [] } });
|
|
3014
|
-
ClassicChannelManager.logMessage(instance,
|
|
3286
|
+
ClassicChannelManager.logMessage(instance, sender, text, new Date());
|
|
3015
3287
|
posted++;
|
|
3016
3288
|
}
|
|
3017
3289
|
catch (err) {
|
|
@@ -3020,13 +3292,38 @@ export class FleetManager {
|
|
|
3020
3292
|
}
|
|
3021
3293
|
return posted;
|
|
3022
3294
|
}
|
|
3023
|
-
|
|
3295
|
+
/**
|
|
3296
|
+
* #1666: a delivered web chat message starts the same cancel/progress lifecycle in the instance's topic as a platform
|
|
3297
|
+
* inbound (afterUserInboundDelivered): the same bubble, ticker and retire rules. Not its ✅ tracking — the web message
|
|
3298
|
+
* id is no platform message, and the echo is the fleet's own post. The web send calls this on the echo's lane.
|
|
3299
|
+
*/
|
|
3300
|
+
startWebChatProgress(instanceName) {
|
|
3301
|
+
if (this.shuttingDown)
|
|
3302
|
+
return Promise.resolve();
|
|
3303
|
+
return this.sendCancelButton(instanceName);
|
|
3304
|
+
}
|
|
3305
|
+
/**
|
|
3306
|
+
* `copy: false` (#1666/#1668): an entry that is not a copy of a message — the web send's Cancel/progress bubble. It
|
|
3307
|
+
* 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
|
|
3308
|
+
* starts (waiting behind a slow echo is not its delay), its own timeout only releases the lane (the copies queued
|
|
3309
|
+
* behind it are not "behind an ambiguous copy"), and another entry's timeout never drops it. A copy reserved while
|
|
3310
|
+
* any non-copy is still open starts its own budget when the last of them finishes: a slow bubble is no copy's delay,
|
|
3311
|
+
* however far back the copy is. And a timeout only ever drops copies reserved AFTER the entry that timed out — with
|
|
3312
|
+
* deferred budgets, deadlines no longer grow with the order, so that rule is stated, not implied (#1668 r3).
|
|
3313
|
+
*/
|
|
3314
|
+
reserveWebChannelEcho(instanceName, sendEcho, opts = {}) {
|
|
3315
|
+
const copy = opts.copy !== false;
|
|
3024
3316
|
const epoch = this.getDeliveryEpoch(instanceName);
|
|
3025
|
-
const
|
|
3317
|
+
const queued = this.webChannelEchoTails.get(instanceName);
|
|
3318
|
+
// The last open non-copy ahead of this copy: its budget starts when that one finishes.
|
|
3319
|
+
const nonCopyAhead = copy && (queued?.nonCopiesOpen ?? 0) > 0 ? queued?.lastNonCopyDone : undefined;
|
|
3320
|
+
let deadlineAt = copy && !nonCopyAhead ? performance.now() + 5_000 : Number.POSITIVE_INFINITY;
|
|
3026
3321
|
let decide;
|
|
3027
3322
|
const admission = new Promise(resolve => { decide = resolve; });
|
|
3028
|
-
const queue =
|
|
3323
|
+
const queue = queued
|
|
3029
3324
|
?? { tail: Promise.resolve(), pending: new Set() };
|
|
3325
|
+
const seq = queue.nextSeq ?? 0;
|
|
3326
|
+
queue.nextSeq = seq + 1;
|
|
3030
3327
|
const previous = queue.tail;
|
|
3031
3328
|
let resolveDone;
|
|
3032
3329
|
const tail = new Promise(resolve => { resolveDone = resolve; });
|
|
@@ -3037,6 +3334,8 @@ export class FleetManager {
|
|
|
3037
3334
|
if (done)
|
|
3038
3335
|
return;
|
|
3039
3336
|
done = true;
|
|
3337
|
+
if (!copy)
|
|
3338
|
+
queue.nonCopiesOpen = Math.max(0, (queue.nonCopiesOpen ?? 0) - 1);
|
|
3040
3339
|
clearTimeout(timer);
|
|
3041
3340
|
queue.pending.delete(entry);
|
|
3042
3341
|
if (guard && this.webChannelEchoGuards.get(instanceName) === guard) {
|
|
@@ -3044,7 +3343,7 @@ export class FleetManager {
|
|
|
3044
3343
|
}
|
|
3045
3344
|
resolveDone();
|
|
3046
3345
|
};
|
|
3047
|
-
const entry = { started: false, drop: () => {
|
|
3346
|
+
const entry = { seq, started: false, copy, drop: () => {
|
|
3048
3347
|
this.logger.warn({ instanceName }, "Queued web channel echo dropped after ordering timeout");
|
|
3049
3348
|
finish();
|
|
3050
3349
|
} };
|
|
@@ -3054,14 +3353,16 @@ export class FleetManager {
|
|
|
3054
3353
|
expired = true;
|
|
3055
3354
|
this.logger.warn({ instanceName, inFlight: entry.started }, "Web channel echo ordering timed out");
|
|
3056
3355
|
finish();
|
|
3057
|
-
// Do not post copies that were queued behind an ambiguous platform send.
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3356
|
+
// Do not post copies that were queued behind an ambiguous platform send. A non-copy that timed out was no copy
|
|
3357
|
+
// at all (nothing behind it is ambiguous), and a non-copy is never dropped as one.
|
|
3358
|
+
if (copy)
|
|
3359
|
+
for (const pending of queue.pending)
|
|
3360
|
+
if (pending.seq > seq && !pending.started && pending.copy)
|
|
3361
|
+
pending.drop();
|
|
3061
3362
|
};
|
|
3062
3363
|
const checkDeadline = () => {
|
|
3063
|
-
if (done)
|
|
3064
|
-
return;
|
|
3364
|
+
if (done || deadlineAt === Number.POSITIVE_INFINITY)
|
|
3365
|
+
return; // a non-copy's budget has not started yet
|
|
3065
3366
|
const remaining = deadlineAt - performance.now();
|
|
3066
3367
|
if (remaining <= 0)
|
|
3067
3368
|
expire();
|
|
@@ -3070,8 +3371,19 @@ export class FleetManager {
|
|
|
3070
3371
|
};
|
|
3071
3372
|
queue.pending.add(entry);
|
|
3072
3373
|
queue.tail = tail;
|
|
3374
|
+
if (!copy) {
|
|
3375
|
+
queue.nonCopiesOpen = (queue.nonCopiesOpen ?? 0) + 1;
|
|
3376
|
+
queue.lastNonCopyDone = tail;
|
|
3377
|
+
}
|
|
3073
3378
|
this.webChannelEchoTails.set(instanceName, queue);
|
|
3074
3379
|
checkDeadline();
|
|
3380
|
+
// Registered before the admission below (which waits on `previous`, itself after the non-copy ahead), so the budget
|
|
3381
|
+
// is set by the time that runs.
|
|
3382
|
+
if (nonCopyAhead)
|
|
3383
|
+
void nonCopyAhead.then(() => { if (!done) {
|
|
3384
|
+
deadlineAt = performance.now() + 5_000;
|
|
3385
|
+
checkDeadline();
|
|
3386
|
+
} });
|
|
3075
3387
|
void Promise.all([previous, admission]).then(([, accepted]) => {
|
|
3076
3388
|
if (done)
|
|
3077
3389
|
return;
|
|
@@ -3080,11 +3392,15 @@ export class FleetManager {
|
|
|
3080
3392
|
return;
|
|
3081
3393
|
}
|
|
3082
3394
|
// A delayed timer callback must not admit an already expired copy.
|
|
3083
|
-
if (performance.now() >= deadlineAt) {
|
|
3395
|
+
if (copy && performance.now() >= deadlineAt) {
|
|
3084
3396
|
expire();
|
|
3085
3397
|
return;
|
|
3086
3398
|
}
|
|
3087
3399
|
entry.started = true;
|
|
3400
|
+
if (!copy) {
|
|
3401
|
+
deadlineAt = performance.now() + 5_000;
|
|
3402
|
+
checkDeadline();
|
|
3403
|
+
}
|
|
3088
3404
|
guard = { epoch, deadlineAt };
|
|
3089
3405
|
this.webChannelEchoGuards.set(instanceName, guard);
|
|
3090
3406
|
let request;
|
|
@@ -3119,6 +3435,101 @@ export class FleetManager {
|
|
|
3119
3435
|
instanceLaunchSupportsSteer(instanceName) {
|
|
3120
3436
|
return this.daemons.get(instanceName)?.launchSupportsSteer();
|
|
3121
3437
|
}
|
|
3438
|
+
humanReceiptDaemons = new WeakSet();
|
|
3439
|
+
/** Cached FM + lifecycle + Daemon + tmux owner; unknown ownership grants nothing. */
|
|
3440
|
+
humanReceiptOwner(instanceName, daemon = this.daemons.get(instanceName)) {
|
|
3441
|
+
try {
|
|
3442
|
+
if (this.shuttingDown || !daemon || this.daemons.get(instanceName) !== daemon)
|
|
3443
|
+
return null;
|
|
3444
|
+
const epoch = this.lifecycle.epochOf(instanceName);
|
|
3445
|
+
const owner = daemon.captureHumanReceiptOwner();
|
|
3446
|
+
if (!Number.isSafeInteger(epoch) || !owner)
|
|
3447
|
+
return null;
|
|
3448
|
+
return JSON.stringify([this.managerBootId, epoch, this.getDeliveryEpoch(instanceName), humanOwnerObjectId(daemon), owner]);
|
|
3449
|
+
}
|
|
3450
|
+
catch {
|
|
3451
|
+
return null;
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
listenForHumanReceipt(instanceName, daemon) {
|
|
3455
|
+
// Lazy initialization also supports narrow prototype-based integration fixtures.
|
|
3456
|
+
this.humanReceiptDaemons ??= new WeakSet();
|
|
3457
|
+
if (this.humanReceiptDaemons.has(daemon))
|
|
3458
|
+
return;
|
|
3459
|
+
this.humanReceiptDaemons.add(daemon);
|
|
3460
|
+
daemon.on("human_receipt_result", (result) => {
|
|
3461
|
+
try {
|
|
3462
|
+
this.deliveryOutbox?.settleHumanReceipt(result.receipt_id, instanceName, result.owner_key, result.proof, this.humanReceiptOwner(instanceName, daemon));
|
|
3463
|
+
}
|
|
3464
|
+
catch (err) {
|
|
3465
|
+
this.logger.error({ err, instanceName }, "Could not persist human delivery evidence");
|
|
3466
|
+
}
|
|
3467
|
+
});
|
|
3468
|
+
}
|
|
3469
|
+
/** Evidence-only admission. Bots/system/agent payloads have no trusted origin. */
|
|
3470
|
+
humanReceiptPayload(instanceName, payload, origin, originalBody) {
|
|
3471
|
+
if (!origin || !this.deliveryOutbox)
|
|
3472
|
+
return payload;
|
|
3473
|
+
const row = this.deliveryOutbox.createHumanReceipt({ targetInstance: instanceName, originalBody,
|
|
3474
|
+
origin: { ...origin }, inputKind: String(payload.type), managerBootId: this.managerBootId });
|
|
3475
|
+
return { ...payload, meta: { ...(payload.meta ?? {}),
|
|
3476
|
+
human_receipt_id: row.receipt_id, verification_id: row.message_id } };
|
|
3477
|
+
}
|
|
3478
|
+
/** Bind at the actual IPC send, after all wake/reconnect waits. No human retry lane. */
|
|
3479
|
+
bindHumanReceiptPayload(instanceName, payload) {
|
|
3480
|
+
const meta = payload.meta;
|
|
3481
|
+
if (!meta?.human_receipt_id || !this.deliveryOutbox)
|
|
3482
|
+
return payload;
|
|
3483
|
+
const daemon = this.daemons.get(instanceName);
|
|
3484
|
+
const owner = this.humanReceiptOwner(instanceName, daemon);
|
|
3485
|
+
if (!daemon || !owner) {
|
|
3486
|
+
this.deliveryOutbox.markHumanReceiptUncertain(meta.human_receipt_id, instanceName);
|
|
3487
|
+
return payload;
|
|
3488
|
+
}
|
|
3489
|
+
this.listenForHumanReceipt(instanceName, daemon);
|
|
3490
|
+
const daemonOwner = daemon.captureHumanReceiptOwner();
|
|
3491
|
+
const wire = daemon.humanReceiptWire(String(payload.content ?? ""), meta, String(payload.type));
|
|
3492
|
+
const row = this.deliveryOutbox.bindHumanReceipt(meta.human_receipt_id, instanceName, owner, wire, this.managerBootId);
|
|
3493
|
+
if (!daemonOwner || this.humanReceiptOwner(instanceName, daemon) !== owner)
|
|
3494
|
+
throw new Error("Human delivery ownership changed");
|
|
3495
|
+
// /raw retains exact bytes and never acquires a positive verification capability.
|
|
3496
|
+
if (payload.type === "raw_paste" || String(payload.content ?? "").startsWith("/raw ")) {
|
|
3497
|
+
this.deliveryOutbox.markHumanReceiptUncertain(row.receipt_id, instanceName);
|
|
3498
|
+
return payload;
|
|
3499
|
+
}
|
|
3500
|
+
return { ...payload, meta: { ...meta, human_owner_key: owner, human_daemon_owner: daemonOwner, human_wire_sha256: row.wire_sha256 } };
|
|
3501
|
+
}
|
|
3502
|
+
async deliverHumanToInstance(instanceName, payload, origin, originalBody, options) {
|
|
3503
|
+
const admitted = this.humanReceiptPayload(instanceName, payload, origin, originalBody);
|
|
3504
|
+
const id = admitted.meta?.human_receipt_id;
|
|
3505
|
+
let sent = false;
|
|
3506
|
+
try {
|
|
3507
|
+
const result = options ? await this.deliverToInstance(instanceName, admitted, options) : await this.deliverToInstance(instanceName, admitted);
|
|
3508
|
+
sent = result !== false;
|
|
3509
|
+
return result;
|
|
3510
|
+
}
|
|
3511
|
+
finally {
|
|
3512
|
+
if (!sent && id)
|
|
3513
|
+
this.deliveryOutbox?.markHumanReceiptUncertain(id, instanceName);
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
/** Synchronous steer/btw handoff, using the same evidence and owner binding. */
|
|
3517
|
+
sendHumanControl(instanceName, payload, origin, originalBody) {
|
|
3518
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
3519
|
+
if (this.shuttingDown || !ipc?.connected)
|
|
3520
|
+
return false;
|
|
3521
|
+
const admitted = this.humanReceiptPayload(instanceName, payload, origin, originalBody);
|
|
3522
|
+
const id = admitted.meta?.human_receipt_id;
|
|
3523
|
+
let sent = false;
|
|
3524
|
+
try {
|
|
3525
|
+
sent = ipc.send(this.bindHumanReceiptPayload(instanceName, admitted));
|
|
3526
|
+
return sent;
|
|
3527
|
+
}
|
|
3528
|
+
finally {
|
|
3529
|
+
if (!sent && id)
|
|
3530
|
+
this.deliveryOutbox?.markHumanReceiptUncertain(id, instanceName);
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3122
3533
|
/** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
|
|
3123
3534
|
async deliverToInstance(instanceName, payload, options = {}) {
|
|
3124
3535
|
const deliveryEpoch = this.getDeliveryEpoch(instanceName);
|
|
@@ -3199,6 +3610,20 @@ export class FleetManager {
|
|
|
3199
3610
|
hasFleetAdmins(adapterId) {
|
|
3200
3611
|
return (this.adminListOf(adapterId)?.length ?? 0) > 0;
|
|
3201
3612
|
}
|
|
3613
|
+
/**
|
|
3614
|
+
* Where this adapter's fleet-admin list is in fleet.yaml, as the operator wrote it, for the "disabled" replies:
|
|
3615
|
+
* `channel.access.allowed_users` (the single-channel form), else the `channels:` entry — by its `id:`, or its
|
|
3616
|
+
* `type:` when it has none. Read from the raw file: loading turns `channel:` into `channels` and fills in ids.
|
|
3617
|
+
*/
|
|
3618
|
+
adminListKey(adapterId) {
|
|
3619
|
+
const raw = this.rawFleetConfig;
|
|
3620
|
+
if (!Array.isArray(raw.channels) && raw.channel)
|
|
3621
|
+
return "channel.access.allowed_users";
|
|
3622
|
+
const channels = Array.isArray(raw.channels) ? raw.channels : this.fleetConfig?.channels ?? [];
|
|
3623
|
+
const id = adapterId ?? this.getPrimaryAdapterId();
|
|
3624
|
+
const entry = channels.find(ch => (ch.id ?? ch.type) === id);
|
|
3625
|
+
return `channels[${entry?.id ? `id: ${entry.id}` : `type: ${entry?.type ?? id}`}].access.allowed_users`;
|
|
3626
|
+
}
|
|
3202
3627
|
/**
|
|
3203
3628
|
* The one question the privileged commands (/update, /doctor, /dashboard, /collab) ask: may THIS caller,
|
|
3204
3629
|
* through THIS adapter, run it. `disabled` is "nobody can" — an empty list — and is told apart from
|
|
@@ -3251,6 +3676,9 @@ export class FleetManager {
|
|
|
3251
3676
|
* the check and the act, so the channel's instance cannot be rebound to another bot in between.
|
|
3252
3677
|
*/
|
|
3253
3678
|
async dispatchSlash(slash, adapterId, adapter) {
|
|
3679
|
+
return withSpawnHoldReply(text => slash.respond(text), () => this.dispatchSlashCore(slash, adapterId, adapter));
|
|
3680
|
+
}
|
|
3681
|
+
async dispatchSlashCore(slash, adapterId, adapter) {
|
|
3254
3682
|
// #1569: an alias (/web) is its command (/dashboard) from here on — the door, the table and the handler see one name.
|
|
3255
3683
|
const data = { ...slash, command: canonicalCommand(slash.command) };
|
|
3256
3684
|
const door = this.slashDoor(data, adapterId);
|
|
@@ -3273,6 +3701,9 @@ export class FleetManager {
|
|
|
3273
3701
|
if (!decision.allow) {
|
|
3274
3702
|
this.logger.info({ command: data.command, adapterId, scope, reply: decision.reply[0] }, "Slash command refused by the command table");
|
|
3275
3703
|
const [key, ...args] = decision.reply;
|
|
3704
|
+
// A command off for want of fleet admins names where they go (`{0}` in its disabled reply).
|
|
3705
|
+
if (decision.reply === spec.disabled)
|
|
3706
|
+
args.push(this.adminListKey(adapterId));
|
|
3276
3707
|
await data.respond(t(key, ...args)).catch(() => { });
|
|
3277
3708
|
return;
|
|
3278
3709
|
}
|
|
@@ -3308,7 +3739,7 @@ export class FleetManager {
|
|
|
3308
3739
|
userId: data.userId,
|
|
3309
3740
|
username,
|
|
3310
3741
|
source: "discord",
|
|
3311
|
-
timestamp: new Date(),
|
|
3742
|
+
timestamp: new Date(), adapterId, humanOrigin: data.humanOrigin, humanOriginalBody: text,
|
|
3312
3743
|
});
|
|
3313
3744
|
}
|
|
3314
3745
|
else if (data.command === "save") {
|
|
@@ -3332,6 +3763,9 @@ export class FleetManager {
|
|
|
3332
3763
|
else if (data.command === "model") {
|
|
3333
3764
|
await this.handleModelSlash(data, adapterId);
|
|
3334
3765
|
}
|
|
3766
|
+
else if (data.command === "backend") {
|
|
3767
|
+
await this.handleBackendSlash(data, adapterId);
|
|
3768
|
+
}
|
|
3335
3769
|
else if (data.command === "clear") {
|
|
3336
3770
|
await this.handleClearSlash(data, adapterId);
|
|
3337
3771
|
}
|
|
@@ -3425,7 +3859,7 @@ export class FleetManager {
|
|
|
3425
3859
|
await data.respond(result.durable ? t("dashboard.revoked", result.count) : t("dashboard.revoked_not_durable", result.count));
|
|
3426
3860
|
}
|
|
3427
3861
|
else {
|
|
3428
|
-
const owner = this.dashboardOwner(data.userId, adapterId, data.channelId);
|
|
3862
|
+
const owner = this.dashboardOwner(data.userId, adapterId, data.channelId, undefined, data.username);
|
|
3429
3863
|
if (!owner) {
|
|
3430
3864
|
await this.sendLocalDashboard(data, adapterId);
|
|
3431
3865
|
return;
|
|
@@ -3453,7 +3887,8 @@ export class FleetManager {
|
|
|
3453
3887
|
await data.respond(t("classic.no_agent"));
|
|
3454
3888
|
return;
|
|
3455
3889
|
}
|
|
3456
|
-
const
|
|
3890
|
+
const originalSteerText = String(data.options?.message ?? "");
|
|
3891
|
+
const steerText = originalSteerText.trim();
|
|
3457
3892
|
if (!steerText) {
|
|
3458
3893
|
await data.respond(t("steer.usage"));
|
|
3459
3894
|
return;
|
|
@@ -3463,7 +3898,7 @@ export class FleetManager {
|
|
|
3463
3898
|
// rerouting the instance's replies to the slash context.
|
|
3464
3899
|
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
3465
3900
|
chatId: "", messageId: "", username: data.username ?? "user",
|
|
3466
|
-
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
3901
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord", humanOrigin: data.humanOrigin, humanOriginalBody: originalSteerText,
|
|
3467
3902
|
});
|
|
3468
3903
|
await data.respond(result);
|
|
3469
3904
|
}
|
|
@@ -3473,14 +3908,15 @@ export class FleetManager {
|
|
|
3473
3908
|
await data.respond(t("classic.no_agent"));
|
|
3474
3909
|
return;
|
|
3475
3910
|
}
|
|
3476
|
-
const
|
|
3911
|
+
const originalBtwText = String(data.options?.message ?? "");
|
|
3912
|
+
const btwText = originalBtwText.trim();
|
|
3477
3913
|
if (!btwText) {
|
|
3478
3914
|
await data.respond(t("btw.usage"));
|
|
3479
3915
|
return;
|
|
3480
3916
|
}
|
|
3481
3917
|
const result = this.topicCommands.sendBtw(name, btwText, {
|
|
3482
3918
|
chatId: "", messageId: "", username: data.username ?? "user",
|
|
3483
|
-
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
3919
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord", humanOrigin: data.humanOrigin, humanOriginalBody: originalBtwText,
|
|
3484
3920
|
});
|
|
3485
3921
|
await data.respond(result);
|
|
3486
3922
|
}
|
|
@@ -3780,12 +4216,16 @@ export class FleetManager {
|
|
|
3780
4216
|
const autoPauseChanged = oldAutoPause.get(ch.instanceName) !== newAutoPause;
|
|
3781
4217
|
// #1220: a running CLI keeps the login it was launched with, so a new
|
|
3782
4218
|
// credential_profile only takes effect through a restart.
|
|
3783
|
-
const { changed: profileChanged, startsFresh: profileSwitchStartsFresh, from: profileFrom, to: profileTo } = this.classicProfileChange(ch.instanceName, backendChanged);
|
|
4219
|
+
const { changed: profileChanged, startsFresh: profileSwitchStartsFresh, from: profileFrom, to: profileTo, carry: profileCarry } = this.classicProfileChange(ch.instanceName, backendChanged);
|
|
3784
4220
|
if (this.daemons.has(ch.instanceName) && (backendChanged || modelChanged || autoPauseChanged || profileChanged)) {
|
|
3785
4221
|
this.logger.info({ instanceName: ch.instanceName, backendFrom: oldBackends.get(ch.instanceName), backendTo: newBackend, modelFrom: oldModels.get(ch.instanceName), modelTo: newModel,
|
|
3786
4222
|
...(profileChanged ? { profileFrom, profileTo } : {}) }, "Backend/model/profile changed — restarting");
|
|
4223
|
+
const launchedIn = this.daemons.get(ch.instanceName)?.getConfigSnapshot?.()?.working_directory;
|
|
3787
4224
|
await this.stopInstance(ch.instanceName).catch(() => { });
|
|
3788
|
-
|
|
4225
|
+
// #1753: the conversation goes along where it can; otherwise a switch that loses it starts fresh.
|
|
4226
|
+
if (profileCarry)
|
|
4227
|
+
await this.switchConversation(ch.instanceName, { ...profileCarry, workingDirectory: launchedIn });
|
|
4228
|
+
else if (profileSwitchStartsFresh)
|
|
3789
4229
|
this.writeFreshStartMarker(ch.instanceName);
|
|
3790
4230
|
// Small delay to let tmux window clean up
|
|
3791
4231
|
await new Promise(r => setTimeout(r, 2000));
|
|
@@ -4418,6 +4858,9 @@ export class FleetManager {
|
|
|
4418
4858
|
// Published so the outage hand-off can wait for an explicit stop that began
|
|
4419
4859
|
// BEFORE it (the generation fence alone only catches stops that begin after
|
|
4420
4860
|
// the hand-off snapshotted it).
|
|
4861
|
+
// #1716: scope the marker to this stop — a marker older than beganAt is
|
|
4862
|
+
// from an earlier stop and never reports.
|
|
4863
|
+
const beganAt = Date.now();
|
|
4421
4864
|
const run = (async () => {
|
|
4422
4865
|
try {
|
|
4423
4866
|
await this.lifecycle.stop(name, transition);
|
|
@@ -4425,6 +4868,10 @@ export class FleetManager {
|
|
|
4425
4868
|
finally {
|
|
4426
4869
|
this.clearNoncePromptsForInstance(name);
|
|
4427
4870
|
}
|
|
4871
|
+
const backgroundWorkEnded = readBackgroundWorkEndedMarker(this.getInstanceDir(name), beganAt) !== null;
|
|
4872
|
+
if (backgroundWorkEnded)
|
|
4873
|
+
this.notifyInstanceTopic(name, t("stop.background_work_ended", name));
|
|
4874
|
+
return { backgroundWorkEnded };
|
|
4428
4875
|
})().finally(() => { if (this.stopsInFlight.get(name) === run)
|
|
4429
4876
|
this.stopsInFlight.delete(name); });
|
|
4430
4877
|
this.stopsInFlight.set(name, run);
|
|
@@ -4439,6 +4886,16 @@ export class FleetManager {
|
|
|
4439
4886
|
const inFlight = this.restartsInFlight.get(name);
|
|
4440
4887
|
if (inFlight) {
|
|
4441
4888
|
this.logger.info({ name }, "restartSingleInstance: joining the restart already in flight");
|
|
4889
|
+
// #1753: a credential-profile switch joining it still needs its conversation carried in that restart's stop →
|
|
4890
|
+
// start gap: handed to the restart while it has not reached the gap (and carries nothing else); otherwise not
|
|
4891
|
+
// carried, and said so — the restart has already started, or will start, the CLI without it.
|
|
4892
|
+
if (opts?.carry) {
|
|
4893
|
+
const gap = this.restartGaps.get(name);
|
|
4894
|
+
if (gap && !gap.reached && !gap.carry)
|
|
4895
|
+
gap.carry = opts.carry;
|
|
4896
|
+
else
|
|
4897
|
+
opts.carry.result = { carried: false, reason: gap?.carry ? "a restart in flight carries another switch" : "joined a restart in flight past its stop" };
|
|
4898
|
+
}
|
|
4442
4899
|
return inFlight;
|
|
4443
4900
|
}
|
|
4444
4901
|
const workingDirectory = this.fleetConfig?.instances[name]?.working_directory || this.getInstanceDir(name);
|
|
@@ -4447,6 +4904,8 @@ export class FleetManager {
|
|
|
4447
4904
|
// spawn gate is taken inside the transition, never the other way round, so
|
|
4448
4905
|
// the start being waited for can still get its own gate slot.
|
|
4449
4906
|
this.lifecycle.invalidate(name);
|
|
4907
|
+
const gap = { carry: opts?.carry, reached: false };
|
|
4908
|
+
this.restartGaps.set(name, gap);
|
|
4450
4909
|
const run = this.lifecycle.runTransition(name, transition => this.spawnGate.run({
|
|
4451
4910
|
instanceName: name,
|
|
4452
4911
|
workingDirectory,
|
|
@@ -4455,6 +4914,8 @@ export class FleetManager {
|
|
|
4455
4914
|
}, () => this.doRestartSingleInstance(name, opts, transition)))
|
|
4456
4915
|
.finally(() => {
|
|
4457
4916
|
this.restartsInFlight.delete(name);
|
|
4917
|
+
if (this.restartGaps.get(name) === gap)
|
|
4918
|
+
this.restartGaps.delete(name);
|
|
4458
4919
|
this.scheduleDeliveryOutboxPump();
|
|
4459
4920
|
});
|
|
4460
4921
|
this.restartsInFlight.set(name, run);
|
|
@@ -4524,10 +4985,41 @@ export class FleetManager {
|
|
|
4524
4985
|
async doRestartWithSlot(name, opts, transition, config, wasPaused) {
|
|
4525
4986
|
const pausedAt = wasPaused ? (this.lifecycle.getLastPausedAt(name) ?? readPausedAt(this.getInstanceDir(name))) : null;
|
|
4526
4987
|
const pauseReason = wasPaused ? readPauseReason(this.getInstanceDir(name)) : null;
|
|
4988
|
+
// #1753: the directory the CLI being stopped was launched in — its conversation is under that directory's key.
|
|
4989
|
+
const launchedIn = this.daemons.get(name)?.getConfigSnapshot?.()?.working_directory;
|
|
4990
|
+
// Between the stop and the start: the old CLI's transcript is complete, the new one has not looked yet.
|
|
4991
|
+
/** `launching`: the login the start will use — read only when a switch is to be carried. */
|
|
4992
|
+
const betweenStopAndStart = async (launching) => {
|
|
4993
|
+
// #1753: this restart's own carry, or one a caller joined it with before this point (restartSingleInstance).
|
|
4994
|
+
const gap = this.restartGaps.get(name);
|
|
4995
|
+
const carry = gap?.carry ?? opts?.carry;
|
|
4996
|
+
if (gap)
|
|
4997
|
+
gap.reached = true;
|
|
4998
|
+
if (!carry) {
|
|
4999
|
+
if (opts?.freshStart)
|
|
5000
|
+
this.writeFreshStartMarker(name);
|
|
5001
|
+
return;
|
|
5002
|
+
}
|
|
5003
|
+
// Only into the login this start really launches: a restart that read its configuration before the switch was
|
|
5004
|
+
// saved (one a switch joined) launches the old one — a copy into the new would be reported as carried, falsely.
|
|
5005
|
+
const launch = launching();
|
|
5006
|
+
if (launch.backend !== carry.backend || launch.profile !== carry.to) {
|
|
5007
|
+
const login = (p) => (p ? `profile "${p}"` : "the default login");
|
|
5008
|
+
carry.result = { carried: false, reason: `the restart launches ${launch.backend !== carry.backend ? launch.backend : login(launch.profile)}, not ${login(carry.to)}` };
|
|
5009
|
+
this.logger.info({ name, launches: launch, carry: { backend: carry.backend, from: carry.from, to: carry.to } }, "Conversation not carried: the restart launches another login");
|
|
5010
|
+
// The old login resumes its own conversation; any other starts fresh, as an uncarried switch does.
|
|
5011
|
+
if (launch.backend !== carry.backend || launch.profile !== carry.from)
|
|
5012
|
+
this.writeFreshStartMarker(name);
|
|
5013
|
+
return;
|
|
5014
|
+
}
|
|
5015
|
+
carry.result = await this.switchConversation(name, { ...carry, workingDirectory: launchedIn });
|
|
5016
|
+
};
|
|
4527
5017
|
if (config) {
|
|
4528
5018
|
await this.stopInstance(name, transition);
|
|
4529
|
-
|
|
4530
|
-
this.
|
|
5019
|
+
await betweenStopAndStart(() => {
|
|
5020
|
+
const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
5021
|
+
return { backend, profile: credentialHomeSpec(backend) ? instanceCredentialProfile(config, this.fleetConfig?.defaults, backend) : null };
|
|
5022
|
+
});
|
|
4531
5023
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
4532
5024
|
await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => this.startInstance(name, config, topicMode ?? false, "fleet-topic", false, transition));
|
|
4533
5025
|
this.stormWindow.markRecovered(name);
|
|
@@ -4540,8 +5032,10 @@ export class FleetManager {
|
|
|
4540
5032
|
const adapterId = this.classicChannels.getAdapterIdByInstance(name);
|
|
4541
5033
|
await this.stopInstance(name, transition);
|
|
4542
5034
|
await new Promise(r => setTimeout(r, 1000)); // let tmux clean up
|
|
4543
|
-
|
|
4544
|
-
this.
|
|
5035
|
+
await betweenStopAndStart(() => {
|
|
5036
|
+
const backend = this.classicChannels.getBackendByInstance(name, fleetBackend);
|
|
5037
|
+
return { backend, profile: credentialHomeSpec(backend) ? profileName(this.classicChannels.getCredentialProfileByInstance(name, this.fleetConfig?.defaults)) : null };
|
|
5038
|
+
});
|
|
4545
5039
|
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));
|
|
4546
5040
|
this.stormWindow.markRecovered(name);
|
|
4547
5041
|
return;
|
|
@@ -4562,6 +5056,43 @@ export class FleetManager {
|
|
|
4562
5056
|
this.logger.warn({ err, name }, "freshStart: failed to write crash-state marker");
|
|
4563
5057
|
}
|
|
4564
5058
|
}
|
|
5059
|
+
/**
|
|
5060
|
+
* #1753: a credential-profile switch, between the old CLI's stop and the new one's start. Where the backend can take
|
|
5061
|
+
* the conversation along (claude-code), copy it into the new profile and make it the one `--continue` picks; when that
|
|
5062
|
+
* cannot be done, or the backend cannot, start fresh as before. Null when nothing was tried.
|
|
5063
|
+
*/
|
|
5064
|
+
async switchConversation(name, sw) {
|
|
5065
|
+
if (!credentialSwitchCarriesConversation(sw.backend)) {
|
|
5066
|
+
if (credentialSwitchStartsFresh(sw.backend))
|
|
5067
|
+
this.writeFreshStartMarker(name);
|
|
5068
|
+
return null;
|
|
5069
|
+
}
|
|
5070
|
+
let sessionId = null;
|
|
5071
|
+
try {
|
|
5072
|
+
sessionId = readFileSync(join(this.getInstanceDir(name), "session-id"), "utf-8");
|
|
5073
|
+
}
|
|
5074
|
+
catch { /* none: nothing to carry */ }
|
|
5075
|
+
const dir = (profile) => claudeConfigDirFor(profile ? credentialProfileHome(this.dataDir, sw.backend, profile) : null);
|
|
5076
|
+
let result;
|
|
5077
|
+
try {
|
|
5078
|
+
result = await carryClaudeConversation({ sessionId, workingDirectory: sw.workingDirectory ?? "", fromConfigDir: dir(sw.from), toConfigDir: dir(sw.to) });
|
|
5079
|
+
}
|
|
5080
|
+
catch (err) {
|
|
5081
|
+
result = { carried: false, reason: `copy failed: ${err.message}` };
|
|
5082
|
+
}
|
|
5083
|
+
if (result.carried) {
|
|
5084
|
+
const fields = { name, from: sw.from ?? "default", to: sw.to ?? "default", bytes: result.bytes, ms: result.ms, replacedOlderCopy: result.replacedOlderCopy };
|
|
5085
|
+
if (result.bytes > CARRY_LARGE_BYTES)
|
|
5086
|
+
this.logger.warn(fields, "Conversation carried to the new credential profile (a large transcript)");
|
|
5087
|
+
else
|
|
5088
|
+
this.logger.info(fields, "Conversation carried to the new credential profile");
|
|
5089
|
+
}
|
|
5090
|
+
else {
|
|
5091
|
+
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");
|
|
5092
|
+
this.writeFreshStartMarker(name);
|
|
5093
|
+
}
|
|
5094
|
+
return result;
|
|
5095
|
+
}
|
|
4565
5096
|
/** Load .env file from data dir into process.env */
|
|
4566
5097
|
loadEnvFile(owner) {
|
|
4567
5098
|
const envPath = join(this.dataDir, ".env");
|
|
@@ -4625,7 +5156,13 @@ export class FleetManager {
|
|
|
4625
5156
|
return { display: issued.display, expiresAt: issued.expiresAt, ttlMinutes: Math.round(LOGIN_CODE_TTL_MS / 60_000) };
|
|
4626
5157
|
}
|
|
4627
5158
|
tunnelPurposeLane = null;
|
|
5159
|
+
previewTunnel = null;
|
|
4628
5160
|
publicWebLink = null;
|
|
5161
|
+
/** #1723: the public link's preview tunnel — a manager and lease of its own, never the fleet's one tunnel lane. */
|
|
5162
|
+
getPreviewTunnel() {
|
|
5163
|
+
return this.previewTunnel ??= new ManagedTunnel({ dataDir: join(this.dataDir, PUBLIC_PREVIEW_TUNNEL_DIR),
|
|
5164
|
+
log: () => this.logger.warn("Public preview tunnel cleanup requires attention") });
|
|
5165
|
+
}
|
|
4629
5166
|
getTunnelLane() {
|
|
4630
5167
|
return this.tunnelPurposeLane ??= new TunnelPurposeLane(new ManagedTunnel({ dataDir: this.dataDir,
|
|
4631
5168
|
log: () => this.logger.warn("Managed tunnel cleanup requires attention") }));
|
|
@@ -4644,7 +5181,7 @@ export class FleetManager {
|
|
|
4644
5181
|
&& cfg.topic_id?.toString() === owner.threadId);
|
|
4645
5182
|
}
|
|
4646
5183
|
/** Exact owning General. No primary-adapter or name fallback participates in authorization. */
|
|
4647
|
-
dashboardOwner(userId, adapterId, address, threadId) {
|
|
5184
|
+
dashboardOwner(userId, adapterId, address, threadId, username) {
|
|
4648
5185
|
const world = this.worlds.get(adapterId);
|
|
4649
5186
|
if (!world || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(userId, adapterId))
|
|
4650
5187
|
return null;
|
|
@@ -4659,10 +5196,65 @@ export class FleetManager {
|
|
|
4659
5196
|
const matched = dc ? topic === address || (group === address && topic === threadId)
|
|
4660
5197
|
: group === address && ((topic === "1" && (threadId === undefined || threadId === "1")) || topic === threadId);
|
|
4661
5198
|
if (group && matched)
|
|
4662
|
-
return { userId, adapterId, chatId: group, threadId: topic, binding: world.adapter };
|
|
5199
|
+
return { userId, adapterId, chatId: group, threadId: topic, binding: world.adapter, ...(username ? { username } : {}) };
|
|
4663
5200
|
}
|
|
4664
5201
|
return null;
|
|
4665
5202
|
}
|
|
5203
|
+
/**
|
|
5204
|
+
* #1742: the General a fleet admin's DM speaks for (`/start web`): the same owner `/web` in that General would make
|
|
5205
|
+
* — this bot's configured group and its one General topic. No address to match in a DM, so it must be unambiguous:
|
|
5206
|
+
* no General, or more than one on this bot and group, and there is no owner (nothing is guessed).
|
|
5207
|
+
*/
|
|
5208
|
+
dmDashboardOwner(userId, adapterId, username) {
|
|
5209
|
+
const world = this.worlds.get(adapterId);
|
|
5210
|
+
if (!world || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(userId, adapterId))
|
|
5211
|
+
return null;
|
|
5212
|
+
const configured = (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).find(ch => (ch.id ?? ch.type) === adapterId);
|
|
5213
|
+
const group = String(configured?.group_id ?? "");
|
|
5214
|
+
if (!group)
|
|
5215
|
+
return null;
|
|
5216
|
+
const topics = Object.entries(this.fleetConfig?.instances ?? {})
|
|
5217
|
+
.filter(([name, cfg]) => cfg.general_topic && cfg.topic_id != null && this.getInstanceAdapterId(name) === adapterId
|
|
5218
|
+
&& String(this.getGroupIdForInstance(name) ?? "") === group)
|
|
5219
|
+
.map(([, cfg]) => cfg.topic_id.toString());
|
|
5220
|
+
if (topics.length !== 1)
|
|
5221
|
+
return null;
|
|
5222
|
+
const owner = { userId, adapterId, chatId: group, threadId: topics[0], binding: world.adapter, ...(username ? { username } : {}) };
|
|
5223
|
+
return this.publicOwnerCurrent(owner) ? owner : null;
|
|
5224
|
+
}
|
|
5225
|
+
/**
|
|
5226
|
+
* #1742: a fleet admin whose Telegram `/web` could not reach them privately may next open the bot's DM and press
|
|
5227
|
+
* Telegram's START, which sends a bare `/start` — read as `/start web` (the DM menu) for 10 minutes, once, for that
|
|
5228
|
+
* admin on that bot. Monotonic: a wall-clock jump neither opens nor shuts it.
|
|
5229
|
+
*/
|
|
5230
|
+
tgWebStartWindows = new Map();
|
|
5231
|
+
openTgWebStartWindow(adapterId, userId) {
|
|
5232
|
+
const now = performance.now();
|
|
5233
|
+
for (const [key, until] of this.tgWebStartWindows)
|
|
5234
|
+
if (until <= now)
|
|
5235
|
+
this.tgWebStartWindows.delete(key);
|
|
5236
|
+
this.tgWebStartWindows.set(`${adapterId}\0${userId}`, now + TG_WEB_START_WINDOW_MS);
|
|
5237
|
+
}
|
|
5238
|
+
/** Uses the window up: true when it was open. */
|
|
5239
|
+
takeTgWebStartWindow(adapterId, userId) {
|
|
5240
|
+
const key = `${adapterId}\0${userId}`;
|
|
5241
|
+
const until = this.tgWebStartWindows.get(key);
|
|
5242
|
+
this.tgWebStartWindows.delete(key);
|
|
5243
|
+
return until !== undefined && performance.now() < until;
|
|
5244
|
+
}
|
|
5245
|
+
/** #1742: `/start web` from a fleet admin: the /web menu right here in the DM, for the General this bot serves. */
|
|
5246
|
+
async startWebInDm(msg, adapter) {
|
|
5247
|
+
if (!adapter)
|
|
5248
|
+
return;
|
|
5249
|
+
const owner = msg.chatId === msg.userId ? this.dmDashboardOwner(msg.userId, msg.adapterId ?? "", msg.username) : null;
|
|
5250
|
+
if (!owner) {
|
|
5251
|
+
await adapter.sendText(msg.chatId, t("dashboard.start_web_ack"));
|
|
5252
|
+
return;
|
|
5253
|
+
}
|
|
5254
|
+
if (!await this.showDashboardMenu(owner, undefined, undefined, msg.chatId)) {
|
|
5255
|
+
await adapter.sendText(msg.chatId, t("dashboard.menu_failed")).catch(() => undefined);
|
|
5256
|
+
}
|
|
5257
|
+
}
|
|
4666
5258
|
getPublicWebLink() {
|
|
4667
5259
|
this.initializeWebSessions();
|
|
4668
5260
|
return this.publicWebLink ??= new PublicWebLink({
|
|
@@ -4677,7 +5269,14 @@ export class FleetManager {
|
|
|
4677
5269
|
if (result && !result.durable)
|
|
4678
5270
|
this.logger.warn("Public session revocation could not be saved; exposure remains closed");
|
|
4679
5271
|
},
|
|
4680
|
-
log: (event, exposureId) => this.logger.info({ event, exposureId }, "Public web link"),
|
|
5272
|
+
log: (event, exposureId, detail) => this.logger.info({ event, exposureId, ...(detail ? { detail } : {}) }, "Public web link"),
|
|
5273
|
+
createPreview: ({ id, expiresAt, binary, protocol, dashboardOrigin, isCurrent }) => {
|
|
5274
|
+
if (!publicPreviewWanted(this.fleetConfig?.web))
|
|
5275
|
+
return null;
|
|
5276
|
+
return new PublicPreview({ exposureId: id, expiresAt, dashboardOrigin, isCurrent, manager: this.getPreviewTunnel(),
|
|
5277
|
+
provider: () => new CloudflaredProvider({ binaryName: binary, protocol }),
|
|
5278
|
+
log: (event, detail) => this.logger.info({ event, exposureId: id, ...(detail ? { detail } : {}) }, "Public web link") });
|
|
5279
|
+
},
|
|
4681
5280
|
onCleanupUnconfirmed: (result, owner) => {
|
|
4682
5281
|
this.logger.warn({ pid: result.confirmed ? null : result.pid }, "Public web access closed; tunnel cleanup unconfirmed");
|
|
4683
5282
|
const world = this.worlds.get(owner.adapterId);
|
|
@@ -4700,7 +5299,7 @@ export class FleetManager {
|
|
|
4700
5299
|
/** Telegram's typed command enters through the same strict owner and nonce path as Discord. */
|
|
4701
5300
|
async dashboardMenu(msg) {
|
|
4702
5301
|
const id = msg.adapterId;
|
|
4703
|
-
const owner = id ? this.dashboardOwner(msg.userId, id, msg.chatId, msg.threadId) : null;
|
|
5302
|
+
const owner = id ? this.dashboardOwner(msg.userId, id, msg.chatId, msg.threadId, msg.username) : null;
|
|
4704
5303
|
if (!owner) {
|
|
4705
5304
|
const adapter = id ? this.adapters.get(id) : undefined;
|
|
4706
5305
|
await adapter?.sendText(msg.chatId, t("dashboard.public_general"), { threadId: msg.threadId });
|
|
@@ -4710,6 +5309,16 @@ export class FleetManager {
|
|
|
4710
5309
|
await owner.binding.sendText(owner.chatId, t("dashboard.menu_failed"), { threadId: owner.threadId }).catch(() => undefined);
|
|
4711
5310
|
}
|
|
4712
5311
|
}
|
|
5312
|
+
/**
|
|
5313
|
+
* #1686: build the Telegram deep-link that opens a DM with this bot pre-seeded with `/start web`.
|
|
5314
|
+
* Returns null when no botUsername is known or the username fails Telegram's format validation.
|
|
5315
|
+
*/
|
|
5316
|
+
tgStartWebUrl(adapterId) {
|
|
5317
|
+
const username = this.worlds.get(adapterId)?.botUsername;
|
|
5318
|
+
if (!username || !/^[A-Za-z0-9_]{5,32}$/.test(username))
|
|
5319
|
+
return null;
|
|
5320
|
+
return `https://t.me/${username}?start=web`;
|
|
5321
|
+
}
|
|
4713
5322
|
async sendLocalDashboard(data, adapterId) {
|
|
4714
5323
|
if (this.shuttingDown || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(data.userId, adapterId)) {
|
|
4715
5324
|
await data.respond(t("not_authorized"));
|
|
@@ -4723,7 +5332,9 @@ export class FleetManager {
|
|
|
4723
5332
|
return;
|
|
4724
5333
|
}
|
|
4725
5334
|
this.initializeWebSessions();
|
|
4726
|
-
|
|
5335
|
+
// #1827: the code goes privately to this account (a DM, or else this slash command's ephemeral answer): whoever
|
|
5336
|
+
// redeems it is recognised as that account. Recognition only — no owner (this is not an owning General's code).
|
|
5337
|
+
const login = this.webLoginCodes.issue({ epoch: tokenEpoch(token), recipient: { adapterId, userId: data.userId, ...(data.username ? { username: data.username } : {}) } });
|
|
4727
5338
|
const current = () => !this.shuttingDown && this.adapters.get(adapterId) === adapter
|
|
4728
5339
|
&& this.webToken === token && this.hasFleetAdmins(adapterId) && this.isFleetAdmin(data.userId, adapterId);
|
|
4729
5340
|
const privateText = `${text}\n${t("dashboard.code", login.display, Math.round(LOGIN_CODE_TTL_MS / 60_000))}`;
|
|
@@ -4823,7 +5434,11 @@ export class FleetManager {
|
|
|
4823
5434
|
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");
|
|
4824
5435
|
}
|
|
4825
5436
|
/** False when the menu was not shown (the caller still owes the user an answer). */
|
|
4826
|
-
|
|
5437
|
+
/**
|
|
5438
|
+
* `dmChatId` (#1742): show it in the owner's own private chat with the bot instead of the General — the owner, its
|
|
5439
|
+
* gates and the code's route are the same; only where the menu sits (and its clicks are good) changes.
|
|
5440
|
+
*/
|
|
5441
|
+
async showDashboardMenu(owner, respondButtons, retireButtons, dmChatId) {
|
|
4827
5442
|
if (!this.publicOwnerCurrent(owner)) {
|
|
4828
5443
|
this.logger.info("Dashboard menu not shown: the General owner changed");
|
|
4829
5444
|
return false;
|
|
@@ -4831,16 +5446,17 @@ export class FleetManager {
|
|
|
4831
5446
|
const adapter = owner.binding;
|
|
4832
5447
|
const status = this.getPublicWebStatus();
|
|
4833
5448
|
const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
+ "\n" + t("dashboard.
|
|
4837
|
-
+ (status.
|
|
4838
|
-
|
|
5449
|
+
// Show the action before any link exists. TG's Start instructions belong to confirmed DM refusal below.
|
|
5450
|
+
const menuText = this.topicCommands.getDashboardMenuText()
|
|
5451
|
+
+ (publicAllowed ? "\n" + t("dashboard.public_risk") : "")
|
|
5452
|
+
+ (status.state !== "closed" ? "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
|
|
5453
|
+
+ (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "") : "");
|
|
5454
|
+
const choices = [{ action: "local", label: t("dashboard.local_signin") },
|
|
4839
5455
|
...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
|
|
4840
5456
|
...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
|
|
4841
5457
|
const posted = await this.postNonceButtonPrompt({
|
|
4842
5458
|
prefix: "dashboard:", alertType: "login", instanceName: "dashboard", adapter, adapterId: owner.adapterId,
|
|
4843
|
-
chatId: owner.chatId, threadId: owner.threadId, timeoutMs: 5 * 60_000,
|
|
5459
|
+
chatId: dmChatId ?? owner.chatId, threadId: dmChatId === undefined ? owner.threadId : undefined, timeoutMs: 5 * 60_000,
|
|
4844
5460
|
message: menuText,
|
|
4845
5461
|
choices, expiredText: t("buttons.stale"), extra: { requesterUserId: owner.userId, dashboardOwner: owner, publicExposureId: this.publicWebLink?.exposureId },
|
|
4846
5462
|
...(respondButtons ? { deliver: async (c) => {
|
|
@@ -4903,15 +5519,19 @@ export class FleetManager {
|
|
|
4903
5519
|
}) : null;
|
|
4904
5520
|
let lastSteps;
|
|
4905
5521
|
let deliveredByDm = false;
|
|
5522
|
+
// #1742: a DM was tried and did not go — only then is "open the private chat" the way out.
|
|
5523
|
+
let dmRefused = false;
|
|
4906
5524
|
const send = async (url, exposureId, expiresAt, current = () => this.publicOwnerCurrent(owner)) => {
|
|
4907
5525
|
const token = this.webToken;
|
|
4908
5526
|
if (!token || !current())
|
|
4909
5527
|
return false;
|
|
4910
5528
|
this.initializeWebSessions();
|
|
4911
5529
|
const issued = this.webLoginCodes.issue({ epoch: tokenEpoch(token), audience: exposureId, owner });
|
|
4912
|
-
//
|
|
4913
|
-
const
|
|
4914
|
-
|
|
5530
|
+
// Only a public link has a link expiry; the code's five-minute lifetime applies to either packet.
|
|
5531
|
+
const minutes = Math.round(LOGIN_CODE_TTL_MS / 60_000);
|
|
5532
|
+
const text = expiresAt !== undefined
|
|
5533
|
+
? t("dashboard.private_public", url, issued.display, minutes, formatFleetTime(expiresAt))
|
|
5534
|
+
: t("dashboard.private_local", url, issued.display, minutes);
|
|
4915
5535
|
const deadline = performance.now() + 10_000;
|
|
4916
5536
|
const direct = owner.binding;
|
|
4917
5537
|
// The route the sign-in message took: the code-only follow-up takes the same one (#1586).
|
|
@@ -4928,6 +5548,7 @@ export class FleetManager {
|
|
|
4928
5548
|
}
|
|
4929
5549
|
catch (err) {
|
|
4930
5550
|
this.logDashboardDmFailure(err);
|
|
5551
|
+
dmRefused = true;
|
|
4931
5552
|
if (direct.type !== "discord" || !data.respondPrivate || !current())
|
|
4932
5553
|
throw new Error("private delivery unavailable");
|
|
4933
5554
|
const sent = await withinBudget(data.respondPrivate(text, c), deadline);
|
|
@@ -4973,11 +5594,23 @@ export class FleetManager {
|
|
|
4973
5594
|
this.logger.warn({ err }, "Dashboard sign-in delivery failed");
|
|
4974
5595
|
}
|
|
4975
5596
|
// Only safe, static words enter General; never link, code or platform error text.
|
|
4976
|
-
|
|
4977
|
-
|
|
5597
|
+
// #1742: Telegram's failure walks through opening the DM (never a bare /start, which starts a ClassicBot agent),
|
|
5598
|
+
// with the "先開私訊 →" button when the bot's username is known; and for a while a bare /start there counts as
|
|
5599
|
+
// /start web. A menu shown in the DM itself says the details are right below it. Only when a DM was tried and did
|
|
5600
|
+
// not go: a link that never got that far (the tunnel lane held, the owner gone) says so instead.
|
|
5601
|
+
const telegram = owner.binding.type === "telegram";
|
|
5602
|
+
const dmSteps = !ok && telegram && dmRefused;
|
|
5603
|
+
const tgLink = dmSteps ? this.tgStartWebUrl(adapterId) : null;
|
|
5604
|
+
if (dmSteps)
|
|
5605
|
+
this.openTgWebStartWindow(adapterId, owner.userId);
|
|
5606
|
+
const outcome = t(!ok ? (!telegram ? "dashboard.private_failed" : !dmSteps ? "dashboard.link_failed" : tgLink ? "dashboard.private_failed_tg" : "dashboard.private_failed_tg_nolink")
|
|
5607
|
+
: telegram && entry.chatId === owner.userId ? "dashboard.private_sent_below"
|
|
5608
|
+
: owner.binding.type !== "discord" ? "dashboard.private_sent" : deliveredByDm ? "dashboard.private_sent_dm" : "dashboard.private_sent_here");
|
|
4978
5609
|
await progress;
|
|
4979
5610
|
await steps?.close(); // the last step edit has landed: the outcome cannot be overwritten by it
|
|
4980
|
-
|
|
5611
|
+
// #1686: attach the deep-link button on the failure outcome (Telegram only; Discord and the editClicked path ignore urlButtons).
|
|
5612
|
+
const failUrlButtons = tgLink ? [{ label: t("dashboard.start_web_label"), url: tgLink }] : undefined;
|
|
5613
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, lastSteps ? renderPublicLinkProgress(lastSteps, performance.now(), outcome) : outcome, data.editClicked, failUrlButtons);
|
|
4981
5614
|
return true;
|
|
4982
5615
|
}
|
|
4983
5616
|
/** `/dashboard revoke`: sign out every browser and withdraw any unused code. */
|
|
@@ -5087,7 +5720,6 @@ export class FleetManager {
|
|
|
5087
5720
|
messageId,
|
|
5088
5721
|
});
|
|
5089
5722
|
this.lastUpdateProgressText = null;
|
|
5090
|
-
this.updateCompletionTipText = null;
|
|
5091
5723
|
this.startUpdateProgressMonitor(adapter);
|
|
5092
5724
|
}
|
|
5093
5725
|
/** Persist the public response, wait for idle, then start the canonical service restart. */
|
|
@@ -5118,7 +5750,6 @@ export class FleetManager {
|
|
|
5118
5750
|
return false;
|
|
5119
5751
|
}
|
|
5120
5752
|
this.lastUpdateProgressText = null;
|
|
5121
|
-
this.updateCompletionTipText = null;
|
|
5122
5753
|
this.startUpdateProgressMonitor(adapter);
|
|
5123
5754
|
await this.waitForFullRestartIdleGrace();
|
|
5124
5755
|
// An update can begin while the idle wait yields. Never launch a second
|
|
@@ -5219,6 +5850,86 @@ export class FleetManager {
|
|
|
5219
5850
|
setUpdateProgressStage(this.dataDir, "failed", { error: message });
|
|
5220
5851
|
}
|
|
5221
5852
|
/** The old fleet edits CLI install stages; the new fleet adopts the same message below. */
|
|
5853
|
+
/**
|
|
5854
|
+
* #1660: arm the post-completion cleanup watch and make sure its monitor
|
|
5855
|
+
* actually runs. The new fleet adopts the receipt at boot but nothing else
|
|
5856
|
+
* starts this timer on that path (Prism a) — without this, the owed
|
|
5857
|
+
* outcome would sit in the marker unwatched. READY never waits for it.
|
|
5858
|
+
*/
|
|
5859
|
+
/**
|
|
5860
|
+
* #1735: what the completion of an adopted update does about its retire outcome, read from the marker as it is now.
|
|
5861
|
+
* Already landed with a warning (a detached restart returns before this fleet boots, so the updater retires first)
|
|
5862
|
+
* → render it in the completion. Still owed (`cleanup: "pending"` — a service restart waits for READY, which comes
|
|
5863
|
+
* after the completion) → watch for it. Anything else (no retire on this update, done silently, another receipt)
|
|
5864
|
+
* → nothing.
|
|
5865
|
+
*/
|
|
5866
|
+
/**
|
|
5867
|
+
* #1716: background work this restart/update ended, scoped to the operation
|
|
5868
|
+
* so an older stop never reports (`sinceAt` null = plain boot, report
|
|
5869
|
+
* nothing). A method — not a module function — because the #1737
|
|
5870
|
+
* extracted-completion harness runs this region verbatim with only
|
|
5871
|
+
* `this`-methods plus a fixed context.
|
|
5872
|
+
*/
|
|
5873
|
+
collectBackgroundWorkEndedNames(names, sinceAt) {
|
|
5874
|
+
if (sinceAt === null)
|
|
5875
|
+
return [];
|
|
5876
|
+
return names.filter(n => readBackgroundWorkEndedMarker(this.getInstanceDir(n), sinceAt) !== null);
|
|
5877
|
+
}
|
|
5878
|
+
updateCleanupAtCompletion(pending, operation) {
|
|
5879
|
+
if (!pending || operation !== "update")
|
|
5880
|
+
return null;
|
|
5881
|
+
const receipt = updateReceiptOf(pending);
|
|
5882
|
+
if (!receipt)
|
|
5883
|
+
return null;
|
|
5884
|
+
const current = readUpdateProgress(this.dataDir);
|
|
5885
|
+
const verdict = cleanupWatchVerdict(current, receipt);
|
|
5886
|
+
if (verdict.action === "append")
|
|
5887
|
+
return { render: { warning: verdict.warning, cleanupCommand: verdict.cleanupCommand } };
|
|
5888
|
+
if (verdict.action === "wait" && current?.progress.cleanup === "pending")
|
|
5889
|
+
return { watch: receipt };
|
|
5890
|
+
return null;
|
|
5891
|
+
}
|
|
5892
|
+
armCleanupWatch(receipt, summary) {
|
|
5893
|
+
this.updateCleanupWatch = { receipt, summary };
|
|
5894
|
+
if (!this.updateProgressTimer)
|
|
5895
|
+
this.startUpdateProgressMonitor();
|
|
5896
|
+
}
|
|
5897
|
+
/**
|
|
5898
|
+
* #1660: append a recorded cleanup outcome to the adopted update message,
|
|
5899
|
+
* then release the receipt — but only if this receipt still owns the
|
|
5900
|
+
* marker. The await yields, so a newer receipt (e.g. a full-restart that
|
|
5901
|
+
* landed mid-edit) must not be cleared or disturbed (Prism b).
|
|
5902
|
+
*/
|
|
5903
|
+
async appendCleanupWarning(target, watch, warning, cleanupCommand) {
|
|
5904
|
+
const text = formatRestartProgressCompletion("update", {
|
|
5905
|
+
...watch.summary,
|
|
5906
|
+
warningText: warning,
|
|
5907
|
+
warningCommand: cleanupCommand,
|
|
5908
|
+
}, watch.receipt.startedAt);
|
|
5909
|
+
const stopWatching = () => {
|
|
5910
|
+
this.updateCleanupWatch = null;
|
|
5911
|
+
if (this.updateProgressTimer)
|
|
5912
|
+
clearInterval(this.updateProgressTimer);
|
|
5913
|
+
this.updateProgressTimer = null;
|
|
5914
|
+
};
|
|
5915
|
+
if (text !== this.lastUpdateProgressText) {
|
|
5916
|
+
const adapter = this.adapters.get(target.adapterId);
|
|
5917
|
+
if (!adapter)
|
|
5918
|
+
return; // retry on a later tick
|
|
5919
|
+
await adapter.editMessage(target.chatId, target.messageId, text, target.threadId);
|
|
5920
|
+
this.lastUpdateProgressText = text;
|
|
5921
|
+
}
|
|
5922
|
+
const verdict = cleanupWatchVerdict(readUpdateProgress(this.dataDir), watch.receipt);
|
|
5923
|
+
if (verdict.action === "drop") {
|
|
5924
|
+
// Someone else owns the marker now — leave it entirely alone.
|
|
5925
|
+
this.updateCleanupWatch = null;
|
|
5926
|
+
return;
|
|
5927
|
+
}
|
|
5928
|
+
if (verdict.action === "wait")
|
|
5929
|
+
return; // not terminal; keep watching
|
|
5930
|
+
clearUpdateMarker(this.dataDir);
|
|
5931
|
+
stopWatching();
|
|
5932
|
+
}
|
|
5222
5933
|
startUpdateProgressMonitor(initialAdapter) {
|
|
5223
5934
|
if (this.updateProgressTimer)
|
|
5224
5935
|
clearInterval(this.updateProgressTimer);
|
|
@@ -5232,21 +5943,54 @@ export class FleetManager {
|
|
|
5232
5943
|
this.updateProgressTimer = null;
|
|
5233
5944
|
return;
|
|
5234
5945
|
}
|
|
5946
|
+
// #1660: the completion summary was already posted; only watch for
|
|
5947
|
+
// the retire outcome owed to this exact receipt. Never stage-edit
|
|
5948
|
+
// here (that would clobber the posted completion), and never touch a
|
|
5949
|
+
// receipt that no longer matches (checklist 3/5).
|
|
5950
|
+
const watch = this.updateCleanupWatch;
|
|
5951
|
+
if (watch) {
|
|
5952
|
+
const stopWatching = () => {
|
|
5953
|
+
this.updateCleanupWatch = null;
|
|
5954
|
+
if (this.updateProgressTimer)
|
|
5955
|
+
clearInterval(this.updateProgressTimer);
|
|
5956
|
+
this.updateProgressTimer = null;
|
|
5957
|
+
};
|
|
5958
|
+
const verdict = cleanupWatchVerdict(marker, watch.receipt);
|
|
5959
|
+
if (verdict.action === "drop") {
|
|
5960
|
+
// Receipt gone or replaced (e.g. a later full-restart adopted the
|
|
5961
|
+
// marker): not ours — leave it to its own lifecycle, drop the watch.
|
|
5962
|
+
this.logger.info("[update] cleanup watch: marker receipt changed, dropping watch");
|
|
5963
|
+
this.updateCleanupWatch = null;
|
|
5964
|
+
}
|
|
5965
|
+
else if (verdict.action === "release") {
|
|
5966
|
+
// Done silent, or the updater died past the max age: release it.
|
|
5967
|
+
clearUpdateMarker(this.dataDir);
|
|
5968
|
+
stopWatching();
|
|
5969
|
+
return;
|
|
5970
|
+
}
|
|
5971
|
+
else if (verdict.action === "wait") {
|
|
5972
|
+
return;
|
|
5973
|
+
}
|
|
5974
|
+
else {
|
|
5975
|
+
this.updateProgressEditRunning = true;
|
|
5976
|
+
try {
|
|
5977
|
+
await this.appendCleanupWarning(marker.progress.target, watch, verdict.warning, verdict.cleanupCommand);
|
|
5978
|
+
}
|
|
5979
|
+
catch (err) {
|
|
5980
|
+
this.logger.warn({ err }, "[update] cleanup watch: failed to append warning, will retry");
|
|
5981
|
+
}
|
|
5982
|
+
finally {
|
|
5983
|
+
this.updateProgressEditRunning = false;
|
|
5984
|
+
}
|
|
5985
|
+
return;
|
|
5986
|
+
}
|
|
5987
|
+
}
|
|
5235
5988
|
const target = marker.progress.target;
|
|
5236
5989
|
const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
|
|
5237
5990
|
if (!adapter)
|
|
5238
5991
|
return;
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
// reach RestartProgress.finish(). Append the same optional tip here.
|
|
5242
|
-
if (marker.progress.stage === "complete" && this.tipsEnabled()) {
|
|
5243
|
-
if (this.updateCompletionTipText === null) {
|
|
5244
|
-
const tip = this.pickAvailableTip();
|
|
5245
|
-
this.updateCompletionTipText = tip ? this.formatTip(tip) : "";
|
|
5246
|
-
}
|
|
5247
|
-
if (this.updateCompletionTipText)
|
|
5248
|
-
text += `\n\n${this.updateCompletionTipText}`;
|
|
5249
|
-
}
|
|
5992
|
+
// #1854: no tip on an update's completion (either path); tips come on their own schedule and from /tips.
|
|
5993
|
+
const text = formatUpdateProgress(marker);
|
|
5250
5994
|
if (text === this.lastUpdateProgressText)
|
|
5251
5995
|
return;
|
|
5252
5996
|
this.updateProgressEditRunning = true;
|
|
@@ -5297,7 +6041,10 @@ export class FleetManager {
|
|
|
5297
6041
|
this.rotateFleetLogs();
|
|
5298
6042
|
const fleet = this.loadConfig(configPath);
|
|
5299
6043
|
this.slimFleetConfigAtStartup();
|
|
6044
|
+
this.logConfigWarningsAtStartup();
|
|
5300
6045
|
setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
|
|
6046
|
+
// #1737 review: the marker this boot adopts — the startup clear may only ever clear this one.
|
|
6047
|
+
const bootUpdateMarker = updateMarkerIdentity(this.dataDir);
|
|
5301
6048
|
const savedUpdateProgress = readUpdateProgress(this.dataDir);
|
|
5302
6049
|
const pendingUpdateProgress = savedUpdateProgress
|
|
5303
6050
|
&& savedUpdateProgress.progress.stage !== "failed"
|
|
@@ -5322,7 +6069,11 @@ export class FleetManager {
|
|
|
5322
6069
|
// Pre-flight (advisory, fire-and-forget): warm the per-backend auth cache
|
|
5323
6070
|
// before instances spawn, so a CLI that comes up on its sign-in screen gets
|
|
5324
6071
|
// an instant 🔑 verdict instead of decaying into crash/MCP-died noise.
|
|
5325
|
-
this.lifecycle.primeAuthVerification(Object.values(fleet.instances).map(config =>
|
|
6072
|
+
this.lifecycle.primeAuthVerification(Object.values(fleet.instances).map(config => {
|
|
6073
|
+
const backend = config.backend ?? fleet.defaults?.backend ?? "claude-code";
|
|
6074
|
+
// #1699 follow-up: each instance's own login — a profile is probed with its own environment.
|
|
6075
|
+
return { backend, profile: credentialHomeSpec(backend) ? instanceCredentialProfile(config, fleet.defaults, backend) : null };
|
|
6076
|
+
}));
|
|
5326
6077
|
// Start tmux control mode client for idle detection
|
|
5327
6078
|
if (!this.controlClient) {
|
|
5328
6079
|
this.controlClient = new TmuxControlClient(getTmuxSession(), 2000, this.logger);
|
|
@@ -5626,6 +6377,7 @@ export class FleetManager {
|
|
|
5626
6377
|
// into the same broken state, and a user who has legitimately stopped every
|
|
5627
6378
|
// instance would get a restart loop. Those conditions surface through /health
|
|
5628
6379
|
// (which now returns 503) and through the General-topic notifications instead.
|
|
6380
|
+
setSdNotifyWarn((fields, message) => this.logger.warn(fields, message));
|
|
5629
6381
|
this.watchdogTimer = setInterval(() => sdNotify("WATCHDOG=1"), 30_000);
|
|
5630
6382
|
// #1231: a stalled loop is what turns a slash command into "did not respond";
|
|
5631
6383
|
// put each long stall in the log instead of leaving it to be guessed at.
|
|
@@ -5727,19 +6479,31 @@ export class FleetManager {
|
|
|
5727
6479
|
const _require = createRequire(import.meta.url);
|
|
5728
6480
|
const agendVersion = _require("../package.json").version ?? "unknown";
|
|
5729
6481
|
await progressStart;
|
|
5730
|
-
|
|
6482
|
+
// #1660/#1735: the nvm route's retire outcome, read NOW (not from the boot snapshot). Either order happens:
|
|
6483
|
+
// a detached fleet's `agend restart` returns before this fleet boots, so the outcome may already be here —
|
|
6484
|
+
// render it in the completion itself; a service restart waits for READY (after this), so it may still be owed
|
|
6485
|
+
// — post without it and arm the watch, which appends it to the same receipt once it lands. READY never waits
|
|
6486
|
+
// for cleanup (that would deadlock with an updater that retires after its restart returns).
|
|
6487
|
+
const cleanupOutcome = this.updateCleanupAtCompletion(pendingUpdateProgress, pendingProgressOperation);
|
|
6488
|
+
// #1716: background work this restart/update ended, scoped to the
|
|
6489
|
+
// operation so an older stop never reports. No pending operation means
|
|
6490
|
+
// a plain boot — nothing was stopped by an operation, report nothing.
|
|
6491
|
+
const backgroundWorkEndedNames = this.collectBackgroundWorkEndedNames(configuredNames, pendingUpdateProgress?.startedAt ?? null);
|
|
6492
|
+
const completionSummary = {
|
|
5731
6493
|
running: started,
|
|
5732
6494
|
total,
|
|
5733
6495
|
version: agendVersion,
|
|
5734
6496
|
pausedNames,
|
|
5735
6497
|
failedNames,
|
|
5736
|
-
|
|
5737
|
-
|
|
5738
|
-
|
|
5739
|
-
|
|
5740
|
-
|
|
5741
|
-
|
|
5742
|
-
|
|
6498
|
+
backgroundWorkEndedNames,
|
|
6499
|
+
...(cleanupOutcome && "render" in cleanupOutcome
|
|
6500
|
+
? { warningText: cleanupOutcome.render.warning, warningCommand: cleanupOutcome.render.cleanupCommand }
|
|
6501
|
+
: {}),
|
|
6502
|
+
};
|
|
6503
|
+
const progressCompleted = await startupProgress.finish(completionSummary);
|
|
6504
|
+
// Armed only after the completion posted: an append racing ahead of it would be overwritten by it.
|
|
6505
|
+
if (cleanupOutcome && "watch" in cleanupOutcome)
|
|
6506
|
+
this.armCleanupWatch(cleanupOutcome.watch, completionSummary);
|
|
5743
6507
|
if (!progressCompleted && fleet.channel?.group_id) {
|
|
5744
6508
|
let text;
|
|
5745
6509
|
if (pendingProgressOperation === "full-restart") {
|
|
@@ -5749,6 +6513,7 @@ export class FleetManager {
|
|
|
5749
6513
|
version: agendVersion,
|
|
5750
6514
|
pausedNames,
|
|
5751
6515
|
failedNames,
|
|
6516
|
+
backgroundWorkEndedNames,
|
|
5752
6517
|
}, pendingUpdateProgress.startedAt);
|
|
5753
6518
|
}
|
|
5754
6519
|
else if (failedNames.length === 0 && pausedNames.length === 0) {
|
|
@@ -5761,6 +6526,18 @@ export class FleetManager {
|
|
|
5761
6526
|
text = t("fleet.ready_with_failed", started, total, agendVersion, failedNames.join(", "))
|
|
5762
6527
|
+ (pausedNames.length > 0 ? `\n⏸ Paused: ${pausedNames.join(", ")}` : "");
|
|
5763
6528
|
}
|
|
6529
|
+
// #1716: the fallback carries the same background-work names as the
|
|
6530
|
+
// completion (inline: this block is executed verbatim by the #1737
|
|
6531
|
+
// extracted-completion harness, which resolves `this`-methods plus a
|
|
6532
|
+
// fixed context — no new bare module references here).
|
|
6533
|
+
if (backgroundWorkEndedNames.length) {
|
|
6534
|
+
text += `\n⚠️ Background work was running and was ended: ${backgroundWorkEndedNames.join(", ")}`;
|
|
6535
|
+
}
|
|
6536
|
+
// #1735: an outcome that already landed goes in this message too; one still owed is the watch's.
|
|
6537
|
+
if (cleanupOutcome && "render" in cleanupOutcome) {
|
|
6538
|
+
text += `\n\n⚠️ ${cleanupOutcome.render.warning}`
|
|
6539
|
+
+ (cleanupOutcome.render.cleanupCommand ? `\n\`$ ${cleanupOutcome.render.cleanupCommand}\`` : "");
|
|
6540
|
+
}
|
|
5764
6541
|
await this.sendFleetStartCompletionFallback(String(fleet.channel.group_id), text, generalThreadId != null ? String(generalThreadId) : undefined);
|
|
5765
6542
|
}
|
|
5766
6543
|
// After "fleet ready", so the notice is not the first thing people see of a restart.
|
|
@@ -5769,6 +6546,7 @@ export class FleetManager {
|
|
|
5769
6546
|
// Health HTTP endpoint
|
|
5770
6547
|
this.startHealthServer(fleet.health_port ?? 19280);
|
|
5771
6548
|
// #1266: buttons that ended while AgEnD was down (expired, or chosen before a restart) are shown as ended now.
|
|
6549
|
+
// #1823: and polls: a deadline that passed meanwhile closes now (results once); an open one's countdown is redrawn.
|
|
5772
6550
|
void this.replyButtons()?.sweep().catch(err => this.logger.warn({ err }, "Reply-button sweep failed"));
|
|
5773
6551
|
// Daily update check — first check after 1 hour, then every 24 hours
|
|
5774
6552
|
this.updateCheckTimer = setTimeout(() => {
|
|
@@ -5782,7 +6560,7 @@ export class FleetManager {
|
|
|
5782
6560
|
// A SIGHUP may arrive after the PID/general is available but before the
|
|
5783
6561
|
// rest of startup finishes. Replay one coalesced reload only after all
|
|
5784
6562
|
// startup-owned lifecycle work and signal handlers are in place.
|
|
5785
|
-
this.finishStartup();
|
|
6563
|
+
this.finishStartup(bootUpdateMarker);
|
|
5786
6564
|
// Tell systemd we are ready only now. This used to fire right after the
|
|
5787
6565
|
// generals started — before adapters, classic instances, topic creation and the
|
|
5788
6566
|
// health server — so `systemctl start` returned success while the fleet was
|
|
@@ -6510,6 +7288,24 @@ export class FleetManager {
|
|
|
6510
7288
|
});
|
|
6511
7289
|
}
|
|
6512
7290
|
}
|
|
7291
|
+
else if (msg.type === "raw_paste_written") {
|
|
7292
|
+
// Board 003648ac: a web /compact's paste reached the pane (or never will): its watch starts, or ends as lost.
|
|
7293
|
+
if (typeof msg.id === "string")
|
|
7294
|
+
this.compactWatchService?.pasted(name, msg.id, msg.outcome === "written", { owner: msg.owner, checkpoint: msg.checkpoint, pane: msg.pane });
|
|
7295
|
+
}
|
|
7296
|
+
else if (msg.type === "compact_pane_result") {
|
|
7297
|
+
// Board 907c7899: kiro's pane said how that /compact ended.
|
|
7298
|
+
if (typeof msg.id === "string")
|
|
7299
|
+
this.compactWatchService?.paneResult(name, msg.id, msg.owner, msg.outcome);
|
|
7300
|
+
}
|
|
7301
|
+
else if (msg.type === "runtime_switch_result") {
|
|
7302
|
+
// #1690: what our own runtime /model came to (the daemon answered claude's "Switch model?" or saw the result).
|
|
7303
|
+
const waiter = typeof msg.id === "string" ? this.runtimeSwitchWaiters.get(msg.id) : undefined;
|
|
7304
|
+
if (waiter) {
|
|
7305
|
+
this.runtimeSwitchWaiters.delete(msg.id);
|
|
7306
|
+
waiter(msg);
|
|
7307
|
+
}
|
|
7308
|
+
}
|
|
6513
7309
|
else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
|
|
6514
7310
|
this.cacheInstanceExecutionState(name, msg);
|
|
6515
7311
|
if (msg.type === "instance_state_response") {
|
|
@@ -7022,6 +7818,24 @@ export class FleetManager {
|
|
|
7022
7818
|
});
|
|
7023
7819
|
}
|
|
7024
7820
|
async handleInboundMessage(msg) {
|
|
7821
|
+
msg = { ...msg, humanOriginalBody: msg.humanOriginalBody ?? msg.text, humanOrigin: msg.isBotMessage ? undefined : msg.humanOrigin && { ...msg.humanOrigin } };
|
|
7822
|
+
if (msg.isBotMessage)
|
|
7823
|
+
return withSpawnHoldReply(undefined, () => this.handleInboundMessageCore(msg));
|
|
7824
|
+
return withSpawnHoldReply(text => this.inboundNoticeAdapter(msg)?.sendText(msg.chatId, text, { threadId: msg.threadId }) ?? Promise.resolve(), () => this.handleInboundMessageCore(msg));
|
|
7825
|
+
}
|
|
7826
|
+
/**
|
|
7827
|
+
* #1752: the bot that speaks back about an inbound message — in a fleet topic the owning instance's adapter, never
|
|
7828
|
+
* the sibling that happened to win the dedup race; elsewhere (ClassicBot rooms, no routed instance) the receiver.
|
|
7829
|
+
*/
|
|
7830
|
+
inboundNoticeAdapter(msg) {
|
|
7831
|
+
const id = this.governingAccess(msg, this.inboundRouteThreadId(msg)).adapterId ?? "";
|
|
7832
|
+
return this.worlds.get(id)?.adapter ?? this.adapters.get(id) ?? this.adapter;
|
|
7833
|
+
}
|
|
7834
|
+
async handleInboundMessageCore(msg) {
|
|
7835
|
+
// #1682: who wrote, for the web chat's mention pills (a person, never one of the fleet's bots: those are named by
|
|
7836
|
+
// the instance they belong to).
|
|
7837
|
+
if (!msg.isBotMessage && msg.userId && msg.username)
|
|
7838
|
+
this.seenUsers.record(msg.userId, msg.username);
|
|
7025
7839
|
// Platform author identity is the authority. This runs before routing,
|
|
7026
7840
|
// collab/commands/access/dedup and does not depend on any echo setting/ACK.
|
|
7027
7841
|
const fleetBotIds = new Set();
|
|
@@ -7049,6 +7863,15 @@ export class FleetManager {
|
|
|
7049
7863
|
return;
|
|
7050
7864
|
}
|
|
7051
7865
|
const threadId = this.inboundRouteThreadId(msg);
|
|
7866
|
+
// #1752: a post by one of this fleet's own bots is never input to a fleet topic's instance. They speak there only
|
|
7867
|
+
// for an instance (its replies, notices, warnings) — taking that as a user turn makes one bot's notice another
|
|
7868
|
+
// agent's "user" message, and a loop. Fleet /collab and open mode admit OTHER bots and webhooks; they never meant
|
|
7869
|
+
// our own. Exempt: ClassicBot rooms, where bots this fleet runs deliberately talk to each other by @mention
|
|
7870
|
+
// (same-channel multi-bot collab), and no-thread Telegram (classic candidates; Telegram bots never see each other).
|
|
7871
|
+
if (msg.userId && fleetBotIds.has(msg.userId) && threadId && !this.classicChannels?.hasChannel(threadId)) {
|
|
7872
|
+
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");
|
|
7873
|
+
return;
|
|
7874
|
+
}
|
|
7052
7875
|
const rootCommand = this.telegramRootCommandTarget(msg, threadId);
|
|
7053
7876
|
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");
|
|
7054
7877
|
// Ownership and per-adapter filtering both run before the dedup claim below.
|
|
@@ -7177,6 +8000,23 @@ export class FleetManager {
|
|
|
7177
8000
|
}
|
|
7178
8001
|
// Handle /start command
|
|
7179
8002
|
if (text === "/start" || text.startsWith("/start ")) {
|
|
8003
|
+
// #1686: intercept /start web before the ClassicBot admission flow runs.
|
|
8004
|
+
// /start alone already opens the DM for future bot-to-user messages — no further action needed.
|
|
8005
|
+
const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
|
|
8006
|
+
// #1742: a bare /start from a fleet admin whose /web just failed to reach them privately is that /start web
|
|
8007
|
+
// (Telegram's START button sends it bare) — once, within the window; any other bare /start is ClassicBot's.
|
|
8008
|
+
const admin = isPrivateChat && this.isFleetAdmin(msg.userId, msg.adapterId ?? "");
|
|
8009
|
+
const webStart = isPrivateChat && (requestedBackend === "web"
|
|
8010
|
+
|| (text === "/start" && admin && this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId)));
|
|
8011
|
+
if (webStart) {
|
|
8012
|
+
if (requestedBackend === "web")
|
|
8013
|
+
this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId);
|
|
8014
|
+
// #1742: the /web menu right here; the code comes to this DM.
|
|
8015
|
+
if (admin)
|
|
8016
|
+
await this.startWebInDm(msg, msgAdapter ?? undefined);
|
|
8017
|
+
// Non-fleet-admins: /start alone is enough to open the DM channel; no further action.
|
|
8018
|
+
return;
|
|
8019
|
+
}
|
|
7180
8020
|
// All new starts, including explicit backend and later chooser callbacks,
|
|
7181
8021
|
// share validateClassicStart. Existing channels are recognized first.
|
|
7182
8022
|
const blocker = this.validateClassicStart(chatId, msg.userId, undefined, msg.adapterId);
|
|
@@ -7185,7 +8025,6 @@ export class FleetManager {
|
|
|
7185
8025
|
return;
|
|
7186
8026
|
}
|
|
7187
8027
|
const channelName = msg.username || chatId;
|
|
7188
|
-
const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
|
|
7189
8028
|
if (requestedBackend) {
|
|
7190
8029
|
// handleClassicStart binds the instance to this adapter authoritatively.
|
|
7191
8030
|
const reply = await this.handleClassicStart(chatId, channelName, msg.userId, undefined, msg.adapterId, requestedBackend);
|
|
@@ -7253,6 +8092,21 @@ export class FleetManager {
|
|
|
7253
8092
|
}
|
|
7254
8093
|
return;
|
|
7255
8094
|
}
|
|
8095
|
+
// #1806: /backend — channel admin, as /model. A ClassicBot agent is not switched by it (it says so).
|
|
8096
|
+
if (text === "/backend" || text.startsWith("/backend ") || text.startsWith("/backend@")) {
|
|
8097
|
+
if (!this.isModelAdmin(msg.userId, chatId, msg.adapterId)) {
|
|
8098
|
+
await msgAdapter?.sendText(chatId, t("permission.denied"));
|
|
8099
|
+
return;
|
|
8100
|
+
}
|
|
8101
|
+
const backendInstance = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
8102
|
+
if (!backendInstance) {
|
|
8103
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
8104
|
+
return;
|
|
8105
|
+
}
|
|
8106
|
+
if (msgAdapter)
|
|
8107
|
+
await this.runBackendCommand(backendInstance, text, msg.userId, chatId, msgAdapter, chatId, undefined, msg.adapterId);
|
|
8108
|
+
return;
|
|
8109
|
+
}
|
|
7256
8110
|
// Handle /compact command (admin only)
|
|
7257
8111
|
const classicCompact = parseCompactCommand(text);
|
|
7258
8112
|
if (classicCompact) {
|
|
@@ -7438,7 +8292,7 @@ export class FleetManager {
|
|
|
7438
8292
|
const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, generalInstance);
|
|
7439
8293
|
const generalReactions = this.pendingReactionsMeta(generalInstance);
|
|
7440
8294
|
try {
|
|
7441
|
-
await this.
|
|
8295
|
+
await this.deliverHumanToInstance(generalInstance, {
|
|
7442
8296
|
type: "fleet_inbound",
|
|
7443
8297
|
content: text,
|
|
7444
8298
|
targetSession: generalInstance,
|
|
@@ -7459,7 +8313,7 @@ export class FleetManager {
|
|
|
7459
8313
|
...generalReactions.meta,
|
|
7460
8314
|
...extraMeta,
|
|
7461
8315
|
},
|
|
7462
|
-
});
|
|
8316
|
+
}, msg.humanOrigin, msg.humanOriginalBody ?? msg.text);
|
|
7463
8317
|
generalReactions.consume();
|
|
7464
8318
|
this.afterUserInboundDelivered(generalInstance, msg);
|
|
7465
8319
|
this.logger.info(`${msg.username} → ${generalInstance}: ${(text ?? "").slice(0, 100)}`);
|
|
@@ -7539,7 +8393,7 @@ export class FleetManager {
|
|
|
7539
8393
|
const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, instanceName);
|
|
7540
8394
|
const reactions = this.pendingReactionsMeta(instanceName);
|
|
7541
8395
|
try {
|
|
7542
|
-
await this.
|
|
8396
|
+
await this.deliverHumanToInstance(instanceName, {
|
|
7543
8397
|
type: "fleet_inbound",
|
|
7544
8398
|
content: text,
|
|
7545
8399
|
targetSession: instanceName, // Channel messages → instance's own session
|
|
@@ -7558,7 +8412,7 @@ export class FleetManager {
|
|
|
7558
8412
|
...reactions.meta,
|
|
7559
8413
|
...extraMeta,
|
|
7560
8414
|
},
|
|
7561
|
-
});
|
|
8415
|
+
}, msg.humanOrigin, msg.humanOriginalBody ?? msg.text);
|
|
7562
8416
|
// Only after the message actually went out. A failed delivery keeps the
|
|
7563
8417
|
// reactions queued for the retry / the next message.
|
|
7564
8418
|
reactions.consume();
|
|
@@ -7601,9 +8455,14 @@ export class FleetManager {
|
|
|
7601
8455
|
if (Date.now() - lastWarn < 30 * 60_000)
|
|
7602
8456
|
return;
|
|
7603
8457
|
this.rateLimitWarnedAt.set(instanceName, Date.now());
|
|
7604
|
-
|
|
7605
|
-
|
|
7606
|
-
|
|
8458
|
+
// #1752: the instance's own bot, in the instance's own topic. `msg.adapterId` is whichever sibling bot won the
|
|
8459
|
+
// inbound dedup race; a warning from that bot is a stranger's post in this topic (and, to the owning adapter, a
|
|
8460
|
+
// bot message it would take as input).
|
|
8461
|
+
const warnAdapter = this.getAdapterForInstance(instanceName);
|
|
8462
|
+
const dest = this.cancelButtonDestination(instanceName);
|
|
8463
|
+
const { chatId, threadId } = dest?.threadId ? dest : { chatId: msg.chatId, threadId: msg.threadId ?? undefined };
|
|
8464
|
+
if (warnAdapter && chatId) {
|
|
8465
|
+
warnAdapter.sendText(chatId, warning, { threadId }).catch(() => { });
|
|
7607
8466
|
}
|
|
7608
8467
|
}
|
|
7609
8468
|
/** Handle outbound tool calls from a daemon instance */
|
|
@@ -7647,7 +8506,7 @@ export class FleetManager {
|
|
|
7647
8506
|
if (!claimNotice(path, WEB_CHAT_NOTICE, adapterId))
|
|
7648
8507
|
continue;
|
|
7649
8508
|
try {
|
|
7650
|
-
await world.adapter.sendText(target.chatId, t("upgrade.web_chat"
|
|
8509
|
+
await world.adapter.sendText(target.chatId, t("upgrade.web_chat"), target.opts);
|
|
7651
8510
|
this.logger.info({ adapterId, notice: WEB_CHAT_NOTICE }, "Announced web chat in General");
|
|
7652
8511
|
}
|
|
7653
8512
|
catch (err) {
|
|
@@ -7810,6 +8669,17 @@ export class FleetManager {
|
|
|
7810
8669
|
respond(null, `reply: ${parsedButtons.error}`);
|
|
7811
8670
|
return;
|
|
7812
8671
|
}
|
|
8672
|
+
// #1823: a poll is checked with its buttons. Where it cannot be shown it is refused, never sent as text: text
|
|
8673
|
+
// options would never come back as results.
|
|
8674
|
+
const parsedPoll = parsePoll(args.poll, parsedButtons && "buttons" in parsedButtons ? parsedButtons.buttons : null, args.text);
|
|
8675
|
+
if (parsedPoll && "error" in parsedPoll) {
|
|
8676
|
+
respond(null, `reply: ${parsedPoll.error}`);
|
|
8677
|
+
return;
|
|
8678
|
+
}
|
|
8679
|
+
if (parsedPoll && (!outAdapter.supportsPolls || !this.replyButtons())) {
|
|
8680
|
+
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`);
|
|
8681
|
+
return;
|
|
8682
|
+
}
|
|
7813
8683
|
// Stickers (#1226) are checked before anything is sent: a refused one is the reply's error, not a gap.
|
|
7814
8684
|
const stickerProblem = await this.replyStickerProblem(outAdapter, args, threadId, contextAdapterId ?? this.getInstanceAdapterId(senderInstanceName ?? instanceName));
|
|
7815
8685
|
if (stickerProblem) {
|
|
@@ -7829,7 +8699,7 @@ export class FleetManager {
|
|
|
7829
8699
|
const buttons = outAdapter.supportsReplyButtons ? this.replyButtons() : null;
|
|
7830
8700
|
if (buttons) {
|
|
7831
8701
|
const outId = outAdapter === WEB_ONLY_REPLY_SINK ? "web" : outAdapter.id ?? contextAdapterId ?? "";
|
|
7832
|
-
replySet = buttons.prepare({ instance: instanceName, adapterId: outId, chatId: String(args.chat_id ?? ""), threadId }, parsedButtons.buttons);
|
|
8702
|
+
replySet = buttons.prepare({ instance: instanceName, adapterId: outId, chatId: String(args.chat_id ?? ""), threadId }, parsedButtons.buttons, parsedPoll && "poll" in parsedPoll ? parsedPoll.poll : null);
|
|
7833
8703
|
}
|
|
7834
8704
|
else {
|
|
7835
8705
|
args.text = `${String(args.text)}\n\n${replyButtonsFallbackText(parsedButtons.buttons)}`;
|
|
@@ -7849,6 +8719,9 @@ export class FleetManager {
|
|
|
7849
8719
|
this.replyButtonsCtl?.discard(replySet.id);
|
|
7850
8720
|
}
|
|
7851
8721
|
}
|
|
8722
|
+
// #1823: the agent learns its poll's id (the results name it).
|
|
8723
|
+
if (replySet?.poll && !error && result && typeof result === "object")
|
|
8724
|
+
result = { ...result, poll_id: replySet.id };
|
|
7852
8725
|
ticket.complete(result, error);
|
|
7853
8726
|
// Return the platform outcome first. Bookkeeping below must never turn
|
|
7854
8727
|
// a confirmed Discord/Telegram POST into a tool error if a secondary
|
|
@@ -7861,7 +8734,7 @@ export class FleetManager {
|
|
|
7861
8734
|
// an agent cannot invent it to suppress the normal completion marker.
|
|
7862
8735
|
if (!error && result != null && msg.statusOnly !== true) {
|
|
7863
8736
|
try {
|
|
7864
|
-
this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
|
|
8737
|
+
void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
|
|
7865
8738
|
}
|
|
7866
8739
|
catch (err) {
|
|
7867
8740
|
this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
|
|
@@ -7876,7 +8749,7 @@ export class FleetManager {
|
|
|
7876
8749
|
});
|
|
7877
8750
|
}
|
|
7878
8751
|
};
|
|
7879
|
-
if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord, replySet ? { replyButtons: replySet.callbacks } : {})) {
|
|
8752
|
+
if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord, replySet ? { replyButtons: replySet.callbacks, ...(replySet.poll ? { replyPoll: replySet.poll } : {}) } : {})) {
|
|
7880
8753
|
return;
|
|
7881
8754
|
}
|
|
7882
8755
|
if (replySet)
|
|
@@ -7914,6 +8787,7 @@ export class FleetManager {
|
|
|
7914
8787
|
webOnlyPromptPlace() {
|
|
7915
8788
|
return this.worlds.size === 0 && this.isWebOnlyFleet() ? { adapter: WEB_ONLY_PROMPT_SINK, adapterId: "web", chatId: "web" } : null;
|
|
7916
8789
|
}
|
|
8790
|
+
/** Resolves when the reply is in the web chat (#1770: after its files are copied); the caller need not wait. */
|
|
7917
8791
|
afterReplyRouted(instanceName, args, senderSessionName, buttons = null) {
|
|
7918
8792
|
// A reply is NOT proof the turn is over (#410) — but it is not proof of
|
|
7919
8793
|
// more work either. Split the difference: an instance that is clearly
|
|
@@ -7931,27 +8805,74 @@ export class FleetManager {
|
|
|
7931
8805
|
this.reactDone(instanceName);
|
|
7932
8806
|
const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
|
|
7933
8807
|
this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
|
|
7934
|
-
// Files the agent attached are shown in the web chat too: registered for fetching by id (the path
|
|
7935
|
-
// already passed the reply tool's sendability check, and is resolved once more here), never by path.
|
|
7936
|
-
const replyFiles = Array.isArray(args.files) ? args.files.filter((f) => typeof f === "string") : [];
|
|
7937
|
-
const attachments = replyFiles
|
|
7938
|
-
.map(path => this.webFiles.registerServed({ path, instance: instanceName }))
|
|
7939
|
-
.filter((f) => f !== null)
|
|
7940
|
-
.map(publicAttachment);
|
|
7941
|
-
// The one place a delivered agent reply reaches the web chat: the server marks it `agent` (#1306) — the only
|
|
7942
|
-
// role that may get HTML preview cards. Never inferred from the sender name or the text.
|
|
7943
|
-
this.emitSseEvent("message", {
|
|
7944
|
-
instance: instanceName, sender: senderSessionName ?? instanceName, role: "agent",
|
|
7945
|
-
text: (args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX),
|
|
7946
|
-
ts: new Date().toISOString(),
|
|
7947
|
-
...(attachments.length ? { attachments } : {}),
|
|
7948
|
-
...(buttons ? { buttons } : {}), // #1266
|
|
7949
|
-
});
|
|
7950
8808
|
// Log bot reply to classic instance chat-log
|
|
7951
8809
|
const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
|
|
7952
8810
|
if (isClassic) {
|
|
7953
8811
|
ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
|
|
7954
8812
|
}
|
|
8813
|
+
// Files the agent attached are shown in the web chat too, fetched by id, never by path. #1770: each is copied
|
|
8814
|
+
// first (web-reply-files.ts), and the copy is what the chat serves and keeps — so the message is shown once the
|
|
8815
|
+
// copies are done, and an instance's replies wait for the one before them: they reach the chat in order.
|
|
8816
|
+
const replyFiles = Array.isArray(args.files) ? args.files.filter((f) => typeof f === "string") : [];
|
|
8817
|
+
const ts = new Date().toISOString();
|
|
8818
|
+
const show = (attachments) => {
|
|
8819
|
+
// The one place a delivered agent reply reaches the web chat: the server marks it `agent` (#1306) — the only
|
|
8820
|
+
// role that may get HTML preview cards. Never inferred from the sender name or the text.
|
|
8821
|
+
this.emitSseEvent("message", {
|
|
8822
|
+
instance: instanceName, sender: senderSessionName ?? instanceName, role: "agent",
|
|
8823
|
+
text: (args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX),
|
|
8824
|
+
ts,
|
|
8825
|
+
...(attachments.length ? { attachments } : {}),
|
|
8826
|
+
...(buttons ? { buttons } : {}), // #1266
|
|
8827
|
+
});
|
|
8828
|
+
};
|
|
8829
|
+
const before = this.webReplyOrder.get(instanceName);
|
|
8830
|
+
if (!replyFiles.length && !before) {
|
|
8831
|
+
show([]);
|
|
8832
|
+
return Promise.resolve();
|
|
8833
|
+
}
|
|
8834
|
+
const done = (before ?? Promise.resolve())
|
|
8835
|
+
.then(() => this.webReplyAttachments(instanceName, replyFiles))
|
|
8836
|
+
.catch((err) => { this.logger.warn({ err, instanceName }, "Reply files could not be prepared for the web chat"); return []; })
|
|
8837
|
+
.then((attachments) => { try {
|
|
8838
|
+
show(attachments);
|
|
8839
|
+
}
|
|
8840
|
+
catch (err) {
|
|
8841
|
+
this.logger.warn({ err, instanceName }, "Reply could not be shown in the web chat");
|
|
8842
|
+
} })
|
|
8843
|
+
.finally(() => { if (this.webReplyOrder.get(instanceName) === done)
|
|
8844
|
+
this.webReplyOrder.delete(instanceName); });
|
|
8845
|
+
this.webReplyOrder.set(instanceName, done);
|
|
8846
|
+
return done;
|
|
8847
|
+
}
|
|
8848
|
+
/**
|
|
8849
|
+
* #1770: a reply's files as the web chat shows them. Each is copied into the instance's web-files/ and served from
|
|
8850
|
+
* the copy. One the copy refuses (a symlink, not a regular file, over 50 MB, a state file, missing) is not shown;
|
|
8851
|
+
* one that could not be copied (I/O) is served from the agent's own path, as before — until the fleet restarts.
|
|
8852
|
+
*/
|
|
8853
|
+
async webReplyAttachments(instanceName, paths) {
|
|
8854
|
+
const home = getAgendHome();
|
|
8855
|
+
const dir = webFilesDir(home, instanceName);
|
|
8856
|
+
const out = [];
|
|
8857
|
+
for (const path of paths) {
|
|
8858
|
+
const id = randomBytes(16).toString("hex");
|
|
8859
|
+
const got = dir ? await snapshotReplyFile(path, dir, id, { stateDir: home, maxDirBytes: this.webReplyMaxBytes }) : null;
|
|
8860
|
+
if (got && "evicted" in got && got.evicted.length)
|
|
8861
|
+
this.webFiles.dropServed(got.evicted);
|
|
8862
|
+
let file = null;
|
|
8863
|
+
if (got?.ok)
|
|
8864
|
+
file = this.webFiles.registerServed({ id, path: got.path, name: basename(path), instance: instanceName, snapshot: true });
|
|
8865
|
+
else if (got?.refused)
|
|
8866
|
+
this.logger.info({ instanceName, file: basename(path), reason: got.reason }, "Reply file not shown in the web chat");
|
|
8867
|
+
else {
|
|
8868
|
+
if (got)
|
|
8869
|
+
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");
|
|
8870
|
+
file = this.webFiles.registerServed({ path, instance: instanceName });
|
|
8871
|
+
}
|
|
8872
|
+
if (file)
|
|
8873
|
+
out.push(publicAttachment(file));
|
|
8874
|
+
}
|
|
8875
|
+
return out;
|
|
7955
8876
|
}
|
|
7956
8877
|
// ===================== Scheduler =====================
|
|
7957
8878
|
async handleScheduleTrigger(schedule, stableRunId = randomUUID(), retry) {
|
|
@@ -9191,6 +10112,21 @@ export class FleetManager {
|
|
|
9191
10112
|
? `Refusing to save fleet.yaml: it would be invalid (${introduced.map(e => `${e.path}: ${e.message}`).join("; ")})`
|
|
9192
10113
|
: null;
|
|
9193
10114
|
}
|
|
10115
|
+
/**
|
|
10116
|
+
* #1874 follow-up: the validator's warnings, once per start, in daemon.log — a setting fleet.yaml has that AgEnD
|
|
10117
|
+
* ignores (e.g. `web.access`) is otherwise silent unless someone runs `agend validate`.
|
|
10118
|
+
*/
|
|
10119
|
+
logConfigWarningsAtStartup() {
|
|
10120
|
+
let warnings = [];
|
|
10121
|
+
try {
|
|
10122
|
+
warnings = validateFleetConfig(this.rawFleetConfig).warnings;
|
|
10123
|
+
}
|
|
10124
|
+
catch (err) {
|
|
10125
|
+
this.logger.debug({ err }, "fleet.yaml validation failed (startup warnings)");
|
|
10126
|
+
}
|
|
10127
|
+
for (const w of warnings)
|
|
10128
|
+
this.logger.warn({ path: w.path }, `fleet.yaml: ${w.path ? `${w.path}: ` : ""}${w.message}`);
|
|
10129
|
+
}
|
|
9194
10130
|
/** One-time upgrade migration; invalid YAML is never rewritten. */
|
|
9195
10131
|
slimFleetConfigAtStartup() {
|
|
9196
10132
|
const redundantPaths = collectRedundantInstanceDefaultPaths(this.rawFleetConfig);
|
|
@@ -9350,7 +10286,9 @@ export class FleetManager {
|
|
|
9350
10286
|
else
|
|
9351
10287
|
await this.lifecycle.remove(name, authorization);
|
|
9352
10288
|
// A later same-name registration owns its files and caches.
|
|
9353
|
-
|
|
10289
|
+
// Own keys only: an instance named "constructor" is removed as any other (its inherited namesake is not a
|
|
10290
|
+
// re-registration) — #1677 review.
|
|
10291
|
+
if (Object.hasOwn(this.fleetConfig?.instances ?? {}, name) || this.daemons.has(name))
|
|
9354
10292
|
return;
|
|
9355
10293
|
execution?.assert();
|
|
9356
10294
|
// Clean up per-instance tracking maps so they don't grow unbounded
|
|
@@ -9362,6 +10300,14 @@ export class FleetManager {
|
|
|
9362
10300
|
// must not be shown the old one's conversation, and deleted names must not pile up.
|
|
9363
10301
|
this.webChatHistory.forget(name);
|
|
9364
10302
|
this.webFiles.forget(name);
|
|
10303
|
+
this.compactWatchService?.forget(name);
|
|
10304
|
+
// #1664: and it is nobody's favorite any more (every device sees that at its next status frame).
|
|
10305
|
+
try {
|
|
10306
|
+
this.webPrefs.forgetInstance(name);
|
|
10307
|
+
}
|
|
10308
|
+
catch (err) {
|
|
10309
|
+
this.logger.warn({ err: err.message, name }, "Dropping a removed instance from web favorites failed");
|
|
10310
|
+
}
|
|
9365
10311
|
// Clean up statusline watcher + instance directory
|
|
9366
10312
|
this.statuslineWatcher.unwatch(name);
|
|
9367
10313
|
// instanceDirToClean and effectiveBackend are captured before lifecycle.remove
|
|
@@ -9562,10 +10508,12 @@ export class FleetManager {
|
|
|
9562
10508
|
return this.webImageCaches[kind];
|
|
9563
10509
|
}
|
|
9564
10510
|
/**
|
|
9565
|
-
*
|
|
9566
|
-
* whatever an instance still names — #1635 review)
|
|
10511
|
+
* The bots instances and ClassicBot rooms speak through, by connections configured now (a connection removed from
|
|
10512
|
+
* fleet.yaml is nobody's, whatever an instance still names — #1635 review). `own`: instance → its own bot (answering
|
|
10513
|
+
* for it alone; the primary too, when it serves one instance only). `speaking` (#1867): instance → the bot it speaks through — its own, a shared one,
|
|
10514
|
+
* or, with none configured for it, the primary.
|
|
9567
10515
|
*/
|
|
9568
|
-
|
|
10516
|
+
webBots() {
|
|
9569
10517
|
const instances = this.fleetConfig?.instances ?? {};
|
|
9570
10518
|
const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
9571
10519
|
const configured = new Set(channels.map(ch => ch.id ?? ch.type).filter((id) => typeof id === "string"));
|
|
@@ -9575,8 +10523,17 @@ export class FleetManager {
|
|
|
9575
10523
|
if (!Object.hasOwn(instances, room.instanceName))
|
|
9576
10524
|
assignments.push([room.instanceName, assigned(room.instanceName)]);
|
|
9577
10525
|
}
|
|
9578
|
-
|
|
10526
|
+
const primary = this.getPrimaryAdapterId(), fallback = primary && configured.has(primary) ? primary : undefined;
|
|
10527
|
+
const speaking = new Map();
|
|
10528
|
+
for (const [instance, connection] of assignments) {
|
|
10529
|
+
const c = connection ?? fallback;
|
|
10530
|
+
if (c)
|
|
10531
|
+
speaking.set(instance, c);
|
|
10532
|
+
}
|
|
10533
|
+
return { own: ownBotConnections(assignments), speaking };
|
|
9579
10534
|
}
|
|
10535
|
+
/** instance → the connection that is its own bot (see webBots). */
|
|
10536
|
+
ownBots() { return this.webBots().own; }
|
|
9580
10537
|
/**
|
|
9581
10538
|
* The bot a connection is now: its running adapter and that bot's own user id. Everything cached for an own bot is
|
|
9582
10539
|
* keyed by both, so a bot put in its place (a token replaced under the same connection id) never shows the old one's
|
|
@@ -9619,8 +10576,11 @@ export class FleetManager {
|
|
|
9619
10576
|
},
|
|
9620
10577
|
};
|
|
9621
10578
|
}
|
|
9622
|
-
/**
|
|
9623
|
-
|
|
10579
|
+
/**
|
|
10580
|
+
* One instance's avatar and ring for the status frame. Synchronous: what is cached now; fetches run behind. The bot
|
|
10581
|
+
* is its own, else the one it speaks through (#1867) — one cache entry per bot, so a shared bot is fetched once.
|
|
10582
|
+
*/
|
|
10583
|
+
webIdentityImage(name, displayName, profileAvatar, bots) {
|
|
9624
10584
|
let profileVersion = null;
|
|
9625
10585
|
if (profileAvatar && imageMimeForFile(profileAvatar)) {
|
|
9626
10586
|
try {
|
|
@@ -9628,22 +10588,26 @@ export class FleetManager {
|
|
|
9628
10588
|
}
|
|
9629
10589
|
catch { /* removed */ }
|
|
9630
10590
|
}
|
|
9631
|
-
const
|
|
10591
|
+
const ownConnection = bots.own.get(name), connection = ownConnection ?? bots.speaking.get(name);
|
|
10592
|
+
const owner = connection ? this.botOwner(connection) : null;
|
|
9632
10593
|
const source = connection && owner?.adapter.fetchBotAvatar ? this.botAvatarSource(connection, owner) : null;
|
|
9633
10594
|
const bot = source ? this.webImageCache("avatars").peek(source.key, source.load) : null;
|
|
9634
10595
|
const cfg = this.ownInstanceConfig(name);
|
|
9635
10596
|
const avatar = avatarFor({ name, displayName, profileVersion, bot: bot && connection ? { connection, version: bot.version } : null,
|
|
9636
10597
|
personaEmoji: cfg?.status_emojis?.delivered });
|
|
9637
|
-
// The ring belongs to
|
|
9638
|
-
const accent = !profileVersion && bot &&
|
|
9639
|
-
? this.webAccent(
|
|
10598
|
+
// The ring belongs to an own bot's face: only when that is what is shown (a shared bot's would ring everyone alike).
|
|
10599
|
+
const accent = !profileVersion && bot && ownConnection && owner
|
|
10600
|
+
? this.webAccent(ownConnection, owner, cfg ? (cfg.topic_id == null ? undefined : String(cfg.topic_id)) : this.classicChannels?.getChannelIdByInstance(name))
|
|
9640
10601
|
: null;
|
|
9641
10602
|
return accent ? { avatar, accent } : { avatar };
|
|
9642
10603
|
}
|
|
9643
|
-
/**
|
|
10604
|
+
/**
|
|
10605
|
+
* GET /ui/img/bot|avatar/<key>: what is cached, never a fetch. Only a connection some instance or room speaks
|
|
10606
|
+
* through (#1867), or a known instance's upload.
|
|
10607
|
+
*/
|
|
9644
10608
|
webImage(kind, key) {
|
|
9645
10609
|
if (kind === "bot") {
|
|
9646
|
-
if (![...this.
|
|
10610
|
+
if (![...this.webBots().speaking.values()].includes(key))
|
|
9647
10611
|
return null;
|
|
9648
10612
|
const owner = this.botOwner(key);
|
|
9649
10613
|
return owner ? this.webImageCache("avatars").read(this.botAvatarSource(key, owner).key) : null;
|
|
@@ -9663,6 +10627,45 @@ export class FleetManager {
|
|
|
9663
10627
|
return null;
|
|
9664
10628
|
}
|
|
9665
10629
|
}
|
|
10630
|
+
/**
|
|
10631
|
+
* #1682 GET /ui/mentions: names for the mention ids a page found in what it rendered — never a Discord request; in order:
|
|
10632
|
+
* the fleet's bots (an instance's own bot is that instance's label: fleet.yaml, and the View profiles read from
|
|
10633
|
+
* profiles.db, as the status frame does), the people seen on inbound messages, and the Discord adapters' caches
|
|
10634
|
+
* (members, users, roles, channels).
|
|
10635
|
+
*/
|
|
10636
|
+
webMentionNames(query) {
|
|
10637
|
+
const bots = (q) => {
|
|
10638
|
+
const u = Object.create(null);
|
|
10639
|
+
if (!q.u.length)
|
|
10640
|
+
return { u };
|
|
10641
|
+
let own = new Map();
|
|
10642
|
+
try {
|
|
10643
|
+
own = this.ownBots();
|
|
10644
|
+
}
|
|
10645
|
+
catch { /* none known */ }
|
|
10646
|
+
const profiles = profileIdentities(this.dataDir);
|
|
10647
|
+
const rooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
|
|
10648
|
+
for (const [instance, connection] of own) {
|
|
10649
|
+
const id = this.adapters.get(connection)?.getBotUserId?.();
|
|
10650
|
+
if (!id || !q.u.includes(id))
|
|
10651
|
+
continue;
|
|
10652
|
+
const cfg = Object.hasOwn(this.fleetConfig?.instances ?? {}, instance) ? this.fleetConfig.instances[instance] : undefined;
|
|
10653
|
+
u[id] = resolveInstanceIdentity({ cfg, classic: rooms.get(instance), profile: profiles.get(instance) }).display_name || instance;
|
|
10654
|
+
}
|
|
10655
|
+
return { u };
|
|
10656
|
+
};
|
|
10657
|
+
const seen = (q) => {
|
|
10658
|
+
const u = Object.create(null);
|
|
10659
|
+
for (const id of q.u) {
|
|
10660
|
+
const name = this.seenUsers.get(id);
|
|
10661
|
+
if (name)
|
|
10662
|
+
u[id] = name;
|
|
10663
|
+
}
|
|
10664
|
+
return { u };
|
|
10665
|
+
};
|
|
10666
|
+
const caches = [...this.adapters.values()].filter(a => typeof a.mentionNames === "function").map(a => (q) => a.mentionNames(q));
|
|
10667
|
+
return resolveMentions(query, [bots, seen, ...caches]);
|
|
10668
|
+
}
|
|
9666
10669
|
/** GET /ui/emojis: the custom emoji of every server the fleet's Discord bots are in (the Settings picker's cache). */
|
|
9667
10670
|
async webEmojiList() {
|
|
9668
10671
|
const adopted = [];
|
|
@@ -11041,6 +12044,8 @@ export class FleetManager {
|
|
|
11041
12044
|
return true;
|
|
11042
12045
|
if (await this.handleEffortSelection(data, adapterId))
|
|
11043
12046
|
return true;
|
|
12047
|
+
if (await this.handleBackendSelection(data, adapterId))
|
|
12048
|
+
return true;
|
|
11044
12049
|
if (await this.handleHangPrompt(data, adapterId, adapter))
|
|
11045
12050
|
return true;
|
|
11046
12051
|
if (data.callbackData.startsWith("cancel:")) {
|
|
@@ -11106,6 +12111,48 @@ export class FleetManager {
|
|
|
11106
12111
|
});
|
|
11107
12112
|
}
|
|
11108
12113
|
cacheService = null;
|
|
12114
|
+
/**
|
|
12115
|
+
* #1699: the credential profile an instance's CLI signs in with on `backend` — fleet.yaml's (with its defaults), or a
|
|
12116
|
+
* ClassicBot room's launched or would-be login when the room runs that backend. Null: the backend's default login
|
|
12117
|
+
* (also for a backend that has no profiles, where the option is ignored).
|
|
12118
|
+
*/
|
|
12119
|
+
credentialProfileOf(name, backend) {
|
|
12120
|
+
if (!credentialHomeSpec(backend))
|
|
12121
|
+
return null;
|
|
12122
|
+
const cfg = this.fleetConfig?.instances?.[name];
|
|
12123
|
+
if (cfg)
|
|
12124
|
+
return instanceCredentialProfile(cfg, this.fleetConfig?.defaults, backend);
|
|
12125
|
+
const launched = this.daemons.get(name)?.getConfigSnapshot?.();
|
|
12126
|
+
if (launched)
|
|
12127
|
+
return (launched.backend ?? "claude-code") === backend ? profileName(profileOf(launched.backend_options, backend)) : null;
|
|
12128
|
+
if (!this.classicChannels || this.classicChannels.getBackendByInstance(name, this.fleetConfig?.defaults?.backend) !== backend)
|
|
12129
|
+
return null;
|
|
12130
|
+
return profileName(this.classicChannels.getCredentialProfileByInstance(name, this.fleetConfig?.defaults));
|
|
12131
|
+
}
|
|
12132
|
+
/**
|
|
12133
|
+
* #1699: the claude config dir an instance's CLI uses — its credential profile's, else exactly as before
|
|
12134
|
+
* (CLAUDE_CONFIG_DIR or ~/.claude).
|
|
12135
|
+
*/
|
|
12136
|
+
claudeConfigDirOf(name) {
|
|
12137
|
+
const profile = this.credentialProfileOf(name, "claude-code");
|
|
12138
|
+
return claudeConfigDirFor(profile ? credentialProfileHome(getAgendHome(), "claude-code", profile) : null);
|
|
12139
|
+
}
|
|
12140
|
+
/** The instances the cache analysis reads: fleet.yaml's, then the ClassicBot rooms (their workspace under the AgEnD home, their own backend). */
|
|
12141
|
+
cacheInstances() {
|
|
12142
|
+
const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
|
|
12143
|
+
name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
|
|
12144
|
+
claudeConfigDir: this.claudeConfigDirOf(name),
|
|
12145
|
+
}));
|
|
12146
|
+
const taken = new Set(fleet.map(i => i.name));
|
|
12147
|
+
const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
|
|
12148
|
+
name: ch.instanceName,
|
|
12149
|
+
backend: this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
|
|
12150
|
+
workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
|
|
12151
|
+
ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
|
|
12152
|
+
claudeConfigDir: this.claudeConfigDirOf(ch.instanceName),
|
|
12153
|
+
}));
|
|
12154
|
+
return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
|
|
12155
|
+
}
|
|
11109
12156
|
/**
|
|
11110
12157
|
* #1468: the prompt-cache expiry analysis for the web, per instance, over `window`. Read from each instance's
|
|
11111
12158
|
* ledger as it is now; the first request starts the bounded, persisted catch-up over the transcripts
|
|
@@ -11114,20 +12161,7 @@ export class FleetManager {
|
|
|
11114
12161
|
cacheReport(window) {
|
|
11115
12162
|
const w = Object.prototype.hasOwnProperty.call(WINDOWS, window) ? window : "7d";
|
|
11116
12163
|
this.cacheService ??= new CacheService({
|
|
11117
|
-
|
|
11118
|
-
instances: () => {
|
|
11119
|
-
const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
|
|
11120
|
-
name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
|
|
11121
|
-
}));
|
|
11122
|
-
const taken = new Set(fleet.map(i => i.name));
|
|
11123
|
-
const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
|
|
11124
|
-
name: ch.instanceName,
|
|
11125
|
-
backend: this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
|
|
11126
|
-
workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
|
|
11127
|
-
ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
|
|
11128
|
-
}));
|
|
11129
|
-
return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
|
|
11130
|
-
},
|
|
12164
|
+
instances: () => this.cacheInstances(),
|
|
11131
12165
|
claudeProjectsDir: () => join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects"),
|
|
11132
12166
|
claudeKey: claudeProjectKey,
|
|
11133
12167
|
codexSessionsDir: () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions"),
|
|
@@ -11279,10 +12313,18 @@ export class FleetManager {
|
|
|
11279
12313
|
* The caller has passed the /ui gate (session, same origin, CSRF). Here: the prompt must be one offered
|
|
11280
12314
|
* on the dashboard, about the instance the page named, and the action one of its own buttons.
|
|
11281
12315
|
*/
|
|
11282
|
-
/** #
|
|
11283
|
-
|
|
12316
|
+
/** #1823: close_poll — `instance` closes one of its own polls before the deadline. */
|
|
12317
|
+
closePollEarly(instance, id) {
|
|
12318
|
+
const buttons = this.replyButtons();
|
|
12319
|
+
return buttons ? buttons.closeEarly(instance, id) : Promise.resolve({ ok: false, error: "polls are unavailable until AgEnD restarts" });
|
|
12320
|
+
}
|
|
12321
|
+
/**
|
|
12322
|
+
* #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
|
|
12323
|
+
* poll (#1823) it is `voter`'s vote: the page's session.
|
|
12324
|
+
*/
|
|
12325
|
+
clickWebReplyButton(instance, id, index, voter) {
|
|
11284
12326
|
const buttons = this.replyButtons();
|
|
11285
|
-
return buttons ? buttons.clickWeb(instance, id, index) : Promise.resolve({ status: 409, error: t("reply_buttons.closed") });
|
|
12327
|
+
return buttons ? buttons.clickWeb(instance, id, index, voter) : Promise.resolve({ status: 409, error: t("reply_buttons.closed") });
|
|
11286
12328
|
}
|
|
11287
12329
|
async clickWebPrompt(instance, nonce, action) {
|
|
11288
12330
|
if (!/^[0-9a-f]{32}$/.test(nonce) || !/^[a-z][a-z-]{0,23}$/.test(action))
|
|
@@ -11314,7 +12356,9 @@ export class FleetManager {
|
|
|
11314
12356
|
}
|
|
11315
12357
|
async retireNonceButtons(pending, messageId, text,
|
|
11316
12358
|
/** The click's own edit of a private prompt (AdapterCallbackData.editClicked); preferred over the prompt's. */
|
|
11317
|
-
editClicked
|
|
12359
|
+
editClicked,
|
|
12360
|
+
/** #1686: URL buttons to attach when editing the outcome (Telegram only; ignored when editClicked or retire is set). */
|
|
12361
|
+
urlButtons) {
|
|
11318
12362
|
this.webPromptGone(pending, text);
|
|
11319
12363
|
try {
|
|
11320
12364
|
const retire = editClicked ?? pending.retire;
|
|
@@ -11324,7 +12368,12 @@ export class FleetManager {
|
|
|
11324
12368
|
}
|
|
11325
12369
|
if (!pending.adapter.editMessageRemoveButtons)
|
|
11326
12370
|
throw new Error("adapter cannot remove prompt buttons");
|
|
11327
|
-
|
|
12371
|
+
if (urlButtons) {
|
|
12372
|
+
await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId, urlButtons);
|
|
12373
|
+
}
|
|
12374
|
+
else {
|
|
12375
|
+
await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
|
|
12376
|
+
}
|
|
11328
12377
|
}
|
|
11329
12378
|
catch (err) {
|
|
11330
12379
|
// The action was already atomically consumed. An edit failure must not
|
|
@@ -11332,7 +12381,8 @@ export class FleetManager {
|
|
|
11332
12381
|
// only place some outcomes are told (cancel, later, a resend's result),
|
|
11333
12382
|
// so the text goes out as a message instead (#1133).
|
|
11334
12383
|
this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons; posting the outcome instead");
|
|
11335
|
-
|
|
12384
|
+
// #1742: with its link button, if it had one — the outcome may be the only way to the DM.
|
|
12385
|
+
await pending.adapter.sendText(pending.chatId, text, { threadId: pending.threadId, ...(urlButtons ? { urlButtons } : {}) })
|
|
11336
12386
|
.catch(sendErr => this.logger.warn({ err: sendErr, instanceName: pending.instanceName }, "Could not post a prompt's outcome"));
|
|
11337
12387
|
}
|
|
11338
12388
|
}
|
|
@@ -11627,6 +12677,8 @@ export class FleetManager {
|
|
|
11627
12677
|
}
|
|
11628
12678
|
};
|
|
11629
12679
|
}
|
|
12680
|
+
/** #1806: the web's /backend confirmations — one per question, used once, for the arguments and backend asked about. */
|
|
12681
|
+
webBackendTokens = new Map();
|
|
11630
12682
|
/** #1269: the web's /clear confirmations — one token per question, used once, for the instance as it was asked about. */
|
|
11631
12683
|
webClearTokens = new Map();
|
|
11632
12684
|
/** #1269: an instance's chat command from the web chat (web-commands.ts); `publicLink`: a gateway request. */
|
|
@@ -11634,8 +12686,10 @@ export class FleetManager {
|
|
|
11634
12686
|
const webMeta = (instance) => {
|
|
11635
12687
|
const topicId = this.fleetConfig?.instances[instance]?.topic_id;
|
|
11636
12688
|
return {
|
|
11637
|
-
|
|
11638
|
-
|
|
12689
|
+
// #1827: a /steer or /btw reaches the agent as the web person who wrote it.
|
|
12690
|
+
chatId: this.getGroupIdForInstance(instance) || "", messageId: newWebMessageId(),
|
|
12691
|
+
username: opts.person?.name ?? WEB_NAME_FALLBACK, userId: opts.person?.userId ?? WEB_NAME_FALLBACK,
|
|
12692
|
+
threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web", humanOrigin: webHumanOrigin(true),
|
|
11639
12693
|
};
|
|
11640
12694
|
};
|
|
11641
12695
|
const choicesFromCache = (instance) => {
|
|
@@ -11654,7 +12708,22 @@ export class FleetManager {
|
|
|
11654
12708
|
return this.classicChannels?.getChannelIdByInstance(instance) !== undefined ? "classic" : null;
|
|
11655
12709
|
},
|
|
11656
12710
|
ctx: (instance) => this.topicCommands.getCtxText(instance),
|
|
11657
|
-
|
|
12711
|
+
// Board 003648ac: a watch for the paste, armed when the daemon says it was written (raw_paste_written); one
|
|
12712
|
+
// for a command that was not handed over is dropped.
|
|
12713
|
+
compact: async (instance, instructions) => {
|
|
12714
|
+
let watch = null;
|
|
12715
|
+
try {
|
|
12716
|
+
const r = this.topicCommands.compactInstance(instance, instructions, () => (watch = this.compactWatch.begin(instance)));
|
|
12717
|
+
if (!r.sent && watch)
|
|
12718
|
+
this.compactWatch.cancel(instance, watch);
|
|
12719
|
+
return { ...r, watch: r.sent ? watch : null };
|
|
12720
|
+
}
|
|
12721
|
+
catch (err) {
|
|
12722
|
+
if (watch)
|
|
12723
|
+
this.compactWatch.cancel(instance, watch);
|
|
12724
|
+
throw err;
|
|
12725
|
+
}
|
|
12726
|
+
},
|
|
11658
12727
|
applyModel: (instance, name) => this.applyModel(instance, name),
|
|
11659
12728
|
modelChoices: choicesFromCache,
|
|
11660
12729
|
applyEffort: (instance, level) => this.applyEffort(instance, level),
|
|
@@ -11665,9 +12734,44 @@ export class FleetManager {
|
|
|
11665
12734
|
const current = this.resolveInstanceEffort(instance).effort;
|
|
11666
12735
|
return { current, options: levels.map(l => ({ id: l, label: this.effortChoiceLabel(l, current) })) };
|
|
11667
12736
|
},
|
|
12737
|
+
backendStep: async (instance, args) => {
|
|
12738
|
+
const refusal = this.backendCommandRefusal(instance);
|
|
12739
|
+
if (refusal)
|
|
12740
|
+
return { error: refusal };
|
|
12741
|
+
// The cached model lists only, as the web's /model: never a live probe on this path.
|
|
12742
|
+
const step = await backendStep(this.backendCommandDeps(instance, { live: false }), instance, backendArgs(args));
|
|
12743
|
+
if (step.kind === "refuse")
|
|
12744
|
+
return step.noop ? { text: step.text } : { error: step.text };
|
|
12745
|
+
if (step.kind === "choose") {
|
|
12746
|
+
const id = (token) => joinBackendArgs([...step.args, token]);
|
|
12747
|
+
return { choices: { prompt: step.text, current: step.current === null ? null : id(step.current), options: step.choices.map(c => ({ id: id(c.token), label: c.label })) } };
|
|
12748
|
+
}
|
|
12749
|
+
const now = performance.now();
|
|
12750
|
+
for (const [k, v] of this.webBackendTokens)
|
|
12751
|
+
if (v.deadline < now)
|
|
12752
|
+
this.webBackendTokens.delete(k);
|
|
12753
|
+
const token = randomBytes(16).toString("hex");
|
|
12754
|
+
this.webBackendTokens.set(token, {
|
|
12755
|
+
instance, asked: joinBackendArgs(backendArgs(args)), args: step.args, from: step.from,
|
|
12756
|
+
target: this.fleetConfig.instances[instance], deadline: now + CLASSIC_BACKEND_SELECTION_TIMEOUT_MS,
|
|
12757
|
+
});
|
|
12758
|
+
return { confirm: { token, message: step.text } };
|
|
12759
|
+
},
|
|
12760
|
+
backendConfirm: async (instance, args, token) => {
|
|
12761
|
+
const entry = this.webBackendTokens.get(token);
|
|
12762
|
+
this.webBackendTokens.delete(token); // used once, whatever happens next
|
|
12763
|
+
if (!entry || entry.instance !== instance || entry.asked !== joinBackendArgs(backendArgs(args)) || performance.now() > entry.deadline) {
|
|
12764
|
+
return { status: 409, text: t("backend.selection_expired") };
|
|
12765
|
+
}
|
|
12766
|
+
// The instance it was asked about: one deleted and created again under the name, stopped or not, is another.
|
|
12767
|
+
const stillCurrent = () => this.backendTargetIs(instance, entry.target);
|
|
12768
|
+
if (!stillCurrent())
|
|
12769
|
+
return { status: 409, text: t("menu.click_stale") };
|
|
12770
|
+
return { status: 200, text: await this.applyBackendSwitch(instance, entry.args, entry.from, stillCurrent) };
|
|
12771
|
+
},
|
|
11668
12772
|
cancel: (instance) => this.cancelInstance(instance),
|
|
11669
|
-
steer: (instance, text) => this.topicCommands.sendSteer(instance, text, webMeta(instance)),
|
|
11670
|
-
btw: (instance, text) => this.topicCommands.sendBtw(instance, text, webMeta(instance)),
|
|
12773
|
+
steer: (instance, text, originalBody) => this.topicCommands.sendSteer(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
|
|
12774
|
+
btw: (instance, text, originalBody) => this.topicCommands.sendBtw(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
|
|
11671
12775
|
pauseWake: (instance, action) => this.topicCommands.runPauseWake(instance, action),
|
|
11672
12776
|
save: (instance, filename) => this.topicCommands.sendSave(instance, filename),
|
|
11673
12777
|
clearAsk: (instance) => {
|
|
@@ -11693,7 +12797,8 @@ export class FleetManager {
|
|
|
11693
12797
|
// sendClear sends its first IPC synchronously: no await separates this check from the effect.
|
|
11694
12798
|
if (this.shuttingDown || !entry.fence())
|
|
11695
12799
|
return { status: 409, text: t("menu.click_stale") };
|
|
11696
|
-
|
|
12800
|
+
const r = this.topicCommands.clearInstance(instance);
|
|
12801
|
+
return r.sent ? { status: 200, text: r.text, sent: true } : { status: 409, text: r.text };
|
|
11697
12802
|
},
|
|
11698
12803
|
t: (key, ...args) => t(key, ...args),
|
|
11699
12804
|
}, input, opts);
|
|
@@ -11938,9 +13043,12 @@ export class FleetManager {
|
|
|
11938
13043
|
* Backends with no remote login flow (opencode logs in from a terminal) get
|
|
11939
13044
|
* no button — the alert's own wording already tells them what to run.
|
|
11940
13045
|
*/
|
|
11941
|
-
async offerBackendLogin(targetInstance, backend) {
|
|
13046
|
+
async offerBackendLogin(targetInstance, backend, profile) {
|
|
11942
13047
|
if (!LOGIN_FLOWS[backend])
|
|
11943
13048
|
return;
|
|
13049
|
+
if (profile && !CHAT_PROFILE_LOGIN_BACKENDS.has(backend))
|
|
13050
|
+
return; // that profile signs in on the host
|
|
13051
|
+
const shown = profile ? t("login.profile_label", backend, profile) : backend;
|
|
11944
13052
|
const adapterId = this.getInstanceAdapterId(targetInstance);
|
|
11945
13053
|
const adapter = this.getAdapterForInstance(targetInstance);
|
|
11946
13054
|
const chatId = this.getGroupIdForInstance(targetInstance);
|
|
@@ -11958,9 +13066,10 @@ export class FleetManager {
|
|
|
11958
13066
|
adapterId,
|
|
11959
13067
|
chatId,
|
|
11960
13068
|
threadId,
|
|
11961
|
-
message: t("login.offer",
|
|
11962
|
-
choices: [{ action: backend, label: t("login.offer_action",
|
|
13069
|
+
message: t("login.offer", shown),
|
|
13070
|
+
choices: [{ action: backend, label: t("login.offer_action", shown) }],
|
|
11963
13071
|
expiredText: t("buttons.stale"),
|
|
13072
|
+
...(profile ? { extra: { loginProfile: profile } } : {}),
|
|
11964
13073
|
});
|
|
11965
13074
|
}
|
|
11966
13075
|
/** Consume a General assist button exactly once. */
|
|
@@ -12773,7 +13882,7 @@ export class FleetManager {
|
|
|
12773
13882
|
fleetConfig: () => this.fleetConfig,
|
|
12774
13883
|
isFleetAdmin: (userId, adapterId) => this.isFleetAdmin(userId, adapterId),
|
|
12775
13884
|
eventLog: () => this.eventLog,
|
|
12776
|
-
recoverBackendInstances: backend => this.recoverBackendInstances(backend),
|
|
13885
|
+
recoverBackendInstances: (backend, profile) => this.recoverBackendInstances(backend, undefined, profile ?? null),
|
|
12777
13886
|
claimWindow: backend => this.loginWindow.tryClaim("web", backend),
|
|
12778
13887
|
releaseWindow: claim => { this.loginWindow.release(claim); },
|
|
12779
13888
|
isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
|
|
@@ -12782,11 +13891,12 @@ export class FleetManager {
|
|
|
12782
13891
|
reserveTunnel: owner => this.getTunnelLane().reserve("login", owner),
|
|
12783
13892
|
// Throws when the prompt cannot be posted: the controller tells the
|
|
12784
13893
|
// user so instead of leaving "Starting…" as the last word (#1133).
|
|
12785
|
-
postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText }) => {
|
|
13894
|
+
postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText, profile }) => {
|
|
12786
13895
|
await this.postNonceButtonPromptOrThrow({
|
|
12787
13896
|
prefix, alertType: "login", instanceName,
|
|
12788
13897
|
adapter: chat.adapter, adapterId: chat.adapterId, chatId: chat.chatId, threadId: chat.threadId,
|
|
12789
13898
|
message, choices, expiredText,
|
|
13899
|
+
...(profile ? { extra: { loginProfile: profile } } : {}),
|
|
12790
13900
|
});
|
|
12791
13901
|
},
|
|
12792
13902
|
});
|
|
@@ -13000,10 +14110,20 @@ export class FleetManager {
|
|
|
13000
14110
|
// A removed backend (#1280) is neither installed nor signed into: say what replaces it.
|
|
13001
14111
|
if (isRemovedBackend(wanted))
|
|
13002
14112
|
return removedBackendMessage(wanted);
|
|
14113
|
+
// #1699 part 2: a profile is admitted before anything is installed for it — a name that is not one, or a backend
|
|
14114
|
+
// whose profiles sign in on the host, is refused here, not after an install it did not need.
|
|
14115
|
+
if (opts.profile !== undefined) {
|
|
14116
|
+
const admitted = admitProfileLogin(wanted, opts.profile);
|
|
14117
|
+
if ("refusal" in admitted)
|
|
14118
|
+
return admitted.refusal;
|
|
14119
|
+
opts = { ...opts, profile: admitted.profile };
|
|
14120
|
+
}
|
|
13003
14121
|
if (BACKEND_INSTALLATION_INFO[wanted] && !this.isCliInstalled(wanted)) {
|
|
13004
14122
|
const flow = LOGIN_FLOWS[wanted];
|
|
13005
14123
|
this.recordLoginFlow(wanted, flow && flow.remoteLogin !== "unsupported" ? "install_then_login" : "install_only", chat.userId);
|
|
13006
|
-
return
|
|
14124
|
+
return opts.profile !== undefined
|
|
14125
|
+
? this.startInstallSession(wanted, chat, { profile: opts.profile })
|
|
14126
|
+
: this.startInstallSession(wanted, chat);
|
|
13007
14127
|
}
|
|
13008
14128
|
this.recordLoginFlow(wanted, "login", chat.userId);
|
|
13009
14129
|
return this.launchSignIn(backendArg, chat, opts);
|
|
@@ -13092,7 +14212,9 @@ export class FleetManager {
|
|
|
13092
14212
|
* cancelled: they are still coming back, and are reported as pending so the
|
|
13093
14213
|
* message can say so instead of implying failure.
|
|
13094
14214
|
*/
|
|
13095
|
-
async recoverBackendInstances(backend, deadlineMs = POST_LOGIN_RECOVERY_DEADLINE_MS
|
|
14215
|
+
async recoverBackendInstances(backend, deadlineMs = POST_LOGIN_RECOVERY_DEADLINE_MS,
|
|
14216
|
+
/** #1699 part 2: the login just signed in — a profile, or null for the backend's default one. */
|
|
14217
|
+
profile = null) {
|
|
13096
14218
|
const woken = [];
|
|
13097
14219
|
const restarted = [];
|
|
13098
14220
|
const pending = [];
|
|
@@ -13100,6 +14222,9 @@ export class FleetManager {
|
|
|
13100
14222
|
for (const name of this.configuredBackendInstanceNames()) {
|
|
13101
14223
|
if (this.backendNameOf(name) !== backend)
|
|
13102
14224
|
continue;
|
|
14225
|
+
// Only the instances on the login that changed: another profile's (or the default's) credentials did not.
|
|
14226
|
+
if (this.credentialProfileOf(name, backend) !== profile)
|
|
14227
|
+
continue;
|
|
13103
14228
|
const status = this.getInstanceStatus(name);
|
|
13104
14229
|
if (status !== "paused" && status !== "running")
|
|
13105
14230
|
continue;
|
|
@@ -13137,14 +14262,16 @@ export class FleetManager {
|
|
|
13137
14262
|
if (claimed === "consumed")
|
|
13138
14263
|
return true;
|
|
13139
14264
|
const { entry, action: backend } = claimed;
|
|
13140
|
-
|
|
13141
|
-
|
|
14265
|
+
const profile = entry.loginProfile;
|
|
14266
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", profile ? t("login.profile_label", backend, profile) : backend));
|
|
14267
|
+
const chat = {
|
|
13142
14268
|
adapter: entry.adapter,
|
|
13143
14269
|
adapterId: entry.adapterId,
|
|
13144
14270
|
chatId: entry.chatId,
|
|
13145
14271
|
threadId: entry.threadId,
|
|
13146
14272
|
userId: data.userId,
|
|
13147
|
-
}
|
|
14273
|
+
};
|
|
14274
|
+
const text = profile ? await this.startLoginSession(backend, chat, { profile }) : await this.startLoginSession(backend, chat);
|
|
13148
14275
|
if (text)
|
|
13149
14276
|
await this.postPromptOutcome(entry, text);
|
|
13150
14277
|
return true;
|
|
@@ -13158,11 +14285,13 @@ export class FleetManager {
|
|
|
13158
14285
|
return true;
|
|
13159
14286
|
const { entry, action } = claimed;
|
|
13160
14287
|
const backend = entry.instanceName;
|
|
14288
|
+
const profile = entry.loginProfile;
|
|
14289
|
+
const shown = profile ? t("login.profile_label", backend, profile) : backend;
|
|
13161
14290
|
if (action === "cancel") {
|
|
13162
|
-
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled",
|
|
14291
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled", shown));
|
|
13163
14292
|
return true;
|
|
13164
14293
|
}
|
|
13165
|
-
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend",
|
|
14294
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", shown));
|
|
13166
14295
|
const text = await this.startLoginSession(backend, {
|
|
13167
14296
|
adapter: entry.adapter,
|
|
13168
14297
|
adapterId: entry.adapterId,
|
|
@@ -13174,6 +14303,7 @@ export class FleetManager {
|
|
|
13174
14303
|
tokenPresent: action === "go-relogin" || action === "go-relogin-tunnel",
|
|
13175
14304
|
// The consent is the button itself: only the explicit "Open public link" actions carry it.
|
|
13176
14305
|
tunnel: action === "go-tunnel" || action === "go-relogin-tunnel",
|
|
14306
|
+
...(profile ? { profile } : {}),
|
|
13177
14307
|
});
|
|
13178
14308
|
if (text)
|
|
13179
14309
|
await this.postPromptOutcome(entry, text);
|
|
@@ -13201,7 +14331,7 @@ export class FleetManager {
|
|
|
13201
14331
|
/** An install that succeeded, between its success line and the sign-in it hands over to (#1131). */
|
|
13202
14332
|
installHandoff = null;
|
|
13203
14333
|
/** Start a CLI install session. Caller enforces admin. */
|
|
13204
|
-
async startInstallSession(backendArg, chat) {
|
|
14334
|
+
async startInstallSession(backendArg, chat, signIn = {}) {
|
|
13205
14335
|
const backend = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
|
|
13206
14336
|
const info = BACKEND_INSTALLATION_INFO[backend];
|
|
13207
14337
|
if (!info)
|
|
@@ -13251,10 +14381,18 @@ export class FleetManager {
|
|
|
13251
14381
|
}
|
|
13252
14382
|
return;
|
|
13253
14383
|
}
|
|
14384
|
+
// Between the install ending and the sign-in starting there is no
|
|
14385
|
+
// session to cancel; this hand-off is what `/login cancel` stops — from
|
|
14386
|
+
// here on, as the login-shell lookup below is an await.
|
|
14387
|
+
const handoff = { backend, cancelled: false };
|
|
14388
|
+
this.installHandoff = handoff;
|
|
13254
14389
|
// The installer may only have added the binary to a profile PATH; a
|
|
13255
14390
|
// fresh login shell sees that, the fleet process's PATH may not.
|
|
13256
|
-
const installedAt = this.locateBinaryOnLoginShell(info.binary) ?? this.locateInInstallerBinDirs(info);
|
|
14391
|
+
const installedAt = (await this.locateBinaryOnLoginShell(info.binary)) ?? this.locateInInstallerBinDirs(info);
|
|
14392
|
+
if (handoff.cancelled || this.installHandoff !== handoff)
|
|
14393
|
+
return;
|
|
13257
14394
|
if (!installedAt) {
|
|
14395
|
+
this.installHandoff = null;
|
|
13258
14396
|
await chat.adapter.sendText(chat.chatId, t("install.verify_failed", backend, info.binary), { threadId: chat.threadId }).catch(() => { });
|
|
13259
14397
|
return;
|
|
13260
14398
|
}
|
|
@@ -13264,6 +14402,7 @@ export class FleetManager {
|
|
|
13264
14402
|
// installed, reported as verified, and then offered nowhere.
|
|
13265
14403
|
this.adoptBinaryDirectory(installedAt, backend);
|
|
13266
14404
|
if (!LOGIN_FLOWS[backend]) {
|
|
14405
|
+
this.installHandoff = null;
|
|
13267
14406
|
await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
|
|
13268
14407
|
return;
|
|
13269
14408
|
}
|
|
@@ -13272,10 +14411,6 @@ export class FleetManager {
|
|
|
13272
14411
|
// publish a durable completion line first so a successful install can
|
|
13273
14412
|
// never look like it silently disappeared. Include the binary that
|
|
13274
14413
|
// passed the fresh-login-shell verification and the exact next step.
|
|
13275
|
-
// Between the install ending and the sign-in starting there is no
|
|
13276
|
-
// session to cancel; this hand-off is what `/login cancel` stops.
|
|
13277
|
-
const handoff = { backend, cancelled: false };
|
|
13278
|
-
this.installHandoff = handoff;
|
|
13279
14414
|
await chat.adapter.sendText(chat.chatId, t("install.success", backend, info.binary), { threadId: chat.threadId }).catch(err => this.logger.warn({ err, backend }, "Failed to send durable install success notification"));
|
|
13280
14415
|
if (handoff.cancelled || this.installHandoff !== handoff)
|
|
13281
14416
|
return;
|
|
@@ -13285,7 +14420,8 @@ export class FleetManager {
|
|
|
13285
14420
|
// without a click.
|
|
13286
14421
|
// launchSignIn, not startLoginSession: if the new binary were still
|
|
13287
14422
|
// not visible, startLoginSession would route straight back to an install.
|
|
13288
|
-
|
|
14423
|
+
// A `/login <backend> <profile>` that had to install first signs that profile in, not the default login.
|
|
14424
|
+
const next = await this.launchSignIn(backend, chat, signIn.profile !== undefined ? { profile: signIn.profile } : {}).catch((err) => t("login.failed", backend, err?.message ?? String(err)));
|
|
13289
14425
|
if (next) {
|
|
13290
14426
|
await chat.adapter.sendText(chat.chatId, next, { threadId: chat.threadId })
|
|
13291
14427
|
.catch(err => this.logger.warn({ err, backend }, "Could not post the sign-in step after an install"));
|
|
@@ -13333,16 +14469,14 @@ export class FleetManager {
|
|
|
13333
14469
|
* The absolute path a fresh login shell resolves `binary` to, or null. Only
|
|
13334
14470
|
* a real executable file counts: `command -v` also answers with an alias
|
|
13335
14471
|
* definition or a function name, neither of which a spawn could run.
|
|
14472
|
+
* Async (runOnLoginShell): a login file that backgrounds a process holding the shell's output once froze the fleet loop here.
|
|
13336
14473
|
*/
|
|
13337
|
-
locateBinaryOnLoginShell(binary) {
|
|
13338
|
-
return measureSyncWork("fleet.installLookup", () => this.locateBinaryOnLoginShellSync(binary));
|
|
13339
|
-
}
|
|
13340
|
-
locateBinaryOnLoginShellSync(binary) {
|
|
14474
|
+
async locateBinaryOnLoginShell(binary) {
|
|
13341
14475
|
try {
|
|
13342
|
-
const result =
|
|
14476
|
+
const result = await runOnLoginShell(`command -v ${binary}`);
|
|
13343
14477
|
if (result.status !== 0)
|
|
13344
14478
|
return null;
|
|
13345
|
-
const path =
|
|
14479
|
+
const path = result.stdout.trim().split("\n").pop()?.trim() ?? "";
|
|
13346
14480
|
if (!isAbsolute(path))
|
|
13347
14481
|
return null;
|
|
13348
14482
|
accessSync(path, fsConstants.X_OK);
|
|
@@ -13393,8 +14527,16 @@ export class FleetManager {
|
|
|
13393
14527
|
return;
|
|
13394
14528
|
}
|
|
13395
14529
|
const backend = String(data.options?.backend ?? "").trim();
|
|
14530
|
+
// #1699 part 2: a supplied profile is never dropped — a blank one is refused by the admission like any bad name,
|
|
14531
|
+
// and one without a backend is refused rather than becoming the default login's picker.
|
|
14532
|
+
const profileSupplied = data.options?.profile !== undefined && data.options?.profile !== null;
|
|
14533
|
+
const profile = profileSupplied ? String(data.options?.profile).trim() : undefined;
|
|
14534
|
+
if (profileSupplied && !backend) {
|
|
14535
|
+
await data.respond(t("login.profile_needs_backend"));
|
|
14536
|
+
return;
|
|
14537
|
+
}
|
|
13396
14538
|
if (backend) {
|
|
13397
|
-
const text = await this.startLoginSession(backend, chat);
|
|
14539
|
+
const text = profile !== undefined ? await this.startLoginSession(backend, chat, { profile }) : await this.startLoginSession(backend, chat);
|
|
13398
14540
|
await data.respond(text ?? t("login.confirm_posted"));
|
|
13399
14541
|
return;
|
|
13400
14542
|
}
|
|
@@ -13429,16 +14571,26 @@ export class FleetManager {
|
|
|
13429
14571
|
try {
|
|
13430
14572
|
const store = new WebChatDiskStore({ home: getAgendHome(), logger: this.logger, ownerOf: (name) => this.webChatOwner(name) });
|
|
13431
14573
|
const names = new Set([...Object.keys(fleet.instances ?? {}), ...(this.classicChannels?.getAll().map(c => c.instanceName) ?? [])]);
|
|
13432
|
-
// A kept message's files are served again under their ids only from the instance's own inbox
|
|
13433
|
-
// checks (web-upload.ts restoreServed); they leave the ledger with their message.
|
|
14574
|
+
// A kept message's files are served again under their ids only from the instance's own inbox or (#1770) its reply
|
|
14575
|
+
// snapshots, by the ledger's checks (web-upload.ts restoreServed); they leave the ledger with their message.
|
|
13434
14576
|
this.webChatHistory.setFiles({
|
|
13435
14577
|
storablePath: (id) => this.webFiles.storablePath(id),
|
|
13436
|
-
restore: (entry, instance) => this.webFiles.restoreServed(entry, instance, join(getAgendHome(), "workspaces", instance, "inbox")),
|
|
14578
|
+
restore: (entry, instance) => this.webFiles.restoreServed(entry, instance, join(getAgendHome(), "workspaces", instance, "inbox"), webFilesDir(getAgendHome(), instance) ?? undefined),
|
|
13437
14579
|
drop: (ids) => this.webFiles.dropServed(ids),
|
|
13438
14580
|
});
|
|
13439
14581
|
let restored = 0;
|
|
13440
14582
|
for (const name of names)
|
|
13441
14583
|
restored += this.webChatHistory.restore(name, store.load(name));
|
|
14584
|
+
// #1770: before any instance replies, the snapshots no restored message holds (it left the history while the
|
|
14585
|
+
// fleet was down, or a copy stopped midway) are removed.
|
|
14586
|
+
let swept = 0;
|
|
14587
|
+
for (const name of names) {
|
|
14588
|
+
const dir = webFilesDir(getAgendHome(), name);
|
|
14589
|
+
if (dir)
|
|
14590
|
+
swept += sweepReplySnapshots(dir, this.webFiles.heldSnapshots(name));
|
|
14591
|
+
}
|
|
14592
|
+
if (swept)
|
|
14593
|
+
this.logger.info({ swept }, "Removed web reply snapshots no message holds");
|
|
13442
14594
|
this.webChatStore = store;
|
|
13443
14595
|
this.webChatHistory.setStore(store);
|
|
13444
14596
|
if (restored)
|
|
@@ -13473,6 +14625,7 @@ export class FleetManager {
|
|
|
13473
14625
|
messageId: typeof m.messageId === "string" ? m.messageId : undefined,
|
|
13474
14626
|
role: typeof m.role === "string" ? m.role : undefined,
|
|
13475
14627
|
buttons: m.buttons,
|
|
14628
|
+
click: m.click,
|
|
13476
14629
|
});
|
|
13477
14630
|
broadcastSseEvent(this.sseClients, event, recorded, onError, this.webChatHistory.cursorOf(recorded));
|
|
13478
14631
|
return;
|
|
@@ -13524,7 +14677,7 @@ export class FleetManager {
|
|
|
13524
14677
|
const instanceHangConfig = this.fleetConfig?.instances[instanceName]?.hang_detector;
|
|
13525
14678
|
const configuredMinutes = instanceHangConfig?.timeout_minutes
|
|
13526
14679
|
?? this.fleetConfig?.defaults?.hang_detector?.timeout_minutes
|
|
13527
|
-
?? 15
|
|
14680
|
+
?? DEFAULT_STUCK_TIMEOUT_MS / 60_000; // #1854: the detector's own default (10 min), not a stale 15
|
|
13528
14681
|
const unchangedMinutes = unchangedForMs == null
|
|
13529
14682
|
? configuredMinutes
|
|
13530
14683
|
: Math.max(1, Math.floor(unchangedForMs / 60_000));
|
|
@@ -13582,12 +14735,12 @@ export class FleetManager {
|
|
|
13582
14735
|
"codex": "AGENTS.md",
|
|
13583
14736
|
"opencode": "AGENTS.md",
|
|
13584
14737
|
"kiro-cli": ".kiro/steering/project.md",
|
|
13585
|
-
// Grok reads AGENTS.md project docs; agy reads .agents/
|
|
14738
|
+
// Grok reads AGENTS.md project docs; agy reads .agents/AGENTS.md (board 0340ec89: not the lowercase name) — the
|
|
13586
14739
|
// same files their writeConfig() appends fleet instructions to.
|
|
13587
14740
|
"grok": "AGENTS.md",
|
|
13588
14741
|
// muse init scaffolds AGENTS.md and the binary reads it as project rules.
|
|
13589
14742
|
"muse": "AGENTS.md",
|
|
13590
|
-
"antigravity": ".agents/
|
|
14743
|
+
"antigravity": ".agents/AGENTS.md",
|
|
13591
14744
|
"mock": "CLAUDE.md",
|
|
13592
14745
|
};
|
|
13593
14746
|
static GENERAL_INSTRUCTIONS = `# Fleet Coordinator
|
|
@@ -13627,10 +14780,17 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
13627
14780
|
ensureGeneralInstructionsSync(workDir, backendName, instanceName) {
|
|
13628
14781
|
const backend = backendName ?? "claude-code";
|
|
13629
14782
|
workDir = this.resolveKnowledgeWorkDir(workDir, backend, instanceName);
|
|
14783
|
+
// Board 0340ec89: General's file from before, under the lowercase name agy never read, becomes the one it reads.
|
|
14784
|
+
if (backend === "antigravity")
|
|
14785
|
+
migrateAgyAgentsFile(workDir);
|
|
13630
14786
|
const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
|
|
13631
14787
|
const filePath = join(workDir, filename);
|
|
13632
14788
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
13633
|
-
if (
|
|
14789
|
+
if (backend === "antigravity" && danglingSymlink(filePath)) {
|
|
14790
|
+
// #1914 review: a person's dangling symlink there is kept and not followed (nothing created where it points).
|
|
14791
|
+
this.logger.warn({ filePath }, "General's agy instructions file is a dangling symlink; left as it is, not written");
|
|
14792
|
+
}
|
|
14793
|
+
else if (!existsSync(filePath)) {
|
|
13634
14794
|
writeFileSync(filePath, FleetManager.GENERAL_INSTRUCTIONS, "utf-8");
|
|
13635
14795
|
this.logger.info({ filePath }, "Created general instance instructions file");
|
|
13636
14796
|
}
|
|
@@ -13727,6 +14887,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
13727
14887
|
}
|
|
13728
14888
|
const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
|
|
13729
14889
|
const instructionsPath = join(workDir, filename);
|
|
14890
|
+
if (backend === "antigravity" && danglingSymlink(instructionsPath))
|
|
14891
|
+
return; // kept, not followed (#1914 review; logged above)
|
|
13730
14892
|
const body = files.map(f => readFileSync(join(srcSteering, f), "utf-8").trim()).join("\n\n");
|
|
13731
14893
|
const block = `${FleetManager.STEERING_BLOCK_BEGIN}\n${body}\n${FleetManager.STEERING_BLOCK_END}`;
|
|
13732
14894
|
let existing = "";
|
|
@@ -13747,6 +14909,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
13747
14909
|
mkdirSync(dirname(instructionsPath), { recursive: true });
|
|
13748
14910
|
writeFileSync(instructionsPath, next);
|
|
13749
14911
|
}
|
|
14912
|
+
// Board 0340ec89: a legacy agy file that stays beside the new one (a person's own) loses only AgEnD's block.
|
|
14913
|
+
const legacy = backend === "antigravity" ? agyLegacyAgentsFile(workDir) : null;
|
|
14914
|
+
if (legacy) {
|
|
14915
|
+
try {
|
|
14916
|
+
const old = readFileSync(legacy, "utf-8");
|
|
14917
|
+
const b = old.indexOf(FleetManager.STEERING_BLOCK_BEGIN), e = old.indexOf(FleetManager.STEERING_BLOCK_END);
|
|
14918
|
+
if (b !== -1 && e > b) {
|
|
14919
|
+
const rest = (old.slice(0, b).trimEnd() + "\n" + old.slice(e + FleetManager.STEERING_BLOCK_END.length).trimStart()).trim();
|
|
14920
|
+
if (rest)
|
|
14921
|
+
writeFileSync(legacy, rest + "\n");
|
|
14922
|
+
else
|
|
14923
|
+
rmSync(legacy, { force: true });
|
|
14924
|
+
}
|
|
14925
|
+
}
|
|
14926
|
+
catch { /* best effort: the new file already has the block */ }
|
|
14927
|
+
}
|
|
13750
14928
|
}
|
|
13751
14929
|
static STEERING_BLOCK_BEGIN = "<!-- >>> agend:core-rules — managed by AgEnD; edits inside this block are overwritten -->";
|
|
13752
14930
|
static STEERING_BLOCK_END = "<!-- <<< agend:core-rules -->";
|
|
@@ -14229,12 +15407,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14229
15407
|
const reactions = this.pendingReactionsMeta(instanceName);
|
|
14230
15408
|
Object.assign(meta, reactions.meta);
|
|
14231
15409
|
try {
|
|
14232
|
-
await this.
|
|
15410
|
+
await this.deliverHumanToInstance(instanceName, {
|
|
14233
15411
|
type: "fleet_inbound",
|
|
14234
15412
|
content: fullText,
|
|
14235
15413
|
targetSession: instanceName,
|
|
14236
15414
|
meta,
|
|
14237
|
-
});
|
|
15415
|
+
}, msg.humanOrigin, msg.humanOriginalBody ?? text);
|
|
14238
15416
|
reactions.consume();
|
|
14239
15417
|
}
|
|
14240
15418
|
catch (err) {
|
|
@@ -14251,6 +15429,50 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14251
15429
|
});
|
|
14252
15430
|
}
|
|
14253
15431
|
/** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
|
|
15432
|
+
/** #1690: our own runtime switches awaiting the daemon's outcome, by id. */
|
|
15433
|
+
runtimeSwitchWaiters = new Map();
|
|
15434
|
+
/**
|
|
15435
|
+
* #1690: type `/model <model>` and wait for what it came to: the daemon answers claude's "Switch model?" for us (Yes
|
|
15436
|
+
* when it names this model, No otherwise) or sees claude's own result. Null when it could not be sent; a timeout is
|
|
15437
|
+
* "not confirmed".
|
|
15438
|
+
*/
|
|
15439
|
+
runRuntimeModelSwitch(instanceName, model) {
|
|
15440
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
15441
|
+
if (!ipc)
|
|
15442
|
+
return Promise.resolve({ outcome: "not_sent" });
|
|
15443
|
+
const id = `switch-${randomUUID()}`;
|
|
15444
|
+
return new Promise(resolve => {
|
|
15445
|
+
const timer = setTimeout(() => { this.runtimeSwitchWaiters.delete(id); resolve({ outcome: "not_confirmed" }); }, RUNTIME_SWITCH_RESULT_MS);
|
|
15446
|
+
timer.unref?.();
|
|
15447
|
+
this.runtimeSwitchWaiters.set(id, result => { clearTimeout(timer); resolve(result); });
|
|
15448
|
+
const sent = ipc.send({ type: "raw_paste", content: `/model ${model}`, runtime_switch: { id, kind: "model", requested: model } });
|
|
15449
|
+
if (!sent) {
|
|
15450
|
+
clearTimeout(timer);
|
|
15451
|
+
this.runtimeSwitchWaiters.delete(id);
|
|
15452
|
+
resolve({ outcome: "not_sent" });
|
|
15453
|
+
return;
|
|
15454
|
+
}
|
|
15455
|
+
this.logger.info({ instanceName, model }, "Runtime model switch sent");
|
|
15456
|
+
});
|
|
15457
|
+
}
|
|
15458
|
+
/**
|
|
15459
|
+
* #1690: the names claude-code is known to accept, for checking `/model <name>` BEFORE it is typed — claude itself
|
|
15460
|
+
* accepts any string ("Set model to opsu"), and its "Switch model?" then names the typo. Static aliases (with [1m]),
|
|
15461
|
+
* the cached account catalog, the configured model; without a cached catalog, any `claude-…` id too (cannot check).
|
|
15462
|
+
*/
|
|
15463
|
+
claudeModelKnown(instanceName, model,
|
|
15464
|
+
/** #1806: null on a switch INTO claude-code — the configured model then belongs to the backend being left. */
|
|
15465
|
+
configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model) {
|
|
15466
|
+
const aliases = ["default", "sonnet", "opus", "haiku", "opusplan", "Fable"];
|
|
15467
|
+
const api = (this.readCliEnv("claude-code")?.apiModels ?? []).map(m => m.id);
|
|
15468
|
+
const lower = model.trim().toLowerCase();
|
|
15469
|
+
const bare = lower.replace(/\[1m\]$/, "");
|
|
15470
|
+
const known = aliases.some(a => a.toLowerCase() === bare)
|
|
15471
|
+
|| api.some(id => id.toLowerCase() === lower || `${id.toLowerCase()}[1m]` === lower)
|
|
15472
|
+
|| (typeof configured === "string" && configured.toLowerCase() === lower)
|
|
15473
|
+
|| (api.length === 0 && /^claude-[a-z0-9][a-z0-9.-]*(\[1m\])?$/.test(lower));
|
|
15474
|
+
return { known, names: [...aliases, ...api.filter(id => !id.endsWith("[1m]")).slice(0, 12)] };
|
|
15475
|
+
}
|
|
14254
15476
|
pasteRawToClassicInstance(instanceName, text) {
|
|
14255
15477
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
14256
15478
|
if (!ipc) {
|
|
@@ -14850,7 +16072,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14850
16072
|
try {
|
|
14851
16073
|
// Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
|
|
14852
16074
|
result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
|
|
14853
|
-
? await
|
|
16075
|
+
? await withSpawnHoldReply(async (text) => {
|
|
16076
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
16077
|
+
if (progressMsgId)
|
|
16078
|
+
await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
|
|
16079
|
+
else
|
|
16080
|
+
await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
|
|
16081
|
+
}
|
|
16082
|
+
else
|
|
16083
|
+
await pending.respond(text);
|
|
16084
|
+
}, () => this.applyEffort(pending.instanceName, level)) : t("menu.click_stale");
|
|
14854
16085
|
}
|
|
14855
16086
|
catch (err) {
|
|
14856
16087
|
this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
|
|
@@ -15175,7 +16406,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15175
16406
|
try {
|
|
15176
16407
|
// Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
|
|
15177
16408
|
result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
|
|
15178
|
-
? await
|
|
16409
|
+
? await withSpawnHoldReply(async (text) => {
|
|
16410
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
16411
|
+
if (progressMsgId)
|
|
16412
|
+
await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
|
|
16413
|
+
else
|
|
16414
|
+
await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
|
|
16415
|
+
}
|
|
16416
|
+
else
|
|
16417
|
+
await pending.respond(text);
|
|
16418
|
+
}, () => this.applyModel(pending.instanceName, model)) : t("menu.click_stale");
|
|
15179
16419
|
}
|
|
15180
16420
|
catch (err) {
|
|
15181
16421
|
this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
|
|
@@ -15203,7 +16443,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15203
16443
|
/** How this instance's backend applies an effort change. */
|
|
15204
16444
|
effortStrategyFor(instanceName) {
|
|
15205
16445
|
try {
|
|
15206
|
-
const { strategy, levels } =
|
|
16446
|
+
const { strategy, levels } = this.instanceEffortMetadata(instanceName);
|
|
15207
16447
|
return strategy !== "unsupported" && levels.length > 0 ? strategy : "unsupported";
|
|
15208
16448
|
}
|
|
15209
16449
|
catch {
|
|
@@ -15213,13 +16453,23 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15213
16453
|
/** Effort levels this instance's backend actually accepts (empty = unsupported). */
|
|
15214
16454
|
effortLevelsFor(instanceName) {
|
|
15215
16455
|
try {
|
|
15216
|
-
const { strategy, levels } =
|
|
16456
|
+
const { strategy, levels } = this.instanceEffortMetadata(instanceName);
|
|
15217
16457
|
return strategy === "unsupported" ? [] : levels;
|
|
15218
16458
|
}
|
|
15219
16459
|
catch {
|
|
15220
16460
|
return [];
|
|
15221
16461
|
}
|
|
15222
16462
|
}
|
|
16463
|
+
/** The instance's backend's effort metadata; agy's levels are its model's (#1825), the one the instance runs. */
|
|
16464
|
+
instanceEffortMetadata(instanceName) {
|
|
16465
|
+
const backend = this.backendNameForInstance(instanceName);
|
|
16466
|
+
let model = null;
|
|
16467
|
+
if (backend === "antigravity") {
|
|
16468
|
+
const resolved = this.resolveInstanceModel(instanceName);
|
|
16469
|
+
model = resolved.source === "unresolved" ? null : resolved.model;
|
|
16470
|
+
}
|
|
16471
|
+
return readEffortMetadata(backend, this.getInstanceDir(instanceName), model);
|
|
16472
|
+
}
|
|
15223
16473
|
/** Configured effort for an instance: per-instance, else fleet default, else none. */
|
|
15224
16474
|
resolveInstanceEffort(instanceName) {
|
|
15225
16475
|
const own = this.fleetConfig?.instances[instanceName]?.effort;
|
|
@@ -15309,21 +16559,34 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15309
16559
|
}
|
|
15310
16560
|
catch { /* default restart */ }
|
|
15311
16561
|
const warn = isModelCompatible(backendName, model) ? "" : t("model.pattern_warning", model, backendName);
|
|
16562
|
+
// #1690: claude takes any name; a mistyped one would be "switched" to and fail the next turn. Checked first —
|
|
16563
|
+
// nothing is typed or saved for an unknown one.
|
|
16564
|
+
if (backendName === "claude-code") {
|
|
16565
|
+
const check = this.claudeModelKnown(instanceName, model);
|
|
16566
|
+
if (!check.known)
|
|
16567
|
+
return t("model.unknown_name", model, check.names.join(", "));
|
|
16568
|
+
}
|
|
15312
16569
|
if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
|
|
15313
16570
|
return `${warn}${t("effort.not_running", instanceName)}`;
|
|
15314
16571
|
}
|
|
16572
|
+
// #1690: claude-code's runtime switch reports what it came to; the model is saved unless claude was told No.
|
|
16573
|
+
if (strategy === "runtime" && backendName === "claude-code") {
|
|
16574
|
+
const result = await this.runRuntimeModelSwitch(instanceName, model);
|
|
16575
|
+
if (result.outcome === "mismatch")
|
|
16576
|
+
return `${warn}${t("model.switch_mismatch", instanceName, result.shown, model)}`;
|
|
16577
|
+
if (result.outcome === "not_sent")
|
|
16578
|
+
return `${warn}${t("model.switch_not_sent", instanceName, model)}`;
|
|
16579
|
+
const persistedNow = this.persistInstanceModel(instanceName, model);
|
|
16580
|
+
if (!persistedNow)
|
|
16581
|
+
return `${warn}${t("model.persist_failed", instanceName)}`;
|
|
16582
|
+
const head = result.outcome === "switched"
|
|
16583
|
+
? (result.confirmed ? t("model.switch_confirmed", instanceName, model) : t("model.runtime_success", instanceName, model))
|
|
16584
|
+
: t("model.switch_unconfirmed", instanceName, model, Math.round(RUNTIME_SWITCH_RESULT_MS / 1000));
|
|
16585
|
+
return `${warn}${head}${this.effortSuffix(instanceName)}`;
|
|
16586
|
+
}
|
|
15315
16587
|
// Persist either way: a runtime switch must survive the next respawn too,
|
|
15316
16588
|
// or the instance silently reverts to the CLI default after a fleet restart.
|
|
15317
|
-
|
|
15318
|
-
if (this.fleetConfig?.instances[instanceName]) {
|
|
15319
|
-
this.fleetConfig.instances[instanceName].model = model;
|
|
15320
|
-
this.saveFleetConfig();
|
|
15321
|
-
persisted = true;
|
|
15322
|
-
}
|
|
15323
|
-
else if (this.classicChannels?.setModelByInstance(instanceName, model)) {
|
|
15324
|
-
persisted = true;
|
|
15325
|
-
}
|
|
15326
|
-
if (!persisted)
|
|
16589
|
+
if (!this.persistInstanceModel(instanceName, model))
|
|
15327
16590
|
return `${warn}${t("model.persist_failed", instanceName)}`;
|
|
15328
16591
|
if (strategy === "runtime") {
|
|
15329
16592
|
this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
|
|
@@ -15332,6 +16595,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15332
16595
|
await this.restartSingleInstance(instanceName);
|
|
15333
16596
|
return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
|
|
15334
16597
|
}
|
|
16598
|
+
/** Save an instance's model (fleet.yaml, or its ClassicBot channel). False when neither holds it. */
|
|
16599
|
+
persistInstanceModel(instanceName, model) {
|
|
16600
|
+
if (this.fleetConfig?.instances[instanceName]) {
|
|
16601
|
+
this.fleetConfig.instances[instanceName].model = model;
|
|
16602
|
+
this.saveFleetConfig();
|
|
16603
|
+
return true;
|
|
16604
|
+
}
|
|
16605
|
+
return this.classicChannels?.setModelByInstance(instanceName, model) === true;
|
|
16606
|
+
}
|
|
15335
16607
|
/**
|
|
15336
16608
|
* The trailing "Current effort: …" line for a /model reply.
|
|
15337
16609
|
*
|
|
@@ -15349,6 +16621,377 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15349
16621
|
? `\n${t("effort.current_fleet", effort)}`
|
|
15350
16622
|
: `\n${t("effort.current", effort)}`;
|
|
15351
16623
|
}
|
|
16624
|
+
// ── #1806: /backend — another CLI backend, with its model and effort, in one write and one relaunch ──
|
|
16625
|
+
/** The steps' view of this fleet (backend-command.ts). `live`: a missing or stale model list may be probed (not on the web). */
|
|
16626
|
+
backendCommandDeps(instanceName, opts) {
|
|
16627
|
+
return {
|
|
16628
|
+
currentBackend: (name) => this.backendNameForInstance(name),
|
|
16629
|
+
currentDisplay: (name) => `${this.backendNameForInstance(name)} · ${this.resolveInstanceModel(name).display}`,
|
|
16630
|
+
installedBackends: () => KNOWN_BACKENDS.filter(b => b !== "mock" && this.backendInstalledHere(b)),
|
|
16631
|
+
models: (backend) => this.backendModelOptions(backend, opts),
|
|
16632
|
+
// #1808: the same rule update_instance_config's switch applies (backendModelVerdict).
|
|
16633
|
+
modelKnown: (backend, model) => this.backendModelVerdict(instanceName, backend, model).ok,
|
|
16634
|
+
effortLevels: (backend, model) => this.backendEffortLevels(instanceName, backend, model),
|
|
16635
|
+
inherited: (key) => {
|
|
16636
|
+
const value = getEffectiveInstanceDefaults(this.fleetConfig?.defaults)[key];
|
|
16637
|
+
return typeof value === "string" && value ? value : undefined;
|
|
16638
|
+
},
|
|
16639
|
+
modelCompatible: (backend, model) => isModelCompatible(backend, model),
|
|
16640
|
+
state: (name) => {
|
|
16641
|
+
const status = this.getInstanceStatus(name);
|
|
16642
|
+
return { running: status === "running", busy: status === "running" && !this.getInstanceIdle(name), status };
|
|
16643
|
+
},
|
|
16644
|
+
t: (key, ...args) => t(key, ...args),
|
|
16645
|
+
};
|
|
16646
|
+
}
|
|
16647
|
+
/**
|
|
16648
|
+
* `/effort`'s levels for a backend this instance may move to, for the model that will run there (codex's levels are
|
|
16649
|
+
* per model; null: its configured default). Empty: it has no effort setting. A file read, never a CLI.
|
|
16650
|
+
*/
|
|
16651
|
+
backendEffortLevels(instanceName, backend, model = null) {
|
|
16652
|
+
// codex: the model the NEXT launch runs, for the levels and the gate alike (#1807 review r2).
|
|
16653
|
+
let runs = model;
|
|
16654
|
+
if (backend === "codex") {
|
|
16655
|
+
const next = this.prospectiveCodexModel(instanceName, model);
|
|
16656
|
+
if (!next.proven)
|
|
16657
|
+
return [];
|
|
16658
|
+
runs = next.model;
|
|
16659
|
+
}
|
|
16660
|
+
let levels;
|
|
16661
|
+
try {
|
|
16662
|
+
const metadata = readEffortMetadata(backend, this.getInstanceDir(instanceName), runs);
|
|
16663
|
+
levels = metadata.strategy === "unsupported" ? [] : metadata.levels;
|
|
16664
|
+
}
|
|
16665
|
+
catch {
|
|
16666
|
+
return [];
|
|
16667
|
+
}
|
|
16668
|
+
// #1811: only the levels this host's launch of that backend will pass (launch-effort.ts, the gate the launch
|
|
16669
|
+
// itself applies) — an effort chosen here reaches the new CLI with the one relaunch, or is not offered.
|
|
16670
|
+
const takes = this.launchEffortGate(instanceName, backend, runs);
|
|
16671
|
+
return levels.filter(takes);
|
|
16672
|
+
}
|
|
16673
|
+
/**
|
|
16674
|
+
* #1808: whether a backend takes this model name — the one rule for /backend and update_instance_config's switch.
|
|
16675
|
+
* The name must be one its launch line can carry (modelNameCarried); then its offered list (the cached CLI env), and
|
|
16676
|
+
* claude-code's known names (without this instance's configured model, which belongs to the backend being left).
|
|
16677
|
+
* With no list known, the backend's usual name pattern decides.
|
|
16678
|
+
*/
|
|
16679
|
+
backendModelVerdict(instanceName, backend, model,
|
|
16680
|
+
/** The provider the backend will run on (default: this instance's own, else the fleet's). */
|
|
16681
|
+
opts = { provider: this.customProviderFor(instanceName, backend) }) {
|
|
16682
|
+
// A custom provider has its own catalog, which the CLI reads at launch and AgEnD cannot see before one (#1815
|
|
16683
|
+
// review): only the launch-line rule applies — never the account's list or the default provider's naming pattern.
|
|
16684
|
+
if (opts.provider)
|
|
16685
|
+
return modelNameCarried(backend, model) ? { ok: true } : { ok: false, choices: [] };
|
|
16686
|
+
const listed = this.readCliEnv(backend)?.models ?? [];
|
|
16687
|
+
const choices = listed.slice(0, 24).map(o => o.id);
|
|
16688
|
+
if (!modelNameCarried(backend, model))
|
|
16689
|
+
return { ok: false, choices };
|
|
16690
|
+
if (listed.some(o => o.id === model))
|
|
16691
|
+
return { ok: true };
|
|
16692
|
+
if (backend === "claude-code") {
|
|
16693
|
+
const check = this.claudeModelKnown(instanceName, model, null);
|
|
16694
|
+
return check.known ? { ok: true } : { ok: false, choices: check.names };
|
|
16695
|
+
}
|
|
16696
|
+
return listed.length === 0 && isModelCompatible(backend, model) ? { ok: true } : { ok: false, choices };
|
|
16697
|
+
}
|
|
16698
|
+
/**
|
|
16699
|
+
* #1807 review r2: the model codex's next launch of this instance runs. Every launch's writeConfig copies the SHARED
|
|
16700
|
+
* config.toml into the instance's codex home first, so that source decides — not the home's copy from an earlier
|
|
16701
|
+
* launch. Read-only; a model chosen here (`-c model=…`) outranks it; a project file that could change it, unproven.
|
|
16702
|
+
*/
|
|
16703
|
+
prospectiveCodexModel(instanceName, model) {
|
|
16704
|
+
const { sharedHome } = codexMetadataHomes(this.getInstanceDir(instanceName));
|
|
16705
|
+
return resolveCodexLaunchModel({
|
|
16706
|
+
isolatedHome: sharedHome, sharedHome, model,
|
|
16707
|
+
workingDirectory: this.fleetConfig?.instances[instanceName]?.working_directory,
|
|
16708
|
+
});
|
|
16709
|
+
}
|
|
16710
|
+
/** #1811: whether a launch of `backend` here would pass a level: the same gate its builder applies. */
|
|
16711
|
+
launchEffortGate(instanceName, backend, model) {
|
|
16712
|
+
if (backend === "claude-code" || backend === "grok" || backend === "antigravity") {
|
|
16713
|
+
const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
|
|
16714
|
+
if (!binary)
|
|
16715
|
+
return () => false;
|
|
16716
|
+
const path = resolveBinary(binary);
|
|
16717
|
+
return level => flagEffort(backend, level, path).arg !== null;
|
|
16718
|
+
}
|
|
16719
|
+
if (backend === "codex") {
|
|
16720
|
+
// `model` is the next launch's, proven by the caller (prospectiveCodexModel); the catalog is codex's own cache.
|
|
16721
|
+
if (!model)
|
|
16722
|
+
return () => false;
|
|
16723
|
+
const catalog = readCodexModelEffortLevels({ ...codexMetadataHomes(this.getInstanceDir(instanceName)), model });
|
|
16724
|
+
return level => codexEffort(level, catalog).arg !== null;
|
|
16725
|
+
}
|
|
16726
|
+
// kiro-cli gates its own --effort by version at launch (a quiet console note otherwise); muse always passes it.
|
|
16727
|
+
return () => true;
|
|
16728
|
+
}
|
|
16729
|
+
/** Installed the way the launch finds a CLI (resolveBinary): on PATH or in a known install directory. */
|
|
16730
|
+
backendInstalledHere(backend) {
|
|
16731
|
+
const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
|
|
16732
|
+
return !!binary && resolveBinary(binary) !== binary;
|
|
16733
|
+
}
|
|
16734
|
+
/** The models `/model` would offer on a backend: the cached CLI env, else (live) a bounded probe. Never throws. */
|
|
16735
|
+
async backendModelOptions(backend, opts) {
|
|
16736
|
+
const cached = this.readCliEnv(backend);
|
|
16737
|
+
if (!opts.live || (cached?.models.length && !this.cliEnvNeedsRefresh(cached)))
|
|
16738
|
+
return cached?.models ?? [];
|
|
16739
|
+
opts.onLiveProbe?.();
|
|
16740
|
+
const env = await this.probeBackendBounded(backend).catch(() => null);
|
|
16741
|
+
return env?.models.length ? env.models : cached?.models ?? [];
|
|
16742
|
+
}
|
|
16743
|
+
/**
|
|
16744
|
+
* #1806 review: the instance a `/backend` was asked about is still this config entry — not one deleted and created
|
|
16745
|
+
* again under the same name, or replaced by a Settings edit (both put a new object there), stopped or not.
|
|
16746
|
+
*/
|
|
16747
|
+
backendTargetIs(instanceName, target) {
|
|
16748
|
+
const instances = this.fleetConfig?.instances;
|
|
16749
|
+
return !!target && !!instances && Object.prototype.hasOwnProperty.call(instances, instanceName) && instances[instanceName] === target;
|
|
16750
|
+
}
|
|
16751
|
+
/** `/backend` is for fleet.yaml instances: update_instance_config does not manage a ClassicBot agent. */
|
|
16752
|
+
backendCommandRefusal(instanceName) {
|
|
16753
|
+
return this.fleetConfig?.instances && Object.prototype.hasOwnProperty.call(this.fleetConfig.instances, instanceName)
|
|
16754
|
+
? null : t("backend.classic_unsupported", instanceName);
|
|
16755
|
+
}
|
|
16756
|
+
/**
|
|
16757
|
+
* Apply a confirmed `/backend`: judged again first (a menu lives a minute — the backend may have moved, a CLI been
|
|
16758
|
+
* removed), then update_instance_config's write: backend, model and effort at once, refused and rolled back whole
|
|
16759
|
+
* (its credential-profile login gate included), then one fresh relaunch of a running instance.
|
|
16760
|
+
*/
|
|
16761
|
+
async applyBackendSwitch(instanceName, args, from, stillCurrent) {
|
|
16762
|
+
if (this.backendNameForInstance(instanceName) !== from)
|
|
16763
|
+
return t("backend.changed", instanceName, from);
|
|
16764
|
+
if (!stillCurrent())
|
|
16765
|
+
return t("menu.click_stale");
|
|
16766
|
+
const deps = this.backendCommandDeps(instanceName, { live: false });
|
|
16767
|
+
const step = await backendStep(deps, instanceName, args);
|
|
16768
|
+
if (step.kind !== "confirm")
|
|
16769
|
+
return step.kind === "refuse" ? step.text : t("backend.usage");
|
|
16770
|
+
// Asked again after the await, with nothing awaited between here and the write (applyInstanceConfigPatch writes
|
|
16771
|
+
// synchronously): two switches confirmed together, or an instance changed meanwhile, never both succeed.
|
|
16772
|
+
if (this.backendNameForInstance(instanceName) !== from)
|
|
16773
|
+
return t("backend.changed", instanceName, from);
|
|
16774
|
+
if (!stillCurrent())
|
|
16775
|
+
return t("menu.click_stale");
|
|
16776
|
+
const { plan } = step;
|
|
16777
|
+
const shown = planDisplay(deps, plan);
|
|
16778
|
+
const { value, error } = await new Promise(resolve => {
|
|
16779
|
+
applyInstanceConfigPatch(this, instanceName, { backend: plan.backend, model: plan.model, effort: plan.effort }, (value, error) => resolve({ value, error }));
|
|
16780
|
+
});
|
|
16781
|
+
if (error)
|
|
16782
|
+
return t("backend.refused", instanceName, error);
|
|
16783
|
+
const result = (value ?? {});
|
|
16784
|
+
if (result.warning)
|
|
16785
|
+
return t("backend.restart_failed", instanceName, plan.backend, result.warning);
|
|
16786
|
+
if (result.restarted)
|
|
16787
|
+
return t("backend.switched", instanceName, plan.backend, shown.model, shown.effort);
|
|
16788
|
+
return t("backend.switched_not_running", instanceName, plan.backend, shown.model, shown.effort, this.getInstanceStatus(instanceName));
|
|
16789
|
+
}
|
|
16790
|
+
/** A step's buttons: index callbacks (a model id can be longer than Telegram's 64-byte callback data allows). */
|
|
16791
|
+
backendStepButtons(instanceName, nonce, step) {
|
|
16792
|
+
if (step.kind === "choose")
|
|
16793
|
+
return step.choices.map((c, i) => ({ id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:${i}`, label: c.label }));
|
|
16794
|
+
if (step.kind !== "confirm")
|
|
16795
|
+
return [];
|
|
16796
|
+
const running = this.getInstanceStatus(instanceName) === "running";
|
|
16797
|
+
return [
|
|
16798
|
+
{ id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:0`, label: t(running ? "backend.confirm_button" : "backend.confirm_button_stopped") },
|
|
16799
|
+
{ id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:1`, label: t("backend.cancel_button") },
|
|
16800
|
+
];
|
|
16801
|
+
}
|
|
16802
|
+
/**
|
|
16803
|
+
* Show a `/backend` step (a choice, or the confirmation) where the command was given: Discord edits its interaction
|
|
16804
|
+
* reply; Telegram retires the previous step's keyboard and posts the next. One pending entry per step, under a fresh
|
|
16805
|
+
* nonce: a click on an older step finds nothing.
|
|
16806
|
+
*/
|
|
16807
|
+
async presentBackendStep(place, step) {
|
|
16808
|
+
const nonce = randomBytes(6).toString("hex");
|
|
16809
|
+
const buttons = this.backendStepButtons(place.instanceName, nonce, step);
|
|
16810
|
+
const timer = setTimeout(() => {
|
|
16811
|
+
const p = this.pendingBackendSelects.get(nonce);
|
|
16812
|
+
if (!p)
|
|
16813
|
+
return;
|
|
16814
|
+
this.pendingBackendSelects.delete(nonce);
|
|
16815
|
+
void this.endBackendMenu(p, t("backend.selection_expired")).catch(() => { });
|
|
16816
|
+
}, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
16817
|
+
timer.unref?.();
|
|
16818
|
+
const pending = { ...place, step, timer };
|
|
16819
|
+
this.pendingBackendSelects.set(nonce, pending);
|
|
16820
|
+
try {
|
|
16821
|
+
if (step.kind === "confirm" && place.respondButtons) {
|
|
16822
|
+
await place.respondButtons(step.text, buttons);
|
|
16823
|
+
return;
|
|
16824
|
+
}
|
|
16825
|
+
if (place.respondChoices) {
|
|
16826
|
+
await place.respondChoices(step.text, buttons);
|
|
16827
|
+
return;
|
|
16828
|
+
}
|
|
16829
|
+
if (!place.adapter || !place.adapterChatId)
|
|
16830
|
+
throw new Error("no place to show the menu");
|
|
16831
|
+
if (place.menuMessageId && place.adapter.editMessageRemoveButtons) {
|
|
16832
|
+
const chosen = t("backend.chosen", joinBackendArgs(step.args));
|
|
16833
|
+
await place.adapter.editMessageRemoveButtons(place.adapterChatId, place.menuMessageId, chosen, place.adapterThreadId).catch(() => { });
|
|
16834
|
+
}
|
|
16835
|
+
const menuMessageId = await place.adapter.promptUser(place.adapterChatId, step.text, buttons, { threadId: place.adapterThreadId });
|
|
16836
|
+
const fresh = this.pendingBackendSelects.get(nonce);
|
|
16837
|
+
if (fresh)
|
|
16838
|
+
fresh.menuMessageId = menuMessageId;
|
|
16839
|
+
}
|
|
16840
|
+
catch (err) {
|
|
16841
|
+
this.pendingBackendSelects.delete(nonce);
|
|
16842
|
+
clearTimeout(timer);
|
|
16843
|
+
this.logger.warn({ err, instanceName: place.instanceName }, "backend menu failed");
|
|
16844
|
+
await place.respond(t("backend.usage")).catch(() => { });
|
|
16845
|
+
}
|
|
16846
|
+
}
|
|
16847
|
+
/** Close a `/backend` menu with a last word: its buttons go (Telegram edits them away), the text stays. Returns where it was said. */
|
|
16848
|
+
async endBackendMenu(pending, text) {
|
|
16849
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
16850
|
+
if (pending.menuMessageId && pending.adapter.editMessageRemoveButtons) {
|
|
16851
|
+
try {
|
|
16852
|
+
await pending.adapter.editMessageRemoveButtons(pending.adapterChatId, pending.menuMessageId, text, pending.adapterThreadId);
|
|
16853
|
+
return pending.menuMessageId;
|
|
16854
|
+
}
|
|
16855
|
+
catch { /* say it in a new message */ }
|
|
16856
|
+
}
|
|
16857
|
+
return (await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId })).messageId;
|
|
16858
|
+
}
|
|
16859
|
+
if (pending.retireButtons)
|
|
16860
|
+
await pending.retireButtons(text);
|
|
16861
|
+
else
|
|
16862
|
+
await pending.respond(text);
|
|
16863
|
+
return undefined;
|
|
16864
|
+
}
|
|
16865
|
+
/** Start `/backend` for a fleet instance from Discord or Telegram; the caller has checked the channel admin. */
|
|
16866
|
+
async beginBackendCommand(place, args) {
|
|
16867
|
+
const refusal = this.backendCommandRefusal(place.instanceName);
|
|
16868
|
+
if (refusal) {
|
|
16869
|
+
await place.respond(refusal);
|
|
16870
|
+
return;
|
|
16871
|
+
}
|
|
16872
|
+
if (!place.respondChoices && !(place.adapter && place.adapterChatId)) {
|
|
16873
|
+
await place.respond(t("backend.usage"));
|
|
16874
|
+
return;
|
|
16875
|
+
}
|
|
16876
|
+
place = { ...place, target: this.fleetConfig.instances[place.instanceName] };
|
|
16877
|
+
const deps = this.backendCommandDeps(place.instanceName, {
|
|
16878
|
+
live: true,
|
|
16879
|
+
onLiveProbe: () => { void place.respond(t("model.refreshing")).catch(() => { }); },
|
|
16880
|
+
});
|
|
16881
|
+
const step = await backendStep(deps, place.instanceName, args);
|
|
16882
|
+
if (step.kind === "refuse") {
|
|
16883
|
+
await place.respond(step.text);
|
|
16884
|
+
return;
|
|
16885
|
+
}
|
|
16886
|
+
await this.presentBackendStep(place, step);
|
|
16887
|
+
}
|
|
16888
|
+
/** `/backend` slash handler: channel admin, as `/model`. Options or typed text give the direct form. */
|
|
16889
|
+
async handleBackendSlash(data, adapterId) {
|
|
16890
|
+
if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
|
|
16891
|
+
await data.respond(t("permission.denied"));
|
|
16892
|
+
return;
|
|
16893
|
+
}
|
|
16894
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
16895
|
+
if (!name) {
|
|
16896
|
+
await data.respond(t("classic.no_agent"));
|
|
16897
|
+
return;
|
|
16898
|
+
}
|
|
16899
|
+
const option = (key) => typeof data.options?.[key] === "string" ? data.options[key].trim() : "";
|
|
16900
|
+
const given = [option("backend"), option("model"), option("effort")];
|
|
16901
|
+
// A later option without the one before it says nothing that can be judged in order.
|
|
16902
|
+
if (given.some((value, i) => value && given.slice(0, i).some(before => !before))) {
|
|
16903
|
+
await data.respond(t("backend.usage"));
|
|
16904
|
+
return;
|
|
16905
|
+
}
|
|
16906
|
+
const args = given[0] ? given.filter(Boolean) : backendArgs(data.text ?? "");
|
|
16907
|
+
await this.beginBackendCommand({ instanceName: name, userId: data.userId, channelId: data.channelId, adapterId, respond: data.respond, respondChoices: data.respondChoices,
|
|
16908
|
+
respondButtons: data.respondButtons, retireButtons: data.retireButtons }, args);
|
|
16909
|
+
}
|
|
16910
|
+
/** Telegram `/backend` in a fleet topic (topic-commands) or a ClassicBot chat; the caller has checked the admin. */
|
|
16911
|
+
async runBackendCommand(instanceName, text, userId, channelId, adapter, chatId, threadId, adapterId) {
|
|
16912
|
+
const respond = async (reply) => (await adapter.sendText(chatId, reply, { threadId })).messageId;
|
|
16913
|
+
await this.beginBackendCommand({ instanceName, userId, channelId, adapterId, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId }, backendArgs(text.replace(/^\/backend(@\S+)?/, "")));
|
|
16914
|
+
}
|
|
16915
|
+
/** Consume a `/backend` click: the next step, or on the confirmation the switch itself. True for every backend-select id. */
|
|
16916
|
+
async handleBackendSelection(data, adapterId) {
|
|
16917
|
+
if (!data.callbackData.startsWith(BACKEND_SELECT_CALLBACK_PREFIX))
|
|
16918
|
+
return false;
|
|
16919
|
+
const match = data.callbackData.match(/^backend-select:([0-9a-f]+):(\d{1,2})$/);
|
|
16920
|
+
if (!match)
|
|
16921
|
+
return true;
|
|
16922
|
+
const pending = this.pendingBackendSelects.get(match[1]);
|
|
16923
|
+
if (!pending)
|
|
16924
|
+
return true;
|
|
16925
|
+
// As a /model click (#754 audit): the admin who opened it, through the same bot, in the same channel, still admin.
|
|
16926
|
+
const cbChannel = data.threadId ?? data.chatId;
|
|
16927
|
+
if (!data.userId || data.userId !== pending.userId
|
|
16928
|
+
|| (pending.adapterId !== undefined && pending.adapterId !== adapterId)
|
|
16929
|
+
|| (cbChannel !== pending.channelId && data.chatId !== pending.channelId)
|
|
16930
|
+
|| !this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)) {
|
|
16931
|
+
data.ack?.(t("buttons.admin_only"));
|
|
16932
|
+
return true;
|
|
16933
|
+
}
|
|
16934
|
+
this.pendingBackendSelects.delete(match[1]);
|
|
16935
|
+
clearTimeout(pending.timer);
|
|
16936
|
+
if (!pending.menuMessageId && data.messageId)
|
|
16937
|
+
pending.menuMessageId = data.messageId;
|
|
16938
|
+
const index = Number(match[2]);
|
|
16939
|
+
const { step } = pending;
|
|
16940
|
+
if (step.kind === "choose") {
|
|
16941
|
+
const choice = step.choices[index];
|
|
16942
|
+
if (!choice)
|
|
16943
|
+
return true;
|
|
16944
|
+
if (!this.backendTargetIs(pending.instanceName, pending.target)) {
|
|
16945
|
+
await this.endBackendMenu(pending, t("menu.click_stale")).catch(() => { });
|
|
16946
|
+
return true;
|
|
16947
|
+
}
|
|
16948
|
+
const next = await backendStep(this.backendCommandDeps(pending.instanceName, { live: true }), pending.instanceName, [...step.args, choice.token]);
|
|
16949
|
+
if (next.kind === "refuse") {
|
|
16950
|
+
await this.endBackendMenu(pending, next.text).catch(() => { });
|
|
16951
|
+
return true;
|
|
16952
|
+
}
|
|
16953
|
+
await this.presentBackendStep(pending, next);
|
|
16954
|
+
return true;
|
|
16955
|
+
}
|
|
16956
|
+
if (index === 1) {
|
|
16957
|
+
await this.endBackendMenu(pending, t("backend.cancelled")).catch(() => { });
|
|
16958
|
+
return true;
|
|
16959
|
+
}
|
|
16960
|
+
if (index !== 0)
|
|
16961
|
+
return true;
|
|
16962
|
+
// Confirmed: say so at once, then switch in the background (the relaunch takes a while) and say how it went.
|
|
16963
|
+
const progressId = await this.endBackendMenu(pending, t("backend.switching", pending.instanceName, step.plan.backend)).catch(() => undefined);
|
|
16964
|
+
const say = async (text) => {
|
|
16965
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
16966
|
+
if (progressId)
|
|
16967
|
+
await pending.adapter.editMessage(pending.adapterChatId, progressId, text, pending.adapterThreadId);
|
|
16968
|
+
else
|
|
16969
|
+
await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
|
|
16970
|
+
}
|
|
16971
|
+
else
|
|
16972
|
+
await pending.respond(text);
|
|
16973
|
+
};
|
|
16974
|
+
void (async () => {
|
|
16975
|
+
let result;
|
|
16976
|
+
try {
|
|
16977
|
+
// Asked again after the awaits above: the instance may have moved, or the admin lost admin.
|
|
16978
|
+
const stillCurrent = () => this.backendTargetIs(pending.instanceName, pending.target)
|
|
16979
|
+
&& this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId);
|
|
16980
|
+
result = stillCurrent()
|
|
16981
|
+
? await withSpawnHoldReply(say, () => this.applyBackendSwitch(pending.instanceName, step.args, step.from, stillCurrent))
|
|
16982
|
+
: t("menu.click_stale");
|
|
16983
|
+
}
|
|
16984
|
+
catch (err) {
|
|
16985
|
+
this.logger.error({ err, instance: pending.instanceName, backend: step.plan.backend }, "Backend switch failed");
|
|
16986
|
+
result = t("backend.failed", pending.instanceName, step.plan.backend, err instanceof Error ? err.message : String(err));
|
|
16987
|
+
}
|
|
16988
|
+
await say(result).catch(() => {
|
|
16989
|
+
if (pending.adapter && pending.adapterChatId)
|
|
16990
|
+
pending.adapter.sendText(pending.adapterChatId, result, { threadId: pending.adapterThreadId }).catch(() => { });
|
|
16991
|
+
});
|
|
16992
|
+
})();
|
|
16993
|
+
return true;
|
|
16994
|
+
}
|
|
15352
16995
|
/** Read recent chat log for agent context */
|
|
15353
16996
|
getRecentChatLog(instanceName, maxLines = 10) {
|
|
15354
16997
|
const logDir = ClassicChannelManager.chatLogDir(instanceName);
|
|
@@ -15442,6 +17085,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15442
17085
|
}
|
|
15443
17086
|
/** Handle Discord's required static slash choice, warning before a likely startup failure. */
|
|
15444
17087
|
async handleClassicStartSlash(data, adapterId) {
|
|
17088
|
+
return withSpawnHoldReply(text => data.respond(text), () => this.handleClassicStartSlashCore(data, adapterId));
|
|
17089
|
+
}
|
|
17090
|
+
async handleClassicStartSlashCore(data, adapterId) {
|
|
15445
17091
|
const requestedBackend = typeof data.options?.backend === "string" ? data.options.backend : undefined;
|
|
15446
17092
|
if (!requestedBackend) {
|
|
15447
17093
|
// beta.31 made this option required. Discord can briefly retain the old
|
|
@@ -15558,7 +17204,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15558
17204
|
// failure. The selected backend is still attempted as requested.
|
|
15559
17205
|
if (warning)
|
|
15560
17206
|
await pending.complete(warning, pending.messageId);
|
|
15561
|
-
const reply = await this.handleClassicStart(pending.channelId, pending.channelName, pending.userId, pending.guildId, pending.adapterId, selectedBackend);
|
|
17207
|
+
const reply = await withSpawnHoldReply(text => pending.complete(text, pending.messageId), () => this.handleClassicStart(pending.channelId, pending.channelName, pending.userId, pending.guildId, pending.adapterId, selectedBackend));
|
|
15562
17208
|
await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
|
|
15563
17209
|
}
|
|
15564
17210
|
/** Start a classic channel instance with lightweight config */
|
|
@@ -15639,12 +17285,29 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15639
17285
|
const ch = this.classicChannels.unregister(channelId, adapterId);
|
|
15640
17286
|
if (!ch)
|
|
15641
17287
|
return t("classic.no_agent");
|
|
17288
|
+
// #1664: the room is gone (not paused or stopped): nobody's favorite any more — unless the name is still an
|
|
17289
|
+
// instance (fleet.yaml's, or another room's), so a later room of the same name does not inherit an old star.
|
|
17290
|
+
if (!Object.hasOwn(this.fleetConfig?.instances ?? {}, ch.instanceName)
|
|
17291
|
+
&& !this.classicChannels.getAll().some((c) => c.instanceName === ch.instanceName)) {
|
|
17292
|
+
try {
|
|
17293
|
+
this.webPrefs.forgetInstance(ch.instanceName);
|
|
17294
|
+
}
|
|
17295
|
+
catch (err) {
|
|
17296
|
+
this.logger.warn({ err: err.message, instanceName: ch.instanceName }, "Dropping a removed room from web favorites failed");
|
|
17297
|
+
}
|
|
17298
|
+
}
|
|
15642
17299
|
this.instanceWorldBinding.delete(ch.instanceName);
|
|
15643
|
-
|
|
17300
|
+
// #1716: say so in the reply when background work was ended with the stop.
|
|
17301
|
+
// Null-safe: a foreign stopInstance stub may resolve nothing at all.
|
|
17302
|
+
const stopOutcome = await this.stopInstance(ch.instanceName).catch(err => {
|
|
17303
|
+
this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance");
|
|
17304
|
+
return { backgroundWorkEnded: false };
|
|
17305
|
+
});
|
|
15644
17306
|
clearPausedMarker(this.getInstanceDir(ch.instanceName));
|
|
15645
17307
|
this.reregisterClassicChannels();
|
|
15646
17308
|
this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
|
|
15647
|
-
return t("classic.stopped")
|
|
17309
|
+
return t("classic.stopped")
|
|
17310
|
+
+ (stopOutcome?.backgroundWorkEnded ? `\n${t("classic.stopped_bg_work")}` : "");
|
|
15648
17311
|
}
|
|
15649
17312
|
/**
|
|
15650
17313
|
* Idempotent while in flight: SIGINT and SIGTERM share one handler and the
|
|
@@ -15740,6 +17403,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15740
17403
|
clearInterval(this.eventLogPruneTimer);
|
|
15741
17404
|
this.eventLogPruneTimer = null;
|
|
15742
17405
|
}
|
|
17406
|
+
this.logService?.unwatchAll(); // #1218: a Log's watch timers end with the fleet
|
|
17407
|
+
this.compactWatchService?.stopAll();
|
|
15743
17408
|
if (this.outboxPruneTimer) {
|
|
15744
17409
|
clearInterval(this.outboxPruneTimer);
|
|
15745
17410
|
this.outboxPruneTimer = null;
|
|
@@ -15839,8 +17504,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15839
17504
|
this.controlClient = null;
|
|
15840
17505
|
if (this.healthServer) {
|
|
15841
17506
|
this.healthServerListening = false;
|
|
15842
|
-
this.healthServer
|
|
15843
|
-
this.
|
|
17507
|
+
const server = this.healthServer;
|
|
17508
|
+
const drain = this.healthServerDrain ?? trackWebServer(server);
|
|
17509
|
+
await drain.close(this.sseClients);
|
|
17510
|
+
if (this.healthServer === server) {
|
|
17511
|
+
this.healthServer = null;
|
|
17512
|
+
this.healthServerDrain = null;
|
|
17513
|
+
}
|
|
15844
17514
|
}
|
|
15845
17515
|
this.stopPreviewListener();
|
|
15846
17516
|
// The store writes lastSeen at most once a minute; what that debounce is still holding — and any
|
|
@@ -16169,8 +17839,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16169
17839
|
if (old.backend !== backend || old.model !== model || old.autoPauseAfter !== autoPauseAfter || profile.changed) {
|
|
16170
17840
|
this.logger.info({ instanceName: ch.instanceName, ...(profile.changed ? { profileFrom: profile.from, profileTo: profile.to } : {}) }, "Classic cold config changed — restarting");
|
|
16171
17841
|
observe?.(ch.instanceName, "restart", "running");
|
|
17842
|
+
const launchedIn = this.daemons.get(ch.instanceName)?.getConfigSnapshot?.()?.working_directory;
|
|
16172
17843
|
await this.stopInstance(ch.instanceName).catch(() => { });
|
|
16173
|
-
|
|
17844
|
+
// #1753: the conversation goes along where it can; otherwise a switch that loses it starts fresh.
|
|
17845
|
+
if (profile.carry)
|
|
17846
|
+
await this.switchConversation(ch.instanceName, { ...profile.carry, workingDirectory: launchedIn });
|
|
17847
|
+
else if (profile.startsFresh)
|
|
16174
17848
|
this.writeFreshStartMarker(ch.instanceName);
|
|
16175
17849
|
await this.startClassicInstanceUnattended(ch, "classic instance after fleet reload");
|
|
16176
17850
|
observe?.(ch.instanceName, "restart", "done");
|
|
@@ -17714,37 +19388,41 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17714
19388
|
const currentVersion = JSON.parse(readFileSync(pkgPath, "utf-8")).version ?? "0.0.0";
|
|
17715
19389
|
const latest = await npmVersion("@songsid/agend");
|
|
17716
19390
|
let target = latest;
|
|
19391
|
+
let fromChannel = false;
|
|
17717
19392
|
// The same channel rule as `agend update` (#1259): an alpha follows @alpha, any other prerelease @beta.
|
|
17718
19393
|
const channel = installedChannel(currentVersion);
|
|
17719
19394
|
if (channel !== "latest") {
|
|
17720
|
-
// Prerelease users track their own channel (never fall back to @latest,
|
|
17721
|
-
// which is older, and an alpha is never offered @beta), but should also
|
|
17722
|
-
// hear when a newer STABLE ships — pick whichever is the newest.
|
|
17723
19395
|
let pre = "";
|
|
17724
19396
|
try {
|
|
17725
19397
|
pre = await npmVersion(`@songsid/agend@${channel}`);
|
|
17726
19398
|
}
|
|
17727
19399
|
catch { /* no such tag */ }
|
|
17728
|
-
|
|
17729
|
-
|
|
19400
|
+
// #1926: a beta install hears about @beta only: beta users stay on the beta line after a stable ships, so
|
|
19401
|
+
// @latest is not their target. Taking the higher of the two made a same-core stable (2.2.0 over beta.16) the
|
|
19402
|
+
// target, which the same-core guard then dropped, so the newer beta.17 was never announced.
|
|
19403
|
+
target = pre;
|
|
19404
|
+
fromChannel = true;
|
|
19405
|
+
// An alpha is still told about a stable that passed its own minor (with --stable, #1259): only a stable of
|
|
19406
|
+
// another core that is newer than its channel's release. A same-core @latest (2.2.0 for 2.2.0-alpha.3) is not
|
|
19407
|
+
// taken from @latest and no longer hides a newer alpha; once the release points @alpha at it, it comes from
|
|
19408
|
+
// @alpha like any channel release.
|
|
19409
|
+
const core = (v) => v.replace(/^v/, "").split("-")[0];
|
|
19410
|
+
if (channel === "alpha" && latest && core(latest) !== core(currentVersion) && (!pre || this.semverGt(latest, pre))) {
|
|
17730
19411
|
target = latest;
|
|
19412
|
+
fromChannel = false;
|
|
19413
|
+
}
|
|
17731
19414
|
}
|
|
17732
|
-
// A beta already at/ahead of its matching stable must NOT be told to
|
|
17733
|
-
// "update" to that stable — e.g. 2.0.11-beta.41 already contains everything
|
|
17734
|
-
// in stable 2.0.11, so semverGt(2.0.11, 2.0.11-beta.41) being true (a stable
|
|
17735
|
-
// outranks a prerelease of the same core) is a false positive here. Suppress
|
|
17736
|
-
// only that same-core stable-vs-my-beta case; a higher stable core (2.0.12)
|
|
17737
|
-
// or a newer beta (2.0.11-beta.50) still notifies via semverGt below.
|
|
17738
|
-
const core = (v) => v.replace(/^v/, "").split("-")[0];
|
|
17739
|
-
const betaSupersedesStable = isPrereleaseVersion(currentVersion) && !isPrereleaseVersion(target) && core(target) === core(currentVersion);
|
|
17740
19415
|
// Only notify when target is genuinely newer (semver), so a beta user on
|
|
17741
19416
|
// 2.0.8-beta.16 is never told that stable 2.0.7 is "available".
|
|
17742
|
-
if (target &&
|
|
19417
|
+
if (target && this.semverGt(target, currentVersion)) {
|
|
17743
19418
|
const generalId = this.findGeneralInstance();
|
|
17744
19419
|
if (generalId) {
|
|
17745
19420
|
// No release URL — Discord's SuppressEmbeds proved unreliable and the
|
|
17746
19421
|
// link preview looked bad. Version + /update instruction is enough.
|
|
17747
|
-
|
|
19422
|
+
// From the install's own channel tag, this is what /update installs — even a stable the release pointed the
|
|
19423
|
+
// tag at (2.2.0 on @alpha / @beta): the plain /update text, never "/update stays on … use --stable" (#1928).
|
|
19424
|
+
const key = fromChannel ? "update.available_current" : updateNoticeKey(currentVersion, target);
|
|
19425
|
+
this.notifyInstanceTopic(generalId, t(key, `v${target}`, `v${currentVersion}`));
|
|
17748
19426
|
}
|
|
17749
19427
|
}
|
|
17750
19428
|
}
|
|
@@ -17809,8 +19487,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17809
19487
|
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)");
|
|
17810
19488
|
}
|
|
17811
19489
|
/** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
|
|
17812
|
-
dispatchWebHttp(req, res, port) {
|
|
19490
|
+
dispatchWebHttp(req, res, port, closing = false) {
|
|
17813
19491
|
try {
|
|
19492
|
+
// #1937: the listeners allow an upload long enough for a slow link; every other request keeps its old bound on
|
|
19493
|
+
// arriving whole — 10 s through the public link's gateway, Node's 300 s here.
|
|
19494
|
+
if (!isUploadRequest(req))
|
|
19495
|
+
limitRequestReceive(req, gatewayRequestContext(req) ? PUBLIC_GATEWAY_RECEIVE_MS : 300_000);
|
|
17814
19496
|
res.setHeader("Content-Type", "application/json");
|
|
17815
19497
|
// No Referer to a tunnel host, an upstream proxy, or any page linked from
|
|
17816
19498
|
// the panel — the dashboard URL is itself a credential-bearing address.
|
|
@@ -17828,6 +19510,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17828
19510
|
res.end(JSON.stringify({ error: WEB_HOST_REJECTED_MESSAGE }));
|
|
17829
19511
|
return;
|
|
17830
19512
|
}
|
|
19513
|
+
if (closing) {
|
|
19514
|
+
res.writeHead(503, { Connection: "close", "Retry-After": "10" });
|
|
19515
|
+
res.end(JSON.stringify({ error: "fleet stopping" }));
|
|
19516
|
+
return;
|
|
19517
|
+
}
|
|
17831
19518
|
const requestPath = new URL(req.url ?? "/", `http://localhost:${port}`).pathname;
|
|
17832
19519
|
// Branding is public, after Host admission: automatic favicon requests
|
|
17833
19520
|
// must not become noisy web-token 401s in the browser console.
|
|
@@ -18013,14 +19700,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18013
19700
|
return;
|
|
18014
19701
|
}
|
|
18015
19702
|
(async () => {
|
|
19703
|
+
let held = false;
|
|
18016
19704
|
try {
|
|
18017
19705
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
18018
|
-
await
|
|
19706
|
+
await withSpawnHoldReply(async (message) => {
|
|
19707
|
+
held = true;
|
|
19708
|
+
res.writeHead(202, { "Content-Type": "application/json" });
|
|
19709
|
+
res.end(JSON.stringify({ held: true, message, instance: name }));
|
|
19710
|
+
}, () => this.startInstance(name, config, topicMode ?? false, "fleet-topic", true));
|
|
18019
19711
|
this.emitSseEvent("status", this.getUiStatus());
|
|
19712
|
+
if (held)
|
|
19713
|
+
return;
|
|
18020
19714
|
res.writeHead(200);
|
|
18021
19715
|
res.end(JSON.stringify({ ok: true }));
|
|
18022
19716
|
}
|
|
18023
19717
|
catch (err) {
|
|
19718
|
+
if (held) {
|
|
19719
|
+
this.logger.error({ err, name }, "Held start failed");
|
|
19720
|
+
return;
|
|
19721
|
+
}
|
|
18024
19722
|
res.writeHead(500);
|
|
18025
19723
|
res.end(JSON.stringify({ error: `Start failed: ${err.message}` }));
|
|
18026
19724
|
}
|
|
@@ -18035,15 +19733,24 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18035
19733
|
const name = decodeURIComponent(req.url.slice("/restart/".length));
|
|
18036
19734
|
this.logger.info({ name }, "Instance restart requested via HTTP");
|
|
18037
19735
|
(async () => {
|
|
19736
|
+
let held = false;
|
|
18038
19737
|
try {
|
|
18039
|
-
await
|
|
19738
|
+
await withSpawnHoldReply(async (message) => {
|
|
19739
|
+
held = true;
|
|
19740
|
+
res.writeHead(202, { "Content-Type": "application/json" });
|
|
19741
|
+
res.end(JSON.stringify({ held: true, message, instance: name }));
|
|
19742
|
+
}, () => this.restartSingleInstance(name, { explicit: true }));
|
|
18040
19743
|
this.logger.info({ name }, "Instance restarted");
|
|
18041
19744
|
this.emitSseEvent("status", this.getUiStatus());
|
|
19745
|
+
if (held)
|
|
19746
|
+
return;
|
|
18042
19747
|
res.writeHead(200);
|
|
18043
19748
|
res.end(JSON.stringify({ restarted: name }));
|
|
18044
19749
|
}
|
|
18045
19750
|
catch (err) {
|
|
18046
19751
|
this.logger.error({ err, name }, "Instance restart failed");
|
|
19752
|
+
if (held)
|
|
19753
|
+
return;
|
|
18047
19754
|
const status = err.message.includes("not found") ? 404 : 500;
|
|
18048
19755
|
res.writeHead(status);
|
|
18049
19756
|
res.end(JSON.stringify({ error: `Restart failed: ${err.message}` }));
|
|
@@ -18126,7 +19833,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18126
19833
|
// Defensive for direct/unit callers; normal startup initializes these before adapters.
|
|
18127
19834
|
if (!this.webToken || !this.webSessions)
|
|
18128
19835
|
this.initializeWebAuthTokens();
|
|
18129
|
-
|
|
19836
|
+
const server = createServer((req, res) => this.dispatchWebHttp(req, res, port, drain.closing));
|
|
19837
|
+
// #1937: Node's default (300 s to receive a request) would cut a large upload on a slow link. The listener allows
|
|
19838
|
+
// more; the upload route keeps its own clocks (no byte for a minute, or 30 minutes in all), and every other request
|
|
19839
|
+
// keeps the old 300 s (limitRequestReceive in dispatchWebHttp).
|
|
19840
|
+
server.requestTimeout = UPLOAD_REQUEST_TIMEOUT_MS;
|
|
19841
|
+
const drain = trackWebServer(server);
|
|
19842
|
+
this.healthServer = server;
|
|
19843
|
+
this.healthServerDrain = drain;
|
|
18130
19844
|
const markListening = (afterTakeover = false) => {
|
|
18131
19845
|
this.healthServerListening = true;
|
|
18132
19846
|
this.logger.info({ port }, afterTakeover
|
|
@@ -18212,7 +19926,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18212
19926
|
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");
|
|
18213
19927
|
return;
|
|
18214
19928
|
}
|
|
18215
|
-
const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig
|
|
19929
|
+
const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig,
|
|
19930
|
+
// #1723: with a web.preview_origin, the open public link's dashboard frames this listener's preview too.
|
|
19931
|
+
extraOrigins: () => { const o = settings.origin ? this.publicWebLink?.openDashboardOrigin() : null; return o ? [o] : []; } });
|
|
18216
19932
|
this.previewListener = listener;
|
|
18217
19933
|
listener.server.on("error", (err) => {
|
|
18218
19934
|
if (this.previewListener !== listener)
|
|
@@ -18261,6 +19977,37 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18261
19977
|
decided.reason = "Previews are not available on this fleet right now.";
|
|
18262
19978
|
return { ...decided, boot: listener ? listener.bootId : null };
|
|
18263
19979
|
}
|
|
19980
|
+
/**
|
|
19981
|
+
* #1723: for one page of the public link (exposure `exposureId`, at `dashboardOrigin`): its own preview tunnel's
|
|
19982
|
+
* origin once up, "starting" until then, why it failed if it did — or a configured web.preview_origin, used instead.
|
|
19983
|
+
*/
|
|
19984
|
+
publicPreviewForUi(exposureId, dashboardOrigin) {
|
|
19985
|
+
const web = this.fleetConfig?.web;
|
|
19986
|
+
const off = (code, reason, extra = {}) => ({ dashboardOrigin, previewOrigin: null, reason, code, boot: null, frameSrc: null, ...extra });
|
|
19987
|
+
if (web?.preview === false)
|
|
19988
|
+
return off("fleetOff", "Previews are turned off for this fleet (web.preview: false).");
|
|
19989
|
+
if (web?.preview_on_public_link === false)
|
|
19990
|
+
return off("publicLink", "Previews are off on the public link (web.preview_on_public_link: false).");
|
|
19991
|
+
const configured = typeof web?.preview_origin === "string" ? normalizeOrigin(web.preview_origin) : null;
|
|
19992
|
+
if (configured) {
|
|
19993
|
+
const listener = this.previewListening ? this.previewListener : null;
|
|
19994
|
+
if (!listener)
|
|
19995
|
+
return off("publicLink", "Previews are not available on this fleet right now.");
|
|
19996
|
+
if (!configured.startsWith("https:"))
|
|
19997
|
+
return off("needHttps", "Previews need an https web.preview_origin when the dashboard is reached over https.");
|
|
19998
|
+
return { dashboardOrigin, previewOrigin: configured, reason: null, code: null, boot: listener.bootId, frameSrc: `${configured}/frame` };
|
|
19999
|
+
}
|
|
20000
|
+
const status = this.publicWebLink?.previewStatus(exposureId) ?? null;
|
|
20001
|
+
if (!status)
|
|
20002
|
+
return off("publicLink", "Previews are not offered on this public link.");
|
|
20003
|
+
// The page may load before the preview tunnel has a host: it frames the quick-tunnel wildcard, and only ever the
|
|
20004
|
+
// origin /ui/preview-status names (the preview side's exact frame-ancestors is the boundary).
|
|
20005
|
+
if (status.state === "starting")
|
|
20006
|
+
return off("publicLinkStarting", "Preview is starting…", { frameSrc: PUBLIC_PREVIEW_FRAME_SRC });
|
|
20007
|
+
if (status.state === "failed")
|
|
20008
|
+
return off("publicLinkPreviewFailed", `Previews are unavailable on this public link (${status.reason}).`, { detail: status.reason });
|
|
20009
|
+
return { dashboardOrigin, previewOrigin: status.origin, reason: null, code: null, boot: status.boot, frameSrc: PUBLIC_PREVIEW_FRAME_SRC };
|
|
20010
|
+
}
|
|
18264
20011
|
getUiStatus() {
|
|
18265
20012
|
return measureSyncWork("fleet.uiStatus", () => this.getUiStatusSync());
|
|
18266
20013
|
}
|
|
@@ -18278,12 +20025,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18278
20025
|
const classicRooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
|
|
18279
20026
|
const profiles = profileIdentities(this.dataDir);
|
|
18280
20027
|
// #1630: decorative — whatever goes wrong deciding an avatar never costs the frame (every instance gets its first character).
|
|
18281
|
-
let
|
|
20028
|
+
let bots = { own: new Map(), speaking: new Map() };
|
|
18282
20029
|
try {
|
|
18283
|
-
|
|
20030
|
+
bots = this.webBots();
|
|
18284
20031
|
}
|
|
18285
20032
|
catch (err) {
|
|
18286
|
-
this.logger.debug({ err }, "
|
|
20033
|
+
this.logger.debug({ err }, "bots unreadable (getUiStatus)");
|
|
18287
20034
|
}
|
|
18288
20035
|
const instances = names.map(name => {
|
|
18289
20036
|
const statusFile = join(this.getInstanceDir(name), "statusline.json");
|
|
@@ -18322,9 +20069,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18322
20069
|
const effort = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.effort;
|
|
18323
20070
|
const effort_source = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.source;
|
|
18324
20071
|
const identity = resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic: classicRooms.get(name), profile: profiles.get(name) });
|
|
20072
|
+
const compact = this.compactWatchService?.view(name) ?? null;
|
|
18325
20073
|
let image;
|
|
18326
20074
|
try {
|
|
18327
|
-
image = this.webIdentityImage(name, identity.display_name, profiles.get(name)?.avatar_path,
|
|
20075
|
+
image = this.webIdentityImage(name, identity.display_name, profiles.get(name)?.avatar_path, bots);
|
|
18328
20076
|
}
|
|
18329
20077
|
catch (err) {
|
|
18330
20078
|
this.logger.debug({ err, name }, "avatar unreadable (getUiStatus)");
|
|
@@ -18349,6 +20097,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18349
20097
|
effort,
|
|
18350
20098
|
effort_source,
|
|
18351
20099
|
...this.instancePresentation(name),
|
|
20100
|
+
// Board 003648ac: the last web /compact, while it runs and briefly after (the chat's card follows it).
|
|
20101
|
+
...(compact ? { compact } : {}),
|
|
18352
20102
|
};
|
|
18353
20103
|
});
|
|
18354
20104
|
return {
|
|
@@ -18357,6 +20107,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18357
20107
|
uptime: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
18358
20108
|
// #1519 P7: how many chat connections are configured — 0 is a web-only fleet, which gets the first-run card.
|
|
18359
20109
|
connections: (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).length,
|
|
20110
|
+
// #1665: the host preferences' revision — a page that has another re-reads /ui/prefs (no values in this frame).
|
|
20111
|
+
prefs_rev: this.webPrefs.rev(),
|
|
20112
|
+
// #1685: host memory pressure while it slows or holds new agents (the dashboard's indicator; macOS elevated is
|
|
20113
|
+
// shown here rather than in chat). null when normal, unknown or advisory.
|
|
20114
|
+
host_memory: this.hostMemoryIndicator(),
|
|
18360
20115
|
};
|
|
18361
20116
|
}
|
|
18362
20117
|
}
|