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

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 +41 -0
  35. package/dist/fleet-manager.js +471 -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";
@@ -78,6 +78,11 @@ export class FleetManager {
78
78
  topicIcons = {};
79
79
  lastActivity = new Map();
80
80
  lastInboundUser = new Map(); // instanceName → last username
81
+ // Pending "🛑 Cancel" button per instance — sent after a message is delivered,
82
+ // retired when the agent replies, when cancelled, or after a timeout.
83
+ pendingCancelMessages = new Map();
84
+ // Last user message delivered to each instance — used to react ✅ on completion.
85
+ lastInboundMsg = new Map();
81
86
  topicArchiver;
82
87
  controlClient = null;
83
88
  classicChannels = null;
@@ -443,6 +448,10 @@ export class FleetManager {
443
448
  .catch(e => this.logger.warn({ err: e }, "Failed to send daily summary"));
444
449
  // Rotate classic channel chat logs daily
445
450
  this.classicChannels?.rotateLogs();
451
+ this.rotateInboxes();
452
+ // Rotate fleet.log daily too (besides the startup size check above), so a
453
+ // long-running fleet doesn't accumulate an unbounded log.
454
+ rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
446
455
  }, () => {
447
456
  const instances = Object.keys(this.fleetConfig?.instances ?? {});
448
457
  const costMap = new Map();
@@ -454,6 +463,7 @@ export class FleetManager {
454
463
  this.dailySummary.start();
455
464
  // Rotate classic channel chat logs daily (piggyback on daily summary timer)
456
465
  this.classicChannels?.rotateLogs();
466
+ this.rotateInboxes();
457
467
  // Auto-create general instance(s) — one per adapter that lacks a general
458
468
  const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
459
469
  const generalInstances = Object.entries(fleet.instances).filter(([, inst]) => inst.general_topic === true);
@@ -681,6 +691,41 @@ export class FleetManager {
681
691
  };
682
692
  process.once("SIGUSR1", onFullRestart);
683
693
  }
694
+ /**
695
+ * Delete inbox files older than retentionDays (by mtime). Cleans the shared
696
+ * inbox (`<dataDir>/inbox`) and every workspace inbox
697
+ * (`<agendHome>/workspaces/*\/inbox`). Piggybacks on the daily summary timer,
698
+ * mirroring classic chat-log rotation (same 7-day retention).
699
+ */
700
+ rotateInboxes(retentionDays = 7) {
701
+ const cutoff = Date.now() - retentionDays * 86400_000;
702
+ const dirs = [join(this.dataDir, "inbox")];
703
+ const workspacesDir = join(getAgendHome(), "workspaces");
704
+ if (existsSync(workspacesDir)) {
705
+ for (const ws of readdirSync(workspacesDir)) {
706
+ dirs.push(join(workspacesDir, ws, "inbox"));
707
+ }
708
+ }
709
+ let deleted = 0;
710
+ for (const dir of dirs) {
711
+ if (!existsSync(dir))
712
+ continue;
713
+ for (const file of readdirSync(dir)) {
714
+ const full = join(dir, file);
715
+ try {
716
+ const st = statSync(full);
717
+ if (st.isFile() && st.mtimeMs < cutoff) {
718
+ unlinkSync(full);
719
+ deleted++;
720
+ }
721
+ }
722
+ catch { /* file vanished or unreadable — skip */ }
723
+ }
724
+ }
725
+ if (deleted > 0)
726
+ this.logger.info({ deleted }, "Rotated inbox files");
727
+ return deleted;
728
+ }
684
729
  /** Start the shared channel adapter(s) for topic mode */
685
730
  async startSharedAdapter(fleet) {
686
731
  const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
@@ -732,13 +777,22 @@ export class FleetManager {
732
777
  await this.startInstance(instanceName, config, topicMode);
733
778
  // startInstance already calls connectIpcToInstance
734
779
  }
735
- this.adapter?.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`).catch(() => { });
780
+ this.adapter?.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
736
781
  }
737
782
  else {
738
- this.adapter?.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`).catch(() => { });
783
+ this.adapter?.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
739
784
  }
740
785
  return;
741
786
  }
787
+ if (data.callbackData.startsWith("cancel:")) {
788
+ const instanceName = data.callbackData.slice("cancel:".length);
789
+ // Idempotent: a button click only acts while the button is live. A
790
+ // second click (entry already cleared) is a no-op — don't re-send the
791
+ // interrupt key. (The /cancel command path calls cancelInstance directly.)
792
+ if (this.pendingCancelMessages.has(instanceName))
793
+ this.cancelInstance(instanceName);
794
+ return;
795
+ }
742
796
  }, this.logger, "adapter.callback_query"));
