@songsid/agend 2.3.5-alpha.2 → 2.3.5-alpha.4
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/dist/agend-home.d.ts +13 -0
- package/dist/agend-home.js +42 -0
- package/dist/agend-home.js.map +1 -1
- package/dist/agent-runtime-api.d.ts +4 -0
- package/dist/agent-runtime-api.js +59 -0
- package/dist/agent-runtime-api.js.map +1 -0
- package/dist/agent-runtime-change.d.ts +74 -0
- package/dist/agent-runtime-change.js +102 -0
- package/dist/agent-runtime-change.js.map +1 -0
- package/dist/auth-api.d.ts +21 -0
- package/dist/auth-api.js +95 -4
- package/dist/auth-api.js.map +1 -1
- package/dist/authority/web-authority.d.ts +12 -2
- package/dist/authority/web-authority.js +25 -4
- package/dist/authority/web-authority.js.map +1 -1
- package/dist/backend/claude-code.js +16 -1
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex-footer-hints.d.ts +4 -0
- package/dist/backend/codex-footer-hints.js +27 -0
- package/dist/backend/codex-footer-hints.js.map +1 -0
- package/dist/backend/codex.js +19 -11
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/kiro-identity.d.ts +2 -0
- package/dist/backend/kiro-identity.js +13 -6
- package/dist/backend/kiro-identity.js.map +1 -1
- package/dist/backend/kiro.js +18 -3
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend-availability.d.ts +6 -0
- package/dist/backend-availability.js +13 -5
- package/dist/backend-availability.js.map +1 -1
- package/dist/channel/mcp-tools.js +1 -1
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/cli.js +6 -9
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +12 -1
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.d.ts +5 -0
- package/dist/daemon.js +107 -30
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-manager.d.ts +146 -8
- package/dist/fleet-manager.js +1030 -166
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-windows-runtime.d.ts +6 -1
- package/dist/fleet-windows-runtime.js +77 -1
- package/dist/fleet-windows-runtime.js.map +1 -1
- package/dist/fleet-yaml-patch.d.ts +3 -0
- package/dist/fleet-yaml-patch.js +103 -0
- package/dist/fleet-yaml-patch.js.map +1 -0
- package/dist/general-binding.d.ts +85 -0
- package/dist/general-binding.js +298 -0
- package/dist/general-binding.js.map +1 -0
- package/dist/general-host-owner.d.ts +8 -0
- package/dist/general-host-owner.js +26 -0
- package/dist/general-host-owner.js.map +1 -0
- package/dist/general-instance.d.ts +7 -0
- package/dist/general-instance.js +20 -3
- package/dist/general-instance.js.map +1 -1
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +2 -0
- package/dist/general-role.d.ts +5 -0
- package/dist/general-role.js +26 -0
- package/dist/general-role.js.map +1 -0
- package/dist/instance-lifecycle.d.ts +1 -0
- package/dist/instance-lifecycle.js +26 -4
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.d.ts +2 -0
- package/dist/instructions.js +6 -0
- package/dist/instructions.js.map +1 -1
- package/dist/kiro-resume-summary.d.ts +37 -0
- package/dist/kiro-resume-summary.js +85 -0
- package/dist/kiro-resume-summary.js.map +1 -0
- package/dist/locale.js +59 -0
- package/dist/locale.js.map +1 -1
- package/dist/login-controller.js +9 -1
- package/dist/login-controller.js.map +1 -1
- package/dist/native-pane-lifecycle.js +4 -1
- package/dist/native-pane-lifecycle.js.map +1 -1
- package/dist/native-pane-manager.d.ts +1 -0
- package/dist/native-pane-manager.js +23 -2
- package/dist/native-pane-manager.js.map +1 -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 -1
- package/dist/needs-you.js +5 -2
- package/dist/needs-you.js.map +1 -1
- package/dist/office-share-page.js +1 -1
- package/dist/office-share-page.js.map +1 -1
- package/dist/package-preimage.js +27 -2
- package/dist/package-preimage.js.map +1 -1
- package/dist/pane-port.d.ts +11 -0
- package/dist/public-web-gateway.js +9 -0
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/public-web-link.d.ts +11 -0
- package/dist/public-web-link.js +13 -0
- package/dist/public-web-link.js.map +1 -1
- package/dist/quickstart-api.js +9 -5
- package/dist/quickstart-api.js.map +1 -1
- package/dist/quickstart.d.ts +1 -0
- package/dist/quickstart.js +4 -1
- package/dist/quickstart.js.map +1 -1
- package/dist/reply-buttons-controller.js +10 -0
- package/dist/reply-buttons-controller.js.map +1 -1
- package/dist/runtime-api.js +84 -9
- package/dist/runtime-api.js.map +1 -1
- package/dist/runtime-build-info.d.ts +9 -0
- package/dist/runtime-build-info.js +57 -0
- package/dist/runtime-build-info.js.map +1 -0
- package/dist/runtime-bundle.d.ts +3 -1
- package/dist/runtime-bundle.js +17 -8
- package/dist/runtime-bundle.js.map +1 -1
- package/dist/runtime-cli.d.ts +6 -1
- package/dist/runtime-cli.js +46 -11
- package/dist/runtime-cli.js.map +1 -1
- package/dist/runtime-client-store.d.ts +20 -1
- package/dist/runtime-client-store.js +56 -4
- package/dist/runtime-client-store.js.map +1 -1
- package/dist/runtime-client.d.ts +12 -0
- package/dist/runtime-client.js +43 -7
- package/dist/runtime-client.js.map +1 -1
- package/dist/runtime-connection.d.ts +22 -0
- package/dist/runtime-connection.js +93 -0
- package/dist/runtime-connection.js.map +1 -0
- package/dist/runtime-coordinator.d.ts +9 -0
- package/dist/runtime-coordinator.js +81 -34
- package/dist/runtime-coordinator.js.map +1 -1
- package/dist/runtime-general-protocol.d.ts +22 -0
- package/dist/runtime-general-protocol.js +57 -0
- package/dist/runtime-general-protocol.js.map +1 -0
- package/dist/runtime-general-workspace.d.ts +17 -0
- package/dist/runtime-general-workspace.js +213 -0
- package/dist/runtime-general-workspace.js.map +1 -0
- package/dist/runtime-pane-assets.d.ts +1 -1
- package/dist/runtime-pane-assets.js +1 -1
- package/dist/runtime-pane-assets.js.map +1 -1
- package/dist/runtime-pane-boot.d.ts +7 -0
- package/dist/runtime-pane-boot.js +23 -0
- package/dist/runtime-pane-boot.js.map +1 -1
- package/dist/runtime-pane-host.d.ts +1 -0
- package/dist/runtime-pane-host.js +22 -1
- package/dist/runtime-pane-host.js.map +1 -1
- package/dist/runtime-pane-mcp.d.ts +8 -0
- package/dist/runtime-pane-mcp.js +15 -2
- package/dist/runtime-pane-mcp.js.map +1 -1
- package/dist/runtime-pane-port.d.ts +6 -1
- package/dist/runtime-pane-port.js +21 -0
- package/dist/runtime-pane-port.js.map +1 -1
- package/dist/runtime-pane-protocol.d.ts +4 -1
- package/dist/runtime-pane-protocol.js +7 -1
- package/dist/runtime-pane-protocol.js.map +1 -1
- package/dist/runtime-protocol.d.ts +13 -0
- package/dist/runtime-protocol.js +18 -0
- package/dist/runtime-protocol.js.map +1 -1
- package/dist/settings-api.d.ts +5 -0
- package/dist/settings-api.js +37 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-baseline.d.ts +1 -0
- package/dist/settings-baseline.js +1 -1
- package/dist/settings-baseline.js.map +1 -1
- package/dist/settings-confirmation.d.ts +1 -0
- package/dist/settings-confirmation.js +1 -0
- package/dist/settings-confirmation.js.map +1 -1
- package/dist/settings-effect.d.ts +4 -0
- package/dist/settings-effect.js +36 -1
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.d.ts +4 -0
- package/dist/settings-http-confirmation.js +20 -1
- package/dist/settings-http-confirmation.js.map +1 -1
- package/dist/settings-request-capability.d.ts +2 -0
- package/dist/settings-request-capability.js +4 -0
- package/dist/settings-request-capability.js.map +1 -1
- package/dist/settings-route-lifetime.d.ts +14 -0
- package/dist/settings-route-lifetime.js +48 -0
- package/dist/settings-route-lifetime.js.map +1 -0
- package/dist/settings-transaction.d.ts +4 -0
- package/dist/settings-transaction.js +9 -0
- package/dist/settings-transaction.js.map +1 -1
- package/dist/setup-form.js +6 -3
- package/dist/setup-form.js.map +1 -1
- package/dist/setup-i18n.js +3 -0
- package/dist/setup-i18n.js.map +1 -1
- package/dist/stall-reporter.d.ts +21 -0
- package/dist/stall-reporter.js +82 -0
- package/dist/stall-reporter.js.map +1 -0
- package/dist/tmux-control.d.ts +2 -2
- package/dist/tmux-control.js +4 -2
- package/dist/tmux-control.js.map +1 -1
- package/dist/tmux-exec-helper.d.ts +6 -3
- package/dist/tmux-exec-helper.js +115 -53
- package/dist/tmux-exec-helper.js.map +1 -1
- package/dist/tool-permissions.js +2 -1
- package/dist/tool-permissions.js.map +1 -1
- package/dist/tunnel/cloudflared-install.d.ts +4 -0
- package/dist/tunnel/cloudflared-install.js +20 -1
- package/dist/tunnel/cloudflared-install.js.map +1 -1
- package/dist/tunnel/cloudflared.d.ts +8 -1
- package/dist/tunnel/cloudflared.js +34 -6
- package/dist/tunnel/cloudflared.js.map +1 -1
- package/dist/tunnel/lease.d.ts +3 -3
- package/dist/tunnel/lease.js +23 -19
- package/dist/tunnel/lease.js.map +1 -1
- package/dist/tunnel/manager.js +6 -7
- package/dist/tunnel/manager.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/ui/agent-proposals.js +2 -2
- package/dist/ui/agent-runtime-settings.js +67 -0
- package/dist/ui/agent-runtime.js +20 -2
- package/dist/ui/backend-signin.js +36 -21
- package/dist/ui/general-location.js +71 -0
- package/dist/ui/native-instance-settings.js +5 -8
- package/dist/ui/native-pane-recovery.js +1 -1
- package/dist/ui/panel-chat.js +5 -2
- package/dist/ui/panel-fleet.js +4 -2
- package/dist/ui/panel-needs.js +2 -0
- package/dist/ui/panel-runtimes.js +136 -33
- package/dist/ui/panel-settings.js +227 -138
- package/dist/ui/panel-windows-cli.js +77 -41
- package/dist/ui/settings-agent-filters.js +94 -0
- package/dist/ui/settings-appearance.js +122 -23
- package/dist/ui/settings-dialogs.js +13 -7
- package/dist/ui/settings-guests.js +25 -11
- package/dist/ui/settings-layout.js +245 -0
- package/dist/ui/settings-model.js +10 -1
- package/dist/ui/settings-outside.js +176 -0
- package/dist/ui/settings-registry.js +6 -3
- package/dist/ui/settings-shortcuts.js +65 -0
- package/dist/ui/settings-strings.js +95 -0
- package/dist/ui/settings-web-authority.js +14 -11
- package/dist/ui/shared/app-i18n.js +2 -2
- package/dist/ui/shared/app-shell.js +17 -7
- package/dist/ui/shared/app.css +1038 -13
- package/dist/ui/shared/instance-nav.js +16 -5
- package/dist/ui/shared/machine-name.js +13 -0
- package/dist/ui/shared/panel-view.js +266 -17
- package/dist/ui/shared/qrcode.module.js +2237 -0
- package/dist/ui/shared/settings-width.js +31 -0
- package/dist/ui/shared/signin.js +39 -5
- package/dist/ui/shared/tokens.css +1 -0
- package/dist/ui/shared/ui-dialog.js +2 -2
- package/dist/ui/shared/ui-menu.js +3 -3
- package/dist/ui/shared/vendor/qrcode-generator.LICENSE +27 -0
- package/dist/ui/shared/vendor/vendor.json +19 -2
- package/dist/ui/shared/view-strings.js +28 -0
- package/dist/ui/windows-cli-catalog.js +41 -12
- package/dist/unattended-install.d.ts +1 -1
- package/dist/unattended-install.js +19 -5
- package/dist/unattended-install.js.map +1 -1
- package/dist/update-check.d.ts +2 -8
- package/dist/update-check.js +2 -5
- package/dist/update-check.js.map +1 -1
- package/dist/view-api.d.ts +15 -0
- package/dist/view-api.js +94 -33
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.d.ts +13 -0
- package/dist/web-api.js +80 -0
- package/dist/web-api.js.map +1 -1
- package/dist/web-login.d.ts +4 -3
- package/dist/web-login.js +23 -14
- package/dist/web-login.js.map +1 -1
- package/dist/web-only-fleet.d.ts +20 -0
- package/dist/web-only-fleet.js +21 -0
- package/dist/web-only-fleet.js.map +1 -1
- package/dist/web-remote-access.d.ts +152 -0
- package/dist/web-remote-access.js +368 -0
- package/dist/web-remote-access.js.map +1 -0
- package/dist/web-session.d.ts +12 -0
- package/dist/web-session.js +22 -8
- package/dist/web-session.js.map +1 -1
- package/dist/windows-agents.d.ts +15 -1
- package/dist/windows-agents.js +37 -10
- package/dist/windows-agents.js.map +1 -1
- package/dist/windows-cli-api.js +2 -1
- package/dist/windows-cli-api.js.map +1 -1
- package/dist/windows-cli-worker.d.ts +1 -0
- package/dist/windows-cli-worker.js +2 -2
- package/dist/windows-cli-worker.js.map +1 -1
- package/dist/windows-cli.d.ts +1 -0
- package/dist/windows-cli.js +7 -2
- package/dist/windows-cli.js.map +1 -1
- package/launcher/agend-install.cjs +16 -2
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { buildNativeFleetInstructions } from "./instructions.js";
|
|
2
|
+
import { GENERAL_ROLE_INSTRUCTIONS } from "./general-role.js";
|
|
1
3
|
import { NativePaneRecoveryError } from "./native-pane-recovery.js";
|
|
2
4
|
import { nativePaneToolAvailable, nativePaneToolRefusal } from "./native-pane-tools.js";
|
|
3
5
|
import { windowsAgentsFor } from "./windows-agents.js";
|
|
4
|
-
import {
|
|
6
|
+
import { AgentRuntimeChanges } from "./agent-runtime-change.js";
|
|
5
7
|
import { supportsVerifiedKiroMigration, getCachedKiroCliCompatibility, kiroV1DbPath, kiroV2SessionsDir } from "./backend/kiro.js";
|
|
6
8
|
import { listKiroV1Sessions, readKiroClassicLinks } from "./backend/kiro-identity.js";
|
|
7
9
|
import { readKiroV1SavedModel } from "./backend/kiro-retired-model.js";
|
|
@@ -18,6 +20,10 @@ import { settingsDisplay } from "./settings-change.js";
|
|
|
18
20
|
import { SettingsConfirmationError, SettingsConfirmationStore, settingsSourceLabel } from "./settings-confirmation.js";
|
|
19
21
|
import { SettingsHttpConfirmation } from "./settings-http-confirmation.js";
|
|
20
22
|
import { SettingsControlServer } from "./settings-control.js";
|
|
23
|
+
import { SettingsRouteLifetime } from "./settings-route-lifetime.js";
|
|
24
|
+
import { GeneralManagedFileError } from "./runtime-general-protocol.js";
|
|
25
|
+
import { GeneralBindings, generalBindingRevision, parseGeneralSwitchInput, switchGeneralLocation } from "./general-binding.js";
|
|
26
|
+
import { confirmGeneralHostExit } from "./general-host-owner.js";
|
|
21
27
|
import { settingsFingerprint, SettingsExecution, settingsRevision, noteSettingsWrite, settingsFileResource, assertSettingsLease, trySettingsLease, waitSettingsLease, settingsUndo, undoSettingsPaths } from "./settings-transaction.js";
|
|
22
28
|
import { performance } from "node:perf_hooks";
|
|
23
29
|
import { withSpawnHoldReply } from "./spawn-hold-reply.js";
|
|
@@ -33,7 +39,7 @@ import { clearPublicLinkOwner, readPublicLinkOwner, writePublicLinkOwner } from
|
|
|
33
39
|
import { PUBLIC_PREVIEW_FRAME_SRC, PUBLIC_PREVIEW_TUNNEL_DIR, PublicPreview, publicPreviewWanted } from "./public-preview.js";
|
|
34
40
|
import { CloudflaredProvider } from "./tunnel/cloudflared.js";
|
|
35
41
|
import { ensureCloudflared } from "./tunnel/cloudflared-install.js";
|
|
36
|
-
import { unattendedInstallCommand } from "./unattended-install.js";
|
|
42
|
+
import { unattendedInstallCommand, loginShell } from "./unattended-install.js";
|
|
37
43
|
import { InstallStream } from "./install-stream.js";
|
|
38
44
|
import { namedTestLane, runNamedLinkTest } from "./tunnel/named-link-test.js";
|
|
39
45
|
import { daemonTopicMode, isWebOnlyFleetConfig } from "./web-only-fleet.js";
|
|
@@ -45,10 +51,10 @@ import { withinBudget } from "./monotonic-budget.js";
|
|
|
45
51
|
import { measureSyncWork } from "./sync-work-attribution.js";
|
|
46
52
|
import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
|
|
47
53
|
import { ProfileControlServer } from "./profile-control.js";
|
|
48
|
-
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync,
|
|
54
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, realpathSync, lstatSync, closeSync } from "node:fs";
|
|
49
55
|
import { atomicWriteFileSync } from "./atomic-write.js";
|
|
50
56
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
51
|
-
import { freemem, totalmem, homedir } from "node:os";
|
|
57
|
+
import { freemem, totalmem, homedir, hostname } from "node:os";
|
|
52
58
|
import { effectiveCpuCount } from "./cpu-quota.js";
|
|
53
59
|
import { createServer } from "node:http";
|
|
54
60
|
import { Worker } from "node:worker_threads";
|
|
@@ -62,6 +68,7 @@ import { applyKiroMigration, kiroMigrationPrompt, planKiroMigration } from "./ki
|
|
|
62
68
|
import { fleetLabel } from "./fleet-label.js";
|
|
63
69
|
import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, sameUpdateMarker, setUpdateProgressStage, updateMarkerIdentity, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
|
|
64
70
|
import { formatUpdateProgress } from "./update-progress.js";
|
|
71
|
+
import { startStallReporter } from "./stall-reporter.js";
|
|
65
72
|
import { sdNotify, sdNotifyBlocking, setSdNotifyWarn, startSdWatchdog } from "./sd-notify.js";
|
|
66
73
|
import { StartupMemoryDeadline, fleetStopConcurrency } from "./startup-memory-deadline.js";
|
|
67
74
|
import { readFleetMemory } from "./process-memory.js";
|
|
@@ -80,7 +87,7 @@ import { isProbeableRouteTarget } from "./fleet-context.js";
|
|
|
80
87
|
import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, loadFleetConfigSource, loadRawFleetConfigSource, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG, autoGeneralInstanceConfig } from "./config.js";
|
|
81
88
|
import { EventLog } from "./event-log.js";
|
|
82
89
|
import { binaryProbe } from "./binary-probe.js";
|
|
83
|
-
import { AVAILABILITY_STALE_MS, BackendAvailabilityStore, hostAvailabilityProbes } from "./backend-availability.js";
|
|
90
|
+
import { AVAILABILITY_STALE_MS, BackendAvailabilityStore, hostAvailabilityProbes, findExecutableIn, resolveBackendBinary } from "./backend-availability.js";
|
|
84
91
|
import { classifySqliteOpenError } from "./sqlite-open-errors.js";
|
|
85
92
|
import { OutboxOpenError } from "./outbox-open-error.js";
|
|
86
93
|
import { AdapterWorld } from "./adapter-world.js";
|
|
@@ -286,6 +293,7 @@ import { bypassesWebGate, handleAuthRequest, serveGuestRefusedPage, serveSigninP
|
|
|
286
293
|
import { isWebPageNavigation } from "./web-shell-routes.js";
|
|
287
294
|
import { guestSessionPolicy, localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
|
|
288
295
|
import { WebNotices, WEB_NOTICES_FILE } from "./web-notices.js";
|
|
296
|
+
import { isOutsideLinkOwner, OUTSIDE_LINK_OWNER, OUTSIDE_LINK_RETRY_MS, OUTSIDE_OWN_CLOSES, outsideDuration, outsideTimer, TOTP_STEP_SECONDS, WEB_REMOTE_ACCESS_FILE, WebRemoteAccess } from "./web-remote-access.js";
|
|
289
297
|
import { authorityNow, evaluateWebAuthority, HOST_OPERATOR, VERIFIED_SESSION_HOURS, webAuthorityPolicy } from "./authority/web-authority.js";
|
|
290
298
|
import { AuthorityStateStore } from "./authority/authority-state.js";
|
|
291
299
|
import { SessionAuthorizations } from "./authority/session-authorizations.js";
|
|
@@ -645,6 +653,14 @@ function emojiListFilter(opts) {
|
|
|
645
653
|
}
|
|
646
654
|
/** #1920: a guest's scope until phase 2 derives one: nothing. */
|
|
647
655
|
const NO_GUEST_SCOPE = new Set();
|
|
656
|
+
/** #1490 row 27: a restart asked for one lifecycle of an instance after it was stopped, restarted or replaced. */
|
|
657
|
+
export class RestartSupersededError extends Error {
|
|
658
|
+
/** `shown`: the instance as the chat list names it (shownInstanceName). */
|
|
659
|
+
constructor(shown) {
|
|
660
|
+
super(t("restart.superseded", shown));
|
|
661
|
+
this.name = "RestartSupersededError";
|
|
662
|
+
}
|
|
663
|
+
}
|
|
648
664
|
export class FleetManager {
|
|
649
665
|
dataDir;
|
|
650
666
|
static signalTarget = null;
|
|
@@ -762,11 +778,18 @@ export class FleetManager {
|
|
|
762
778
|
return this.runtimeHub ??= new RuntimeCoordinator(this.dataDir, {
|
|
763
779
|
handleMcp: (machineId, runId, body) => this.runtimeWindowsExecutor().mcp(machineId, runId, body),
|
|
764
780
|
handlePaneMcp: async (machineId, body, current) => (await this.nativePanes()).handle(machineId, body, current),
|
|
781
|
+
warn: (fields, message) => this.logger.warn(fields, message),
|
|
765
782
|
changed: event => {
|
|
766
783
|
if (event?.type === "machine" && this.runtimeHub?.machine(event.machineId)?.status !== "online") {
|
|
767
784
|
for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {}))
|
|
768
|
-
if (config.machine_id === event.machineId)
|
|
785
|
+
if (config.machine_id === event.machineId) {
|
|
769
786
|
this.nativeMcpStore?.closeInstance(name);
|
|
787
|
+
if (config.general_topic) {
|
|
788
|
+
this.cancelPendingDeliveries(name);
|
|
789
|
+
this.holdGeneralOldDeliveries(name);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
this.needsYou?.poke();
|
|
770
793
|
}
|
|
771
794
|
if (this.runtimeStatusScheduled || this.shuttingDown)
|
|
772
795
|
return;
|
|
@@ -806,6 +829,8 @@ export class FleetManager {
|
|
|
806
829
|
removed: name => this.nativeInstanceRemoved(name),
|
|
807
830
|
paneCurrent: name => this.daemons.get(name)?.nativePaneCurrent?.() === true,
|
|
808
831
|
machineAvailable: id => remoteRuntimeEnabled() && this.runtimeCoordinator().machine(id)?.status === "online",
|
|
832
|
+
machineGeneration: id => this.runtimeCoordinator().machine(id)?.bootId ?? null,
|
|
833
|
+
paneAvailable: id => this.runtimeCoordinator().machine(id)?.paneProtocol === 1,
|
|
809
834
|
validateMachine: (id, cli, sandbox) => {
|
|
810
835
|
const machine = this.runtimeCoordinator().machine(id);
|
|
811
836
|
const provider = machine?.inventory?.clis.find(row => row.id === cli && row.runnable && row.supports.resume);
|
|
@@ -821,6 +846,152 @@ export class FleetManager {
|
|
|
821
846
|
},
|
|
822
847
|
}, { bridge: this.runtimeWindowsExecutor() });
|
|
823
848
|
}
|
|
849
|
+
agentRuntimeChangeService;
|
|
850
|
+
agentRuntimeChanges() {
|
|
851
|
+
if (this.agentRuntimeChangeService)
|
|
852
|
+
return this.agentRuntimeChangeService;
|
|
853
|
+
const windowPins = new Map();
|
|
854
|
+
return this.agentRuntimeChangeService ??= new AgentRuntimeChanges({
|
|
855
|
+
config: name => Object.hasOwn(this.fleetConfig?.instances ?? {}, name) ? this.fleetConfig.instances[name] : undefined,
|
|
856
|
+
defaults: () => this.fleetConfig?.defaults,
|
|
857
|
+
generation: () => [this.managerBootId, this.settingsGeneration],
|
|
858
|
+
epoch: name => this.lifecycle.epochOf(name),
|
|
859
|
+
backendState: (name, backend) => !isWindowsInstance(this.fleetConfig?.instances[name]) ? this.backendAvailabilityFor(name).find(row => row.backend === backend)?.state : undefined,
|
|
860
|
+
transition: (name, run) => this.lifecycle.runTransition(name, async (transition) => {
|
|
861
|
+
const config = this.fleetConfig?.instances[name];
|
|
862
|
+
if (!isWindowsInstance(config)) {
|
|
863
|
+
const path = join(this.getInstanceDir(name), "window-id");
|
|
864
|
+
let pin;
|
|
865
|
+
if (existsSync(path)) {
|
|
866
|
+
if (statSync(path).size > 128)
|
|
867
|
+
throw Error("Invalid saved CLI window identity");
|
|
868
|
+
pin = readFileSync(path, "utf8").trim();
|
|
869
|
+
if (!/^@\d+$/.test(pin))
|
|
870
|
+
throw Error("Invalid saved CLI window identity");
|
|
871
|
+
}
|
|
872
|
+
else if (this.daemons.has(name))
|
|
873
|
+
throw Error("The current CLI window identity is unavailable");
|
|
874
|
+
windowPins.set(name, pin);
|
|
875
|
+
}
|
|
876
|
+
try {
|
|
877
|
+
return await run(async () => {
|
|
878
|
+
if (isNativeExecInstance(this.fleetConfig?.instances[name])) {
|
|
879
|
+
this.lifecycle.invalidate(name);
|
|
880
|
+
this.nativeInstanceRuntime().setPaused(name, true);
|
|
881
|
+
}
|
|
882
|
+
else
|
|
883
|
+
await this.stopInstance(name, transition);
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
finally {
|
|
887
|
+
windowPins.delete(name);
|
|
888
|
+
}
|
|
889
|
+
}),
|
|
890
|
+
idle: name => {
|
|
891
|
+
if (this.shuttingDown || this.lifecycle.hasWorkLease(name) || this.deliveryOutbox?.countForTarget(name, ["queued", "retry_wait", "delivering", "submission_started", "reconciliation_pending", "uncertain"]))
|
|
892
|
+
return false;
|
|
893
|
+
const config = this.fleetConfig?.instances[name];
|
|
894
|
+
if (!config)
|
|
895
|
+
return false;
|
|
896
|
+
if (isWindowsInstance(config)) {
|
|
897
|
+
try {
|
|
898
|
+
const agent = this.nativeInstanceRuntime().get(name);
|
|
899
|
+
if (!agent || agent.blocked || agent.activeId && !isNativePaneInstance(config))
|
|
900
|
+
return false;
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
return false;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
if (!this.daemons.has(name) && !isWindowsInstance(config) && existsSync(join(this.getInstanceDir(name), "window-id")))
|
|
907
|
+
return false;
|
|
908
|
+
return !this.daemons.has(name) || this.getInstanceExecutionState(name) === "idle";
|
|
909
|
+
},
|
|
910
|
+
prepare: async (name, before, target, current) => {
|
|
911
|
+
if (!current())
|
|
912
|
+
throw Error("Runtime change authority expired");
|
|
913
|
+
if (isWindowsInstance(before))
|
|
914
|
+
this.nativeInstanceRuntime().importHistory(name);
|
|
915
|
+
if (target.runtime === "windows")
|
|
916
|
+
return this.nativeInstanceRuntime().prepareRuntimeChange(name, before, target, current);
|
|
917
|
+
const sameAccount = !isWindowsInstance(before) && target.backend === (before.backend || this.fleetConfig?.defaults?.backend || "claude-code");
|
|
918
|
+
const availability = (sameAccount ? this.backendAvailabilityFor(name) : this.backendAvailabilityRows()).find(row => row.backend === target.backend);
|
|
919
|
+
if (availability && ["not_installed", "not_signed_in", "too_old"].includes(availability.state))
|
|
920
|
+
throw Error("Choose an available CLI on the Fleet host");
|
|
921
|
+
const supplied = target.directory.trim(), cwd = supplied || join(this.dataDir, "workspaces", name);
|
|
922
|
+
if (!isAbsolute(cwd) || /^[A-Za-z]:[\\/]/.test(cwd) || /[\x00-\x1f]/.test(cwd))
|
|
923
|
+
throw Error("Enter an absolute Linux working directory or leave it empty");
|
|
924
|
+
if (!current())
|
|
925
|
+
throw Error("Runtime change authority expired");
|
|
926
|
+
if (supplied) {
|
|
927
|
+
if (!statSync(cwd).isDirectory())
|
|
928
|
+
throw Error("The Linux working directory is not a directory");
|
|
929
|
+
}
|
|
930
|
+
else
|
|
931
|
+
mkdirSync(cwd, { recursive: true, mode: 0o700 });
|
|
932
|
+
const config = { ...before, runtime: "wsl", working_directory: cwd, backend: target.backend, model: target.model };
|
|
933
|
+
if (before.backend !== target.backend)
|
|
934
|
+
config.model_failover = [];
|
|
935
|
+
for (const key of ["machine_id", "native_agent_id", "native_adapter", "native_sandbox"])
|
|
936
|
+
delete config[key];
|
|
937
|
+
if (!sameAccount)
|
|
938
|
+
delete config.backend_options;
|
|
939
|
+
return { config, discard: () => { } };
|
|
940
|
+
},
|
|
941
|
+
closed: async (name, before) => {
|
|
942
|
+
if (this.daemons.has(name))
|
|
943
|
+
return false;
|
|
944
|
+
if (isWindowsInstance(before))
|
|
945
|
+
return !this.nativeInstanceRuntime().get(name)?.activeId;
|
|
946
|
+
// An unreadable tmux state is not evidence that the old process exited.
|
|
947
|
+
const session = getTmuxSession();
|
|
948
|
+
if (!await TmuxManager.sessionExistsStrict(session))
|
|
949
|
+
return true;
|
|
950
|
+
const pin = windowPins.get(name);
|
|
951
|
+
return !(await TmuxManager.listWindowsStrict(session)).some(window => window.name === name || pin && window.id === pin);
|
|
952
|
+
},
|
|
953
|
+
fresh: (name, requestId) => {
|
|
954
|
+
const dir = this.getInstanceDir(name), moved = [];
|
|
955
|
+
const restore = () => { for (const [source, saved] of moved.reverse())
|
|
956
|
+
if (!existsSync(source) && existsSync(saved))
|
|
957
|
+
renameSync(saved, source); };
|
|
958
|
+
try {
|
|
959
|
+
for (const file of ["session-id", "session-id.backend"]) {
|
|
960
|
+
const source = join(dir, file), saved = join(dir, `${file}.runtime-${requestId}`);
|
|
961
|
+
if (existsSync(source)) {
|
|
962
|
+
if (existsSync(saved))
|
|
963
|
+
throw Error("Session backup already exists");
|
|
964
|
+
renameSync(source, saved);
|
|
965
|
+
moved.push([source, saved]);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return restore;
|
|
969
|
+
}
|
|
970
|
+
catch (error) {
|
|
971
|
+
restore();
|
|
972
|
+
throw error;
|
|
973
|
+
}
|
|
974
|
+
},
|
|
975
|
+
save: (name, before, next) => {
|
|
976
|
+
if (this.fleetConfig?.instances[name] !== before)
|
|
977
|
+
throw Error("Agent settings changed before saving Runtime");
|
|
978
|
+
// Mark the replacement stopped before committing its config. A marker failure must not leave a saved
|
|
979
|
+
// Windows config referring to a profile the preparation rollback would then remove.
|
|
980
|
+
writePausedMarker(this.getInstanceDir(name), Date.now(), "operator");
|
|
981
|
+
const fleet = this.fleetConfig;
|
|
982
|
+
fleet.instances[name] = next;
|
|
983
|
+
try {
|
|
984
|
+
this.saveFleetConfig();
|
|
985
|
+
}
|
|
986
|
+
catch (error) {
|
|
987
|
+
fleet.instances[name] = before;
|
|
988
|
+
throw error;
|
|
989
|
+
}
|
|
990
|
+
// A move deliberately leaves the replacement stopped; its next explicit Start opens the fresh CLI.
|
|
991
|
+
},
|
|
992
|
+
changed: name => { this.instanceStateCache.delete(name); this.instanceProcessStatus.delete(name); this.emitSseEvent("status", this.getUiStatus()); },
|
|
993
|
+
});
|
|
994
|
+
}
|
|
824
995
|
/** Explicit dashboard opt-in, with the same General confirmation and ordinary topic/Daemon routing. */
|
|
825
996
|
async createNativePaneInstance(profile, channelId, authorized, execution) {
|
|
826
997
|
const { createNativePane } = await import("./native-pane-create.js");
|
|
@@ -957,7 +1128,8 @@ export class FleetManager {
|
|
|
957
1128
|
const now = this.fleetConfig?.instances[name];
|
|
958
1129
|
if (this.shuttingDown || !isNativePaneInstance(now) || now?.native_agent_id !== config.native_agent_id
|
|
959
1130
|
|| now?.machine_id !== config.machine_id || now?.working_directory !== config.working_directory || now?.backend !== config.backend
|
|
960
|
-
|| now?.model !== config.model || now?.native_sandbox !== config.native_sandbox || now?.systemPrompt !== config.systemPrompt || now?.workflow !== config.workflow
|
|
1131
|
+
|| now?.model !== config.model || now?.native_sandbox !== config.native_sandbox || now?.systemPrompt !== config.systemPrompt || now?.workflow !== config.workflow
|
|
1132
|
+
|| now?.general_topic !== config.general_topic || now?.native_general_binding !== config.native_general_binding)
|
|
961
1133
|
throw Error("Native pane configuration or lifetime changed");
|
|
962
1134
|
if (config.machine_id ? this.remotePaneDrain || !remoteRuntimeEnabled() : this.localPaneDrain || !localWindowsCliEnabled())
|
|
963
1135
|
throw Error("Native runtime is switched off");
|
|
@@ -982,10 +1154,10 @@ export class FleetManager {
|
|
|
982
1154
|
}, subscribe: listener => hub.subscribe(listener),
|
|
983
1155
|
epoch: () => hub.paneTransportEpoch(machineId), version: provider => hub.machine(machineId)?.inventory?.clis.find(row => row.id === provider && row.runnable)?.version ?? null };
|
|
984
1156
|
},
|
|
985
|
-
instructions: (name, config) =>
|
|
1157
|
+
instructions: (name, config) => buildNativeFleetInstructions({ instanceName: name, workingDirectory: config.working_directory,
|
|
986
1158
|
displayName: config.display_name, description: config.description, customPrompt: config.systemPrompt,
|
|
987
1159
|
workflow: config.workflow, taskBoard: !dashboardRefusalIn(this.fleetConfig, name, "task"),
|
|
988
|
-
runtimeIdentity: { kind: "fleet-topic", backend: config.backend ?? "codex", model: config.model || "default" } }),
|
|
1160
|
+
runtimeIdentity: { kind: "fleet-topic", backend: config.backend ?? "codex", model: config.model || "default" } }, config.general_topic === true),
|
|
989
1161
|
menu: name => TOOLS.filter(tool => allowed(name, tool.name)).map(tool => ({ ...tool })),
|
|
990
1162
|
allowed: (name, tool, args) => allowed(name, tool) && !nativePaneToolRefusal(tool, args), published: (name, daemon) => this.daemons.get(name) === daemon,
|
|
991
1163
|
invoke: async (daemon, input, current, name) => {
|
|
@@ -1010,8 +1182,308 @@ export class FleetManager {
|
|
|
1010
1182
|
},
|
|
1011
1183
|
});
|
|
1012
1184
|
}
|
|
1185
|
+
/** Prepare only bundled role files; this neither switches General nor starts a provider process. */
|
|
1186
|
+
async prepareWindowsGeneralWorkspace(name, machineId, cli, bindingId, authorized) {
|
|
1187
|
+
const config = this.fleetConfig?.instances[name], hub = this.runtimeCoordinator(), epoch = hub.paneTransportEpoch(machineId);
|
|
1188
|
+
if (!config?.general_topic || !epoch)
|
|
1189
|
+
throw Error("General or its paired runtime is unavailable");
|
|
1190
|
+
const current = () => authorized() && !this.shuttingDown && remoteRuntimeEnabled() && this.runtimeHub === hub
|
|
1191
|
+
&& this.fleetConfig?.instances[name] === config && hub.paneTransportEpoch(machineId) === epoch;
|
|
1192
|
+
const [{ loadGeneralKnowledge }, { validateGeneralWorkspaceRequest, validateGeneralWorkspaceReceipt, generalWorkspaceUpgrade }, { assertAuditedNativePaneVersion }] = await Promise.all([
|
|
1193
|
+
import("./runtime-general-workspace.js"), import("./runtime-general-protocol.js"), import("./runtime-pane-native.js"),
|
|
1194
|
+
]);
|
|
1195
|
+
if (!current())
|
|
1196
|
+
throw Error("General workspace authority expired");
|
|
1197
|
+
assertAuditedNativePaneVersion(cli, hub.machine(machineId)?.inventory?.clis.find(row => row.id === cli && row.runnable)?.version ?? null);
|
|
1198
|
+
const request = validateGeneralWorkspaceRequest({ action: "general-workspace", bindingId, instance: name, cli, packHash: loadGeneralKnowledge(cli).hash });
|
|
1199
|
+
const inventory = await hub.callPane(machineId, { action: "list" }, maxAgentsPerMachine());
|
|
1200
|
+
if (!current())
|
|
1201
|
+
throw Error("General workspace authority expired");
|
|
1202
|
+
if (inventory?.capabilities?.generalWorkspace !== true)
|
|
1203
|
+
throw new NativePaneRecoveryError({ kind: "host-upgrade" });
|
|
1204
|
+
const result = await hub.callPane(machineId, request, maxAgentsPerMachine());
|
|
1205
|
+
if (!current())
|
|
1206
|
+
throw Error("General workspace authority expired");
|
|
1207
|
+
if (generalWorkspaceUpgrade(result, request))
|
|
1208
|
+
throw new NativePaneRecoveryError({ kind: "host-upgrade" });
|
|
1209
|
+
return validateGeneralWorkspaceReceipt(result, request);
|
|
1210
|
+
}
|
|
1211
|
+
generalLocationFlights = new Map();
|
|
1212
|
+
generalAttentionEpisodes = new Map();
|
|
1213
|
+
generalBindings() { return new GeneralBindings(this.dataDir); }
|
|
1214
|
+
generalLocations() {
|
|
1215
|
+
const store = this.generalBindings();
|
|
1216
|
+
return { generals: Object.entries(this.fleetConfig?.instances ?? {}).filter(([, cfg]) => cfg.general_topic).map(([name, cfg]) => {
|
|
1217
|
+
const record = store.read(name), machine = cfg.machine_id ? this.runtimeHub?.machine(cfg.machine_id) : null;
|
|
1218
|
+
return { name, displayName: cfg.display_name ?? name, location: isWindowsInstance(cfg) ? "windows" : "host",
|
|
1219
|
+
machineId: cfg.machine_id ?? null, cli: cfg.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code", model: cfg.model ?? "",
|
|
1220
|
+
revision: generalBindingRevision(record, cfg), canSwitchBack: !!record && record.active !== "host",
|
|
1221
|
+
offline: isWindowsInstance(cfg) && (!remoteRuntimeEnabled() || machine?.status !== "online" || !this.runtimeHub?.paneTransportEpoch(cfg.machine_id)),
|
|
1222
|
+
retryTarget: record?.pending?.phase === "selected" ? (record.pending.to === "host" ? { location: "host" } : (() => { const row = record.windows.find(item => item.id === record.pending.to); return { location: "windows", machineId: row.machineId, cli: row.cli, model: row.config.model ?? "" }; })()) : null,
|
|
1223
|
+
hold: record?.hold ?? (record?.pending ? record.pending.phase === "selected" ? "start" : "exit" : null), switching: this.generalLocationFlights.has(name) };
|
|
1224
|
+
}), machines: (this.runtimeHub?.listMachines() ?? []).filter(row => row.status !== "pending" && row.status !== "revoked").map(row => ({
|
|
1225
|
+
id: row.id, name: row.name, online: row.status === "online" && !!this.runtimeHub?.paneTransportEpoch(row.id),
|
|
1226
|
+
clis: row.inventory?.clis.filter(cli => cli.runnable && (cli.id === "claude-code" || cli.id === "codex")).map(cli => ({ id: cli.id, version: cli.version })) ?? [],
|
|
1227
|
+
})) };
|
|
1228
|
+
}
|
|
1229
|
+
/** Pure preflight for the confirmation diff and stale-tab refusal; never prepares files or starts a CLI. */
|
|
1230
|
+
previewGeneralLocation(raw) {
|
|
1231
|
+
const input = parseGeneralSwitchInput(raw), cfg = this.fleetConfig?.instances[input.name];
|
|
1232
|
+
if (!cfg?.general_topic || this.shuttingDown)
|
|
1233
|
+
throw Error("General is unavailable");
|
|
1234
|
+
const record = this.generalBindings().read(input.name);
|
|
1235
|
+
if (input.revision !== generalBindingRevision(record, cfg))
|
|
1236
|
+
throw Error("General location changed; read its settings again");
|
|
1237
|
+
const connections = cfg.channel_id ? [cfg.channel_id] : [];
|
|
1238
|
+
const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
1239
|
+
if (channels.length && (connections.length !== 1 || channels.filter(row => (row.id ?? row.type) === connections[0]).length !== 1))
|
|
1240
|
+
throw Error("General's connection cannot be resolved");
|
|
1241
|
+
if (input.target.location === "host") {
|
|
1242
|
+
if (!record)
|
|
1243
|
+
throw Error("General's saved host location is unavailable");
|
|
1244
|
+
return { input, connections, summary: [t("general_location.confirm_host"), t("general_location.history")] };
|
|
1245
|
+
}
|
|
1246
|
+
const active = record?.windows.find(row => row.id === record.active);
|
|
1247
|
+
if (!record?.pending && record?.hold !== "start" && active?.machineId === input.target.machineId && active.cli === input.target.cli)
|
|
1248
|
+
throw Error("General already runs at this location");
|
|
1249
|
+
const machine = this.runtimeHub?.machine(input.target.machineId);
|
|
1250
|
+
if (!remoteRuntimeEnabled() || machine?.status !== "online" || !this.runtimeHub?.paneTransportEpoch(machine.id))
|
|
1251
|
+
throw Error(t("general_location.offline"));
|
|
1252
|
+
const provider = input.target.cli;
|
|
1253
|
+
if (!machine.inventory?.clis.some(cli => cli.id === provider && cli.runnable))
|
|
1254
|
+
throw Error("The chosen Windows CLI is unavailable");
|
|
1255
|
+
return { input, connections, summary: [t("general_location.confirm_windows", settingsDisplay(machine.name)), t("general_location.history")] };
|
|
1256
|
+
}
|
|
1257
|
+
/** All entry points, including automatic native recovery, must respect an interrupted binding switch. */
|
|
1258
|
+
assertGeneralBindingStart(name, config) {
|
|
1259
|
+
if (!config.general_topic)
|
|
1260
|
+
return;
|
|
1261
|
+
try {
|
|
1262
|
+
this.generalBindings().assertStart(name, config);
|
|
1263
|
+
}
|
|
1264
|
+
catch (err) {
|
|
1265
|
+
this.logger.warn({ name, err }, "General location start held by its saved binding");
|
|
1266
|
+
this.needsYou?.poke();
|
|
1267
|
+
throw new UnsupportedCliError(t("general_location.exit_unconfirmed"));
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
/** Only the Settings replay's confirmed admin can enter; the whole switch owns one lifecycle transition. */
|
|
1271
|
+
async switchGeneralLocation(raw, authorized, execution) {
|
|
1272
|
+
const { input } = this.previewGeneralLocation(raw), name = input.name;
|
|
1273
|
+
return this.lifecycle.runTransition(name, async (transition) => {
|
|
1274
|
+
execution.assert();
|
|
1275
|
+
if (!authorized() || this.generalLocationFlights.has(name))
|
|
1276
|
+
throw Error("General location authority expired or a switch is already running");
|
|
1277
|
+
const token = Symbol(name), generation = this.settingsGeneration;
|
|
1278
|
+
const destinationEpoch = input.target.location === "windows" ? this.runtimeHub?.paneTransportEpoch(input.target.machineId) : null;
|
|
1279
|
+
let expected = this.fleetConfig.instances[name], stopGeneration = this.explicitStopGeneration.get(name) ?? 0, epoch = this.lifecycle.epochOf(name);
|
|
1280
|
+
const hostSnapshot = !isWindowsInstance(expected) ? structuredClone(this.daemons.get(name)?.getConfigSnapshot() ?? expected) : undefined;
|
|
1281
|
+
const current = () => !this.shuttingDown && this.settingsGeneration === generation && authorized()
|
|
1282
|
+
&& this.generalLocationFlights.get(name) === token && this.fleetConfig?.instances[name] === expected
|
|
1283
|
+
&& (input.target.location !== "windows" || remoteRuntimeEnabled() && !!destinationEpoch && this.runtimeHub?.paneTransportEpoch(input.target.machineId) === destinationEpoch)
|
|
1284
|
+
&& (this.explicitStopGeneration.get(name) ?? 0) === stopGeneration && this.lifecycle.epochOf(name) === epoch;
|
|
1285
|
+
const check = () => { if (!current())
|
|
1286
|
+
throw Error("General location switch was superseded"); };
|
|
1287
|
+
this.generalLocationFlights.set(name, token);
|
|
1288
|
+
// Old ephemeral waiters carry this epoch; they cannot wake into the selected conversation.
|
|
1289
|
+
this.cancelPendingDeliveries(name);
|
|
1290
|
+
let lease = null;
|
|
1291
|
+
try {
|
|
1292
|
+
lease = await waitSettingsLease([settingsFileResource(this.getSettingsConfigPath()), `general:${this.dataDir}:${name}`], () => current() && execution.current(), performance.now() + execution.remainingMs, execution.owner);
|
|
1293
|
+
const store = this.generalBindings();
|
|
1294
|
+
const result = await switchGeneralLocation({ store, current: () => expected, assert: check,
|
|
1295
|
+
prepare: async (target, retained) => {
|
|
1296
|
+
if (target.location !== "windows")
|
|
1297
|
+
return null;
|
|
1298
|
+
const old = retained.windows.find(row => row.machineId === target.machineId && row.cli === target.cli);
|
|
1299
|
+
const id = old?.id ?? randomUUID(), agentId = old?.agentId ?? randomUUID();
|
|
1300
|
+
const publication = await this.prepareWindowsGeneralWorkspace(name, target.machineId, target.cli, id, () => current() && execution.current());
|
|
1301
|
+
check();
|
|
1302
|
+
execution.assert();
|
|
1303
|
+
const config = { ...structuredClone(expected), runtime: "windows", machine_id: target.machineId,
|
|
1304
|
+
native_adapter: "pane", native_agent_id: agentId, native_general_binding: id, native_sandbox: "workspace-write",
|
|
1305
|
+
backend: target.cli, model: target.model, working_directory: publication.cwd, systemPrompt: "", agent_mode: "cli" };
|
|
1306
|
+
// Host credentials, shell hooks and provider-specific options never become Windows launch configuration.
|
|
1307
|
+
for (const key of ["backend_options", "credential_profile", "pre_task_command"])
|
|
1308
|
+
delete config[key];
|
|
1309
|
+
windowsAgentsFor(this, this.runtimeWindowsExecutor()).ensureGeneralBinding({ id: agentId, instance: name, machineId: target.machineId,
|
|
1310
|
+
cli: target.cli, cwd: publication.cwd, name: (expected.display_name ?? name).slice(0, 64), model: target.model, instructions: "", description: expected.description ?? "" }, () => current() && execution.current());
|
|
1311
|
+
return { id, agentId, machineId: target.machineId, cli: target.cli, config };
|
|
1312
|
+
},
|
|
1313
|
+
idle: async () => {
|
|
1314
|
+
check();
|
|
1315
|
+
const daemon = this.daemons.get(name);
|
|
1316
|
+
return !daemon || daemon.getInstanceStateSnapshot().state === "idle" && daemon.getWorkingRecoveryState().idle
|
|
1317
|
+
&& daemon.getInteractionSnapshot().phase === "clear" && !this.lifecycle.hasWorkLease(name)
|
|
1318
|
+
&& Date.now() - (this.lastDeliveryAt.get(name) ?? 0) >= 2000;
|
|
1319
|
+
},
|
|
1320
|
+
hostSnapshot: () => hostSnapshot,
|
|
1321
|
+
capture: async () => {
|
|
1322
|
+
if (isWindowsInstance(expected))
|
|
1323
|
+
return undefined;
|
|
1324
|
+
const daemon = this.daemons.get(name);
|
|
1325
|
+
if (!daemon)
|
|
1326
|
+
throw Error(t("general_location.exit_unconfirmed"));
|
|
1327
|
+
const owner = await daemon.generalHostOwner();
|
|
1328
|
+
check();
|
|
1329
|
+
execution.assert();
|
|
1330
|
+
return owner;
|
|
1331
|
+
},
|
|
1332
|
+
stop: async (owner) => {
|
|
1333
|
+
check();
|
|
1334
|
+
const stopping = this.stopInstance(name, transition);
|
|
1335
|
+
stopGeneration++;
|
|
1336
|
+
epoch++;
|
|
1337
|
+
await stopping;
|
|
1338
|
+
check();
|
|
1339
|
+
if (isWindowsInstance(expected)) {
|
|
1340
|
+
const profile = windowsAgentsFor(this, this.runtimeWindowsExecutor()).get(expected.native_agent_id);
|
|
1341
|
+
if (profile.activeId || profile.paneLaunchId)
|
|
1342
|
+
throw Error(t("general_location.exit_unconfirmed"));
|
|
1343
|
+
execution.mutate(() => this.holdGeneralOldDeliveries(name));
|
|
1344
|
+
return undefined;
|
|
1345
|
+
}
|
|
1346
|
+
if (!owner || !await confirmGeneralHostExit(owner, getTmuxSession()))
|
|
1347
|
+
throw Error(t("general_location.exit_unconfirmed"));
|
|
1348
|
+
check();
|
|
1349
|
+
execution.mutate(() => this.holdGeneralOldDeliveries(name));
|
|
1350
|
+
return owner.pin;
|
|
1351
|
+
},
|
|
1352
|
+
save: next => {
|
|
1353
|
+
check();
|
|
1354
|
+
const previous = expected;
|
|
1355
|
+
try {
|
|
1356
|
+
this.fleetConfig.instances[name] = next;
|
|
1357
|
+
this.saveFleetConfig([{ path: ["instances", name], value: next }]);
|
|
1358
|
+
expected = next;
|
|
1359
|
+
this.routing.rebuild(this.fleetConfig);
|
|
1360
|
+
}
|
|
1361
|
+
catch (err) {
|
|
1362
|
+
this.fleetConfig.instances[name] = previous;
|
|
1363
|
+
throw err;
|
|
1364
|
+
}
|
|
1365
|
+
},
|
|
1366
|
+
start: async () => {
|
|
1367
|
+
check();
|
|
1368
|
+
if (!execution.completed)
|
|
1369
|
+
throw Error("General's selected binding has no committed approval");
|
|
1370
|
+
// The committed receipt permits only this selected startup. Live authority still fences every awaited spawn boundary.
|
|
1371
|
+
const startup = new SettingsExecution({ current: () => current() && execution.completed && execution.remainingMs > 0,
|
|
1372
|
+
snapshot: () => this.fleetConfig });
|
|
1373
|
+
try {
|
|
1374
|
+
await this.startInstance(name, expected, true, "fleet-topic", false, transition, startup);
|
|
1375
|
+
}
|
|
1376
|
+
finally {
|
|
1377
|
+
startup.close();
|
|
1378
|
+
}
|
|
1379
|
+
check();
|
|
1380
|
+
const daemon = this.daemons.get(name);
|
|
1381
|
+
if (!daemon || daemon.getProcessStatus() !== "running" || isNativePaneInstance(expected) && !daemon.nativePaneCurrent())
|
|
1382
|
+
throw Error(t("general_location.start_failed"));
|
|
1383
|
+
await this.connectIpcToInstance(name);
|
|
1384
|
+
check();
|
|
1385
|
+
},
|
|
1386
|
+
notify: (kind, err) => {
|
|
1387
|
+
const message = t(kind === "exit" ? "general_location.exit_unconfirmed" : kind === "start" ? "general_location.start_failed" : "general_location.complete", isWindowsInstance(expected) ? this.runtimeHub?.machine(expected.machine_id)?.name ?? t("general_location.windows") : t("general_location.host"));
|
|
1388
|
+
this.eventLog?.insert(name, "general_location", { phase: kind, message });
|
|
1389
|
+
if (err)
|
|
1390
|
+
this.logger.warn({ name, phase: kind, err }, message);
|
|
1391
|
+
else
|
|
1392
|
+
this.logger.info({ name, phase: kind }, message);
|
|
1393
|
+
this.needsYou?.poke();
|
|
1394
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
1395
|
+
this.notifyInstanceTopic(name, message);
|
|
1396
|
+
},
|
|
1397
|
+
}, input, execution);
|
|
1398
|
+
return { ...result, message: result.started ? t("general_location.complete", isWindowsInstance(expected)
|
|
1399
|
+
? this.runtimeHub?.machine(expected.machine_id)?.name ?? t("general_location.windows") : t("general_location.host")) : t("general_location.start_failed") };
|
|
1400
|
+
}
|
|
1401
|
+
finally {
|
|
1402
|
+
lease?.release();
|
|
1403
|
+
if (this.generalLocationFlights.get(name) === token)
|
|
1404
|
+
this.generalLocationFlights.delete(name);
|
|
1405
|
+
this.needsYou?.poke();
|
|
1406
|
+
}
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
/** An offline PC or a confirmed binding boundary holds old waiting input; it never becomes another conversation's queue. */
|
|
1410
|
+
holdGeneralOldDeliveries(name) {
|
|
1411
|
+
const outbox = this.deliveryOutbox;
|
|
1412
|
+
if (!outbox)
|
|
1413
|
+
return;
|
|
1414
|
+
for (const row of outbox.listPending().filter(row => row.targetInstance === name)) {
|
|
1415
|
+
if (row.state !== "submission_started")
|
|
1416
|
+
outbox.failBeforeBegin(row.deliveryId, t("general_location.input_not_replayed"));
|
|
1417
|
+
else if (row.targetDaemonBootId) {
|
|
1418
|
+
outbox.markTargetReconciliationPending(name, row.targetDaemonBootId);
|
|
1419
|
+
if (!outbox.reconcileAttempt(row.deliveryId, row.targetDaemonBootId, row.attemptNo, "uncertain", t("general_location.input_not_replayed")))
|
|
1420
|
+
throw Error("General's old delivery could not be held");
|
|
1421
|
+
}
|
|
1422
|
+
else
|
|
1423
|
+
throw Error("General's old delivery has no confirmed owner");
|
|
1424
|
+
}
|
|
1425
|
+
this.needsYou?.poke();
|
|
1426
|
+
}
|
|
1427
|
+
generalLocationDeliveryHold(name) {
|
|
1428
|
+
const config = this.fleetConfig?.instances[name];
|
|
1429
|
+
if (!config?.general_topic)
|
|
1430
|
+
return null;
|
|
1431
|
+
if (this.generalLocationFlights?.has(name))
|
|
1432
|
+
return t("general_location.exit_unconfirmed");
|
|
1433
|
+
let record;
|
|
1434
|
+
try {
|
|
1435
|
+
record = this.generalBindings().read(name);
|
|
1436
|
+
}
|
|
1437
|
+
catch {
|
|
1438
|
+
return t("general_location.start_failed");
|
|
1439
|
+
}
|
|
1440
|
+
if (record?.pending || record?.hold)
|
|
1441
|
+
return t(record.pending?.phase === "selected" || record.hold === "start" ? "general_location.start_failed" : "general_location.exit_unconfirmed");
|
|
1442
|
+
if (isWindowsInstance(config) && config.machine_id && (!remoteRuntimeEnabled() || this.runtimeHub?.machine(config.machine_id)?.status !== "online" || !this.runtimeHub?.paneTransportEpoch(config.machine_id)))
|
|
1443
|
+
return t("general_location.offline");
|
|
1444
|
+
return null;
|
|
1445
|
+
}
|
|
1446
|
+
generalLocationAttention(name) {
|
|
1447
|
+
const cfg = this.fleetConfig?.instances[name];
|
|
1448
|
+
if (!cfg?.general_topic)
|
|
1449
|
+
return null;
|
|
1450
|
+
const detail = this.generalLocationDeliveryHold(name);
|
|
1451
|
+
if (!detail) {
|
|
1452
|
+
this.generalAttentionEpisodes.delete(name);
|
|
1453
|
+
return null;
|
|
1454
|
+
}
|
|
1455
|
+
let record = null;
|
|
1456
|
+
try {
|
|
1457
|
+
record = this.generalBindings().read(name);
|
|
1458
|
+
}
|
|
1459
|
+
catch { /* a corrupt journal is a visible hold too */ }
|
|
1460
|
+
const offline = !record?.pending && !record?.hold && isWindowsInstance(cfg) && detail === t("general_location.offline");
|
|
1461
|
+
const key = record?.pending?.id ?? `${record?.revision ?? "unknown"}:${offline ? "offline" : "hold"}`;
|
|
1462
|
+
let episode = this.generalAttentionEpisodes.get(name);
|
|
1463
|
+
if (!episode || episode.key !== key) {
|
|
1464
|
+
episode = { key, since: record?.pending?.at ?? Date.now() };
|
|
1465
|
+
this.generalAttentionEpisodes.set(name, episode);
|
|
1466
|
+
}
|
|
1467
|
+
return { ...episode, detail, offline };
|
|
1468
|
+
}
|
|
1013
1469
|
async nativePaneFor(name, config, assertCurrent) {
|
|
1014
|
-
|
|
1470
|
+
try {
|
|
1471
|
+
return await (await this.nativePanes()).prepare(name, config, assertCurrent);
|
|
1472
|
+
}
|
|
1473
|
+
catch (err) {
|
|
1474
|
+
if (config.general_topic && err instanceof GeneralManagedFileError) {
|
|
1475
|
+
assertCurrent();
|
|
1476
|
+
const store = this.generalBindings(), record = store.read(name);
|
|
1477
|
+
if (record && record.active === config.native_general_binding) {
|
|
1478
|
+
record.hold = "start";
|
|
1479
|
+
store.write(record);
|
|
1480
|
+
}
|
|
1481
|
+
this.logger.warn({ name, managedFile: err.file }, t("general_location.start_failed"));
|
|
1482
|
+
this.needsYou?.poke();
|
|
1483
|
+
throw new UnsupportedCliError(t("general_location.start_failed"));
|
|
1484
|
+
}
|
|
1485
|
+
throw err;
|
|
1486
|
+
}
|
|
1015
1487
|
}
|
|
1016
1488
|
async recoverNativePane(name, launchId, authorized) {
|
|
1017
1489
|
const config = this.fleetConfig?.instances[name];
|
|
@@ -1556,6 +2028,7 @@ export class FleetManager {
|
|
|
1556
2028
|
watchdogTimer = null;
|
|
1557
2029
|
startupMemoryDeadline = null;
|
|
1558
2030
|
eventLoopWatch = null;
|
|
2031
|
+
stallReporter = null;
|
|
1559
2032
|
startedAt = 0;
|
|
1560
2033
|
stormOpenNotifyTimer = null;
|
|
1561
2034
|
memoryLogAt = null;
|
|
@@ -1599,6 +2072,37 @@ export class FleetManager {
|
|
|
1599
2072
|
webChatRemovals = new Map();
|
|
1600
2073
|
/** #1774: a removal under way — its replies' fate waits for its outcome (success: theirs no more; failure: theirs). */
|
|
1601
2074
|
webChatRemovalsPending = new Map();
|
|
2075
|
+
/**
|
|
2076
|
+
* #1490 row 27: "still the same lifecycle of `name`" — its lifecycle epoch, moved on synchronously by every stop,
|
|
2077
|
+
* restart and removal (the map is never pruned, so a same-name replacement has a later one). Taken when a button
|
|
2078
|
+
* or menu is claimed or offered, asked after every await before acting by name.
|
|
2079
|
+
*/
|
|
2080
|
+
/** #1490 row 27: an instance as the chat list names it — its display name where it has one, else its name. */
|
|
2081
|
+
shownInstanceName(name) {
|
|
2082
|
+
try {
|
|
2083
|
+
const classic = this.classicChannels?.getAll().find(ch => ch.instanceName === name) ?? null;
|
|
2084
|
+
return resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic, profile: profileIdentities(this.dataDir).get(name) }).display_name || name;
|
|
2085
|
+
}
|
|
2086
|
+
catch {
|
|
2087
|
+
return name;
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
lifecyclePin(name) {
|
|
2091
|
+
// Fail closed: a lifecycle that cannot say its epoch holds nothing (and never stops a prompt from being posted).
|
|
2092
|
+
let epoch;
|
|
2093
|
+
try {
|
|
2094
|
+
epoch = this.lifecycle?.epochOf(name);
|
|
2095
|
+
}
|
|
2096
|
+
catch {
|
|
2097
|
+
return () => false;
|
|
2098
|
+
}
|
|
2099
|
+
return () => { try {
|
|
2100
|
+
return !this.shuttingDown && this.lifecycle?.epochOf(name) === epoch;
|
|
2101
|
+
}
|
|
2102
|
+
catch {
|
|
2103
|
+
return false;
|
|
2104
|
+
} };
|
|
2105
|
+
}
|
|
1602
2106
|
webChatGeneration(name) { return this.webChatGenerations.get(name) ?? 0; }
|
|
1603
2107
|
/** #1774: who an instance's reply is from, taken when it is sent: the name's generation and its daemon then. */
|
|
1604
2108
|
webChatIdentity(name) { return { generation: this.webChatGeneration(name), daemon: this.daemons.get(name) }; }
|
|
@@ -2085,6 +2589,7 @@ export class FleetManager {
|
|
|
2085
2589
|
const current = () => !this.shuttingDown && this.settingsGeneration === generation;
|
|
2086
2590
|
const baselines = this.settingsBaselineReader = new SettingsBaselines({ dataDir: this.dataDir, configPath: () => this.configPath,
|
|
2087
2591
|
config: () => this.fleetConfig, current: () => [this.settingsGeneration, this.settingsRuntimeRevision, this.shuttingDown],
|
|
2592
|
+
generalLocation: input => this.previewGeneralLocation(input),
|
|
2088
2593
|
proof: (kind, target, id, binding, key) => {
|
|
2089
2594
|
const map = kind === "provider" ? this.providerSecretChallenges : kind === "binding" ? this.connectionBindingChallenges : this.connectionSecretChallenges;
|
|
2090
2595
|
const proof = map.get(id);
|
|
@@ -2129,6 +2634,7 @@ export class FleetManager {
|
|
|
2129
2634
|
&& sessions.isCurrent(record, tokenEpoch(this.webToken)) && (!exposure || exposure.isCurrent()) };
|
|
2130
2635
|
}, baseline: () => baselines.read(), snapshot: () => baselines.snapshot(),
|
|
2131
2636
|
selfConfirm: (req, id) => this.settingsSelfConfirmation(store, req, id),
|
|
2637
|
+
directAgentCreation: true,
|
|
2132
2638
|
job: async (id) => {
|
|
2133
2639
|
const settlement = this.settingsJobSettlements.get(id);
|
|
2134
2640
|
if (!settlement)
|
|
@@ -2163,8 +2669,10 @@ export class FleetManager {
|
|
|
2163
2669
|
const auth = authorizeSession(req, this.webToken, this.webSessions, { touch: false });
|
|
2164
2670
|
if (auth.kind !== "ok")
|
|
2165
2671
|
return null;
|
|
2166
|
-
// #2453 (§0.2): never over the public link — not even a named-link session that is web-verified there.
|
|
2167
|
-
|
|
2672
|
+
// #2453 (§0.2): never over the public link — not even a named-link session that is web-verified there. #3008: except
|
|
2673
|
+
// this computer's admin signed in from outside (the authenticator's code, over the link connections from outside
|
|
2674
|
+
// opened): the web-only fleet's owner, judged below by the same authority as here (hostOperator).
|
|
2675
|
+
if (gatewayRequestContext(req) && auth.session.remote !== "totp")
|
|
2168
2676
|
return null;
|
|
2169
2677
|
// …and never a change that turns the named-link option on: that one needs a General's card or the host.
|
|
2170
2678
|
if (!store.selfConfirmable(id))
|
|
@@ -2197,13 +2705,16 @@ export class FleetManager {
|
|
|
2197
2705
|
if (own.length !== 1 || own[0].type !== chat.platform || !chat.admin(actor.user_id))
|
|
2198
2706
|
return null;
|
|
2199
2707
|
const affected = [...store.affectedConnections(id)]; // read now: a finished change no longer has them
|
|
2200
|
-
|
|
2708
|
+
const destinations = this.settingsNoticeGenerals(affected, chat.platform);
|
|
2709
|
+
if (!destinations.length)
|
|
2201
2710
|
return null;
|
|
2202
2711
|
const name = this.webPerson({ adapterId: actor.adapter_id, userId: actor.user_id }).name;
|
|
2203
2712
|
return {
|
|
2204
2713
|
label: t("settings_confirm.web_verified_label", name),
|
|
2205
2714
|
// admin() asks chat.current() too; a General a card could go to must still be there (else the host CLI's).
|
|
2206
|
-
current: () => !!verified() && chat.admin(actor.user_id) &&
|
|
2715
|
+
current: () => !!verified() && chat.admin(actor.user_id) && destinations.some(route => route.current()),
|
|
2716
|
+
followStop: async (name, begin) => { const route = destinations.find(item => item.current()); if (!route)
|
|
2717
|
+
throw Error("Settings route expired"); await route.followStop(name, begin); },
|
|
2207
2718
|
done: view => this.settingsWebConfirmedNotice(view, affected, name, chat.platform),
|
|
2208
2719
|
};
|
|
2209
2720
|
}
|
|
@@ -2223,27 +2734,10 @@ export class FleetManager {
|
|
|
2223
2734
|
*/
|
|
2224
2735
|
agentProposalsRev = 0;
|
|
2225
2736
|
agentProposalOwner(req) {
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
return null;
|
|
2231
|
-
// Each check twice over: here, and in the authority itself (it refuses the public link and a chat fleet's session).
|
|
2232
|
-
// #2997 review (Prism P2): asked of the LIVE session each time — the request's own cookie authenticated again, the
|
|
2233
|
-
// same handle — never a record captured at the click: a sign-out, an expiry or a revocation while the request is
|
|
2234
|
-
// being applied fences it.
|
|
2235
|
-
const handle = auth.session.handle, peer = webPeerOf(req), gateway = !!gatewayRequestContext(req);
|
|
2236
|
-
const owner = () => {
|
|
2237
|
-
if (!this.isWebOnlyFleet())
|
|
2238
|
-
return false;
|
|
2239
|
-
const live = authorizeSession(req, this.webToken, this.webSessions, { touch: false });
|
|
2240
|
-
if (live.kind !== "ok" || live.session.handle !== handle)
|
|
2241
|
-
return false;
|
|
2242
|
-
const now = this.webAuthority({ session: live.session, gateway, peer });
|
|
2243
|
-
const a = now.authority.kind === "web-verified" && now.authority.session?.handle === handle ? now.authority.actor : null;
|
|
2244
|
-
return !!a && a.adapter_id === HOST_OPERATOR.adapterId && a.user_id === HOST_OPERATOR.userId;
|
|
2245
|
-
};
|
|
2246
|
-
return owner() ? { label: t("settings_confirm.host_operator_label"), current: owner } : null;
|
|
2737
|
+
// #3008: this computer's admin here, or signed in from outside (over the link connections from outside opened) —
|
|
2738
|
+
// asked of the LIVE session each time (#2997 review): a sign-out, an expiry, a revocation or the switch going off
|
|
2739
|
+
// while the request is applied fences it.
|
|
2740
|
+
return this.liveOwner(req, { hostOnly: false });
|
|
2247
2741
|
}
|
|
2248
2742
|
/** alpha.2: the agents' requests waiting for this web-only fleet's owner, or null when the reader is not that owner. */
|
|
2249
2743
|
webAgentProposals(req) {
|
|
@@ -2277,20 +2771,31 @@ export class FleetManager {
|
|
|
2277
2771
|
return { status, body: { error: code, message: t(code === "admin_required" ? "settings_confirm.web_owner_required" : "settings_confirm.no_longer_pending"), view: store.inspect(id) } };
|
|
2278
2772
|
}
|
|
2279
2773
|
}
|
|
2280
|
-
/**
|
|
2774
|
+
/** A confirmation belongs to the server route/lifetime, not the availability of General's model. */
|
|
2775
|
+
settingsGeneralRoute(name, platform, excluded) {
|
|
2776
|
+
const config = this.fleetConfig?.instances[name];
|
|
2777
|
+
if (!config?.general_topic || this.shuttingDown || this.ipcStoppingInstances.has(name))
|
|
2778
|
+
return null;
|
|
2779
|
+
const adapterId = this.getInstanceAdapterId(name), adapter = this.getAdapterForInstance(name);
|
|
2780
|
+
if (!adapterId || !adapter || excluded.has(adapterId) || adapter.type !== platform)
|
|
2781
|
+
return null;
|
|
2782
|
+
const chatId = this.getGroupIdForInstance(name), topic = String(config.topic_id ?? "");
|
|
2783
|
+
if (!chatId || !topic)
|
|
2784
|
+
return null;
|
|
2785
|
+
const route = () => !this.shuttingDown && this.fleetConfig?.instances[name]?.general_topic === true
|
|
2786
|
+
&& this.adapterState.get(adapterId)?.status === "connected"
|
|
2787
|
+
&& this.getInstanceAdapterId(name) === adapterId && this.getAdapterForInstance(name) === adapter
|
|
2788
|
+
&& this.getGroupIdForInstance(name) === chatId && String(this.fleetConfig?.instances[name]?.topic_id ?? "") === topic;
|
|
2789
|
+
const lifetime = new SettingsRouteLifetime(name, route, () => this.lifecycle.epochOf(name), () => this.ipcStoppingInstances.has(name));
|
|
2790
|
+
return lifetime.current() ? { adapterId, adapter, chatId, topic, current: lifetime.current, followStop: lifetime.followStop.bind(lifetime) } : null;
|
|
2791
|
+
}
|
|
2792
|
+
/** Connected, same-platform, unaffected server routes can post cards even while the model is offline. */
|
|
2281
2793
|
settingsNoticeGenerals(affected, platform) {
|
|
2282
|
-
const excluded = new Set(affected)
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
if (!adapterId || !adapter || excluded.has(adapterId) || adapter.type !== platform || this.adapterState.get(adapterId)?.status !== "connected")
|
|
2288
|
-
continue;
|
|
2289
|
-
const chatId = this.getGroupIdForInstance(name), topic = String(config.topic_id ?? "");
|
|
2290
|
-
if (chatId && topic)
|
|
2291
|
-
found.push({ adapterId, adapter, chatId, topic });
|
|
2292
|
-
}
|
|
2293
|
-
return found;
|
|
2794
|
+
const excluded = new Set(affected);
|
|
2795
|
+
return Object.keys(this.fleetConfig?.instances ?? {}).flatMap(name => {
|
|
2796
|
+
const route = this.settingsGeneralRoute(name, platform, excluded);
|
|
2797
|
+
return route ? [route] : [];
|
|
2798
|
+
});
|
|
2294
2799
|
}
|
|
2295
2800
|
/**
|
|
2296
2801
|
* #2170: General's notice of a change confirmed from the web — the first General a chat card could have been posted
|
|
@@ -2470,25 +2975,15 @@ export class FleetManager {
|
|
|
2470
2975
|
return;
|
|
2471
2976
|
}
|
|
2472
2977
|
const excluded = new Set(store.affectedConnections(view.id));
|
|
2473
|
-
for (const
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
const adapterId = this.getInstanceAdapterId(name), adapter = this.getAdapterForInstance(name);
|
|
2477
|
-
const general = this.daemons.get(name);
|
|
2478
|
-
if (!adapterId || !adapter || !general || excluded.has(adapterId) || adapter.type !== authority.platform)
|
|
2479
|
-
continue;
|
|
2480
|
-
const chatId = this.getGroupIdForInstance(name), topic = String(config.topic_id ?? "");
|
|
2481
|
-
if (!chatId || !topic)
|
|
2978
|
+
for (const name of Object.keys(this.fleetConfig?.instances ?? {})) {
|
|
2979
|
+
const route = this.settingsGeneralRoute(name, authority.platform, excluded);
|
|
2980
|
+
if (!route)
|
|
2482
2981
|
continue;
|
|
2982
|
+
const { adapterId, adapter, chatId, topic } = route;
|
|
2483
2983
|
let retired = false, attached = false;
|
|
2484
2984
|
const postingDeadline = performance.now() + Math.min(5000, view.remaining_ms);
|
|
2485
|
-
const current = () => !retired && (attached || performance.now() < postingDeadline) &&
|
|
2486
|
-
&&
|
|
2487
|
-
&& this.daemons.get(name) === general && !this.ipcStoppingInstances.has(name)
|
|
2488
|
-
&& this.adapterState.get(adapterId)?.status === "connected"
|
|
2489
|
-
&& this.getInstanceAdapterId(name) === adapterId && this.getAdapterForInstance(name) === adapter
|
|
2490
|
-
&& this.getGroupIdForInstance(name) === chatId && String(this.fleetConfig?.instances[name]?.topic_id ?? "") === topic
|
|
2491
|
-
&& authority.current();
|
|
2985
|
+
const current = () => !retired && (attached || performance.now() < postingDeadline) && this.settingsGeneration === generation
|
|
2986
|
+
&& route.current() && authority.current();
|
|
2492
2987
|
if (!current())
|
|
2493
2988
|
continue;
|
|
2494
2989
|
try {
|
|
@@ -2498,7 +2993,7 @@ export class FleetManager {
|
|
|
2498
2993
|
t("settings_confirm.requester", view.requested_by), ...view.summary].join("\n"),
|
|
2499
2994
|
choices: [{ action: "confirm", label: t("settings_confirm.confirm_button") }, { action: "reject", label: t("settings_confirm.reject_button") }],
|
|
2500
2995
|
expiredText: t("settings_confirm.no_longer_pending"), timeoutMs: view.remaining_ms,
|
|
2501
|
-
extra: { pendingChangeId: view.id, confirmationCurrent: current, confirmationAdmin: userId => current() && authority.admin(userId) },
|
|
2996
|
+
extra: { pendingChangeId: view.id, confirmationCurrent: current, confirmationAdmin: userId => current() && authority.admin(userId), confirmationStop: route.followStop },
|
|
2502
2997
|
});
|
|
2503
2998
|
void posted.then(nonce => {
|
|
2504
2999
|
if (!retired)
|
|
@@ -2766,6 +3261,7 @@ export class FleetManager {
|
|
|
2766
3261
|
try {
|
|
2767
3262
|
const view = await this.settingsGate().store.decide(entry.pendingChangeId, action, {
|
|
2768
3263
|
label: t("settings_confirm.admin_label", data.userId ?? ""),
|
|
3264
|
+
followStop: entry.confirmationStop,
|
|
2769
3265
|
current: () => entry.confirmationCurrent?.() === true && this.getAdapterForInstance(entry.instanceName) === entry.adapter
|
|
2770
3266
|
&& !!data.userId && entry.confirmationAdmin?.(data.userId) === true,
|
|
2771
3267
|
});
|
|
@@ -3140,6 +3636,9 @@ export class FleetManager {
|
|
|
3140
3636
|
}
|
|
3141
3637
|
/** Synchronous SQLite admission; the caller may acknowledge only after this returns. */
|
|
3142
3638
|
admitDurableDelivery(input) {
|
|
3639
|
+
const bindingHold = this.generalLocationDeliveryHold(input.targetInstance);
|
|
3640
|
+
if (bindingHold)
|
|
3641
|
+
throw Error(bindingHold);
|
|
3143
3642
|
const outbox = this.deliveryOutbox;
|
|
3144
3643
|
if (!outbox)
|
|
3145
3644
|
throw new Error("Durable delivery store is unavailable");
|
|
@@ -3179,7 +3678,7 @@ export class FleetManager {
|
|
|
3179
3678
|
const human = (item) => {
|
|
3180
3679
|
if (!item.authority || !state)
|
|
3181
3680
|
return item;
|
|
3182
|
-
const authority =
|
|
3681
|
+
const authority = this.receiptAuthorityNow(item.authority, state);
|
|
3183
3682
|
return { ...item, authority, ...(authority.downgraded ? { source_instance: "human:web:unknown" } : {}) };
|
|
3184
3683
|
};
|
|
3185
3684
|
// #1641: a sender that sees only `queued` cannot tell "next in line" from "the target is stuck": say which.
|
|
@@ -3208,6 +3707,8 @@ export class FleetManager {
|
|
|
3208
3707
|
return this.daemons.get(target)?.notAcceptingReason?.() ?? null;
|
|
3209
3708
|
}
|
|
3210
3709
|
getDaemonBootId(instanceName) {
|
|
3710
|
+
if (this.generalLocationDeliveryHold(instanceName))
|
|
3711
|
+
return undefined;
|
|
3211
3712
|
if (isNativeExecInstance(this.fleetConfig?.instances[instanceName])) {
|
|
3212
3713
|
return !this.shuttingDown && windowsCliEnabled() && this.nativeMcpStore?.hasActive(instanceName) ? this.managerBootId : undefined;
|
|
3213
3714
|
}
|
|
@@ -3219,8 +3720,22 @@ export class FleetManager {
|
|
|
3219
3720
|
/** Called after a replacement Daemon object is installed in lifecycle.daemons. */
|
|
3220
3721
|
onDaemonReady(name, daemonBootId) {
|
|
3221
3722
|
const daemon = this.daemons.get(name);
|
|
3222
|
-
if (daemon?.bootId === daemonBootId)
|
|
3723
|
+
if (daemon?.bootId === daemonBootId) {
|
|
3223
3724
|
this.listenForWorkingState(name, daemon);
|
|
3725
|
+
const cfg = this.fleetConfig?.instances[name];
|
|
3726
|
+
if (cfg?.general_topic && !this.generalLocationFlights.has(name) && daemon.getProcessStatus() === "running"
|
|
3727
|
+
&& (!isNativePaneInstance(cfg) || daemon.nativePaneCurrent())) {
|
|
3728
|
+
try {
|
|
3729
|
+
if (this.generalBindings().completeSelected(name, cfg)) {
|
|
3730
|
+
this.needsYou?.poke();
|
|
3731
|
+
this.logger.info({ name }, "General location's selected start receipt settled after recovery");
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
catch (err) {
|
|
3735
|
+
this.logger.warn({ name, err }, "General location start receipt remains held");
|
|
3736
|
+
}
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
3224
3739
|
// #2802: no post-restart notices. The outbox still delivers or fails a row accepted before the restart, and an open
|
|
3225
3740
|
// request is reminded by the ordinary turn-ended nudge (sweepReplyObligations) once the owner is idle.
|
|
3226
3741
|
this.scheduleDeliveryOutboxPump();
|
|
@@ -5698,6 +6213,8 @@ export class FleetManager {
|
|
|
5698
6213
|
}
|
|
5699
6214
|
/** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
|
|
5700
6215
|
async deliverToInstance(instanceName, payload, options = {}) {
|
|
6216
|
+
if (this.generalLocationDeliveryHold(instanceName))
|
|
6217
|
+
return false;
|
|
5701
6218
|
if (isNativeExecInstance(this.fleetConfig?.instances[instanceName])) {
|
|
5702
6219
|
return this.nativeInstanceRuntime().deliver(instanceName, payload, { stillCurrent: options.stillCurrent,
|
|
5703
6220
|
...(options.authorityCurrent ? { authorityCurrent: options.authorityCurrent } : {}) });
|
|
@@ -6526,7 +7043,26 @@ export class FleetManager {
|
|
|
6526
7043
|
if (resumePaused)
|
|
6527
7044
|
this.lifecycle.markActivitySeedNow(name);
|
|
6528
7045
|
const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
6529
|
-
|
|
7046
|
+
// #1490 row 67 (main): off-loop resolve before skill/instruction publication
|
|
7047
|
+
// constructs a backend (resolveKnowledgeWorkDir). Capture stop/lifecycle
|
|
7048
|
+
// ownership first: a Stop during this wait finishes and bumps the epoch,
|
|
7049
|
+
// and the inner lifecycle check would otherwise capture the new epoch and
|
|
7050
|
+
// accept the stale start. Lifecycle start looks up again before its own
|
|
7051
|
+
// createBackend; the probe cache makes that cheap. (alpha: a native Windows
|
|
7052
|
+
// agent's CLI is on that PC — nothing to resolve on this one.)
|
|
7053
|
+
const epoch = this.lifecycle.epochOf(name);
|
|
7054
|
+
const stopGen = this.explicitStopGeneration.get(name) ?? 0;
|
|
7055
|
+
const windows = isWindowsInstance(config);
|
|
7056
|
+
const resolved = windows ? null : await resolveBackendBinary(backend);
|
|
7057
|
+
execution?.assert();
|
|
7058
|
+
if (this.lifecycle.epochOf(name) !== epoch
|
|
7059
|
+
|| (this.explicitStopGeneration.get(name) ?? 0) !== stopGen) {
|
|
7060
|
+
throw new SupersededStartError(name);
|
|
7061
|
+
}
|
|
7062
|
+
const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
|
|
7063
|
+
if (binary && !windows)
|
|
7064
|
+
noteResolvedBinary(binary, resolved ?? binary);
|
|
7065
|
+
if (!windows && config.general_topic) {
|
|
6530
7066
|
this.ensureGeneralInstructions(config.working_directory, backend, name);
|
|
6531
7067
|
}
|
|
6532
7068
|
else if (!isWindowsInstance(config) && kind === "fleet-topic") {
|
|
@@ -7184,6 +7720,8 @@ export class FleetManager {
|
|
|
7184
7720
|
}
|
|
7185
7721
|
/** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
|
|
7186
7722
|
async restartSingleInstance(name, opts) {
|
|
7723
|
+
if (opts?.ownerCurrent && !opts.ownerCurrent())
|
|
7724
|
+
throw new RestartSupersededError(this.shownInstanceName(name));
|
|
7187
7725
|
if (isNativeExecInstance(this.fleetConfig?.instances[name])) {
|
|
7188
7726
|
const runtime = this.nativeInstanceRuntime();
|
|
7189
7727
|
if (runtime.get(name)?.blocked)
|
|
@@ -7552,6 +8090,9 @@ export class FleetManager {
|
|
|
7552
8090
|
log: () => this.logger.warn("Managed tunnel cleanup requires attention") }));
|
|
7553
8091
|
}
|
|
7554
8092
|
publicOwnerCurrent(owner) {
|
|
8093
|
+
// #3008: the outside link is this computer's admin's — while "Allow connections from outside" is on, web-only.
|
|
8094
|
+
if (isOutsideLinkOwner(owner))
|
|
8095
|
+
return !this.shuttingDown && this.outsideOn();
|
|
7555
8096
|
if (this.shuttingDown || !this.hasFleetAdmins(owner.adapterId) || !this.isFleetAdmin(owner.userId, owner.adapterId))
|
|
7556
8097
|
return false;
|
|
7557
8098
|
const configured = (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).find(ch => (ch.id ?? ch.type) === owner.adapterId);
|
|
@@ -7641,7 +8182,11 @@ export class FleetManager {
|
|
|
7641
8182
|
}
|
|
7642
8183
|
getPublicWebLink() {
|
|
7643
8184
|
this.initializeWebSessions();
|
|
7644
|
-
return this.publicWebLink ??= new PublicWebLink(
|
|
8185
|
+
return this.publicWebLink ??= new PublicWebLink(this.publicWebLinkDeps());
|
|
8186
|
+
}
|
|
8187
|
+
/** The public link's wiring to this fleet (a test builds one with the tunnel itself stubbed: `ensure`, `provider`). */
|
|
8188
|
+
publicWebLinkDeps() {
|
|
8189
|
+
return {
|
|
7645
8190
|
dataDir: this.dataDir, web: () => this.fleetConfig?.web,
|
|
7646
8191
|
permitted: owner => this.publicOwnerCurrent(owner),
|
|
7647
8192
|
reserve: id => this.getTunnelLane().reserve("dashboard", id),
|
|
@@ -7674,7 +8219,9 @@ export class FleetManager {
|
|
|
7674
8219
|
if (world && world.adapter === owner.binding)
|
|
7675
8220
|
void world.adapter.sendText(owner.chatId, t("dashboard.public_cleanup"), { threadId: owner.threadId }).catch(() => { });
|
|
7676
8221
|
},
|
|
7677
|
-
|
|
8222
|
+
onClose: (owner, reason) => { if (isOutsideLinkOwner(owner))
|
|
8223
|
+
this.outsideLinkClosed(reason); },
|
|
8224
|
+
};
|
|
7678
8225
|
}
|
|
7679
8226
|
publicLinkAutoOpen = null;
|
|
7680
8227
|
/**
|
|
@@ -8494,7 +9041,7 @@ export class FleetManager {
|
|
|
8494
9041
|
return { kind: "refused" };
|
|
8495
9042
|
if (device.tier === "guest")
|
|
8496
9043
|
return this.requestGuestDeviceCode(device, input, token);
|
|
8497
|
-
const paused = this.webLoginCodes.pausedForMs();
|
|
9044
|
+
const paused = this.webLoginCodes.pausedForMs(input.exposureId ?? "local");
|
|
8498
9045
|
if (paused > 0)
|
|
8499
9046
|
return { kind: "limited", retryAfterMs: paused };
|
|
8500
9047
|
const admitted = this.codeRequestLimiter.admit(device.idHash);
|
|
@@ -8979,6 +9526,9 @@ export class FleetManager {
|
|
|
8979
9526
|
this.watchdogTimer = startSdWatchdog();
|
|
8980
9527
|
this.eventLoopWatch?.stop();
|
|
8981
9528
|
this.eventLoopWatch = startEventLoopWatch({ logger: this.logger });
|
|
9529
|
+
// #3009: the event-loop watch runs on the main thread; this one does not, so a stall that never ends is still named.
|
|
9530
|
+
this.stallReporter?.stop();
|
|
9531
|
+
this.stallReporter = startStallReporter({ logFile: join(this.dataDir, "daemon.log") });
|
|
8982
9532
|
try {
|
|
8983
9533
|
await this.startAllWithinDeadline(configPath, deadline);
|
|
8984
9534
|
}
|
|
@@ -8992,6 +9542,8 @@ export class FleetManager {
|
|
|
8992
9542
|
this.watchdogTimer = null;
|
|
8993
9543
|
this.eventLoopWatch?.stop();
|
|
8994
9544
|
this.eventLoopWatch = null;
|
|
9545
|
+
this.stallReporter?.stop();
|
|
9546
|
+
this.stallReporter = null;
|
|
8995
9547
|
}
|
|
8996
9548
|
}
|
|
8997
9549
|
}
|
|
@@ -9263,6 +9815,10 @@ export class FleetManager {
|
|
|
9263
9815
|
const generalDir = join(getAgendHome(), name);
|
|
9264
9816
|
mkdirSync(generalDir, { recursive: true, mode: 0o700 });
|
|
9265
9817
|
const backendName = fleet.defaults.backend ?? "claude-code";
|
|
9818
|
+
const resolved = await resolveBackendBinary(backendName);
|
|
9819
|
+
const binary = BACKEND_INSTALLATION_INFO[backendName]?.binary;
|
|
9820
|
+
if (binary)
|
|
9821
|
+
noteResolvedBinary(binary, resolved ?? binary);
|
|
9266
9822
|
this.ensureGeneralInstructions(generalDir, backendName, name);
|
|
9267
9823
|
fleet.instances[name] = autoGeneralInstanceConfig(fleet.defaults, generalDir, need.adapterId); // #2598: its kiro_ui is the fleet's
|
|
9268
9824
|
generalsCreated = true;
|
|
@@ -9542,6 +10098,10 @@ export class FleetManager {
|
|
|
9542
10098
|
// #named-link-ttl: a named link with auto_open comes back now — after the web listener and the chat connections.
|
|
9543
10099
|
this.startPublicLinkAutoOpen();
|
|
9544
10100
|
}
|
|
10101
|
+
// #3008: connections from outside were on when the fleet stopped: the link opens again (its address is new for a
|
|
10102
|
+
// quick link — Settings shows it on this computer).
|
|
10103
|
+
if (this.outsideOn())
|
|
10104
|
+
this.openOutsideLink([0, ...OUTSIDE_LINK_RETRY_MS]);
|
|
9545
10105
|
// #1266: buttons that ended while AgEnD was down (expired, or chosen before a restart) are shown as ended now.
|
|
9546
10106
|
// #1823: and polls: a deadline that passed meanwhile closes now (results once); an open one's countdown is redrawn.
|
|
9547
10107
|
void this.replyButtons()?.sweep().catch(err => this.logger.warn({ err }, "Reply-button sweep failed"));
|
|
@@ -11475,6 +12035,12 @@ export class FleetManager {
|
|
|
11475
12035
|
if (msg.adapterId)
|
|
11476
12036
|
this.bindInstanceAdapter(generalInstance, msg.adapterId, true);
|
|
11477
12037
|
const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
12038
|
+
const locationHold = this.generalLocationDeliveryHold(generalInstance);
|
|
12039
|
+
if (locationHold) {
|
|
12040
|
+
await inboundAdapter.sendText(msg.chatId, locationHold, { threadId: msg.threadId || undefined });
|
|
12041
|
+
this.needsYou?.poke();
|
|
12042
|
+
return;
|
|
12043
|
+
}
|
|
11478
12044
|
// React immediately — before any other API calls — through the status
|
|
11479
12045
|
// path, which tracks Telegram slot ownership and Discord status adds.
|
|
11480
12046
|
if (msg.chatId && msg.messageId) {
|
|
@@ -11562,6 +12128,13 @@ export class FleetManager {
|
|
|
11562
12128
|
if (instanceConfig?.general_topic && await this.topicCommands.handleGeneralCommand(msg, instanceName)) {
|
|
11563
12129
|
return;
|
|
11564
12130
|
}
|
|
12131
|
+
const locationHold = this.generalLocationDeliveryHold(instanceName);
|
|
12132
|
+
if (locationHold) {
|
|
12133
|
+
const adapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
12134
|
+
await adapter.sendText(msg.chatId, locationHold, { threadId: msg.threadId || undefined });
|
|
12135
|
+
this.needsYou?.poke();
|
|
12136
|
+
return;
|
|
12137
|
+
}
|
|
11565
12138
|
// Bind instance to the adapter that delivered this message
|
|
11566
12139
|
if (msg.adapterId)
|
|
11567
12140
|
this.bindInstanceAdapter(instanceName, msg.adapterId, true);
|
|
@@ -13811,6 +14384,9 @@ export class FleetManager {
|
|
|
13811
14384
|
await this.lifecycle.remove(name, authorization, execution, transition);
|
|
13812
14385
|
else
|
|
13813
14386
|
await this.lifecycle.remove(name, authorization);
|
|
14387
|
+
// #1490 row 27: its hang/exit offers were for the instance now gone (stopInstance clears them; removal stops
|
|
14388
|
+
// through the lifecycle directly). The offers' own lifecycle pins refuse them too.
|
|
14389
|
+
this.clearNoncePromptsForInstance(name);
|
|
13814
14390
|
// #1774: removed — its replies still copying their files are no one's to show, replaced or not (a failed removal
|
|
13815
14391
|
// throws above: its replies stay its own).
|
|
13816
14392
|
this.webChatRemovals.set(name, { below: removedBelow, ...(removedDaemon !== undefined ? { daemon: removedDaemon } : {}) });
|
|
@@ -15491,6 +16067,7 @@ export class FleetManager {
|
|
|
15491
16067
|
threadId: opts.threadId,
|
|
15492
16068
|
expiredText: opts.expiredText,
|
|
15493
16069
|
createdAt: Date.now(),
|
|
16070
|
+
lifecycleCurrent: this.lifecyclePin(opts.instanceName),
|
|
15494
16071
|
...opts.extra,
|
|
15495
16072
|
};
|
|
15496
16073
|
entry.timer = setTimeout(() => {
|
|
@@ -16021,6 +16598,7 @@ export class FleetManager {
|
|
|
16021
16598
|
signIn: pauseReason === "auth" ? this.signInFor(name) : null,
|
|
16022
16599
|
usageHold: this.daemons.get(name)?.usageHold?.() ?? null,
|
|
16023
16600
|
nativeDeliveryHold: this.daemons.get(name)?.nativeDeliveryHold?.() ?? null,
|
|
16601
|
+
generalLocation: this.generalLocationAttention(name),
|
|
16024
16602
|
paneRecovery: (() => {
|
|
16025
16603
|
const config = this.fleetConfig?.instances[name];
|
|
16026
16604
|
if (!isNativePaneInstance(config) || !config.native_agent_id)
|
|
@@ -16634,7 +17212,7 @@ export class FleetManager {
|
|
|
16634
17212
|
isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
|
|
16635
17213
|
authorization: input.session ? this.sessionAuthorizationStore()?.get(input.session.handle) ?? null : null,
|
|
16636
17214
|
policy: { ...webAuthorityPolicy(this.fleetConfig?.web), enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now: Date.now(),
|
|
16637
|
-
hostOperator: this.isWebOnlyFleet(),
|
|
17215
|
+
hostOperator: this.isWebOnlyFleet(), outside: this.outsideLink(),
|
|
16638
17216
|
});
|
|
16639
17217
|
}
|
|
16640
17218
|
sessionAuthorizations = null;
|
|
@@ -16694,6 +17272,269 @@ export class FleetManager {
|
|
|
16694
17272
|
const { durable } = store.authorize(session.handle, HOST_OPERATOR, now, state.epoch, now + policy.maxSessionMs, peer);
|
|
16695
17273
|
this.logger.info({ handle: session.handle, durable }, "Host web session authorized (this computer's admin)");
|
|
16696
17274
|
}
|
|
17275
|
+
// ── #3008: connections from outside (a web-only fleet; decision 28c2d3a3 rule 3, the user's simplified form) ──
|
|
17276
|
+
webRemoteAccessState = null;
|
|
17277
|
+
/** The authenticator, the outside switch and the wrong-code limiter (web-remote-access.json), read on first use. */
|
|
17278
|
+
webRemoteAccess() {
|
|
17279
|
+
if (!this.dataDir)
|
|
17280
|
+
return null;
|
|
17281
|
+
this.webRemoteAccessState ??= new WebRemoteAccess(join(this.dataDir, WEB_REMOTE_ACCESS_FILE), this.logger);
|
|
17282
|
+
return this.webRemoteAccessState;
|
|
17283
|
+
}
|
|
17284
|
+
/** "Allow connections from outside" is on (an app set up) and this is a web-only fleet. */
|
|
17285
|
+
outsideOn() {
|
|
17286
|
+
return this.isWebOnlyFleet() && (this.webRemoteAccess()?.outside ?? false);
|
|
17287
|
+
}
|
|
17288
|
+
/** The link connections from outside opened, while it is open and still theirs: its exposure. Else null. */
|
|
17289
|
+
outsideLink() {
|
|
17290
|
+
if (!this.outsideOn())
|
|
17291
|
+
return null;
|
|
17292
|
+
const open = this.publicWebLink?.openSignin();
|
|
17293
|
+
return open && isOutsideLinkOwner(open.owner) ? { exposureId: open.exposureId } : null;
|
|
17294
|
+
}
|
|
17295
|
+
/**
|
|
17296
|
+
* The request's live session, asked again each time `current()` is called (its own cookie authenticated anew, the same
|
|
17297
|
+
* handle): this web-only fleet's owner — this computer's admin, on this computer (`hostOnly`), or also signed in from
|
|
17298
|
+
* outside — and web-verified now. Null when it is not (a guest, a LAN session, a chat fleet's admin, nobody).
|
|
17299
|
+
*/
|
|
17300
|
+
liveOwner(req, opts) {
|
|
17301
|
+
if (!this.isWebOnlyFleet())
|
|
17302
|
+
return null;
|
|
17303
|
+
if (opts.hostOnly && gatewayRequestContext(req))
|
|
17304
|
+
return null;
|
|
17305
|
+
const auth = authorizeSession(req, this.webToken, this.webSessions, { touch: false });
|
|
17306
|
+
if (auth.kind !== "ok")
|
|
17307
|
+
return null;
|
|
17308
|
+
const handle = auth.session.handle, peer = webPeerOf(req), gateway = !!gatewayRequestContext(req);
|
|
17309
|
+
const owner = () => {
|
|
17310
|
+
if (!this.isWebOnlyFleet())
|
|
17311
|
+
return false;
|
|
17312
|
+
const live = authorizeSession(req, this.webToken, this.webSessions, { touch: false });
|
|
17313
|
+
if (live.kind !== "ok" || live.session.handle !== handle)
|
|
17314
|
+
return false;
|
|
17315
|
+
if (opts.hostOnly && (live.session.host !== true || peer !== "loopback"))
|
|
17316
|
+
return false;
|
|
17317
|
+
const now = this.webAuthority({ session: live.session, gateway, peer });
|
|
17318
|
+
const a = now.authority.kind === "web-verified" && now.authority.session?.handle === handle ? now.authority.actor : null;
|
|
17319
|
+
return !!a && a.adapter_id === HOST_OPERATOR.adapterId && a.user_id === HOST_OPERATOR.userId;
|
|
17320
|
+
};
|
|
17321
|
+
return owner() ? { label: t("settings_confirm.host_operator_label"), handle, current: owner } : null;
|
|
17322
|
+
}
|
|
17323
|
+
/** Every session signed in from outside ends (and the authorizations kept for them). */
|
|
17324
|
+
endOutsideSessions() {
|
|
17325
|
+
const ended = new Set(this.webSessions?.list(undefined, r => r.remote === "totp").map(r => r.handle) ?? []);
|
|
17326
|
+
if (!ended.size)
|
|
17327
|
+
return;
|
|
17328
|
+
this.sessionAuthorizationStore()?.prune(a => !ended.has(a.handle));
|
|
17329
|
+
const result = this.webSessions?.revokeAll(r => r.remote === "totp");
|
|
17330
|
+
if (result && !result.durable)
|
|
17331
|
+
this.logger.warn("Outside sessions ended, but that could not be saved");
|
|
17332
|
+
this.logger.info({ count: ended.size }, "Sessions signed in from outside ended");
|
|
17333
|
+
}
|
|
17334
|
+
/**
|
|
17335
|
+
* #1939 §10 / #3008: a stored receipt's authority as it reads now — the web authority state, and for this computer's
|
|
17336
|
+
* admin from outside the switch and the last revocation of remote authority (Prism P2 2).
|
|
17337
|
+
*/
|
|
17338
|
+
receiptAuthorityNow(stored, state = this.webAuthorityState()) {
|
|
17339
|
+
return authorityNow(stored, { ...state, outside: this.outsideOn(), outsideHandles: this.webRemoteAccess()?.admitted ?? new Set() });
|
|
17340
|
+
}
|
|
17341
|
+
/** #3008 (the sign-in page): this exposure is the outside link, open now. */
|
|
17342
|
+
isOutsideLink(exposureId) {
|
|
17343
|
+
return this.outsideLink()?.exposureId === exposureId;
|
|
17344
|
+
}
|
|
17345
|
+
/** What Settings shows: set up or not, the switch, a wait in force, the link — its address only to this computer. */
|
|
17346
|
+
webOutsideStatus(req) {
|
|
17347
|
+
const store = this.webRemoteAccess(), status = store?.status();
|
|
17348
|
+
const here = !!this.liveOwner(req, { hostOnly: true });
|
|
17349
|
+
const open = this.outsideOn() ? this.publicWebLink?.openSignin() : null;
|
|
17350
|
+
const mine = !!open && isOutsideLinkOwner(open.owner);
|
|
17351
|
+
const linkStatus = this.outsideOn() ? this.getPublicWebStatus() : null;
|
|
17352
|
+
return { webOnly: this.isWebOnlyFleet(), here, enrolled: !!status?.enrolled, enrolledAt: status?.enrolledAt ?? null,
|
|
17353
|
+
outside: this.outsideOn(), pausedForMs: status?.pausedForMs ?? 0,
|
|
17354
|
+
link: linkStatus && linkStatus.state !== "closed" ? { ...linkStatus, ...(here && mine ? { url: open.url } : {}), quick: !publicLinkNamedHostname(this.fleetConfig?.web) } : null };
|
|
17355
|
+
}
|
|
17356
|
+
/** Set up (or set up again): a new secret, shown once to this computer's admin. Null: not them. */
|
|
17357
|
+
webOutsideBeginEnrollment(req) {
|
|
17358
|
+
const owner = this.liveOwner(req, { hostOnly: true }), store = this.webRemoteAccess();
|
|
17359
|
+
if (!owner || !store)
|
|
17360
|
+
return null;
|
|
17361
|
+
return store.beginEnrollment(owner.handle, hostname() || "fleet");
|
|
17362
|
+
}
|
|
17363
|
+
/** The new app's first code: on "ok" it replaces the old one, whose sessions from outside end. */
|
|
17364
|
+
webOutsideConfirmEnrollment(req, code) {
|
|
17365
|
+
const owner = this.liveOwner(req, { hostOnly: true }), store = this.webRemoteAccess();
|
|
17366
|
+
if (!owner || !store || !owner.current())
|
|
17367
|
+
return "not_here";
|
|
17368
|
+
const had = store.enrolled;
|
|
17369
|
+
const r = store.confirmEnrollment(owner.handle, code);
|
|
17370
|
+
if (r === "ok") {
|
|
17371
|
+
this.logger.info({ again: had }, "Authenticator app set up for connections from outside");
|
|
17372
|
+
if (had)
|
|
17373
|
+
this.endOutsideSessions();
|
|
17374
|
+
}
|
|
17375
|
+
return r;
|
|
17376
|
+
}
|
|
17377
|
+
/** Forget the app: the switch goes off, the link closes, every session from outside ends. */
|
|
17378
|
+
webOutsideRemove(req) {
|
|
17379
|
+
const owner = this.liveOwner(req, { hostOnly: true }), store = this.webRemoteAccess();
|
|
17380
|
+
if (!owner || !store || !owner.current())
|
|
17381
|
+
return "not_here";
|
|
17382
|
+
// #3008 review (Prism P2 1): shut at once whatever the disk says; say so when it could not be saved (retried).
|
|
17383
|
+
const saved = store.remove();
|
|
17384
|
+
this.closeOutsideLink("outside removed");
|
|
17385
|
+
this.logger.info({ saved }, "Authenticator app removed; connections from outside off");
|
|
17386
|
+
return saved ? "ok" : "unsaved";
|
|
17387
|
+
}
|
|
17388
|
+
/** The switch. On: needs an app set up; the link opens. Off: it closes and every session from outside ends. */
|
|
17389
|
+
webOutsideSwitch(req, on) {
|
|
17390
|
+
const owner = this.liveOwner(req, { hostOnly: true }), store = this.webRemoteAccess();
|
|
17391
|
+
if (!owner || !store || !owner.current())
|
|
17392
|
+
return "not_here";
|
|
17393
|
+
if (on && !publicLinkSettings(this.fleetConfig?.web).allowed)
|
|
17394
|
+
return "not_allowed";
|
|
17395
|
+
const r = store.setOutside(on);
|
|
17396
|
+
if (r === "not_enrolled")
|
|
17397
|
+
return r;
|
|
17398
|
+
this.logger.info({ on, saved: r === "ok" }, "Connections from outside switched");
|
|
17399
|
+
// #3008 review (Prism P2 1): Off shuts at once even when it could not be saved (the store retries); On opens only
|
|
17400
|
+
// once saved — a switch the disk does not hold would not survive a restart.
|
|
17401
|
+
if (!on)
|
|
17402
|
+
this.closeOutsideLink("outside off");
|
|
17403
|
+
else if (r === "ok")
|
|
17404
|
+
this.openOutsideLink([0, ...OUTSIDE_LINK_RETRY_MS]);
|
|
17405
|
+
return r;
|
|
17406
|
+
}
|
|
17407
|
+
outsideAttempt = null;
|
|
17408
|
+
outsideOpening = false;
|
|
17409
|
+
/**
|
|
17410
|
+
* Open the outside link: an attempt after each delay in turn until one opens (#3008 B: twice more at most — the quick
|
|
17411
|
+
* link's address service limits how often it may be asked). Every attempt asks again: the switch still on, the fleet
|
|
17412
|
+
* running. A final failure: one Notice; the switch stays on, so the next start tries again.
|
|
17413
|
+
*/
|
|
17414
|
+
openOutsideLink(delays) {
|
|
17415
|
+
this.outsideAttempt?.cancel();
|
|
17416
|
+
this.outsideAttempt = null;
|
|
17417
|
+
const attempt = (n) => {
|
|
17418
|
+
this.outsideAttempt = null;
|
|
17419
|
+
if (this.shuttingDown || !this.outsideOn() || this.outsideOpening)
|
|
17420
|
+
return;
|
|
17421
|
+
this.outsideOpening = true;
|
|
17422
|
+
// alpha CI 4abe58cd: an On right after an Off finds the old link still closing (its tunnel stopping): wait for that
|
|
17423
|
+
// close, then open — never "busy" and a 30 s retry. The switch is asked again after the wait by openUnattended
|
|
17424
|
+
// itself (permitted → publicOwnerCurrent: the fleet running, the switch on): an Off meanwhile opens nothing.
|
|
17425
|
+
const link = this.getPublicWebLink();
|
|
17426
|
+
void link.whenClosed().then(() => link.openUnattended(OUTSIDE_LINK_OWNER))
|
|
17427
|
+
.catch(() => "failed").then(outcome => {
|
|
17428
|
+
this.outsideOpening = false;
|
|
17429
|
+
if (outcome === "opened") {
|
|
17430
|
+
this.logger.info({ attempt: n + 1 }, "Outside link opened");
|
|
17431
|
+
return;
|
|
17432
|
+
}
|
|
17433
|
+
if (outcome === "busy" && this.outsideLink())
|
|
17434
|
+
return; // already open (ours)
|
|
17435
|
+
if (this.shuttingDown || !this.outsideOn())
|
|
17436
|
+
return;
|
|
17437
|
+
if (n + 1 < delays.length) {
|
|
17438
|
+
this.outsideAttempt = outsideTimer(() => attempt(n + 1), delays[n + 1]);
|
|
17439
|
+
return;
|
|
17440
|
+
}
|
|
17441
|
+
this.logger.warn({ attempts: n + 1 }, "Outside link could not be opened; giving up until the next start or switch");
|
|
17442
|
+
this.recordWebNotice("fleet", t("outside.notice_ended", t("outside.reason_failed")));
|
|
17443
|
+
});
|
|
17444
|
+
};
|
|
17445
|
+
this.outsideAttempt = outsideTimer(() => attempt(0), delays[0] ?? 0);
|
|
17446
|
+
}
|
|
17447
|
+
closeOutsideLink(reason) {
|
|
17448
|
+
this.outsideAttempt?.cancel();
|
|
17449
|
+
this.outsideAttempt = null;
|
|
17450
|
+
const open = this.publicWebLink?.openSignin();
|
|
17451
|
+
if (this.publicWebLink && (!open || isOutsideLinkOwner(open.owner)))
|
|
17452
|
+
void this.publicWebLink.close(reason);
|
|
17453
|
+
this.endOutsideSessions();
|
|
17454
|
+
}
|
|
17455
|
+
/** PublicWebLink.onClose for the outside link: what closed it decides what happens next (#3008 B). */
|
|
17456
|
+
outsideLinkClosed(reason) {
|
|
17457
|
+
if (this.shuttingDown || OUTSIDE_OWN_CLOSES.has(reason) || !this.outsideOn())
|
|
17458
|
+
return;
|
|
17459
|
+
// #3008 review (Prism P2 3): expiry first — its lifetime counts from the opening request, so it can run out while the
|
|
17460
|
+
// opening is still under way: the switch goes off then too, and that attempt's end (outsideOn false) retries nothing.
|
|
17461
|
+
if (reason === "expired") {
|
|
17462
|
+
// Its lifetime ran out: the switch goes off (only this computer turns it on again — a session from outside can't).
|
|
17463
|
+
this.outsideAttempt?.cancel();
|
|
17464
|
+
this.outsideAttempt = null;
|
|
17465
|
+
if (this.webRemoteAccess()?.setOutside(false) === "unsaved")
|
|
17466
|
+
this.logger.warn("Outside link expired; the switch's Off could not be saved yet (retried)");
|
|
17467
|
+
this.endOutsideSessions();
|
|
17468
|
+
this.logger.info("Outside link expired; connections from outside off");
|
|
17469
|
+
this.recordWebNotice("fleet", t("outside.notice_ended", t("outside.reason_expired")));
|
|
17470
|
+
return;
|
|
17471
|
+
}
|
|
17472
|
+
if (this.outsideOpening)
|
|
17473
|
+
return; // that attempt's own failure: its retry
|
|
17474
|
+
// Dropped (the tunnel exited, the gateway failed, the configuration changed): reopened up to twice.
|
|
17475
|
+
this.logger.info({ reason }, "Outside link closed; reopening");
|
|
17476
|
+
this.openOutsideLink(OUTSIDE_LINK_RETRY_MS);
|
|
17477
|
+
}
|
|
17478
|
+
/**
|
|
17479
|
+
* #3008 (auth-api POST /auth/totp): a sign-in through the outside link with the authenticator's code. "ok": the caller
|
|
17480
|
+
* makes the session (SessionRecord.remote) and then calls authorizeOutsideSession. Each wrong code counts; a wait that
|
|
17481
|
+
* starts is a Notice; a code that matches a step just outside the window logs how far this clock seems to be off.
|
|
17482
|
+
*/
|
|
17483
|
+
outsideSignIn(exposureId, code) {
|
|
17484
|
+
const link = this.outsideLink(), store = this.webRemoteAccess();
|
|
17485
|
+
if (!link || !store || !exposureId || link.exposureId !== exposureId)
|
|
17486
|
+
return { kind: "closed" };
|
|
17487
|
+
const v = store.verify(code);
|
|
17488
|
+
if (v.kind === "ok")
|
|
17489
|
+
return { kind: "ok" };
|
|
17490
|
+
if (v.kind === "paused")
|
|
17491
|
+
return v;
|
|
17492
|
+
if (v.kind === "not_enrolled")
|
|
17493
|
+
return { kind: "closed" };
|
|
17494
|
+
if (v.kind === "unavailable")
|
|
17495
|
+
return v;
|
|
17496
|
+
if (v.skewSteps !== null)
|
|
17497
|
+
this.logger.warn({ offsetSeconds: v.skewSteps * TOTP_STEP_SECONDS }, "A wrong authenticator code matched a nearby time step: this computer's clock may be off");
|
|
17498
|
+
if (v.paused) {
|
|
17499
|
+
this.logger.warn({ waitMs: v.retryAfterMs }, "Sign-in from outside paused after wrong codes");
|
|
17500
|
+
this.recordWebNotice("fleet", t("outside.notice_paused", outsideDuration(v.retryAfterMs), v.wrong));
|
|
17501
|
+
return { kind: "paused", retryAfterMs: v.retryAfterMs };
|
|
17502
|
+
}
|
|
17503
|
+
return { kind: "wrong" };
|
|
17504
|
+
}
|
|
17505
|
+
/**
|
|
17506
|
+
* #3008: a session just made by outsideSignIn's "ok" is this computer's admin for its authorization's life. False: it
|
|
17507
|
+
* could not be admitted (its handle not saved, #3015 review r3) — the caller ends it.
|
|
17508
|
+
*/
|
|
17509
|
+
authorizeOutsideSession(session, peer, label) {
|
|
17510
|
+
const store = this.sessionAuthorizationStore(), link = this.outsideLink();
|
|
17511
|
+
if (!store || session.remote !== "totp" || session.owner || !link || session.exposureId !== link.exposureId)
|
|
17512
|
+
return false;
|
|
17513
|
+
if (!this.webRemoteAccess()?.admit(session.handle)) {
|
|
17514
|
+
this.logger.warn({ handle: session.handle }, "Session from outside not admitted: it could not be saved");
|
|
17515
|
+
return false;
|
|
17516
|
+
}
|
|
17517
|
+
const state = this.webAuthorityState(), now = Date.now(), policy = webAuthorityPolicy(this.fleetConfig?.web);
|
|
17518
|
+
const check = evaluateWebAuthority({
|
|
17519
|
+
session, gateway: true, peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
|
|
17520
|
+
isAdmin: () => false,
|
|
17521
|
+
authorization: { handle: session.handle, owner: HOST_OPERATOR, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch },
|
|
17522
|
+
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now,
|
|
17523
|
+
hostOperator: true, outside: link,
|
|
17524
|
+
});
|
|
17525
|
+
if (check.refusal) {
|
|
17526
|
+
this.logger.info({ handle: session.handle, refusal: check.refusal }, "Session from outside not authorized");
|
|
17527
|
+
return false;
|
|
17528
|
+
}
|
|
17529
|
+
{
|
|
17530
|
+
const live = new Set(this.webSessions?.list(undefined, () => true).map(r => r.handle) ?? []);
|
|
17531
|
+
store.prune(a => a.epoch === state.epoch && now <= Math.min(a.until, a.authorizedAt + policy.maxSessionMs) && live.has(a.handle));
|
|
17532
|
+
const { durable } = store.authorize(session.handle, HOST_OPERATOR, now, state.epoch, now + policy.maxSessionMs, peer);
|
|
17533
|
+
this.logger.info({ handle: session.handle, durable }, "Session from outside authorized (this computer's admin)");
|
|
17534
|
+
}
|
|
17535
|
+
this.recordWebNotice("fleet", t("outside.notice_signed_in", label));
|
|
17536
|
+
return true;
|
|
17537
|
+
}
|
|
16697
17538
|
authorityStateStore = null;
|
|
16698
17539
|
/**
|
|
16699
17540
|
* #1939 §10: the switch, "Only this computer can authorize" and the revocation epoch in force for the configuration as
|
|
@@ -16714,6 +17555,10 @@ export class FleetManager {
|
|
|
16714
17555
|
return true;
|
|
16715
17556
|
if (session.surface === "local")
|
|
16716
17557
|
return false;
|
|
17558
|
+
// #3008 review (Prism P2 4): signed in from outside with the authenticator's code — its opt-in is the outside
|
|
17559
|
+
// switch, not these options (the authority judges it so): it keeps its authorization.
|
|
17560
|
+
if (session.remote === "totp")
|
|
17561
|
+
return false;
|
|
16717
17562
|
const peer = a.peer ?? "named_link";
|
|
16718
17563
|
return (!!state.namedLinkClosed && peer === "named_link") || (!!state.quickLinkClosed && peer === "quick_link");
|
|
16719
17564
|
};
|
|
@@ -16863,7 +17708,7 @@ export class FleetManager {
|
|
|
16863
17708
|
authorization: s?.owner ? { handle: s.handle, owner: s.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch } : null,
|
|
16864
17709
|
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now,
|
|
16865
17710
|
// #2910 D1: a host operator's session is never asked about (no account to ask): only a sign-in authorizes it.
|
|
16866
|
-
hostOperator: this.isWebOnlyFleet(),
|
|
17711
|
+
hostOperator: this.isWebOnlyFleet(), outside: this.outsideLink(),
|
|
16867
17712
|
});
|
|
16868
17713
|
return r.refusal;
|
|
16869
17714
|
}
|
|
@@ -17221,21 +18066,27 @@ export class FleetManager {
|
|
|
17221
18066
|
if (claimed === "consumed")
|
|
17222
18067
|
return true;
|
|
17223
18068
|
const { entry: pending, action } = claimed;
|
|
18069
|
+
// #1490 row 27: the lifecycle the button was offered for — a replacement since (removal does not clear prompts) is stale.
|
|
18070
|
+
const current = pending.lifecycleCurrent ?? this.lifecyclePin(pending.instanceName);
|
|
17224
18071
|
this.eventLog?.insert(pending.instanceName, oom ? "session_oom_action" : "normal_exit_action", { action, userId: data.userId });
|
|
17225
18072
|
if (action === "ignore") {
|
|
17226
18073
|
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.ignored", pending.instanceName));
|
|
17227
18074
|
return true;
|
|
17228
18075
|
}
|
|
18076
|
+
if (!current()) {
|
|
18077
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("restart.superseded", this.shownInstanceName(pending.instanceName)));
|
|
18078
|
+
return true;
|
|
18079
|
+
}
|
|
17229
18080
|
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.restarting", pending.instanceName));
|
|
17230
18081
|
try {
|
|
17231
|
-
await this.restartSingleInstance(pending.instanceName, { explicit: true, ...(oom ? { freshStart: action === "fresh", sessionOomRetry: pending.sessionOomRetry,
|
|
18082
|
+
await this.restartSingleInstance(pending.instanceName, { explicit: true, ownerCurrent: current, ...(oom ? { freshStart: action === "fresh", sessionOomRetry: pending.sessionOomRetry,
|
|
17232
18083
|
...(pending.confirmationCurrent ? { recoveryCurrent: pending.confirmationCurrent } : {}) } : {}) });
|
|
17233
18084
|
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("exit.restarted", pending.instanceName), pending.threadId);
|
|
17234
18085
|
}
|
|
17235
18086
|
catch (err) {
|
|
17236
18087
|
const message = err instanceof Error ? err.message : String(err);
|
|
17237
18088
|
this.logger.error({ err, instanceName: pending.instanceName }, "Normal-exit restart failed");
|
|
17238
|
-
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("exit.restart_failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show normal-exit restart error"));
|
|
18089
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, err instanceof RestartSupersededError ? message : t("exit.restart_failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show normal-exit restart error"));
|
|
17239
18090
|
}
|
|
17240
18091
|
return true;
|
|
17241
18092
|
}
|
|
@@ -18357,7 +19208,10 @@ export class FleetManager {
|
|
|
18357
19208
|
return this.browserBackendSignInsInstance ??= new BrowserBackendSignIns({
|
|
18358
19209
|
dataDir: this.dataDir, lock: this.loginWindow,
|
|
18359
19210
|
current: owner => this.browserSignInOwnerCurrent(owner),
|
|
18360
|
-
installed: async () =>
|
|
19211
|
+
installed: async () => {
|
|
19212
|
+
const found = await Promise.all(ONBOARDING_BACKENDS.map(async (backend) => (await this.isCliInstalledOffLoop(backend)) ? backend : null));
|
|
19213
|
+
return found.filter((backend) => backend !== null);
|
|
19214
|
+
},
|
|
18361
19215
|
checkAuth: backend => checkAuthStatus(LOGIN_FLOWS[backend].authCheck),
|
|
18362
19216
|
checkConsoleAuth: () => checkAuthStatus(LOGIN_FLOWS.antigravity.authCheck),
|
|
18363
19217
|
transport: () => createStructuredSignInTransport(),
|
|
@@ -18461,13 +19315,13 @@ export class FleetManager {
|
|
|
18461
19315
|
* #2233: `/login` for a dashboard requester. The same controller and checks; a CLI that is not installed is refused
|
|
18462
19316
|
* with its install command (chat's install-first runs a terminal of its own, which the dashboard does not offer).
|
|
18463
19317
|
*/
|
|
18464
|
-
startWebDashboardLogin(backendArg, chat, opts) {
|
|
19318
|
+
async startWebDashboardLogin(backendArg, chat, opts) {
|
|
18465
19319
|
const wanted = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
|
|
18466
19320
|
if (isRemovedBackend(wanted))
|
|
18467
|
-
return
|
|
19321
|
+
return removedBackendMessage(wanted);
|
|
18468
19322
|
const info = BACKEND_INSTALLATION_INFO[wanted];
|
|
18469
|
-
if (info && LOGIN_FLOWS[wanted]?.remoteLogin !== "unsupported" && !this.
|
|
18470
|
-
return
|
|
19323
|
+
if (info && LOGIN_FLOWS[wanted]?.remoteLogin !== "unsupported" && !(await this.isCliInstalledOffLoop(wanted))) {
|
|
19324
|
+
return t("login.web_dashboard_not_installed", wanted, info.install);
|
|
18471
19325
|
}
|
|
18472
19326
|
if (!opts.skipAuthCheck)
|
|
18473
19327
|
this.recordLoginFlow(wanted, "login", chat.userId);
|
|
@@ -18515,8 +19369,10 @@ export class FleetManager {
|
|
|
18515
19369
|
? removedBackendMessage(backend)
|
|
18516
19370
|
: !installed.has(backend)
|
|
18517
19371
|
? (BACKEND_INSTALLATION_INFO[backend] ? t("login.install_by_name", backend) : t("login.install_on_host", backend))
|
|
19372
|
+
// #2981: one short line in the bare /login list — the full refusal (why, and what to run) is `/login <it>`'s;
|
|
19373
|
+
// a bare /login used to post Antigravity's whole paragraph to someone who never asked about it.
|
|
18518
19374
|
: flow?.remoteLogin === "unsupported"
|
|
18519
|
-
? t("login.
|
|
19375
|
+
? t("login.host_only", flow.command)
|
|
18520
19376
|
: backend === "opencode" ? t("login.unsupported", backend) : t("login.no_remote_flow", backend)}`).join("\n");
|
|
18521
19377
|
await chat.adapter.sendText(chat.chatId, guidance, { threadId: chat.threadId })
|
|
18522
19378
|
.catch(err => this.logger.warn({ err }, "Failed to post unsupported login guidance"));
|
|
@@ -18959,9 +19815,14 @@ export class FleetManager {
|
|
|
18959
19815
|
// session only judges that verdict; it does not read the output for URLs or prompts.
|
|
18960
19816
|
// M-2281 (#2980): no terminal to ask on, low priority, stoppable as a whole, the user's own $SHELL — see
|
|
18961
19817
|
// unattended-install.ts. A prompt nobody can answer fails at once instead of waiting out the timeout.
|
|
19818
|
+
const shell = (await loginShell()) ?? null;
|
|
19819
|
+
if (!this.loginWindow.isCurrent(claim)) {
|
|
19820
|
+
this.loginWindow.release(claim);
|
|
19821
|
+
return t("login.web_shutting_down");
|
|
19822
|
+
}
|
|
18962
19823
|
const flow = {
|
|
18963
19824
|
backend,
|
|
18964
|
-
command: unattendedInstallCommand(info.unattended ?? info.install),
|
|
19825
|
+
command: unattendedInstallCommand(info.unattended ?? info.install, shell),
|
|
18965
19826
|
successPattern: /$^/,
|
|
18966
19827
|
timeoutMs: this.installTimeoutMs,
|
|
18967
19828
|
};
|
|
@@ -19005,7 +19866,9 @@ export class FleetManager {
|
|
|
19005
19866
|
return;
|
|
19006
19867
|
// The installer may only have added the binary to a profile PATH; a
|
|
19007
19868
|
// fresh login shell sees that, the fleet process's PATH may not.
|
|
19008
|
-
|
|
19869
|
+
// #3009 (leader, cid-3017-3029-overlap): both lookups off the fleet's main thread — the login shell's own check,
|
|
19870
|
+
// then the installer's own directories through backend-availability's check (a killable child tests each one).
|
|
19871
|
+
const installedAt = (await this.locateBinaryOnLoginShell(info.binary)) ?? (await this.locateInstalledBackend(backend));
|
|
19009
19872
|
if (handoff.cancelled || this.installHandoff !== handoff)
|
|
19010
19873
|
return;
|
|
19011
19874
|
if (!installedAt) {
|
|
@@ -19097,38 +19960,27 @@ export class FleetManager {
|
|
|
19097
19960
|
*/
|
|
19098
19961
|
async locateBinaryOnLoginShell(binary) {
|
|
19099
19962
|
try {
|
|
19100
|
-
|
|
19963
|
+
// #3009: the executable-file check runs inside the same login shell (a killable child), never as accessSync/statSync
|
|
19964
|
+
// on the fleet's thread — a path on a filesystem that stopped answering held the whole fleet there.
|
|
19965
|
+
const result = await runOnLoginShell(`p=$(command -v ${binary}) && [ -f "$p" ] && [ -x "$p" ] && printf '%s\n' "$p"`);
|
|
19101
19966
|
if (result.status !== 0)
|
|
19102
19967
|
return null;
|
|
19103
19968
|
const path = result.stdout.trim().split("\n").pop()?.trim() ?? "";
|
|
19104
|
-
|
|
19105
|
-
return null;
|
|
19106
|
-
accessSync(path, fsConstants.X_OK);
|
|
19107
|
-
return statSync(path).isFile() ? path : null;
|
|
19969
|
+
return isAbsolute(path) ? path : null;
|
|
19108
19970
|
}
|
|
19109
19971
|
catch {
|
|
19110
19972
|
return null;
|
|
19111
19973
|
}
|
|
19112
19974
|
}
|
|
19113
19975
|
/**
|
|
19114
|
-
* #1092: the binary in one of the directories its own installer installs to,
|
|
19115
|
-
*
|
|
19116
|
-
*
|
|
19117
|
-
*
|
|
19976
|
+
* #1092: the binary in one of the directories its own installer installs to, when no login shell can see it (the
|
|
19977
|
+
* installer recorded its PATH in an interactive-only rc file) — only those directories (#1092). #3009: through
|
|
19978
|
+
* backend-availability's install-directory check (the one resolveBackendBinary uses): one killable child, never
|
|
19979
|
+
* accessSync/statSync on the fleet's thread. An executable file counts, a symlink to one too (codex's launcher is one).
|
|
19118
19980
|
*/
|
|
19119
|
-
|
|
19120
|
-
|
|
19121
|
-
|
|
19122
|
-
continue;
|
|
19123
|
-
const path = join(dir, info.binary);
|
|
19124
|
-
try {
|
|
19125
|
-
accessSync(path, fsConstants.X_OK);
|
|
19126
|
-
if (statSync(path).isFile())
|
|
19127
|
-
return path;
|
|
19128
|
-
}
|
|
19129
|
-
catch { /* not there */ }
|
|
19130
|
-
}
|
|
19131
|
-
return null;
|
|
19981
|
+
async locateInstalledBackend(backend) {
|
|
19982
|
+
const info = BACKEND_INSTALLATION_INFO[backend];
|
|
19983
|
+
return info ? (await findExecutableIn(info.binary, info.binDirs?.(process.env, homedir()) ?? [])) ?? null : null;
|
|
19132
19984
|
}
|
|
19133
19985
|
/** Put an installed binary's directory on the fleet's PATH (#1059), once. */
|
|
19134
19986
|
adoptBinaryDirectory(binaryPath, backend) {
|
|
@@ -19444,20 +20296,26 @@ export class FleetManager {
|
|
|
19444
20296
|
if (claimed === "consumed")
|
|
19445
20297
|
return true;
|
|
19446
20298
|
const { entry: pending, action } = claimed;
|
|
20299
|
+
// #1490 row 27: the lifecycle the button was offered for — a replacement since (removal does not clear prompts) is stale.
|
|
20300
|
+
const current = pending.lifecycleCurrent ?? this.lifecyclePin(pending.instanceName);
|
|
19447
20301
|
this.eventLog?.insert(pending.instanceName, "hang_action", { action, userId: data.userId });
|
|
19448
20302
|
if (action === "wait") {
|
|
19449
20303
|
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.waiting", pending.instanceName));
|
|
19450
20304
|
return true;
|
|
19451
20305
|
}
|
|
20306
|
+
if (!current()) {
|
|
20307
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("restart.superseded", this.shownInstanceName(pending.instanceName)));
|
|
20308
|
+
return true;
|
|
20309
|
+
}
|
|
19452
20310
|
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.restarting", pending.instanceName));
|
|
19453
20311
|
try {
|
|
19454
|
-
await this.restartSingleInstance(pending.instanceName, { explicit: true });
|
|
20312
|
+
await this.restartSingleInstance(pending.instanceName, { explicit: true, ownerCurrent: current });
|
|
19455
20313
|
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restarted", pending.instanceName), pending.threadId);
|
|
19456
20314
|
}
|
|
19457
20315
|
catch (err) {
|
|
19458
20316
|
const message = err instanceof Error ? err.message : String(err);
|
|
19459
20317
|
this.logger.error({ err, instanceName: pending.instanceName }, "Hang force-restart failed");
|
|
19460
|
-
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restart_failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show hang restart error"));
|
|
20318
|
+
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, err instanceof RestartSupersededError ? message : t("hang.restart_failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show hang restart error"));
|
|
19461
20319
|
}
|
|
19462
20320
|
return true;
|
|
19463
20321
|
}
|
|
@@ -19475,36 +20333,7 @@ export class FleetManager {
|
|
|
19475
20333
|
"antigravity": ".agents/AGENTS.md",
|
|
19476
20334
|
"mock": "CLAUDE.md",
|
|
19477
20335
|
};
|
|
19478
|
-
static GENERAL_INSTRUCTIONS =
|
|
19479
|
-
|
|
19480
|
-
You are the fleet coordinator — the central entry point for this AgEnD fleet.
|
|
19481
|
-
You route tasks, manage instances, enforce policies, and synthesize results.
|
|
19482
|
-
Do NOT modify project files directly — delegate file changes to the project's instance.
|
|
19483
|
-
You CAN write code snippets, explain code, and answer technical questions directly.
|
|
19484
|
-
|
|
19485
|
-
## Task Routing
|
|
19486
|
-
|
|
19487
|
-
- **Handle directly**: no file/exec access needed, answerable from knowledge, ≤2 reasoning steps (Q&A, translation, status queries, code snippets).
|
|
19488
|
-
- **Delegate to 1 instance**: scoped to one project/repo, needs file access or execution.
|
|
19489
|
-
- **Coordinate multiple**: spans repos, outputs feed each other, or parallel helps (max 3 per task).
|
|
19490
|
-
|
|
19491
|
-
Instance discovery: start with list_instances(); follow the guidance in its response. Prefer reuse; never duplicate a running instance.
|
|
19492
|
-
|
|
19493
|
-
## Reply Contract
|
|
19494
|
-
|
|
19495
|
-
Every final response to the user contains: the result (the actual answer or deliverable) and gaps (anything incomplete — omit if none). Summarize instance reports; omit internal coordination noise.
|
|
19496
|
-
|
|
19497
|
-
## After Restart
|
|
19498
|
-
|
|
19499
|
-
BEFORE processing any new messages: 1. list_instances() 2. list_teams() 3. list_decisions(). Only then handle requests.
|
|
19500
|
-
|
|
19501
|
-
## Playbooks (on-demand skills)
|
|
19502
|
-
|
|
19503
|
-
Detailed procedures live in your skills — consult them when the situation comes up rather than from memory:
|
|
19504
|
-
- **delegation-playbook** — delegation protocol, loop prevention, parallel vs sequential, result/failure handling, team management, instance configuration tips.
|
|
19505
|
-
- **development-workflow** — the fleet-wide code-change policy you enforce when delegating code tasks.
|
|
19506
|
-
Plus the operational skills (fleet-health, instance-lifecycle, scheduling, session-management, …).
|
|
19507
|
-
`;
|
|
20336
|
+
static GENERAL_INSTRUCTIONS = GENERAL_ROLE_INSTRUCTIONS;
|
|
19508
20337
|
/** Ensure the general instance has its project instructions file + knowledge */
|
|
19509
20338
|
ensureGeneralInstructions(workDir, backendName, instanceName) {
|
|
19510
20339
|
measureSyncWork("fleet.generalInstructions", () => this.ensureGeneralInstructionsSync(workDir, backendName, instanceName));
|
|
@@ -19533,6 +20362,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19533
20362
|
resolveKnowledgeWorkDir(workDir, backend, instanceName) {
|
|
19534
20363
|
// Backend resolution may create/normalize the real cwd. Instructions and
|
|
19535
20364
|
// skills must land where the CLI actually runs, not an assumed path.
|
|
20365
|
+
// Caller pins resolveBackendBinary first so createBackend does not `which`.
|
|
19536
20366
|
try {
|
|
19537
20367
|
const resolved = createBackend(backend, join(getAgendHome(), "cli-env"))
|
|
19538
20368
|
.resolveWorkingDirectory?.(workDir, instanceName);
|
|
@@ -20878,7 +21708,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20878
21708
|
}));
|
|
20879
21709
|
const timer = setTimeout(() => this.pendingEffortSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
20880
21710
|
timer.unref?.();
|
|
20881
|
-
this.pendingEffortSelects.set(nonce, { instanceName: name, userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond });
|
|
21711
|
+
this.pendingEffortSelects.set(nonce, { instanceName: name, instanceCurrent: this.lifecyclePin(name), userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond });
|
|
20882
21712
|
try {
|
|
20883
21713
|
await data.respondChoices(t("effort.menu", this.effortMenuHeader(name)), choices);
|
|
20884
21714
|
}
|
|
@@ -20913,7 +21743,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20913
21743
|
}
|
|
20914
21744
|
}, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
20915
21745
|
timer.unref?.();
|
|
20916
|
-
this.pendingEffortSelects.set(nonce, { instanceName, userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
21746
|
+
this.pendingEffortSelects.set(nonce, { instanceName, instanceCurrent: this.lifecyclePin(instanceName), userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
20917
21747
|
try {
|
|
20918
21748
|
const menuMessageId = await adapter.promptUser(chatId, t("effort.menu", this.effortMenuHeader(instanceName)), choices, { threadId });
|
|
20919
21749
|
const pending = this.pendingEffortSelects.get(nonce);
|
|
@@ -20938,6 +21768,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20938
21768
|
const pending = this.pendingEffortSelects.get(match[1]);
|
|
20939
21769
|
if (!pending)
|
|
20940
21770
|
return true;
|
|
21771
|
+
// #1490 row 27: the menu was for one lifecycle of the instance; stopped, restarted or replaced since, it is stale.
|
|
21772
|
+
if (!pending.instanceCurrent()) {
|
|
21773
|
+
this.pendingEffortSelects.delete(match[1]);
|
|
21774
|
+
clearTimeout(pending.timer);
|
|
21775
|
+
data.ack?.(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName)));
|
|
21776
|
+
await pending.respond(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))).catch(() => { });
|
|
21777
|
+
return true;
|
|
21778
|
+
}
|
|
20941
21779
|
// The admin who opened the menu, through the adapter that posted it, in the same channel — and still an admin
|
|
20942
21780
|
// when they click (#754 audit): the menu lives for a minute, and a click carries its own callback data.
|
|
20943
21781
|
const cbChannel = data.threadId ?? data.chatId;
|
|
@@ -20980,17 +21818,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20980
21818
|
let result;
|
|
20981
21819
|
try {
|
|
20982
21820
|
// Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
|
|
20983
|
-
result =
|
|
20984
|
-
|
|
20985
|
-
|
|
20986
|
-
if (
|
|
20987
|
-
|
|
21821
|
+
result = !pending.instanceCurrent() ? t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))
|
|
21822
|
+
: this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
|
|
21823
|
+
? await withSpawnHoldReply(async (text) => {
|
|
21824
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
21825
|
+
if (progressMsgId)
|
|
21826
|
+
await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
|
|
21827
|
+
else
|
|
21828
|
+
await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
|
|
21829
|
+
}
|
|
20988
21830
|
else
|
|
20989
|
-
await pending.
|
|
20990
|
-
}
|
|
20991
|
-
else
|
|
20992
|
-
await pending.respond(text);
|
|
20993
|
-
}, () => this.applyEffort(pending.instanceName, level)) : t("menu.click_stale");
|
|
21831
|
+
await pending.respond(text);
|
|
21832
|
+
}, () => this.applyEffort(pending.instanceName, level, { current: pending.instanceCurrent })) : t("menu.click_stale");
|
|
20994
21833
|
}
|
|
20995
21834
|
catch (err) {
|
|
20996
21835
|
this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
|
|
@@ -21047,7 +21886,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21047
21886
|
const choices = this.modelMenuChoices(name, nonce, options, currentModel);
|
|
21048
21887
|
const timer = setTimeout(() => this.pendingModelSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
21049
21888
|
timer.unref?.();
|
|
21050
|
-
this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond, respondChoices: data.respondChoices });
|
|
21889
|
+
this.pendingModelSelects.set(nonce, { instanceName: name, instanceCurrent: this.lifecyclePin(name), model: "", userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond, respondChoices: data.respondChoices });
|
|
21051
21890
|
try {
|
|
21052
21891
|
await data.respondChoices(t("model.menu", `**${currentDisplay}**`), choices);
|
|
21053
21892
|
}
|
|
@@ -21083,7 +21922,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21083
21922
|
}
|
|
21084
21923
|
}, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
21085
21924
|
timer.unref?.();
|
|
21086
|
-
this.pendingModelSelects.set(nonce, { instanceName, model: "", userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
21925
|
+
this.pendingModelSelects.set(nonce, { instanceName, instanceCurrent: this.lifecyclePin(instanceName), model: "", userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
21087
21926
|
try {
|
|
21088
21927
|
const menuMessageId = await adapter.promptUser(chatId, t("model.menu", currentDisplay), choices, { threadId });
|
|
21089
21928
|
const pending = this.pendingModelSelects.get(nonce);
|
|
@@ -21256,6 +22095,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21256
22095
|
const pending = this.pendingModelSelects.get(match[1]);
|
|
21257
22096
|
if (!pending)
|
|
21258
22097
|
return true;
|
|
22098
|
+
// #1490 row 27: the menu was for one lifecycle of the instance; stopped, restarted or replaced since, it is stale.
|
|
22099
|
+
if (!pending.instanceCurrent()) {
|
|
22100
|
+
this.pendingModelSelects.delete(match[1]);
|
|
22101
|
+
clearTimeout(pending.timer);
|
|
22102
|
+
data.ack?.(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName)));
|
|
22103
|
+
await pending.respond(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))).catch(() => { });
|
|
22104
|
+
return true;
|
|
22105
|
+
}
|
|
21259
22106
|
// The admin who opened the menu, through the adapter that posted it, in the same channel — and still an admin
|
|
21260
22107
|
// when they click (#754 audit): the menu lives for a minute, and a click carries its own callback data.
|
|
21261
22108
|
const cbChannel = data.threadId ?? data.chatId;
|
|
@@ -21316,17 +22163,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21316
22163
|
let result;
|
|
21317
22164
|
try {
|
|
21318
22165
|
// Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
|
|
21319
|
-
result =
|
|
21320
|
-
|
|
21321
|
-
|
|
21322
|
-
if (
|
|
21323
|
-
|
|
22166
|
+
result = !pending.instanceCurrent() ? t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))
|
|
22167
|
+
: this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
|
|
22168
|
+
? await withSpawnHoldReply(async (text) => {
|
|
22169
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
22170
|
+
if (progressMsgId)
|
|
22171
|
+
await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
|
|
22172
|
+
else
|
|
22173
|
+
await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
|
|
22174
|
+
}
|
|
21324
22175
|
else
|
|
21325
|
-
await pending.
|
|
21326
|
-
}
|
|
21327
|
-
else
|
|
21328
|
-
await pending.respond(text);
|
|
21329
|
-
}, () => this.applyModel(pending.instanceName, model)) : t("menu.click_stale");
|
|
22176
|
+
await pending.respond(text);
|
|
22177
|
+
}, () => this.applyModel(pending.instanceName, model, { current: pending.instanceCurrent })) : t("menu.click_stale");
|
|
21330
22178
|
}
|
|
21331
22179
|
catch (err) {
|
|
21332
22180
|
this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
|
|
@@ -21423,7 +22271,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21423
22271
|
* restart backends only read it at launch, so it is persisted and the
|
|
21424
22272
|
* instance respawns.
|
|
21425
22273
|
*/
|
|
21426
|
-
async applyEffort(instanceName, requested) {
|
|
22274
|
+
async applyEffort(instanceName, requested, opts) {
|
|
22275
|
+
// #1490 row 27 (main): a menu's choice is for the lifecycle it was offered to — asked first and fenced into the restart.
|
|
22276
|
+
if (opts?.current && !opts.current())
|
|
22277
|
+
return t("menu.instance_replaced", this.shownInstanceName(instanceName));
|
|
21427
22278
|
if (isWindowsInstance(this.fleetConfig?.instances[instanceName]))
|
|
21428
22279
|
return t("effort.unsupported", this.backendNameForInstance(instanceName));
|
|
21429
22280
|
const level = requested.trim().toLowerCase();
|
|
@@ -21460,10 +22311,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21460
22311
|
this.pasteRawToClassicInstance(instanceName, `/effort ${applied}`);
|
|
21461
22312
|
return `${warn}${t("effort.runtime_success", instanceName, applied)}`;
|
|
21462
22313
|
}
|
|
21463
|
-
await this.restartSingleInstance(instanceName);
|
|
22314
|
+
await this.restartSingleInstance(instanceName, { ownerCurrent: opts?.current });
|
|
21464
22315
|
return `${warn}${t("effort.restart_success", instanceName, applied)}`;
|
|
21465
22316
|
}
|
|
21466
|
-
async applyModel(instanceName, model) {
|
|
22317
|
+
async applyModel(instanceName, model, opts) {
|
|
22318
|
+
// #1490 row 27: a menu's choice is for the lifecycle it was offered to — asked first, after the runtime switch's
|
|
22319
|
+
// await, and fenced into the restart.
|
|
22320
|
+
if (opts?.current && !opts.current())
|
|
22321
|
+
return t("menu.instance_replaced", this.shownInstanceName(instanceName));
|
|
21467
22322
|
// Reject model names with newlines or control characters: they would be
|
|
21468
22323
|
// persisted to fleet.yaml and pasted raw into the CLI (P3 from #1490 audit).
|
|
21469
22324
|
if (/[\x00-\x1f\x7f]/.test(model)) {
|
|
@@ -21503,6 +22358,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21503
22358
|
// #1690: claude-code's runtime switch reports what it came to; the model is saved unless claude was told No.
|
|
21504
22359
|
if (strategy === "runtime" && backendName === "claude-code") {
|
|
21505
22360
|
const result = await this.runRuntimeModelSwitch(instanceName, model);
|
|
22361
|
+
if (opts?.current && !opts.current())
|
|
22362
|
+
return t("menu.instance_replaced", this.shownInstanceName(instanceName));
|
|
21506
22363
|
if (result.outcome === "mismatch")
|
|
21507
22364
|
return `${warn}${t("model.switch_mismatch", instanceName, result.shown, model)}`;
|
|
21508
22365
|
if (result.outcome === "not_sent")
|
|
@@ -21523,7 +22380,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21523
22380
|
this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
|
|
21524
22381
|
return `${warn}${t("model.runtime_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
|
|
21525
22382
|
}
|
|
21526
|
-
await this.restartSingleInstance(instanceName);
|
|
22383
|
+
await this.restartSingleInstance(instanceName, { ownerCurrent: opts?.current });
|
|
21527
22384
|
return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
|
|
21528
22385
|
}
|
|
21529
22386
|
/** Save an instance's model (fleet.yaml, or its ClassicBot channel). False when neither holds it. */
|
|
@@ -22464,6 +23321,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
22464
23321
|
const settingsControlStopped = settingsControl?.close();
|
|
22465
23322
|
this.publicLinkAutoOpen?.stop();
|
|
22466
23323
|
const publicStopped = this.publicWebLink?.close("fleet shutdown");
|
|
23324
|
+
this.webRemoteAccessState?.close(); // #3008: an unsaved switch gets its last write
|
|
22467
23325
|
const sharesStopped = this.officeSharesInstance?.close(); // #2636: every Office share ends, and its tunnel
|
|
22468
23326
|
// #1386: every live "Needs you" message says the fleet stopped (capabilities revoked first) — while the
|
|
22469
23327
|
// adapters can still edit. Bounded: a platform that does not answer must not hold the shutdown.
|
|
@@ -22502,6 +23360,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
22502
23360
|
}
|
|
22503
23361
|
this.eventLoopWatch?.stop();
|
|
22504
23362
|
this.eventLoopWatch = null;
|
|
23363
|
+
this.stallReporter?.stop();
|
|
23364
|
+
this.stallReporter = null;
|
|
22505
23365
|
// A login/install window is a dedicated tmux server with its own TTL
|
|
22506
23366
|
// timer and HTTP listener living in THIS process: without an explicit
|
|
22507
23367
|
// shutdown it would outlive us as an owner-less login CLI (sol B3).
|
|
@@ -24963,7 +25823,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
24963
25823
|
if (decision.reason === "no-credential" && req.method === "GET"
|
|
24964
25824
|
&& String(req.headers.accept ?? "").includes("text/html")
|
|
24965
25825
|
&& isWebPageNavigation(requestPath)) {
|
|
24966
|
-
|
|
25826
|
+
const link = gatewayRequestContext(req);
|
|
25827
|
+
serveSigninPage(res, 401, { webOnly: this.isWebOnlyFleet(), outside: !!link && this.isOutsideLink(link.exposureId) });
|
|
24967
25828
|
return;
|
|
24968
25829
|
}
|
|
24969
25830
|
res.writeHead(decision.status);
|
|
@@ -25618,6 +26479,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
25618
26479
|
}
|
|
25619
26480
|
return {
|
|
25620
26481
|
name,
|
|
26482
|
+
general_topic: nativeConfig?.general_topic === true,
|
|
25621
26483
|
...(isNativePaneInstance(nativeConfig) ? { runtime: "windows", native_adapter: "pane", native_agent_id: nativeConfig.native_agent_id,
|
|
25622
26484
|
...(() => {
|
|
25623
26485
|
try {
|
|
@@ -25655,6 +26517,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
25655
26517
|
role: identity.role || undefined,
|
|
25656
26518
|
tags: identity.tags,
|
|
25657
26519
|
teams: instanceTeams(this.fleetConfig?.teams, name), // #2172: the sidebar groups a team's members under it
|
|
26520
|
+
// #3008 G2: a web-only fleet's General (the agent its owner ticked) — the sidebar pins it first.
|
|
26521
|
+
...(this.isWebOnlyFleet() && this.fleetConfig?.instances[name]?.general_topic === true ? { general: true } : {}),
|
|
25658
26522
|
last_reply: this.webChatHistory.lastAgentReply(name) ?? undefined, // #2179: its last reply to a person (ISO)
|
|
25659
26523
|
// #2855: its last reply that reached the channel — the only reply time a guest's sidebar gets (guestStatus).
|
|
25660
26524
|
last_reply_channel: this.webChatHistory.lastAgentReply(name, { channel: true }) ?? undefined,
|