@songsid/agend 2.0.8-beta.8 → 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 +54 -5
  26. package/dist/fleet-manager.js +423 -170
  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,16 +787,20 @@ 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
  }
747
797
  if (data.callbackData.startsWith("cancel:")) {
748
798
  const instanceName = data.callbackData.slice("cancel:".length);
749
- this.cancelInstance(instanceName);
799
+ // Idempotent: a button click only acts while the button is live. A
800
+ // second click (entry already cleared) is a no-op — don't re-send the
801
+ // interrupt key. (The /cancel command path calls cancelInstance directly.)
802
+ if (this.hasCancelButton(instanceName))
803
+ this.cancelInstance(instanceName);
750
804
  return;
751
805
  }
752
806
  }, this.logger, "adapter.callback_query"));
@@ -790,7 +844,11 @@ export class FleetManager {
790
844
  timestamp: new Date(),
791
845
  });
792
846
  }
793
- 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.
794
852
  if (!this.classicChannels?.isAdmin(data.userId)) {
795
853
  await data.respond("⛔ This command requires admin access.");
796
854
  return;
@@ -800,25 +858,13 @@ export class FleetManager {
800
858
  await data.respond("No active agent in this channel. Use `/start` first.");
801
859
  return;
802
860
  }
803
- let rawCmd;
804
- if (data.command === "save") {
805
- const filename = data.options?.filename;
806
- if (!/^[\w.-]+$/.test(filename)) {
807
- await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
808
- return;
809
- }
810
- rawCmd = data.options?.force ? `/chat save ${filename} -f` : `/chat save ${filename}`;
811
- }
812
- else {
813
- const filename = data.options?.filename;
814
- if (!/^[\w.-]+$/.test(filename)) {
815
- await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
816
- return;
817
- }
818
- 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;
819
865
  }
820
- this.pasteRawToClassicInstance(target.name, rawCmd);
821
- 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}`);
822
868
  }
823
869
  else if (data.command === "compact") {
824
870
  const target = this.routing.resolve(data.channelId);
@@ -844,42 +890,8 @@ export class FleetManager {
844
890
  await data.respond("No active agent in this channel.");
845
891
  return;
846
892
  }
847
- const instanceName = target.name;
848
- const backend = target.kind === "classic"
849
- ? (this.classicChannels?.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend) ?? "claude-code")
850
- : (this.fleetConfig?.instances[instanceName]?.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
851
- let context = null;
852
- // Try statusline.json first
853
- try {
854
- const statusFile = join(this.getInstanceDir(instanceName), "statusline.json");
855
- if (existsSync(statusFile)) {
856
- const d = JSON.parse(readFileSync(statusFile, "utf-8"));
857
- context = d.context_window?.used_percentage ?? null;
858
- }
859
- }
860
- catch { /* ignore */ }
861
- // Fallback: capture tmux pane
862
- if (context == null) {
863
- try {
864
- const { execFileSync } = await import("node:child_process");
865
- const { getTmuxSocketName } = await import("./paths.js");
866
- const socketName = getTmuxSocketName();
867
- const tmuxArgs = socketName
868
- ? ["-L", socketName, "capture-pane", "-t", `${getTmuxSession()}:${instanceName}`, "-p"]
869
- : ["capture-pane", "-t", `${getTmuxSession()}:${instanceName}`, "-p"];
870
- const pane = execFileSync("tmux", tmuxArgs, { encoding: "utf-8", timeout: 2000, stdio: ["pipe", "pipe", "pipe"] });
871
- const m = pane.match(/(\d+)%.*[!❯>]/m) || pane.match(/◔\s*(\d+)%/) || pane.match(/\[(\d+)%\]/);
872
- if (m)
873
- context = parseInt(m[1], 10);
874
- }
875
- catch { /* ignore */ }
876
- }
877
- if (context != null) {
878
- await data.respond(`📊 Context: ${context}% used\nBackend: ${backend}\nInstance: ${instanceName}`);
879
- }
880
- else {
881
- await data.respond(`Context info not available yet.\nBackend: ${backend}\nInstance: ${instanceName}`);
882
- }
893
+ // Single source of truth (statusline.json + robust tmux pane fallback).
894
+ await data.respond(await this.topicCommands.getCtxText(target.name));
883
895
  }
884
896
  else if (data.command === "collab") {
885
897
  const collabTarget = this.routing.resolve(data.channelId);
@@ -1040,15 +1052,18 @@ export class FleetManager {
1040
1052
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
1041
1053
  await this.startInstance(instanceName, config, topicMode);
1042
1054
  }
1043
- adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`).catch(() => { });
1055
+ adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`, data.threadId).catch(() => { });
1044
1056
  }
1045
1057
  else {
1046
- 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(() => { });
1047
1059
  }
1048
1060
  return;
1049
1061
  }
1050
1062
  if (data.callbackData.startsWith("cancel:")) {
1051
- this.cancelInstance(data.callbackData.slice("cancel:".length));
1063
+ const instanceName = data.callbackData.slice("cancel:".length);
1064
+ // Idempotent: only the first click (while the button is live) acts.
1065
+ if (this.hasCancelButton(instanceName))
1066
+ this.cancelInstance(instanceName);
1052
1067
  return;
1053
1068
  }
1054
1069
  }, this.logger, `adapter[${adapterId}].callback_query`));
@@ -1091,7 +1106,11 @@ export class FleetManager {
1091
1106
  timestamp: new Date(),
1092
1107
  });
1093
1108
  }
1094
- 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.
1095
1114
  if (!this.classicChannels?.isAdmin(data.userId)) {
1096
1115
  await data.respond("⛔ This command requires admin access.");
1097
1116
  return;
@@ -1101,25 +1120,13 @@ export class FleetManager {
1101
1120
  await data.respond("No active agent in this channel. Use `/start` first.");
1102
1121
  return;
1103
1122
  }
1104
- let rawCmd;
1105
- if (data.command === "save") {
1106
- const filename = data.options?.filename;
1107
- if (!/^[\w.-]+$/.test(filename)) {
1108
- await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1109
- return;
1110
- }
1111
- rawCmd = data.options?.force ? `/chat save ${filename} -f` : `/chat save ${filename}`;
1112
- }
1113
- else {
1114
- const filename = data.options?.filename;
1115
- if (!/^[\w.-]+$/.test(filename)) {
1116
- await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
1117
- return;
1118
- }
1119
- 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;
1120
1127
  }
1121
- this.pasteRawToClassicInstance(target.name, rawCmd);
1122
- 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}`);
1123
1130
  }
