@songsid/agend 2.3.0-beta.2 → 2.3.0-beta.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 (189) hide show
  1. package/dist/agent-whats-new.js +2 -0
  2. package/dist/agent-whats-new.js.map +1 -1
  3. package/dist/auth-api.d.ts +30 -1
  4. package/dist/auth-api.js +42 -12
  5. package/dist/auth-api.js.map +1 -1
  6. package/dist/backend/claude-code.d.ts +2 -2
  7. package/dist/backend/claude-code.js +5 -4
  8. package/dist/backend/claude-code.js.map +1 -1
  9. package/dist/backend/types.d.ts +6 -0
  10. package/dist/backend/types.js +15 -0
  11. package/dist/backend/types.js.map +1 -1
  12. package/dist/backend-availability.d.ts +120 -0
  13. package/dist/backend-availability.js +396 -0
  14. package/dist/backend-availability.js.map +1 -0
  15. package/dist/backend-command.d.ts +21 -3
  16. package/dist/backend-command.js +23 -4
  17. package/dist/backend-command.js.map +1 -1
  18. package/dist/channel/adapters/discord.d.ts +7 -0
  19. package/dist/channel/adapters/discord.js +35 -0
  20. package/dist/channel/adapters/discord.js.map +1 -1
  21. package/dist/channel/mcp-tools.js +8 -4
  22. package/dist/channel/mcp-tools.js.map +1 -1
  23. package/dist/channel/types.d.ts +6 -0
  24. package/dist/classic-channel-manager.d.ts +8 -3
  25. package/dist/classic-channel-manager.js +10 -3
  26. package/dist/classic-channel-manager.js.map +1 -1
  27. package/dist/config-validator.js +73 -2
  28. package/dist/config-validator.js.map +1 -1
  29. package/dist/delivery-outbox.js +5 -0
  30. package/dist/delivery-outbox.js.map +1 -1
  31. package/dist/fleet-context.d.ts +10 -0
  32. package/dist/fleet-manager.d.ts +238 -5
  33. package/dist/fleet-manager.js +878 -71
  34. package/dist/fleet-manager.js.map +1 -1
  35. package/dist/fleet-traffic.d.ts +38 -0
  36. package/dist/fleet-traffic.js +96 -0
  37. package/dist/fleet-traffic.js.map +1 -0
  38. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +13 -5
  39. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -1
  40. package/dist/general-knowledge/skills/web-appearance/SKILL.md +56 -11
  41. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +398 -0
  42. package/dist/human-delivery-receipts.js +2 -1
  43. package/dist/human-delivery-receipts.js.map +1 -1
  44. package/dist/locale.js +48 -8
  45. package/dist/locale.js.map +1 -1
  46. package/dist/memory-hogs.d.ts +27 -0
  47. package/dist/memory-hogs.js +124 -0
  48. package/dist/memory-hogs.js.map +1 -0
  49. package/dist/outbound-handlers.d.ts +2 -0
  50. package/dist/outbound-handlers.js +40 -1
  51. package/dist/outbound-handlers.js.map +1 -1
  52. package/dist/outbound-schemas.d.ts +92 -0
  53. package/dist/outbound-schemas.js +55 -1
  54. package/dist/outbound-schemas.js.map +1 -1
  55. package/dist/public-web-gateway.js +1 -1
  56. package/dist/public-web-gateway.js.map +1 -1
  57. package/dist/public-web-link.d.ts +9 -0
  58. package/dist/public-web-link.js +8 -0
  59. package/dist/public-web-link.js.map +1 -1
  60. package/dist/quickstart-api.d.ts +10 -1
  61. package/dist/quickstart-api.js +12 -4
  62. package/dist/quickstart-api.js.map +1 -1
  63. package/dist/reply-buttons-controller.d.ts +2 -0
  64. package/dist/reply-buttons-controller.js +1 -1
  65. package/dist/reply-buttons-controller.js.map +1 -1
  66. package/dist/tmux-manager.d.ts +5 -0
  67. package/dist/tmux-manager.js +16 -0
  68. package/dist/tmux-manager.js.map +1 -1
  69. package/dist/tool-permissions.js +4 -0
  70. package/dist/tool-permissions.js.map +1 -1
  71. package/dist/topic-commands.d.ts +5 -0
  72. package/dist/topic-commands.js +16 -0
  73. package/dist/topic-commands.js.map +1 -1
  74. package/dist/types.d.ts +25 -1
  75. package/dist/ui/app-effects.js +33 -9
  76. package/dist/ui/chat-effects.js +62 -4
  77. package/dist/ui/chat-strings.js +40 -4
  78. package/dist/ui/chat-thread.js +17 -6
  79. package/dist/ui/effect-trial-panel.js +19 -16
  80. package/dist/ui/fleet-canvas-layout.js +111 -0
  81. package/dist/ui/fleet-canvas.js +489 -0
  82. package/dist/ui/fleet-org.js +27 -12
  83. package/dist/ui/fleet-traffic.js +224 -0
  84. package/dist/ui/panel-chat.js +12 -5
  85. package/dist/ui/panel-details.js +2 -1
  86. package/dist/ui/panel-fleet.js +32 -11
  87. package/dist/ui/panel-settings.js +6 -4
  88. package/dist/ui/preview.js +3 -2
  89. package/dist/ui/settings-appearance.js +34 -18
  90. package/dist/ui/settings-catalog.js +89 -0
  91. package/dist/ui/settings-dialogs.js +40 -12
  92. package/dist/ui/settings-effects.js +4 -3
  93. package/dist/ui/settings-learn.js +25 -0
  94. package/dist/ui/settings-model.js +6 -2
  95. package/dist/ui/settings-mods.js +4 -3
  96. package/dist/ui/settings-owner-effects.js +8 -8
  97. package/dist/ui/settings-strings.js +40 -38
  98. package/dist/ui/shared/app-i18n.js +2 -2
  99. package/dist/ui/shared/app-prefs.js +1 -1
  100. package/dist/ui/shared/app-shell.js +23 -1
  101. package/dist/ui/shared/app-store.js +12 -1
  102. package/dist/ui/shared/app-stream.js +2 -1
  103. package/dist/ui/shared/app.css +186 -11
  104. package/dist/ui/shared/card-strings.js +4 -0
  105. package/dist/ui/shared/effect-app-runtime.js +1 -1
  106. package/dist/ui/shared/effect-frame.js +10 -5
  107. package/dist/ui/shared/effect-geometry.js +24 -9
  108. package/dist/ui/shared/effect-policy.js +2 -1
  109. package/dist/ui/shared/effect-registry.js +71 -17
  110. package/dist/ui/shared/effect-stock-trust.js +23 -2
  111. package/dist/ui/shared/effect-trial-shadow.js +62 -0
  112. package/dist/ui/shared/effect-trial-stage.js +15 -9
  113. package/dist/ui/shared/effect-trial.css +40 -0
  114. package/dist/ui/shared/identity-card.js +75 -4
  115. package/dist/ui/shared/identity-color.js +3 -3
  116. package/dist/ui/shared/instance-nav.js +20 -4
  117. package/dist/ui/shared/panel-view.js +24 -4
  118. package/dist/ui/shared/view-strings.js +22 -0
  119. package/dist/view-api.d.ts +32 -0
  120. package/dist/view-api.js +55 -4
  121. package/dist/view-api.js.map +1 -1
  122. package/dist/web-api.d.ts +19 -4
  123. package/dist/web-api.js +178 -20
  124. package/dist/web-api.js.map +1 -1
  125. package/dist/web-appearance-policy.d.ts +4 -6
  126. package/dist/web-appearance-policy.js +5 -12
  127. package/dist/web-appearance-policy.js.map +1 -1
  128. package/dist/web-appearance-tools.js +5 -1
  129. package/dist/web-appearance-tools.js.map +1 -1
  130. package/dist/web-appearance-view.d.ts +14 -1
  131. package/dist/web-appearance-view.js +98 -10
  132. package/dist/web-appearance-view.js.map +1 -1
  133. package/dist/web-appearance.d.ts +2 -2
  134. package/dist/web-appearance.js +5 -6
  135. package/dist/web-appearance.js.map +1 -1
  136. package/dist/web-auth.js +1 -1
  137. package/dist/web-auth.js.map +1 -1
  138. package/dist/web-card.d.ts +14 -0
  139. package/dist/web-card.js +40 -6
  140. package/dist/web-card.js.map +1 -1
  141. package/dist/web-channel-echo.d.ts +7 -1
  142. package/dist/web-channel-echo.js +6 -2
  143. package/dist/web-channel-echo.js.map +1 -1
  144. package/dist/web-chat-history.d.ts +40 -0
  145. package/dist/web-chat-history.js +66 -3
  146. package/dist/web-chat-history.js.map +1 -1
  147. package/dist/web-commands.d.ts +1 -0
  148. package/dist/web-effect-tools.js +2 -3
  149. package/dist/web-effect-tools.js.map +1 -1
  150. package/dist/web-effect-trials.d.ts +2 -2
  151. package/dist/web-effect-trials.js +3 -3
  152. package/dist/web-effect-trials.js.map +1 -1
  153. package/dist/web-guest-access.d.ts +125 -0
  154. package/dist/web-guest-access.js +232 -0
  155. package/dist/web-guest-access.js.map +1 -0
  156. package/dist/web-guest-rooms.d.ts +82 -0
  157. package/dist/web-guest-rooms.js +135 -0
  158. package/dist/web-guest-rooms.js.map +1 -0
  159. package/dist/web-identity-palette.d.ts +1 -1
  160. package/dist/web-identity-palette.js +2 -2
  161. package/dist/web-identity-palette.js.map +1 -1
  162. package/dist/web-login.d.ts +47 -0
  163. package/dist/web-login.js +115 -0
  164. package/dist/web-login.js.map +1 -1
  165. package/dist/web-mod-agent.d.ts +1 -1
  166. package/dist/web-mod-agent.js +2 -4
  167. package/dist/web-mod-agent.js.map +1 -1
  168. package/dist/web-mod-store.d.ts +5 -0
  169. package/dist/web-mod-store.js +6 -0
  170. package/dist/web-mod-store.js.map +1 -1
  171. package/dist/web-prefs.d.ts +8 -0
  172. package/dist/web-prefs.js +9 -0
  173. package/dist/web-prefs.js.map +1 -1
  174. package/dist/web-preview.d.ts +6 -2
  175. package/dist/web-preview.js +4 -2
  176. package/dist/web-preview.js.map +1 -1
  177. package/dist/web-returning-device.d.ts +39 -0
  178. package/dist/web-returning-device.js +46 -5
  179. package/dist/web-returning-device.js.map +1 -1
  180. package/dist/web-session.d.ts +58 -10
  181. package/dist/web-session.js +104 -22
  182. package/dist/web-session.js.map +1 -1
  183. package/dist/web-settings-tools.d.ts +81 -0
  184. package/dist/web-settings-tools.js +257 -0
  185. package/dist/web-settings-tools.js.map +1 -0
  186. package/dist/web-theme.d.ts +2 -0
  187. package/dist/web-theme.js +10 -1
  188. package/dist/web-theme.js.map +1 -1
  189. package/package.json +1 -1
@@ -48,6 +48,7 @@ import { isProbeableRouteTarget } from "./fleet-context.js";
48
48
  import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
49
49
  import { EventLog } from "./event-log.js";
50
50
  import { binaryProbe } from "./binary-probe.js";
51
+ import { AVAILABILITY_STALE_MS, BackendAvailabilityStore, hostAvailabilityProbes } from "./backend-availability.js";
51
52
  import { classifySqliteOpenError } from "./sqlite-open-errors.js";
52
53
  import { OutboxOpenError } from "./outbox-open-error.js";
53
54
  import { AdapterWorld } from "./adapter-world.js";
@@ -65,7 +66,7 @@ import { createBackend } from "./backend/factory.js";
65
66
  import { readEffortMetadata } from "./backend/effort-metadata.js";
66
67
  import { codexEffort, flagEffort } from "./backend/launch-effort.js";
67
68
  import { codexMetadataHomes, readCodexModelEffortLevels, resolveCodexLaunchModel } from "./backend/codex-metadata.js";
68
- import { CLI_ENV_TTL_MS, isModelCompatible, resolveBinary, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
69
+ import { CLI_ENV_TTL_MS, isModelCompatible, noteResolvedBinary, resolveBinary, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
69
70
  import { createLogger, rotateLogIfNeeded } from "./logger.js";
70
71
  import { processAttachments } from "./channel/attachment-handler.js";
71
72
  import { routeToolCall } from "./channel/tool-router.js";
@@ -134,9 +135,9 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
134
135
  }
135
136
  return tasks;
136
137
  }
137
- import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
138
+ import { handleWebRequest, broadcastSseEvent, broadcastToGuests, SSE_HEARTBEAT_MS } from "./web-api.js";
138
139
  import { LogService } from "./log-service.js";
139
- import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
140
+ import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId, webChatFrom } from "./web-chat-history.js";
140
141
  import { WebChatDiskStore } from "./web-chat-store.js";
141
142
  import { SeenUsers, resolveMentions } from "./web-mentions.js";
142
143
  import { OPERATOR, WebPrefsStore } from "./web-prefs.js";
@@ -158,7 +159,7 @@ import { ReplyButtonsController } from "./reply-buttons-controller.js";
158
159
  import { parsePoll } from "./reply-polls.js";
159
160
  import { isUploadRequest, limitRequestReceive, publicAttachment, sweepOrphanedUploads, UPLOAD_REQUEST_TIMEOUT_MS, uploadLimits, WebFileLedger } from "./web-upload.js";
160
161
  import { REPLY_FILES_MAX_BYTES, snapshotReplyFile, sweepReplySnapshots, webFilesDir } from "./web-reply-files.js";
