@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.
Files changed (212) hide show
  1. package/dist/agend-home.d.ts +13 -0
  2. package/dist/agend-home.js +42 -0
  3. package/dist/agend-home.js.map +1 -1
  4. package/dist/auth-api.d.ts +21 -0
  5. package/dist/auth-api.js +91 -4
  6. package/dist/auth-api.js.map +1 -1
  7. package/dist/authority/web-authority.d.ts +12 -2
  8. package/dist/authority/web-authority.js +25 -4
  9. package/dist/authority/web-authority.js.map +1 -1
  10. package/dist/backend/claude-code.js +16 -1
  11. package/dist/backend/claude-code.js.map +1 -1
  12. package/dist/backend/codex.js +11 -1
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/kiro-identity.d.ts +2 -0
  15. package/dist/backend/kiro-identity.js +13 -6
  16. package/dist/backend/kiro-identity.js.map +1 -1
  17. package/dist/backend/kiro.js +18 -3
  18. package/dist/backend/kiro.js.map +1 -1
  19. package/dist/backend-availability.d.ts +6 -0
  20. package/dist/backend-availability.js +13 -5
  21. package/dist/backend-availability.js.map +1 -1
  22. package/dist/channel/mcp-tools.js +1 -1
  23. package/dist/channel/mcp-tools.js.map +1 -1
  24. package/dist/cli.js +6 -9
  25. package/dist/cli.js.map +1 -1
  26. package/dist/config-validator.js +12 -1
  27. package/dist/config-validator.js.map +1 -1
  28. package/dist/daemon.d.ts +5 -0
  29. package/dist/daemon.js +107 -30
  30. package/dist/daemon.js.map +1 -1
  31. package/dist/fleet-manager.d.ts +143 -8
  32. package/dist/fleet-manager.js +874 -166
  33. package/dist/fleet-manager.js.map +1 -1
  34. package/dist/fleet-windows-runtime.js +7 -0
  35. package/dist/fleet-windows-runtime.js.map +1 -1
  36. package/dist/fleet-yaml-patch.d.ts +3 -0
  37. package/dist/fleet-yaml-patch.js +103 -0
  38. package/dist/fleet-yaml-patch.js.map +1 -0
  39. package/dist/general-binding.d.ts +85 -0
  40. package/dist/general-binding.js +298 -0
  41. package/dist/general-binding.js.map +1 -0
  42. package/dist/general-host-owner.d.ts +8 -0
  43. package/dist/general-host-owner.js +26 -0
  44. package/dist/general-host-owner.js.map +1 -0
  45. package/dist/general-instance.d.ts +7 -0
  46. package/dist/general-instance.js +20 -3
  47. package/dist/general-instance.js.map +1 -1
  48. package/dist/general-knowledge/skills/model-discovery/SKILL.md +2 -0
  49. package/dist/general-role.d.ts +5 -0
  50. package/dist/general-role.js +26 -0
  51. package/dist/general-role.js.map +1 -0
  52. package/dist/instance-lifecycle.d.ts +1 -0
  53. package/dist/instance-lifecycle.js +26 -4
  54. package/dist/instance-lifecycle.js.map +1 -1
  55. package/dist/instructions.d.ts +2 -0
  56. package/dist/instructions.js +6 -0
  57. package/dist/instructions.js.map +1 -1
  58. package/dist/kiro-resume-summary.d.ts +37 -0
  59. package/dist/kiro-resume-summary.js +85 -0
  60. package/dist/kiro-resume-summary.js.map +1 -0
  61. package/dist/locale.js +59 -0
  62. package/dist/locale.js.map +1 -1
  63. package/dist/login-controller.js +9 -1
  64. package/dist/login-controller.js.map +1 -1
  65. package/dist/native-pane-lifecycle.js +4 -1
  66. package/dist/native-pane-lifecycle.js.map +1 -1
  67. package/dist/native-pane-manager.d.ts +1 -0
  68. package/dist/native-pane-manager.js +23 -2
  69. package/dist/native-pane-manager.js.map +1 -1
  70. package/dist/needs-you-hub.js +1 -1
  71. package/dist/needs-you-hub.js.map +1 -1
  72. package/dist/needs-you.d.ts +8 -1
  73. package/dist/needs-you.js +5 -2
  74. package/dist/needs-you.js.map +1 -1
  75. package/dist/package-preimage.js +27 -2
  76. package/dist/package-preimage.js.map +1 -1
  77. package/dist/pane-port.d.ts +11 -0
  78. package/dist/public-web-gateway.js +9 -0
  79. package/dist/public-web-gateway.js.map +1 -1
  80. package/dist/public-web-link.d.ts +5 -0
  81. package/dist/public-web-link.js +4 -0
  82. package/dist/public-web-link.js.map +1 -1
  83. package/dist/quickstart-api.js +9 -5
  84. package/dist/quickstart-api.js.map +1 -1
  85. package/dist/quickstart.d.ts +1 -0
  86. package/dist/quickstart.js +4 -1
  87. package/dist/quickstart.js.map +1 -1
  88. package/dist/reply-buttons-controller.js +10 -0
  89. package/dist/reply-buttons-controller.js.map +1 -1
  90. package/dist/runtime-bundle.js +3 -0
  91. package/dist/runtime-bundle.js.map +1 -1
  92. package/dist/runtime-general-protocol.d.ts +22 -0
  93. package/dist/runtime-general-protocol.js +57 -0
  94. package/dist/runtime-general-protocol.js.map +1 -0
  95. package/dist/runtime-general-workspace.d.ts +17 -0
  96. package/dist/runtime-general-workspace.js +213 -0
  97. package/dist/runtime-general-workspace.js.map +1 -0
  98. package/dist/runtime-pane-assets.d.ts +1 -1
  99. package/dist/runtime-pane-assets.js +1 -1
  100. package/dist/runtime-pane-assets.js.map +1 -1
  101. package/dist/runtime-pane-host.d.ts +1 -0
  102. package/dist/runtime-pane-host.js +22 -1
  103. package/dist/runtime-pane-host.js.map +1 -1
  104. package/dist/runtime-pane-mcp.d.ts +8 -0
  105. package/dist/runtime-pane-mcp.js +15 -2
  106. package/dist/runtime-pane-mcp.js.map +1 -1
  107. package/dist/runtime-pane-port.d.ts +6 -1
  108. package/dist/runtime-pane-port.js +21 -0
  109. package/dist/runtime-pane-port.js.map +1 -1
  110. package/dist/runtime-pane-protocol.d.ts +4 -1
  111. package/dist/runtime-pane-protocol.js +7 -1
  112. package/dist/runtime-pane-protocol.js.map +1 -1
  113. package/dist/settings-api.d.ts +5 -0
  114. package/dist/settings-api.js +37 -1
  115. package/dist/settings-api.js.map +1 -1
  116. package/dist/settings-baseline.d.ts +1 -0
  117. package/dist/settings-baseline.js +1 -1
  118. package/dist/settings-baseline.js.map +1 -1
  119. package/dist/settings-confirmation.d.ts +1 -0
  120. package/dist/settings-confirmation.js +1 -0
  121. package/dist/settings-confirmation.js.map +1 -1
  122. package/dist/settings-effect.d.ts +4 -0
  123. package/dist/settings-effect.js +35 -0
  124. package/dist/settings-effect.js.map +1 -1
  125. package/dist/settings-http-confirmation.d.ts +1 -0
  126. package/dist/settings-http-confirmation.js +4 -0
  127. package/dist/settings-http-confirmation.js.map +1 -1
  128. package/dist/settings-request-capability.d.ts +2 -0
  129. package/dist/settings-request-capability.js +4 -0
  130. package/dist/settings-request-capability.js.map +1 -1
  131. package/dist/settings-route-lifetime.d.ts +14 -0
  132. package/dist/settings-route-lifetime.js +48 -0
  133. package/dist/settings-route-lifetime.js.map +1 -0
  134. package/dist/settings-transaction.d.ts +4 -0
  135. package/dist/settings-transaction.js +9 -0
  136. package/dist/settings-transaction.js.map +1 -1
  137. package/dist/setup-form.js +6 -3
  138. package/dist/setup-form.js.map +1 -1
  139. package/dist/setup-i18n.js +3 -0
  140. package/dist/setup-i18n.js.map +1 -1
  141. package/dist/stall-reporter.d.ts +21 -0
  142. package/dist/stall-reporter.js +82 -0
  143. package/dist/stall-reporter.js.map +1 -0
  144. package/dist/tmux-control.d.ts +2 -2
  145. package/dist/tmux-control.js +4 -2
  146. package/dist/tmux-control.js.map +1 -1
  147. package/dist/tmux-exec-helper.d.ts +6 -3
  148. package/dist/tmux-exec-helper.js +115 -53
  149. package/dist/tmux-exec-helper.js.map +1 -1
  150. package/dist/tool-permissions.js +2 -1
  151. package/dist/tool-permissions.js.map +1 -1
  152. package/dist/tunnel/cloudflared-install.d.ts +4 -0
  153. package/dist/tunnel/cloudflared-install.js +20 -1
  154. package/dist/tunnel/cloudflared-install.js.map +1 -1
  155. package/dist/tunnel/cloudflared.d.ts +8 -1
  156. package/dist/tunnel/cloudflared.js +34 -6
  157. package/dist/tunnel/cloudflared.js.map +1 -1
  158. package/dist/tunnel/lease.d.ts +3 -3
  159. package/dist/tunnel/lease.js +23 -19
  160. package/dist/tunnel/lease.js.map +1 -1
  161. package/dist/tunnel/manager.js +6 -7
  162. package/dist/tunnel/manager.js.map +1 -1
  163. package/dist/types.d.ts +2 -0
  164. package/dist/ui/agent-proposals.js +2 -2
  165. package/dist/ui/general-location.js +71 -0
  166. package/dist/ui/native-pane-recovery.js +1 -1
  167. package/dist/ui/panel-chat.js +2 -0
  168. package/dist/ui/panel-needs.js +2 -0
  169. package/dist/ui/panel-settings.js +5 -1
  170. package/dist/ui/settings-dialogs.js +8 -2
  171. package/dist/ui/settings-model.js +2 -1
  172. package/dist/ui/settings-outside.js +176 -0
  173. package/dist/ui/settings-registry.js +1 -0
  174. package/dist/ui/settings-strings.js +2 -0
  175. package/dist/ui/shared/app-i18n.js +2 -2
  176. package/dist/ui/shared/app.css +16 -1
  177. package/dist/ui/shared/instance-nav.js +13 -3
  178. package/dist/ui/shared/panel-view.js +266 -17
  179. package/dist/ui/shared/qrcode.module.js +2237 -0
  180. package/dist/ui/shared/signin.js +39 -5
  181. package/dist/ui/shared/vendor/qrcode-generator.LICENSE +27 -0
  182. package/dist/ui/shared/vendor/vendor.json +19 -2
  183. package/dist/ui/shared/view-strings.js +28 -0
  184. package/dist/unattended-install.d.ts +1 -1
  185. package/dist/unattended-install.js +19 -5
  186. package/dist/unattended-install.js.map +1 -1
  187. package/dist/update-check.d.ts +2 -8
  188. package/dist/update-check.js +2 -5
  189. package/dist/update-check.js.map +1 -1
  190. package/dist/view-api.d.ts +15 -0
  191. package/dist/view-api.js +94 -33
  192. package/dist/view-api.js.map +1 -1
  193. package/dist/web-api.d.ts +12 -0
  194. package/dist/web-api.js +77 -0
  195. package/dist/web-api.js.map +1 -1
  196. package/dist/web-login.d.ts +4 -3
  197. package/dist/web-login.js +23 -14
  198. package/dist/web-login.js.map +1 -1
  199. package/dist/web-only-fleet.d.ts +20 -0
  200. package/dist/web-only-fleet.js +21 -0
  201. package/dist/web-only-fleet.js.map +1 -1
  202. package/dist/web-remote-access.d.ts +152 -0
  203. package/dist/web-remote-access.js +368 -0
  204. package/dist/web-remote-access.js.map +1 -0
  205. package/dist/web-session.d.ts +12 -0
  206. package/dist/web-session.js +22 -8
  207. package/dist/web-session.js.map +1 -1
  208. package/dist/windows-agents.d.ts +12 -0
  209. package/dist/windows-agents.js +24 -0
  210. package/dist/windows-agents.js.map +1 -1
  211. package/launcher/agend-install.cjs +16 -2
  212. package/package.json +1 -1
