botmux 2.51.0 → 2.52.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 (86) hide show
  1. package/README.en.md +36 -1
  2. package/README.md +33 -1
  3. package/dist/bot-registry.d.ts +30 -0
  4. package/dist/bot-registry.d.ts.map +1 -1
  5. package/dist/bot-registry.js +31 -0
  6. package/dist/bot-registry.js.map +1 -1
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +400 -3
  9. package/dist/cli.js.map +1 -1
  10. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  11. package/dist/core/dashboard-ipc-server.js +37 -0
  12. package/dist/core/dashboard-ipc-server.js.map +1 -1
  13. package/dist/core/dispatch.d.ts +163 -0
  14. package/dist/core/dispatch.d.ts.map +1 -0
  15. package/dist/core/dispatch.js +212 -0
  16. package/dist/core/dispatch.js.map +1 -0
  17. package/dist/daemon.d.ts +3 -0
  18. package/dist/daemon.d.ts.map +1 -1
  19. package/dist/daemon.js +184 -11
  20. package/dist/daemon.js.map +1 -1
  21. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  22. package/dist/dashboard/web/bot-defaults.js +114 -0
  23. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  24. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  25. package/dist/dashboard/web/i18n.js +22 -0
  26. package/dist/dashboard/web/i18n.js.map +1 -1
  27. package/dist/dashboard-web/app.js +449 -426
  28. package/dist/dashboard.js +20 -0
  29. package/dist/dashboard.js.map +1 -1
  30. package/dist/i18n/en.d.ts.map +1 -1
  31. package/dist/i18n/en.js +7 -1
  32. package/dist/i18n/en.js.map +1 -1
  33. package/dist/i18n/zh.d.ts.map +1 -1
  34. package/dist/i18n/zh.js +7 -1
  35. package/dist/i18n/zh.js.map +1 -1
  36. package/dist/im/lark/card-builder.d.ts +4 -2
  37. package/dist/im/lark/card-builder.d.ts.map +1 -1
  38. package/dist/im/lark/card-builder.js +15 -3
  39. package/dist/im/lark/card-builder.js.map +1 -1
  40. package/dist/im/lark/card-handler.d.ts.map +1 -1
  41. package/dist/im/lark/card-handler.js +6 -4
  42. package/dist/im/lark/card-handler.js.map +1 -1
  43. package/dist/im/lark/event-dispatcher.d.ts +12 -0
  44. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  45. package/dist/im/lark/event-dispatcher.js +61 -36
  46. package/dist/im/lark/event-dispatcher.js.map +1 -1
  47. package/dist/im/lark/grant-command.d.ts +13 -0
  48. package/dist/im/lark/grant-command.d.ts.map +1 -1
  49. package/dist/im/lark/grant-command.js +49 -3
  50. package/dist/im/lark/grant-command.js.map +1 -1
  51. package/dist/im/lark/grant-pending.d.ts +7 -4
  52. package/dist/im/lark/grant-pending.d.ts.map +1 -1
  53. package/dist/im/lark/grant-pending.js +12 -6
  54. package/dist/im/lark/grant-pending.js.map +1 -1
  55. package/dist/services/grant-prefs-store.d.ts +23 -0
  56. package/dist/services/grant-prefs-store.d.ts.map +1 -0
  57. package/dist/services/grant-prefs-store.js +94 -0
  58. package/dist/services/grant-prefs-store.js.map +1 -0
  59. package/dist/services/grant-store.d.ts +34 -2
  60. package/dist/services/grant-store.d.ts.map +1 -1
  61. package/dist/services/grant-store.js +160 -9
  62. package/dist/services/grant-store.js.map +1 -1
  63. package/dist/services/quota-dedup.d.ts +33 -0
  64. package/dist/services/quota-dedup.d.ts.map +1 -0
  65. package/dist/services/quota-dedup.js +67 -0
  66. package/dist/services/quota-dedup.js.map +1 -0
  67. package/dist/skills/definitions.d.ts.map +1 -1
  68. package/dist/skills/definitions.js +73 -0
  69. package/dist/skills/definitions.js.map +1 -1
  70. package/dist/types.d.ts +7 -0
  71. package/dist/types.d.ts.map +1 -1
  72. package/dist/utils/anchor-serializer.d.ts +11 -0
  73. package/dist/utils/anchor-serializer.d.ts.map +1 -0
  74. package/dist/utils/anchor-serializer.js +49 -0
  75. package/dist/utils/anchor-serializer.js.map +1 -0
  76. package/dist/utils/input-gate.d.ts +31 -0
  77. package/dist/utils/input-gate.d.ts.map +1 -0
  78. package/dist/utils/input-gate.js +27 -0
  79. package/dist/utils/input-gate.js.map +1 -0
  80. package/dist/utils/web-terminal-seed.d.ts +40 -0
  81. package/dist/utils/web-terminal-seed.d.ts.map +1 -0
  82. package/dist/utils/web-terminal-seed.js +46 -0
  83. package/dist/utils/web-terminal-seed.js.map +1 -0
  84. package/dist/worker.js +23 -6
  85. package/dist/worker.js.map +1 -1
  86. package/package.json +1 -1
