@songsid/agend 2.0.8-beta.1 → 2.0.8-beta.10

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 (42) hide show
  1. package/README.md +3 -6
  2. package/dist/backend/antigravity.js +6 -2
  3. package/dist/backend/antigravity.js.map +1 -1
  4. package/dist/backend/claude-code.js +3 -2
  5. package/dist/backend/claude-code.js.map +1 -1
  6. package/dist/backend/codex.js +4 -2
  7. package/dist/backend/codex.js.map +1 -1
  8. package/dist/backend/kiro.js +4 -2
  9. package/dist/backend/kiro.js.map +1 -1
  10. package/dist/backend/opencode.js.map +1 -1
  11. package/dist/backend/types.d.ts +8 -0
  12. package/dist/backend/types.js +12 -0
  13. package/dist/backend/types.js.map +1 -1
  14. package/dist/channel/adapters/discord.d.ts +57 -0
  15. package/dist/channel/adapters/discord.js +623 -0
  16. package/dist/channel/adapters/discord.js.map +1 -0
  17. package/dist/channel/adapters/telegram.d.ts +3 -0
  18. package/dist/channel/adapters/telegram.js +7 -0
  19. package/dist/channel/adapters/telegram.js.map +1 -1
  20. package/dist/channel/factory.js +10 -1
  21. package/dist/channel/factory.js.map +1 -1
  22. package/dist/channel/types.d.ts +6 -1
  23. package/dist/cli.js +2 -3
  24. package/dist/cli.js.map +1 -1
  25. package/dist/daemon.d.ts +6 -0
  26. package/dist/daemon.js +64 -22
  27. package/dist/daemon.js.map +1 -1
  28. package/dist/fleet-context.d.ts +2 -0
  29. package/dist/fleet-manager.d.ts +19 -0
  30. package/dist/fleet-manager.js +221 -35
  31. package/dist/fleet-manager.js.map +1 -1
  32. package/dist/general-knowledge/skills/session-management/SKILL.md +56 -1
  33. package/dist/outbound-handlers.d.ts +1 -0
  34. package/dist/outbound-handlers.js +3 -0
  35. package/dist/outbound-handlers.js.map +1 -1
  36. package/dist/quickstart.js +2 -3
  37. package/dist/quickstart.js.map +1 -1
  38. package/dist/tmux-manager.d.ts +1 -1
  39. package/dist/tmux-manager.js.map +1 -1
  40. package/dist/topic-commands.js +8 -0
  41. package/dist/topic-commands.js.map +1 -1
  42. package/package.json +2 -4
@@ -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;
@@ -739,6 +744,15 @@ export class FleetManager {
739
744
  }
740
745
  return;
741
746
  }
747
+ if (data.callbackData.startsWith("cancel:")) {
748
+ const instanceName = data.callbackData.slice("cancel:".length);
749
+ // Idempotent: a button click only acts while the button is live. A
750
+ // second click (entry already cleared) is a no-op — don't re-send the
751
+ // interrupt key. (The /cancel command path calls cancelInstance directly.)
752
+ if (this.pendingCancelMessages.has(instanceName))
753
+ this.cancelInstance(instanceName);
754
+ return;
755
+ }
742
756
  }, this.logger, "adapter.callback_query"));
