@songsid/agend 2.0.8-beta.9 → 2.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) 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/agent-endpoint.js +6 -0
  6. package/dist/agent-endpoint.js.map +1 -1
  7. package/dist/backend/codex.js +10 -3
  8. package/dist/backend/codex.js.map +1 -1
  9. package/dist/channel/adapters/discord.d.ts +4 -4
  10. package/dist/channel/adapters/discord.js +162 -111
  11. package/dist/channel/adapters/discord.js.map +1 -1
  12. package/dist/channel/adapters/telegram.d.ts +1 -1
  13. package/dist/channel/adapters/telegram.js +3 -1
  14. package/dist/channel/adapters/telegram.js.map +1 -1
  15. package/dist/channel/tool-router.js +4 -2
  16. package/dist/channel/tool-router.js.map +1 -1
  17. package/dist/channel/tool-tracker.js +2 -2
  18. package/dist/channel/tool-tracker.js.map +1 -1
  19. package/dist/channel/types.d.ts +14 -6
  20. package/dist/cli.js +150 -95
  21. package/dist/cli.js.map +1 -1
  22. package/dist/daemon.d.ts +25 -1
  23. package/dist/daemon.js +149 -43
  24. package/dist/daemon.js.map +1 -1
  25. package/dist/fleet-manager.d.ts +47 -5
  26. package/dist/fleet-manager.js +362 -139
  27. package/dist/fleet-manager.js.map +1 -1
  28. package/dist/instance-lifecycle.js +9 -0
  29. package/dist/instance-lifecycle.js.map +1 -1
  30. package/dist/logger.d.ts +9 -1
  31. package/dist/logger.js +17 -7
  32. package/dist/logger.js.map +1 -1
  33. package/dist/outbound-handlers.d.ts +3 -0
  34. package/dist/outbound-handlers.js +17 -0
  35. package/dist/outbound-handlers.js.map +1 -1
  36. package/dist/outbound-schemas.d.ts +1 -1
  37. package/dist/tmux-control.d.ts +10 -0
  38. package/dist/tmux-control.js +29 -0
  39. package/dist/tmux-control.js.map +1 -1
  40. package/dist/tmux-manager.d.ts +6 -0
  41. package/dist/tmux-manager.js +17 -0
  42. package/dist/tmux-manager.js.map +1 -1
  43. package/dist/topic-commands.d.ts +21 -0
  44. package/dist/topic-commands.js +73 -6
  45. package/dist/topic-commands.js.map +1 -1
  46. package/package.json +3 -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,14 @@ 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;