package/dist/daemon.js CHANGED
@@ -27,7 +27,7 @@ import { buildTerminalUrl, setTerminalProxyPort } from './core/terminal-url.js';
27
27
  import { startTerminalProxy } from './core/terminal-proxy.js';
28
28
  import * as scheduler from './core/scheduler.js';
29
29
  import { scanMultipleProjects } from './services/project-scanner.js';
30
- import { buildRepoSelectCard, buildStreamingCard, getCliDisplayName } from './im/lark/card-builder.js';
30
+ import { buildQuotaExhaustedCard, buildRepoSelectCard, buildStreamingCard, getCliDisplayName } from './im/lark/card-builder.js';
31
31
  import { t as tr, botLocale, localeForBot } from './i18n/index.js';
32
32
  import { createCliAdapterSync } from './adapters/cli/registry.js';
33
33
  import { initWorkerPool, setActiveSessionsRegistry, forkWorker, killWorker, scheduleCardPatch, setCurrentCliVersion, CARD_POSTING_SENTINEL, parkStreamCard, closeSession as closeSessionHelper, ensureCliEnv, writableTerminalLinkFor, } from './core/worker-pool.js';
@@ -36,14 +36,16 @@ import { saveFrozenCards } from './services/frozen-card-store.js';
36
36
  import { DAEMON_COMMANDS, SESSIONLESS_DAEMON_COMMANDS, PASSTHROUGH_COMMANDS, handleCommand, parseSlashCommandInvocation, parseForceTopicInvocation } from './core/command-handler.js';
37
37
  import { findInheritablePeer } from './core/inherit-peer.js';
38
38
  import { isCallbackUrl, handleCallbackUrl } from './utils/user-token.js';
39
+ import { consumeQuota, removeChatGrant, removeGlobalGrant } from './services/grant-store.js';
40
+ import { abortCharge, commitCharge, beginCharge } from './services/quota-dedup.js';
39
41
  import { getSessionWorkingDir, getProjectScanDirs, expandHome, downloadResources, formatAttachmentsHint, buildNewTopicPrompt, buildFollowUpContent, buildBridgeInputContent, buildReforkPrompt, getAvailableBots, restoreActiveSessions, executeScheduledTask, persistStreamCardState, rememberLastCliInput, } from './core/session-manager.js';
40
42
  import { handleCardAction } from './im/lark/card-handler.js';
41
- import { executeWorkflowCommand, resolveBotSnapshot, } from './im/lark/workflow-slash-command.js';
43
+ import { executeWorkflowCommand, parseWorkflowCommand, resolveBotSnapshot, } from './im/lark/workflow-slash-command.js';
42
44
  import { workflowRunDetailUrl } from './im/lark/workflow-cards.js';
