@songsid/agend 2.1.12-beta.6 → 2.1.12-beta.8

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 (61) hide show
  1. package/dist/agent-cli.js +13 -2
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/backend/claude-code.d.ts +34 -1
  4. package/dist/backend/claude-code.js +123 -0
  5. package/dist/backend/claude-code.js.map +1 -1
  6. package/dist/backend/types.d.ts +21 -0
  7. package/dist/backend/types.js.map +1 -1
  8. package/dist/channel/adapters/discord.d.ts +13 -0
  9. package/dist/channel/adapters/discord.js +43 -0
  10. package/dist/channel/adapters/discord.js.map +1 -1
  11. package/dist/channel/mcp-tools.js +1 -1
  12. package/dist/channel/mcp-tools.js.map +1 -1
  13. package/dist/cli.js +26 -0
  14. package/dist/cli.js.map +1 -1
  15. package/dist/command-table.d.ts +1 -1
  16. package/dist/command-table.js +7 -1
  17. package/dist/command-table.js.map +1 -1
  18. package/dist/config-validator.js +4 -0
  19. package/dist/config-validator.js.map +1 -1
  20. package/dist/cpu-profile.d.ts +32 -0
  21. package/dist/cpu-profile.js +221 -0
  22. package/dist/cpu-profile.js.map +1 -0
  23. package/dist/daemon.d.ts +27 -1
  24. package/dist/daemon.js +131 -30
  25. package/dist/daemon.js.map +1 -1
  26. package/dist/delivery-outbox.d.ts +22 -0
  27. package/dist/delivery-outbox.js +111 -0
  28. package/dist/delivery-outbox.js.map +1 -1
  29. package/dist/fleet-context.d.ts +10 -1
  30. package/dist/fleet-manager.d.ts +66 -2
  31. package/dist/fleet-manager.js +409 -62
  32. package/dist/fleet-manager.js.map +1 -1
  33. package/dist/locale.js +26 -0
  34. package/dist/locale.js.map +1 -1
  35. package/dist/outbound-handlers.d.ts +2 -0
  36. package/dist/outbound-handlers.js +8 -0
  37. package/dist/outbound-handlers.js.map +1 -1
  38. package/dist/outbound-schemas.d.ts +15 -1
  39. package/dist/outbound-schemas.js +9 -3
  40. package/dist/outbound-schemas.js.map +1 -1
  41. package/dist/pane-input-residue.d.ts +2 -0
  42. package/dist/pane-input-residue.js +5 -1
  43. package/dist/pane-input-residue.js.map +1 -1
  44. package/dist/profile-control.d.ts +20 -0
  45. package/dist/profile-control.js +308 -0
  46. package/dist/profile-control.js.map +1 -0
  47. package/dist/runtime-cpu-profile.d.ts +37 -0
  48. package/dist/runtime-cpu-profile.js +123 -0
  49. package/dist/runtime-cpu-profile.js.map +1 -0
  50. package/dist/scheduler/db.d.ts +29 -4
  51. package/dist/scheduler/db.js +96 -6
  52. package/dist/scheduler/db.js.map +1 -1
  53. package/dist/scheduler/types.d.ts +33 -0
  54. package/dist/service-installer.d.ts +6 -0
  55. package/dist/service-installer.js +18 -2
  56. package/dist/service-installer.js.map +1 -1
  57. package/dist/topic-commands.d.ts +12 -1
  58. package/dist/topic-commands.js +33 -1
  59. package/dist/topic-commands.js.map +1 -1
  60. package/dist/types.d.ts +4 -0
  61. package/package.json +1 -1
@@ -1,4 +1,6 @@
1
1
  import { measureSyncWork } from "./sync-work-attribution.js";
2
+ import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
3
+ import { ProfileControlServer } from "./profile-control.js";
2
4
  import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, constants as fsConstants } from "node:fs";
3
5
  import { createHash, randomBytes, randomUUID } from "node:crypto";
4
6
  import { spawnSync } from "node:child_process";
@@ -57,6 +59,51 @@ import { DeliveryOutbox } from "./delivery-outbox.js";
57
59
  // active manager/target generation pair owns the lane until the daemon reports
58
60
  // a state transition or that generation is replaced.
59
61
  export const DURABLE_DELIVERY_LANE_ALERT_MS = 35 * 60_000;
