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.
Files changed (87) hide show
  1. package/dist/cli.js +56 -11
  2. package/dist/cli.js.map +1 -1
  3. package/dist/core/command-handler.d.ts.map +1 -1
  4. package/dist/core/command-handler.js +10 -4
  5. package/dist/core/command-handler.js.map +1 -1
  6. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  7. package/dist/core/dashboard-ipc-server.js +65 -3
  8. package/dist/core/dashboard-ipc-server.js.map +1 -1
  9. package/dist/core/dashboard-rows.d.ts.map +1 -1
  10. package/dist/core/dashboard-rows.js +6 -1
  11. package/dist/core/dashboard-rows.js.map +1 -1
  12. package/dist/core/scheduler.d.ts +1 -0
  13. package/dist/core/scheduler.d.ts.map +1 -1
  14. package/dist/core/scheduler.js +1 -0
  15. package/dist/core/scheduler.js.map +1 -1
  16. package/dist/core/session-manager.d.ts.map +1 -1
  17. package/dist/core/session-manager.js +90 -52
  18. package/dist/core/session-manager.js.map +1 -1
  19. package/dist/core/types.d.ts +18 -2
  20. package/dist/core/types.d.ts.map +1 -1
  21. package/dist/core/types.js +14 -3
  22. package/dist/core/types.js.map +1 -1
  23. package/dist/core/worker-pool.d.ts.map +1 -1
  24. package/dist/core/worker-pool.js +22 -22
  25. package/dist/core/worker-pool.js.map +1 -1
  26. package/dist/daemon.d.ts.map +1 -1
  27. package/dist/daemon.js +185 -90
  28. package/dist/daemon.js.map +1 -1
  29. package/dist/dashboard/operator-selector.d.ts +17 -0
  30. package/dist/dashboard/operator-selector.d.ts.map +1 -0
  31. package/dist/dashboard/operator-selector.js +37 -0
  32. package/dist/dashboard/operator-selector.js.map +1 -0
  33. package/dist/dashboard/web/groups.d.ts.map +1 -1
  34. package/dist/dashboard/web/groups.js +61 -7
  35. package/dist/dashboard/web/groups.js.map +1 -1
  36. package/dist/dashboard-web/app.js +60 -53
  37. package/dist/dashboard-web/style.css +10 -1
  38. package/dist/dashboard.js +119 -34
  39. package/dist/dashboard.js.map +1 -1
  40. package/dist/im/lark/card-handler.js +8 -8
  41. package/dist/im/lark/card-handler.js.map +1 -1
  42. package/dist/im/lark/client.d.ts +27 -0
  43. package/dist/im/lark/client.d.ts.map +1 -1
  44. package/dist/im/lark/client.js +102 -8
  45. package/dist/im/lark/client.js.map +1 -1
  46. package/dist/im/lark/event-dispatcher.d.ts +42 -4
  47. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  48. package/dist/im/lark/event-dispatcher.js +121 -47
  49. package/dist/im/lark/event-dispatcher.js.map +1 -1
  50. package/dist/services/groups-store.d.ts +22 -0
  51. package/dist/services/groups-store.d.ts.map +1 -1
  52. package/dist/services/groups-store.js +39 -1
  53. package/dist/services/groups-store.js.map +1 -1
  54. package/dist/services/schedule-store.d.ts +1 -0
  55. package/dist/services/schedule-store.d.ts.map +1 -1
  56. package/dist/services/schedule-store.js +1 -0
  57. package/dist/services/schedule-store.js.map +1 -1
  58. package/dist/services/session-store.d.ts +12 -0
  59. package/dist/services/session-store.d.ts.map +1 -1
  60. package/dist/services/session-store.js +19 -2
  61. package/dist/services/session-store.js.map +1 -1
  62. package/dist/setup/detect-platform.d.ts +14 -0
  63. package/dist/setup/detect-platform.d.ts.map +1 -0
  64. package/dist/setup/detect-platform.js +139 -0
  65. package/dist/setup/detect-platform.js.map +1 -0
  66. package/dist/setup/ensure-fonts.d.ts +13 -0
  67. package/dist/setup/ensure-fonts.d.ts.map +1 -0
  68. package/dist/setup/ensure-fonts.js +225 -0
  69. package/dist/setup/ensure-fonts.js.map +1 -0
  70. package/dist/setup/ensure-tmux.d.ts +11 -0
  71. package/dist/setup/ensure-tmux.d.ts.map +1 -0
  72. package/dist/setup/ensure-tmux.js +151 -0
  73. package/dist/setup/ensure-tmux.js.map +1 -0
  74. package/dist/setup/index.d.ts +9 -0
  75. package/dist/setup/index.d.ts.map +1 -0
  76. package/dist/setup/index.js +35 -0
  77. package/dist/setup/index.js.map +1 -0
  78. package/dist/types.d.ts +12 -0
  79. package/dist/types.d.ts.map +1 -1
  80. package/dist/utils/bot-mention-dedup.d.ts +26 -0
  81. package/dist/utils/bot-mention-dedup.d.ts.map +1 -0
  82. package/dist/utils/bot-mention-dedup.js +56 -0
  83. package/dist/utils/bot-mention-dedup.js.map +1 -0
  84. package/dist/utils/screenshot-renderer.d.ts.map +1 -1
  85. package/dist/utils/screenshot-renderer.js +12 -5
  86. package/dist/utils/screenshot-renderer.js.map +1 -1
  87. 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 to a message, automatically using reply_in_thread for p2p sessions.