43
45
  import { buildWorkflowStartingCard, buildWorkflowProgressCard, buildAttemptDeeplinkEnricher, } from './im/lark/workflow-progress-card.js';
44
46
  import { EventLog as WorkflowEventLog } from './workflows/events/append.js';
45
47
  import { replay as replayWorkflow } from './workflows/events/replay.js';
46
- import { isBotMentioned, probeBotOpenId, startLarkEventDispatcher, writeBotInfoFile, canOperate, isKnownPeerBot, checkRequiredScopes } from './im/lark/event-dispatcher.js';
48
+ import { isBotMentioned, probeBotOpenId, startLarkEventDispatcher, writeBotInfoFile, canOperate, evaluateTalk, grantCommandRestriction, isKnownPeerBot, checkRequiredScopes } from './im/lark/event-dispatcher.js';
47
49
  import { learnFromMentions, resolveSender, flushIdentityCacheSync } from './im/lark/identity-cache.js';
48
50
  import { renderSenderTag } from './core/session-manager.js';
49
51
  import { markSessionActivity } from './core/session-activity.js';
@@ -222,6 +224,94 @@ async function sessionReply(anchor, content, msgType = 'text', larkAppId) {
222
224
  // Thread-scope (or unknown / legacy): reply in thread.
223
225
  return replyMessage(appId, anchor, content, msgType, true);
224
226
  }
