@songsid/agend 2.3.0-beta.2 → 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 (122) 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.js +10 -5
  4. package/dist/auth-api.js.map +1 -1
  5. package/dist/backend/claude-code.d.ts +2 -2
  6. package/dist/backend/claude-code.js +5 -4
  7. package/dist/backend/claude-code.js.map +1 -1
  8. package/dist/backend/types.d.ts +5 -0
  9. package/dist/backend/types.js.map +1 -1
  10. package/dist/channel/mcp-tools.js +4 -0
  11. package/dist/channel/mcp-tools.js.map +1 -1
  12. package/dist/classic-channel-manager.d.ts +8 -3
  13. package/dist/classic-channel-manager.js +10 -3
  14. package/dist/classic-channel-manager.js.map +1 -1
  15. package/dist/delivery-outbox.js +5 -0
  16. package/dist/delivery-outbox.js.map +1 -1
  17. package/dist/fleet-manager.d.ts +68 -3
  18. package/dist/fleet-manager.js +247 -41
  19. package/dist/fleet-manager.js.map +1 -1
  20. package/dist/fleet-traffic.d.ts +38 -0
  21. package/dist/fleet-traffic.js +96 -0
  22. package/dist/fleet-traffic.js.map +1 -0
  23. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +13 -5
  24. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -1
  25. package/dist/general-knowledge/skills/web-appearance/SKILL.md +39 -4
  26. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +398 -0
  27. package/dist/locale.js +6 -4
  28. package/dist/locale.js.map +1 -1
  29. package/dist/memory-hogs.d.ts +27 -0
  30. package/dist/memory-hogs.js +124 -0
  31. package/dist/memory-hogs.js.map +1 -0
  32. package/dist/outbound-handlers.d.ts +2 -0
  33. package/dist/outbound-handlers.js +40 -1
  34. package/dist/outbound-handlers.js.map +1 -1
  35. package/dist/outbound-schemas.d.ts +88 -0
  36. package/dist/outbound-schemas.js +50 -0
  37. package/dist/outbound-schemas.js.map +1 -1
  38. package/dist/public-web-gateway.js +1 -1
  39. package/dist/public-web-gateway.js.map +1 -1
  40. package/dist/tmux-manager.d.ts +5 -0
  41. package/dist/tmux-manager.js +16 -0
  42. package/dist/tmux-manager.js.map +1 -1
  43. package/dist/tool-permissions.js +4 -0
  44. package/dist/tool-permissions.js.map +1 -1
  45. package/dist/types.d.ts +1 -1
  46. package/dist/ui/app-effects.js +33 -9
  47. package/dist/ui/chat-effects.js +62 -4
  48. package/dist/ui/chat-strings.js +40 -4
  49. package/dist/ui/chat-thread.js +13 -5
  50. package/dist/ui/effect-trial-panel.js +19 -16
  51. package/dist/ui/fleet-canvas-layout.js +111 -0
  52. package/dist/ui/fleet-canvas.js +489 -0
  53. package/dist/ui/fleet-org.js +27 -12
  54. package/dist/ui/fleet-traffic.js +224 -0
  55. package/dist/ui/panel-chat.js +8 -2
  56. package/dist/ui/panel-details.js +2 -1
  57. package/dist/ui/panel-fleet.js +17 -5
  58. package/dist/ui/panel-settings.js +3 -2
  59. package/dist/ui/preview.js +3 -2
  60. package/dist/ui/settings-appearance.js +34 -18
  61. package/dist/ui/settings-dialogs.js +3 -6
  62. package/dist/ui/settings-effects.js +4 -3
  63. package/dist/ui/settings-learn.js +25 -0
  64. package/dist/ui/settings-model.js +6 -2
  65. package/dist/ui/settings-mods.js +4 -3
  66. package/dist/ui/settings-owner-effects.js +8 -8
  67. package/dist/ui/settings-strings.js +34 -38
  68. package/dist/ui/shared/app-i18n.js +2 -2
  69. package/dist/ui/shared/app-prefs.js +1 -1
  70. package/dist/ui/shared/app-shell.js +23 -1
  71. package/dist/ui/shared/app-store.js +12 -1
  72. package/dist/ui/shared/app-stream.js +2 -1
  73. package/dist/ui/shared/app.css +152 -10
  74. package/dist/ui/shared/effect-app-runtime.js +1 -1
  75. package/dist/ui/shared/effect-frame.js +10 -5
  76. package/dist/ui/shared/effect-geometry.js +24 -9
  77. package/dist/ui/shared/effect-policy.js +2 -1
  78. package/dist/ui/shared/effect-registry.js +71 -17
  79. package/dist/ui/shared/effect-trial-shadow.js +62 -0
  80. package/dist/ui/shared/effect-trial-stage.js +15 -9
  81. package/dist/ui/shared/effect-trial.css +40 -0
  82. package/dist/ui/shared/identity-card.js +5 -2
  83. package/dist/ui/shared/identity-color.js +3 -3
  84. package/dist/ui/shared/instance-nav.js +20 -4
  85. package/dist/ui/shared/panel-view.js +2 -2
  86. package/dist/ui/shared/view-strings.js +16 -0
  87. package/dist/view-api.d.ts +21 -0
  88. package/dist/view-api.js +28 -0
  89. package/dist/view-api.js.map +1 -1
  90. package/dist/web-api.d.ts +5 -6
  91. package/dist/web-api.js +57 -10
  92. package/dist/web-api.js.map +1 -1
  93. package/dist/web-appearance-view.d.ts +10 -0
  94. package/dist/web-appearance-view.js +77 -8
  95. package/dist/web-appearance-view.js.map +1 -1
  96. package/dist/web-auth.js +1 -1
  97. package/dist/web-auth.js.map +1 -1
  98. package/dist/web-effect-trials.d.ts +2 -2
  99. package/dist/web-effect-trials.js +3 -3
  100. package/dist/web-effect-trials.js.map +1 -1
  101. package/dist/web-guest-access.d.ts +67 -0
  102. package/dist/web-guest-access.js +126 -0
  103. package/dist/web-guest-access.js.map +1 -0
  104. package/dist/web-identity-palette.d.ts +1 -1
  105. package/dist/web-identity-palette.js +2 -2
  106. package/dist/web-identity-palette.js.map +1 -1
  107. package/dist/web-mod-store.d.ts +5 -0
  108. package/dist/web-mod-store.js +6 -0
  109. package/dist/web-mod-store.js.map +1 -1
  110. package/dist/web-prefs.d.ts +8 -0
  111. package/dist/web-prefs.js +9 -0
  112. package/dist/web-prefs.js.map +1 -1
  113. package/dist/web-preview.d.ts +6 -2
  114. package/dist/web-preview.js +4 -2
  115. package/dist/web-preview.js.map +1 -1
  116. package/dist/web-session.d.ts +15 -7
  117. package/dist/web-session.js +25 -8
  118. package/dist/web-session.js.map +1 -1
  119. package/dist/web-settings-tools.d.ts +69 -0
  120. package/dist/web-settings-tools.js +247 -0
  121. package/dist/web-settings-tools.js.map +1 -0
  122. package/package.json +1 -1