161
- import { cardPicture, handleViewRequest, humanCards, identityColors, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
162
+ import { cardPicture, handleViewRequest, humanCards, identityColors, isViewPath, personBannerFocus, personIdentityColour, profileIdentities, resolveInstanceIdentity, setPersonBannerFocus, setPersonIdentityColour } from "./view-api.js";
162
163
  import { autoIdentityColor } from "./web-identity-color.js";
163
164
  import { AppearanceTools } from "./web-appearance-tools.js";
164
165
  import { cardVersion, resolveCard } from "./web-card.js";
@@ -178,7 +179,11 @@ import { runOnLoginShell } from "./login-shell.js";
178
179
  import { tightenInstanceDirs } from "./private-dir.js";
179
180
  import { decideSlash } from "./slash-authz.js";
180
181
  import { canonicalCommand, commandSpec, decideCommand, ruleFor } from "./command-table.js";
181
- import { runVisibilityCommand } from "./cross-instance-notice.js";
182
+ import { crossInstanceVisibility, runVisibilityCommand } from "./cross-instance-notice.js";
183
+ import { WebSettingsTools } from "./web-settings-tools.js";
184
+ import { bindGuestRequest, guestRouteDecision } from "./web-guest-access.js";
185
+ import { GUEST_ROOM_TICK_MS, GuestRoomMembership, guestGroupRooms } from "./web-guest-rooms.js";
186
+ import { TrafficLog, trafficItem } from "./fleet-traffic.js";
182
187
  import { installedChannel, updateNoticeKey } from "./update-check.js";
183
188
  import { resolveInstalledAgend } from "./update-dispatch.js";
184
189
  import { resolveUpdateLaunch, openUpdateLog, updateLogPath, watchUpdateLaunch } from "./update-launch.js";
@@ -190,7 +195,7 @@ import { setLocale, detectLocale, getLocale, t } from "./locale.js";
190
195
  import { recentChatContext } from "./log-tail.js";
191
196
  import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet-control-audit.js";
192
197
  import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
193
- import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
198
+ import { ClassicChannelManager, classicWebEchoOn, getClassicBackendChoices, inferClassicChannelType, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
194
199
  import { assertExplicitInstanceRemoval } from "./instance-removal.js";
195
200
  import { KNOWN_BACKENDS, validateFleetConfig } from "./config-validator.js";
196
201
  import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
@@ -209,6 +214,7 @@ import { NeedsYouHub } from "./needs-you-hub.js";
209
214
  import { WEB_CHAT_NOTICE, claimNotice, hasWebChat, releaseNotice, upgradeNoticesPath } from "./upgrade-notices.js";
210
215
  import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
211
216
  import { buildOrgChart } from "./web-org.js";
217
+ import { topMemoryHogs } from "./memory-hogs.js";
212
218
  import { GENERAL_POINTING_REFUSALS, SNOWFLAKE, hasPlaceRefs, placeRef, webRoute, webTextLinks, placeSuffix, renderPlaceRefs, stripPlaceRefs, topicUrl, worldPlaceOf } from "./topic-link.js";
213
219
  import { CacheService, cacheWindow } from "./cache-service.js";
214
220
  import { runWebCommand } from "./web-commands.js";
@@ -220,8 +226,8 @@ import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefu
220
226
  import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
221
227
  import { bypassesWebGate, handleAuthRequest, serveSigninPage } from "./auth-api.js";
222
228
  import { isWebPageNavigation } from "./web-shell-routes.js";
223
- import { localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
224
- import { WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
229
+ import { guestSessionPolicy, localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
230
+ import { GuestLoginCodes, WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
225
231
  import { CodeRequestLimiter, ReturningDevices } from "./web-returning-device.js";
226
232
  import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
227
233
  import { createPreviewListener, normalizeOrigin, previewAvailability, previewSettings } from "./web-preview.js";
@@ -234,7 +240,7 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
234
240
  /** A self-restart is a whole service restart; 120s is the apply budget, not this. */
235
241
  const SELF_RESTART_DEADLINE_MS = 300_000;
236
242
  import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
237
- import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
243
+ import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileLogin, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
238
244
  import { carryClaudeConversation } from "./backend/claude-carry.js";
239
245
  import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
240
246
  import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
@@ -504,6 +510,8 @@ function emojiListFilter(opts) {
504
510
  withImageUrls: flag(opts.with_image_urls),
505
511
  };
506
512
  }
513
+ /** #1920: a guest's scope until phase 2 derives one: nothing. */
514
+ const NO_GUEST_SCOPE = new Set();
507
515
  export class FleetManager {
508
516
  dataDir;
509
517
  static signalTarget = null;
@@ -774,6 +782,8 @@ export class FleetManager {
774
782
  /** #1306: the preview listener (health_port + 1 by default), and whether it is listening. */
775
783
  previewListener = null;
776
784
  previewListening = false;
785
+ /** The preview listener could not open its port: "<port>: <error code>", for the page (#2061 follow-up); null otherwise. */
786
+ previewListenError = null;
777
787
  /** The ports the preview listener was started for, and the inputs it was built from (a reload compares them). */
778
788
  previewPorts = null;
779
789
  previewInputs = "";
@@ -792,6 +802,9 @@ export class FleetManager {
792
802
  /** Injectable only to keep the chat→CLI reload hand-off deterministic in tests. */
793
803
  fullRestartLauncher = launchFullRestartHelper;
794
804
  eventLogPruneTimer = null;
805
+ /** #1920 §3.2: the background reads of guests' ClassicBot room membership (a minute's tick; reads only what is due). */
806
+ guestRoomsTimer = null;
807
+ guestRoomMembership = null;
795
808
  outboxPruneTimer = null;
796
809
  logRotateTimer = null;
797
810
  discordPresenceTimer = null;
@@ -806,6 +819,17 @@ export class FleetManager {
806
819
  startedAt = 0;
807
820
  stormOpenNotifyTimer = null;
808
821
  memoryLogAt = null;
822
+ /**
823
+ * #2015: while host memory is elevated or critical, the processes holding the most in the fleet's service and whose
824
+ * they are (an instance, the fleet, tmux), for the pressure log, /health and the dashboard — never chat (#1712).
825
+ * Rescanned at most once a minute (async, one at a time); cleared when pressure ends.
826
+ */
827
+ memoryTop = null;
828
+ memoryTopScan = { at: null, inFlight: false, loggedAt: null };
829
+ /** The scan (a seam for tests: they give it a fake /proc). */
830
+ memoryHogScan = topMemoryHogs;
831
+ /** The fleet's own tmux session exists (only then are its panes read: a fleet that never started has none). */
832
+ tmuxSessionEnsured = false;
809
833
  memoryDebugAt = null;
810
834
  memoryLogLevel = null;
811
835
  /** #1685: the host_memory value the last status frame carried, so a change re-sends the frame. */
@@ -1079,6 +1103,29 @@ export class FleetManager {
1079
1103
  get ownerCss() {
1080
1104
  return (this.ownerCssStore ??= new OwnerCssStore(this.dataDir, this.webPrefs));
1081
1105
  }
1106
+ webSettingsToolsService;
1107
+ /**
1108
+ * #2056: Settings → Web appearance for a tool (General, a coordinator, full) — the page's own stores and checks, and the
1109
+ * page's announcement after a change (web-settings-tools.ts).
1110
+ */
1111
+ get webSettings() {
1112
+ return (this.webSettingsToolsService ??= new WebSettingsTools({
1113
+ prefs: this.webPrefs, ownerCss: this.ownerCss, mods: this.mods, effects: this.effectService,
1114
+ instances: () => [...Object.keys(this.fleetConfig?.instances ?? {}),
1115
+ ...(this.classicChannels?.getAll() ?? []).map((ch) => ch.instanceName).filter((n) => !Object.hasOwn(this.fleetConfig?.instances ?? {}, n))],
1116
+ colour: (name) => personIdentityColour(this.dataDir, name),
1117
+ setColour: (name, patch) => { const w = setPersonIdentityColour(this.dataDir, name, patch); if (w.ok)
1118
+ this.identityChanged(); return w; },
1119
+ bannerFocus: (name) => personBannerFocus(this.dataDir, name),
1120
+ setBannerFocus: (name, focus) => { const w = setPersonBannerFocus(this.dataDir, name, focus); if (w.ok)
1121
+ this.identityChanged(); return w; },
1122
+ placements: (name) => this.themeStore.get(name).placements.effects.map((e) => (typeof e === "string" ? { id: e } : e)),
1123
+ announce: () => { try {
1124
+ this.emitSseEvent("status", this.getUiStatus());
1125
+ }
1126
+ catch { /* the next status frame carries it */ } },
1127
+ }));
1128
+ }
1082
1129
  get webPrefs() {
1083
1130
  // #1664: a favorite must be an instance of this fleet now — fleet.yaml's (own keys) or a ClassicBot room's.
1084
1131
  return (this.webPrefsStore ??= new WebPrefsStore(this.dataDir, {
@@ -1349,9 +1396,26 @@ export class FleetManager {
1349
1396
  sseHeartbeatMs = SSE_HEARTBEAT_MS;
1350
1397
  /** The dashboard's login codes. Memory only: a code that outlives the process is a code nobody can prove was not copied. */
1351
1398
  webLoginCodes = null;
1399
+ /**
1400
+ * #1920 step 4: guests' codes, apart from the admins' — one per account, so no guest's `/web` withdraws another's or
1401
+ * an admin's code (#2093 review), at most 32 at once. Local sign-in only for now (audience "local").
1402
+ */
1403
+ webGuestLoginCodes = null;
1404
+ /** #1920 step 4: when each account last got a guest code (monotonic) — one a minute, so `/web` is no DM flood. */
1405
+ guestCodeAskedAt = new Map();
1406
+ /** #2093 review: an account's guest /web in progress — one at a time, so two at once cannot both pass the minute. */
1407
+ guestCodeInFlight = new Set();
1408
+ /**
1409
+ * #2093 review: revokes a guest /web in progress must see — every web session's (`/web revoke` from an admin) bumps
1410
+ * the global count, an account's own bumps its entry — checked before the code is issued and while it is delivered.
1411
+ */
1412
+ guestRevokes = 0;
1413
+ guestAccountRevokes = new Map();
1352
1414
  /** #1570: browsers that may ask the sign-in page for a fresh code, and how often they may. */
1353
1415
  returningDevices = null;
1354
1416
  codeRequestLimiter = new CodeRequestLimiter();
1417
+ /** #1920 step 4b: guests' device code requests, limited apart — guests never use up the admins' fleet-wide budget. */
1418
+ guestCodeRequestLimiter = new CodeRequestLimiter();
1355
1419
  /**
1356
1420
  * Set while a Settings apply job is driving the reconcile. The reconcile
1357
1421
  * stays the single doer; it just says out loud what it is doing to whom, so
@@ -1499,6 +1563,7 @@ export class FleetManager {
1499
1563
  meta: {
1500
1564
  chat_id: set.chatId, message_id: messageId, user: by.username, user_id: by.userId, ts,
1501
1565
  thread_id: set.threadId, adapter_id: inbound.adapterId, source: inbound.source,
1566
+ ...(web && by.guest ? { web_guest: "1" } : {}), // #1920: "[user:… via web (guest), id:…]", as /ui/send
1502
1567
  ...reactions.meta,
1503
1568
  },
1504
1569
  }, by.humanOrigin, button.label)) !== false;
@@ -1519,6 +1584,9 @@ export class FleetManager {
1519
1584
  this.emitSseEvent("message", {
1520
1585
  instance: set.instance, sender: by.username, role: "user", text: content, ts, ...(web ? { messageId } : {}),
1521
1586
  click: { label: button.label, of: set.id },
1587
+ // #1920: a platform click is its clicker's; it is the instance's channel's only when its buttons are there (the
1588
+ // same rule as a reply's: a set sent to another topic or a DM is not). A web click is the web's.
1589
+ ...(web ? {} : { from: webChatFrom(set.adapterId, by.userId), ...(this.clickInOwnChannel(set) ? { channel: true } : {}) }),
1522
1590
  });
1523
1591
  return true;
1524
1592
  }
@@ -1573,6 +1641,18 @@ export class FleetManager {
1573
1641
  }
1574
1642
  }
1575
1643
  });
1644
+ // #1918: the org canvas's traffic — from the row this listener's events already name, never a probe of its own.
1645
+ // The row as it is now (it has already changed when the event comes): its state, not the event's, is the truth.
1646
+ outbox.on("state", (event) => {
1647
+ const row = event.deliveryId ? outbox.get(event.deliveryId) : undefined;
1648
+ if (row)
1649
+ this.publishTraffic(row);
1650
+ });
1651
+ outbox.on("consumed", (event) => {
1652
+ const row = event.deliveryId ? outbox.get(event.deliveryId) : undefined;
1653
+ if (row)
1654
+ this.publishTraffic(row, "consumed");
1655
+ });
1576
1656
  outbox.on("generation_recovered", () => this.scheduleDeliveryOutboxPump());
1577
1657
  this.replyObligationTimer = setInterval(() => measureSyncWork("fleet.replyObligationSweep", () => this.sweepReplyObligations()), REPLY_OBLIGATION_SWEEP_MS);
1578
1658
  this.replyObligationTimer.unref?.();
@@ -1594,6 +1674,40 @@ export class FleetManager {
1594
1674
  });
1595
1675
  this.scheduleDeliveryOutboxPump();
1596
1676
  }
1677
+ /** #1918: cross-instance traffic, as the org canvas draws it (fleet-traffic.ts). */
1678
+ trafficLog = new TrafficLog();
1679
+ trafficMemo = null;
1680
+ /** One delivery row's traffic item, kept for the snapshot and sent to the open pages when it is news. */
1681
+ publishTraffic(row, state) {
1682
+ try {
1683
+ const item = trafficItem(row, this.fleetConfig, new Date().toISOString(), state);
1684
+ const news = item ? this.trafficLog.note(item) : null;
1685
+ if (news) {
1686
+ this.trafficMemo = null;
1687
+ this.emitSseEvent("traffic", news);
1688
+ }
1689
+ }
1690
+ catch (err) {
1691
+ this.logger.debug({ err }, "traffic: an item was not published");
1692
+ }
1693
+ }
1694
+ /**
1695
+ * GET /ui/traffic: what is in flight now (the outbox's pending rows) and what finished in the last two minutes, at
1696
+ * most TRAFFIC_SNAPSHOT_MAX items. Worked out at most once every two seconds, so the public link's polling (or any
1697
+ * number of pages) costs one indexed read per two seconds. Visibility is applied as of now.
1698
+ */
1699
+ trafficSnapshot() {
1700
+ const nowMs = performance.now();
1701
+ if (this.trafficMemo && nowMs - this.trafficMemo.atMs < 2_000)
1702
+ return this.trafficMemo.value;
1703
+ const now = new Date().toISOString();
1704
+ const pending = (this.deliveryOutbox?.listPending() ?? [])
1705
+ .map((r) => trafficItem(r, this.fleetConfig, r.createdAt ?? now)).filter((i) => i !== null);
1706
+ const visible = (name) => crossInstanceVisibility(this.fleetConfig, name) !== "hidden";
1707
+ const items = this.trafficLog.snapshot(pending).filter((i) => visible(i.from) && visible(i.to));
1708
+ this.trafficMemo = { atMs: nowMs, value: { now, items } };
1709
+ return this.trafficMemo.value;
1710
+ }
1597
1711
  /** Synchronous SQLite admission; the caller may acknowledge only after this returns. */
1598
1712
  admitDurableDelivery(input) {
1599
1713
  const outbox = this.deliveryOutbox;
@@ -1606,6 +1720,8 @@ export class FleetManager {
1606
1720
  }
1607
1721
  const sourceKey = `mcp:${input.sourceInstance}:${input.operationId}:${input.targetInstance}:${input.targetSession ?? ""}:${input.kind}`;
1608
1722
  const admitted = outbox.admit({ ...input, sourceDaemonBootId, sourceKey });
1723
+ if (admitted.inserted)
1724
+ this.publishTraffic(admitted.delivery);
1609
1725
  if (admitted.inserted)
1610
1726
  this.logger.info({
1611
1727
  deliveryId: admitted.delivery.deliveryId,
@@ -1979,17 +2095,58 @@ export class FleetManager {
1979
2095
  this.logger.info({ hostMemory: snapshot }, "Host memory recovered");
1980
2096
  }
1981
2097
  this.memoryLogLevel = snapshot.level;
2098
+ if (this.memoryTop) {
2099
+ this.memoryTop = null;
2100
+ this.memoryTopScan.loggedAt = null;
2101
+ this.emitSseEvent("status", this.getUiStatus());
2102
+ }
1982
2103
  return;
1983
2104
  }
2105
+ if (snapshot.level === "elevated" || snapshot.level === "critical")
2106
+ this.refreshMemoryTop(snapshot);
1984
2107
  if (changed || this.memoryLogAt === null || now - this.memoryLogAt >= 10 * 60_000) {
1985
2108
  this.memoryLogAt = now;
1986
- this.logger.warn({ hostMemory: snapshot }, snapshot.level === "critical"
2109
+ this.logger.warn({ hostMemory: snapshot, ...(this.memoryTop ? { topProcesses: this.memoryTop.processes } : {}) }, snapshot.level === "critical"
1987
2110
  ? "Host memory critical — deferring new CLI spawns"
1988
2111
  : snapshot.level === "elevated" ? "Host memory pressure — slowing new CLI spawns"
1989
2112
  : "Host memory sample unavailable — slowing new CLI spawns");
1990
2113
  }
1991
2114
  this.memoryLogLevel = snapshot.level;
1992
2115
  }
2116
+ /**
2117
+ * #2015: rescan the largest processes (at most once a minute, one at a time). The first result of an episode, and
2118
+ * then at most every 10 minutes, is logged with the pressure; /health and the dashboard read the latest. Never chat.
2119
+ */
2120
+ refreshMemoryTop(snapshot) {
2121
+ const scan = this.memoryTopScan;
2122
+ const now = performance.now();
2123
+ if (scan.inFlight || (scan.at !== null && now - scan.at < 60_000))
2124
+ return;
2125
+ scan.inFlight = true;
2126
+ scan.at = now;
2127
+ void (async () => {
2128
+ const session = getTmuxSession();
2129
+ const [tmuxServerPid, panes] = this.tmuxSessionEnsured
2130
+ ? await Promise.all([TmuxManager.getServerPid(session), TmuxManager.listPanePids(session)])
2131
+ : [null, []];
2132
+ const processes = await this.memoryHogScan({
2133
+ fleetPid: process.pid, tmuxServerPid, panes,
2134
+ isInstance: name => this.daemons.has(name) || Object.hasOwn(this.fleetConfig?.instances ?? {}, name),
2135
+ }, 3);
2136
+ // Pressure may have ended while the scan ran: nothing is kept then.
2137
+ if (this.shuttingDown || processes.length === 0 || this.hostMemoryIndicator() === null)
2138
+ return;
2139
+ const before = JSON.stringify(this.memoryTop?.processes ?? null);
2140
+ this.memoryTop = { at: new Date().toISOString(), processes };
2141
+ if (scan.loggedAt === null || performance.now() - scan.loggedAt >= 10 * 60_000) {
2142
+ scan.loggedAt = performance.now();
2143
+ this.logger.warn({ hostMemory: snapshot, topProcesses: processes }, "Host memory pressure — largest processes in the fleet");
2144
+ }
2145
+ if (JSON.stringify(processes) !== before)
2146
+ this.emitSseEvent("status", this.getUiStatus());
2147
+ })().catch(err => this.logger.debug({ err }, "Largest-process scan failed"))
2148
+ .finally(() => { scan.inFlight = false; });
2149
+ }
1993
2150
  /** Wire the one fleet-wide storm into notification and recovery surfaces. */
1994
2151
  bindStormWindowEvents() {
1995
2152
  this.stormWindow.on("opened", (snapshot) => {
@@ -2311,6 +2468,7 @@ export class FleetManager {
2311
2468
  this.fleetConfig = loaded;
2312
2469
  this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
2313
2470
  this.warnAboutRemovedLoginMode(loaded);
2471
+ this.enforceGuestSwitch();
2314
2472
  return this.fleetConfig;
2315
2473
  }
2316
2474
  relayLoginModeWarned = false;
@@ -3436,7 +3594,8 @@ export class FleetManager {
3436
3594
  * for `instance` (#1320 part B). Per entry: the channel's own adapter, no
3437
3595
  * thread, mention suppression on. Returns the number of channels posted
3438
3596
  * to. Failures are per-entry warn-and-continue — a failed echo never fails
3439
- * the web send. Entries without `web_echo: true` are never touched.
3597
+ * the web send. Only entries classicWebEchoOn admits are touched (#2054: unset follows web.echo_to_channel; an explicit
3598
+ * `web_echo: false` never echoes).
3440
3599
  *
3441
3600
  * Every copy is fenced at start time: an entry whose explicit world is
3442
3601
  * gone is skipped (only legacy adapterId-less entries fall back to the
@@ -3447,6 +3606,15 @@ export class FleetManager {
3447
3606
  * already in flight may still land late — the contract permits that.
3448
3607
  */
3449
3608
  /** `sender` (#1827): the web person's name, as the room log records them ("web-user" when none). */
3609
+ /**
3610
+ * #1920 §5.2: a web message's channel copy was sent — it is now what the channel saw. Kept so (and on disk), and sent
3611
+ * to the guests' streams only: an admin's page already has it, and nothing about it changed for them.
3612
+ */
3613
+ webMessageInChannel(instance, messageId) {
3614
+ const m = this.webChatHistory.markChannel(instance, messageId);
3615
+ if (m)
3616
+ broadcastToGuests(this.sseClients, "message", m, (err) => this.logger.debug({ err }, "SSE client write failed; evicting"));
3617
+ }
3450
3618
  async sendClassicWebEcho(instance, text, sender = WEB_NAME_FALLBACK) {
3451
3619
  const guard = this.webChannelEchoGuards.get(instance);
3452
3620
  const handled = new Set();
@@ -3463,7 +3631,7 @@ export class FleetManager {
3463
3631
  }
3464
3632
  }
3465
3633
  const entry = (this.classicChannels?.getAll() ?? [])
3466
- .find(candidate => candidate.instanceName === instance && candidate.webEcho === true
3634
+ .find(candidate => candidate.instanceName === instance && classicWebEchoOn(candidate.webEcho, this.fleetConfig?.web?.echo_to_channel)
3467
3635
  && !handled.has(`${candidate.channelId}#${candidate.adapterId ?? ""}`));
3468
3636
  if (!entry)
3469
3637
  break;
@@ -3882,6 +4050,14 @@ export class FleetManager {
3882
4050
  return;
3883
4051
  }
3884
4052
  const scope = door.scope;
4053
+ // #1920 step 4: a non-admin's /web while guest sign-in is on is theirs to sign in as a guest (or revoke their own)
4054
+ // — before the table, which keeps /web for fleet admins. Per account: it touches no instance binding.
4055
+ // Decided synchronously, so an admin's /web still reaches the table with no await after the door (#1399).
4056
+ if (data.command === "dashboard" && this.isGuestWebAsk(adapterId, data.userId)) {
4057
+ await this.guestWebCommand({ adapterId, userId: data.userId, username: data.username,
4058
+ arg: String(data.options?.action ?? "").trim().toLowerCase(), replyHere: text => data.respond(text), replyPrivately: text => data.respond(text) });
4059
+ return;
4060
+ }
3885
4061
  const spec = commandSpec(data.command);
3886
4062
  if (!spec) {
3887
4063
  this.logger.info({ command: data.command, adapterId }, "Slash command is not in the command table");
@@ -5326,10 +5502,14 @@ export class FleetManager {
5326
5502
  // #1620: fleet.yaml's web.session.local, read at every check — a Settings save or a reload applies at once
5327
5503
  // (shortening ends the sessions it now outlives). The public link's policy stays fixed.
5328
5504
  localPolicy: () => localSessionPolicy(this.fleetConfig?.web?.session?.local),
5505
+ // #1920 §5.4: a guest's, from web.guests.session (never longer than the surface's own).
5506
+ guestPolicy: () => guestSessionPolicy(this.fleetConfig?.web?.guests?.session),
5329
5507
  });
5508
+ this.enforceGuestSwitch(); // #1920: guests off while the fleet was down
5330
5509
  }
5331
5510
  if (!this.returningDevices) {
5332
5511
  this.returningDevices = new ReturningDevices({ dataDir: this.dataDir, onWarn: message => this.logger.warn(message) });
5512
+ this.enforceGuestSwitch(); // #1920: guests' devices too, if guests went off while down
5333
5513
  }
5334
5514
  if (!this.webLoginCodes) {
5335
5515
  this.webLoginCodes = new WebLoginCodes({
@@ -5341,6 +5521,16 @@ export class FleetManager {
5341
5521
  },
5342
5522
  });
5343
5523
  }
5524
+ if (!this.webGuestLoginCodes) {
5525
+ this.webGuestLoginCodes = new GuestLoginCodes({
5526
+ onEvent: event => {
5527
+ if (event === "burned")
5528
+ this.logger.warn("A guest web login code was used up by wrong attempts");
5529
+ else if (event === "breaker-open")
5530
+ this.logger.warn("Guest web sign-in paused: too many wrong login codes");
5531
+ },
5532
+ });
5533
+ }
5344
5534
  }
5345
5535
  /**
5346
5536
  * A single-use login code for the dashboard, for a channel only the operator
@@ -5464,6 +5654,7 @@ export class FleetManager {
5464
5654
  dispatch: (req, res) => this.dispatchWebHttp(req, res, this.fleetConfig?.health_port ?? 19280) }),
5465
5655
  revoke: id => {
5466
5656
  this.webLoginCodes?.revokeAudience(id);
5657
+ this.webGuestLoginCodes?.revokeAudience(id);
5467
5658
  const result = this.webSessions?.revokeExposure(id);
5468
5659
  if (result && !result.durable)
5469
5660
  this.logger.warn("Public session revocation could not be saved; exposure remains closed");
@@ -5495,6 +5686,24 @@ export class FleetManager {
5495
5686
  if (!current() || !this.publicOwnerCurrent(info.owner))
5496
5687
  throw new Error("public owner changed");
5497
5688
  }
5689
+ /**
5690
+ * #1920 §5.5 (step 4b): a guest signing in through the public link — said in the General of the admin who opened it
5691
+ * (who, how many agents), awaited as an admin's public sign-in is, while that link is the open one, its opener still
5692
+ * current, and the account still an admitted guest. A failure refuses the sign-in.
5693
+ */
5694
+ async confirmGuestPublicWebLogin(info, current) {
5695
+ const admitted = () => {
5696
+ const open = this.publicWebLink?.openSignin();
5697
+ return current() && open && open.exposureId === info.exposureId && this.publicOwnerCurrent(open.owner) && this.guestSignInAllowed(info.account) ? open : null;
5698
+ };
5699
+ const open = admitted();
5700
+ if (!open)
5701
+ throw new Error("guest public sign-in unavailable");
5702
+ const adapter = open.owner.binding;
5703
+ await adapter.sendText(open.owner.chatId, t("web.guest_public_login_notice", this.webPerson(info.account).name, info.label, this.guestScope(info.account).size, info.handle.slice(0, 8)), { threadId: open.owner.threadId, allowedMentions: { parse: [] } });
5704
+ if (!admitted())
5705
+ throw new Error("guest public sign-in changed");
5706
+ }
5498
5707
  /** Telegram's typed command enters through the same strict owner and nonce path as Discord. */
5499
5708
  async dashboardMenu(msg) {
5500
5709
  const id = msg.adapterId;
@@ -5818,6 +6027,8 @@ export class FleetManager {
5818
6027
  this.initializeWebSessions();
5819
6028
  void this.publicWebLink?.close("dashboard revoke");
5820
6029
  this.webLoginCodes.revoke();
6030
+ this.webGuestLoginCodes.revokeAll();
6031
+ this.guestRevokes++; // and every guest /web still on its way
5821
6032
  // #1570: no browser may ask the sign-in page for a code any more.
5822
6033
  const devices = this.returningDevices.revokeAll();
5823
6034
  const sessions = this.webSessions.revokeAll();
@@ -5828,6 +6039,145 @@ export class FleetManager {
5828
6039
  this.logger.warn({ count: result.count }, "Web sessions revoked in memory only — the session file could not be updated or removed");
5829
6040
  return result;
5830
6041
  }
6042
+ /** #1920 step 4: whether a `/web` is a guest's — guest sign-in on, and not from a fleet admin (theirs runs as before). */
6043
+ isGuestWebAsk(adapterId, userId) {
6044
+ return !!adapterId && this.fleetConfig?.web?.guests?.enabled === true && !this.isFleetAdmin(userId, adapterId);
6045
+ }
6046
+ /** #1920 step 4 (auth-api): a guest code's account may still sign in — guests on, and something in its scope now. */
6047
+ guestSignInAllowed(account) {
6048
+ return this.fleetConfig?.web?.guests?.enabled === true && !this.isFleetAdmin(account.userId, account.adapterId) && this.guestScope(account).size > 0;
6049
+ }
6050
+ /**
6051
+ * #1920 §5.4: a guest's own `/web revoke` — its sessions and its outstanding code; nobody else's (an admin's revokes
6052
+ * everyone's, as before).
6053
+ */
6054
+ revokeGuestWebSessions(owner) {
6055
+ this.initializeWebSessions();
6056
+ const key = `${owner.adapterId}\u0000${owner.userId}`;
6057
+ this.guestAccountRevokes.set(key, (this.guestAccountRevokes.get(key) ?? 0) + 1); // its /web on its way, too
6058
+ while (this.guestAccountRevokes.size > 4096)
6059
+ this.guestAccountRevokes.delete(this.guestAccountRevokes.keys().next().value);
6060
+ this.webGuestLoginCodes.revokeRecipient(owner);
6061
+ // §5.4: its sessions and its returning devices (no browser of theirs may ask for a code any more).
6062
+ const devices = this.returningDevices.revokeGuest(owner);
6063
+ const ended = this.webSessions.revokeAll(r => r.tier === "guest" && r.owner?.adapterId === owner.adapterId && r.owner.userId === owner.userId);
6064
+ this.logger.info({ adapterId: owner.adapterId, count: ended.count, devices: devices.count, durable: ended.durable && devices.durable }, "Guest web sessions revoked by their account");
6065
+ return { count: ended.count, durable: ended.durable && devices.durable };
6066
+ }
6067
+ /**
6068
+ * #1920 §2.3 (step 4): `/web` from someone who is not a fleet admin, while guest sign-in is on. `revoke` ends their
6069
+ * own guest sessions. Otherwise a guest code (tier guest, bound to this account) goes privately — a DM, else the
6070
+ * slash command's ephemeral answer (`replyPrivately`) — once a minute at most, and only when their scope (from what
6071
+ * they may already use in chat, rooms read at sign-in) holds something. The code itself is never posted where asked.
6072
+ * Callers ask isGuestWebAsk first (synchronously); false here too when it is not a guest's.
6073
+ */
6074
+ async guestWebCommand(ask) {
6075
+ if (!this.isGuestWebAsk(ask.adapterId, ask.userId))
6076
+ return false;
6077
+ const owner = { adapterId: ask.adapterId, userId: ask.userId };
6078
+ const say = (text) => ask.replyHere(text).catch(() => undefined);
6079
+ if (ask.arg === "revoke") {
6080
+ const ended = this.revokeGuestWebSessions(owner);
6081
+ await say(t(ended.durable ? "web.guest_revoked" : "web.guest_revoked_not_durable", ended.count));
6082
+ return true;
6083
+ }
6084
+ const token = this.webToken;
6085
+ const adapter = this.adapters.get(ask.adapterId);
6086
+ if (this.shuttingDown || !token || !adapter) {
6087
+ await say(t("dashboard.starting"));
6088
+ return true;
6089
+ }
6090
+ const key = `${owner.adapterId}\u0000${owner.userId}`, asked = this.guestCodeAskedAt.get(key);
6091
+ if (this.guestCodeInFlight.has(key) || (asked !== undefined && performance.now() - asked < 60_000)) {
6092
+ await say(t("web.guest_wait"));
6093
+ return true;
6094
+ }
6095
+ this.guestCodeInFlight.add(key);
6096
+ try {
6097
+ await this.issueGuestCode(ask, owner, key, adapter, token);
6098
+ }
6099
+ finally {
6100
+ this.guestCodeInFlight.delete(key);
6101
+ }
6102
+ return true;
6103
+ }
6104
+ /** guestWebCommand's issuance, one per account at a time; a revoke made meanwhile (either kind) ends it unissued. */
6105
+ async issueGuestCode(ask, owner, key, adapter, token) {
6106
+ const say = (text) => ask.replyHere(text).catch(() => undefined);
6107
+ const revokes = this.guestRevokes, accountRevokes = this.guestAccountRevokes.get(key) ?? 0;
6108
+ const unrevoked = () => this.guestRevokes === revokes && (this.guestAccountRevokes.get(key) ?? 0) === accountRevokes;
6109
+ // §3.2: its rooms are read at sign-in (bounded; never longer than the reads' own deadline).
6110
+ await withinBudget(this.refreshGuestRooms(owner, { mode: "force" }), performance.now() + 6_000).catch(() => undefined);
6111
+ if (!unrevoked())
6112
+ return; // revoked while its rooms were read: no code
6113
+ const scope = this.guestScope(owner);
6114
+ if (!scope.size) {
6115
+ const open = this.worlds.get(owner.adapterId)?.accessManager.getMode() === "open";
6116
+ await say(t(open ? "web.guest_not_set_up" : "web.guest_nothing"));
6117
+ return;
6118
+ }
6119
+ this.initializeWebSessions();
6120
+ const codes = this.webGuestLoginCodes;
6121
+ if (codes.pausedForMs() > 0) {
6122
+ await say(t("web.guest_paused"));
6123
+ return;
6124
+ }
6125
+ // §5.5 (step 4b): with the public link open, the code works through it too (an admin opened it; a guest is still a
6126
+ // guest there) — most guests are not on the host's network. Its closing withdraws that audience (revokeAudience).
6127
+ const open = this.publicWebLink?.openSignin() ?? null;
6128
+ // One code per account (a new one replaces only its own); at the cap, refused — never another guest's evicted.
6129
+ const login = codes.issue({ epoch: tokenEpoch(token), audiences: ["local", ...(open ? [open.exposureId] : [])],
6130
+ recipient: { adapterId: owner.adapterId, userId: owner.userId, ...(ask.username ? { username: ask.username } : {}) } });
6131
+ if (!login) {
6132
+ await say(t("web.guest_busy"));
6133
+ return;
6134
+ }
6135
+ this.guestCodeAskedAt.delete(key);
6136
+ this.guestCodeAskedAt.set(key, performance.now());
6137
+ while (this.guestCodeAskedAt.size > 256)
6138
+ this.guestCodeAskedAt.delete(this.guestCodeAskedAt.keys().next().value);
6139
+ const current = () => !this.shuttingDown && this.webToken === token && this.adapters.get(owner.adapterId) === adapter
6140
+ && unrevoked() && this.guestSignInAllowed(owner);
6141
+ const privateText = `${t("web.guest_intro", scope.size)}\n${this.topicCommands.getDashboardText()}${open ? `\n${t("web.guest_public", open.url)}` : ""}\n${t("dashboard.code", login.display, Math.round(LOGIN_CODE_TTL_MS / 60_000))}`;
6142
+ const deadline = performance.now() + 10_000;
6143
+ try {
6144
+ let byDm = false;
6145
+ try {
6146
+ if (!adapter.sendDirect)
6147
+ throw new Error("DM unavailable");
6148
+ await withinBudget(adapter.sendDirect(owner.userId, `${privateText}\n${t("dashboard.code_next")}`, { disablePreview: true }), Math.min(deadline, performance.now() + 5_000));
6149
+ byDm = true;
6150
+ }
6151
+ catch (err) {
6152
+ this.logDashboardDmFailure(err);
6153
+ if (!ask.replyPrivately || !current())
6154
+ throw err;
6155
+ const id = await withinBudget(ask.replyPrivately(privateText), deadline); // a slash command's answer: ephemeral
6156
+ if (!id)
6157
+ throw new Error("private delivery unconfirmed");
6158
+ }
6159
+ if (!current())
6160
+ throw new Error("guest no longer admitted");
6161
+ if (byDm) {
6162
+ await this.sendCodeAlone((body, opts) => adapter.sendDirect(owner.userId, body, opts), adapter.type, login.display);
6163
+ if (!current())
6164
+ throw new Error("guest no longer admitted");
6165
+ await say(t("web.guest_sent"));
6166
+ }
6167
+ }
6168
+ catch (err) {
6169
+ codes.revokeIfCurrent(login.issuanceId); // this flow's code only, never a later one's
6170
+ this.logger.info({ err: err?.message, adapterId: owner.adapterId }, "Guest sign-in code was not delivered privately");
6171
+ if (!unrevoked())
6172
+ return; // revoked meanwhile: the revoke's own answer said so
6173
+ const start = adapter.type === "telegram" ? this.tgStartWebUrl(owner.adapterId) : null;
6174
+ // #1742 for guests (step 4b-3): its START button (a bare /start, or the link's /start web) then asks again, from
6175
+ // the DM — once, within the window.
6176
+ if (adapter.type === "telegram")
6177
+ this.openTgWebStartWindow(owner.adapterId, owner.userId);
6178
+ await say(start ? t("web.guest_dm_failed_link", start) : t("web.guest_dm_failed"));
6179
+ }
6180
+ }
5831
6181
  /** #1570 (auth-api): this browser becomes a returning device of the code's owner. The cookie value, or null. */
5832
6182
  rememberReturningDevice(owner, input) {
5833
6183
  const token = this.webToken;
@@ -5855,6 +6205,12 @@ export class FleetManager {
5855
6205
  if (!token || this.shuttingDown)
5856
6206
  return { kind: "refused" };
5857
6207
  this.initializeWebSessions();
6208
+ // #2095 review: verified before the request's body was read — a revoke (its own, or an admin's) made meanwhile
6209
+ // removed it, and nothing it asks for may then go out.
6210
+ if (!this.returningDevices.current(device, tokenEpoch(token)))
6211
+ return { kind: "refused" };
6212
+ if (device.tier === "guest")
6213
+ return this.requestGuestDeviceCode(device, input, token);
5858
6214
  const paused = this.webLoginCodes.pausedForMs();
5859
6215
  if (paused > 0)
5860
6216
  return { kind: "limited", retryAfterMs: paused };
@@ -5886,7 +6242,7 @@ export class FleetManager {
5886
6242
  this.logger.info({ err: err?.message, adapterId }, "Sign-in code request: the private message could not be sent");
5887
6243
  return { kind: "refused" };
5888
6244
  }
5889
- if (this.webToken !== token || !this.publicOwnerCurrent(owner)) {
6245
+ if (this.webToken !== token || !this.publicOwnerCurrent(owner) || !this.returningDevices.current(device, tokenEpoch(token))) {
5890
6246
  this.webLoginCodes.revokeIfCurrent(issued.issuanceId);
5891
6247
  return { kind: "refused" };
5892
6248
  }
@@ -5894,15 +6250,94 @@ export class FleetManager {
5894
6250
  // Asked again after the follow-up's wait: the owner, the token, shutdown, and a public request's exposure.
5895
6251
  const exposureGone = input.surface === "gateway"
5896
6252
  && (this.publicWebLink?.exposureId !== input.exposureId || this.publicWebLink?.status().state !== "open");
5897
- if (this.shuttingDown || this.webToken !== token || !this.publicOwnerCurrent(owner) || exposureGone) {
6253
+ if (this.shuttingDown || this.webToken !== token || !this.publicOwnerCurrent(owner) || exposureGone || !this.returningDevices.current(device, tokenEpoch(token))) {
5898
6254
  this.webLoginCodes.revokeIfCurrent(issued.issuanceId);
5899
6255
  return { kind: "refused" };
5900
6256
  }
5901
6257
  this.logger.info({ adapterId, surface: input.surface }, "Web sign-in code sent privately to a returning device's owner");
5902
6258
  return { kind: "sent" };
5903
6259
  }
6260
+ /**
6261
+ * #1920 step 4b: a guest's returning device asks for a code — a guest code for that account, by DM only, under the
6262
+ * same rules as its /web (guests on, not an admin, something in scope now; one per account and the cap; one flow per
6263
+ * account at a time; a revoke meanwhile ends it), its own limiter, and the guests' breaker. Asked through the public
6264
+ * link: only while that exposure is the open one (its code then works through it alone), as an admin's device.
6265
+ */
6266
+ async requestGuestDeviceCode(device, input, token) {
6267
+ const codes = this.webGuestLoginCodes;
6268
+ const account = { adapterId: device.owner.adapterId, userId: device.owner.userId };
6269
+ const key = `${account.adapterId}\u0000${account.userId}`;
6270
+ const adapter = this.adapters.get(account.adapterId);
6271
+ const viaLink = input.surface === "gateway";
6272
+ const linkOpen = () => !!input.exposureId && this.publicWebLink?.openSignin()?.exposureId === input.exposureId;
6273
+ if ((viaLink ? !linkOpen() : input.exposureId !== undefined) || !adapter?.sendDirect || !this.guestSignInAllowed(account)) {
6274
+ this.logger.info({ adapterId: account.adapterId, surface: input.surface }, "Guest sign-in code request refused");
6275
+ return { kind: "refused" };
6276
+ }
6277
+ const paused = codes.pausedForMs();
6278
+ if (paused > 0)
6279
+ return { kind: "limited", retryAfterMs: paused };
6280
+ const asked = this.guestCodeAskedAt.get(key);
6281
+ if (this.guestCodeInFlight.has(key))
6282
+ return { kind: "limited", retryAfterMs: 5_000 };
6283
+ if (asked !== undefined && performance.now() - asked < 60_000)
6284
+ return { kind: "limited", retryAfterMs: 60_000 - (performance.now() - asked) };
6285
+ const admitted = this.guestCodeRequestLimiter.admit(device.idHash);
6286
+ if (!admitted.ok)
6287
+ return { kind: "limited", retryAfterMs: admitted.retryAfterMs };
6288
+ const revokes = this.guestRevokes, accountRevokes = this.guestAccountRevokes.get(key) ?? 0;
6289
+ const current = () => !this.shuttingDown && this.webToken === token && this.adapters.get(account.adapterId) === adapter
6290
+ && this.guestRevokes === revokes && (this.guestAccountRevokes.get(key) ?? 0) === accountRevokes && this.guestSignInAllowed(account)
6291
+ && (!viaLink || linkOpen()) && this.returningDevices.current(device, tokenEpoch(token));
6292
+ const issued = codes.issue({ epoch: tokenEpoch(token), recipient: account, audiences: [viaLink ? input.exposureId : "local"] });
6293
+ if (!issued)
6294
+ return { kind: "limited", retryAfterMs: 60_000 }; // the guests' cap: never someone's evicted
6295
+ this.guestCodeInFlight.add(key);
6296
+ this.guestCodeAskedAt.delete(key);
6297
+ this.guestCodeAskedAt.set(key, performance.now());
6298
+ try {
6299
+ const text = t("web.resend_code", issued.display, Math.round(LOGIN_CODE_TTL_MS / 60_000), input.label || t("web.resend_unknown_browser"), t(viaLink ? "web.resend_public" : "web.resend_local"))
6300
+ + "\n" + t("dashboard.code_next");
6301
+ try {
6302
+ await withinBudget(adapter.sendDirect(account.userId, text, { disablePreview: true }), performance.now() + 5_000);
6303
+ }
6304
+ catch (err) {
6305
+ codes.revokeIfCurrent(issued.issuanceId);
6306
+ this.logger.info({ err: err?.message, adapterId: account.adapterId }, "Guest sign-in code request: the private message could not be sent");
6307
+ return { kind: "refused" };
6308
+ }
6309
+ if (!current()) {
6310
+ codes.revokeIfCurrent(issued.issuanceId);
6311
+ return { kind: "refused" };
6312
+ }
6313
+ await this.sendCodeAlone((body, opts) => adapter.sendDirect(account.userId, body, opts), adapter.type, issued.display);
6314
+ if (!current()) {
6315
+ codes.revokeIfCurrent(issued.issuanceId);
6316
+ return { kind: "refused" };
6317
+ }
6318
+ this.logger.info({ adapterId: account.adapterId }, "Guest sign-in code sent privately to a returning device's account");
6319
+ return { kind: "sent" };
6320
+ }
6321
+ finally {
6322
+ this.guestCodeInFlight.delete(key);
6323
+ }
6324
+ }
6325
+ /** #1920 step 4b (auth-api): this browser becomes a guest's returning device. The cookie value, or null. */
6326
+ rememberGuestDevice(account, input) {
6327
+ const token = this.webToken;
6328
+ if (!token)
6329
+ return null;
6330
+ this.initializeWebSessions();
6331
+ return this.returningDevices.rememberGuest(account, { ...input, tokenEpoch: tokenEpoch(token), webToken: token });
6332
+ }
5904
6333
  /** Called by the sign-in endpoint: a login the operator did not make should be visible to them. */
5905
6334
  onWebLogin(info) {
6335
+ // #1920 §2.3: a guest's sign-in is always said in General (web.notify_login is the operator's own sign-ins): who,
6336
+ // and how many agents they now reach — admins stay aware of everyone who is not one of them.
6337
+ if (info.tier === "guest" && info.owner) {
6338
+ this.notifyFleetError(t("web.guest_login_notice", this.webPerson(info.owner).name, info.label, this.guestScope(info.owner).size, info.handle.slice(0, 8)));
6339
+ return;
6340
+ }
5906
6341
  if (this.fleetConfig?.web?.notify_login === false)
5907
6342
  return;
5908
6343
  // The session handle makes each notice distinct: notifyFleetError throttles by text, and a second
@@ -6266,6 +6701,9 @@ export class FleetManager {
6266
6701
  const { getTmuxSocketName: getSocket } = await import("./paths.js");
6267
6702
  TmuxManager.setSocketName(getSocket());
6268
6703
  await TmuxManager.ensureSession(getTmuxSession());
6704
+ this.tmuxSessionEnsured = true;
6705
+ // #2072: what this host can run — probed in the background, published for every picker.
6706
+ this.startAvailabilityRefresh();
6269
6707
  // Pre-flight (advisory, fire-and-forget): warm the per-backend auth cache
6270
6708
  // before instances spawn, so a CLI that comes up on its sign-in screen gets
6271
6709
  // an instant 🔑 verdict instead of decaying into crash/MCP-died noise.
@@ -6589,6 +7027,11 @@ export class FleetManager {
6589
7027
  this.pruneEventLog();
6590
7028
  this.eventLogPruneTimer = setInterval(() => this.pruneEventLog(), 24 * 60 * 60_000);
6591
7029
  this.eventLogPruneTimer.unref?.();
7030
+ // #1920 §3.2: guests' ClassicBot room membership, read in the background — now, then each key ten minutes after
7031
+ // its answer (a minute after a failed read; the tick only finds what is due). A request only reads the answers.
7032
+ void this.refreshAllGuestRooms();
7033
+ this.guestRoomsTimer = setInterval(() => { void this.refreshAllGuestRooms(); }, GUEST_ROOM_TICK_MS);
7034
+ this.guestRoomsTimer.unref?.();
6592
7035
  // #1335: delivery-outbox.db and Task Board retention. Same once-at-startup
6593
7036
  // + daily-timer pattern; chunked DELETEs so one run cannot hold the loop.
6594
7037
  void this.pruneOutboxAndTasks();
@@ -7089,7 +7532,8 @@ export class FleetManager {
7089
7532
  adapters: { total: this.adapterState.size, connected, states, details },
7090
7533
  startupComplete: this.startupComplete,
7091
7534
  memory: readFleetMemory(),
7092
- hostMemory,
7535
+ hostMemory: this.memoryTop && (hostMemory.level === "elevated" || hostMemory.level === "critical")
7536
+ ? { ...hostMemory, topProcesses: this.memoryTop.processes, topProcessesAt: this.memoryTop.at } : hostMemory,
7093
7537
  problems,
7094
7538
  };
7095
7539
  }
@@ -8240,6 +8684,13 @@ export class FleetManager {
8240
8684
  if (!isPrivateChat && !cmdMatch && rawText.startsWith("/")) {
8241
8685
  return; // bare slash in group — ignore silently
8242
8686
  }
8687
+ // #1920 step 4b-3: /web typed in a ClassicBot room (a group: `/web@ThisBot`) by someone who is not a fleet admin,
8688
+ // while guest sign-in is on: their guest code, privately — as anywhere else. An admin's goes on as before.
8689
+ if (/^\/(?:web|dashboard)(?:\s|$)/i.test(text) && msgAdapter && this.isGuestWebAsk(msg.adapterId, msg.userId)) {
8690
+ await this.guestWebCommand({ adapterId: msg.adapterId, userId: msg.userId, username: msg.username,
8691
+ arg: text.replace(/^\/(?:web|dashboard)/i, "").trim().toLowerCase(), replyHere: body => msgAdapter.sendText(chatId, body) });
8692
+ return;
8693
+ }
8243
8694
  // Handle /start command
8244
8695
  if (text === "/start" || text.startsWith("/start ")) {
8245
8696
  // #1686: intercept /start web before the ClassicBot admission flow runs.
@@ -8248,15 +8699,21 @@ export class FleetManager {
8248
8699
  // #1742: a bare /start from a fleet admin whose /web just failed to reach them privately is that /start web
8249
8700
  // (Telegram's START button sends it bare) — once, within the window; any other bare /start is ClassicBot's.
8250
8701
  const admin = isPrivateChat && this.isFleetAdmin(msg.userId, msg.adapterId ?? "");
8702
+ // #1920 step 4b-3: a guest's too (guest sign-in on, not an admin) — its /web reached it no other way.
8703
+ const guest = isPrivateChat && !admin && this.isGuestWebAsk(msg.adapterId, msg.userId);
8251
8704
  const webStart = isPrivateChat && (requestedBackend === "web"
8252
- || (text === "/start" && admin && this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId)));
8705
+ || (text === "/start" && (admin || guest) && this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId)));
8253
8706
  if (webStart) {
8254
8707
  if (requestedBackend === "web")
8255
8708
  this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId);
8256
8709
  // #1742: the /web menu right here; the code comes to this DM.
8257
8710
  if (admin)
8258
8711
  await this.startWebInDm(msg, msgAdapter ?? undefined);
8259
- // Non-fleet-admins: /start alone is enough to open the DM channel; no further action.
8712
+ // A guest: its guest code, here (this DM is where it goes).
8713
+ else if (guest && msgAdapter)
8714
+ await this.guestWebCommand({ adapterId: msg.adapterId, userId: msg.userId, username: msg.username, arg: "",
8715
+ replyHere: body => msgAdapter.sendText(chatId, body) });
8716
+ // Anyone else: /start alone is enough to open the DM channel; no further action.
8260
8717
  return;
8261
8718
  }
8262
8719
  // All new starts, including explicit backend and later chooser callbacks,
@@ -8560,10 +9017,7 @@ export class FleetManager {
8560
9017
  this.afterUserInboundDelivered(generalInstance, msg);
8561
9018
  this.logger.info(`${msg.username} → ${generalInstance}: ${(text ?? "").slice(0, 100)}`);
8562
9019
  this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), generalInstance);
8563
- this.emitSseEvent("message", {
8564
- instance: generalInstance, sender: msg.username, role: "user",
8565
- text: (text ?? "").slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
8566
- });
9020
+ this.emitPlatformInbound(generalInstance, msg, text ?? "");
8567
9021
  }
8568
9022
  catch (err) {
8569
9023
  this.logger.warn({ err: err.message, instanceName: generalInstance }, "General wake/delivery failed");
@@ -8672,9 +9126,16 @@ export class FleetManager {
8672
9126
  this.afterUserInboundDelivered(instanceName, msg);
8673
9127
  this.logger.info(`${msg.username} → ${instanceName}: ${(text ?? "").slice(0, 100)}`);
8674
9128
  this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), instanceName);
9129
+ this.emitPlatformInbound(instanceName, msg, text ?? "");
9130
+ }
9131
+ /**
9132
+ * A person's platform message as the web chat shows it — the user's own words, as a user (#1306), in the instance's
9133
+ * history (#1565). #1920 §5.2: said in the instance's topic, General or room, so it is that channel's, by that account.
9134
+ */
9135
+ emitPlatformInbound(instance, msg, text) {
8675
9136
  this.emitSseEvent("message", {
8676
- instance: instanceName, sender: msg.username, role: "user",
8677
- text: (text ?? "").slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
9137
+ instance, sender: msg.username, role: "user", text: text.slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
9138
+ channel: true, from: webChatFrom(msg.adapterId, msg.userId),
8678
9139
  });
8679
9140
  }
8680
9141
  /** Handle outbound tool calls from a daemon instance */
@@ -8976,7 +9437,14 @@ export class FleetManager {
8976
9437
  // an agent cannot invent it to suppress the normal completion marker.
8977
9438
  if (!error && result != null && msg.statusOnly !== true) {
8978
9439
  try {
8979
- void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
9440
+ // #1920 §5.2: shown to the instance's guests only when it landed in the instance's own topic or room.
9441
+ let inChannel = false;
9442
+ try {
9443
+ inChannel = outAdapter !== WEB_ONLY_REPLY_SINK
9444
+ && this.landedInOwnChannel(instanceName, contextAdapterId ?? this.getInstanceAdapterId(instanceName), result);
9445
+ }
9446
+ catch { /* not known: not the channel's (fail closed), and the bookkeeping below still runs */ }
9447
+ void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView, inChannel);
8980
9448
  }
8981
9449
  catch (err) {
8982
9450
  this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
@@ -9030,7 +9498,35 @@ export class FleetManager {
9030
9498
  return this.worlds.size === 0 && this.isWebOnlyFleet() ? { adapter: WEB_ONLY_PROMPT_SINK, adapterId: "web", chatId: "web" } : null;
9031
9499
  }
9032
9500
  /** Resolves when the reply is in the web chat (#1770: after its files are copied); the caller need not wait. */
9033
- afterReplyRouted(instanceName, args, senderSessionName, buttons = null) {
9501
+ /**
9502
+ * #1920 §5.2: whether a platform send landed in the instance's own place — its ClassicBot room, or its topic in its
9503
+ * connection's group — so its channel's members saw it. Anything else (another chat, a DM, a context left from General,
9504
+ * an answer without the chat it went to) is not the channel's.
9505
+ */
9506
+ landedInOwnChannel(instance, adapterId, sent) {
9507
+ const s = sent && typeof sent === "object" ? sent : {};
9508
+ if (!adapterId || typeof s.chatId !== "string" || !s.chatId)
9509
+ return false;
9510
+ const roomId = this.classicChannels?.getChannelIdByInstance(instance);
9511
+ if (roomId !== undefined)
9512
+ return (this.classicChannels?.getAdapterIdByInstance(instance) ?? this.getPrimaryAdapterId()) === adapterId && roomId === s.chatId;
9513
+ const cfg = this.fleetConfig?.instances && Object.hasOwn(this.fleetConfig.instances, instance) ? this.fleetConfig.instances[instance] : undefined;
9514
+ const world = this.worlds.get(adapterId);
9515
+ if (!cfg || this.getInstanceAdapterId(instance) !== adapterId || !world?.groupId || s.chatId !== world.groupId)
9516
+ return false;
9517
+ const thread = typeof s.threadId === "string" && s.threadId ? s.threadId : null;
9518
+ return cfg.topic_id == null ? thread === null : thread === String(cfg.topic_id);
9519
+ }
9520
+ /** #1920 §5.2 (#2085 review): a platform click's buttons are in the instance's own place — else not its channel's. */
9521
+ clickInOwnChannel(set) {
9522
+ try {
9523
+ return this.landedInOwnChannel(set.instance, set.adapterId, { chatId: set.chatId, threadId: set.threadId || undefined });
9524
+ }
9525
+ catch {
9526
+ return false;
9527
+ }
9528
+ }
9529
+ afterReplyRouted(instanceName, args, senderSessionName, buttons = null, inChannel = false) {
9034
9530
  // A reply is NOT proof the turn is over (#410) — but it is not proof of
9035
9531
  // more work either. Split the difference: an instance that is clearly
9036
9532
  // idle loses the button now; one that looks busy keeps it (re-posted
@@ -9066,6 +9562,7 @@ export class FleetManager {
9066
9562
  ts,
9067
9563
  ...(attachments.length ? { attachments } : {}),
9068
9564
  ...(buttons ? { buttons } : {}), // #1266
9565
+ ...(inChannel ? { channel: true } : {}), // #1920 §5.2
9069
9566
  });
9070
9567
  };
9071
9568
  const before = this.webReplyOrder.get(instanceName);
@@ -10319,6 +10816,7 @@ export class FleetManager {
10319
10816
  */
10320
10817
  saveFleetConfig(explicitPatches = []) {
10321
10818
  this.publicWebLink?.refresh();
10819
+ this.enforceGuestSwitch(); // #1920: a Settings save turning guests off ends their sessions
10322
10820
  if (!this.fleetConfig || !this.configPath)
10323
10821
  return;
10324
10822
  if (!this.savedFleetConfigSnapshot)
@@ -13034,11 +13532,12 @@ export class FleetManager {
13034
13532
  };
13035
13533
  };
13036
13534
  const choicesFromCache = (instance) => {
13037
- const cached = this.readCliEnv(this.backendNameForInstance(instance)); // the cached catalog only: never a probe here
13535
+ const backend = this.backendNameForInstance(instance);
13536
+ const cached = this.readCliEnv(backend, this.catalogProfile(backend, instance)); // the cached catalog only: never a probe here
13038
13537
  if (!cached?.models.length)
13039
13538
  return null;
13040
13539
  const current = this.resolveInstanceModel(instance).model;
13041
- return { current, options: cached.models.map(o => ({ id: o.id, label: this.modelChoiceLabel(o, current) })) };
13540
+ return { current, options: this.withCurrentModel(cached.models, current).map(o => ({ id: o.id, label: this.modelChoiceLabel(o, current) })) };
13042
13541
  };
13043
13542
  return runWebCommand({
13044
13543
  // Own entries only: a name like "__proto__" or "constructor" must not be found on the prototype (#1476 review).
@@ -13085,7 +13584,7 @@ export class FleetManager {
13085
13584
  return step.noop ? { text: step.text } : { error: step.text };
13086
13585
  if (step.kind === "choose") {
13087
13586
  const id = (token) => joinBackendArgs([...step.args, token]);
13088
- return { choices: { prompt: step.text, current: step.current === null ? null : id(step.current), options: step.choices.map(c => ({ id: id(c.token), label: c.label })) } };
13587
+ return { choices: { prompt: step.text, current: step.current === null ? null : id(step.current), options: step.choices.map(c => ({ id: id(c.token), label: c.label, ...(c.disabled ? { disabled: c.disabled } : {}) })) } };
13089
13588
  }
13090
13589
  const now = performance.now();
13091
13590
  for (const [k, v] of this.webBackendTokens)
@@ -14223,7 +14722,12 @@ export class FleetManager {
14223
14722
  fleetConfig: () => this.fleetConfig,
14224
14723
  isFleetAdmin: (userId, adapterId) => this.isFleetAdmin(userId, adapterId),
14225
14724
  eventLog: () => this.eventLog,
14226
- recoverBackendInstances: (backend, profile) => this.recoverBackendInstances(backend, undefined, profile ?? null),
14725
+ recoverBackendInstances: (backend, profile) => {
14726
+ // #2072: a sign-in just finished — the pickers learn it now, not at the next pass.
14727
+ if (!profile)
14728
+ void this.backendAvailability.refresh([backend], { force: true });
14729
+ return this.recoverBackendInstances(backend, undefined, profile ?? null);
14730
+ },
14227
14731
  claimWindow: backend => this.loginWindow.tryClaim("web", backend),
14228
14732
  releaseWindow: claim => { this.loginWindow.release(claim); },
14229
14733
  isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
@@ -14742,6 +15246,8 @@ export class FleetManager {
14742
15246
  // binary the same way, so a binary only a login shell could see was
14743
15247
  // installed, reported as verified, and then offered nowhere.
14744
15248
  this.adoptBinaryDirectory(installedAt, backend);
15249
+ // #2072: an install just finished — the pickers learn it now, not at the next pass.
15250
+ void this.backendAvailability.refresh([backend], { force: true });
14745
15251
  if (!LOGIN_FLOWS[backend]) {
14746
15252
  this.installHandoff = null;
14747
15253
  await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
@@ -14976,6 +15482,8 @@ export class FleetManager {
14976
15482
  buttons: m.buttons,
14977
15483
  click: m.click,
14978
15484
  links: m.links,
15485
+ channel: m.channel,
15486
+ from: m.from,
14979
15487
  });
14980
15488
  broadcastSseEvent(this.sseClients, event, recorded, onError, this.webChatHistory.cursorOf(recorded));
14981
15489
  // #1629: live-only receipt after the recorded agent reply. Never persist/replay it or give it an SSE cursor.
@@ -15776,10 +16284,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15776
16284
  this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
15777
16285
  // #1592: the web chat shows what was asked, as for every other inbound — the user's own words (no chat-log
15778
16286
  // context), as a user (#1306: never a role read from text), in the room's history (#1565).
15779
- this.emitSseEvent("message", {
15780
- instance: instanceName, sender: msg.username, role: "user",
15781
- text: text.slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
15782
- });
16287
+ this.emitPlatformInbound(instanceName, msg, text);
15783
16288
  }
15784
16289
  /** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
15785
16290
  /** #1690: our own runtime switches awaiting the daemon's outcome, by id. */
@@ -15817,7 +16322,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15817
16322
  /** #1806: null on a switch INTO claude-code — the configured model then belongs to the backend being left. */
15818
16323
  configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model) {
15819
16324
  const aliases = ["default", "sonnet", "opus", "haiku", "opusplan", "Fable"];
15820
- const api = (this.readCliEnv("claude-code")?.apiModels ?? []).map(m => m.id);
16325
+ const api = (this.readCliEnv("claude-code", this.catalogProfile("claude-code", instanceName))?.apiModels ?? []).map(m => m.id);
15821
16326
  const lower = model.trim().toLowerCase();
15822
16327
  const bare = lower.replace(/\[1m\]$/, "");
15823
16328
  const known = aliases.some(a => a.toLowerCase() === bare)
@@ -15845,13 +16350,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15845
16350
  : undefined;
15846
16351
  return classic ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
15847
16352
  }
15848
- cliEnvPath(backend) {
15849
- return join(getAgendHome(), "cli-env", `${backend.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
16353
+ /**
16354
+ * #2072: a catalog is the account's, so a credential profile has its own. Only claude-code's probe reads an account
16355
+ * catalog through a profile's login (its `GET /v1/models` with the profile's token); other backends' probes read the
16356
+ * process environment, so their catalog stays the backend's own.
16357
+ */
16358
+ catalogProfile(backend, instanceName) {
16359
+ return backend === "claude-code" && instanceName ? this.credentialProfileOf(instanceName, backend) : null;
16360
+ }
16361
+ cliEnvPath(backend, profile = null) {
16362
+ const safe = (s) => s.replace(/[^A-Za-z0-9._-]/g, "_");
16363
+ return join(getAgendHome(), "cli-env", profile ? `${safe(backend)}@${safe(profile)}.json` : `${safe(backend)}.json`);
15850
16364
  }
15851
- /** Read the cached CLI env for a backend, or null if missing/stale/unparseable. */
15852
- readCliEnv(backend) {
16365
+ /** Read the cached CLI env for a backend (a credential profile's own, when given), or null if missing/stale/unparseable. */
16366
+ readCliEnv(backend, profile = null) {
15853
16367
  try {
15854
- const env = JSON.parse(readFileSync(this.cliEnvPath(backend), "utf-8"));
16368
+ const env = JSON.parse(readFileSync(this.cliEnvPath(backend, profile), "utf-8"));
15855
16369
  if (typeof env?.probedAt === "number" && Date.now() - env.probedAt < CLI_ENV_TTL_MS)
15856
16370
  return env;
15857
16371
  }
@@ -15894,10 +16408,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15894
16408
  * previous list, never stall the command that asked for it.
15895
16409
  */
15896
16410
  async probeBackendBounded(backend, opts = {}) {
15897
- // #725: single-flight per backend. Explicit vendor refreshes remain a
16411
+ // #725: single-flight per backend (#2072: per backend and credential profile). Explicit vendor refreshes remain a
15898
16412
  // separate flight from an ordinary probe, while sysinfo can join either.
15899
- const regularFlight = this.pendingCliEnvProbes.get(backend);
15900
- const vendorFlight = this.pendingVendorCliEnvProbes.get(backend);
16413
+ const profile = opts.profile ?? null;
16414
+ const key = profile ? `${backend}@${profile}` : backend;
16415
+ const regularFlight = this.pendingCliEnvProbes.get(key);
16416
+ const vendorFlight = this.pendingVendorCliEnvProbes.get(key);
15901
16417
  if (opts.refreshVendorCatalog && vendorFlight)
15902
16418
  return vendorFlight;
15903
16419
  if (!opts.refreshVendorCatalog && (vendorFlight || regularFlight))
@@ -15908,25 +16424,26 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15908
16424
  const epoch = this.cliEnvProbeEpoch;
15909
16425
  const input = {
15910
16426
  mode: "env", backend, instanceDir,
15911
- config: { workingDirectory: "", instanceDir, instanceName: `probe-${backend}`, mcpServers: {} },
16427
+ config: { workingDirectory: "", instanceDir, instanceName: `probe-${backend}`, mcpServers: {},
16428
+ ...(profile ? { credentialStoreHome: credentialProfileStoreHome(getAgendHome(), backend, profile) } : {}) },
15912
16429
  refreshVendorCatalog: opts.refreshVendorCatalog === true,
15913
16430
  };
15914
- const bounded = this.runBackendProbeWorker(`global:${backend}`, input).then(probed => {
16431
+ const bounded = this.runBackendProbeWorker(`global:${key}`, input).then(probed => {
15915
16432
  if (!probed || Array.isArray(probed) || this.shuttingDown || epoch !== this.cliEnvProbeEpoch)
15916
16433
  return null;
15917
16434
  try {
15918
- return this.persistCliEnvProbeResult(backend, probed);
16435
+ return this.persistCliEnvProbeResult(backend, probed, profile);
15919
16436
  }
15920
16437
  catch (err) {
15921
- this.logger.warn({ backend, err }, "CLI env cache write failed");
16438
+ this.logger.warn({ backend, profile, err }, "CLI env cache write failed");
15922
16439
  return null;
15923
16440
  }
15924
16441
  }).finally(() => {
15925
16442
  const flights = opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes;
15926
- if (flights.get(backend) === bounded)
15927
- flights.delete(backend);
16443
+ if (flights.get(key) === bounded)
16444
+ flights.delete(key);
15928
16445
  });
15929
- (opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(backend, bounded);
16446
+ (opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(key, bounded);
15930
16447
  return bounded;
15931
16448
  }
15932
16449
  stopCliEnvProbes() {
@@ -16041,12 +16558,21 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16041
16558
  modelDisplayForInstance(instanceName) {
16042
16559
  return this.resolveInstanceModel(instanceName).display;
16043
16560
  }
16561
+ /**
16562
+ * #2072: the model an instance runs is always among its choices — one its CLI's catalog does not list (an older
16563
+ * name, a custom provider's, a typed one) is shown first, marked, so the menu never hides what is running.
16564
+ */
16565
+ withCurrentModel(options, currentModel) {
16566
+ if (!currentModel || currentModel === "default" || options.some(o => o.id === currentModel))
16567
+ return options;
16568
+ return [{ id: currentModel, label: currentModel, description: t("model.not_listed") }, ...options];
16569
+ }
16044
16570
  modelChoiceLabel(option, currentModel) {
16045
16571
  const label = option.description ? `${option.label} — ${option.description}` : option.label;
16046
16572
  return option.id === currentModel ? `✓ ${label}` : label;
16047
16573
  }
16048
16574
  /** Preserve the existing cache merge rules for worker probes. */
16049
- persistCliEnvProbeResult(backend, probed) {
16575
+ persistCliEnvProbeResult(backend, probed, profile = null) {
16050
16576
  const env = { backend, probedAt: Date.now(), ...probed };
16051
16577
  // An empty result must never overwrite a catalog we already have. Some
16052
16578
  // probes hit the network (`agy models` fetches, 5s cap), so a slow moment
@@ -16055,14 +16581,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16055
16581
  // antigravity cache replaced by an empty one. Keep the known models and
16056
16582
  // let the fresher currentModel/version through.
16057
16583
  if (!env.models?.length) {
16058
- const previous = this.readCliEnv(backend);
16584
+ const previous = this.readCliEnv(backend, profile);
16059
16585
  if (previous?.models?.length)
16060
16586
  env.models = previous.models;
16061
16587
  }
16062
16588
  // Same protection for the extended catalog: one offline moment must not
16063
16589
  // blank a good account list for the whole cache TTL.
16064
16590
  if (!env.apiModels?.length) {
16065
- const previous = this.readCliEnv(backend);
16591
+ const previous = this.readCliEnv(backend, profile);
16066
16592
  if (previous?.apiModels?.length)
16067
16593
  env.apiModels = previous.apiModels;
16068
16594
  }
@@ -16070,12 +16596,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16070
16596
  // keeps the cached levels only for that same binary: both versions known and equal, cache still valid. A help
16071
16597
  // that was read and lists none ([]) is an answer and is written as is, so the fallback applies.
16072
16598
  if (env.effortLevels === undefined) {
16073
- const previous = this.readCliEnv(backend);
16599
+ const previous = this.readCliEnv(backend, profile);
16074
16600
  if (previous?.effortLevels && previous.version && env.version && previous.version === env.version) {
16075
16601
  env.effortLevels = previous.effortLevels;
16076
16602
  }
16077
16603
  }
16078
- const path = this.cliEnvPath(backend);
16604
+ const path = this.cliEnvPath(backend, profile);
16079
16605
  mkdirSync(dirname(path), { recursive: true });
16080
16606
  writeFileSync(path, JSON.stringify(env, null, 2));
16081
16607
  return env;
@@ -16097,6 +16623,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16097
16623
  // indefinitely — each is wrapped by CLI_ENV_PROBE_DEADLINE_MS.
16098
16624
  for (const b of backends)
16099
16625
  void this.probeBackendBounded(b);
16626
+ // #2072: each Claude credential profile in use has its own account catalog. Only fleet.yaml's agents are walked
16627
+ // here; a ClassicBot room on a profile is probed on its first /model (catalogProfile knows rooms too).
16628
+ const profiles = new Set();
16629
+ for (const name of Object.keys(this.fleetConfig?.instances ?? {})) {
16630
+ if (this.backendNameForInstance(name) !== "claude-code")
16631
+ continue;
16632
+ const profile = this.catalogProfile("claude-code", name);
16633
+ if (profile)
16634
+ profiles.add(profile);
16635
+ }
16636
+ for (const profile of profiles)
16637
+ void this.probeBackendBounded("claude-code", { profile });
16100
16638
  }
16101
16639
  /** Best-effort model list for `/model`: cached CLI env first, else live probe. Never throws. */
16102
16640
  async getModelOptions(instanceName, refresh = false, onLiveProbe) {
@@ -16110,7 +16648,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16110
16648
  */
16111
16649
  async getModelOptionsWithSource(instanceName, opts = {}) {
16112
16650
  const backendName = this.backendNameForInstance(instanceName);
16113
- const cached = this.readCliEnv(backendName);
16651
+ const profile = this.catalogProfile(backendName, instanceName);
16652
+ const cached = this.readCliEnv(backendName, profile);
16114
16653
  if (!opts.refresh && cached?.models.length && !this.cliEnvNeedsRefresh(cached)) {
16115
16654
  return { models: cached.models, source: "cache" };
16116
16655
  }
@@ -16121,7 +16660,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16121
16660
  // Stale, missing, or a forced refresh → probe live (also refreshes the cache).
16122
16661
  // A newly released model is invisible until this runs, which is why staleness
16123
16662
  // triggers it rather than waiting for the 24h hard expiry or a cold start.
16124
- const env = await this.probeBackendBounded(backendName, { refreshVendorCatalog: opts.refreshVendorCatalog });
16663
+ const env = await this.probeBackendBounded(backendName, { refreshVendorCatalog: opts.refreshVendorCatalog, profile });
16125
16664
  if (env?.models.length)
16126
16665
  return { models: env.models, source: "live" };
16127
16666
  // Probe failed or timed out: the previous list is still the best answer.
@@ -16136,7 +16675,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16136
16675
  modelMenuChoices(instanceName, nonce, options, currentModel) {
16137
16676
  const isClaude = this.backendNameForInstance(instanceName) === "claude-code";
16138
16677
  const choices = [{ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:__refresh__`, label: t("model.refresh") }];
16139
- for (const o of options.slice(0, isClaude ? 23 : 24)) {
16678
+ for (const o of this.withCurrentModel(options, currentModel).slice(0, isClaude ? 23 : 24)) {
16140
16679
  choices.push({ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`, label: this.modelChoiceLabel(o, currentModel) });
16141
16680
  }
16142
16681
  if (isClaude)
@@ -16543,16 +17082,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16543
17082
  }
16544
17083
  }
16545
17084
  /** Cached-or-live account catalog behind "/model → more models" (claude). */
16546
- async claudeApiModelOptions() {
16547
- const cached = this.readCliEnv("claude-code");
17085
+ async claudeApiModelOptions(instanceName) {
17086
+ // #2072: the account behind THIS instance — its credential profile's catalog, else the default login's.
17087
+ const profile = this.catalogProfile("claude-code", instanceName);
17088
+ const cached = this.readCliEnv("claude-code", profile);
16548
17089
  if (cached?.apiModels?.length)
16549
17090
  return cached.apiModels;
16550
- const env = await this.probeBackendBounded("claude-code");
17091
+ const env = await this.probeBackendBounded("claude-code", { profile });
16551
17092
  return env?.apiModels ?? [];
16552
17093
  }
16553
17094
  /** Replace a consumed "/model" menu with the full account catalog tier. */
16554
17095
  async expandClaudeModelMenu(pending) {
16555
- const expanded = await this.claudeApiModelOptions();
17096
+ const expanded = await this.claudeApiModelOptions(pending.instanceName);
16556
17097
  if (!expanded.length) {
16557
17098
  await pending.respond(t("model.more_unavailable")).catch(() => { });
16558
17099
  return;
@@ -16980,7 +17521,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16980
17521
  return {
16981
17522
  currentBackend: (name) => this.backendNameForInstance(name),
16982
17523
  currentDisplay: (name) => `${this.backendNameForInstance(name)} · ${this.resolveInstanceModel(name).display}`,
16983
- installedBackends: () => KNOWN_BACKENDS.filter(b => b !== "mock" && this.backendInstalledHere(b)),
17524
+ availability: () => this.backendAvailabilityFor(instanceName),
16984
17525
  models: (backend) => this.backendModelOptions(backend, opts),
16985
17526
  // #1808: the same rule update_instance_config's switch applies (backendModelVerdict).
16986
17527
  modelKnown: (backend, model) => this.backendModelVerdict(instanceName, backend, model).ok,
@@ -17079,10 +17620,76 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17079
17620
  // kiro-cli gates its own --effort by version at launch (a quiet console note otherwise); muse always passes it.
17080
17621
  return () => true;
17081
17622
  }
17082
- /** Installed the way the launch finds a CLI (resolveBinary): on PATH or in a known install directory. */
17083
- backendInstalledHere(backend) {
17084
- const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
17085
- return !!binary && resolveBinary(binary) !== binary;
17623
+ /**
17624
+ * #2072: what this host can run, from the published availability snapshot (backend-availability.ts). Read only: a
17625
+ * handler never starts a probe, not even in the background — launch, the timer, and /login install or sign-in do.
17626
+ */
17627
+ backendAvailabilityRows() {
17628
+ return this.backendAvailability.snapshot();
17629
+ }
17630
+ /**
17631
+ * #2072 part 3: the models a Settings field offers for `backend` — the cached catalog (an instance's own credential
17632
+ * profile's, when it is named and has one), claude's account catalog included. Read only: never a probe.
17633
+ */
17634
+ modelCatalogFor(backend, instance) {
17635
+ if (!KNOWN_BACKENDS.includes(backend) || backend === "mock")
17636
+ return [];
17637
+ const own = instance && Object.prototype.hasOwnProperty.call(this.fleetConfig?.instances ?? {}, instance) ? instance
17638
+ : instance && this.classicChannels?.getChannelIdByInstance(instance) !== undefined ? instance : null;
17639
+ const env = this.readCliEnv(backend, this.catalogProfile(backend, own));
17640
+ const seen = new Set(), out = [];
17641
+ for (const m of [...(env?.models ?? []), ...(env?.apiModels ?? [])])
17642
+ if (!seen.has(m.id)) {
17643
+ seen.add(m.id);
17644
+ out.push(m);
17645
+ }
17646
+ return out;
17647
+ }
17648
+ /**
17649
+ * #2072 (#2078 review): the snapshot as THIS instance would see it. Its sign-in on a backend is its own credential
17650
+ * profile's when it has one there — the host's default login says nothing about it. A profile's login is a file check
17651
+ * (credentialProfileLogin), no process.
17652
+ */
17653
+ backendAvailabilityFor(instanceName) {
17654
+ return this.backendAvailabilityRows().map(row => {
17655
+ const profile = this.credentialProfileOf(instanceName, row.backend);
17656
+ if (!profile || (row.state !== "usable" && row.state !== "not_signed_in" && row.state !== "unknown") || !row.path)
17657
+ return row;
17658
+ let login = "unknown";
17659
+ try {
17660
+ login = credentialProfileLogin(this.dataDir, row.backend, profile).state;
17661
+ }
17662
+ catch { /* unknown */ }
17663
+ // Signed in there: usable for this instance. Signed out (or unknown): the host's default login says nothing about
17664
+ // it, so no "not signed in" claim from it — update_instance_config's profile gate refuses a signed-out profile
17665
+ // when applied, naming the profile and the command that signs it in.
17666
+ if (login === "logged-in")
17667
+ return { ...row, auth: "valid", state: "usable" };
17668
+ return row.state === "not_signed_in" ? { ...row, auth: "unknown", state: "unknown" } : row;
17669
+ });
17670
+ }
17671
+ /** The probes the snapshot runs (tests replace them: no CLI, no account, no network). */
17672
+ availabilityProbes = hostAvailabilityProbes;
17673
+ availabilityStore = null;
17674
+ availabilityTimer = null;
17675
+ get backendAvailability() {
17676
+ return this.availabilityStore ??= new BackendAvailabilityStore(KNOWN_BACKENDS.filter(b => b !== "mock" && !!BACKEND_INSTALLATION_INFO[b]), this.availabilityProbes, { onChange: () => this.noteAvailabilityPaths() });
17677
+ }
17678
+ /** A launch need not look the binary up again (a synchronous `which` on the loop) when the snapshot found it. */
17679
+ noteAvailabilityPaths() {
17680
+ for (const row of this.availabilityStore?.snapshot() ?? []) {
17681
+ const binary = BACKEND_INSTALLATION_INFO[row.backend]?.binary;
17682
+ if (binary)
17683
+ noteResolvedBinary(binary, row.state === "not_installed" ? null : row.path);
17684
+ }
17685
+ }
17686
+ /** Probe now and then every AVAILABILITY_STALE_MS; a pass that finds nothing changed starts no CLI. */
17687
+ startAvailabilityRefresh() {
17688
+ void this.backendAvailability.refresh();
17689
+ if (this.availabilityTimer)
17690
+ clearInterval(this.availabilityTimer);
17691
+ this.availabilityTimer = setInterval(() => { void this.backendAvailability.refresh(); }, AVAILABILITY_STALE_MS);
17692
+ this.availabilityTimer.unref?.();
17086
17693
  }
17087
17694
  /** The models `/model` would offer on a backend: the cached CLI env, else (live) a bounded probe. Never throws. */
17088
17695
  async backendModelOptions(backend, opts) {
@@ -17424,9 +18031,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17424
18031
  }
17425
18032
  return undefined;
17426
18033
  }
18034
+ /** #2072: from the published snapshot (no `which` on the loop); not checked yet counts as installed (no false alarm). */
17427
18035
  isBackendInstalled(backend) {
17428
- const installation = BACKEND_INSTALLATION_INFO[backend];
17429
- return !!installation && checkBinaryInstalled(installation.binary);
18036
+ if (!BACKEND_INSTALLATION_INFO[backend])
18037
+ return false;
18038
+ return this.backendAvailability.get(backend)?.state !== "not_installed";
17430
18039
  }
17431
18040
  getMissingBackendWarning(backend) {
17432
18041
  if (!backend)
@@ -17764,6 +18373,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17764
18373
  clearInterval(this.eventLogPruneTimer);
17765
18374
  this.eventLogPruneTimer = null;
17766
18375
  }
18376
+ if (this.guestRoomsTimer) {
18377
+ clearInterval(this.guestRoomsTimer);
18378
+ this.guestRoomsTimer = null;
18379
+ }
17767
18380
  this.logService?.unwatchAll(); // #1218: a Log's watch timers end with the fleet
17768
18381
  this.compactWatchService?.stopAll();
17769
18382
  if (this.outboxPruneTimer) {
@@ -17863,6 +18476,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17863
18476
  this.adapters.clear();
17864
18477
  this.controlClient?.stop();
17865
18478
  this.controlClient = null;
18479
+ if (this.availabilityTimer) {
18480
+ clearInterval(this.availabilityTimer);
18481
+ this.availabilityTimer = null;
18482
+ }
17866
18483
  if (this.healthServer) {
17867
18484
  this.healthServerListening = false;
17868
18485
  const server = this.healthServer;
@@ -19847,6 +20464,147 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19847
20464
  this.rejectedHostsLogged.add(name);
19848
20465
  this.logger.warn({ host: name }, "Web request refused: Host is not allowed (add it to web.allowed_hosts if this is a proxy you run)");
19849
20466
  }
20467
+ /**
20468
+ * #1920 §3: the instances a guest session may see — derived on every request from the account's chat access, never
20469
+ * stored; nothing while `web.guests.enabled` is not true.
20470
+ * - Its connection's fleet topics and General (what it may talk to in chat): when that connection's access lets the
20471
+ * account in (AccessManager.isAllowed — allowed_users or paired). An `open` connection lets everyone in, so it gives
20472
+ * only the instances an admin named for its guests (`web.guests.open_instances`; the user's decision: least
20473
+ * privilege, as an open group has no list of people).
20474
+ * - A Telegram private ClassicBot room that is the account's own (its chat id is the user id).
20475
+ * - A ClassicBot group or guild room on its connection: only while its membership is verified (§3.2 — a "member"
20476
+ * answer read in the background, at most 15 minutes old; unknown, failed or stale hides it).
20477
+ * - Then an admin's narrowing (`web.guests.narrow`), which only takes away.
20478
+ * A fleet admin is not a guest: an admin outside the connection's access gets nothing here (admins sign in as admins).
20479
+ */
20480
+ guestScope(owner) {
20481
+ const guests = this.fleetConfig?.web?.guests;
20482
+ if (!owner || guests?.enabled !== true)
20483
+ return NO_GUEST_SCOPE;
20484
+ const own = (map, key) => (map && Object.hasOwn(map, key) ? map[key] : undefined);
20485
+ const scope = new Set();
20486
+ const world = this.worlds.get(owner.adapterId);
20487
+ if (world && world.accessManager.isAllowed(owner.userId)) {
20488
+ const named = world.accessManager.getMode() === "open" ? new Set(own(guests.open_instances, owner.adapterId) ?? []) : null;
20489
+ for (const name of Object.keys(this.fleetConfig?.instances ?? {})) {
20490
+ if (this.getInstanceAdapterId(name) !== owner.adapterId || this.isClassicInstance(name))
20491
+ continue;
20492
+ if (!named || named.has(name))
20493
+ scope.add(name);
20494
+ }
20495
+ }
20496
+ const rooms = this.classicChannels?.getAll() ?? [];
20497
+ for (const room of rooms) {
20498
+ if (this.isOwnPrivateRoom(owner, room))
20499
+ scope.add(room.instanceName);
20500
+ }
20501
+ const members = this.guestRooms();
20502
+ for (const room of guestGroupRooms(rooms, owner)) {
20503
+ if (members.verified(room, owner.userId))
20504
+ scope.add(room.instance);
20505
+ }
20506
+ const narrow = own(guests.narrow, `${owner.adapterId}:${owner.userId}`);
20507
+ if (narrow) {
20508
+ const keep = new Set(narrow);
20509
+ for (const name of [...scope])
20510
+ if (!keep.has(name))
20511
+ scope.delete(name);
20512
+ }
20513
+ return scope.size ? scope : NO_GUEST_SCOPE;
20514
+ }
20515
+ /**
20516
+ * #1920 §3.5: a guest session still admitted — current, with something in scope. A scope that became empty (guest
20517
+ * sign-in off, the account removed from the connection's access, everything narrowed away) ends the session here,
20518
+ * so the next request and the open stream find it gone.
20519
+ */
20520
+ guestStillAdmitted(record) {
20521
+ return this.guestAdmission(record) === "ok";
20522
+ }
20523
+ /**
20524
+ * The same, with a third answer (§3.2): "pending" — nothing in scope now, but a room's membership is not read yet
20525
+ * (a restart, a room just created). The request is refused and the session kept; its read starts in the background.
20526
+ */
20527
+ guestAdmission(record) {
20528
+ if (!this.webSessions || !this.webToken || !this.webSessions.isCurrent(record, tokenEpoch(this.webToken)))
20529
+ return "gone";
20530
+ const owner = record.owner;
20531
+ if (this.guestScope(owner).size)
20532
+ return "ok";
20533
+ if (owner && this.fleetConfig?.web?.guests?.enabled === true) {
20534
+ const members = this.guestRooms(), rooms = guestGroupRooms(this.classicChannels?.getAll() ?? [], owner);
20535
+ if (rooms.some((room) => members.pending(room, owner.userId))) {
20536
+ void this.refreshGuestRooms(owner, { mode: "triggered" }); // rate-limited per room (30 s), single-flight
20537
+ return "pending";
20538
+ }
20539
+ }
20540
+ this.enforceGuestSwitch();
20541
+ const ended = this.webSessions.revokeByHandle(record.handle);
20542
+ if (ended.found)
20543
+ this.logger.info({ adapterId: record.owner?.adapterId, durable: ended.durable }, "Guest web session ended: nothing left in its scope");
20544
+ return "gone";
20545
+ }
20546
+ /** #1920 §3.2: the membership answers; the read goes to the room's own connection, never waits past its deadline. */
20547
+ guestRooms() {
20548
+ this.guestRoomMembership ??= new GuestRoomMembership({
20549
+ now: () => performance.now(),
20550
+ read: (room, userId) => {
20551
+ const adapter = this.worlds.get(room.adapterId)?.adapter;
20552
+ return room.kind === "telegram" ? adapter?.isGroupMember?.(room.channelId, userId) : adapter?.canViewChannel?.(room.channelId, userId);
20553
+ },
20554
+ // Said once per key while its reads keep failing (no user id: the room and connection are what an admin fixes).
20555
+ onPersistentFailure: (room, failures) => this.logger.warn({ adapterId: room.adapterId, instance: room.instance, failures }, "Guest room membership keeps failing to read (the bot may lack the permission, or was removed); retrying with backoff up to 10 minutes"),
20556
+ onRecovered: (room) => this.logger.info({ adapterId: room.adapterId, instance: room.instance }, "Guest room membership reads again"),
20557
+ });
20558
+ return this.guestRoomMembership;
20559
+ }
20560
+ /** #1920 §3.2: read this guest's rooms that are due (GuestRoomMembership.refresh says when each mode reads). */
20561
+ async refreshGuestRooms(owner, opts = {}) {
20562
+ if (this.fleetConfig?.web?.guests?.enabled !== true)
20563
+ return;
20564
+ try {
20565
+ await this.guestRooms().refresh(owner, guestGroupRooms(this.classicChannels?.getAll() ?? [], owner), opts);
20566
+ }
20567
+ catch (err) {
20568
+ this.logger.warn({ err: err.message }, "Guest room membership could not be read");
20569
+ }
20570
+ }
20571
+ /** Every account with a live guest session, one after another (the guest caps bound them); one pass at a time. */
20572
+ async refreshAllGuestRooms() {
20573
+ if (this.guestRoomsPass || this.fleetConfig?.web?.guests?.enabled !== true || !this.webSessions)
20574
+ return;
20575
+ this.guestRoomsPass = true;
20576
+ try {
20577
+ for (const owner of this.webSessions.guestOwners())
20578
+ await this.refreshGuestRooms(owner);
20579
+ }
20580
+ finally {
20581
+ this.guestRoomsPass = false;
20582
+ }
20583
+ }
20584
+ guestRoomsPass = false;
20585
+ /**
20586
+ * #1920 §3.5: guest sign-in turned off ends every guest session — checked when the configuration is read or saved
20587
+ * and when the sessions are opened, so turning it back on never revives one.
20588
+ */
20589
+ enforceGuestSwitch() {
20590
+ if (this.fleetConfig?.web?.guests?.enabled === true || !this.webSessions)
20591
+ return;
20592
+ const ended = this.webSessions.revokeAll((r) => r.tier === "guest");
20593
+ if (ended.count)
20594
+ this.logger.info({ count: ended.count, durable: ended.durable }, "Guest web sign-in is off: every guest session ended");
20595
+ // #1920 step 4b: and every guest's returning device — turning it back on lets no old browser ask for a code.
20596
+ const devices = this.returningDevices?.revokeGuest();
20597
+ if (devices?.count)
20598
+ this.logger.info({ count: devices.count, durable: devices.durable }, "Guest web sign-in is off: every guest's returning device forgotten");
20599
+ }
20600
+ /** #1920 §5.2: whether this instance is the account's own Telegram private ClassicBot room (its chat id is the user id). */
20601
+ guestOwnRoom(owner, instance) {
20602
+ return (this.classicChannels?.getAll() ?? []).some((room) => room.instanceName === instance && this.isOwnPrivateRoom(owner, room));
20603
+ }
20604
+ /** A private room's chat id is its one user's id (a group's is negative, so never equal); Telegram only. */
20605
+ isOwnPrivateRoom(owner, room) {
20606
+ return room.adapterId === owner.adapterId && room.channelId === owner.userId && inferClassicChannelType(room.channelId) === "telegram";
20607
+ }
19850
20608
  /** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
19851
20609
  dispatchWebHttp(req, res, port, closing = false) {
19852
20610
  try {
@@ -19907,6 +20665,40 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19907
20665
  res.end(JSON.stringify({ error: decision.message }));
19908
20666
  return;
19909
20667
  }
20668
+ // #1920: a guest reaches only GUEST_ROUTES, within the scope derived for it now — default deny, before any
20669
+ // handler, on this listener and the public link's gateway alike (both come through here).
20670
+ if (decision.via === "session" && decision.session.tier === "guest") {
20671
+ const owner = decision.session.owner;
20672
+ const admitted = decision.session;
20673
+ const admission = this.guestAdmission(admitted);
20674
+ if (admission === "pending") {
20675
+ // §3.2: a room's membership is being read — try again shortly; the session stays.
20676
+ res.writeHead(503, { "Retry-After": "5" });
20677
+ res.end(JSON.stringify({ error: "guest_scope_pending" }));
20678
+ return;
20679
+ }
20680
+ if (admission === "gone") {
20681
+ res.writeHead(401);
20682
+ res.end(JSON.stringify({ error: "guest_scope_empty" }));
20683
+ return;
20684
+ }
20685
+ const verdict = guestRouteDecision(req.method ?? "GET", parsedUrl, this.guestScope(owner));
20686
+ if (verdict.kind === "deny") {
20687
+ res.writeHead(verdict.status);
20688
+ res.end(JSON.stringify({ error: verdict.error }));
20689
+ return;
20690
+ }
20691
+ // Phase 1b: the handlers that answer per recipient (the stream, the poll, a file) know it is a guest's, and
20692
+ // ask its scope anew each time; an emptied scope ends the session (and closes its stream).
20693
+ bindGuestRequest(req, { owner, scope: () => this.guestScope(owner), current: () => this.guestStillAdmitted(admitted),
20694
+ // #1920 §5.2: its own Telegram private room shows it everything; the kept messages place ticks and buttons.
20695
+ ownRoom: (instance) => this.guestOwnRoom(owner, instance),
20696
+ // §3.2: a stream that connects reads its rooms again (in the background; at most once a minute).
20697
+ streamOpened: () => { void this.refreshGuestRooms(owner, { mode: "force" }); },
20698
+ history: () => this.webChatHistory,
20699
+ // #1920 §5.1: guests chat unless the host made them read-only (web.guests.chat: false).
20700
+ chat: () => this.fleetConfig?.web?.guests?.chat !== false });
20701
+ }
19910
20702
  }
19911
20703
  if (req.method === "GET" && req.url === "/health") {
19912
20704
  const health = this.getFleetHealth();
@@ -20297,6 +21089,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20297
21089
  this.logger.warn({ code: err.code, port: settings.port }, "Preview listener unavailable; HTML previews are off");
20298
21090
  this.previewListening = false;
20299
21091
  this.previewListener = null;
21092
+ this.previewListenError = `${settings.port}: ${err.code ?? err.message}`;
20300
21093
  });
20301
21094
  // An optional listener never takes the fleet down: a port Node refuses outright throws here, synchronously.
20302
21095
  try {
@@ -20307,6 +21100,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20307
21100
  this.logger.warn({ err: err.message, port: settings.port }, "Preview listener cannot listen; HTML previews are off");
20308
21101
  this.previewListening = false;
20309
21102
  this.previewListener = null;
21103
+ this.previewListenError = `${settings.port}: ${err.code ?? err.message}`;
20310
21104
  }
20311
21105
  }
20312
21106
  /** What a reload may change hot: web.preview, and the dashboard names (the shim's allow-list and frame-ancestors). */
@@ -20327,6 +21121,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20327
21121
  }
20328
21122
  stopPreviewListener() {
20329
21123
  this.previewListening = false;
21124
+ this.previewListenError = null;
20330
21125
  this.previewListener?.close();
20331
21126
  this.previewListener = null;
20332
21127
  }
@@ -20334,10 +21129,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20334
21129
  previewForUi(hostHeader, secure) {
20335
21130
  const listener = this.previewListening ? this.previewListener : null;
20336
21131
  const decided = previewAvailability(listener ? listener.settings : null, hostHeader, secure, listener ? listener.origins : undefined);
21132
+ // Previews are on, but no listener answers: say why — its port could not be opened (the error, never "turned off"),
21133
+ // or it is not listening yet.
20337
21134
  if (!listener && this.fleetConfig?.web?.preview !== false)
20338
- decided.reason = "Previews are not available on this fleet right now.";
21135
+ Object.assign(decided, this.previewNotListening());
20339
21136
  return { ...decided, boot: listener ? listener.bootId : null };
20340
21137
  }
21138
+ previewNotListening() {
21139
+ const failed = this.previewListenError;
21140
+ return failed
21141
+ ? { code: "listenFailed", detail: failed, reason: `Previews are unavailable: the fleet could not open its preview port (${failed}). Another program may be using it — set web.preview_port, then restart AgEnD.` }
21142
+ : { code: "notListening", reason: "Previews are not available on this fleet right now." };
21143
+ }
20341
21144
  /**
20342
21145
  * #1723: for one page of the public link (exposure `exposureId`, at `dashboardOrigin`): its own preview tunnel's
20343
21146
  * origin once up, "starting" until then, why it failed if it did — or a configured web.preview_origin, used instead.
@@ -20352,8 +21155,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20352
21155
  const configured = typeof web?.preview_origin === "string" ? normalizeOrigin(web.preview_origin) : null;
20353
21156
  if (configured) {
20354
21157
  const listener = this.previewListening ? this.previewListener : null;
20355
- if (!listener)
20356
- return off("publicLink", "Previews are not available on this fleet right now.");
21158
+ if (!listener) {
21159
+ const why = this.previewNotListening();
21160
+ return off(why.code, why.reason, why.detail ? { detail: why.detail } : {});
21161
+ }
20357
21162
  if (!configured.startsWith("https:"))
20358
21163
  return off("needHttps", "Previews need an https web.preview_origin when the dashboard is reached over https.");
20359
21164
  return { dashboardOrigin, previewOrigin: configured, reason: null, code: null, boot: listener.bootId, frameSrc: `${configured}/frame` };
@@ -20493,6 +21298,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20493
21298
  // #1685: host memory pressure while it slows or holds new agents (the dashboard's indicator; macOS elevated is
20494
21299
  // shown here rather than in chat). null when normal, unknown or advisory.
20495
21300
  host_memory: this.hostMemoryIndicator(),
21301
+ // #2015: the largest processes while it lasts (name, pid, RSS, and whose: an instance, the fleet or tmux).
21302
+ ...(this.hostMemoryIndicator() && this.memoryTop ? { host_memory_top: this.memoryTop.processes } : {}),
20496
21303
  };
20497
21304
  }
20498
21305
  }