1124
1131
  else if (data.command === "compact") {
1125
1132
  const target = this.routing.resolve(data.channelId);
@@ -1145,25 +1152,8 @@ export class FleetManager {
1145
1152
  await data.respond("No active agent in this channel.");
1146
1153
  return;
1147
1154
  }
1148
- const instanceName = target.name;
1149
- const ctxBackend = target.kind === "classic"
1150
- ? (this.classicChannels?.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend) ?? "claude-code")
1151
- : (this.fleetConfig?.instances[instanceName]?.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
1152
- let context = null;
1153
- try {
1154
- const statusFile = join(this.getInstanceDir(instanceName), "statusline.json");
1155
- if (existsSync(statusFile)) {
1156
- const d = JSON.parse(readFileSync(statusFile, "utf-8"));
1157
- context = d.context_window?.used_percentage ?? null;
1158
- }
1159
- }
1160
- catch { /* ignore */ }
1161
- if (context != null) {
1162
- await data.respond(`📊 Context: ${context}% used\nBackend: ${ctxBackend}\nInstance: ${instanceName}`);
1163
- }
1164
- else {
1165
- await data.respond(`Context info not available yet.\nBackend: ${ctxBackend}\nInstance: ${instanceName}`);
1166
- }
1155
+ // Single source of truth (statusline.json + robust tmux pane fallback).
1156
+ await data.respond(await this.topicCommands.getCtxText(target.name));
1167
1157
  }
