botmux 2.13.2 → 2.15.0
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/dist/cli.js +56 -11
- package/dist/cli.js.map +1 -1
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +10 -4
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +65 -3
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/dashboard-rows.d.ts.map +1 -1
- package/dist/core/dashboard-rows.js +6 -1
- package/dist/core/dashboard-rows.js.map +1 -1
- package/dist/core/scheduler.d.ts +1 -0
- package/dist/core/scheduler.d.ts.map +1 -1
- package/dist/core/scheduler.js +1 -0
- package/dist/core/scheduler.js.map +1 -1
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +90 -52
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/types.d.ts +18 -2
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +14 -3
- package/dist/core/types.js.map +1 -1
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +22 -22
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +185 -90
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/operator-selector.d.ts +17 -0
- package/dist/dashboard/operator-selector.d.ts.map +1 -0
- package/dist/dashboard/operator-selector.js +37 -0
- package/dist/dashboard/operator-selector.js.map +1 -0
- package/dist/dashboard/web/groups.d.ts.map +1 -1
- package/dist/dashboard/web/groups.js +61 -7
- package/dist/dashboard/web/groups.js.map +1 -1
- package/dist/dashboard-web/app.js +60 -53
- package/dist/dashboard-web/style.css +10 -1
- package/dist/dashboard.js +119 -34
- package/dist/dashboard.js.map +1 -1
- package/dist/im/lark/card-handler.js +8 -8
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/im/lark/client.d.ts +27 -0
- package/dist/im/lark/client.d.ts.map +1 -1
- package/dist/im/lark/client.js +102 -8
- package/dist/im/lark/client.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts +42 -4
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +121 -47
- package/dist/im/lark/event-dispatcher.js.map +1 -1
- package/dist/services/groups-store.d.ts +22 -0
- package/dist/services/groups-store.d.ts.map +1 -1
- package/dist/services/groups-store.js +39 -1
- package/dist/services/groups-store.js.map +1 -1
- package/dist/services/schedule-store.d.ts +1 -0
- package/dist/services/schedule-store.d.ts.map +1 -1
- package/dist/services/schedule-store.js +1 -0
- package/dist/services/schedule-store.js.map +1 -1
- package/dist/services/session-store.d.ts +12 -0
- package/dist/services/session-store.d.ts.map +1 -1
- package/dist/services/session-store.js +19 -2
- package/dist/services/session-store.js.map +1 -1
- package/dist/setup/detect-platform.d.ts +14 -0
- package/dist/setup/detect-platform.d.ts.map +1 -0
- package/dist/setup/detect-platform.js +139 -0
- package/dist/setup/detect-platform.js.map +1 -0
- package/dist/setup/ensure-fonts.d.ts +13 -0
- package/dist/setup/ensure-fonts.d.ts.map +1 -0
- package/dist/setup/ensure-fonts.js +225 -0
- package/dist/setup/ensure-fonts.js.map +1 -0
- package/dist/setup/ensure-tmux.d.ts +11 -0
- package/dist/setup/ensure-tmux.d.ts.map +1 -0
- package/dist/setup/ensure-tmux.js +151 -0
- package/dist/setup/ensure-tmux.js.map +1 -0
- package/dist/setup/index.d.ts +9 -0
- package/dist/setup/index.d.ts.map +1 -0
- package/dist/setup/index.js +35 -0
- package/dist/setup/index.js.map +1 -0
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/bot-mention-dedup.d.ts +26 -0
- package/dist/utils/bot-mention-dedup.d.ts.map +1 -0
- package/dist/utils/bot-mention-dedup.js +56 -0
- package/dist/utils/bot-mention-dedup.js.map +1 -0
- package/dist/utils/screenshot-renderer.d.ts.map +1 -1
- package/dist/utils/screenshot-renderer.js +12 -5
- package/dist/utils/screenshot-renderer.js.map +1 -1
- package/package.json +1 -1
package/dist/daemon.js
CHANGED
|
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
const __filename = fileURLToPath(import.meta.url);
|
|
7
7
|
const __dirname = dirname(__filename);
|
|
8
8
|
import { config } from './config.js';
|
|
9
|
-
import { replyMessage, resolveAllowedUsers } from './im/lark/client.js';
|
|
9
|
+
import { replyMessage, resolveAllowedUsers, sendMessage } from './im/lark/client.js';
|
|
10
10
|
import { loadBotConfigs, registerBot, getBot, getAllBots, findOncallChat } from './bot-registry.js';
|
|
11
11
|
import * as sessionStore from './services/session-store.js';
|
|
12
12
|
import * as scheduleStore from './services/schedule-store.js';
|
|
@@ -15,7 +15,7 @@ import { parseEventMessage, resolveNonsupportMessage, stripLeadingMentions } fro
|
|
|
15
15
|
import { expandMergeForward } from './im/lark/merge-forward.js';
|
|
16
16
|
import { logger } from './utils/logger.js';
|
|
17
17
|
import { ensureCjkFontsInstalled } from './utils/font-installer.js';
|
|
18
|
-
import { sessionKey } from './core/types.js';
|
|
18
|
+
import { sessionKey, sessionAnchorId } from './core/types.js';
|
|
19
19
|
import * as scheduler from './core/scheduler.js';
|
|
20
20
|
import { scanMultipleProjects } from './services/project-scanner.js';
|
|
21
21
|
import { buildRepoSelectCard, buildStreamingCard, getCliDisplayName } from './im/lark/card-builder.js';
|
|
@@ -28,6 +28,7 @@ import { isCallbackUrl, handleCallbackUrl } from './utils/user-token.js';
|
|
|
28
28
|
import { getSessionWorkingDir, getProjectScanDirs, downloadResources, formatAttachmentsHint, buildNewTopicPrompt, buildFollowUpContent, buildBridgeInputContent, getAvailableBots, restoreActiveSessions, executeScheduledTask, persistStreamCardState, } from './core/session-manager.js';
|
|
29
29
|
import { handleCardAction } from './im/lark/card-handler.js';
|
|
30
30
|
import { isBotMentioned, probeBotOpenId, startLarkEventDispatcher, writeBotInfoFile, canOperate } from './im/lark/event-dispatcher.js';
|
|
31
|
+
import { isBotMentionMessageHandled, markBotMentionMessageHandled } from './utils/bot-mention-dedup.js';
|
|
31
32
|
// ─── State ───────────────────────────────────────────────────────────────────
|
|
32
33
|
const activeSessions = new Map();
|
|
33
34
|
// Cache last /repo scan results per chat for /repo <number> fallback
|
|
@@ -35,18 +36,31 @@ const lastRepoScan = new Map();
|
|
|
35
36
|
const cliVersionCache = new Map();
|
|
36
37
|
const VERSION_CHECK_INTERVAL = 60_000; // cache 1 min
|
|
37
38
|
/**
|
|
38
|
-
* Reply
|
|
39
|
-
*
|
|
40
|
-
*
|
|
39
|
+
* Reply into a session — scope-aware.
|
|
40
|
+
*
|
|
41
|
+
* `anchor` is whatever the caller has at hand:
|
|
42
|
+
* - thread-scope sessions → rootMessageId
|
|
43
|
+
* - chat-scope sessions → chatId
|
|
44
|
+
*
|
|
45
|
+
* Behaviour:
|
|
46
|
+
* - thread-scope (or no matching DS, the legacy default) → reply with
|
|
47
|
+
* reply_in_thread=true to the anchor message_id
|
|
48
|
+
* - chat-scope → send a plain
|
|
49
|
+
* message to ds.chatId (no reply, no thread). Cards / button values
|
|
50
|
+
* embed the chatId so handleCardAction can route back into the same
|
|
51
|
+
* session.
|
|
52
|
+
*
|
|
53
|
+
* Lark message ids start with `om_` and chat ids with `oc_`, so the two
|
|
54
|
+
* address spaces never collide; the lookup just tries both.
|
|
41
55
|
*/
|
|
42
|
-
async function sessionReply(
|
|
56
|
+
async function sessionReply(anchor, content, msgType = 'text', larkAppId) {
|
|
43
57
|
let ds;
|
|
44
58
|
if (larkAppId) {
|
|
45
|
-
ds = activeSessions.get(sessionKey(
|
|
59
|
+
ds = activeSessions.get(sessionKey(anchor, larkAppId));
|
|
46
60
|
}
|
|
47
61
|
else {
|
|
48
62
|
for (const s of activeSessions.values()) {
|
|
49
|
-
if (s
|
|
63
|
+
if (sessionAnchorId(s) === anchor) {
|
|
50
64
|
ds = s;
|
|
51
65
|
break;
|
|
52
66
|
}
|
|
@@ -55,7 +69,20 @@ async function sessionReply(rootId, content, msgType = 'text', larkAppId) {
|
|
|
55
69
|
const appId = larkAppId ?? ds?.larkAppId ?? getAllBots()[0]?.config.larkAppId;
|
|
56
70
|
if (!appId)
|
|
57
71
|
throw new Error('No bot configured');
|
|
58
|
-
|
|
72
|
+
// Chat-scope: post a plain message to the chat. No reply_in_thread → keeps
|
|
73
|
+
// the conversation flat in 普通群 / p2p. The card layer carries chatId in
|
|
74
|
+
// its button values, so handleCardAction routes back via sessionKey(chatId).
|
|
75
|
+
//
|
|
76
|
+
// Detect chat-scope from either ds.scope or anchor's `oc_` prefix. The
|
|
77
|
+
// prefix fallback covers the close-button race: card-handler deletes ds
|
|
78
|
+
// from activeSessions BEFORE sending the close-confirmation reply, so by
|
|
79
|
+
// the time we run, ds is gone — but the anchor (chatId, oc_xxx) is enough
|
|
80
|
+
// to know we should sendMessage, not reply_in_thread to a non-message-id.
|
|
81
|
+
if (ds?.scope === 'chat' || anchor.startsWith('oc_')) {
|
|
82
|
+
return sendMessage(appId, ds?.chatId ?? anchor, content, msgType);
|
|
83
|
+
}
|
|
84
|
+
// Thread-scope (or unknown / legacy): reply in thread.
|
|
85
|
+
return replyMessage(appId, anchor, content, msgType, true);
|
|
59
86
|
}
|
|
60
87
|
// ─── PID file ────────────────────────────────────────────────────────────────
|
|
61
88
|
function getPidFile() {
|
|
@@ -187,7 +214,8 @@ const cardDeps = {
|
|
|
187
214
|
lastRepoScan,
|
|
188
215
|
};
|
|
189
216
|
// ─── Event handling ──────────────────────────────────────────────────────────
|
|
190
|
-
async function handleNewTopic(data,
|
|
217
|
+
async function handleNewTopic(data, ctx) {
|
|
218
|
+
const { chatId, messageId, chatType, scope, anchor, larkAppId } = ctx;
|
|
191
219
|
await resolveNonsupportMessage(data, larkAppId);
|
|
192
220
|
const { parsed, resources } = parseEventMessage(data);
|
|
193
221
|
// Expand merge_forward: fetch sub-messages and collect their resources
|
|
@@ -200,28 +228,29 @@ async function handleNewTopic(data, chatId, messageId, chatType = 'group', larkA
|
|
|
200
228
|
const cmdContent = stripLeadingMentions(content, parsed.mentions);
|
|
201
229
|
const senderOpenId = data.sender?.sender_id?.open_id;
|
|
202
230
|
const botCfg = getBot(larkAppId).config;
|
|
203
|
-
logger.info(`New
|
|
231
|
+
logger.info(`New session: "${content.substring(0, 60)}" (scope=${scope}, anchor=${anchor.substring(0, 12)}, resources: ${resources.length}, active: ${getActiveCount()}, messageId: ${messageId}, chatId: ${chatId})`);
|
|
204
232
|
// Intercept daemon commands in new topics (no session needed for some commands)
|
|
205
233
|
const invocation = parseSlashCommandInvocation(cmdContent);
|
|
206
234
|
if (invocation) {
|
|
207
235
|
const { cmd, content: commandContent } = invocation;
|
|
208
236
|
if (PASSTHROUGH_COMMANDS.has(cmd)) {
|
|
209
|
-
await sessionReply(
|
|
237
|
+
await sessionReply(anchor, `${cmd} 需要在已有会话内使用(先发一条普通消息启动 CLI)。`, 'text', larkAppId);
|
|
210
238
|
return;
|
|
211
239
|
}
|
|
212
240
|
if (DAEMON_COMMANDS.has(cmd)) {
|
|
213
241
|
// Oncall groups: any member can talk, but daemon commands (except /oncall
|
|
214
242
|
// itself which gates bind/unbind inside) are owner-only.
|
|
215
243
|
if (cmd !== '/oncall' && findOncallChat(larkAppId, chatId) && !canOperate(larkAppId, chatId, senderOpenId)) {
|
|
216
|
-
await sessionReply(
|
|
244
|
+
await sessionReply(anchor, `⚠️ ${cmd} 仅 oncall owner 可执行。`, 'text', larkAppId);
|
|
217
245
|
return;
|
|
218
246
|
}
|
|
219
247
|
const session = sessionStore.createSession(chatId, messageId, cmdContent.substring(0, 50), chatType);
|
|
220
248
|
session.larkAppId = larkAppId;
|
|
221
249
|
session.ownerOpenId = senderOpenId;
|
|
222
250
|
session.lastCallerOpenId = senderOpenId;
|
|
251
|
+
session.scope = scope;
|
|
223
252
|
sessionStore.updateSession(session);
|
|
224
|
-
activeSessions.set(sessionKey(
|
|
253
|
+
activeSessions.set(sessionKey(anchor, larkAppId), {
|
|
225
254
|
session,
|
|
226
255
|
worker: null,
|
|
227
256
|
workerPort: null,
|
|
@@ -229,6 +258,7 @@ async function handleNewTopic(data, chatId, messageId, chatType = 'group', larkA
|
|
|
229
258
|
larkAppId,
|
|
230
259
|
chatId,
|
|
231
260
|
chatType,
|
|
261
|
+
scope,
|
|
232
262
|
spawnedAt: Date.now(),
|
|
233
263
|
cliVersion: cliVersionCache.get(botCfg.cliId)?.version ?? 'unknown',
|
|
234
264
|
lastMessageAt: Date.now(),
|
|
@@ -236,7 +266,7 @@ async function handleNewTopic(data, chatId, messageId, chatType = 'group', larkA
|
|
|
236
266
|
ownerOpenId: senderOpenId,
|
|
237
267
|
});
|
|
238
268
|
// Pass mention-stripped content so /command argument parsing works.
|
|
239
|
-
await handleCommand(cmd,
|
|
269
|
+
await handleCommand(cmd, anchor, { ...parsed, content: commandContent }, commandDeps, larkAppId);
|
|
240
270
|
return;
|
|
241
271
|
}
|
|
242
272
|
}
|
|
@@ -246,18 +276,39 @@ async function handleNewTopic(data, chatId, messageId, chatType = 'group', larkA
|
|
|
246
276
|
parsed.attachments = attachments;
|
|
247
277
|
}
|
|
248
278
|
if (needLogin) {
|
|
249
|
-
sessionReply(
|
|
279
|
+
sessionReply(anchor, '⚠️ 部分图片/文件下载失败(缺少 User Token)。请在话题中发送 /login 授权后重新发送。', 'text', larkAppId);
|
|
250
280
|
}
|
|
251
281
|
refreshCliVersion(botCfg.cliId, botCfg.cliPathOverride);
|
|
252
|
-
// Create session in pending-repo state — don't spawn CLI yet
|
|
282
|
+
// Create session in pending-repo state — don't spawn CLI yet.
|
|
283
|
+
// For thread-scope, rootMessageId == anchor (the thread root).
|
|
284
|
+
// For chat-scope, rootMessageId stores the seed message_id (audit only);
|
|
285
|
+
// routing keys off chatId via sessionAnchorId().
|
|
253
286
|
const session = sessionStore.createSession(chatId, messageId, parsed.content.substring(0, 50), chatType);
|
|
254
287
|
session.larkAppId = larkAppId;
|
|
255
288
|
session.ownerOpenId = senderOpenId;
|
|
289
|
+
session.scope = scope;
|
|
256
290
|
sessionStore.updateSession(session);
|
|
257
|
-
messageQueue.ensureQueue(
|
|
258
|
-
messageQueue.appendMessage(
|
|
291
|
+
messageQueue.ensureQueue(anchor);
|
|
292
|
+
messageQueue.appendMessage(anchor, parsed);
|
|
259
293
|
// Oncall group: pin working dir from binding, skip repo selection entirely.
|
|
260
294
|
const oncallEntry = findOncallChat(larkAppId, chatId);
|
|
295
|
+
// Cross-bot inheritance: when a sibling bot already has an active session
|
|
296
|
+
// anchored here (typical: user @s a second Claude/Aiden in a thread or 普通群
|
|
297
|
+
// for review), reuse its workingDir and skip the repo card. The same block
|
|
298
|
+
// exists in handleThreadReply's auto-create branch — both handlers can land
|
|
299
|
+
// an unowned message after the 4fec43c routing change, so the inheritance
|
|
300
|
+
// probe has to run in both places.
|
|
301
|
+
let inheritedFrom = null;
|
|
302
|
+
if (!oncallEntry) {
|
|
303
|
+
const peers = scope === 'thread'
|
|
304
|
+
? sessionStore.findActiveSessionsByRoot(anchor)
|
|
305
|
+
: sessionStore.findActiveChatScopeSessionsByChat(chatId);
|
|
306
|
+
const peer = peers.find(p => p.larkAppId !== larkAppId && !!p.workingDir);
|
|
307
|
+
if (peer && peer.workingDir) {
|
|
308
|
+
inheritedFrom = { sessionId: peer.sessionId, larkAppId: peer.larkAppId, workingDir: peer.workingDir };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const pinnedWorkingDir = oncallEntry?.workingDir ?? inheritedFrom?.workingDir;
|
|
261
312
|
const ds = {
|
|
262
313
|
session,
|
|
263
314
|
worker: null,
|
|
@@ -266,29 +317,33 @@ async function handleNewTopic(data, chatId, messageId, chatType = 'group', larkA
|
|
|
266
317
|
larkAppId,
|
|
267
318
|
chatId,
|
|
268
319
|
chatType,
|
|
320
|
+
scope,
|
|
269
321
|
spawnedAt: Date.now(),
|
|
270
322
|
cliVersion: cliVersionCache.get(botCfg.cliId)?.version ?? 'unknown',
|
|
271
323
|
lastMessageAt: Date.now(),
|
|
272
324
|
hasHistory: false,
|
|
273
|
-
pendingRepo: !
|
|
325
|
+
pendingRepo: !pinnedWorkingDir,
|
|
274
326
|
pendingPrompt: content,
|
|
275
327
|
pendingAttachments: attachments.length > 0 ? attachments : undefined,
|
|
276
328
|
pendingMentions: parsed.mentions,
|
|
277
329
|
ownerOpenId: senderOpenId,
|
|
278
330
|
currentTurnTitle: content.substring(0, 50),
|
|
279
|
-
workingDir:
|
|
331
|
+
workingDir: pinnedWorkingDir,
|
|
280
332
|
};
|
|
281
|
-
if (
|
|
282
|
-
ds.session.workingDir =
|
|
333
|
+
if (pinnedWorkingDir) {
|
|
334
|
+
ds.session.workingDir = pinnedWorkingDir;
|
|
283
335
|
sessionStore.updateSession(ds.session);
|
|
284
336
|
}
|
|
285
|
-
activeSessions.set(sessionKey(
|
|
286
|
-
//
|
|
287
|
-
if (
|
|
337
|
+
activeSessions.set(sessionKey(anchor, larkAppId), ds);
|
|
338
|
+
// Pinned (oncall binding or inherited from sibling bot): spawn CLI immediately.
|
|
339
|
+
if (pinnedWorkingDir) {
|
|
288
340
|
const selfBot = getBot(larkAppId);
|
|
289
341
|
const prompt = buildNewTopicPrompt(content, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, chatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId });
|
|
290
342
|
forkWorker(ds, prompt);
|
|
291
|
-
|
|
343
|
+
const reason = oncallEntry
|
|
344
|
+
? `oncall-bound chat ${chatId}`
|
|
345
|
+
: `inherited from sibling session ${inheritedFrom.sessionId.substring(0, 8)} (app=${inheritedFrom.larkAppId ?? 'unknown'})`;
|
|
346
|
+
logger.info(`[${tag(ds)}] ${reason} → workingDir=${pinnedWorkingDir}, skipped repo select`);
|
|
292
347
|
return;
|
|
293
348
|
}
|
|
294
349
|
// Show repo selection card
|
|
@@ -300,8 +355,8 @@ async function handleNewTopic(data, chatId, messageId, chatType = 'group', larkA
|
|
|
300
355
|
if (projects.length > 0) {
|
|
301
356
|
lastRepoScan.set(chatId, projects);
|
|
302
357
|
const currentCwd = getSessionWorkingDir(ds);
|
|
303
|
-
const cardJson = buildRepoSelectCard(projects, currentCwd,
|
|
304
|
-
ds.repoCardMessageId = await sessionReply(
|
|
358
|
+
const cardJson = buildRepoSelectCard(projects, currentCwd, anchor);
|
|
359
|
+
ds.repoCardMessageId = await sessionReply(anchor, cardJson, 'interactive', larkAppId);
|
|
305
360
|
logger.info(`[${tag(ds)}] Waiting for repo selection (${projects.length} projects)`);
|
|
306
361
|
}
|
|
307
362
|
else {
|
|
@@ -313,7 +368,8 @@ async function handleNewTopic(data, chatId, messageId, chatType = 'group', larkA
|
|
|
313
368
|
logger.info(`Session ${session.sessionId} ready (no projects to select), total active: ${getActiveCount()}`);
|
|
314
369
|
}
|
|
315
370
|
}
|
|
316
|
-
async function handleThreadReply(data,
|
|
371
|
+
async function handleThreadReply(data, ctx) {
|
|
372
|
+
const { chatId: ctxChatId, chatType: ctxChatType, scope, anchor, larkAppId } = ctx;
|
|
317
373
|
await resolveNonsupportMessage(data, larkAppId);
|
|
318
374
|
const { parsed, resources } = parseEventMessage(data);
|
|
319
375
|
// Expand merge_forward: fetch sub-messages and collect their resources
|
|
@@ -328,7 +384,9 @@ async function handleThreadReply(data, rootId, larkAppId) {
|
|
|
328
384
|
if (isCallbackUrl(content)) {
|
|
329
385
|
const result = await handleCallbackUrl(content);
|
|
330
386
|
if (result) {
|
|
331
|
-
|
|
387
|
+
// Route through sessionReply so chat-scope (普通群) lands as a plain
|
|
388
|
+
// chat message instead of a forced new thread.
|
|
389
|
+
sessionReply(anchor, result, 'text', larkAppId)
|
|
332
390
|
.catch(err => logger.error(`Failed to reply login result: ${err}`));
|
|
333
391
|
return;
|
|
334
392
|
}
|
|
@@ -338,40 +396,48 @@ async function handleThreadReply(data, rootId, larkAppId) {
|
|
|
338
396
|
if (invocation) {
|
|
339
397
|
const { cmd, content: commandContent } = invocation;
|
|
340
398
|
if (PASSTHROUGH_COMMANDS.has(cmd)) {
|
|
341
|
-
const ds = activeSessions.get(sessionKey(
|
|
399
|
+
const ds = activeSessions.get(sessionKey(anchor, larkAppId));
|
|
342
400
|
if (ds?.worker && !ds.worker.killed) {
|
|
343
401
|
ds.worker.send({ type: 'raw_input', content: commandContent });
|
|
344
402
|
ds.lastMessageAt = Date.now();
|
|
345
|
-
logger.info(`[${
|
|
403
|
+
logger.info(`[${anchor.substring(0, 12)}] Passthrough ${cmd} → worker`);
|
|
346
404
|
}
|
|
347
405
|
else {
|
|
348
|
-
sessionReply(
|
|
406
|
+
sessionReply(anchor, `${cmd} 需要活跃的 CLI 进程,当前话题无运行中的会话。`, 'text', larkAppId);
|
|
349
407
|
}
|
|
350
408
|
return;
|
|
351
409
|
}
|
|
352
410
|
if (DAEMON_COMMANDS.has(cmd)) {
|
|
353
411
|
// Oncall owner gate for thread-reply daemon commands
|
|
354
|
-
const existingDs = activeSessions.get(sessionKey(
|
|
355
|
-
const threadChatId = existingDs?.chatId ?? data?.message?.chat_id;
|
|
412
|
+
const existingDs = activeSessions.get(sessionKey(anchor, larkAppId));
|
|
413
|
+
const threadChatId = existingDs?.chatId ?? ctxChatId ?? data?.message?.chat_id;
|
|
356
414
|
const threadSenderOpenId = parsed.senderId || data?.sender?.sender_id?.open_id;
|
|
357
415
|
if (cmd !== '/oncall' && threadChatId && findOncallChat(larkAppId, threadChatId) && !canOperate(larkAppId, threadChatId, threadSenderOpenId)) {
|
|
358
|
-
sessionReply(
|
|
416
|
+
sessionReply(anchor, `⚠️ ${cmd} 仅 oncall owner 可执行。`, 'text', larkAppId);
|
|
359
417
|
return;
|
|
360
418
|
}
|
|
361
419
|
// Pass mention-stripped content so /command argument parsing works.
|
|
362
|
-
handleCommand(cmd,
|
|
420
|
+
handleCommand(cmd, anchor, { ...parsed, content: commandContent }, commandDeps, larkAppId);
|
|
363
421
|
return;
|
|
364
422
|
}
|
|
365
423
|
}
|
|
366
|
-
logger.info(`
|
|
367
|
-
let ds = activeSessions.get(sessionKey(
|
|
368
|
-
// If another bot already owns this
|
|
424
|
+
logger.info(`Reply in ${scope}-scope session ${anchor.substring(0, 12)}: ${content.substring(0, 100)} (resources: ${resources.length})`);
|
|
425
|
+
let ds = activeSessions.get(sessionKey(anchor, larkAppId));
|
|
426
|
+
// If another bot already owns this anchor, ignore unmentioned replies here as a
|
|
369
427
|
// second line of defense. Explicit @mentions are still allowed to spin up/take over.
|
|
428
|
+
// For chat-scope: another bot's session in the same chat is keyed by its own chatId.
|
|
429
|
+
// For thread-scope: same rootMessageId may have peer sessions across bots.
|
|
370
430
|
if (!ds) {
|
|
371
431
|
const mentionedThisBot = isBotMentioned(larkAppId, data?.message ?? {}, data?.sender?.sender_id?.open_id);
|
|
372
|
-
const hasOtherBot = [...activeSessions.values()].some(s =>
|
|
432
|
+
const hasOtherBot = [...activeSessions.values()].some(s => {
|
|
433
|
+
if (s.larkAppId === larkAppId)
|
|
434
|
+
return false;
|
|
435
|
+
if (s.scope === 'chat')
|
|
436
|
+
return s.chatId === ctxChatId && scope === 'chat';
|
|
437
|
+
return s.session.rootMessageId === anchor;
|
|
438
|
+
});
|
|
373
439
|
if (hasOtherBot && !mentionedThisBot) {
|
|
374
|
-
logger.info(`[${larkAppId}] Ignoring
|
|
440
|
+
logger.info(`[${larkAppId}] Ignoring ${scope}-scope ${anchor}; another bot already owns it`);
|
|
375
441
|
return;
|
|
376
442
|
}
|
|
377
443
|
}
|
|
@@ -382,7 +448,7 @@ async function handleThreadReply(data, rootId, larkAppId) {
|
|
|
382
448
|
parsed.attachments = attachments;
|
|
383
449
|
}
|
|
384
450
|
if (needLogin) {
|
|
385
|
-
sessionReply(
|
|
451
|
+
sessionReply(anchor, '⚠️ 部分图片/文件下载失败(缺少 User Token)。请在话题中发送 /login 授权后重新发送。', 'text', effectiveAppId);
|
|
386
452
|
}
|
|
387
453
|
// Update last message time + last caller (used by `botmux send` to address
|
|
388
454
|
// reply cards to whoever triggered this turn — matters in oncall groups
|
|
@@ -411,39 +477,52 @@ async function handleThreadReply(data, rootId, larkAppId) {
|
|
|
411
477
|
if (!ds.pendingFollowUps)
|
|
412
478
|
ds.pendingFollowUps = [];
|
|
413
479
|
ds.pendingFollowUps.push(enriched);
|
|
414
|
-
await sessionReply(
|
|
480
|
+
await sessionReply(anchor, '请先在上方卡片中选择仓库,您的消息已暂存,选择后会自动发送。', 'text', larkAppId);
|
|
415
481
|
return;
|
|
416
482
|
}
|
|
417
|
-
// Route to file queue
|
|
418
|
-
messageQueue.ensureQueue(
|
|
419
|
-
messageQueue.appendMessage(
|
|
483
|
+
// Route to file queue (keyed by anchor: rootMessageId for thread, chatId for chat)
|
|
484
|
+
messageQueue.ensureQueue(anchor);
|
|
485
|
+
messageQueue.appendMessage(anchor, parsed);
|
|
420
486
|
if (!ds) {
|
|
421
|
-
// No active session
|
|
422
|
-
|
|
423
|
-
|
|
487
|
+
// No active session at this anchor — auto-create. This branch is mostly a
|
|
488
|
+
// safety net; the dispatcher routes here only when isSessionOwner() returns
|
|
489
|
+
// true, but races (between check and execution, or session-closed events)
|
|
490
|
+
// can land us here.
|
|
491
|
+
if (activeSessions.has(sessionKey(anchor, larkAppId))) {
|
|
492
|
+
logger.info(`[${larkAppId}] Session already exists for ${scope}-scope ${anchor}, skipping auto-create`);
|
|
424
493
|
return;
|
|
425
494
|
}
|
|
426
|
-
const
|
|
427
|
-
const
|
|
495
|
+
const autoCreateChatId = ctxChatId ?? data?.message?.chat_id ?? '';
|
|
496
|
+
const autoCreateChatType = ctxChatType ?? (data?.message?.chat_type === 'p2p' ? 'p2p' : 'group');
|
|
428
497
|
const botCfg = getBot(larkAppId).config;
|
|
429
|
-
logger.info(`No active session for
|
|
498
|
+
logger.info(`No active session for ${scope}-scope ${anchor}, auto-creating new session...`);
|
|
430
499
|
refreshCliVersion(botCfg.cliId, botCfg.cliPathOverride);
|
|
431
500
|
const senderOId = data.sender?.sender_id?.open_id;
|
|
432
|
-
|
|
501
|
+
// For thread-scope: rootMessageId = anchor (real thread root).
|
|
502
|
+
// For chat-scope: rootMessageId = the message_id that triggered this auto-create
|
|
503
|
+
// (used as audit trail; routing key is chatId).
|
|
504
|
+
const rootIdForStore = scope === 'thread' ? anchor : parsed.messageId;
|
|
505
|
+
const session = sessionStore.createSession(autoCreateChatId, rootIdForStore, parsed.content.substring(0, 50), autoCreateChatType);
|
|
433
506
|
session.larkAppId = larkAppId;
|
|
434
507
|
session.ownerOpenId = senderOId;
|
|
435
508
|
session.lastCallerOpenId = senderOId;
|
|
509
|
+
session.scope = scope;
|
|
436
510
|
sessionStore.updateSession(session);
|
|
437
511
|
// Oncall group: pin working dir from binding, skip repo selection entirely
|
|
438
512
|
// (mirrors handleNewTopic — auto-create was missing this check).
|
|
439
|
-
const oncallEntry = findOncallChat(larkAppId,
|
|
513
|
+
const oncallEntry = findOncallChat(larkAppId, autoCreateChatId);
|
|
440
514
|
// Cross-bot inheritance: when another bot already pinned a working dir for
|
|
441
|
-
// this
|
|
515
|
+
// this anchor (typical: Bot A is mid-task and @mentions Bot B for review),
|
|
442
516
|
// inherit it so we can spawn immediately with the @-content as the prompt
|
|
443
517
|
// — same shape as a human-initiated start, no repo selection round-trip.
|
|
518
|
+
// Thread-scope: peer lookup is by rootMessageId.
|
|
519
|
+
// Chat-scope (普通群): peer lookup is by chatId among other bots' chat-scope
|
|
520
|
+
// sessions in the same chat — same intent, different anchor.
|
|
444
521
|
let inheritedFrom = null;
|
|
445
522
|
if (!oncallEntry) {
|
|
446
|
-
const peers =
|
|
523
|
+
const peers = scope === 'thread'
|
|
524
|
+
? sessionStore.findActiveSessionsByRoot(anchor)
|
|
525
|
+
: sessionStore.findActiveChatScopeSessionsByChat(autoCreateChatId);
|
|
447
526
|
const peer = peers.find(p => p.larkAppId !== larkAppId && !!p.workingDir);
|
|
448
527
|
if (peer && peer.workingDir) {
|
|
449
528
|
inheritedFrom = { sessionId: peer.sessionId, larkAppId: peer.larkAppId, workingDir: peer.workingDir };
|
|
@@ -456,8 +535,9 @@ async function handleThreadReply(data, rootId, larkAppId) {
|
|
|
456
535
|
workerPort: null,
|
|
457
536
|
workerToken: null,
|
|
458
537
|
larkAppId,
|
|
459
|
-
chatId,
|
|
460
|
-
chatType,
|
|
538
|
+
chatId: autoCreateChatId,
|
|
539
|
+
chatType: autoCreateChatType,
|
|
540
|
+
scope,
|
|
461
541
|
spawnedAt: Date.now(),
|
|
462
542
|
cliVersion: cliVersionCache.get(botCfg.cliId)?.version ?? 'unknown',
|
|
463
543
|
lastMessageAt: Date.now(),
|
|
@@ -474,15 +554,15 @@ async function handleThreadReply(data, rootId, larkAppId) {
|
|
|
474
554
|
newDs.session.workingDir = pinnedWorkingDir;
|
|
475
555
|
sessionStore.updateSession(newDs.session);
|
|
476
556
|
}
|
|
477
|
-
activeSessions.set(sessionKey(
|
|
557
|
+
activeSessions.set(sessionKey(anchor, larkAppId), newDs);
|
|
478
558
|
// Pinned (oncall binding or inherited from peer bot in same thread):
|
|
479
559
|
// spawn CLI immediately, skip repo selection.
|
|
480
560
|
if (pinnedWorkingDir) {
|
|
481
561
|
const selfBot = getBot(larkAppId);
|
|
482
|
-
const prompt = buildNewTopicPrompt(parsed.content, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId,
|
|
562
|
+
const prompt = buildNewTopicPrompt(parsed.content, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, autoCreateChatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId });
|
|
483
563
|
forkWorker(newDs, prompt);
|
|
484
564
|
const reason = oncallEntry
|
|
485
|
-
? `oncall-bound chat ${
|
|
565
|
+
? `oncall-bound chat ${autoCreateChatId}`
|
|
486
566
|
: `inherited from peer session ${inheritedFrom.sessionId.substring(0, 8)} (app=${inheritedFrom.larkAppId ?? 'unknown'})`;
|
|
487
567
|
logger.info(`[${tag(newDs)}] ${reason} → workingDir=${pinnedWorkingDir}, skipped repo select`);
|
|
488
568
|
return;
|
|
@@ -494,17 +574,17 @@ async function handleThreadReply(data, rootId, larkAppId) {
|
|
|
494
574
|
projects = scanMultipleProjects(scanDirs2);
|
|
495
575
|
}
|
|
496
576
|
if (projects.length > 0) {
|
|
497
|
-
lastRepoScan.set(
|
|
577
|
+
lastRepoScan.set(autoCreateChatId, projects);
|
|
498
578
|
const currentCwd = getSessionWorkingDir(newDs);
|
|
499
|
-
const cardJson = buildRepoSelectCard(projects, currentCwd,
|
|
500
|
-
newDs.repoCardMessageId = await sessionReply(
|
|
579
|
+
const cardJson = buildRepoSelectCard(projects, currentCwd, anchor);
|
|
580
|
+
newDs.repoCardMessageId = await sessionReply(anchor, cardJson, 'interactive', larkAppId);
|
|
501
581
|
logger.info(`[${tag(newDs)}] Waiting for repo selection (${projects.length} projects)`);
|
|
502
582
|
}
|
|
503
583
|
else {
|
|
504
584
|
// No projects found — skip repo selection, spawn directly
|
|
505
585
|
newDs.pendingRepo = false;
|
|
506
586
|
const selfBot = getBot(larkAppId);
|
|
507
|
-
const prompt = buildNewTopicPrompt(parsed.content, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId,
|
|
587
|
+
const prompt = buildNewTopicPrompt(parsed.content, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, autoCreateChatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId });
|
|
508
588
|
forkWorker(newDs, prompt);
|
|
509
589
|
}
|
|
510
590
|
return;
|
|
@@ -539,7 +619,7 @@ async function handleThreadReply(data, rootId, larkAppId) {
|
|
|
539
619
|
const dsBotCfg = getBot(ds.larkAppId).config;
|
|
540
620
|
const prevTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(dsBotCfg.cliId);
|
|
541
621
|
const prevMode = ds.displayMode ?? 'hidden';
|
|
542
|
-
const frozenCard = buildStreamingCard(ds.session.sessionId, ds
|
|
622
|
+
const frozenCard = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, prevTitle, ds.lastScreenContent ?? '', 'idle', dsBotCfg.cliId, prevMode, ds.streamCardNonce, ds.currentImageKey, !!ds.adoptedFrom, false);
|
|
543
623
|
// Freeze through the serialization queue to avoid racing with an in-flight PATCH.
|
|
544
624
|
// scheduleCardPatch replaces any stale pending item (latest-wins).
|
|
545
625
|
scheduleCardPatch(ds, frozenCard);
|
|
@@ -588,8 +668,18 @@ function processBotMentionSignal(signal) {
|
|
|
588
668
|
logger.debug(`[bot-mention] No bot found for open_id ${signal.targetBotOpenId}`);
|
|
589
669
|
return;
|
|
590
670
|
}
|
|
671
|
+
// Cross-path dedup: WSClient may have already enqueued this turn. messageId
|
|
672
|
+
// is the canonical key (per-message, immune to ordering between WS push and
|
|
673
|
+
// fs watch).
|
|
674
|
+
if (isBotMentionMessageHandled(signal.messageId)) {
|
|
675
|
+
logger.debug(`[bot-mention] Signal-file path skipping ${signal.messageId.substring(0, 12)}: already handled by WSClient`);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
591
678
|
const targetAppId = targetBot.config.larkAppId;
|
|
592
|
-
|
|
679
|
+
// Anchor depends on sender's session scope: chat-scope sessions are keyed
|
|
680
|
+
// by chatId, thread-scope by rootMessageId.
|
|
681
|
+
const anchor = signal.scope === 'chat' ? signal.chatId : signal.rootMessageId;
|
|
682
|
+
const ds = activeSessions.get(sessionKey(anchor, targetAppId));
|
|
593
683
|
if (ds && ds.worker && !ds.worker.killed) {
|
|
594
684
|
// Target bot has an active session in this thread — send the message.
|
|
595
685
|
// Look up sender name from bots-info.json (each daemon only registers its own bot,
|
|
@@ -622,11 +712,12 @@ function processBotMentionSignal(signal) {
|
|
|
622
712
|
ds.streamCardPending = true;
|
|
623
713
|
ds.currentTurnTitle = signal.content.substring(0, 50);
|
|
624
714
|
persistStreamCardState(ds);
|
|
715
|
+
markBotMentionMessageHandled(signal.messageId);
|
|
625
716
|
ds.worker.send({ type: 'message', content: enrichedContent });
|
|
626
|
-
logger.info(`[bot-mention] Routed message from ${signal.senderAppId} to ${targetAppId}
|
|
717
|
+
logger.info(`[bot-mention] Routed message from ${signal.senderAppId} to ${targetAppId} (scope=${signal.scope ?? 'thread'}, anchor=${anchor.substring(0, 12)})`);
|
|
627
718
|
}
|
|
628
719
|
else {
|
|
629
|
-
logger.debug(`[bot-mention] Target bot ${targetAppId} has no active worker
|
|
720
|
+
logger.debug(`[bot-mention] Target bot ${targetAppId} has no active worker at ${signal.scope ?? 'thread'}-scope anchor ${anchor.substring(0, 12)} — WSClient auto-create path will handle it if @mention was delivered`);
|
|
630
721
|
}
|
|
631
722
|
}
|
|
632
723
|
function isSignalForMe(signal) {
|
|
@@ -710,17 +801,6 @@ export async function startDaemon(botIndex) {
|
|
|
710
801
|
startedAt: Date.now(),
|
|
711
802
|
lastHeartbeat: Date.now(),
|
|
712
803
|
};
|
|
713
|
-
writeDaemonDescriptor(desc);
|
|
714
|
-
const descriptorHeartbeat = setInterval(() => {
|
|
715
|
-
desc.lastHeartbeat = Date.now();
|
|
716
|
-
try {
|
|
717
|
-
writeDaemonDescriptor(desc);
|
|
718
|
-
}
|
|
719
|
-
catch { /* best effort */ }
|
|
720
|
-
}, 30_000);
|
|
721
|
-
// Don't keep the event loop alive on this interval alone.
|
|
722
|
-
if (typeof descriptorHeartbeat.unref === 'function')
|
|
723
|
-
descriptorHeartbeat.unref();
|
|
724
804
|
// Initialise worker pool with daemon callbacks
|
|
725
805
|
initWorkerPool({
|
|
726
806
|
sessionReply,
|
|
@@ -738,14 +818,31 @@ export async function startDaemon(botIndex) {
|
|
|
738
818
|
// so dashboard IPC and other consumers can list/lookup live sessions.
|
|
739
819
|
setActiveSessionsRegistry(activeSessions);
|
|
740
820
|
// Seed dashboard IPC botName with the bot's config id; the friendly name from
|
|
741
|
-
// /bot/v3/info is wired into the registry descriptor (
|
|
821
|
+
// /bot/v3/info is wired into the registry descriptor (below) but the IPC server
|
|
742
822
|
// also needs its own copy for SessionRow.botName.
|
|
743
823
|
setBotName(cfg.larkAppId);
|
|
744
824
|
setLarkAppId(cfg.larkAppId);
|
|
745
|
-
// Bind dashboard IPC HTTP server
|
|
746
|
-
//
|
|
825
|
+
// Bind dashboard IPC HTTP server BEFORE publishing the registry descriptor.
|
|
826
|
+
// Otherwise the dashboard process can race-fetch the IPC port from the
|
|
827
|
+
// descriptor and hit ECONNREFUSED before we're listening — that left every
|
|
828
|
+
// newly-started daemon's hydrate failing on dashboard startup. Binds to
|
|
829
|
+
// 127.0.0.1 only since the dashboard sibling runs on the same host.
|
|
747
830
|
const ipcHandle = await startIpcServer({ port: ipcPort, host: '127.0.0.1' });
|
|
748
831
|
logger.info(`[dashboard-ipc] listening on 127.0.0.1:${ipcHandle.port} (bot ${idx})`);
|
|
832
|
+
// Now that the IPC port is actually listening, publish the descriptor so
|
|
833
|
+
// the dashboard can discover us and successfully fetch /api/sessions etc.
|
|
834
|
+
desc.lastHeartbeat = Date.now();
|
|
835
|
+
writeDaemonDescriptor(desc);
|
|
836
|
+
const descriptorHeartbeat = setInterval(() => {
|
|
837
|
+
desc.lastHeartbeat = Date.now();
|
|
838
|
+
try {
|
|
839
|
+
writeDaemonDescriptor(desc);
|
|
840
|
+
}
|
|
841
|
+
catch { /* best effort */ }
|
|
842
|
+
}, 30_000);
|
|
843
|
+
// Don't keep the event loop alive on this interval alone.
|
|
844
|
+
if (typeof descriptorHeartbeat.unref === 'function')
|
|
845
|
+
descriptorHeartbeat.unref();
|
|
749
846
|
// Per-bot initialization
|
|
750
847
|
for (const bot of getAllBots()) {
|
|
751
848
|
const cfg = bot.config;
|
|
@@ -783,11 +880,9 @@ export async function startDaemon(botIndex) {
|
|
|
783
880
|
// Start event dispatcher for this bot
|
|
784
881
|
startLarkEventDispatcher(cfg.larkAppId, cfg.larkAppSecret, {
|
|
785
882
|
handleCardAction: (data, appId) => handleCardAction(data, cardDeps, appId),
|
|
786
|
-
handleNewTopic: (data,
|
|
787
|
-
handleThreadReply: (data,
|
|
788
|
-
isSessionOwner: (
|
|
789
|
-
return activeSessions.has(sessionKey(rootId, appId));
|
|
790
|
-
},
|
|
883
|
+
handleNewTopic: (data, ctx) => handleNewTopic(data, ctx),
|
|
884
|
+
handleThreadReply: (data, ctx) => handleThreadReply(data, ctx),
|
|
885
|
+
isSessionOwner: (anchor, appId) => activeSessions.has(sessionKey(anchor, appId)),
|
|
791
886
|
});
|
|
792
887
|
}
|
|
793
888
|
// Restore active sessions from previous run
|