@songsid/agend 2.0.8-beta.3 → 2.0.8-beta.31

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 (56) hide show
  1. package/README.md +8 -3
  2. package/dist/adapter-world.d.ts +1 -1
  3. package/dist/adapter-world.js +2 -2
  4. package/dist/adapter-world.js.map +1 -1
  5. package/dist/backend/antigravity.js +6 -2
  6. package/dist/backend/antigravity.js.map +1 -1
  7. package/dist/backend/claude-code.js +3 -2
  8. package/dist/backend/claude-code.js.map +1 -1
  9. package/dist/backend/codex.js +14 -5
  10. package/dist/backend/codex.js.map +1 -1
  11. package/dist/backend/kiro.js +4 -2
  12. package/dist/backend/kiro.js.map +1 -1
  13. package/dist/backend/opencode.js.map +1 -1
  14. package/dist/backend/types.d.ts +8 -0
  15. package/dist/backend/types.js +12 -0
  16. package/dist/backend/types.js.map +1 -1
  17. package/dist/channel/adapters/discord.d.ts +5 -1
  18. package/dist/channel/adapters/discord.js +192 -105
  19. package/dist/channel/adapters/discord.js.map +1 -1
  20. package/dist/channel/adapters/telegram.d.ts +3 -0
  21. package/dist/channel/adapters/telegram.js +7 -0
  22. package/dist/channel/adapters/telegram.js.map +1 -1
  23. package/dist/channel/tool-router.js +1 -1
  24. package/dist/channel/tool-router.js.map +1 -1
  25. package/dist/channel/tool-tracker.js +2 -2
  26. package/dist/channel/tool-tracker.js.map +1 -1
  27. package/dist/channel/types.d.ts +12 -2
  28. package/dist/cli.js +150 -95
  29. package/dist/cli.js.map +1 -1
  30. package/dist/daemon.d.ts +31 -1
  31. package/dist/daemon.js +163 -47
  32. package/dist/daemon.js.map +1 -1
  33. package/dist/fleet-context.d.ts +2 -0
  34. package/dist/fleet-manager.d.ts +53 -0
  35. package/dist/fleet-manager.js +514 -139
  36. package/dist/fleet-manager.js.map +1 -1
  37. package/dist/general-knowledge/skills/session-management/SKILL.md +56 -1
  38. package/dist/instance-lifecycle.js +9 -0
  39. package/dist/instance-lifecycle.js.map +1 -1
  40. package/dist/logger.d.ts +9 -1
  41. package/dist/logger.js +17 -7
  42. package/dist/logger.js.map +1 -1
  43. package/dist/outbound-handlers.d.ts +2 -0
  44. package/dist/outbound-handlers.js +13 -0
  45. package/dist/outbound-handlers.js.map +1 -1
  46. package/dist/outbound-schemas.d.ts +1 -1
  47. package/dist/tmux-control.d.ts +10 -0
  48. package/dist/tmux-control.js +29 -0
  49. package/dist/tmux-control.js.map +1 -1
  50. package/dist/tmux-manager.d.ts +7 -1
  51. package/dist/tmux-manager.js +17 -0
  52. package/dist/tmux-manager.js.map +1 -1
  53. package/dist/topic-commands.d.ts +21 -0
  54. package/dist/topic-commands.js +81 -6
  55. package/dist/topic-commands.js.map +1 -1
  56. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync } from "node:fs";
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 { createServer } from "node:http";
4
4
  import { join, dirname, basename } from "node:path";
@@ -22,7 +22,7 @@ import { processAttachments } from "./channel/attachment-handler.js";
22
22
  import { routeToolCall } from "./channel/tool-router.js";
23
23
  import { Scheduler } from "./scheduler/index.js";
24
24
  import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
25
- import { TopicCommands, sanitizeInstanceName } from "./topic-commands.js";
25
+ import { TopicCommands, sanitizeInstanceName, saveCommandForBackend, parseSaveFilename, SAVE_FILENAME_RE, SAVE_UNSUPPORTED_MSG } from "./topic-commands.js";
26
26
  import { DailySummary } from "./daily-summary.js";
27
27
  import { WebhookEmitter } from "./webhook-emitter.js";
28
28
  import { TmuxControlClient } from "./tmux-control.js";
@@ -45,6 +45,10 @@ export function resolveReplyThreadId(argsThreadId, instanceConfig) {
45
45
  }
46
46
  return instanceConfig?.topic_id != null ? String(instanceConfig.topic_id) : undefined;
47
47
  }
