@songsid/agend 2.3.1 → 2.3.2-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/THIRD_PARTY_NOTICES.md +40 -0
- package/dist/agent-cli.js +21 -2
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.js +5 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/antigravity-usage-scan.d.ts +58 -0
- package/dist/antigravity-usage-scan.js +257 -0
- package/dist/antigravity-usage-scan.js.map +1 -0
- package/dist/antigravity-usage-worker.d.ts +1 -0
- package/dist/antigravity-usage-worker.js +10 -0
- package/dist/antigravity-usage-worker.js.map +1 -0
- package/dist/auth-api.js +4 -0
- package/dist/auth-api.js.map +1 -1
- package/dist/authority/authority-state.d.ts +4 -0
- package/dist/authority/authority-state.js +16 -10
- package/dist/authority/authority-state.js.map +1 -1
- package/dist/authority/session-authorizations.d.ts +7 -1
- package/dist/authority/session-authorizations.js +6 -4
- package/dist/authority/session-authorizations.js.map +1 -1
- package/dist/authority/web-authority.d.ts +19 -6
- package/dist/authority/web-authority.js +22 -8
- package/dist/authority/web-authority.js.map +1 -1
- package/dist/backend/kiro-identity.d.ts +64 -0
- package/dist/backend/kiro-identity.js +101 -0
- package/dist/backend/kiro-identity.js.map +1 -1
- package/dist/backend/kiro-retired-model.d.ts +62 -0
- package/dist/backend/kiro-retired-model.js +160 -0
- package/dist/backend/kiro-retired-model.js.map +1 -0
- package/dist/backend/kiro.d.ts +71 -2
- package/dist/backend/kiro.js +352 -11
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/types.d.ts +59 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/backend-signin-guide.d.ts +1 -0
- package/dist/backend-signin-guide.js +9 -0
- package/dist/backend-signin-guide.js.map +1 -0
- package/dist/backend-signin-pending.d.ts +10 -0
- package/dist/backend-signin-pending.js +44 -0
- package/dist/backend-signin-pending.js.map +1 -0
- package/dist/browser-backend-signins.d.ts +56 -0
- package/dist/browser-backend-signins.js +189 -0
- package/dist/browser-backend-signins.js.map +1 -0
- package/dist/cache-ledger.d.ts +38 -2
- package/dist/cache-ledger.js +133 -8
- package/dist/cache-ledger.js.map +1 -1
- package/dist/cache-prices.d.ts +2 -1
- package/dist/cache-prices.js +5 -2
- package/dist/cache-prices.js.map +1 -1
- package/dist/cache-service.d.ts +68 -0
- package/dist/cache-service.js +355 -13
- package/dist/cache-service.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +13 -0
- package/dist/channel/adapters/discord.js +103 -16
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-tools.js +34 -7
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/tool-router.js +2 -1
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +11 -0
- package/dist/classic-channel-manager.d.ts +11 -0
- package/dist/classic-channel-manager.js +39 -1
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +21 -2
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.d.ts +7 -1
- package/dist/config-validator.js +92 -12
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +19 -0
- package/dist/config.js +31 -1
- package/dist/config.js.map +1 -1
- package/dist/cpu-quota.d.ts +23 -0
- package/dist/cpu-quota.js +86 -0
- package/dist/cpu-quota.js.map +1 -0
- package/dist/daemon.d.ts +57 -3
- package/dist/daemon.js +330 -50
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard-permissions.d.ts +20 -6
- package/dist/dashboard-permissions.js +58 -9
- package/dist/dashboard-permissions.js.map +1 -1
- package/dist/delivery-outbox.d.ts +12 -1
- package/dist/delivery-outbox.js +67 -15
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.js +11 -1
- package/dist/doctor.js.map +1 -1
- package/dist/event-log.d.ts +5 -0
- package/dist/event-log.js +13 -0
- package/dist/event-log.js.map +1 -1
- package/dist/fleet-manager.d.ts +212 -0
- package/dist/fleet-manager.js +914 -111
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-process-handlers.d.ts +1 -0
- package/dist/fleet-process-handlers.js +9 -1
- package/dist/fleet-process-handlers.js.map +1 -1
- package/dist/forged-envelope-scan.d.ts +9 -0
- package/dist/forged-envelope-scan.js +30 -5
- package/dist/forged-envelope-scan.js.map +1 -1
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +5 -2
- package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +14 -1
- package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +28 -5
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +31 -8
- package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +138 -59
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +145 -61
- package/dist/guest-seen-people.d.ts +38 -0
- package/dist/guest-seen-people.js +78 -0
- package/dist/guest-seen-people.js.map +1 -0
- package/dist/instance-config-impact.js +14 -6
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +20 -0
- package/dist/instance-lifecycle.js +71 -13
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/kiro-db-reader.d.ts +5 -0
- package/dist/kiro-db-reader.js +18 -0
- package/dist/kiro-db-reader.js.map +1 -1
- package/dist/kiro-engine-status.d.ts +2 -0
- package/dist/kiro-engine-status.js +9 -0
- package/dist/kiro-engine-status.js.map +1 -1
- package/dist/kiro-migrate.d.ts +49 -0
- package/dist/kiro-migrate.js +105 -0
- package/dist/kiro-migrate.js.map +1 -0
- package/dist/kiro-transcript-lane.d.ts +2 -0
- package/dist/kiro-transcript-lane.js.map +1 -1
- package/dist/kiro-transcript-worker.js +1 -0
- package/dist/kiro-transcript-worker.js.map +1 -1
- package/dist/kiro-tui-forensic.d.ts +7 -0
- package/dist/kiro-tui-forensic.js +110 -0
- package/dist/kiro-tui-forensic.js.map +1 -0
- package/dist/kiro-usage-run.d.ts +7 -0
- package/dist/kiro-usage-run.js +39 -0
- package/dist/kiro-usage-run.js.map +1 -0
- package/dist/kiro-usage-scan.d.ts +49 -0
- package/dist/kiro-usage-scan.js +223 -0
- package/dist/kiro-usage-scan.js.map +1 -0
- package/dist/kiro-usage-worker.d.ts +1 -0
- package/dist/kiro-usage-worker.js +10 -0
- package/dist/kiro-usage-worker.js.map +1 -0
- package/dist/kiro-usage.d.ts +58 -0
- package/dist/kiro-usage.js +310 -0
- package/dist/kiro-usage.js.map +1 -0
- package/dist/locale.js +120 -12
- package/dist/locale.js.map +1 -1
- package/dist/log-service.js +1 -1
- package/dist/log-service.js.map +1 -1
- package/dist/login-url.d.ts +4 -0
- package/dist/login-url.js +22 -0
- package/dist/login-url.js.map +1 -0
- package/dist/mac-auto-restore.d.ts +117 -0
- package/dist/mac-auto-restore.js +294 -0
- package/dist/mac-auto-restore.js.map +1 -0
- package/dist/needs-you-hub.d.ts +2 -1
- package/dist/needs-you-hub.js +1 -1
- package/dist/needs-you-hub.js.map +1 -1
- package/dist/needs-you.d.ts +8 -2
- package/dist/needs-you.js +6 -0
- package/dist/needs-you.js.map +1 -1
- package/dist/notice-delivery.d.ts +16 -0
- package/dist/notice-delivery.js +35 -0
- package/dist/notice-delivery.js.map +1 -0
- package/dist/office-share-page.d.ts +16 -0
- package/dist/office-share-page.js +85 -0
- package/dist/office-share-page.js.map +1 -0
- package/dist/office-share-service.d.ts +91 -0
- package/dist/office-share-service.js +176 -0
- package/dist/office-share-service.js.map +1 -0
- package/dist/office-share.d.ts +258 -0
- package/dist/office-share.js +549 -0
- package/dist/office-share.js.map +1 -0
- package/dist/opencode-usage-scan.d.ts +28 -0
- package/dist/opencode-usage-scan.js +92 -0
- package/dist/opencode-usage-scan.js.map +1 -0
- package/dist/opencode-usage-worker.d.ts +1 -0
- package/dist/opencode-usage-worker.js +10 -0
- package/dist/opencode-usage-worker.js.map +1 -0
- package/dist/outbound-handlers.js +22 -2
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +28 -2
- package/dist/outbound-schemas.js +29 -2
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +16 -2
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/quickstart-api.js +13 -2
- package/dist/quickstart-api.js.map +1 -1
- package/dist/quickstart.js +2 -1
- package/dist/quickstart.js.map +1 -1
- package/dist/remote-card-answer.d.ts +83 -0
- package/dist/remote-card-answer.js +208 -0
- package/dist/remote-card-answer.js.map +1 -0
- package/dist/reply-triggers.d.ts +2 -0
- package/dist/reply-triggers.js +3 -0
- package/dist/reply-triggers.js.map +1 -0
- package/dist/settings-api.d.ts +28 -0
- package/dist/settings-api.js +148 -17
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +54 -17
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +12 -3
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-instance-patch.js +4 -1
- package/dist/settings-instance-patch.js.map +1 -1
- package/dist/setup-form.d.ts +1 -1
- package/dist/setup-form.js +55 -6
- package/dist/setup-form.js.map +1 -1
- package/dist/setup-host.d.ts +9 -0
- package/dist/setup-host.js +127 -6
- package/dist/setup-host.js.map +1 -1
- package/dist/setup-signin-assets.d.ts +2 -0
- package/dist/setup-signin-assets.js +6 -0
- package/dist/setup-signin-assets.js.map +1 -0
- package/dist/setup-wizard.js +2 -0
- package/dist/setup-wizard.js.map +1 -1
- package/dist/start-attempt.d.ts +9 -5
- package/dist/start-attempt.js +27 -8
- package/dist/start-attempt.js.map +1 -1
- package/dist/start-failure-chat.d.ts +4 -0
- package/dist/start-failure-chat.js +23 -6
- package/dist/start-failure-chat.js.map +1 -1
- package/dist/start-failure-notice.d.ts +4 -0
- package/dist/start-failure-notice.js +8 -0
- package/dist/start-failure-notice.js.map +1 -1
- package/dist/start-failure.d.ts +25 -0
- package/dist/start-failure.js +75 -2
- package/dist/start-failure.js.map +1 -1
- package/dist/structured-signin-transport.d.ts +8 -0
- package/dist/structured-signin-transport.js +79 -0
- package/dist/structured-signin-transport.js.map +1 -0
- package/dist/structured-signin.d.ts +131 -0
- package/dist/structured-signin.js +388 -0
- package/dist/structured-signin.js.map +1 -0
- package/dist/tool-permissions.js +12 -0
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.js +6 -2
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-sources.d.ts +26 -2
- package/dist/transcript-sources.js +103 -40
- package/dist/transcript-sources.js.map +1 -1
- package/dist/turn-reply-guard.d.ts +25 -0
- package/dist/turn-reply-guard.js +111 -12
- package/dist/turn-reply-guard.js.map +1 -1
- package/dist/types.d.ts +46 -9
- package/dist/ui/backend-signin.js +68 -12
- package/dist/ui/chat-ambient.js +1 -1
- package/dist/ui/chat-effects.js +4 -4
- package/dist/ui/chat-render.js +5 -2
- package/dist/ui/chat-strings.js +4 -4
- package/dist/ui/chat-thread.js +2 -2
- package/dist/ui/first-run.js +2 -2
- package/dist/ui/fleet-canvas-view.js +114 -0
- package/dist/ui/fleet-canvas.js +15 -77
- package/dist/ui/fleet-office-editor.js +94 -14
- package/dist/ui/fleet-office-layout.js +235 -0
- package/dist/ui/fleet-office-model.js +12 -6
- package/dist/ui/fleet-office-overview.js +167 -0
- package/dist/ui/fleet-office-pack.d.ts +44 -0
- package/dist/ui/fleet-office-pack.js +352 -37
- package/dist/ui/fleet-office-skin.js +220 -36
- package/dist/ui/fleet-office-store.js +112 -7
- package/dist/ui/fleet-office-view.js +155 -0
- package/dist/ui/fleet-office-zones.js +264 -42
- package/dist/ui/fleet-office.js +957 -160
- package/dist/ui/fleet-usage.js +26 -21
- package/dist/ui/office-share-dialog.js +145 -0
- package/dist/ui/office-share-page.js +164 -0
- package/dist/ui/panel-chat.js +9 -2
- package/dist/ui/panel-details.js +2 -2
- package/dist/ui/panel-fleet.js +11 -77
- package/dist/ui/panel-log.js +4 -4
- package/dist/ui/panel-needs.js +4 -4
- package/dist/ui/panel-settings.js +608 -167
- package/dist/ui/search-dialog.js +26 -7
- package/dist/ui/settings-appearance.js +82 -29
- package/dist/ui/settings-apply.js +42 -7
- package/dist/ui/settings-dialogs.js +76 -23
- package/dist/ui/settings-draft.js +51 -0
- package/dist/ui/settings-guests.js +255 -79
- package/dist/ui/settings-learn.js +2 -2
- package/dist/ui/settings-model.js +16 -6
- package/dist/ui/settings-registry.js +146 -0
- package/dist/ui/settings-strings.js +116 -44
- package/dist/ui/settings-web-authority.js +34 -23
- package/dist/ui/shared/app-i18n.js +18 -2
- package/dist/ui/shared/app-nav.js +10 -5
- package/dist/ui/shared/app-prefs.js +80 -2
- package/dist/ui/shared/app-route.js +39 -5
- package/dist/ui/shared/app-shell.js +73 -10
- package/dist/ui/shared/app-stream.js +3 -1
- package/dist/ui/shared/app.css +307 -17
- package/dist/ui/shared/app.js +3 -0
- package/dist/ui/shared/effect-stock-trust.js +1 -1
- package/dist/ui/shared/identity-color.js +4 -4
- package/dist/ui/shared/office/cozy.png +0 -0
- package/dist/ui/shared/side-panel.js +95 -0
- package/dist/ui/shared/signin.js +2 -2
- package/dist/ui/shared/ui-dialog.js +6 -1
- package/dist/ui/shared/ui-icons.js +2 -0
- package/dist/ui/shared/web-authorize.js +13 -12
- package/dist/ui/structured-signin.js +115 -0
- package/dist/usage-report.d.ts +20 -0
- package/dist/usage-report.js +54 -17
- package/dist/usage-report.js.map +1 -1
- package/dist/usage-rollup.d.ts +9 -1
- package/dist/usage-rollup.js +20 -4
- package/dist/usage-rollup.js.map +1 -1
- package/dist/web-api.d.ts +26 -2
- package/dist/web-api.js +361 -5
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.js +8 -2
- package/dist/web-auth.js.map +1 -1
- package/dist/web-backend-login.d.ts +1 -7
- package/dist/web-backend-login.js +2 -24
- package/dist/web-backend-login.js.map +1 -1
- package/dist/web-experimental.d.ts +1 -1
- package/dist/web-experimental.js +1 -1
- package/dist/web-experimental.js.map +1 -1
- package/dist/web-mod-agent.js +2 -2
- package/dist/web-mod-agent.js.map +1 -1
- package/dist/web-office-acts.d.ts +173 -0
- package/dist/web-office-acts.js +285 -0
- package/dist/web-office-acts.js.map +1 -0
- package/dist/web-office-skins.d.ts +109 -0
- package/dist/web-office-skins.js +299 -0
- package/dist/web-office-skins.js.map +1 -0
- package/dist/web-office-store.d.ts +8 -2
- package/dist/web-office-store.js +77 -14
- package/dist/web-office-store.js.map +1 -1
- package/dist/web-settings-patch.d.ts +8 -1
- package/dist/web-settings-patch.js +65 -5
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-shell-routes.d.ts +4 -2
- package/dist/web-shell-routes.js +17 -7
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/working-recovery.d.ts +104 -0
- package/dist/working-recovery.js +298 -0
- package/dist/working-recovery.js.map +1 -0
- package/dist/working-state-journal.d.ts +5 -1
- package/dist/working-state-journal.js +31 -3
- package/dist/working-state-journal.js.map +1 -1
- package/package.json +3 -2
- package/templates/windows-shortcuts.ps1 +155 -140
package/dist/fleet-manager.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { hasLegacyOffice, normalizeOfficeStorage } from "./dashboard-permissions.js";
|
|
2
|
+
import { loginNoticeMode, normalizeNoticeDelivery, setNoticeMode } from "./notice-delivery.js";
|
|
3
|
+
import { DEFAULT_REPLY_TRIGGERS } from "./reply-triggers.js";
|
|
4
|
+
import { WorkingRecoveryCoordinator, WORKING_RECOVERY_KIND, buildWorkingRecoveryPrompt } from "./working-recovery.js";
|
|
1
5
|
import { PUBLIC_TUNNEL_TOKEN_ENV, PUBLIC_TUNNEL_TOKEN_HINT, normalizePublicTunnelToken } from "./public-tunnel-config.js";
|
|
2
6
|
import { humanOwnerObjectId, webHumanOrigin } from "./human-delivery-receipts.js";
|
|
3
7
|
import { SettingsBaselines } from "./settings-baseline.js";
|
|
@@ -18,6 +22,9 @@ import { PublicLinkAutoOpen } from "./public-link-auto-open.js";
|
|
|
18
22
|
import { clearPublicLinkOwner, readPublicLinkOwner, writePublicLinkOwner } from "./public-link-owner.js";
|
|
19
23
|
import { PUBLIC_PREVIEW_FRAME_SRC, PUBLIC_PREVIEW_TUNNEL_DIR, PublicPreview, publicPreviewWanted } from "./public-preview.js";
|
|
20
24
|
import { CloudflaredProvider } from "./tunnel/cloudflared.js";
|
|
25
|
+
import { ensureCloudflared } from "./tunnel/cloudflared-install.js";
|
|
26
|
+
import { OFFICE_SHARE_TUNNEL_DIR, OfficeShareService } from "./office-share-service.js";
|
|
27
|
+
import { ALL_OFFICES } from "./office-share.js";
|
|
21
28
|
import { TunnelPurposeLane } from "./tunnel/purpose-lane.js";
|
|
22
29
|
import { ManagedTunnel } from "./tunnel/manager.js";
|
|
23
30
|
import { withinBudget } from "./monotonic-budget.js";
|
|
@@ -27,13 +34,16 @@ import { ProfileControlServer } from "./profile-control.js";
|
|
|
27
34
|
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, lstatSync, constants as fsConstants, closeSync } from "node:fs";
|
|
28
35
|
import { atomicWriteFileSync } from "./atomic-write.js";
|
|
29
36
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
30
|
-
import { freemem, totalmem,
|
|
37
|
+
import { freemem, totalmem, homedir } from "node:os";
|
|
38
|
+
import { effectiveCpuCount } from "./cpu-quota.js";
|
|
31
39
|
import { createServer } from "node:http";
|
|
32
40
|
import { Worker } from "node:worker_threads";
|
|
33
41
|
import { ProbeWorkerPool } from "./probe-worker-pool.js";
|
|
34
42
|
import { join, dirname, basename, delimiter, isAbsolute, relative, resolve as resolvePath } from "node:path";
|
|
35
43
|
import { fileURLToPath } from "node:url";
|
|
44
|
+
import { isDeepStrictEqual } from "node:util";
|
|
36
45
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
46
|
+
import { applyKiroMigration, kiroMigrationPrompt, planKiroMigration } from "./kiro-migrate.js";
|
|
37
47
|
import { fleetLabel } from "./fleet-label.js";
|
|
38
48
|
import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, sameUpdateMarker, setUpdateProgressStage, updateMarkerIdentity, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
|
|
39
49
|
import { formatUpdateProgress } from "./update-progress.js";
|
|
@@ -52,7 +62,7 @@ const __dirname = dirname(__filename);
|
|
|
52
62
|
const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
|
|
53
63
|
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";
|
|
54
64
|
import { isProbeableRouteTarget } from "./fleet-context.js";
|
|
55
|
-
import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
|
|
65
|
+
import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG, autoGeneralInstanceConfig } from "./config.js";
|
|
56
66
|
import { EventLog } from "./event-log.js";
|
|
57
67
|
import { binaryProbe } from "./binary-probe.js";
|
|
58
68
|
import { AVAILABILITY_STALE_MS, BackendAvailabilityStore, hostAvailabilityProbes } from "./backend-availability.js";
|
|
@@ -158,6 +168,8 @@ import { agendVersion } from "./agent-whats-new.js";
|
|
|
158
168
|
import { OwnerCssStore } from "./web-owner-css-store.js";
|
|
159
169
|
import { ThemeStore } from "./web-theme-store.js";
|
|
160
170
|
import { OfficeStore, OfficeWriteLimiter } from "./web-office-store.js";
|
|
171
|
+
import { OfficeSkinStore } from "./web-office-skins.js";
|
|
172
|
+
import { OfficeActs } from "./web-office-acts.js";
|
|
161
173
|
import { WebAppearance } from "./web-appearance.js";
|
|
162
174
|
import { WebEffectLibrary } from "./web-effect-library.js";
|
|
163
175
|
import { WebEffectTrials } from "./web-effect-trials.js";
|
|
@@ -187,7 +199,7 @@ import { discordGeneralChannelId } from "./discord-general.js";
|
|
|
187
199
|
import { parseEnvText } from "./env-file.js";
|
|
188
200
|
import { isDisallowedIntentsError, isRejectedTokenError } from "./discord-permissions.js";
|
|
189
201
|
import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
|
|
190
|
-
import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
|
|
202
|
+
import { LOGIN_FLOWS, checkAuthStatus, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
|
|
191
203
|
import { LoginSession } from "./login-manager.js";
|
|
192
204
|
import { runOnLoginShell } from "./login-shell.js";
|
|
193
205
|
import { tightenInstanceDirs } from "./private-dir.js";
|
|
@@ -207,12 +219,16 @@ import { admitProfileLogin, LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOG
|
|
|
207
219
|
import { WebBackendLogins, dashboardSignInSupported } from "./web-backend-login.js";
|
|
208
220
|
import { runBeforeDeadline } from "./deadline.js";
|
|
209
221
|
import { LoginWindowLock } from "./login-window-lock.js";
|
|
222
|
+
import { BrowserBackendSignIns } from "./browser-backend-signins.js";
|
|
223
|
+
import { createStructuredSignInTransport } from "./structured-signin-transport.js";
|
|
224
|
+
import { ONBOARDING_BACKENDS, readPendingBackendSignIns } from "./backend-signin-pending.js";
|
|
225
|
+
import { structuredSignInSupported } from "./structured-signin.js";
|
|
210
226
|
import { handleSettingsRequest } from "./settings-api.js";
|
|
211
227
|
import { setLocale, detectLocale, getLocale, t } from "./locale.js";
|
|
212
228
|
import { recentChatContext } from "./log-tail.js";
|
|
213
229
|
import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet-control-audit.js";
|
|
214
230
|
import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
|
|
215
|
-
import { ClassicChannelManager, classicWebEchoOn, getClassicBackendChoices, inferClassicChannelType, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
|
|
231
|
+
import { ClassicChannelManager, classicWebEchoOn, getClassicBackendChoices, inferClassicChannelType, isSelectableClassicBackend, readClassicLastActivityAt, readClassicRoomNames } from "./classic-channel-manager.js";
|
|
216
232
|
import { assertExplicitInstanceRemoval } from "./instance-removal.js";
|
|
217
233
|
import { KNOWN_BACKENDS, validateFleetConfig } from "./config-validator.js";
|
|
218
234
|
import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
|
|
@@ -270,6 +286,7 @@ import { PROVIDER_SECRET_SPECS, PUBLIC_TUNNEL_SECRET_SPEC, providerSecretSpec, p
|
|
|
270
286
|
/** A self-restart is a whole service restart; 120s is the apply budget, not this. */
|
|
271
287
|
const SELF_RESTART_DEADLINE_MS = 300_000;
|
|
272
288
|
import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
|
|
289
|
+
import { kiroHome } from "./backend/kiro-kas-store.js";
|
|
273
290
|
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileLogin, credentialProfileLoginCommand, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
274
291
|
import { carryClaudeConversation } from "./backend/claude-carry.js";
|
|
275
292
|
import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
|
|
@@ -467,6 +484,10 @@ const OOM_RETRY_CALLBACK_PREFIX = "oom-retry:";
|
|
|
467
484
|
const NEEDS_PAUSE_TTL_MS = 10_000;
|
|
468
485
|
const HANG_CALLBACK_PREFIX = "hang:";
|
|
469
486
|
const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
|
|
487
|
+
/** #2598 B2: a kiro classic → TUI move's Confirm in General. "kiro-migrate:" (13) + 32 hex + ":confirm" (8) = 53 bytes. */
|
|
488
|
+
const KIRO_MIGRATE_CALLBACK_PREFIX = "kiro-migrate:";
|
|
489
|
+
/** Long enough for an admin to read the prompt; the move is judged again on the click. */
|
|
490
|
+
const KIRO_MIGRATE_TIMEOUT_MS = 10 * 60_000;
|
|
470
491
|
/**
|
|
471
492
|
* The prompts the web dashboard also offers: the ones about an instance's own health, which a dashboard
|
|
472
493
|
* user — holding the full-fleet web credential — may answer exactly as a fleet admin on the platform may.
|
|
@@ -562,6 +583,7 @@ export const CLI_ENV_PROBE_DEADLINE_MS = 16_000;
|
|
|
562
583
|
* started ten CLIs together, saturated the CPU, and healthy starts then missed
|
|
563
584
|
* their startup budget — which used to cost the user their conversation. Cores
|
|
564
585
|
* bound how many can actually make progress; memory bounds how many fit.
|
|
586
|
+
* `cores` is what this process may use (cpuset and cgroup quota, #749), not the host's count.
|
|
565
587
|
*/
|
|
566
588
|
export function deriveSpawnConcurrency(freeMemMB, cores) {
|
|
567
589
|
const byMemory = Math.floor(freeMemMB / 300);
|
|
@@ -605,6 +627,99 @@ export class FleetManager {
|
|
|
605
627
|
managerBootId = randomUUID();
|
|
606
628
|
deliveryOutbox = null;
|
|
607
629
|
deliveryOutboxRecovered = false;
|
|
630
|
+
workingRecovery = null;
|
|
631
|
+
workingRecoveryTimer = null;
|
|
632
|
+
workingRecoveryCause = "unknown_interruption";
|
|
633
|
+
workingRecoveryGroup = this.managerBootId;
|
|
634
|
+
recoveryEnabled(name) {
|
|
635
|
+
const own = this.fleetConfig?.instances[name]?.resume_interrupted_work;
|
|
636
|
+
return own ?? this.fleetConfig?.defaults?.resume_interrupted_work ?? true;
|
|
637
|
+
}
|
|
638
|
+
initializeWorkingRecovery(outbox) {
|
|
639
|
+
const store = outbox.workingRecovery;
|
|
640
|
+
this.workingRecoveryCause = store.beginRun(this.managerBootId, process.pid, Date.now());
|
|
641
|
+
this.workingRecovery = new WorkingRecoveryCoordinator({
|
|
642
|
+
store, now: () => performance.now(), active: () => this.startupComplete && !this.shuttingDown && outbox.isOpen,
|
|
643
|
+
pressure: () => {
|
|
644
|
+
const p = this.memoryPressure.snapshot();
|
|
645
|
+
return p.sampledAt === null || Date.now() - p.sampledAt > 60_000 ? { ...p, level: "unknown" } : p;
|
|
646
|
+
}, storm: () => this.stormWindow.isActive(),
|
|
647
|
+
enabled: name => this.recoveryEnabled(name),
|
|
648
|
+
live: name => {
|
|
649
|
+
const daemon = this.daemons.get(name);
|
|
650
|
+
if (this.sessionOomStopped(name))
|
|
651
|
+
return { owner: "", configuration: "", sessionId: null, idle: false,
|
|
652
|
+
paused: false, stopped: true, legacyPending: false, legacyCovered: false, nativeWork: false, cancelled: false, background: null };
|
|
653
|
+
return daemon?.getWorkingRecoveryState?.() ?? null;
|
|
654
|
+
},
|
|
655
|
+
card: id => this.scheduler?.db.board.findCard(id) ?? (this.scheduler ? undefined : null),
|
|
656
|
+
laneFree: name => !this.isInstanceRestarting(name) && !this.activeDurableTargets.has(name)
|
|
657
|
+
&& outbox.countForTarget(name, ["queued", "retry_wait", "delivering", "submission_started", "reconciliation_pending"]) === 0,
|
|
658
|
+
admit: (episode, tasks, advisory) => outbox.admitWorkingRecovery(episode, buildWorkingRecoveryPrompt(tasks, this.workingRecoveryCause, advisory), advisory),
|
|
659
|
+
summary: (group, rows) => {
|
|
660
|
+
const general = this.findGeneralInstance();
|
|
661
|
+
if (!general || !this.daemons.has(general))
|
|
662
|
+
return;
|
|
663
|
+
// Our own informational notice must not supersede General's recovery
|
|
664
|
+
// as unrelated pending work before it crosses the input boundary.
|
|
665
|
+
const generalEpisode = store.get(general);
|
|
666
|
+
if (generalEpisode?.outcome === "admitted" && generalEpisode.deliveryId) {
|
|
667
|
+
const state = outbox.get(generalEpisode.deliveryId)?.state;
|
|
668
|
+
if (!state || !["delivered", "uncertain", "failed", "cancelled"].includes(state))
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
const deferredAdvisories = rows.filter(row => row.outcome === "deferred"
|
|
672
|
+
&& (this.daemons.get(row.instance)?.getWorkingRecoveryState?.().background ?? null) === null)
|
|
673
|
+
.map(row => `${row.instance} (${row.reason})`);
|
|
674
|
+
const posted = store.summary(group, () => outbox.admitSystemNotice(general, `working-recovery-summary:${group}`, `[system:working-recovery-summary] Observed cause: unknown interruption${this.workingRecoveryCause === "unknown_interruption" ? "" : `; prior requested action: ${this.workingRecoveryCause}`}. ${rows.length} interrupted instances; continuation queued ${rows.filter(r => r.outcome === "admitted").length}, excluded ${rows.filter(r => r.outcome === "excluded").length}, still deferred ${rows.filter(r => r.outcome === "deferred").length}. ${rows.filter(r => r.reason === "advisory_background_unknown").length ? `Advisory, background unknown: ${rows.filter(r => r.reason === "advisory_background_unknown").map(r => r.instance).join(", ")}. ` : ""}${deferredAdvisories.length ? `Deferred advisory candidates (background unknown; all other gates still apply): ${deferredAdvisories.join(", ")}. ` : ""}Details are in the Log. Deferred work remains eligible; no action or confirmation is requested.`));
|
|
675
|
+
if (posted) {
|
|
676
|
+
const counts = { interrupted: rows.length, queued: rows.filter(row => row.outcome === "admitted").length,
|
|
677
|
+
excluded: rows.filter(row => row.outcome === "excluded").length, deferred: rows.filter(row => row.outcome === "deferred").length };
|
|
678
|
+
const requestedAction = this.workingRecoveryCause === "unknown_interruption" ? null : this.workingRecoveryCause;
|
|
679
|
+
this.logger.info({ group, ...counts, observedCause: "unknown_interruption", requestedAction }, "Interrupted work recovery summary queued for General");
|
|
680
|
+
this.eventLog?.insert(general, "working_state", { reason: "recovery_summary", group, ...counts,
|
|
681
|
+
observedCause: "unknown_interruption", requestedAction,
|
|
682
|
+
message: `Interrupted work summary queued for General: ${counts.interrupted} interrupted; ${counts.queued} queued, ${counts.excluded} excluded, ${counts.deferred} deferred.` });
|
|
683
|
+
}
|
|
684
|
+
},
|
|
685
|
+
log: (episode, outcome, reason) => {
|
|
686
|
+
const requestedAction = this.workingRecoveryCause === "unknown_interruption" ? null : this.workingRecoveryCause;
|
|
687
|
+
this.logger.info({ name: episode.instance, episode: episode.key, outcome, reason,
|
|
688
|
+
observedCause: "unknown_interruption", requestedAction }, "Interrupted work recovery decision");
|
|
689
|
+
this.eventLog?.insert(episode.instance, "working_state", {
|
|
690
|
+
message: `Interrupted work: ${outcome} (${reason}); observed cause unknown_interruption${requestedAction ? `; prior requested action ${requestedAction}` : ""}.`,
|
|
691
|
+
reason, outcome, observedCause: "unknown_interruption", requestedAction
|
|
692
|
+
});
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
if (this.workingRecoveryTimer)
|
|
696
|
+
clearInterval(this.workingRecoveryTimer);
|
|
697
|
+
this.workingRecoveryTimer = setInterval(() => {
|
|
698
|
+
if (!outbox.isOpen || this.shuttingDown)
|
|
699
|
+
return;
|
|
700
|
+
try {
|
|
701
|
+
this.workingRecovery?.tick();
|
|
702
|
+
this.scheduleDeliveryOutboxPump();
|
|
703
|
+
}
|
|
704
|
+
catch {
|
|
705
|
+
this.logger.warn("Working recovery deferred: prerequisites or durable store unavailable");
|
|
706
|
+
}
|
|
707
|
+
}, 1_000);
|
|
708
|
+
this.workingRecoveryTimer.unref?.();
|
|
709
|
+
}
|
|
710
|
+
recoveryDeliveryEligible(row, writing = false) {
|
|
711
|
+
if (row.kind !== WORKING_RECOVERY_KIND)
|
|
712
|
+
return true;
|
|
713
|
+
const outbox = this.deliveryOutbox, episode = outbox?.workingRecovery.get(row.targetInstance);
|
|
714
|
+
if (!episode || episode.deliveryId !== row.deliveryId
|
|
715
|
+
|| outbox.hasOtherPendingWork(row.targetInstance, row.deliveryId)
|
|
716
|
+
|| this.workingRecovery?.verdict(episode).outcome === "excluded") {
|
|
717
|
+
outbox?.withdrawWorkingRecovery(row.deliveryId, "recovery superseded or no longer authorized before input");
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
return (writing ? this.workingRecovery?.accepts(row.targetInstance, episode.key)
|
|
721
|
+
: this.workingRecovery?.canDispatch(row.targetInstance, episode.key)) === true;
|
|
722
|
+
}
|
|
608
723
|
/** Phase 2b: wakes paused targets for queued durable work (delivery_worker ≠ off). */
|
|
609
724
|
wakeCoordinator = null;
|
|
610
725
|
/**
|
|
@@ -796,6 +911,8 @@ export class FleetManager {
|
|
|
796
911
|
pendingBackendSelects = new Map();
|
|
797
912
|
/** nonce → pending button prompt (hang restart, interactive assist, clean-exit restart). */
|
|
798
913
|
pendingNonceButtons = new Map();
|
|
914
|
+
/** #2598 B2: the kiro moves waiting on a fleet admin's Confirm in General, by prompt nonce. */
|
|
915
|
+
pendingKiroMigrations = new Map();
|
|
799
916
|
/** #1386 "Needs you": the list, its live chat messages and Acknowledge. Started with the fleet (finishStartup). */
|
|
800
917
|
needsYou = null;
|
|
801
918
|
/** When the fleet first saw an instance crashed, for its "Needs you" age; cleared when it is not. */
|
|
@@ -1054,9 +1171,106 @@ export class FleetManager {
|
|
|
1054
1171
|
}
|
|
1055
1172
|
officeStoreInstance;
|
|
1056
1173
|
/** Fleet → Org chart → Office: the offices people made (web-offices.json, beside web-themes.json). */
|
|
1057
|
-
get officeStore() { return (this.officeStoreInstance ??= new OfficeStore(this.dataDir)); }
|
|
1174
|
+
get officeStore() { return (this.officeStoreInstance ??= new OfficeStore(this.dataDir, id => this.officeSkins.pack(id))); }
|
|
1175
|
+
officeSkinsInstance;
|
|
1176
|
+
/** #2513: the Office view's uploaded skin packs (<AGEND_HOME>/web-office-skins/). */
|
|
1177
|
+
get officeSkins() { return (this.officeSkinsInstance ??= new OfficeSkinStore(this.dataDir)); }
|
|
1178
|
+
/** How often a person may upload or delete a skin pack; memory only, as the offices' limiter. */
|
|
1179
|
+
officeSkinWrites = new OfficeWriteLimiter(undefined, { perWindow: 10, windowMs: 60_000 });
|
|
1058
1180
|
/** How often a person may save the offices (PUT /ui/offices); memory only, as the appearance limiter. */
|
|
1059
1181
|
officeWrites = new OfficeWriteLimiter();
|
|
1182
|
+
officeSharesInstance;
|
|
1183
|
+
/**
|
|
1184
|
+
* #2636: Office share links — the live Office view for people outside the fleet, read-only, on a quick tunnel of
|
|
1185
|
+
* their own (a manager and lease of its own: never the fleet's one tunnel lane nor the public link's preview tunnel).
|
|
1186
|
+
*/
|
|
1187
|
+
get officeShares() {
|
|
1188
|
+
return (this.officeSharesInstance ??= new OfficeShareService({
|
|
1189
|
+
source: {
|
|
1190
|
+
status: () => this.getUiStatus(),
|
|
1191
|
+
org: () => this.orgChart(),
|
|
1192
|
+
offices: () => this.officeStore.list(),
|
|
1193
|
+
acts: () => this.officeActs.live(),
|
|
1194
|
+
traffic: () => this.trafficSnapshot(),
|
|
1195
|
+
needs: () => this.needsYouItems().map(i => i.instance),
|
|
1196
|
+
hidden: name => crossInstanceVisibility(this.fleetConfig, name) === "hidden",
|
|
1197
|
+
skins: this.officeSkins,
|
|
1198
|
+
},
|
|
1199
|
+
manager: new ManagedTunnel({ dataDir: join(this.dataDir, OFFICE_SHARE_TUNNEL_DIR), log: () => this.logger.warn("Office share tunnel cleanup requires attention") }),
|
|
1200
|
+
ensure: signal => ensureCloudflared({ dataDir: this.dataDir, pinnedOnly: true, signal }),
|
|
1201
|
+
provider: binary => new CloudflaredProvider({ binaryName: binary, protocol: this.fleetConfig?.web?.public_link?.protocol ?? "http2" }),
|
|
1202
|
+
config: () => this.fleetConfig?.web?.office_share,
|
|
1203
|
+
officeExists: id => this.officeStore.list().offices.some(o => o.id === id),
|
|
1204
|
+
ownerCurrent: owner => this.officeShareOwnerCurrent(owner),
|
|
1205
|
+
changed: () => this.emitSseEvent("office_shares", {}),
|
|
1206
|
+
log: (event, detail) => this.logger.info({ event, ...(detail ? { detail } : {}) }, "Office share link"),
|
|
1207
|
+
}));
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* #2636: a share made or ended — said in General, as a sign-in is (who, which office, for how long; never its URL).
|
|
1211
|
+
* `share` null with `count`: "Revoke all".
|
|
1212
|
+
*/
|
|
1213
|
+
officeShareNotice(event, who, share, count) {
|
|
1214
|
+
const office = !share ? "" : share.office === ALL_OFFICES ? t("office_share.all") : this.officeStore.list().offices.find(o => o.id === share.office)?.name ?? share.office;
|
|
1215
|
+
const minutes = share ? Math.round((share.expiresAt - share.createdAt) / 60_000) : 0;
|
|
1216
|
+
const text = event === "created" ? t("office_share.created_notice", who, office, minutes < 60 ? `${minutes} min` : `${Math.round(minutes / 60)} h`)
|
|
1217
|
+
: share ? t("office_share.revoked_notice", who, office) : t("office_share.revoked_all_notice", who, count);
|
|
1218
|
+
this.notifyFleetError(text, { throttle: false });
|
|
1219
|
+
}
|
|
1220
|
+
/** A share's owner: a person (`person:<adapter>:<user>`) still a fleet admin there; the host's own admin session stays. */
|
|
1221
|
+
officeShareOwnerCurrent(owner) {
|
|
1222
|
+
const m = /^person:([^:]+):(.+)$/.exec(owner);
|
|
1223
|
+
if (!m)
|
|
1224
|
+
return owner === "admin-session";
|
|
1225
|
+
return this.hasFleetAdmins(m[1]) && this.isFleetAdmin(m[2], m[1]);
|
|
1226
|
+
}
|
|
1227
|
+
officeActsInstance;
|
|
1228
|
+
/** #2578: the agents' acts in the Office view (office_view / office_set), memory only; every change is `office_acts`. */
|
|
1229
|
+
get officeActs() {
|
|
1230
|
+
return (this.officeActsInstance ??= new OfficeActs({
|
|
1231
|
+
offices: () => this.officeStore.list().offices,
|
|
1232
|
+
pack: id => this.officeSkins.pack(id),
|
|
1233
|
+
chart: () => { const c = this.orgChart(); return { names: Object.keys(c.instances), teams: c.teams }; },
|
|
1234
|
+
people: () => this.officePeople(),
|
|
1235
|
+
changed: acts => this.emitSseEvent("office_acts", { acts, now: Date.now() }),
|
|
1236
|
+
}));
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* #2578: each instance's state as the org chart shows it (fleet-org.js orgState, from the same fields the status
|
|
1240
|
+
* frame and the Needs-you list carry) and its display name (resolveInstanceIdentity, as getUiStatus) — read once.
|
|
1241
|
+
*/
|
|
1242
|
+
officePeople() {
|
|
1243
|
+
const needs = new Map();
|
|
1244
|
+
for (const item of this.needsYouItems())
|
|
1245
|
+
needs.set(item.instance, (needs.get(item.instance) ?? 0) + 1);
|
|
1246
|
+
const classicRooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
|
|
1247
|
+
let profiles = null;
|
|
1248
|
+
return {
|
|
1249
|
+
stateOf: name => {
|
|
1250
|
+
if (!this.isKnownInstance(name))
|
|
1251
|
+
return "missing";
|
|
1252
|
+
const p = this.instancePresentation(name), status = this.getInstanceStatus(name);
|
|
1253
|
+
if (p.state === "awaiting_input" || (needs.get(name) ?? 0) > 0)
|
|
1254
|
+
return "needs";
|
|
1255
|
+
if (p.startup_phase === "queued" || p.startup_phase === "starting")
|
|
1256
|
+
return p.startup_phase;
|
|
1257
|
+
if (status === "crashed" || status === "paused")
|
|
1258
|
+
return status;
|
|
1259
|
+
if (status !== "running")
|
|
1260
|
+
return "stopped";
|
|
1261
|
+
return p.execution_state === "stuck" || p.execution_state === "working" ? p.execution_state : "idle";
|
|
1262
|
+
},
|
|
1263
|
+
displayName: name => {
|
|
1264
|
+
try {
|
|
1265
|
+
profiles ??= profileIdentities(this.dataDir);
|
|
1266
|
+
return resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic: classicRooms.get(name), profile: profiles.get(name) }).display_name || undefined;
|
|
1267
|
+
}
|
|
1268
|
+
catch {
|
|
1269
|
+
return undefined;
|
|
1270
|
+
}
|
|
1271
|
+
},
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1060
1274
|
/** #1629: the theme tools, a person's reset and lock, and the one appearance rate limiter (shared with effects). */
|
|
1061
1275
|
get webAppearance() {
|
|
1062
1276
|
return (this.webAppearanceService ??= new WebAppearance(this.themeStore, () => this.fleetConfig?.web ?? null, () => { try {
|
|
@@ -1582,6 +1796,74 @@ export class FleetManager {
|
|
|
1582
1796
|
// No platform confirmation was reachable. Host inspection is still required.
|
|
1583
1797
|
this.logger.info({ id: view.id }, "Settings change requires local `agend settings confirm <id>`");
|
|
1584
1798
|
}
|
|
1799
|
+
kiroMigrateDeps() {
|
|
1800
|
+
return {
|
|
1801
|
+
agendHome: getAgendHome(),
|
|
1802
|
+
fleetConfig: () => this.fleetConfig,
|
|
1803
|
+
saveFleetConfig: () => this.saveFleetConfig(),
|
|
1804
|
+
status: name => this.getInstanceStatus(name),
|
|
1805
|
+
restart: name => this.restartSingleInstance(name, { explicit: true }),
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
/**
|
|
1809
|
+
* #2598 B2, migrate_kiro_instance: judge the move, then ask in General for a fleet admin's Confirm. Nothing changes
|
|
1810
|
+
* here; the move runs on the Confirm (handleKiroMigrateCallback), judged again then.
|
|
1811
|
+
*/
|
|
1812
|
+
async requestKiroMigration(name, startFresh) {
|
|
1813
|
+
const judged = planKiroMigration(this.kiroMigrateDeps(), name, startFresh);
|
|
1814
|
+
if (!judged.ok)
|
|
1815
|
+
return judged;
|
|
1816
|
+
const general = this.findGeneralInstance();
|
|
1817
|
+
const adapterId = general ? this.getInstanceAdapterId(general) : undefined;
|
|
1818
|
+
const adapter = general ? this.getAdapterForInstance(general) : undefined;
|
|
1819
|
+
const chatId = general ? this.getGroupIdForInstance(general) : undefined;
|
|
1820
|
+
const topic = general ? String(this.fleetConfig?.instances[general]?.topic_id ?? "") : "";
|
|
1821
|
+
if (!general || !adapterId || !adapter || !chatId)
|
|
1822
|
+
return { ok: false, error: t("kiro_migrate.no_general") };
|
|
1823
|
+
const { plan } = judged;
|
|
1824
|
+
try {
|
|
1825
|
+
const nonce = await this.postNonceButtonPromptOrThrow({
|
|
1826
|
+
prefix: KIRO_MIGRATE_CALLBACK_PREFIX, alertType: "clear_confirm", instanceName: name, adapter, adapterId, chatId,
|
|
1827
|
+
...(topic ? { threadId: topic } : {}),
|
|
1828
|
+
message: kiroMigrationPrompt(plan, this.getInstanceStatus(name) === "running"),
|
|
1829
|
+
choices: [{ action: "confirm", label: t("kiro_migrate.confirm_button") }, { action: "cancel", label: t("kiro_migrate.cancel_button") }],
|
|
1830
|
+
expiredText: t("kiro_migrate.expired", name),
|
|
1831
|
+
timeoutMs: KIRO_MIGRATE_TIMEOUT_MS,
|
|
1832
|
+
});
|
|
1833
|
+
this.pendingKiroMigrations.set(nonce, plan);
|
|
1834
|
+
const expiry = setTimeout(() => this.pendingKiroMigrations.delete(nonce), KIRO_MIGRATE_TIMEOUT_MS + 60_000);
|
|
1835
|
+
expiry.unref?.();
|
|
1836
|
+
}
|
|
1837
|
+
catch (err) {
|
|
1838
|
+
return { ok: false, error: `${t("kiro_migrate.no_general")} (${err.message})` };
|
|
1839
|
+
}
|
|
1840
|
+
return { ok: true, text: t("kiro_migrate.requested"), classic_conversation: plan.classicId };
|
|
1841
|
+
}
|
|
1842
|
+
/** A fleet admin's Confirm or Cancel on a kiro move. Any fleet admin (the default nonce authorization), in General. */
|
|
1843
|
+
async handleKiroMigrateCallback(data, adapterId, adapter) {
|
|
1844
|
+
const claimed = this.consumeNonceCallback(KIRO_MIGRATE_CALLBACK_PREFIX, /^kiro-migrate:([0-9a-f]{32}):(confirm|cancel)$/, data, adapterId, adapter);
|
|
1845
|
+
if (claimed === null)
|
|
1846
|
+
return false;
|
|
1847
|
+
if (claimed === "consumed")
|
|
1848
|
+
return true;
|
|
1849
|
+
const { entry, action } = claimed;
|
|
1850
|
+
const nonce = data.callbackData.slice(KIRO_MIGRATE_CALLBACK_PREFIX.length, KIRO_MIGRATE_CALLBACK_PREFIX.length + 32);
|
|
1851
|
+
const plan = this.pendingKiroMigrations.get(nonce);
|
|
1852
|
+
this.pendingKiroMigrations.delete(nonce);
|
|
1853
|
+
const messageId = entry.messageId ?? data.messageId;
|
|
1854
|
+
if (action === "cancel" || !plan) {
|
|
1855
|
+
await this.retireNonceButtons(entry, messageId, t(plan ? "kiro_migrate.cancelled" : "kiro_migrate.expired", entry.instanceName));
|
|
1856
|
+
return true;
|
|
1857
|
+
}
|
|
1858
|
+
this.eventLog?.insert(plan.name, "kiro_migrate", { classicId: plan.classicId, userId: data.userId });
|
|
1859
|
+
await this.retireNonceButtons(entry, messageId, t("kiro_migrate.applying", plan.name));
|
|
1860
|
+
const outcome = await applyKiroMigration(this.kiroMigrateDeps(), plan);
|
|
1861
|
+
this.logger.info({ name: plan.name, ok: outcome.ok, classicId: plan.classicId }, "kiro classic → TUI move");
|
|
1862
|
+
this.notifyFleetError(outcome.text);
|
|
1863
|
+
if (outcome.ok)
|
|
1864
|
+
this.notifyInstanceTopic(plan.name, outcome.text);
|
|
1865
|
+
return true;
|
|
1866
|
+
}
|
|
1585
1867
|
async handleSettingsChangeCallback(data, adapterId, adapter) {
|
|
1586
1868
|
const result = this.consumeNonceCallback("settings-confirm:", /^settings-confirm:([0-9a-f]{32}):(confirm|reject)$/, data, adapterId, adapter, undefined, (userId, entry) => entry.confirmationAdmin?.(userId) === true);
|
|
1587
1869
|
if (!result)
|
|
@@ -1846,6 +2128,7 @@ export class FleetManager {
|
|
|
1846
2128
|
}
|
|
1847
2129
|
this.deliveryOutbox = outbox;
|
|
1848
2130
|
this.deliveryOutboxRecovered = true;
|
|
2131
|
+
this.initializeWorkingRecovery(outbox);
|
|
1849
2132
|
outbox.on("working_references", (names) => {
|
|
1850
2133
|
for (const name of names)
|
|
1851
2134
|
this.observeWorkingState(name, "obligations");
|
|
@@ -2124,6 +2407,10 @@ export class FleetManager {
|
|
|
2124
2407
|
lastActivityAt: this.lastActivity.get(name) ?? null, references, totalReferences, unavailable });
|
|
2125
2408
|
if (!entry)
|
|
2126
2409
|
return;
|
|
2410
|
+
if (reason === "cancelled")
|
|
2411
|
+
outbox.workingRecovery.cancel(name, observation.configuration, observation.sessionId);
|
|
2412
|
+
if (entry.prior)
|
|
2413
|
+
outbox.workingRecovery.discover(name, entry.prior, this.workingRecoveryGroup);
|
|
2127
2414
|
const state = entry.current.observation.executionState ?? "unknown";
|
|
2128
2415
|
const counts = { tasks: entry.current.references.filter(r => r.kind === "task").length,
|
|
2129
2416
|
owed: entry.current.references.filter(r => r.kind === "owed").length,
|
|
@@ -2217,7 +2504,7 @@ export class FleetManager {
|
|
|
2217
2504
|
// Phase 2c: a lane a queue worker owns is never claimed here.
|
|
2218
2505
|
target => this.queueWorkers.has(target) || this.isInstanceRestarting(target) || this.wakeCoordinator?.blocksClaim(target)
|
|
2219
2506
|
? null
|
|
2220
|
-
: this.daemons.get(target)?.bootId ?? null, this.activeDurableTargets);
|
|
2507
|
+
: this.daemons.get(target)?.bootId ?? null, this.activeDurableTargets, row => this.recoveryDeliveryEligible(row));
|
|
2221
2508
|
if (!claimed)
|
|
2222
2509
|
break;
|
|
2223
2510
|
this.activeDurableTargets.add(claimed.targetInstance);
|
|
@@ -2279,7 +2566,7 @@ export class FleetManager {
|
|
|
2279
2566
|
available: () => !this.shuttingDown && this.deliveryOutbox?.isOpen === true,
|
|
2280
2567
|
blocked: () => this.isInstanceRestarting(target) || this.wakeCoordinator?.blocksClaim(target) === true,
|
|
2281
2568
|
daemonBootId: () => this.daemons.get(target)?.bootId ?? null,
|
|
2282
|
-
claim: bootId => this.deliveryOutbox?.claimNext(this.managerBootId, candidate => candidate === target ? bootId : null, new Set()),
|
|
2569
|
+
claim: bootId => this.deliveryOutbox?.claimNext(this.managerBootId, candidate => candidate === target ? bootId : null, new Set(), row => this.recoveryDeliveryEligible(row)),
|
|
2283
2570
|
tryAcquireBudget: () => {
|
|
2284
2571
|
if (this.activeDurableTargets.size >= 8)
|
|
2285
2572
|
return false;
|
|
@@ -2320,7 +2607,24 @@ export class FleetManager {
|
|
|
2320
2607
|
// including time spent waiting for a handoff/idle gate to return.
|
|
2321
2608
|
try {
|
|
2322
2609
|
let sent;
|
|
2323
|
-
if (claimed.kind ===
|
|
2610
|
+
if (claimed.kind === WORKING_RECOVERY_KIND) {
|
|
2611
|
+
const daemon = this.daemons.get(target), key = claimed.sourceKey.replace(/^working-recovery:/, "");
|
|
2612
|
+
if (!daemon || typeof payload.content !== "string")
|
|
2613
|
+
throw new Error("Working recovery owner unavailable");
|
|
2614
|
+
const release = this.workingRecovery?.reserveDispatch(target, key);
|
|
2615
|
+
if (!release) {
|
|
2616
|
+
outbox.retryBeforeBegin(claimed.deliveryId, daemon.bootId, claimed.attemptNo, "working recovery pace or prerequisites pending", 5_000);
|
|
2617
|
+
return;
|
|
2618
|
+
}
|
|
2619
|
+
try {
|
|
2620
|
+
await daemon.deliverWorkingRecovery(payload.content, claimed, () => this.daemons.get(target) === daemon && this.recoveryDeliveryEligible(claimed, true));
|
|
2621
|
+
}
|
|
2622
|
+
finally {
|
|
2623
|
+
release();
|
|
2624
|
+
}
|
|
2625
|
+
sent = true;
|
|
2626
|
+
}
|
|
2627
|
+
else if (claimed.kind === "raw_paste") {
|
|
2324
2628
|
if (payload.type !== "raw_paste" || typeof payload.content !== "string") {
|
|
2325
2629
|
throw new Error("Durable raw_paste row has an invalid payload");
|
|
2326
2630
|
}
|
|
@@ -2413,7 +2717,7 @@ export class FleetManager {
|
|
|
2413
2717
|
const explicit = this.fleetConfig?.defaults?.startup?.concurrency;
|
|
2414
2718
|
if (explicit != null)
|
|
2415
2719
|
return Math.max(1, Math.min(20, explicit));
|
|
2416
|
-
return deriveSpawnConcurrency(Math.round(freemem() / (1024 * 1024)),
|
|
2720
|
+
return deriveSpawnConcurrency(Math.round(freemem() / (1024 * 1024)), effectiveCpuCount());
|
|
2417
2721
|
}
|
|
2418
2722
|
/** #1685: "elevated" or "critical" while host memory pressure slows or holds new agents; otherwise null (an
|
|
2419
2723
|
* unknown macOS reading — the advisory case — is unknown, so null too). */
|
|
@@ -2630,8 +2934,9 @@ export class FleetManager {
|
|
|
2630
2934
|
// the next tunnel and says so, it does not block the fleet.
|
|
2631
2935
|
this.announceToolPermissionsChange();
|
|
2632
2936
|
this.tightenInstanceDirectories();
|
|
2633
|
-
// #1723: the public link's preview tunnel keeps its own lease beside it; a leftover one is reaped the same way
|
|
2634
|
-
|
|
2937
|
+
// #1723: the public link's preview tunnel keeps its own lease beside it; a leftover one is reaped the same way (and
|
|
2938
|
+
// #2636's Office share tunnel's).
|
|
2939
|
+
for (const leaseDir of [this.dataDir, join(this.dataDir, PUBLIC_PREVIEW_TUNNEL_DIR), join(this.dataDir, OFFICE_SHARE_TUNNEL_DIR)]) {
|
|
2635
2940
|
void reapStaleTunnel(leaseDir)
|
|
2636
2941
|
.then(outcome => {
|
|
2637
2942
|
if (outcome.kind === "manual")
|
|
@@ -3004,6 +3309,7 @@ export class FleetManager {
|
|
|
3004
3309
|
}
|
|
3005
3310
|
/** SIGUSR1: full process reload. Names who asked (#1120). */
|
|
3006
3311
|
onFullRestartSignal() {
|
|
3312
|
+
this.recordWorkingRestartIntent("manual_restart");
|
|
3007
3313
|
this.logger.info(`Received SIGUSR1, initiating full restart (process reload)... ${describeSignalSource(this.dataDir, "SIGUSR1")}`);
|
|
3008
3314
|
this.gracefulShutdownForReload()
|
|
3009
3315
|
.then(() => {
|
|
@@ -3195,7 +3501,7 @@ export class FleetManager {
|
|
|
3195
3501
|
await data.respond(t("permission.denied"));
|
|
3196
3502
|
return;
|
|
3197
3503
|
}
|
|
3198
|
-
this.fleetConfig
|
|
3504
|
+
setNoticeMode(this.fleetConfig, "tips", mode === "on");
|
|
3199
3505
|
this.saveFleetConfig();
|
|
3200
3506
|
await data.respond(t(mode === "on" ? "tips.enabled" : "tips.disabled"));
|
|
3201
3507
|
return;
|
|
@@ -4866,7 +5172,7 @@ export class FleetManager {
|
|
|
4866
5172
|
return new WakeCoordinator({
|
|
4867
5173
|
mode: target => this.deliveryWorkerMode(target),
|
|
4868
5174
|
available: () => !this.shuttingDown && this.deliveryOutbox?.isOpen === true,
|
|
4869
|
-
listPending: () => this.deliveryOutbox?.listPending() ?? [],
|
|
5175
|
+
listPending: () => this.deliveryOutbox?.listPending().filter(row => row.kind !== WORKING_RECOVERY_KIND) ?? [],
|
|
4870
5176
|
isPaused: target => !this.sessionOomStopped(target) && this.lifecycle.isPaused(target),
|
|
4871
5177
|
pauseReason: target => readPauseReason(this.getInstanceDir(target)),
|
|
4872
5178
|
isRestarting: target => this.isInstanceRestarting(target),
|
|
@@ -4985,6 +5291,7 @@ export class FleetManager {
|
|
|
4985
5291
|
}
|
|
4986
5292
|
classicBehaviorUpdate(instanceName) {
|
|
4987
5293
|
return {
|
|
5294
|
+
reply_triggers: this.classicChannels?.getReplyTriggersByInstance(instanceName, this.fleetConfig?.defaults?.reply_triggers) ?? DEFAULT_REPLY_TRIGGERS,
|
|
4988
5295
|
tool_progress: this.classicChannels?.getToolProgressByInstance(instanceName, this.fleetConfig?.defaults?.tool_progress) ?? "off",
|
|
4989
5296
|
reply_completion_guard: this.classicChannels?.getReplyCompletionGuardByInstance(instanceName, this.fleetConfig?.defaults?.reply_completion_guard) ?? true,
|
|
4990
5297
|
};
|
|
@@ -5283,36 +5590,40 @@ export class FleetManager {
|
|
|
5283
5590
|
* config file races. Stagger applies between outer lifecycle admissions.
|
|
5284
5591
|
* TODO: per-instance startup timeout (existing issue, not introduced here)
|
|
5285
5592
|
*/
|
|
5286
|
-
async startInstancesWithConcurrency(entries, topicMode, onReady) {
|
|
5593
|
+
async startInstancesWithConcurrency(entries, topicMode, onReady, classic = []) {
|
|
5287
5594
|
// Persisted pauses are intentionally preserved across fleet restarts. Filter
|
|
5288
5595
|
// them before grouping/staggering: startInstance() retains its own guard as
|
|
5289
5596
|
// a final backstop, but putting a no-op entry in this queue still consumes a
|
|
5290
5597
|
// full stagger slot for every distinct working directory.
|
|
5291
5598
|
const runnableEntries = entries.filter(([name]) => !this.lifecycle.isPaused(name));
|
|
5599
|
+
// ClassicBot rooms queue with the fleet instances, at the same pressure pacing (#2642 follow-up): they used to start
|
|
5600
|
+
// only after the whole fleet, the chat connections, topics and icons. A name the fleet also configures is the fleet's.
|
|
5601
|
+
const fleetNames = new Set(entries.map(([name]) => name));
|
|
5602
|
+
const runnableClassic = classic.filter(ch => !this.lifecycle.isPaused(ch.instanceName) && !fleetNames.has(ch.instanceName));
|
|
5292
5603
|
const pausedCount = entries.length - runnableEntries.length;
|
|
5293
5604
|
if (pausedCount > 0) {
|
|
5294
5605
|
this.logger.info({ pausedCount }, "Paused instances excluded from startup queue");
|
|
5295
5606
|
}
|
|
5296
|
-
if (runnableEntries.length === 0)
|
|
5607
|
+
if (runnableEntries.length === 0 && runnableClassic.length === 0)
|
|
5297
5608
|
return;
|
|
5298
5609
|
if (this.fleetConfig?.defaults?.startup?.concurrency == null) {
|
|
5299
5610
|
this.logger.info({
|
|
5300
5611
|
concurrency: this.spawnConcurrency(),
|
|
5301
5612
|
freeMemMB: Math.round(freemem() / (1024 * 1024)),
|
|
5302
|
-
totalInstances: runnableEntries.length,
|
|
5613
|
+
totalInstances: runnableEntries.length + runnableClassic.length,
|
|
5303
5614
|
}, "Adaptive startup concurrency");
|
|
5304
5615
|
}
|
|
5305
5616
|
const startedAt = performance.now();
|
|
5306
5617
|
let active = 0, peakConcurrentStarts = 0, readyInstances = 0;
|
|
5307
|
-
|
|
5618
|
+
const admit = (name, workingDirectory, start) => this.spawnGate.run({
|
|
5308
5619
|
instanceName: name,
|
|
5309
|
-
workingDirectory
|
|
5620
|
+
workingDirectory,
|
|
5310
5621
|
reason: "startup",
|
|
5311
5622
|
stage: "lifecycle",
|
|
5312
5623
|
}, async () => {
|
|
5313
5624
|
peakConcurrentStarts = Math.max(peakConcurrentStarts, ++active);
|
|
5314
5625
|
try {
|
|
5315
|
-
if (await
|
|
5626
|
+
if (await start()) {
|
|
5316
5627
|
readyInstances++;
|
|
5317
5628
|
onReady?.(name);
|
|
5318
5629
|
}
|
|
@@ -5320,9 +5631,13 @@ export class FleetManager {
|
|
|
5320
5631
|
finally {
|
|
5321
5632
|
active--;
|
|
5322
5633
|
}
|
|
5323
|
-
})
|
|
5634
|
+
});
|
|
5635
|
+
await Promise.all([
|
|
5636
|
+
...runnableEntries.map(([name, config]) => admit(name, config.working_directory, () => this.startInstanceUnattended(name, config, topicMode, "instance"))),
|
|
5637
|
+
...runnableClassic.map(ch => admit(ch.instanceName, join(getAgendHome(), "workspaces", ch.instanceName), () => this.startClassicInstanceUnattended(ch, "classic instance"))),
|
|
5638
|
+
]);
|
|
5324
5639
|
this.logger.info({
|
|
5325
|
-
totalInstances: runnableEntries.length, readyInstances,
|
|
5640
|
+
totalInstances: runnableEntries.length + runnableClassic.length, classicInstances: runnableClassic.length, readyInstances,
|
|
5326
5641
|
elapsedSeconds: Math.round((performance.now() - startedAt) / 100) / 10,
|
|
5327
5642
|
peakConcurrentStarts,
|
|
5328
5643
|
}, "Fleet startup batch completed");
|
|
@@ -6722,7 +7037,8 @@ export class FleetManager {
|
|
|
6722
7037
|
const [adapterId, userId] = k.split("\u0000");
|
|
6723
7038
|
accounts.set(k, { adapterId, userId, sessions: 0, lastSeen: null });
|
|
6724
7039
|
}
|
|
6725
|
-
|
|
7040
|
+
// #2348: every granted account is listed too, signed in or not.
|
|
7041
|
+
for (const key of [...Object.keys(cfg?.narrow ?? {}), ...Object.keys(cfg?.grants ?? {})]) {
|
|
6726
7042
|
const at = key.indexOf(":"), adapterId = key.slice(0, at), userId = key.slice(at + 1), k = `${adapterId}\u0000${userId}`;
|
|
6727
7043
|
if (at > 0 && !accounts.has(k))
|
|
6728
7044
|
accounts.set(k, { adapterId, userId, sessions: 0, lastSeen: null });
|
|
@@ -6731,10 +7047,166 @@ export class FleetManager {
|
|
|
6731
7047
|
const owner = { adapterId: a.adapterId, userId: a.userId };
|
|
6732
7048
|
const badge = this.guestBadge(owner);
|
|
6733
7049
|
// `reach`: before narrowing — what Settings offers to narrow to (#2104 review: never an agent it cannot reach).
|
|
7050
|
+
const grant = own(cfg?.grants, `${a.adapterId}:${a.userId}`);
|
|
7051
|
+
const sent = this.guestCodeSent.get(`${a.adapterId}:${a.userId}`);
|
|
6734
7052
|
return { ...a, name: badge.name, platform: badge.platform, scope: [...this.guestScope(owner)].sort(), reach: [...this.guestReach(owner)].sort(),
|
|
6735
|
-
devices: devices.get(k) ?? 0, narrow: own(cfg?.narrow, `${a.adapterId}:${a.userId}`) ?? null
|
|
7053
|
+
devices: devices.get(k) ?? 0, narrow: own(cfg?.narrow, `${a.adapterId}:${a.userId}`) ?? null,
|
|
7054
|
+
// #2348: the grant as fleet.yaml has it (null: none), and the last code an admin sent from Settings.
|
|
7055
|
+
grant: grant ? { ...(grant.name ? { name: grant.name } : {}), instances: [...grant.instances] } : null,
|
|
7056
|
+
...(sent ? { code: { at: sent.at, outcome: sent.outcome.kind, ...(sent.outcome.kind === "sent" ? { expiresAt: sent.at + sent.outcome.expiresInMs } : {}),
|
|
7057
|
+
...(sent.outcome.kind === "dm_failed" ? { reason: sent.outcome.reason, platform: sent.outcome.platform, ...(sent.outcome.startUrl ? { startUrl: sent.outcome.startUrl } : {}) } : {}) } } : {}) };
|
|
6736
7058
|
}).sort((x, y) => (y.lastSeen ?? -1) - (x.lastSeen ?? -1));
|
|
6737
|
-
|
|
7059
|
+
// #2348: `explicit` — grants are the scope (default deny); false: the derived scope of #1920, until grants are saved.
|
|
7060
|
+
// #2667: the ClassicBot rooms there are now, apart from fleet.yaml's instances (a grant may name either: guestReach),
|
|
7061
|
+
// with its display name as the sidebar resolves it (a profile alias, else the room's own), when it has one.
|
|
7062
|
+
const profiles = profileIdentities(this.dataDir);
|
|
7063
|
+
const rooms = (this.classicChannels?.getAll() ?? []).filter((r) => !Object.hasOwn(this.fleetConfig?.instances ?? {}, r.instanceName))
|
|
7064
|
+
.map((r) => {
|
|
7065
|
+
const shown = resolveInstanceIdentity({ classic: r, profile: profiles.get(r.instanceName) }).display_name;
|
|
7066
|
+
return { name: r.instanceName, ...(shown && shown !== r.instanceName ? { display_name: shown } : {}) };
|
|
7067
|
+
})
|
|
7068
|
+
.sort((x, y) => (x.name < y.name ? -1 : x.name > y.name ? 1 : 0));
|
|
7069
|
+
return { enabled: cfg?.enabled === true, chat: cfg?.chat !== false, explicit: cfg?.grants !== undefined, instances: Object.keys(this.fleetConfig?.instances ?? {}).sort(), rooms, connections, guests,
|
|
7070
|
+
audit: this.guestAudit() };
|
|
7071
|
+
}
|
|
7072
|
+
/** #2667: the ClassicBot rooms' instance names now — what a settings save's validation knows besides fleet.yaml's instances. */
|
|
7073
|
+
classicRoomNames() {
|
|
7074
|
+
return (this.classicChannels?.getAll() ?? []).map((r) => r.instanceName);
|
|
7075
|
+
}
|
|
7076
|
+
/**
|
|
7077
|
+
* #2499 review: the guest audit an admin reads — the newest grant changes and admin-sent codes from the event log
|
|
7078
|
+
* (`fleet` rows: no agent's Log shows them). Through guestOverview only: Settings' admin gate, never over the public
|
|
7079
|
+
* link, never a guest's. The person's name as the card shows it; a session handle stays on the server (`by` → whether
|
|
7080
|
+
* a person saved it here or it was confirmed in General).
|
|
7081
|
+
*/
|
|
7082
|
+
guestAudit(limit = FleetManager.GUEST_AUDIT_SHOWN) {
|
|
7083
|
+
if (!this.eventLog)
|
|
7084
|
+
return [];
|
|
7085
|
+
// One read, newest first by row id: a total order (created_at is to the second), so the cap never drops a newer row.
|
|
7086
|
+
let rows = [];
|
|
7087
|
+
try {
|
|
7088
|
+
rows = this.eventLog.latestOfTypes("fleet", ["guest_grant_changed", "guest_code_sent"], limit);
|
|
7089
|
+
}
|
|
7090
|
+
catch {
|
|
7091
|
+
return [];
|
|
7092
|
+
}
|
|
7093
|
+
const names = (list) => (Array.isArray(list) ? list.filter((n) => typeof n === "string") : []);
|
|
7094
|
+
return rows.flatMap((r) => {
|
|
7095
|
+
const p = r.payload ?? {}, account = typeof p.account === "string" ? p.account : "";
|
|
7096
|
+
const at = account.indexOf(":");
|
|
7097
|
+
if (at <= 0)
|
|
7098
|
+
return [];
|
|
7099
|
+
const name = this.webPerson({ adapterId: account.slice(0, at), userId: account.slice(at + 1) }).name;
|
|
7100
|
+
const when = Date.parse(r.created_at.includes("T") ? r.created_at : `${r.created_at.replace(" ", "T")}Z`);
|
|
7101
|
+
return r.event_type === "guest_grant_changed"
|
|
7102
|
+
? [{ kind: "grant", at: when, account, name, via: p.via === "confirmed" ? "confirmed" : "direct", added: names(p.added), removed: names(p.removed),
|
|
7103
|
+
endedSessions: typeof p.endedSessions === "number" ? p.endedSessions : 0 }]
|
|
7104
|
+
: [{ kind: "code", at: when, account, name, outcome: typeof p.outcome === "string" ? p.outcome : "unknown" }];
|
|
7105
|
+
});
|
|
7106
|
+
}
|
|
7107
|
+
static GUEST_AUDIT_SHOWN = 20;
|
|
7108
|
+
/**
|
|
7109
|
+
* #2348: Settings → Guests' picker — the people AgEnD has seen in chat (seen_people, from human receipts: never a bot,
|
|
7110
|
+
* never the web), without fleet admins (they sign in as admins) or accounts of a connection the fleet no longer has.
|
|
7111
|
+
* Admin only (settings-api.ts), never over the public link.
|
|
7112
|
+
*/
|
|
7113
|
+
guestPeople() {
|
|
7114
|
+
const seen = this.deliveryOutbox?.isOpen ? this.deliveryOutbox.seenPeople() : [];
|
|
7115
|
+
const people = seen.filter((p) => this.worlds.has(p.adapterId) && !this.isFleetAdmin(p.userId, p.adapterId)).map((p) => {
|
|
7116
|
+
const owner = { adapterId: p.adapterId, userId: p.userId };
|
|
7117
|
+
return { adapterId: p.adapterId, userId: p.userId, platform: this.worlds.get(p.adapterId)?.adapter.type ?? p.adapterId, username: p.username,
|
|
7118
|
+
name: this.webPerson(owner).name, lastSeen: p.lastSeen, instances: p.instances.map((i) => i.name) };
|
|
7119
|
+
});
|
|
7120
|
+
return { people };
|
|
7121
|
+
}
|
|
7122
|
+
/**
|
|
7123
|
+
* #2348: why this account may not be given a grant, or null. Only someone AgEnD has seen in chat, on a connection the
|
|
7124
|
+
* fleet runs, who is not a fleet admin of it (an admin is never a guest).
|
|
7125
|
+
*/
|
|
7126
|
+
guestGrantRefusal(adapterId, userId) {
|
|
7127
|
+
if (!this.worlds.has(adapterId) && !this.fleetConfig?.channels?.some((c) => (c.id ?? c.type) === adapterId))
|
|
7128
|
+
return "no such connection in this fleet";
|
|
7129
|
+
if (this.isFleetAdmin(userId, adapterId))
|
|
7130
|
+
return "a fleet admin signs in as an admin, not as a guest";
|
|
7131
|
+
if (!this.deliveryOutbox?.isOpen || !this.deliveryOutbox.isSeenPerson(adapterId, userId))
|
|
7132
|
+
return "only someone AgEnD has seen in chat can be given guest access";
|
|
7133
|
+
return null;
|
|
7134
|
+
}
|
|
7135
|
+
/**
|
|
7136
|
+
* #2348 delta §5: a grant changed with a save — one event-log entry per person, shown in Settings → Guests' Recent
|
|
7137
|
+
* changes (guestOverview: admin-only, never over the public link or to a guest): who saved it (an admin's
|
|
7138
|
+
* session handle, the header token, or null when replayed for a confirmation), whether it applied at once or was
|
|
7139
|
+
* confirmed in General, the agents added and taken away, and the sessions a removal ended. Never message content.
|
|
7140
|
+
*/
|
|
7141
|
+
noteGuestGrantChange(change) {
|
|
7142
|
+
this.logger.info({ account: change.account, via: change.via, added: change.added.length, removed: change.removed.length }, "Guest access changed in Settings");
|
|
7143
|
+
try {
|
|
7144
|
+
this.eventLog?.insert("fleet", "guest_grant_changed", { ...change });
|
|
7145
|
+
}
|
|
7146
|
+
catch { /* the log line stands */ }
|
|
7147
|
+
}
|
|
7148
|
+
/** #2348: an admin's Send sign-in code, per admin (the caller): at most this many in ADMIN_CODE_WINDOW_MS. */
|
|
7149
|
+
static ADMIN_CODE_MAX = 10;
|
|
7150
|
+
static ADMIN_CODE_WINDOW_MS = 10 * 60_000;
|
|
7151
|
+
adminCodeSends = new Map();
|
|
7152
|
+
/** #2348: the last admin-sent code per account, for the Settings card ("Code sent 18:42 · expires 18:52", or why not). */
|
|
7153
|
+
guestCodeSent = new Map();
|
|
7154
|
+
/**
|
|
7155
|
+
* #2348: Settings → Guests' "Send sign-in code" — a guest code to an account an admin granted access, by DM only (no
|
|
7156
|
+
* chat to answer in, nothing posted anywhere). The same issuance as the person's own /web (one code per account, the
|
|
7157
|
+
* code store's caps and breaker, the public link's audience when it is open) and the same once-a-minute limit per
|
|
7158
|
+
* account, plus a per-admin cap. Refused without guest sign-in, for a fleet admin, or with nothing granted. Audited.
|
|
7159
|
+
*/
|
|
7160
|
+
async sendGuestCodeByAdmin(owner, by,
|
|
7161
|
+
/** #2365 review: the initiating admin is still signed in and still an admin (settings-api re-checks the session). */
|
|
7162
|
+
current = () => true) {
|
|
7163
|
+
const guests = this.fleetConfig?.web?.guests;
|
|
7164
|
+
if (guests?.enabled !== true)
|
|
7165
|
+
return { status: 409, body: { ok: false, outcome: "guests_off", error: "Guest sign-in is off" } };
|
|
7166
|
+
if (this.isFleetAdmin(owner.userId, owner.adapterId))
|
|
7167
|
+
return { status: 409, body: { ok: false, outcome: "admin", error: "A fleet admin signs in as an admin" } };
|
|
7168
|
+
if (!this.guestScope(owner).size)
|
|
7169
|
+
return { status: 409, body: { ok: false, outcome: "nothing", error: "Nothing is granted to this person yet: save their access first" } };
|
|
7170
|
+
const token = this.webToken;
|
|
7171
|
+
const adapter = this.adapters.get(owner.adapterId);
|
|
7172
|
+
if (this.shuttingDown || !token || !adapter)
|
|
7173
|
+
return { status: 503, body: { ok: false, outcome: "starting", error: "AgEnD is starting or this connection is not running" } };
|
|
7174
|
+
const key = `${owner.adapterId}\u0000${owner.userId}`, asked = this.guestCodeAskedAt.get(key);
|
|
7175
|
+
const now = performance.now();
|
|
7176
|
+
if (this.guestCodeInFlight.has(key) || (asked !== undefined && now - asked < 60_000)) {
|
|
7177
|
+
return { status: 429, body: { ok: false, outcome: "wait", retryAfterMs: asked !== undefined ? Math.max(0, 60_000 - (now - asked)) : 60_000 } };
|
|
7178
|
+
}
|
|
7179
|
+
const mine = (this.adminCodeSends.get(by) ?? []).filter((at) => now - at < FleetManager.ADMIN_CODE_WINDOW_MS);
|
|
7180
|
+
if (mine.length >= FleetManager.ADMIN_CODE_MAX)
|
|
7181
|
+
return { status: 429, body: { ok: false, outcome: "admin_limit", retryAfterMs: FleetManager.ADMIN_CODE_WINDOW_MS - (now - mine[0]) } };
|
|
7182
|
+
this.adminCodeSends.set(by, [...mine, now]);
|
|
7183
|
+
while (this.adminCodeSends.size > 256)
|
|
7184
|
+
this.adminCodeSends.delete(this.adminCodeSends.keys().next().value);
|
|
7185
|
+
let outcome = { kind: "busy" };
|
|
7186
|
+
this.guestCodeInFlight.add(key);
|
|
7187
|
+
try {
|
|
7188
|
+
await this.issueGuestCode({ replyHere: async () => undefined }, owner, key, adapter, token, (o) => { outcome = o; }, current);
|
|
7189
|
+
}
|
|
7190
|
+
finally {
|
|
7191
|
+
this.guestCodeInFlight.delete(key);
|
|
7192
|
+
}
|
|
7193
|
+
const done = outcome;
|
|
7194
|
+
// #2365 review: the admin's session ended (or stopped being an admin's) while the code was on its way: nothing was
|
|
7195
|
+
// sent, or what was made is withdrawn — the answer says so, as to anyone no longer signed in.
|
|
7196
|
+
if (done.kind === "revoked" && !current())
|
|
7197
|
+
return { status: 401, body: { ok: false, outcome: "revoked", error: "no longer authorized" } };
|
|
7198
|
+
this.guestCodeSent.set(`${owner.adapterId}:${owner.userId}`, { at: Date.now(), outcome: done });
|
|
7199
|
+
while (this.guestCodeSent.size > 512)
|
|
7200
|
+
this.guestCodeSent.delete(this.guestCodeSent.keys().next().value);
|
|
7201
|
+
// Audited: who asked, for whom, what happened (never the code).
|
|
7202
|
+
this.logger.info({ by, adapterId: owner.adapterId, outcome: done.kind }, "Guest sign-in code sent by an admin from Settings");
|
|
7203
|
+
try {
|
|
7204
|
+
this.eventLog?.insert("fleet", "guest_code_sent", { by, account: `${owner.adapterId}:${owner.userId}`, outcome: done.kind, instances: this.guestScope(owner).size });
|
|
7205
|
+
}
|
|
7206
|
+
catch { /* the log line stands */ }
|
|
7207
|
+
const status = done.kind === "sent" ? 200 : done.kind === "dm_failed" ? 502 : 409;
|
|
7208
|
+
return { status, body: { ok: done.kind === "sent", outcome: done.kind, ...(done.kind === "sent" ? { expiresAt: Date.now() + done.expiresInMs, publicLink: done.publicLink } : {}),
|
|
7209
|
+
...(done.kind === "dm_failed" ? { platform: done.platform, reason: done.reason, ...(done.startUrl ? { startUrl: done.startUrl } : {}) } : {}) } };
|
|
6738
7210
|
}
|
|
6739
7211
|
/** #1920 phase 3 (the app shell): a guest's chip — its web name, and its connection's platform (discord, telegram). */
|
|
6740
7212
|
guestBadge(owner) {
|
|
@@ -6803,24 +7275,35 @@ export class FleetManager {
|
|
|
6803
7275
|
return true;
|
|
6804
7276
|
}
|
|
6805
7277
|
/** guestWebCommand's issuance, one per account at a time; a revoke made meanwhile (either kind) ends it unissued. */
|
|
6806
|
-
async issueGuestCode(ask, owner, key, adapter, token
|
|
7278
|
+
async issueGuestCode(ask, owner, key, adapter, token,
|
|
7279
|
+
/** #2348: how it ended, for a caller with no chat to answer in (Settings → Guests' Send sign-in code). */
|
|
7280
|
+
report = () => { },
|
|
7281
|
+
/**
|
|
7282
|
+
* #2365 review: the one who asked may still ask — for an admin's Send sign-in code, that admin's session as it is
|
|
7283
|
+
* now. Asked with the revocation checks, after every wait: false ends the issuance, and withdraws its code if one was made.
|
|
7284
|
+
*/
|
|
7285
|
+
stillAllowed = () => true) {
|
|
6807
7286
|
const say = (text) => ask.replyHere(text).catch(() => undefined);
|
|
6808
7287
|
const revokes = this.guestRevokes, accountRevokes = this.guestAccountRevokes.get(key) ?? 0;
|
|
6809
|
-
const unrevoked = () => this.guestRevokes === revokes && (this.guestAccountRevokes.get(key) ?? 0) === accountRevokes;
|
|
7288
|
+
const unrevoked = () => this.guestRevokes === revokes && (this.guestAccountRevokes.get(key) ?? 0) === accountRevokes && stillAllowed();
|
|
6810
7289
|
// §3.2: its rooms are read at sign-in (bounded; never longer than the reads' own deadline).
|
|
6811
7290
|
await withinBudget(this.refreshGuestRooms(owner, { mode: "force" }), performance.now() + 6_000).catch(() => undefined);
|
|
6812
|
-
if (!unrevoked())
|
|
6813
|
-
|
|
7291
|
+
if (!unrevoked()) {
|
|
7292
|
+
report({ kind: "revoked" });
|
|
7293
|
+
return;
|
|
7294
|
+
} // revoked while its rooms were read: no code
|
|
6814
7295
|
const scope = this.guestScope(owner);
|
|
6815
7296
|
if (!scope.size) {
|
|
6816
7297
|
const open = this.worlds.get(owner.adapterId)?.accessManager.getMode() === "open";
|
|
6817
7298
|
await say(t(open ? "web.guest_not_set_up" : "web.guest_nothing"));
|
|
7299
|
+
report({ kind: "nothing" });
|
|
6818
7300
|
return;
|
|
6819
7301
|
}
|
|
6820
7302
|
this.initializeWebSessions();
|
|
6821
7303
|
const codes = this.webGuestLoginCodes;
|
|
6822
7304
|
if (codes.pausedForMs() > 0) {
|
|
6823
7305
|
await say(t("web.guest_paused"));
|
|
7306
|
+
report({ kind: "paused" });
|
|
6824
7307
|
return;
|
|
6825
7308
|
}
|
|
6826
7309
|
// §5.5 (step 4b): with the public link open, the code works through it too (an admin opened it; a guest is still a
|
|
@@ -6831,6 +7314,7 @@ export class FleetManager {
|
|
|
6831
7314
|
recipient: { adapterId: owner.adapterId, userId: owner.userId, ...(ask.username ? { username: ask.username } : {}) } });
|
|
6832
7315
|
if (!login) {
|
|
6833
7316
|
await say(t("web.guest_busy"));
|
|
7317
|
+
report({ kind: "busy" });
|
|
6834
7318
|
return;
|
|
6835
7319
|
}
|
|
6836
7320
|
this.guestCodeAskedAt.delete(key);
|
|
@@ -6865,13 +7349,17 @@ export class FleetManager {
|
|
|
6865
7349
|
throw new Error("guest no longer admitted");
|
|
6866
7350
|
await say(t("web.guest_sent"));
|
|
6867
7351
|
}
|
|
7352
|
+
report({ kind: "sent", expiresInMs: LOGIN_CODE_TTL_MS, publicLink: !!open });
|
|
6868
7353
|
}
|
|
6869
7354
|
catch (err) {
|
|
6870
7355
|
codes.revokeIfCurrent(login.issuanceId); // this flow's code only, never a later one's
|
|
6871
7356
|
this.logger.info({ err: err?.message, adapterId: owner.adapterId }, "Guest sign-in code was not delivered privately");
|
|
6872
|
-
if (!unrevoked())
|
|
6873
|
-
|
|
7357
|
+
if (!unrevoked()) {
|
|
7358
|
+
report({ kind: "revoked" });
|
|
7359
|
+
return;
|
|
7360
|
+
} // revoked meanwhile: the revoke's own answer said so
|
|
6874
7361
|
const start = adapter.type === "telegram" ? this.tgStartWebUrl(owner.adapterId) : null;
|
|
7362
|
+
report({ kind: "dm_failed", platform: adapter.type, reason: String(err?.message ?? err).slice(0, 200), ...(start ? { startUrl: start } : {}) });
|
|
6875
7363
|
// #1742 for guests (step 4b-3): its START button (a bare /start, or the link's /start web) then asks again, from
|
|
6876
7364
|
// the DM — once, within the window.
|
|
6877
7365
|
if (adapter.type === "telegram")
|
|
@@ -7039,7 +7527,8 @@ export class FleetManager {
|
|
|
7039
7527
|
this.notifyFleetError(t("web.guest_login_notice", this.webPerson(info.owner).name, info.label, this.guestScope(info.owner).size, info.handle.slice(0, 8)));
|
|
7040
7528
|
return;
|
|
7041
7529
|
}
|
|
7042
|
-
|
|
7530
|
+
// Never by DM (hmsg-3b8ac2af: Telegram cannot DM a user who has not started the bot); a legacy "dm" reads as on.
|
|
7531
|
+
if (!loginNoticeMode(this.fleetConfig?.web))
|
|
7043
7532
|
return;
|
|
7044
7533
|
// The session handle makes each notice distinct: notifyFleetError throttles by text, and a second
|
|
7045
7534
|
// sign-in from the same kind of browser is exactly the one the operator most needs to hear about.
|
|
@@ -7413,11 +7902,13 @@ export class FleetManager {
|
|
|
7413
7902
|
}
|
|
7414
7903
|
}
|
|
7415
7904
|
async startAllWithinDeadline(configPath, deadline) {
|
|
7416
|
-
this.
|
|
7905
|
+
if (!this.structuredLoginCleanupFailed)
|
|
7906
|
+
this.loginWindow.reopen(); // a stopAll → startAll restart must accept login windows again
|
|
7417
7907
|
this.loginController?.reopen();
|
|
7418
7908
|
const startupStartedAt = Date.now();
|
|
7419
7909
|
FleetManager.signalTarget = this;
|
|
7420
7910
|
this.startupComplete = false;
|
|
7911
|
+
this.workingRecovery?.reset();
|
|
7421
7912
|
this.startupDashboard = null;
|
|
7422
7913
|
// Cleared here, not at the end of doStopAll: a stop has an async tail, and
|
|
7423
7914
|
// anything arriving during it is still part of the stop.
|
|
@@ -7673,12 +8164,7 @@ export class FleetManager {
|
|
|
7673
8164
|
mkdirSync(generalDir, { recursive: true, mode: 0o700 });
|
|
7674
8165
|
const backendName = fleet.defaults.backend ?? "claude-code";
|
|
7675
8166
|
this.ensureGeneralInstructions(generalDir, backendName, name);
|
|
7676
|
-
fleet.instances[name] =
|
|
7677
|
-
...DEFAULT_INSTANCE_CONFIG,
|
|
7678
|
-
working_directory: generalDir,
|
|
7679
|
-
general_topic: true,
|
|
7680
|
-
channel_id: need.adapterId,
|
|
7681
|
-
};
|
|
8167
|
+
fleet.instances[name] = autoGeneralInstanceConfig(fleet.defaults, generalDir, need.adapterId); // #2598: its kiro_ui is the fleet's
|
|
7682
8168
|
generalsCreated = true;
|
|
7683
8169
|
}
|
|
7684
8170
|
if (generalsCreated)
|
|
@@ -7824,9 +8310,11 @@ export class FleetManager {
|
|
|
7824
8310
|
this.rotateAllInstanceLogs();
|
|
7825
8311
|
}, 24 * 60 * 60_000);
|
|
7826
8312
|
this.logRotateTimer.unref?.();
|
|
7827
|
-
// Phase 2: Start remaining instances with staggered concurrency
|
|
7828
|
-
|
|
7829
|
-
|
|
8313
|
+
// Phase 2: Start remaining instances, and the ClassicBot rooms with them, with staggered concurrency. A room needs no
|
|
8314
|
+
// chat connection to spawn: its open-list is set above (reregisterClassicChannels) and inbound queues until it is up.
|
|
8315
|
+
const classicAtBoot = topicMode && (fleet.channel || fleet.channels?.length) ? this.classicChannels?.getAll() ?? [] : [];
|
|
8316
|
+
if (others.length > 0 || classicAtBoot.length > 0) {
|
|
8317
|
+
await this.startInstancesWithConcurrency(others, topicMode, () => startupProgress.markReady(), classicAtBoot);
|
|
7830
8318
|
}
|
|
7831
8319
|
if (topicMode && (fleet.channel || fleet.channels?.length)) {
|
|
7832
8320
|
await adapterStartup;
|
|
@@ -7868,22 +8356,7 @@ export class FleetManager {
|
|
|
7868
8356
|
// IPC is already wired by startInstancesWithConcurrency → startInstance →
|
|
7869
8357
|
// connectIpcToInstance. The previous 3s sleep + connectToInstances loop
|
|
7870
8358
|
// was redundant.
|
|
7871
|
-
//
|
|
7872
|
-
if (this.classicChannels) {
|
|
7873
|
-
const fleetBackend = this.fleetConfig?.defaults?.backend;
|
|
7874
|
-
const channels = this.classicChannels.getAll()
|
|
7875
|
-
.filter(ch => !this.lifecycle.isPaused(ch.instanceName));
|
|
7876
|
-
const concurrency = 3;
|
|
7877
|
-
let idx = 0;
|
|
7878
|
-
while (idx < channels.length) {
|
|
7879
|
-
const batch = channels.slice(idx, idx + concurrency);
|
|
7880
|
-
await Promise.allSettled(batch.map(async (ch) => {
|
|
7881
|
-
if (await this.startClassicInstanceUnattended(ch, "classic instance"))
|
|
7882
|
-
startupProgress.markReady();
|
|
7883
|
-
}));
|
|
7884
|
-
idx += concurrency;
|
|
7885
|
-
}
|
|
7886
|
-
}
|
|
8359
|
+
// The ClassicBot rooms started in Phase 2, with the fleet.
|
|
7887
8360
|
for (const name of Object.keys(fleet.instances)) {
|
|
7888
8361
|
this.startStatuslineWatcher(name);
|
|
7889
8362
|
}
|
|
@@ -7996,12 +8469,17 @@ export class FleetManager {
|
|
|
7996
8469
|
// health server — so `systemctl start` returned success while the fleet was
|
|
7997
8470
|
// still deaf: no path existed for a user message to arrive.
|
|
7998
8471
|
sdNotify("READY=1");
|
|
8472
|
+
this.workingRecovery?.ready();
|
|
7999
8473
|
// #2481: this version runs — the CLI's start-failure banner stops, and this is the version to go back to later.
|
|
8000
8474
|
try {
|
|
8001
8475
|
const { ownPackage, recordStarted } = await import("./start-failure.js");
|
|
8002
8476
|
const version = ownPackage().version;
|
|
8003
8477
|
if (version)
|
|
8004
8478
|
recordStarted(this.dataDir, version, process.pid, Date.now());
|
|
8479
|
+
// #2481: a start completed — the failed tree an automatic restore kept for a report can go.
|
|
8480
|
+
const prefix = ownPackage().prefix;
|
|
8481
|
+
if (prefix)
|
|
8482
|
+
void (await import("./mac-auto-restore.js")).pruneFailedTrees(prefix).catch(() => { });
|
|
8005
8483
|
}
|
|
8006
8484
|
catch (err) {
|
|
8007
8485
|
this.logger.debug({ err }, "fleet-started record not written");
|
|
@@ -8145,25 +8623,43 @@ export class FleetManager {
|
|
|
8145
8623
|
if (channelConfigs.length === 0)
|
|
8146
8624
|
return;
|
|
8147
8625
|
// Start ALL adapters in parallel — any single failure doesn't block others.
|
|
8148
|
-
const
|
|
8626
|
+
const settled = channelConfigs.map(() => false);
|
|
8627
|
+
// Each connection is recorded (connected, or retrying with its retry scheduled) as its own login settles.
|
|
8628
|
+
const recorded = channelConfigs.map((cfg, i) => (i === 0
|
|
8149
8629
|
? this.startSingleAdapter(fleet, cfg)
|
|
8150
|
-
: this.startAdditionalAdapter(cfg)))
|
|
8151
|
-
|
|
8152
|
-
|
|
8153
|
-
|
|
8154
|
-
|
|
8155
|
-
|
|
8156
|
-
|
|
8157
|
-
|
|
8158
|
-
|
|
8159
|
-
|
|
8160
|
-
|
|
8161
|
-
|
|
8162
|
-
|
|
8163
|
-
|
|
8164
|
-
}
|
|
8630
|
+
: this.startAdditionalAdapter(cfg)).then(() => ({ status: "fulfilled" }), (reason) => ({ status: "rejected", reason }))
|
|
8631
|
+
.then(result => { settled[i] = true; this.recordAdapterStartup(cfg, result); })
|
|
8632
|
+
.catch(err => this.logger.error({ err }, "Recording a chat connection's startup failed")));
|
|
8633
|
+
// #2642: bounded. Startup continues past a connection still logging in after this long (ClassicBot instances,
|
|
8634
|
+
// READY); that connection is recorded, or retried, when its login settles, and joins the fleet as it would anyway.
|
|
8635
|
+
let timer;
|
|
8636
|
+
const inTime = await Promise.race([
|
|
8637
|
+
Promise.all(recorded).then(() => true),
|
|
8638
|
+
new Promise(resolve => { timer = setTimeout(() => resolve(false), FleetManager.ADAPTER_STARTUP_WAIT_MS); timer.unref?.(); }),
|
|
8639
|
+
]).finally(() => clearTimeout(timer));
|
|
8640
|
+
if (!inTime) {
|
|
8641
|
+
this.logger.warn({
|
|
8642
|
+
pending: channelConfigs.filter((_, i) => !settled[i]).map(cfg => cfg.id ?? cfg.type),
|
|
8643
|
+
waitedMs: FleetManager.ADAPTER_STARTUP_WAIT_MS,
|
|
8644
|
+
}, "Chat connection still connecting — fleet startup continues without waiting for it");
|
|
8165
8645
|
}
|
|
8166
8646
|
}
|
|
8647
|
+
/** How long fleet startup waits for its chat connections before it continues without the slow ones (#2642). */
|
|
8648
|
+
static ADAPTER_STARTUP_WAIT_MS = 60_000;
|
|
8649
|
+
/** Track a connection's startup outcome; a failure gets the background retry. */
|
|
8650
|
+
recordAdapterStartup(cfg, result) {
|
|
8651
|
+
const adapterId = cfg.id ?? cfg.type;
|
|
8652
|
+
if (result.status === "fulfilled") {
|
|
8653
|
+
this.adapterState.set(adapterId, { status: "connected", retryCount: 0 });
|
|
8654
|
+
return;
|
|
8655
|
+
}
|
|
8656
|
+
const err = result.reason;
|
|
8657
|
+
this.logger.error({ adapterId, err: err?.message ?? err }, "Adapter startup failed — scheduling background retry");
|
|
8658
|
+
this.adapterState.set(adapterId, { status: "retrying", retryCount: 0, lastError: err?.message ?? String(err) });
|
|
8659
|
+
this.scheduleAdapterRetry(adapterId);
|
|
8660
|
+
// Notify admin via whichever adapter is already up
|
|
8661
|
+
this.notifyAdapterFailure(adapterId, err?.message ?? String(err));
|
|
8662
|
+
}
|
|
8167
8663
|
/** Keep trying while the fleet runs, with a five-minute cap even after a long outage. */
|
|
8168
8664
|
scheduleAdapterRetry(adapterId) {
|
|
8169
8665
|
const INITIAL_DELAY_MS = 5_000;
|
|
@@ -10666,6 +11162,7 @@ export class FleetManager {
|
|
|
10666
11162
|
// overwritten with coordinates the target's adapter cannot address.
|
|
10667
11163
|
...(foreignWorld ? {} : { chat_id: reply_chat_id, thread_id: reply_thread_id }),
|
|
10668
11164
|
user: "scheduler",
|
|
11165
|
+
source: "schedule",
|
|
10669
11166
|
...(adapterId ? { adapter_id: adapterId } : {}),
|
|
10670
11167
|
},
|
|
10671
11168
|
}, { waitForIdle: true, ...(retryCurrent ? { stillCurrent: retryCurrent } : {}) });
|
|
@@ -10842,7 +11339,7 @@ export class FleetManager {
|
|
|
10842
11339
|
if (!ipc || !fleetRequestId)
|
|
10843
11340
|
return;
|
|
10844
11341
|
const type = String(msg.type);
|
|
10845
|
-
const responseType = isCanvasWorkerTool(type.replace(/^fleet_/, "")) ? "fleet_web_theme_response"
|
|
11342
|
+
const responseType = isCanvasWorkerTool(type.replace(/^fleet_/, "")) || type === "fleet_office_view" || type === "fleet_office_set" ? "fleet_web_theme_response"
|
|
10846
11343
|
: type.startsWith("fleet_schedule_") ? "fleet_schedule_response"
|
|
10847
11344
|
: type.startsWith("fleet_decision_") ? "fleet_decision_response"
|
|
10848
11345
|
: type === "fleet_task" ? "fleet_task_response"
|
|
@@ -10896,7 +11393,8 @@ export class FleetManager {
|
|
|
10896
11393
|
|| type === "fleet_get_web_appearance" || type === "fleet_preview_web_appearance"
|
|
10897
11394
|
|| type === "fleet_set_web_widget" || type === "fleet_remove_web_widget"
|
|
10898
11395
|
|| type === "fleet_set_web_ambient" || type === "fleet_clear_web_ambient"
|
|
10899
|
-
|| type === "fleet_export_web_mod" || type === "fleet_propose_web_mod"
|
|
11396
|
+
|| type === "fleet_export_web_mod" || type === "fleet_propose_web_mod"
|
|
11397
|
+
|| type === "fleet_office_view" || type === "fleet_office_set") {
|
|
10900
11398
|
this.handleWebTheme(name, msg);
|
|
10901
11399
|
return;
|
|
10902
11400
|
}
|
|
@@ -10917,7 +11415,8 @@ export class FleetManager {
|
|
|
10917
11415
|
const result = tool === "get_web_appearance" || tool === "preview_web_appearance"
|
|
10918
11416
|
? this.appearanceTools.handle(instanceName, tool, args)
|
|
10919
11417
|
: tool === "export_web_mod" || tool === "propose_web_mod" ? this.agentMods.handle(instanceName, tool, args)
|
|
10920
|
-
: this.
|
|
11418
|
+
: tool === "office_view" || tool === "office_set" ? this.officeActs.handle(instanceName, tool, args)
|
|
11419
|
+
: this.webAppearance.handleTool(instanceName, tool, args);
|
|
10921
11420
|
ipc.send({ type: "fleet_web_theme_response", fleetRequestId, result });
|
|
10922
11421
|
}
|
|
10923
11422
|
catch (err) {
|
|
@@ -11353,6 +11852,8 @@ export class FleetManager {
|
|
|
11353
11852
|
return this.appearanceTools.handle(instance, tool, args);
|
|
11354
11853
|
if (tool === "export_web_mod" || tool === "propose_web_mod")
|
|
11355
11854
|
return this.agentMods.handle(instance, tool, args);
|
|
11855
|
+
if (tool === "office_view" || tool === "office_set")
|
|
11856
|
+
return this.officeActs.handle(instance, tool, args); // #2578
|
|
11356
11857
|
return this.webAppearance.handleTool(instance, tool, args);
|
|
11357
11858
|
}
|
|
11358
11859
|
async handlePreviewEmojisHttp(instance, args) {
|
|
@@ -11787,6 +12288,24 @@ export class FleetManager {
|
|
|
11787
12288
|
return;
|
|
11788
12289
|
if (!this.savedFleetConfigSnapshot)
|
|
11789
12290
|
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
12291
|
+
normalizeNoticeDelivery(this.fleetConfig);
|
|
12292
|
+
normalizeOfficeStorage(this.fleetConfig);
|
|
12293
|
+
explicitPatches = explicitPatches.flatMap(patch => {
|
|
12294
|
+
const path = patch.path;
|
|
12295
|
+
if (path.at(-2) === "dashboard" && path.at(-1) === "office" && !patch.remove) {
|
|
12296
|
+
return [{ ...patch, path: [...path.slice(0, -2), "office_access"] }, { path, remove: true }];
|
|
12297
|
+
}
|
|
12298
|
+
if (path.at(-1) === "dashboard" && patch.value && typeof patch.value === "object" && Object.hasOwn(patch.value, "office")) {
|
|
12299
|
+
const { office, ...dashboard } = patch.value;
|
|
12300
|
+
return [{ ...patch, value: dashboard }, { path: [...path.slice(0, -1), "office_access"], value: office, remove: office === null }];
|
|
12301
|
+
}
|
|
12302
|
+
// #2581's DM choice was withdrawn: an old string is just "on", posted in the topic, and its route is dropped.
|
|
12303
|
+
if (patch.path.join(".") === "web.notify_login" && patch.value === "dm")
|
|
12304
|
+
return [{ ...patch, value: true }, { path: ["web", "notice_delivery", "login"], remove: true }];
|
|
12305
|
+
if (patch.path.join(".") === "defaults.tips" && patch.value === "dm")
|
|
12306
|
+
return [{ ...patch, value: true }, { path: ["web", "notice_delivery", "tips"], remove: true }];
|
|
12307
|
+
return [patch];
|
|
12308
|
+
});
|
|
11790
12309
|
// Re-read immediately before patching so an unrelated concurrent/manual
|
|
11791
12310
|
// edit is retained. Invalid concurrent YAML is never overwritten.
|
|
11792
12311
|
const source = existsSync(this.configPath) ? readFileSync(this.configPath, "utf-8") : "{}\n";
|
|
@@ -11862,9 +12381,46 @@ export class FleetManager {
|
|
|
11862
12381
|
},
|
|
11863
12382
|
});
|
|
11864
12383
|
this.rawFleetConfig = loadRawFleetConfig(this.configPath);
|
|
12384
|
+
// The instance map is defaults-expanded. A defaults edit changes that view even when it writes no instance
|
|
12385
|
+
// leaves. Re-resolve from the committed YAML so inherited values cannot become stale overrides on the next save.
|
|
12386
|
+
// Keep the FleetConfig object itself: Settings and other request contexts hold it.
|
|
12387
|
+
const resolvedInstances = loadFleetConfig(this.configPath).instances;
|
|
12388
|
+
for (const [name, resolved] of Object.entries(resolvedInstances)) {
|
|
12389
|
+
const current = this.fleetConfig.instances[name];
|
|
12390
|
+
if (!current) {
|
|
12391
|
+
this.fleetConfig.instances[name] = resolved;
|
|
12392
|
+
continue;
|
|
12393
|
+
}
|
|
12394
|
+
// Lifecycle rollback fences hold these object identities across save. Refresh their values, not their owner.
|
|
12395
|
+
for (const key of Object.keys(current))
|
|
12396
|
+
if (!Object.hasOwn(resolved, key))
|
|
12397
|
+
delete current[key];
|
|
12398
|
+
Object.assign(current, resolved);
|
|
12399
|
+
}
|
|
11865
12400
|
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
12401
|
+
this.refreshSavedDashboardPermissions();
|
|
11866
12402
|
this.logger.info({ path: this.configPath, strippedDefaults: redundantPaths.length }, "Saved fleet config (lossless patch)");
|
|
11867
12403
|
}
|
|
12404
|
+
/** A permission tightening is effective when its save returns, independently of the later apply job or IPC. */
|
|
12405
|
+
refreshSavedDashboardPermissions() {
|
|
12406
|
+
if (!this.fleetConfig)
|
|
12407
|
+
return;
|
|
12408
|
+
const classicNames = new Set(this.classicChannels?.getAll().map(ch => ch.instanceName) ?? []);
|
|
12409
|
+
for (const [name, daemon] of this.daemons) {
|
|
12410
|
+
const next = this.fleetConfig.instances[name] ?? (classicNames.has(name) ? getEffectiveInstanceDefaults(this.fleetConfig.defaults) : undefined);
|
|
12411
|
+
const previous = daemon.getConfigSnapshot?.();
|
|
12412
|
+
if (!next || !previous)
|
|
12413
|
+
continue;
|
|
12414
|
+
if (isDeepStrictEqual(previous.dashboard, next.dashboard) && previous.office_access === next.office_access)
|
|
12415
|
+
continue;
|
|
12416
|
+
// Only permissions are adopted here. Cold fields retain the live daemon's old snapshot, so reconcile still
|
|
12417
|
+
// detects that they require a restart. MCP menus also watch the raw file; this updates the next spawn's env.
|
|
12418
|
+
daemon.applyConfigUpdate({ dashboard: next.dashboard ?? null, office_access: next.office_access ?? null });
|
|
12419
|
+
if (taskBoardSwitchedOn(previous.dashboard, next.dashboard)) {
|
|
12420
|
+
this.admitTaskBoardNotice(name, TASK_BOARD_ON_NOTICE, `task-board-on:${name}:${Date.now()}`);
|
|
12421
|
+
}
|
|
12422
|
+
}
|
|
12423
|
+
}
|
|
11868
12424
|
/** Why the config written to `candidate` must not replace fleet.yaml, or null: it does not load, or it adds errors. */
|
|
11869
12425
|
savedFleetConfigProblem(candidate) {
|
|
11870
12426
|
let after;
|
|
@@ -11893,7 +12449,7 @@ export class FleetManager {
|
|
|
11893
12449
|
logConfigWarningsAtStartup() {
|
|
11894
12450
|
let warnings = [];
|
|
11895
12451
|
try {
|
|
11896
|
-
warnings = validateFleetConfig(this.rawFleetConfig).warnings;
|
|
12452
|
+
warnings = validateFleetConfig(this.rawFleetConfig, { rooms: readClassicRoomNames(this.dataDir) }).warnings;
|
|
11897
12453
|
}
|
|
11898
12454
|
catch (err) {
|
|
11899
12455
|
this.logger.debug({ err }, "fleet.yaml validation failed (startup warnings)");
|
|
@@ -11902,7 +12458,44 @@ export class FleetManager {
|
|
|
11902
12458
|
this.logger.warn({ path: w.path }, `fleet.yaml: ${w.path ? `${w.path}: ` : ""}${w.message}`);
|
|
11903
12459
|
}
|
|
11904
12460
|
/** One-time upgrade migration; invalid YAML is never rewritten. */
|
|
12461
|
+
migrateNoticeDeliveryStorage() {
|
|
12462
|
+
if (this.fleetConfig && this.configPath && (this.fleetConfig.web?.notify_login === "dm" || this.fleetConfig.defaults.tips === "dm"
|
|
12463
|
+
|| this.fleetConfig.web?.notice_delivery !== undefined)
|
|
12464
|
+
&& validateFleetConfig(this.rawFleetConfig).valid) {
|
|
12465
|
+
try {
|
|
12466
|
+
this.writeFleetConfigBackup(readFileSync(this.configPath, "utf8"));
|
|
12467
|
+
this.saveFleetConfig();
|
|
12468
|
+
this.logger.info("Notices no longer go by DM: stored them as topic booleans and dropped the old DM routing");
|
|
12469
|
+
}
|
|
12470
|
+
catch (err) {
|
|
12471
|
+
this.logger.warn({ err }, "Could not migrate DM notice routing; keeping the original file");
|
|
12472
|
+
}
|
|
12473
|
+
}
|
|
12474
|
+
}
|
|
12475
|
+
migrateOfficeStorage() {
|
|
12476
|
+
if (!this.fleetConfig || !this.configPath || !hasLegacyOffice(this.rawFleetConfig) || !validateFleetConfig(this.rawFleetConfig).valid)
|
|
12477
|
+
return;
|
|
12478
|
+
try {
|
|
12479
|
+
this.writeFleetConfigBackup(readFileSync(this.configPath, "utf8"));
|
|
12480
|
+
const patches = [];
|
|
12481
|
+
const raw = this.rawFleetConfig;
|
|
12482
|
+
for (const [path, item] of [[["defaults"], raw.defaults], ...Object.entries(raw.instances ?? {}).map(([name, value]) => [["instances", name], value])]) {
|
|
12483
|
+
if (!item?.dashboard || !Object.hasOwn(item.dashboard, "office"))
|
|
12484
|
+
continue;
|
|
12485
|
+
patches.push({ path: [...path, "dashboard", "office"], remove: true });
|
|
12486
|
+
if (!Object.hasOwn(item, "office_access") && typeof item.dashboard.office === "boolean")
|
|
12487
|
+
patches.push({ path: [...path, "office_access"], value: item.dashboard.office });
|
|
12488
|
+
}
|
|
12489
|
+
this.saveFleetConfig(patches);
|
|
12490
|
+
this.logger.info("Stored Office access outside dashboard for downgrade compatibility");
|
|
12491
|
+
}
|
|
12492
|
+
catch (err) {
|
|
12493
|
+
this.logger.warn({ err }, "Could not migrate Office access; keeping the original file");
|
|
12494
|
+
}
|
|
12495
|
+
}
|
|
11905
12496
|
slimFleetConfigAtStartup() {
|
|
12497
|
+
this.migrateOfficeStorage();
|
|
12498
|
+
this.migrateNoticeDeliveryStorage();
|
|
11906
12499
|
const redundantPaths = collectRedundantInstanceDefaultPaths(this.rawFleetConfig);
|
|
11907
12500
|
if (redundantPaths.length === 0)
|
|
11908
12501
|
return;
|
|
@@ -12125,6 +12718,7 @@ export class FleetManager {
|
|
|
12125
12718
|
this.effectToolsService?.forget(name);
|
|
12126
12719
|
this.mods.forgetInstance(name);
|
|
12127
12720
|
this.agentMods.forget(name);
|
|
12721
|
+
this.officeActsInstance?.forget(name);
|
|
12128
12722
|
}
|
|
12129
12723
|
catch (err) {
|
|
12130
12724
|
this.logger.warn({ err: err.message, name }, "Dropping a removed instance's web theme failed");
|
|
@@ -13931,6 +14525,8 @@ export class FleetManager {
|
|
|
13931
14525
|
return true;
|
|
13932
14526
|
if (await this.handleSettingsChangeCallback(data, adapterId, adapter))
|
|
13933
14527
|
return true;
|
|
14528
|
+
if (await this.handleKiroMigrateCallback(data, adapterId, adapter))
|
|
14529
|
+
return true;
|
|
13934
14530
|
if (await this.handleDashboardCallback(data, adapterId, adapter))
|
|
13935
14531
|
return true;
|
|
13936
14532
|
if (await this.handleClassicApproval(data, adapterId, adapter))
|
|
@@ -14051,6 +14647,7 @@ export class FleetManager {
|
|
|
14051
14647
|
const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
|
|
14052
14648
|
name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
|
|
14053
14649
|
claudeConfigDir: this.claudeConfigDirOf(name), profile: this.usageProfileOf(name, this.backendNameOf(name)),
|
|
14650
|
+
kiroDbPath: this.kiroDbPathOf(name, this.backendNameOf(name)),
|
|
14054
14651
|
}));
|
|
14055
14652
|
const taken = new Set(fleet.map(i => i.name));
|
|
14056
14653
|
const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
|
|
@@ -14060,6 +14657,7 @@ export class FleetManager {
|
|
|
14060
14657
|
ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
|
|
14061
14658
|
claudeConfigDir: this.claudeConfigDirOf(ch.instanceName),
|
|
14062
14659
|
profile: this.usageProfileOf(ch.instanceName, this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code"),
|
|
14660
|
+
kiroDbPath: this.kiroDbPathOf(ch.instanceName, this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code"),
|
|
14063
14661
|
}));
|
|
14064
14662
|
return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
|
|
14065
14663
|
}
|
|
@@ -14080,8 +14678,19 @@ export class FleetManager {
|
|
|
14080
14678
|
return this.credentialProfileOf(name, "claude-code") ?? "default";
|
|
14081
14679
|
if (backend === "codex")
|
|
14082
14680
|
return "unrecorded";
|
|
14681
|
+
if (backend === "kiro-cli")
|
|
14682
|
+
return this.credentialProfileOf(name, "kiro-cli") ?? "default";
|
|
14683
|
+
if (backend === "grok" || backend === "muse" || backend === "opencode" || backend === "antigravity")
|
|
14684
|
+
return "default";
|
|
14083
14685
|
return undefined;
|
|
14084
14686
|
}
|
|
14687
|
+
/** #2122 §10: a Kiro instance with a credential profile writes its classic store in that profile's home. */
|
|
14688
|
+
kiroDbPathOf(name, backend) {
|
|
14689
|
+
if (backend !== "kiro-cli")
|
|
14690
|
+
return undefined;
|
|
14691
|
+
const profile = this.credentialProfileOf(name, "kiro-cli");
|
|
14692
|
+
return profile ? join(credentialProfileStoreHome(getAgendHome(), "kiro-cli", profile), "data.sqlite3") : undefined;
|
|
14693
|
+
}
|
|
14085
14694
|
/** #2122: the fleet's day boundary zone — defaults.scheduler.default_timezone, else this host's. */
|
|
14086
14695
|
usageZone() {
|
|
14087
14696
|
return this.fleetConfig?.defaults?.scheduler?.default_timezone?.trim() || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
@@ -14124,6 +14733,19 @@ export class FleetManager {
|
|
|
14124
14733
|
rolloutsUnreadable: root => sharedRolloutIndex(root).unreadable,
|
|
14125
14734
|
metaPath: join(this.dataDir, "cache-codex-rollouts.json"),
|
|
14126
14735
|
zone: () => this.usageZone(),
|
|
14736
|
+
// #2122 §10: Kiro's shared classic store (XDG_DATA_HOME, as kiro-cli resolves it) and its KIRO_HOME.
|
|
14737
|
+
kiroDbPath: () => join(process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share"), "kiro-cli", "data.sqlite3"),
|
|
14738
|
+
kiroHome: () => kiroHome(),
|
|
14739
|
+
// #2122 §10 step 3: grok keeps its sessions under ~/.grok (grok.ts reads them there too).
|
|
14740
|
+
grokSessionsDir: () => join(homedir(), ".grok", "sessions"),
|
|
14741
|
+
// #2122 §10 step 4: muse keeps sessions under $XDG_DATA_HOME/muse (else ~/.local/share/muse), as muse.ts reads them.
|
|
14742
|
+
// #2122 §10 step 5: OpenCode's database, under XDG_DATA_HOME as opencode resolves it.
|
|
14743
|
+
// #2122 §10 step 6: agy's conversations, under ~/.gemini as the antigravity backend reads its settings.
|
|
14744
|
+
antigravityDir: () => join(homedir(), ".gemini", "antigravity-cli", "conversations"),
|
|
14745
|
+
// The version AgEnD's CLI probe last recorded for agy: its cache file, whatever its age (never runs agy).
|
|
14746
|
+
antigravityVersion: () => this.lastRecordedCliVersion("antigravity"),
|
|
14747
|
+
opencodeDbPath: () => join(process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share"), "opencode", "opencode.db"),
|
|
14748
|
+
museSessionsDir: () => join(process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share"), "muse", "sessions"),
|
|
14127
14749
|
log: (level, msg, extra) => this.logger[level](extra ?? {}, msg),
|
|
14128
14750
|
});
|
|
14129
14751
|
}
|
|
@@ -14244,6 +14866,7 @@ export class FleetManager {
|
|
|
14244
14866
|
return this.deliveryOutbox.acknowledge(deliveryId, by);
|
|
14245
14867
|
},
|
|
14246
14868
|
tasks: () => this.needsYouTaskItems(),
|
|
14869
|
+
backendSignIns: () => this.needsYouBackendSignIns(),
|
|
14247
14870
|
taskAction: action => this.taskActionFromChat(action),
|
|
14248
14871
|
settings: () => ({
|
|
14249
14872
|
liveMessage: this.fleetConfig?.needs_you?.live_message !== false,
|
|
@@ -14760,7 +15383,7 @@ export class FleetManager {
|
|
|
14760
15383
|
...(input.instance !== undefined ? { target: { adapterId: this.getInstanceAdapterId(input.instance) } } : {}),
|
|
14761
15384
|
isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
|
|
14762
15385
|
authorization: input.session ? this.sessionAuthorizationStore()?.get(input.session.handle) ?? null : null,
|
|
14763
|
-
policy: { ...webAuthorityPolicy(this.fleetConfig?.web), enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now: Date.now(),
|
|
15386
|
+
policy: { ...webAuthorityPolicy(this.fleetConfig?.web), enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now: Date.now(),
|
|
14764
15387
|
});
|
|
14765
15388
|
}
|
|
14766
15389
|
sessionAuthorizations = null;
|
|
@@ -14785,13 +15408,13 @@ export class FleetManager {
|
|
|
14785
15408
|
session, gateway, peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
|
|
14786
15409
|
isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
|
|
14787
15410
|
authorization: { handle: session.handle, owner: session.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch },
|
|
14788
|
-
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now,
|
|
15411
|
+
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now,
|
|
14789
15412
|
});
|
|
14790
15413
|
if (check.refusal)
|
|
14791
15414
|
return { ok: false, refusal: check.refusal };
|
|
14792
15415
|
const live = new Set(this.webSessions?.list(undefined, () => true).map(r => r.handle) ?? []);
|
|
14793
15416
|
store.prune(a => a.epoch === state.epoch && now <= Math.min(a.until, a.authorizedAt + policy.maxSessionMs) && live.has(a.handle));
|
|
14794
|
-
return { ok: true, ...store.authorize(session.handle, session.owner, now, state.epoch, now + policy.maxSessionMs) };
|
|
15417
|
+
return { ok: true, ...store.authorize(session.handle, session.owner, now, state.epoch, now + policy.maxSessionMs, peer) };
|
|
14795
15418
|
}
|
|
14796
15419
|
authorityStateStore = null;
|
|
14797
15420
|
/**
|
|
@@ -14800,13 +15423,24 @@ export class FleetManager {
|
|
|
14800
15423
|
*/
|
|
14801
15424
|
webAuthorityState() {
|
|
14802
15425
|
if (!this.dataDir)
|
|
14803
|
-
return { enabled: false, loopbackOnly: true, namedLink: false, epoch: 0 };
|
|
15426
|
+
return { enabled: false, loopbackOnly: true, namedLink: false, quickLink: false, epoch: 0 };
|
|
14804
15427
|
this.authorityStateStore ??= new AuthorityStateStore(join(this.dataDir, "web-authority.json"), this.logger, undefined, undefined, () => { this.ensureDeliveryOutbox?.(); return this.deliveryOutbox?.maxWebAuthorityEpoch() ?? 0; });
|
|
14805
15428
|
const state = this.authorityStateStore.current(this.fleetConfig?.web?.verified_authority);
|
|
14806
|
-
// #2453 (§0.2):
|
|
14807
|
-
// good: their authorizations go (a later "on" can never bring one back without its account's Confirm).
|
|
14808
|
-
|
|
14809
|
-
|
|
15429
|
+
// #2453 (§0.2) / §0.3: a link's option seen going off — the authority of every session over that link ends now, for
|
|
15430
|
+
// good: their authorizations go (a later "on" can never bring one back without its account's Confirm). A record from
|
|
15431
|
+
// before §0.3 has no peer: over the gateway, only a named-link session could have been authorized then.
|
|
15432
|
+
if (state.namedLinkClosed || state.quickLinkClosed) {
|
|
15433
|
+
const ended = (a) => {
|
|
15434
|
+
const session = this.webSessions?.findByHandle(a.handle);
|
|
15435
|
+
if (!session)
|
|
15436
|
+
return true;
|
|
15437
|
+
if (session.surface === "local")
|
|
15438
|
+
return false;
|
|
15439
|
+
const peer = a.peer ?? "named_link";
|
|
15440
|
+
return (!!state.namedLinkClosed && peer === "named_link") || (!!state.quickLinkClosed && peer === "quick_link");
|
|
15441
|
+
};
|
|
15442
|
+
this.sessionAuthorizationStore()?.prune(a => !ended(a));
|
|
15443
|
+
}
|
|
14810
15444
|
return state;
|
|
14811
15445
|
}
|
|
14812
15446
|
/**
|
|
@@ -14839,7 +15473,8 @@ export class FleetManager {
|
|
|
14839
15473
|
.filter(a => a.epoch === state.epoch && sessions.has(a.handle) && now <= Math.min(a.until, a.authorizedAt + maxMs))
|
|
14840
15474
|
.map(a => ({ handle: a.handle, label: sessions.get(a.handle).label, account: `${a.owner.adapterId}:${a.owner.userId}`, authorizedAt: a.authorizedAt,
|
|
14841
15475
|
until: Math.min(a.until, a.authorizedAt + maxMs, sessions.get(a.handle).absoluteExpiry),
|
|
14842
|
-
|
|
15476
|
+
// Over a link: the temporary one (§0.3), else the named one (a record from before §0.3 has no peer).
|
|
15477
|
+
...(sessions.get(a.handle).surface === "gateway" ? (a.peer === "quick_link" ? { quick: true } : { named: true }) : {}) })) : [];
|
|
14843
15478
|
const host = this.fleetConfig?.web?.public_link?.hostname;
|
|
14844
15479
|
return { state, configured, hours: VERIFIED_SESSION_HOURS, lanHosts, authorized, namedHost: typeof host === "string" && host ? host : null };
|
|
14845
15480
|
}
|
|
@@ -14854,9 +15489,17 @@ export class FleetManager {
|
|
|
14854
15489
|
const adapterOf = (adapterId) => this.adapters.get(adapterId) ?? this.worlds.get(adapterId)?.adapter;
|
|
14855
15490
|
// The host's clock, with its zone: the person may read the DM on a phone in another one.
|
|
14856
15491
|
const when = (ms) => new Date(ms).toLocaleString(undefined, { timeZoneName: "short" });
|
|
15492
|
+
const quickHost = () => { try {
|
|
15493
|
+
const u = this.publicWebLink?.openSignin()?.url;
|
|
15494
|
+
return u ? new URL(u).host : "?";
|
|
15495
|
+
}
|
|
15496
|
+
catch {
|
|
15497
|
+
return "?";
|
|
15498
|
+
} };
|
|
14857
15499
|
const where = (peer) => peer === "named_link"
|
|
14858
15500
|
? t("web_auth.peer_named_link", String(this.fleetConfig?.web?.public_link?.hostname ?? "?")) // §0.2: the internet
|
|
14859
|
-
:
|
|
15501
|
+
: peer === "quick_link" ? t("web_auth.peer_quick_link", quickHost()) // §0.3: the internet
|
|
15502
|
+
: t(peer === "loopback" ? "web_auth.peer_loopback" : "web_auth.peer_lan");
|
|
14860
15503
|
return this.authorizationRequests = new SessionAuthorizationRequests({
|
|
14861
15504
|
now: () => performance.now(), wallNow: () => Date.now(),
|
|
14862
15505
|
maxSessionMs: () => webAuthorityPolicy(this.fleetConfig?.web).maxSessionMs,
|
|
@@ -14890,8 +15533,8 @@ export class FleetManager {
|
|
|
14890
15533
|
const record = this.webSessions?.findByHandle(handle);
|
|
14891
15534
|
if (!record)
|
|
14892
15535
|
return { ok: false, refusal: "session_gone" };
|
|
14893
|
-
// §0.2: a
|
|
14894
|
-
const r = this.authorizeWebSession(record, peer, peer === "named_link");
|
|
15536
|
+
// §0.2 / §0.3: a session over a link is judged as the gateway request it is (its exposure still the current one).
|
|
15537
|
+
const r = this.authorizeWebSession(record, peer, peer === "named_link" || peer === "quick_link");
|
|
14895
15538
|
// What the card says: the end of the authorization, or the session's own end when that comes first.
|
|
14896
15539
|
return r.ok ? { ok: true, until: Math.min(r.authorization.until, record.absoluteExpiry) } : { ok: false, refusal: r.refusal };
|
|
14897
15540
|
},
|
|
@@ -14937,7 +15580,7 @@ export class FleetManager {
|
|
|
14937
15580
|
session: s, gateway: input.gateway, peer: input.peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
|
|
14938
15581
|
isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
|
|
14939
15582
|
authorization: s?.owner ? { handle: s.handle, owner: s.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch } : null,
|
|
14940
|
-
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now,
|
|
15583
|
+
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now,
|
|
14941
15584
|
});
|
|
14942
15585
|
return r.refusal;
|
|
14943
15586
|
}
|
|
@@ -16228,6 +16871,55 @@ export class FleetManager {
|
|
|
16228
16871
|
}
|
|
16229
16872
|
return this.loginController;
|
|
16230
16873
|
}
|
|
16874
|
+
browserBackendSignInsInstance = null;
|
|
16875
|
+
structuredLoginCleanupFailed = false;
|
|
16876
|
+
pendingBackendSince = new Map();
|
|
16877
|
+
browserSignInOwnerCurrent(owner) {
|
|
16878
|
+
const record = owner;
|
|
16879
|
+
return !this.shuttingDown && typeof record.handle === "string" && this.webSessions?.findByHandle(record.handle) === owner
|
|
16880
|
+
&& this.isLocalAdminSession(record.handle);
|
|
16881
|
+
}
|
|
16882
|
+
/** #2599: default-account controls share the exact writer reservation with chat/profile/install. */
|
|
16883
|
+
get browserBackendSignIns() {
|
|
16884
|
+
return this.browserBackendSignInsInstance ??= new BrowserBackendSignIns({
|
|
16885
|
+
dataDir: this.dataDir, lock: this.loginWindow,
|
|
16886
|
+
current: owner => this.browserSignInOwnerCurrent(owner),
|
|
16887
|
+
installed: async () => ONBOARDING_BACKENDS.filter(backend => this.isCliInstalled(backend)),
|
|
16888
|
+
checkAuth: backend => checkAuthStatus(LOGIN_FLOWS[backend].authCheck),
|
|
16889
|
+
checkConsoleAuth: () => checkAuthStatus(LOGIN_FLOWS.antigravity.authCheck),
|
|
16890
|
+
transport: () => createStructuredSignInTransport(),
|
|
16891
|
+
changed: (event, owner, launched) => {
|
|
16892
|
+
try {
|
|
16893
|
+
this.eventLog?.insert("login", "browser_backend_signin", event);
|
|
16894
|
+
}
|
|
16895
|
+
catch { /* safe metadata only */ }
|
|
16896
|
+
broadcastSseEvent(this.sseClients, "backend_login", {}, err => this.logger.debug({ err }, "backend_login event failed"));
|
|
16897
|
+
this.needsYou?.poke();
|
|
16898
|
+
if (!this.browserSignInOwnerCurrent(owner))
|
|
16899
|
+
return;
|
|
16900
|
+
if (event.phase === "starting") {
|
|
16901
|
+
const record = owner;
|
|
16902
|
+
this.notifyFleetError(t("login.web_dashboard_notice", event.backend, record.label, record.handle.slice(0, 8)));
|
|
16903
|
+
}
|
|
16904
|
+
if (event.phase === "done") {
|
|
16905
|
+
void this.backendAvailability.refresh([event.backend], { force: true }).catch(() => { });
|
|
16906
|
+
if (launched)
|
|
16907
|
+
void this.recoverBackendInstances(event.backend, undefined, null).catch(() => this.logger.warn({ backend: event.backend }, "Post sign-in recovery failed"));
|
|
16908
|
+
}
|
|
16909
|
+
},
|
|
16910
|
+
});
|
|
16911
|
+
}
|
|
16912
|
+
needsYouBackendSignIns() {
|
|
16913
|
+
const pending = readPendingBackendSignIns(this.dataDir).pending;
|
|
16914
|
+
for (const backend of this.pendingBackendSince?.keys() ?? [])
|
|
16915
|
+
if (!pending.includes(backend))
|
|
16916
|
+
this.pendingBackendSince.delete(backend);
|
|
16917
|
+
return pending.map(backend => {
|
|
16918
|
+
if (!this.pendingBackendSince.has(backend))
|
|
16919
|
+
this.pendingBackendSince.set(backend, Date.now());
|
|
16920
|
+
return { backend, since: this.pendingBackendSince.get(backend), browser: structuredSignInSupported(backend) };
|
|
16921
|
+
});
|
|
16922
|
+
}
|
|
16231
16923
|
/** #2233: sign-ins started from the web dashboard (local admin sessions), on the same controller as chat's. */
|
|
16232
16924
|
webBackendLoginsInstance = null;
|
|
16233
16925
|
get webBackendLogins() {
|
|
@@ -16580,6 +17272,14 @@ export class FleetManager {
|
|
|
16580
17272
|
// "cancelled" is the detail both legacy onDone handlers keep quiet about —
|
|
16581
17273
|
// a stopping fleet must not announce "login failed — fleet shutdown".
|
|
16582
17274
|
await Promise.all([
|
|
17275
|
+
bounded(this.browserBackendSignInsInstance?.close().then(confirmed => {
|
|
17276
|
+
if (confirmed)
|
|
17277
|
+
this.browserBackendSignInsInstance = null;
|
|
17278
|
+
else {
|
|
17279
|
+
this.structuredLoginCleanupFailed = true;
|
|
17280
|
+
this.logger.error("Structured login cleanup failed; sign-in admission remains closed until the host is restarted.");
|
|
17281
|
+
}
|
|
17282
|
+
}), "browser-controls"),
|
|
16583
17283
|
bounded(this.loginController?.shutdown(), "web-login"),
|
|
16584
17284
|
bounded(this.activeInstall?.session.cancel("cancelled"), "install"),
|
|
16585
17285
|
]);
|
|
@@ -16596,6 +17296,13 @@ export class FleetManager {
|
|
|
16596
17296
|
this.installHandoff = null;
|
|
16597
17297
|
return t("login.cancelled", backend);
|
|
16598
17298
|
}
|
|
17299
|
+
const browserLogin = this.browserBackendSignInsInstance, browserBackend = browserLogin?.activeBackend;
|
|
17300
|
+
if (browserLogin && browserBackend) {
|
|
17301
|
+
const confirmed = await browserLogin.close();
|
|
17302
|
+
if (confirmed)
|
|
17303
|
+
this.browserBackendSignInsInstance = null;
|
|
17304
|
+
return confirmed ? t("login.cancelled", browserBackend) : t("login.browser_cleanup_failed");
|
|
17305
|
+
}
|
|
16599
17306
|
if (this.loginController?.isActive())
|
|
16600
17307
|
return this.loginController.cancel();
|
|
16601
17308
|
return t("login.no_session");
|
|
@@ -16767,7 +17474,7 @@ export class FleetManager {
|
|
|
16767
17474
|
// session only judges that verdict; it does not read the output for URLs or prompts.
|
|
16768
17475
|
const flow = {
|
|
16769
17476
|
backend,
|
|
16770
|
-
command: info.install,
|
|
17477
|
+
command: info.unattended ?? info.install,
|
|
16771
17478
|
successPattern: /$^/,
|
|
16772
17479
|
timeoutMs: 10 * 60 * 1000,
|
|
16773
17480
|
};
|
|
@@ -17764,7 +18471,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17764
18471
|
const isPrimaryAdapter = !mentionWorld || mentionWorld.adapter === this.adapter;
|
|
17765
18472
|
const adapterBotUserId = mentionWorld?.botUserId ?? (isPrimaryAdapter ? this.botUserId : undefined);
|
|
17766
18473
|
const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
|
|
17767
|
-
|
|
18474
|
+
// #2551: a native reply to one of THIS bot's messages is addressed to it, as a mention is (a Discord reply carries
|
|
18475
|
+
// its ping in the reference, not the text). One hop only: a reply to a message of ours that itself answered a bot
|
|
18476
|
+
// (or the sender) is a chain of bots answering each other — a mention still carries it on. A reply to our answer
|
|
18477
|
+
// to a person is the ordinary handoff, and is addressed.
|
|
18478
|
+
const replyTriggers = this.classicChannels?.getReplyTriggers(channelId, msg.adapterId, this.fleetConfig?.defaults?.reply_triggers) ?? DEFAULT_REPLY_TRIGGERS;
|
|
18479
|
+
const repliesToUs = replyTriggers && !!adapterBotUserId && msg.replyToAuthorId === adapterBotUserId && msg.replyToChain !== true;
|
|
18480
|
+
// Snapshot/quoted content is context, never the forwarder's own mention.
|
|
18481
|
+
const addressText = msg.source === "discord" ? (msg.humanOriginalBody ?? text) : text;
|
|
18482
|
+
const isMentioned = (mentionTag && addressText.includes(mentionTag)) || repliesToUs;
|
|
17768
18483
|
if (!isMentioned) {
|
|
17769
18484
|
// Bare attachment (no @mention) — already saved above; just acknowledge.
|
|
17770
18485
|
if (saved) {
|
|
@@ -18099,6 +18814,19 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18099
18814
|
catch { /* missing / stale / corrupt */ }
|
|
18100
18815
|
return null;
|
|
18101
18816
|
}
|
|
18817
|
+
/**
|
|
18818
|
+
* #2122 §10: the version the CLI probe last recorded for a backend — its cache file read whatever its age (the usage
|
|
18819
|
+
* reader's version gate needs the last known version, not a fresh one); null when none was recorded. Never a probe.
|
|
18820
|
+
*/
|
|
18821
|
+
lastRecordedCliVersion(backend) {
|
|
18822
|
+
try {
|
|
18823
|
+
const v = JSON.parse(readFileSync(this.cliEnvPath(backend), "utf-8")).version;
|
|
18824
|
+
return typeof v === "string" && v.trim() ? v.trim() : null;
|
|
18825
|
+
}
|
|
18826
|
+
catch {
|
|
18827
|
+
return null;
|
|
18828
|
+
}
|
|
18829
|
+
}
|
|
18102
18830
|
/** Return cached CLI versions immediately. Refresh is started after /sysinfo is sent. */
|
|
18103
18831
|
getBackendCliVersionSnapshot() {
|
|
18104
18832
|
const snapshot = {};
|
|
@@ -20033,6 +20761,23 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20033
20761
|
}
|
|
20034
20762
|
tmuxExecHelperTimer = null;
|
|
20035
20763
|
stopAllInFlight = null;
|
|
20764
|
+
/** Signal-handler continuation, after successful graceful shutdown only. */
|
|
20765
|
+
noteRequestedShutdown() {
|
|
20766
|
+
const progress = readUpdateProgress(this.dataDir)?.progress;
|
|
20767
|
+
// Host CLI updates carry a generic marker with no chat progress target.
|
|
20768
|
+
const update = progress ? updateProgressOperation(progress) === "update"
|
|
20769
|
+
&& ["installed", "stopping", "starting"].includes(progress.stage) : isUpdateInProgress(this.dataDir);
|
|
20770
|
+
this.recordWorkingRestartIntent(update ? "update" : "manual_restart");
|
|
20771
|
+
}
|
|
20772
|
+
recordWorkingRestartIntent(cause) {
|
|
20773
|
+
this.workingRecoveryCause = cause;
|
|
20774
|
+
try {
|
|
20775
|
+
this.deliveryOutbox?.workingRecovery.requested(this.managerBootId, cause);
|
|
20776
|
+
}
|
|
20777
|
+
catch {
|
|
20778
|
+
this.logger.warn("Could not record requested restart; next recovery will report an unknown interruption");
|
|
20779
|
+
}
|
|
20780
|
+
}
|
|
20036
20781
|
/** Stops release memory. Reassess between batches without waiting for recovery. */
|
|
20037
20782
|
async stopInstancesInBatches(entries, stop) {
|
|
20038
20783
|
let index = 0;
|
|
@@ -20050,15 +20795,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20050
20795
|
}
|
|
20051
20796
|
}
|
|
20052
20797
|
async doStopAll() {
|
|
20798
|
+
// The scheduler closes before individual daemons stop. Preserve the owned
|
|
20799
|
+
// task/reply boundary while those stores are still available, not a later
|
|
20800
|
+
// shutdown snapshot whose unavailable sources cannot authorize recovery.
|
|
20801
|
+
for (const name of this.daemons.keys())
|
|
20802
|
+
this.observeWorkingState(name, "before_stop");
|
|
20053
20803
|
this.startupMemoryDeadline?.stop();
|
|
20054
20804
|
this.startupMemoryDeadline = null;
|
|
20055
20805
|
this.startupComplete = false;
|
|
20806
|
+
this.workingRecovery?.reset();
|
|
20056
20807
|
this.startupDashboard = null;
|
|
20057
20808
|
this.reloadPending = false;
|
|
20058
20809
|
// Before anything is stopped: everything that dies from here on dies
|
|
20059
20810
|
// because we asked it to. Set synchronously — doStopAll runs to its first
|
|
20060
20811
|
// await in the same tick as the signal handler, so no event can slip in.
|
|
20061
20812
|
this.shuttingDown = true;
|
|
20813
|
+
this.workingRecovery?.reset();
|
|
20814
|
+
if (this.workingRecoveryTimer)
|
|
20815
|
+
clearInterval(this.workingRecoveryTimer);
|
|
20816
|
+
this.workingRecoveryTimer = null;
|
|
20062
20817
|
if (this.updateProgressTimer) {
|
|
20063
20818
|
clearInterval(this.updateProgressTimer);
|
|
20064
20819
|
this.updateProgressTimer = null;
|
|
@@ -20071,6 +20826,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20071
20826
|
const settingsControlStopped = settingsControl?.close();
|
|
20072
20827
|
this.publicLinkAutoOpen?.stop();
|
|
20073
20828
|
const publicStopped = this.publicWebLink?.close("fleet shutdown");
|
|
20829
|
+
const sharesStopped = this.officeSharesInstance?.close(); // #2636: every Office share ends, and its tunnel
|
|
20074
20830
|
// #1386: every live "Needs you" message says the fleet stopped (capabilities revoked first) — while the
|
|
20075
20831
|
// adapters can still edit. Bounded: a platform that does not answer must not hold the shutdown.
|
|
20076
20832
|
const needsStopped = this.stopNeedsYou();
|
|
@@ -20109,6 +20865,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20109
20865
|
// timer and HTTP listener living in THIS process: without an explicit
|
|
20110
20866
|
// shutdown it would outlive us as an owner-less login CLI (sol B3).
|
|
20111
20867
|
await publicStopped;
|
|
20868
|
+
await sharesStopped?.catch(() => { });
|
|
20112
20869
|
await profileStopped;
|
|
20113
20870
|
await needsStopped;
|
|
20114
20871
|
await settingsControlStopped;
|
|
@@ -20461,6 +21218,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20461
21218
|
// mark every row done and tell the user "changes applied".
|
|
20462
21219
|
return { rejected: why };
|
|
20463
21220
|
}
|
|
21221
|
+
this.migrateOfficeStorage();
|
|
21222
|
+
this.migrateNoticeDeliveryStorage();
|
|
20464
21223
|
if (canvasWorkerEnabled(oldConfig?.web) !== this.canvasWorkerEnabled()) {
|
|
20465
21224
|
this.canvasWorkerSettingChanged();
|
|
20466
21225
|
}
|
|
@@ -20738,6 +21497,27 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20738
21497
|
fleetSignatureMismatchKeys() {
|
|
20739
21498
|
return this.fleetSignatureMismatch;
|
|
20740
21499
|
}
|
|
21500
|
+
/**
|
|
21501
|
+
* #2347 restart flow: saved changes that take effect only after AgEnD restarts — the startup-only keys fleet.yaml
|
|
21502
|
+
* now differs on, and the newest apply job whose fleet row can carry the restart (`job_id`, null when none can: a
|
|
21503
|
+
* hand edit, or a job past its retention). Null when nothing is pending, and when a restart could not clear it
|
|
21504
|
+
* (the signature mismatch the panel reports instead). The same test requestSettingsSelfRestart makes.
|
|
21505
|
+
*/
|
|
21506
|
+
settingsRestartPending() {
|
|
21507
|
+
if (this.appliedFleetLevel === null || this.fleetSignatureMismatch)
|
|
21508
|
+
return null;
|
|
21509
|
+
const next = this.nextFleetConfig();
|
|
21510
|
+
if (this.appliedFleetLevel === this.fleetLevelSignature(next))
|
|
21511
|
+
return null;
|
|
21512
|
+
let job = null;
|
|
21513
|
+
// A job whose restart was asked for already has its row in flight ("running"), so it is never offered again.
|
|
21514
|
+
for (const candidate of this.applyJobs.all()) {
|
|
21515
|
+
const row = candidate.targets.find(item => item.target === APPLY_FLEET_TARGET);
|
|
21516
|
+
if (row?.status === "restart-required" && (!job || candidate.startedAt >= job.startedAt))
|
|
21517
|
+
job = candidate;
|
|
21518
|
+
}
|
|
21519
|
+
return { keys: fleetLevelDifferences(this.startupFleetConfig, next), job_id: job ? job.id : null };
|
|
21520
|
+
}
|
|
20741
21521
|
/**
|
|
20742
21522
|
* Restart AgEnD itself on behalf of a Settings apply.
|
|
20743
21523
|
*
|
|
@@ -21988,6 +22768,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21988
22768
|
this.logger.info("No instances to restart");
|
|
21989
22769
|
return;
|
|
21990
22770
|
}
|
|
22771
|
+
this.workingRecoveryGroup = randomUUID();
|
|
22772
|
+
this.recordWorkingRestartIntent("manual_restart");
|
|
22773
|
+
this.workingRecovery?.reset();
|
|
21991
22774
|
this.logger.info(`Graceful restart: waiting for ${instanceNames.length} instances to idle...`);
|
|
21992
22775
|
const groupId = this.fleetConfig?.channel?.group_id;
|
|
21993
22776
|
const generalName = this.findGeneralInstance();
|
|
@@ -22061,34 +22844,32 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
22061
22844
|
}
|
|
22062
22845
|
// General is ready again; now its topic can own the live progress message.
|
|
22063
22846
|
await restartProgress.start(progressTarget);
|
|
22064
|
-
|
|
22065
|
-
|
|
22847
|
+
// The ClassicBot rooms (killed during orphan cleanup) restart with the fleet instances, in the same queue; their
|
|
22848
|
+
// open-lists are refreshed first so a room's inbound is never dropped while it starts.
|
|
22849
|
+
const restartClassic = topicMode ? this.classicChannels?.getAll() ?? [] : [];
|
|
22850
|
+
if (restartClassic.length > 0)
|
|
22851
|
+
this.reregisterClassicChannels();
|
|
22852
|
+
if (restartOthers.length > 0 || restartClassic.length > 0) {
|
|
22853
|
+
await this.startInstancesWithConcurrency(restartOthers, topicMode, () => restartProgress.markReady(), restartClassic);
|
|
22066
22854
|
}
|
|
22067
22855
|
if (topicMode) {
|
|
22068
22856
|
this.routing.rebuild(this.fleetConfig);
|
|
22069
22857
|
this.reregisterClassicChannels();
|
|
22070
22858
|
// startInstance already calls connectIpcToInstance, no need for connectToInstances here
|
|
22071
|
-
// Restart classic channel instances (killed during orphan cleanup)
|
|
22072
|
-
if (this.classicChannels) {
|
|
22073
|
-
const fleetBackend = this.fleetConfig?.defaults?.backend;
|
|
22074
|
-
const channels = this.classicChannels.getAll()
|
|
22075
|
-
.filter(ch => !this.lifecycle.isPaused(ch.instanceName));
|
|
22076
|
-
const concurrency = 3;
|
|
22077
|
-
let idx = 0;
|
|
22078
|
-
while (idx < channels.length) {
|
|
22079
|
-
const batch = channels.slice(idx, idx + concurrency);
|
|
22080
|
-
await Promise.allSettled(batch.map(async (ch) => {
|
|
22081
|
-
if (await this.startClassicInstanceUnattended(ch, "classic instance"))
|
|
22082
|
-
restartProgress.markReady();
|
|
22083
|
-
}));
|
|
22084
|
-
idx += concurrency;
|
|
22085
|
-
}
|
|
22086
|
-
}
|
|
22087
22859
|
for (const name of Object.keys(fleet.instances)) {
|
|
22088
22860
|
this.startStatuslineWatcher(name);
|
|
22089
22861
|
}
|
|
22090
22862
|
}
|
|
22863
|
+
this.workingRecovery?.ready();
|
|
22091
22864
|
this.logger.info("Graceful restart complete");
|
|
22865
|
+
// The in-process interval keeps its label, but a later unrelated process
|
|
22866
|
+
// crash must not inherit this completed request as its shutdown intent.
|
|
22867
|
+
try {
|
|
22868
|
+
this.deliveryOutbox?.workingRecovery.clearRequested(this.managerBootId);
|
|
22869
|
+
}
|
|
22870
|
+
catch {
|
|
22871
|
+
this.logger.warn("Could not clear completed in-process restart intent");
|
|
22872
|
+
}
|
|
22092
22873
|
const configuredNames = this.configuredStartupInstanceNames(fleet, topicMode);
|
|
22093
22874
|
const total = configuredNames.length;
|
|
22094
22875
|
const started = configuredNames.filter(name => this.daemons.has(name)).length;
|
|
@@ -22143,6 +22924,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
22143
22924
|
}
|
|
22144
22925
|
const ipc = this.instanceIpcClients.get(name);
|
|
22145
22926
|
if (ipc?.connected) {
|
|
22927
|
+
daemon?.noteLegacyRestartReminder?.();
|
|
22146
22928
|
ipc.send({
|
|
22147
22929
|
type: "fleet_inbound",
|
|
22148
22930
|
content: msg,
|
|
@@ -22290,6 +23072,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
22290
23072
|
const guests = this.fleetConfig?.web?.guests;
|
|
22291
23073
|
if (!owner || guests?.enabled !== true)
|
|
22292
23074
|
return NO_GUEST_SCOPE;
|
|
23075
|
+
// #2348: explicit grants, once the key exists, are the whole scope — no narrowing on top (a grant is already exact).
|
|
23076
|
+
if (guests.grants !== undefined) {
|
|
23077
|
+
const granted = this.guestReach(owner);
|
|
23078
|
+
return granted.size ? granted : NO_GUEST_SCOPE;
|
|
23079
|
+
}
|
|
22293
23080
|
const scope = new Set(this.guestReach(owner));
|
|
22294
23081
|
const narrow = guests.narrow && Object.hasOwn(guests.narrow, `${owner.adapterId}:${owner.userId}`) ? guests.narrow[`${owner.adapterId}:${owner.userId}`] : undefined;
|
|
22295
23082
|
if (narrow) {
|
|
@@ -22310,6 +23097,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
22310
23097
|
const scope = new Set();
|
|
22311
23098
|
if (guests?.enabled !== true)
|
|
22312
23099
|
return scope;
|
|
23100
|
+
// #2348: with explicit grants, what an admin granted this account — of the instances that exist now (fleet.yaml's, or
|
|
23101
|
+
// a ClassicBot room's) — and nothing else: no connection access, no room membership, no open_instances.
|
|
23102
|
+
if (guests.grants !== undefined) {
|
|
23103
|
+
const key = `${owner.adapterId}:${owner.userId}`;
|
|
23104
|
+
const grant = Object.hasOwn(guests.grants, key) ? guests.grants[key] : undefined;
|
|
23105
|
+
if (!grant || !Array.isArray(grant.instances))
|
|
23106
|
+
return scope;
|
|
23107
|
+
const rooms = new Set((this.classicChannels?.getAll() ?? []).map((r) => r.instanceName));
|
|
23108
|
+
for (const name of grant.instances) {
|
|
23109
|
+
if ((this.fleetConfig?.instances && Object.hasOwn(this.fleetConfig.instances, name)) || rooms.has(name))
|
|
23110
|
+
scope.add(name);
|
|
23111
|
+
}
|
|
23112
|
+
return scope;
|
|
23113
|
+
}
|
|
22313
23114
|
const own = (map, key) => (map && Object.hasOwn(map, key) ? map[key] : undefined);
|
|
22314
23115
|
const world = this.worlds.get(owner.adapterId);
|
|
22315
23116
|
if (world && world.accessManager.isAllowed(owner.userId)) {
|
|
@@ -22351,7 +23152,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
22351
23152
|
const owner = record.owner;
|
|
22352
23153
|
if (this.guestScope(owner).size)
|
|
22353
23154
|
return "ok";
|
|
22354
|
-
|
|
23155
|
+
// #2348: with explicit grants no room's membership can give anything: nothing granted is gone, not pending. Nor does
|
|
23156
|
+
// a connection's start matter: grants are config (and ClassicBot rooms), read without the adapter.
|
|
23157
|
+
if (owner && this.fleetConfig?.web?.guests?.enabled === true && this.fleetConfig.web.guests.grants === undefined) {
|
|
22355
23158
|
// Before adapters initialize, topic scope is unknown, not revoked. Keep the returning cookie but grant no
|
|
22356
23159
|
// request until its configured world exists. Removed connections and initialized denied access stay final.
|
|
22357
23160
|
const channels = this.fleetConfig.channels ?? (this.fleetConfig.channel ? [this.fleetConfig.channel] : []);
|