39
- * Always reply in thread to create/continue a topic.
40
- * This ensures topic-style replies in all chat types (p2p, group, topic group).
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(rootId, content, msgType = 'text', larkAppId) {
56
+ async function sessionReply(anchor, content, msgType = 'text', larkAppId) {
43
57
  let ds;
44
58
  if (larkAppId) {
45
- ds = activeSessions.get(sessionKey(rootId, larkAppId));
59
+ ds = activeSessions.get(sessionKey(anchor, larkAppId));
46
60
  }
47
61
  else {
48
62
  for (const s of activeSessions.values()) {
49
- if (s.session.rootMessageId === rootId) {
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
- return replyMessage(appId, rootId, content, msgType, true);
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, chatId, messageId, chatType = 'group', larkAppId) {
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 topic: "${content.substring(0, 60)}" (resources: ${resources.length}, active: ${getActiveCount()}, messageId: ${messageId}, chatId: ${chatId}`);
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(messageId, `${cmd} 需要在已有会话内使用(先发一条普通消息启动 CLI)。`, 'text', larkAppId);
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(messageId, `⚠️ ${cmd} 仅 oncall owner 可执行。`, 'text', larkAppId);
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(messageId, larkAppId), {
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, messageId, { ...parsed, content: commandContent }, commandDeps, larkAppId);
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(messageId, '⚠️ 部分图片/文件下载失败(缺少 User Token)。请在话题中发送 /login 授权后重新发送。', 'text', larkAppId);
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(messageId);
258
- messageQueue.appendMessage(messageId, parsed);
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: !oncallEntry,
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: oncallEntry?.workingDir,
331
+ workingDir: pinnedWorkingDir,
280
332
  };
281
- if (oncallEntry) {
282
- ds.session.workingDir = oncallEntry.workingDir;
333
+ if (pinnedWorkingDir) {
334
+ ds.session.workingDir = pinnedWorkingDir;
283
335
  sessionStore.updateSession(ds.session);
284
336
  }
285
- activeSessions.set(sessionKey(messageId, larkAppId), ds);
286
- // Oncall-bound chat: spawn CLI immediately with the pinned working dir.
287
- if (oncallEntry) {
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
- logger.info(`[${tag(ds)}] Oncall-bound chat ${chatId} → workingDir=${oncallEntry.workingDir}, skipped repo select`);
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, messageId);
304
- ds.repoCardMessageId = await sessionReply(messageId, cardJson, 'interactive', larkAppId);
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, rootId, larkAppId) {
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
- replyMessage(larkAppId, parsed.messageId, JSON.stringify({ text: result }), 'text', true)
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(rootId, larkAppId));
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(`[${rootId.substring(0, 12)}] Passthrough ${cmd} → worker`);
403
+ logger.info(`[${anchor.substring(0, 12)}] Passthrough ${cmd} → worker`);
346
404
  }
347
405
  else {
348
- sessionReply(rootId, `${cmd} 需要活跃的 CLI 进程,当前话题无运行中的会话。`, 'text', larkAppId);
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(rootId, larkAppId));
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(rootId, `⚠️ ${cmd} 仅 oncall owner 可执行。`, 'text', larkAppId);
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, rootId, { ...parsed, content: commandContent }, commandDeps, larkAppId);
420
+ handleCommand(cmd, anchor, { ...parsed, content: commandContent }, commandDeps, larkAppId);
363
421
  return;
364
422
  }
365
423
  }
366
- logger.info(`Thread reply in ${rootId}: ${content.substring(0, 100)} (resources: ${resources.length})`);
367
- let ds = activeSessions.get(sessionKey(rootId, larkAppId));
368
- // If another bot already owns this thread, ignore unmentioned replies here as a
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 => s.session.rootMessageId === rootId && s.larkAppId !== larkAppId);
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 thread ${rootId}; another bot already owns it`);
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(rootId, '⚠️ 部分图片/文件下载失败(缺少 User Token)。请在话题中发送 /login 授权后重新发送。', 'text', effectiveAppId);
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(rootId, '请先在上方卡片中选择仓库,您的消息已暂存,选择后会自动发送。', 'text', larkAppId);
480
+ await sessionReply(anchor, '请先在上方卡片中选择仓库,您的消息已暂存,选择后会自动发送。', 'text', larkAppId);
415
481
  return;
416
482
  }
417
- // Route to file queue
418
- messageQueue.ensureQueue(rootId);
419
- messageQueue.appendMessage(rootId, parsed);
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 for this thread — auto-create with repo selection
422
- if (activeSessions.has(sessionKey(rootId, larkAppId))) {
423
- logger.info(`[${larkAppId}] Session already exists for thread ${rootId}, skipping auto-create`);
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 chatId = data?.message?.chat_id ?? '';
427
- const chatType = (data?.message?.chat_type === 'p2p' ? 'p2p' : 'group');
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 thread ${rootId}, auto-creating new session...`);
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
- const session = sessionStore.createSession(chatId, rootId, parsed.content.substring(0, 50), chatType);
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, chatId);
513
+ const oncallEntry = findOncallChat(larkAppId, autoCreateChatId);
440
514
  // Cross-bot inheritance: when another bot already pinned a working dir for
441
- // this thread (typical: Bot A is mid-task and @mentions Bot B for review),
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 = sessionStore.findActiveSessionsByRoot(rootId);
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(rootId, larkAppId), newDs);
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, chatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId });
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 ${chatId}`
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(chatId, projects);
577
+ lastRepoScan.set(autoCreateChatId, projects);
498
578
  const currentCwd = getSessionWorkingDir(newDs);
499
- const cardJson = buildRepoSelectCard(projects, currentCwd, rootId);
500
- newDs.repoCardMessageId = await sessionReply(rootId, cardJson, 'interactive', larkAppId);
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, chatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId });
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.session.rootMessageId, readUrl, prevTitle, ds.lastScreenContent ?? '', 'idle', dsBotCfg.cliId, prevMode, ds.streamCardNonce, ds.currentImageKey, !!ds.adoptedFrom, false);
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
- const ds = activeSessions.get(sessionKey(signal.rootMessageId, targetAppId));
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} in thread ${signal.rootMessageId}`);
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 for thread ${signal.rootMessageId}`);
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 (above) but the IPC server
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. Binds to 127.0.0.1 only — the dashboard
746
- // sibling process is the sole consumer and runs on the same host.
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, chatId, messageId, chatType, appId) => handleNewTopic(data, chatId, messageId, chatType, appId),
787
- handleThreadReply: (data, rootId, appId) => handleThreadReply(data, rootId, appId),
788
- isSessionOwner: (rootId, appId) => {
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