62
+ /**
63
+ * #1335: Cap an unfiltered task list at 100 rows (most recently updated first).
64
+ * Exported so the production branch can be tested directly without starting a fleet.
65
+ * Filtered calls pass-through unchanged. Empty strings count as "not set" (P3).
66
+ * #1336: generic over the row shape so it works on both full Task and compact rows.
67
+ */
68
+ export const TASK_LIST_CAP = 100;
69
+ /**
70
+ * #1336: the non-terminal statuses a bare `list` returns by default. `done`
71
+ * and `cancelled` are excluded unless the caller passes `filter_status`.
72
+ */
73
+ export const LIVE_TASK_STATUSES = ["open", "claimed", "blocked"];
74
+ /**
75
+ * #1336 P2: shared normalization for filter_status across both task handlers
76
+ * and the cap helper. Trims whitespace before dropping empties, so
77
+ * " \t " and [" ", "\t"] both normalize to undefined (not treated as explicit
78
+ * filters). The same result drives the live-only default and the cap decision.
79
+ */
80
+ export function normalizeStatusFilter(v) {
81
+ if (typeof v === "string") {
82
+ const s = v.trim();
83
+ return s || undefined;
84
+ }
85
+ if (Array.isArray(v) && v.every(x => typeof x === "string")) {
86
+ const arr = [...new Set(v.map(s => s.trim()).filter(s => s.length > 0))];
87
+ return arr.length > 0 ? arr : undefined;
88
+ }
89
+ return undefined;
90
+ }
91
+ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
92
+ // Use the shared normalizer so " \t " is treated identically to undefined.
93
+ const normalized = normalizeStatusFilter(filterStatus);
94
+ const hasStatusFilter = Array.isArray(normalized) ? normalized.length > 0 : !!normalized;
95
+ const isFiltered = !!filterAssignee || hasStatusFilter;
96
+ if (!isFiltered && tasks.length > TASK_LIST_CAP) {
97
+ const omitted = tasks.length - TASK_LIST_CAP;
98
+ tasks.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
99
+ return {
100
+ tasks: tasks.slice(0, TASK_LIST_CAP),
101
+ omitted,
102
+ hint: `${omitted} older task(s) omitted — use filter_assignee or filter_status to narrow results`,
103
+ };
104
+ }
105
+ return tasks;
106
+ }
60
107
  import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
61
108
  import { handleViewRequest, isViewPath } from "./view-api.js";
62
109
  import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
