@songsid/agend 2.3.0-beta.1 → 2.3.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/dist/agent-cli.js +5 -5
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/agent-file-read.d.ts +11 -6
  4. package/dist/agent-file-read.js +32 -17
  5. package/dist/agent-file-read.js.map +1 -1
  6. package/dist/agent-whats-new.js +10 -13
  7. package/dist/agent-whats-new.js.map +1 -1
  8. package/dist/auth-api.js +10 -5
  9. package/dist/auth-api.js.map +1 -1
  10. package/dist/backend/claude-code.d.ts +2 -2
  11. package/dist/backend/claude-code.js +10 -5
  12. package/dist/backend/claude-code.js.map +1 -1
  13. package/dist/backend/types.d.ts +13 -0
  14. package/dist/backend/types.js +10 -0
  15. package/dist/backend/types.js.map +1 -1
  16. package/dist/cache-analysis.d.ts +47 -1
  17. package/dist/cache-analysis.js +68 -8
  18. package/dist/cache-analysis.js.map +1 -1
  19. package/dist/cache-report-tool.d.ts +17 -0
  20. package/dist/cache-report-tool.js +40 -0
  21. package/dist/cache-report-tool.js.map +1 -0
  22. package/dist/cache-service.d.ts +15 -0
  23. package/dist/cache-service.js +26 -1
  24. package/dist/cache-service.js.map +1 -1
  25. package/dist/channel/mcp-tools.js +9 -3
  26. package/dist/channel/mcp-tools.js.map +1 -1
  27. package/dist/classic-channel-manager.d.ts +8 -3
  28. package/dist/classic-channel-manager.js +10 -3
  29. package/dist/classic-channel-manager.js.map +1 -1
  30. package/dist/cli.js +7 -3
  31. package/dist/cli.js.map +1 -1
  32. package/dist/config-validator.js +5 -1
  33. package/dist/config-validator.js.map +1 -1
  34. package/dist/daemon.js +5 -5
  35. package/dist/daemon.js.map +1 -1
  36. package/dist/delivery-outbox.js +5 -0
  37. package/dist/delivery-outbox.js.map +1 -1
  38. package/dist/fleet-manager.d.ts +71 -3
  39. package/dist/fleet-manager.js +256 -45
  40. package/dist/fleet-manager.js.map +1 -1
  41. package/dist/fleet-traffic.d.ts +38 -0
  42. package/dist/fleet-traffic.js +96 -0
  43. package/dist/fleet-traffic.js.map +1 -0
  44. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +20 -5
  45. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -1
  46. package/dist/general-knowledge/skills/web-appearance/SKILL.md +149 -5
  47. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +398 -0
  48. package/dist/locale.js +6 -4
  49. package/dist/locale.js.map +1 -1
  50. package/dist/memory-hogs.d.ts +27 -0
  51. package/dist/memory-hogs.js +124 -0
  52. package/dist/memory-hogs.js.map +1 -0
  53. package/dist/outbound-handlers.d.ts +4 -0
  54. package/dist/outbound-handlers.js +74 -1
  55. package/dist/outbound-handlers.js.map +1 -1
  56. package/dist/outbound-schemas.d.ts +98 -0
  57. package/dist/outbound-schemas.js +56 -0
  58. package/dist/outbound-schemas.js.map +1 -1
  59. package/dist/public-web-gateway.js +1 -1
  60. package/dist/public-web-gateway.js.map +1 -1
  61. package/dist/scheduler/scheduler.d.ts +12 -0
  62. package/dist/scheduler/scheduler.js +83 -10
  63. package/dist/scheduler/scheduler.js.map +1 -1
  64. package/dist/service-installer.d.ts +2 -1
  65. package/dist/service-installer.js +18 -3
  66. package/dist/service-installer.js.map +1 -1
  67. package/dist/tmux-manager.d.ts +5 -0
  68. package/dist/tmux-manager.js +16 -0
  69. package/dist/tmux-manager.js.map +1 -1
  70. package/dist/tool-permissions.d.ts +5 -0
  71. package/dist/tool-permissions.js +15 -2
  72. package/dist/tool-permissions.js.map +1 -1
  73. package/dist/types.d.ts +1 -1
  74. package/dist/ui/app-effects.js +33 -9
  75. package/dist/ui/chat-effects.js +62 -4
  76. package/dist/ui/chat-strings.js +40 -4
  77. package/dist/ui/chat-thread.js +13 -5
  78. package/dist/ui/effect-trial-panel.js +19 -16
  79. package/dist/ui/fleet-cache.js +23 -1
  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 +8 -2
  85. package/dist/ui/panel-details.js +2 -1
  86. package/dist/ui/panel-fleet.js +17 -5
  87. package/dist/ui/panel-settings.js +3 -2
  88. package/dist/ui/preview.js +3 -2
  89. package/dist/ui/settings-appearance.js +34 -18
  90. package/dist/ui/settings-dialogs.js +3 -6
  91. package/dist/ui/settings-effects.js +4 -3
  92. package/dist/ui/settings-learn.js +25 -0
  93. package/dist/ui/settings-model.js +6 -2
  94. package/dist/ui/settings-mods.js +4 -3
  95. package/dist/ui/settings-owner-effects.js +8 -8
  96. package/dist/ui/settings-strings.js +34 -38
  97. package/dist/ui/shared/app-i18n.js +2 -2
  98. package/dist/ui/shared/app-prefs.js +1 -1
  99. package/dist/ui/shared/app-shell.js +23 -1
  100. package/dist/ui/shared/app-store.js +12 -1
  101. package/dist/ui/shared/app-stream.js +2 -1
  102. package/dist/ui/shared/app.css +163 -12
  103. package/dist/ui/shared/effect-app-runtime.js +1 -1
  104. package/dist/ui/shared/effect-frame.js +10 -5
  105. package/dist/ui/shared/effect-geometry.js +24 -9
  106. package/dist/ui/shared/effect-policy.js +2 -1
  107. package/dist/ui/shared/effect-registry.js +71 -17
  108. package/dist/ui/shared/effect-trial-shadow.js +62 -0
  109. package/dist/ui/shared/effect-trial-stage.js +15 -9
  110. package/dist/ui/shared/effect-trial.css +40 -0
  111. package/dist/ui/shared/identity-card.js +5 -2
  112. package/dist/ui/shared/identity-color.js +3 -3
  113. package/dist/ui/shared/instance-nav.js +35 -9
  114. package/dist/ui/shared/panel-view.js +2 -2
  115. package/dist/ui/shared/usage-dialog.js +14 -2
  116. package/dist/ui/shared/view-strings.js +22 -2
  117. package/dist/ui/signin.html +1 -1
  118. package/dist/usage/providers.d.ts +3 -0
  119. package/dist/usage/providers.js +4 -3
  120. package/dist/usage/providers.js.map +1 -1
  121. package/dist/view-api.d.ts +21 -0
  122. package/dist/view-api.js +28 -0
  123. package/dist/view-api.js.map +1 -1
  124. package/dist/web-api.d.ts +5 -6
  125. package/dist/web-api.js +60 -11
  126. package/dist/web-api.js.map +1 -1
  127. package/dist/web-appearance-view.d.ts +10 -0
  128. package/dist/web-appearance-view.js +77 -8
  129. package/dist/web-appearance-view.js.map +1 -1
  130. package/dist/web-asset-parts.d.ts +3 -8
  131. package/dist/web-asset-parts.js.map +1 -1
  132. package/dist/web-auth.js +1 -1
  133. package/dist/web-auth.js.map +1 -1
  134. package/dist/web-effect-service.js +3 -1
  135. package/dist/web-effect-service.js.map +1 -1
  136. package/dist/web-effect-tools.js +4 -1
  137. package/dist/web-effect-tools.js.map +1 -1
  138. package/dist/web-effect-trials.d.ts +4 -4
  139. package/dist/web-effect-trials.js +6 -6
  140. package/dist/web-effect-trials.js.map +1 -1
  141. package/dist/web-effect-upload.d.ts +2 -4
  142. package/dist/web-effect-upload.js +3 -8
  143. package/dist/web-effect-upload.js.map +1 -1
  144. package/dist/web-effect.d.ts +14 -0
  145. package/dist/web-effect.js +21 -0
  146. package/dist/web-effect.js.map +1 -1
  147. package/dist/web-guest-access.d.ts +67 -0
  148. package/dist/web-guest-access.js +126 -0
  149. package/dist/web-guest-access.js.map +1 -0
  150. package/dist/web-identity-palette.d.ts +1 -1
  151. package/dist/web-identity-palette.js +2 -2
  152. package/dist/web-identity-palette.js.map +1 -1
  153. package/dist/web-mod-agent.js +5 -3
  154. package/dist/web-mod-agent.js.map +1 -1
  155. package/dist/web-mod-bundle.d.ts +5 -0
  156. package/dist/web-mod-bundle.js +6 -0
  157. package/dist/web-mod-bundle.js.map +1 -1
  158. package/dist/web-mod-store.d.ts +5 -0
  159. package/dist/web-mod-store.js +8 -1
  160. package/dist/web-mod-store.js.map +1 -1
  161. package/dist/web-prefs.d.ts +8 -0
  162. package/dist/web-prefs.js +9 -0
  163. package/dist/web-prefs.js.map +1 -1
  164. package/dist/web-preview.d.ts +6 -2
  165. package/dist/web-preview.js +4 -2
  166. package/dist/web-preview.js.map +1 -1
  167. package/dist/web-session.d.ts +15 -7
  168. package/dist/web-session.js +25 -8
  169. package/dist/web-session.js.map +1 -1
  170. package/dist/web-settings-tools.d.ts +69 -0
  171. package/dist/web-settings-tools.js +247 -0
  172. package/dist/web-settings-tools.js.map +1 -0
  173. package/package.json +1 -1
  174. package/templates/systemd.service.ejs +8 -0
