@songsid/agend 2.1.2-beta.47 → 2.1.2-beta.49

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.
@@ -51,6 +51,8 @@ import { readLastInboundAt } from "./daemon.js";
51
51
  import { clearPausedMarker } from "./pause-marker.js";
52
52
  import { releaseProcessFleetLock } from "./fleet-lock.js";
53
53
  import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
54
+ import { loadOrCreateWebToken, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
55
+ import { RestartProgress } from "./restart-progress.js";
54
56
  import { getTmuxSession } from "./config.js";
55
57
  export function resolveReplyThreadId(argsThreadId, instanceConfig) {
56
58
  if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
@@ -285,6 +287,7 @@ export class FleetManager {
285
287
  sseClients = new Set();
286
288
  webToken = null;
287
289
  viewToken = null;
290
+ healthServerListening = false;
288
291
  constructor(dataDir) {
289
292
  this.dataDir = dataDir;
290
293
  FleetManager.signalTarget = this;
@@ -1059,7 +1062,18 @@ export class FleetManager {
1059
1062
  * to avoid config file races. Stagger delay is group-to-group, not instance-to-instance.
1060
1063
  * TODO: per-instance startup timeout (existing issue, not introduced here)
1061
1064
  */
1062
- async startInstancesWithConcurrency(entries, topicMode) {
1065
+ async startInstancesWithConcurrency(entries, topicMode, onReady) {
1066
+ // Persisted pauses are intentionally preserved across fleet restarts. Filter
1067
+ // them before grouping/staggering: startInstance() retains its own guard as
1068
+ // a final backstop, but putting a no-op entry in this queue still consumes a
1069
+ // full stagger slot for every distinct working directory.
1070
+ const runnableEntries = entries.filter(([name]) => !this.lifecycle.isPaused(name));
1071
+ const pausedCount = entries.length - runnableEntries.length;
1072
+ if (pausedCount > 0) {
1073
+ this.logger.info({ pausedCount }, "Paused instances excluded from startup queue");
1074
+ }
1075
+ if (runnableEntries.length === 0)
1076
+ return;
1063
1077
  const raw = this.fleetConfig?.defaults?.startup;
1064
1078
  const explicitConcurrency = raw?.concurrency;
1065
1079
  const staggerMs = Math.max(0, Math.min(30_000, raw?.stagger_delay_ms ?? 500));
@@ -1074,10 +1088,10 @@ export class FleetManager {
1074
1088
  else {
1075
1089
  const freeMemMB = Math.round(freemem() / (1024 * 1024));
1076
1090
  concurrency = Math.max(2, Math.min(10, Math.floor(freeMemMB / ESTIMATED_MB_PER_INSTANCE)));
1077
- this.logger.info({ concurrency, freeMemMB: freeMemMB, totalInstances: entries.length }, "Adaptive startup concurrency");
1091
+ this.logger.info({ concurrency, freeMemMB: freeMemMB, totalInstances: runnableEntries.length }, "Adaptive startup concurrency");
1078
1092
  }
1079
1093
  const byWorkDir = new Map();
1080
- for (const [name, config] of entries) {
1094
+ for (const [name, config] of runnableEntries) {
1081
1095
  const dir = config.working_directory;
1082
1096
  if (!byWorkDir.has(dir))
1083
1097
  byWorkDir.set(dir, []);
@@ -1120,7 +1134,14 @@ export class FleetManager {
1120
1134
  lastStartAt = Date.now();
1121
1135
  (async () => {
1122
1136
  for (const [name, config] of group) {
1123
- await this.startInstance(name, config, topicMode).catch((err) => this.logger.error({ err, name }, "Failed to start instance"));
1137
+ try {
1138
+ await this.startInstance(name, config, topicMode);
1139
+ if (this.daemons.has(name))
1140
+ onReady?.(name);
1141
+ }
1142
+ catch (err) {
1143
+ this.logger.error({ err, name }, "Failed to start instance");
1144
+ }
1124
1145
  }
1125
1146
  })().finally(() => {
1126
1147
  running--;
@@ -1134,6 +1155,34 @@ export class FleetManager {
1134
1155
  startNext();
1135
1156
  });
1136
1157
  }
1158
+ runnableStartupCount(fleet, includeClassic) {
1159
+ const names = new Set(Object.keys(fleet.instances));
1160
+ if (includeClassic) {
1161
+ for (const channel of this.classicChannels?.getAll() ?? [])
1162
+ names.add(channel.instanceName);
1163
+ }
1164
+ let count = 0;
1165
+ for (const name of names) {
1166
+ if (!this.lifecycle.isPaused(name))
1167
+ count++;
1168
+ }
1169
+ return count;
1170
+ }
1171
+ restartProgressTarget() {
1172
+ const generalName = this.findGeneralInstance();
1173
+ if (!generalName)
1174
+ return null;
1175
+ const adapter = this.getAdapterForInstance(generalName);
1176
+ const chatId = this.getGroupIdForInstance(generalName);
1177
+ if (!adapter || !chatId)
1178
+ return null;
1179
+ const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
1180
+ return {
1181
+ adapter,
1182
+ chatId,
1183
+ threadId: topicId != null ? String(topicId) : undefined,
1184
+ };
1185
+ }
1137
1186
  async stopInstance(name) {
1138
1187
  this.failoverActive.delete(name);
1139
1188
  this.cancelIdleButtonRetirement(name);
@@ -1225,8 +1274,24 @@ export class FleetManager {
1225
1274
  process.env[key] = value;
1226
1275
  }
1227
1276
  }
1277
+ /** Initialize auth before any adapter can answer /dashboard. */
1278
+ initializeWebAuthTokens() {
1279
+ this.webToken = loadOrCreateWebToken(this.dataDir);
1280
+ this.viewToken = randomBytes(24).toString("hex");
1281
+ const viewTokenPath = join(this.dataDir, "view.token");
1282
+ writeFileSync(viewTokenPath, this.viewToken, { encoding: "utf8", mode: 0o600 });
1283
+ try {
1284
+ chmodSync(viewTokenPath, 0o600);
1285
+ }
1286
+ catch { /* best effort */ }
1287
+ this.healthServerListening = false;
1288
+ }
1289
+ getDashboardAccess() {
1290
+ return { ready: this.healthServerListening, token: this.webToken };
1291
+ }
1228
1292
  /** Start all instances from fleet config */
1229
1293
  async startAll(configPath) {
1294
+ const startupStartedAt = Date.now();
1230
1295
  FleetManager.signalTarget = this;
1231
1296
  this.startupComplete = false;
1232
1297
  // Cleared here, not at the end of doStopAll: a stop has an async tail, and
@@ -1238,6 +1303,7 @@ export class FleetManager {
1238
1303
  rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
1239
1304
  const fleet = this.loadConfig(configPath);
1240
1305
  setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
1306
+ this.initializeWebAuthTokens();
1241
1307
  const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
1242
1308
  // Set tmux socket isolation for custom AGEND_HOME
1243
1309
  const { getTmuxSocketName: getSocket } = await import("./paths.js");
@@ -1493,10 +1559,13 @@ export class FleetManager {
1493
1559
  const allEntries = Object.entries(fleet.instances);
1494
1560
  const generals = allEntries.filter(([_, cfg]) => cfg.general_topic);
1495
1561
  const others = allEntries.filter(([_, cfg]) => !cfg.general_topic);
1562
+ const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), startupStartedAt, this.logger);
1496
1563
  if (generals.length > 0) {
1497
1564
  for (const [name, cfg] of generals) {
1498
1565
  try {
1499
1566
  await this.startInstance(name, cfg, topicMode);
1567
+ if (this.daemons.has(name))
1568
+ startupProgress.markReady();
1500
1569
  }
1501
1570
  catch (err) {
1502
1571
  this.logger.error({ err, name }, "Failed to start general instance");
@@ -1511,6 +1580,26 @@ export class FleetManager {
1511
1580
  }
1512
1581
  }
1513
1582
  }
1583
+ // The adapter must exist before General can receive the progress message.
1584
+ // Start it after General is ready, in parallel with the remaining CLIs, so
1585
+ // progress is visible without adding adapter startup time to the critical path.
1586
+ let adapterStartup = null;
1587
+ let progressStart = Promise.resolve(false);
1588
+ if (topicMode && (fleet.channel || fleet.channels?.length)) {
1589
+ // An adapter becoming reachable during startup can receive messages; make
1590
+ // all existing topic ids routable before opening that inbound path.
1591
+ this.routing.rebuild(fleet);
1592
+ this.reregisterClassicChannels();
1593
+ adapterStartup = (async () => {
1594
+ try {
1595
+ await this.startSharedAdapter(fleet);
1596
+ }
1597
+ catch (err) {
1598
+ this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
1599
+ }
1600
+ })();
1601
+ progressStart = adapterStartup.then(() => startupProgress.start(this.restartProgressTarget()));
1602
+ }
1514
1603
  // The systemd watchdog answers exactly one question: is this process still
1515
1604
  // turning its event loop? Pinging from a timer proves that, and after the
1516
1605
  // blocking child-process calls were made async it is a meaningful signal —
@@ -1536,15 +1625,10 @@ export class FleetManager {
1536
1625
  this.logRotateTimer.unref?.();
1537
1626
  // Phase 2: Start remaining instances with staggered concurrency
1538
1627
  if (others.length > 0) {
1539
- await this.startInstancesWithConcurrency(others, topicMode);
1628
+ await this.startInstancesWithConcurrency(others, topicMode, () => startupProgress.markReady());
1540
1629
  }
1541
1630
  if (topicMode && (fleet.channel || fleet.channels?.length)) {
1542
- try {
1543
- await this.startSharedAdapter(fleet);
1544
- }
1545
- catch (err) {
1546
- this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
1547
- }
1631
+ await adapterStartup;
1548
1632
  // Bind every fleet instance deterministically. Explicit channel_id wins;
1549
1633
  // otherwise channels[0] is authoritative. Do not infer identity from
1550
1634
  // concurrent adapter startup or whichever bot receives a message first.
@@ -1585,18 +1669,30 @@ export class FleetManager {
1585
1669
  // Start classic channel instances (parallel, concurrency 3)
1586
1670
  if (this.classicChannels) {
1587
1671
  const fleetBackend = this.fleetConfig?.defaults?.backend;
1588
- const channels = this.classicChannels.getAll();
1672
+ const channels = this.classicChannels.getAll()
1673
+ .filter(ch => !this.lifecycle.isPaused(ch.instanceName));
1589
1674
  const concurrency = 3;
1590
1675
  let idx = 0;
1591
1676
  while (idx < channels.length) {
1592
1677
  const batch = channels.slice(idx, idx + concurrency);
1593
- await Promise.allSettled(batch.map(ch => this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend), this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after)).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance"))));
1678
+ await Promise.allSettled(batch.map(async (ch) => {
1679
+ try {
1680
+ await this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend), this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
1681
+ if (this.daemons.has(ch.instanceName))
1682
+ startupProgress.markReady();
1683
+ }
1684
+ catch (err) {
1685
+ this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance");
1686
+ }
1687
+ }));
1594
1688
  idx += concurrency;
1595
1689
  }
1596
1690
  }
1597
1691
  for (const name of Object.keys(fleet.instances)) {
1598
1692
  this.startStatuslineWatcher(name);
1599
1693
  }
1694
+ await progressStart;
1695
+ const progressCompleted = await startupProgress.finish();
1600
1696
  // Notify General topic that fleet is up
1601
1697
  const classicCount = this.classicChannels?.getAll().length ?? 0;
1602
1698
  const total = Object.keys(fleet.instances).length + classicCount;
@@ -1609,7 +1705,7 @@ export class FleetManager {
1609
1705
  const { createRequire } = await import("node:module");
1610
1706
  const _require = createRequire(import.meta.url);
1611
1707
  const agendVersion = _require("../package.json").version ?? "unknown";
1612
- if (this.adapter && fleet.channel?.group_id) {
1708
+ if (!progressCompleted && this.adapter && fleet.channel?.group_id) {
1613
1709
  let text;
1614
1710
  if (failedNames.length === 0 && pausedNames.length === 0) {
1615
1711
  text = t("fleet.ready", started, total, agendVersion);
@@ -6331,6 +6427,7 @@ When users create specialized instances, suggest these configurations:
6331
6427
  this.controlClient?.stop();
6332
6428
  this.controlClient = null;
6333
6429
  if (this.healthServer) {
6430
+ this.healthServerListening = false;
6334
6431
  this.healthServer.close();
6335
6432
  this.healthServer = null;
6336
6433
  }
@@ -6588,6 +6685,10 @@ When users create specialized instances, suggest these configurations:
6588
6685
  clearTimeout(timeoutHandle);
6589
6686
  }
6590
6687
  this.logger.info("All instances idle — restarting...");
6688
+ const restartStartedAt = Date.now();
6689
+ // Capture the live adapter/topic before General's daemon is stopped. The
6690
+ // channel adapter remains connected throughout an in-process restart.
6691
+ const progressTarget = this.restartProgressTarget();
6591
6692
  this.clearStatuslineWatchers();
6592
6693
  for (const [, ipc] of this.instanceIpcClients) {
6593
6694
  await ipc.close();
@@ -6612,15 +6713,25 @@ When users create specialized instances, suggest these configurations:
6612
6713
  const fleet = this.loadConfig(this.configPath);
6613
6714
  this.fleetConfig = fleet;
6614
6715
  const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
6716
+ const restartProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), restartStartedAt, this.logger);
6615
6717
  // Phase 1: generals first
6616
6718
  const restartEntries = Object.entries(fleet.instances);
6617
6719
  const restartGenerals = restartEntries.filter(([_, cfg]) => cfg.general_topic);
6618
6720
  const restartOthers = restartEntries.filter(([_, cfg]) => !cfg.general_topic);
6619
6721
  for (const [name, cfg] of restartGenerals) {
6620
- await this.startInstance(name, cfg, topicMode).catch(err => this.logger.error({ err, name }, "Failed to start general instance"));
6722
+ try {
6723
+ await this.startInstance(name, cfg, topicMode);
6724
+ if (this.daemons.has(name))
6725
+ restartProgress.markReady();
6726
+ }
6727
+ catch (err) {
6728
+ this.logger.error({ err, name }, "Failed to start general instance");
6729
+ }
6621
6730
  }
6731
+ // General is ready again; now its topic can own the live progress message.
6732
+ await restartProgress.start(progressTarget);
6622
6733
  if (restartOthers.length > 0) {
6623
- await this.startInstancesWithConcurrency(restartOthers, topicMode);
6734
+ await this.startInstancesWithConcurrency(restartOthers, topicMode, () => restartProgress.markReady());
6624
6735
  }
6625
6736
  if (topicMode) {
6626
6737
  this.routing.rebuild(this.fleetConfig);
@@ -6629,12 +6740,22 @@ When users create specialized instances, suggest these configurations:
6629
6740
  // Restart classic channel instances (killed during orphan cleanup)
6630
6741
  if (this.classicChannels) {
6631
6742
  const fleetBackend = this.fleetConfig?.defaults?.backend;
6632
- const channels = this.classicChannels.getAll();
6743
+ const channels = this.classicChannels.getAll()
6744
+ .filter(ch => !this.lifecycle.isPaused(ch.instanceName));
6633
6745
  const concurrency = 3;
6634
6746
  let idx = 0;
6635
6747
  while (idx < channels.length) {
6636
6748
  const batch = channels.slice(idx, idx + concurrency);
6637
- await Promise.allSettled(batch.map(ch => this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend), this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after)).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance"))));
6749
+ await Promise.allSettled(batch.map(async (ch) => {
6750
+ try {
6751
+ await this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend), this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
6752
+ if (this.daemons.has(ch.instanceName))
6753
+ restartProgress.markReady();
6754
+ }
6755
+ catch (err) {
6756
+ this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance");
6757
+ }
6758
+ }));
6638
6759
  idx += concurrency;
6639
6760
  }
6640
6761
  }
@@ -6643,6 +6764,7 @@ When users create specialized instances, suggest these configurations:
6643
6764
  }
6644
6765
  }
6645
6766
  this.logger.info("Graceful restart complete");
6767
+ const progressCompleted = await restartProgress.finish();
6646
6768
  if (groupId && this.adapter) {
6647
6769
  const total = Object.keys(fleet.instances).length;
6648
6770
  const started = this.daemons.size;
@@ -6663,8 +6785,10 @@ When users create specialized instances, suggest these configurations:
6663
6785
  restartText = t("fleet.ready_with_failed", started, total, agendVersion2, failedNames.join(", "))
6664
6786
  + (pausedNames2.length > 0 ? `\n⏸ Paused: ${pausedNames2.join(", ")}` : "");
6665
6787
  }
6666
- await this.adapter.sendText(String(groupId), restartText, notifyOpts)
6667
- .catch(e => this.logger.warn({ err: e }, "Failed to post restart completion notification"));
6788
+ if (!progressCompleted) {
6789
+ await this.adapter.sendText(String(groupId), restartText, notifyOpts)
6790
+ .catch(e => this.logger.warn({ err: e }, "Failed to post restart completion notification"));
6791
+ }
6668
6792
  // Notify each instance's channel — staggered to avoid rate limit storm
6669
6793
  const instances = Object.entries(this.fleetConfig?.instances ?? {});
6670
6794
  this.logger.info({ count: instances.length }, "Sending restart notification to instances (staggered)");
@@ -6799,26 +6923,11 @@ When users create specialized instances, suggest these configurations:
6799
6923
  // ── Health HTTP endpoint ─────────────────────────────────────────────
6800
6924
  startHealthServer(port) {
6801
6925
  this.startedAt = Date.now();
6802
- // Generate web token before server starts so auth is enforced from the first request.
6803
- this.webToken = randomBytes(24).toString("hex");
6804
- const tokenPath = join(this.dataDir, "web.token");
6805
- writeFileSync(tokenPath, this.webToken, { mode: 0o600 });
6806
- // Defensive: if file existed previously with looser perms, tighten it.
6807
- try {
6808
- chmodSync(tokenPath, 0o600);
6809
- }
6810
- catch {
6811
- // best-effort
6812
- }
6813
- // Separate read-only token for the /view page: grants terminal-view + profile
6814
- // read, but never write (POSTs still require the full web token).
6815
- this.viewToken = randomBytes(24).toString("hex");
6816
- const viewTokenPath = join(this.dataDir, "view.token");
6817
- writeFileSync(viewTokenPath, this.viewToken, { mode: 0o600 });
6818
- try {
6819
- chmodSync(viewTokenPath, 0o600);
6820
- }
6821
- catch { /* best-effort */ }
6926
+ this.healthServerListening = false;
6927
+ this.healthPortRetried = false;
6928
+ // Defensive for direct/unit callers; normal startup initializes these before adapters.
6929
+ if (!this.webToken || !this.viewToken)
6930
+ this.initializeWebAuthTokens();
6822
6931
  this.healthServer = createServer((req, res) => {
6823
6932
  res.setHeader("Content-Type", "application/json");
6824
6933
  // Public health probe — no auth required.
@@ -6845,7 +6954,7 @@ When users create specialized instances, suggest these configurations:
6845
6954
  ?? (typeof headerToken === "string" ? headerToken : null);
6846
6955
  if (!this.webToken || providedToken !== this.webToken) {
6847
6956
  res.writeHead(401);
6848
- res.end(JSON.stringify({ error: "Unauthorized" }));
6957
+ res.end(JSON.stringify({ error: WEB_TOKEN_INVALID_MESSAGE }));
6849
6958
  return;
6850
6959
  }
6851
6960
  }
@@ -7060,10 +7169,20 @@ When users create specialized instances, suggest these configurations:
7060
7169
  res.writeHead(404);
7061
7170
  res.end(JSON.stringify({ error: "not found" }));
7062
7171
  });
7172
+ const markListening = (afterTakeover = false) => {
7173
+ this.healthServerListening = true;
7174
+ this.logger.info({ port }, afterTakeover
7175
+ ? "Health endpoint listening (after takeover)"
7176
+ : "Health endpoint listening");
7177
+ this.logger.info({ url: `http://localhost:${port}/ui?token=${this.webToken}` }, "Web UI available");
7178
+ this.logger.info({ url: `http://localhost:${port}/view?token=${this.viewToken}` }, "Web View available");
7179
+ };
7063
7180
  this.healthServer.on("error", (err) => {
7181
+ this.healthServerListening = false;
7064
7182
  if (err.code === "EADDRINUSE") {
7065
7183
  if (this.healthPortRetried) {
7066
- this.logger.debug({ port }, "Health port still in use after takeover — skipping health endpoint");
7184
+ this.logger.error({ err, port }, "Health port still in use after takeover — dashboard disabled");
7185
+ this.notifyFleetError(`⚠️ Dashboard unavailable — health port ${port} is already in use. Stop the conflicting process or configure a different health_port.`);
7067
7186
  return;
7068
7187
  }
7069
7188
  this.healthPortRetried = true;
@@ -7084,19 +7203,14 @@ When users create specialized instances, suggest these configurations:
7084
7203
  setTimeout(() => {
7085
7204
  if (!this.healthServer)
7086
7205
  return;
7087
- this.healthServer.listen(port, "127.0.0.1", () => {
7088
- this.logger.info({ port }, "Health endpoint listening (after takeover)");
7089
- });
7206
+ this.healthServer.listen(port, "127.0.0.1", () => markListening(true));
7090
7207
  }, 1500);
7091
7208
  return;
7092
7209
  }
7093
7210
  this.logger.error({ err, port }, "Health server error");
7211
+ this.notifyFleetError(`⚠️ Dashboard unavailable — health server failed: ${err.message}`);
7094
7212
  });
7095
- this.healthServer.listen(port, "127.0.0.1", () => {
7096
- this.logger.info({ port }, "Health endpoint listening");
7097
- });
7098
- this.logger.info({ url: `http://localhost:${port}/ui?token=${this.webToken}` }, "Web UI available");
7099
- this.logger.info({ url: `http://localhost:${port}/view?token=${this.viewToken}` }, "Web View available");
7213
+ this.healthServer.listen(port, "127.0.0.1", () => markListening());
7100
7214
  }
7101
7215
  getUiStatus() {
7102
7216
  const fleetNames = Object.keys(this.fleetConfig?.instances ?? {});