@@ -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, accessSync, realpathSync, lstatSync, constants as fsConstants, closeSync } from "node:fs";
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) => buildFleetInstructions({ instanceName: name, workingDirectory: config.working_directory,
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
- return (await this.nativePanes()).prepare(name, config, assertCurrent);
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
- if (gatewayRequestContext(req))
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
- if (!this.settingsNoticeGenerals(affected, chat.platform).length)
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) && this.settingsNoticeGenerals(affected, chat.platform).length > 0,
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
- if (!this.isWebOnlyFleet() || gatewayRequestContext(req))
2227
- return null;
2228
- const auth = authorizeSession(req, this.webToken, this.webSessions, { touch: false });
2229
- if (auth.kind !== "ok")
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
- /** #2170: the Generals a chat card could be posted in (promptSettingsChange's rule): connected, on the platform, unaffected. */
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), found = [];
2283
- for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
2284
- if (!config.general_topic || !this.daemons.get(name))
2285
- continue;
2286
- const adapterId = this.getInstanceAdapterId(name), adapter = this.getAdapterForInstance(name);
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 [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
2474
- if (!config.general_topic)
2475
- continue;
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) && !this.shuttingDown && this.settingsGeneration === generation
2486
- && this.fleetConfig?.instances[name]?.general_topic === true
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 = authorityNow(item.authority, state);
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
- if (!isWindowsInstance(config) && config.general_topic) {
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 () => ONBOARDING_BACKENDS.filter(backend => this.isCliInstalled(backend)),
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 Promise.resolve(removedBackendMessage(wanted));
19165
+ return removedBackendMessage(wanted);
18468
19166
  const info = BACKEND_INSTALLATION_INFO[wanted];
18469
- if (info && LOGIN_FLOWS[wanted]?.remoteLogin !== "unsupported" && !this.isCliInstalled(wanted)) {
18470
- return Promise.resolve(t("login.web_dashboard_not_installed", wanted, info.install));
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.remote_unsupported_agent_cli", backend, flow.command)
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
- const installedAt = (await this.locateBinaryOnLoginShell(info.binary)) ?? this.locateInInstallerBinDirs(info);
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
- const result = await runOnLoginShell(`command -v ${binary}`);
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
- if (!isAbsolute(path))
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
- * when no login shell can see it (the installer recorded its PATH in an
19116
- * interactive-only rc file). The same executable-file check as the login
19117
- * shell lookup; a symlink to an executable counts, as codex's launcher is one.
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
- locateInInstallerBinDirs(info) {
19120
- for (const dir of info.binDirs?.(process.env, homedir()) ?? []) {
19121
- if (!isAbsolute(dir))
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 = `# Fleet Coordinator
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 = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
20984
- ? await withSpawnHoldReply(async (text) => {
20985
- if (pending.adapter && pending.adapterChatId) {
20986
- if (progressMsgId)
20987
- await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
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.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
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 = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
21320
- ? await withSpawnHoldReply(async (text) => {
21321
- if (pending.adapter && pending.adapterChatId) {
21322
- if (progressMsgId)
21323
- await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
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.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
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
- serveSigninPage(res, 401, { webOnly: this.isWebOnlyFleet() });
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,