@@ -158,7 +158,7 @@ import { ReplyButtonsController } from "./reply-buttons-controller.js";
158
158
  import { parsePoll } from "./reply-polls.js";
159
159
  import { isUploadRequest, limitRequestReceive, publicAttachment, sweepOrphanedUploads, UPLOAD_REQUEST_TIMEOUT_MS, uploadLimits, WebFileLedger } from "./web-upload.js";
160
160
  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";
161
+ import { cardPicture, handleViewRequest, humanCards, identityColors, isViewPath, personIdentityColour, profileIdentities, resolveInstanceIdentity, setPersonIdentityColour } from "./view-api.js";
162
162
  import { autoIdentityColor } from "./web-identity-color.js";
163
163
  import { AppearanceTools } from "./web-appearance-tools.js";
164
164
  import { cardVersion, resolveCard } from "./web-card.js";
@@ -178,7 +178,10 @@ import { runOnLoginShell } from "./login-shell.js";
178
178
  import { tightenInstanceDirs } from "./private-dir.js";
179
179
  import { decideSlash } from "./slash-authz.js";
180
180
  import { canonicalCommand, commandSpec, decideCommand, ruleFor } from "./command-table.js";
181
- import { runVisibilityCommand } from "./cross-instance-notice.js";
181
+ import { crossInstanceVisibility, runVisibilityCommand } from "./cross-instance-notice.js";
182
+ import { WebSettingsTools } from "./web-settings-tools.js";
183
+ import { bindGuestRequest, guestRouteDecision } from "./web-guest-access.js";
184
+ import { TrafficLog, trafficItem } from "./fleet-traffic.js";
182
185
  import { installedChannel, updateNoticeKey } from "./update-check.js";
183
186
  import { resolveInstalledAgend } from "./update-dispatch.js";
184
187
  import { resolveUpdateLaunch, openUpdateLog, updateLogPath, watchUpdateLaunch } from "./update-launch.js";
@@ -190,7 +193,7 @@ import { setLocale, detectLocale, getLocale, t } from "./locale.js";
190
193
  import { recentChatContext } from "./log-tail.js";
191
194
  import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet-control-audit.js";
192
195
  import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
