@songsid/agend 2.1.4-beta.52 → 2.1.4-beta.54

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.
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync } from "node:fs";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { spawnSync } from "node:child_process";
4
- import { freemem } from "node:os";
4
+ import { freemem, totalmem } from "node:os";
5
5
  import { createServer } from "node:http";
6
6
  import { join, dirname, basename } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
@@ -567,7 +567,8 @@ export class FleetManager {
567
567
  getSysInfo() {
568
568
  const mem = process.memoryUsage();
569
569
  const toMB = (b) => Math.round(b / 1024 / 1024 * 10) / 10;
570
- const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => ({
570
+ // Fleet instances (fleet.yaml)
571
+ const fleetInstances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => ({
571
572
  name,
572
573
  status: this.getInstanceStatus(name),
573
574
  state: this.getInstanceExecutionState(name),
@@ -575,12 +576,46 @@ export class FleetManager {
575
576
  costCents: this.costGuard?.getDailyCostCents(name) ?? 0,
576
577
  rateLimits: this.statuslineWatcher.getRateLimits(name) ?? null,
577
578
  }));
579
+ // Classic instances (classicBot.yaml) — dedupe against fleet
580
+ const fleetNames = new Set(fleetInstances.map(i => i.name));
581
+ const classicInstances = (this.classicChannels?.getAll() ?? [])
582
+ .filter(ch => !fleetNames.has(ch.instanceName))
583
+ .map(ch => ({
584
+ name: ch.instanceName,
585
+ status: this.getInstanceStatus(ch.instanceName),
586
+ state: this.getInstanceExecutionState(ch.instanceName),
587
+ ipc: this.instanceIpcClients.has(ch.instanceName),
588
+ costCents: this.costGuard?.getDailyCostCents(ch.instanceName) ?? 0,
589
+ rateLimits: this.statuslineWatcher.getRateLimits(ch.instanceName) ?? null,
590
+ }));
591
+ // Combined roster (matches /api/fleet and agend ls)
592
+ const allInstances = [...fleetInstances, ...classicInstances];
593
+ // Fleet summary counts (fleet + Classic combined)
594
+ const running_count = allInstances.filter(i => i.status === "running").length;
595
+ const paused_count = allInstances.filter(i => i.status === "paused").length;
596
+ // System memory (GB, 1 decimal)
597
+ const totalGB = totalmem() / (1024 ** 3);
598
+ const usedGB = (totalmem() - freemem()) / (1024 ** 3);
599
+ const system_mem_gb = {
600
+ used: Math.round(usedGB * 10) / 10,
601
+ total: Math.round(totalGB * 10) / 10,
602
+ };
603
+ // Fleet memory: O(1) cgroup read (includes entire service tree: fleet + CLIs + MCP servers)
604
+ // This avoids blocking the event loop with per-instance tree scans.
605
+ const fleetMem = readFleetMemory();
606
+ const fleet_mem_mb = fleetMem.cgroupAnonBytes != null
607
+ ? Math.round(fleetMem.cgroupAnonBytes / (1024 * 1024) * 10) / 10
608
+ : null;
578
609
  return {
579
610
  uptime_seconds: Math.floor((Date.now() - this.startedAt) / 1000),
580
611
  memory_mb: { rss: toMB(mem.rss), heapUsed: toMB(mem.heapUsed), heapTotal: toMB(mem.heapTotal) },
581
- instances,
612
+ instances: fleetInstances, // SysInfo.instances is fleet-only; /api/fleet enriches with Classic
582
613
  fleet_cost_cents: this.costGuard?.getFleetTotalCents() ?? 0,
583
614
  fleet_cost_limit_cents: this.costGuard?.getLimitCents() ?? 0,
615
+ running_count,
616
+ paused_count,
617
+ fleet_mem_mb,
618
+ system_mem_gb,
584
619
  };
585
620
  }
586
621
  /** Load fleet.yaml and build routing table */
@@ -2926,7 +2961,8 @@ export class FleetManager {
2926
2961
  await data.respond(text);
2927
2962
  }
2928
2963
  else if (data.command === "sysinfo") {
2929
- await data.respond(this.topicCommands.getSysInfoText());
2964
+ // Slash commands are Discord-only; use plain lines (no markdown table)
2965
+ await data.respond(this.topicCommands.getSysInfoText({ platform: "discord" }));
2930
2966
  }
2931
2967
  else if (data.command === "dashboard") {
2932
2968
  // Reply is ephemeral (adapter defers non-chat commands ephemerally), so
@@ -3272,7 +3308,8 @@ export class FleetManager {
3272
3308
  await data.respond(text);
3273
3309
  }
3274
3310
  else if (data.command === "sysinfo") {
3275
- await data.respond(this.topicCommands.getSysInfoText());
3311
+ // Slash commands are Discord-only; use plain lines (no markdown table)
3312
+ await data.respond(this.topicCommands.getSysInfoText({ platform: "discord" }));
3276
3313
  }
3277
3314
  else if (data.command === "dashboard") {
3278
3315
  // Reply is ephemeral (adapter defers non-chat commands ephemerally), so
@@ -5404,14 +5441,6 @@ export class FleetManager {
5404
5441
  return;
5405
5442
  }
5406
5443
  const suppressed = seen?.suppressed ?? 0;
5407
- this.fleetErrorNotices.set(key, { at: now, suppressed: 0 });
5408
- // Bound the map: it is keyed by message text, and a message with a varying
5409
- // suffix (a path, an id) would otherwise grow it without limit.
5410
- if (this.fleetErrorNotices.size > 100) {
5411
- const oldest = this.fleetErrorNotices.keys().next().value;
5412
- if (oldest !== undefined)
5413
- this.fleetErrorNotices.delete(oldest);
5414
- }
5415
5444
  const body = suppressed > 0
5416
5445
  ? `${text}\n${t("fleet.error_suppressed", suppressed, Math.round(FleetManager.FLEET_ERROR_THROTTLE_MS / 60_000))}`
5417
5446
  : text;
@@ -5421,26 +5450,60 @@ export class FleetManager {
5421
5450
  // topic_id to post into it.
5422
5451
  const general = Object.entries(this.fleetConfig?.instances ?? {})
5423
5452
  .find(([, config]) => config.general_topic === true)?.[0];
5453
+ // "dispatched", not "delivered": see notifyInstanceTopic. A platform that
5454
+ // rejects the message afterwards still consumes the throttle window, which
5455
+ // is the pre-existing semantic and not something this change alters.
5456
+ let dispatched = false;
5424
5457
  if (general) {
5425
- this.notifyInstanceTopic(general, body);
5426
- return;
5458
+ dispatched = this.notifyInstanceTopic(general, body);
5427
5459
  }
5428
- // No General instance — fall back to the primary channel's group.
5429
- const channelCfg = this.getChannelConfig();
5430
- const groupId = channelCfg?.group_id;
5431
- if (this.adapter && groupId) {
5432
- this.adapter.sendText(String(groupId), body)
5433
- .catch(err => this.logger.warn({ err }, "Failed to send fleet error notification"));
5460
+ else {
5461
+ // No General instance — fall back to the primary channel's group.
5462
+ const groupId = this.getChannelConfig()?.group_id;
5463
+ if (this.adapter && groupId) {
5464
+ this.adapter.sendText(String(groupId), body)
5465
+ .catch(err => this.logger.warn({ err }, "Failed to send fleet error notification"));
5466
+ dispatched = true;
5467
+ }
5468
+ }
5469
+ if (!dispatched) {
5470
+ // Do NOT record the throttle key. It used to be claimed before delivery
5471
+ // was attempted, so a message nobody could receive — every fleet error
5472
+ // raised before adapters exist, which is when startup faults happen —
5473
+ // also suppressed the next ten minutes of identical ones. Leaving the key
5474
+ // unspent lets the next caller, once an adapter is up, actually get
5475
+ // through. The counter is not reset either: nothing was shown.
5476
+ this.logger.warn({ text: body }, "Fleet error could not be delivered (no adapter or no target yet) — not consuming the throttle window");
5434
5477
  return;
5435
5478
  }
5436
- this.logger.warn({ text: body }, "Fleet error had no notification target (no General instance, no adapter)");
5479
+ this.fleetErrorNotices.set(key, { at: now, suppressed: 0 });
5480
+ // Bound the map: it is keyed by message text, and a message with a varying
5481
+ // suffix (a path, an id) would otherwise grow it without limit.
5482
+ if (this.fleetErrorNotices.size > 100) {
5483
+ const oldest = this.fleetErrorNotices.keys().next().value;
5484
+ if (oldest !== undefined)
5485
+ this.fleetErrorNotices.delete(oldest);
5486
+ }
5437
5487
  }
5438
5488
  static FLEET_ERROR_THROTTLE_MS = 10 * 60_000;
5439
5489
  fleetErrorNotices = new Map();
5490
+ /**
5491
+ * Post into an instance's topic. Returns whether a send was DISPATCHED — a
5492
+ * target was resolved and sendText was called — so a caller that must not
5493
+ * lose the message (notifyFleetError, which spends a throttle key) can tell
5494
+ * that from silence. Existing callers ignore the result and are unaffected.
5495
+ *
5496
+ * Not a delivery guarantee: sendText is fire-and-forget, and a platform-side
5497
+ * rejection surfaces only as a warn in its .catch. True delivery confirmation
5498
+ * would have to make this async and change every caller.
5499
+ */
5440
5500
  notifyInstanceTopic(instanceName, text, extraOpts) {
5441
5501
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
5442
- if (!adapter)
5443
- return;
5502
+ if (!adapter) {
5503
+ // Early startup: adapters are created well after the fleet object exists.
5504
+ this.logger.warn({ instanceName }, "No adapter yet — instance topic notification not sent");
5505
+ return false;
5506
+ }
5444
5507
  const channelCfg = this.getChannelConfig(this.getInstanceAdapterId(instanceName));
5445
5508
  const groupId = channelCfg?.group_id;
5446
5509
  // Fleet topic instance
@@ -5448,20 +5511,23 @@ export class FleetManager {
5448
5511
  if (threadId != null && groupId) {
5449
5512
  adapter.sendText(String(groupId), text, { threadId: String(threadId), ...extraOpts })
5450
5513
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send instance topic notification"));
5451
- return;
5514
+ return true;
5452
5515
  }
5453
5516
  // Classic instance: find its channelId from the classic manager
5454
5517
  const classicChatId = this.classicChannels?.getChannelIdByInstance(instanceName);
5455
5518
  if (classicChatId) {
5456
5519
  adapter.sendText(classicChatId, text, extraOpts)
5457
5520
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send classic notification"));
5458
- return;
5521
+ return true;
5459
5522
  }
5460
5523
  // Fallback: send to group without threadId
5461
5524
  if (groupId) {
5462
5525
  adapter.sendText(String(groupId), text, extraOpts)
5463
5526
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
5527
+ return true;
5464
5528
  }
5529
+ this.logger.warn({ instanceName }, "No group id — instance topic notification not sent");
5530
+ return false;
5465
5531
  }
5466
5532
  // ── Nonce-armed button prompts (hang / assist / exit / clear) ──
5467
5533
  //
@@ -9802,72 +9868,81 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9802
9868
  }
9803
9869
  // Fleet API (enriched for agent board)
9804
9870
  if (req.method === "GET" && req.url === "/api/fleet") {
9805
- const sysInfo = this.getSysInfo();
9806
- const fleetInstances = sysInfo.instances.map(inst => ({ ...inst, classic: false }));
9807
- const fleetNames = new Set(fleetInstances.map(inst => inst.name));
9808
- const classicInstances = (this.classicChannels?.getAll() ?? [])
9809
- .filter(channel => !fleetNames.has(channel.instanceName))
9810
- .map(channel => ({
9811
- name: channel.instanceName,
9812
- status: this.getInstanceStatus(channel.instanceName),
9813
- state: this.getInstanceExecutionState(channel.instanceName),
9814
- ipc: this.instanceIpcClients.has(channel.instanceName),
9815
- costCents: this.costGuard?.getDailyCostCents(channel.instanceName) ?? 0,
9816
- rateLimits: this.statuslineWatcher.getRateLimits(channel.instanceName) ?? null,
9817
- classic: true,
9818
- classicName: channel.name,
9819
- channelId: channel.channelId,
9820
- adapterId: channel.adapterId ?? null,
9821
- }));
9822
- const enriched = [...fleetInstances, ...classicInstances].map(inst => {
9823
- const config = this.fleetConfig?.instances[inst.name];
9824
- const persistedInboundAt = readLastInboundAt(this.getInstanceDir(inst.name));
9825
- const lastActivity = inst.classic
9826
- ? Math.max(persistedInboundAt ?? 0, readClassicLastActivityAt(this.dataDir, inst.name) ?? 0) || null
9827
- : (persistedInboundAt ?? this.lastActivityMs(inst.name)) || null;
9828
- const backend = this.backendNameForInstance(inst.name);
9829
- const resolvedModel = this.resolveInstanceModel(inst.name);
9830
- const effortStrategy = this.effortStrategyFor(inst.name);
9831
- const resolvedEffort = this.resolveInstanceEffort(inst.name);
9832
- // Find claimed tasks for this instance
9833
- let currentTask = null;
9834
- try {
9835
- const tasks = this.scheduler?.db.listTasks({ assignee: inst.name, status: "claimed" });
9836
- if (tasks?.length)
9837
- currentTask = tasks[0].title;
9838
- }
9839
- catch (err) {
9840
- this.logger.debug({ err, name: inst.name }, "Scheduler listTasks failed (/api/fleet)");
9871
+ try {
9872
+ const sysInfo = this.getSysInfo();
9873
+ const fleetInstances = sysInfo.instances.map(inst => ({ ...inst, classic: false }));
9874
+ const fleetNames = new Set(fleetInstances.map(inst => inst.name));
9875
+ const classicInstances = (this.classicChannels?.getAll() ?? [])
9876
+ .filter(channel => !fleetNames.has(channel.instanceName))
9877
+ .map(channel => ({
9878
+ name: channel.instanceName,
9879
+ status: this.getInstanceStatus(channel.instanceName),
9880
+ state: this.getInstanceExecutionState(channel.instanceName),
9881
+ ipc: this.instanceIpcClients.has(channel.instanceName),
9882
+ costCents: this.costGuard?.getDailyCostCents(channel.instanceName) ?? 0,
9883
+ rateLimits: this.statuslineWatcher.getRateLimits(channel.instanceName) ?? null,
9884
+ classic: true,
9885
+ classicName: channel.name,
9886
+ channelId: channel.channelId,
9887
+ adapterId: channel.adapterId ?? null,
9888
+ }));
9889
+ const enriched = [...fleetInstances, ...classicInstances].map(inst => {
9890
+ const config = this.fleetConfig?.instances[inst.name];
9891
+ const persistedInboundAt = readLastInboundAt(this.getInstanceDir(inst.name));
9892
+ const lastActivity = inst.classic
9893
+ ? Math.max(persistedInboundAt ?? 0, readClassicLastActivityAt(this.dataDir, inst.name) ?? 0) || null
9894
+ : (persistedInboundAt ?? this.lastActivityMs(inst.name)) || null;
9895
+ const backend = this.backendNameForInstance(inst.name);
9896
+ const resolvedModel = this.resolveInstanceModel(inst.name);
9897
+ const effortStrategy = this.effortStrategyFor(inst.name);
9898
+ const resolvedEffort = this.resolveInstanceEffort(inst.name);
9899
+ // Find claimed tasks for this instance
9900
+ let currentTask = null;
9901
+ try {
9902
+ const tasks = this.scheduler?.db.listTasks({ assignee: inst.name, status: "claimed" });
9903
+ if (tasks?.length)
9904
+ currentTask = tasks[0].title;
9905
+ }
9906
+ catch (err) {
9907
+ this.logger.debug({ err, name: inst.name }, "Scheduler listTasks failed (/api/fleet)");
9908
+ }
9909
+ return {
9910
+ ...inst,
9911
+ description: config?.description ?? ("classicName" in inst ? inst.classicName : null),
9912
+ backend,
9913
+ // Settings renders these runtime-effective values rather than the
9914
+ // sparse user-authored YAML. `auto` means the supported CLI is
9915
+ // using its own effort default; null is reserved for unsupported.
9916
+ model: resolvedModel.model,
9917
+ model_display: resolvedModel.display,
9918
+ model_source: resolvedModel.source,
9919
+ effort: effortStrategy === "unsupported" ? null : (resolvedEffort.effort ?? "auto"),
9920
+ effort_supported: effortStrategy !== "unsupported",
9921
+ tool_set: config?.tool_set ?? "full",
9922
+ general_topic: config?.general_topic ?? false,
9923
+ // User activity is persisted by the daemon, so both the board and
9924
+ // auto-pause retain an accurate age across fleet restarts.
9925
+ lastActivity,
9926
+ currentTask,
9927
+ idle: this.getInstanceIdle(inst.name),
9928
+ state: this.getInstanceExecutionState(inst.name),
9929
+ };
9930
+ });
9931
+ res.setHeader("Access-Control-Allow-Origin", "*");
9932
+ res.writeHead(200);
9933
+ res.end(JSON.stringify({
9934
+ ...sysInfo,
9935
+ version: this.currentVersion,
9936
+ instances: enriched,
9937
+ }));
9938
+ }
9939
+ catch (err) {
9940
+ this.logger.error({ err }, "/api/fleet failed");
9941
+ if (!res.headersSent) {
9942
+ res.writeHead(500);
9943
+ res.end("Internal Server Error");
9841
9944
  }
9842
- return {
9843
- ...inst,
9844
- description: config?.description ?? ("classicName" in inst ? inst.classicName : null),
9845
- backend,
9846
- // Settings renders these runtime-effective values rather than the
9847
- // sparse user-authored YAML. `auto` means the supported CLI is
9848
- // using its own effort default; null is reserved for unsupported.
9849
- model: resolvedModel.model,
9850
- model_display: resolvedModel.display,
9851
- model_source: resolvedModel.source,
9852
- effort: effortStrategy === "unsupported" ? null : (resolvedEffort.effort ?? "auto"),
9853
- effort_supported: effortStrategy !== "unsupported",
9854
- tool_set: config?.tool_set ?? "full",
9855
- general_topic: config?.general_topic ?? false,
9856
- // User activity is persisted by the daemon, so both the board and
9857
- // auto-pause retain an accurate age across fleet restarts.
9858
- lastActivity,
9859
- currentTask,
9860
- idle: this.getInstanceIdle(inst.name),
9861
- state: this.getInstanceExecutionState(inst.name),
9862
- };
9863
- });
9864
- res.setHeader("Access-Control-Allow-Origin", "*");
9865
- res.writeHead(200);
9866
- res.end(JSON.stringify({
9867
- ...sysInfo,
9868
- version: this.currentVersion,
9869
- instances: enriched,
9870
- }));
9945
+ }
9871
9946
  return;
9872
9947
  }
9873
9948
  // Activity API