1168
1158
  else if (data.command === "collab") {
1169
1159
  const collabTarget2 = this.routing.resolve(data.channelId);
@@ -1531,6 +1521,15 @@ export class FleetManager {
1531
1521
  const isBotMentioned = !!(botUser && text.toLowerCase().includes(`@${botUser.toLowerCase()}`));
1532
1522
  const isPrivateChat = !chatId.startsWith("-"); // Telegram: positive = private, negative = group
1533
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
+ }
1534
1533
  // Handle /start command
1535
1534
  if (text === "/start" || text.startsWith("/start ")) {
1536
1535
  if (isPrivateChat) {
@@ -1622,6 +1621,36 @@ export class FleetManager {
1622
1621
  await msgAdapter?.sendText(chatId, reply);
1623
1622
  return;
1624
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
+ }
1625
1654
  // Route to classic channel if registered
1626
1655
  const target = this.routing.resolve(chatId);
1627
1656
  if (target?.kind === "classic") {
@@ -1737,7 +1766,7 @@ export class FleetManager {
1737
1766
  const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1738
1767
  // React immediately — before any other Discord API calls
1739
1768
  if (msg.chatId && msg.messageId) {
1740
- inboundAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
1769
+ inboundAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
1741
1770
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
1742
1771
  }
1743
1772
  // These may hit Discord API (topic icon, archive) — do after react
@@ -1900,7 +1929,7 @@ export class FleetManager {
1900
1929
  if (!chatId)
1901
1930
  return;
1902
1931
  if (editMessageId) {
1903
- 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"));
1904
1933
  }
1905
1934
  else {
1906
1935
  statusAdapter.sendText(chatId, text, { threadId }).then((sent) => {
@@ -1938,6 +1967,8 @@ export class FleetManager {
1938
1967
  payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
1939
1968
  meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
1940
1969
  });
1970
+ // A scheduled trigger also puts the instance to work — show a cancel button.
1971
+ void this.sendCancelButton(target);
1941
1972
  return true;
1942
1973
  };
1943
1974
  if (deliver()) {
@@ -2567,9 +2598,57 @@ export class FleetManager {
2567
2598
  // Sent after delivering a user message to an instance; clicking it (or
2568
2599
  // /cancel) sends Escape to the instance's pane to interrupt generation.
2569
2600
  /** Send a "🛑 Cancel" button to the instance's topic/channel after delivery. */
2570
- async sendCancelButton(instanceName) {
2571
- // Replace any stale button for this instance first.
2572
- 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);
2573
2652
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
2574
2653
  if (!adapter)
2575
2654
  return;
@@ -2604,53 +2683,140 @@ export class FleetManager {
2604
2683
  message: "👀 處理中…",
2605
2684
  choices: [{ id: `cancel:${instanceName}`, label: "🛑 取消" }],
2606
2685
  }, threadId ? { threadId } : undefined);
2607
- const timer = setTimeout(() => this.clearCancelButton(instanceName), 30_000);
2608
- this.pendingCancelMessages.set(instanceName, {
2609
- adapterId, chatId: sent.chatId, messageId: sent.messageId, threadId: sent.threadId ?? threadId, timer,
2610
- });
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");
2611
2717
  }
2612
2718
  catch (e) {
2613
- 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");
2614
2720
  }
2615
2721
  }
2616
- /** Retire (delete) the pending cancel button on reply, cancel, or timeout. */
2617
- clearCancelButton(instanceName) {
2618
- const pending = this.pendingCancelMessages.get(instanceName);
2619
- 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)
2620
2735
  return;
2621
- clearTimeout(pending.timer);
2622
- this.pendingCancelMessages.delete(instanceName);
2623
- const adapter = (pending.adapterId ? this.worlds.get(pending.adapterId)?.adapter : undefined)
2624
- ?? this.getAdapterForInstance(instanceName) ?? this.adapter;
2625
- 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`);
2626
2760
  return;
2627
- if (adapter.deleteMessage) {
2628
- adapter.deleteMessage(pending.chatId, pending.messageId)
2629
- .catch((e) => this.logger.debug({ err: e.message, instanceName }, "Failed to delete cancel button"));
2630
2761
  }
2631
- else {
2632
- // Fallback for adapters without delete: at least drop the button.
2633
- const remove = adapter.editMessageRemoveButtons?.bind(adapter) ?? adapter.editMessage.bind(adapter);
2634
- 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);
2635
2794
  }
2636
2795
  }
2796
+ /**
2797
+ * Reaction target chat id. Telegram reactions key on the supergroup chat_id
2798
+ * (the topic thread is NOT a chat_id), so a forum-topic message must react on
2799
+ * msg.chatId — reacting on threadId silently fails. Discord reactions key on
2800
+ * the channel/thread id.
2801
+ */
2802
+ reactTarget(msg) {
2803
+ return msg.source === "telegram" ? msg.chatId : (msg.threadId ?? msg.chatId);
2804
+ }
2637
2805
  /** Remember the user message just delivered, so we can react ✅ when done. */
2638
2806
  trackInboundMsg(instanceName, msg) {
2639
2807
  if (!msg.chatId || !msg.messageId)
2640
2808
  return;
2641
2809
  this.lastInboundMsg.set(instanceName, {
2642
- adapterId: msg.adapterId, chatId: msg.chatId, threadId: msg.threadId ?? undefined, messageId: msg.messageId,
2810
+ adapterId: msg.adapterId, chatId: msg.chatId, threadId: msg.threadId ?? undefined, messageId: msg.messageId, source: msg.source,
2643
2811
  });
2644
2812
  }
2645
- /** 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. */
2646
2816
  reactDone(instanceName) {
2647
- const m = this.lastInboundMsg.get(instanceName);
2648
- if (!m)
2817
+ if (!this.lastInboundMsg.has(instanceName))
2649
2818
  return;
2650
2819
  this.lastInboundMsg.delete(instanceName);
2651
- const adapter = (m.adapterId ? this.worlds.get(m.adapterId)?.adapter : undefined)
2652
- ?? this.getAdapterForInstance(instanceName) ?? this.adapter;
2653
- adapter?.react(m.threadId ?? m.chatId, m.messageId, "✅").catch(() => { });
2654
2820
  }
2655
2821
  /** Interrupt an instance's current generation (cancel button / /cancel). */
2656
2822
  cancelInstance(instanceName) {
@@ -3000,10 +3166,17 @@ When users create specialized instances, suggest these configurations:
3000
3166
  // Skip empty bot messages (e.g., reactions) — don't pollute chat log
3001
3167
  if (msg.isBotMessage && !text && !msg.attachments?.length)
3002
3168
  return;
3169
+ // Save attachments FIRST so the chat-log records their inbox paths
3170
+ // (consistent with the /chat path). Otherwise a non-@mention image is
3171
+ // saved to inbox but its path never reaches the agent — the log keeps
3172
+ // only a pathless filename, so later context can't locate the file.
3173
+ const saved = msg.attachments?.length ? await this.saveClassicAttachment(instanceName, msg) : undefined;
3003
3174
  // Log every message (including other bots) to chat-logs
3004
- const collabAttachTag = msg.attachments?.length
3005
- ? ` [${msg.attachments.map(a => `${a.kind === "photo" ? "📷" : "📎"} ${a.filename || a.kind}`).join(", ")}]`
3006
- : "";
3175
+ const collabAttachTag = saved
3176
+ ? ` [${saved.kind === "photo" ? "📷" : "📎"} saved: ${saved.paths.join(", ")}]`
3177
+ : (msg.attachments?.length
3178
+ ? ` [${msg.attachments.map(a => `${a.kind === "photo" ? "📷" : "📎"} ${a.filename || a.kind}`).join(", ")}]`
3179
+ : "");
3007
3180
  ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText);
3008
3181
  this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message");
3009
3182
  // Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here
@@ -3011,19 +3184,16 @@ When users create specialized instances, suggest these configurations:
3011
3184
  const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
3012
3185
  const isMentioned = mentionTag && text.includes(mentionTag);
3013
3186
  if (!isMentioned) {
3014
- // Save bare attachments (stickers, images) even without @mention
3015
- if (msg.attachments?.length) {
3016
- const saved = await this.saveClassicAttachment(instanceName, msg);
3017
- if (saved) {
3018
- const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
3019
- const noMentionReactChatId = msg.threadId ?? msg.chatId;
3020
- if (reactAdapter && noMentionReactChatId && msg.messageId) {
3021
- const emoji = msg.source === "telegram"
3022
- ? (saved.kind === "photo" ? "👌" : "👍")
3023
- : (saved.kind === "photo" ? "📸" : "📎");
3024
- reactAdapter.react(noMentionReactChatId, msg.messageId, emoji)
3025
- .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
3026
- }
3187
+ // Bare attachment (no @mention) already saved above; just acknowledge.
3188
+ if (saved) {
3189
+ const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
3190
+ const noMentionReactChatId = msg.threadId ?? msg.chatId;
3191
+ if (reactAdapter && noMentionReactChatId && msg.messageId) {
3192
+ const emoji = msg.source === "telegram"
3193
+ ? (saved.kind === "photo" ? "👌" : "👍")
3194
+ : (saved.kind === "photo" ? "📸" : "📎");
3195
+ reactAdapter.react(noMentionReactChatId, msg.messageId, emoji)
3196
+ .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
3027
3197
  }
3028
3198
  }
3029
3199
  return;
@@ -3041,8 +3211,7 @@ When users create specialized instances, suggest these configurations:
3041
3211
  // Block /raw bypass
3042
3212
  if (cleanText.startsWith("/raw "))
3043
3213
  return;
3044
- // Save and process attachments (same as /chat mode)
3045
- const saved = await this.saveClassicAttachment(instanceName, msg);
3214
+ // Attachments already saved at the top of the collab block.
3046
3215
  if (saved && classicAdapter && collabReactChatId && msg.messageId) {
3047
3216
  const emoji = msg.source === "telegram"
3048
3217
  ? (saved.kind === "photo" ? "👌" : "👍")
@@ -3199,21 +3368,32 @@ When users create specialized instances, suggest these configurations:
3199
3368
  this.logger.warn({ instanceName }, "Classic channel instance IPC not connected");
3200
3369
  return;
3201
3370
  }
3371
+ const meta = {
3372
+ chat_id: msg.chatId,
3373
+ message_id: msg.messageId,
3374
+ user: msg.username,
3375
+ user_id: msg.userId,
3376
+ ts: msg.timestamp.toISOString(),
3377
+ thread_id: msg.threadId ?? "",
3378
+ source: msg.source,
3379
+ ...extraMeta,
3380
+ ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
3381
+ };
3382
+ // If the triggering message carried no image of its own, surface the most
3383
+ // recent image saved earlier in this channel (logged as "[📷 saved: <path>]"
3384
+ // by an untriggered collab message) as image_path, so the agent's
3385
+ // read-the-image trigger fires instead of the path sitting inert in context.
3386
+ if (!meta.image_path && logContext) {
3387
+ const saves = [...logContext.matchAll(/\[📷 saved: ([^\]]+)\]/g)];
3388
+ if (saves.length > 0) {
3389
+ meta.image_path = saves[saves.length - 1][1].split(",")[0].trim();
3390
+ }
3391
+ }
3202
3392
  ipc.send({
3203
3393
  type: "fleet_inbound",
3204
3394
  content: fullText,
3205
3395
  targetSession: instanceName,
3206
- meta: {
3207
- chat_id: msg.chatId,
3208
- message_id: msg.messageId,
3209
- user: msg.username,
3210
- user_id: msg.userId,
3211
- ts: msg.timestamp.toISOString(),
3212
- thread_id: msg.threadId ?? "",
3213
- source: msg.source,
3214
- ...extraMeta,
3215
- ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
3216
- },
3396
+ meta,
3217
3397
  });
3218
3398
  this.lastInboundUser.set(instanceName, msg.username);
3219
3399
  this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
@@ -3695,14 +3875,21 @@ When users create specialized instances, suggest these configurations:
3695
3875
  const latest = execSync("npm view @songsid/agend version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
3696
3876
  let target = latest;
3697
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 = "";
3698
3882
  try {
3699
- const beta = execSync("npm view @songsid/agend@beta version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
3700
- if (beta && beta !== currentVersion)
3701
- target = beta;
3883
+ beta = execSync("npm view @songsid/agend@beta version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
3702
3884
  }
3703
3885
  catch { /* no beta tag */ }
3886
+ target = beta || latest;
3887
+ if (latest && this.semverGt(latest, target))
3888
+ target = latest;
3704
3889
  }
3705
- 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)) {
3706
3893
  const generalId = this.findGeneralInstance();
3707
3894
  if (generalId) {
3708
3895
  this.notifyInstanceTopic(generalId, `🆕 AgEnD v${target} available (current: v${currentVersion}). Use \`/update\` to upgrade.`);
@@ -3711,6 +3898,49 @@ When users create specialized instances, suggest these configurations:
3711
3898
  }
3712
3899
  catch { /* silent — network issues */ }
3713
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
+ }
3714
3944
  // ── Health HTTP endpoint ─────────────────────────────────────────────
3715
3945
  startHealthServer(port) {
3716
3946
  this.startedAt = Date.now();
@@ -3887,6 +4117,29 @@ When users create specialized instances, suggest these configurations:
3887
4117
  })();
3888
4118
  return;
3889
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
+ }
3890
4143
  // ── Agent CLI endpoint ─────
3891
4144
  if (req.url === "/agent" && req.method === "POST") {
3892
4145
  handleAgentRequest(req, res, this);