193
- import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
196
+ import { ClassicChannelManager, classicWebEchoOn, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
194
197
  import { assertExplicitInstanceRemoval } from "./instance-removal.js";
195
198
  import { KNOWN_BACKENDS, validateFleetConfig } from "./config-validator.js";
196
199
  import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
@@ -209,8 +212,9 @@ import { NeedsYouHub } from "./needs-you-hub.js";
209
212
  import { WEB_CHAT_NOTICE, claimNotice, hasWebChat, releaseNotice, upgradeNoticesPath } from "./upgrade-notices.js";
210
213
  import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
211
214
  import { buildOrgChart } from "./web-org.js";
215
+ import { topMemoryHogs } from "./memory-hogs.js";
212
216
  import { GENERAL_POINTING_REFUSALS, SNOWFLAKE, hasPlaceRefs, placeRef, webRoute, webTextLinks, placeSuffix, renderPlaceRefs, stripPlaceRefs, topicUrl, worldPlaceOf } from "./topic-link.js";
213
- import { CacheService, WINDOWS } from "./cache-service.js";
217
+ import { CacheService, cacheWindow } from "./cache-service.js";
214
218
  import { runWebCommand } from "./web-commands.js";
215
219
  import { CompactWatch, launchIdentity } from "./compact-watch.js";
216
220
  import { agyLegacyAgentsFile, danglingSymlink, migrateAgyAgentsFile } from "./backend/antigravity.js";
@@ -234,7 +238,7 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
234
238
  /** A self-restart is a whole service restart; 120s is the apply budget, not this. */
235
239
  const SELF_RESTART_DEADLINE_MS = 300_000;
236
240
  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";
241
+ import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
238
242
  import { carryClaudeConversation } from "./backend/claude-carry.js";
239
243
  import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
240
244
  import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
@@ -504,6 +508,8 @@ function emojiListFilter(opts) {
504
508
  withImageUrls: flag(opts.with_image_urls),
505
509
  };
506
510
  }