48
+ /** Retry cadence for retiring a cancel button whose delete failed (e.g. a DC
49
+ * forum thread the bot momentarily can't reach). 3 retries × 5min = 15min. */
50
+ const CANCEL_BTN_RETRY_INTERVAL_MS = 5 * 60_000;
51
+ const CANCEL_BTN_MAX_RETRIES = 3;
48
52
  export class FleetManager {
49
53
  dataDir;
50
54
  children = new Map();
@@ -78,6 +82,13 @@ export class FleetManager {
78
82
  topicIcons = {};
79
83
  lastActivity = new Map();
80
84
  lastInboundUser = new Map(); // instanceName → last username
85
+ // Active "🛑 Cancel" buttons, tracked per button (keyed by messageId) rather
86
+ // than one-per-instance. A button is retired (deleted, with bounded retry) on
87
+ // reply, on cancel, or when a newer button supersedes it for the same
88
+ // instance. Per-button tracking means a failed delete never strands a button.
89
+ cancelButtons = new Map();
90
+ // Last user message delivered to each instance — used to react ✅ on completion.
91
+ lastInboundMsg = new Map();
81
92
  topicArchiver;
82
93
  controlClient = null;
83
94
  classicChannels = null;
@@ -443,6 +454,10 @@ export class FleetManager {
443
454
  .catch(e => this.logger.warn({ err: e }, "Failed to send daily summary"));
444
455
  // Rotate classic channel chat logs daily
445
456
  this.classicChannels?.rotateLogs();
457
+ this.rotateInboxes();
458
+ // Rotate fleet.log daily too (besides the startup size check above), so a
459
+ // long-running fleet doesn't accumulate an unbounded log.
460
+ rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
446
461
  }, () => {
447
462
  const instances = Object.keys(this.fleetConfig?.instances ?? {});
448
463
  const costMap = new Map();
@@ -454,6 +469,7 @@ export class FleetManager {
454
469
  this.dailySummary.start();
455
470
  // Rotate classic channel chat logs daily (piggyback on daily summary timer)
456
471
  this.classicChannels?.rotateLogs();
472
+ this.rotateInboxes();
457
473
  // Auto-create general instance(s) — one per adapter that lacks a general
458
474
  const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
459
475
  const generalInstances = Object.entries(fleet.instances).filter(([, inst]) => inst.general_topic === true);
@@ -681,6 +697,41 @@ export class FleetManager {
681
697
  };
682
698
  process.once("SIGUSR1", onFullRestart);
683
699
  }
700
+ /**
701
+ * Delete inbox files older than retentionDays (by mtime). Cleans the shared
702
+ * inbox (`<dataDir>/inbox`) and every workspace inbox
703
+ * (`<agendHome>/workspaces/*\/inbox`). Piggybacks on the daily summary timer,
704
+ * mirroring classic chat-log rotation (same 7-day retention).
705
+ */
706
+ rotateInboxes(retentionDays = 7) {
707
+ const cutoff = Date.now() - retentionDays * 86400_000;
708
+ const dirs = [join(this.dataDir, "inbox")];
709
+ const workspacesDir = join(getAgendHome(), "workspaces");
710
+ if (existsSync(workspacesDir)) {
711
+ for (const ws of readdirSync(workspacesDir)) {
712
+ dirs.push(join(workspacesDir, ws, "inbox"));
713
+ }
714
+ }
715
+ let deleted = 0;
716
+ for (const dir of dirs) {
717
+ if (!existsSync(dir))
718
+ continue;
719
+ for (const file of readdirSync(dir)) {
720
+ const full = join(dir, file);
721
+ try {
722
+ const st = statSync(full);
723
+ if (st.isFile() && st.mtimeMs < cutoff) {
724
+ unlinkSync(full);
725
+ deleted++;
726
+ }
727
+ }
728
+ catch { /* file vanished or unreadable — skip */ }
729
+ }
730
+ }
731
+ if (deleted > 0)
732
+ this.logger.info({ deleted }, "Rotated inbox files");
733
+ return deleted;
734
+ }
684
735
  /** Start the shared channel adapter(s) for topic mode */
685
736
  async startSharedAdapter(fleet) {
686
737
  const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
@@ -732,13 +783,22 @@ export class FleetManager {
732
783
  await this.startInstance(instanceName, config, topicMode);
733
784
  // startInstance already calls connectIpcToInstance
734
785
  }
735
- this.adapter?.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`).catch(() => { });
786
+ this.adapter?.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
736
787
  }
737
788
  else {
738
- this.adapter?.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`).catch(() => { });
789
+ this.adapter?.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
739
790
  }
740
791
  return;
741
792
  }
793
+ if (data.callbackData.startsWith("cancel:")) {
794
+ const instanceName = data.callbackData.slice("cancel:".length);
795
+ // Idempotent: a button click only acts while the button is live. A
796
+ // second click (entry already cleared) is a no-op — don't re-send the
797
+ // interrupt key. (The /cancel command path calls cancelInstance directly.)
798
+ if (this.hasCancelButton(instanceName))
799
+ this.cancelInstance(instanceName);
800
+ return;
801
+ }
742
802
  }, this.logger, "adapter.callback_query"));