52
+ /** Backstop: every 5min, retire a button whose instance has gone idle. Catches
53
+ * buttons no clear trigger reached (e.g. a scheduled/HTTP turn that never called
54
+ * reply). 5min (not the old 2s idle-watch) so Thinking isn't misread as idle. */
55
+ const CANCEL_BTN_IDLE_CHECK_INTERVAL_MS = 5 * 60_000;
48
56
  export class FleetManager {
49
57
  dataDir;
50
58
  children = new Map();
@@ -78,9 +86,11 @@ export class FleetManager {
78
86
  topicIcons = {};
79
87
  lastActivity = new Map();
80
88
  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();
89
+ // Active "🛑 Cancel" buttons, tracked per button (keyed by messageId) rather
90
+ // than one-per-instance. A button is retired (deleted, with bounded retry) on
91
+ // reply, on cancel, or when a newer button supersedes it for the same
92
+ // instance. Per-button tracking means a failed delete never strands a button.
93
+ cancelButtons = new Map();
84
94
  // Last user message delivered to each instance — used to react ✅ on completion.
85
95
  lastInboundMsg = new Map();
86
96
  topicArchiver;
@@ -448,6 +458,10 @@ export class FleetManager {
448
458
  .catch(e => this.logger.warn({ err: e }, "Failed to send daily summary"));
449
459
  // Rotate classic channel chat logs daily
450
460
  this.classicChannels?.rotateLogs();
461
+ this.rotateInboxes();
462
+ // Rotate fleet.log daily too (besides the startup size check above), so a
463
+ // long-running fleet doesn't accumulate an unbounded log.
464
+ rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
451
465
  }, () => {
452
466
  const instances = Object.keys(this.fleetConfig?.instances ?? {});
453
467
  const costMap = new Map();
@@ -459,6 +473,7 @@ export class FleetManager {
459
473
  this.dailySummary.start();
460
474
  // Rotate classic channel chat logs daily (piggyback on daily summary timer)
461
475
  this.classicChannels?.rotateLogs();
476
+ this.rotateInboxes();
462
477
  // Auto-create general instance(s) — one per adapter that lacks a general
463
478
  const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
464
479
  const generalInstances = Object.entries(fleet.instances).filter(([, inst]) => inst.general_topic === true);
@@ -686,6 +701,41 @@ export class FleetManager {
686
701
  };
687
702
  process.once("SIGUSR1", onFullRestart);
688
703
  }
704
+ /**
705
+ * Delete inbox files older than retentionDays (by mtime). Cleans the shared
706
+ * inbox (`<dataDir>/inbox`) and every workspace inbox
707
+ * (`<agendHome>/workspaces/*\/inbox`). Piggybacks on the daily summary timer,
708
+ * mirroring classic chat-log rotation (same 7-day retention).
709
+ */
710
+ rotateInboxes(retentionDays = 7) {
711
+ const cutoff = Date.now() - retentionDays * 86400_000;
712
+ const dirs = [join(this.dataDir, "inbox")];
713
+ const workspacesDir = join(getAgendHome(), "workspaces");
714
+ if (existsSync(workspacesDir)) {
715
+ for (const ws of readdirSync(workspacesDir)) {
716
+ dirs.push(join(workspacesDir, ws, "inbox"));
717
+ }
718
+ }
719
+ let deleted = 0;
720
+ for (const dir of dirs) {
721
+ if (!existsSync(dir))
722
+ continue;
723
+ for (const file of readdirSync(dir)) {
724
+ const full = join(dir, file);
725
+ try {
726
+ const st = statSync(full);
727
+ if (st.isFile() && st.mtimeMs < cutoff) {
728
+ unlinkSync(full);
729
+ deleted++;
730
+ }
731
+ }
732
+ catch { /* file vanished or unreadable — skip */ }
733
+ }
734
+ }
735
+ if (deleted > 0)
736
+ this.logger.info({ deleted }, "Rotated inbox files");
737
+ return deleted;
738
+ }
689
739
  /** Start the shared channel adapter(s) for topic mode */
690
740
  async startSharedAdapter(fleet) {
691
741
  const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
@@ -737,10 +787,10 @@ export class FleetManager {
737
787
  await this.startInstance(instanceName, config, topicMode);
738
788
  // startInstance already calls connectIpcToInstance
739
789
  }
740
- this.adapter?.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`).catch(() => { });
790
+ this.adapter?.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
741
791
  }
742
792
  else {
743
- this.adapter?.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`).catch(() => { });
793
+ this.adapter?.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
744
794
  }
745
795
  return;
746
796
  }
@@ -749,7 +799,7 @@ export class FleetManager {
749
799
  // Idempotent: a button click only acts while the button is live. A
750
800
  // second click (entry already cleared) is a no-op — don't re-send the
751
801
  // interrupt key. (The /cancel command path calls cancelInstance directly.)
752
- if (this.pendingCancelMessages.has(instanceName))
802
+ if (this.hasCancelButton(instanceName))
753
803
  this.cancelInstance(instanceName);
754
804
  return;
755
805
  }
@@ -794,7 +844,11 @@ export class FleetManager {
794
844
  timestamp: new Date(),
795
845
  });
796
846
  }
