@songsid/agend 2.0.8-beta.1 → 2.0.8-beta.11
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.
- package/README.md +3 -6
- package/dist/backend/antigravity.js +6 -2
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.js +3 -2
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.js +4 -2
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/kiro.js +4 -2
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/opencode.js.map +1 -1
- package/dist/backend/types.d.ts +8 -0
- package/dist/backend/types.js +12 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +57 -0
- package/dist/channel/adapters/discord.js +623 -0
- package/dist/channel/adapters/discord.js.map +1 -0
- package/dist/channel/adapters/telegram.d.ts +3 -0
- package/dist/channel/adapters/telegram.js +7 -0
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/factory.js +10 -1
- package/dist/channel/factory.js.map +1 -1
- package/dist/channel/types.d.ts +6 -1
- package/dist/cli.js +2 -3
- package/dist/cli.js.map +1 -1
- package/dist/daemon.d.ts +6 -0
- package/dist/daemon.js +64 -22
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-context.d.ts +2 -0
- package/dist/fleet-manager.d.ts +19 -0
- package/dist/fleet-manager.js +223 -71
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/session-management/SKILL.md +56 -1
- package/dist/outbound-handlers.d.ts +1 -0
- package/dist/outbound-handlers.js +3 -0
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/quickstart.js +2 -3
- package/dist/quickstart.js.map +1 -1
- package/dist/tmux-manager.d.ts +1 -1
- package/dist/tmux-manager.js.map +1 -1
- package/dist/topic-commands.d.ts +8 -0
- package/dist/topic-commands.js +30 -6
- package/dist/topic-commands.js.map +1 -1
- package/package.json +2 -4
package/dist/fleet-manager.js
CHANGED
|
@@ -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,48 +833,23 @@ export class FleetManager {
|
|
|
819
833
|
const result = await this.topicCommands.sendCompact(target.name);
|
|
820
834
|
await data.respond(result);
|
|
821
835
|
}
|
|
822
|
-
else if (data.command === "
|
|
836
|
+
else if (data.command === "cancel") {
|
|
823
837
|
const target = this.routing.resolve(data.channelId);
|
|
824
838
|
if (!target) {
|
|
825
839
|
await data.respond("No active agent in this channel.");
|
|
826
840
|
return;
|
|
827
841
|
}
|
|
828
|
-
const
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
if (existsSync(statusFile)) {
|
|
837
|
-
const d = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
838
|
-
context = d.context_window?.used_percentage ?? null;
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
catch { /* ignore */ }
|
|
842
|
-
// Fallback: capture tmux pane
|
|
843
|
-
if (context == null) {
|
|
844
|
-
try {
|
|
845
|
-
const { execFileSync } = await import("node:child_process");
|
|
846
|
-
const { getTmuxSocketName } = await import("./paths.js");
|
|
847
|
-
const socketName = getTmuxSocketName();
|
|
848
|
-
const tmuxArgs = socketName
|
|
849
|
-
? ["-L", socketName, "capture-pane", "-t", `${getTmuxSession()}:${instanceName}`, "-p"]
|
|
850
|
-
: ["capture-pane", "-t", `${getTmuxSession()}:${instanceName}`, "-p"];
|
|
851
|
-
const pane = execFileSync("tmux", tmuxArgs, { encoding: "utf-8", timeout: 2000, stdio: ["pipe", "pipe", "pipe"] });
|
|
852
|
-
const m = pane.match(/(\d+)%.*[!❯>]/m) || pane.match(/◔\s*(\d+)%/) || pane.match(/\[(\d+)%\]/);
|
|
853
|
-
if (m)
|
|
854
|
-
context = parseInt(m[1], 10);
|
|
855
|
-
}
|
|
856
|
-
catch { /* ignore */ }
|
|
857
|
-
}
|
|
858
|
-
if (context != null) {
|
|
859
|
-
await data.respond(`📊 Context: ${context}% used\nBackend: ${backend}\nInstance: ${instanceName}`);
|
|
860
|
-
}
|
|
861
|
-
else {
|
|
862
|
-
await data.respond(`Context info not available yet.\nBackend: ${backend}\nInstance: ${instanceName}`);
|
|
842
|
+
const ok = this.cancelInstance(target.name);
|
|
843
|
+
await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
|
|
844
|
+
}
|
|
845
|
+
else if (data.command === "ctx") {
|
|
846
|
+
const target = this.routing.resolve(data.channelId);
|
|
847
|
+
if (!target) {
|
|
848
|
+
await data.respond("No active agent in this channel.");
|
|
849
|
+
return;
|
|
863
850
|
}
|
|
851
|
+
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
852
|
+
await data.respond(await this.topicCommands.getCtxText(target.name));
|
|
864
853
|
}
|
|
865
854
|
else if (data.command === "collab") {
|
|
866
855
|
const collabTarget = this.routing.resolve(data.channelId);
|
|
@@ -1026,6 +1015,14 @@ export class FleetManager {
|
|
|
1026
1015
|
else {
|
|
1027
1016
|
adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`).catch(() => { });
|
|
1028
1017
|
}
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
if (data.callbackData.startsWith("cancel:")) {
|
|
1021
|
+
const instanceName = data.callbackData.slice("cancel:".length);
|
|
1022
|
+
// Idempotent: only the first click (while the button is live) acts.
|
|
1023
|
+
if (this.pendingCancelMessages.has(instanceName))
|
|
1024
|
+
this.cancelInstance(instanceName);
|
|
1025
|
+
return;
|
|
1029
1026
|
}
|
|
1030
1027
|
}, this.logger, `adapter[${adapterId}].callback_query`));
|
|
1031
1028
|
adapter.on("topic_closed", safeHandler(async (data) => {
|
|
@@ -1106,6 +1103,15 @@ export class FleetManager {
|
|
|
1106
1103
|
const result = await this.topicCommands.sendCompact(target.name);
|
|
1107
1104
|
await data.respond(result);
|
|
1108
1105
|
}
|
|
1106
|
+
else if (data.command === "cancel") {
|
|
1107
|
+
const target = this.routing.resolve(data.channelId);
|
|
1108
|
+
if (!target) {
|
|
1109
|
+
await data.respond("No active agent in this channel.");
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
const ok = this.cancelInstance(target.name);
|
|
1113
|
+
await data.respond(ok ? `🛑 Sent cancel to ${target.name}.` : `❌ ${target.name} not running.`);
|
|
1114
|
+
}
|
|
1109
1115
|
else if (data.command === "ctx") {
|
|
1110
1116
|
const target = this.routing.resolve(data.channelId);
|
|
1111
1117
|
if (!target) {
|
|
@@ -1567,6 +1573,17 @@ export class FleetManager {
|
|
|
1567
1573
|
await msgAdapter?.sendText(chatId, result);
|
|
1568
1574
|
return;
|
|
1569
1575
|
}
|
|
1576
|
+
// Handle /cancel command
|
|
1577
|
+
if (text === "/cancel" || text.startsWith("/cancel@")) {
|
|
1578
|
+
const cancelTarget = this.routing.resolve(chatId);
|
|
1579
|
+
if (!cancelTarget || cancelTarget.kind !== "classic") {
|
|
1580
|
+
await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
const ok = this.cancelInstance(cancelTarget.name);
|
|
1584
|
+
await msgAdapter?.sendText(chatId, ok ? `🛑 已送出取消給 ${cancelTarget.name}。` : `❌ ${cancelTarget.name} 未在執行。`);
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1570
1587
|
// Handle /ctx command
|
|
1571
1588
|
if (text === "/ctx" || text.startsWith("/ctx@")) {
|
|
1572
1589
|
const ctxTarget = this.routing.resolve(chatId);
|
|
@@ -1654,6 +1671,8 @@ export class FleetManager {
|
|
|
1654
1671
|
instance: generalInstance, sender: msg.username,
|
|
1655
1672
|
text: (text ?? "").slice(0, 2000), ts: new Date().toISOString(),
|
|
1656
1673
|
});
|
|
1674
|
+
this.trackInboundMsg(generalInstance, msg);
|
|
1675
|
+
void this.sendCancelButton(generalInstance);
|
|
1657
1676
|
}
|
|
1658
1677
|
}
|
|
1659
1678
|
return;
|
|
@@ -1691,7 +1710,7 @@ export class FleetManager {
|
|
|
1691
1710
|
const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
1692
1711
|
// React immediately — before any other Discord API calls
|
|
1693
1712
|
if (msg.chatId && msg.messageId) {
|
|
1694
|
-
inboundAdapter.react(
|
|
1713
|
+
inboundAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
|
|
1695
1714
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
1696
1715
|
}
|
|
1697
1716
|
// These may hit Discord API (topic icon, archive) — do after react
|
|
@@ -1730,6 +1749,8 @@ export class FleetManager {
|
|
|
1730
1749
|
instance: instanceName, sender: msg.username,
|
|
1731
1750
|
text: (text ?? "").slice(0, 2000), ts: new Date().toISOString(),
|
|
1732
1751
|
});
|
|
1752
|
+
this.trackInboundMsg(instanceName, msg);
|
|
1753
|
+
void this.sendCancelButton(instanceName);
|
|
1733
1754
|
}
|
|
1734
1755
|
/** Handle outbound tool calls from a daemon instance */
|
|
1735
1756
|
/** Warn (but don't block) when rate limits are high. 30-min debounce per instance. */
|
|
@@ -1803,6 +1824,9 @@ export class FleetManager {
|
|
|
1803
1824
|
// Route standard channel tools (reply, react, edit_message, download_attachment)
|
|
1804
1825
|
if (routeToolCall(outAdapter, tool, args, threadId, respond)) {
|
|
1805
1826
|
if (tool === "reply") {
|
|
1827
|
+
// Agent answered — retire its pending cancel button and mark ✅ done.
|
|
1828
|
+
this.clearCancelButton(instanceName);
|
|
1829
|
+
this.reactDone(instanceName);
|
|
1806
1830
|
const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
|
|
1807
1831
|
this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
|
|
1808
1832
|
this.emitSseEvent("message", {
|
|
@@ -1887,6 +1911,8 @@ export class FleetManager {
|
|
|
1887
1911
|
payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
|
|
1888
1912
|
meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
|
|
1889
1913
|
});
|
|
1914
|
+
// A scheduled trigger also puts the instance to work — show a cancel button.
|
|
1915
|
+
void this.sendCancelButton(target);
|
|
1890
1916
|
return true;
|
|
1891
1917
|
};
|
|
1892
1918
|
if (deliver()) {
|
|
@@ -2512,6 +2538,114 @@ export class FleetManager {
|
|
|
2512
2538
|
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
|
|
2513
2539
|
}
|
|
2514
2540
|
}
|
|
2541
|
+
// ── Cancel button ────────────────────────────────────────────────────
|
|
2542
|
+
// Sent after delivering a user message to an instance; clicking it (or
|
|
2543
|
+
// /cancel) sends Escape to the instance's pane to interrupt generation.
|
|
2544
|
+
/** Send a "🛑 Cancel" button to the instance's topic/channel after delivery. */
|
|
2545
|
+
async sendCancelButton(instanceName) {
|
|
2546
|
+
// Replace any stale button for this instance first.
|
|
2547
|
+
this.clearCancelButton(instanceName);
|
|
2548
|
+
const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
2549
|
+
if (!adapter)
|
|
2550
|
+
return;
|
|
2551
|
+
const adapterId = this.instanceWorldBinding.get(instanceName);
|
|
2552
|
+
const groupId = this.getChannelConfig(adapterId)?.group_id;
|
|
2553
|
+
const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
|
|
2554
|
+
let chatId;
|
|
2555
|
+
let threadId;
|
|
2556
|
+
if (topicId != null && groupId) {
|
|
2557
|
+
// Fleet topic instance.
|
|
2558
|
+
chatId = String(groupId);
|
|
2559
|
+
threadId = String(topicId);
|
|
2560
|
+
}
|
|
2561
|
+
else {
|
|
2562
|
+
// Classic instance: chatId from the routing table.
|
|
2563
|
+
for (const [cid, target] of this.routing.entries()) {
|
|
2564
|
+
if (target.kind === "classic" && target.name === instanceName) {
|
|
2565
|
+
chatId = cid;
|
|
2566
|
+
break;
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
// General / flat fallback: post to the group (no thread).
|
|
2570
|
+
if (!chatId && groupId)
|
|
2571
|
+
chatId = String(groupId);
|
|
2572
|
+
}
|
|
2573
|
+
if (!chatId)
|
|
2574
|
+
return;
|
|
2575
|
+
try {
|
|
2576
|
+
const sent = await adapter.notifyAlert(chatId, {
|
|
2577
|
+
type: "cancel",
|
|
2578
|
+
instanceName,
|
|
2579
|
+
message: "👀 處理中…",
|
|
2580
|
+
choices: [{ id: `cancel:${instanceName}`, label: "🛑 取消" }],
|
|
2581
|
+
}, threadId ? { threadId } : undefined);
|
|
2582
|
+
const timer = setTimeout(() => this.clearCancelButton(instanceName), 30_000);
|
|
2583
|
+
this.pendingCancelMessages.set(instanceName, {
|
|
2584
|
+
adapterId, chatId: sent.chatId, messageId: sent.messageId, threadId: sent.threadId ?? threadId, timer,
|
|
2585
|
+
});
|
|
2586
|
+
}
|
|
2587
|
+
catch (e) {
|
|
2588
|
+
this.logger.debug({ err: e.message, instanceName }, "Failed to send cancel button");
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
/** Retire (delete) the pending cancel button — on reply, cancel, or timeout. */
|
|
2592
|
+
clearCancelButton(instanceName) {
|
|
2593
|
+
const pending = this.pendingCancelMessages.get(instanceName);
|
|
2594
|
+
if (!pending)
|
|
2595
|
+
return;
|
|
2596
|
+
clearTimeout(pending.timer);
|
|
2597
|
+
this.pendingCancelMessages.delete(instanceName);
|
|
2598
|
+
const adapter = (pending.adapterId ? this.worlds.get(pending.adapterId)?.adapter : undefined)
|
|
2599
|
+
?? this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
2600
|
+
if (!adapter)
|
|
2601
|
+
return;
|
|
2602
|
+
if (adapter.deleteMessage) {
|
|
2603
|
+
adapter.deleteMessage(pending.chatId, pending.messageId)
|
|
2604
|
+
.catch((e) => this.logger.debug({ err: e.message, instanceName }, "Failed to delete cancel button"));
|
|
2605
|
+
}
|
|
2606
|
+
else {
|
|
2607
|
+
// Fallback for adapters without delete: at least drop the button.
|
|
2608
|
+
const remove = adapter.editMessageRemoveButtons?.bind(adapter) ?? adapter.editMessage.bind(adapter);
|
|
2609
|
+
remove(pending.chatId, pending.messageId, "✅").catch(() => { });
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
/**
|
|
2613
|
+
* Reaction target chat id. Telegram reactions key on the supergroup chat_id
|
|
2614
|
+
* (the topic thread is NOT a chat_id), so a forum-topic message must react on
|
|
2615
|
+
* msg.chatId — reacting on threadId silently fails. Discord reactions key on
|
|
2616
|
+
* the channel/thread id.
|
|
2617
|
+
*/
|
|
2618
|
+
reactTarget(msg) {
|
|
2619
|
+
return msg.source === "telegram" ? msg.chatId : (msg.threadId ?? msg.chatId);
|
|
2620
|
+
}
|
|
2621
|
+
/** Remember the user message just delivered, so we can react ✅ when done. */
|
|
2622
|
+
trackInboundMsg(instanceName, msg) {
|
|
2623
|
+
if (!msg.chatId || !msg.messageId)
|
|
2624
|
+
return;
|
|
2625
|
+
this.lastInboundMsg.set(instanceName, {
|
|
2626
|
+
adapterId: msg.adapterId, chatId: msg.chatId, threadId: msg.threadId ?? undefined, messageId: msg.messageId, source: msg.source,
|
|
2627
|
+
});
|
|
2628
|
+
}
|
|
2629
|
+
/** React ✅ on the last user message after the agent replies. */
|
|
2630
|
+
reactDone(instanceName) {
|
|
2631
|
+
const m = this.lastInboundMsg.get(instanceName);
|
|
2632
|
+
if (!m)
|
|
2633
|
+
return;
|
|
2634
|
+
this.lastInboundMsg.delete(instanceName);
|
|
2635
|
+
const adapter = (m.adapterId ? this.worlds.get(m.adapterId)?.adapter : undefined)
|
|
2636
|
+
?? this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
2637
|
+
adapter?.react(this.reactTarget(m), m.messageId, "✅").catch(() => { });
|
|
2638
|
+
}
|
|
2639
|
+
/** Interrupt an instance's current generation (cancel button / /cancel). */
|
|
2640
|
+
cancelInstance(instanceName) {
|
|
2641
|
+
const daemon = this.daemons.get(instanceName);
|
|
2642
|
+
if (!daemon)
|
|
2643
|
+
return false;
|
|
2644
|
+
daemon.sendEscape().catch(e => this.logger.warn({ err: e, instanceName }, "sendEscape failed"));
|
|
2645
|
+
this.lastInboundMsg.delete(instanceName);
|
|
2646
|
+
this.clearCancelButton(instanceName);
|
|
2647
|
+
return true;
|
|
2648
|
+
}
|
|
2515
2649
|
queueMirrorMessage(text) {
|
|
2516
2650
|
const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
|
|
2517
2651
|
if (mirrorTopicId == null || !this.adapter)
|
|
@@ -2850,10 +2984,17 @@ When users create specialized instances, suggest these configurations:
|
|
|
2850
2984
|
// Skip empty bot messages (e.g., reactions) — don't pollute chat log
|
|
2851
2985
|
if (msg.isBotMessage && !text && !msg.attachments?.length)
|
|
2852
2986
|
return;
|
|
2987
|
+
// Save attachments FIRST so the chat-log records their inbox paths
|
|
2988
|
+
// (consistent with the /chat path). Otherwise a non-@mention image is
|
|
2989
|
+
// saved to inbox but its path never reaches the agent — the log keeps
|
|
2990
|
+
// only a pathless filename, so later context can't locate the file.
|
|
2991
|
+
const saved = msg.attachments?.length ? await this.saveClassicAttachment(instanceName, msg) : undefined;
|
|
2853
2992
|
// Log every message (including other bots) to chat-logs
|
|
2854
|
-
const collabAttachTag =
|
|
2855
|
-
? ` [${
|
|
2856
|
-
:
|
|
2993
|
+
const collabAttachTag = saved
|
|
2994
|
+
? ` [${saved.kind === "photo" ? "📷" : "📎"} saved: ${saved.paths.join(", ")}]`
|
|
2995
|
+
: (msg.attachments?.length
|
|
2996
|
+
? ` [${msg.attachments.map(a => `${a.kind === "photo" ? "📷" : "📎"} ${a.filename || a.kind}`).join(", ")}]`
|
|
2997
|
+
: "");
|
|
2857
2998
|
ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText);
|
|
2858
2999
|
this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message");
|
|
2859
3000
|
// Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here
|
|
@@ -2861,19 +3002,16 @@ When users create specialized instances, suggest these configurations:
|
|
|
2861
3002
|
const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
|
|
2862
3003
|
const isMentioned = mentionTag && text.includes(mentionTag);
|
|
2863
3004
|
if (!isMentioned) {
|
|
2864
|
-
//
|
|
2865
|
-
if (
|
|
2866
|
-
const
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
const
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
reactAdapter.react(noMentionReactChatId, msg.messageId, emoji)
|
|
2875
|
-
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
2876
|
-
}
|
|
3005
|
+
// Bare attachment (no @mention) — already saved above; just acknowledge.
|
|
3006
|
+
if (saved) {
|
|
3007
|
+
const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
3008
|
+
const noMentionReactChatId = msg.threadId ?? msg.chatId;
|
|
3009
|
+
if (reactAdapter && noMentionReactChatId && msg.messageId) {
|
|
3010
|
+
const emoji = msg.source === "telegram"
|
|
3011
|
+
? (saved.kind === "photo" ? "👌" : "👍")
|
|
3012
|
+
: (saved.kind === "photo" ? "📸" : "📎");
|
|
3013
|
+
reactAdapter.react(noMentionReactChatId, msg.messageId, emoji)
|
|
3014
|
+
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
2877
3015
|
}
|
|
2878
3016
|
}
|
|
2879
3017
|
return;
|
|
@@ -2891,8 +3029,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
2891
3029
|
// Block /raw bypass
|
|
2892
3030
|
if (cleanText.startsWith("/raw "))
|
|
2893
3031
|
return;
|
|
2894
|
-
//
|
|
2895
|
-
const saved = await this.saveClassicAttachment(instanceName, msg);
|
|
3032
|
+
// Attachments already saved at the top of the collab block.
|
|
2896
3033
|
if (saved && classicAdapter && collabReactChatId && msg.messageId) {
|
|
2897
3034
|
const emoji = msg.source === "telegram"
|
|
2898
3035
|
? (saved.kind === "photo" ? "👌" : "👍")
|
|
@@ -3049,24 +3186,37 @@ When users create specialized instances, suggest these configurations:
|
|
|
3049
3186
|
this.logger.warn({ instanceName }, "Classic channel instance IPC not connected");
|
|
3050
3187
|
return;
|
|
3051
3188
|
}
|
|
3189
|
+
const meta = {
|
|
3190
|
+
chat_id: msg.chatId,
|
|
3191
|
+
message_id: msg.messageId,
|
|
3192
|
+
user: msg.username,
|
|
3193
|
+
user_id: msg.userId,
|
|
3194
|
+
ts: msg.timestamp.toISOString(),
|
|
3195
|
+
thread_id: msg.threadId ?? "",
|
|
3196
|
+
source: msg.source,
|
|
3197
|
+
...extraMeta,
|
|
3198
|
+
...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
|
|
3199
|
+
};
|
|
3200
|
+
// If the triggering message carried no image of its own, surface the most
|
|
3201
|
+
// recent image saved earlier in this channel (logged as "[📷 saved: <path>]"
|
|
3202
|
+
// by an untriggered collab message) as image_path, so the agent's
|
|
3203
|
+
// read-the-image trigger fires instead of the path sitting inert in context.
|
|
3204
|
+
if (!meta.image_path && logContext) {
|
|
3205
|
+
const saves = [...logContext.matchAll(/\[📷 saved: ([^\]]+)\]/g)];
|
|
3206
|
+
if (saves.length > 0) {
|
|
3207
|
+
meta.image_path = saves[saves.length - 1][1].split(",")[0].trim();
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3052
3210
|
ipc.send({
|
|
3053
3211
|
type: "fleet_inbound",
|
|
3054
3212
|
content: fullText,
|
|
3055
3213
|
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
|
-
},
|
|
3214
|
+
meta,
|
|
3067
3215
|
});
|
|
3068
3216
|
this.lastInboundUser.set(instanceName, msg.username);
|
|
3069
3217
|
this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
|
|
3218
|
+
this.trackInboundMsg(instanceName, msg);
|
|
3219
|
+
void this.sendCancelButton(instanceName);
|
|
3070
3220
|
}
|
|
3071
3221
|
/** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
|
|
3072
3222
|
pasteRawToClassicInstance(instanceName, text) {
|
|
@@ -3186,9 +3336,11 @@ When users create specialized instances, suggest these configurations:
|
|
|
3186
3336
|
this.topicArchiver.stop();
|
|
3187
3337
|
this.scheduler?.shutdown();
|
|
3188
3338
|
// Stop instances in parallel batches to avoid long sequential waits.
|
|
3189
|
-
// Concurrency
|
|
3190
|
-
|
|
3339
|
+
// Concurrency scales with fleet size — larger fleets tolerate more parallel
|
|
3340
|
+
// tmux ops, while small fleets stay conservative to avoid overwhelming the
|
|
3341
|
+
// tmux server.
|
|
3191
3342
|
const entries = [...this.daemons.entries()];
|
|
3343
|
+
const STOP_CONCURRENCY = entries.length > 30 ? 15 : entries.length >= 10 ? 10 : 5;
|
|
3192
3344
|
for (const [name] of entries)
|
|
3193
3345
|
this.ipcStoppingInstances.add(name);
|
|
3194
3346
|
for (let i = 0; i < entries.length; i += STOP_CONCURRENCY) {
|
|
@@ -3203,9 +3355,9 @@ When users create specialized instances, suggest these configurations:
|
|
|
3203
3355
|
this.daemons.delete(name);
|
|
3204
3356
|
}));
|
|
3205
3357
|
}
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
}
|
|
3358
|
+
// Close IPC clients in parallel — serial close over a large fleet adds
|
|
3359
|
+
// noticeable latency.
|
|
3360
|
+
await Promise.all([...this.instanceIpcClients.values()].map(ipc => Promise.resolve(ipc.close()).catch(() => { })));
|
|
3209
3361
|
this.instanceIpcClients.clear();
|
|
3210
3362
|
this.ipcStoppingInstances.clear();
|
|
3211
3363
|
for (const [, w] of this.worlds) {
|