@@ -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,6 +212,7 @@ 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
217
  import { CacheService, cacheWindow } from "./cache-service.js";
214
218
  import { runWebCommand } from "./web-commands.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
  }
@@ -13034,11 +13165,12 @@ export class FleetManager {
13034
13165
  };
13035
13166
  };
13036
13167
  const choicesFromCache = (instance) => {
13037
- 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
13038
13170
  if (!cached?.models.length)
13039
13171
  return null;
13040
13172
  const current = this.resolveInstanceModel(instance).model;
13041
- 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) })) };
13042
13174
  };
13043
13175
  return runWebCommand({
13044
13176
  // Own entries only: a name like "__proto__" or "constructor" must not be found on the prototype (#1476 review).
@@ -15817,7 +15949,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15817
15949
  /** #1806: null on a switch INTO claude-code — the configured model then belongs to the backend being left. */
15818
15950
  configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model) {
15819
15951
  const aliases = ["default", "sonnet", "opus", "haiku", "opusplan", "Fable"];
15820
- 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);
15821
15953
  const lower = model.trim().toLowerCase();
15822
15954
  const bare = lower.replace(/\[1m\]$/, "");
15823
15955
  const known = aliases.some(a => a.toLowerCase() === bare)
@@ -15845,13 +15977,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15845
15977
  : undefined;
15846
15978
  return classic ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
15847
15979
  }