227
+ async function revokeQuotaGrant(larkAppId, chatId, senderOpenId, ev) {
228
+ const result = ev.reason === 'chatGrant'
229
+ ? await removeChatGrant(larkAppId, chatId, senderOpenId)
230
+ : ev.reason === 'globalGrant'
231
+ ? await removeGlobalGrant(larkAppId, senderOpenId)
232
+ : { ok: true, removed: false };
233
+ if (!result.ok) {
234
+ logger.warn(`[quota:${larkAppId}] revoke after quota exhaustion failed: reason=${result.reason} user=${senderOpenId.substring(0, 12)} reasonType=${ev.reason}`);
235
+ }
236
+ }
237
+ async function notifyQuotaExhausted(larkAppId, anchor, senderOpenId, limit) {
238
+ if (typeof limit !== 'number')
239
+ return;
240
+ try {
241
+ await sessionReply(anchor, buildQuotaExhaustedCard(senderOpenId, limit, localeForBot(larkAppId)), 'interactive', larkAppId);
242
+ }
243
+ catch (err) {
244
+ logger.warn(`[quota:${larkAppId}] quota exhausted notify failed: ${err}`);
245
+ }
246
+ }
247
+ export async function enforceMessageQuotaForCliInput(larkAppId, chatId, senderOpenId, messageId, anchor) {
248
+ const ev = evaluateTalk(larkAppId, chatId, senderOpenId);
249
+ if (!ev.allowed) {
250
+ logger.debug(`[quota:${larkAppId}] dropping message ${messageId.substring(0, 12)} from non-allowed sender ${senderOpenId?.substring(0, 12) ?? '?'}`);
251
+ return false;
252
+ }
253
+ if (!ev.quotaKey)
254
+ return true;
255
+ if (!senderOpenId)
256
+ return false;
257
+ // 去重三态:'done' = 同条已成功扣费 → 放行(不重复扣);'pending' = 同条扣费 in-flight 未定论
258
+ // → fail-closed drop(绝不在定论前放行第二投);'fresh' = 首次见 → 继续扣费。
259
+ const charge = beginCharge(larkAppId, messageId);
260
+ if (charge === 'done')
261
+ return true;
262
+ if (charge === 'pending')
263
+ return false;
264
+ let quota;
265
+ try {
266
+ quota = await consumeQuota(larkAppId, ev.quotaKey);
267
+ }
268
+ catch (err) {
269
+ logger.warn(`[quota:${larkAppId}] consume failed; dropping message ${messageId.substring(0, 12)}: ${err}`);
270
+ abortCharge(larkAppId, messageId);
271
+ return false;
272
+ }
273
+ // 无额度记录(无限授权):放行;标 done 去重后续重投。
274
+ if (!quota.tracked) {
275
+ commitCharge(larkAppId, messageId);
276
+ return true;
277
+ }
278
+ // 已超额:fail-closed drop。**绝不 commit 成 done**(否则同条重投会被 'done' 直接放行,
279
+ // 在 revoke 自愈失败/竞态时绕过硬上限)——abortCharge 让重投重新走扣费判定(仍会被拒,
280
+ // 或在授权已收回时被上面的 evaluateTalk 闸拦掉)。
281
+ if (!quota.allow) {
282
+ abortCharge(larkAppId, messageId);
283
+ await revokeQuotaGrant(larkAppId, chatId, senderOpenId, ev);
284
+ await notifyQuotaExhausted(larkAppId, anchor, senderOpenId, quota.limit);
285
+ return false;
286
+ }
287
+ // 扣费成功才定论为 done。
288
+ commitCharge(larkAppId, messageId);
289
+ if (quota.exhausted) {
290
+ await revokeQuotaGrant(larkAppId, chatId, senderOpenId, ev);
291
+ await notifyQuotaExhausted(larkAppId, anchor, senderOpenId, quota.limit);
292
+ }
293
+ return true;
294
+ }
295
+ export function grantRestrictedCommandText(larkAppId, chatId, senderOpenId, cmd) {
296
+ return grantCommandRestriction(larkAppId, chatId, senderOpenId).blocked
297
+ ? tr('cmd.grant_restricted', { cmd }, localeForBot(larkAppId))
298
+ : undefined;
299
+ }
300
+ export function grantRestrictedSlashCommandText(larkAppId, chatId, senderOpenId, cmd) {
301
+ if (!/^\/[a-z][a-z0-9_-]*$/.test(cmd))
302
+ return undefined;
303
+ return grantRestrictedCommandText(larkAppId, chatId, senderOpenId, cmd);
304
+ }
305
+ async function replyGrantRestrictionIfNeeded(larkAppId, chatId, senderOpenId, anchor, cmd) {
306
+ const text = grantRestrictedCommandText(larkAppId, chatId, senderOpenId, cmd);
307
+ if (!text)
308
+ return false;
309
+ await sessionReply(anchor, text, 'text', larkAppId);
310
+ return true;
311
+ }
312
+ function forceTopicCommandLabel(content) {
313
+ return /^\/topic(?:\s|$)/i.test(content.trimStart()) ? '/topic' : '/t';
314
+ }
225
315
  // ─── PID file ────────────────────────────────────────────────────────────────