743
797
  this.adapter.on("topic_closed", safeHandler(async (data) => {
744
798
  // Skip unbind if we archived this topic ourselves
@@ -780,7 +834,11 @@ export class FleetManager {
780
834
  timestamp: new Date(),
781
835
  });
782
836
  }
783
- else if (data.command === "save" || data.command === "load") {
837
+ else if (data.command === "save") {
838
+ await this.handleSlashSave(data);
839
+ }
840
+ else if (data.command === "load") {
841
+ // load is kiro-cli/classic only — no claude-code equivalent.
784
842
  if (!this.classicChannels?.isAdmin(data.userId)) {
785
843
  await data.respond("⛔ This command requires admin access.");
786
844
  return;
@@ -790,25 +848,13 @@ export class FleetManager {
790
848
  await data.respond("No active agent in this channel. Use `/start` first.");
791
849
  return;
792
850
  }
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}`;
851
+ const filename = data.options?.filename;
852
+ if (!SAVE_FILENAME_RE.test(filename ?? "")) {
853
+ await data.respond("⛔ Invalid filename only letters, numbers, dots, hyphens, underscores allowed.");
854
+ return;
809
855
  }
810
- this.pasteRawToClassicInstance(target.name, rawCmd);
811
- await data.respond(`✅ Sent \`${rawCmd}\` to ${target.name}`);
856
+ this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
857
+ await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
812
858
  }
813
859
  else if (data.command === "compact") {
814
860
  const target = this.routing.resolve(data.channelId);
@@ -819,48 +865,23 @@ export class FleetManager {
819
865
  const result = await this.topicCommands.sendCompact(target.name);
820
866
  await data.respond(result);
821
867
  }
822
- else if (data.command === "ctx") {
868
+ else if (data.command === "cancel") {
823
869
  const target = this.routing.resolve(data.channelId);
824
870
  if (!target) {
825
871
  await data.respond("No active agent in this channel.");
826
872
  return;
827
873
  }
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}`);
874
+ const ok = this.cancelInstance(target.name);
875
+ await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
876
+ }
877
+ else if (data.command === "ctx") {
878
+ const target = this.routing.resolve(data.channelId);
879
+ if (!target) {
880
+ await data.respond("No active agent in this channel.");
881
+ return;
863
882
  }
883
+ // Single source of truth (statusline.json + robust tmux pane fallback).
884
+ await data.respond(await this.topicCommands.getCtxText(target.name));
864
885
  }
865
886
  else if (data.command === "collab") {
866
887
  const collabTarget = this.routing.resolve(data.channelId);
@@ -1021,11 +1042,19 @@ export class FleetManager {
1021
1042
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
1022
1043
  await this.startInstance(instanceName, config, topicMode);
1023
1044
  }
1024
- adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`).catch(() => { });
1045
+ adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
1025
1046
  }
1026
1047
  else {
1027
- adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`).catch(() => { });
1048
+ adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
1028
1049
  }
1050
+ return;
1051
+ }
1052
+ if (data.callbackData.startsWith("cancel:")) {
1053
+ const instanceName = data.callbackData.slice("cancel:".length);
1054
+ // Idempotent: only the first click (while the button is live) acts.
1055
+ if (this.pendingCancelMessages.has(instanceName))
1056
+ this.cancelInstance(instanceName);
1057
+ return;
1029
1058
  }
1030
1059
  }, this.logger, `adapter[${adapterId}].callback_query`));
1031
1060
  adapter.on("topic_closed", safeHandler(async (data) => {
@@ -1067,7 +1096,11 @@ export class FleetManager {
1067
1096
  timestamp: new Date(),
1068
1097
  });
1069
1098
  }
1070
- else if (data.command === "save" || data.command === "load") {
1099
+ else if (data.command === "save") {
1100
+ await this.handleSlashSave(data);
1101
+ }
1102
+ else if (data.command === "load") {
1103
+ // load is kiro-cli/classic only — no claude-code equivalent.
1071
1104
  if (!this.classicChannels?.isAdmin(data.userId)) {
1072
1105
  await data.respond("⛔ This command requires admin access.");
1073
1106
  return;
@@ -1077,25 +1110,13 @@ export class FleetManager {
1077
1110
  await data.respond("No active agent in this channel. Use `/start` first.");
1078
1111
  return;
1079
1112
  }
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}`;
1113
+ const filename = data.options?.filename;
1114
+ if (!SAVE_FILENAME_RE.test(filename ?? "")) {
1115
+ await data.respond("⛔ Invalid filename only letters, numbers, dots, hyphens, underscores allowed.");
1116
+ return;
1096
1117
  }
1097
- this.pasteRawToClassicInstance(target.name, rawCmd);
1098
- await data.respond(`✅ Sent \`${rawCmd}\` to ${target.name}`);
1118
+ this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
1119
+ await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
1099
1120
  }
1100
1121
  else if (data.command === "compact") {
1101
1122
  const target = this.routing.resolve(data.channelId);
@@ -1106,31 +1127,23 @@ export class FleetManager {
1106
1127
  const result = await this.topicCommands.sendCompact(target.name);
1107
1128
  await data.respond(result);
1108
1129
  }
1109
- else if (data.command === "ctx") {
1130
+ else if (data.command === "cancel") {
1110
1131
  const target = this.routing.resolve(data.channelId);
1111
1132
  if (!target) {
1112
1133
  await data.respond("No active agent in this channel.");
1113
1134
  return;
1114
1135
  }
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}`);
1136
+ const ok = this.cancelInstance(target.name);
1137
+ await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
1138
+ }
1139
+ else if (data.command === "ctx") {
1140
+ const target = this.routing.resolve(data.channelId);
1141
+ if (!target) {
1142
+ await data.respond("No active agent in this channel.");
1143
+ return;
1133
1144
  }
1145
+ // Single source of truth (statusline.json + robust tmux pane fallback).
1146
+ await data.respond(await this.topicCommands.getCtxText(target.name));
1134
1147
  }
1135
1148
  else if (data.command === "collab") {
1136
1149
  const collabTarget2 = this.routing.resolve(data.channelId);
@@ -1498,6 +1511,15 @@ export class FleetManager {
1498
1511
  const isBotMentioned = !!(botUser && text.toLowerCase().includes(`@${botUser.toLowerCase()}`));
1499
1512
  const isPrivateChat = !chatId.startsWith("-"); // Telegram: positive = private, negative = group
1500
1513
  const msgAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1514
+ // In a TG Classic group, ignore bare slash commands (no @bot specified).
1515
+ // Prevents multiple bots all responding to the same /ctx, /compact, etc.
1516
+ // `/cmd@otherbot` already returned above; `/cmd@mybot` set cmdMatch, so it
1517
+ // still processes. Private chat (only one bot) always processes.
1518
+ // NOTE: this also silently drops bare `/start` in a group, so group
1519
+ // onboarding now requires `/start@mybot` — consistent with the policy.
1520
+ if (!isPrivateChat && !cmdMatch && rawText.startsWith("/")) {
1521
+ return; // bare slash in group — ignore silently
1522
+ }
1501
1523
  // Handle /start command
1502
1524
  if (text === "/start" || text.startsWith("/start ")) {
1503
1525
  if (isPrivateChat) {
@@ -1567,6 +1589,17 @@ export class FleetManager {
1567
1589
  await msgAdapter?.sendText(chatId, result);
1568
1590
  return;
1569
1591
  }
1592
+ // Handle /cancel command
1593
+ if (text === "/cancel" || text.startsWith("/cancel@")) {
1594
+ const cancelTarget = this.routing.resolve(chatId);
1595
+ if (!cancelTarget || cancelTarget.kind !== "classic") {
1596
+ await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1597
+ return;
1598
+ }
1599
+ const ok = this.cancelInstance(cancelTarget.name);
1600
+ await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelTarget.name}。` : `❌ ${cancelTarget.name} 未在執行。`);
1601
+ return;
1602
+ }
1570
1603
  // Handle /ctx command
1571
1604
  if (text === "/ctx" || text.startsWith("/ctx@")) {
1572
1605
  const ctxTarget = this.routing.resolve(chatId);
@@ -1578,6 +1611,36 @@ export class FleetManager {
1578
1611
  await msgAdapter?.sendText(chatId, reply);
1579
1612
  return;
1580
1613
  }
1614
+ // Handle /save command (admin only)
1615
+ if (text === "/save" || text.startsWith("/save ") || text.startsWith("/save@")) {
1616
+ if (!this.classicChannels.isAdmin(msg.userId)) {
1617
+ await msgAdapter?.sendText(chatId, "⛔ /save requires admin access.");
1618
+ return;
1619
+ }
1620
+ const saveTarget = this.routing.resolve(chatId);
1621
+ if (!saveTarget || saveTarget.kind !== "classic") {
1622
+ await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1623
+ return;
1624
+ }
1625
+ const filename = parseSaveFilename(text);
1626
+ if (!filename) {
1627
+ await msgAdapter?.sendText(chatId, "Usage: /save <filename>");
1628
+ return;
1629
+ }
1630
+ if (!SAVE_FILENAME_RE.test(filename)) {
1631
+ await msgAdapter?.sendText(chatId, "⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1632
+ return;
1633
+ }
1634
+ const backend = this.classicChannels.getBackendByInstance(saveTarget.name, this.fleetConfig?.defaults?.backend);
1635
+ const cmd = saveCommandForBackend(backend, filename);
1636
+ if (!cmd) {
1637
+ await msgAdapter?.sendText(chatId, SAVE_UNSUPPORTED_MSG);
1638
+ return;
1639
+ }
1640
+ this.pasteRawToClassicInstance(saveTarget.name, cmd);
1641
+ await msgAdapter?.sendText(chatId, `✅ Sent \`${cmd}\` to ${saveTarget.name}`);
1642
+ return;
1643
+ }
1581
1644
  // Route to classic channel if registered
1582
1645
  const target = this.routing.resolve(chatId);
1583
1646
  if (target?.kind === "classic") {
@@ -1654,6 +1717,8 @@ export class FleetManager {
1654
1717
  instance: generalInstance, sender: msg.username,
1655
1718
  text: (text ?? "").slice(0, 2000), ts: new Date().toISOString(),
1656
1719
  });
1720
+ this.trackInboundMsg(generalInstance, msg);
1721
+ void this.sendCancelButton(generalInstance);
1657
1722
  }
1658
1723
  }
1659
1724
  return;
@@ -1691,7 +1756,7 @@ export class FleetManager {
1691
1756
  const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1692
1757
  // React immediately — before any other Discord API calls
1693
1758
  if (msg.chatId && msg.messageId) {
1694
- inboundAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
1759
+ inboundAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
1695
1760
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
1696
1761
  }
1697
1762
  // These may hit Discord API (topic icon, archive) — do after react
@@ -1730,6 +1795,8 @@ export class FleetManager {
1730
1795
  instance: instanceName, sender: msg.username,
1731
1796
  text: (text ?? "").slice(0, 2000), ts: new Date().toISOString(),
1732
1797
  });
1798
+ this.trackInboundMsg(instanceName, msg);
1799
+ void this.sendCancelButton(instanceName);
1733
1800
  }