15848
- cliEnvPath(backend) {
15849
- 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`);
15850
15991
  }
15851
- /** Read the cached CLI env for a backend, or null if missing/stale/unparseable. */
15852
- 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) {
15853
15994
  try {
15854
- const env = JSON.parse(readFileSync(this.cliEnvPath(backend), "utf-8"));
15995
+ const env = JSON.parse(readFileSync(this.cliEnvPath(backend, profile), "utf-8"));
15855
15996
  if (typeof env?.probedAt === "number" && Date.now() - env.probedAt < CLI_ENV_TTL_MS)
15856
15997
  return env;
15857
15998
  }
@@ -15894,10 +16035,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15894
16035
  * previous list, never stall the command that asked for it.
15895
16036
  */
15896
16037
  async probeBackendBounded(backend, opts = {}) {
15897
- // #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
15898
16039
  // 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);
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);
15901
16044
  if (opts.refreshVendorCatalog && vendorFlight)
15902
16045
  return vendorFlight;
15903
16046
  if (!opts.refreshVendorCatalog && (vendorFlight || regularFlight))
@@ -15908,25 +16051,26 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15908
16051
  const epoch = this.cliEnvProbeEpoch;
15909
16052
  const input = {
15910
16053
  mode: "env", backend, instanceDir,
15911
- config: { workingDirectory: "", instanceDir, instanceName: `probe-${backend}`, mcpServers: {} },
16054
+ config: { workingDirectory: "", instanceDir, instanceName: `probe-${backend}`, mcpServers: {},
16055
+ ...(profile ? { credentialStoreHome: credentialProfileStoreHome(getAgendHome(), backend, profile) } : {}) },
15912
16056
  refreshVendorCatalog: opts.refreshVendorCatalog === true,
15913
16057
  };
15914
- const bounded = this.runBackendProbeWorker(`global:${backend}`, input).then(probed => {
16058
+ const bounded = this.runBackendProbeWorker(`global:${key}`, input).then(probed => {
15915
16059
  if (!probed || Array.isArray(probed) || this.shuttingDown || epoch !== this.cliEnvProbeEpoch)
15916
16060
  return null;
15917
16061
  try {
15918
- return this.persistCliEnvProbeResult(backend, probed);
16062
+ return this.persistCliEnvProbeResult(backend, probed, profile);
15919
16063
  }
15920
16064
  catch (err) {
15921
- this.logger.warn({ backend, err }, "CLI env cache write failed");
16065
+ this.logger.warn({ backend, profile, err }, "CLI env cache write failed");
15922
16066
  return null;
15923
16067
  }
15924
16068
  }).finally(() => {
15925
16069
  const flights = opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes;
15926
- if (flights.get(backend) === bounded)
15927
- flights.delete(backend);
16070
+ if (flights.get(key) === bounded)
16071
+ flights.delete(key);
15928
16072
  });
15929
- (opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(backend, bounded);
16073
+ (opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(key, bounded);
15930
16074
  return bounded;
15931
16075
  }
15932
16076
  stopCliEnvProbes() {
@@ -16041,12 +16185,21 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16041
16185
  modelDisplayForInstance(instanceName) {
16042
16186
  return this.resolveInstanceModel(instanceName).display;
16043
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
+ }
16044
16197
  modelChoiceLabel(option, currentModel) {
16045
16198
  const label = option.description ? `${option.label} — ${option.description}` : option.label;
16046
16199
  return option.id === currentModel ? `✓ ${label}` : label;
16047
16200
  }
16048
16201
  /** Preserve the existing cache merge rules for worker probes. */
16049
- persistCliEnvProbeResult(backend, probed) {
16202
+ persistCliEnvProbeResult(backend, probed, profile = null) {
16050
16203
  const env = { backend, probedAt: Date.now(), ...probed };
16051
16204
  // An empty result must never overwrite a catalog we already have. Some
16052
16205
  // probes hit the network (`agy models` fetches, 5s cap), so a slow moment
@@ -16055,14 +16208,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16055
16208
  // antigravity cache replaced by an empty one. Keep the known models and
16056
16209
  // let the fresher currentModel/version through.
16057
16210
  if (!env.models?.length) {
16058
- const previous = this.readCliEnv(backend);
16211
+ const previous = this.readCliEnv(backend, profile);
16059
16212
  if (previous?.models?.length)
16060
16213
  env.models = previous.models;
16061
16214
  }
16062
16215
  // Same protection for the extended catalog: one offline moment must not
16063
16216
  // blank a good account list for the whole cache TTL.
16064
16217
  if (!env.apiModels?.length) {
16065
- const previous = this.readCliEnv(backend);
16218
+ const previous = this.readCliEnv(backend, profile);
16066
16219
  if (previous?.apiModels?.length)
16067
16220
  env.apiModels = previous.apiModels;
16068
16221
  }
@@ -16070,12 +16223,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16070
16223
  // keeps the cached levels only for that same binary: both versions known and equal, cache still valid. A help
16071
16224
  // that was read and lists none ([]) is an answer and is written as is, so the fallback applies.
16072
16225
  if (env.effortLevels === undefined) {
16073
- const previous = this.readCliEnv(backend);
16226
+ const previous = this.readCliEnv(backend, profile);
16074
16227
  if (previous?.effortLevels && previous.version && env.version && previous.version === env.version) {
16075
16228
  env.effortLevels = previous.effortLevels;
16076
16229
  }
16077
16230
  }
16078
- const path = this.cliEnvPath(backend);
16231
+ const path = this.cliEnvPath(backend, profile);
16079
16232
  mkdirSync(dirname(path), { recursive: true });
16080
16233
  writeFileSync(path, JSON.stringify(env, null, 2));
16081
16234
  return env;
@@ -16097,6 +16250,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16097
16250
  // indefinitely — each is wrapped by CLI_ENV_PROBE_DEADLINE_MS.
16098
16251
  for (const b of backends)
16099
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 });
16100
16265
  }
16101
16266
  /** Best-effort model list for `/model`: cached CLI env first, else live probe. Never throws. */
16102
16267
  async getModelOptions(instanceName, refresh = false, onLiveProbe) {
@@ -16110,7 +16275,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16110
16275
  */
16111
16276
  async getModelOptionsWithSource(instanceName, opts = {}) {
16112
16277
  const backendName = this.backendNameForInstance(instanceName);
16113
- const cached = this.readCliEnv(backendName);
16278
+ const profile = this.catalogProfile(backendName, instanceName);
16279
+ const cached = this.readCliEnv(backendName, profile);
16114
16280
  if (!opts.refresh && cached?.models.length && !this.cliEnvNeedsRefresh(cached)) {
16115
16281
  return { models: cached.models, source: "cache" };
16116
16282
  }
@@ -16121,7 +16287,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16121
16287
  // Stale, missing, or a forced refresh → probe live (also refreshes the cache).
16122
16288
  // A newly released model is invisible until this runs, which is why staleness
16123
16289
  // triggers it rather than waiting for the 24h hard expiry or a cold start.
16124
- const env = await this.probeBackendBounded(backendName, { refreshVendorCatalog: opts.refreshVendorCatalog });
16290
+ const env = await this.probeBackendBounded(backendName, { refreshVendorCatalog: opts.refreshVendorCatalog, profile });
16125
16291
  if (env?.models.length)
16126
16292
  return { models: env.models, source: "live" };
16127
16293
  // Probe failed or timed out: the previous list is still the best answer.
@@ -16136,7 +16302,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16136
16302
  modelMenuChoices(instanceName, nonce, options, currentModel) {
16137
16303
  const isClaude = this.backendNameForInstance(instanceName) === "claude-code";
16138
16304
  const choices = [{ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:__refresh__`, label: t("model.refresh") }];