743
803
  this.adapter.on("topic_closed", safeHandler(async (data) => {
744
804
  // Skip unbind if we archived this topic ourselves
@@ -780,7 +840,11 @@ export class FleetManager {
780
840
  timestamp: new Date(),
781
841
  });
782
842
  }
783
- else if (data.command === "save" || data.command === "load") {
843
+ else if (data.command === "save") {
844
+ await this.handleSlashSave(data);
845
+ }
846
+ else if (data.command === "load") {
847
+ // load is kiro-cli/classic only — no claude-code equivalent.
784
848
  if (!this.classicChannels?.isAdmin(data.userId)) {
785
849
  await data.respond("⛔ This command requires admin access.");
786
850
  return;
@@ -790,25 +854,13 @@ export class FleetManager {
790
854
  await data.respond("No active agent in this channel. Use `/start` first.");
791
855
  return;
792
856
  }
793
- let rawCmd;
794
- if (data.command === "save") {
795
- const filename = data.options?.filename;
796
- if (!/^[\w.-]+$/.test(filename)) {
797
- await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
798
- return;
799
- }
800
- rawCmd = data.options?.force ? `/chat save ${filename} -f` : `/chat save ${filename}`;
801
- }
802
- else {
803
- const filename = data.options?.filename;
804
- if (!/^[\w.-]+$/.test(filename)) {
805
- await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
806
- return;
807
- }
808
- rawCmd = `/chat load ${filename}`;
857
+ const filename = data.options?.filename;
858
+ if (!SAVE_FILENAME_RE.test(filename ?? "")) {
859
+ await data.respond("⛔ Invalid filename only letters, numbers, dots, hyphens, underscores allowed.");
860
+ return;
809
861
  }
810
- this.pasteRawToClassicInstance(target.name, rawCmd);
811
- await data.respond(`✅ Sent \`${rawCmd}\` to ${target.name}`);
862
+ this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
863
+ await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
812
864
  }
813
865
  else if (data.command === "compact") {
814
866
  const target = this.routing.resolve(data.channelId);
@@ -819,48 +871,23 @@ export class FleetManager {
819
871
  const result = await this.topicCommands.sendCompact(target.name);
820
872
  await data.respond(result);
821
873
  }
822
- else if (data.command === "ctx") {
874
+ else if (data.command === "cancel") {
823
875
  const target = this.routing.resolve(data.channelId);
824
876
  if (!target) {
825
877
  await data.respond("No active agent in this channel.");
826
878
  return;
827
879
  }
828
- const instanceName = target.name;
829
- const backend = target.kind === "classic"
830
- ? (this.classicChannels?.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend) ?? "claude-code")
831
- : (this.fleetConfig?.instances[instanceName]?.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
832
- let context = null;
833
- // Try statusline.json first
834
- try {
835
- const statusFile = join(this.getInstanceDir(instanceName), "statusline.json");
836
- if (existsSync(statusFile)) {
837
- const d = JSON.parse(readFileSync(statusFile, "utf-8"));
838
- context = d.context_window?.used_percentage ?? null;
839
- }
840
- }
841
- catch { /* ignore */ }
842
- // Fallback: capture tmux pane
843
- if (context == null) {
844
- try {
845
- const { execFileSync } = await import("node:child_process");
846
- const { getTmuxSocketName } = await import("./paths.js");
847
- const socketName = getTmuxSocketName();
848
- const tmuxArgs = socketName
849
- ? ["-L", socketName, "capture-pane", "-t", `${getTmuxSession()}:${instanceName}`, "-p"]
850
- : ["capture-pane", "-t", `${getTmuxSession()}:${instanceName}`, "-p"];
851
- const pane = execFileSync("tmux", tmuxArgs, { encoding: "utf-8", timeout: 2000, stdio: ["pipe", "pipe", "pipe"] });
852
- const m = pane.match(/(\d+)%.*[!❯>]/m) || pane.match(/◔\s*(\d+)%/) || pane.match(/\[(\d+)%\]/);
853
- if (m)
854
- context = parseInt(m[1], 10);
855
- }
856
- catch { /* ignore */ }
857
- }
858
- if (context != null) {
859
- await data.respond(`📊 Context: ${context}% used\nBackend: ${backend}\nInstance: ${instanceName}`);
860
- }
861
- else {
862
- await data.respond(`Context info not available yet.\nBackend: ${backend}\nInstance: ${instanceName}`);
880
+ const ok = this.cancelInstance(target.name);
881
+ await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
882
+ }
883
+ else if (data.command === "ctx") {
884
+ const target = this.routing.resolve(data.channelId);
885
+ if (!target) {
886
+ await data.respond("No active agent in this channel.");
887
+ return;
863
888
  }
889
+ // Single source of truth (statusline.json + robust tmux pane fallback).
890
+ await data.respond(await this.topicCommands.getCtxText(target.name));
864
891
  }
865
892
  else if (data.command === "collab") {
866
893
  const collabTarget = this.routing.resolve(data.channelId);
@@ -1021,11 +1048,19 @@ export class FleetManager {
1021
1048
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
1022
1049
  await this.startInstance(instanceName, config, topicMode);
1023
1050
  }
1024
- adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`).catch(() => { });
1051
+ adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
1025
1052
  }
1026
1053
  else {
1027
- adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`).catch(() => { });
1054
+ adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
1028
1055
  }
1056
+ return;
1057
+ }
1058
+ if (data.callbackData.startsWith("cancel:")) {
1059
+ const instanceName = data.callbackData.slice("cancel:".length);
1060
+ // Idempotent: only the first click (while the button is live) acts.
1061
+ if (this.hasCancelButton(instanceName))
1062
+ this.cancelInstance(instanceName);
1063
+ return;
1029
1064
  }
1030
1065
  }, this.logger, `adapter[${adapterId}].callback_query`));
1031
1066
  adapter.on("topic_closed", safeHandler(async (data) => {
@@ -1067,7 +1102,11 @@ export class FleetManager {
1067
1102
  timestamp: new Date(),
1068
1103
  });
1069
1104
  }
1070
- else if (data.command === "save" || data.command === "load") {
1105
+ else if (data.command === "save") {
1106
+ await this.handleSlashSave(data);
1107
+ }
1108
+ else if (data.command === "load") {
1109
+ // load is kiro-cli/classic only — no claude-code equivalent.
1071
1110
  if (!this.classicChannels?.isAdmin(data.userId)) {
1072
1111
  await data.respond("⛔ This command requires admin access.");
1073
1112
  return;
@@ -1077,25 +1116,13 @@ export class FleetManager {
1077
1116
  await data.respond("No active agent in this channel. Use `/start` first.");
1078
1117
  return;
1079
1118
  }
1080
- let rawCmd;
1081
- if (data.command === "save") {
1082
- const filename = data.options?.filename;
1083
- if (!/^[\w.-]+$/.test(filename)) {
1084
- await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1085
- return;
1086
- }
1087
- rawCmd = data.options?.force ? `/chat save ${filename} -f` : `/chat save ${filename}`;
1088
- }
1089
- else {
1090
- const filename = data.options?.filename;
1091
- if (!/^[\w.-]+$/.test(filename)) {
1092
- await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1093
- return;
1094
- }
1095
- rawCmd = `/chat load ${filename}`;
1119
+ const filename = data.options?.filename;
1120
+ if (!SAVE_FILENAME_RE.test(filename ?? "")) {
1121
+ await data.respond("⛔ Invalid filename only letters, numbers, dots, hyphens, underscores allowed.");
1122
+ return;
1096
1123
  }
1097
- this.pasteRawToClassicInstance(target.name, rawCmd);
1098
- await data.respond(`✅ Sent \`${rawCmd}\` to ${target.name}`);
1124
+ this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
1125
+ await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
1099
1126
  }
1100
1127
  else if (data.command === "compact") {
1101
1128
  const target = this.routing.resolve(data.channelId);
@@ -1106,31 +1133,23 @@ export class FleetManager {
1106
1133
  const result = await this.topicCommands.sendCompact(target.name);
1107
1134
  await data.respond(result);
1108
1135
  }
1109
- else if (data.command === "ctx") {
1136
+ else if (data.command === "cancel") {
1110
1137
  const target = this.routing.resolve(data.channelId);
1111
1138
  if (!target) {
1112
1139
  await data.respond("No active agent in this channel.");
1113
1140
  return;
1114
1141
  }
1115
- const instanceName = target.name;
1116
- const ctxBackend = target.kind === "classic"
1117
- ? (this.classicChannels?.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend) ?? "claude-code")
1118
- : (this.fleetConfig?.instances[instanceName]?.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
1119
- let context = null;
1120
- try {
1121
- const statusFile = join(this.getInstanceDir(instanceName), "statusline.json");
1122
- if (existsSync(statusFile)) {
1123
- const d = JSON.parse(readFileSync(statusFile, "utf-8"));
1124
- context = d.context_window?.used_percentage ?? null;
1125
- }
1126
- }
1127
- catch { /* ignore */ }
1128
- if (context != null) {
1129
- await data.respond(`📊 Context: ${context}% used\nBackend: ${ctxBackend}\nInstance: ${instanceName}`);
1130
- }
1131
- else {
1132
- await data.respond(`Context info not available yet.\nBackend: ${ctxBackend}\nInstance: ${instanceName}`);
1142
+ const ok = this.cancelInstance(target.name);
1143
+ await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
1144
+ }
1145
+ else if (data.command === "ctx") {
1146
+ const target = this.routing.resolve(data.channelId);
1147
+ if (!target) {
1148
+ await data.respond("No active agent in this channel.");
1149
+ return;
1133
1150
  }
1151
+ // Single source of truth (statusline.json + robust tmux pane fallback).
1152
+ await data.respond(await this.topicCommands.getCtxText(target.name));
1134
1153
  }
1135
1154
  else if (data.command === "collab") {
1136
1155
  const collabTarget2 = this.routing.resolve(data.channelId);
@@ -1498,6 +1517,15 @@ export class FleetManager {
1498
1517
  const isBotMentioned = !!(botUser && text.toLowerCase().includes(`@${botUser.toLowerCase()}`));
1499
1518
  const isPrivateChat = !chatId.startsWith("-"); // Telegram: positive = private, negative = group
1500
1519
  const msgAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1520
+ // In a TG Classic group, ignore bare slash commands (no @bot specified).
1521
+ // Prevents multiple bots all responding to the same /ctx, /compact, etc.
1522
+ // `/cmd@otherbot` already returned above; `/cmd@mybot` set cmdMatch, so it
1523
+ // still processes. Private chat (only one bot) always processes.
1524
+ // NOTE: this also silently drops bare `/start` in a group, so group
1525
+ // onboarding now requires `/start@mybot` — consistent with the policy.
1526
+ if (!isPrivateChat && !cmdMatch && rawText.startsWith("/")) {
1527
+ return; // bare slash in group — ignore silently
1528
+ }
1501
1529
  // Handle /start command
1502
1530
  if (text === "/start" || text.startsWith("/start ")) {
1503
1531
  if (isPrivateChat) {
@@ -1567,6 +1595,17 @@ export class FleetManager {
1567
1595
  await msgAdapter?.sendText(chatId, result);
1568
1596
  return;
1569
1597
  }
1598
+ // Handle /cancel command
1599
+ if (text === "/cancel" || text.startsWith("/cancel@")) {
1600
+ const cancelTarget = this.routing.resolve(chatId);
1601
+ if (!cancelTarget || cancelTarget.kind !== "classic") {
1602
+ await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1603
+ return;
1604
+ }
1605
+ const ok = this.cancelInstance(cancelTarget.name);
1606
+ await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelTarget.name}。` : `❌ ${cancelTarget.name} 未在執行。`);
1607
+ return;
1608
+ }
1570
1609
  // Handle /ctx command
1571
1610
  if (text === "/ctx" || text.startsWith("/ctx@")) {
1572
1611
  const ctxTarget = this.routing.resolve(chatId);
@@ -1578,6 +1617,36 @@ export class FleetManager {
1578
1617
  await msgAdapter?.sendText(chatId, reply);
1579
1618
  return;
1580
1619
  }
1620
+ // Handle /save command (admin only)
1621
+ if (text === "/save" || text.startsWith("/save ") || text.startsWith("/save@")) {
1622
+ if (!this.classicChannels.isAdmin(msg.userId)) {
1623
+ await msgAdapter?.sendText(chatId, "⛔ /save requires admin access.");
1624
+ return;
1625
+ }
1626
+ const saveTarget = this.routing.resolve(chatId);
1627
+ if (!saveTarget || saveTarget.kind !== "classic") {
1628
+ await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1629
+ return;
1630
+ }
1631
+ const filename = parseSaveFilename(text);
1632
+ if (!filename) {
1633
+ await msgAdapter?.sendText(chatId, "Usage: /save <filename>");
1634
+ return;
1635
+ }
1636
+ if (!SAVE_FILENAME_RE.test(filename)) {
1637
+ await msgAdapter?.sendText(chatId, "⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1638
+ return;
1639
+ }
1640
+ const backend = this.classicChannels.getBackendByInstance(saveTarget.name, this.fleetConfig?.defaults?.backend);
1641
+ const cmd = saveCommandForBackend(backend, filename);
1642
+ if (!cmd) {
1643
+ await msgAdapter?.sendText(chatId, SAVE_UNSUPPORTED_MSG);
1644
+ return;
1645
+ }
1646
+ this.pasteRawToClassicInstance(saveTarget.name, cmd);
1647
+ await msgAdapter?.sendText(chatId, `✅ Sent \`${cmd}\` to ${saveTarget.name}`);
1648
+ return;
1649
+ }
1581
1650
  // Route to classic channel if registered
1582
1651
  const target = this.routing.resolve(chatId);
1583
1652
  if (target?.kind === "classic") {
@@ -1654,6 +1723,8 @@ export class FleetManager {
1654
1723
  instance: generalInstance, sender: msg.username,
1655
1724
  text: (text ?? "").slice(0, 2000), ts: new Date().toISOString(),
1656
1725
  });
1726
+ this.trackInboundMsg(generalInstance, msg);
1727
+ void this.sendCancelButton(generalInstance);
1657
1728
  }
1658
1729
  }
1659
1730
  return;
@@ -1691,7 +1762,7 @@ export class FleetManager {
1691
1762
  const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1692
1763
  // React immediately — before any other Discord API calls
1693
1764
  if (msg.chatId && msg.messageId) {
1694
- inboundAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
1765
+ inboundAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
1695
1766
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
1696
1767
  }
1697
1768
  // These may hit Discord API (topic icon, archive) — do after react
@@ -1730,6 +1801,8 @@ export class FleetManager {
1730
1801
  instance: instanceName, sender: msg.username,
1731
1802
  text: (text ?? "").slice(0, 2000), ts: new Date().toISOString(),
1732
1803
  });
1804
+ this.trackInboundMsg(instanceName, msg);
1805
+ void this.sendCancelButton(instanceName);
1733
1806
  }
1734
1807
  /** Handle outbound tool calls from a daemon instance */
1735
1808
  /** Warn (but don't block) when rate limits are high. 30-min debounce per instance. */
@@ -1803,6 +1876,9 @@ export class FleetManager {
1803
1876
  // Route standard channel tools (reply, react, edit_message, download_attachment)
1804
1877
  if (routeToolCall(outAdapter, tool, args, threadId, respond)) {
1805
1878
  if (tool === "reply") {
1879
+ // Agent answered — retire its pending cancel button and mark ✅ done.
1880
+ this.clearCancelButton(instanceName);
1881
+ this.reactDone(instanceName);
1806
1882
  const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
1807
1883
  this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
1808
1884
  this.emitSseEvent("message", {
@@ -1849,7 +1925,7 @@ export class FleetManager {
1849
1925
  if (!chatId)
1850
1926
  return;
1851
1927
  if (editMessageId) {
1852
- statusAdapter.editMessage(chatId, editMessageId, text).catch(e => this.logger.debug({ err: e }, "Failed to edit tool status message"));
1928
+ statusAdapter.editMessage(chatId, editMessageId, text, threadId).catch(e => this.logger.debug({ err: e }, "Failed to edit tool status message"));
1853
1929
  }
1854
1930
  else {
1855
1931
  statusAdapter.sendText(chatId, text, { threadId }).then((sent) => {
@@ -1887,6 +1963,8 @@ export class FleetManager {
1887
1963
  payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
1888
1964
  meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
1889
1965
  });
1966
+ // A scheduled trigger also puts the instance to work — show a cancel button.
1967
+ void this.sendCancelButton(target);
1890
1968
  return true;
1891
1969
  };
1892
1970
  if (deliver()) {
@@ -2512,6 +2590,212 @@ export class FleetManager {
2512
2590
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
2513
2591
  }
2514
2592
  }
2593
+ // ── Cancel button ────────────────────────────────────────────────────
2594
+ // Sent after delivering a user message to an instance; clicking it (or
2595
+ // /cancel) sends Escape to the instance's pane to interrupt generation.
2596
+ /** Send a "🛑 Cancel" button to the instance's topic/channel after delivery. */
2597
+ /**
2598
+ * Handle the DC `/save` slash command for both classic AND fleet-topic targets.
2599
+ * Picks the backend-appropriate command (kiro → /chat save, claude → /export);
2600
+ * unsupported backends get a clear error. Routes via classic paste or fleet IPC.
2601
+ */
2602
+ async handleSlashSave(data) {
2603
+ if (!this.classicChannels?.isAdmin(data.userId)) {
2604
+ await data.respond("⛔ This command requires admin access.");
2605
+ return;
2606
+ }
2607
+ const target = this.routing.resolve(data.channelId);
2608
+ if (!target) {
2609
+ await data.respond("No active agent in this channel. Use `/start` first.");
2610
+ return;
2611
+ }
2612
+ const filename = data.options?.filename ?? "";
2613
+ if (!SAVE_FILENAME_RE.test(filename)) {
2614
+ await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
2615
+ return;
2616
+ }
2617
+ const backend = target.kind === "classic"
2618
+ ? this.classicChannels.getBackendByInstance(target.name, this.fleetConfig?.defaults?.backend)
2619
+ : (this.fleetConfig?.instances[target.name]?.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
2620
+ // force (-f) is only meaningful for kiro/classic /chat save.
2621
+ const force = target.kind === "classic" && !!data.options?.force;
2622
+ const cmd = saveCommandForBackend(backend, filename, force);
2623
+ if (!cmd) {
2624
+ await data.respond(SAVE_UNSUPPORTED_MSG);
2625
+ return;
2626
+ }
2627
+ if (target.kind === "classic") {
2628
+ this.pasteRawToClassicInstance(target.name, cmd);
2629
+ }
2630
+ else {
2631
+ this.instanceIpcClients.get(target.name)?.send({ type: "raw_paste", content: cmd });
2632
+ }
2633
+ await data.respond(`✅ Sent \`${cmd}\` to ${target.name}`);
2634
+ }
2635
+ /** Whether the instance currently has at least one live cancel button. */
2636
+ hasCancelButton(instanceName) {
2637
+ for (const e of this.cancelButtons.values()) {
2638
+ if (e.instanceName === instanceName)
2639
+ return true;
2640
+ }
2641
+ return false;
2642
+ }
2643
+ async sendCancelButton(instanceName) {
2644
+ // At most one button shown per instance: retire any existing ones first
2645
+ // (delete + bounded retry). Each is tracked separately, so a failed delete
2646
+ // here doesn't strand it — it keeps retrying on its own timer.
2647
+ this.retireInstanceButtons(instanceName);
2648
+ const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
2649
+ if (!adapter)
2650
+ return;
2651
+ const adapterId = this.instanceWorldBinding.get(instanceName);
2652
+ const groupId = this.getChannelConfig(adapterId)?.group_id;
2653
+ const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
2654
+ let chatId;
2655
+ let threadId;
2656
+ if (topicId != null && groupId) {
2657
+ // Fleet topic instance.
2658
+ chatId = String(groupId);
2659
+ threadId = String(topicId);
2660
+ }
2661
+ else {
2662
+ // Classic instance: chatId from the routing table.
2663
+ for (const [cid, target] of this.routing.entries()) {
2664
+ if (target.kind === "classic" && target.name === instanceName) {
2665
+ chatId = cid;
2666
+ break;
2667
+ }
2668
+ }
2669
+ // General / flat fallback: post to the group (no thread).
2670
+ if (!chatId && groupId)
2671
+ chatId = String(groupId);
2672
+ }
2673
+ if (!chatId)
2674
+ return;
2675
+ try {
2676
+ const sent = await adapter.notifyAlert(chatId, {
2677
+ type: "cancel",
2678
+ instanceName,
2679
+ message: "👀 處理中…",
2680
+ choices: [{ id: `cancel:${instanceName}`, label: "🛑 取消" }],
2681
+ }, threadId ? { threadId } : undefined);
2682
+ // A concurrent sendCancelButton for the same instance may have posted its
2683
+ // own button while we awaited notifyAlert. Retire any other buttons for
2684
+ // this instance (not the one we just posted) so only the newest shows.
2685
+ for (const other of this.cancelButtons.values()) {
2686
+ if (other.instanceName === instanceName)
2687
+ this.retireButton(other);
2688
+ }
2689
+ this.cancelButtons.set(sent.messageId, {
2690
+ instanceName,
2691
+ adapterId,
2692
+ chatId: sent.chatId,
2693
+ messageId: sent.messageId,
2694
+ threadId: sent.threadId ?? threadId,
2695
+ retryCount: 0,
2696
+ });
2697
+ this.logger.info({ instanceName, messageId: sent.messageId }, "Cancel button sent");
2698
+ }
2699
+ catch (e) {
2700
+ this.logger.warn({ err: e.message, instanceName }, "Failed to send cancel button");
2701
+ }
2702
+ }
2703
+ /** Retire (delete) every cancel button belonging to an instance. */
2704
+ retireInstanceButtons(instanceName) {
2705
+ // Snapshot first — retireButton may delete entries from the map on success.
2706
+ for (const e of [...this.cancelButtons.values()]) {
2707
+ if (e.instanceName === instanceName)
2708
+ this.retireButton(e);
2709
+ }
2710
+ }
2711
+ /** Begin retiring one button (delete + bounded retry on failure). Idempotent:
2712
+ * a button already in a retire cycle is left to its own timer, so a second
2713
+ * retire request (e.g. a new send + the post-await sweep) won't double-delete. */
2714
+ retireButton(entry) {
2715
+ if (entry.retiring)
2716
+ return;
2717
+ entry.retiring = true;
2718
+ this.attemptButtonDelete(entry);
2719
+ }
2720
+ attemptButtonDelete(entry) {
2721
+ this.deleteButtonMessage(entry)
2722
+ .then(() => {
2723
+ if (entry.retryTimer)
2724
+ clearTimeout(entry.retryTimer);
2725
+ this.cancelButtons.delete(entry.messageId);
2726
+ this.logger.info({ instanceName: entry.instanceName, messageId: entry.messageId }, "Cancel button removed");
2727
+ })
2728
+ .catch((err) => this.scheduleButtonRetry(entry, err));
2729
+ }
2730
+ /** Re-attempt a failed button delete up to CANCEL_BTN_MAX_RETRIES times. */
2731
+ scheduleButtonRetry(entry, err) {
2732
+ if (entry.retryCount >= CANCEL_BTN_MAX_RETRIES) {
2733
+ if (entry.retryTimer)
2734
+ clearTimeout(entry.retryTimer);
2735
+ this.cancelButtons.delete(entry.messageId);
2736
+ this.logger.warn({ instanceName: entry.instanceName, messageId: entry.messageId, err: err.message }, `Cancel button delete gave up after ${CANCEL_BTN_MAX_RETRIES} retries`);
2737
+ return;
2738
+ }
2739
+ entry.retryCount++;
2740
+ this.logger.warn({ instanceName: entry.instanceName, messageId: entry.messageId, attempt: entry.retryCount, err: err.message }, "Cancel button delete failed, will retry");
2741
+ if (entry.retryTimer)
2742
+ clearTimeout(entry.retryTimer);
2743
+ // Continue the same retire cycle (bypass the retiring-guard in retireButton).
2744
+ entry.retryTimer = setTimeout(() => this.attemptButtonDelete(entry), CANCEL_BTN_RETRY_INTERVAL_MS);
2745
+ }
2746
+ /** Delete one button's message via its own adapter. Resolves on success,
2747
+ * rejects on failure so the caller can retry. */
2748
+ deleteButtonMessage(e) {
2749
+ const adapter = (e.adapterId ? this.worlds.get(e.adapterId)?.adapter : undefined) ?? this.adapter;
2750
+ if (!adapter)
2751
+ return Promise.reject(new Error("no adapter for cancel button"));
2752
+ if (adapter.deleteMessage)
2753
+ return adapter.deleteMessage(e.chatId, e.messageId, e.threadId);
2754
+ if (adapter.editMessageRemoveButtons)
2755
+ return adapter.editMessageRemoveButtons(e.chatId, e.messageId, "✅", e.threadId);
2756
+ return adapter.editMessage(e.chatId, e.messageId, "✅", e.threadId);
2757
+ }
2758
+ /** Retire all cancel buttons for an instance — on reply, cancel, or report. */
2759
+ clearCancelButton(instanceName) {
2760
+ this.retireInstanceButtons(instanceName);
2761
+ }
2762
+ /**
2763
+ * Reaction target chat id. Telegram reactions key on the supergroup chat_id
2764
+ * (the topic thread is NOT a chat_id), so a forum-topic message must react on
2765
+ * msg.chatId — reacting on threadId silently fails. Discord reactions key on
2766
+ * the channel/thread id.
2767
+ */
2768
+ reactTarget(msg) {
2769
+ return msg.source === "telegram" ? msg.chatId : (msg.threadId ?? msg.chatId);
2770
+ }
2771
+ /** Remember the user message just delivered, so we can react ✅ when done. */
2772
+ trackInboundMsg(instanceName, msg) {
2773
+ if (!msg.chatId || !msg.messageId)
2774
+ return;
2775
+ this.lastInboundMsg.set(instanceName, {
2776
+ adapterId: msg.adapterId, chatId: msg.chatId, threadId: msg.threadId ?? undefined, messageId: msg.messageId, source: msg.source,
2777
+ });
2778
+ }
2779
+ /** React ✅ on the last user message after the agent replies. */
2780
+ reactDone(instanceName) {
2781
+ const m = this.lastInboundMsg.get(instanceName);
2782
+ if (!m)
2783
+ return;
2784
+ this.lastInboundMsg.delete(instanceName);
2785
+ const adapter = (m.adapterId ? this.worlds.get(m.adapterId)?.adapter : undefined)
2786
+ ?? this.getAdapterForInstance(instanceName) ?? this.adapter;
2787
+ adapter?.react(this.reactTarget(m), m.messageId, "✅").catch(() => { });
2788
+ }
2789
+ /** Interrupt an instance's current generation (cancel button / /cancel). */
2790
+ cancelInstance(instanceName) {
2791
+ const daemon = this.daemons.get(instanceName);
2792
+ if (!daemon)
2793
+ return false;
2794
+ daemon.sendEscape().catch(e => this.logger.warn({ err: e, instanceName }, "sendEscape failed"));
2795
+ this.lastInboundMsg.delete(instanceName);
2796
+ this.clearCancelButton(instanceName);
2797
+ return true;
2798
+ }
2515
2799
  queueMirrorMessage(text) {
2516
2800
  const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
2517
2801
  if (mirrorTopicId == null || !this.adapter)
@@ -2850,10 +3134,17 @@ When users create specialized instances, suggest these configurations:
2850
3134
  // Skip empty bot messages (e.g., reactions) — don't pollute chat log
2851
3135
  if (msg.isBotMessage && !text && !msg.attachments?.length)
2852
3136
  return;
3137
+ // Save attachments FIRST so the chat-log records their inbox paths
3138
+ // (consistent with the /chat path). Otherwise a non-@mention image is
3139
+ // saved to inbox but its path never reaches the agent — the log keeps
3140
+ // only a pathless filename, so later context can't locate the file.
3141
+ const saved = msg.attachments?.length ? await this.saveClassicAttachment(instanceName, msg) : undefined;
2853
3142
  // Log every message (including other bots) to chat-logs
2854
- const collabAttachTag = msg.attachments?.length
2855
- ? ` [${msg.attachments.map(a => `${a.kind === "photo" ? "📷" : "📎"} ${a.filename || a.kind}`).join(", ")}]`
2856
- : "";
3143
+ const collabAttachTag = saved
3144
+ ? ` [${saved.kind === "photo" ? "📷" : "📎"} saved: ${saved.paths.join(", ")}]`
3145
+ : (msg.attachments?.length
3146
+ ? ` [${msg.attachments.map(a => `${a.kind === "photo" ? "📷" : "📎"} ${a.filename || a.kind}`).join(", ")}]`
3147
+ : "");
2857
3148
  ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText);
2858
3149
  this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message");
2859
3150
  // Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here
@@ -2861,19 +3152,16 @@ When users create specialized instances, suggest these configurations:
2861
3152
  const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
2862
3153
  const isMentioned = mentionTag && text.includes(mentionTag);
2863
3154
  if (!isMentioned) {
2864
- // Save bare attachments (stickers, images) even without @mention
2865
- if (msg.attachments?.length) {
2866
- const saved = await this.saveClassicAttachment(instanceName, msg);
2867
- if (saved) {
2868
- const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
2869
- const noMentionReactChatId = msg.threadId ?? msg.chatId;
2870
- if (reactAdapter && noMentionReactChatId && msg.messageId) {
2871
- const emoji = msg.source === "telegram"
2872
- ? (saved.kind === "photo" ? "👌" : "👍")
2873
- : (saved.kind === "photo" ? "📸" : "📎");
2874
- reactAdapter.react(noMentionReactChatId, msg.messageId, emoji)
2875
- .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
2876
- }
3155
+ // Bare attachment (no @mention) already saved above; just acknowledge.
3156
+ if (saved) {
3157
+ const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
3158
+ const noMentionReactChatId = msg.threadId ?? msg.chatId;
3159
+ if (reactAdapter && noMentionReactChatId && msg.messageId) {
3160
+ const emoji = msg.source === "telegram"
3161
+ ? (saved.kind === "photo" ? "👌" : "👍")
3162
+ : (saved.kind === "photo" ? "📸" : "📎");
3163
+ reactAdapter.react(noMentionReactChatId, msg.messageId, emoji)
3164
+ .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
2877
3165
  }
2878
3166
  }
2879
3167
  return;
@@ -2891,8 +3179,7 @@ When users create specialized instances, suggest these configurations:
2891
3179
  // Block /raw bypass
2892
3180
  if (cleanText.startsWith("/raw "))
2893
3181
  return;
2894
- // Save and process attachments (same as /chat mode)
2895
- const saved = await this.saveClassicAttachment(instanceName, msg);
3182
+ // Attachments already saved at the top of the collab block.
2896
3183
  if (saved && classicAdapter && collabReactChatId && msg.messageId) {
2897
3184
  const emoji = msg.source === "telegram"
2898
3185
  ? (saved.kind === "photo" ? "👌" : "👍")
@@ -3049,24 +3336,37 @@ When users create specialized instances, suggest these configurations:
3049
3336
  this.logger.warn({ instanceName }, "Classic channel instance IPC not connected");
3050
3337
  return;
3051
3338
  }
3339
+ const meta = {
3340
+ chat_id: msg.chatId,
3341
+ message_id: msg.messageId,
3342
+ user: msg.username,
3343
+ user_id: msg.userId,
3344
+ ts: msg.timestamp.toISOString(),
3345
+ thread_id: msg.threadId ?? "",
3346
+ source: msg.source,
3347
+ ...extraMeta,
3348
+ ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
3349
+ };
3350
+ // If the triggering message carried no image of its own, surface the most
3351
+ // recent image saved earlier in this channel (logged as "[📷 saved: <path>]"
3352
+ // by an untriggered collab message) as image_path, so the agent's
3353
+ // read-the-image trigger fires instead of the path sitting inert in context.
3354
+ if (!meta.image_path && logContext) {
3355
+ const saves = [...logContext.matchAll(/\[📷 saved: ([^\]]+)\]/g)];
3356
+ if (saves.length > 0) {
3357
+ meta.image_path = saves[saves.length - 1][1].split(",")[0].trim();
3358
+ }
3359
+ }
3052
3360
  ipc.send({
3053
3361
  type: "fleet_inbound",
3054
3362
  content: fullText,
3055
3363
  targetSession: instanceName,
3056
- meta: {
3057
- chat_id: msg.chatId,
3058
- message_id: msg.messageId,
3059
- user: msg.username,
3060
- user_id: msg.userId,
3061
- ts: msg.timestamp.toISOString(),
3062
- thread_id: msg.threadId ?? "",
3063
- source: msg.source,
3064
- ...extraMeta,
3065
- ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
3066
- },
3364
+ meta,
3067
3365
  });
3068
3366
  this.lastInboundUser.set(instanceName, msg.username);
3069
3367
  this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
3368
+ this.trackInboundMsg(instanceName, msg);
3369
+ void this.sendCancelButton(instanceName);
3070
3370
  }
3071
3371
  /** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
3072
3372
  pasteRawToClassicInstance(instanceName, text) {
@@ -3186,9 +3486,11 @@ When users create specialized instances, suggest these configurations:
3186
3486
  this.topicArchiver.stop();
3187
3487
  this.scheduler?.shutdown();
3188
3488
  // Stop instances in parallel batches to avoid long sequential waits.
3189
- // Concurrency limited to avoid overwhelming the tmux server.
3190
- const STOP_CONCURRENCY = 5;
3489
+ // Concurrency scales with fleet size larger fleets tolerate more parallel
3490
+ // tmux ops, while small fleets stay conservative to avoid overwhelming the
3491
+ // tmux server.
3191
3492
  const entries = [...this.daemons.entries()];
3493
+ const STOP_CONCURRENCY = entries.length > 30 ? 15 : entries.length >= 10 ? 10 : 5;
3192
3494
  for (const [name] of entries)
3193
3495
  this.ipcStoppingInstances.add(name);
3194
3496
  for (let i = 0; i < entries.length; i += STOP_CONCURRENCY) {
@@ -3203,9 +3505,9 @@ When users create specialized instances, suggest these configurations:
3203
3505
  this.daemons.delete(name);
3204
3506
  }));
3205
3507
  }
3206
- for (const [, ipc] of this.instanceIpcClients) {
3207
- await ipc.close();
3208
- }
3508
+ // Close IPC clients in parallel — serial close over a large fleet adds
3509
+ // noticeable latency.
3510
+ await Promise.all([...this.instanceIpcClients.values()].map(ipc => Promise.resolve(ipc.close()).catch(() => { })));
3209
3511
  this.instanceIpcClients.clear();
3210
3512
  this.ipcStoppingInstances.clear();
3211
3513
  for (const [, w] of this.worlds) {
@@ -3541,14 +3843,21 @@ When users create specialized instances, suggest these configurations:
3541
3843
  const latest = execSync("npm view @songsid/agend version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
3542
3844
  let target = latest;
3543
3845
  if (currentVersion.includes("-beta")) {
3846
+ // Beta users track the @beta channel (never fall back to @latest, which is
3847
+ // older), but should also hear when a newer STABLE ships — pick whichever
3848
+ // of beta/latest is the newest.
3849
+ let beta = "";
3544
3850
  try {
3545
- const beta = execSync("npm view @songsid/agend@beta version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
3546
- if (beta && beta !== currentVersion)
3547
- target = beta;
3851
+ beta = execSync("npm view @songsid/agend@beta version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
3548
3852
  }
3549
3853
  catch { /* no beta tag */ }
3854
+ target = beta || latest;
3855
+ if (latest && this.semverGt(latest, target))
3856
+ target = latest;
3550
3857
  }
3551
- if (target && target !== currentVersion) {
3858
+ // Only notify when target is genuinely newer (semver), so a beta user on
3859
+ // 2.0.8-beta.16 is never told that stable 2.0.7 is "available".
3860
+ if (target && this.semverGt(target, currentVersion)) {
3552
3861
  const generalId = this.findGeneralInstance();
3553
3862
  if (generalId) {
3554
3863
  this.notifyInstanceTopic(generalId, `🆕 AgEnD v${target} available (current: v${currentVersion}). Use \`/update\` to upgrade.`);
@@ -3557,6 +3866,49 @@ When users create specialized instances, suggest these configurations:
3557
3866
  }
3558
3867
  catch { /* silent — network issues */ }
3559
3868
  }
3869
+ /**
3870
+ * Semver "a > b". Compares major.minor.patch numerically; a version without a
3871
+ * prerelease outranks the same core with one (2.0.8 > 2.0.8-beta.16); two
3872
+ * prereleases compare identifier-by-identifier (numeric < alphanumeric, numeric
3873
+ * fields compared as numbers). Sufficient for our X.Y.Z[-beta.N] scheme.
3874
+ */
3875
+ semverGt(a, b) {
3876
+ const parse = (v) => {
3877
+ const [core, pre] = v.replace(/^v/, "").split("-");
3878
+ const nums = core.split(".").map(n => parseInt(n, 10) || 0);
3879
+ return { nums: [nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0], pre: pre ? pre.split(".") : [] };
3880
+ };
3881
+ const pa = parse(a), pb = parse(b);
3882
+ for (let i = 0; i < 3; i++) {
3883
+ if (pa.nums[i] !== pb.nums[i])
3884
+ return pa.nums[i] > pb.nums[i];
3885
+ }
3886
+ if (pa.pre.length === 0 && pb.pre.length === 0)
3887
+ return false;
3888
+ if (pa.pre.length === 0)
3889
+ return true; // a stable, b prerelease → a > b
3890
+ if (pb.pre.length === 0)
3891
+ return false; // a prerelease, b stable → a < b
3892
+ const len = Math.max(pa.pre.length, pb.pre.length);
3893
+ for (let i = 0; i < len; i++) {
3894
+ const x = pa.pre[i], y = pb.pre[i];
3895
+ if (x === undefined)
3896
+ return false; // a has fewer identifiers → a < b
3897
+ if (y === undefined)
3898
+ return true; // a has more identifiers → a > b
3899
+ const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y);
3900
+ if (xn && yn) {
3901
+ const dx = parseInt(x, 10), dy = parseInt(y, 10);
3902
+ if (dx !== dy)
3903
+ return dx > dy;
3904
+ }
3905
+ else if (xn !== yn)
3906
+ return yn; // numeric has lower precedence than alphanumeric
3907
+ else if (x !== y)
3908
+ return x > y; // both alphanumeric
3909
+ }
3910
+ return false; // identical
3911
+ }
3560
3912
  // ── Health HTTP endpoint ─────────────────────────────────────────────
3561
3913
  startHealthServer(port) {
3562
3914
  this.startedAt = Date.now();
@@ -3733,6 +4085,29 @@ When users create specialized instances, suggest these configurations:
3733
4085
  })();
3734
4086
  return;
3735
4087
  }
4088
+ if (req.method === "POST" && req.url?.startsWith("/stop/")) {
4089
+ const name = decodeURIComponent(req.url.slice("/stop/".length));
4090
+ this.logger.info({ name }, "Instance stop requested via HTTP");
4091
+ (async () => {
4092
+ try {
4093
+ // Runs inside the live fleet process: lifecycle.stop finds the
4094
+ // in-memory daemon and stops just this instance. (Doing this from a
4095
+ // detached CLI FleetManager would read the shared daemon.pid — the
4096
+ // fleet's own pid — and kill the whole fleet.)
4097
+ await this.stopInstance(name);
4098
+ this.logger.info({ name }, "Instance stopped");
4099
+ this.emitSseEvent("status", this.getUiStatus());
4100
+ res.writeHead(200);
4101
+ res.end(JSON.stringify({ stopped: name }));
4102
+ }
4103
+ catch (err) {
4104
+ this.logger.error({ err, name }, "Instance stop failed");
4105
+ res.writeHead(500);
4106
+ res.end(JSON.stringify({ error: `Stop failed: ${err.message}` }));
4107
+ }
4108
+ })();
4109
+ return;
4110
+ }
3736
4111
  // ── Agent CLI endpoint ─────
3737
4112
  if (req.url === "/agent" && req.method === "POST") {
3738
4113
  handleAgentRequest(req, res, this);