1734
1801
  /** Handle outbound tool calls from a daemon instance */
1735
1802
  /** Warn (but don't block) when rate limits are high. 30-min debounce per instance. */
@@ -1803,6 +1870,9 @@ export class FleetManager {
1803
1870
  // Route standard channel tools (reply, react, edit_message, download_attachment)
1804
1871
  if (routeToolCall(outAdapter, tool, args, threadId, respond)) {
1805
1872
  if (tool === "reply") {
1873
+ // Agent answered — retire its pending cancel button and mark ✅ done.
1874
+ this.clearCancelButton(instanceName);
1875
+ this.reactDone(instanceName);
1806
1876
  const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
1807
1877
  this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
1808
1878
  this.emitSseEvent("message", {
@@ -1849,7 +1919,7 @@ export class FleetManager {
1849
1919
  if (!chatId)
1850
1920
  return;
1851
1921
  if (editMessageId) {
1852
- statusAdapter.editMessage(chatId, editMessageId, text).catch(e => this.logger.debug({ err: e }, "Failed to edit tool status message"));
1922
+ statusAdapter.editMessage(chatId, editMessageId, text, threadId).catch(e => this.logger.debug({ err: e }, "Failed to edit tool status message"));
1853
1923
  }
1854
1924
  else {
1855
1925
  statusAdapter.sendText(chatId, text, { threadId }).then((sent) => {
@@ -1887,6 +1957,8 @@ export class FleetManager {
1887
1957
  payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
1888
1958
  meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
1889
1959
  });
1960
+ // A scheduled trigger also puts the instance to work — show a cancel button.
1961
+ void this.sendCancelButton(target);
1890
1962
  return true;
1891
1963
  };
1892
1964
  if (deliver()) {
@@ -2512,6 +2584,175 @@ export class FleetManager {
2512
2584
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
2513
2585
  }
2514
2586
  }
2587
+ // ── Cancel button ────────────────────────────────────────────────────
2588
+ // Sent after delivering a user message to an instance; clicking it (or
2589
+ // /cancel) sends Escape to the instance's pane to interrupt generation.
2590
+ /** Send a "🛑 Cancel" button to the instance's topic/channel after delivery. */
2591
+ /**
2592
+ * Handle the DC `/save` slash command for both classic AND fleet-topic targets.
2593
+ * Picks the backend-appropriate command (kiro → /chat save, claude → /export);
2594
+ * unsupported backends get a clear error. Routes via classic paste or fleet IPC.
2595
+ */
2596
+ async handleSlashSave(data) {
2597
+ if (!this.classicChannels?.isAdmin(data.userId)) {
2598
+ await data.respond("⛔ This command requires admin access.");
2599
+ return;
2600
+ }
2601
+ const target = this.routing.resolve(data.channelId);
2602
+ if (!target) {
2603
+ await data.respond("No active agent in this channel. Use `/start` first.");
2604
+ return;
2605
+ }
2606
+ const filename = data.options?.filename ?? "";
2607
+ if (!SAVE_FILENAME_RE.test(filename)) {
2608
+ await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
2609
+ return;
2610
+ }
2611
+ const backend = target.kind === "classic"
2612
+ ? this.classicChannels.getBackendByInstance(target.name, this.fleetConfig?.defaults?.backend)
2613
+ : (this.fleetConfig?.instances[target.name]?.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
2614
+ // force (-f) is only meaningful for kiro/classic /chat save.
2615
+ const force = target.kind === "classic" && !!data.options?.force;
2616
+ const cmd = saveCommandForBackend(backend, filename, force);
2617
+ if (!cmd) {
2618
+ await data.respond(SAVE_UNSUPPORTED_MSG);
2619
+ return;
2620
+ }
2621
+ if (target.kind === "classic") {
2622
+ this.pasteRawToClassicInstance(target.name, cmd);
2623
+ }
2624
+ else {
2625
+ this.instanceIpcClients.get(target.name)?.send({ type: "raw_paste", content: cmd });
2626
+ }
2627
+ await data.respond(`✅ Sent \`${cmd}\` to ${target.name}`);
2628
+ }
2629
+ async sendCancelButton(instanceName) {
2630
+ // Replace any stale button for this instance first.
2631
+ this.clearCancelButton(instanceName);
2632
+ const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
2633
+ if (!adapter)
2634
+ return;
2635
+ const adapterId = this.instanceWorldBinding.get(instanceName);
2636
+ const groupId = this.getChannelConfig(adapterId)?.group_id;
2637
+ const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
2638
+ let chatId;
2639
+ let threadId;
2640
+ if (topicId != null && groupId) {
2641
+ // Fleet topic instance.
2642
+ chatId = String(groupId);
2643
+ threadId = String(topicId);
2644
+ }
2645
+ else {
2646
+ // Classic instance: chatId from the routing table.
2647
+ for (const [cid, target] of this.routing.entries()) {
2648
+ if (target.kind === "classic" && target.name === instanceName) {
2649
+ chatId = cid;
2650
+ break;
2651
+ }
2652
+ }
2653
+ // General / flat fallback: post to the group (no thread).
2654
+ if (!chatId && groupId)
2655
+ chatId = String(groupId);
2656
+ }
2657
+ if (!chatId)
2658
+ return;
2659
+ try {
2660
+ const sent = await adapter.notifyAlert(chatId, {
2661
+ type: "cancel",
2662
+ instanceName,
2663
+ message: "👀 處理中…",
2664
+ choices: [{ id: `cancel:${instanceName}`, label: "🛑 取消" }],
2665
+ }, threadId ? { threadId } : undefined);
2666
+ const entry = { adapterId, chatId: sent.chatId, messageId: sent.messageId, threadId: sent.threadId ?? threadId };
2667
+ // A concurrent sendCancelButton for the same instance may have installed a
2668
+ // pending entry while we awaited notifyAlert — we BOTH passed the
2669
+ // clear-before-send when the slot was still empty. Retire whatever is there
2670
+ // now (delete its message + clear its timer) so overwriting the slot doesn't
2671
+ // orphan that button or leak its timer.
2672
+ const prev = this.pendingCancelMessages.get(instanceName);
2673
+ if (prev) {
2674
+ clearTimeout(prev.timer);
2675
+ this.deleteCancelMessage(prev);
2676
+ }
2677
+ // Hard cap: delete THIS specific button after 30min. The timer targets its
2678
+ // own captured message (not "whatever is pending for the instance" — which
2679
+ // could be a different, later button), so a replaced/orphaned button is
2680
+ // still reliably cleaned even if the immediate delete above ever missed it.
2681
+ const timer = setTimeout(() => {
2682
+ this.logger.debug({ instanceName, messageId: entry.messageId }, "Cancel button 30min cap fired");
2683
+ this.deleteCancelMessage(entry);
2684
+ const cur = this.pendingCancelMessages.get(instanceName);
2685
+ if (cur && cur.messageId === entry.messageId)
2686
+ this.pendingCancelMessages.delete(instanceName);
2687
+ }, 30 * 60_000);
2688
+ this.pendingCancelMessages.set(instanceName, { ...entry, timer });
2689
+ }
2690
+ catch (e) {
2691
+ this.logger.debug({ err: e.message, instanceName }, "Failed to send cancel button");
2692
+ }
2693
+ }
2694
+ /** Delete a specific cancel-button message (best-effort, via its own adapter). */
2695
+ deleteCancelMessage(e) {
2696
+ const adapter = (e.adapterId ? this.worlds.get(e.adapterId)?.adapter : undefined) ?? this.adapter;
2697
+ if (!adapter)
2698
+ return;
2699
+ if (adapter.deleteMessage) {
2700
+ adapter.deleteMessage(e.chatId, e.messageId, e.threadId)
2701
+ .catch((err) => this.logger.debug({ err: err.message }, "Failed to delete cancel button"));
2702
+ }
2703
+ else if (adapter.editMessageRemoveButtons) {
2704
+ adapter.editMessageRemoveButtons(e.chatId, e.messageId, "✅", e.threadId).catch(() => { });
2705
+ }
2706
+ else {
2707
+ adapter.editMessage(e.chatId, e.messageId, "✅", e.threadId).catch(() => { });
2708
+ }
2709
+ }
2710
+ /** Retire (delete) the pending cancel button — on reply, cancel, report, or 30min cap. */
2711
+ clearCancelButton(instanceName) {
2712
+ const pending = this.pendingCancelMessages.get(instanceName);
2713
+ if (!pending)
2714
+ return;
2715
+ clearTimeout(pending.timer);
2716
+ this.pendingCancelMessages.delete(instanceName);
2717
+ this.deleteCancelMessage(pending);
2718
+ }
2719
+ /**
2720
+ * Reaction target chat id. Telegram reactions key on the supergroup chat_id
2721
+ * (the topic thread is NOT a chat_id), so a forum-topic message must react on
2722
+ * msg.chatId — reacting on threadId silently fails. Discord reactions key on
2723
+ * the channel/thread id.
2724
+ */
2725
+ reactTarget(msg) {
2726
+ return msg.source === "telegram" ? msg.chatId : (msg.threadId ?? msg.chatId);
2727
+ }
2728
+ /** Remember the user message just delivered, so we can react ✅ when done. */
2729
+ trackInboundMsg(instanceName, msg) {
2730
+ if (!msg.chatId || !msg.messageId)
2731
+ return;
2732
+ this.lastInboundMsg.set(instanceName, {
2733
+ adapterId: msg.adapterId, chatId: msg.chatId, threadId: msg.threadId ?? undefined, messageId: msg.messageId, source: msg.source,
2734
+ });
2735
+ }
2736
+ /** React ✅ on the last user message after the agent replies. */
2737
+ reactDone(instanceName) {
2738
+ const m = this.lastInboundMsg.get(instanceName);
2739
+ if (!m)
2740
+ return;
2741
+ this.lastInboundMsg.delete(instanceName);
2742
+ const adapter = (m.adapterId ? this.worlds.get(m.adapterId)?.adapter : undefined)
2743
+ ?? this.getAdapterForInstance(instanceName) ?? this.adapter;
2744
+ adapter?.react(this.reactTarget(m), m.messageId, "✅").catch(() => { });
2745
+ }
2746
+ /** Interrupt an instance's current generation (cancel button / /cancel). */
2747
+ cancelInstance(instanceName) {
2748
+ const daemon = this.daemons.get(instanceName);
2749
+ if (!daemon)
2750
+ return false;
2751
+ daemon.sendEscape().catch(e => this.logger.warn({ err: e, instanceName }, "sendEscape failed"));
2752
+ this.lastInboundMsg.delete(instanceName);
2753
+ this.clearCancelButton(instanceName);
2754
+ return true;
2755
+ }
2515
2756
  queueMirrorMessage(text) {
2516
2757
  const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
2517
2758
  if (mirrorTopicId == null || !this.adapter)
@@ -2850,10 +3091,17 @@ When users create specialized instances, suggest these configurations:
2850
3091
  // Skip empty bot messages (e.g., reactions) — don't pollute chat log
2851
3092
  if (msg.isBotMessage && !text && !msg.attachments?.length)
2852
3093
  return;
3094
+ // Save attachments FIRST so the chat-log records their inbox paths
3095
+ // (consistent with the /chat path). Otherwise a non-@mention image is
3096
+ // saved to inbox but its path never reaches the agent — the log keeps
3097
+ // only a pathless filename, so later context can't locate the file.
3098
+ const saved = msg.attachments?.length ? await this.saveClassicAttachment(instanceName, msg) : undefined;
2853
3099
  // 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
- : "";
3100
+ const collabAttachTag = saved
3101
+ ? ` [${saved.kind === "photo" ? "📷" : "📎"} saved: ${saved.paths.join(", ")}]`
3102
+ : (msg.attachments?.length
3103
+ ? ` [${msg.attachments.map(a => `${a.kind === "photo" ? "📷" : "📎"} ${a.filename || a.kind}`).join(", ")}]`
3104
+ : "");
2857
3105
  ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText);
2858
3106
  this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message");
2859
3107
  // Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here
@@ -2861,19 +3109,16 @@ When users create specialized instances, suggest these configurations:
2861
3109
  const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
2862
3110
  const isMentioned = mentionTag && text.includes(mentionTag);
2863
3111
  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
- }
3112
+ // Bare attachment (no @mention) already saved above; just acknowledge.
3113
+ if (saved) {
3114
+ const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
3115
+ const noMentionReactChatId = msg.threadId ?? msg.chatId;
3116
+ if (reactAdapter && noMentionReactChatId && msg.messageId) {
3117
+ const emoji = msg.source === "telegram"
3118
+ ? (saved.kind === "photo" ? "👌" : "👍")
3119
+ : (saved.kind === "photo" ? "📸" : "📎");
3120
+ reactAdapter.react(noMentionReactChatId, msg.messageId, emoji)
3121
+ .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
2877
3122
  }
2878
3123
  }
2879
3124
  return;
@@ -2891,8 +3136,7 @@ When users create specialized instances, suggest these configurations:
2891
3136
  // Block /raw bypass
2892
3137
  if (cleanText.startsWith("/raw "))
2893
3138
  return;
2894
- // Save and process attachments (same as /chat mode)
2895
- const saved = await this.saveClassicAttachment(instanceName, msg);
3139
+ // Attachments already saved at the top of the collab block.
2896
3140
  if (saved && classicAdapter && collabReactChatId && msg.messageId) {
2897
3141
  const emoji = msg.source === "telegram"
2898
3142
  ? (saved.kind === "photo" ? "👌" : "👍")
@@ -3049,24 +3293,37 @@ When users create specialized instances, suggest these configurations:
3049
3293
  this.logger.warn({ instanceName }, "Classic channel instance IPC not connected");
3050
3294
  return;
3051
3295
  }
3296
+ const meta = {
3297
+ chat_id: msg.chatId,
3298
+ message_id: msg.messageId,
3299
+ user: msg.username,
3300
+ user_id: msg.userId,
3301
+ ts: msg.timestamp.toISOString(),
3302
+ thread_id: msg.threadId ?? "",
3303
+ source: msg.source,
3304
+ ...extraMeta,
3305
+ ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
3306
+ };
3307
+ // If the triggering message carried no image of its own, surface the most
3308
+ // recent image saved earlier in this channel (logged as "[📷 saved: <path>]"
3309
+ // by an untriggered collab message) as image_path, so the agent's
3310
+ // read-the-image trigger fires instead of the path sitting inert in context.
3311
+ if (!meta.image_path && logContext) {
3312
+ const saves = [...logContext.matchAll(/\[📷 saved: ([^\]]+)\]/g)];
3313
+ if (saves.length > 0) {
3314
+ meta.image_path = saves[saves.length - 1][1].split(",")[0].trim();
3315
+ }
3316
+ }
3052
3317
  ipc.send({
3053
3318
  type: "fleet_inbound",
3054
3319
  content: fullText,
3055
3320
  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
- },
3321
+ meta,
3067
3322
  });
3068
3323
  this.lastInboundUser.set(instanceName, msg.username);
3069
3324
  this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
3325
+ this.trackInboundMsg(instanceName, msg);
3326
+ void this.sendCancelButton(instanceName);
3070
3327
  }
3071
3328
  /** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
3072
3329
  pasteRawToClassicInstance(instanceName, text) {
@@ -3186,9 +3443,11 @@ When users create specialized instances, suggest these configurations:
3186
3443
  this.topicArchiver.stop();
3187
3444
  this.scheduler?.shutdown();
3188
3445
  // Stop instances in parallel batches to avoid long sequential waits.
3189
- // Concurrency limited to avoid overwhelming the tmux server.
3190
- const STOP_CONCURRENCY = 5;
3446
+ // Concurrency scales with fleet size larger fleets tolerate more parallel
3447
+ // tmux ops, while small fleets stay conservative to avoid overwhelming the
3448
+ // tmux server.
3191
3449
  const entries = [...this.daemons.entries()];
3450
+ const STOP_CONCURRENCY = entries.length > 30 ? 15 : entries.length >= 10 ? 10 : 5;
3192
3451
  for (const [name] of entries)
3193
3452
  this.ipcStoppingInstances.add(name);
3194
3453
  for (let i = 0; i < entries.length; i += STOP_CONCURRENCY) {
@@ -3203,9 +3462,9 @@ When users create specialized instances, suggest these configurations:
3203
3462
  this.daemons.delete(name);
3204
3463
  }));
3205
3464
  }
3206
- for (const [, ipc] of this.instanceIpcClients) {
3207
- await ipc.close();
3208
- }
3465
+ // Close IPC clients in parallel — serial close over a large fleet adds
3466
+ // noticeable latency.
3467
+ await Promise.all([...this.instanceIpcClients.values()].map(ipc => Promise.resolve(ipc.close()).catch(() => { })));
3209
3468
  this.instanceIpcClients.clear();
3210
3469
  this.ipcStoppingInstances.clear();
3211
3470
  for (const [, w] of this.worlds) {
@@ -3541,14 +3800,21 @@ When users create specialized instances, suggest these configurations:
3541
3800
  const latest = execSync("npm view @songsid/agend version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
3542
3801
  let target = latest;
3543
3802
  if (currentVersion.includes("-beta")) {
3803
+ // Beta users track the @beta channel (never fall back to @latest, which is
3804
+ // older), but should also hear when a newer STABLE ships — pick whichever
3805
+ // of beta/latest is the newest.
3806
+ let beta = "";
3544
3807
  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;
3808
+ beta = execSync("npm view @songsid/agend@beta version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
3548
3809
  }
3549
3810
  catch { /* no beta tag */ }
3811
+ target = beta || latest;
3812
+ if (latest && this.semverGt(latest, target))
3813
+ target = latest;
3550
3814
  }
3551
- if (target && target !== currentVersion) {
3815
+ // Only notify when target is genuinely newer (semver), so a beta user on
3816
+ // 2.0.8-beta.16 is never told that stable 2.0.7 is "available".
3817
+ if (target && this.semverGt(target, currentVersion)) {
3552
3818
  const generalId = this.findGeneralInstance();
3553
3819
  if (generalId) {
3554
3820
  this.notifyInstanceTopic(generalId, `🆕 AgEnD v${target} available (current: v${currentVersion}). Use \`/update\` to upgrade.`);
@@ -3557,6 +3823,49 @@ When users create specialized instances, suggest these configurations:
3557
3823
  }
3558
3824
  catch { /* silent — network issues */ }
3559
3825
  }
3826
+ /**
3827
+ * Semver "a > b". Compares major.minor.patch numerically; a version without a
3828
+ * prerelease outranks the same core with one (2.0.8 > 2.0.8-beta.16); two
3829
+ * prereleases compare identifier-by-identifier (numeric < alphanumeric, numeric
3830
+ * fields compared as numbers). Sufficient for our X.Y.Z[-beta.N] scheme.
3831
+ */
3832
+ semverGt(a, b) {
3833
+ const parse = (v) => {
3834
+ const [core, pre] = v.replace(/^v/, "").split("-");
3835
+ const nums = core.split(".").map(n => parseInt(n, 10) || 0);
3836
+ return { nums: [nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0], pre: pre ? pre.split(".") : [] };
3837
+ };
3838
+ const pa = parse(a), pb = parse(b);
3839
+ for (let i = 0; i < 3; i++) {
3840
+ if (pa.nums[i] !== pb.nums[i])
3841
+ return pa.nums[i] > pb.nums[i];
3842
+ }
3843
+ if (pa.pre.length === 0 && pb.pre.length === 0)
3844
+ return false;
3845
+ if (pa.pre.length === 0)
3846
+ return true; // a stable, b prerelease → a > b
3847
+ if (pb.pre.length === 0)
3848
+ return false; // a prerelease, b stable → a < b
3849
+ const len = Math.max(pa.pre.length, pb.pre.length);
3850
+ for (let i = 0; i < len; i++) {
3851
+ const x = pa.pre[i], y = pb.pre[i];
3852
+ if (x === undefined)
3853
+ return false; // a has fewer identifiers → a < b
3854
+ if (y === undefined)
3855
+ return true; // a has more identifiers → a > b
3856
+ const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y);
3857
+ if (xn && yn) {
3858
+ const dx = parseInt(x, 10), dy = parseInt(y, 10);
3859
+ if (dx !== dy)
3860
+ return dx > dy;
3861
+ }
3862
+ else if (xn !== yn)
3863
+ return yn; // numeric has lower precedence than alphanumeric
3864
+ else if (x !== y)
3865
+ return x > y; // both alphanumeric
3866
+ }
3867
+ return false; // identical
3868
+ }
3560
3869
  // ── Health HTTP endpoint ─────────────────────────────────────────────
3561
3870
  startHealthServer(port) {
3562
3871
  this.startedAt = Date.now();
@@ -3733,6 +4042,29 @@ When users create specialized instances, suggest these configurations:
3733
4042
  })();
3734
4043
  return;
3735
4044
  }