@@ -571,6 +618,7 @@ export class FleetManager {
571
618
  /** Injectable only to keep the chat→CLI reload hand-off deterministic in tests. */
572
619
  fullRestartLauncher = launchFullRestartHelper;
573
620
  eventLogPruneTimer = null;
621
+ outboxPruneTimer = null;
574
622
  logRotateTimer = null;
575
623
  discordPresenceTimer = null;
576
624
  discordPresenceEagerTimer = null;
@@ -1543,6 +1591,85 @@ export class FleetManager {
1543
1591
  child.once("error", err => this.failUpdateProgress(err.message));
1544
1592
  child.unref();
1545
1593
  }
1594
+ async handleProfileSlash(data, adapterId) {
1595
+ const route = this.routing.resolve(data.channelId);
1596
+ await this.handleGeneralProfile(route?.kind === "general" ? route.name : undefined, data.userId, adapterId, data.options?.seconds, data.respond);
1597
+ }
1598
+ /** Typed /profile belongs only to Telegram's General dispatcher. */
1599
+ async runProfileCommand(msg, seconds) {
1600
+ if (msg.source !== "telegram")
1601
+ return;
1602
+ const adapterId = msg.adapterId ?? this.getPrimaryAdapterId();
1603
+ const route = msg.threadId ? this.routing.resolve(msg.threadId) : undefined;
1604
+ const general = msg.threadId ? (route?.kind === "general" ? route.name : undefined)
1605
+ : Object.keys(this.fleetConfig?.instances ?? {}).find(name => this.fleetConfig.instances[name].general_topic
1606
+ && this.getInstanceAdapterId(name) === adapterId);
1607
+ const adapter = adapterId ? this.adapters.get(adapterId) : this.adapter;
1608
+ if (!adapter)
1609
+ return;
1610
+ await this.handleGeneralProfile(general, msg.userId, adapterId, seconds, async (text) => (await adapter.sendText(msg.chatId, text, { threadId: msg.threadId })).messageId);
1611
+ }
1612
+ async handleGeneralProfile(general, userId, ingressAdapterId, seconds, respond) {
1613
+ if (!general || !this.fleetConfig?.instances[general]?.general_topic) {
1614
+ await respond(t("profile.general_only"));
1615
+ return;
1616
+ }
1617
+ const ownerId = this.getInstanceAdapterId(general);
1618
+ if (!ownerId || ownerId !== ingressAdapterId) {
1619
+ await respond(t("not_authorized"));
1620
+ return;
1621
+ }
1622
+ const gate = this.fleetAdminGate(userId, ownerId);
1623
+ if (gate !== "ok") {
1624
+ await respond(t(gate === "disabled" ? "profile.disabled" : "not_authorized"));
1625
+ return;
1626
+ }
1627
+ const adapter = this.getAdapterForInstance(general);
1628
+ const group = String(this.getGroupIdForInstance(general) ?? "");
1629
+ const topic = this.fleetConfig.instances[general].topic_id?.toString();
1630
+ if (!adapter || this.worlds.get(ownerId)?.adapter !== adapter || !group) {
1631
+ await respond(t("profile.unavailable"));
1632
+ return;
1633
+ }
1634
+ let duration;
1635
+ try {
1636
+ duration = profileDuration(seconds);
1637
+ }
1638
+ catch {
1639
+ await respond(t("profile.invalid"));
1640
+ return;
1641
+ }
1642
+ let ticket;
1643
+ try {
1644
+ ticket = await this.startCpuProfile(duration);
1645
+ }
1646
+ catch (err) {
1647
+ await respond(err instanceof ProfileBusyError ? t("profile.busy", String(err.remainingSeconds)) : t("profile.unavailable"));
1648
+ return;
1649
+ }
1650
+ // A long recording must not hold a Discord interaction (or the inbound handler) open.
1651
+ const initial = Promise.resolve().then(() => respond(t("profile.started", String(ticket.seconds))));
1652
+ void (async () => {
1653
+ await initial.catch(err => this.logger.warn({ err }, "CPU profile acknowledgement failed"));
1654
+ let message;
1655
+ try {
1656
+ const result = await ticket.done;
1657
+ const size = result.bytes === null ? t("profile.size_unknown") : `${(result.bytes / 1048576).toFixed(2)} MiB`;
1658
+ message = t("profile.saved", result.path, size);
1659
+ }
1660
+ catch {
1661
+ message = t("profile.failed");
1662
+ }
1663
+ // Never send a delayed artifact path to a replacement adapter/topic/world.
1664
+ if (this.shuttingDown || this.getInstanceAdapterId(general) !== ownerId
1665
+ || this.getAdapterForInstance(general) !== adapter || String(this.getGroupIdForInstance(general) ?? "") !== group
1666
+ || this.fleetConfig?.instances[general]?.topic_id?.toString() !== topic
1667
+ || !this.fleetConfig?.instances[general]?.general_topic)
1668
+ return;
1669
+ await adapter.sendText(group, message, { threadId: topic });
1670
+ })().catch(err => this.logger.warn({ err }, "CPU profile General notice failed"));
1671
+ await initial.catch(() => { });
1672
+ }
1546
1673
  async handleRestartSlash(data, adapterId) {
1547
1674
  if (!this.isFleetAdmin(data.userId, adapterId)) {
1548
1675
  await data.respond(t("not_authorized"));
@@ -2314,6 +2441,36 @@ export class FleetManager {
2314
2441
  return "disabled";
2315
2442
  return this.isFleetAdmin(userId, adapterId) ? "ok" : "denied";
2316
2443
  }
2444
+ runtimeCpuProfiler = null;
2445
+ cpuProfileControl = null;
2446
+ getCpuProfiler() {
2447
+ if (!this.runtimeCpuProfiler || (this.runtimeCpuProfiler.closed && !this.shuttingDown)) {
2448
+ this.runtimeCpuProfiler = new RuntimeCpuProfiler({ dataDir: this.dataDir,
2449
+ logger: { info: message => this.logger.info(message), warn: message => this.logger.warn(message) } });
2450
+ }
2451
+ return this.runtimeCpuProfiler;
2452
+ }
2453
+ /** Called only by cold CLI startup, after claiming the fleet singleton. */
2454
+ async startCpuProfileControl() {
2455
+ if (this.shuttingDown || this.cpuProfileControl)
2456
+ return;
2457
+ const control = new ProfileControlServer(this.dataDir, this.getCpuProfiler());
2458
+ this.cpuProfileControl = control; // shutdown owns even an in-flight listen
2459
+ try {
2460
+ await control.listen();
2461
+ }
2462
+ catch (err) {
2463
+ if (this.cpuProfileControl === control)
2464
+ this.cpuProfileControl = null;
2465
+ throw err;
2466
+ }
2467
+ }
2468
+ startEnvironmentCpuProfile() { return this.getCpuProfiler().startFromEnvironment(); }
2469
+ startCpuProfile(seconds) {
2470
+ if (this.shuttingDown)
2471
+ return Promise.reject(new Error("Fleet is stopping."));
2472
+ return this.getCpuProfiler().start(seconds);
2473
+ }
2317
2474
  /**
2318
2475
  * Every Discord slash command, for every adapter, in one place (it used to be two copies that could drift).
2319
2476
  *
@@ -2446,6 +2603,9 @@ export class FleetManager {
2446
2603
  else if (data.command === "update") {
2447
2604
  await this.handleUpdateSlash(data, adapterId);
2448
2605
  }
2606
+ else if (data.command === "profile") {
2607
+ await this.handleProfileSlash(data, adapterId);
2608
+ }
2449
2609
  else if (data.command === "doctor") {
2450
2610
  await data.respond(await this.runBackendDoctor());
2451
2611
  }
@@ -4116,6 +4276,11 @@ export class FleetManager {
4116
4276
  this.pruneEventLog();
4117
4277
  this.eventLogPruneTimer = setInterval(() => this.pruneEventLog(), 24 * 60 * 60_000);
4118
4278
  this.eventLogPruneTimer.unref?.();
4279
+ // #1335: delivery-outbox.db and Task Board retention. Same once-at-startup
4280
+ // + daily-timer pattern; chunked DELETEs so one run cannot hold the loop.
4281
+ void this.pruneOutboxAndTasks();
4282
+ this.outboxPruneTimer = setInterval(() => { void this.pruneOutboxAndTasks(); }, 24 * 60 * 60_000);
4283
+ this.outboxPruneTimer.unref?.();
4119
4284
  // Same shape for logs, and for the same reason: the only sweep that
4120
4285
  // covered them lived inside the daily-summary callback, so it did not run at
4121
4286
  // all when summaries were off.
@@ -5274,6 +5439,35 @@ export class FleetManager {
5274
5439
  * the message came from — its own world's group_id. Two Telegram fleet
5275
5440
  * worlds can number topics alike; a coincidence is dropped, never delivered.
5276
5441
  */
5442
+ /**
5443
+ * #1346: whether this copy may claim the shared dedup key for
5444
+ * command-like text. Fleet topics resolve an owning adapter and only its
5445
+ * copy proceeds; anything else (classic targets, unknown routing, no
5446
+ * identity) passes through to the existing handling.
5447
+ */
5448
+ isOwnerCommandCopy(msg, threadId) {
5449
+ if (!msg.adapterId || threadId === undefined)
5450
+ return true;
5451
+ const target = this.resolveInboundTarget(msg, threadId);
5452
+ if (!target || target.kind === "classic")
5453
+ return true;
5454
+ const owner = this.getInstanceAdapterId(target.name);
5455
+ if (owner && msg.adapterId !== owner)
5456
+ return false;
5457
+ // #1346 6b: /cmd@otherbot is addressed elsewhere — ignore it even on the
5458
+ // owner's copy. An unknown username can't be judged: let it through (the
5459
+ // receiver gate above still applies). Same case-insensitive rule as the
5460
+ // Telegram classic branch.
5461
+ // Hyphenated aliases count too (e.g. /install-cli): Telegram command
5462
+ // names cannot contain "-", but typed aliases can.
5463
+ const suffix = msg.text?.trim().match(/^\/[\w-]+@(\S+)/)?.[1];
5464
+ if (suffix && owner) {
5465
+ const ownerUser = this.worlds.get(owner)?.botUsername;
5466
+ if (ownerUser && suffix.toLowerCase() !== ownerUser.toLowerCase())
5467
+ return false;
5468
+ }
5469
+ return true;
5470
+ }
5277
5471
  resolveInboundTarget(msg, threadId) {
5278
5472
  if (msg.source !== "telegram")
5279
5473
  return this.routing.resolve(threadId);
@@ -5337,6 +5531,16 @@ export class FleetManager {
5337
5531
  // EXCEPTION — classic channels with same-channel multi-bot: two bots may own
5338
5532
  // separate agents in one channel, so each bot must process its OWN copy of
5339
5533
  // the message (the @mention filter downstream decides who actually forwards).
5534
+ // #1346: a non-owner adapter's copy of command-like text must not burn
5535
+ // the shared dedup key — the owner's copy still has to run the command.
5536
+ // (Non-command input keeps first-wins delivery.) Classic channels keep
5537
+ // per-adapter keys — first-/start-contact onboarding needs every copy
5538
+ // to flow — so only fleet targets are judged here; the in-handler gates
5539
+ // (owner entry check, Discord ignore) cover classic instead.
5540
+ if (msg.messageId && /^\/\w/.test(msg.text?.trim() ?? "") && !this.isOwnerCommandCopy(msg, threadId)) {
5541
+ this.logger.debug({ adapterId: msg.adapterId, threadId }, "Non-owner command copy — skipping before dedup claim");
5542
+ return;
5543
+ }
5340
5544
  // Key the dedup per-adapter there so a sibling bot's copy isn't dropped.
5341
5545
  if (msg.messageId) {
5342
5546
  const classicCid = threadId || msg.chatId;
@@ -5665,11 +5869,14 @@ export class FleetManager {
5665
5869
  // Unregistered group: ignore
5666
5870
  return;
5667
5871
  }
5668
- // General topic: check /ctx /compact /collab first, then admin commands
5872
+ // General topic: Discord /xxx gets the system note first (never a
5873
+ // command); other sources fall through to the handlers below.
5669
5874
  const generalInstance = this.findGeneralInstance(msg.adapterId);
5875
+ if (generalInstance && await this.replyDiscordNotACommand(msg, generalInstance))
5876
+ return;
5670
5877
  if (generalInstance && await this.topicCommands.handleInstanceCommand(msg, generalInstance))
5671
5878
  return;
5672
- if (await this.topicCommands.handleGeneralCommand(msg))
5879
+ if (generalInstance && await this.topicCommands.handleGeneralCommand(msg, generalInstance))
5673
5880
  return;
5674
5881
  // Forward to General Topic instance if configured
5675
5882
  if (generalInstance) {
@@ -5754,13 +5961,18 @@ export class FleetManager {
5754
5961
  return;
5755
5962
  }
5756
5963
  const instanceName = target.name;
5964
+ // #1346: on Discord, plain-text /xxx runs no command — the owner posts
5965
+ // the system note before the handlers below ever see the text.
5966
+ if (await this.replyDiscordNotACommand(msg, instanceName)) {
5967
+ return;
5968
+ }
5757
5969
  // Intercept /ctx /compact /collab in ANY topic (including general)
5758
5970
  if (await this.topicCommands.handleInstanceCommand(msg, instanceName)) {
5759
5971
  return;
5760
5972
  }
5761
5973
  // Intercept admin commands (/status, /restart, /sysinfo) in general topics
5762
5974
  const instanceConfig = this.fleetConfig?.instances[instanceName];
5763
- if (instanceConfig?.general_topic && await this.topicCommands.handleGeneralCommand(msg)) {
5975
+ if (instanceConfig?.general_topic && await this.topicCommands.handleGeneralCommand(msg, instanceName)) {
5764
5976
  return;
5765
5977
  }
5766
5978
  // Bind instance to the adapter that delivered this message
@@ -6636,45 +6848,74 @@ export class FleetManager {
6636
6848
  const asStatus = (v) => {
6637
6849
  return (v === "open" || v === "claimed" || v === "done" || v === "blocked" || v === "cancelled") ? v : undefined;
6638
6850
  };
6639
- switch (action) {
6640
- case "create": {
6641
- const title = asStr(args.title);
6642
- if (!title)
6643
- return { error: "title is required" };
6644
- return db.createTask({
6645
- title,
6646
- description: asStr(args.description),
6647
- priority: asPriority(args.priority),
6648
- assignee: asStr(args.assignee),
6649
- depends_on: asStrArr(args.depends_on),
6650
- created_by: instance,
6651
- });
6652
- }
6653
- case "list": return db.listTasks({ assignee: asStr(args.filter_assignee), status: asStr(args.filter_status) });
6654
- case "claim": {
6655
- const id = asStr(args.id);
6656
- if (!id)
6657
- return { error: "id is required" };
6658
- return db.claimTask(id, instance);
6659
- }
6660
- case "done": {
6661
- const id = asStr(args.id);
6662
- if (!id)
6663
- return { error: "id is required" };
6664
- return db.completeTask(id, asStr(args.result));
6665
- }
6666
- case "update": {
6667
- const id = asStr(args.id);
6668
- if (!id)
6669
- return { error: "id is required" };
6670
- return db.updateTask(id, {
6671
- status: asStatus(args.status),
6672
- assignee: asStr(args.assignee),
6673
- result: asStr(args.result),
6674
- priority: asPriority(args.priority),
6675
- });
6851
+ // #1336 P2: use the shared normalizer — trims whitespace AND drops empties,
6852
+ // so " \t " / [" ","\t"] are treated identically to undefined.
6853
+ const asStatusFilter = normalizeStatusFilter;
6854
+ // #1336: small write ack — identifying fields only.
6855
+ const ack = (t) => ({ id: t.id, status: t.status, updated_at: t.updated_at });
6856
+ try {
6857
+ switch (action) {
6858
+ case "create": {
6859
+ const title = asStr(args.title);
6860
+ if (!title)
6861
+ return { error: "title is required" };
6862
+ return ack(db.createTask({
6863
+ title,
6864
+ description: asStr(args.description),
6865
+ priority: asPriority(args.priority),
6866
+ assignee: asStr(args.assignee),
6867
+ depends_on: asStrArr(args.depends_on),
6868
+ created_by: instance,
6869
+ }));
6870
+ }
6871
+ case "list": {
6872
+ const filterAssignee = asStr(args.filter_assignee) || undefined;
6873
+ const explicitStatus = asStatusFilter(args.filter_status);
6874
+ // #1336 item 1: default to live-only (no done/cancelled) unless the
6875
+ // caller explicitly asked for a status.
6876
+ const effectiveStatus = explicitStatus ?? LIVE_TASK_STATUSES;
6877
+ const verbose = args.verbose === true;
6878
+ const tasks = verbose
6879
+ ? db.listTasks({ assignee: filterAssignee, status: effectiveStatus, verbose: true })
6880
+ : db.listTasks({ assignee: filterAssignee, status: effectiveStatus });
6881
+ // #1335: cap unfiltered list. The default live-only filter does not
6882
+ // count as an explicit filter for the cap decision.
6883
+ return applyTaskListCap(tasks, filterAssignee, explicitStatus);
6884
+ }
6885
+ case "get": {
6886
+ const id = asStr(args.id);
6887
+ if (!id)
6888
+ return { error: "id is required" };
6889
+ return db.getTaskByPrefix(id);
6890
+ }
6891
+ case "claim": {
6892
+ const id = asStr(args.id);
6893
+ if (!id)
6894
+ return { error: "id is required" };
6895
+ return ack(db.claimTask(db.getTaskByPrefix(id).id, instance));
6896
+ }
6897
+ case "done": {
6898
+ const id = asStr(args.id);
6899
+ if (!id)
6900
+ return { error: "id is required" };
6901
+ return ack(db.completeTask(db.getTaskByPrefix(id).id, asStr(args.result)));
6902
+ }
6903
+ case "update": {
6904
+ const id = asStr(args.id);
6905
+ if (!id)
6906
+ return { error: "id is required" };
6907
+ return ack(db.updateTask(db.getTaskByPrefix(id).id, {
6908
+ status: asStatus(args.status),
6909
+ assignee: asStr(args.assignee),
6910
+ result: asStr(args.result),
6911
+ priority: asPriority(args.priority),
6912
+ }));
6913
+ }
6914
+ default: return { error: `Unknown task action: ${action}` };
6676
6915
  }
6677
- default: return { error: `Unknown task action: ${action}` };
6916
+ }
6917
+ catch (err) {
6918
+ return { error: err.message };
6678
6919
  }
6679
6920
  }
6680
6921
  async handleSetDisplayNameHttp(instance, name) {
@@ -6745,11 +6986,17 @@ export class FleetManager {
6745
6986
  }
6746
6987
  const db = this.scheduler.db;
6747
6988
  const action = payload.action;
6989
+ // #1336 P2: use the shared normalizer — trims whitespace AND drops empties,
6990
+ // so " \t " / [" ","\t"] are treated identically to undefined.
6991
+ const asStatusFilter = normalizeStatusFilter;
6992
+ const ack = (t) => ({ id: t.id, status: t.status, updated_at: t.updated_at });
6748
6993
  try {
6749
6994
  let result;
6995
+ // Full task kept for activity logging; the IPC reply uses the compact ack.
6996
+ let logTask;
6750
6997
  switch (action) {
6751
6998
  case "create":
6752
- result = db.createTask({
6999
+ logTask = db.createTask({
6753
7000
  title: payload.title,
6754
7001
  description: payload.description,
6755
7002
  priority: payload.priority,
@@ -6757,43 +7004,57 @@ export class FleetManager {
6757
7004
  depends_on: payload.depends_on,
6758
7005
  created_by: meta.instance_name || instanceName,
6759
7006
  });
7007
+ result = ack(logTask);
6760
7008
  break;
6761
- case "list":
6762
- result = db.listTasks({
6763
- assignee: payload.filter_assignee,
6764
- status: payload.filter_status,
6765
- });
7009
+ case "list": {
7010
+ // P3: normalize empty strings to undefined — SchedulerDb.listTasks
7011
+ // ignores them but the cap logic must treat them as "not filtered".
7012
+ const filterAssignee = payload.filter_assignee || undefined;
7013
+ const explicitStatus = asStatusFilter(payload.filter_status);
7014
+ // #1336 item 1: default to live-only (no done/cancelled).
7015
+ const effectiveStatus = explicitStatus ?? LIVE_TASK_STATUSES;
7016
+ const verbose = payload.verbose === true;
7017
+ const tasks = verbose
7018
+ ? db.listTasks({ assignee: filterAssignee, status: effectiveStatus, verbose: true })
7019
+ : db.listTasks({ assignee: filterAssignee, status: effectiveStatus });
7020
+ // #1335: cap unfiltered list — the default live-only filter does not
7021
+ // count as an explicit filter.
7022
+ result = applyTaskListCap(tasks, filterAssignee, explicitStatus);
7023
+ break;
7024
+ }
7025
+ case "get":
7026
+ result = db.getTaskByPrefix(payload.id);
6766
7027
  break;
6767
7028
  case "claim":
6768
- result = db.claimTask(payload.id, meta.instance_name || instanceName);
7029
+ logTask = db.claimTask(db.getTaskByPrefix(payload.id).id, meta.instance_name || instanceName);
7030
+ result = ack(logTask);
6769
7031
  break;
6770
7032
  case "done":
6771
- result = db.completeTask(payload.id, payload.result);
7033
+ logTask = db.completeTask(db.getTaskByPrefix(payload.id).id, payload.result);
7034
+ result = ack(logTask);
6772
7035
  break;
6773
7036
  case "update":
6774
- result = db.updateTask(payload.id, {
7037
+ logTask = db.updateTask(db.getTaskByPrefix(payload.id).id, {
6775
7038
  status: payload.status,
6776
7039
  assignee: payload.assignee,
6777
7040
  result: payload.result,
6778
7041
  priority: payload.priority,
6779
7042
  });
7043
+ result = ack(logTask);
6780
7044
  break;
6781
7045
  default:
6782
7046
  throw new Error(`Unknown task action: ${action}`);
6783
7047
  }
6784
7048
  ipc.send({ type: "fleet_task_response", fleetRequestId, result });
6785
7049
  // Activity log for task lifecycle events
6786
- if (action === "create") {
6787
- const t = result;
6788
- this.eventLog?.logActivity("task_update", instanceName, `created task: ${t.title}`, t.assignee ?? undefined);
7050
+ if (action === "create" && logTask) {
7051
+ this.eventLog?.logActivity("task_update", instanceName, `created task: ${logTask.title}`, logTask.assignee ?? undefined);
6789
7052
  }
6790
- else if (action === "claim") {
6791
- const t = result;
6792
- this.eventLog?.logActivity("task_update", instanceName, `claimed: ${t.title}`);
7053
+ else if (action === "claim" && logTask) {
7054
+ this.eventLog?.logActivity("task_update", instanceName, `claimed: ${logTask.title}`);
6793
7055
  }
6794
- else if (action === "done") {
6795
- const t = result;
6796
- this.eventLog?.logActivity("task_update", instanceName, `completed: ${t.title}`, undefined, t.result ?? undefined);
7056
+ else if (action === "done" && logTask) {
7057
+ this.eventLog?.logActivity("task_update", instanceName, `completed: ${logTask.title}`, undefined, logTask.result ?? undefined);
6797
7058
  }
6798
7059
  }
6799
7060
  catch (err) {
@@ -8165,6 +8426,42 @@ export class FleetManager {
8165
8426
  this.logger.warn({ err }, "Event log prune failed");
8166
8427
  }
8167
8428
  }
8429
+ /** #1335: Prune delivery-outbox.db and Task Board in chunked async passes. */
8430
+ async pruneOutboxAndTasks() {
8431
+ const days = this.fleetConfig?.defaults?.retention_days ?? 30;
8432
+ // Outbox prune
8433
+ const outbox = this.deliveryOutbox;
8434
+ if (outbox?.isOpen) {
8435
+ try {
8436
+ const { pruned, durationMs } = await outbox.prune(days);
8437
+ if (pruned > 0) {
8438
+ this.logger.info({ pruned, durationMs: Math.round(durationMs) }, "Delivery outbox pruned");
8439
+ }
8440
+ }
8441
+ catch (err) {
8442
+ this.logger.warn({ err }, "Delivery outbox prune failed");
8443
+ }
8444
+ }
8445
+ // Task Board prune
8446
+ if (this.scheduler?.db) {
8447
+ try {
8448
+ const pruned = await this.scheduler.db.pruneOldTasks(days);
8449
+ if (pruned > 0) {
8450
+ this.logger.info({ pruned }, "Task board pruned");
8451
+ }
8452
+ }
8453
+ catch (err) {
8454
+ this.logger.warn({ err }, "Task board prune failed");
8455
+ }
8456
+ }
8457
+ }
8458
+ /**
8459
+ * #1335: True when the delivery_id was in the outbox, was pruned by
8460
+ * retention, and the caller is the original source or target (#1340 P2 🔒).
8461
+ */
8462
+ wasDeliveryIdPrunedForCaller(deliveryId, callerInstance) {
8463
+ return this.deliveryOutbox?.wasDeliveryIdPrunedForCaller(deliveryId, callerInstance) ?? false;
8464
+ }
8168
8465
  openEventLog() {
8169
8466
  const dbPath = join(this.dataDir, "events.db");
8170
8467
  try {
@@ -10941,13 +11238,50 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
10941
11238
  this.failoverActive.clear();
10942
11239
  }
10943
11240
  // ── Classic Channel Methods ──────────────────────────────────────────
11241
+ /**
11242
+ * #1346: on Discord, plain-text /xxx is never a command — the slash menu
11243
+ * is. Both fleet call sites run this BEFORE the command handlers, so even
11244
+ * the owning adapter's copy never executes text. The owning adapter posts
11245
+ * one system note and consumes the message so it never reaches the agent;
11246
+ * other adapters' copies stay silent (and the shared dedup key means
11247
+ * exactly one copy gets here). Telegram keeps text commands working —
11248
+ * text is its command path — and other sources pass through untouched.
11249
+ */
11250
+ async replyDiscordNotACommand(msg, instanceName) {
11251
+ if (msg.source !== "discord")
11252
+ return false;
11253
+ if (!/^\/\w/.test(msg.text?.trim() ?? ""))
11254
+ return false;
11255
+ const owner = this.getInstanceAdapterId(instanceName);
11256
+ if (msg.adapterId && owner && msg.adapterId !== owner)
11257
+ return false;
11258
+ const adapter = this.worlds.get(owner ?? msg.adapterId ?? "")?.adapter ?? this.adapter;
11259
+ if (!adapter)
11260
+ return false;
11261
+ await adapter.sendText(msg.chatId, t("cmd.not_a_command"), { threadId: msg.threadId });
11262
+ return true;
11263
+ }
10944
11264
  /** Handle a message in a classic channel: log it, forward only /chat messages */
10945
11265
  async handleClassicChannelMessage(instanceName, msg) {
10946
11266
  const text = msg.text ?? "";
10947
11267
  const channelId = msg.threadId ?? msg.chatId;
10948
11268
  const isCollabMode = this.classicChannels?.isCollab(channelId, msg.adapterId) ?? false;
10949
- // Handle /ctx in classic mode — always, regardless of collab mode
11269
+ // #1346: Discord ClassicBot channels ignore plain-text /xxx silently —
11270
+ // no warning (multi-bot groups must not all warn), no command, and no
11271
+ // forward to the agent either. The received-reaction and chat-log paths
11272
+ // below still run (a /chat ack is not a reply). Telegram keeps working
11273
+ // (text is its command path there).
11274
+ const discordSlashText = msg.source === "discord" && /^\/\w/.test(text.trim());
11275
+ // Handle /ctx in classic mode — always, regardless of collab mode.
11276
+ // Only the adapter that owns an entry in this channel answers; the
11277
+ // per-adapter dedup key already scopes copies, this guards the rest.
11278
+ // Discord /chat still flows through (its received-reaction below is an
11279
+ // ack, not a reply); only its forward is skipped.
11280
+ if (discordSlashText && !/^\/chat(\s|$)/.test(text.trim()))
11281
+ return;
10950
11282
  if (text === "/ctx" || text.startsWith("/ctx@")) {
11283
+ if (!this.classicChannels?.getInstanceByChannel(channelId, msg.adapterId))
11284
+ return;
10951
11285
  const reply = await this.topicCommands.getCtxText(instanceName);
10952
11286
  const classicAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
10953
11287
  if (classicAdapter)
@@ -11049,7 +11383,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11049
11383
  finalText = `${tags}\n${finalText}`;
11050
11384
  }
11051
11385
  }
11052
- await this.forwardToClassicInstance(instanceName, finalText, msg, extraMeta);
11386
+ // #1346: Discord typed /xxx is never forwarded (reacts above already ran).
11387
+ if (!discordSlashText)
11388
+ await this.forwardToClassicInstance(instanceName, finalText, msg, extraMeta);
11053
11389
  return;
11054
11390
  }
11055
11391
  // Normal mode: /chat trigger
@@ -11116,7 +11452,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11116
11452
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
11117
11453
  }
11118
11454
  }
11119
- await this.forwardToClassicInstance(instanceName, finalText, msg, extraMeta);
11455
+ // #1346: Discord typed /chat gets its received-reaction above but is
11456
+ // never forwarded to the agent.
11457
+ if (!discordSlashText)
11458
+ await this.forwardToClassicInstance(instanceName, finalText, msg, extraMeta);
11120
11459
  }
11121
11460
  /** Download photo or document attachment to classic instance workspace inbox. Returns { path, kind } or undefined. */
11122
11461
  async saveClassicAttachment(instanceName, msg) {
@@ -12554,6 +12893,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12554
12893
  // because we asked it to. Set synchronously — doStopAll runs to its first
12555
12894
  // await in the same tick as the signal handler, so no event can slip in.
12556
12895
  this.shuttingDown = true;
12896
+ const profileStopped = this.runtimeCpuProfiler?.shutdown("fleet shutdown");
12557
12897
  this.ipcStoppingInstances.add("__fleet_stopping__");
12558
12898
  // Release held delivery promises before awaiting daemon shutdown, then
12559
12899
  // reject spawn work which has not started. Otherwise a storm backoff could
@@ -12582,6 +12922,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12582
12922
  // A login/install window is a dedicated tmux server with its own TTL
12583
12923
  // timer and HTTP listener living in THIS process: without an explicit
12584
12924
  // shutdown it would outlive us as an owner-less login CLI (sol B3).
12925
+ await profileStopped;
12926
+ await this.cpuProfileControl?.close();
12927
+ this.cpuProfileControl = null;
12585
12928
  await this.shutdownLoginWindows();
12586
12929
  // Cancel adapter retry timers
12587
12930
  for (const state of this.adapterState.values()) {
@@ -12604,6 +12947,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12604
12947
  clearInterval(this.eventLogPruneTimer);
12605
12948
  this.eventLogPruneTimer = null;
12606
12949
  }
12950
+ if (this.outboxPruneTimer) {
12951
+ clearInterval(this.outboxPruneTimer);
12952
+ this.outboxPruneTimer = null;
12953
+ }
12607
12954
  if (this.replyObligationTimer) {
12608
12955
  clearInterval(this.replyObligationTimer);
12609
12956
  this.replyObligationTimer = null;