743
757
  this.adapter.on("topic_closed", safeHandler(async (data) => {
744
758
  // Skip unbind if we archived this topic ourselves
@@ -819,6 +833,15 @@ export class FleetManager {
819
833
  const result = await this.topicCommands.sendCompact(target.name);
820
834
  await data.respond(result);
821
835
  }
836
+ else if (data.command === "cancel") {
837
+ const target = this.routing.resolve(data.channelId);
838
+ if (!target) {
839
+ await data.respond("No active agent in this channel.");
840
+ return;
841
+ }
842
+ const ok = this.cancelInstance(target.name);
843
+ await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
844
+ }
822
845
  else if (data.command === "ctx") {
823
846
  const target = this.routing.resolve(data.channelId);
824
847
  if (!target) {
@@ -1026,6 +1049,14 @@ export class FleetManager {
1026
1049
  else {
1027
1050
  adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`).catch(() => { });
1028
1051
  }
1052
+ return;
1053
+ }
1054
+ if (data.callbackData.startsWith("cancel:")) {
1055
+ const instanceName = data.callbackData.slice("cancel:".length);
1056
+ // Idempotent: only the first click (while the button is live) acts.
1057
+ if (this.pendingCancelMessages.has(instanceName))
1058
+ this.cancelInstance(instanceName);
1059
+ return;
1029
1060
  }
1030
1061
  }, this.logger, `adapter[${adapterId}].callback_query`));
1031
1062
  adapter.on("topic_closed", safeHandler(async (data) => {
@@ -1106,6 +1137,15 @@ export class FleetManager {
1106
1137
  const result = await this.topicCommands.sendCompact(target.name);
1107
1138
  await data.respond(result);
1108
1139
  }
1140
+ else if (data.command === "cancel") {
1141
+ const target = this.routing.resolve(data.channelId);
1142
+ if (!target) {
1143
+ await data.respond("No active agent in this channel.");
1144
+ return;
1145
+ }
1146
+ const ok = this.cancelInstance(target.name);
1147
+ await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
1148
+ }
1109
1149
  else if (data.command === "ctx") {
1110
1150
  const target = this.routing.resolve(data.channelId);
1111
1151
  if (!target) {
@@ -1567,6 +1607,17 @@ export class FleetManager {
1567
1607
  await msgAdapter?.sendText(chatId, result);
1568
1608
  return;
1569
1609
  }
1610
+ // Handle /cancel command
1611
+ if (text === "/cancel" || text.startsWith("/cancel@")) {
1612
+ const cancelTarget = this.routing.resolve(chatId);
1613
+ if (!cancelTarget || cancelTarget.kind !== "classic") {
1614
+ await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1615
+ return;
1616
+ }
1617
+ const ok = this.cancelInstance(cancelTarget.name);
1618
+ await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelTarget.name}。` : `❌ ${cancelTarget.name} 未在執行。`);
1619
+ return;
1620
+ }
1570
1621
  // Handle /ctx command
1571
1622
  if (text === "/ctx" || text.startsWith("/ctx@")) {
1572
1623
  const ctxTarget = this.routing.resolve(chatId);
@@ -1654,6 +1705,8 @@ export class FleetManager {
1654
1705
  instance: generalInstance, sender: msg.username,
1655
1706
  text: (text ?? "").slice(0, 2000), ts: new Date().toISOString(),
1656
1707
  });
1708
+ this.trackInboundMsg(generalInstance, msg);
1709
+ void this.sendCancelButton(generalInstance);
1657
1710
  }
1658
1711
  }
1659
1712
  return;
@@ -1691,7 +1744,7 @@ export class FleetManager {
1691
1744
  const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1692
1745
  // React immediately — before any other Discord API calls
1693
1746
  if (msg.chatId && msg.messageId) {
1694
- inboundAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
1747
+ inboundAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
1695
1748
  .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
1696
1749
  }
1697
1750
  // These may hit Discord API (topic icon, archive) — do after react
@@ -1730,6 +1783,8 @@ export class FleetManager {
1730
1783
  instance: instanceName, sender: msg.username,
1731
1784
  text: (text ?? "").slice(0, 2000), ts: new Date().toISOString(),
1732
1785
  });
1786
+ this.trackInboundMsg(instanceName, msg);
1787
+ void this.sendCancelButton(instanceName);
1733
1788
  }
1734
1789
  /** Handle outbound tool calls from a daemon instance */
1735
1790
  /** Warn (but don't block) when rate limits are high. 30-min debounce per instance. */
@@ -1803,6 +1858,9 @@ export class FleetManager {
1803
1858
  // Route standard channel tools (reply, react, edit_message, download_attachment)
1804
1859
  if (routeToolCall(outAdapter, tool, args, threadId, respond)) {
1805
1860
  if (tool === "reply") {
1861
+ // Agent answered — retire its pending cancel button and mark ✅ done.
1862
+ this.clearCancelButton(instanceName);
1863
+ this.reactDone(instanceName);
1806
1864
  const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
1807
1865
  this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
1808
1866
  this.emitSseEvent("message", {
@@ -1887,6 +1945,8 @@ export class FleetManager {
1887
1945
  payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
1888
1946
  meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
1889
1947
  });
1948
+ // A scheduled trigger also puts the instance to work — show a cancel button.
1949
+ void this.sendCancelButton(target);
1890
1950
  return true;
1891
1951
  };
1892
1952
  if (deliver()) {
@@ -2512,6 +2572,114 @@ export class FleetManager {
2512
2572
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
2513
2573
  }
2514
2574
  }
2575
+ // ── Cancel button ────────────────────────────────────────────────────
2576
+ // Sent after delivering a user message to an instance; clicking it (or
2577
+ // /cancel) sends Escape to the instance's pane to interrupt generation.
2578
+ /** Send a "🛑 Cancel" button to the instance's topic/channel after delivery. */
2579
+ async sendCancelButton(instanceName) {
2580
+ // Replace any stale button for this instance first.
2581
+ this.clearCancelButton(instanceName);
2582
+ const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
2583
+ if (!adapter)
2584
+ return;
2585
+ const adapterId = this.instanceWorldBinding.get(instanceName);
2586
+ const groupId = this.getChannelConfig(adapterId)?.group_id;
2587
+ const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
2588
+ let chatId;
2589
+ let threadId;
2590
+ if (topicId != null && groupId) {
2591
+ // Fleet topic instance.
2592
+ chatId = String(groupId);
2593
+ threadId = String(topicId);
2594
+ }
2595
+ else {
2596
+ // Classic instance: chatId from the routing table.
2597
+ for (const [cid, target] of this.routing.entries()) {
2598
+ if (target.kind === "classic" && target.name === instanceName) {
2599
+ chatId = cid;
2600
+ break;
2601
+ }
2602
+ }
2603
+ // General / flat fallback: post to the group (no thread).
2604
+ if (!chatId && groupId)
2605
+ chatId = String(groupId);
2606
+ }
2607
+ if (!chatId)
2608
+ return;
2609
+ try {
2610
+ const sent = await adapter.notifyAlert(chatId, {
2611
+ type: "cancel",
2612
+ instanceName,
2613
+ message: "👀 處理中…",
2614
+ choices: [{ id: `cancel:${instanceName}`, label: "🛑 取消" }],
2615
+ }, threadId ? { threadId } : undefined);
2616
+ const timer = setTimeout(() => this.clearCancelButton(instanceName), 30_000);
2617
+ this.pendingCancelMessages.set(instanceName, {
2618
+ adapterId, chatId: sent.chatId, messageId: sent.messageId, threadId: sent.threadId ?? threadId, timer,
2619
+ });
2620
+ }
2621
+ catch (e) {
2622
+ this.logger.debug({ err: e.message, instanceName }, "Failed to send cancel button");
2623
+ }
2624
+ }
2625
+ /** Retire (delete) the pending cancel button — on reply, cancel, or timeout. */
2626
+ clearCancelButton(instanceName) {
2627
+ const pending = this.pendingCancelMessages.get(instanceName);
2628
+ if (!pending)
2629
+ return;
2630
+ clearTimeout(pending.timer);
2631
+ this.pendingCancelMessages.delete(instanceName);
2632
+ const adapter = (pending.adapterId ? this.worlds.get(pending.adapterId)?.adapter : undefined)
2633
+ ?? this.getAdapterForInstance(instanceName) ?? this.adapter;
2634
+ if (!adapter)
2635
+ return;
2636
+ if (adapter.deleteMessage) {
2637
+ adapter.deleteMessage(pending.chatId, pending.messageId)
2638
+ .catch((e) => this.logger.debug({ err: e.message, instanceName }, "Failed to delete cancel button"));
2639
+ }
2640
+ else {
2641
+ // Fallback for adapters without delete: at least drop the button.
2642
+ const remove = adapter.editMessageRemoveButtons?.bind(adapter) ?? adapter.editMessage.bind(adapter);
2643
+ remove(pending.chatId, pending.messageId, "✅").catch(() => { });
2644
+ }
2645
+ }
2646
+ /**
2647
+ * Reaction target chat id. Telegram reactions key on the supergroup chat_id
2648
+ * (the topic thread is NOT a chat_id), so a forum-topic message must react on
2649
+ * msg.chatId — reacting on threadId silently fails. Discord reactions key on
2650
+ * the channel/thread id.
2651
+ */
2652
+ reactTarget(msg) {
2653
+ return msg.source === "telegram" ? msg.chatId : (msg.threadId ?? msg.chatId);
2654
+ }
2655
+ /** Remember the user message just delivered, so we can react ✅ when done. */
2656
+ trackInboundMsg(instanceName, msg) {
2657
+ if (!msg.chatId || !msg.messageId)
2658
+ return;
2659
+ this.lastInboundMsg.set(instanceName, {
2660
+ adapterId: msg.adapterId, chatId: msg.chatId, threadId: msg.threadId ?? undefined, messageId: msg.messageId, source: msg.source,
2661
+ });
2662
+ }
2663
+ /** React ✅ on the last user message after the agent replies. */
2664
+ reactDone(instanceName) {
2665
+ const m = this.lastInboundMsg.get(instanceName);
2666
+ if (!m)
2667
+ return;
2668
+ this.lastInboundMsg.delete(instanceName);
2669
+ const adapter = (m.adapterId ? this.worlds.get(m.adapterId)?.adapter : undefined)
2670
+ ?? this.getAdapterForInstance(instanceName) ?? this.adapter;
2671
+ adapter?.react(this.reactTarget(m), m.messageId, "✅").catch(() => { });
2672
+ }
2673
+ /** Interrupt an instance's current generation (cancel button / /cancel). */
2674
+ cancelInstance(instanceName) {
2675
+ const daemon = this.daemons.get(instanceName);
2676
+ if (!daemon)
2677
+ return false;
2678
+ daemon.sendEscape().catch(e => this.logger.warn({ err: e, instanceName }, "sendEscape failed"));
2679
+ this.lastInboundMsg.delete(instanceName);
2680
+ this.clearCancelButton(instanceName);
2681
+ return true;
2682
+ }
2515
2683
  queueMirrorMessage(text) {
2516
2684
  const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
2517
2685
  if (mirrorTopicId == null || !this.adapter)
@@ -2850,10 +3018,17 @@ When users create specialized instances, suggest these configurations:
2850
3018
  // Skip empty bot messages (e.g., reactions) — don't pollute chat log
2851
3019
  if (msg.isBotMessage && !text && !msg.attachments?.length)
2852
3020
  return;
3021
+ // Save attachments FIRST so the chat-log records their inbox paths
3022
+ // (consistent with the /chat path). Otherwise a non-@mention image is
3023
+ // saved to inbox but its path never reaches the agent — the log keeps
3024
+ // only a pathless filename, so later context can't locate the file.
3025
+ const saved = msg.attachments?.length ? await this.saveClassicAttachment(instanceName, msg) : undefined;
2853
3026
  // 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
- : "";
3027
+ const collabAttachTag = saved
3028
+ ? ` [${saved.kind === "photo" ? "📷" : "📎"} saved: ${saved.paths.join(", ")}]`
3029
+ : (msg.attachments?.length
3030
+ ? ` [${msg.attachments.map(a => `${a.kind === "photo" ? "📷" : "📎"} ${a.filename || a.kind}`).join(", ")}]`
3031
+ : "");
2857
3032
  ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText);
2858
3033
  this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message");
2859
3034
  // Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here
@@ -2861,19 +3036,16 @@ When users create specialized instances, suggest these configurations:
2861
3036
  const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
2862
3037
  const isMentioned = mentionTag && text.includes(mentionTag);
2863
3038
  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
- }
3039
+ // Bare attachment (no @mention) already saved above; just acknowledge.
3040
+ if (saved) {
3041
+ const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
3042
+ const noMentionReactChatId = msg.threadId ?? msg.chatId;
3043
+ if (reactAdapter && noMentionReactChatId && msg.messageId) {
3044
+ const emoji = msg.source === "telegram"
3045
+ ? (saved.kind === "photo" ? "👌" : "👍")
3046
+ : (saved.kind === "photo" ? "📸" : "📎");
3047
+ reactAdapter.react(noMentionReactChatId, msg.messageId, emoji)
3048
+ .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
2877
3049
  }
2878
3050
  }
2879
3051
  return;
@@ -2891,8 +3063,7 @@ When users create specialized instances, suggest these configurations:
2891
3063
  // Block /raw bypass
2892
3064
  if (cleanText.startsWith("/raw "))
2893
3065
  return;
2894
- // Save and process attachments (same as /chat mode)
2895
- const saved = await this.saveClassicAttachment(instanceName, msg);
3066
+ // Attachments already saved at the top of the collab block.
2896
3067
  if (saved && classicAdapter && collabReactChatId && msg.messageId) {
2897
3068
  const emoji = msg.source === "telegram"
2898
3069
  ? (saved.kind === "photo" ? "👌" : "👍")
@@ -3049,24 +3220,37 @@ When users create specialized instances, suggest these configurations:
3049
3220
  this.logger.warn({ instanceName }, "Classic channel instance IPC not connected");
3050
3221
  return;
3051
3222
  }
3223
+ const meta = {
3224
+ chat_id: msg.chatId,
3225
+ message_id: msg.messageId,
3226
+ user: msg.username,
3227
+ user_id: msg.userId,
3228
+ ts: msg.timestamp.toISOString(),
3229
+ thread_id: msg.threadId ?? "",
3230
+ source: msg.source,
3231
+ ...extraMeta,
3232
+ ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
3233
+ };
3234
+ // If the triggering message carried no image of its own, surface the most
3235
+ // recent image saved earlier in this channel (logged as "[📷 saved: <path>]"
3236
+ // by an untriggered collab message) as image_path, so the agent's
3237
+ // read-the-image trigger fires instead of the path sitting inert in context.
3238
+ if (!meta.image_path && logContext) {
3239
+ const saves = [...logContext.matchAll(/\[📷 saved: ([^\]]+)\]/g)];
3240
+ if (saves.length > 0) {
3241
+ meta.image_path = saves[saves.length - 1][1].split(",")[0].trim();
3242
+ }
3243
+ }
3052
3244
  ipc.send({
3053
3245
  type: "fleet_inbound",
3054
3246
  content: fullText,
3055
3247
  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
- },
3248
+ meta,
3067
3249
  });
3068
3250
  this.lastInboundUser.set(instanceName, msg.username);
3069
3251
  this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
3252
+ this.trackInboundMsg(instanceName, msg);
3253
+ void this.sendCancelButton(instanceName);
3070
3254
  }
3071
3255
  /** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
3072
3256
  pasteRawToClassicInstance(instanceName, text) {
@@ -3186,9 +3370,11 @@ When users create specialized instances, suggest these configurations:
3186
3370
  this.topicArchiver.stop();
3187
3371
  this.scheduler?.shutdown();
3188
3372
  // Stop instances in parallel batches to avoid long sequential waits.
3189
- // Concurrency limited to avoid overwhelming the tmux server.
3190
- const STOP_CONCURRENCY = 5;
3373
+ // Concurrency scales with fleet size larger fleets tolerate more parallel
3374
+ // tmux ops, while small fleets stay conservative to avoid overwhelming the
3375
+ // tmux server.
3191
3376
  const entries = [...this.daemons.entries()];
3377
+ const STOP_CONCURRENCY = entries.length > 30 ? 15 : entries.length >= 10 ? 10 : 5;
3192
3378
  for (const [name] of entries)
3193
3379
  this.ipcStoppingInstances.add(name);
3194
3380
  for (let i = 0; i < entries.length; i += STOP_CONCURRENCY) {
@@ -3203,9 +3389,9 @@ When users create specialized instances, suggest these configurations:
3203
3389
  this.daemons.delete(name);
3204
3390
  }));
3205
3391
  }
3206
- for (const [, ipc] of this.instanceIpcClients) {
3207
- await ipc.close();
3208
- }
3392
+ // Close IPC clients in parallel — serial close over a large fleet adds
3393
+ // noticeable latency.
3394
+ await Promise.all([...this.instanceIpcClients.values()].map(ipc => Promise.resolve(ipc.close()).catch(() => { })));
3209
3395
  this.instanceIpcClients.clear();
3210
3396
  this.ipcStoppingInstances.clear();
3211
3397
  for (const [, w] of this.worlds) {