@songsid/agend 2.3.5-alpha.2 → 2.3.5-alpha.4

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