511
+ /** #1920: a guest's scope until phase 2 derives one: nothing. */
512
+ const NO_GUEST_SCOPE = new Set();
507
513
  export class FleetManager {
508
514
  dataDir;
509
515
  static signalTarget = null;
@@ -774,6 +780,8 @@ export class FleetManager {
774
780
  /** #1306: the preview listener (health_port + 1 by default), and whether it is listening. */
775
781
  previewListener = null;
776
782
  previewListening = false;
783
+ /** The preview listener could not open its port: "<port>: <error code>", for the page (#2061 follow-up); null otherwise. */
784
+ previewListenError = null;
777
785
  /** The ports the preview listener was started for, and the inputs it was built from (a reload compares them). */
778
786
  previewPorts = null;
779
787
  previewInputs = "";
@@ -806,6 +814,17 @@ export class FleetManager {
806
814
  startedAt = 0;
807
815
  stormOpenNotifyTimer = null;
808
816
  memoryLogAt = null;
817
+ /**
818
+ * #2015: while host memory is elevated or critical, the processes holding the most in the fleet's service and whose
819
+ * they are (an instance, the fleet, tmux), for the pressure log, /health and the dashboard — never chat (#1712).
820
+ * Rescanned at most once a minute (async, one at a time); cleared when pressure ends.
821
+ */
822
+ memoryTop = null;
823
+ memoryTopScan = { at: null, inFlight: false, loggedAt: null };
824
+ /** The scan (a seam for tests: they give it a fake /proc). */
825
+ memoryHogScan = topMemoryHogs;
826
+ /** The fleet's own tmux session exists (only then are its panes read: a fleet that never started has none). */
827
+ tmuxSessionEnsured = false;
809
828
  memoryDebugAt = null;
810
829
  memoryLogLevel = null;
811
830
  /** #1685: the host_memory value the last status frame carried, so a change re-sends the frame. */
@@ -1079,6 +1098,26 @@ export class FleetManager {
1079
1098
  get ownerCss() {
1080
1099
  return (this.ownerCssStore ??= new OwnerCssStore(this.dataDir, this.webPrefs));
1081
1100
  }
1101
+ webSettingsToolsService;
1102
+ /**
1103
+ * #2056: Settings → Web appearance for a tool (General, a coordinator, full) — the page's own stores and checks, and the
1104
+ * page's announcement after a change (web-settings-tools.ts).
1105
+ */
1106
+ get webSettings() {
1107
+ return (this.webSettingsToolsService ??= new WebSettingsTools({
1108
+ prefs: this.webPrefs, ownerCss: this.ownerCss, mods: this.mods, effects: this.effectService,
1109
+ instances: () => [...Object.keys(this.fleetConfig?.instances ?? {}),
1110
+ ...(this.classicChannels?.getAll() ?? []).map((ch) => ch.instanceName).filter((n) => !Object.hasOwn(this.fleetConfig?.instances ?? {}, n))],
1111
+ colour: (name) => personIdentityColour(this.dataDir, name),
1112
+ setColour: (name, patch) => { const w = setPersonIdentityColour(this.dataDir, name, patch); if (w.ok)
1113
+ this.identityChanged(); return w; },
1114
+ placements: (name) => this.themeStore.get(name).placements.effects.map((e) => (typeof e === "string" ? { id: e } : e)),
1115
+ announce: () => { try {
1116
+ this.emitSseEvent("status", this.getUiStatus());
1117
+ }
1118
+ catch { /* the next status frame carries it */ } },
1119
+ }));
1120
+ }
1082
1121
  get webPrefs() {
1083
1122
  // #1664: a favorite must be an instance of this fleet now — fleet.yaml's (own keys) or a ClassicBot room's.
1084
1123
  return (this.webPrefsStore ??= new WebPrefsStore(this.dataDir, {
@@ -1573,6 +1612,18 @@ export class FleetManager {
1573
1612
  }
1574
1613
  }
1575
1614
  });
1615
+ // #1918: the org canvas's traffic — from the row this listener's events already name, never a probe of its own.
1616
+ // The row as it is now (it has already changed when the event comes): its state, not the event's, is the truth.
1617
+ outbox.on("state", (event) => {
1618
+ const row = event.deliveryId ? outbox.get(event.deliveryId) : undefined;
1619
+ if (row)
1620
+ this.publishTraffic(row);
1621
+ });
1622
+ outbox.on("consumed", (event) => {
1623
+ const row = event.deliveryId ? outbox.get(event.deliveryId) : undefined;
1624
+ if (row)
1625
+ this.publishTraffic(row, "consumed");
1626
+ });
1576
1627
  outbox.on("generation_recovered", () => this.scheduleDeliveryOutboxPump());
1577
1628
  this.replyObligationTimer = setInterval(() => measureSyncWork("fleet.replyObligationSweep", () => this.sweepReplyObligations()), REPLY_OBLIGATION_SWEEP_MS);
1578
1629
  this.replyObligationTimer.unref?.();
@@ -1594,6 +1645,40 @@ export class FleetManager {
1594
1645
  });
1595
1646
  this.scheduleDeliveryOutboxPump();
1596
1647
  }
1648
+ /** #1918: cross-instance traffic, as the org canvas draws it (fleet-traffic.ts). */
1649
+ trafficLog = new TrafficLog();
1650
+ trafficMemo = null;
1651
+ /** One delivery row's traffic item, kept for the snapshot and sent to the open pages when it is news. */
1652
+ publishTraffic(row, state) {
1653
+ try {
1654
+ const item = trafficItem(row, this.fleetConfig, new Date().toISOString(), state);
1655
+ const news = item ? this.trafficLog.note(item) : null;
1656
+ if (news) {
1657
+ this.trafficMemo = null;
1658
+ this.emitSseEvent("traffic", news);
1659
+ }
1660
+ }
1661
+ catch (err) {
1662
+ this.logger.debug({ err }, "traffic: an item was not published");
1663
+ }
1664
+ }
1665
+ /**
1666
+ * GET /ui/traffic: what is in flight now (the outbox's pending rows) and what finished in the last two minutes, at
1667
+ * most TRAFFIC_SNAPSHOT_MAX items. Worked out at most once every two seconds, so the public link's polling (or any
1668
+ * number of pages) costs one indexed read per two seconds. Visibility is applied as of now.
1669
+ */
1670
+ trafficSnapshot() {
1671
+ const nowMs = performance.now();
1672
+ if (this.trafficMemo && nowMs - this.trafficMemo.atMs < 2_000)
1673
+ return this.trafficMemo.value;
1674
+ const now = new Date().toISOString();
1675
+ const pending = (this.deliveryOutbox?.listPending() ?? [])
1676
+ .map((r) => trafficItem(r, this.fleetConfig, r.createdAt ?? now)).filter((i) => i !== null);
1677
+ const visible = (name) => crossInstanceVisibility(this.fleetConfig, name) !== "hidden";
1678
+ const items = this.trafficLog.snapshot(pending).filter((i) => visible(i.from) && visible(i.to));
1679
+ this.trafficMemo = { atMs: nowMs, value: { now, items } };
1680
+ return this.trafficMemo.value;
1681
+ }
1597
1682
  /** Synchronous SQLite admission; the caller may acknowledge only after this returns. */
1598
1683
  admitDurableDelivery(input) {
1599
1684
  const outbox = this.deliveryOutbox;
@@ -1606,6 +1691,8 @@ export class FleetManager {
1606
1691
  }
1607
1692
  const sourceKey = `mcp:${input.sourceInstance}:${input.operationId}:${input.targetInstance}:${input.targetSession ?? ""}:${input.kind}`;
1608
1693
  const admitted = outbox.admit({ ...input, sourceDaemonBootId, sourceKey });
1694
+ if (admitted.inserted)
1695
+ this.publishTraffic(admitted.delivery);
1609
1696
  if (admitted.inserted)
1610
1697
  this.logger.info({
1611
1698
  deliveryId: admitted.delivery.deliveryId,
@@ -1979,17 +2066,58 @@ export class FleetManager {
1979
2066
  this.logger.info({ hostMemory: snapshot }, "Host memory recovered");
1980
2067
  }
1981
2068
  this.memoryLogLevel = snapshot.level;
2069
+ if (this.memoryTop) {
2070
+ this.memoryTop = null;
2071
+ this.memoryTopScan.loggedAt = null;
2072
+ this.emitSseEvent("status", this.getUiStatus());
2073
+ }
1982
2074
  return;
1983
2075
  }
2076
+ if (snapshot.level === "elevated" || snapshot.level === "critical")
2077
+ this.refreshMemoryTop(snapshot);
1984
2078
  if (changed || this.memoryLogAt === null || now - this.memoryLogAt >= 10 * 60_000) {
1985
2079
  this.memoryLogAt = now;
1986
- this.logger.warn({ hostMemory: snapshot }, snapshot.level === "critical"
2080
+ this.logger.warn({ hostMemory: snapshot, ...(this.memoryTop ? { topProcesses: this.memoryTop.processes } : {}) }, snapshot.level === "critical"
1987
2081
  ? "Host memory critical — deferring new CLI spawns"
1988
2082
  : snapshot.level === "elevated" ? "Host memory pressure — slowing new CLI spawns"
1989
2083
  : "Host memory sample unavailable — slowing new CLI spawns");
1990
2084
  }
1991
2085
  this.memoryLogLevel = snapshot.level;
1992
2086
  }
2087
+ /**
2088
+ * #2015: rescan the largest processes (at most once a minute, one at a time). The first result of an episode, and
2089
+ * then at most every 10 minutes, is logged with the pressure; /health and the dashboard read the latest. Never chat.
2090
+ */
2091
+ refreshMemoryTop(snapshot) {
2092
+ const scan = this.memoryTopScan;
2093
+ const now = performance.now();
2094
+ if (scan.inFlight || (scan.at !== null && now - scan.at < 60_000))
2095
+ return;
2096
+ scan.inFlight = true;
2097
+ scan.at = now;
2098
+ void (async () => {
2099
+ const session = getTmuxSession();
2100
+ const [tmuxServerPid, panes] = this.tmuxSessionEnsured
2101
+ ? await Promise.all([TmuxManager.getServerPid(session), TmuxManager.listPanePids(session)])
2102
+ : [null, []];
2103
+ const processes = await this.memoryHogScan({
2104
+ fleetPid: process.pid, tmuxServerPid, panes,
2105
+ isInstance: name => this.daemons.has(name) || Object.hasOwn(this.fleetConfig?.instances ?? {}, name),
2106
+ }, 3);
2107
+ // Pressure may have ended while the scan ran: nothing is kept then.
2108
+ if (this.shuttingDown || processes.length === 0 || this.hostMemoryIndicator() === null)
2109
+ return;
2110
+ const before = JSON.stringify(this.memoryTop?.processes ?? null);
2111
+ this.memoryTop = { at: new Date().toISOString(), processes };
2112
+ if (scan.loggedAt === null || performance.now() - scan.loggedAt >= 10 * 60_000) {
2113
+ scan.loggedAt = performance.now();
2114
+ this.logger.warn({ hostMemory: snapshot, topProcesses: processes }, "Host memory pressure — largest processes in the fleet");
2115
+ }
2116
+ if (JSON.stringify(processes) !== before)
2117
+ this.emitSseEvent("status", this.getUiStatus());
2118
+ })().catch(err => this.logger.debug({ err }, "Largest-process scan failed"))
2119
+ .finally(() => { scan.inFlight = false; });
2120
+ }
1993
2121
  /** Wire the one fleet-wide storm into notification and recovery surfaces. */
1994
2122
  bindStormWindowEvents() {
1995
2123
  this.stormWindow.on("opened", (snapshot) => {
@@ -3436,7 +3564,8 @@ export class FleetManager {
3436
3564
  * for `instance` (#1320 part B). Per entry: the channel's own adapter, no
3437
3565
  * thread, mention suppression on. Returns the number of channels posted
3438
3566
  * 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.
3567
+ * the web send. Only entries classicWebEchoOn admits are touched (#2054: unset follows web.echo_to_channel; an explicit
3568
+ * `web_echo: false` never echoes).
3440
3569
  *
3441
3570
  * Every copy is fenced at start time: an entry whose explicit world is
3442
3571
  * gone is skipped (only legacy adapterId-less entries fall back to the
@@ -3463,7 +3592,7 @@ export class FleetManager {
3463
3592
  }
3464
3593
  }
3465
3594
  const entry = (this.classicChannels?.getAll() ?? [])
3466
- .find(candidate => candidate.instanceName === instance && candidate.webEcho === true
3595
+ .find(candidate => candidate.instanceName === instance && classicWebEchoOn(candidate.webEcho, this.fleetConfig?.web?.echo_to_channel)
3467
3596
  && !handled.has(`${candidate.channelId}#${candidate.adapterId ?? ""}`));
3468
3597
  if (!entry)
3469
3598
  break;
@@ -6266,6 +6395,7 @@ export class FleetManager {
6266
6395
  const { getTmuxSocketName: getSocket } = await import("./paths.js");
6267
6396
  TmuxManager.setSocketName(getSocket());
6268
6397
  await TmuxManager.ensureSession(getTmuxSession());
6398
+ this.tmuxSessionEnsured = true;
6269
6399
  // Pre-flight (advisory, fire-and-forget): warm the per-backend auth cache
6270
6400
  // before instances spawn, so a CLI that comes up on its sign-in screen gets
6271
6401
  // an instant 🔑 verdict instead of decaying into crash/MCP-died noise.
@@ -7089,7 +7219,8 @@ export class FleetManager {
7089
7219
  adapters: { total: this.adapterState.size, connected, states, details },
7090
7220
  startupComplete: this.startupComplete,
7091
7221
  memory: readFleetMemory(),
7092
- hostMemory,
7222
+ hostMemory: this.memoryTop && (hostMemory.level === "elevated" || hostMemory.level === "critical")
7223
+ ? { ...hostMemory, topProcesses: this.memoryTop.processes, topProcessesAt: this.memoryTop.at } : hostMemory,
7093
7224
  problems,
7094
7225
  };
7095
7226
  }
@@ -12497,8 +12628,14 @@ export class FleetManager {
12497
12628
  * (cache-service.ts) — never a vendor call, never a synchronous transcript read.
12498
12629
  */
12499
12630
  cacheReport(window) {
12500
- const w = Object.prototype.hasOwnProperty.call(WINDOWS, window) ? window : "7d";
12501
- this.cacheService ??= new CacheService({
12631
+ return this.cacheServiceFor().report(cacheWindow(window));
12632
+ }
12633
+ /** #2001: get_cache_report's — the same report, as last worked out (reused for a minute, until the next pass). */
12634
+ cacheReportPublished(window) {
12635
+ return this.cacheServiceFor().published(cacheWindow(window));
12636
+ }
12637
+ cacheServiceFor() {
12638
+ return this.cacheService ??= new CacheService({
12502
12639
  instances: () => this.cacheInstances(),
12503
12640
  claudeProjectsDir: () => join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects"),
12504
12641
  claudeKey: claudeProjectKey,
@@ -12507,7 +12644,6 @@ export class FleetManager {
12507
12644
  metaPath: join(this.dataDir, "cache-codex-rollouts.json"),
12508
12645
  log: (level, msg, extra) => this.logger[level](extra ?? {}, msg),
12509
12646
  });
12510
- return this.cacheService.report(w);
12511
12647
  }
12512
12648
  /** The web's Acknowledge (any item; a signed-in session is fleet-admin level). */
12513
12649
  acknowledgeNeedsItem(id, principal) {
@@ -13029,11 +13165,12 @@ export class FleetManager {
13029
13165
  };
13030
13166
  };
13031
13167
  const choicesFromCache = (instance) => {
13032
- const cached = this.readCliEnv(this.backendNameForInstance(instance)); // the cached catalog only: never a probe here
13168
+ const backend = this.backendNameForInstance(instance);
13169
+ const cached = this.readCliEnv(backend, this.catalogProfile(backend, instance)); // the cached catalog only: never a probe here
13033
13170
  if (!cached?.models.length)
13034
13171
  return null;
13035
13172
  const current = this.resolveInstanceModel(instance).model;
13036
- return { current, options: cached.models.map(o => ({ id: o.id, label: this.modelChoiceLabel(o, current) })) };
13173
+ return { current, options: this.withCurrentModel(cached.models, current).map(o => ({ id: o.id, label: this.modelChoiceLabel(o, current) })) };
13037
13174
  };
13038
13175
  return runWebCommand({
13039
13176
  // Own entries only: a name like "__proto__" or "constructor" must not be found on the prototype (#1476 review).
@@ -15812,7 +15949,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15812
15949
  /** #1806: null on a switch INTO claude-code — the configured model then belongs to the backend being left. */
15813
15950
  configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model) {
15814
15951
  const aliases = ["default", "sonnet", "opus", "haiku", "opusplan", "Fable"];
15815
- const api = (this.readCliEnv("claude-code")?.apiModels ?? []).map(m => m.id);
15952
+ const api = (this.readCliEnv("claude-code", this.catalogProfile("claude-code", instanceName))?.apiModels ?? []).map(m => m.id);
15816
15953
  const lower = model.trim().toLowerCase();
15817
15954
  const bare = lower.replace(/\[1m\]$/, "");
15818
15955
  const known = aliases.some(a => a.toLowerCase() === bare)
@@ -15840,13 +15977,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15840
15977
  : undefined;
15841
15978
  return classic ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
15842
15979
  }
15843
- cliEnvPath(backend) {
15844
- return join(getAgendHome(), "cli-env", `${backend.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
15980
+ /**
15981
+ * #2072: a catalog is the account's, so a credential profile has its own. Only claude-code's probe reads an account
15982
+ * catalog through a profile's login (its `GET /v1/models` with the profile's token); other backends' probes read the
15983
+ * process environment, so their catalog stays the backend's own.
15984
+ */
15985
+ catalogProfile(backend, instanceName) {
15986
+ return backend === "claude-code" && instanceName ? this.credentialProfileOf(instanceName, backend) : null;
15987
+ }
15988
+ cliEnvPath(backend, profile = null) {
15989
+ const safe = (s) => s.replace(/[^A-Za-z0-9._-]/g, "_");
15990
+ return join(getAgendHome(), "cli-env", profile ? `${safe(backend)}@${safe(profile)}.json` : `${safe(backend)}.json`);
15845
15991
  }
15846
- /** Read the cached CLI env for a backend, or null if missing/stale/unparseable. */
15847
- readCliEnv(backend) {
15992
+ /** Read the cached CLI env for a backend (a credential profile's own, when given), or null if missing/stale/unparseable. */
15993
+ readCliEnv(backend, profile = null) {
15848
15994
  try {
15849
- const env = JSON.parse(readFileSync(this.cliEnvPath(backend), "utf-8"));
15995
+ const env = JSON.parse(readFileSync(this.cliEnvPath(backend, profile), "utf-8"));
15850
15996
  if (typeof env?.probedAt === "number" && Date.now() - env.probedAt < CLI_ENV_TTL_MS)
15851
15997
  return env;
15852
15998
  }
@@ -15889,10 +16035,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15889
16035
  * previous list, never stall the command that asked for it.
15890
16036
  */
15891
16037
  async probeBackendBounded(backend, opts = {}) {
15892
- // #725: single-flight per backend. Explicit vendor refreshes remain a
16038
+ // #725: single-flight per backend (#2072: per backend and credential profile). Explicit vendor refreshes remain a
15893
16039
  // separate flight from an ordinary probe, while sysinfo can join either.
15894
- const regularFlight = this.pendingCliEnvProbes.get(backend);
15895
- const vendorFlight = this.pendingVendorCliEnvProbes.get(backend);
16040
+ const profile = opts.profile ?? null;
16041
+ const key = profile ? `${backend}@${profile}` : backend;
16042
+ const regularFlight = this.pendingCliEnvProbes.get(key);
16043
+ const vendorFlight = this.pendingVendorCliEnvProbes.get(key);
15896
16044
  if (opts.refreshVendorCatalog && vendorFlight)
15897
16045
  return vendorFlight;
15898
16046
  if (!opts.refreshVendorCatalog && (vendorFlight || regularFlight))
@@ -15903,25 +16051,26 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15903
16051
  const epoch = this.cliEnvProbeEpoch;
15904
16052
  const input = {
15905
16053
  mode: "env", backend, instanceDir,
15906
- config: { workingDirectory: "", instanceDir, instanceName: `probe-${backend}`, mcpServers: {} },
16054
+ config: { workingDirectory: "", instanceDir, instanceName: `probe-${backend}`, mcpServers: {},
16055
+ ...(profile ? { credentialStoreHome: credentialProfileStoreHome(getAgendHome(), backend, profile) } : {}) },
15907
16056
  refreshVendorCatalog: opts.refreshVendorCatalog === true,
15908
16057
  };
15909
- const bounded = this.runBackendProbeWorker(`global:${backend}`, input).then(probed => {
16058
+ const bounded = this.runBackendProbeWorker(`global:${key}`, input).then(probed => {
15910
16059
  if (!probed || Array.isArray(probed) || this.shuttingDown || epoch !== this.cliEnvProbeEpoch)
15911
16060
  return null;
15912
16061
  try {
15913
- return this.persistCliEnvProbeResult(backend, probed);
16062
+ return this.persistCliEnvProbeResult(backend, probed, profile);
15914
16063
  }
15915
16064
  catch (err) {
15916
- this.logger.warn({ backend, err }, "CLI env cache write failed");
16065
+ this.logger.warn({ backend, profile, err }, "CLI env cache write failed");
15917
16066
  return null;
15918
16067
  }
15919
16068
  }).finally(() => {
15920
16069
  const flights = opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes;
15921
- if (flights.get(backend) === bounded)
15922
- flights.delete(backend);
16070
+ if (flights.get(key) === bounded)
16071
+ flights.delete(key);
15923
16072
  });
15924
- (opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(backend, bounded);
16073
+ (opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(key, bounded);
15925
16074
  return bounded;
15926
16075
  }
15927
16076
  stopCliEnvProbes() {
@@ -16036,12 +16185,21 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16036
16185
  modelDisplayForInstance(instanceName) {
16037
16186
  return this.resolveInstanceModel(instanceName).display;
16038
16187
  }
16188
+ /**
16189
+ * #2072: the model an instance runs is always among its choices — one its CLI's catalog does not list (an older
16190
+ * name, a custom provider's, a typed one) is shown first, marked, so the menu never hides what is running.
16191
+ */
16192
+ withCurrentModel(options, currentModel) {
16193
+ if (!currentModel || currentModel === "default" || options.some(o => o.id === currentModel))
16194
+ return options;
16195
+ return [{ id: currentModel, label: currentModel, description: t("model.not_listed") }, ...options];
16196
+ }
16039
16197
  modelChoiceLabel(option, currentModel) {
16040
16198
  const label = option.description ? `${option.label} — ${option.description}` : option.label;
16041
16199
  return option.id === currentModel ? `✓ ${label}` : label;
16042
16200
  }
16043
16201
  /** Preserve the existing cache merge rules for worker probes. */
16044
- persistCliEnvProbeResult(backend, probed) {
16202
+ persistCliEnvProbeResult(backend, probed, profile = null) {
16045
16203
  const env = { backend, probedAt: Date.now(), ...probed };
16046
16204
  // An empty result must never overwrite a catalog we already have. Some
16047
16205
  // probes hit the network (`agy models` fetches, 5s cap), so a slow moment
@@ -16050,14 +16208,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16050
16208
  // antigravity cache replaced by an empty one. Keep the known models and
16051
16209
  // let the fresher currentModel/version through.
16052
16210
  if (!env.models?.length) {
16053
- const previous = this.readCliEnv(backend);
16211
+ const previous = this.readCliEnv(backend, profile);
16054
16212
  if (previous?.models?.length)
16055
16213
  env.models = previous.models;
16056
16214
  }
16057
16215
  // Same protection for the extended catalog: one offline moment must not
16058
16216
  // blank a good account list for the whole cache TTL.
16059
16217
  if (!env.apiModels?.length) {
16060
- const previous = this.readCliEnv(backend);
16218
+ const previous = this.readCliEnv(backend, profile);
16061
16219
  if (previous?.apiModels?.length)
16062
16220
  env.apiModels = previous.apiModels;
16063
16221
  }
@@ -16065,12 +16223,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16065
16223
  // keeps the cached levels only for that same binary: both versions known and equal, cache still valid. A help
16066
16224
  // that was read and lists none ([]) is an answer and is written as is, so the fallback applies.
16067
16225
  if (env.effortLevels === undefined) {
16068
- const previous = this.readCliEnv(backend);
16226
+ const previous = this.readCliEnv(backend, profile);
16069
16227
  if (previous?.effortLevels && previous.version && env.version && previous.version === env.version) {
16070
16228
  env.effortLevels = previous.effortLevels;
16071
16229
  }
16072
16230
  }
16073
- const path = this.cliEnvPath(backend);
16231
+ const path = this.cliEnvPath(backend, profile);
16074
16232
  mkdirSync(dirname(path), { recursive: true });
16075
16233
  writeFileSync(path, JSON.stringify(env, null, 2));
16076
16234
  return env;
@@ -16092,6 +16250,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16092
16250
  // indefinitely — each is wrapped by CLI_ENV_PROBE_DEADLINE_MS.
16093
16251
  for (const b of backends)
16094
16252
  void this.probeBackendBounded(b);
16253
+ // #2072: each Claude credential profile in use has its own account catalog. Only fleet.yaml's agents are walked
16254
+ // here; a ClassicBot room on a profile is probed on its first /model (catalogProfile knows rooms too).
16255
+ const profiles = new Set();
16256
+ for (const name of Object.keys(this.fleetConfig?.instances ?? {})) {
16257
+ if (this.backendNameForInstance(name) !== "claude-code")
16258
+ continue;
16259
+ const profile = this.catalogProfile("claude-code", name);
16260
+ if (profile)
16261
+ profiles.add(profile);
16262
+ }
16263
+ for (const profile of profiles)
16264
+ void this.probeBackendBounded("claude-code", { profile });
16095
16265
  }
16096
16266
  /** Best-effort model list for `/model`: cached CLI env first, else live probe. Never throws. */
16097
16267
  async getModelOptions(instanceName, refresh = false, onLiveProbe) {
@@ -16105,7 +16275,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16105
16275
  */
16106
16276
  async getModelOptionsWithSource(instanceName, opts = {}) {
16107
16277
  const backendName = this.backendNameForInstance(instanceName);
16108
- const cached = this.readCliEnv(backendName);
16278
+ const profile = this.catalogProfile(backendName, instanceName);
16279
+ const cached = this.readCliEnv(backendName, profile);
16109
16280
  if (!opts.refresh && cached?.models.length && !this.cliEnvNeedsRefresh(cached)) {
16110
16281
  return { models: cached.models, source: "cache" };
16111
16282
  }
@@ -16116,7 +16287,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16116
16287
  // Stale, missing, or a forced refresh → probe live (also refreshes the cache).
16117
16288
  // A newly released model is invisible until this runs, which is why staleness
16118
16289
  // triggers it rather than waiting for the 24h hard expiry or a cold start.
16119
- const env = await this.probeBackendBounded(backendName, { refreshVendorCatalog: opts.refreshVendorCatalog });
16290
+ const env = await this.probeBackendBounded(backendName, { refreshVendorCatalog: opts.refreshVendorCatalog, profile });
16120
16291
  if (env?.models.length)
16121
16292
  return { models: env.models, source: "live" };
16122
16293
  // Probe failed or timed out: the previous list is still the best answer.
@@ -16131,7 +16302,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16131
16302
  modelMenuChoices(instanceName, nonce, options, currentModel) {
16132
16303
  const isClaude = this.backendNameForInstance(instanceName) === "claude-code";
16133
16304
  const choices = [{ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:__refresh__`, label: t("model.refresh") }];
16134
- for (const o of options.slice(0, isClaude ? 23 : 24)) {
16305
+ for (const o of this.withCurrentModel(options, currentModel).slice(0, isClaude ? 23 : 24)) {
16135
16306
  choices.push({ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`, label: this.modelChoiceLabel(o, currentModel) });
16136
16307
  }
16137
16308
  if (isClaude)
@@ -16538,16 +16709,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16538
16709
  }
16539
16710
  }
16540
16711
  /** Cached-or-live account catalog behind "/model → more models" (claude). */
16541
- async claudeApiModelOptions() {
16542
- const cached = this.readCliEnv("claude-code");
16712
+ async claudeApiModelOptions(instanceName) {
16713
+ // #2072: the account behind THIS instance — its credential profile's catalog, else the default login's.
16714
+ const profile = this.catalogProfile("claude-code", instanceName);
16715
+ const cached = this.readCliEnv("claude-code", profile);
16543
16716
  if (cached?.apiModels?.length)
16544
16717
  return cached.apiModels;
16545
- const env = await this.probeBackendBounded("claude-code");
16718
+ const env = await this.probeBackendBounded("claude-code", { profile });
16546
16719
  return env?.apiModels ?? [];
16547
16720
  }
16548
16721
  /** Replace a consumed "/model" menu with the full account catalog tier. */
16549
16722
  async expandClaudeModelMenu(pending) {
16550
- const expanded = await this.claudeApiModelOptions();
16723
+ const expanded = await this.claudeApiModelOptions(pending.instanceName);
16551
16724
  if (!expanded.length) {
16552
16725
  await pending.respond(t("model.more_unavailable")).catch(() => { });
16553
16726
  return;
@@ -19842,6 +20015,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19842
20015
  this.rejectedHostsLogged.add(name);
19843
20016
  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)");
19844
20017
  }
20018
+ /**
20019
+ * #1920: the instances a guest session may see — derived on every request, never stored. Phase 2 derives it from
20020
+ * the account's chat access (and an admin's naming for an open connection); until then a guest sees nothing.
20021
+ */
20022
+ guestScope(_owner) {
20023
+ return NO_GUEST_SCOPE;
20024
+ }
19845
20025
  /** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
19846
20026
  dispatchWebHttp(req, res, port, closing = false) {
19847
20027
  try {
@@ -19902,6 +20082,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19902
20082
  res.end(JSON.stringify({ error: decision.message }));
19903
20083
  return;
19904
20084
  }
20085
+ // #1920: a guest reaches only GUEST_ROUTES, within the scope derived for it now — default deny, before any
20086
+ // handler, on this listener and the public link's gateway alike (both come through here).
20087
+ if (decision.via === "session" && decision.session.tier === "guest") {
20088
+ const owner = decision.session.owner;
20089
+ const verdict = guestRouteDecision(req.method ?? "GET", parsedUrl, this.guestScope(owner));
20090
+ if (verdict.kind === "deny") {
20091
+ res.writeHead(verdict.status);
20092
+ res.end(JSON.stringify({ error: verdict.error }));
20093
+ return;
20094
+ }
20095
+ // Phase 1b: the handlers that answer per recipient (the stream, the poll, a file) know it is a guest's, and
20096
+ // ask its scope anew each time.
20097
+ const admitted = decision.session;
20098
+ bindGuestRequest(req, { owner, scope: () => this.guestScope(owner),
20099
+ current: () => !!this.webSessions && !!this.webToken && this.webSessions.isCurrent(admitted, tokenEpoch(this.webToken)) });
20100
+ }
19905
20101
  }
19906
20102
  if (req.method === "GET" && req.url === "/health") {
19907
20103
  const health = this.getFleetHealth();
@@ -20292,6 +20488,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20292
20488
  this.logger.warn({ code: err.code, port: settings.port }, "Preview listener unavailable; HTML previews are off");
20293
20489
  this.previewListening = false;
20294
20490
  this.previewListener = null;
20491
+ this.previewListenError = `${settings.port}: ${err.code ?? err.message}`;
20295
20492
  });
20296
20493
  // An optional listener never takes the fleet down: a port Node refuses outright throws here, synchronously.
20297
20494
  try {
@@ -20302,6 +20499,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20302
20499
  this.logger.warn({ err: err.message, port: settings.port }, "Preview listener cannot listen; HTML previews are off");
20303
20500
  this.previewListening = false;
20304
20501
  this.previewListener = null;
20502
+ this.previewListenError = `${settings.port}: ${err.code ?? err.message}`;
20305
20503
  }
20306
20504
  }
20307
20505
  /** What a reload may change hot: web.preview, and the dashboard names (the shim's allow-list and frame-ancestors). */
@@ -20322,6 +20520,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20322
20520
  }
20323
20521
  stopPreviewListener() {
20324
20522
  this.previewListening = false;
20523
+ this.previewListenError = null;
20325
20524
  this.previewListener?.close();
20326
20525
  this.previewListener = null;
20327
20526
  }
@@ -20329,10 +20528,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20329
20528
  previewForUi(hostHeader, secure) {
20330
20529
  const listener = this.previewListening ? this.previewListener : null;
20331
20530
  const decided = previewAvailability(listener ? listener.settings : null, hostHeader, secure, listener ? listener.origins : undefined);
20531
+ // Previews are on, but no listener answers: say why — its port could not be opened (the error, never "turned off"),
20532
+ // or it is not listening yet.
20332
20533
  if (!listener && this.fleetConfig?.web?.preview !== false)
20333
- decided.reason = "Previews are not available on this fleet right now.";
20534
+ Object.assign(decided, this.previewNotListening());
20334
20535
  return { ...decided, boot: listener ? listener.bootId : null };
20335
20536
  }
20537
+ previewNotListening() {
20538
+ const failed = this.previewListenError;
20539
+ return failed
20540
+ ? { 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.` }
20541
+ : { code: "notListening", reason: "Previews are not available on this fleet right now." };
20542
+ }
20336
20543
  /**
20337
20544
  * #1723: for one page of the public link (exposure `exposureId`, at `dashboardOrigin`): its own preview tunnel's
20338
20545
  * origin once up, "starting" until then, why it failed if it did — or a configured web.preview_origin, used instead.
@@ -20347,8 +20554,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20347
20554
  const configured = typeof web?.preview_origin === "string" ? normalizeOrigin(web.preview_origin) : null;
20348
20555
  if (configured) {
20349
20556
  const listener = this.previewListening ? this.previewListener : null;
20350
- if (!listener)
20351
- return off("publicLink", "Previews are not available on this fleet right now.");
20557
+ if (!listener) {
20558
+ const why = this.previewNotListening();
20559
+ return off(why.code, why.reason, why.detail ? { detail: why.detail } : {});
20560
+ }
20352
20561
  if (!configured.startsWith("https:"))
20353
20562
  return off("needHttps", "Previews need an https web.preview_origin when the dashboard is reached over https.");
20354
20563
  return { dashboardOrigin, previewOrigin: configured, reason: null, code: null, boot: listener.bootId, frameSrc: `${configured}/frame` };
@@ -20488,6 +20697,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20488
20697
  // #1685: host memory pressure while it slows or holds new agents (the dashboard's indicator; macOS elevated is
20489
20698
  // shown here rather than in chat). null when normal, unknown or advisory.
20490
20699
  host_memory: this.hostMemoryIndicator(),
20700
+ // #2015: the largest processes while it lasts (name, pid, RSS, and whose: an instance, the fleet or tmux).
20701
+ ...(this.hostMemoryIndicator() && this.memoryTop ? { host_memory_top: this.memoryTop.processes } : {}),
20491
20702
  };
20492
20703
  }
20493
20704
  }