4045
+ if (req.method === "POST" && req.url?.startsWith("/stop/")) {
4046
+ const name = decodeURIComponent(req.url.slice("/stop/".length));
4047
+ this.logger.info({ name }, "Instance stop requested via HTTP");
4048
+ (async () => {
4049
+ try {
4050
+ // Runs inside the live fleet process: lifecycle.stop finds the
4051
+ // in-memory daemon and stops just this instance. (Doing this from a
4052
+ // detached CLI FleetManager would read the shared daemon.pid — the
4053
+ // fleet's own pid — and kill the whole fleet.)
4054
+ await this.stopInstance(name);
4055
+ this.logger.info({ name }, "Instance stopped");
4056
+ this.emitSseEvent("status", this.getUiStatus());
4057
+ res.writeHead(200);
4058
+ res.end(JSON.stringify({ stopped: name }));
4059
+ }
4060
+ catch (err) {
4061
+ this.logger.error({ err, name }, "Instance stop failed");
4062
+ res.writeHead(500);
4063
+ res.end(JSON.stringify({ error: `Stop failed: ${err.message}` }));
4064
+ }
4065
+ })();
4066
+ return;
4067
+ }
3736
4068
  // ── Agent CLI endpoint ─────
3737
4069
  if (req.url === "/agent" && req.method === "POST") {
3738
4070
  handleAgentRequest(req, res, this);