797
- else if (data.command === "save" || data.command === "load") {
847
+ else if (data.command === "save") {
848
+ await this.handleSlashSave(data);
849
+ }
850
+ else if (data.command === "load") {
851
+ // load is kiro-cli/classic only — no claude-code equivalent.
798
852
  if (!this.classicChannels?.isAdmin(data.userId)) {
799
853
  await data.respond("⛔ This command requires admin access.");
800
854
  return;
@@ -804,25 +858,13 @@ export class FleetManager {
804
858
  await data.respond("No active agent in this channel. Use `/start` first.");
805
859
  return;
806
860
  }
807
- let rawCmd;
808
- if (data.command === "save") {
809
- const filename = data.options?.filename;
810
- if (!/^[\w.-]+$/.test(filename)) {
811
- await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
812
- return;
813
- }
814
- rawCmd = data.options?.force ? `/chat save ${filename} -f` : `/chat save ${filename}`;
815
- }
816
- else {
817
- const filename = data.options?.filename;
818
- if (!/^[\w.-]+$/.test(filename)) {
819
- await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
820
- return;
821
- }
822
- rawCmd = `/chat load ${filename}`;
861
+ const filename = data.options?.filename;
862
+ if (!SAVE_FILENAME_RE.test(filename ?? "")) {
863
+ await data.respond("⛔ Invalid filename only letters, numbers, dots, hyphens, underscores allowed.");
864
+ return;
823
865
  }
824
- this.pasteRawToClassicInstance(target.name, rawCmd);
825
- await data.respond(`✅ Sent \`${rawCmd}\` to ${target.name}`);
866
+ this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
867
+ await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
826
868
  }
827
869
  else if (data.command === "compact") {
828
870
  const target = this.routing.resolve(data.channelId);
@@ -848,42 +890,8 @@ export class FleetManager {
848
890
  await data.respond("No active agent in this channel.");
849
891
  return;
850
892
  }
851
- const instanceName = target.name;
852
- const backend = target.kind === "classic"
853
- ? (this.classicChannels?.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend) ?? "claude-code")
854
- : (this.fleetConfig?.instances[instanceName]?.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
855
- let context = null;
856
- // Try statusline.json first
857
- try {
858
- const statusFile = join(this.getInstanceDir(instanceName), "statusline.json");
859
- if (existsSync(statusFile)) {
860
- const d = JSON.parse(readFileSync(statusFile, "utf-8"));
861
- context = d.context_window?.used_percentage ?? null;
862
- }
863
- }
864
- catch { /* ignore */ }
865
- // Fallback: capture tmux pane
866
- if (context == null) {
867
- try {
868
- const { execFileSync } = await import("node:child_process");
869
- const { getTmuxSocketName } = await import("./paths.js");
870
- const socketName = getTmuxSocketName();
871
- const tmuxArgs = socketName
872
- ? ["-L", socketName, "capture-pane", "-t", `${getTmuxSession()}:${instanceName}`, "-p"]
873
- : ["capture-pane", "-t", `${getTmuxSession()}:${instanceName}`, "-p"];
874
- const pane = execFileSync("tmux", tmuxArgs, { encoding: "utf-8", timeout: 2000, stdio: ["pipe", "pipe", "pipe"] });
875
- const m = pane.match(/(\d+)%.*[!❯>]/m) || pane.match(/◔\s*(\d+)%/) || pane.match(/\[(\d+)%\]/);
876
- if (m)
877
- context = parseInt(m[1], 10);
878
- }
879
- catch { /* ignore */ }
880
- }
881
- if (context != null) {
882
- await data.respond(`📊 Context: ${context}% used\nBackend: ${backend}\nInstance: ${instanceName}`);
883
- }
884
- else {
885
- await data.respond(`Context info not available yet.\nBackend: ${backend}\nInstance: ${instanceName}`);
886
- }
893
+ // Single source of truth (statusline.json + robust tmux pane fallback).
894
+ await data.respond(await this.topicCommands.getCtxText(target.name));
887
895
  }
888
896
  else if (data.command === "collab") {
889
897
  const collabTarget = this.routing.resolve(data.channelId);
@@ -1044,17 +1052,17 @@ export class FleetManager {
1044
1052
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
1045
1053
  await this.startInstance(instanceName, config, topicMode);
1046
1054
  }
1047
- adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`).catch(() => { });
1055
+ adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
1048
1056
  }
1049
1057
  else {
1050
- adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`).catch(() => { });
1058
+ adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`, data.threadId).catch(() => { });
1051
1059
  }
1052
1060
  return;
1053
1061
  }
1054
1062
  if (data.callbackData.startsWith("cancel:")) {
1055
1063
  const instanceName = data.callbackData.slice("cancel:".length);
1056
1064
  // Idempotent: only the first click (while the button is live) acts.
1057
- if (this.pendingCancelMessages.has(instanceName))
1065
+ if (this.hasCancelButton(instanceName))
1058
1066
  this.cancelInstance(instanceName);
1059
1067
  return;
1060
1068
  }
@@ -1098,7 +1106,11 @@ export class FleetManager {
1098
1106
  timestamp: new Date(),
1099
1107
  });
1100
1108
  }
1101
- else if (data.command === "save" || data.command === "load") {
1109
+ else if (data.command === "save") {
1110
+ await this.handleSlashSave(data);
1111
+ }
1112
+ else if (data.command === "load") {
1113
+ // load is kiro-cli/classic only — no claude-code equivalent.
1102
1114
  if (!this.classicChannels?.isAdmin(data.userId)) {
1103
1115
  await data.respond("⛔ This command requires admin access.");
1104
1116
  return;
@@ -1108,25 +1120,13 @@ export class FleetManager {
1108
1120
  await data.respond("No active agent in this channel. Use `/start` first.");
1109
1121
  return;
1110
1122
  }
1111
- let rawCmd;
1112
- if (data.command === "save") {
1113
- const filename = data.options?.filename;
1114
- if (!/^[\w.-]+$/.test(filename)) {
1115
- await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1116
- return;
1117
- }
1118
- rawCmd = data.options?.force ? `/chat save ${filename} -f` : `/chat save ${filename}`;
1119
- }
1120
- else {
1121
- const filename = data.options?.filename;
1122
- if (!/^[\w.-]+$/.test(filename)) {
1123
- await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1124
- return;
1125
- }
1126
- rawCmd = `/chat load ${filename}`;
1123
+ const filename = data.options?.filename;
1124
+ if (!SAVE_FILENAME_RE.test(filename ?? "")) {
1125
+ await data.respond("⛔ Invalid filename only letters, numbers, dots, hyphens, underscores allowed.");
1126
+ return;
1127
1127
  }
1128
- this.pasteRawToClassicInstance(target.name, rawCmd);
1129
- await data.respond(`✅ Sent \`${rawCmd}\` to ${target.name}`);
1128
+ this.pasteRawToClassicInstance(target.name, `/chat load ${filename}`);
1129
+ await data.respond(`✅ Sent \`/chat load ${filename}\` to ${target.name}`);
1130
1130
  }
1131
1131
  else if (data.command === "compact") {
1132
1132
  const target = this.routing.resolve(data.channelId);
@@ -1152,25 +1152,8 @@ export class FleetManager {
1152
1152
  await data.respond("No active agent in this channel.");
1153
1153
  return;
1154
1154
  }
1155
- const instanceName = target.name;
1156
- const ctxBackend = target.kind === "classic"
1157
- ? (this.classicChannels?.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend) ?? "claude-code")
1158
- : (this.fleetConfig?.instances[instanceName]?.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
1159
- let context = null;
1160
- try {
1161
- const statusFile = join(this.getInstanceDir(instanceName), "statusline.json");
1162
- if (existsSync(statusFile)) {
1163
- const d = JSON.parse(readFileSync(statusFile, "utf-8"));
1164
- context = d.context_window?.used_percentage ?? null;
1165
- }
1166
- }
1167
- catch { /* ignore */ }
1168
- if (context != null) {
1169
- await data.respond(`📊 Context: ${context}% used\nBackend: ${ctxBackend}\nInstance: ${instanceName}`);
1170
- }
1171
- else {
1172
- await data.respond(`Context info not available yet.\nBackend: ${ctxBackend}\nInstance: ${instanceName}`);
1173
- }
1155
+ // Single source of truth (statusline.json + robust tmux pane fallback).
1156
+ await data.respond(await this.topicCommands.getCtxText(target.name));
1174
1157
  }
1175
1158
  else if (data.command === "collab") {
1176
1159
  const collabTarget2 = this.routing.resolve(data.channelId);
@@ -1538,6 +1521,15 @@ export class FleetManager {
1538
1521
  const isBotMentioned = !!(botUser && text.toLowerCase().includes(`@${botUser.toLowerCase()}`));
1539
1522
  const isPrivateChat = !chatId.startsWith("-"); // Telegram: positive = private, negative = group
1540
1523
  const msgAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1524
+ // In a TG Classic group, ignore bare slash commands (no @bot specified).
1525
+ // Prevents multiple bots all responding to the same /ctx, /compact, etc.
1526
+ // `/cmd@otherbot` already returned above; `/cmd@mybot` set cmdMatch, so it
1527
+ // still processes. Private chat (only one bot) always processes.
1528
+ // NOTE: this also silently drops bare `/start` in a group, so group
1529
+ // onboarding now requires `/start@mybot` — consistent with the policy.
1530
+ if (!isPrivateChat && !cmdMatch && rawText.startsWith("/")) {
1531
+ return; // bare slash in group — ignore silently
1532
+ }
1541
1533
  // Handle /start command
1542
1534
  if (text === "/start" || text.startsWith("/start ")) {
1543
1535
  if (isPrivateChat) {
@@ -1629,6 +1621,36 @@ export class FleetManager {
1629
1621
  await msgAdapter?.sendText(chatId, reply);
1630
1622
  return;
1631
1623
  }
1624
+ // Handle /save command (admin only)
1625
+ if (text === "/save" || text.startsWith("/save ") || text.startsWith("/save@")) {
1626
+ if (!this.classicChannels.isAdmin(msg.userId)) {
1627
+ await msgAdapter?.sendText(chatId, "⛔ /save requires admin access.");
1628
+ return;
1629
+ }
1630
+ const saveTarget = this.routing.resolve(chatId);
1631
+ if (!saveTarget || saveTarget.kind !== "classic") {
1632
+ await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1633
+ return;
1634
+ }
1635
+ const filename = parseSaveFilename(text);
1636
+ if (!filename) {
1637
+ await msgAdapter?.sendText(chatId, "Usage: /save <filename>");
1638
+ return;
1639
+ }
1640
+ if (!SAVE_FILENAME_RE.test(filename)) {
1641
+ await msgAdapter?.sendText(chatId, "⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1642
+ return;
1643
+ }
1644
+ const backend = this.classicChannels.getBackendByInstance(saveTarget.name, this.fleetConfig?.defaults?.backend);
1645
+ const cmd = saveCommandForBackend(backend, filename);
1646
+ if (!cmd) {
1647
+ await msgAdapter?.sendText(chatId, SAVE_UNSUPPORTED_MSG);
1648
+ return;
1649
+ }
1650
+ this.pasteRawToClassicInstance(saveTarget.name, cmd);
1651
+ await msgAdapter?.sendText(chatId, `✅ Sent \`${cmd}\` to ${saveTarget.name}`);
1652
+ return;
1653
+ }
1632
1654
  // Route to classic channel if registered
1633
1655
  const target = this.routing.resolve(chatId);
1634
1656
  if (target?.kind === "classic") {
@@ -1907,7 +1929,7 @@ export class FleetManager {
1907
1929
  if (!chatId)
1908
1930
  return;
1909
1931
  if (editMessageId) {
1910
- statusAdapter.editMessage(chatId, editMessageId, text).catch(e => this.logger.debug({ err: e }, "Failed to edit tool status message"));
1932
+ statusAdapter.editMessage(chatId, editMessageId, text, threadId).catch(e => this.logger.debug({ err: e }, "Failed to edit tool status message"));
1911
1933
  }
1912
1934
  else {
1913
1935
  statusAdapter.sendText(chatId, text, { threadId }).then((sent) => {
@@ -1945,6 +1967,8 @@ export class FleetManager {
1945
1967
  payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
1946
1968
  meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
1947
1969
  });
1970
+ // A scheduled trigger also puts the instance to work — show a cancel button.
1971
+ void this.sendCancelButton(target);
1948
1972
  return true;
1949
1973
  };
1950
1974
  if (deliver()) {
@@ -2574,9 +2598,57 @@ export class FleetManager {
2574
2598
  // Sent after delivering a user message to an instance; clicking it (or
2575
2599
  // /cancel) sends Escape to the instance's pane to interrupt generation.
2576
2600
  /** Send a "🛑 Cancel" button to the instance's topic/channel after delivery. */
2577
- async sendCancelButton(instanceName) {
2578
- // Replace any stale button for this instance first.
2579
- this.clearCancelButton(instanceName);
2601
+ /**
2602
+ * Handle the DC `/save` slash command for both classic AND fleet-topic targets.
2603
+ * Picks the backend-appropriate command (kiro → /chat save, claude → /export);
2604
+ * unsupported backends get a clear error. Routes via classic paste or fleet IPC.
2605
+ */
2606
+ async handleSlashSave(data) {
2607
+ if (!this.classicChannels?.isAdmin(data.userId)) {
2608
+ await data.respond("⛔ This command requires admin access.");
2609
+ return;
2610
+ }
2611
+ const target = this.routing.resolve(data.channelId);
2612
+ if (!target) {
2613
+ await data.respond("No active agent in this channel. Use `/start` first.");
2614
+ return;
2615
+ }
2616
+ const filename = data.options?.filename ?? "";
2617
+ if (!SAVE_FILENAME_RE.test(filename)) {
2618
+ await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
2619
+ return;
2620
+ }
2621
+ const backend = target.kind === "classic"
2622
+ ? this.classicChannels.getBackendByInstance(target.name, this.fleetConfig?.defaults?.backend)
2623
+ : (this.fleetConfig?.instances[target.name]?.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
2624
+ // force (-f) is only meaningful for kiro/classic /chat save.
2625
+ const force = target.kind === "classic" && !!data.options?.force;
2626
+ const cmd = saveCommandForBackend(backend, filename, force);
2627
+ if (!cmd) {
2628
+ await data.respond(SAVE_UNSUPPORTED_MSG);
2629
+ return;
2630
+ }
2631
+ if (target.kind === "classic") {
2632
+ this.pasteRawToClassicInstance(target.name, cmd);
2633
+ }
2634
+ else {
2635
+ this.instanceIpcClients.get(target.name)?.send({ type: "raw_paste", content: cmd });
2636
+ }
2637
+ await data.respond(`✅ Sent \`${cmd}\` to ${target.name}`);
2638
+ }
2639
+ /** Whether the instance currently has at least one live cancel button. */
2640
+ hasCancelButton(instanceName) {
2641
+ for (const e of this.cancelButtons.values()) {
2642
+ if (e.instanceName === instanceName)
2643
+ return true;
2644
+ }
2645
+ return false;
2646
+ }
2647
+ async sendCancelButton(instanceName, correlationId) {
2648
+ // At most one button shown per instance: retire any existing ones first
2649
+ // (delete + bounded retry). Each is tracked separately, so a failed delete
2650
+ // here doesn't strand it — it keeps retrying on its own timer.
2651
+ this.retireInstanceButtons(instanceName);
2580
2652
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
2581
2653
  if (!adapter)
2582
2654
  return;
@@ -2611,34 +2683,114 @@ export class FleetManager {
2611
2683
  message: "👀 處理中…",
2612
2684
  choices: [{ id: `cancel:${instanceName}`, label: "🛑 取消" }],
2613
2685
  }, threadId ? { threadId } : undefined);
2614
- const timer = setTimeout(() => this.clearCancelButton(instanceName), 30_000);
2615
- this.pendingCancelMessages.set(instanceName, {
2616
- adapterId, chatId: sent.chatId, messageId: sent.messageId, threadId: sent.threadId ?? threadId, timer,
2617
- });
2686
+ // A concurrent sendCancelButton for the same instance may have posted its
2687
+ // own button while we awaited notifyAlert. Retire any other buttons for
2688
+ // this instance (not the one we just posted) so only the newest shows.
2689
+ for (const other of this.cancelButtons.values()) {
2690
+ if (other.instanceName === instanceName)
2691
+ this.retireButton(other);
2692
+ }
2693
+ const entry = {
2694
+ instanceName,
2695
+ adapterId,
2696
+ chatId: sent.chatId,
2697
+ messageId: sent.messageId,
2698
+ threadId: sent.threadId ?? threadId,
2699
+ correlationId,
2700
+ retryCount: 0,
2701
+ };
2702
+ // Idle-check backstop: every 5min, if the instance is idle, retire the
2703
+ // button. Covers turns that end without hitting a clear trigger (reply /
2704
+ // cancel / correlation). Cleared in discardButton when the entry is removed.
2705
+ entry.idleCheckTimer = setInterval(() => {
2706
+ if (!this.cancelButtons.has(entry.messageId)) {
2707
+ clearInterval(entry.idleCheckTimer);
2708
+ return;
2709
+ }
2710
+ if (this.getInstanceIdle(instanceName)) {
2711
+ this.logger.info({ instanceName, messageId: entry.messageId }, "Cancel button idle backstop retiring");
2712
+ this.retireButton(entry);
2713
+ }
2714
+ }, CANCEL_BTN_IDLE_CHECK_INTERVAL_MS);
2715
+ this.cancelButtons.set(sent.messageId, entry);
2716
+ this.logger.info({ instanceName, messageId: sent.messageId }, "Cancel button sent");
2618
2717
  }
2619
2718
  catch (e) {
2620
- this.logger.debug({ err: e.message, instanceName }, "Failed to send cancel button");
2719
+ this.logger.warn({ err: e.message, instanceName }, "Failed to send cancel button");
2621
2720
  }
2622
2721
  }
2623
- /** Retire (delete) the pending cancel button on reply, cancel, or timeout. */
2624
- clearCancelButton(instanceName) {
2625
- const pending = this.pendingCancelMessages.get(instanceName);
2626
- if (!pending)
2722
+ /** Retire (delete) every cancel button belonging to an instance. */
2723
+ retireInstanceButtons(instanceName) {
2724
+ // Snapshot first — retireButton may delete entries from the map on success.
2725
+ for (const e of [...this.cancelButtons.values()]) {
2726
+ if (e.instanceName === instanceName)
2727
+ this.retireButton(e);
2728
+ }
2729
+ }
2730
+ /** Begin retiring one button (delete + bounded retry on failure). Idempotent:
2731
+ * a button already in a retire cycle is left to its own timer, so a second
2732
+ * retire request (e.g. a new send + the post-await sweep) won't double-delete. */
2733
+ retireButton(entry) {
2734
+ if (entry.retiring)
2627
2735
  return;
2628
- clearTimeout(pending.timer);
2629
- this.pendingCancelMessages.delete(instanceName);
2630
- const adapter = (pending.adapterId ? this.worlds.get(pending.adapterId)?.adapter : undefined)
2631
- ?? this.getAdapterForInstance(instanceName) ?? this.adapter;
2632
- if (!adapter)
2736
+ entry.retiring = true;
2737
+ this.attemptButtonDelete(entry);
2738
+ }
2739
+ attemptButtonDelete(entry) {
2740
+ this.deleteButtonMessage(entry)
2741
+ .then(() => {
2742
+ this.discardButton(entry);
2743
+ this.logger.info({ instanceName: entry.instanceName, messageId: entry.messageId }, "Cancel button removed");
2744
+ })
2745
+ .catch((err) => this.scheduleButtonRetry(entry, err));
2746
+ }
2747
+ /** Clear an entry's timers (retry + idle-check) and drop it from the map. */
2748
+ discardButton(entry) {
2749
+ if (entry.retryTimer)
2750
+ clearTimeout(entry.retryTimer);
2751
+ if (entry.idleCheckTimer)
2752
+ clearInterval(entry.idleCheckTimer);
2753
+ this.cancelButtons.delete(entry.messageId);
2754
+ }
2755
+ /** Re-attempt a failed button delete up to CANCEL_BTN_MAX_RETRIES times. */
2756
+ scheduleButtonRetry(entry, err) {
2757
+ if (entry.retryCount >= CANCEL_BTN_MAX_RETRIES) {
2758
+ this.discardButton(entry);
2759
+ this.logger.warn({ instanceName: entry.instanceName, messageId: entry.messageId, err: err.message }, `Cancel button delete gave up after ${CANCEL_BTN_MAX_RETRIES} retries`);
2633
2760
  return;
2634
- if (adapter.deleteMessage) {
2635
- adapter.deleteMessage(pending.chatId, pending.messageId)
2636
- .catch((e) => this.logger.debug({ err: e.message, instanceName }, "Failed to delete cancel button"));
2637
2761
  }
2638
- else {
2639
- // Fallback for adapters without delete: at least drop the button.
2640
- const remove = adapter.editMessageRemoveButtons?.bind(adapter) ?? adapter.editMessage.bind(adapter);
2641
- remove(pending.chatId, pending.messageId, "✅").catch(() => { });
2762
+ entry.retryCount++;
2763
+ this.logger.warn({ instanceName: entry.instanceName, messageId: entry.messageId, attempt: entry.retryCount, err: err.message }, "Cancel button delete failed, will retry");
2764
+ if (entry.retryTimer)
2765
+ clearTimeout(entry.retryTimer);
2766
+ // Continue the same retire cycle (bypass the retiring-guard in retireButton).
2767
+ entry.retryTimer = setTimeout(() => this.attemptButtonDelete(entry), CANCEL_BTN_RETRY_INTERVAL_MS);
2768
+ }
2769
+ /** Delete one button's message via its own adapter. Resolves on success,
2770
+ * rejects on failure so the caller can retry. */
2771
+ deleteButtonMessage(e) {
2772
+ const adapter = (e.adapterId ? this.worlds.get(e.adapterId)?.adapter : undefined) ?? this.adapter;
2773
+ if (!adapter)
2774
+ return Promise.reject(new Error("no adapter for cancel button"));
2775
+ if (adapter.deleteMessage)
2776
+ return adapter.deleteMessage(e.chatId, e.messageId, e.threadId);
2777
+ if (adapter.editMessageRemoveButtons)
2778
+ return adapter.editMessageRemoveButtons(e.chatId, e.messageId, "✅", e.threadId);
2779
+ return adapter.editMessage(e.chatId, e.messageId, "✅", e.threadId);
2780
+ }
2781
+ /** Retire all cancel buttons for an instance — on reply or cancel. */
2782
+ clearCancelButton(instanceName) {
2783
+ this.retireInstanceButtons(instanceName);
2784
+ }
2785
+ /** Retire the cross-instance button matching a delegate→report correlation id.
2786
+ * Used by report_result, where the sender's self-derived name may not match
2787
+ * the target-address name the button was registered under. */
2788
+ clearCancelButtonByCorrelation(correlationId) {
2789
+ if (!correlationId)
2790
+ return;
2791
+ for (const e of [...this.cancelButtons.values()]) {
2792
+ if (e.correlationId === correlationId)
2793
+ this.retireButton(e);
2642
2794
  }
2643
2795
  }
2644
2796
  /**
@@ -2658,15 +2810,13 @@ export class FleetManager {
2658
2810
  adapterId: msg.adapterId, chatId: msg.chatId, threadId: msg.threadId ?? undefined, messageId: msg.messageId, source: msg.source,
2659
2811
  });
2660
2812
  }
2661
- /** React on the last user message after the agent replies. */
2813
+ /** Clear the tracked last-inbound message after the agent replies. The ✅
2814
+ * reaction is already applied by delivery confirmation (message_confirmed), so
2815
+ * reacting again here would be a duplicate API call — we only drop the entry. */
2662
2816
  reactDone(instanceName) {
2663
- const m = this.lastInboundMsg.get(instanceName);
2664
- if (!m)
2817
+ if (!this.lastInboundMsg.has(instanceName))
2665
2818
  return;
2666
2819
  this.lastInboundMsg.delete(instanceName);
2667
- const adapter = (m.adapterId ? this.worlds.get(m.adapterId)?.adapter : undefined)
2668
- ?? this.getAdapterForInstance(instanceName) ?? this.adapter;
2669
- adapter?.react(this.reactTarget(m), m.messageId, "✅").catch(() => { });
2670
2820
  }
2671
2821
  /** Interrupt an instance's current generation (cancel button / /cancel). */
2672
2822
  cancelInstance(instanceName) {
@@ -3725,14 +3875,21 @@ When users create specialized instances, suggest these configurations:
3725
3875
  const latest = execSync("npm view @songsid/agend version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
3726
3876
  let target = latest;
3727
3877
  if (currentVersion.includes("-beta")) {
3878
+ // Beta users track the @beta channel (never fall back to @latest, which is
3879
+ // older), but should also hear when a newer STABLE ships — pick whichever
3880
+ // of beta/latest is the newest.
3881
+ let beta = "";
3728
3882
  try {
3729
- const beta = execSync("npm view @songsid/agend@beta version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
3730
- if (beta && beta !== currentVersion)
3731
- target = beta;
3883
+ beta = execSync("npm view @songsid/agend@beta version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
3732
3884
  }
3733
3885
  catch { /* no beta tag */ }
3886
+ target = beta || latest;
3887
+ if (latest && this.semverGt(latest, target))
3888
+ target = latest;
3734
3889
  }
3735
- if (target && target !== currentVersion) {
3890
+ // Only notify when target is genuinely newer (semver), so a beta user on
3891
+ // 2.0.8-beta.16 is never told that stable 2.0.7 is "available".
3892
+ if (target && this.semverGt(target, currentVersion)) {
3736
3893
  const generalId = this.findGeneralInstance();
3737
3894
  if (generalId) {
3738
3895
  this.notifyInstanceTopic(generalId, `🆕 AgEnD v${target} available (current: v${currentVersion}). Use \`/update\` to upgrade.`);
@@ -3741,6 +3898,49 @@ When users create specialized instances, suggest these configurations:
3741
3898
  }
3742
3899
  catch { /* silent — network issues */ }
3743
3900
  }
3901
+ /**
3902
+ * Semver "a > b". Compares major.minor.patch numerically; a version without a
3903
+ * prerelease outranks the same core with one (2.0.8 > 2.0.8-beta.16); two
3904
+ * prereleases compare identifier-by-identifier (numeric < alphanumeric, numeric
3905
+ * fields compared as numbers). Sufficient for our X.Y.Z[-beta.N] scheme.
3906
+ */
3907
+ semverGt(a, b) {
3908
+ const parse = (v) => {
3909
+ const [core, pre] = v.replace(/^v/, "").split("-");
3910
+ const nums = core.split(".").map(n => parseInt(n, 10) || 0);
3911
+ return { nums: [nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0], pre: pre ? pre.split(".") : [] };
3912
+ };
3913
+ const pa = parse(a), pb = parse(b);
3914
+ for (let i = 0; i < 3; i++) {
3915
+ if (pa.nums[i] !== pb.nums[i])
3916
+ return pa.nums[i] > pb.nums[i];
3917
+ }
3918
+ if (pa.pre.length === 0 && pb.pre.length === 0)
3919
+ return false;
3920
+ if (pa.pre.length === 0)
3921
+ return true; // a stable, b prerelease → a > b
3922
+ if (pb.pre.length === 0)
3923
+ return false; // a prerelease, b stable → a < b
3924
+ const len = Math.max(pa.pre.length, pb.pre.length);
3925
+ for (let i = 0; i < len; i++) {
3926
+ const x = pa.pre[i], y = pb.pre[i];
3927
+ if (x === undefined)
3928
+ return false; // a has fewer identifiers → a < b
3929
+ if (y === undefined)
3930
+ return true; // a has more identifiers → a > b
3931
+ const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y);
3932
+ if (xn && yn) {
3933
+ const dx = parseInt(x, 10), dy = parseInt(y, 10);
3934
+ if (dx !== dy)
3935
+ return dx > dy;
3936
+ }
3937
+ else if (xn !== yn)
3938
+ return yn; // numeric has lower precedence than alphanumeric
3939
+ else if (x !== y)
3940
+ return x > y; // both alphanumeric
3941
+ }
3942
+ return false; // identical
3943
+ }
3744
3944
  // ── Health HTTP endpoint ─────────────────────────────────────────────
3745
3945
  startHealthServer(port) {
3746
3946
  this.startedAt = Date.now();
@@ -3917,6 +4117,29 @@ When users create specialized instances, suggest these configurations:
3917
4117
  })();
3918
4118
  return;
3919
4119
  }
4120
+ if (req.method === "POST" && req.url?.startsWith("/stop/")) {
4121
+ const name = decodeURIComponent(req.url.slice("/stop/".length));
4122
+ this.logger.info({ name }, "Instance stop requested via HTTP");
4123
+ (async () => {
4124
+ try {
4125
+ // Runs inside the live fleet process: lifecycle.stop finds the
4126
+ // in-memory daemon and stops just this instance. (Doing this from a
4127
+ // detached CLI FleetManager would read the shared daemon.pid — the
4128
+ // fleet's own pid — and kill the whole fleet.)
4129
+ await this.stopInstance(name);
4130
+ this.logger.info({ name }, "Instance stopped");
4131
+ this.emitSseEvent("status", this.getUiStatus());
4132
+ res.writeHead(200);
4133
+ res.end(JSON.stringify({ stopped: name }));
4134
+ }
4135
+ catch (err) {
4136
+ this.logger.error({ err, name }, "Instance stop failed");
4137
+ res.writeHead(500);
4138
+ res.end(JSON.stringify({ error: `Stop failed: ${err.message}` }));
4139
+ }
4140
+ })();
4141
+ return;
4142
+ }
3920
4143
  // ── Agent CLI endpoint ─────
3921
4144
  if (req.url === "/agent" && req.method === "POST") {
3922
4145
  handleAgentRequest(req, res, this);