226
316
  function getPidFile() {
227
317
  const botIndex = process.env.BOTMUX_BOT_INDEX;
@@ -1384,8 +1474,12 @@ async function handleNewTopic(data, ctx) {
1384
1474
  // (already thread-scope) it's just a prefix strip — no routing change.
1385
1475
  // Empty prompt is allowed: the user can fill it in while the repo card is
1386
1476
  // pending (pendingFollowUps in handleThreadReply picks up subsequent text).
1477
+ const senderOpenId = data.sender?.sender_id?.open_id;
1387
1478
  const forceTopic = parseForceTopicInvocation(cmdContent);
1388
1479
  if (forceTopic) {
1480
+ if (await replyGrantRestrictionIfNeeded(larkAppId, chatId, senderOpenId, anchor, forceTopicCommandLabel(cmdContent))) {
1481
+ return;
1482
+ }
1389
1483
  if (scope === 'chat') {
1390
1484
  scope = 'thread';
1391
1485
  anchor = messageId;
@@ -1395,10 +1489,15 @@ async function handleNewTopic(data, ctx) {
1395
1489
  cmdContent = forceTopic.prompt;
1396
1490
  logger.info(`[/t] Force-topic invocation: prompt="${forceTopic.prompt.substring(0, 60)}" (scope=${scope}, anchor=${anchor.substring(0, 12)})`);
1397
1491
  }
1398
- const senderOpenId = data.sender?.sender_id?.open_id;
1492
+ // senderOpenId 已在上方(force-topic grant 限制前)声明;这里只补 master 新增的 senderUnionId。
1399
1493
  const senderUnionId = data.sender?.sender_id?.union_id;
1400
1494
  const botCfg = getBot(larkAppId).config;
1401
1495
  logger.info(`New session: "${content.substring(0, 60)}" (scope=${scope}, anchor=${anchor.substring(0, 12)}, resources: ${resources.length}, active: ${getActiveCount()}, messageId: ${messageId}, chatId: ${chatId})`);
1496
+ if (parseWorkflowCommand(cmdContent)) {
1497
+ if (await replyGrantRestrictionIfNeeded(larkAppId, chatId, senderOpenId, anchor, '/workflow')) {
1498
+ return;
1499
+ }
1500
+ }
1402
1501
  if (await handleWorkflowCommandIfAny(cmdContent, anchor, chatId, larkAppId, senderOpenId)) {
1403
1502
  return;
1404
1503
  }
@@ -1406,6 +1505,11 @@ async function handleNewTopic(data, ctx) {
1406
1505
  const invocation = parseSlashCommandInvocation(cmdContent);
1407
1506
  if (invocation) {
1408
1507
  const { cmd, content: commandContent } = invocation;
1508
+ const restrictedText = grantRestrictedSlashCommandText(larkAppId, chatId, senderOpenId, cmd);
1509
+ if (restrictedText) {
1510
+ await sessionReply(anchor, restrictedText, 'text', larkAppId);
1511
+ return;
1512
+ }
1409
1513
  if (PASSTHROUGH_COMMANDS.has(cmd)) {
1410
1514
  await sessionReply(anchor, tr('daemon.cmd_requires_session', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
1411
1515
  return;
@@ -1479,6 +1583,9 @@ async function handleNewTopic(data, ctx) {
1479
1583
  return;
1480
1584
  }
1481
1585
  }
1586
+ if (!await enforceMessageQuotaForCliInput(larkAppId, chatId, senderOpenId, messageId, anchor)) {
1587
+ return;
1588
+ }
1482
1589
  // Download attachments
1483
1590
  const { attachments, needLogin } = await downloadResources(larkAppId, messageId, resources);
1484
1591
  if (attachments.length > 0) {
@@ -1874,6 +1981,8 @@ async function handleThreadReply(data, ctx) {
1874
1981
  const content = parsed.content.trim();
1875
1982
  // Strip leading @<bot> mentions so "@bot /restart" is recognized as a command.
1876
1983
  const cmdContent = stripLeadingMentions(content, parsed.mentions);
1984
+ const threadSenderOpenId = parsed.senderId || data?.sender?.sender_id?.open_id;
1985
+ const threadChatId = ctxChatId ?? data?.message?.chat_id;
1877
1986
  // Intercept OAuth callback URLs (from /login flow)
1878
1987
  if (isCallbackUrl(content)) {
1879
1988
  const result = await handleCallbackUrl(content);
@@ -1885,13 +1994,31 @@ async function handleThreadReply(data, ctx) {
1885
1994
  return;
1886
1995
  }
1887
1996
  }
1888
- if (await handleWorkflowCommandIfAny(cmdContent, anchor, ctxChatId ?? data?.message?.chat_id, larkAppId, parsed.senderId || data?.sender?.sender_id?.open_id)) {
1997
+ const threadForceTopic = parseForceTopicInvocation(cmdContent);
1998
+ if (threadForceTopic) {
1999
+ if (await replyGrantRestrictionIfNeeded(larkAppId, threadChatId, threadSenderOpenId, anchor, forceTopicCommandLabel(cmdContent))) {
2000
+ return;
2001
+ }
2002
+ }
2003
+ if (parseWorkflowCommand(cmdContent)) {
2004
+ if (await replyGrantRestrictionIfNeeded(larkAppId, threadChatId, threadSenderOpenId, anchor, '/workflow')) {
2005
+ return;
2006
+ }
2007
+ }
2008
+ if (await handleWorkflowCommandIfAny(cmdContent, anchor, threadChatId, larkAppId, threadSenderOpenId)) {
1889
2009
  return;
1890
2010
  }
1891
2011
  // Intercept daemon commands
1892
2012
  const invocation = parseSlashCommandInvocation(cmdContent);
1893
2013
  if (invocation) {
1894
2014
  const { cmd, content: commandContent } = invocation;
2015
+ const existingDs = activeSessions.get(sessionKey(anchor, larkAppId));
2016
+ const effectiveThreadChatId = existingDs?.chatId ?? threadChatId;
2017
+ const restrictedText = grantRestrictedSlashCommandText(larkAppId, effectiveThreadChatId, threadSenderOpenId, cmd);
2018
+ if (restrictedText) {
2019
+ await sessionReply(anchor, restrictedText, 'text', larkAppId);
2020
+ return;
2021
+ }
1895
2022
  if (PASSTHROUGH_COMMANDS.has(cmd)) {
1896
2023
  // 语义边界(刻意保留,非疏漏):passthrough(/model /clear /compact 等)按
1897
2024
  // “发给 CLI 的对话输入”处理,因此不过下面 DAEMON_COMMANDS 的 oncall
@@ -1900,7 +2027,7 @@ async function handleThreadReply(data, ctx) {
1900
2027
  // 已存在的 session 发这些命令(清上下文/换模型,需已有活跃 worker,无法凭空
1901
2028
  // 拉起)。TODO(后续产品决策):是否把 CLI passthrough 也纳入 canOperate,
1902
2029
  // 收紧到与 daemon 命令同档;这会同时改变真人 oncall 成员的现有行为,应单独评估。
1903
- const ds = activeSessions.get(sessionKey(anchor, larkAppId));
2030
+ const ds = existingDs;
1904
2031
  if (ds?.worker && !ds.worker.killed) {
1905
2032
  // Mark a new turn so the CLI's response to /model, /clear, /compact, etc.
1906
2033
  // shows up as a fresh streaming card instead of silently PATCH-ing the
@@ -1918,16 +2045,54 @@ async function handleThreadReply(data, ctx) {
1918
2045
  if (DAEMON_COMMANDS.has(cmd)) {
1919
2046
  // canOperate gate for thread-reply daemon commands — required in every chat
1920
2047
  // (see spawn-path gate above). Denies chat-granted users management commands.
1921
- const existingDs = activeSessions.get(sessionKey(anchor, larkAppId));
1922
- const threadChatId = existingDs?.chatId ?? ctxChatId ?? data?.message?.chat_id;
1923
- const threadSenderOpenId = parsed.senderId || data?.sender?.sender_id?.open_id;
1924
- if (!canOperate(larkAppId, threadChatId, threadSenderOpenId)) {
2048
+ if (!canOperate(larkAppId, effectiveThreadChatId, threadSenderOpenId)) {
1925
2049
  sessionReply(anchor, tr('daemon.cmd_allowed_users_only', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
1926
2050
  return;
1927
2051
  }
2052
+ // First message of a fresh thread carrying a session-needing daemon command
2053
+ // — e.g. another bot dispatched `/repo <path>` into a brand-new thread.
2054
+ // Without a session, handleCommand gets ds=undefined and `/repo` (and other
2055
+ // session commands) fall through to the repo-select card. Create the session
2056
+ // first, mirroring handleNewTopic's first-message `/repo` pendingRepo setup.
2057
+ // Session-less commands (/group /g) don't need one.
2058
+ if (!existingDs && threadChatId && !SESSIONLESS_DAEMON_COMMANDS.has(cmd)) {
2059
+ const session = sessionStore.createSession(threadChatId, anchor, cmdContent.substring(0, 50), ctxChatType);
2060
+ const now = Date.now();
2061
+ session.larkAppId = larkAppId;
2062
+ session.ownerOpenId = threadSenderOpenId;
2063
+ session.creatorOpenId = threadSenderOpenId; // stable creator (= dispatch orchestrator for /repo prime) — see Session.creatorOpenId
2064
+ session.ownerUnionId = data?.sender?.sender_id?.union_id;
2065
+ session.lastCallerOpenId = threadSenderOpenId;
2066
+ session.lastMessageAt = new Date(now).toISOString();
2067
+ session.scope = scope;
2068
+ let cmdPending;
2069
+ if (cmd === '/repo') {
2070
+ const { pinnedWorkingDir } = await resolvePinnedWorkingDir({ scope, anchor, chatId: threadChatId, chatType: ctxChatType, larkAppId });
2071
+ if (pinnedWorkingDir)
2072
+ session.workingDir = pinnedWorkingDir;
2073
+ cmdPending = { pendingRepo: true, pendingPrompt: '', workingDir: pinnedWorkingDir };
2074
+ }
2075
+ sessionStore.updateSession(session);
2076
+ activeSessions.set(sessionKey(anchor, larkAppId), {
2077
+ session,
2078
+ worker: null,
2079
+ workerPort: null,
2080
+ workerToken: null,
2081
+ larkAppId,
2082
+ chatId: threadChatId,
2083
+ chatType: ctxChatType,
2084
+ scope,
2085
+ spawnedAt: Date.parse(session.createdAt) || now,
2086
+ cliVersion: cliVersionCache.get(getBot(larkAppId).config.cliId)?.version ?? 'unknown',
2087
+ lastMessageAt: now,
2088
+ hasHistory: false,
2089
+ ownerOpenId: threadSenderOpenId,
2090
+ ...cmdPending,
2091
+ });
2092
+ }
1928
2093
  // Pass mention-stripped content so /command argument parsing works.
1929
2094
  // chatId lets session-less handlers (e.g. /group) reach the chat roster.
1930
- handleCommand(cmd, anchor, { ...parsed, content: commandContent, chatId: threadChatId }, commandDeps, larkAppId);
2095
+ await handleCommand(cmd, anchor, { ...parsed, content: commandContent, chatId: threadChatId }, commandDeps, larkAppId);
1931
2096
  return;
1932
2097
  }
1933
2098
  }
@@ -1951,6 +2116,10 @@ async function handleThreadReply(data, ctx) {
1951
2116
  return;
1952
2117
  }
1953
2118
  }
2119
+ const quotaSenderOpenId = threadSenderOpenId;
2120
+ if (!await enforceMessageQuotaForCliInput(larkAppId, ctxChatId ?? data?.message?.chat_id, quotaSenderOpenId, parsed.messageId, anchor)) {
2121
+ return;
2122
+ }
1954
2123
  // Download attachments
1955
2124
  const effectiveAppId = ds?.larkAppId ?? larkAppId;
1956
2125
  const { attachments, needLogin } = await downloadResources(effectiveAppId, parsed.messageId, resources);
@@ -2039,6 +2208,10 @@ async function handleThreadReply(data, ctx) {
2039
2208
  const ownerUnionId = isForeignBot ? undefined : senderUId;
2040
2209
  session.larkAppId = larkAppId;
2041
2210
  session.ownerOpenId = ownerOpenId;
2211
+ // creatorOpenId is the raw creating sender — set even for foreign-bot
2212
+ // sessions (unlike ownerOpenId, nulled above) so `botmux report` can find the
2213
+ // dispatch orchestrator on a no-`/repo` kickoff auto-create. See Session.creatorOpenId.
2214
+ session.creatorOpenId = senderOId;
2042
2215
  session.ownerUnionId = ownerUnionId;
2043
2216
  session.lastCallerOpenId = senderOId;
2044
2217
  session.quoteTargetId = parsed.messageId;