@songsid/agend 2.3.5-alpha.2 → 2.3.5-alpha.3
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/auth-api.d.ts +21 -0
- package/dist/auth-api.js +91 -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.js +11 -1
- 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 +143 -8
- package/dist/fleet-manager.js +874 -166
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-windows-runtime.js +7 -0
- 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/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 +5 -0
- package/dist/public-web-link.js +4 -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-bundle.js +3 -0
- package/dist/runtime-bundle.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-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/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 +35 -0
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.d.ts +1 -0
- package/dist/settings-http-confirmation.js +4 -0
- 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/general-location.js +71 -0
- package/dist/ui/native-pane-recovery.js +1 -1
- package/dist/ui/panel-chat.js +2 -0
- package/dist/ui/panel-needs.js +2 -0
- package/dist/ui/panel-settings.js +5 -1
- package/dist/ui/settings-dialogs.js +8 -2
- package/dist/ui/settings-model.js +2 -1
- package/dist/ui/settings-outside.js +176 -0
- package/dist/ui/settings-registry.js +1 -0
- package/dist/ui/settings-strings.js +2 -0
- package/dist/ui/shared/app-i18n.js +2 -2
- package/dist/ui/shared/app.css +16 -1
- package/dist/ui/shared/instance-nav.js +13 -3
- package/dist/ui/shared/panel-view.js +266 -17
- package/dist/ui/shared/qrcode.module.js +2237 -0
- package/dist/ui/shared/signin.js +39 -5
- 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/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 +12 -0
- package/dist/web-api.js +77 -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 +12 -0
- package/dist/windows-agents.js +24 -0
- package/dist/windows-agents.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,8 @@
|
|
|
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 { buildFleetInstructions } from "./instructions.js";
|
|
5
6
|
import { supportsVerifiedKiroMigration, getCachedKiroCliCompatibility, kiroV1DbPath, kiroV2SessionsDir } from "./backend/kiro.js";
|
|
6
7
|
import { listKiroV1Sessions, readKiroClassicLinks } from "./backend/kiro-identity.js";
|
|
7
8
|
import { readKiroV1SavedModel } from "./backend/kiro-retired-model.js";
|
|
@@ -18,6 +19,10 @@ import { settingsDisplay } from "./settings-change.js";
|
|
|
18
19
|
import { SettingsConfirmationError, SettingsConfirmationStore, settingsSourceLabel } from "./settings-confirmation.js";
|
|
19
20
|
import { SettingsHttpConfirmation } from "./settings-http-confirmation.js";
|
|
20
21
|
import { SettingsControlServer } from "./settings-control.js";
|
|
22
|
+
import { SettingsRouteLifetime } from "./settings-route-lifetime.js";
|
|
23
|
+
import { GeneralManagedFileError } from "./runtime-general-protocol.js";
|
|
24
|
+
import { GeneralBindings, generalBindingRevision, parseGeneralSwitchInput, switchGeneralLocation } from "./general-binding.js";
|
|
25
|
+
import { confirmGeneralHostExit } from "./general-host-owner.js";
|
|
21
26
|
import { settingsFingerprint, SettingsExecution, settingsRevision, noteSettingsWrite, settingsFileResource, assertSettingsLease, trySettingsLease, waitSettingsLease, settingsUndo, undoSettingsPaths } from "./settings-transaction.js";
|
|
22
27
|
import { performance } from "node:perf_hooks";
|
|
23
28
|
import { withSpawnHoldReply } from "./spawn-hold-reply.js";
|
|
@@ -33,7 +38,7 @@ import { clearPublicLinkOwner, readPublicLinkOwner, writePublicLinkOwner } from
|
|
|
33
38
|
import { PUBLIC_PREVIEW_FRAME_SRC, PUBLIC_PREVIEW_TUNNEL_DIR, PublicPreview, publicPreviewWanted } from "./public-preview.js";
|
|
34
39
|
import { CloudflaredProvider } from "./tunnel/cloudflared.js";
|
|
35
40
|
import { ensureCloudflared } from "./tunnel/cloudflared-install.js";
|
|
36
|
-
import { unattendedInstallCommand } from "./unattended-install.js";
|
|
41
|
+
import { unattendedInstallCommand, loginShell } from "./unattended-install.js";
|
|
37
42
|
import { InstallStream } from "./install-stream.js";
|
|
38
43
|
import { namedTestLane, runNamedLinkTest } from "./tunnel/named-link-test.js";
|
|
39
44
|
import { daemonTopicMode, isWebOnlyFleetConfig } from "./web-only-fleet.js";
|
|
@@ -45,10 +50,10 @@ import { withinBudget } from "./monotonic-budget.js";
|
|
|
45
50
|
import { measureSyncWork } from "./sync-work-attribution.js";
|
|
46
51
|
import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
|
|
47
52
|
import { ProfileControlServer } from "./profile-control.js";
|
|
48
|
-
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync,
|
|
53
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, realpathSync, lstatSync, closeSync } from "node:fs";
|
|
49
54
|
import { atomicWriteFileSync } from "./atomic-write.js";
|
|
50
55
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
51
|
-
import { freemem, totalmem, homedir } from "node:os";
|
|
56
|
+
import { freemem, totalmem, homedir, hostname } from "node:os";
|
|
52
57
|
import { effectiveCpuCount } from "./cpu-quota.js";
|
|
53
58
|
import { createServer } from "node:http";
|
|
54
59
|
import { Worker } from "node:worker_threads";
|
|
@@ -62,6 +67,7 @@ import { applyKiroMigration, kiroMigrationPrompt, planKiroMigration } from "./ki
|
|
|
62
67
|
import { fleetLabel } from "./fleet-label.js";
|
|
63
68
|
import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, sameUpdateMarker, setUpdateProgressStage, updateMarkerIdentity, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
|
|
64
69
|
import { formatUpdateProgress } from "./update-progress.js";
|
|
70
|
+
import { startStallReporter } from "./stall-reporter.js";
|
|
65
71
|
import { sdNotify, sdNotifyBlocking, setSdNotifyWarn, startSdWatchdog } from "./sd-notify.js";
|
|
66
72
|
import { StartupMemoryDeadline, fleetStopConcurrency } from "./startup-memory-deadline.js";
|
|
67
73
|
import { readFleetMemory } from "./process-memory.js";
|
|
@@ -80,7 +86,7 @@ import { isProbeableRouteTarget } from "./fleet-context.js";
|
|
|
80
86
|
import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, loadFleetConfigSource, loadRawFleetConfigSource, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG, autoGeneralInstanceConfig } from "./config.js";
|
|
81
87
|
import { EventLog } from "./event-log.js";
|
|
82
88
|
import { binaryProbe } from "./binary-probe.js";
|
|
83
|
-
import { AVAILABILITY_STALE_MS, BackendAvailabilityStore, hostAvailabilityProbes } from "./backend-availability.js";
|
|
89
|
+
import { AVAILABILITY_STALE_MS, BackendAvailabilityStore, hostAvailabilityProbes, findExecutableIn, resolveBackendBinary } from "./backend-availability.js";
|
|
84
90
|
import { classifySqliteOpenError } from "./sqlite-open-errors.js";
|
|
85
91
|
import { OutboxOpenError } from "./outbox-open-error.js";
|
|
86
92
|
import { AdapterWorld } from "./adapter-world.js";
|
|
@@ -286,6 +292,7 @@ import { bypassesWebGate, handleAuthRequest, serveGuestRefusedPage, serveSigninP
|
|
|
286
292
|
import { isWebPageNavigation } from "./web-shell-routes.js";
|
|
287
293
|
import { guestSessionPolicy, localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
|
|
288
294
|
import { WebNotices, WEB_NOTICES_FILE } from "./web-notices.js";
|
|
295
|
+
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
296
|
import { authorityNow, evaluateWebAuthority, HOST_OPERATOR, VERIFIED_SESSION_HOURS, webAuthorityPolicy } from "./authority/web-authority.js";
|
|
290
297
|
import { AuthorityStateStore } from "./authority/authority-state.js";
|
|
291
298
|
import { SessionAuthorizations } from "./authority/session-authorizations.js";
|
|
@@ -645,6 +652,14 @@ function emojiListFilter(opts) {
|
|
|
645
652
|
}
|
|
646
653
|
/** #1920: a guest's scope until phase 2 derives one: nothing. */
|
|
647
654
|
const NO_GUEST_SCOPE = new Set();
|
|
655
|
+
/** #1490 row 27: a restart asked for one lifecycle of an instance after it was stopped, restarted or replaced. */
|
|
656
|
+
export class RestartSupersededError extends Error {
|
|
657
|
+
/** `shown`: the instance as the chat list names it (shownInstanceName). */
|
|
658
|
+
constructor(shown) {
|
|
659
|
+
super(t("restart.superseded", shown));
|
|
660
|
+
this.name = "RestartSupersededError";
|
|
661
|
+
}
|
|
662
|
+
}
|
|
648
663
|
export class FleetManager {
|
|
649
664
|
dataDir;
|
|
650
665
|
static signalTarget = null;
|
|
@@ -765,8 +780,14 @@ export class FleetManager {
|
|
|
765
780
|
changed: event => {
|
|
766
781
|
if (event?.type === "machine" && this.runtimeHub?.machine(event.machineId)?.status !== "online") {
|
|
767
782
|
for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {}))
|
|
768
|
-
if (config.machine_id === event.machineId)
|
|
783
|
+
if (config.machine_id === event.machineId) {
|
|
769
784
|
this.nativeMcpStore?.closeInstance(name);
|
|
785
|
+
if (config.general_topic) {
|
|
786
|
+
this.cancelPendingDeliveries(name);
|
|
787
|
+
this.holdGeneralOldDeliveries(name);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
this.needsYou?.poke();
|
|
770
791
|
}
|
|
771
792
|
if (this.runtimeStatusScheduled || this.shuttingDown)
|
|
772
793
|
return;
|
|
@@ -957,7 +978,8 @@ export class FleetManager {
|
|
|
957
978
|
const now = this.fleetConfig?.instances[name];
|
|
958
979
|
if (this.shuttingDown || !isNativePaneInstance(now) || now?.native_agent_id !== config.native_agent_id
|
|
959
980
|
|| 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
|
|
981
|
+
|| now?.model !== config.model || now?.native_sandbox !== config.native_sandbox || now?.systemPrompt !== config.systemPrompt || now?.workflow !== config.workflow
|
|
982
|
+
|| now?.general_topic !== config.general_topic || now?.native_general_binding !== config.native_general_binding)
|
|
961
983
|
throw Error("Native pane configuration or lifetime changed");
|
|
962
984
|
if (config.machine_id ? this.remotePaneDrain || !remoteRuntimeEnabled() : this.localPaneDrain || !localWindowsCliEnabled())
|
|
963
985
|
throw Error("Native runtime is switched off");
|
|
@@ -982,10 +1004,10 @@ export class FleetManager {
|
|
|
982
1004
|
}, subscribe: listener => hub.subscribe(listener),
|
|
983
1005
|
epoch: () => hub.paneTransportEpoch(machineId), version: provider => hub.machine(machineId)?.inventory?.clis.find(row => row.id === provider && row.runnable)?.version ?? null };
|
|
984
1006
|
},
|
|
985
|
-
instructions: (name, config) =>
|
|
1007
|
+
instructions: (name, config) => buildNativeFleetInstructions({ instanceName: name, workingDirectory: config.working_directory,
|
|
986
1008
|
displayName: config.display_name, description: config.description, customPrompt: config.systemPrompt,
|
|
987
1009
|
workflow: config.workflow, taskBoard: !dashboardRefusalIn(this.fleetConfig, name, "task"),
|
|
988
|
-
runtimeIdentity: { kind: "fleet-topic", backend: config.backend ?? "codex", model: config.model || "default" } }),
|
|
1010
|
+
runtimeIdentity: { kind: "fleet-topic", backend: config.backend ?? "codex", model: config.model || "default" } }, config.general_topic === true),
|
|
989
1011
|
menu: name => TOOLS.filter(tool => allowed(name, tool.name)).map(tool => ({ ...tool })),
|
|
990
1012
|
allowed: (name, tool, args) => allowed(name, tool) && !nativePaneToolRefusal(tool, args), published: (name, daemon) => this.daemons.get(name) === daemon,
|
|
991
1013
|
invoke: async (daemon, input, current, name) => {
|
|
@@ -1010,8 +1032,308 @@ export class FleetManager {
|
|
|
1010
1032
|
},
|
|
1011
1033
|
});
|
|
1012
1034
|
}
|
|
1035
|
+
/** Prepare only bundled role files; this neither switches General nor starts a provider process. */
|
|
1036
|
+
async prepareWindowsGeneralWorkspace(name, machineId, cli, bindingId, authorized) {
|
|
1037
|
+
const config = this.fleetConfig?.instances[name], hub = this.runtimeCoordinator(), epoch = hub.paneTransportEpoch(machineId);
|
|
1038
|
+
if (!config?.general_topic || !epoch)
|
|
1039
|
+
throw Error("General or its paired runtime is unavailable");
|
|
1040
|
+
const current = () => authorized() && !this.shuttingDown && remoteRuntimeEnabled() && this.runtimeHub === hub
|
|
1041
|
+
&& this.fleetConfig?.instances[name] === config && hub.paneTransportEpoch(machineId) === epoch;
|
|
1042
|
+
const [{ loadGeneralKnowledge }, { validateGeneralWorkspaceRequest, validateGeneralWorkspaceReceipt, generalWorkspaceUpgrade }, { assertAuditedNativePaneVersion }] = await Promise.all([
|
|
1043
|
+
import("./runtime-general-workspace.js"), import("./runtime-general-protocol.js"), import("./runtime-pane-native.js"),
|
|
1044
|
+
]);
|
|
1045
|
+
if (!current())
|
|
1046
|
+
throw Error("General workspace authority expired");
|
|
1047
|
+
assertAuditedNativePaneVersion(cli, hub.machine(machineId)?.inventory?.clis.find(row => row.id === cli && row.runnable)?.version ?? null);
|
|
1048
|
+
const request = validateGeneralWorkspaceRequest({ action: "general-workspace", bindingId, instance: name, cli, packHash: loadGeneralKnowledge(cli).hash });
|
|
1049
|
+
const inventory = await hub.callPane(machineId, { action: "list" }, maxAgentsPerMachine());
|
|
1050
|
+
if (!current())
|
|
1051
|
+
throw Error("General workspace authority expired");
|
|
1052
|
+
if (inventory?.capabilities?.generalWorkspace !== true)
|
|
1053
|
+
throw new NativePaneRecoveryError({ kind: "host-upgrade" });
|
|
1054
|
+
const result = await hub.callPane(machineId, request, maxAgentsPerMachine());
|
|
1055
|
+
if (!current())
|
|
1056
|
+
throw Error("General workspace authority expired");
|
|
1057
|
+
if (generalWorkspaceUpgrade(result, request))
|
|
1058
|
+
throw new NativePaneRecoveryError({ kind: "host-upgrade" });
|
|
1059
|
+
return validateGeneralWorkspaceReceipt(result, request);
|
|
1060
|
+
}
|
|
1061
|
+
generalLocationFlights = new Map();
|
|
1062
|
+
generalAttentionEpisodes = new Map();
|
|
1063
|
+
generalBindings() { return new GeneralBindings(this.dataDir); }
|
|
1064
|
+
generalLocations() {
|
|
1065
|
+
const store = this.generalBindings();
|
|
1066
|
+
return { generals: Object.entries(this.fleetConfig?.instances ?? {}).filter(([, cfg]) => cfg.general_topic).map(([name, cfg]) => {
|
|
1067
|
+
const record = store.read(name), machine = cfg.machine_id ? this.runtimeHub?.machine(cfg.machine_id) : null;
|
|
1068
|
+
return { name, displayName: cfg.display_name ?? name, location: isWindowsInstance(cfg) ? "windows" : "host",
|
|
1069
|
+
machineId: cfg.machine_id ?? null, cli: cfg.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code", model: cfg.model ?? "",
|
|
1070
|
+
revision: generalBindingRevision(record, cfg), canSwitchBack: !!record && record.active !== "host",
|
|
1071
|
+
offline: isWindowsInstance(cfg) && (!remoteRuntimeEnabled() || machine?.status !== "online" || !this.runtimeHub?.paneTransportEpoch(cfg.machine_id)),
|
|
1072
|
+
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,
|
|
1073
|
+
hold: record?.hold ?? (record?.pending ? record.pending.phase === "selected" ? "start" : "exit" : null), switching: this.generalLocationFlights.has(name) };
|
|
1074
|
+
}), machines: (this.runtimeHub?.listMachines() ?? []).filter(row => row.status !== "pending" && row.status !== "revoked").map(row => ({
|
|
1075
|
+
id: row.id, name: row.name, online: row.status === "online" && !!this.runtimeHub?.paneTransportEpoch(row.id),
|
|
1076
|
+
clis: row.inventory?.clis.filter(cli => cli.runnable && (cli.id === "claude-code" || cli.id === "codex")).map(cli => ({ id: cli.id, version: cli.version })) ?? [],
|
|
1077
|
+
})) };
|
|
1078
|
+
}
|
|
1079
|
+
/** Pure preflight for the confirmation diff and stale-tab refusal; never prepares files or starts a CLI. */
|
|
1080
|
+
previewGeneralLocation(raw) {
|
|
1081
|
+
const input = parseGeneralSwitchInput(raw), cfg = this.fleetConfig?.instances[input.name];
|
|
1082
|
+
if (!cfg?.general_topic || this.shuttingDown)
|
|
1083
|
+
throw Error("General is unavailable");
|
|
1084
|
+
const record = this.generalBindings().read(input.name);
|
|
1085
|
+
if (input.revision !== generalBindingRevision(record, cfg))
|
|
1086
|
+
throw Error("General location changed; read its settings again");
|
|
1087
|
+
const connections = cfg.channel_id ? [cfg.channel_id] : [];
|
|
1088
|
+
const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
1089
|
+
if (channels.length && (connections.length !== 1 || channels.filter(row => (row.id ?? row.type) === connections[0]).length !== 1))
|
|
1090
|
+
throw Error("General's connection cannot be resolved");
|
|
1091
|
+
if (input.target.location === "host") {
|
|
1092
|
+
if (!record)
|
|
1093
|
+
throw Error("General's saved host location is unavailable");
|
|
1094
|
+
return { input, connections, summary: [t("general_location.confirm_host"), t("general_location.history")] };
|
|
1095
|
+
}
|
|
1096
|
+
const active = record?.windows.find(row => row.id === record.active);
|
|
1097
|
+
if (!record?.pending && record?.hold !== "start" && active?.machineId === input.target.machineId && active.cli === input.target.cli)
|
|
1098
|
+
throw Error("General already runs at this location");
|
|
1099
|
+
const machine = this.runtimeHub?.machine(input.target.machineId);
|
|
1100
|
+
if (!remoteRuntimeEnabled() || machine?.status !== "online" || !this.runtimeHub?.paneTransportEpoch(machine.id))
|
|
1101
|
+
throw Error(t("general_location.offline"));
|
|
1102
|
+
const provider = input.target.cli;
|
|
1103
|
+
if (!machine.inventory?.clis.some(cli => cli.id === provider && cli.runnable))
|
|
1104
|
+
throw Error("The chosen Windows CLI is unavailable");
|
|
1105
|
+
return { input, connections, summary: [t("general_location.confirm_windows", settingsDisplay(machine.name)), t("general_location.history")] };
|
|
1106
|
+
}
|
|
1107
|
+
/** All entry points, including automatic native recovery, must respect an interrupted binding switch. */
|
|
1108
|
+
assertGeneralBindingStart(name, config) {
|
|
1109
|
+
if (!config.general_topic)
|
|
1110
|
+
return;
|
|
1111
|
+
try {
|
|
1112
|
+
this.generalBindings().assertStart(name, config);
|
|
1113
|
+
}
|
|
1114
|
+
catch (err) {
|
|
1115
|
+
this.logger.warn({ name, err }, "General location start held by its saved binding");
|
|
1116
|
+
this.needsYou?.poke();
|
|
1117
|
+
throw new UnsupportedCliError(t("general_location.exit_unconfirmed"));
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
/** Only the Settings replay's confirmed admin can enter; the whole switch owns one lifecycle transition. */
|
|
1121
|
+
async switchGeneralLocation(raw, authorized, execution) {
|
|
1122
|
+
const { input } = this.previewGeneralLocation(raw), name = input.name;
|
|
1123
|
+
return this.lifecycle.runTransition(name, async (transition) => {
|
|
1124
|
+
execution.assert();
|
|
1125
|
+
if (!authorized() || this.generalLocationFlights.has(name))
|
|
1126
|
+
throw Error("General location authority expired or a switch is already running");
|
|
1127
|
+
const token = Symbol(name), generation = this.settingsGeneration;
|
|
1128
|
+
const destinationEpoch = input.target.location === "windows" ? this.runtimeHub?.paneTransportEpoch(input.target.machineId) : null;
|
|
1129
|
+
let expected = this.fleetConfig.instances[name], stopGeneration = this.explicitStopGeneration.get(name) ?? 0, epoch = this.lifecycle.epochOf(name);
|
|
1130
|
+
const hostSnapshot = !isWindowsInstance(expected) ? structuredClone(this.daemons.get(name)?.getConfigSnapshot() ?? expected) : undefined;
|
|
1131
|
+
const current = () => !this.shuttingDown && this.settingsGeneration === generation && authorized()
|
|
1132
|
+
&& this.generalLocationFlights.get(name) === token && this.fleetConfig?.instances[name] === expected
|
|
1133
|
+
&& (input.target.location !== "windows" || remoteRuntimeEnabled() && !!destinationEpoch && this.runtimeHub?.paneTransportEpoch(input.target.machineId) === destinationEpoch)
|
|
1134
|
+
&& (this.explicitStopGeneration.get(name) ?? 0) === stopGeneration && this.lifecycle.epochOf(name) === epoch;
|
|
1135
|
+
const check = () => { if (!current())
|
|
1136
|
+
throw Error("General location switch was superseded"); };
|
|
1137
|
+
this.generalLocationFlights.set(name, token);
|
|
1138
|
+
// Old ephemeral waiters carry this epoch; they cannot wake into the selected conversation.
|
|
1139
|
+
this.cancelPendingDeliveries(name);
|
|
1140
|
+
let lease = null;
|
|
1141
|
+
try {
|
|
1142
|
+
lease = await waitSettingsLease([settingsFileResource(this.getSettingsConfigPath()), `general:${this.dataDir}:${name}`], () => current() && execution.current(), performance.now() + execution.remainingMs, execution.owner);
|
|
1143
|
+
const store = this.generalBindings();
|
|
1144
|
+
const result = await switchGeneralLocation({ store, current: () => expected, assert: check,
|
|
1145
|
+
prepare: async (target, retained) => {
|
|
1146
|
+
if (target.location !== "windows")
|
|
1147
|
+
return null;
|
|
1148
|
+
const old = retained.windows.find(row => row.machineId === target.machineId && row.cli === target.cli);
|
|
1149
|
+
const id = old?.id ?? randomUUID(), agentId = old?.agentId ?? randomUUID();
|
|
1150
|
+
const publication = await this.prepareWindowsGeneralWorkspace(name, target.machineId, target.cli, id, () => current() && execution.current());
|
|
1151
|
+
check();
|
|
1152
|
+
execution.assert();
|
|
1153
|
+
const config = { ...structuredClone(expected), runtime: "windows", machine_id: target.machineId,
|
|
1154
|
+
native_adapter: "pane", native_agent_id: agentId, native_general_binding: id, native_sandbox: "workspace-write",
|
|
1155
|
+
backend: target.cli, model: target.model, working_directory: publication.cwd, systemPrompt: "", agent_mode: "cli" };
|
|
1156
|
+
// Host credentials, shell hooks and provider-specific options never become Windows launch configuration.
|
|
1157
|
+
for (const key of ["backend_options", "credential_profile", "pre_task_command"])
|
|
1158
|
+
delete config[key];
|
|
1159
|
+
windowsAgentsFor(this, this.runtimeWindowsExecutor()).ensureGeneralBinding({ id: agentId, instance: name, machineId: target.machineId,
|
|
1160
|
+
cli: target.cli, cwd: publication.cwd, name: (expected.display_name ?? name).slice(0, 64), model: target.model, instructions: "", description: expected.description ?? "" }, () => current() && execution.current());
|
|
1161
|
+
return { id, agentId, machineId: target.machineId, cli: target.cli, config };
|
|
1162
|
+
},
|
|
1163
|
+
idle: async () => {
|
|
1164
|
+
check();
|
|
1165
|
+
const daemon = this.daemons.get(name);
|
|
1166
|
+
return !daemon || daemon.getInstanceStateSnapshot().state === "idle" && daemon.getWorkingRecoveryState().idle
|
|
1167
|
+
&& daemon.getInteractionSnapshot().phase === "clear" && !this.lifecycle.hasWorkLease(name)
|
|
1168
|
+
&& Date.now() - (this.lastDeliveryAt.get(name) ?? 0) >= 2000;
|
|
1169
|
+
},
|
|
1170
|
+
hostSnapshot: () => hostSnapshot,
|
|
1171
|
+
capture: async () => {
|
|
1172
|
+
if (isWindowsInstance(expected))
|
|
1173
|
+
return undefined;
|
|
1174
|
+
const daemon = this.daemons.get(name);
|
|
1175
|
+
if (!daemon)
|
|
1176
|
+
throw Error(t("general_location.exit_unconfirmed"));
|
|
1177
|
+
const owner = await daemon.generalHostOwner();
|
|
1178
|
+
check();
|
|
1179
|
+
execution.assert();
|
|
1180
|
+
return owner;
|
|
1181
|
+
},
|
|
1182
|
+
stop: async (owner) => {
|
|
1183
|
+
check();
|
|
1184
|
+
const stopping = this.stopInstance(name, transition);
|
|
1185
|
+
stopGeneration++;
|
|
1186
|
+
epoch++;
|
|
1187
|
+
await stopping;
|
|
1188
|
+
check();
|
|
1189
|
+
if (isWindowsInstance(expected)) {
|
|
1190
|
+
const profile = windowsAgentsFor(this, this.runtimeWindowsExecutor()).get(expected.native_agent_id);
|
|
1191
|
+
if (profile.activeId || profile.paneLaunchId)
|
|
1192
|
+
throw Error(t("general_location.exit_unconfirmed"));
|
|
1193
|
+
execution.mutate(() => this.holdGeneralOldDeliveries(name));
|
|
1194
|
+
return undefined;
|
|
1195
|
+
}
|
|
1196
|
+
if (!owner || !await confirmGeneralHostExit(owner, getTmuxSession()))
|
|
1197
|
+
throw Error(t("general_location.exit_unconfirmed"));
|
|
1198
|
+
check();
|
|
1199
|
+
execution.mutate(() => this.holdGeneralOldDeliveries(name));
|
|
1200
|
+
return owner.pin;
|
|
1201
|
+
},
|
|
1202
|
+
save: next => {
|
|
1203
|
+
check();
|
|
1204
|
+
const previous = expected;
|
|
1205
|
+
try {
|
|
1206
|
+
this.fleetConfig.instances[name] = next;
|
|
1207
|
+
this.saveFleetConfig([{ path: ["instances", name], value: next }]);
|
|
1208
|
+
expected = next;
|
|
1209
|
+
this.routing.rebuild(this.fleetConfig);
|
|
1210
|
+
}
|
|
1211
|
+
catch (err) {
|
|
1212
|
+
this.fleetConfig.instances[name] = previous;
|
|
1213
|
+
throw err;
|
|
1214
|
+
}
|
|
1215
|
+
},
|
|
1216
|
+
start: async () => {
|
|
1217
|
+
check();
|
|
1218
|
+
if (!execution.completed)
|
|
1219
|
+
throw Error("General's selected binding has no committed approval");
|
|
1220
|
+
// The committed receipt permits only this selected startup. Live authority still fences every awaited spawn boundary.
|
|
1221
|
+
const startup = new SettingsExecution({ current: () => current() && execution.completed && execution.remainingMs > 0,
|
|
1222
|
+
snapshot: () => this.fleetConfig });
|
|
1223
|
+
try {
|
|
1224
|
+
await this.startInstance(name, expected, true, "fleet-topic", false, transition, startup);
|
|
1225
|
+
}
|
|
1226
|
+
finally {
|
|
1227
|
+
startup.close();
|
|
1228
|
+
}
|
|
1229
|
+
check();
|
|
1230
|
+
const daemon = this.daemons.get(name);
|
|
1231
|
+
if (!daemon || daemon.getProcessStatus() !== "running" || isNativePaneInstance(expected) && !daemon.nativePaneCurrent())
|
|
1232
|
+
throw Error(t("general_location.start_failed"));
|
|
1233
|
+
await this.connectIpcToInstance(name);
|
|
1234
|
+
check();
|
|
1235
|
+
},
|
|
1236
|
+
notify: (kind, err) => {
|
|
1237
|
+
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"));
|
|
1238
|
+
this.eventLog?.insert(name, "general_location", { phase: kind, message });
|
|
1239
|
+
if (err)
|
|
1240
|
+
this.logger.warn({ name, phase: kind, err }, message);
|
|
1241
|
+
else
|
|
1242
|
+
this.logger.info({ name, phase: kind }, message);
|
|
1243
|
+
this.needsYou?.poke();
|
|
1244
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
1245
|
+
this.notifyInstanceTopic(name, message);
|
|
1246
|
+
},
|
|
1247
|
+
}, input, execution);
|
|
1248
|
+
return { ...result, message: result.started ? t("general_location.complete", isWindowsInstance(expected)
|
|
1249
|
+
? this.runtimeHub?.machine(expected.machine_id)?.name ?? t("general_location.windows") : t("general_location.host")) : t("general_location.start_failed") };
|
|
1250
|
+
}
|
|
1251
|
+
finally {
|
|
1252
|
+
lease?.release();
|
|
1253
|
+
if (this.generalLocationFlights.get(name) === token)
|
|
1254
|
+
this.generalLocationFlights.delete(name);
|
|
1255
|
+
this.needsYou?.poke();
|
|
1256
|
+
}
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
/** An offline PC or a confirmed binding boundary holds old waiting input; it never becomes another conversation's queue. */
|
|
1260
|
+
holdGeneralOldDeliveries(name) {
|
|
1261
|
+
const outbox = this.deliveryOutbox;
|
|
1262
|
+
if (!outbox)
|
|
1263
|
+
return;
|
|
1264
|
+
for (const row of outbox.listPending().filter(row => row.targetInstance === name)) {
|
|
1265
|
+
if (row.state !== "submission_started")
|
|
1266
|
+
outbox.failBeforeBegin(row.deliveryId, t("general_location.input_not_replayed"));
|
|
1267
|
+
else if (row.targetDaemonBootId) {
|
|
1268
|
+
outbox.markTargetReconciliationPending(name, row.targetDaemonBootId);
|
|
1269
|
+
if (!outbox.reconcileAttempt(row.deliveryId, row.targetDaemonBootId, row.attemptNo, "uncertain", t("general_location.input_not_replayed")))
|
|
1270
|
+
throw Error("General's old delivery could not be held");
|
|
1271
|
+
}
|
|
1272
|
+
else
|
|
1273
|
+
throw Error("General's old delivery has no confirmed owner");
|
|
1274
|
+
}
|
|
1275
|
+
this.needsYou?.poke();
|
|
1276
|
+
}
|
|
1277
|
+
generalLocationDeliveryHold(name) {
|
|
1278
|
+
const config = this.fleetConfig?.instances[name];
|
|
1279
|
+
if (!config?.general_topic)
|
|
1280
|
+
return null;
|
|
1281
|
+
if (this.generalLocationFlights?.has(name))
|
|
1282
|
+
return t("general_location.exit_unconfirmed");
|
|
1283
|
+
let record;
|
|
1284
|
+
try {
|
|
1285
|
+
record = this.generalBindings().read(name);
|
|
1286
|
+
}
|
|
1287
|
+
catch {
|
|
1288
|
+
return t("general_location.start_failed");
|
|
1289
|
+
}
|
|
1290
|
+
if (record?.pending || record?.hold)
|
|
1291
|
+
return t(record.pending?.phase === "selected" || record.hold === "start" ? "general_location.start_failed" : "general_location.exit_unconfirmed");
|
|
1292
|
+
if (isWindowsInstance(config) && config.machine_id && (!remoteRuntimeEnabled() || this.runtimeHub?.machine(config.machine_id)?.status !== "online" || !this.runtimeHub?.paneTransportEpoch(config.machine_id)))
|
|
1293
|
+
return t("general_location.offline");
|
|
1294
|
+
return null;
|
|
1295
|
+
}
|
|
1296
|
+
generalLocationAttention(name) {
|
|
1297
|
+
const cfg = this.fleetConfig?.instances[name];
|
|
1298
|
+
if (!cfg?.general_topic)
|
|
1299
|
+
return null;
|
|
1300
|
+
const detail = this.generalLocationDeliveryHold(name);
|
|
1301
|
+
if (!detail) {
|
|
1302
|
+
this.generalAttentionEpisodes.delete(name);
|
|
1303
|
+
return null;
|
|
1304
|
+
}
|
|
1305
|
+
let record = null;
|
|
1306
|
+
try {
|
|
1307
|
+
record = this.generalBindings().read(name);
|
|
1308
|
+
}
|
|
1309
|
+
catch { /* a corrupt journal is a visible hold too */ }
|
|
1310
|
+
const offline = !record?.pending && !record?.hold && isWindowsInstance(cfg) && detail === t("general_location.offline");
|
|
1311
|
+
const key = record?.pending?.id ?? `${record?.revision ?? "unknown"}:${offline ? "offline" : "hold"}`;
|
|
1312
|
+
let episode = this.generalAttentionEpisodes.get(name);
|
|
1313
|
+
if (!episode || episode.key !== key) {
|
|
1314
|
+
episode = { key, since: record?.pending?.at ?? Date.now() };
|
|
1315
|
+
this.generalAttentionEpisodes.set(name, episode);
|
|
1316
|
+
}
|
|
1317
|
+
return { ...episode, detail, offline };
|
|
1318
|
+
}
|
|
1013
1319
|
async nativePaneFor(name, config, assertCurrent) {
|
|
1014
|
-
|
|
1320
|
+
try {
|
|
1321
|
+
return await (await this.nativePanes()).prepare(name, config, assertCurrent);
|
|
1322
|
+
}
|
|
1323
|
+
catch (err) {
|
|
1324
|
+
if (config.general_topic && err instanceof GeneralManagedFileError) {
|
|
1325
|
+
assertCurrent();
|
|
1326
|
+
const store = this.generalBindings(), record = store.read(name);
|
|
1327
|
+
if (record && record.active === config.native_general_binding) {
|
|
1328
|
+
record.hold = "start";
|
|
1329
|
+
store.write(record);
|
|
1330
|
+
}
|
|
1331
|
+
this.logger.warn({ name, managedFile: err.file }, t("general_location.start_failed"));
|
|
1332
|
+
this.needsYou?.poke();
|
|
1333
|
+
throw new UnsupportedCliError(t("general_location.start_failed"));
|
|
1334
|
+
}
|
|
1335
|
+
throw err;
|
|
1336
|
+
}
|
|
1015
1337
|
}
|
|
1016
1338
|
async recoverNativePane(name, launchId, authorized) {
|
|
1017
1339
|
const config = this.fleetConfig?.instances[name];
|
|
@@ -1556,6 +1878,7 @@ export class FleetManager {
|
|
|
1556
1878
|
watchdogTimer = null;
|
|
1557
1879
|
startupMemoryDeadline = null;
|
|
1558
1880
|
eventLoopWatch = null;
|
|
1881
|
+
stallReporter = null;
|
|
1559
1882
|
startedAt = 0;
|
|
1560
1883
|
stormOpenNotifyTimer = null;
|
|
1561
1884
|
memoryLogAt = null;
|
|
@@ -1599,6 +1922,37 @@ export class FleetManager {
|
|
|
1599
1922
|
webChatRemovals = new Map();
|
|
1600
1923
|
/** #1774: a removal under way — its replies' fate waits for its outcome (success: theirs no more; failure: theirs). */
|
|
1601
1924
|
webChatRemovalsPending = new Map();
|
|
1925
|
+
/**
|
|
1926
|
+
* #1490 row 27: "still the same lifecycle of `name`" — its lifecycle epoch, moved on synchronously by every stop,
|
|
1927
|
+
* restart and removal (the map is never pruned, so a same-name replacement has a later one). Taken when a button
|
|
1928
|
+
* or menu is claimed or offered, asked after every await before acting by name.
|
|
1929
|
+
*/
|
|
1930
|
+
/** #1490 row 27: an instance as the chat list names it — its display name where it has one, else its name. */
|
|
1931
|
+
shownInstanceName(name) {
|
|
1932
|
+
try {
|
|
1933
|
+
const classic = this.classicChannels?.getAll().find(ch => ch.instanceName === name) ?? null;
|
|
1934
|
+
return resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic, profile: profileIdentities(this.dataDir).get(name) }).display_name || name;
|
|
1935
|
+
}
|
|
1936
|
+
catch {
|
|
1937
|
+
return name;
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
lifecyclePin(name) {
|
|
1941
|
+
// Fail closed: a lifecycle that cannot say its epoch holds nothing (and never stops a prompt from being posted).
|
|
1942
|
+
let epoch;
|
|
1943
|
+
try {
|
|
1944
|
+
epoch = this.lifecycle?.epochOf(name);
|
|
1945
|
+
}
|
|
1946
|
+
catch {
|
|
1947
|
+
return () => false;
|
|
1948
|
+
}
|
|
1949
|
+
return () => { try {
|
|
1950
|
+
return !this.shuttingDown && this.lifecycle?.epochOf(name) === epoch;
|
|
1951
|
+
}
|
|
1952
|
+
catch {
|
|
1953
|
+
return false;
|
|
1954
|
+
} };
|
|
1955
|
+
}
|
|
1602
1956
|
webChatGeneration(name) { return this.webChatGenerations.get(name) ?? 0; }
|
|
1603
1957
|
/** #1774: who an instance's reply is from, taken when it is sent: the name's generation and its daemon then. */
|
|
1604
1958
|
webChatIdentity(name) { return { generation: this.webChatGeneration(name), daemon: this.daemons.get(name) }; }
|
|
@@ -2085,6 +2439,7 @@ export class FleetManager {
|
|
|
2085
2439
|
const current = () => !this.shuttingDown && this.settingsGeneration === generation;
|
|
2086
2440
|
const baselines = this.settingsBaselineReader = new SettingsBaselines({ dataDir: this.dataDir, configPath: () => this.configPath,
|
|
2087
2441
|
config: () => this.fleetConfig, current: () => [this.settingsGeneration, this.settingsRuntimeRevision, this.shuttingDown],
|
|
2442
|
+
generalLocation: input => this.previewGeneralLocation(input),
|
|
2088
2443
|
proof: (kind, target, id, binding, key) => {
|
|
2089
2444
|
const map = kind === "provider" ? this.providerSecretChallenges : kind === "binding" ? this.connectionBindingChallenges : this.connectionSecretChallenges;
|
|
2090
2445
|
const proof = map.get(id);
|
|
@@ -2163,8 +2518,10 @@ export class FleetManager {
|
|
|
2163
2518
|
const auth = authorizeSession(req, this.webToken, this.webSessions, { touch: false });
|
|
2164
2519
|
if (auth.kind !== "ok")
|
|
2165
2520
|
return null;
|
|
2166
|
-
// #2453 (§0.2): never over the public link — not even a named-link session that is web-verified there.
|
|
2167
|
-
|
|
2521
|
+
// #2453 (§0.2): never over the public link — not even a named-link session that is web-verified there. #3008: except
|
|
2522
|
+
// this computer's admin signed in from outside (the authenticator's code, over the link connections from outside
|
|
2523
|
+
// opened): the web-only fleet's owner, judged below by the same authority as here (hostOperator).
|
|
2524
|
+
if (gatewayRequestContext(req) && auth.session.remote !== "totp")
|
|
2168
2525
|
return null;
|
|
2169
2526
|
// …and never a change that turns the named-link option on: that one needs a General's card or the host.
|
|
2170
2527
|
if (!store.selfConfirmable(id))
|
|
@@ -2197,13 +2554,16 @@ export class FleetManager {
|
|
|
2197
2554
|
if (own.length !== 1 || own[0].type !== chat.platform || !chat.admin(actor.user_id))
|
|
2198
2555
|
return null;
|
|
2199
2556
|
const affected = [...store.affectedConnections(id)]; // read now: a finished change no longer has them
|
|
2200
|
-
|
|
2557
|
+
const destinations = this.settingsNoticeGenerals(affected, chat.platform);
|
|
2558
|
+
if (!destinations.length)
|
|
2201
2559
|
return null;
|
|
2202
2560
|
const name = this.webPerson({ adapterId: actor.adapter_id, userId: actor.user_id }).name;
|
|
2203
2561
|
return {
|
|
2204
2562
|
label: t("settings_confirm.web_verified_label", name),
|
|
2205
2563
|
// 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) &&
|
|
2564
|
+
current: () => !!verified() && chat.admin(actor.user_id) && destinations.some(route => route.current()),
|
|
2565
|
+
followStop: async (name, begin) => { const route = destinations.find(item => item.current()); if (!route)
|
|
2566
|
+
throw Error("Settings route expired"); await route.followStop(name, begin); },
|
|
2207
2567
|
done: view => this.settingsWebConfirmedNotice(view, affected, name, chat.platform),
|
|
2208
2568
|
};
|
|
2209
2569
|
}
|
|
@@ -2223,27 +2583,10 @@ export class FleetManager {
|
|
|
2223
2583
|
*/
|
|
2224
2584
|
agentProposalsRev = 0;
|
|
2225
2585
|
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;
|
|
2586
|
+
// #3008: this computer's admin here, or signed in from outside (over the link connections from outside opened) —
|
|
2587
|
+
// asked of the LIVE session each time (#2997 review): a sign-out, an expiry, a revocation or the switch going off
|
|
2588
|
+
// while the request is applied fences it.
|
|
2589
|
+
return this.liveOwner(req, { hostOnly: false });
|
|
2247
2590
|
}
|
|
2248
2591
|
/** alpha.2: the agents' requests waiting for this web-only fleet's owner, or null when the reader is not that owner. */
|
|
2249
2592
|
webAgentProposals(req) {
|
|
@@ -2277,20 +2620,31 @@ export class FleetManager {
|
|
|
2277
2620
|
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
2621
|
}
|
|
2279
2622
|
}
|
|
2280
|
-
/**
|
|
2623
|
+
/** A confirmation belongs to the server route/lifetime, not the availability of General's model. */
|
|
2624
|
+
settingsGeneralRoute(name, platform, excluded) {
|
|
2625
|
+
const config = this.fleetConfig?.instances[name];
|
|
2626
|
+
if (!config?.general_topic || this.shuttingDown || this.ipcStoppingInstances.has(name))
|
|
2627
|
+
return null;
|
|
2628
|
+
const adapterId = this.getInstanceAdapterId(name), adapter = this.getAdapterForInstance(name);
|
|
2629
|
+
if (!adapterId || !adapter || excluded.has(adapterId) || adapter.type !== platform)
|
|
2630
|
+
return null;
|
|
2631
|
+
const chatId = this.getGroupIdForInstance(name), topic = String(config.topic_id ?? "");
|
|
2632
|
+
if (!chatId || !topic)
|
|
2633
|
+
return null;
|
|
2634
|
+
const route = () => !this.shuttingDown && this.fleetConfig?.instances[name]?.general_topic === true
|
|
2635
|
+
&& this.adapterState.get(adapterId)?.status === "connected"
|
|
2636
|
+
&& this.getInstanceAdapterId(name) === adapterId && this.getAdapterForInstance(name) === adapter
|
|
2637
|
+
&& this.getGroupIdForInstance(name) === chatId && String(this.fleetConfig?.instances[name]?.topic_id ?? "") === topic;
|
|
2638
|
+
const lifetime = new SettingsRouteLifetime(name, route, () => this.lifecycle.epochOf(name), () => this.ipcStoppingInstances.has(name));
|
|
2639
|
+
return lifetime.current() ? { adapterId, adapter, chatId, topic, current: lifetime.current, followStop: lifetime.followStop.bind(lifetime) } : null;
|
|
2640
|
+
}
|
|
2641
|
+
/** Connected, same-platform, unaffected server routes can post cards even while the model is offline. */
|
|
2281
2642
|
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;
|
|
2643
|
+
const excluded = new Set(affected);
|
|
2644
|
+
return Object.keys(this.fleetConfig?.instances ?? {}).flatMap(name => {
|
|
2645
|
+
const route = this.settingsGeneralRoute(name, platform, excluded);
|
|
2646
|
+
return route ? [route] : [];
|
|
2647
|
+
});
|
|
2294
2648
|
}
|
|
2295
2649
|
/**
|
|
2296
2650
|
* #2170: General's notice of a change confirmed from the web — the first General a chat card could have been posted
|
|
@@ -2470,25 +2824,15 @@ export class FleetManager {
|
|
|
2470
2824
|
return;
|
|
2471
2825
|
}
|
|
2472
2826
|
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)
|
|
2827
|
+
for (const name of Object.keys(this.fleetConfig?.instances ?? {})) {
|
|
2828
|
+
const route = this.settingsGeneralRoute(name, authority.platform, excluded);
|
|
2829
|
+
if (!route)
|
|
2482
2830
|
continue;
|
|
2831
|
+
const { adapterId, adapter, chatId, topic } = route;
|
|
2483
2832
|
let retired = false, attached = false;
|
|
2484
2833
|
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();
|
|
2834
|
+
const current = () => !retired && (attached || performance.now() < postingDeadline) && this.settingsGeneration === generation
|
|
2835
|
+
&& route.current() && authority.current();
|
|
2492
2836
|
if (!current())
|
|
2493
2837
|
continue;
|
|
2494
2838
|
try {
|
|
@@ -2498,7 +2842,7 @@ export class FleetManager {
|
|
|
2498
2842
|
t("settings_confirm.requester", view.requested_by), ...view.summary].join("\n"),
|
|
2499
2843
|
choices: [{ action: "confirm", label: t("settings_confirm.confirm_button") }, { action: "reject", label: t("settings_confirm.reject_button") }],
|
|
2500
2844
|
expiredText: t("settings_confirm.no_longer_pending"), timeoutMs: view.remaining_ms,
|
|
2501
|
-
extra: { pendingChangeId: view.id, confirmationCurrent: current, confirmationAdmin: userId => current() && authority.admin(userId) },
|
|
2845
|
+
extra: { pendingChangeId: view.id, confirmationCurrent: current, confirmationAdmin: userId => current() && authority.admin(userId), confirmationStop: route.followStop },
|
|
2502
2846
|
});
|
|
2503
2847
|
void posted.then(nonce => {
|
|
2504
2848
|
if (!retired)
|
|
@@ -2766,6 +3110,7 @@ export class FleetManager {
|
|
|
2766
3110
|
try {
|
|
2767
3111
|
const view = await this.settingsGate().store.decide(entry.pendingChangeId, action, {
|
|
2768
3112
|
label: t("settings_confirm.admin_label", data.userId ?? ""),
|
|
3113
|
+
followStop: entry.confirmationStop,
|
|
2769
3114
|
current: () => entry.confirmationCurrent?.() === true && this.getAdapterForInstance(entry.instanceName) === entry.adapter
|
|
2770
3115
|
&& !!data.userId && entry.confirmationAdmin?.(data.userId) === true,
|
|
2771
3116
|
});
|
|
@@ -3140,6 +3485,9 @@ export class FleetManager {
|
|
|
3140
3485
|
}
|
|
3141
3486
|
/** Synchronous SQLite admission; the caller may acknowledge only after this returns. */
|
|
3142
3487
|
admitDurableDelivery(input) {
|
|
3488
|
+
const bindingHold = this.generalLocationDeliveryHold(input.targetInstance);
|
|
3489
|
+
if (bindingHold)
|
|
3490
|
+
throw Error(bindingHold);
|
|
3143
3491
|
const outbox = this.deliveryOutbox;
|
|
3144
3492
|
if (!outbox)
|
|
3145
3493
|
throw new Error("Durable delivery store is unavailable");
|
|
@@ -3179,7 +3527,7 @@ export class FleetManager {
|
|
|
3179
3527
|
const human = (item) => {
|
|
3180
3528
|
if (!item.authority || !state)
|
|
3181
3529
|
return item;
|
|
3182
|
-
const authority =
|
|
3530
|
+
const authority = this.receiptAuthorityNow(item.authority, state);
|
|
3183
3531
|
return { ...item, authority, ...(authority.downgraded ? { source_instance: "human:web:unknown" } : {}) };
|
|
3184
3532
|
};
|
|
3185
3533
|
// #1641: a sender that sees only `queued` cannot tell "next in line" from "the target is stuck": say which.
|
|
@@ -3208,6 +3556,8 @@ export class FleetManager {
|
|
|
3208
3556
|
return this.daemons.get(target)?.notAcceptingReason?.() ?? null;
|
|
3209
3557
|
}
|
|
3210
3558
|
getDaemonBootId(instanceName) {
|
|
3559
|
+
if (this.generalLocationDeliveryHold(instanceName))
|
|
3560
|
+
return undefined;
|
|
3211
3561
|
if (isNativeExecInstance(this.fleetConfig?.instances[instanceName])) {
|
|
3212
3562
|
return !this.shuttingDown && windowsCliEnabled() && this.nativeMcpStore?.hasActive(instanceName) ? this.managerBootId : undefined;
|
|
3213
3563
|
}
|
|
@@ -3219,8 +3569,22 @@ export class FleetManager {
|
|
|
3219
3569
|
/** Called after a replacement Daemon object is installed in lifecycle.daemons. */
|
|
3220
3570
|
onDaemonReady(name, daemonBootId) {
|
|
3221
3571
|
const daemon = this.daemons.get(name);
|
|
3222
|
-
if (daemon?.bootId === daemonBootId)
|
|
3572
|
+
if (daemon?.bootId === daemonBootId) {
|
|
3223
3573
|
this.listenForWorkingState(name, daemon);
|
|
3574
|
+
const cfg = this.fleetConfig?.instances[name];
|
|
3575
|
+
if (cfg?.general_topic && !this.generalLocationFlights.has(name) && daemon.getProcessStatus() === "running"
|
|
3576
|
+
&& (!isNativePaneInstance(cfg) || daemon.nativePaneCurrent())) {
|
|
3577
|
+
try {
|
|
3578
|
+
if (this.generalBindings().completeSelected(name, cfg)) {
|
|
3579
|
+
this.needsYou?.poke();
|
|
3580
|
+
this.logger.info({ name }, "General location's selected start receipt settled after recovery");
|
|
3581
|
+
}
|
|
3582
|
+
}
|
|
3583
|
+
catch (err) {
|
|
3584
|
+
this.logger.warn({ name, err }, "General location start receipt remains held");
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3224
3588
|
// #2802: no post-restart notices. The outbox still delivers or fails a row accepted before the restart, and an open
|
|
3225
3589
|
// request is reminded by the ordinary turn-ended nudge (sweepReplyObligations) once the owner is idle.
|
|
3226
3590
|
this.scheduleDeliveryOutboxPump();
|
|
@@ -5698,6 +6062,8 @@ export class FleetManager {
|
|
|
5698
6062
|
}
|
|
5699
6063
|
/** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
|
|
5700
6064
|
async deliverToInstance(instanceName, payload, options = {}) {
|
|
6065
|
+
if (this.generalLocationDeliveryHold(instanceName))
|
|
6066
|
+
return false;
|
|
5701
6067
|
if (isNativeExecInstance(this.fleetConfig?.instances[instanceName])) {
|
|
5702
6068
|
return this.nativeInstanceRuntime().deliver(instanceName, payload, { stillCurrent: options.stillCurrent,
|
|
5703
6069
|
...(options.authorityCurrent ? { authorityCurrent: options.authorityCurrent } : {}) });
|
|
@@ -6526,7 +6892,26 @@ export class FleetManager {
|
|
|
6526
6892
|
if (resumePaused)
|
|
6527
6893
|
this.lifecycle.markActivitySeedNow(name);
|
|
6528
6894
|
const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
6529
|
-
|
|
6895
|
+
// #1490 row 67 (main): off-loop resolve before skill/instruction publication
|
|
6896
|
+
// constructs a backend (resolveKnowledgeWorkDir). Capture stop/lifecycle
|
|
6897
|
+
// ownership first: a Stop during this wait finishes and bumps the epoch,
|
|
6898
|
+
// and the inner lifecycle check would otherwise capture the new epoch and
|
|
6899
|
+
// accept the stale start. Lifecycle start looks up again before its own
|
|
6900
|
+
// createBackend; the probe cache makes that cheap. (alpha: a native Windows
|
|
6901
|
+
// agent's CLI is on that PC — nothing to resolve on this one.)
|
|
6902
|
+
const epoch = this.lifecycle.epochOf(name);
|
|
6903
|
+
const stopGen = this.explicitStopGeneration.get(name) ?? 0;
|
|
6904
|
+
const windows = isWindowsInstance(config);
|
|
6905
|
+
const resolved = windows ? null : await resolveBackendBinary(backend);
|
|
6906
|
+
execution?.assert();
|
|
6907
|
+
if (this.lifecycle.epochOf(name) !== epoch
|
|
6908
|
+
|| (this.explicitStopGeneration.get(name) ?? 0) !== stopGen) {
|
|
6909
|
+
throw new SupersededStartError(name);
|
|
6910
|
+
}
|
|
6911
|
+
const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
|
|
6912
|
+
if (binary && !windows)
|
|
6913
|
+
noteResolvedBinary(binary, resolved ?? binary);
|
|
6914
|
+
if (!windows && config.general_topic) {
|
|
6530
6915
|
this.ensureGeneralInstructions(config.working_directory, backend, name);
|
|
6531
6916
|
}
|
|
6532
6917
|
else if (!isWindowsInstance(config) && kind === "fleet-topic") {
|
|
@@ -7184,6 +7569,8 @@ export class FleetManager {
|
|
|
7184
7569
|
}
|
|
7185
7570
|
/** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
|
|
7186
7571
|
async restartSingleInstance(name, opts) {
|
|
7572
|
+
if (opts?.ownerCurrent && !opts.ownerCurrent())
|
|
7573
|
+
throw new RestartSupersededError(this.shownInstanceName(name));
|
|
7187
7574
|
if (isNativeExecInstance(this.fleetConfig?.instances[name])) {
|
|
7188
7575
|
const runtime = this.nativeInstanceRuntime();
|
|
7189
7576
|
if (runtime.get(name)?.blocked)
|
|
@@ -7552,6 +7939,9 @@ export class FleetManager {
|
|
|
7552
7939
|
log: () => this.logger.warn("Managed tunnel cleanup requires attention") }));
|
|
7553
7940
|
}
|
|
7554
7941
|
publicOwnerCurrent(owner) {
|
|
7942
|
+
// #3008: the outside link is this computer's admin's — while "Allow connections from outside" is on, web-only.
|
|
7943
|
+
if (isOutsideLinkOwner(owner))
|
|
7944
|
+
return !this.shuttingDown && this.outsideOn();
|
|
7555
7945
|
if (this.shuttingDown || !this.hasFleetAdmins(owner.adapterId) || !this.isFleetAdmin(owner.userId, owner.adapterId))
|
|
7556
7946
|
return false;
|
|
7557
7947
|
const configured = (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).find(ch => (ch.id ?? ch.type) === owner.adapterId);
|
|
@@ -7641,7 +8031,11 @@ export class FleetManager {
|
|
|
7641
8031
|
}
|
|
7642
8032
|
getPublicWebLink() {
|
|
7643
8033
|
this.initializeWebSessions();
|
|
7644
|
-
return this.publicWebLink ??= new PublicWebLink(
|
|
8034
|
+
return this.publicWebLink ??= new PublicWebLink(this.publicWebLinkDeps());
|
|
8035
|
+
}
|
|
8036
|
+
/** The public link's wiring to this fleet (a test builds one with the tunnel itself stubbed: `ensure`, `provider`). */
|
|
8037
|
+
publicWebLinkDeps() {
|
|
8038
|
+
return {
|
|
7645
8039
|
dataDir: this.dataDir, web: () => this.fleetConfig?.web,
|
|
7646
8040
|
permitted: owner => this.publicOwnerCurrent(owner),
|
|
7647
8041
|
reserve: id => this.getTunnelLane().reserve("dashboard", id),
|
|
@@ -7674,7 +8068,9 @@ export class FleetManager {
|
|
|
7674
8068
|
if (world && world.adapter === owner.binding)
|
|
7675
8069
|
void world.adapter.sendText(owner.chatId, t("dashboard.public_cleanup"), { threadId: owner.threadId }).catch(() => { });
|
|
7676
8070
|
},
|
|
7677
|
-
|
|
8071
|
+
onClose: (owner, reason) => { if (isOutsideLinkOwner(owner))
|
|
8072
|
+
this.outsideLinkClosed(reason); },
|
|
8073
|
+
};
|
|
7678
8074
|
}
|
|
7679
8075
|
publicLinkAutoOpen = null;
|
|
7680
8076
|
/**
|
|
@@ -8494,7 +8890,7 @@ export class FleetManager {
|
|
|
8494
8890
|
return { kind: "refused" };
|
|
8495
8891
|
if (device.tier === "guest")
|
|
8496
8892
|
return this.requestGuestDeviceCode(device, input, token);
|
|
8497
|
-
const paused = this.webLoginCodes.pausedForMs();
|
|
8893
|
+
const paused = this.webLoginCodes.pausedForMs(input.exposureId ?? "local");
|
|
8498
8894
|
if (paused > 0)
|
|
8499
8895
|
return { kind: "limited", retryAfterMs: paused };
|
|
8500
8896
|
const admitted = this.codeRequestLimiter.admit(device.idHash);
|
|
@@ -8979,6 +9375,9 @@ export class FleetManager {
|
|
|
8979
9375
|
this.watchdogTimer = startSdWatchdog();
|
|
8980
9376
|
this.eventLoopWatch?.stop();
|
|
8981
9377
|
this.eventLoopWatch = startEventLoopWatch({ logger: this.logger });
|
|
9378
|
+
// #3009: the event-loop watch runs on the main thread; this one does not, so a stall that never ends is still named.
|
|
9379
|
+
this.stallReporter?.stop();
|
|
9380
|
+
this.stallReporter = startStallReporter({ logFile: join(this.dataDir, "daemon.log") });
|
|
8982
9381
|
try {
|
|
8983
9382
|
await this.startAllWithinDeadline(configPath, deadline);
|
|
8984
9383
|
}
|
|
@@ -8992,6 +9391,8 @@ export class FleetManager {
|
|
|
8992
9391
|
this.watchdogTimer = null;
|
|
8993
9392
|
this.eventLoopWatch?.stop();
|
|
8994
9393
|
this.eventLoopWatch = null;
|
|
9394
|
+
this.stallReporter?.stop();
|
|
9395
|
+
this.stallReporter = null;
|
|
8995
9396
|
}
|
|
8996
9397
|
}
|
|
8997
9398
|
}
|
|
@@ -9263,6 +9664,10 @@ export class FleetManager {
|
|
|
9263
9664
|
const generalDir = join(getAgendHome(), name);
|
|
9264
9665
|
mkdirSync(generalDir, { recursive: true, mode: 0o700 });
|
|
9265
9666
|
const backendName = fleet.defaults.backend ?? "claude-code";
|
|
9667
|
+
const resolved = await resolveBackendBinary(backendName);
|
|
9668
|
+
const binary = BACKEND_INSTALLATION_INFO[backendName]?.binary;
|
|
9669
|
+
if (binary)
|
|
9670
|
+
noteResolvedBinary(binary, resolved ?? binary);
|
|
9266
9671
|
this.ensureGeneralInstructions(generalDir, backendName, name);
|
|
9267
9672
|
fleet.instances[name] = autoGeneralInstanceConfig(fleet.defaults, generalDir, need.adapterId); // #2598: its kiro_ui is the fleet's
|
|
9268
9673
|
generalsCreated = true;
|
|
@@ -9542,6 +9947,10 @@ export class FleetManager {
|
|
|
9542
9947
|
// #named-link-ttl: a named link with auto_open comes back now — after the web listener and the chat connections.
|
|
9543
9948
|
this.startPublicLinkAutoOpen();
|
|
9544
9949
|
}
|
|
9950
|
+
// #3008: connections from outside were on when the fleet stopped: the link opens again (its address is new for a
|
|
9951
|
+
// quick link — Settings shows it on this computer).
|
|
9952
|
+
if (this.outsideOn())
|
|
9953
|
+
this.openOutsideLink([0, ...OUTSIDE_LINK_RETRY_MS]);
|
|
9545
9954
|
// #1266: buttons that ended while AgEnD was down (expired, or chosen before a restart) are shown as ended now.
|
|
9546
9955
|
// #1823: and polls: a deadline that passed meanwhile closes now (results once); an open one's countdown is redrawn.
|
|
9547
9956
|
void this.replyButtons()?.sweep().catch(err => this.logger.warn({ err }, "Reply-button sweep failed"));
|
|
@@ -11475,6 +11884,12 @@ export class FleetManager {
|
|
|
11475
11884
|
if (msg.adapterId)
|
|
11476
11885
|
this.bindInstanceAdapter(generalInstance, msg.adapterId, true);
|
|
11477
11886
|
const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
11887
|
+
const locationHold = this.generalLocationDeliveryHold(generalInstance);
|
|
11888
|
+
if (locationHold) {
|
|
11889
|
+
await inboundAdapter.sendText(msg.chatId, locationHold, { threadId: msg.threadId || undefined });
|
|
11890
|
+
this.needsYou?.poke();
|
|
11891
|
+
return;
|
|
11892
|
+
}
|
|
11478
11893
|
// React immediately — before any other API calls — through the status
|
|
11479
11894
|
// path, which tracks Telegram slot ownership and Discord status adds.
|
|
11480
11895
|
if (msg.chatId && msg.messageId) {
|
|
@@ -11562,6 +11977,13 @@ export class FleetManager {
|
|
|
11562
11977
|
if (instanceConfig?.general_topic && await this.topicCommands.handleGeneralCommand(msg, instanceName)) {
|
|
11563
11978
|
return;
|
|
11564
11979
|
}
|
|
11980
|
+
const locationHold = this.generalLocationDeliveryHold(instanceName);
|
|
11981
|
+
if (locationHold) {
|
|
11982
|
+
const adapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
11983
|
+
await adapter.sendText(msg.chatId, locationHold, { threadId: msg.threadId || undefined });
|
|
11984
|
+
this.needsYou?.poke();
|
|
11985
|
+
return;
|
|
11986
|
+
}
|
|
11565
11987
|
// Bind instance to the adapter that delivered this message
|
|
11566
11988
|
if (msg.adapterId)
|
|
11567
11989
|
this.bindInstanceAdapter(instanceName, msg.adapterId, true);
|
|
@@ -13811,6 +14233,9 @@ export class FleetManager {
|
|
|
13811
14233
|
await this.lifecycle.remove(name, authorization, execution, transition);
|
|
13812
14234
|
else
|
|
13813
14235
|
await this.lifecycle.remove(name, authorization);
|
|
14236
|
+
// #1490 row 27: its hang/exit offers were for the instance now gone (stopInstance clears them; removal stops
|
|
14237
|
+
// through the lifecycle directly). The offers' own lifecycle pins refuse them too.
|
|
14238
|
+
this.clearNoncePromptsForInstance(name);
|
|
13814
14239
|
// #1774: removed — its replies still copying their files are no one's to show, replaced or not (a failed removal
|
|
13815
14240
|
// throws above: its replies stay its own).
|
|
13816
14241
|
this.webChatRemovals.set(name, { below: removedBelow, ...(removedDaemon !== undefined ? { daemon: removedDaemon } : {}) });
|
|
@@ -15491,6 +15916,7 @@ export class FleetManager {
|
|
|
15491
15916
|
threadId: opts.threadId,
|
|
15492
15917
|
expiredText: opts.expiredText,
|
|
15493
15918
|
createdAt: Date.now(),
|
|
15919
|
+
lifecycleCurrent: this.lifecyclePin(opts.instanceName),
|
|
15494
15920
|
...opts.extra,
|
|
15495
15921
|
};
|
|
15496
15922
|
entry.timer = setTimeout(() => {
|
|
@@ -16021,6 +16447,7 @@ export class FleetManager {
|
|
|
16021
16447
|
signIn: pauseReason === "auth" ? this.signInFor(name) : null,
|
|
16022
16448
|
usageHold: this.daemons.get(name)?.usageHold?.() ?? null,
|
|
16023
16449
|
nativeDeliveryHold: this.daemons.get(name)?.nativeDeliveryHold?.() ?? null,
|
|
16450
|
+
generalLocation: this.generalLocationAttention(name),
|
|
16024
16451
|
paneRecovery: (() => {
|
|
16025
16452
|
const config = this.fleetConfig?.instances[name];
|
|
16026
16453
|
if (!isNativePaneInstance(config) || !config.native_agent_id)
|
|
@@ -16634,7 +17061,7 @@ export class FleetManager {
|
|
|
16634
17061
|
isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
|
|
16635
17062
|
authorization: input.session ? this.sessionAuthorizationStore()?.get(input.session.handle) ?? null : null,
|
|
16636
17063
|
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(),
|
|
17064
|
+
hostOperator: this.isWebOnlyFleet(), outside: this.outsideLink(),
|
|
16638
17065
|
});
|
|
16639
17066
|
}
|
|
16640
17067
|
sessionAuthorizations = null;
|
|
@@ -16694,6 +17121,264 @@ export class FleetManager {
|
|
|
16694
17121
|
const { durable } = store.authorize(session.handle, HOST_OPERATOR, now, state.epoch, now + policy.maxSessionMs, peer);
|
|
16695
17122
|
this.logger.info({ handle: session.handle, durable }, "Host web session authorized (this computer's admin)");
|
|
16696
17123
|
}
|
|
17124
|
+
// ── #3008: connections from outside (a web-only fleet; decision 28c2d3a3 rule 3, the user's simplified form) ──
|
|
17125
|
+
webRemoteAccessState = null;
|
|
17126
|
+
/** The authenticator, the outside switch and the wrong-code limiter (web-remote-access.json), read on first use. */
|
|
17127
|
+
webRemoteAccess() {
|
|
17128
|
+
if (!this.dataDir)
|
|
17129
|
+
return null;
|
|
17130
|
+
this.webRemoteAccessState ??= new WebRemoteAccess(join(this.dataDir, WEB_REMOTE_ACCESS_FILE), this.logger);
|
|
17131
|
+
return this.webRemoteAccessState;
|
|
17132
|
+
}
|
|
17133
|
+
/** "Allow connections from outside" is on (an app set up) and this is a web-only fleet. */
|
|
17134
|
+
outsideOn() {
|
|
17135
|
+
return this.isWebOnlyFleet() && (this.webRemoteAccess()?.outside ?? false);
|
|
17136
|
+
}
|
|
17137
|
+
/** The link connections from outside opened, while it is open and still theirs: its exposure. Else null. */
|
|
17138
|
+
outsideLink() {
|
|
17139
|
+
if (!this.outsideOn())
|
|
17140
|
+
return null;
|
|
17141
|
+
const open = this.publicWebLink?.openSignin();
|
|
17142
|
+
return open && isOutsideLinkOwner(open.owner) ? { exposureId: open.exposureId } : null;
|
|
17143
|
+
}
|
|
17144
|
+
/**
|
|
17145
|
+
* The request's live session, asked again each time `current()` is called (its own cookie authenticated anew, the same
|
|
17146
|
+
* handle): this web-only fleet's owner — this computer's admin, on this computer (`hostOnly`), or also signed in from
|
|
17147
|
+
* outside — and web-verified now. Null when it is not (a guest, a LAN session, a chat fleet's admin, nobody).
|
|
17148
|
+
*/
|
|
17149
|
+
liveOwner(req, opts) {
|
|
17150
|
+
if (!this.isWebOnlyFleet())
|
|
17151
|
+
return null;
|
|
17152
|
+
if (opts.hostOnly && gatewayRequestContext(req))
|
|
17153
|
+
return null;
|
|
17154
|
+
const auth = authorizeSession(req, this.webToken, this.webSessions, { touch: false });
|
|
17155
|
+
if (auth.kind !== "ok")
|
|
17156
|
+
return null;
|
|
17157
|
+
const handle = auth.session.handle, peer = webPeerOf(req), gateway = !!gatewayRequestContext(req);
|
|
17158
|
+
const owner = () => {
|
|
17159
|
+
if (!this.isWebOnlyFleet())
|
|
17160
|
+
return false;
|
|
17161
|
+
const live = authorizeSession(req, this.webToken, this.webSessions, { touch: false });
|
|
17162
|
+
if (live.kind !== "ok" || live.session.handle !== handle)
|
|
17163
|
+
return false;
|
|
17164
|
+
if (opts.hostOnly && (live.session.host !== true || peer !== "loopback"))
|
|
17165
|
+
return false;
|
|
17166
|
+
const now = this.webAuthority({ session: live.session, gateway, peer });
|
|
17167
|
+
const a = now.authority.kind === "web-verified" && now.authority.session?.handle === handle ? now.authority.actor : null;
|
|
17168
|
+
return !!a && a.adapter_id === HOST_OPERATOR.adapterId && a.user_id === HOST_OPERATOR.userId;
|
|
17169
|
+
};
|
|
17170
|
+
return owner() ? { label: t("settings_confirm.host_operator_label"), handle, current: owner } : null;
|
|
17171
|
+
}
|
|
17172
|
+
/** Every session signed in from outside ends (and the authorizations kept for them). */
|
|
17173
|
+
endOutsideSessions() {
|
|
17174
|
+
const ended = new Set(this.webSessions?.list(undefined, r => r.remote === "totp").map(r => r.handle) ?? []);
|
|
17175
|
+
if (!ended.size)
|
|
17176
|
+
return;
|
|
17177
|
+
this.sessionAuthorizationStore()?.prune(a => !ended.has(a.handle));
|
|
17178
|
+
const result = this.webSessions?.revokeAll(r => r.remote === "totp");
|
|
17179
|
+
if (result && !result.durable)
|
|
17180
|
+
this.logger.warn("Outside sessions ended, but that could not be saved");
|
|
17181
|
+
this.logger.info({ count: ended.size }, "Sessions signed in from outside ended");
|
|
17182
|
+
}
|
|
17183
|
+
/**
|
|
17184
|
+
* #1939 §10 / #3008: a stored receipt's authority as it reads now — the web authority state, and for this computer's
|
|
17185
|
+
* admin from outside the switch and the last revocation of remote authority (Prism P2 2).
|
|
17186
|
+
*/
|
|
17187
|
+
receiptAuthorityNow(stored, state = this.webAuthorityState()) {
|
|
17188
|
+
return authorityNow(stored, { ...state, outside: this.outsideOn(), outsideHandles: this.webRemoteAccess()?.admitted ?? new Set() });
|
|
17189
|
+
}
|
|
17190
|
+
/** #3008 (the sign-in page): this exposure is the outside link, open now. */
|
|
17191
|
+
isOutsideLink(exposureId) {
|
|
17192
|
+
return this.outsideLink()?.exposureId === exposureId;
|
|
17193
|
+
}
|
|
17194
|
+
/** What Settings shows: set up or not, the switch, a wait in force, the link — its address only to this computer. */
|
|
17195
|
+
webOutsideStatus(req) {
|
|
17196
|
+
const store = this.webRemoteAccess(), status = store?.status();
|
|
17197
|
+
const here = !!this.liveOwner(req, { hostOnly: true });
|
|
17198
|
+
const open = this.outsideOn() ? this.publicWebLink?.openSignin() : null;
|
|
17199
|
+
const mine = !!open && isOutsideLinkOwner(open.owner);
|
|
17200
|
+
const linkStatus = this.outsideOn() ? this.getPublicWebStatus() : null;
|
|
17201
|
+
return { webOnly: this.isWebOnlyFleet(), here, enrolled: !!status?.enrolled, enrolledAt: status?.enrolledAt ?? null,
|
|
17202
|
+
outside: this.outsideOn(), pausedForMs: status?.pausedForMs ?? 0,
|
|
17203
|
+
link: linkStatus && linkStatus.state !== "closed" ? { ...linkStatus, ...(here && mine ? { url: open.url } : {}), quick: !publicLinkNamedHostname(this.fleetConfig?.web) } : null };
|
|
17204
|
+
}
|
|
17205
|
+
/** Set up (or set up again): a new secret, shown once to this computer's admin. Null: not them. */
|
|
17206
|
+
webOutsideBeginEnrollment(req) {
|
|
17207
|
+
const owner = this.liveOwner(req, { hostOnly: true }), store = this.webRemoteAccess();
|
|
17208
|
+
if (!owner || !store)
|
|
17209
|
+
return null;
|
|
17210
|
+
return store.beginEnrollment(owner.handle, hostname() || "fleet");
|
|
17211
|
+
}
|
|
17212
|
+
/** The new app's first code: on "ok" it replaces the old one, whose sessions from outside end. */
|
|
17213
|
+
webOutsideConfirmEnrollment(req, code) {
|
|
17214
|
+
const owner = this.liveOwner(req, { hostOnly: true }), store = this.webRemoteAccess();
|
|
17215
|
+
if (!owner || !store || !owner.current())
|
|
17216
|
+
return "not_here";
|
|
17217
|
+
const had = store.enrolled;
|
|
17218
|
+
const r = store.confirmEnrollment(owner.handle, code);
|
|
17219
|
+
if (r === "ok") {
|
|
17220
|
+
this.logger.info({ again: had }, "Authenticator app set up for connections from outside");
|
|
17221
|
+
if (had)
|
|
17222
|
+
this.endOutsideSessions();
|
|
17223
|
+
}
|
|
17224
|
+
return r;
|
|
17225
|
+
}
|
|
17226
|
+
/** Forget the app: the switch goes off, the link closes, every session from outside ends. */
|
|
17227
|
+
webOutsideRemove(req) {
|
|
17228
|
+
const owner = this.liveOwner(req, { hostOnly: true }), store = this.webRemoteAccess();
|
|
17229
|
+
if (!owner || !store || !owner.current())
|
|
17230
|
+
return "not_here";
|
|
17231
|
+
// #3008 review (Prism P2 1): shut at once whatever the disk says; say so when it could not be saved (retried).
|
|
17232
|
+
const saved = store.remove();
|
|
17233
|
+
this.closeOutsideLink("outside removed");
|
|
17234
|
+
this.logger.info({ saved }, "Authenticator app removed; connections from outside off");
|
|
17235
|
+
return saved ? "ok" : "unsaved";
|
|
17236
|
+
}
|
|
17237
|
+
/** The switch. On: needs an app set up; the link opens. Off: it closes and every session from outside ends. */
|
|
17238
|
+
webOutsideSwitch(req, on) {
|
|
17239
|
+
const owner = this.liveOwner(req, { hostOnly: true }), store = this.webRemoteAccess();
|
|
17240
|
+
if (!owner || !store || !owner.current())
|
|
17241
|
+
return "not_here";
|
|
17242
|
+
if (on && !publicLinkSettings(this.fleetConfig?.web).allowed)
|
|
17243
|
+
return "not_allowed";
|
|
17244
|
+
const r = store.setOutside(on);
|
|
17245
|
+
if (r === "not_enrolled")
|
|
17246
|
+
return r;
|
|
17247
|
+
this.logger.info({ on, saved: r === "ok" }, "Connections from outside switched");
|
|
17248
|
+
// #3008 review (Prism P2 1): Off shuts at once even when it could not be saved (the store retries); On opens only
|
|
17249
|
+
// once saved — a switch the disk does not hold would not survive a restart.
|
|
17250
|
+
if (!on)
|
|
17251
|
+
this.closeOutsideLink("outside off");
|
|
17252
|
+
else if (r === "ok")
|
|
17253
|
+
this.openOutsideLink([0, ...OUTSIDE_LINK_RETRY_MS]);
|
|
17254
|
+
return r;
|
|
17255
|
+
}
|
|
17256
|
+
outsideAttempt = null;
|
|
17257
|
+
outsideOpening = false;
|
|
17258
|
+
/**
|
|
17259
|
+
* Open the outside link: an attempt after each delay in turn until one opens (#3008 B: twice more at most — the quick
|
|
17260
|
+
* link's address service limits how often it may be asked). Every attempt asks again: the switch still on, the fleet
|
|
17261
|
+
* running. A final failure: one Notice; the switch stays on, so the next start tries again.
|
|
17262
|
+
*/
|
|
17263
|
+
openOutsideLink(delays) {
|
|
17264
|
+
this.outsideAttempt?.cancel();
|
|
17265
|
+
this.outsideAttempt = null;
|
|
17266
|
+
const attempt = (n) => {
|
|
17267
|
+
this.outsideAttempt = null;
|
|
17268
|
+
if (this.shuttingDown || !this.outsideOn() || this.outsideOpening)
|
|
17269
|
+
return;
|
|
17270
|
+
this.outsideOpening = true;
|
|
17271
|
+
void this.getPublicWebLink().openUnattended(OUTSIDE_LINK_OWNER).catch(() => "failed").then(outcome => {
|
|
17272
|
+
this.outsideOpening = false;
|
|
17273
|
+
if (outcome === "opened") {
|
|
17274
|
+
this.logger.info({ attempt: n + 1 }, "Outside link opened");
|
|
17275
|
+
return;
|
|
17276
|
+
}
|
|
17277
|
+
if (outcome === "busy" && this.outsideLink())
|
|
17278
|
+
return; // already open (ours)
|
|
17279
|
+
if (this.shuttingDown || !this.outsideOn())
|
|
17280
|
+
return;
|
|
17281
|
+
if (n + 1 < delays.length) {
|
|
17282
|
+
this.outsideAttempt = outsideTimer(() => attempt(n + 1), delays[n + 1]);
|
|
17283
|
+
return;
|
|
17284
|
+
}
|
|
17285
|
+
this.logger.warn({ attempts: n + 1 }, "Outside link could not be opened; giving up until the next start or switch");
|
|
17286
|
+
this.recordWebNotice("fleet", t("outside.notice_ended", t("outside.reason_failed")));
|
|
17287
|
+
});
|
|
17288
|
+
};
|
|
17289
|
+
this.outsideAttempt = outsideTimer(() => attempt(0), delays[0] ?? 0);
|
|
17290
|
+
}
|
|
17291
|
+
closeOutsideLink(reason) {
|
|
17292
|
+
this.outsideAttempt?.cancel();
|
|
17293
|
+
this.outsideAttempt = null;
|
|
17294
|
+
const open = this.publicWebLink?.openSignin();
|
|
17295
|
+
if (this.publicWebLink && (!open || isOutsideLinkOwner(open.owner)))
|
|
17296
|
+
void this.publicWebLink.close(reason);
|
|
17297
|
+
this.endOutsideSessions();
|
|
17298
|
+
}
|
|
17299
|
+
/** PublicWebLink.onClose for the outside link: what closed it decides what happens next (#3008 B). */
|
|
17300
|
+
outsideLinkClosed(reason) {
|
|
17301
|
+
if (this.shuttingDown || OUTSIDE_OWN_CLOSES.has(reason) || !this.outsideOn())
|
|
17302
|
+
return;
|
|
17303
|
+
// #3008 review (Prism P2 3): expiry first — its lifetime counts from the opening request, so it can run out while the
|
|
17304
|
+
// opening is still under way: the switch goes off then too, and that attempt's end (outsideOn false) retries nothing.
|
|
17305
|
+
if (reason === "expired") {
|
|
17306
|
+
// Its lifetime ran out: the switch goes off (only this computer turns it on again — a session from outside can't).
|
|
17307
|
+
this.outsideAttempt?.cancel();
|
|
17308
|
+
this.outsideAttempt = null;
|
|
17309
|
+
if (this.webRemoteAccess()?.setOutside(false) === "unsaved")
|
|
17310
|
+
this.logger.warn("Outside link expired; the switch's Off could not be saved yet (retried)");
|
|
17311
|
+
this.endOutsideSessions();
|
|
17312
|
+
this.logger.info("Outside link expired; connections from outside off");
|
|
17313
|
+
this.recordWebNotice("fleet", t("outside.notice_ended", t("outside.reason_expired")));
|
|
17314
|
+
return;
|
|
17315
|
+
}
|
|
17316
|
+
if (this.outsideOpening)
|
|
17317
|
+
return; // that attempt's own failure: its retry
|
|
17318
|
+
// Dropped (the tunnel exited, the gateway failed, the configuration changed): reopened up to twice.
|
|
17319
|
+
this.logger.info({ reason }, "Outside link closed; reopening");
|
|
17320
|
+
this.openOutsideLink(OUTSIDE_LINK_RETRY_MS);
|
|
17321
|
+
}
|
|
17322
|
+
/**
|
|
17323
|
+
* #3008 (auth-api POST /auth/totp): a sign-in through the outside link with the authenticator's code. "ok": the caller
|
|
17324
|
+
* makes the session (SessionRecord.remote) and then calls authorizeOutsideSession. Each wrong code counts; a wait that
|
|
17325
|
+
* starts is a Notice; a code that matches a step just outside the window logs how far this clock seems to be off.
|
|
17326
|
+
*/
|
|
17327
|
+
outsideSignIn(exposureId, code) {
|
|
17328
|
+
const link = this.outsideLink(), store = this.webRemoteAccess();
|
|
17329
|
+
if (!link || !store || !exposureId || link.exposureId !== exposureId)
|
|
17330
|
+
return { kind: "closed" };
|
|
17331
|
+
const v = store.verify(code);
|
|
17332
|
+
if (v.kind === "ok")
|
|
17333
|
+
return { kind: "ok" };
|
|
17334
|
+
if (v.kind === "paused")
|
|
17335
|
+
return v;
|
|
17336
|
+
if (v.kind === "not_enrolled")
|
|
17337
|
+
return { kind: "closed" };
|
|
17338
|
+
if (v.kind === "unavailable")
|
|
17339
|
+
return v;
|
|
17340
|
+
if (v.skewSteps !== null)
|
|
17341
|
+
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");
|
|
17342
|
+
if (v.paused) {
|
|
17343
|
+
this.logger.warn({ waitMs: v.retryAfterMs }, "Sign-in from outside paused after wrong codes");
|
|
17344
|
+
this.recordWebNotice("fleet", t("outside.notice_paused", outsideDuration(v.retryAfterMs), v.wrong));
|
|
17345
|
+
return { kind: "paused", retryAfterMs: v.retryAfterMs };
|
|
17346
|
+
}
|
|
17347
|
+
return { kind: "wrong" };
|
|
17348
|
+
}
|
|
17349
|
+
/**
|
|
17350
|
+
* #3008: a session just made by outsideSignIn's "ok" is this computer's admin for its authorization's life. False: it
|
|
17351
|
+
* could not be admitted (its handle not saved, #3015 review r3) — the caller ends it.
|
|
17352
|
+
*/
|
|
17353
|
+
authorizeOutsideSession(session, peer, label) {
|
|
17354
|
+
const store = this.sessionAuthorizationStore(), link = this.outsideLink();
|
|
17355
|
+
if (!store || session.remote !== "totp" || session.owner || !link || session.exposureId !== link.exposureId)
|
|
17356
|
+
return false;
|
|
17357
|
+
if (!this.webRemoteAccess()?.admit(session.handle)) {
|
|
17358
|
+
this.logger.warn({ handle: session.handle }, "Session from outside not admitted: it could not be saved");
|
|
17359
|
+
return false;
|
|
17360
|
+
}
|
|
17361
|
+
const state = this.webAuthorityState(), now = Date.now(), policy = webAuthorityPolicy(this.fleetConfig?.web);
|
|
17362
|
+
const check = evaluateWebAuthority({
|
|
17363
|
+
session, gateway: true, peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
|
|
17364
|
+
isAdmin: () => false,
|
|
17365
|
+
authorization: { handle: session.handle, owner: HOST_OPERATOR, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch },
|
|
17366
|
+
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now,
|
|
17367
|
+
hostOperator: true, outside: link,
|
|
17368
|
+
});
|
|
17369
|
+
if (check.refusal) {
|
|
17370
|
+
this.logger.info({ handle: session.handle, refusal: check.refusal }, "Session from outside not authorized");
|
|
17371
|
+
return false;
|
|
17372
|
+
}
|
|
17373
|
+
{
|
|
17374
|
+
const live = new Set(this.webSessions?.list(undefined, () => true).map(r => r.handle) ?? []);
|
|
17375
|
+
store.prune(a => a.epoch === state.epoch && now <= Math.min(a.until, a.authorizedAt + policy.maxSessionMs) && live.has(a.handle));
|
|
17376
|
+
const { durable } = store.authorize(session.handle, HOST_OPERATOR, now, state.epoch, now + policy.maxSessionMs, peer);
|
|
17377
|
+
this.logger.info({ handle: session.handle, durable }, "Session from outside authorized (this computer's admin)");
|
|
17378
|
+
}
|
|
17379
|
+
this.recordWebNotice("fleet", t("outside.notice_signed_in", label));
|
|
17380
|
+
return true;
|
|
17381
|
+
}
|
|
16697
17382
|
authorityStateStore = null;
|
|
16698
17383
|
/**
|
|
16699
17384
|
* #1939 §10: the switch, "Only this computer can authorize" and the revocation epoch in force for the configuration as
|
|
@@ -16714,6 +17399,10 @@ export class FleetManager {
|
|
|
16714
17399
|
return true;
|
|
16715
17400
|
if (session.surface === "local")
|
|
16716
17401
|
return false;
|
|
17402
|
+
// #3008 review (Prism P2 4): signed in from outside with the authenticator's code — its opt-in is the outside
|
|
17403
|
+
// switch, not these options (the authority judges it so): it keeps its authorization.
|
|
17404
|
+
if (session.remote === "totp")
|
|
17405
|
+
return false;
|
|
16717
17406
|
const peer = a.peer ?? "named_link";
|
|
16718
17407
|
return (!!state.namedLinkClosed && peer === "named_link") || (!!state.quickLinkClosed && peer === "quick_link");
|
|
16719
17408
|
};
|
|
@@ -16863,7 +17552,7 @@ export class FleetManager {
|
|
|
16863
17552
|
authorization: s?.owner ? { handle: s.handle, owner: s.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch } : null,
|
|
16864
17553
|
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now,
|
|
16865
17554
|
// #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(),
|
|
17555
|
+
hostOperator: this.isWebOnlyFleet(), outside: this.outsideLink(),
|
|
16867
17556
|
});
|
|
16868
17557
|
return r.refusal;
|
|
16869
17558
|
}
|
|
@@ -17221,21 +17910,27 @@ export class FleetManager {
|
|
|
17221
17910
|
if (claimed === "consumed")
|
|
17222
17911
|
return true;
|
|
17223
17912
|
const { entry: pending, action } = claimed;
|
|
17913
|
+
// #1490 row 27: the lifecycle the button was offered for — a replacement since (removal does not clear prompts) is stale.
|
|
17914
|
+
const current = pending.lifecycleCurrent ?? this.lifecyclePin(pending.instanceName);
|
|
17224
17915
|
this.eventLog?.insert(pending.instanceName, oom ? "session_oom_action" : "normal_exit_action", { action, userId: data.userId });
|
|
17225
17916
|
if (action === "ignore") {
|
|
17226
17917
|
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.ignored", pending.instanceName));
|
|
17227
17918
|
return true;
|
|
17228
17919
|
}
|
|
17920
|
+
if (!current()) {
|
|
17921
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("restart.superseded", this.shownInstanceName(pending.instanceName)));
|
|
17922
|
+
return true;
|
|
17923
|
+
}
|
|
17229
17924
|
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.restarting", pending.instanceName));
|
|
17230
17925
|
try {
|
|
17231
|
-
await this.restartSingleInstance(pending.instanceName, { explicit: true, ...(oom ? { freshStart: action === "fresh", sessionOomRetry: pending.sessionOomRetry,
|
|
17926
|
+
await this.restartSingleInstance(pending.instanceName, { explicit: true, ownerCurrent: current, ...(oom ? { freshStart: action === "fresh", sessionOomRetry: pending.sessionOomRetry,
|
|
17232
17927
|
...(pending.confirmationCurrent ? { recoveryCurrent: pending.confirmationCurrent } : {}) } : {}) });
|
|
17233
17928
|
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("exit.restarted", pending.instanceName), pending.threadId);
|
|
17234
17929
|
}
|
|
17235
17930
|
catch (err) {
|
|
17236
17931
|
const message = err instanceof Error ? err.message : String(err);
|
|
17237
17932
|
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"));
|
|
17933
|
+
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
17934
|
}
|
|
17240
17935
|
return true;
|
|
17241
17936
|
}
|
|
@@ -18357,7 +19052,10 @@ export class FleetManager {
|
|
|
18357
19052
|
return this.browserBackendSignInsInstance ??= new BrowserBackendSignIns({
|
|
18358
19053
|
dataDir: this.dataDir, lock: this.loginWindow,
|
|
18359
19054
|
current: owner => this.browserSignInOwnerCurrent(owner),
|
|
18360
|
-
installed: async () =>
|
|
19055
|
+
installed: async () => {
|
|
19056
|
+
const found = await Promise.all(ONBOARDING_BACKENDS.map(async (backend) => (await this.isCliInstalledOffLoop(backend)) ? backend : null));
|
|
19057
|
+
return found.filter((backend) => backend !== null);
|
|
19058
|
+
},
|
|
18361
19059
|
checkAuth: backend => checkAuthStatus(LOGIN_FLOWS[backend].authCheck),
|
|
18362
19060
|
checkConsoleAuth: () => checkAuthStatus(LOGIN_FLOWS.antigravity.authCheck),
|
|
18363
19061
|
transport: () => createStructuredSignInTransport(),
|
|
@@ -18461,13 +19159,13 @@ export class FleetManager {
|
|
|
18461
19159
|
* #2233: `/login` for a dashboard requester. The same controller and checks; a CLI that is not installed is refused
|
|
18462
19160
|
* with its install command (chat's install-first runs a terminal of its own, which the dashboard does not offer).
|
|
18463
19161
|
*/
|
|
18464
|
-
startWebDashboardLogin(backendArg, chat, opts) {
|
|
19162
|
+
async startWebDashboardLogin(backendArg, chat, opts) {
|
|
18465
19163
|
const wanted = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
|
|
18466
19164
|
if (isRemovedBackend(wanted))
|
|
18467
|
-
return
|
|
19165
|
+
return removedBackendMessage(wanted);
|
|
18468
19166
|
const info = BACKEND_INSTALLATION_INFO[wanted];
|
|
18469
|
-
if (info && LOGIN_FLOWS[wanted]?.remoteLogin !== "unsupported" && !this.
|
|
18470
|
-
return
|
|
19167
|
+
if (info && LOGIN_FLOWS[wanted]?.remoteLogin !== "unsupported" && !(await this.isCliInstalledOffLoop(wanted))) {
|
|
19168
|
+
return t("login.web_dashboard_not_installed", wanted, info.install);
|
|
18471
19169
|
}
|
|
18472
19170
|
if (!opts.skipAuthCheck)
|
|
18473
19171
|
this.recordLoginFlow(wanted, "login", chat.userId);
|
|
@@ -18515,8 +19213,10 @@ export class FleetManager {
|
|
|
18515
19213
|
? removedBackendMessage(backend)
|
|
18516
19214
|
: !installed.has(backend)
|
|
18517
19215
|
? (BACKEND_INSTALLATION_INFO[backend] ? t("login.install_by_name", backend) : t("login.install_on_host", backend))
|
|
19216
|
+
// #2981: one short line in the bare /login list — the full refusal (why, and what to run) is `/login <it>`'s;
|
|
19217
|
+
// a bare /login used to post Antigravity's whole paragraph to someone who never asked about it.
|
|
18518
19218
|
: flow?.remoteLogin === "unsupported"
|
|
18519
|
-
? t("login.
|
|
19219
|
+
? t("login.host_only", flow.command)
|
|
18520
19220
|
: backend === "opencode" ? t("login.unsupported", backend) : t("login.no_remote_flow", backend)}`).join("\n");
|
|
18521
19221
|
await chat.adapter.sendText(chat.chatId, guidance, { threadId: chat.threadId })
|
|
18522
19222
|
.catch(err => this.logger.warn({ err }, "Failed to post unsupported login guidance"));
|
|
@@ -18959,9 +19659,14 @@ export class FleetManager {
|
|
|
18959
19659
|
// session only judges that verdict; it does not read the output for URLs or prompts.
|
|
18960
19660
|
// M-2281 (#2980): no terminal to ask on, low priority, stoppable as a whole, the user's own $SHELL — see
|
|
18961
19661
|
// unattended-install.ts. A prompt nobody can answer fails at once instead of waiting out the timeout.
|
|
19662
|
+
const shell = (await loginShell()) ?? null;
|
|
19663
|
+
if (!this.loginWindow.isCurrent(claim)) {
|
|
19664
|
+
this.loginWindow.release(claim);
|
|
19665
|
+
return t("login.web_shutting_down");
|
|
19666
|
+
}
|
|
18962
19667
|
const flow = {
|
|
18963
19668
|
backend,
|
|
18964
|
-
command: unattendedInstallCommand(info.unattended ?? info.install),
|
|
19669
|
+
command: unattendedInstallCommand(info.unattended ?? info.install, shell),
|
|
18965
19670
|
successPattern: /$^/,
|
|
18966
19671
|
timeoutMs: this.installTimeoutMs,
|
|
18967
19672
|
};
|
|
@@ -19005,7 +19710,9 @@ export class FleetManager {
|
|
|
19005
19710
|
return;
|
|
19006
19711
|
// The installer may only have added the binary to a profile PATH; a
|
|
19007
19712
|
// fresh login shell sees that, the fleet process's PATH may not.
|
|
19008
|
-
|
|
19713
|
+
// #3009 (leader, cid-3017-3029-overlap): both lookups off the fleet's main thread — the login shell's own check,
|
|
19714
|
+
// then the installer's own directories through backend-availability's check (a killable child tests each one).
|
|
19715
|
+
const installedAt = (await this.locateBinaryOnLoginShell(info.binary)) ?? (await this.locateInstalledBackend(backend));
|
|
19009
19716
|
if (handoff.cancelled || this.installHandoff !== handoff)
|
|
19010
19717
|
return;
|
|
19011
19718
|
if (!installedAt) {
|
|
@@ -19097,38 +19804,27 @@ export class FleetManager {
|
|
|
19097
19804
|
*/
|
|
19098
19805
|
async locateBinaryOnLoginShell(binary) {
|
|
19099
19806
|
try {
|
|
19100
|
-
|
|
19807
|
+
// #3009: the executable-file check runs inside the same login shell (a killable child), never as accessSync/statSync
|
|
19808
|
+
// on the fleet's thread — a path on a filesystem that stopped answering held the whole fleet there.
|
|
19809
|
+
const result = await runOnLoginShell(`p=$(command -v ${binary}) && [ -f "$p" ] && [ -x "$p" ] && printf '%s\n' "$p"`);
|
|
19101
19810
|
if (result.status !== 0)
|
|
19102
19811
|
return null;
|
|
19103
19812
|
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;
|
|
19813
|
+
return isAbsolute(path) ? path : null;
|
|
19108
19814
|
}
|
|
19109
19815
|
catch {
|
|
19110
19816
|
return null;
|
|
19111
19817
|
}
|
|
19112
19818
|
}
|
|
19113
19819
|
/**
|
|
19114
|
-
* #1092: the binary in one of the directories its own installer installs to,
|
|
19115
|
-
*
|
|
19116
|
-
*
|
|
19117
|
-
*
|
|
19820
|
+
* #1092: the binary in one of the directories its own installer installs to, when no login shell can see it (the
|
|
19821
|
+
* installer recorded its PATH in an interactive-only rc file) — only those directories (#1092). #3009: through
|
|
19822
|
+
* backend-availability's install-directory check (the one resolveBackendBinary uses): one killable child, never
|
|
19823
|
+
* accessSync/statSync on the fleet's thread. An executable file counts, a symlink to one too (codex's launcher is one).
|
|
19118
19824
|
*/
|
|
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;
|
|
19825
|
+
async locateInstalledBackend(backend) {
|
|
19826
|
+
const info = BACKEND_INSTALLATION_INFO[backend];
|
|
19827
|
+
return info ? (await findExecutableIn(info.binary, info.binDirs?.(process.env, homedir()) ?? [])) ?? null : null;
|
|
19132
19828
|
}
|
|
19133
19829
|
/** Put an installed binary's directory on the fleet's PATH (#1059), once. */
|
|
19134
19830
|
adoptBinaryDirectory(binaryPath, backend) {
|
|
@@ -19444,20 +20140,26 @@ export class FleetManager {
|
|
|
19444
20140
|
if (claimed === "consumed")
|
|
19445
20141
|
return true;
|
|
19446
20142
|
const { entry: pending, action } = claimed;
|
|
20143
|
+
// #1490 row 27: the lifecycle the button was offered for — a replacement since (removal does not clear prompts) is stale.
|
|
20144
|
+
const current = pending.lifecycleCurrent ?? this.lifecyclePin(pending.instanceName);
|
|
19447
20145
|
this.eventLog?.insert(pending.instanceName, "hang_action", { action, userId: data.userId });
|
|
19448
20146
|
if (action === "wait") {
|
|
19449
20147
|
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.waiting", pending.instanceName));
|
|
19450
20148
|
return true;
|
|
19451
20149
|
}
|
|
20150
|
+
if (!current()) {
|
|
20151
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("restart.superseded", this.shownInstanceName(pending.instanceName)));
|
|
20152
|
+
return true;
|
|
20153
|
+
}
|
|
19452
20154
|
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.restarting", pending.instanceName));
|
|
19453
20155
|
try {
|
|
19454
|
-
await this.restartSingleInstance(pending.instanceName, { explicit: true });
|
|
20156
|
+
await this.restartSingleInstance(pending.instanceName, { explicit: true, ownerCurrent: current });
|
|
19455
20157
|
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restarted", pending.instanceName), pending.threadId);
|
|
19456
20158
|
}
|
|
19457
20159
|
catch (err) {
|
|
19458
20160
|
const message = err instanceof Error ? err.message : String(err);
|
|
19459
20161
|
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"));
|
|
20162
|
+
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
20163
|
}
|
|
19462
20164
|
return true;
|
|
19463
20165
|
}
|
|
@@ -19475,36 +20177,7 @@ export class FleetManager {
|
|
|
19475
20177
|
"antigravity": ".agents/AGENTS.md",
|
|
19476
20178
|
"mock": "CLAUDE.md",
|
|
19477
20179
|
};
|
|
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
|
-
`;
|
|
20180
|
+
static GENERAL_INSTRUCTIONS = GENERAL_ROLE_INSTRUCTIONS;
|
|
19508
20181
|
/** Ensure the general instance has its project instructions file + knowledge */
|
|
19509
20182
|
ensureGeneralInstructions(workDir, backendName, instanceName) {
|
|
19510
20183
|
measureSyncWork("fleet.generalInstructions", () => this.ensureGeneralInstructionsSync(workDir, backendName, instanceName));
|
|
@@ -19533,6 +20206,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19533
20206
|
resolveKnowledgeWorkDir(workDir, backend, instanceName) {
|
|
19534
20207
|
// Backend resolution may create/normalize the real cwd. Instructions and
|
|
19535
20208
|
// skills must land where the CLI actually runs, not an assumed path.
|
|
20209
|
+
// Caller pins resolveBackendBinary first so createBackend does not `which`.
|
|
19536
20210
|
try {
|
|
19537
20211
|
const resolved = createBackend(backend, join(getAgendHome(), "cli-env"))
|
|
19538
20212
|
.resolveWorkingDirectory?.(workDir, instanceName);
|
|
@@ -20878,7 +21552,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20878
21552
|
}));
|
|
20879
21553
|
const timer = setTimeout(() => this.pendingEffortSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
20880
21554
|
timer.unref?.();
|
|
20881
|
-
this.pendingEffortSelects.set(nonce, { instanceName: name, userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond });
|
|
21555
|
+
this.pendingEffortSelects.set(nonce, { instanceName: name, instanceCurrent: this.lifecyclePin(name), userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond });
|
|
20882
21556
|
try {
|
|
20883
21557
|
await data.respondChoices(t("effort.menu", this.effortMenuHeader(name)), choices);
|
|
20884
21558
|
}
|
|
@@ -20913,7 +21587,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20913
21587
|
}
|
|
20914
21588
|
}, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
20915
21589
|
timer.unref?.();
|
|
20916
|
-
this.pendingEffortSelects.set(nonce, { instanceName, userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
21590
|
+
this.pendingEffortSelects.set(nonce, { instanceName, instanceCurrent: this.lifecyclePin(instanceName), userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
20917
21591
|
try {
|
|
20918
21592
|
const menuMessageId = await adapter.promptUser(chatId, t("effort.menu", this.effortMenuHeader(instanceName)), choices, { threadId });
|
|
20919
21593
|
const pending = this.pendingEffortSelects.get(nonce);
|
|
@@ -20938,6 +21612,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20938
21612
|
const pending = this.pendingEffortSelects.get(match[1]);
|
|
20939
21613
|
if (!pending)
|
|
20940
21614
|
return true;
|
|
21615
|
+
// #1490 row 27: the menu was for one lifecycle of the instance; stopped, restarted or replaced since, it is stale.
|
|
21616
|
+
if (!pending.instanceCurrent()) {
|
|
21617
|
+
this.pendingEffortSelects.delete(match[1]);
|
|
21618
|
+
clearTimeout(pending.timer);
|
|
21619
|
+
data.ack?.(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName)));
|
|
21620
|
+
await pending.respond(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))).catch(() => { });
|
|
21621
|
+
return true;
|
|
21622
|
+
}
|
|
20941
21623
|
// The admin who opened the menu, through the adapter that posted it, in the same channel — and still an admin
|
|
20942
21624
|
// when they click (#754 audit): the menu lives for a minute, and a click carries its own callback data.
|
|
20943
21625
|
const cbChannel = data.threadId ?? data.chatId;
|
|
@@ -20980,17 +21662,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20980
21662
|
let result;
|
|
20981
21663
|
try {
|
|
20982
21664
|
// 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
|
-
|
|
21665
|
+
result = !pending.instanceCurrent() ? t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))
|
|
21666
|
+
: this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
|
|
21667
|
+
? await withSpawnHoldReply(async (text) => {
|
|
21668
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
21669
|
+
if (progressMsgId)
|
|
21670
|
+
await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
|
|
21671
|
+
else
|
|
21672
|
+
await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
|
|
21673
|
+
}
|
|
20988
21674
|
else
|
|
20989
|
-
await pending.
|
|
20990
|
-
}
|
|
20991
|
-
else
|
|
20992
|
-
await pending.respond(text);
|
|
20993
|
-
}, () => this.applyEffort(pending.instanceName, level)) : t("menu.click_stale");
|
|
21675
|
+
await pending.respond(text);
|
|
21676
|
+
}, () => this.applyEffort(pending.instanceName, level, { current: pending.instanceCurrent })) : t("menu.click_stale");
|
|
20994
21677
|
}
|
|
20995
21678
|
catch (err) {
|
|
20996
21679
|
this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
|
|
@@ -21047,7 +21730,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21047
21730
|
const choices = this.modelMenuChoices(name, nonce, options, currentModel);
|
|
21048
21731
|
const timer = setTimeout(() => this.pendingModelSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
21049
21732
|
timer.unref?.();
|
|
21050
|
-
this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond, respondChoices: data.respondChoices });
|
|
21733
|
+
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
21734
|
try {
|
|
21052
21735
|
await data.respondChoices(t("model.menu", `**${currentDisplay}**`), choices);
|
|
21053
21736
|
}
|
|
@@ -21083,7 +21766,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21083
21766
|
}
|
|
21084
21767
|
}, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
21085
21768
|
timer.unref?.();
|
|
21086
|
-
this.pendingModelSelects.set(nonce, { instanceName, model: "", userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
21769
|
+
this.pendingModelSelects.set(nonce, { instanceName, instanceCurrent: this.lifecyclePin(instanceName), model: "", userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
21087
21770
|
try {
|
|
21088
21771
|
const menuMessageId = await adapter.promptUser(chatId, t("model.menu", currentDisplay), choices, { threadId });
|
|
21089
21772
|
const pending = this.pendingModelSelects.get(nonce);
|
|
@@ -21256,6 +21939,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21256
21939
|
const pending = this.pendingModelSelects.get(match[1]);
|
|
21257
21940
|
if (!pending)
|
|
21258
21941
|
return true;
|
|
21942
|
+
// #1490 row 27: the menu was for one lifecycle of the instance; stopped, restarted or replaced since, it is stale.
|
|
21943
|
+
if (!pending.instanceCurrent()) {
|
|
21944
|
+
this.pendingModelSelects.delete(match[1]);
|
|
21945
|
+
clearTimeout(pending.timer);
|
|
21946
|
+
data.ack?.(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName)));
|
|
21947
|
+
await pending.respond(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))).catch(() => { });
|
|
21948
|
+
return true;
|
|
21949
|
+
}
|
|
21259
21950
|
// The admin who opened the menu, through the adapter that posted it, in the same channel — and still an admin
|
|
21260
21951
|
// when they click (#754 audit): the menu lives for a minute, and a click carries its own callback data.
|
|
21261
21952
|
const cbChannel = data.threadId ?? data.chatId;
|
|
@@ -21316,17 +22007,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21316
22007
|
let result;
|
|
21317
22008
|
try {
|
|
21318
22009
|
// 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
|
-
|
|
22010
|
+
result = !pending.instanceCurrent() ? t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))
|
|
22011
|
+
: this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
|
|
22012
|
+
? await withSpawnHoldReply(async (text) => {
|
|
22013
|
+
if (pending.adapter && pending.adapterChatId) {
|
|
22014
|
+
if (progressMsgId)
|
|
22015
|
+
await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
|
|
22016
|
+
else
|
|
22017
|
+
await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
|
|
22018
|
+
}
|
|
21324
22019
|
else
|
|
21325
|
-
await pending.
|
|
21326
|
-
}
|
|
21327
|
-
else
|
|
21328
|
-
await pending.respond(text);
|
|
21329
|
-
}, () => this.applyModel(pending.instanceName, model)) : t("menu.click_stale");
|
|
22020
|
+
await pending.respond(text);
|
|
22021
|
+
}, () => this.applyModel(pending.instanceName, model, { current: pending.instanceCurrent })) : t("menu.click_stale");
|
|
21330
22022
|
}
|
|
21331
22023
|
catch (err) {
|
|
21332
22024
|
this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
|
|
@@ -21423,7 +22115,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21423
22115
|
* restart backends only read it at launch, so it is persisted and the
|
|
21424
22116
|
* instance respawns.
|
|
21425
22117
|
*/
|
|
21426
|
-
async applyEffort(instanceName, requested) {
|
|
22118
|
+
async applyEffort(instanceName, requested, opts) {
|
|
22119
|
+
// #1490 row 27 (main): a menu's choice is for the lifecycle it was offered to — asked first and fenced into the restart.
|
|
22120
|
+
if (opts?.current && !opts.current())
|
|
22121
|
+
return t("menu.instance_replaced", this.shownInstanceName(instanceName));
|
|
21427
22122
|
if (isWindowsInstance(this.fleetConfig?.instances[instanceName]))
|
|
21428
22123
|
return t("effort.unsupported", this.backendNameForInstance(instanceName));
|
|
21429
22124
|
const level = requested.trim().toLowerCase();
|
|
@@ -21460,10 +22155,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21460
22155
|
this.pasteRawToClassicInstance(instanceName, `/effort ${applied}`);
|
|
21461
22156
|
return `${warn}${t("effort.runtime_success", instanceName, applied)}`;
|
|
21462
22157
|
}
|
|
21463
|
-
await this.restartSingleInstance(instanceName);
|
|
22158
|
+
await this.restartSingleInstance(instanceName, { ownerCurrent: opts?.current });
|
|
21464
22159
|
return `${warn}${t("effort.restart_success", instanceName, applied)}`;
|
|
21465
22160
|
}
|
|
21466
|
-
async applyModel(instanceName, model) {
|
|
22161
|
+
async applyModel(instanceName, model, opts) {
|
|
22162
|
+
// #1490 row 27: a menu's choice is for the lifecycle it was offered to — asked first, after the runtime switch's
|
|
22163
|
+
// await, and fenced into the restart.
|
|
22164
|
+
if (opts?.current && !opts.current())
|
|
22165
|
+
return t("menu.instance_replaced", this.shownInstanceName(instanceName));
|
|
21467
22166
|
// Reject model names with newlines or control characters: they would be
|
|
21468
22167
|
// persisted to fleet.yaml and pasted raw into the CLI (P3 from #1490 audit).
|
|
21469
22168
|
if (/[\x00-\x1f\x7f]/.test(model)) {
|
|
@@ -21503,6 +22202,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21503
22202
|
// #1690: claude-code's runtime switch reports what it came to; the model is saved unless claude was told No.
|
|
21504
22203
|
if (strategy === "runtime" && backendName === "claude-code") {
|
|
21505
22204
|
const result = await this.runRuntimeModelSwitch(instanceName, model);
|
|
22205
|
+
if (opts?.current && !opts.current())
|
|
22206
|
+
return t("menu.instance_replaced", this.shownInstanceName(instanceName));
|
|
21506
22207
|
if (result.outcome === "mismatch")
|
|
21507
22208
|
return `${warn}${t("model.switch_mismatch", instanceName, result.shown, model)}`;
|
|
21508
22209
|
if (result.outcome === "not_sent")
|
|
@@ -21523,7 +22224,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21523
22224
|
this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
|
|
21524
22225
|
return `${warn}${t("model.runtime_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
|
|
21525
22226
|
}
|
|
21526
|
-
await this.restartSingleInstance(instanceName);
|
|
22227
|
+
await this.restartSingleInstance(instanceName, { ownerCurrent: opts?.current });
|
|
21527
22228
|
return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
|
|
21528
22229
|
}
|
|
21529
22230
|
/** Save an instance's model (fleet.yaml, or its ClassicBot channel). False when neither holds it. */
|
|
@@ -22464,6 +23165,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
22464
23165
|
const settingsControlStopped = settingsControl?.close();
|
|
22465
23166
|
this.publicLinkAutoOpen?.stop();
|
|
22466
23167
|
const publicStopped = this.publicWebLink?.close("fleet shutdown");
|
|
23168
|
+
this.webRemoteAccessState?.close(); // #3008: an unsaved switch gets its last write
|
|
22467
23169
|
const sharesStopped = this.officeSharesInstance?.close(); // #2636: every Office share ends, and its tunnel
|
|
22468
23170
|
// #1386: every live "Needs you" message says the fleet stopped (capabilities revoked first) — while the
|
|
22469
23171
|
// adapters can still edit. Bounded: a platform that does not answer must not hold the shutdown.
|
|
@@ -22502,6 +23204,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
22502
23204
|
}
|
|
22503
23205
|
this.eventLoopWatch?.stop();
|
|
22504
23206
|
this.eventLoopWatch = null;
|
|
23207
|
+
this.stallReporter?.stop();
|
|
23208
|
+
this.stallReporter = null;
|
|
22505
23209
|
// A login/install window is a dedicated tmux server with its own TTL
|
|
22506
23210
|
// timer and HTTP listener living in THIS process: without an explicit
|
|
22507
23211
|
// shutdown it would outlive us as an owner-less login CLI (sol B3).
|
|
@@ -24963,7 +25667,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
24963
25667
|
if (decision.reason === "no-credential" && req.method === "GET"
|
|
24964
25668
|
&& String(req.headers.accept ?? "").includes("text/html")
|
|
24965
25669
|
&& isWebPageNavigation(requestPath)) {
|
|
24966
|
-
|
|
25670
|
+
const link = gatewayRequestContext(req);
|
|
25671
|
+
serveSigninPage(res, 401, { webOnly: this.isWebOnlyFleet(), outside: !!link && this.isOutsideLink(link.exposureId) });
|
|
24967
25672
|
return;
|
|
24968
25673
|
}
|
|
24969
25674
|
res.writeHead(decision.status);
|
|
@@ -25618,6 +26323,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
25618
26323
|
}
|
|
25619
26324
|
return {
|
|
25620
26325
|
name,
|
|
26326
|
+
general_topic: nativeConfig?.general_topic === true,
|
|
25621
26327
|
...(isNativePaneInstance(nativeConfig) ? { runtime: "windows", native_adapter: "pane", native_agent_id: nativeConfig.native_agent_id,
|
|
25622
26328
|
...(() => {
|
|
25623
26329
|
try {
|
|
@@ -25655,6 +26361,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
25655
26361
|
role: identity.role || undefined,
|
|
25656
26362
|
tags: identity.tags,
|
|
25657
26363
|
teams: instanceTeams(this.fleetConfig?.teams, name), // #2172: the sidebar groups a team's members under it
|
|
26364
|
+
// #3008 G2: a web-only fleet's General (the agent its owner ticked) — the sidebar pins it first.
|
|
26365
|
+
...(this.isWebOnlyFleet() && this.fleetConfig?.instances[name]?.general_topic === true ? { general: true } : {}),
|
|
25658
26366
|
last_reply: this.webChatHistory.lastAgentReply(name) ?? undefined, // #2179: its last reply to a person (ISO)
|
|
25659
26367
|
// #2855: its last reply that reached the channel — the only reply time a guest's sidebar gets (guestStatus).
|
|
25660
26368
|
last_reply_channel: this.webChatHistory.lastAgentReply(name, { channel: true }) ?? undefined,
|