16139
- for (const o of options.slice(0, isClaude ? 23 : 24)) {
16305
+ for (const o of this.withCurrentModel(options, currentModel).slice(0, isClaude ? 23 : 24)) {
16140
16306
  choices.push({ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`, label: this.modelChoiceLabel(o, currentModel) });
16141
16307
  }
16142
16308
  if (isClaude)
@@ -16543,16 +16709,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16543
16709
  }
16544
16710
  }
16545
16711
  /** Cached-or-live account catalog behind "/model → more models" (claude). */
16546
- async claudeApiModelOptions() {
16547
- 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);
16548
16716
  if (cached?.apiModels?.length)
16549
16717
  return cached.apiModels;
16550
- const env = await this.probeBackendBounded("claude-code");
16718
+ const env = await this.probeBackendBounded("claude-code", { profile });
16551
16719
  return env?.apiModels ?? [];
16552
16720
  }
16553
16721
  /** Replace a consumed "/model" menu with the full account catalog tier. */
16554
16722
  async expandClaudeModelMenu(pending) {
16555
- const expanded = await this.claudeApiModelOptions();
16723
+ const expanded = await this.claudeApiModelOptions(pending.instanceName);
16556
16724
  if (!expanded.length) {
16557
16725
  await pending.respond(t("model.more_unavailable")).catch(() => { });
16558
16726
  return;
@@ -19847,6 +20015,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19847
20015
  this.rejectedHostsLogged.add(name);
19848
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)");
19849
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
+ }
19850
20025
  /** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
19851
20026
  dispatchWebHttp(req, res, port, closing = false) {
19852
20027
  try {
@@ -19907,6 +20082,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19907
20082
  res.end(JSON.stringify({ error: decision.message }));
19908
20083
  return;
19909
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
+ }
19910
20101
  }
19911
20102
  if (req.method === "GET" && req.url === "/health") {
19912
20103
  const health = this.getFleetHealth();
@@ -20297,6 +20488,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20297
20488
  this.logger.warn({ code: err.code, port: settings.port }, "Preview listener unavailable; HTML previews are off");
20298
20489
  this.previewListening = false;
20299
20490
  this.previewListener = null;
20491
+ this.previewListenError = `${settings.port}: ${err.code ?? err.message}`;
20300
20492
  });
20301
20493
  // An optional listener never takes the fleet down: a port Node refuses outright throws here, synchronously.
20302
20494
  try {
@@ -20307,6 +20499,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20307
20499
  this.logger.warn({ err: err.message, port: settings.port }, "Preview listener cannot listen; HTML previews are off");
20308
20500
  this.previewListening = false;
20309
20501
  this.previewListener = null;
20502
+ this.previewListenError = `${settings.port}: ${err.code ?? err.message}`;
20310
20503
  }
20311
20504
  }
20312
20505
  /** What a reload may change hot: web.preview, and the dashboard names (the shim's allow-list and frame-ancestors). */
@@ -20327,6 +20520,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20327
20520
  }
20328
20521
  stopPreviewListener() {
20329
20522
  this.previewListening = false;
20523
+ this.previewListenError = null;
20330
20524
  this.previewListener?.close();
20331
20525
  this.previewListener = null;
20332
20526
  }
@@ -20334,10 +20528,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20334
20528
  previewForUi(hostHeader, secure) {
20335
20529
  const listener = this.previewListening ? this.previewListener : null;
20336
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.
20337
20533
  if (!listener && this.fleetConfig?.web?.preview !== false)
20338
- decided.reason = "Previews are not available on this fleet right now.";
20534
+ Object.assign(decided, this.previewNotListening());
20339
20535
  return { ...decided, boot: listener ? listener.bootId : null };
20340
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
+ }
20341
20543
  /**
20342
20544
  * #1723: for one page of the public link (exposure `exposureId`, at `dashboardOrigin`): its own preview tunnel's
20343
20545
  * origin once up, "starting" until then, why it failed if it did — or a configured web.preview_origin, used instead.
@@ -20352,8 +20554,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20352
20554
  const configured = typeof web?.preview_origin === "string" ? normalizeOrigin(web.preview_origin) : null;
20353
20555
  if (configured) {
20354
20556
  const listener = this.previewListening ? this.previewListener : null;
20355
- if (!listener)
20356
- 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
+ }
20357
20561
  if (!configured.startsWith("https:"))
20358
20562
  return off("needHttps", "Previews need an https web.preview_origin when the dashboard is reached over https.");
20359
20563
  return { dashboardOrigin, previewOrigin: configured, reason: null, code: null, boot: listener.bootId, frameSrc: `${configured}/frame` };
@@ -20493,6 +20697,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20493
20697
  // #1685: host memory pressure while it slows or holds new agents (the dashboard's indicator; macOS elevated is
20494
20698
  // shown here rather than in chat). null when normal, unknown or advisory.
20495
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 } : {}),
20496
20702
  };
20497
20703
  }
20498
20704
  }