@songsid/agend 2.2.0-alpha.9 → 2.2.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-TW.md +1 -1
- package/dist/agent-cli-instructions.md +16 -9
- package/dist/agent-cli.js +15 -7
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.js +21 -9
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-whats-new.d.ts +47 -0
- package/dist/agent-whats-new.js +147 -0
- package/dist/agent-whats-new.js.map +1 -0
- package/dist/auth-api.d.ts +2 -2
- package/dist/auth-api.js +4 -2
- package/dist/auth-api.js.map +1 -1
- package/dist/backend/antigravity.d.ts +3 -0
- package/dist/backend/antigravity.js +9 -0
- 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 +1 -1
- package/dist/backend/effort-metadata.js +3 -2
- package/dist/backend/effort-metadata.js.map +1 -1
- package/dist/backend/grok.d.ts +4 -0
- package/dist/backend/grok.js +14 -0
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro-engine-ledger.d.ts +5 -0
- package/dist/backend/kiro-engine-ledger.js +20 -0
- package/dist/backend/kiro-engine-ledger.js.map +1 -1
- package/dist/backend/kiro-identity.d.ts +6 -0
- package/dist/backend/kiro-identity.js +19 -5
- package/dist/backend/kiro-identity.js.map +1 -1
- package/dist/backend/kiro.d.ts +39 -3
- package/dist/backend/kiro.js +143 -19
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/launch-effort.d.ts +33 -0
- package/dist/backend/launch-effort.js +123 -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 +33 -4
- package/dist/backend/types.js +2 -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 +98 -3
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +26 -2
- package/dist/channel/adapters/telegram.js +123 -19
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-server.js +9 -1
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.d.ts +11 -0
- package/dist/channel/mcp-tools.js +24 -13
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/message-edit-errors.d.ts +5 -0
- package/dist/channel/message-edit-errors.js +24 -0
- package/dist/channel/message-edit-errors.js.map +1 -0
- package/dist/channel/telegram-markdown.d.ts +6 -0
- package/dist/channel/telegram-markdown.js +104 -0
- package/dist/channel/telegram-markdown.js.map +1 -0
- package/dist/channel/tool-router.js +9 -3
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +41 -3
- package/dist/cli.js +133 -28
- package/dist/cli.js.map +1 -1
- package/dist/command-table.d.ts +2 -2
- package/dist/command-table.js +6 -3
- package/dist/command-table.js.map +1 -1
- package/dist/config-validator.js +59 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/cross-instance-envelope.d.ts +15 -2
- package/dist/cross-instance-envelope.js +29 -5
- package/dist/cross-instance-envelope.js.map +1 -1
- package/dist/daemon.d.ts +156 -1
- package/dist/daemon.js +764 -89
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +62 -7
- package/dist/delivery-outbox.js +309 -22
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/delivery-reconciliation.d.ts +18 -1
- package/dist/delivery-reconciliation.js +67 -2
- package/dist/delivery-reconciliation.js.map +1 -1
- package/dist/fleet-context.d.ts +10 -1
- package/dist/fleet-manager.d.ts +352 -10
- package/dist/fleet-manager.js +1925 -175
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/backend-providers/SKILL.md +1 -1
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +92 -21
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +27 -3
- package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +8 -8
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +5 -5
- package/dist/general-knowledge/skills/fleet-health/SKILL.md +4 -4
- package/dist/general-knowledge/skills/fleet-restart/SKILL.md +13 -13
- package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +3 -2
- package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +6 -0
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +9 -9
- package/dist/general-knowledge/skills/multi-channel/SKILL.md +8 -2
- package/dist/general-knowledge/skills/scheduling/SKILL.md +5 -3
- package/dist/general-knowledge/skills/session-management/SKILL.md +6 -5
- package/dist/general-knowledge/skills/tui-effort/SKILL.md +2 -2
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +8 -3
- package/dist/general-knowledge/steering/core-rules.md +8 -7
- package/dist/host-memory.d.ts +10 -0
- package/dist/host-memory.js +33 -1
- package/dist/host-memory.js.map +1 -1
- package/dist/human-delivery-receipts.d.ts +83 -0
- package/dist/human-delivery-receipts.js +194 -0
- package/dist/human-delivery-receipts.js.map +1 -0
- package/dist/instance-avatar.d.ts +36 -0
- package/dist/instance-avatar.js +57 -0
- package/dist/instance-avatar.js.map +1 -0
- package/dist/instance-config-impact.js +6 -0
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +29 -6
- package/dist/instance-lifecycle.js +79 -28
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.d.ts +3 -1
- package/dist/instructions.js +11 -8
- package/dist/instructions.js.map +1 -1
- package/dist/kiro-log-gate.d.ts +42 -0
- package/dist/kiro-log-gate.js +39 -0
- package/dist/kiro-log-gate.js.map +1 -0
- package/dist/locale.js +204 -44
- package/dist/locale.js.map +1 -1
- package/dist/log-service.d.ts +99 -0
- package/dist/log-service.js +243 -0
- package/dist/log-service.js.map +1 -0
- package/dist/log-steps.d.ts +169 -0
- package/dist/log-steps.js +858 -0
- package/dist/log-steps.js.map +1 -0
- package/dist/login-controller.d.ts +20 -3
- package/dist/login-controller.js +71 -19
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.d.ts +5 -2
- package/dist/login-flows.js +5 -4
- package/dist/login-flows.js.map +1 -1
- package/dist/memory-pressure.d.ts +13 -1
- package/dist/memory-pressure.js +50 -10
- package/dist/memory-pressure.js.map +1 -1
- package/dist/needs-you-hub.js +2 -0
- package/dist/needs-you-hub.js.map +1 -1
- package/dist/needs-you-live.d.ts +6 -0
- package/dist/needs-you-live.js +23 -3
- package/dist/needs-you-live.js.map +1 -1
- package/dist/next-steps.js +1 -1
- package/dist/next-steps.js.map +1 -1
- package/dist/outbound-handlers.d.ts +100 -0
- package/dist/outbound-handlers.js +263 -96
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +26 -15
- package/dist/outbound-schemas.js +24 -7
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-link-progress.d.ts +1 -1
- package/dist/public-link-progress.js +8 -2
- package/dist/public-link-progress.js.map +1 -1
- package/dist/public-preview.d.ts +101 -0
- package/dist/public-preview.js +236 -0
- package/dist/public-preview.js.map +1 -0
- package/dist/public-web-gateway.js +11 -4
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/public-web-link.d.ts +24 -1
- package/dist/public-web-link.js +63 -9
- package/dist/public-web-link.js.map +1 -1
- package/dist/reply-buttons-controller.d.ts +3 -0
- package/dist/reply-buttons-controller.js +3 -2
- package/dist/reply-buttons-controller.js.map +1 -1
- package/dist/restart-guard.d.ts +6 -3
- package/dist/restart-guard.js +6 -3
- package/dist/restart-guard.js.map +1 -1
- package/dist/restart-progress.d.ts +33 -0
- package/dist/restart-progress.js +30 -0
- package/dist/restart-progress.js.map +1 -1
- package/dist/service-activation.d.ts +15 -7
- package/dist/service-activation.js +46 -29
- package/dist/service-activation.js.map +1 -1
- package/dist/service-installer.d.ts +31 -0
- package/dist/service-installer.js +41 -2
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.js +85 -25
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +72 -1
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +28 -4
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.js +7 -0
- package/dist/settings-http-confirmation.js.map +1 -1
- package/dist/setup-form.d.ts +1 -1
- package/dist/setup-form.js +1 -1
- package/dist/spawn-gate.js +5 -1
- package/dist/spawn-gate.js.map +1 -1
- package/dist/spawn-hold-reply.d.ts +12 -0
- package/dist/spawn-hold-reply.js +39 -0
- package/dist/spawn-hold-reply.js.map +1 -0
- package/dist/status-emojis.d.ts +10 -3
- package/dist/status-emojis.js +11 -2
- package/dist/status-emojis.js.map +1 -1
- package/dist/systemd-peer-bus.d.ts +95 -0
- package/dist/systemd-peer-bus.js +430 -0
- package/dist/systemd-peer-bus.js.map +1 -0
- package/dist/systemd-runtime.d.ts +27 -1
- package/dist/systemd-runtime.js +69 -7
- package/dist/systemd-runtime.js.map +1 -1
- package/dist/tips.js +4 -4
- package/dist/tips.js.map +1 -1
- package/dist/tool-progress.js +3 -2
- package/dist/tool-progress.js.map +1 -1
- package/dist/topic-commands.d.ts +42 -2
- package/dist/topic-commands.js +92 -7
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-delta-reader.d.ts +3 -3
- package/dist/transcript-delta-reader.js +8 -8
- package/dist/transcript-delta-reader.js.map +1 -1
- package/dist/transcript-jsonl.d.ts +80 -3
- package/dist/transcript-jsonl.js +147 -10
- package/dist/transcript-jsonl.js.map +1 -1
- package/dist/transcript-monitor.d.ts +19 -0
- package/dist/transcript-monitor.js +87 -15
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +78 -0
- package/dist/transcript-sources.js +375 -64
- package/dist/transcript-sources.js.map +1 -1
- package/dist/transcript-turns.d.ts +15 -2
- package/dist/transcript-turns.js +15 -2
- package/dist/transcript-turns.js.map +1 -1
- package/dist/tunnel/cloudflared.d.ts +7 -15
- package/dist/tunnel/cloudflared.js +36 -1
- package/dist/tunnel/cloudflared.js.map +1 -1
- package/dist/tunnel/purpose-lane.d.ts +5 -1
- package/dist/tunnel/purpose-lane.js +14 -7
- package/dist/tunnel/purpose-lane.js.map +1 -1
- package/dist/tunnel/types.d.ts +5 -1
- package/dist/tunnel/types.js.map +1 -1
- package/dist/turn-resume.d.ts +2 -0
- package/dist/turn-resume.js +1 -1
- package/dist/turn-resume.js.map +1 -1
- package/dist/types.d.ts +25 -0
- package/dist/ui/chat-commands.js +1 -0
- package/dist/ui/chat-emojis.js +33 -0
- package/dist/ui/chat-mentions.js +63 -0
- package/dist/ui/chat-render.js +42 -2
- package/dist/ui/chat-store.js +2 -2
- package/dist/ui/chat-strings.js +38 -20
- package/dist/ui/chat-thread.js +69 -12
- package/dist/ui/chat-time.js +80 -0
- package/dist/ui/panel-chat.js +39 -10
- package/dist/ui/panel-details.js +2 -0
- package/dist/ui/panel-fleet.js +199 -57
- package/dist/ui/panel-log.js +428 -0
- package/dist/ui/panel-needs.js +6 -2
- package/dist/ui/panel-settings.js +137 -32
- package/dist/ui/preview-panel.js +55 -15
- package/dist/ui/preview-status.js +28 -0
- package/dist/ui/preview.js +44 -3
- package/dist/ui/settings-dialogs.js +7 -2
- package/dist/ui/settings-model.js +2 -1
- package/dist/ui/settings-strings.js +46 -4
- package/dist/ui/settings-token.js +1 -1
- package/dist/ui/shared/app-i18n.js +43 -17
- package/dist/ui/shared/app-prefs.js +250 -0
- package/dist/ui/shared/app-route.js +7 -2
- package/dist/ui/shared/app-shell.js +165 -8
- package/dist/ui/shared/app-store.js +5 -1
- package/dist/ui/shared/app-stream.js +23 -5
- package/dist/ui/shared/app.css +159 -23
- package/dist/ui/shared/app.js +11 -1
- package/dist/ui/shared/fonts/README.md +1 -1
- package/dist/ui/shared/header-tools.js +55 -5
- package/dist/ui/shared/instance-nav.js +241 -45
- package/dist/ui/shared/instance-switch.js +4 -4
- package/dist/ui/shared/panel-view.js +6 -0
- package/dist/ui/shared/signin.js +7 -7
- package/dist/ui/shared/theme.js +39 -7
- package/dist/ui/shared/tokens.css +10 -1
- package/dist/ui/shared/ui-icons.js +1 -0
- package/dist/ui/shared/usage-dialog.js +23 -5
- package/dist/ui/shared/view-strings.js +32 -4
- package/dist/ui/signin.html +1 -1
- package/dist/unrecognised-prompt.d.ts +23 -0
- package/dist/unrecognised-prompt.js +74 -0
- package/dist/unrecognised-prompt.js.map +1 -0
- package/dist/update-install.d.ts +28 -2
- package/dist/update-install.js +22 -4
- package/dist/update-install.js.map +1 -1
- package/dist/update-marker.d.ts +51 -0
- package/dist/update-marker.js +61 -0
- package/dist/update-marker.js.map +1 -1
- package/dist/update-preflight.d.ts +100 -0
- package/dist/update-preflight.js +264 -0
- package/dist/update-preflight.js.map +1 -0
- package/dist/updater-caller.d.ts +31 -0
- package/dist/updater-caller.js +109 -0
- package/dist/updater-caller.js.map +1 -0
- package/dist/usage/i18n-keys.d.ts +1 -1
- package/dist/usage/i18n-keys.js +1 -1
- package/dist/usage/i18n-keys.js.map +1 -1
- package/dist/usage/providers.d.ts +34 -5
- package/dist/usage/providers.js +160 -27
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/statusline-usage.d.ts +6 -2
- package/dist/usage/statusline-usage.js +10 -4
- package/dist/usage/statusline-usage.js.map +1 -1
- package/dist/view-api.d.ts +1 -1
- package/dist/view-api.js +38 -8
- package/dist/view-api.js.map +1 -1
- package/dist/wake-coordinator.d.ts +11 -0
- package/dist/wake-coordinator.js +38 -4
- package/dist/wake-coordinator.js.map +1 -1
- package/dist/web-api.d.ts +49 -2
- package/dist/web-api.js +421 -31
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.d.ts +2 -2
- package/dist/web-auth.js +9 -4
- package/dist/web-auth.js.map +1 -1
- package/dist/web-commands.d.ts +27 -4
- package/dist/web-commands.js +19 -4
- package/dist/web-commands.js.map +1 -1
- package/dist/web-host-guard.d.ts +3 -11
- package/dist/web-host-guard.js +3 -12
- package/dist/web-host-guard.js.map +1 -1
- package/dist/web-images.d.ts +47 -0
- package/dist/web-images.js +169 -0
- package/dist/web-images.js.map +1 -0
- package/dist/web-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 +63 -0
- package/dist/web-prefs.js +265 -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-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 +36 -1
- package/dist/web-session.js +53 -6
- package/dist/web-session.js.map +1 -1
- package/dist/web-settings-patch.d.ts +23 -0
- package/dist/web-settings-patch.js +77 -0
- package/dist/web-settings-patch.js.map +1 -0
- package/dist/web-shell-routes.d.ts +5 -1
- package/dist/web-shell-routes.js +10 -1
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/web-upload.d.ts +17 -4
- package/dist/web-upload.js +64 -13
- package/dist/web-upload.js.map +1 -1
- package/dist/workflow-templates/default.md +1 -1
- package/package.json +1 -1
- package/templates/launchd.plist.ejs +3 -1
- package/templates/systemd.service.ejs +2 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
+
import { humanOwnerObjectId, webHumanOrigin } from "./human-delivery-receipts.js";
|
|
1
2
|
import { SettingsBaselines } from "./settings-baseline.js";
|
|
2
3
|
import { SettingsConfirmationStore } from "./settings-confirmation.js";
|
|
3
4
|
import { SettingsHttpConfirmation } from "./settings-http-confirmation.js";
|
|
4
5
|
import { SettingsControlServer } from "./settings-control.js";
|
|
5
6
|
import { SettingsExecution, settingsRevision, noteSettingsWrite, settingsFileResource, assertSettingsLease, trySettingsLease, waitSettingsLease, settingsUndo, undoSettingsPaths } from "./settings-transaction.js";
|
|
6
7
|
import { performance } from "node:perf_hooks";
|
|
8
|
+
import { withSpawnHoldReply } from "./spawn-hold-reply.js";
|
|
7
9
|
import { gatewayRequestContext } from "./web-request-context.js";
|
|
8
10
|
import { createPublicWebGateway } from "./public-web-gateway.js";
|
|
9
11
|
import { handleWebIconRequest } from "./web-icons.js";
|
|
12
|
+
import { trackWebServer } from "./web-server-drain.js";
|
|
10
13
|
import { renderPublicLinkProgress, ThrottledMessageEditor } from "./public-link-progress.js";
|
|
11
14
|
import { PublicWebLink, publicLinkSettings } from "./public-web-link.js";
|
|
15
|
+
import { PUBLIC_PREVIEW_FRAME_SRC, PUBLIC_PREVIEW_TUNNEL_DIR, PublicPreview, publicPreviewWanted } from "./public-preview.js";
|
|
16
|
+
import { CloudflaredProvider } from "./tunnel/cloudflared.js";
|
|
12
17
|
import { TunnelPurposeLane } from "./tunnel/purpose-lane.js";
|
|
13
18
|
import { ManagedTunnel } from "./tunnel/manager.js";
|
|
14
19
|
import { withinBudget } from "./monotonic-budget.js";
|
|
@@ -27,20 +32,21 @@ import { join, dirname, basename, delimiter, isAbsolute, resolve as resolvePath
|
|
|
27
32
|
import { fileURLToPath } from "node:url";
|
|
28
33
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
29
34
|
import { fleetLabel } from "./fleet-label.js";
|
|
30
|
-
import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, updateProgressOperation, } from "./update-marker.js";
|
|
35
|
+
import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, sameUpdateMarker, setUpdateProgressStage, updateMarkerIdentity, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
|
|
31
36
|
import { formatUpdateProgress } from "./update-progress.js";
|
|
32
37
|
import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
|
|
33
38
|
import { readFleetMemory } from "./process-memory.js";
|
|
34
39
|
import { MemoryPressure } from "./memory-pressure.js";
|
|
35
40
|
import { replyDedupText, ReplyDeduper } from "./reply-dedup.js";
|
|
36
41
|
import { isMap, isScalar, parseDocument } from "yaml";
|
|
42
|
+
import { kiroLogGate } from "./kiro-log-gate.js";
|
|
37
43
|
const __filename = fileURLToPath(import.meta.url);
|
|
38
44
|
const __dirname = dirname(__filename);
|
|
39
45
|
/** Fallback access policy for a channel with no `access:` block — open (no gate). */
|
|
40
46
|
const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
|
|
41
47
|
import { STATUS_EMOJI_CONFIG_KEYS, STATUS_EMOJI_KEYS, STATUS_EMOJI_SUGGESTIONS, TELEGRAM_REACTION_EMOJIS, builtinStatusEmojis, customEmojiValue, emojiImageUrl, normalizeEmoji, previewStatusEmojis, reactionForm, reactionMatchKey, resolveStatusEmojis, statusAvoidList, statusEmojiProblem, statusMatchKey, statusMatchKeys, textForm, } from "./status-emojis.js";
|
|
42
48
|
import { isProbeableRouteTarget } from "./fleet-context.js";
|
|
43
|
-
import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
|
|
49
|
+
import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
|
|
44
50
|
import { EventLog } from "./event-log.js";
|
|
45
51
|
import { binaryProbe } from "./binary-probe.js";
|
|
46
52
|
import { classifySqliteOpenError } from "./sqlite-open-errors.js";
|
|
@@ -51,13 +57,16 @@ import { startEventLoopWatch } from "./event-loop-watch.js";
|
|
|
51
57
|
import { TmuxManager } from "./tmux-manager.js";
|
|
52
58
|
import { AccessManager } from "./channel/access-manager.js";
|
|
53
59
|
import { IpcClient } from "./channel/ipc-bridge.js";
|
|
60
|
+
import { usesChannelAddress } from "./channel/mcp-tools.js";
|
|
54
61
|
import { formatFleetTime } from "./tz-utils.js";
|
|
55
62
|
import { createAdapter } from "./channel/factory.js";
|
|
56
63
|
import { isWebChannelEcho, WEB_ECHO_PREFIX } from "./web-channel-echo.js";
|
|
57
64
|
import { TelegramAdapter } from "./channel/adapters/telegram.js";
|
|
58
65
|
import { createBackend } from "./backend/factory.js";
|
|
59
66
|
import { readEffortMetadata } from "./backend/effort-metadata.js";
|
|
60
|
-
import {
|
|
67
|
+
import { codexEffort, flagEffort } from "./backend/launch-effort.js";
|
|
68
|
+
import { codexMetadataHomes, readCodexModelEffortLevels, resolveCodexLaunchModel } from "./backend/codex-metadata.js";
|
|
69
|
+
import { CLI_ENV_TTL_MS, isModelCompatible, resolveBinary, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
|
|
61
70
|
import { createLogger, rotateLogIfNeeded } from "./logger.js";
|
|
62
71
|
import { processAttachments } from "./channel/attachment-handler.js";
|
|
63
72
|
import { routeToolCall } from "./channel/tool-router.js";
|
|
@@ -73,8 +82,9 @@ import { RoutingEngine } from "./routing-engine.js";
|
|
|
73
82
|
import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
|
|
74
83
|
import { TopicArchiver } from "./topic-archiver.js";
|
|
75
84
|
import { StatuslineWatcher } from "./statusline-watcher.js";
|
|
76
|
-
import { outboundHandlers } from "./outbound-handlers.js";
|
|
77
|
-
import {
|
|
85
|
+
import { applyInstanceConfigPatch, outboundHandlers } from "./outbound-handlers.js";
|
|
86
|
+
import { backendArgs, backendStep, joinBackendArgs, modelNameCarried, planDisplay } from "./backend-command.js";
|
|
87
|
+
import { DeliveryOutbox, DEFAULT_REPLY_OBLIGATION_TTL_MS, isSilentScheduleDelivery } from "./delivery-outbox.js";
|
|
78
88
|
// The target Daemon can legitimately wait 30 minutes for a busy pane before
|
|
79
89
|
// deciding to defer before submission. This is an alert threshold only: the
|
|
80
90
|
// active manager/target generation pair owns the lane until the daemon reports
|
|
@@ -126,12 +136,19 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
|
|
|
126
136
|
return tasks;
|
|
127
137
|
}
|
|
128
138
|
import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
|
|
139
|
+
import { LogService } from "./log-service.js";
|
|
129
140
|
import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
|
|
130
141
|
import { WebChatDiskStore } from "./web-chat-store.js";
|
|
142
|
+
import { SeenUsers, resolveMentions } from "./web-mentions.js";
|
|
143
|
+
import { WebPrefsStore } from "./web-prefs.js";
|
|
131
144
|
import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
|
|
132
145
|
import { ReplyButtonsController } from "./reply-buttons-controller.js";
|
|
133
146
|
import { publicAttachment, sweepOrphanedUploads, WebFileLedger } from "./web-upload.js";
|
|
147
|
+
import { REPLY_FILES_MAX_BYTES, snapshotReplyFile, sweepReplySnapshots, webFilesDir } from "./web-reply-files.js";
|
|
134
148
|
import { handleViewRequest, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
|
|
149
|
+
import { downloadStickerImage } from "./channel/sticker-download.js";
|
|
150
|
+
import { WebImageCache, imageMimeForFile, WEB_IMAGE_MAX_AGE_MS, WEB_IMAGE_MAX_BYTES, WEB_IMAGE_RETRY_MS } from "./web-images.js";
|
|
151
|
+
import { accentColor, avatarFor, ownBotConnections } from "./instance-avatar.js";
|
|
135
152
|
import { envFileKeys } from "./token-env-name.js";
|
|
136
153
|
import { discordGeneralChannelId } from "./discord-general.js";
|
|
137
154
|
import { parseEnvText } from "./env-file.js";
|
|
@@ -146,7 +163,7 @@ import { runVisibilityCommand } from "./cross-instance-notice.js";
|
|
|
146
163
|
import { installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
|
|
147
164
|
import { resolveInstalledAgend } from "./update-dispatch.js";
|
|
148
165
|
import { resolveUpdateLaunch, watchUpdateLaunch } from "./update-launch.js";
|
|
149
|
-
import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
|
|
166
|
+
import { admitProfileLogin, LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
|
|
150
167
|
import { runBeforeDeadline } from "./deadline.js";
|
|
151
168
|
import { LoginWindowLock } from "./login-window-lock.js";
|
|
152
169
|
import { handleSettingsRequest } from "./settings-api.js";
|
|
@@ -156,11 +173,12 @@ import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet
|
|
|
156
173
|
import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
|
|
157
174
|
import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
|
|
158
175
|
import { assertExplicitInstanceRemoval } from "./instance-removal.js";
|
|
159
|
-
import { validateFleetConfig } from "./config-validator.js";
|
|
176
|
+
import { KNOWN_BACKENDS, validateFleetConfig } from "./config-validator.js";
|
|
160
177
|
import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
|
|
161
178
|
import { presentationState, interactionSummary, sameInteractionOwner } from "./interaction-observation.js";
|
|
162
179
|
import { readLastInboundAt } from "./daemon.js";
|
|
163
180
|
import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
|
|
181
|
+
import { readBackgroundWorkEndedMarker } from "./background-work-ended.js";
|
|
164
182
|
import { DEFAULT_WARM_OVERFLOW, WakeCoordinator } from "./wake-coordinator.js";
|
|
165
183
|
import { TargetQueueWorker } from "./target-queue-worker.js";
|
|
166
184
|
import { resolveDeliveryWorkerMode } from "./types.js";
|
|
@@ -174,17 +192,17 @@ import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
|
|
|
174
192
|
import { buildOrgChart } from "./web-org.js";
|
|
175
193
|
import { CacheService, WINDOWS } from "./cache-service.js";
|
|
176
194
|
import { runWebCommand } from "./web-commands.js";
|
|
177
|
-
import { claudeProjectKey } from "./backend/claude-code.js";
|
|
195
|
+
import { claudeConfigDirFor, claudeProjectKey } from "./backend/claude-code.js";
|
|
178
196
|
import { sharedRolloutIndex } from "./rollout-index.js";
|
|
179
197
|
import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
|
|
180
198
|
import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
|
|
181
199
|
import { bypassesWebGate, handleAuthRequest, serveSigninPage } from "./auth-api.js";
|
|
182
200
|
import { isWebPageNavigation } from "./web-shell-routes.js";
|
|
183
|
-
import { tokenEpoch, WebSessionStore } from "./web-session.js";
|
|
201
|
+
import { localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
|
|
184
202
|
import { WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
|
|
185
203
|
import { CodeRequestLimiter, ReturningDevices } from "./web-returning-device.js";
|
|
186
204
|
import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
|
|
187
|
-
import { createPreviewListener, previewAvailability, previewSettings } from "./web-preview.js";
|
|
205
|
+
import { createPreviewListener, normalizeOrigin, previewAvailability, previewSettings } from "./web-preview.js";
|
|
188
206
|
import { fleetLevelDifferences, fleetLevelSignature, appendedConnections } from "./fleet-level-config.js";
|
|
189
207
|
import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
|
|
190
208
|
import { SecretStore } from "./secret-store.js";
|
|
@@ -194,10 +212,11 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
|
|
|
194
212
|
/** A self-restart is a whole service restart; 120s is the apply budget, not this. */
|
|
195
213
|
const SELF_RESTART_DEADLINE_MS = 300_000;
|
|
196
214
|
import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
|
|
197
|
-
import { credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
215
|
+
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
216
|
+
import { carryClaudeConversation } from "./backend/claude-carry.js";
|
|
198
217
|
import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
|
|
199
218
|
import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
|
|
200
|
-
import { formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
|
|
219
|
+
import { cleanupWatchVerdict, formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
|
|
201
220
|
import { launchFullRestartHelper } from "./full-restart.js";
|
|
202
221
|
import { SYSTEMD_RESTART_INDETERMINATE_EXIT_CODE } from "./service-restart-selection.js";
|
|
203
222
|
import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
|
|
@@ -248,6 +267,10 @@ export function isOrphanInstanceWindowName(name) {
|
|
|
248
267
|
/** Retry cadence for retiring a cancel button whose delete failed (e.g. a DC
|
|
249
268
|
* forum thread the bot momentarily can't reach). 3 retries × 5min = 15min. */
|
|
250
269
|
const CANCEL_BTN_RETRY_INTERVAL_MS = 5 * 60_000;
|
|
270
|
+
/** #1753: a carried conversation this large is logged as a warning (it is still carried). */
|
|
271
|
+
const CARRY_LARGE_BYTES = 256 * 1024 * 1024;
|
|
272
|
+
/** #1742: how long after a Telegram /web that could not reach an admin privately a bare /start counts as /start web. */
|
|
273
|
+
const TG_WEB_START_WINDOW_MS = 10 * 60_000;
|
|
251
274
|
const CANCEL_BTN_MAX_RETRIES = 3;
|
|
252
275
|
/** Backstop: every 5min, retire a button whose instance has gone idle. Catches
|
|
253
276
|
* buttons no clear trigger reached (e.g. a scheduled/HTTP turn that never called
|
|
@@ -333,6 +356,7 @@ const CLASSIC_BACKEND_SELECTION_TIMEOUT_MS = 60_000;
|
|
|
333
356
|
const CLASSIC_BACKEND_CALLBACK_PREFIX = "classic-backend:";
|
|
334
357
|
const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
|
|
335
358
|
const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
|
|
359
|
+
const BACKEND_SELECT_CALLBACK_PREFIX = "backend-select:";
|
|
336
360
|
const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
|
|
337
361
|
const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
|
|
338
362
|
/** #1386: how long a paused instance's marker reason is reused before it is read again (transitions drop it at once). */
|
|
@@ -423,6 +447,8 @@ const CLI_ENV_FRESH_MS = 60 * 60 * 1000;
|
|
|
423
447
|
* and hides exactly the newly released model the user opened `/model` to find.
|
|
424
448
|
* The wait is announced before it starts, so it reads as progress, not a stall.
|
|
425
449
|
*/
|
|
450
|
+
/** #1690: how long `/model` waits for the daemon's word on our own runtime switch (its dialog wait plus settling). */
|
|
451
|
+
export const RUNTIME_SWITCH_RESULT_MS = 12_000;
|
|
426
452
|
export const CLI_ENV_PROBE_DEADLINE_MS = 16_000;
|
|
427
453
|
/**
|
|
428
454
|
* How many CLIs may cold-start at once, from BOTH memory and cores.
|
|
@@ -610,6 +636,11 @@ export class FleetManager {
|
|
|
610
636
|
webChannelEchoGuards = new Map();
|
|
611
637
|
/** instanceName → restart currently executing; concurrent callers join it. */
|
|
612
638
|
restartsInFlight = new Map();
|
|
639
|
+
/**
|
|
640
|
+
* #1753: each in-flight restart's stop → start gap — the conversation carry it will run there (its own, or one a later
|
|
641
|
+
* caller joined with before the gap), and whether the gap has been reached.
|
|
642
|
+
*/
|
|
643
|
+
restartGaps = new Map();
|
|
613
644
|
/**
|
|
614
645
|
* Delayed automatic retries for instances whose startup failed. Before this a
|
|
615
646
|
* failed start was logged once and the instance stayed `stopped` until an
|
|
@@ -645,6 +676,8 @@ export class FleetManager {
|
|
|
645
676
|
/** In-flight /effort selections, same coordinator shape as pendingModelSelects. */
|
|
646
677
|
pendingEffortSelects = new Map();
|
|
647
678
|
pendingModelSelects = new Map();
|
|
679
|
+
/** #1806: in-flight /backend steps, keyed by nonce (see handleBackendSelection). */
|
|
680
|
+
pendingBackendSelects = new Map();
|
|
648
681
|
/** nonce → pending button prompt (hang restart, interactive assist, clean-exit restart). */
|
|
649
682
|
pendingNonceButtons = new Map();
|
|
650
683
|
/** #1386 "Needs you": the list, its live chat messages and Acknowledge. Started with the fleet (finishStartup). */
|
|
@@ -714,6 +747,7 @@ export class FleetManager {
|
|
|
714
747
|
collabInstances = new Set();
|
|
715
748
|
// Health endpoint
|
|
716
749
|
healthServer = null;
|
|
750
|
+
healthServerDrain = null;
|
|
717
751
|
/** #1306: the preview listener (health_port + 1 by default), and whether it is listening. */
|
|
718
752
|
previewListener = null;
|
|
719
753
|
previewListening = false;
|
|
@@ -726,6 +760,13 @@ export class FleetManager {
|
|
|
726
760
|
updateProgressEditRunning = false;
|
|
727
761
|
lastUpdateProgressText = null;
|
|
728
762
|
updateCompletionTipText = null;
|
|
763
|
+
/**
|
|
764
|
+
* #1660: an adopted update whose retire outcome is still owed. The
|
|
765
|
+
* completion summary was (or will be) posted; this watch appends the
|
|
766
|
+
* cleanup outcome to the same receipt instead of letting stage edits
|
|
767
|
+
* clobber it — and refuses a receipt that no longer matches (checklist 3/5).
|
|
768
|
+
*/
|
|
769
|
+
updateCleanupWatch = null;
|
|
729
770
|
/** Injectable only to keep the chat→CLI reload hand-off deterministic in tests. */
|
|
730
771
|
fullRestartLauncher = launchFullRestartHelper;
|
|
731
772
|
eventLogPruneTimer = null;
|
|
@@ -745,8 +786,8 @@ export class FleetManager {
|
|
|
745
786
|
memoryLogAt = null;
|
|
746
787
|
memoryDebugAt = null;
|
|
747
788
|
memoryLogLevel = null;
|
|
748
|
-
|
|
749
|
-
|
|
789
|
+
/** #1685: the host_memory value the last status frame carried, so a change re-sends the frame. */
|
|
790
|
+
memoryIndicatorShown = null;
|
|
750
791
|
// Mirror topic: buffer cross-instance messages, flush every 3s
|
|
751
792
|
mirrorBuffer = [];
|
|
752
793
|
mirrorTimer = null;
|
|
@@ -758,6 +799,21 @@ export class FleetManager {
|
|
|
758
799
|
webChatStore = null;
|
|
759
800
|
/** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
|
|
760
801
|
webFiles = new WebFileLedger();
|
|
802
|
+
/** #1770: per instance, its last web reply still copying its files — the next one is shown after it, in order. */
|
|
803
|
+
webReplyOrder = new Map();
|
|
804
|
+
/** #1770: an instance's web-files byte cap (a field, so a test can make it small). */
|
|
805
|
+
webReplyMaxBytes = REPLY_FILES_MAX_BYTES;
|
|
806
|
+
/** #1682: the people seen on inbound messages (id → name), for the web chat's mention pills; bounded, in memory. */
|
|
807
|
+
seenUsers = new SeenUsers();
|
|
808
|
+
webPrefsStore;
|
|
809
|
+
/** #1665: the web preferences kept on the host (<home>/web-prefs.json), read on first use. */
|
|
810
|
+
get webPrefs() {
|
|
811
|
+
// #1664: a favorite must be an instance of this fleet now — fleet.yaml's (own keys) or a ClassicBot room's.
|
|
812
|
+
return (this.webPrefsStore ??= new WebPrefsStore(this.dataDir, {
|
|
813
|
+
exists: (name) => Object.hasOwn(this.fleetConfig?.instances ?? {}, name)
|
|
814
|
+
|| (this.classicChannels?.getAll() ?? []).some((ch) => ch.instanceName === name),
|
|
815
|
+
}));
|
|
816
|
+
}
|
|
761
817
|
/**
|
|
762
818
|
* Read from disk on every access rather than cached at startup: `agend
|
|
763
819
|
* web-token rotate` runs in a separate process, and a cached copy would keep
|
|
@@ -1147,7 +1203,7 @@ export class FleetManager {
|
|
|
1147
1203
|
const reactions = this.pendingReactionsMeta(set.instance);
|
|
1148
1204
|
let accepted = false;
|
|
1149
1205
|
try {
|
|
1150
|
-
accepted = (await this.
|
|
1206
|
+
accepted = (await this.deliverHumanToInstance(set.instance, {
|
|
1151
1207
|
type: "fleet_inbound",
|
|
1152
1208
|
content,
|
|
1153
1209
|
targetSession: set.instance,
|
|
@@ -1156,7 +1212,7 @@ export class FleetManager {
|
|
|
1156
1212
|
thread_id: set.threadId, adapter_id: inbound.adapterId, source: inbound.source,
|
|
1157
1213
|
...reactions.meta,
|
|
1158
1214
|
},
|
|
1159
|
-
})) !== false;
|
|
1215
|
+
}, by.humanOrigin, button.label)) !== false;
|
|
1160
1216
|
}
|
|
1161
1217
|
finally {
|
|
1162
1218
|
// Both false and throw reopen the choice in the controller. Finish the
|
|
@@ -1215,8 +1271,13 @@ export class FleetManager {
|
|
|
1215
1271
|
if (event.deliveryId && (event.state === "failed" || event.state === "uncertain")) {
|
|
1216
1272
|
const row = outbox.get(event.deliveryId);
|
|
1217
1273
|
if (row && row.kind !== "delivery_outcome_notice" && row.kind !== "post_restart_outcome_notice") {
|
|
1218
|
-
|
|
1219
|
-
|
|
1274
|
+
// #1647: the cause goes in the log (the notice points there), and a silent schedule's paste says what it is.
|
|
1275
|
+
const silent = isSilentScheduleDelivery(row);
|
|
1276
|
+
const notice = silent
|
|
1277
|
+
? t(`delivery.fleet_${row.state}_silent_schedule`, row.targetInstance, row.operationId, row.deliveryId, row.lastError ?? "unknown")
|
|
1278
|
+
: t(`delivery.fleet_${row.state}`, row.targetInstance, row.operationId, row.deliveryId);
|
|
1279
|
+
this.logger.error({ deliveryId: row.deliveryId, operationId: row.operationId, target: row.targetInstance, state: row.state,
|
|
1280
|
+
cause: row.lastError, ...(silent ? { silentSchedule: true } : {}) }, notice);
|
|
1220
1281
|
this.notifyFleetError(notice);
|
|
1221
1282
|
}
|
|
1222
1283
|
}
|
|
@@ -1270,11 +1331,25 @@ export class FleetManager {
|
|
|
1270
1331
|
deliveryId: admitted.delivery.deliveryId,
|
|
1271
1332
|
state: admitted.delivery.state,
|
|
1272
1333
|
duplicate: !admitted.inserted,
|
|
1334
|
+
kind: admitted.delivery.kind,
|
|
1273
1335
|
};
|
|
1274
1336
|
}
|
|
1275
1337
|
queryDurableDeliveryStatus(callerInstance, selector) {
|
|
1276
1338
|
this.ensureDeliveryOutbox();
|
|
1277
|
-
|
|
1339
|
+
const page = this.deliveryOutbox.queryStatusForInstance(callerInstance, selector);
|
|
1340
|
+
// #1641: a sender that sees only `queued` cannot tell "next in line" from "the target is stuck": say which.
|
|
1341
|
+
return {
|
|
1342
|
+
...page,
|
|
1343
|
+
items: page.items.map(item => (item.state === "queued" || item.state === "retry_wait")
|
|
1344
|
+
? { ...item, waiting_reason: this.deliveryWaitingReason(item.target_instance) }
|
|
1345
|
+
: item),
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
/** #1641: why a target is not taking queued work right now, or null when nothing known holds it. */
|
|
1349
|
+
deliveryWaitingReason(target) {
|
|
1350
|
+
if (this.lifecycle.isPaused(target))
|
|
1351
|
+
return t("delivery.hold_state", "paused");
|
|
1352
|
+
return this.daemons.get(target)?.notAcceptingReason?.() ?? null;
|
|
1278
1353
|
}
|
|
1279
1354
|
getDaemonBootId(instanceName) {
|
|
1280
1355
|
return this.daemons.get(instanceName)?.bootId;
|
|
@@ -1298,7 +1373,9 @@ export class FleetManager {
|
|
|
1298
1373
|
// #926: a restart kills the turn that was working on a request, and a
|
|
1299
1374
|
// resumed CLI does not pick it back up. Remind the owner once per restart.
|
|
1300
1375
|
try {
|
|
1301
|
-
|
|
1376
|
+
const now = new Date();
|
|
1377
|
+
this.expireReplyObligations(now);
|
|
1378
|
+
outbox.remindReplyObligations(name, { reason: "restart", graceMs: 0, now, since: now, restartId: daemonBootId });
|
|
1302
1379
|
}
|
|
1303
1380
|
catch (err) {
|
|
1304
1381
|
this.logger.error({ err, owner: name }, "Could not persist the post-restart reply reminder");
|
|
@@ -1316,6 +1393,7 @@ export class FleetManager {
|
|
|
1316
1393
|
return;
|
|
1317
1394
|
const stateOf = (name) => this.daemons.get(name)?.getInstanceState();
|
|
1318
1395
|
try {
|
|
1396
|
+
this.expireReplyObligations(now);
|
|
1319
1397
|
for (const name of this.daemons.keys()) {
|
|
1320
1398
|
if (stateOf(name) === "idle")
|
|
1321
1399
|
outbox.remindReplyObligations(name, { now, graceMs: REPLY_REMINDER_GRACE_MS, reason: "turn-ended" });
|
|
@@ -1327,6 +1405,15 @@ export class FleetManager {
|
|
|
1327
1405
|
this.logger.error({ err }, "Reply-obligation sweep failed");
|
|
1328
1406
|
}
|
|
1329
1407
|
}
|
|
1408
|
+
expireReplyObligations(now) {
|
|
1409
|
+
const hours = this.fleetConfig?.defaults?.reply_obligation_ttl_hours;
|
|
1410
|
+
const duration = typeof hours === "number" && hours > 0 && Number.isFinite(hours * 3_600_000)
|
|
1411
|
+
? hours * 3_600_000 : DEFAULT_REPLY_OBLIGATION_TTL_MS;
|
|
1412
|
+
for (const row of this.deliveryOutbox.expireReplyObligations({ now, ttlMs: duration })) {
|
|
1413
|
+
this.logger.info({ owner: row.ownerInstance, requester: row.requesterInstance,
|
|
1414
|
+
correlationId: row.correlationId, lastAskedAt: row.lastAskedAt, expiredAt: row.expiredAt }, "Reply obligation expired");
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1330
1417
|
scheduleDeliveryOutboxPump(delayMs = 0) {
|
|
1331
1418
|
if (!this.deliveryOutbox || this.shuttingDown || !this.deliveryOutbox.isOpen)
|
|
1332
1419
|
return;
|
|
@@ -1566,6 +1653,14 @@ export class FleetManager {
|
|
|
1566
1653
|
return Math.max(1, Math.min(20, explicit));
|
|
1567
1654
|
return deriveSpawnConcurrency(Math.round(freemem() / (1024 * 1024)), cpus().length);
|
|
1568
1655
|
}
|
|
1656
|
+
/** #1685: "elevated" or "critical" while host memory pressure slows or holds new agents; otherwise null (an
|
|
1657
|
+
* unknown macOS reading — the advisory case — is unknown, so null too). */
|
|
1658
|
+
hostMemoryIndicator() {
|
|
1659
|
+
if (!this.memoryPressure)
|
|
1660
|
+
return null;
|
|
1661
|
+
const level = this.memoryPressure.snapshot().level;
|
|
1662
|
+
return level === "elevated" || level === "critical" ? level : null;
|
|
1663
|
+
}
|
|
1569
1664
|
/** Separate cooldown: changing measurements must not create new text-throttle keys. */
|
|
1570
1665
|
reportMemoryPressure(snapshot) {
|
|
1571
1666
|
if (this.shuttingDown)
|
|
@@ -1576,6 +1671,12 @@ export class FleetManager {
|
|
|
1576
1671
|
this.logger.debug({ hostMemory: snapshot }, "Host memory sample");
|
|
1577
1672
|
}
|
|
1578
1673
|
const changed = snapshot.level !== this.memoryLogLevel;
|
|
1674
|
+
// #1685: the dashboard's indicator (the status frame's host_memory) is re-sent when it changes.
|
|
1675
|
+
const indicator = this.hostMemoryIndicator();
|
|
1676
|
+
if (indicator !== this.memoryIndicatorShown) {
|
|
1677
|
+
this.memoryIndicatorShown = indicator;
|
|
1678
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
1679
|
+
}
|
|
1579
1680
|
if (this.memoryPressure.advisoryOnly()) {
|
|
1580
1681
|
// The sampler logs the unknown kernel value once per lifecycle; no warning/cooldown here.
|
|
1581
1682
|
this.memoryLogLevel = snapshot.level;
|
|
@@ -1596,20 +1697,6 @@ export class FleetManager {
|
|
|
1596
1697
|
: "Host memory sample unavailable — slowing new CLI spawns");
|
|
1597
1698
|
}
|
|
1598
1699
|
this.memoryLogLevel = snapshot.level;
|
|
1599
|
-
if (!snapshot.memory || snapshot.level === "unknown" || (!this.adapter && this.adapters.size === 0))
|
|
1600
|
-
return;
|
|
1601
|
-
const escalation = snapshot.level === "critical" && this.memoryNoticeLevel !== "critical";
|
|
1602
|
-
if (!escalation && this.memoryNoticeAt !== null && now - this.memoryNoticeAt < 10 * 60_000)
|
|
1603
|
-
return;
|
|
1604
|
-
const size = (bytes) => bytes === null ? t("memory.unknown") : `${Math.round(bytes / 1024 / 1024)} MiB`;
|
|
1605
|
-
const action = t(snapshot.level === "critical" ? "memory.holding" : "memory.slowing");
|
|
1606
|
-
const text = this.memoryPressure.platform === "darwin"
|
|
1607
|
-
? t("memory.kernel_pressure", snapshot.memory.darwinPressureLevel ?? t("memory.unknown"), size(snapshot.memory.availableBytes), size(snapshot.memory.swapFreeBytes), action)
|
|
1608
|
-
: t("memory.pressure", t(snapshot.memory.availableKind === "available" ? "memory.available" : "memory.free", size(snapshot.memory.availableBytes)), size(snapshot.memory.swapFreeBytes), action);
|
|
1609
|
-
if (this.notifyFleetError(text, { throttle: false })) {
|
|
1610
|
-
this.memoryNoticeAt = now;
|
|
1611
|
-
this.memoryNoticeLevel = snapshot.level;
|
|
1612
|
-
}
|
|
1613
1700
|
}
|
|
1614
1701
|
/** Wire the one fleet-wide storm into notification and recovery surfaces. */
|
|
1615
1702
|
bindStormWindowEvents() {
|
|
@@ -1730,7 +1817,7 @@ export class FleetManager {
|
|
|
1730
1817
|
isPlannedRestart() {
|
|
1731
1818
|
return this.shuttingDown || isUpdateInProgress(this.dataDir);
|
|
1732
1819
|
}
|
|
1733
|
-
finishStartup() {
|
|
1820
|
+
finishStartup(bootUpdateMarker = null) {
|
|
1734
1821
|
this.startupComplete = true;
|
|
1735
1822
|
this.startNeedsYou();
|
|
1736
1823
|
// Resolve whatever a previous run — or a setup host that crashed — left
|
|
@@ -1740,14 +1827,17 @@ export class FleetManager {
|
|
|
1740
1827
|
// the next tunnel and says so, it does not block the fleet.
|
|
1741
1828
|
this.announceToolPermissionsChange();
|
|
1742
1829
|
this.tightenInstanceDirectories();
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1830
|
+
// #1723: the public link's preview tunnel keeps its own lease beside it; a leftover one is reaped the same way.
|
|
1831
|
+
for (const leaseDir of [this.dataDir, join(this.dataDir, PUBLIC_PREVIEW_TUNNEL_DIR)]) {
|
|
1832
|
+
void reapStaleTunnel(leaseDir)
|
|
1833
|
+
.then(outcome => {
|
|
1834
|
+
if (outcome.kind === "manual")
|
|
1835
|
+
this.logger.warn({ tunnel: outcome }, manualCleanupMessage(outcome));
|
|
1836
|
+
else if (outcome.kind === "reaped")
|
|
1837
|
+
this.logger.info({ how: outcome.how, pid: outcome.pid }, "Reaped a leftover tunnel");
|
|
1838
|
+
})
|
|
1839
|
+
.catch(err => this.logger.warn({ err }, "Tunnel reaper failed"));
|
|
1840
|
+
}
|
|
1751
1841
|
// An existing installation has never written the setup marker — it predates
|
|
1752
1842
|
// it — so `agend setup` would open a pre-fleet form for a fleet that plainly
|
|
1753
1843
|
// exists. A fleet that just came up on a config with agents in it is proof
|
|
@@ -1767,14 +1857,33 @@ export class FleetManager {
|
|
|
1767
1857
|
for (const settled of this.applyJobs.settleAfterRestart()) {
|
|
1768
1858
|
this.logger.info({ jobId: settled.id }, "Settings apply job settled by fleet restart");
|
|
1769
1859
|
}
|
|
1770
|
-
|
|
1771
|
-
// it here (rather than in the update command, which exits before the new
|
|
1772
|
-
// fleet is up) is what keeps the quiet window from outliving the restart.
|
|
1773
|
-
clearUpdateMarker(this.dataDir);
|
|
1860
|
+
this.clearSettledUpdateMarker(bootUpdateMarker);
|
|
1774
1861
|
if (this.reloadPending)
|
|
1775
1862
|
this.scheduleReconcile();
|
|
1776
1863
|
void this.sweepOrphanedCancelButtons();
|
|
1777
1864
|
}
|
|
1865
|
+
/**
|
|
1866
|
+
* We are the post-update fleet: the update is over by definition. Clearing it here (rather than in the update
|
|
1867
|
+
* command, which exits before the new fleet is up) is what keeps the quiet window from outliving the restart.
|
|
1868
|
+
* #1660 exception: an update still owing its retire outcome keeps its receipt until the cleanup watch resolves it —
|
|
1869
|
+
* deleting it here is what lost the warning. Terminal stages are still cleared unconditionally.
|
|
1870
|
+
* #1735: and while the watch is armed it owns the marker — even if the outcome landed after the completion posted
|
|
1871
|
+
* (a launchd kickstart or a detached restart returns at once, so it can land at any point of the boot): the watch
|
|
1872
|
+
* appends it, then clears.
|
|
1873
|
+
* #1737 review: and only the marker this boot adopted. One written since — a `/restart full` or a new `/update`
|
|
1874
|
+
* landing while this boot's completion was still being edited — belongs to its writer and is left alone.
|
|
1875
|
+
*/
|
|
1876
|
+
clearSettledUpdateMarker(bootUpdateMarker) {
|
|
1877
|
+
if (this.updateCleanupWatch)
|
|
1878
|
+
return;
|
|
1879
|
+
const current = updateMarkerIdentity(this.dataDir);
|
|
1880
|
+
if (current && !sameUpdateMarker(current, bootUpdateMarker))
|
|
1881
|
+
return;
|
|
1882
|
+
const leftover = readUpdateProgress(this.dataDir)?.progress;
|
|
1883
|
+
if (leftover?.cleanup === "pending" && leftover.stage !== "failed" && leftover.stage !== "complete")
|
|
1884
|
+
return;
|
|
1885
|
+
clearUpdateMarker(this.dataDir);
|
|
1886
|
+
}
|
|
1778
1887
|
// ── ArchiverContext bridge ────────────────────────────────────────────
|
|
1779
1888
|
lastActivityMs(name) {
|
|
1780
1889
|
return this.lastActivity.get(name) ?? 0;
|
|
@@ -2558,6 +2667,8 @@ export class FleetManager {
|
|
|
2558
2667
|
startsFresh: !backendChanged && credentialSwitchStartsFresh(backend),
|
|
2559
2668
|
from: profileKey(launched),
|
|
2560
2669
|
to: profileKey(next),
|
|
2670
|
+
...(!backendChanged && launched.state !== "invalid" && next.state !== "invalid"
|
|
2671
|
+
? { carry: { backend, from: profileName(launched), to: profileName(next) } } : {}),
|
|
2561
2672
|
};
|
|
2562
2673
|
}
|
|
2563
2674
|
/** `[instance, effective backend, credential profile]` for everything that is
|
|
@@ -2592,6 +2703,55 @@ export class FleetManager {
|
|
|
2592
2703
|
return this.classicChannels?.getAll().some(channel => channel.instanceName === name) ?? false;
|
|
2593
2704
|
}
|
|
2594
2705
|
/** #1523 N2: a ClassicBot room's own summary for its Details page (classicBot.yaml), or null. */
|
|
2706
|
+
/** #1218: the Log tab's on-demand service — made on first use; with `web.log_tab` off it answers off and does nothing. */
|
|
2707
|
+
logService = null;
|
|
2708
|
+
get log() {
|
|
2709
|
+
return this.logService ??= new LogService({
|
|
2710
|
+
enabled: () => this.fleetConfig?.web?.log_tab === true,
|
|
2711
|
+
known: (name) => Object.prototype.hasOwnProperty.call(this.fleetConfig?.instances ?? {}, name) || this.classicRoomFor(name) !== null,
|
|
2712
|
+
source: (name) => this.logSource(name),
|
|
2713
|
+
events: (name, limit) => (this.eventLog?.query({ instance: name, limit }) ?? []),
|
|
2714
|
+
emit: (event, data) => this.emitSseEvent(event, data),
|
|
2715
|
+
});
|
|
2716
|
+
}
|
|
2717
|
+
/** #1218: GET /ui/steps — an instance's steps, read on demand (null: no such instance). */
|
|
2718
|
+
logSteps(name, q) { return this.log.steps(name, q); }
|
|
2719
|
+
/**
|
|
2720
|
+
* #1218: where an instance's steps are read from, resolved only when its Log asks: the transcript its daemon follows
|
|
2721
|
+
* now; for claude-code, also the path its statusline last named (so a stopped instance's Log still shows its last
|
|
2722
|
+
* work). kiro: fleet events with the reason its history is not read (kiro-log-gate.ts); other backends have no
|
|
2723
|
+
* transcript AgEnD can read.
|
|
2724
|
+
*/
|
|
2725
|
+
logSource(name) {
|
|
2726
|
+
const instances = this.fleetConfig?.instances ?? {};
|
|
2727
|
+
const config = Object.prototype.hasOwnProperty.call(instances, name) ? instances[name] : undefined;
|
|
2728
|
+
const backend = config?.backend ?? (config ? null : this.classicRoomFor(name)?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
2729
|
+
const live = this.lifecycle.daemons.get(name)?.logTranscriptPath() ?? null;
|
|
2730
|
+
if (backend === "claude-code") {
|
|
2731
|
+
let path = live;
|
|
2732
|
+
if (!path) {
|
|
2733
|
+
try {
|
|
2734
|
+
path = JSON.parse(readFileSync(join(this.getInstanceDir(name), "statusline.json"), "utf-8")).transcript_path ?? null;
|
|
2735
|
+
}
|
|
2736
|
+
catch {
|
|
2737
|
+
path = null;
|
|
2738
|
+
}
|
|
2739
|
+
if (typeof path !== "string" || !path)
|
|
2740
|
+
path = null;
|
|
2741
|
+
}
|
|
2742
|
+
return { backend, path, format: "claude", ...(path ? {} : { reason: "no_transcript" }) };
|
|
2743
|
+
}
|
|
2744
|
+
if (backend === "codex")
|
|
2745
|
+
return { backend, path: live, format: "codex", ...(live ? {} : { reason: "not_running" }) };
|
|
2746
|
+
if (backend === "kiro-cli") {
|
|
2747
|
+
// #1218 PR4: events only until the launch's root and the current conversation are both proven (kiro-log-gate.ts);
|
|
2748
|
+
// the reason names which proof is missing. With today's proofs the gate never opens, and nothing is read for kiro:
|
|
2749
|
+
// the reader is bound to an admitted launch when the proofs arrive (#849 root, #1789 current conversation).
|
|
2750
|
+
const gate = kiroLogGate(this.lifecycle.daemons.get(name)?.logLaunch() ?? null);
|
|
2751
|
+
return { backend, path: null, format: null, reason: gate.open ? "kiro_session_unobservable" : gate.reason };
|
|
2752
|
+
}
|
|
2753
|
+
return { backend, path: null, format: null, reason: "unsupported" };
|
|
2754
|
+
}
|
|
2595
2755
|
classicRoomFor(name) {
|
|
2596
2756
|
const ch = this.classicChannels?.getAll().find(channel => channel.instanceName === name);
|
|
2597
2757
|
if (!ch)
|
|
@@ -2893,7 +3053,7 @@ export class FleetManager {
|
|
|
2893
3053
|
const queued = this.ipcWaitTails.get(instanceName);
|
|
2894
3054
|
if (!queued) {
|
|
2895
3055
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
2896
|
-
if (ipc?.connected && ipc.send(payload))
|
|
3056
|
+
if (ipc?.connected && ipc.send(this.bindHumanReceiptPayload(instanceName, payload)))
|
|
2897
3057
|
return true;
|
|
2898
3058
|
}
|
|
2899
3059
|
const attempt = (queued ?? Promise.resolve())
|
|
@@ -2928,7 +3088,7 @@ export class FleetManager {
|
|
|
2928
3088
|
// Re-read every round: a reconnect replaces the IpcClient object entirely,
|
|
2929
3089
|
// so a cached reference would stay dead forever.
|
|
2930
3090
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
2931
|
-
if (ipc?.connected && ipc.send(payload))
|
|
3091
|
+
if (ipc?.connected && ipc.send(this.bindHumanReceiptPayload(instanceName, payload)))
|
|
2932
3092
|
return true;
|
|
2933
3093
|
if (Date.now() >= deadline) {
|
|
2934
3094
|
throw new Error(`Instance '${instanceName}' IPC is unavailable`);
|
|
@@ -3003,13 +3163,38 @@ export class FleetManager {
|
|
|
3003
3163
|
}
|
|
3004
3164
|
return posted;
|
|
3005
3165
|
}
|
|
3006
|
-
|
|
3166
|
+
/**
|
|
3167
|
+
* #1666: a delivered web chat message starts the same cancel/progress lifecycle in the instance's topic as a platform
|
|
3168
|
+
* inbound (afterUserInboundDelivered): the same bubble, ticker and retire rules. Not its ✅ tracking — the web message
|
|
3169
|
+
* id is no platform message, and the echo is the fleet's own post. The web send calls this on the echo's lane.
|
|
3170
|
+
*/
|
|
3171
|
+
startWebChatProgress(instanceName) {
|
|
3172
|
+
if (this.shuttingDown)
|
|
3173
|
+
return Promise.resolve();
|
|
3174
|
+
return this.sendCancelButton(instanceName);
|
|
3175
|
+
}
|
|
3176
|
+
/**
|
|
3177
|
+
* `copy: false` (#1666/#1668): an entry that is not a copy of a message — the web send's Cancel/progress bubble. It
|
|
3178
|
+
* 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
|
|
3179
|
+
* starts (waiting behind a slow echo is not its delay), its own timeout only releases the lane (the copies queued
|
|
3180
|
+
* behind it are not "behind an ambiguous copy"), and another entry's timeout never drops it. A copy reserved while
|
|
3181
|
+
* any non-copy is still open starts its own budget when the last of them finishes: a slow bubble is no copy's delay,
|
|
3182
|
+
* however far back the copy is. And a timeout only ever drops copies reserved AFTER the entry that timed out — with
|
|
3183
|
+
* deferred budgets, deadlines no longer grow with the order, so that rule is stated, not implied (#1668 r3).
|
|
3184
|
+
*/
|
|
3185
|
+
reserveWebChannelEcho(instanceName, sendEcho, opts = {}) {
|
|
3186
|
+
const copy = opts.copy !== false;
|
|
3007
3187
|
const epoch = this.getDeliveryEpoch(instanceName);
|
|
3008
|
-
const
|
|
3188
|
+
const queued = this.webChannelEchoTails.get(instanceName);
|
|
3189
|
+
// The last open non-copy ahead of this copy: its budget starts when that one finishes.
|
|
3190
|
+
const nonCopyAhead = copy && (queued?.nonCopiesOpen ?? 0) > 0 ? queued?.lastNonCopyDone : undefined;
|
|
3191
|
+
let deadlineAt = copy && !nonCopyAhead ? performance.now() + 5_000 : Number.POSITIVE_INFINITY;
|
|
3009
3192
|
let decide;
|
|
3010
3193
|
const admission = new Promise(resolve => { decide = resolve; });
|
|
3011
|
-
const queue =
|
|
3194
|
+
const queue = queued
|
|
3012
3195
|
?? { tail: Promise.resolve(), pending: new Set() };
|
|
3196
|
+
const seq = queue.nextSeq ?? 0;
|
|
3197
|
+
queue.nextSeq = seq + 1;
|
|
3013
3198
|
const previous = queue.tail;
|
|
3014
3199
|
let resolveDone;
|
|
3015
3200
|
const tail = new Promise(resolve => { resolveDone = resolve; });
|
|
@@ -3020,6 +3205,8 @@ export class FleetManager {
|
|
|
3020
3205
|
if (done)
|
|
3021
3206
|
return;
|
|
3022
3207
|
done = true;
|
|
3208
|
+
if (!copy)
|
|
3209
|
+
queue.nonCopiesOpen = Math.max(0, (queue.nonCopiesOpen ?? 0) - 1);
|
|
3023
3210
|
clearTimeout(timer);
|
|
3024
3211
|
queue.pending.delete(entry);
|
|
3025
3212
|
if (guard && this.webChannelEchoGuards.get(instanceName) === guard) {
|
|
@@ -3027,7 +3214,7 @@ export class FleetManager {
|
|
|
3027
3214
|
}
|
|
3028
3215
|
resolveDone();
|
|
3029
3216
|
};
|
|
3030
|
-
const entry = { started: false, drop: () => {
|
|
3217
|
+
const entry = { seq, started: false, copy, drop: () => {
|
|
3031
3218
|
this.logger.warn({ instanceName }, "Queued web channel echo dropped after ordering timeout");
|
|
3032
3219
|
finish();
|
|
3033
3220
|
} };
|
|
@@ -3037,14 +3224,16 @@ export class FleetManager {
|
|
|
3037
3224
|
expired = true;
|
|
3038
3225
|
this.logger.warn({ instanceName, inFlight: entry.started }, "Web channel echo ordering timed out");
|
|
3039
3226
|
finish();
|
|
3040
|
-
// Do not post copies that were queued behind an ambiguous platform send.
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3227
|
+
// Do not post copies that were queued behind an ambiguous platform send. A non-copy that timed out was no copy
|
|
3228
|
+
// at all (nothing behind it is ambiguous), and a non-copy is never dropped as one.
|
|
3229
|
+
if (copy)
|
|
3230
|
+
for (const pending of queue.pending)
|
|
3231
|
+
if (pending.seq > seq && !pending.started && pending.copy)
|
|
3232
|
+
pending.drop();
|
|
3044
3233
|
};
|
|
3045
3234
|
const checkDeadline = () => {
|
|
3046
|
-
if (done)
|
|
3047
|
-
return;
|
|
3235
|
+
if (done || deadlineAt === Number.POSITIVE_INFINITY)
|
|
3236
|
+
return; // a non-copy's budget has not started yet
|
|
3048
3237
|
const remaining = deadlineAt - performance.now();
|
|
3049
3238
|
if (remaining <= 0)
|
|
3050
3239
|
expire();
|
|
@@ -3053,8 +3242,19 @@ export class FleetManager {
|
|
|
3053
3242
|
};
|
|
3054
3243
|
queue.pending.add(entry);
|
|
3055
3244
|
queue.tail = tail;
|
|
3245
|
+
if (!copy) {
|
|
3246
|
+
queue.nonCopiesOpen = (queue.nonCopiesOpen ?? 0) + 1;
|
|
3247
|
+
queue.lastNonCopyDone = tail;
|
|
3248
|
+
}
|
|
3056
3249
|
this.webChannelEchoTails.set(instanceName, queue);
|
|
3057
3250
|
checkDeadline();
|
|
3251
|
+
// Registered before the admission below (which waits on `previous`, itself after the non-copy ahead), so the budget
|
|
3252
|
+
// is set by the time that runs.
|
|
3253
|
+
if (nonCopyAhead)
|
|
3254
|
+
void nonCopyAhead.then(() => { if (!done) {
|
|
3255
|
+
deadlineAt = performance.now() + 5_000;
|
|
3256
|
+
checkDeadline();
|
|
3257
|
+
} });
|
|
3058
3258
|
void Promise.all([previous, admission]).then(([, accepted]) => {
|
|
3059
3259
|
if (done)
|
|
3060
3260
|
return;
|
|
@@ -3063,11 +3263,15 @@ export class FleetManager {
|
|
|
3063
3263
|
return;
|
|
3064
3264
|
}
|
|
3065
3265
|
// A delayed timer callback must not admit an already expired copy.
|
|
3066
|
-
if (performance.now() >= deadlineAt) {
|
|
3266
|
+
if (copy && performance.now() >= deadlineAt) {
|
|
3067
3267
|
expire();
|
|
3068
3268
|
return;
|
|
3069
3269
|
}
|
|
3070
3270
|
entry.started = true;
|
|
3271
|
+
if (!copy) {
|
|
3272
|
+
deadlineAt = performance.now() + 5_000;
|
|
3273
|
+
checkDeadline();
|
|
3274
|
+
}
|
|
3071
3275
|
guard = { epoch, deadlineAt };
|
|
3072
3276
|
this.webChannelEchoGuards.set(instanceName, guard);
|
|
3073
3277
|
let request;
|
|
@@ -3102,6 +3306,101 @@ export class FleetManager {
|
|
|
3102
3306
|
instanceLaunchSupportsSteer(instanceName) {
|
|
3103
3307
|
return this.daemons.get(instanceName)?.launchSupportsSteer();
|
|
3104
3308
|
}
|
|
3309
|
+
humanReceiptDaemons = new WeakSet();
|
|
3310
|
+
/** Cached FM + lifecycle + Daemon + tmux owner; unknown ownership grants nothing. */
|
|
3311
|
+
humanReceiptOwner(instanceName, daemon = this.daemons.get(instanceName)) {
|
|
3312
|
+
try {
|
|
3313
|
+
if (this.shuttingDown || !daemon || this.daemons.get(instanceName) !== daemon)
|
|
3314
|
+
return null;
|
|
3315
|
+
const epoch = this.lifecycle.epochOf(instanceName);
|
|
3316
|
+
const owner = daemon.captureHumanReceiptOwner();
|
|
3317
|
+
if (!Number.isSafeInteger(epoch) || !owner)
|
|
3318
|
+
return null;
|
|
3319
|
+
return JSON.stringify([this.managerBootId, epoch, this.getDeliveryEpoch(instanceName), humanOwnerObjectId(daemon), owner]);
|
|
3320
|
+
}
|
|
3321
|
+
catch {
|
|
3322
|
+
return null;
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
listenForHumanReceipt(instanceName, daemon) {
|
|
3326
|
+
// Lazy initialization also supports narrow prototype-based integration fixtures.
|
|
3327
|
+
this.humanReceiptDaemons ??= new WeakSet();
|
|
3328
|
+
if (this.humanReceiptDaemons.has(daemon))
|
|
3329
|
+
return;
|
|
3330
|
+
this.humanReceiptDaemons.add(daemon);
|
|
3331
|
+
daemon.on("human_receipt_result", (result) => {
|
|
3332
|
+
try {
|
|
3333
|
+
this.deliveryOutbox?.settleHumanReceipt(result.receipt_id, instanceName, result.owner_key, result.proof, this.humanReceiptOwner(instanceName, daemon));
|
|
3334
|
+
}
|
|
3335
|
+
catch (err) {
|
|
3336
|
+
this.logger.error({ err, instanceName }, "Could not persist human delivery evidence");
|
|
3337
|
+
}
|
|
3338
|
+
});
|
|
3339
|
+
}
|
|
3340
|
+
/** Evidence-only admission. Bots/system/agent payloads have no trusted origin. */
|
|
3341
|
+
humanReceiptPayload(instanceName, payload, origin, originalBody) {
|
|
3342
|
+
if (!origin || !this.deliveryOutbox)
|
|
3343
|
+
return payload;
|
|
3344
|
+
const row = this.deliveryOutbox.createHumanReceipt({ targetInstance: instanceName, originalBody,
|
|
3345
|
+
origin: { ...origin }, inputKind: String(payload.type), managerBootId: this.managerBootId });
|
|
3346
|
+
return { ...payload, meta: { ...(payload.meta ?? {}),
|
|
3347
|
+
human_receipt_id: row.receipt_id, verification_id: row.message_id } };
|
|
3348
|
+
}
|
|
3349
|
+
/** Bind at the actual IPC send, after all wake/reconnect waits. No human retry lane. */
|
|
3350
|
+
bindHumanReceiptPayload(instanceName, payload) {
|
|
3351
|
+
const meta = payload.meta;
|
|
3352
|
+
if (!meta?.human_receipt_id || !this.deliveryOutbox)
|
|
3353
|
+
return payload;
|
|
3354
|
+
const daemon = this.daemons.get(instanceName);
|
|
3355
|
+
const owner = this.humanReceiptOwner(instanceName, daemon);
|
|
3356
|
+
if (!daemon || !owner) {
|
|
3357
|
+
this.deliveryOutbox.markHumanReceiptUncertain(meta.human_receipt_id, instanceName);
|
|
3358
|
+
return payload;
|
|
3359
|
+
}
|
|
3360
|
+
this.listenForHumanReceipt(instanceName, daemon);
|
|
3361
|
+
const daemonOwner = daemon.captureHumanReceiptOwner();
|
|
3362
|
+
const wire = daemon.humanReceiptWire(String(payload.content ?? ""), meta, String(payload.type));
|
|
3363
|
+
const row = this.deliveryOutbox.bindHumanReceipt(meta.human_receipt_id, instanceName, owner, wire, this.managerBootId);
|
|
3364
|
+
if (!daemonOwner || this.humanReceiptOwner(instanceName, daemon) !== owner)
|
|
3365
|
+
throw new Error("Human delivery ownership changed");
|
|
3366
|
+
// /raw retains exact bytes and never acquires a positive verification capability.
|
|
3367
|
+
if (payload.type === "raw_paste" || String(payload.content ?? "").startsWith("/raw ")) {
|
|
3368
|
+
this.deliveryOutbox.markHumanReceiptUncertain(row.receipt_id, instanceName);
|
|
3369
|
+
return payload;
|
|
3370
|
+
}
|
|
3371
|
+
return { ...payload, meta: { ...meta, human_owner_key: owner, human_daemon_owner: daemonOwner, human_wire_sha256: row.wire_sha256 } };
|
|
3372
|
+
}
|
|
3373
|
+
async deliverHumanToInstance(instanceName, payload, origin, originalBody, options) {
|
|
3374
|
+
const admitted = this.humanReceiptPayload(instanceName, payload, origin, originalBody);
|
|
3375
|
+
const id = admitted.meta?.human_receipt_id;
|
|
3376
|
+
let sent = false;
|
|
3377
|
+
try {
|
|
3378
|
+
const result = options ? await this.deliverToInstance(instanceName, admitted, options) : await this.deliverToInstance(instanceName, admitted);
|
|
3379
|
+
sent = result !== false;
|
|
3380
|
+
return result;
|
|
3381
|
+
}
|
|
3382
|
+
finally {
|
|
3383
|
+
if (!sent && id)
|
|
3384
|
+
this.deliveryOutbox?.markHumanReceiptUncertain(id, instanceName);
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
/** Synchronous steer/btw handoff, using the same evidence and owner binding. */
|
|
3388
|
+
sendHumanControl(instanceName, payload, origin, originalBody) {
|
|
3389
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
3390
|
+
if (this.shuttingDown || !ipc?.connected)
|
|
3391
|
+
return false;
|
|
3392
|
+
const admitted = this.humanReceiptPayload(instanceName, payload, origin, originalBody);
|
|
3393
|
+
const id = admitted.meta?.human_receipt_id;
|
|
3394
|
+
let sent = false;
|
|
3395
|
+
try {
|
|
3396
|
+
sent = ipc.send(this.bindHumanReceiptPayload(instanceName, admitted));
|
|
3397
|
+
return sent;
|
|
3398
|
+
}
|
|
3399
|
+
finally {
|
|
3400
|
+
if (!sent && id)
|
|
3401
|
+
this.deliveryOutbox?.markHumanReceiptUncertain(id, instanceName);
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3105
3404
|
/** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
|
|
3106
3405
|
async deliverToInstance(instanceName, payload, options = {}) {
|
|
3107
3406
|
const deliveryEpoch = this.getDeliveryEpoch(instanceName);
|
|
@@ -3234,6 +3533,9 @@ export class FleetManager {
|
|
|
3234
3533
|
* the check and the act, so the channel's instance cannot be rebound to another bot in between.
|
|
3235
3534
|
*/
|
|
3236
3535
|
async dispatchSlash(slash, adapterId, adapter) {
|
|
3536
|
+
return withSpawnHoldReply(text => slash.respond(text), () => this.dispatchSlashCore(slash, adapterId, adapter));
|
|
3537
|
+
}
|
|
3538
|
+
async dispatchSlashCore(slash, adapterId, adapter) {
|
|
3237
3539
|
// #1569: an alias (/web) is its command (/dashboard) from here on — the door, the table and the handler see one name.
|
|
3238
3540
|
const data = { ...slash, command: canonicalCommand(slash.command) };
|
|
3239
3541
|
const door = this.slashDoor(data, adapterId);
|
|
@@ -3291,7 +3593,7 @@ export class FleetManager {
|
|
|
3291
3593
|
userId: data.userId,
|
|
3292
3594
|
username,
|
|
3293
3595
|
source: "discord",
|
|
3294
|
-
timestamp: new Date(),
|
|
3596
|
+
timestamp: new Date(), adapterId, humanOrigin: data.humanOrigin, humanOriginalBody: text,
|
|
3295
3597
|
});
|
|
3296
3598
|
}
|
|
3297
3599
|
else if (data.command === "save") {
|
|
@@ -3315,6 +3617,9 @@ export class FleetManager {
|
|
|
3315
3617
|
else if (data.command === "model") {
|
|
3316
3618
|
await this.handleModelSlash(data, adapterId);
|
|
3317
3619
|
}
|
|
3620
|
+
else if (data.command === "backend") {
|
|
3621
|
+
await this.handleBackendSlash(data, adapterId);
|
|
3622
|
+
}
|
|
3318
3623
|
else if (data.command === "clear") {
|
|
3319
3624
|
await this.handleClearSlash(data, adapterId);
|
|
3320
3625
|
}
|
|
@@ -3436,7 +3741,8 @@ export class FleetManager {
|
|
|
3436
3741
|
await data.respond(t("classic.no_agent"));
|
|
3437
3742
|
return;
|
|
3438
3743
|
}
|
|
3439
|
-
const
|
|
3744
|
+
const originalSteerText = String(data.options?.message ?? "");
|
|
3745
|
+
const steerText = originalSteerText.trim();
|
|
3440
3746
|
if (!steerText) {
|
|
3441
3747
|
await data.respond(t("steer.usage"));
|
|
3442
3748
|
return;
|
|
@@ -3446,7 +3752,7 @@ export class FleetManager {
|
|
|
3446
3752
|
// rerouting the instance's replies to the slash context.
|
|
3447
3753
|
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
3448
3754
|
chatId: "", messageId: "", username: data.username ?? "user",
|
|
3449
|
-
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
3755
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord", humanOrigin: data.humanOrigin, humanOriginalBody: originalSteerText,
|
|
3450
3756
|
});
|
|
3451
3757
|
await data.respond(result);
|
|
3452
3758
|
}
|
|
@@ -3456,14 +3762,15 @@ export class FleetManager {
|
|
|
3456
3762
|
await data.respond(t("classic.no_agent"));
|
|
3457
3763
|
return;
|
|
3458
3764
|
}
|
|
3459
|
-
const
|
|
3765
|
+
const originalBtwText = String(data.options?.message ?? "");
|
|
3766
|
+
const btwText = originalBtwText.trim();
|
|
3460
3767
|
if (!btwText) {
|
|
3461
3768
|
await data.respond(t("btw.usage"));
|
|
3462
3769
|
return;
|
|
3463
3770
|
}
|
|
3464
3771
|
const result = this.topicCommands.sendBtw(name, btwText, {
|
|
3465
3772
|
chatId: "", messageId: "", username: data.username ?? "user",
|
|
3466
|
-
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
3773
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord", humanOrigin: data.humanOrigin, humanOriginalBody: originalBtwText,
|
|
3467
3774
|
});
|
|
3468
3775
|
await data.respond(result);
|
|
3469
3776
|
}
|
|
@@ -3763,12 +4070,16 @@ export class FleetManager {
|
|
|
3763
4070
|
const autoPauseChanged = oldAutoPause.get(ch.instanceName) !== newAutoPause;
|
|
3764
4071
|
// #1220: a running CLI keeps the login it was launched with, so a new
|
|
3765
4072
|
// credential_profile only takes effect through a restart.
|
|
3766
|
-
const { changed: profileChanged, startsFresh: profileSwitchStartsFresh, from: profileFrom, to: profileTo } = this.classicProfileChange(ch.instanceName, backendChanged);
|
|
4073
|
+
const { changed: profileChanged, startsFresh: profileSwitchStartsFresh, from: profileFrom, to: profileTo, carry: profileCarry } = this.classicProfileChange(ch.instanceName, backendChanged);
|
|
3767
4074
|
if (this.daemons.has(ch.instanceName) && (backendChanged || modelChanged || autoPauseChanged || profileChanged)) {
|
|
3768
4075
|
this.logger.info({ instanceName: ch.instanceName, backendFrom: oldBackends.get(ch.instanceName), backendTo: newBackend, modelFrom: oldModels.get(ch.instanceName), modelTo: newModel,
|
|
3769
4076
|
...(profileChanged ? { profileFrom, profileTo } : {}) }, "Backend/model/profile changed — restarting");
|
|
4077
|
+
const launchedIn = this.daemons.get(ch.instanceName)?.getConfigSnapshot?.()?.working_directory;
|
|
3770
4078
|
await this.stopInstance(ch.instanceName).catch(() => { });
|
|
3771
|
-
|
|
4079
|
+
// #1753: the conversation goes along where it can; otherwise a switch that loses it starts fresh.
|
|
4080
|
+
if (profileCarry)
|
|
4081
|
+
await this.switchConversation(ch.instanceName, { ...profileCarry, workingDirectory: launchedIn });
|
|
4082
|
+
else if (profileSwitchStartsFresh)
|
|
3772
4083
|
this.writeFreshStartMarker(ch.instanceName);
|
|
3773
4084
|
// Small delay to let tmux window clean up
|
|
3774
4085
|
await new Promise(r => setTimeout(r, 2000));
|
|
@@ -4152,6 +4463,33 @@ export class FleetManager {
|
|
|
4152
4463
|
names.add(channel.instanceName);
|
|
4153
4464
|
return [...names];
|
|
4154
4465
|
}
|
|
4466
|
+
/** #1624: migration cannot infer ownership from the running daemon roster.
|
|
4467
|
+
* Stopped/paused ClassicBot entries count too; their workspace is exactly
|
|
4468
|
+
* the one startClassicInstance builds, not this FleetManager's dataDir.
|
|
4469
|
+
*/
|
|
4470
|
+
kiroPeerWorkingDirectories(name) {
|
|
4471
|
+
if (!this.fleetConfig || !this.classicChannels)
|
|
4472
|
+
throw new Error("Kiro peer configuration is not loaded");
|
|
4473
|
+
const peers = [];
|
|
4474
|
+
for (const [peer, cfg] of Object.entries(this.fleetConfig.instances)) {
|
|
4475
|
+
if (peer === name || (cfg.backend ?? this.fleetConfig.defaults?.backend ?? "claude-code") !== "kiro-cli")
|
|
4476
|
+
continue;
|
|
4477
|
+
if (typeof cfg.working_directory !== "string" || !cfg.working_directory)
|
|
4478
|
+
throw new Error("Kiro peer cwd is unknown");
|
|
4479
|
+
peers.push(cfg.working_directory);
|
|
4480
|
+
}
|
|
4481
|
+
for (const ch of this.classicChannels.getAll()) {
|
|
4482
|
+
if (ch.instanceName === name) {
|
|
4483
|
+
if (Object.hasOwn(this.fleetConfig.instances, name))
|
|
4484
|
+
throw new Error("Kiro instance name has two configurations");
|
|
4485
|
+
continue;
|
|
4486
|
+
}
|
|
4487
|
+
if (this.classicChannels.getBackendByInstance(ch.instanceName, this.fleetConfig.defaults?.backend) === "kiro-cli") {
|
|
4488
|
+
peers.push(join(getAgendHome(), "workspaces", ch.instanceName));
|
|
4489
|
+
}
|
|
4490
|
+
}
|
|
4491
|
+
return peers;
|
|
4492
|
+
}
|
|
4155
4493
|
/**
|
|
4156
4494
|
* Probe the same executable set exposed by the web backend catalog.
|
|
4157
4495
|
*
|
|
@@ -4374,6 +4712,9 @@ export class FleetManager {
|
|
|
4374
4712
|
// Published so the outage hand-off can wait for an explicit stop that began
|
|
4375
4713
|
// BEFORE it (the generation fence alone only catches stops that begin after
|
|
4376
4714
|
// the hand-off snapshotted it).
|
|
4715
|
+
// #1716: scope the marker to this stop — a marker older than beganAt is
|
|
4716
|
+
// from an earlier stop and never reports.
|
|
4717
|
+
const beganAt = Date.now();
|
|
4377
4718
|
const run = (async () => {
|
|
4378
4719
|
try {
|
|
4379
4720
|
await this.lifecycle.stop(name, transition);
|
|
@@ -4381,6 +4722,10 @@ export class FleetManager {
|
|
|
4381
4722
|
finally {
|
|
4382
4723
|
this.clearNoncePromptsForInstance(name);
|
|
4383
4724
|
}
|
|
4725
|
+
const backgroundWorkEnded = readBackgroundWorkEndedMarker(this.getInstanceDir(name), beganAt) !== null;
|
|
4726
|
+
if (backgroundWorkEnded)
|
|
4727
|
+
this.notifyInstanceTopic(name, t("stop.background_work_ended", name));
|
|
4728
|
+
return { backgroundWorkEnded };
|
|
4384
4729
|
})().finally(() => { if (this.stopsInFlight.get(name) === run)
|
|
4385
4730
|
this.stopsInFlight.delete(name); });
|
|
4386
4731
|
this.stopsInFlight.set(name, run);
|
|
@@ -4395,6 +4740,16 @@ export class FleetManager {
|
|
|
4395
4740
|
const inFlight = this.restartsInFlight.get(name);
|
|
4396
4741
|
if (inFlight) {
|
|
4397
4742
|
this.logger.info({ name }, "restartSingleInstance: joining the restart already in flight");
|
|
4743
|
+
// #1753: a credential-profile switch joining it still needs its conversation carried in that restart's stop →
|
|
4744
|
+
// start gap: handed to the restart while it has not reached the gap (and carries nothing else); otherwise not
|
|
4745
|
+
// carried, and said so — the restart has already started, or will start, the CLI without it.
|
|
4746
|
+
if (opts?.carry) {
|
|
4747
|
+
const gap = this.restartGaps.get(name);
|
|
4748
|
+
if (gap && !gap.reached && !gap.carry)
|
|
4749
|
+
gap.carry = opts.carry;
|
|
4750
|
+
else
|
|
4751
|
+
opts.carry.result = { carried: false, reason: gap?.carry ? "a restart in flight carries another switch" : "joined a restart in flight past its stop" };
|
|
4752
|
+
}
|
|
4398
4753
|
return inFlight;
|
|
4399
4754
|
}
|
|
4400
4755
|
const workingDirectory = this.fleetConfig?.instances[name]?.working_directory || this.getInstanceDir(name);
|
|
@@ -4403,6 +4758,8 @@ export class FleetManager {
|
|
|
4403
4758
|
// spawn gate is taken inside the transition, never the other way round, so
|
|
4404
4759
|
// the start being waited for can still get its own gate slot.
|
|
4405
4760
|
this.lifecycle.invalidate(name);
|
|
4761
|
+
const gap = { carry: opts?.carry, reached: false };
|
|
4762
|
+
this.restartGaps.set(name, gap);
|
|
4406
4763
|
const run = this.lifecycle.runTransition(name, transition => this.spawnGate.run({
|
|
4407
4764
|
instanceName: name,
|
|
4408
4765
|
workingDirectory,
|
|
@@ -4411,6 +4768,8 @@ export class FleetManager {
|
|
|
4411
4768
|
}, () => this.doRestartSingleInstance(name, opts, transition)))
|
|
4412
4769
|
.finally(() => {
|
|
4413
4770
|
this.restartsInFlight.delete(name);
|
|
4771
|
+
if (this.restartGaps.get(name) === gap)
|
|
4772
|
+
this.restartGaps.delete(name);
|
|
4414
4773
|
this.scheduleDeliveryOutboxPump();
|
|
4415
4774
|
});
|
|
4416
4775
|
this.restartsInFlight.set(name, run);
|
|
@@ -4480,10 +4839,41 @@ export class FleetManager {
|
|
|
4480
4839
|
async doRestartWithSlot(name, opts, transition, config, wasPaused) {
|
|
4481
4840
|
const pausedAt = wasPaused ? (this.lifecycle.getLastPausedAt(name) ?? readPausedAt(this.getInstanceDir(name))) : null;
|
|
4482
4841
|
const pauseReason = wasPaused ? readPauseReason(this.getInstanceDir(name)) : null;
|
|
4842
|
+
// #1753: the directory the CLI being stopped was launched in — its conversation is under that directory's key.
|
|
4843
|
+
const launchedIn = this.daemons.get(name)?.getConfigSnapshot?.()?.working_directory;
|
|
4844
|
+
// Between the stop and the start: the old CLI's transcript is complete, the new one has not looked yet.
|
|
4845
|
+
/** `launching`: the login the start will use — read only when a switch is to be carried. */
|
|
4846
|
+
const betweenStopAndStart = async (launching) => {
|
|
4847
|
+
// #1753: this restart's own carry, or one a caller joined it with before this point (restartSingleInstance).
|
|
4848
|
+
const gap = this.restartGaps.get(name);
|
|
4849
|
+
const carry = gap?.carry ?? opts?.carry;
|
|
4850
|
+
if (gap)
|
|
4851
|
+
gap.reached = true;
|
|
4852
|
+
if (!carry) {
|
|
4853
|
+
if (opts?.freshStart)
|
|
4854
|
+
this.writeFreshStartMarker(name);
|
|
4855
|
+
return;
|
|
4856
|
+
}
|
|
4857
|
+
// Only into the login this start really launches: a restart that read its configuration before the switch was
|
|
4858
|
+
// saved (one a switch joined) launches the old one — a copy into the new would be reported as carried, falsely.
|
|
4859
|
+
const launch = launching();
|
|
4860
|
+
if (launch.backend !== carry.backend || launch.profile !== carry.to) {
|
|
4861
|
+
const login = (p) => (p ? `profile "${p}"` : "the default login");
|
|
4862
|
+
carry.result = { carried: false, reason: `the restart launches ${launch.backend !== carry.backend ? launch.backend : login(launch.profile)}, not ${login(carry.to)}` };
|
|
4863
|
+
this.logger.info({ name, launches: launch, carry: { backend: carry.backend, from: carry.from, to: carry.to } }, "Conversation not carried: the restart launches another login");
|
|
4864
|
+
// The old login resumes its own conversation; any other starts fresh, as an uncarried switch does.
|
|
4865
|
+
if (launch.backend !== carry.backend || launch.profile !== carry.from)
|
|
4866
|
+
this.writeFreshStartMarker(name);
|
|
4867
|
+
return;
|
|
4868
|
+
}
|
|
4869
|
+
carry.result = await this.switchConversation(name, { ...carry, workingDirectory: launchedIn });
|
|
4870
|
+
};
|
|
4483
4871
|
if (config) {
|
|
4484
4872
|
await this.stopInstance(name, transition);
|
|
4485
|
-
|
|
4486
|
-
this.
|
|
4873
|
+
await betweenStopAndStart(() => {
|
|
4874
|
+
const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
4875
|
+
return { backend, profile: credentialHomeSpec(backend) ? instanceCredentialProfile(config, this.fleetConfig?.defaults, backend) : null };
|
|
4876
|
+
});
|
|
4487
4877
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
4488
4878
|
await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => this.startInstance(name, config, topicMode ?? false, "fleet-topic", false, transition));
|
|
4489
4879
|
this.stormWindow.markRecovered(name);
|
|
@@ -4496,8 +4886,10 @@ export class FleetManager {
|
|
|
4496
4886
|
const adapterId = this.classicChannels.getAdapterIdByInstance(name);
|
|
4497
4887
|
await this.stopInstance(name, transition);
|
|
4498
4888
|
await new Promise(r => setTimeout(r, 1000)); // let tmux clean up
|
|
4499
|
-
|
|
4500
|
-
this.
|
|
4889
|
+
await betweenStopAndStart(() => {
|
|
4890
|
+
const backend = this.classicChannels.getBackendByInstance(name, fleetBackend);
|
|
4891
|
+
return { backend, profile: credentialHomeSpec(backend) ? profileName(this.classicChannels.getCredentialProfileByInstance(name, this.fleetConfig?.defaults)) : null };
|
|
4892
|
+
});
|
|
4501
4893
|
await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, fleetBackend), this.classicChannels.getPreTaskCommand(channelId, adapterId), this.classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channelId, adapterId, this.fleetConfig?.defaults?.auto_pause_after), transition));
|
|
4502
4894
|
this.stormWindow.markRecovered(name);
|
|
4503
4895
|
return;
|
|
@@ -4518,6 +4910,43 @@ export class FleetManager {
|
|
|
4518
4910
|
this.logger.warn({ err, name }, "freshStart: failed to write crash-state marker");
|
|
4519
4911
|
}
|
|
4520
4912
|
}
|
|
4913
|
+
/**
|
|
4914
|
+
* #1753: a credential-profile switch, between the old CLI's stop and the new one's start. Where the backend can take
|
|
4915
|
+
* the conversation along (claude-code), copy it into the new profile and make it the one `--continue` picks; when that
|
|
4916
|
+
* cannot be done, or the backend cannot, start fresh as before. Null when nothing was tried.
|
|
4917
|
+
*/
|
|
4918
|
+
async switchConversation(name, sw) {
|
|
4919
|
+
if (!credentialSwitchCarriesConversation(sw.backend)) {
|
|
4920
|
+
if (credentialSwitchStartsFresh(sw.backend))
|
|
4921
|
+
this.writeFreshStartMarker(name);
|
|
4922
|
+
return null;
|
|
4923
|
+
}
|
|
4924
|
+
let sessionId = null;
|
|
4925
|
+
try {
|
|
4926
|
+
sessionId = readFileSync(join(this.getInstanceDir(name), "session-id"), "utf-8");
|
|
4927
|
+
}
|
|
4928
|
+
catch { /* none: nothing to carry */ }
|
|
4929
|
+
const dir = (profile) => claudeConfigDirFor(profile ? credentialProfileHome(this.dataDir, sw.backend, profile) : null);
|
|
4930
|
+
let result;
|
|
4931
|
+
try {
|
|
4932
|
+
result = await carryClaudeConversation({ sessionId, workingDirectory: sw.workingDirectory ?? "", fromConfigDir: dir(sw.from), toConfigDir: dir(sw.to) });
|
|
4933
|
+
}
|
|
4934
|
+
catch (err) {
|
|
4935
|
+
result = { carried: false, reason: `copy failed: ${err.message}` };
|
|
4936
|
+
}
|
|
4937
|
+
if (result.carried) {
|
|
4938
|
+
const fields = { name, from: sw.from ?? "default", to: sw.to ?? "default", bytes: result.bytes, ms: result.ms, replacedOlderCopy: result.replacedOlderCopy };
|
|
4939
|
+
if (result.bytes > CARRY_LARGE_BYTES)
|
|
4940
|
+
this.logger.warn(fields, "Conversation carried to the new credential profile (a large transcript)");
|
|
4941
|
+
else
|
|
4942
|
+
this.logger.info(fields, "Conversation carried to the new credential profile");
|
|
4943
|
+
}
|
|
4944
|
+
else {
|
|
4945
|
+
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");
|
|
4946
|
+
this.writeFreshStartMarker(name);
|
|
4947
|
+
}
|
|
4948
|
+
return result;
|
|
4949
|
+
}
|
|
4521
4950
|
/** Load .env file from data dir into process.env */
|
|
4522
4951
|
loadEnvFile(owner) {
|
|
4523
4952
|
const envPath = join(this.dataDir, ".env");
|
|
@@ -4549,6 +4978,9 @@ export class FleetManager {
|
|
|
4549
4978
|
this.webSessions = new WebSessionStore({
|
|
4550
4979
|
dataDir: this.dataDir,
|
|
4551
4980
|
onWarn: message => this.logger.warn(message),
|
|
4981
|
+
// #1620: fleet.yaml's web.session.local, read at every check — a Settings save or a reload applies at once
|
|
4982
|
+
// (shortening ends the sessions it now outlives). The public link's policy stays fixed.
|
|
4983
|
+
localPolicy: () => localSessionPolicy(this.fleetConfig?.web?.session?.local),
|
|
4552
4984
|
});
|
|
4553
4985
|
}
|
|
4554
4986
|
if (!this.returningDevices) {
|
|
@@ -4578,7 +5010,13 @@ export class FleetManager {
|
|
|
4578
5010
|
return { display: issued.display, expiresAt: issued.expiresAt, ttlMinutes: Math.round(LOGIN_CODE_TTL_MS / 60_000) };
|
|
4579
5011
|
}
|
|
4580
5012
|
tunnelPurposeLane = null;
|
|
5013
|
+
previewTunnel = null;
|
|
4581
5014
|
publicWebLink = null;
|
|
5015
|
+
/** #1723: the public link's preview tunnel — a manager and lease of its own, never the fleet's one tunnel lane. */
|
|
5016
|
+
getPreviewTunnel() {
|
|
5017
|
+
return this.previewTunnel ??= new ManagedTunnel({ dataDir: join(this.dataDir, PUBLIC_PREVIEW_TUNNEL_DIR),
|
|
5018
|
+
log: () => this.logger.warn("Public preview tunnel cleanup requires attention") });
|
|
5019
|
+
}
|
|
4582
5020
|
getTunnelLane() {
|
|
4583
5021
|
return this.tunnelPurposeLane ??= new TunnelPurposeLane(new ManagedTunnel({ dataDir: this.dataDir,
|
|
4584
5022
|
log: () => this.logger.warn("Managed tunnel cleanup requires attention") }));
|
|
@@ -4616,6 +5054,61 @@ export class FleetManager {
|
|
|
4616
5054
|
}
|
|
4617
5055
|
return null;
|
|
4618
5056
|
}
|
|
5057
|
+
/**
|
|
5058
|
+
* #1742: the General a fleet admin's DM speaks for (`/start web`): the same owner `/web` in that General would make
|
|
5059
|
+
* — this bot's configured group and its one General topic. No address to match in a DM, so it must be unambiguous:
|
|
5060
|
+
* no General, or more than one on this bot and group, and there is no owner (nothing is guessed).
|
|
5061
|
+
*/
|
|
5062
|
+
dmDashboardOwner(userId, adapterId) {
|
|
5063
|
+
const world = this.worlds.get(adapterId);
|
|
5064
|
+
if (!world || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(userId, adapterId))
|
|
5065
|
+
return null;
|
|
5066
|
+
const configured = (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).find(ch => (ch.id ?? ch.type) === adapterId);
|
|
5067
|
+
const group = String(configured?.group_id ?? "");
|
|
5068
|
+
if (!group)
|
|
5069
|
+
return null;
|
|
5070
|
+
const topics = Object.entries(this.fleetConfig?.instances ?? {})
|
|
5071
|
+
.filter(([name, cfg]) => cfg.general_topic && cfg.topic_id != null && this.getInstanceAdapterId(name) === adapterId
|
|
5072
|
+
&& String(this.getGroupIdForInstance(name) ?? "") === group)
|
|
5073
|
+
.map(([, cfg]) => cfg.topic_id.toString());
|
|
5074
|
+
if (topics.length !== 1)
|
|
5075
|
+
return null;
|
|
5076
|
+
const owner = { userId, adapterId, chatId: group, threadId: topics[0], binding: world.adapter };
|
|
5077
|
+
return this.publicOwnerCurrent(owner) ? owner : null;
|
|
5078
|
+
}
|
|
5079
|
+
/**
|
|
5080
|
+
* #1742: a fleet admin whose Telegram `/web` could not reach them privately may next open the bot's DM and press
|
|
5081
|
+
* Telegram's START, which sends a bare `/start` — read as `/start web` (the DM menu) for 10 minutes, once, for that
|
|
5082
|
+
* admin on that bot. Monotonic: a wall-clock jump neither opens nor shuts it.
|
|
5083
|
+
*/
|
|
5084
|
+
tgWebStartWindows = new Map();
|
|
5085
|
+
openTgWebStartWindow(adapterId, userId) {
|
|
5086
|
+
const now = performance.now();
|
|
5087
|
+
for (const [key, until] of this.tgWebStartWindows)
|
|
5088
|
+
if (until <= now)
|
|
5089
|
+
this.tgWebStartWindows.delete(key);
|
|
5090
|
+
this.tgWebStartWindows.set(`${adapterId}\0${userId}`, now + TG_WEB_START_WINDOW_MS);
|
|
5091
|
+
}
|
|
5092
|
+
/** Uses the window up: true when it was open. */
|
|
5093
|
+
takeTgWebStartWindow(adapterId, userId) {
|
|
5094
|
+
const key = `${adapterId}\0${userId}`;
|
|
5095
|
+
const until = this.tgWebStartWindows.get(key);
|
|
5096
|
+
this.tgWebStartWindows.delete(key);
|
|
5097
|
+
return until !== undefined && performance.now() < until;
|
|
5098
|
+
}
|
|
5099
|
+
/** #1742: `/start web` from a fleet admin: the /web menu right here in the DM, for the General this bot serves. */
|
|
5100
|
+
async startWebInDm(msg, adapter) {
|
|
5101
|
+
if (!adapter)
|
|
5102
|
+
return;
|
|
5103
|
+
const owner = msg.chatId === msg.userId ? this.dmDashboardOwner(msg.userId, msg.adapterId ?? "") : null;
|
|
5104
|
+
if (!owner) {
|
|
5105
|
+
await adapter.sendText(msg.chatId, t("dashboard.start_web_ack"));
|
|
5106
|
+
return;
|
|
5107
|
+
}
|
|
5108
|
+
if (!await this.showDashboardMenu(owner, undefined, undefined, msg.chatId)) {
|
|
5109
|
+
await adapter.sendText(msg.chatId, t("dashboard.menu_failed")).catch(() => undefined);
|
|
5110
|
+
}
|
|
5111
|
+
}
|
|
4619
5112
|
getPublicWebLink() {
|
|
4620
5113
|
this.initializeWebSessions();
|
|
4621
5114
|
return this.publicWebLink ??= new PublicWebLink({
|
|
@@ -4630,7 +5123,14 @@ export class FleetManager {
|
|
|
4630
5123
|
if (result && !result.durable)
|
|
4631
5124
|
this.logger.warn("Public session revocation could not be saved; exposure remains closed");
|
|
4632
5125
|
},
|
|
4633
|
-
log: (event, exposureId) => this.logger.info({ event, exposureId }, "Public web link"),
|
|
5126
|
+
log: (event, exposureId, detail) => this.logger.info({ event, exposureId, ...(detail ? { detail } : {}) }, "Public web link"),
|
|
5127
|
+
createPreview: ({ id, expiresAt, binary, protocol, dashboardOrigin, isCurrent }) => {
|
|
5128
|
+
if (!publicPreviewWanted(this.fleetConfig?.web))
|
|
5129
|
+
return null;
|
|
5130
|
+
return new PublicPreview({ exposureId: id, expiresAt, dashboardOrigin, isCurrent, manager: this.getPreviewTunnel(),
|
|
5131
|
+
provider: () => new CloudflaredProvider({ binaryName: binary, protocol }),
|
|
5132
|
+
log: (event, detail) => this.logger.info({ event, exposureId: id, ...(detail ? { detail } : {}) }, "Public web link") });
|
|
5133
|
+
},
|
|
4634
5134
|
onCleanupUnconfirmed: (result, owner) => {
|
|
4635
5135
|
this.logger.warn({ pid: result.confirmed ? null : result.pid }, "Public web access closed; tunnel cleanup unconfirmed");
|
|
4636
5136
|
const world = this.worlds.get(owner.adapterId);
|
|
@@ -4663,6 +5163,16 @@ export class FleetManager {
|
|
|
4663
5163
|
await owner.binding.sendText(owner.chatId, t("dashboard.menu_failed"), { threadId: owner.threadId }).catch(() => undefined);
|
|
4664
5164
|
}
|
|
4665
5165
|
}
|
|
5166
|
+
/**
|
|
5167
|
+
* #1686: build the Telegram deep-link that opens a DM with this bot pre-seeded with `/start web`.
|
|
5168
|
+
* Returns null when no botUsername is known or the username fails Telegram's format validation.
|
|
5169
|
+
*/
|
|
5170
|
+
tgStartWebUrl(adapterId) {
|
|
5171
|
+
const username = this.worlds.get(adapterId)?.botUsername;
|
|
5172
|
+
if (!username || !/^[A-Za-z0-9_]{5,32}$/.test(username))
|
|
5173
|
+
return null;
|
|
5174
|
+
return `https://t.me/${username}?start=web`;
|
|
5175
|
+
}
|
|
4666
5176
|
async sendLocalDashboard(data, adapterId) {
|
|
4667
5177
|
if (this.shuttingDown || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(data.userId, adapterId)) {
|
|
4668
5178
|
await data.respond(t("not_authorized"));
|
|
@@ -4734,13 +5244,53 @@ export class FleetManager {
|
|
|
4734
5244
|
this.logger.info({ err: err?.message }, "The code-only follow-up was not sent; the sign-in message holds the code");
|
|
4735
5245
|
}
|
|
4736
5246
|
}
|
|
5247
|
+
/**
|
|
5248
|
+
* #1610: re-arm the public link's close prompt on the SAME message — as its confirm step (確定關閉 / 取消) or back as
|
|
5249
|
+
* the close button — with a fresh nonce carrying the same owner gate (owner, requester, exposure, message, adapter),
|
|
5250
|
+
* and swap the message's buttons keyboard-only: its text is the sign-in message, which must stay. A swap that fails
|
|
5251
|
+
* disarms the new nonce and leaves the link open (the General menu and /dashboard revoke still close it).
|
|
5252
|
+
*/
|
|
5253
|
+
async armDashboardCloseStep(prev, confirm, data) {
|
|
5254
|
+
const nonce = randomBytes(16).toString("hex");
|
|
5255
|
+
const { timer: _timer, nonce: _old, ...rest } = prev;
|
|
5256
|
+
const entry = { ...rest, nonce, closeStep: confirm, createdAt: Date.now() };
|
|
5257
|
+
entry.timer = setTimeout(() => {
|
|
5258
|
+
if (this.pendingNonceButtons.get(nonce) !== entry)
|
|
5259
|
+
return;
|
|
5260
|
+
this.pendingNonceButtons.delete(nonce);
|
|
5261
|
+
this.collapseNoncePrompt(entry)?.catch(err => this.logger.debug({ err }, "Failed to expire the public link's close prompt"));
|
|
5262
|
+
}, 5 * 60_000);
|
|
5263
|
+
entry.timer.unref?.();
|
|
5264
|
+
this.pendingNonceButtons.set(nonce, entry);
|
|
5265
|
+
const choices = confirm
|
|
5266
|
+
? [{ id: `dashboard:${nonce}:confirm`, label: t("dashboard.public_close_confirm") }, { id: `dashboard:${nonce}:keep`, label: t("dashboard.public_close_keep") }]
|
|
5267
|
+
: [{ id: `dashboard:${nonce}:close`, label: t("dashboard.public_close") }];
|
|
5268
|
+
try {
|
|
5269
|
+
if (data.replaceClickedButtons)
|
|
5270
|
+
await data.replaceClickedButtons(choices);
|
|
5271
|
+
else if (prev.adapter.replaceMessageButtons)
|
|
5272
|
+
await prev.adapter.replaceMessageButtons(prev.chatId, prev.messageId ?? data.messageId, choices, prev.threadId);
|
|
5273
|
+
else
|
|
5274
|
+
throw new Error("adapter cannot replace buttons");
|
|
5275
|
+
}
|
|
5276
|
+
catch (err) {
|
|
5277
|
+
if (this.pendingNonceButtons.get(nonce) === entry)
|
|
5278
|
+
this.pendingNonceButtons.delete(nonce);
|
|
5279
|
+
clearTimeout(entry.timer);
|
|
5280
|
+
this.logger.warn({ err: err?.message }, "Could not swap the public link's close buttons; the link stays open");
|
|
5281
|
+
}
|
|
5282
|
+
}
|
|
4737
5283
|
/** Why the DM did not go, for the log only (Discord 50007 = the user does not accept DMs from this server's members). */
|
|
4738
5284
|
logDashboardDmFailure(err) {
|
|
4739
5285
|
const code = err?.code;
|
|
4740
5286
|
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");
|
|
4741
5287
|
}
|
|
4742
5288
|
/** False when the menu was not shown (the caller still owes the user an answer). */
|
|
4743
|
-
|
|
5289
|
+
/**
|
|
5290
|
+
* `dmChatId` (#1742): show it in the owner's own private chat with the bot instead of the General — the owner, its
|
|
5291
|
+
* gates and the code's route are the same; only where the menu sits (and its clicks are good) changes.
|
|
5292
|
+
*/
|
|
5293
|
+
async showDashboardMenu(owner, respondButtons, retireButtons, dmChatId) {
|
|
4744
5294
|
if (!this.publicOwnerCurrent(owner)) {
|
|
4745
5295
|
this.logger.info("Dashboard menu not shown: the General owner changed");
|
|
4746
5296
|
return false;
|
|
@@ -4748,16 +5298,18 @@ export class FleetManager {
|
|
|
4748
5298
|
const adapter = owner.binding;
|
|
4749
5299
|
const status = this.getPublicWebStatus();
|
|
4750
5300
|
const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
|
|
5301
|
+
const tgHint = adapter.type === "telegram" && dmChatId === undefined ? this.tgStartWebUrl(owner.adapterId) : null;
|
|
4751
5302
|
const menuText = this.topicCommands.getDashboardText(false, !publicAllowed)
|
|
4752
5303
|
+ (publicAllowed ? "\n\n" + t("dashboard.public_risk") : "")
|
|
4753
5304
|
+ "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
|
|
4754
|
-
+ (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "")
|
|
5305
|
+
+ (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "")
|
|
5306
|
+
+ (tgHint ? "\n" + t("dashboard.menu_tg_hint", tgHint) : "");
|
|
4755
5307
|
const choices = [{ action: "local", label: t("dashboard.local") },
|
|
4756
5308
|
...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
|
|
4757
5309
|
...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
|
|
4758
5310
|
const posted = await this.postNonceButtonPrompt({
|
|
4759
5311
|
prefix: "dashboard:", alertType: "login", instanceName: "dashboard", adapter, adapterId: owner.adapterId,
|
|
4760
|
-
chatId: owner.chatId, threadId: owner.threadId, timeoutMs: 5 * 60_000,
|
|
5312
|
+
chatId: dmChatId ?? owner.chatId, threadId: dmChatId === undefined ? owner.threadId : undefined, timeoutMs: 5 * 60_000,
|
|
4761
5313
|
message: menuText,
|
|
4762
5314
|
choices, expiredText: t("buttons.stale"), extra: { requesterUserId: owner.userId, dashboardOwner: owner, publicExposureId: this.publicWebLink?.exposureId },
|
|
4763
5315
|
...(respondButtons ? { deliver: async (c) => {
|
|
@@ -4771,7 +5323,11 @@ export class FleetManager {
|
|
|
4771
5323
|
return posted !== null;
|
|
4772
5324
|
}
|
|
4773
5325
|
async handleDashboardCallback(data, adapterId, adapter) {
|
|
4774
|
-
|
|
5326
|
+
// #1610: `confirm`/`keep` answer the close button's confirm step and nothing else; `close` is never a confirm step.
|
|
5327
|
+
// A click of the wrong kind is refused like a non-admin's — unconsumed, so the real buttons still work.
|
|
5328
|
+
const kind = /:([a-z]+)$/.exec(data.callbackData)?.[1];
|
|
5329
|
+
const stepAction = kind === "confirm" || kind === "keep";
|
|
5330
|
+
const claimed = this.consumeNonceCallback("dashboard:", /^dashboard:([0-9a-f]{32}):(local|public|close|confirm|keep)$/, data, adapterId, adapter, undefined, (userId, e) => this.isFleetAdmin(userId, adapterId) && stepAction === (e.closeStep === true));
|
|
4775
5331
|
if (claimed === null)
|
|
4776
5332
|
return false;
|
|
4777
5333
|
if (claimed === "consumed")
|
|
@@ -4782,7 +5338,15 @@ export class FleetManager {
|
|
|
4782
5338
|
data.ack?.(t("not_authorized"));
|
|
4783
5339
|
return true;
|
|
4784
5340
|
}
|
|
4785
|
-
if (action === "close") {
|
|
5341
|
+
if (action === "close" || action === "keep") {
|
|
5342
|
+
// A tap on 關閉公開連結 closes nothing: it offers 確定關閉 / 取消 on the same message (the button sits right above
|
|
5343
|
+
// the code a phone taps to copy). 取消 puts the close button back. Both only re-arm the same, owner-gated prompt.
|
|
5344
|
+
if (!entry.publicExposureId)
|
|
5345
|
+
return true;
|
|
5346
|
+
await this.armDashboardCloseStep(entry, action === "close", data);
|
|
5347
|
+
return true;
|
|
5348
|
+
}
|
|
5349
|
+
if (action === "confirm") {
|
|
4786
5350
|
if (!entry.publicExposureId)
|
|
4787
5351
|
return true;
|
|
4788
5352
|
const result = await this.publicWebLink?.close("admin close", entry.publicExposureId);
|
|
@@ -4808,6 +5372,8 @@ export class FleetManager {
|
|
|
4808
5372
|
}) : null;
|
|
4809
5373
|
let lastSteps;
|
|
4810
5374
|
let deliveredByDm = false;
|
|
5375
|
+
// #1742: a DM was tried and did not go — only then is "open the private chat" the way out.
|
|
5376
|
+
let dmRefused = false;
|
|
4811
5377
|
const send = async (url, exposureId, expiresAt, current = () => this.publicOwnerCurrent(owner)) => {
|
|
4812
5378
|
const token = this.webToken;
|
|
4813
5379
|
if (!token || !current())
|
|
@@ -4833,6 +5399,7 @@ export class FleetManager {
|
|
|
4833
5399
|
}
|
|
4834
5400
|
catch (err) {
|
|
4835
5401
|
this.logDashboardDmFailure(err);
|
|
5402
|
+
dmRefused = true;
|
|
4836
5403
|
if (direct.type !== "discord" || !data.respondPrivate || !current())
|
|
4837
5404
|
throw new Error("private delivery unavailable");
|
|
4838
5405
|
const sent = await withinBudget(data.respondPrivate(text, c), deadline);
|
|
@@ -4878,11 +5445,23 @@ export class FleetManager {
|
|
|
4878
5445
|
this.logger.warn({ err }, "Dashboard sign-in delivery failed");
|
|
4879
5446
|
}
|
|
4880
5447
|
// Only safe, static words enter General; never link, code or platform error text.
|
|
4881
|
-
|
|
4882
|
-
|
|
5448
|
+
// #1742: Telegram's failure walks through opening the DM (never a bare /start, which starts a ClassicBot agent),
|
|
5449
|
+
// with the "先開私訊 →" button when the bot's username is known; and for a while a bare /start there counts as
|
|
5450
|
+
// /start web. A menu shown in the DM itself says the details are right below it. Only when a DM was tried and did
|
|
5451
|
+
// not go: a link that never got that far (the tunnel lane held, the owner gone) says so instead.
|
|
5452
|
+
const telegram = owner.binding.type === "telegram";
|
|
5453
|
+
const dmSteps = !ok && telegram && dmRefused;
|
|
5454
|
+
const tgLink = dmSteps ? this.tgStartWebUrl(adapterId) : null;
|
|
5455
|
+
if (dmSteps)
|
|
5456
|
+
this.openTgWebStartWindow(adapterId, owner.userId);
|
|
5457
|
+
const outcome = t(!ok ? (!telegram ? "dashboard.private_failed" : !dmSteps ? "dashboard.link_failed" : tgLink ? "dashboard.private_failed_tg" : "dashboard.private_failed_tg_nolink")
|
|
5458
|
+
: telegram && entry.chatId === owner.userId ? "dashboard.private_sent_below"
|
|
5459
|
+
: owner.binding.type !== "discord" ? "dashboard.private_sent" : deliveredByDm ? "dashboard.private_sent_dm" : "dashboard.private_sent_here");
|
|
4883
5460
|
await progress;
|
|
4884
5461
|
await steps?.close(); // the last step edit has landed: the outcome cannot be overwritten by it
|
|
4885
|
-
|
|
5462
|
+
// #1686: attach the deep-link button on the failure outcome (Telegram only; Discord and the editClicked path ignore urlButtons).
|
|
5463
|
+
const failUrlButtons = tgLink ? [{ label: t("dashboard.start_web_label"), url: tgLink }] : undefined;
|
|
5464
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, lastSteps ? renderPublicLinkProgress(lastSteps, performance.now(), outcome) : outcome, data.editClicked, failUrlButtons);
|
|
4886
5465
|
return true;
|
|
4887
5466
|
}
|
|
4888
5467
|
/** `/dashboard revoke`: sign out every browser and withdraw any unused code. */
|
|
@@ -5124,6 +5703,86 @@ export class FleetManager {
|
|
|
5124
5703
|
setUpdateProgressStage(this.dataDir, "failed", { error: message });
|
|
5125
5704
|
}
|
|
5126
5705
|
/** The old fleet edits CLI install stages; the new fleet adopts the same message below. */
|
|
5706
|
+
/**
|
|
5707
|
+
* #1660: arm the post-completion cleanup watch and make sure its monitor
|
|
5708
|
+
* actually runs. The new fleet adopts the receipt at boot but nothing else
|
|
5709
|
+
* starts this timer on that path (Prism a) — without this, the owed
|
|
5710
|
+
* outcome would sit in the marker unwatched. READY never waits for it.
|
|
5711
|
+
*/
|
|
5712
|
+
/**
|
|
5713
|
+
* #1735: what the completion of an adopted update does about its retire outcome, read from the marker as it is now.
|
|
5714
|
+
* Already landed with a warning (a detached restart returns before this fleet boots, so the updater retires first)
|
|
5715
|
+
* → render it in the completion. Still owed (`cleanup: "pending"` — a service restart waits for READY, which comes
|
|
5716
|
+
* after the completion) → watch for it. Anything else (no retire on this update, done silently, another receipt)
|
|
5717
|
+
* → nothing.
|
|
5718
|
+
*/
|
|
5719
|
+
/**
|
|
5720
|
+
* #1716: background work this restart/update ended, scoped to the operation
|
|
5721
|
+
* so an older stop never reports (`sinceAt` null = plain boot, report
|
|
5722
|
+
* nothing). A method — not a module function — because the #1737
|
|
5723
|
+
* extracted-completion harness runs this region verbatim with only
|
|
5724
|
+
* `this`-methods plus a fixed context.
|
|
5725
|
+
*/
|
|
5726
|
+
collectBackgroundWorkEndedNames(names, sinceAt) {
|
|
5727
|
+
if (sinceAt === null)
|
|
5728
|
+
return [];
|
|
5729
|
+
return names.filter(n => readBackgroundWorkEndedMarker(this.getInstanceDir(n), sinceAt) !== null);
|
|
5730
|
+
}
|
|
5731
|
+
updateCleanupAtCompletion(pending, operation) {
|
|
5732
|
+
if (!pending || operation !== "update")
|
|
5733
|
+
return null;
|
|
5734
|
+
const receipt = updateReceiptOf(pending);
|
|
5735
|
+
if (!receipt)
|
|
5736
|
+
return null;
|
|
5737
|
+
const current = readUpdateProgress(this.dataDir);
|
|
5738
|
+
const verdict = cleanupWatchVerdict(current, receipt);
|
|
5739
|
+
if (verdict.action === "append")
|
|
5740
|
+
return { render: { warning: verdict.warning, cleanupCommand: verdict.cleanupCommand } };
|
|
5741
|
+
if (verdict.action === "wait" && current?.progress.cleanup === "pending")
|
|
5742
|
+
return { watch: receipt };
|
|
5743
|
+
return null;
|
|
5744
|
+
}
|
|
5745
|
+
armCleanupWatch(receipt, summary) {
|
|
5746
|
+
this.updateCleanupWatch = { receipt, summary };
|
|
5747
|
+
if (!this.updateProgressTimer)
|
|
5748
|
+
this.startUpdateProgressMonitor();
|
|
5749
|
+
}
|
|
5750
|
+
/**
|
|
5751
|
+
* #1660: append a recorded cleanup outcome to the adopted update message,
|
|
5752
|
+
* then release the receipt — but only if this receipt still owns the
|
|
5753
|
+
* marker. The await yields, so a newer receipt (e.g. a full-restart that
|
|
5754
|
+
* landed mid-edit) must not be cleared or disturbed (Prism b).
|
|
5755
|
+
*/
|
|
5756
|
+
async appendCleanupWarning(target, watch, warning, cleanupCommand) {
|
|
5757
|
+
const text = formatRestartProgressCompletion("update", {
|
|
5758
|
+
...watch.summary,
|
|
5759
|
+
warningText: warning,
|
|
5760
|
+
warningCommand: cleanupCommand,
|
|
5761
|
+
}, watch.receipt.startedAt);
|
|
5762
|
+
const stopWatching = () => {
|
|
5763
|
+
this.updateCleanupWatch = null;
|
|
5764
|
+
if (this.updateProgressTimer)
|
|
5765
|
+
clearInterval(this.updateProgressTimer);
|
|
5766
|
+
this.updateProgressTimer = null;
|
|
5767
|
+
};
|
|
5768
|
+
if (text !== this.lastUpdateProgressText) {
|
|
5769
|
+
const adapter = this.adapters.get(target.adapterId);
|
|
5770
|
+
if (!adapter)
|
|
5771
|
+
return; // retry on a later tick
|
|
5772
|
+
await adapter.editMessage(target.chatId, target.messageId, text, target.threadId);
|
|
5773
|
+
this.lastUpdateProgressText = text;
|
|
5774
|
+
}
|
|
5775
|
+
const verdict = cleanupWatchVerdict(readUpdateProgress(this.dataDir), watch.receipt);
|
|
5776
|
+
if (verdict.action === "drop") {
|
|
5777
|
+
// Someone else owns the marker now — leave it entirely alone.
|
|
5778
|
+
this.updateCleanupWatch = null;
|
|
5779
|
+
return;
|
|
5780
|
+
}
|
|
5781
|
+
if (verdict.action === "wait")
|
|
5782
|
+
return; // not terminal; keep watching
|
|
5783
|
+
clearUpdateMarker(this.dataDir);
|
|
5784
|
+
stopWatching();
|
|
5785
|
+
}
|
|
5127
5786
|
startUpdateProgressMonitor(initialAdapter) {
|
|
5128
5787
|
if (this.updateProgressTimer)
|
|
5129
5788
|
clearInterval(this.updateProgressTimer);
|
|
@@ -5137,6 +5796,48 @@ export class FleetManager {
|
|
|
5137
5796
|
this.updateProgressTimer = null;
|
|
5138
5797
|
return;
|
|
5139
5798
|
}
|
|
5799
|
+
// #1660: the completion summary was already posted; only watch for
|
|
5800
|
+
// the retire outcome owed to this exact receipt. Never stage-edit
|
|
5801
|
+
// here (that would clobber the posted completion), and never touch a
|
|
5802
|
+
// receipt that no longer matches (checklist 3/5).
|
|
5803
|
+
const watch = this.updateCleanupWatch;
|
|
5804
|
+
if (watch) {
|
|
5805
|
+
const stopWatching = () => {
|
|
5806
|
+
this.updateCleanupWatch = null;
|
|
5807
|
+
if (this.updateProgressTimer)
|
|
5808
|
+
clearInterval(this.updateProgressTimer);
|
|
5809
|
+
this.updateProgressTimer = null;
|
|
5810
|
+
};
|
|
5811
|
+
const verdict = cleanupWatchVerdict(marker, watch.receipt);
|
|
5812
|
+
if (verdict.action === "drop") {
|
|
5813
|
+
// Receipt gone or replaced (e.g. a later full-restart adopted the
|
|
5814
|
+
// marker): not ours — leave it to its own lifecycle, drop the watch.
|
|
5815
|
+
this.logger.info("[update] cleanup watch: marker receipt changed, dropping watch");
|
|
5816
|
+
this.updateCleanupWatch = null;
|
|
5817
|
+
}
|
|
5818
|
+
else if (verdict.action === "release") {
|
|
5819
|
+
// Done silent, or the updater died past the max age: release it.
|
|
5820
|
+
clearUpdateMarker(this.dataDir);
|
|
5821
|
+
stopWatching();
|
|
5822
|
+
return;
|
|
5823
|
+
}
|
|
5824
|
+
else if (verdict.action === "wait") {
|
|
5825
|
+
return;
|
|
5826
|
+
}
|
|
5827
|
+
else {
|
|
5828
|
+
this.updateProgressEditRunning = true;
|
|
5829
|
+
try {
|
|
5830
|
+
await this.appendCleanupWarning(marker.progress.target, watch, verdict.warning, verdict.cleanupCommand);
|
|
5831
|
+
}
|
|
5832
|
+
catch (err) {
|
|
5833
|
+
this.logger.warn({ err }, "[update] cleanup watch: failed to append warning, will retry");
|
|
5834
|
+
}
|
|
5835
|
+
finally {
|
|
5836
|
+
this.updateProgressEditRunning = false;
|
|
5837
|
+
}
|
|
5838
|
+
return;
|
|
5839
|
+
}
|
|
5840
|
+
}
|
|
5140
5841
|
const target = marker.progress.target;
|
|
5141
5842
|
const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
|
|
5142
5843
|
if (!adapter)
|
|
@@ -5203,6 +5904,8 @@ export class FleetManager {
|
|
|
5203
5904
|
const fleet = this.loadConfig(configPath);
|
|
5204
5905
|
this.slimFleetConfigAtStartup();
|
|
5205
5906
|
setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
|
|
5907
|
+
// #1737 review: the marker this boot adopts — the startup clear may only ever clear this one.
|
|
5908
|
+
const bootUpdateMarker = updateMarkerIdentity(this.dataDir);
|
|
5206
5909
|
const savedUpdateProgress = readUpdateProgress(this.dataDir);
|
|
5207
5910
|
const pendingUpdateProgress = savedUpdateProgress
|
|
5208
5911
|
&& savedUpdateProgress.progress.stage !== "failed"
|
|
@@ -5227,7 +5930,11 @@ export class FleetManager {
|
|
|
5227
5930
|
// Pre-flight (advisory, fire-and-forget): warm the per-backend auth cache
|
|
5228
5931
|
// before instances spawn, so a CLI that comes up on its sign-in screen gets
|
|
5229
5932
|
// an instant 🔑 verdict instead of decaying into crash/MCP-died noise.
|
|
5230
|
-
this.lifecycle.primeAuthVerification(Object.values(fleet.instances).map(config =>
|
|
5933
|
+
this.lifecycle.primeAuthVerification(Object.values(fleet.instances).map(config => {
|
|
5934
|
+
const backend = config.backend ?? fleet.defaults?.backend ?? "claude-code";
|
|
5935
|
+
// #1699 follow-up: each instance's own login — a profile is probed with its own environment.
|
|
5936
|
+
return { backend, profile: credentialHomeSpec(backend) ? instanceCredentialProfile(config, fleet.defaults, backend) : null };
|
|
5937
|
+
}));
|
|
5231
5938
|
// Start tmux control mode client for idle detection
|
|
5232
5939
|
if (!this.controlClient) {
|
|
5233
5940
|
this.controlClient = new TmuxControlClient(getTmuxSession(), 2000, this.logger);
|
|
@@ -5632,19 +6339,37 @@ export class FleetManager {
|
|
|
5632
6339
|
const _require = createRequire(import.meta.url);
|
|
5633
6340
|
const agendVersion = _require("../package.json").version ?? "unknown";
|
|
5634
6341
|
await progressStart;
|
|
5635
|
-
|
|
6342
|
+
// #1660/#1735: the nvm route's retire outcome, read NOW (not from the boot snapshot). Either order happens:
|
|
6343
|
+
// a detached fleet's `agend restart` returns before this fleet boots, so the outcome may already be here —
|
|
6344
|
+
// render it in the completion itself; a service restart waits for READY (after this), so it may still be owed
|
|
6345
|
+
// — post without it and arm the watch, which appends it to the same receipt once it lands. READY never waits
|
|
6346
|
+
// for cleanup (that would deadlock with an updater that retires after its restart returns).
|
|
6347
|
+
const cleanupOutcome = this.updateCleanupAtCompletion(pendingUpdateProgress, pendingProgressOperation);
|
|
6348
|
+
// #1716: background work this restart/update ended, scoped to the
|
|
6349
|
+
// operation so an older stop never reports. No pending operation means
|
|
6350
|
+
// a plain boot — nothing was stopped by an operation, report nothing.
|
|
6351
|
+
const backgroundWorkEndedNames = this.collectBackgroundWorkEndedNames(configuredNames, pendingUpdateProgress?.startedAt ?? null);
|
|
6352
|
+
const completionSummary = {
|
|
5636
6353
|
running: started,
|
|
5637
6354
|
total,
|
|
5638
6355
|
version: agendVersion,
|
|
5639
6356
|
pausedNames,
|
|
5640
6357
|
failedNames,
|
|
6358
|
+
backgroundWorkEndedNames,
|
|
5641
6359
|
tipText: pendingProgressOperation === "update" && this.tipsEnabled()
|
|
5642
6360
|
? (() => {
|
|
5643
6361
|
const tip = this.pickAvailableTip();
|
|
5644
6362
|
return tip ? this.formatTip(tip) : undefined;
|
|
5645
6363
|
})()
|
|
5646
6364
|
: undefined,
|
|
5647
|
-
|
|
6365
|
+
...(cleanupOutcome && "render" in cleanupOutcome
|
|
6366
|
+
? { warningText: cleanupOutcome.render.warning, warningCommand: cleanupOutcome.render.cleanupCommand }
|
|
6367
|
+
: {}),
|
|
6368
|
+
};
|
|
6369
|
+
const progressCompleted = await startupProgress.finish(completionSummary);
|
|
6370
|
+
// Armed only after the completion posted: an append racing ahead of it would be overwritten by it.
|
|
6371
|
+
if (cleanupOutcome && "watch" in cleanupOutcome)
|
|
6372
|
+
this.armCleanupWatch(cleanupOutcome.watch, completionSummary);
|
|
5648
6373
|
if (!progressCompleted && fleet.channel?.group_id) {
|
|
5649
6374
|
let text;
|
|
5650
6375
|
if (pendingProgressOperation === "full-restart") {
|
|
@@ -5654,6 +6379,7 @@ export class FleetManager {
|
|
|
5654
6379
|
version: agendVersion,
|
|
5655
6380
|
pausedNames,
|
|
5656
6381
|
failedNames,
|
|
6382
|
+
backgroundWorkEndedNames,
|
|
5657
6383
|
}, pendingUpdateProgress.startedAt);
|
|
5658
6384
|
}
|
|
5659
6385
|
else if (failedNames.length === 0 && pausedNames.length === 0) {
|
|
@@ -5666,6 +6392,18 @@ export class FleetManager {
|
|
|
5666
6392
|
text = t("fleet.ready_with_failed", started, total, agendVersion, failedNames.join(", "))
|
|
5667
6393
|
+ (pausedNames.length > 0 ? `\n⏸ Paused: ${pausedNames.join(", ")}` : "");
|
|
5668
6394
|
}
|
|
6395
|
+
// #1716: the fallback carries the same background-work names as the
|
|
6396
|
+
// completion (inline: this block is executed verbatim by the #1737
|
|
6397
|
+
// extracted-completion harness, which resolves `this`-methods plus a
|
|
6398
|
+
// fixed context — no new bare module references here).
|
|
6399
|
+
if (backgroundWorkEndedNames.length) {
|
|
6400
|
+
text += `\n⚠️ Background work was running and was ended: ${backgroundWorkEndedNames.join(", ")}`;
|
|
6401
|
+
}
|
|
6402
|
+
// #1735: an outcome that already landed goes in this message too; one still owed is the watch's.
|
|
6403
|
+
if (cleanupOutcome && "render" in cleanupOutcome) {
|
|
6404
|
+
text += `\n\n⚠️ ${cleanupOutcome.render.warning}`
|
|
6405
|
+
+ (cleanupOutcome.render.cleanupCommand ? `\n\`$ ${cleanupOutcome.render.cleanupCommand}\`` : "");
|
|
6406
|
+
}
|
|
5669
6407
|
await this.sendFleetStartCompletionFallback(String(fleet.channel.group_id), text, generalThreadId != null ? String(generalThreadId) : undefined);
|
|
5670
6408
|
}
|
|
5671
6409
|
// After "fleet ready", so the notice is not the first thing people see of a restart.
|
|
@@ -5687,7 +6425,7 @@ export class FleetManager {
|
|
|
5687
6425
|
// A SIGHUP may arrive after the PID/general is available but before the
|
|
5688
6426
|
// rest of startup finishes. Replay one coalesced reload only after all
|
|
5689
6427
|
// startup-owned lifecycle work and signal handlers are in place.
|
|
5690
|
-
this.finishStartup();
|
|
6428
|
+
this.finishStartup(bootUpdateMarker);
|
|
5691
6429
|
// Tell systemd we are ready only now. This used to fire right after the
|
|
5692
6430
|
// generals started — before adapters, classic instances, topic creation and the
|
|
5693
6431
|
// health server — so `systemctl start` returned success while the fleet was
|
|
@@ -6415,6 +7153,14 @@ export class FleetManager {
|
|
|
6415
7153
|
});
|
|
6416
7154
|
}
|
|
6417
7155
|
}
|
|
7156
|
+
else if (msg.type === "runtime_switch_result") {
|
|
7157
|
+
// #1690: what our own runtime /model came to (the daemon answered claude's "Switch model?" or saw the result).
|
|
7158
|
+
const waiter = typeof msg.id === "string" ? this.runtimeSwitchWaiters.get(msg.id) : undefined;
|
|
7159
|
+
if (waiter) {
|
|
7160
|
+
this.runtimeSwitchWaiters.delete(msg.id);
|
|
7161
|
+
waiter(msg);
|
|
7162
|
+
}
|
|
7163
|
+
}
|
|
6418
7164
|
else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
|
|
6419
7165
|
this.cacheInstanceExecutionState(name, msg);
|
|
6420
7166
|
if (msg.type === "instance_state_response") {
|
|
@@ -6927,6 +7673,24 @@ export class FleetManager {
|
|
|
6927
7673
|
});
|
|
6928
7674
|
}
|
|
6929
7675
|
async handleInboundMessage(msg) {
|
|
7676
|
+
msg = { ...msg, humanOriginalBody: msg.humanOriginalBody ?? msg.text, humanOrigin: msg.isBotMessage ? undefined : msg.humanOrigin && { ...msg.humanOrigin } };
|
|
7677
|
+
if (msg.isBotMessage)
|
|
7678
|
+
return withSpawnHoldReply(undefined, () => this.handleInboundMessageCore(msg));
|
|
7679
|
+
return withSpawnHoldReply(text => this.inboundNoticeAdapter(msg)?.sendText(msg.chatId, text, { threadId: msg.threadId }) ?? Promise.resolve(), () => this.handleInboundMessageCore(msg));
|
|
7680
|
+
}
|
|
7681
|
+
/**
|
|
7682
|
+
* #1752: the bot that speaks back about an inbound message — in a fleet topic the owning instance's adapter, never
|
|
7683
|
+
* the sibling that happened to win the dedup race; elsewhere (ClassicBot rooms, no routed instance) the receiver.
|
|
7684
|
+
*/
|
|
7685
|
+
inboundNoticeAdapter(msg) {
|
|
7686
|
+
const id = this.governingAccess(msg, this.inboundRouteThreadId(msg)).adapterId ?? "";
|
|
7687
|
+
return this.worlds.get(id)?.adapter ?? this.adapters.get(id) ?? this.adapter;
|
|
7688
|
+
}
|
|
7689
|
+
async handleInboundMessageCore(msg) {
|
|
7690
|
+
// #1682: who wrote, for the web chat's mention pills (a person, never one of the fleet's bots: those are named by
|
|
7691
|
+
// the instance they belong to).
|
|
7692
|
+
if (!msg.isBotMessage && msg.userId && msg.username)
|
|
7693
|
+
this.seenUsers.record(msg.userId, msg.username);
|
|
6930
7694
|
// Platform author identity is the authority. This runs before routing,
|
|
6931
7695
|
// collab/commands/access/dedup and does not depend on any echo setting/ACK.
|
|
6932
7696
|
const fleetBotIds = new Set();
|
|
@@ -6954,6 +7718,15 @@ export class FleetManager {
|
|
|
6954
7718
|
return;
|
|
6955
7719
|
}
|
|
6956
7720
|
const threadId = this.inboundRouteThreadId(msg);
|
|
7721
|
+
// #1752: a post by one of this fleet's own bots is never input to a fleet topic's instance. They speak there only
|
|
7722
|
+
// for an instance (its replies, notices, warnings) — taking that as a user turn makes one bot's notice another
|
|
7723
|
+
// agent's "user" message, and a loop. Fleet /collab and open mode admit OTHER bots and webhooks; they never meant
|
|
7724
|
+
// our own. Exempt: ClassicBot rooms, where bots this fleet runs deliberately talk to each other by @mention
|
|
7725
|
+
// (same-channel multi-bot collab), and no-thread Telegram (classic candidates; Telegram bots never see each other).
|
|
7726
|
+
if (msg.userId && fleetBotIds.has(msg.userId) && threadId && !this.classicChannels?.hasChannel(threadId)) {
|
|
7727
|
+
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");
|
|
7728
|
+
return;
|
|
7729
|
+
}
|
|
6957
7730
|
const rootCommand = this.telegramRootCommandTarget(msg, threadId);
|
|
6958
7731
|
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");
|
|
6959
7732
|
// Ownership and per-adapter filtering both run before the dedup claim below.
|
|
@@ -7082,6 +7855,23 @@ export class FleetManager {
|
|
|
7082
7855
|
}
|
|
7083
7856
|
// Handle /start command
|
|
7084
7857
|
if (text === "/start" || text.startsWith("/start ")) {
|
|
7858
|
+
// #1686: intercept /start web before the ClassicBot admission flow runs.
|
|
7859
|
+
// /start alone already opens the DM for future bot-to-user messages — no further action needed.
|
|
7860
|
+
const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
|
|
7861
|
+
// #1742: a bare /start from a fleet admin whose /web just failed to reach them privately is that /start web
|
|
7862
|
+
// (Telegram's START button sends it bare) — once, within the window; any other bare /start is ClassicBot's.
|
|
7863
|
+
const admin = isPrivateChat && this.isFleetAdmin(msg.userId, msg.adapterId ?? "");
|
|
7864
|
+
const webStart = isPrivateChat && (requestedBackend === "web"
|
|
7865
|
+
|| (text === "/start" && admin && this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId)));
|
|
7866
|
+
if (webStart) {
|
|
7867
|
+
if (requestedBackend === "web")
|
|
7868
|
+
this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId);
|
|
7869
|
+
// #1742: the /web menu right here; the code comes to this DM.
|
|
7870
|
+
if (admin)
|
|
7871
|
+
await this.startWebInDm(msg, msgAdapter ?? undefined);
|
|
7872
|
+
// Non-fleet-admins: /start alone is enough to open the DM channel; no further action.
|
|
7873
|
+
return;
|
|
7874
|
+
}
|
|
7085
7875
|
// All new starts, including explicit backend and later chooser callbacks,
|
|
7086
7876
|
// share validateClassicStart. Existing channels are recognized first.
|
|
7087
7877
|
const blocker = this.validateClassicStart(chatId, msg.userId, undefined, msg.adapterId);
|
|
@@ -7090,7 +7880,6 @@ export class FleetManager {
|
|
|
7090
7880
|
return;
|
|
7091
7881
|
}
|
|
7092
7882
|
const channelName = msg.username || chatId;
|
|
7093
|
-
const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
|
|
7094
7883
|
if (requestedBackend) {
|
|
7095
7884
|
// handleClassicStart binds the instance to this adapter authoritatively.
|
|
7096
7885
|
const reply = await this.handleClassicStart(chatId, channelName, msg.userId, undefined, msg.adapterId, requestedBackend);
|
|
@@ -7158,6 +7947,21 @@ export class FleetManager {
|
|
|
7158
7947
|
}
|
|
7159
7948
|
return;
|
|
7160
7949
|
}
|
|
7950
|
+
// #1806: /backend — channel admin, as /model. A ClassicBot agent is not switched by it (it says so).
|
|
7951
|
+
if (text === "/backend" || text.startsWith("/backend ") || text.startsWith("/backend@")) {
|
|
7952
|
+
if (!this.isModelAdmin(msg.userId, chatId, msg.adapterId)) {
|
|
7953
|
+
await msgAdapter?.sendText(chatId, t("permission.denied"));
|
|
7954
|
+
return;
|
|
7955
|
+
}
|
|
7956
|
+
const backendInstance = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
|
|
7957
|
+
if (!backendInstance) {
|
|
7958
|
+
await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
|
|
7959
|
+
return;
|
|
7960
|
+
}
|
|
7961
|
+
if (msgAdapter)
|
|
7962
|
+
await this.runBackendCommand(backendInstance, text, msg.userId, chatId, msgAdapter, chatId, undefined, msg.adapterId);
|
|
7963
|
+
return;
|
|
7964
|
+
}
|
|
7161
7965
|
// Handle /compact command (admin only)
|
|
7162
7966
|
const classicCompact = parseCompactCommand(text);
|
|
7163
7967
|
if (classicCompact) {
|
|
@@ -7343,7 +8147,7 @@ export class FleetManager {
|
|
|
7343
8147
|
const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, generalInstance);
|
|
7344
8148
|
const generalReactions = this.pendingReactionsMeta(generalInstance);
|
|
7345
8149
|
try {
|
|
7346
|
-
await this.
|
|
8150
|
+
await this.deliverHumanToInstance(generalInstance, {
|
|
7347
8151
|
type: "fleet_inbound",
|
|
7348
8152
|
content: text,
|
|
7349
8153
|
targetSession: generalInstance,
|
|
@@ -7364,7 +8168,7 @@ export class FleetManager {
|
|
|
7364
8168
|
...generalReactions.meta,
|
|
7365
8169
|
...extraMeta,
|
|
7366
8170
|
},
|
|
7367
|
-
});
|
|
8171
|
+
}, msg.humanOrigin, msg.humanOriginalBody ?? msg.text);
|
|
7368
8172
|
generalReactions.consume();
|
|
7369
8173
|
this.afterUserInboundDelivered(generalInstance, msg);
|
|
7370
8174
|
this.logger.info(`${msg.username} → ${generalInstance}: ${(text ?? "").slice(0, 100)}`);
|
|
@@ -7444,7 +8248,7 @@ export class FleetManager {
|
|
|
7444
8248
|
const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, instanceName);
|
|
7445
8249
|
const reactions = this.pendingReactionsMeta(instanceName);
|
|
7446
8250
|
try {
|
|
7447
|
-
await this.
|
|
8251
|
+
await this.deliverHumanToInstance(instanceName, {
|
|
7448
8252
|
type: "fleet_inbound",
|
|
7449
8253
|
content: text,
|
|
7450
8254
|
targetSession: instanceName, // Channel messages → instance's own session
|
|
@@ -7463,7 +8267,7 @@ export class FleetManager {
|
|
|
7463
8267
|
...reactions.meta,
|
|
7464
8268
|
...extraMeta,
|
|
7465
8269
|
},
|
|
7466
|
-
});
|
|
8270
|
+
}, msg.humanOrigin, msg.humanOriginalBody ?? msg.text);
|
|
7467
8271
|
// Only after the message actually went out. A failed delivery keeps the
|
|
7468
8272
|
// reactions queued for the retry / the next message.
|
|
7469
8273
|
reactions.consume();
|
|
@@ -7506,9 +8310,14 @@ export class FleetManager {
|
|
|
7506
8310
|
if (Date.now() - lastWarn < 30 * 60_000)
|
|
7507
8311
|
return;
|
|
7508
8312
|
this.rateLimitWarnedAt.set(instanceName, Date.now());
|
|
7509
|
-
|
|
7510
|
-
|
|
7511
|
-
|
|
8313
|
+
// #1752: the instance's own bot, in the instance's own topic. `msg.adapterId` is whichever sibling bot won the
|
|
8314
|
+
// inbound dedup race; a warning from that bot is a stranger's post in this topic (and, to the owning adapter, a
|
|
8315
|
+
// bot message it would take as input).
|
|
8316
|
+
const warnAdapter = this.getAdapterForInstance(instanceName);
|
|
8317
|
+
const dest = this.cancelButtonDestination(instanceName);
|
|
8318
|
+
const { chatId, threadId } = dest?.threadId ? dest : { chatId: msg.chatId, threadId: msg.threadId ?? undefined };
|
|
8319
|
+
if (warnAdapter && chatId) {
|
|
8320
|
+
warnAdapter.sendText(chatId, warning, { threadId }).catch(() => { });
|
|
7512
8321
|
}
|
|
7513
8322
|
}
|
|
7514
8323
|
/** Handle outbound tool calls from a daemon instance */
|
|
@@ -7662,10 +8471,12 @@ export class FleetManager {
|
|
|
7662
8471
|
respond(null, "No adapter available");
|
|
7663
8472
|
return;
|
|
7664
8473
|
}
|
|
8474
|
+
// Only channel-addressing schemas receive ClassicBot routing metadata.
|
|
8475
|
+
// Non-channel calls (notably strict delivery_status) retain their arguments.
|
|
7665
8476
|
// For classic instances: force chat_id to channelId and clear thread_id
|
|
7666
8477
|
// (daemon may have set chat_id to guild_id which is wrong for DC; TG may have set thread_id which causes 'thread not found')
|
|
7667
8478
|
const classicChannelId = this.classicChannels?.getChannelIdByInstance(senderInstanceName ?? instanceName);
|
|
7668
|
-
if (classicChannelId) {
|
|
8479
|
+
if (classicChannelId && usesChannelAddress(tool)) {
|
|
7669
8480
|
args.chat_id = classicChannelId;
|
|
7670
8481
|
// #1085: a Telegram forum ClassicBot replies in the topic it was asked
|
|
7671
8482
|
// in. Any other thread_id (threadless Telegram, where it echoes the chat
|
|
@@ -7764,7 +8575,7 @@ export class FleetManager {
|
|
|
7764
8575
|
// an agent cannot invent it to suppress the normal completion marker.
|
|
7765
8576
|
if (!error && result != null && msg.statusOnly !== true) {
|
|
7766
8577
|
try {
|
|
7767
|
-
this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
|
|
8578
|
+
void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
|
|
7768
8579
|
}
|
|
7769
8580
|
catch (err) {
|
|
7770
8581
|
this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
|
|
@@ -7817,6 +8628,7 @@ export class FleetManager {
|
|
|
7817
8628
|
webOnlyPromptPlace() {
|
|
7818
8629
|
return this.worlds.size === 0 && this.isWebOnlyFleet() ? { adapter: WEB_ONLY_PROMPT_SINK, adapterId: "web", chatId: "web" } : null;
|
|
7819
8630
|
}
|
|
8631
|
+
/** Resolves when the reply is in the web chat (#1770: after its files are copied); the caller need not wait. */
|
|
7820
8632
|
afterReplyRouted(instanceName, args, senderSessionName, buttons = null) {
|
|
7821
8633
|
// A reply is NOT proof the turn is over (#410) — but it is not proof of
|
|
7822
8634
|
// more work either. Split the difference: an instance that is clearly
|
|
@@ -7834,27 +8646,74 @@ export class FleetManager {
|
|
|
7834
8646
|
this.reactDone(instanceName);
|
|
7835
8647
|
const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
|
|
7836
8648
|
this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
|
|
7837
|
-
// Files the agent attached are shown in the web chat too: registered for fetching by id (the path
|
|
7838
|
-
// already passed the reply tool's sendability check, and is resolved once more here), never by path.
|
|
7839
|
-
const replyFiles = Array.isArray(args.files) ? args.files.filter((f) => typeof f === "string") : [];
|
|
7840
|
-
const attachments = replyFiles
|
|
7841
|
-
.map(path => this.webFiles.registerServed({ path, instance: instanceName }))
|
|
7842
|
-
.filter((f) => f !== null)
|
|
7843
|
-
.map(publicAttachment);
|
|
7844
|
-
// The one place a delivered agent reply reaches the web chat: the server marks it `agent` (#1306) — the only
|
|
7845
|
-
// role that may get HTML preview cards. Never inferred from the sender name or the text.
|
|
7846
|
-
this.emitSseEvent("message", {
|
|
7847
|
-
instance: instanceName, sender: senderSessionName ?? instanceName, role: "agent",
|
|
7848
|
-
text: (args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX),
|
|
7849
|
-
ts: new Date().toISOString(),
|
|
7850
|
-
...(attachments.length ? { attachments } : {}),
|
|
7851
|
-
...(buttons ? { buttons } : {}), // #1266
|
|
7852
|
-
});
|
|
7853
8649
|
// Log bot reply to classic instance chat-log
|
|
7854
8650
|
const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
|
|
7855
8651
|
if (isClassic) {
|
|
7856
8652
|
ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
|
|
7857
8653
|
}
|
|
8654
|
+
// Files the agent attached are shown in the web chat too, fetched by id, never by path. #1770: each is copied
|
|
8655
|
+
// first (web-reply-files.ts), and the copy is what the chat serves and keeps — so the message is shown once the
|
|
8656
|
+
// copies are done, and an instance's replies wait for the one before them: they reach the chat in order.
|
|
8657
|
+
const replyFiles = Array.isArray(args.files) ? args.files.filter((f) => typeof f === "string") : [];
|
|
8658
|
+
const ts = new Date().toISOString();
|
|
8659
|
+
const show = (attachments) => {
|
|
8660
|
+
// The one place a delivered agent reply reaches the web chat: the server marks it `agent` (#1306) — the only
|
|
8661
|
+
// role that may get HTML preview cards. Never inferred from the sender name or the text.
|
|
8662
|
+
this.emitSseEvent("message", {
|
|
8663
|
+
instance: instanceName, sender: senderSessionName ?? instanceName, role: "agent",
|
|
8664
|
+
text: (args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX),
|
|
8665
|
+
ts,
|
|
8666
|
+
...(attachments.length ? { attachments } : {}),
|
|
8667
|
+
...(buttons ? { buttons } : {}), // #1266
|
|
8668
|
+
});
|
|
8669
|
+
};
|
|
8670
|
+
const before = this.webReplyOrder.get(instanceName);
|
|
8671
|
+
if (!replyFiles.length && !before) {
|
|
8672
|
+
show([]);
|
|
8673
|
+
return Promise.resolve();
|
|
8674
|
+
}
|
|
8675
|
+
const done = (before ?? Promise.resolve())
|
|
8676
|
+
.then(() => this.webReplyAttachments(instanceName, replyFiles))
|
|
8677
|
+
.catch((err) => { this.logger.warn({ err, instanceName }, "Reply files could not be prepared for the web chat"); return []; })
|
|
8678
|
+
.then((attachments) => { try {
|
|
8679
|
+
show(attachments);
|
|
8680
|
+
}
|
|
8681
|
+
catch (err) {
|
|
8682
|
+
this.logger.warn({ err, instanceName }, "Reply could not be shown in the web chat");
|
|
8683
|
+
} })
|
|
8684
|
+
.finally(() => { if (this.webReplyOrder.get(instanceName) === done)
|
|
8685
|
+
this.webReplyOrder.delete(instanceName); });
|
|
8686
|
+
this.webReplyOrder.set(instanceName, done);
|
|
8687
|
+
return done;
|
|
8688
|
+
}
|
|
8689
|
+
/**
|
|
8690
|
+
* #1770: a reply's files as the web chat shows them. Each is copied into the instance's web-files/ and served from
|
|
8691
|
+
* the copy. One the copy refuses (a symlink, not a regular file, over 50 MB, a state file, missing) is not shown;
|
|
8692
|
+
* one that could not be copied (I/O) is served from the agent's own path, as before — until the fleet restarts.
|
|
8693
|
+
*/
|
|
8694
|
+
async webReplyAttachments(instanceName, paths) {
|
|
8695
|
+
const home = getAgendHome();
|
|
8696
|
+
const dir = webFilesDir(home, instanceName);
|
|
8697
|
+
const out = [];
|
|
8698
|
+
for (const path of paths) {
|
|
8699
|
+
const id = randomBytes(16).toString("hex");
|
|
8700
|
+
const got = dir ? await snapshotReplyFile(path, dir, id, { stateDir: home, maxDirBytes: this.webReplyMaxBytes }) : null;
|
|
8701
|
+
if (got && "evicted" in got && got.evicted.length)
|
|
8702
|
+
this.webFiles.dropServed(got.evicted);
|
|
8703
|
+
let file = null;
|
|
8704
|
+
if (got?.ok)
|
|
8705
|
+
file = this.webFiles.registerServed({ id, path: got.path, name: basename(path), instance: instanceName, snapshot: true });
|
|
8706
|
+
else if (got?.refused)
|
|
8707
|
+
this.logger.info({ instanceName, file: basename(path), reason: got.reason }, "Reply file not shown in the web chat");
|
|
8708
|
+
else {
|
|
8709
|
+
if (got)
|
|
8710
|
+
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");
|
|
8711
|
+
file = this.webFiles.registerServed({ path, instance: instanceName });
|
|
8712
|
+
}
|
|
8713
|
+
if (file)
|
|
8714
|
+
out.push(publicAttachment(file));
|
|
8715
|
+
}
|
|
8716
|
+
return out;
|
|
7858
8717
|
}
|
|
7859
8718
|
// ===================== Scheduler =====================
|
|
7860
8719
|
async handleScheduleTrigger(schedule, stableRunId = randomUUID(), retry) {
|
|
@@ -9253,7 +10112,9 @@ export class FleetManager {
|
|
|
9253
10112
|
else
|
|
9254
10113
|
await this.lifecycle.remove(name, authorization);
|
|
9255
10114
|
// A later same-name registration owns its files and caches.
|
|
9256
|
-
|
|
10115
|
+
// Own keys only: an instance named "constructor" is removed as any other (its inherited namesake is not a
|
|
10116
|
+
// re-registration) — #1677 review.
|
|
10117
|
+
if (Object.hasOwn(this.fleetConfig?.instances ?? {}, name) || this.daemons.has(name))
|
|
9257
10118
|
return;
|
|
9258
10119
|
execution?.assert();
|
|
9259
10120
|
// Clean up per-instance tracking maps so they don't grow unbounded
|
|
@@ -9265,6 +10126,13 @@ export class FleetManager {
|
|
|
9265
10126
|
// must not be shown the old one's conversation, and deleted names must not pile up.
|
|
9266
10127
|
this.webChatHistory.forget(name);
|
|
9267
10128
|
this.webFiles.forget(name);
|
|
10129
|
+
// #1664: and it is nobody's favorite any more (every device sees that at its next status frame).
|
|
10130
|
+
try {
|
|
10131
|
+
this.webPrefs.forgetInstance(name);
|
|
10132
|
+
}
|
|
10133
|
+
catch (err) {
|
|
10134
|
+
this.logger.warn({ err: err.message, name }, "Dropping a removed instance from web favorites failed");
|
|
10135
|
+
}
|
|
9268
10136
|
// Clean up statusline watcher + instance directory
|
|
9269
10137
|
this.statuslineWatcher.unwatch(name);
|
|
9270
10138
|
// instanceDirToClean and effectiveBackend are captured before lifecycle.remove
|
|
@@ -9454,6 +10322,190 @@ export class FleetManager {
|
|
|
9454
10322
|
/** Keyed `<connection>\0<server id>`. */
|
|
9455
10323
|
guildEmojiCache = new Map();
|
|
9456
10324
|
guildEmojiFetches = new Map();
|
|
10325
|
+
// ── #1630: what stands for an instance in the web app, and Discord server emoji in web chat ──
|
|
10326
|
+
webImageCaches = null;
|
|
10327
|
+
webImageCache(kind) {
|
|
10328
|
+
const onError = (key, err) => this.logger.debug({ err: err?.message, key }, "web image fetch failed (kept what was cached)");
|
|
10329
|
+
this.webImageCaches ??= {
|
|
10330
|
+
avatars: new WebImageCache({ dir: join(this.dataDir, "cache", "avatars"), onError }),
|
|
10331
|
+
emoji: new WebImageCache({ dir: join(this.dataDir, "cache", "emoji"), onError }),
|
|
10332
|
+
};
|
|
10333
|
+
return this.webImageCaches[kind];
|
|
10334
|
+
}
|
|
10335
|
+
/**
|
|
10336
|
+
* instance → the connection that is its own bot: configured now (a connection removed from fleet.yaml is nobody's,
|
|
10337
|
+
* whatever an instance still names — #1635 review), not the primary, and answering for it alone.
|
|
10338
|
+
*/
|
|
10339
|
+
ownBots() {
|
|
10340
|
+
const instances = this.fleetConfig?.instances ?? {};
|
|
10341
|
+
const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
10342
|
+
const configured = new Set(channels.map(ch => ch.id ?? ch.type).filter((id) => typeof id === "string"));
|
|
10343
|
+
const assigned = (name) => { const c = this.getInstanceAdapterId(name); return c && configured.has(c) ? c : undefined; };
|
|
10344
|
+
const assignments = Object.keys(instances).map(n => [n, assigned(n)]);
|
|
10345
|
+
for (const room of this.classicChannels?.getAll() ?? []) {
|
|
10346
|
+
if (!Object.hasOwn(instances, room.instanceName))
|
|
10347
|
+
assignments.push([room.instanceName, assigned(room.instanceName)]);
|
|
10348
|
+
}
|
|
10349
|
+
return ownBotConnections(this.getPrimaryAdapterId(), assignments);
|
|
10350
|
+
}
|
|
10351
|
+
/**
|
|
10352
|
+
* The bot a connection is now: its running adapter and that bot's own user id. Everything cached for an own bot is
|
|
10353
|
+
* keyed by both, so a bot put in its place (a token replaced under the same connection id) never shows the old one's
|
|
10354
|
+
* face or colour, and the old one's late answers land under the old key (#1635 review). null while it has no id.
|
|
10355
|
+
*/
|
|
10356
|
+
botOwner(connection) {
|
|
10357
|
+
const adapter = this.adapters.get(connection), id = adapter?.getBotUserId?.();
|
|
10358
|
+
return adapter && id ? { adapter, id } : null;
|
|
10359
|
+
}
|
|
10360
|
+
/** Still the same bot behind `connection` (after an await): the same adapter object and the same bot id. */
|
|
10361
|
+
botStillOwns(connection, owner) {
|
|
10362
|
+
return this.adapters.get(connection) === owner.adapter && owner.adapter.getBotUserId?.() === owner.id;
|
|
10363
|
+
}
|
|
10364
|
+
/** keyed `<connection>\0<bot id>\0<channel>`: the bot's colour there, refreshed at most daily (monotonic), one read at a time. */
|
|
10365
|
+
webAccents = new Map();
|
|
10366
|
+
webAccentReads = new Set();
|
|
10367
|
+
webAccent(connection, owner, channelId) {
|
|
10368
|
+
const adapter = owner.adapter;
|
|
10369
|
+
if (!adapter.botAccentColor)
|
|
10370
|
+
return null;
|
|
10371
|
+
const key = `${connection}\0${owner.id}\0${channelId ?? ""}`, now = performance.now(), cached = this.webAccents.get(key);
|
|
10372
|
+
if ((!cached || now >= cached.until) && !this.webAccentReads.has(key)) {
|
|
10373
|
+
this.webAccentReads.add(key);
|
|
10374
|
+
adapter.botAccentColor(channelId)
|
|
10375
|
+
// Keyed by the bot's id: a replaced bot's late answer lands under its own key, which the new bot never reads.
|
|
10376
|
+
.then(value => { this.webAccents.set(key, { value: accentColor(value), until: performance.now() + WEB_IMAGE_MAX_AGE_MS }); }, () => { this.webAccents.set(key, { value: cached?.value ?? null, until: performance.now() + WEB_IMAGE_RETRY_MS }); })
|
|
10377
|
+
.finally(() => this.webAccentReads.delete(key));
|
|
10378
|
+
}
|
|
10379
|
+
return cached?.value ?? null;
|
|
10380
|
+
}
|
|
10381
|
+
/** The cache key of an own bot's avatar, and its loader (which refuses an answer from a bot no longer there). */
|
|
10382
|
+
botAvatarSource(connection, owner) {
|
|
10383
|
+
return {
|
|
10384
|
+
key: `bot:${connection}:${owner.id}`,
|
|
10385
|
+
load: async () => {
|
|
10386
|
+
const got = await owner.adapter.fetchBotAvatar();
|
|
10387
|
+
if (!this.botStillOwns(connection, owner))
|
|
10388
|
+
throw new Error("the bot was replaced while its avatar was fetched");
|
|
10389
|
+
return got;
|
|
10390
|
+
},
|
|
10391
|
+
};
|
|
10392
|
+
}
|
|
10393
|
+
/** One instance's avatar and ring for the status frame. Synchronous: what is cached now; fetches run behind. */
|
|
10394
|
+
webIdentityImage(name, displayName, profileAvatar, own) {
|
|
10395
|
+
let profileVersion = null;
|
|
10396
|
+
if (profileAvatar && imageMimeForFile(profileAvatar)) {
|
|
10397
|
+
try {
|
|
10398
|
+
profileVersion = Math.floor(statSync(join(this.dataDir, "avatars", basename(profileAvatar))).mtimeMs).toString(36);
|
|
10399
|
+
}
|
|
10400
|
+
catch { /* removed */ }
|
|
10401
|
+
}
|
|
10402
|
+
const connection = own.get(name), owner = connection ? this.botOwner(connection) : null;
|
|
10403
|
+
const source = connection && owner?.adapter.fetchBotAvatar ? this.botAvatarSource(connection, owner) : null;
|
|
10404
|
+
const bot = source ? this.webImageCache("avatars").peek(source.key, source.load) : null;
|
|
10405
|
+
const cfg = this.ownInstanceConfig(name);
|
|
10406
|
+
const avatar = avatarFor({ name, displayName, profileVersion, bot: bot && connection ? { connection, version: bot.version } : null,
|
|
10407
|
+
personaEmoji: cfg?.status_emojis?.delivered });
|
|
10408
|
+
// The ring belongs to the bot's own face: only when that is what is shown.
|
|
10409
|
+
const accent = !profileVersion && bot && connection && owner
|
|
10410
|
+
? this.webAccent(connection, owner, cfg ? (cfg.topic_id == null ? undefined : String(cfg.topic_id)) : this.classicChannels?.getChannelIdByInstance(name))
|
|
10411
|
+
: null;
|
|
10412
|
+
return accent ? { avatar, accent } : { avatar };
|
|
10413
|
+
}
|
|
10414
|
+
/** GET /ui/img/bot|avatar/<key>: what is cached, never a fetch. Only an own-bot connection, or a known instance's upload. */
|
|
10415
|
+
webImage(kind, key) {
|
|
10416
|
+
if (kind === "bot") {
|
|
10417
|
+
if (![...this.ownBots().values()].includes(key))
|
|
10418
|
+
return null;
|
|
10419
|
+
const owner = this.botOwner(key);
|
|
10420
|
+
return owner ? this.webImageCache("avatars").read(this.botAvatarSource(key, owner).key) : null;
|
|
10421
|
+
}
|
|
10422
|
+
const known = Object.hasOwn(this.fleetConfig?.instances ?? {}, key) || this.classicChannels?.getChannelIdByInstance(key) !== undefined;
|
|
10423
|
+
if (!known)
|
|
10424
|
+
return null;
|
|
10425
|
+
const file = profileIdentities(this.dataDir).get(key)?.avatar_path;
|
|
10426
|
+
const mime = file ? imageMimeForFile(file) : null;
|
|
10427
|
+
if (!file || !mime)
|
|
10428
|
+
return null;
|
|
10429
|
+
try {
|
|
10430
|
+
const path = join(this.dataDir, "avatars", basename(file));
|
|
10431
|
+
return { bytes: readFileSync(path), mime, version: Math.floor(statSync(path).mtimeMs).toString(36) };
|
|
10432
|
+
}
|
|
10433
|
+
catch {
|
|
10434
|
+
return null;
|
|
10435
|
+
}
|
|
10436
|
+
}
|
|
10437
|
+
/**
|
|
10438
|
+
* #1682 GET /ui/mentions: names for the mention ids a page found in what it rendered — never a Discord request; in order:
|
|
10439
|
+
* the fleet's bots (an instance's own bot is that instance's label: fleet.yaml, and the View profiles read from
|
|
10440
|
+
* profiles.db, as the status frame does), the people seen on inbound messages, and the Discord adapters' caches
|
|
10441
|
+
* (members, users, roles, channels).
|
|
10442
|
+
*/
|
|
10443
|
+
webMentionNames(query) {
|
|
10444
|
+
const bots = (q) => {
|
|
10445
|
+
const u = Object.create(null);
|
|
10446
|
+
if (!q.u.length)
|
|
10447
|
+
return { u };
|
|
10448
|
+
let own = new Map();
|
|
10449
|
+
try {
|
|
10450
|
+
own = this.ownBots();
|
|
10451
|
+
}
|
|
10452
|
+
catch { /* none known */ }
|
|
10453
|
+
const profiles = profileIdentities(this.dataDir);
|
|
10454
|
+
const rooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
|
|
10455
|
+
for (const [instance, connection] of own) {
|
|
10456
|
+
const id = this.adapters.get(connection)?.getBotUserId?.();
|
|
10457
|
+
if (!id || !q.u.includes(id))
|
|
10458
|
+
continue;
|
|
10459
|
+
const cfg = Object.hasOwn(this.fleetConfig?.instances ?? {}, instance) ? this.fleetConfig.instances[instance] : undefined;
|
|
10460
|
+
u[id] = resolveInstanceIdentity({ cfg, classic: rooms.get(instance), profile: profiles.get(instance) }).display_name || instance;
|
|
10461
|
+
}
|
|
10462
|
+
return { u };
|
|
10463
|
+
};
|
|
10464
|
+
const seen = (q) => {
|
|
10465
|
+
const u = Object.create(null);
|
|
10466
|
+
for (const id of q.u) {
|
|
10467
|
+
const name = this.seenUsers.get(id);
|
|
10468
|
+
if (name)
|
|
10469
|
+
u[id] = name;
|
|
10470
|
+
}
|
|
10471
|
+
return { u };
|
|
10472
|
+
};
|
|
10473
|
+
const caches = [...this.adapters.values()].filter(a => typeof a.mentionNames === "function").map(a => (q) => a.mentionNames(q));
|
|
10474
|
+
return resolveMentions(query, [bots, seen, ...caches]);
|
|
10475
|
+
}
|
|
10476
|
+
/** GET /ui/emojis: the custom emoji of every server the fleet's Discord bots are in (the Settings picker's cache). */
|
|
10477
|
+
async webEmojiList() {
|
|
10478
|
+
const adopted = [];
|
|
10479
|
+
for (const [id, world] of [...this.worlds]) {
|
|
10480
|
+
if (world.type !== "discord")
|
|
10481
|
+
continue;
|
|
10482
|
+
const listed = await this.listGuildEmojis(id);
|
|
10483
|
+
if (listed.ok)
|
|
10484
|
+
adopted.push({ id, world, emojis: listed.emojis });
|
|
10485
|
+
}
|
|
10486
|
+
// After the LAST await (#1635 review r2): only the lists whose world is still the one that was read — a connection
|
|
10487
|
+
// removed or replaced while a later list was awaited takes its servers along, whenever it went.
|
|
10488
|
+
const out = new Map();
|
|
10489
|
+
for (const a of adopted) {
|
|
10490
|
+
if (this.worlds.get(a.id) !== a.world)
|
|
10491
|
+
continue;
|
|
10492
|
+
for (const e of a.emojis)
|
|
10493
|
+
if (!out.has(e.id))
|
|
10494
|
+
out.set(e.id, { id: e.id, name: e.name, animated: e.animated });
|
|
10495
|
+
}
|
|
10496
|
+
return [...out.values()];
|
|
10497
|
+
}
|
|
10498
|
+
/** GET /ui/img/emoji/<id>: only an emoji of those servers; fetched once from Discord's CDN, then from the host cache. */
|
|
10499
|
+
async webEmoji(id) {
|
|
10500
|
+
if (!/^\d{15,25}$/.test(id))
|
|
10501
|
+
return null;
|
|
10502
|
+
const emoji = (await this.webEmojiList()).find(e => e.id === id);
|
|
10503
|
+
if (!emoji)
|
|
10504
|
+
return null;
|
|
10505
|
+
const image = await this.webImageCache("emoji").get(`emoji:${id}`, () => downloadStickerImage(`https://cdn.discordapp.com/emojis/${id}.${emoji.animated ? "gif" : "png"}?size=64`, { maxBytes: WEB_IMAGE_MAX_BYTES }));
|
|
10506
|
+
// Still an emoji of the fleet's servers once it is here (a connection removed meanwhile takes its servers along).
|
|
10507
|
+
return image && (await this.webEmojiList()).some(e => e.id === id) ? image : null;
|
|
10508
|
+
}
|
|
9457
10509
|
/**
|
|
9458
10510
|
* What `instanceName` may pick as its persona emoji (the `list_emojis`
|
|
9459
10511
|
* tool): its current status set with where each value comes from, the
|
|
@@ -10641,6 +11693,15 @@ export class FleetManager {
|
|
|
10641
11693
|
if (pending && pending.prefix !== prefix)
|
|
10642
11694
|
pending = undefined;
|
|
10643
11695
|
if (!match || !pending) {
|
|
11696
|
+
// #1610 (Fable r1): the same message may already carry the prompt's NEXT step (the public link's 確定關閉 / 取消
|
|
11697
|
+
// on the sign-in DM, armed by the first tap). A double tap, or a late second one from an old keyboard, lands
|
|
11698
|
+
// here with the spent nonce — and collapsing would wipe the message's text and the live buttons. The clicker is
|
|
11699
|
+
// told; the message is left alone.
|
|
11700
|
+
// #1617: and of the same bot — two bots' DM message ids can collide; another bot's live prompt says nothing here.
|
|
11701
|
+
if ([...this.pendingNonceButtons.values()].some(e => e.prefix === prefix && e.adapterId === callbackAdapterId && e.chatId === data.chatId && e.messageId === data.messageId)) {
|
|
11702
|
+
data.ack?.(t("buttons.stale_notice"));
|
|
11703
|
+
return "consumed";
|
|
11704
|
+
}
|
|
10644
11705
|
const adapter = receivingAdapter ?? this.adapter;
|
|
10645
11706
|
// The clicker always hears why nothing happens (#1133); collapsing the
|
|
10646
11707
|
// dead buttons is a courtesy that can fail (>48h on Telegram, message gone).
|
|
@@ -10790,6 +11851,8 @@ export class FleetManager {
|
|
|
10790
11851
|
return true;
|
|
10791
11852
|
if (await this.handleEffortSelection(data, adapterId))
|
|
10792
11853
|
return true;
|
|
11854
|
+
if (await this.handleBackendSelection(data, adapterId))
|
|
11855
|
+
return true;
|
|
10793
11856
|
if (await this.handleHangPrompt(data, adapterId, adapter))
|
|
10794
11857
|
return true;
|
|
10795
11858
|
if (data.callbackData.startsWith("cancel:")) {
|
|
@@ -10855,6 +11918,48 @@ export class FleetManager {
|
|
|
10855
11918
|
});
|
|
10856
11919
|
}
|
|
10857
11920
|
cacheService = null;
|
|
11921
|
+
/**
|
|
11922
|
+
* #1699: the credential profile an instance's CLI signs in with on `backend` — fleet.yaml's (with its defaults), or a
|
|
11923
|
+
* ClassicBot room's launched or would-be login when the room runs that backend. Null: the backend's default login
|
|
11924
|
+
* (also for a backend that has no profiles, where the option is ignored).
|
|
11925
|
+
*/
|
|
11926
|
+
credentialProfileOf(name, backend) {
|
|
11927
|
+
if (!credentialHomeSpec(backend))
|
|
11928
|
+
return null;
|
|
11929
|
+
const cfg = this.fleetConfig?.instances?.[name];
|
|
11930
|
+
if (cfg)
|
|
11931
|
+
return instanceCredentialProfile(cfg, this.fleetConfig?.defaults, backend);
|
|
11932
|
+
const launched = this.daemons.get(name)?.getConfigSnapshot?.();
|
|
11933
|
+
if (launched)
|
|
11934
|
+
return (launched.backend ?? "claude-code") === backend ? profileName(profileOf(launched.backend_options, backend)) : null;
|
|
11935
|
+
if (!this.classicChannels || this.classicChannels.getBackendByInstance(name, this.fleetConfig?.defaults?.backend) !== backend)
|
|
11936
|
+
return null;
|
|
11937
|
+
return profileName(this.classicChannels.getCredentialProfileByInstance(name, this.fleetConfig?.defaults));
|
|
11938
|
+
}
|
|
11939
|
+
/**
|
|
11940
|
+
* #1699: the claude config dir an instance's CLI uses — its credential profile's, else exactly as before
|
|
11941
|
+
* (CLAUDE_CONFIG_DIR or ~/.claude).
|
|
11942
|
+
*/
|
|
11943
|
+
claudeConfigDirOf(name) {
|
|
11944
|
+
const profile = this.credentialProfileOf(name, "claude-code");
|
|
11945
|
+
return claudeConfigDirFor(profile ? credentialProfileHome(getAgendHome(), "claude-code", profile) : null);
|
|
11946
|
+
}
|
|
11947
|
+
/** The instances the cache analysis reads: fleet.yaml's, then the ClassicBot rooms (their workspace under the AgEnD home, their own backend). */
|
|
11948
|
+
cacheInstances() {
|
|
11949
|
+
const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
|
|
11950
|
+
name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
|
|
11951
|
+
claudeConfigDir: this.claudeConfigDirOf(name),
|
|
11952
|
+
}));
|
|
11953
|
+
const taken = new Set(fleet.map(i => i.name));
|
|
11954
|
+
const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
|
|
11955
|
+
name: ch.instanceName,
|
|
11956
|
+
backend: this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
|
|
11957
|
+
workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
|
|
11958
|
+
ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
|
|
11959
|
+
claudeConfigDir: this.claudeConfigDirOf(ch.instanceName),
|
|
11960
|
+
}));
|
|
11961
|
+
return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
|
|
11962
|
+
}
|
|
10858
11963
|
/**
|
|
10859
11964
|
* #1468: the prompt-cache expiry analysis for the web, per instance, over `window`. Read from each instance's
|
|
10860
11965
|
* ledger as it is now; the first request starts the bounded, persisted catch-up over the transcripts
|
|
@@ -10863,20 +11968,7 @@ export class FleetManager {
|
|
|
10863
11968
|
cacheReport(window) {
|
|
10864
11969
|
const w = Object.prototype.hasOwnProperty.call(WINDOWS, window) ? window : "7d";
|
|
10865
11970
|
this.cacheService ??= new CacheService({
|
|
10866
|
-
|
|
10867
|
-
instances: () => {
|
|
10868
|
-
const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
|
|
10869
|
-
name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
|
|
10870
|
-
}));
|
|
10871
|
-
const taken = new Set(fleet.map(i => i.name));
|
|
10872
|
-
const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
|
|
10873
|
-
name: ch.instanceName,
|
|
10874
|
-
backend: this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
|
|
10875
|
-
workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
|
|
10876
|
-
ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
|
|
10877
|
-
}));
|
|
10878
|
-
return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
|
|
10879
|
-
},
|
|
11971
|
+
instances: () => this.cacheInstances(),
|
|
10880
11972
|
claudeProjectsDir: () => join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects"),
|
|
10881
11973
|
claudeKey: claudeProjectKey,
|
|
10882
11974
|
codexSessionsDir: () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions"),
|
|
@@ -11063,7 +12155,9 @@ export class FleetManager {
|
|
|
11063
12155
|
}
|
|
11064
12156
|
async retireNonceButtons(pending, messageId, text,
|
|
11065
12157
|
/** The click's own edit of a private prompt (AdapterCallbackData.editClicked); preferred over the prompt's. */
|
|
11066
|
-
editClicked
|
|
12158
|
+
editClicked,
|
|
12159
|
+
/** #1686: URL buttons to attach when editing the outcome (Telegram only; ignored when editClicked or retire is set). */
|
|
12160
|
+
urlButtons) {
|
|
11067
12161
|
this.webPromptGone(pending, text);
|
|
11068
12162
|
try {
|
|
11069
12163
|
const retire = editClicked ?? pending.retire;
|
|
@@ -11073,7 +12167,12 @@ export class FleetManager {
|
|
|
11073
12167
|
}
|
|
11074
12168
|
if (!pending.adapter.editMessageRemoveButtons)
|
|
11075
12169
|
throw new Error("adapter cannot remove prompt buttons");
|
|
11076
|
-
|
|
12170
|
+
if (urlButtons) {
|
|
12171
|
+
await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId, urlButtons);
|
|
12172
|
+
}
|
|
12173
|
+
else {
|
|
12174
|
+
await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
|
|
12175
|
+
}
|
|
11077
12176
|
}
|
|
11078
12177
|
catch (err) {
|
|
11079
12178
|
// The action was already atomically consumed. An edit failure must not
|
|
@@ -11081,7 +12180,8 @@ export class FleetManager {
|
|
|
11081
12180
|
// only place some outcomes are told (cancel, later, a resend's result),
|
|
11082
12181
|
// so the text goes out as a message instead (#1133).
|
|
11083
12182
|
this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons; posting the outcome instead");
|
|
11084
|
-
|
|
12183
|
+
// #1742: with its link button, if it had one — the outcome may be the only way to the DM.
|
|
12184
|
+
await pending.adapter.sendText(pending.chatId, text, { threadId: pending.threadId, ...(urlButtons ? { urlButtons } : {}) })
|
|
11085
12185
|
.catch(sendErr => this.logger.warn({ err: sendErr, instanceName: pending.instanceName }, "Could not post a prompt's outcome"));
|
|
11086
12186
|
}
|
|
11087
12187
|
}
|
|
@@ -11376,6 +12476,8 @@ export class FleetManager {
|
|
|
11376
12476
|
}
|
|
11377
12477
|
};
|
|
11378
12478
|
}
|
|
12479
|
+
/** #1806: the web's /backend confirmations — one per question, used once, for the arguments and backend asked about. */
|
|
12480
|
+
webBackendTokens = new Map();
|
|
11379
12481
|
/** #1269: the web's /clear confirmations — one token per question, used once, for the instance as it was asked about. */
|
|
11380
12482
|
webClearTokens = new Map();
|
|
11381
12483
|
/** #1269: an instance's chat command from the web chat (web-commands.ts); `publicLink`: a gateway request. */
|
|
@@ -11384,7 +12486,7 @@ export class FleetManager {
|
|
|
11384
12486
|
const topicId = this.fleetConfig?.instances[instance]?.topic_id;
|
|
11385
12487
|
return {
|
|
11386
12488
|
chatId: this.getGroupIdForInstance(instance) || "", messageId: newWebMessageId(), username: "web-user", userId: "web-user",
|
|
11387
|
-
threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web",
|
|
12489
|
+
threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web", humanOrigin: webHumanOrigin(true),
|
|
11388
12490
|
};
|
|
11389
12491
|
};
|
|
11390
12492
|
const choicesFromCache = (instance) => {
|
|
@@ -11414,9 +12516,44 @@ export class FleetManager {
|
|
|
11414
12516
|
const current = this.resolveInstanceEffort(instance).effort;
|
|
11415
12517
|
return { current, options: levels.map(l => ({ id: l, label: this.effortChoiceLabel(l, current) })) };
|
|
11416
12518
|
},
|
|
12519
|
+
backendStep: async (instance, args) => {
|
|
12520
|
+
const refusal = this.backendCommandRefusal(instance);
|
|
12521
|
+
if (refusal)
|
|
12522
|
+
return { error: refusal };
|
|
12523
|
+
// The cached model lists only, as the web's /model: never a live probe on this path.
|
|
12524
|
+
const step = await backendStep(this.backendCommandDeps(instance, { live: false }), instance, backendArgs(args));
|
|
12525
|
+
if (step.kind === "refuse")
|
|
12526
|
+
return step.noop ? { text: step.text } : { error: step.text };
|
|
12527
|
+
if (step.kind === "choose") {
|
|
12528
|
+
const id = (token) => joinBackendArgs([...step.args, token]);
|
|
12529
|
+
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 })) } };
|
|
12530
|
+
}
|
|
12531
|
+
const now = performance.now();
|
|
12532
|
+
for (const [k, v] of this.webBackendTokens)
|
|
12533
|
+
if (v.deadline < now)
|
|
12534
|
+
this.webBackendTokens.delete(k);
|
|
12535
|
+
const token = randomBytes(16).toString("hex");
|
|
12536
|
+
this.webBackendTokens.set(token, {
|
|
12537
|
+
instance, asked: joinBackendArgs(backendArgs(args)), args: step.args, from: step.from,
|
|
12538
|
+
target: this.fleetConfig.instances[instance], deadline: now + CLASSIC_BACKEND_SELECTION_TIMEOUT_MS,
|
|
12539
|
+
});
|
|
12540
|
+
return { confirm: { token, message: step.text } };
|
|
12541
|
+
},
|
|
12542
|
+
backendConfirm: async (instance, args, token) => {
|
|
12543
|
+
const entry = this.webBackendTokens.get(token);
|
|
12544
|
+
this.webBackendTokens.delete(token); // used once, whatever happens next
|
|
12545
|
+
if (!entry || entry.instance !== instance || entry.asked !== joinBackendArgs(backendArgs(args)) || performance.now() > entry.deadline) {
|
|
12546
|
+
return { status: 409, text: t("backend.selection_expired") };
|
|
12547
|
+
}
|
|
12548
|
+
// The instance it was asked about: one deleted and created again under the name, stopped or not, is another.
|
|
12549
|
+
const stillCurrent = () => this.backendTargetIs(instance, entry.target);
|
|
12550
|
+
if (!stillCurrent())
|
|
12551
|
+
return { status: 409, text: t("menu.click_stale") };
|
|
12552
|
+
return { status: 200, text: await this.applyBackendSwitch(instance, entry.args, entry.from, stillCurrent) };
|
|
12553
|
+
},
|
|
11417
12554
|
cancel: (instance) => this.cancelInstance(instance),
|
|
11418
|
-
steer: (instance, text) => this.topicCommands.sendSteer(instance, text, webMeta(instance)),
|
|
11419
|
-
btw: (instance, text) => this.topicCommands.sendBtw(instance, text, webMeta(instance)),
|
|
12555
|
+
steer: (instance, text, originalBody) => this.topicCommands.sendSteer(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
|
|
12556
|
+
btw: (instance, text, originalBody) => this.topicCommands.sendBtw(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
|
|
11420
12557
|
pauseWake: (instance, action) => this.topicCommands.runPauseWake(instance, action),
|
|
11421
12558
|
save: (instance, filename) => this.topicCommands.sendSave(instance, filename),
|
|
11422
12559
|
clearAsk: (instance) => {
|
|
@@ -11687,9 +12824,12 @@ export class FleetManager {
|
|
|
11687
12824
|
* Backends with no remote login flow (opencode logs in from a terminal) get
|
|
11688
12825
|
* no button — the alert's own wording already tells them what to run.
|
|
11689
12826
|
*/
|
|
11690
|
-
async offerBackendLogin(targetInstance, backend) {
|
|
12827
|
+
async offerBackendLogin(targetInstance, backend, profile) {
|
|
11691
12828
|
if (!LOGIN_FLOWS[backend])
|
|
11692
12829
|
return;
|
|
12830
|
+
if (profile && !CHAT_PROFILE_LOGIN_BACKENDS.has(backend))
|
|
12831
|
+
return; // that profile signs in on the host
|
|
12832
|
+
const shown = profile ? t("login.profile_label", backend, profile) : backend;
|
|
11693
12833
|
const adapterId = this.getInstanceAdapterId(targetInstance);
|
|
11694
12834
|
const adapter = this.getAdapterForInstance(targetInstance);
|
|
11695
12835
|
const chatId = this.getGroupIdForInstance(targetInstance);
|
|
@@ -11707,9 +12847,10 @@ export class FleetManager {
|
|
|
11707
12847
|
adapterId,
|
|
11708
12848
|
chatId,
|
|
11709
12849
|
threadId,
|
|
11710
|
-
message: t("login.offer",
|
|
11711
|
-
choices: [{ action: backend, label: t("login.offer_action",
|
|
12850
|
+
message: t("login.offer", shown),
|
|
12851
|
+
choices: [{ action: backend, label: t("login.offer_action", shown) }],
|
|
11712
12852
|
expiredText: t("buttons.stale"),
|
|
12853
|
+
...(profile ? { extra: { loginProfile: profile } } : {}),
|
|
11713
12854
|
});
|
|
11714
12855
|
}
|
|
11715
12856
|
/** Consume a General assist button exactly once. */
|
|
@@ -12522,7 +13663,7 @@ export class FleetManager {
|
|
|
12522
13663
|
fleetConfig: () => this.fleetConfig,
|
|
12523
13664
|
isFleetAdmin: (userId, adapterId) => this.isFleetAdmin(userId, adapterId),
|
|
12524
13665
|
eventLog: () => this.eventLog,
|
|
12525
|
-
recoverBackendInstances: backend => this.recoverBackendInstances(backend),
|
|
13666
|
+
recoverBackendInstances: (backend, profile) => this.recoverBackendInstances(backend, undefined, profile ?? null),
|
|
12526
13667
|
claimWindow: backend => this.loginWindow.tryClaim("web", backend),
|
|
12527
13668
|
releaseWindow: claim => { this.loginWindow.release(claim); },
|
|
12528
13669
|
isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
|
|
@@ -12531,11 +13672,12 @@ export class FleetManager {
|
|
|
12531
13672
|
reserveTunnel: owner => this.getTunnelLane().reserve("login", owner),
|
|
12532
13673
|
// Throws when the prompt cannot be posted: the controller tells the
|
|
12533
13674
|
// user so instead of leaving "Starting…" as the last word (#1133).
|
|
12534
|
-
postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText }) => {
|
|
13675
|
+
postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText, profile }) => {
|
|
12535
13676
|
await this.postNonceButtonPromptOrThrow({
|
|
12536
13677
|
prefix, alertType: "login", instanceName,
|
|
12537
13678
|
adapter: chat.adapter, adapterId: chat.adapterId, chatId: chat.chatId, threadId: chat.threadId,
|
|
12538
13679
|
message, choices, expiredText,
|
|
13680
|
+
...(profile ? { extra: { loginProfile: profile } } : {}),
|
|
12539
13681
|
});
|
|
12540
13682
|
},
|
|
12541
13683
|
});
|
|
@@ -12749,10 +13891,20 @@ export class FleetManager {
|
|
|
12749
13891
|
// A removed backend (#1280) is neither installed nor signed into: say what replaces it.
|
|
12750
13892
|
if (isRemovedBackend(wanted))
|
|
12751
13893
|
return removedBackendMessage(wanted);
|
|
13894
|
+
// #1699 part 2: a profile is admitted before anything is installed for it — a name that is not one, or a backend
|
|
13895
|
+
// whose profiles sign in on the host, is refused here, not after an install it did not need.
|
|
13896
|
+
if (opts.profile !== undefined) {
|
|
13897
|
+
const admitted = admitProfileLogin(wanted, opts.profile);
|
|
13898
|
+
if ("refusal" in admitted)
|
|
13899
|
+
return admitted.refusal;
|
|
13900
|
+
opts = { ...opts, profile: admitted.profile };
|
|
13901
|
+
}
|
|
12752
13902
|
if (BACKEND_INSTALLATION_INFO[wanted] && !this.isCliInstalled(wanted)) {
|
|
12753
13903
|
const flow = LOGIN_FLOWS[wanted];
|
|
12754
13904
|
this.recordLoginFlow(wanted, flow && flow.remoteLogin !== "unsupported" ? "install_then_login" : "install_only", chat.userId);
|
|
12755
|
-
return
|
|
13905
|
+
return opts.profile !== undefined
|
|
13906
|
+
? this.startInstallSession(wanted, chat, { profile: opts.profile })
|
|
13907
|
+
: this.startInstallSession(wanted, chat);
|
|
12756
13908
|
}
|
|
12757
13909
|
this.recordLoginFlow(wanted, "login", chat.userId);
|
|
12758
13910
|
return this.launchSignIn(backendArg, chat, opts);
|
|
@@ -12841,7 +13993,9 @@ export class FleetManager {
|
|
|
12841
13993
|
* cancelled: they are still coming back, and are reported as pending so the
|
|
12842
13994
|
* message can say so instead of implying failure.
|
|
12843
13995
|
*/
|
|
12844
|
-
async recoverBackendInstances(backend, deadlineMs = POST_LOGIN_RECOVERY_DEADLINE_MS
|
|
13996
|
+
async recoverBackendInstances(backend, deadlineMs = POST_LOGIN_RECOVERY_DEADLINE_MS,
|
|
13997
|
+
/** #1699 part 2: the login just signed in — a profile, or null for the backend's default one. */
|
|
13998
|
+
profile = null) {
|
|
12845
13999
|
const woken = [];
|
|
12846
14000
|
const restarted = [];
|
|
12847
14001
|
const pending = [];
|
|
@@ -12849,6 +14003,9 @@ export class FleetManager {
|
|
|
12849
14003
|
for (const name of this.configuredBackendInstanceNames()) {
|
|
12850
14004
|
if (this.backendNameOf(name) !== backend)
|
|
12851
14005
|
continue;
|
|
14006
|
+
// Only the instances on the login that changed: another profile's (or the default's) credentials did not.
|
|
14007
|
+
if (this.credentialProfileOf(name, backend) !== profile)
|
|
14008
|
+
continue;
|
|
12852
14009
|
const status = this.getInstanceStatus(name);
|
|
12853
14010
|
if (status !== "paused" && status !== "running")
|
|
12854
14011
|
continue;
|
|
@@ -12886,14 +14043,16 @@ export class FleetManager {
|
|
|
12886
14043
|
if (claimed === "consumed")
|
|
12887
14044
|
return true;
|
|
12888
14045
|
const { entry, action: backend } = claimed;
|
|
12889
|
-
|
|
12890
|
-
|
|
14046
|
+
const profile = entry.loginProfile;
|
|
14047
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", profile ? t("login.profile_label", backend, profile) : backend));
|
|
14048
|
+
const chat = {
|
|
12891
14049
|
adapter: entry.adapter,
|
|
12892
14050
|
adapterId: entry.adapterId,
|
|
12893
14051
|
chatId: entry.chatId,
|
|
12894
14052
|
threadId: entry.threadId,
|
|
12895
14053
|
userId: data.userId,
|
|
12896
|
-
}
|
|
14054
|
+
};
|
|
14055
|
+
const text = profile ? await this.startLoginSession(backend, chat, { profile }) : await this.startLoginSession(backend, chat);
|
|
12897
14056
|
if (text)
|
|
12898
14057
|
await this.postPromptOutcome(entry, text);
|
|
12899
14058
|
return true;
|
|
@@ -12907,11 +14066,13 @@ export class FleetManager {
|
|
|
12907
14066
|
return true;
|
|
12908
14067
|
const { entry, action } = claimed;
|
|
12909
14068
|
const backend = entry.instanceName;
|
|
14069
|
+
const profile = entry.loginProfile;
|
|
14070
|
+
const shown = profile ? t("login.profile_label", backend, profile) : backend;
|
|
12910
14071
|
if (action === "cancel") {
|
|
12911
|
-
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled",
|
|
14072
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled", shown));
|
|
12912
14073
|
return true;
|
|
12913
14074
|
}
|
|
12914
|
-
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend",
|
|
14075
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", shown));
|
|
12915
14076
|
const text = await this.startLoginSession(backend, {
|
|
12916
14077
|
adapter: entry.adapter,
|
|
12917
14078
|
adapterId: entry.adapterId,
|
|
@@ -12923,6 +14084,7 @@ export class FleetManager {
|
|
|
12923
14084
|
tokenPresent: action === "go-relogin" || action === "go-relogin-tunnel",
|
|
12924
14085
|
// The consent is the button itself: only the explicit "Open public link" actions carry it.
|
|
12925
14086
|
tunnel: action === "go-tunnel" || action === "go-relogin-tunnel",
|
|
14087
|
+
...(profile ? { profile } : {}),
|
|
12926
14088
|
});
|
|
12927
14089
|
if (text)
|
|
12928
14090
|
await this.postPromptOutcome(entry, text);
|
|
@@ -12950,7 +14112,7 @@ export class FleetManager {
|
|
|
12950
14112
|
/** An install that succeeded, between its success line and the sign-in it hands over to (#1131). */
|
|
12951
14113
|
installHandoff = null;
|
|
12952
14114
|
/** Start a CLI install session. Caller enforces admin. */
|
|
12953
|
-
async startInstallSession(backendArg, chat) {
|
|
14115
|
+
async startInstallSession(backendArg, chat, signIn = {}) {
|
|
12954
14116
|
const backend = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
|
|
12955
14117
|
const info = BACKEND_INSTALLATION_INFO[backend];
|
|
12956
14118
|
if (!info)
|
|
@@ -13034,7 +14196,8 @@ export class FleetManager {
|
|
|
13034
14196
|
// without a click.
|
|
13035
14197
|
// launchSignIn, not startLoginSession: if the new binary were still
|
|
13036
14198
|
// not visible, startLoginSession would route straight back to an install.
|
|
13037
|
-
|
|
14199
|
+
// A `/login <backend> <profile>` that had to install first signs that profile in, not the default login.
|
|
14200
|
+
const next = await this.launchSignIn(backend, chat, signIn.profile !== undefined ? { profile: signIn.profile } : {}).catch((err) => t("login.failed", backend, err?.message ?? String(err)));
|
|
13038
14201
|
if (next) {
|
|
13039
14202
|
await chat.adapter.sendText(chat.chatId, next, { threadId: chat.threadId })
|
|
13040
14203
|
.catch(err => this.logger.warn({ err, backend }, "Could not post the sign-in step after an install"));
|
|
@@ -13142,8 +14305,16 @@ export class FleetManager {
|
|
|
13142
14305
|
return;
|
|
13143
14306
|
}
|
|
13144
14307
|
const backend = String(data.options?.backend ?? "").trim();
|
|
14308
|
+
// #1699 part 2: a supplied profile is never dropped — a blank one is refused by the admission like any bad name,
|
|
14309
|
+
// and one without a backend is refused rather than becoming the default login's picker.
|
|
14310
|
+
const profileSupplied = data.options?.profile !== undefined && data.options?.profile !== null;
|
|
14311
|
+
const profile = profileSupplied ? String(data.options?.profile).trim() : undefined;
|
|
14312
|
+
if (profileSupplied && !backend) {
|
|
14313
|
+
await data.respond(t("login.profile_needs_backend"));
|
|
14314
|
+
return;
|
|
14315
|
+
}
|
|
13145
14316
|
if (backend) {
|
|
13146
|
-
const text = await this.startLoginSession(backend, chat);
|
|
14317
|
+
const text = profile !== undefined ? await this.startLoginSession(backend, chat, { profile }) : await this.startLoginSession(backend, chat);
|
|
13147
14318
|
await data.respond(text ?? t("login.confirm_posted"));
|
|
13148
14319
|
return;
|
|
13149
14320
|
}
|
|
@@ -13178,16 +14349,26 @@ export class FleetManager {
|
|
|
13178
14349
|
try {
|
|
13179
14350
|
const store = new WebChatDiskStore({ home: getAgendHome(), logger: this.logger, ownerOf: (name) => this.webChatOwner(name) });
|
|
13180
14351
|
const names = new Set([...Object.keys(fleet.instances ?? {}), ...(this.classicChannels?.getAll().map(c => c.instanceName) ?? [])]);
|
|
13181
|
-
// A kept message's files are served again under their ids only from the instance's own inbox
|
|
13182
|
-
// checks (web-upload.ts restoreServed); they leave the ledger with their message.
|
|
14352
|
+
// A kept message's files are served again under their ids only from the instance's own inbox or (#1770) its reply
|
|
14353
|
+
// snapshots, by the ledger's checks (web-upload.ts restoreServed); they leave the ledger with their message.
|
|
13183
14354
|
this.webChatHistory.setFiles({
|
|
13184
14355
|
storablePath: (id) => this.webFiles.storablePath(id),
|
|
13185
|
-
restore: (entry, instance) => this.webFiles.restoreServed(entry, instance, join(getAgendHome(), "workspaces", instance, "inbox")),
|
|
14356
|
+
restore: (entry, instance) => this.webFiles.restoreServed(entry, instance, join(getAgendHome(), "workspaces", instance, "inbox"), webFilesDir(getAgendHome(), instance) ?? undefined),
|
|
13186
14357
|
drop: (ids) => this.webFiles.dropServed(ids),
|
|
13187
14358
|
});
|
|
13188
14359
|
let restored = 0;
|
|
13189
14360
|
for (const name of names)
|
|
13190
14361
|
restored += this.webChatHistory.restore(name, store.load(name));
|
|
14362
|
+
// #1770: before any instance replies, the snapshots no restored message holds (it left the history while the
|
|
14363
|
+
// fleet was down, or a copy stopped midway) are removed.
|
|
14364
|
+
let swept = 0;
|
|
14365
|
+
for (const name of names) {
|
|
14366
|
+
const dir = webFilesDir(getAgendHome(), name);
|
|
14367
|
+
if (dir)
|
|
14368
|
+
swept += sweepReplySnapshots(dir, this.webFiles.heldSnapshots(name));
|
|
14369
|
+
}
|
|
14370
|
+
if (swept)
|
|
14371
|
+
this.logger.info({ swept }, "Removed web reply snapshots no message holds");
|
|
13191
14372
|
this.webChatStore = store;
|
|
13192
14373
|
this.webChatHistory.setStore(store);
|
|
13193
14374
|
if (restored)
|
|
@@ -13978,12 +15159,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
13978
15159
|
const reactions = this.pendingReactionsMeta(instanceName);
|
|
13979
15160
|
Object.assign(meta, reactions.meta);
|
|
13980
15161
|
try {
|
|
13981
|
-
await this.
|
|
15162
|
+
await this.deliverHumanToInstance(instanceName, {
|
|
13982
15163
|
type: "fleet_inbound",
|
|
13983
15164
|
content: fullText,
|
|
13984
15165
|
targetSession: instanceName,
|
|
13985
15166
|
meta,
|
|
13986
|
-
});
|
|
15167
|
+
}, msg.humanOrigin, msg.humanOriginalBody ?? text);
|
|
13987
15168
|
reactions.consume();
|
|
13988
15169
|
}
|
|
13989
15170
|
catch (err) {
|
|
@@ -14000,6 +15181,50 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14000
15181
|
});
|
|
14001
15182
|
}
|
|
14002
15183
|
/** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
|
|
15184
|
+
/** #1690: our own runtime switches awaiting the daemon's outcome, by id. */
|
|
15185
|
+
runtimeSwitchWaiters = new Map();
|
|
15186
|
+
/**
|
|
15187
|
+
* #1690: type `/model <model>` and wait for what it came to: the daemon answers claude's "Switch model?" for us (Yes
|
|
15188
|
+
* when it names this model, No otherwise) or sees claude's own result. Null when it could not be sent; a timeout is
|
|
15189
|
+
* "not confirmed".
|
|
15190
|
+
*/
|
|
15191
|
+
runRuntimeModelSwitch(instanceName, model) {
|
|
15192
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
15193
|
+
if (!ipc)
|
|
15194
|
+
return Promise.resolve({ outcome: "not_sent" });
|
|
15195
|
+
const id = `switch-${randomUUID()}`;
|
|
15196
|
+
return new Promise(resolve => {
|
|
15197
|
+
const timer = setTimeout(() => { this.runtimeSwitchWaiters.delete(id); resolve({ outcome: "not_confirmed" }); }, RUNTIME_SWITCH_RESULT_MS);
|
|
15198
|
+
timer.unref?.();
|
|
15199
|
+
this.runtimeSwitchWaiters.set(id, result => { clearTimeout(timer); resolve(result); });
|
|
15200
|
+
const sent = ipc.send({ type: "raw_paste", content: `/model ${model}`, runtime_switch: { id, kind: "model", requested: model } });
|
|
15201
|
+
if (!sent) {
|
|
15202
|
+
clearTimeout(timer);
|
|
15203
|
+
this.runtimeSwitchWaiters.delete(id);
|
|
15204
|
+
resolve({ outcome: "not_sent" });
|
|
15205
|
+
return;
|
|
15206
|
+
}
|
|
15207
|
+
this.logger.info({ instanceName, model }, "Runtime model switch sent");
|
|
15208
|
+
});
|
|
15209
|
+
}
|
|
15210
|
+
/**
|
|
15211
|
+
* #1690: the names claude-code is known to accept, for checking `/model <name>` BEFORE it is typed — claude itself
|
|
15212
|
+
* accepts any string ("Set model to opsu"), and its "Switch model?" then names the typo. Static aliases (with [1m]),
|
|
15213
|
+
* the cached account catalog, the configured model; without a cached catalog, any `claude-…` id too (cannot check).
|
|
15214
|
+
*/
|
|
15215
|
+
claudeModelKnown(instanceName, model,
|
|
15216
|
+
/** #1806: null on a switch INTO claude-code — the configured model then belongs to the backend being left. */
|
|
15217
|
+
configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model) {
|
|
15218
|
+
const aliases = ["default", "sonnet", "opus", "haiku", "opusplan", "Fable"];
|
|
15219
|
+
const api = (this.readCliEnv("claude-code")?.apiModels ?? []).map(m => m.id);
|
|
15220
|
+
const lower = model.trim().toLowerCase();
|
|
15221
|
+
const bare = lower.replace(/\[1m\]$/, "");
|
|
15222
|
+
const known = aliases.some(a => a.toLowerCase() === bare)
|
|
15223
|
+
|| api.some(id => id.toLowerCase() === lower || `${id.toLowerCase()}[1m]` === lower)
|
|
15224
|
+
|| (typeof configured === "string" && configured.toLowerCase() === lower)
|
|
15225
|
+
|| (api.length === 0 && /^claude-[a-z0-9][a-z0-9.-]*(\[1m\])?$/.test(lower));
|
|
15226
|
+
return { known, names: [...aliases, ...api.filter(id => !id.endsWith("[1m]")).slice(0, 12)] };
|
|
15227
|
+
}
|
|
14003
15228
|
pasteRawToClassicInstance(instanceName, text) {
|
|
14004
15229
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
14005
15230
|
if (!ipc) {
|
|
@@ -14599,7 +15824,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14599
15824
|
try {
|
|
14600
15825
|
// Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
|
|
14601
15826
|
result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
|
|
14602
|
-
? await
|
|
15827
|
+
? await withSpawnHoldReply(async (text) => {
|
|
15828
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
15829
|
+
if (progressMsgId)
|
|
15830
|
+
await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
|
|
15831
|
+
else
|
|
15832
|
+
await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
|
|
15833
|
+
}
|
|
15834
|
+
else
|
|
15835
|
+
await pending.respond(text);
|
|
15836
|
+
}, () => this.applyEffort(pending.instanceName, level)) : t("menu.click_stale");
|
|
14603
15837
|
}
|
|
14604
15838
|
catch (err) {
|
|
14605
15839
|
this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
|
|
@@ -14924,7 +16158,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14924
16158
|
try {
|
|
14925
16159
|
// Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
|
|
14926
16160
|
result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
|
|
14927
|
-
? await
|
|
16161
|
+
? await withSpawnHoldReply(async (text) => {
|
|
16162
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
16163
|
+
if (progressMsgId)
|
|
16164
|
+
await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
|
|
16165
|
+
else
|
|
16166
|
+
await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
|
|
16167
|
+
}
|
|
16168
|
+
else
|
|
16169
|
+
await pending.respond(text);
|
|
16170
|
+
}, () => this.applyModel(pending.instanceName, model)) : t("menu.click_stale");
|
|
14928
16171
|
}
|
|
14929
16172
|
catch (err) {
|
|
14930
16173
|
this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
|
|
@@ -15058,21 +16301,34 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15058
16301
|
}
|
|
15059
16302
|
catch { /* default restart */ }
|
|
15060
16303
|
const warn = isModelCompatible(backendName, model) ? "" : t("model.pattern_warning", model, backendName);
|
|
16304
|
+
// #1690: claude takes any name; a mistyped one would be "switched" to and fail the next turn. Checked first —
|
|
16305
|
+
// nothing is typed or saved for an unknown one.
|
|
16306
|
+
if (backendName === "claude-code") {
|
|
16307
|
+
const check = this.claudeModelKnown(instanceName, model);
|
|
16308
|
+
if (!check.known)
|
|
16309
|
+
return t("model.unknown_name", model, check.names.join(", "));
|
|
16310
|
+
}
|
|
15061
16311
|
if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
|
|
15062
16312
|
return `${warn}${t("effort.not_running", instanceName)}`;
|
|
15063
16313
|
}
|
|
16314
|
+
// #1690: claude-code's runtime switch reports what it came to; the model is saved unless claude was told No.
|
|
16315
|
+
if (strategy === "runtime" && backendName === "claude-code") {
|
|
16316
|
+
const result = await this.runRuntimeModelSwitch(instanceName, model);
|
|
16317
|
+
if (result.outcome === "mismatch")
|
|
16318
|
+
return `${warn}${t("model.switch_mismatch", instanceName, result.shown, model)}`;
|
|
16319
|
+
if (result.outcome === "not_sent")
|
|
16320
|
+
return `${warn}${t("model.switch_not_sent", instanceName, model)}`;
|
|
16321
|
+
const persistedNow = this.persistInstanceModel(instanceName, model);
|
|
16322
|
+
if (!persistedNow)
|
|
16323
|
+
return `${warn}${t("model.persist_failed", instanceName)}`;
|
|
16324
|
+
const head = result.outcome === "switched"
|
|
16325
|
+
? (result.confirmed ? t("model.switch_confirmed", instanceName, model) : t("model.runtime_success", instanceName, model))
|
|
16326
|
+
: t("model.switch_unconfirmed", instanceName, model, Math.round(RUNTIME_SWITCH_RESULT_MS / 1000));
|
|
16327
|
+
return `${warn}${head}${this.effortSuffix(instanceName)}`;
|
|
16328
|
+
}
|
|
15064
16329
|
// Persist either way: a runtime switch must survive the next respawn too,
|
|
15065
16330
|
// or the instance silently reverts to the CLI default after a fleet restart.
|
|
15066
|
-
|
|
15067
|
-
if (this.fleetConfig?.instances[instanceName]) {
|
|
15068
|
-
this.fleetConfig.instances[instanceName].model = model;
|
|
15069
|
-
this.saveFleetConfig();
|
|
15070
|
-
persisted = true;
|
|
15071
|
-
}
|
|
15072
|
-
else if (this.classicChannels?.setModelByInstance(instanceName, model)) {
|
|
15073
|
-
persisted = true;
|
|
15074
|
-
}
|
|
15075
|
-
if (!persisted)
|
|
16331
|
+
if (!this.persistInstanceModel(instanceName, model))
|
|
15076
16332
|
return `${warn}${t("model.persist_failed", instanceName)}`;
|
|
15077
16333
|
if (strategy === "runtime") {
|
|
15078
16334
|
this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
|
|
@@ -15081,6 +16337,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15081
16337
|
await this.restartSingleInstance(instanceName);
|
|
15082
16338
|
return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
|
|
15083
16339
|
}
|
|
16340
|
+
/** Save an instance's model (fleet.yaml, or its ClassicBot channel). False when neither holds it. */
|
|
16341
|
+
persistInstanceModel(instanceName, model) {
|
|
16342
|
+
if (this.fleetConfig?.instances[instanceName]) {
|
|
16343
|
+
this.fleetConfig.instances[instanceName].model = model;
|
|
16344
|
+
this.saveFleetConfig();
|
|
16345
|
+
return true;
|
|
16346
|
+
}
|
|
16347
|
+
return this.classicChannels?.setModelByInstance(instanceName, model) === true;
|
|
16348
|
+
}
|
|
15084
16349
|
/**
|
|
15085
16350
|
* The trailing "Current effort: …" line for a /model reply.
|
|
15086
16351
|
*
|
|
@@ -15098,6 +16363,377 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15098
16363
|
? `\n${t("effort.current_fleet", effort)}`
|
|
15099
16364
|
: `\n${t("effort.current", effort)}`;
|
|
15100
16365
|
}
|
|
16366
|
+
// ── #1806: /backend — another CLI backend, with its model and effort, in one write and one relaunch ──
|
|
16367
|
+
/** The steps' view of this fleet (backend-command.ts). `live`: a missing or stale model list may be probed (not on the web). */
|
|
16368
|
+
backendCommandDeps(instanceName, opts) {
|
|
16369
|
+
return {
|
|
16370
|
+
currentBackend: (name) => this.backendNameForInstance(name),
|
|
16371
|
+
currentDisplay: (name) => `${this.backendNameForInstance(name)} · ${this.resolveInstanceModel(name).display}`,
|
|
16372
|
+
installedBackends: () => KNOWN_BACKENDS.filter(b => b !== "mock" && this.backendInstalledHere(b)),
|
|
16373
|
+
models: (backend) => this.backendModelOptions(backend, opts),
|
|
16374
|
+
// #1808: the same rule update_instance_config's switch applies (backendModelVerdict).
|
|
16375
|
+
modelKnown: (backend, model) => this.backendModelVerdict(instanceName, backend, model).ok,
|
|
16376
|
+
effortLevels: (backend, model) => this.backendEffortLevels(instanceName, backend, model),
|
|
16377
|
+
inherited: (key) => {
|
|
16378
|
+
const value = getEffectiveInstanceDefaults(this.fleetConfig?.defaults)[key];
|
|
16379
|
+
return typeof value === "string" && value ? value : undefined;
|
|
16380
|
+
},
|
|
16381
|
+
modelCompatible: (backend, model) => isModelCompatible(backend, model),
|
|
16382
|
+
state: (name) => {
|
|
16383
|
+
const status = this.getInstanceStatus(name);
|
|
16384
|
+
return { running: status === "running", busy: status === "running" && !this.getInstanceIdle(name), status };
|
|
16385
|
+
},
|
|
16386
|
+
t: (key, ...args) => t(key, ...args),
|
|
16387
|
+
};
|
|
16388
|
+
}
|
|
16389
|
+
/**
|
|
16390
|
+
* `/effort`'s levels for a backend this instance may move to, for the model that will run there (codex's levels are
|
|
16391
|
+
* per model; null: its configured default). Empty: it has no effort setting. A file read, never a CLI.
|
|
16392
|
+
*/
|
|
16393
|
+
backendEffortLevels(instanceName, backend, model = null) {
|
|
16394
|
+
// codex: the model the NEXT launch runs, for the levels and the gate alike (#1807 review r2).
|
|
16395
|
+
let runs = model;
|
|
16396
|
+
if (backend === "codex") {
|
|
16397
|
+
const next = this.prospectiveCodexModel(instanceName, model);
|
|
16398
|
+
if (!next.proven)
|
|
16399
|
+
return [];
|
|
16400
|
+
runs = next.model;
|
|
16401
|
+
}
|
|
16402
|
+
let levels;
|
|
16403
|
+
try {
|
|
16404
|
+
const metadata = readEffortMetadata(backend, this.getInstanceDir(instanceName), runs);
|
|
16405
|
+
levels = metadata.strategy === "unsupported" ? [] : metadata.levels;
|
|
16406
|
+
}
|
|
16407
|
+
catch {
|
|
16408
|
+
return [];
|
|
16409
|
+
}
|
|
16410
|
+
// #1811: only the levels this host's launch of that backend will pass (launch-effort.ts, the gate the launch
|
|
16411
|
+
// itself applies) — an effort chosen here reaches the new CLI with the one relaunch, or is not offered.
|
|
16412
|
+
const takes = this.launchEffortGate(instanceName, backend, runs);
|
|
16413
|
+
return levels.filter(takes);
|
|
16414
|
+
}
|
|
16415
|
+
/**
|
|
16416
|
+
* #1808: whether a backend takes this model name — the one rule for /backend and update_instance_config's switch.
|
|
16417
|
+
* The name must be one its launch line can carry (modelNameCarried); then its offered list (the cached CLI env), and
|
|
16418
|
+
* claude-code's known names (without this instance's configured model, which belongs to the backend being left).
|
|
16419
|
+
* With no list known, the backend's usual name pattern decides.
|
|
16420
|
+
*/
|
|
16421
|
+
backendModelVerdict(instanceName, backend, model,
|
|
16422
|
+
/** The provider the backend will run on (default: this instance's own, else the fleet's). */
|
|
16423
|
+
opts = { provider: this.customProviderFor(instanceName, backend) }) {
|
|
16424
|
+
// A custom provider has its own catalog, which the CLI reads at launch and AgEnD cannot see before one (#1815
|
|
16425
|
+
// review): only the launch-line rule applies — never the account's list or the default provider's naming pattern.
|
|
16426
|
+
if (opts.provider)
|
|
16427
|
+
return modelNameCarried(backend, model) ? { ok: true } : { ok: false, choices: [] };
|
|
16428
|
+
const listed = this.readCliEnv(backend)?.models ?? [];
|
|
16429
|
+
const choices = listed.slice(0, 24).map(o => o.id);
|
|
16430
|
+
if (!modelNameCarried(backend, model))
|
|
16431
|
+
return { ok: false, choices };
|
|
16432
|
+
if (listed.some(o => o.id === model))
|
|
16433
|
+
return { ok: true };
|
|
16434
|
+
if (backend === "claude-code") {
|
|
16435
|
+
const check = this.claudeModelKnown(instanceName, model, null);
|
|
16436
|
+
return check.known ? { ok: true } : { ok: false, choices: check.names };
|
|
16437
|
+
}
|
|
16438
|
+
return listed.length === 0 && isModelCompatible(backend, model) ? { ok: true } : { ok: false, choices };
|
|
16439
|
+
}
|
|
16440
|
+
/**
|
|
16441
|
+
* #1807 review r2: the model codex's next launch of this instance runs. Every launch's writeConfig copies the SHARED
|
|
16442
|
+
* config.toml into the instance's codex home first, so that source decides — not the home's copy from an earlier
|
|
16443
|
+
* launch. Read-only; a model chosen here (`-c model=…`) outranks it; a project file that could change it, unproven.
|
|
16444
|
+
*/
|
|
16445
|
+
prospectiveCodexModel(instanceName, model) {
|
|
16446
|
+
const { sharedHome } = codexMetadataHomes(this.getInstanceDir(instanceName));
|
|
16447
|
+
return resolveCodexLaunchModel({
|
|
16448
|
+
isolatedHome: sharedHome, sharedHome, model,
|
|
16449
|
+
workingDirectory: this.fleetConfig?.instances[instanceName]?.working_directory,
|
|
16450
|
+
});
|
|
16451
|
+
}
|
|
16452
|
+
/** #1811: whether a launch of `backend` here would pass a level: the same gate its builder applies. */
|
|
16453
|
+
launchEffortGate(instanceName, backend, model) {
|
|
16454
|
+
if (backend === "claude-code" || backend === "grok" || backend === "antigravity") {
|
|
16455
|
+
const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
|
|
16456
|
+
if (!binary)
|
|
16457
|
+
return () => false;
|
|
16458
|
+
const path = resolveBinary(binary);
|
|
16459
|
+
return level => flagEffort(backend, level, path).arg !== null;
|
|
16460
|
+
}
|
|
16461
|
+
if (backend === "codex") {
|
|
16462
|
+
// `model` is the next launch's, proven by the caller (prospectiveCodexModel); the catalog is codex's own cache.
|
|
16463
|
+
if (!model)
|
|
16464
|
+
return () => false;
|
|
16465
|
+
const catalog = readCodexModelEffortLevels({ ...codexMetadataHomes(this.getInstanceDir(instanceName)), model });
|
|
16466
|
+
return level => codexEffort(level, catalog).arg !== null;
|
|
16467
|
+
}
|
|
16468
|
+
// kiro-cli gates its own --effort by version at launch (a quiet console note otherwise); muse always passes it.
|
|
16469
|
+
return () => true;
|
|
16470
|
+
}
|
|
16471
|
+
/** Installed the way the launch finds a CLI (resolveBinary): on PATH or in a known install directory. */
|
|
16472
|
+
backendInstalledHere(backend) {
|
|
16473
|
+
const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
|
|
16474
|
+
return !!binary && resolveBinary(binary) !== binary;
|
|
16475
|
+
}
|
|
16476
|
+
/** The models `/model` would offer on a backend: the cached CLI env, else (live) a bounded probe. Never throws. */
|
|
16477
|
+
async backendModelOptions(backend, opts) {
|
|
16478
|
+
const cached = this.readCliEnv(backend);
|
|
16479
|
+
if (!opts.live || (cached?.models.length && !this.cliEnvNeedsRefresh(cached)))
|
|
16480
|
+
return cached?.models ?? [];
|
|
16481
|
+
opts.onLiveProbe?.();
|
|
16482
|
+
const env = await this.probeBackendBounded(backend).catch(() => null);
|
|
16483
|
+
return env?.models.length ? env.models : cached?.models ?? [];
|
|
16484
|
+
}
|
|
16485
|
+
/**
|
|
16486
|
+
* #1806 review: the instance a `/backend` was asked about is still this config entry — not one deleted and created
|
|
16487
|
+
* again under the same name, or replaced by a Settings edit (both put a new object there), stopped or not.
|
|
16488
|
+
*/
|
|
16489
|
+
backendTargetIs(instanceName, target) {
|
|
16490
|
+
const instances = this.fleetConfig?.instances;
|
|
16491
|
+
return !!target && !!instances && Object.prototype.hasOwnProperty.call(instances, instanceName) && instances[instanceName] === target;
|
|
16492
|
+
}
|
|
16493
|
+
/** `/backend` is for fleet.yaml instances: update_instance_config does not manage a ClassicBot agent. */
|
|
16494
|
+
backendCommandRefusal(instanceName) {
|
|
16495
|
+
return this.fleetConfig?.instances && Object.prototype.hasOwnProperty.call(this.fleetConfig.instances, instanceName)
|
|
16496
|
+
? null : t("backend.classic_unsupported", instanceName);
|
|
16497
|
+
}
|
|
16498
|
+
/**
|
|
16499
|
+
* Apply a confirmed `/backend`: judged again first (a menu lives a minute — the backend may have moved, a CLI been
|
|
16500
|
+
* removed), then update_instance_config's write: backend, model and effort at once, refused and rolled back whole
|
|
16501
|
+
* (its credential-profile login gate included), then one fresh relaunch of a running instance.
|
|
16502
|
+
*/
|
|
16503
|
+
async applyBackendSwitch(instanceName, args, from, stillCurrent) {
|
|
16504
|
+
if (this.backendNameForInstance(instanceName) !== from)
|
|
16505
|
+
return t("backend.changed", instanceName, from);
|
|
16506
|
+
if (!stillCurrent())
|
|
16507
|
+
return t("menu.click_stale");
|
|
16508
|
+
const deps = this.backendCommandDeps(instanceName, { live: false });
|
|
16509
|
+
const step = await backendStep(deps, instanceName, args);
|
|
16510
|
+
if (step.kind !== "confirm")
|
|
16511
|
+
return step.kind === "refuse" ? step.text : t("backend.usage");
|
|
16512
|
+
// Asked again after the await, with nothing awaited between here and the write (applyInstanceConfigPatch writes
|
|
16513
|
+
// synchronously): two switches confirmed together, or an instance changed meanwhile, never both succeed.
|
|
16514
|
+
if (this.backendNameForInstance(instanceName) !== from)
|
|
16515
|
+
return t("backend.changed", instanceName, from);
|
|
16516
|
+
if (!stillCurrent())
|
|
16517
|
+
return t("menu.click_stale");
|
|
16518
|
+
const { plan } = step;
|
|
16519
|
+
const shown = planDisplay(deps, plan);
|
|
16520
|
+
const { value, error } = await new Promise(resolve => {
|
|
16521
|
+
applyInstanceConfigPatch(this, instanceName, { backend: plan.backend, model: plan.model, effort: plan.effort }, (value, error) => resolve({ value, error }));
|
|
16522
|
+
});
|
|
16523
|
+
if (error)
|
|
16524
|
+
return t("backend.refused", instanceName, error);
|
|
16525
|
+
const result = (value ?? {});
|
|
16526
|
+
if (result.warning)
|
|
16527
|
+
return t("backend.restart_failed", instanceName, plan.backend, result.warning);
|
|
16528
|
+
if (result.restarted)
|
|
16529
|
+
return t("backend.switched", instanceName, plan.backend, shown.model, shown.effort);
|
|
16530
|
+
return t("backend.switched_not_running", instanceName, plan.backend, shown.model, shown.effort, this.getInstanceStatus(instanceName));
|
|
16531
|
+
}
|
|
16532
|
+
/** A step's buttons: index callbacks (a model id can be longer than Telegram's 64-byte callback data allows). */
|
|
16533
|
+
backendStepButtons(instanceName, nonce, step) {
|
|
16534
|
+
if (step.kind === "choose")
|
|
16535
|
+
return step.choices.map((c, i) => ({ id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:${i}`, label: c.label }));
|
|
16536
|
+
if (step.kind !== "confirm")
|
|
16537
|
+
return [];
|
|
16538
|
+
const running = this.getInstanceStatus(instanceName) === "running";
|
|
16539
|
+
return [
|
|
16540
|
+
{ id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:0`, label: t(running ? "backend.confirm_button" : "backend.confirm_button_stopped") },
|
|
16541
|
+
{ id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:1`, label: t("backend.cancel_button") },
|
|
16542
|
+
];
|
|
16543
|
+
}
|
|
16544
|
+
/**
|
|
16545
|
+
* Show a `/backend` step (a choice, or the confirmation) where the command was given: Discord edits its interaction
|
|
16546
|
+
* reply; Telegram retires the previous step's keyboard and posts the next. One pending entry per step, under a fresh
|
|
16547
|
+
* nonce: a click on an older step finds nothing.
|
|
16548
|
+
*/
|
|
16549
|
+
async presentBackendStep(place, step) {
|
|
16550
|
+
const nonce = randomBytes(6).toString("hex");
|
|
16551
|
+
const buttons = this.backendStepButtons(place.instanceName, nonce, step);
|
|
16552
|
+
const timer = setTimeout(() => {
|
|
16553
|
+
const p = this.pendingBackendSelects.get(nonce);
|
|
16554
|
+
if (!p)
|
|
16555
|
+
return;
|
|
16556
|
+
this.pendingBackendSelects.delete(nonce);
|
|
16557
|
+
void this.endBackendMenu(p, t("backend.selection_expired")).catch(() => { });
|
|
16558
|
+
}, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
16559
|
+
timer.unref?.();
|
|
16560
|
+
const pending = { ...place, step, timer };
|
|
16561
|
+
this.pendingBackendSelects.set(nonce, pending);
|
|
16562
|
+
try {
|
|
16563
|
+
if (step.kind === "confirm" && place.respondButtons) {
|
|
16564
|
+
await place.respondButtons(step.text, buttons);
|
|
16565
|
+
return;
|
|
16566
|
+
}
|
|
16567
|
+
if (place.respondChoices) {
|
|
16568
|
+
await place.respondChoices(step.text, buttons);
|
|
16569
|
+
return;
|
|
16570
|
+
}
|
|
16571
|
+
if (!place.adapter || !place.adapterChatId)
|
|
16572
|
+
throw new Error("no place to show the menu");
|
|
16573
|
+
if (place.menuMessageId && place.adapter.editMessageRemoveButtons) {
|
|
16574
|
+
const chosen = t("backend.chosen", joinBackendArgs(step.args));
|
|
16575
|
+
await place.adapter.editMessageRemoveButtons(place.adapterChatId, place.menuMessageId, chosen, place.adapterThreadId).catch(() => { });
|
|
16576
|
+
}
|
|
16577
|
+
const menuMessageId = await place.adapter.promptUser(place.adapterChatId, step.text, buttons, { threadId: place.adapterThreadId });
|
|
16578
|
+
const fresh = this.pendingBackendSelects.get(nonce);
|
|
16579
|
+
if (fresh)
|
|
16580
|
+
fresh.menuMessageId = menuMessageId;
|
|
16581
|
+
}
|
|
16582
|
+
catch (err) {
|
|
16583
|
+
this.pendingBackendSelects.delete(nonce);
|
|
16584
|
+
clearTimeout(timer);
|
|
16585
|
+
this.logger.warn({ err, instanceName: place.instanceName }, "backend menu failed");
|
|
16586
|
+
await place.respond(t("backend.usage")).catch(() => { });
|
|
16587
|
+
}
|
|
16588
|
+
}
|
|
16589
|
+
/** Close a `/backend` menu with a last word: its buttons go (Telegram edits them away), the text stays. Returns where it was said. */
|
|
16590
|
+
async endBackendMenu(pending, text) {
|
|
16591
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
16592
|
+
if (pending.menuMessageId && pending.adapter.editMessageRemoveButtons) {
|
|
16593
|
+
try {
|
|
16594
|
+
await pending.adapter.editMessageRemoveButtons(pending.adapterChatId, pending.menuMessageId, text, pending.adapterThreadId);
|
|
16595
|
+
return pending.menuMessageId;
|
|
16596
|
+
}
|
|
16597
|
+
catch { /* say it in a new message */ }
|
|
16598
|
+
}
|
|
16599
|
+
return (await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId })).messageId;
|
|
16600
|
+
}
|
|
16601
|
+
if (pending.retireButtons)
|
|
16602
|
+
await pending.retireButtons(text);
|
|
16603
|
+
else
|
|
16604
|
+
await pending.respond(text);
|
|
16605
|
+
return undefined;
|
|
16606
|
+
}
|
|
16607
|
+
/** Start `/backend` for a fleet instance from Discord or Telegram; the caller has checked the channel admin. */
|
|
16608
|
+
async beginBackendCommand(place, args) {
|
|
16609
|
+
const refusal = this.backendCommandRefusal(place.instanceName);
|
|
16610
|
+
if (refusal) {
|
|
16611
|
+
await place.respond(refusal);
|
|
16612
|
+
return;
|
|
16613
|
+
}
|
|
16614
|
+
if (!place.respondChoices && !(place.adapter && place.adapterChatId)) {
|
|
16615
|
+
await place.respond(t("backend.usage"));
|
|
16616
|
+
return;
|
|
16617
|
+
}
|
|
16618
|
+
place = { ...place, target: this.fleetConfig.instances[place.instanceName] };
|
|
16619
|
+
const deps = this.backendCommandDeps(place.instanceName, {
|
|
16620
|
+
live: true,
|
|
16621
|
+
onLiveProbe: () => { void place.respond(t("model.refreshing")).catch(() => { }); },
|
|
16622
|
+
});
|
|
16623
|
+
const step = await backendStep(deps, place.instanceName, args);
|
|
16624
|
+
if (step.kind === "refuse") {
|
|
16625
|
+
await place.respond(step.text);
|
|
16626
|
+
return;
|
|
16627
|
+
}
|
|
16628
|
+
await this.presentBackendStep(place, step);
|
|
16629
|
+
}
|
|
16630
|
+
/** `/backend` slash handler: channel admin, as `/model`. Options or typed text give the direct form. */
|
|
16631
|
+
async handleBackendSlash(data, adapterId) {
|
|
16632
|
+
if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
|
|
16633
|
+
await data.respond(t("permission.denied"));
|
|
16634
|
+
return;
|
|
16635
|
+
}
|
|
16636
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
16637
|
+
if (!name) {
|
|
16638
|
+
await data.respond(t("classic.no_agent"));
|
|
16639
|
+
return;
|
|
16640
|
+
}
|
|
16641
|
+
const option = (key) => typeof data.options?.[key] === "string" ? data.options[key].trim() : "";
|
|
16642
|
+
const given = [option("backend"), option("model"), option("effort")];
|
|
16643
|
+
// A later option without the one before it says nothing that can be judged in order.
|
|
16644
|
+
if (given.some((value, i) => value && given.slice(0, i).some(before => !before))) {
|
|
16645
|
+
await data.respond(t("backend.usage"));
|
|
16646
|
+
return;
|
|
16647
|
+
}
|
|
16648
|
+
const args = given[0] ? given.filter(Boolean) : backendArgs(data.text ?? "");
|
|
16649
|
+
await this.beginBackendCommand({ instanceName: name, userId: data.userId, channelId: data.channelId, adapterId, respond: data.respond, respondChoices: data.respondChoices,
|
|
16650
|
+
respondButtons: data.respondButtons, retireButtons: data.retireButtons }, args);
|
|
16651
|
+
}
|
|
16652
|
+
/** Telegram `/backend` in a fleet topic (topic-commands) or a ClassicBot chat; the caller has checked the admin. */
|
|
16653
|
+
async runBackendCommand(instanceName, text, userId, channelId, adapter, chatId, threadId, adapterId) {
|
|
16654
|
+
const respond = async (reply) => (await adapter.sendText(chatId, reply, { threadId })).messageId;
|
|
16655
|
+
await this.beginBackendCommand({ instanceName, userId, channelId, adapterId, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId }, backendArgs(text.replace(/^\/backend(@\S+)?/, "")));
|
|
16656
|
+
}
|
|
16657
|
+
/** Consume a `/backend` click: the next step, or on the confirmation the switch itself. True for every backend-select id. */
|
|
16658
|
+
async handleBackendSelection(data, adapterId) {
|
|
16659
|
+
if (!data.callbackData.startsWith(BACKEND_SELECT_CALLBACK_PREFIX))
|
|
16660
|
+
return false;
|
|
16661
|
+
const match = data.callbackData.match(/^backend-select:([0-9a-f]+):(\d{1,2})$/);
|
|
16662
|
+
if (!match)
|
|
16663
|
+
return true;
|
|
16664
|
+
const pending = this.pendingBackendSelects.get(match[1]);
|
|
16665
|
+
if (!pending)
|
|
16666
|
+
return true;
|
|
16667
|
+
// As a /model click (#754 audit): the admin who opened it, through the same bot, in the same channel, still admin.
|
|
16668
|
+
const cbChannel = data.threadId ?? data.chatId;
|
|
16669
|
+
if (!data.userId || data.userId !== pending.userId
|
|
16670
|
+
|| (pending.adapterId !== undefined && pending.adapterId !== adapterId)
|
|
16671
|
+
|| (cbChannel !== pending.channelId && data.chatId !== pending.channelId)
|
|
16672
|
+
|| !this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)) {
|
|
16673
|
+
data.ack?.(t("buttons.admin_only"));
|
|
16674
|
+
return true;
|
|
16675
|
+
}
|
|
16676
|
+
this.pendingBackendSelects.delete(match[1]);
|
|
16677
|
+
clearTimeout(pending.timer);
|
|
16678
|
+
if (!pending.menuMessageId && data.messageId)
|
|
16679
|
+
pending.menuMessageId = data.messageId;
|
|
16680
|
+
const index = Number(match[2]);
|
|
16681
|
+
const { step } = pending;
|
|
16682
|
+
if (step.kind === "choose") {
|
|
16683
|
+
const choice = step.choices[index];
|
|
16684
|
+
if (!choice)
|
|
16685
|
+
return true;
|
|
16686
|
+
if (!this.backendTargetIs(pending.instanceName, pending.target)) {
|
|
16687
|
+
await this.endBackendMenu(pending, t("menu.click_stale")).catch(() => { });
|
|
16688
|
+
return true;
|
|
16689
|
+
}
|
|
16690
|
+
const next = await backendStep(this.backendCommandDeps(pending.instanceName, { live: true }), pending.instanceName, [...step.args, choice.token]);
|
|
16691
|
+
if (next.kind === "refuse") {
|
|
16692
|
+
await this.endBackendMenu(pending, next.text).catch(() => { });
|
|
16693
|
+
return true;
|
|
16694
|
+
}
|
|
16695
|
+
await this.presentBackendStep(pending, next);
|
|
16696
|
+
return true;
|
|
16697
|
+
}
|
|
16698
|
+
if (index === 1) {
|
|
16699
|
+
await this.endBackendMenu(pending, t("backend.cancelled")).catch(() => { });
|
|
16700
|
+
return true;
|
|
16701
|
+
}
|
|
16702
|
+
if (index !== 0)
|
|
16703
|
+
return true;
|
|
16704
|
+
// Confirmed: say so at once, then switch in the background (the relaunch takes a while) and say how it went.
|
|
16705
|
+
const progressId = await this.endBackendMenu(pending, t("backend.switching", pending.instanceName, step.plan.backend)).catch(() => undefined);
|
|
16706
|
+
const say = async (text) => {
|
|
16707
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
16708
|
+
if (progressId)
|
|
16709
|
+
await pending.adapter.editMessage(pending.adapterChatId, progressId, text, pending.adapterThreadId);
|
|
16710
|
+
else
|
|
16711
|
+
await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
|
|
16712
|
+
}
|
|
16713
|
+
else
|
|
16714
|
+
await pending.respond(text);
|
|
16715
|
+
};
|
|
16716
|
+
void (async () => {
|
|
16717
|
+
let result;
|
|
16718
|
+
try {
|
|
16719
|
+
// Asked again after the awaits above: the instance may have moved, or the admin lost admin.
|
|
16720
|
+
const stillCurrent = () => this.backendTargetIs(pending.instanceName, pending.target)
|
|
16721
|
+
&& this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId);
|
|
16722
|
+
result = stillCurrent()
|
|
16723
|
+
? await withSpawnHoldReply(say, () => this.applyBackendSwitch(pending.instanceName, step.args, step.from, stillCurrent))
|
|
16724
|
+
: t("menu.click_stale");
|
|
16725
|
+
}
|
|
16726
|
+
catch (err) {
|
|
16727
|
+
this.logger.error({ err, instance: pending.instanceName, backend: step.plan.backend }, "Backend switch failed");
|
|
16728
|
+
result = t("backend.failed", pending.instanceName, step.plan.backend, err instanceof Error ? err.message : String(err));
|
|
16729
|
+
}
|
|
16730
|
+
await say(result).catch(() => {
|
|
16731
|
+
if (pending.adapter && pending.adapterChatId)
|
|
16732
|
+
pending.adapter.sendText(pending.adapterChatId, result, { threadId: pending.adapterThreadId }).catch(() => { });
|
|
16733
|
+
});
|
|
16734
|
+
})();
|
|
16735
|
+
return true;
|
|
16736
|
+
}
|
|
15101
16737
|
/** Read recent chat log for agent context */
|
|
15102
16738
|
getRecentChatLog(instanceName, maxLines = 10) {
|
|
15103
16739
|
const logDir = ClassicChannelManager.chatLogDir(instanceName);
|
|
@@ -15191,6 +16827,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15191
16827
|
}
|
|
15192
16828
|
/** Handle Discord's required static slash choice, warning before a likely startup failure. */
|
|
15193
16829
|
async handleClassicStartSlash(data, adapterId) {
|
|
16830
|
+
return withSpawnHoldReply(text => data.respond(text), () => this.handleClassicStartSlashCore(data, adapterId));
|
|
16831
|
+
}
|
|
16832
|
+
async handleClassicStartSlashCore(data, adapterId) {
|
|
15194
16833
|
const requestedBackend = typeof data.options?.backend === "string" ? data.options.backend : undefined;
|
|
15195
16834
|
if (!requestedBackend) {
|
|
15196
16835
|
// beta.31 made this option required. Discord can briefly retain the old
|
|
@@ -15307,7 +16946,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15307
16946
|
// failure. The selected backend is still attempted as requested.
|
|
15308
16947
|
if (warning)
|
|
15309
16948
|
await pending.complete(warning, pending.messageId);
|
|
15310
|
-
const reply = await this.handleClassicStart(pending.channelId, pending.channelName, pending.userId, pending.guildId, pending.adapterId, selectedBackend);
|
|
16949
|
+
const reply = await withSpawnHoldReply(text => pending.complete(text, pending.messageId), () => this.handleClassicStart(pending.channelId, pending.channelName, pending.userId, pending.guildId, pending.adapterId, selectedBackend));
|
|
15311
16950
|
await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
|
|
15312
16951
|
}
|
|
15313
16952
|
/** Start a classic channel instance with lightweight config */
|
|
@@ -15388,12 +17027,29 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15388
17027
|
const ch = this.classicChannels.unregister(channelId, adapterId);
|
|
15389
17028
|
if (!ch)
|
|
15390
17029
|
return t("classic.no_agent");
|
|
17030
|
+
// #1664: the room is gone (not paused or stopped): nobody's favorite any more — unless the name is still an
|
|
17031
|
+
// instance (fleet.yaml's, or another room's), so a later room of the same name does not inherit an old star.
|
|
17032
|
+
if (!Object.hasOwn(this.fleetConfig?.instances ?? {}, ch.instanceName)
|
|
17033
|
+
&& !this.classicChannels.getAll().some((c) => c.instanceName === ch.instanceName)) {
|
|
17034
|
+
try {
|
|
17035
|
+
this.webPrefs.forgetInstance(ch.instanceName);
|
|
17036
|
+
}
|
|
17037
|
+
catch (err) {
|
|
17038
|
+
this.logger.warn({ err: err.message, instanceName: ch.instanceName }, "Dropping a removed room from web favorites failed");
|
|
17039
|
+
}
|
|
17040
|
+
}
|
|
15391
17041
|
this.instanceWorldBinding.delete(ch.instanceName);
|
|
15392
|
-
|
|
17042
|
+
// #1716: say so in the reply when background work was ended with the stop.
|
|
17043
|
+
// Null-safe: a foreign stopInstance stub may resolve nothing at all.
|
|
17044
|
+
const stopOutcome = await this.stopInstance(ch.instanceName).catch(err => {
|
|
17045
|
+
this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance");
|
|
17046
|
+
return { backgroundWorkEnded: false };
|
|
17047
|
+
});
|
|
15393
17048
|
clearPausedMarker(this.getInstanceDir(ch.instanceName));
|
|
15394
17049
|
this.reregisterClassicChannels();
|
|
15395
17050
|
this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
|
|
15396
|
-
return t("classic.stopped")
|
|
17051
|
+
return t("classic.stopped")
|
|
17052
|
+
+ (stopOutcome?.backgroundWorkEnded ? `\n${t("classic.stopped_bg_work")}` : "");
|
|
15397
17053
|
}
|
|
15398
17054
|
/**
|
|
15399
17055
|
* Idempotent while in flight: SIGINT and SIGTERM share one handler and the
|
|
@@ -15489,6 +17145,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15489
17145
|
clearInterval(this.eventLogPruneTimer);
|
|
15490
17146
|
this.eventLogPruneTimer = null;
|
|
15491
17147
|
}
|
|
17148
|
+
this.logService?.unwatchAll(); // #1218: a Log's watch timers end with the fleet
|
|
15492
17149
|
if (this.outboxPruneTimer) {
|
|
15493
17150
|
clearInterval(this.outboxPruneTimer);
|
|
15494
17151
|
this.outboxPruneTimer = null;
|
|
@@ -15588,8 +17245,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15588
17245
|
this.controlClient = null;
|
|
15589
17246
|
if (this.healthServer) {
|
|
15590
17247
|
this.healthServerListening = false;
|
|
15591
|
-
this.healthServer
|
|
15592
|
-
this.
|
|
17248
|
+
const server = this.healthServer;
|
|
17249
|
+
const drain = this.healthServerDrain ?? trackWebServer(server);
|
|
17250
|
+
await drain.close(this.sseClients);
|
|
17251
|
+
if (this.healthServer === server) {
|
|
17252
|
+
this.healthServer = null;
|
|
17253
|
+
this.healthServerDrain = null;
|
|
17254
|
+
}
|
|
15593
17255
|
}
|
|
15594
17256
|
this.stopPreviewListener();
|
|
15595
17257
|
// The store writes lastSeen at most once a minute; what that debounce is still holding — and any
|
|
@@ -15918,8 +17580,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15918
17580
|
if (old.backend !== backend || old.model !== model || old.autoPauseAfter !== autoPauseAfter || profile.changed) {
|
|
15919
17581
|
this.logger.info({ instanceName: ch.instanceName, ...(profile.changed ? { profileFrom: profile.from, profileTo: profile.to } : {}) }, "Classic cold config changed — restarting");
|
|
15920
17582
|
observe?.(ch.instanceName, "restart", "running");
|
|
17583
|
+
const launchedIn = this.daemons.get(ch.instanceName)?.getConfigSnapshot?.()?.working_directory;
|
|
15921
17584
|
await this.stopInstance(ch.instanceName).catch(() => { });
|
|
15922
|
-
|
|
17585
|
+
// #1753: the conversation goes along where it can; otherwise a switch that loses it starts fresh.
|
|
17586
|
+
if (profile.carry)
|
|
17587
|
+
await this.switchConversation(ch.instanceName, { ...profile.carry, workingDirectory: launchedIn });
|
|
17588
|
+
else if (profile.startsFresh)
|
|
15923
17589
|
this.writeFreshStartMarker(ch.instanceName);
|
|
15924
17590
|
await this.startClassicInstanceUnattended(ch, "classic instance after fleet reload");
|
|
15925
17591
|
observe?.(ch.instanceName, "restart", "done");
|
|
@@ -17558,7 +19224,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17558
19224
|
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)");
|
|
17559
19225
|
}
|
|
17560
19226
|
/** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
|
|
17561
|
-
dispatchWebHttp(req, res, port) {
|
|
19227
|
+
dispatchWebHttp(req, res, port, closing = false) {
|
|
17562
19228
|
try {
|
|
17563
19229
|
res.setHeader("Content-Type", "application/json");
|
|
17564
19230
|
// No Referer to a tunnel host, an upstream proxy, or any page linked from
|
|
@@ -17577,6 +19243,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17577
19243
|
res.end(JSON.stringify({ error: WEB_HOST_REJECTED_MESSAGE }));
|
|
17578
19244
|
return;
|
|
17579
19245
|
}
|
|
19246
|
+
if (closing) {
|
|
19247
|
+
res.writeHead(503, { Connection: "close", "Retry-After": "10" });
|
|
19248
|
+
res.end(JSON.stringify({ error: "fleet stopping" }));
|
|
19249
|
+
return;
|
|
19250
|
+
}
|
|
17580
19251
|
const requestPath = new URL(req.url ?? "/", `http://localhost:${port}`).pathname;
|
|
17581
19252
|
// Branding is public, after Host admission: automatic favicon requests
|
|
17582
19253
|
// must not become noisy web-token 401s in the browser console.
|
|
@@ -17762,14 +19433,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17762
19433
|
return;
|
|
17763
19434
|
}
|
|
17764
19435
|
(async () => {
|
|
19436
|
+
let held = false;
|
|
17765
19437
|
try {
|
|
17766
19438
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
17767
|
-
await
|
|
19439
|
+
await withSpawnHoldReply(async (message) => {
|
|
19440
|
+
held = true;
|
|
19441
|
+
res.writeHead(202, { "Content-Type": "application/json" });
|
|
19442
|
+
res.end(JSON.stringify({ held: true, message, instance: name }));
|
|
19443
|
+
}, () => this.startInstance(name, config, topicMode ?? false, "fleet-topic", true));
|
|
17768
19444
|
this.emitSseEvent("status", this.getUiStatus());
|
|
19445
|
+
if (held)
|
|
19446
|
+
return;
|
|
17769
19447
|
res.writeHead(200);
|
|
17770
19448
|
res.end(JSON.stringify({ ok: true }));
|
|
17771
19449
|
}
|
|
17772
19450
|
catch (err) {
|
|
19451
|
+
if (held) {
|
|
19452
|
+
this.logger.error({ err, name }, "Held start failed");
|
|
19453
|
+
return;
|
|
19454
|
+
}
|
|
17773
19455
|
res.writeHead(500);
|
|
17774
19456
|
res.end(JSON.stringify({ error: `Start failed: ${err.message}` }));
|
|
17775
19457
|
}
|
|
@@ -17784,15 +19466,24 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17784
19466
|
const name = decodeURIComponent(req.url.slice("/restart/".length));
|
|
17785
19467
|
this.logger.info({ name }, "Instance restart requested via HTTP");
|
|
17786
19468
|
(async () => {
|
|
19469
|
+
let held = false;
|
|
17787
19470
|
try {
|
|
17788
|
-
await
|
|
19471
|
+
await withSpawnHoldReply(async (message) => {
|
|
19472
|
+
held = true;
|
|
19473
|
+
res.writeHead(202, { "Content-Type": "application/json" });
|
|
19474
|
+
res.end(JSON.stringify({ held: true, message, instance: name }));
|
|
19475
|
+
}, () => this.restartSingleInstance(name, { explicit: true }));
|
|
17789
19476
|
this.logger.info({ name }, "Instance restarted");
|
|
17790
19477
|
this.emitSseEvent("status", this.getUiStatus());
|
|
19478
|
+
if (held)
|
|
19479
|
+
return;
|
|
17791
19480
|
res.writeHead(200);
|
|
17792
19481
|
res.end(JSON.stringify({ restarted: name }));
|
|
17793
19482
|
}
|
|
17794
19483
|
catch (err) {
|
|
17795
19484
|
this.logger.error({ err, name }, "Instance restart failed");
|
|
19485
|
+
if (held)
|
|
19486
|
+
return;
|
|
17796
19487
|
const status = err.message.includes("not found") ? 404 : 500;
|
|
17797
19488
|
res.writeHead(status);
|
|
17798
19489
|
res.end(JSON.stringify({ error: `Restart failed: ${err.message}` }));
|
|
@@ -17875,7 +19566,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17875
19566
|
// Defensive for direct/unit callers; normal startup initializes these before adapters.
|
|
17876
19567
|
if (!this.webToken || !this.webSessions)
|
|
17877
19568
|
this.initializeWebAuthTokens();
|
|
17878
|
-
|
|
19569
|
+
const server = createServer((req, res) => this.dispatchWebHttp(req, res, port, drain.closing));
|
|
19570
|
+
const drain = trackWebServer(server);
|
|
19571
|
+
this.healthServer = server;
|
|
19572
|
+
this.healthServerDrain = drain;
|
|
17879
19573
|
const markListening = (afterTakeover = false) => {
|
|
17880
19574
|
this.healthServerListening = true;
|
|
17881
19575
|
this.logger.info({ port }, afterTakeover
|
|
@@ -17884,7 +19578,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17884
19578
|
// Never the token: fleet.log is readable by anything that can read the
|
|
17885
19579
|
// data dir, is copied into bug reports, and is tailed in shared terminals.
|
|
17886
19580
|
// `/dashboard` and `agend web` are the ways to get an authorized link.
|
|
17887
|
-
this.logger.info({ url: `http://localhost:${port}/ui` }, "Web UI available (open it with /
|
|
19581
|
+
this.logger.info({ url: `http://localhost:${port}/ui` }, "Web UI available (open it with /web or `agend web`)");
|
|
17888
19582
|
this.logger.info({ url: `http://localhost:${port}/view` }, "Web View available");
|
|
17889
19583
|
// #1306: the preview listener starts once the web listener is bound — its frame-ancestors name the real port.
|
|
17890
19584
|
const bound = this.healthServer?.address();
|
|
@@ -17961,7 +19655,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17961
19655
|
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");
|
|
17962
19656
|
return;
|
|
17963
19657
|
}
|
|
17964
|
-
const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig
|
|
19658
|
+
const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig,
|
|
19659
|
+
// #1723: with a web.preview_origin, the open public link's dashboard frames this listener's preview too.
|
|
19660
|
+
extraOrigins: () => { const o = settings.origin ? this.publicWebLink?.openDashboardOrigin() : null; return o ? [o] : []; } });
|
|
17965
19661
|
this.previewListener = listener;
|
|
17966
19662
|
listener.server.on("error", (err) => {
|
|
17967
19663
|
if (this.previewListener !== listener)
|
|
@@ -18010,6 +19706,37 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18010
19706
|
decided.reason = "Previews are not available on this fleet right now.";
|
|
18011
19707
|
return { ...decided, boot: listener ? listener.bootId : null };
|
|
18012
19708
|
}
|
|
19709
|
+
/**
|
|
19710
|
+
* #1723: for one page of the public link (exposure `exposureId`, at `dashboardOrigin`): its own preview tunnel's
|
|
19711
|
+
* origin once up, "starting" until then, why it failed if it did — or a configured web.preview_origin, used instead.
|
|
19712
|
+
*/
|
|
19713
|
+
publicPreviewForUi(exposureId, dashboardOrigin) {
|
|
19714
|
+
const web = this.fleetConfig?.web;
|
|
19715
|
+
const off = (code, reason, extra = {}) => ({ dashboardOrigin, previewOrigin: null, reason, code, boot: null, frameSrc: null, ...extra });
|
|
19716
|
+
if (web?.preview === false)
|
|
19717
|
+
return off("fleetOff", "Previews are turned off for this fleet (web.preview: false).");
|
|
19718
|
+
if (web?.preview_on_public_link === false)
|
|
19719
|
+
return off("publicLink", "Previews are off on the public link (web.preview_on_public_link: false).");
|
|
19720
|
+
const configured = typeof web?.preview_origin === "string" ? normalizeOrigin(web.preview_origin) : null;
|
|
19721
|
+
if (configured) {
|
|
19722
|
+
const listener = this.previewListening ? this.previewListener : null;
|
|
19723
|
+
if (!listener)
|
|
19724
|
+
return off("publicLink", "Previews are not available on this fleet right now.");
|
|
19725
|
+
if (!configured.startsWith("https:"))
|
|
19726
|
+
return off("needHttps", "Previews need an https web.preview_origin when the dashboard is reached over https.");
|
|
19727
|
+
return { dashboardOrigin, previewOrigin: configured, reason: null, code: null, boot: listener.bootId, frameSrc: `${configured}/frame` };
|
|
19728
|
+
}
|
|
19729
|
+
const status = this.publicWebLink?.previewStatus(exposureId) ?? null;
|
|
19730
|
+
if (!status)
|
|
19731
|
+
return off("publicLink", "Previews are not offered on this public link.");
|
|
19732
|
+
// The page may load before the preview tunnel has a host: it frames the quick-tunnel wildcard, and only ever the
|
|
19733
|
+
// origin /ui/preview-status names (the preview side's exact frame-ancestors is the boundary).
|
|
19734
|
+
if (status.state === "starting")
|
|
19735
|
+
return off("publicLinkStarting", "Preview is starting…", { frameSrc: PUBLIC_PREVIEW_FRAME_SRC });
|
|
19736
|
+
if (status.state === "failed")
|
|
19737
|
+
return off("publicLinkPreviewFailed", `Previews are unavailable on this public link (${status.reason}).`, { detail: status.reason });
|
|
19738
|
+
return { dashboardOrigin, previewOrigin: status.origin, reason: null, code: null, boot: status.boot, frameSrc: PUBLIC_PREVIEW_FRAME_SRC };
|
|
19739
|
+
}
|
|
18013
19740
|
getUiStatus() {
|
|
18014
19741
|
return measureSyncWork("fleet.uiStatus", () => this.getUiStatusSync());
|
|
18015
19742
|
}
|
|
@@ -18026,6 +19753,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18026
19753
|
// /api/profiles (resolveInstanceIdentity) — the sidebar groups, searches and labels with it on every page.
|
|
18027
19754
|
const classicRooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
|
|
18028
19755
|
const profiles = profileIdentities(this.dataDir);
|
|
19756
|
+
// #1630: decorative — whatever goes wrong deciding an avatar never costs the frame (every instance gets its first character).
|
|
19757
|
+
let ownBots = new Map();
|
|
19758
|
+
try {
|
|
19759
|
+
ownBots = this.ownBots();
|
|
19760
|
+
}
|
|
19761
|
+
catch (err) {
|
|
19762
|
+
this.logger.debug({ err }, "own bots unreadable (getUiStatus)");
|
|
19763
|
+
}
|
|
18029
19764
|
const instances = names.map(name => {
|
|
18030
19765
|
const statusFile = join(this.getInstanceDir(name), "statusline.json");
|
|
18031
19766
|
let cost = 0;
|
|
@@ -18063,9 +19798,19 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18063
19798
|
const effort = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.effort;
|
|
18064
19799
|
const effort_source = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.source;
|
|
18065
19800
|
const identity = resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic: classicRooms.get(name), profile: profiles.get(name) });
|
|
19801
|
+
let image;
|
|
19802
|
+
try {
|
|
19803
|
+
image = this.webIdentityImage(name, identity.display_name, profiles.get(name)?.avatar_path, ownBots);
|
|
19804
|
+
}
|
|
19805
|
+
catch (err) {
|
|
19806
|
+
this.logger.debug({ err, name }, "avatar unreadable (getUiStatus)");
|
|
19807
|
+
image = { avatar: avatarFor({ name, displayName: identity.display_name }) };
|
|
19808
|
+
}
|
|
18066
19809
|
return {
|
|
18067
19810
|
name,
|
|
18068
19811
|
display_name: identity.display_name || undefined,
|
|
19812
|
+
avatar: image.avatar,
|
|
19813
|
+
...(image.accent ? { accent: image.accent } : {}),
|
|
18069
19814
|
description: identity.description || undefined,
|
|
18070
19815
|
role: identity.role || undefined,
|
|
18071
19816
|
tags: identity.tags,
|
|
@@ -18088,6 +19833,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18088
19833
|
uptime: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
18089
19834
|
// #1519 P7: how many chat connections are configured — 0 is a web-only fleet, which gets the first-run card.
|
|
18090
19835
|
connections: (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).length,
|
|
19836
|
+
// #1665: the host preferences' revision — a page that has another re-reads /ui/prefs (no values in this frame).
|
|
19837
|
+
prefs_rev: this.webPrefs.rev(),
|
|
19838
|
+
// #1685: host memory pressure while it slows or holds new agents (the dashboard's indicator; macOS elevated is
|
|
19839
|
+
// shown here rather than in chat). null when normal, unknown or advisory.
|
|
19840
|
+
host_memory: this.hostMemoryIndicator(),
|
|
18091
19841
|
};
|
|
18092
19842
|
}
|
|
18093
19843
|
}
|