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/cli.js CHANGED
@@ -26,6 +26,7 @@ import { createInterface } from 'node:readline';
26
26
  import { createRequire } from 'node:module';
27
27
  import { createHmac, randomBytes } from 'node:crypto';
28
28
  import { validateWorkingDir } from './core/working-dir.js';
29
+ import { parseDispatchBotSpec, buildDispatchMessages, buildRepoPrimeText, buildReportContent, eligibleAutoMentionAliases, offTopicSubBotTopic } from './core/dispatch.js';
29
30
  import { enableAutostart, disableAutostart, autostartStatus, refreshAutostart } from './autostart.js';
30
31
  import { tmuxEnv } from './setup/ensure-tmux.js';
31
32
  import { writeBotsJsonAtomic as writeBotsAtomic } from './setup/bots-store.js';
@@ -2181,6 +2182,7 @@ botmux v${getVersion()} — IM ↔ AI 编程 CLI 桥接
2181
2182
  --card | --text 强制卡片 / 纯文本(默认按 md 语法自动判断)
2182
2183
  --top-level 发顶层消息(不回复进当前话题)
2183
2184
  --chat-id <oc_xxx> 指定目标群(默认当前话题所在群)
2185
+ --anyway 跳过「@ 到活跃子 bot」护栏强发(见下)
2184
2186
  @ 硬门:每条回复须三选一 --mention/--mention-back/--no-mention,否则报错不发。
2185
2187
  按内容价值选:有实质结论要对方看/确认/决策→--mention-back(或--mention点名);
2186
2188
  纯记录/低优先级进度/简短确认→--no-mention;没信息量的"收到"不如不发。
@@ -2693,7 +2695,7 @@ async function cmdSend(rest) {
2693
2695
  content = readFileSync(contentFile, 'utf-8');
2694
2696
  }
2695
2697
  else {
2696
- const pos = positionals(rest, ['--card', '--text', '--top-level', '--no-quote', '--mention-back', '--no-mention']);
2698
+ const pos = positionals(rest, ['--card', '--text', '--top-level', '--no-quote', '--mention-back', '--no-mention', '--anyway']);
2697
2699
  if (pos.length > 0) {
2698
2700
  content = pos.join(' ');
2699
2701
  }
@@ -2763,6 +2765,47 @@ async function cmdSend(rest) {
2763
2765
  // reply_in_thread, otherwise Lark would force every reply into a fresh
2764
2766
  // topic — defeating the whole point of chat-scope routing.
2765
2767
  const isChatScope = s.scope === 'chat';
2768
+ // ── Footgun guard: orchestrator → sub-bot ──
2769
+ // A dispatched sub-bot's session lives in its sub-topic; @-ing it from the main
2770
+ // chat spawns a fresh, context-less one. The check is computed ONCE and applied
2771
+ // at BOTH mention sources: explicit --mention/--mention-back (blocked here) AND
2772
+ // the prose @Name auto-injection further down (dropped there) — so a prose
2773
+ // `@OtherSubBot` can't slip past after this explicit guard already ran.
2774
+ let dispatchReg = {};
2775
+ try {
2776
+ const regPath = join(resolveDataDir(), 'orchestrate-dispatch.json');
2777
+ if (existsSync(regPath))
2778
+ dispatchReg = JSON.parse(readFileSync(regPath, 'utf-8'));
2779
+ }
2780
+ catch { /* no/!corrupt registry → no guard */ }
2781
+ const dispatchActiveSeeds = new Set();
2782
+ if (Object.keys(dispatchReg).length > 0) {
2783
+ for (const sess of loadSessions().values()) {
2784
+ if (sess.status === 'active' && sess.scope !== 'chat' && sess.rootMessageId) {
2785
+ dispatchActiveSeeds.add(sess.rootMessageId);
2786
+ }
2787
+ }
2788
+ }
2789
+ // Sub-topic seed if `openId` is a dispatched sub-bot in an active topic that is
2790
+ // NOT reachable in the current conversation; else null. The bot I'm replying to
2791
+ // here (quoteTargetSenderOpenId) is reachable, so it's never treated as off-topic.
2792
+ const offTopicSubBotSeed = (openId) => offTopicSubBotTopic({ mentionOpenId: openId, quoteTargetSenderOpenId: s.quoteTargetSenderOpenId, chatId: targetChatId, registry: dispatchReg, activeSeeds: dispatchActiveSeeds });
2793
+ // Explicit --mention / --mention-back of an off-topic sub-bot → block + point to
2794
+ // the right command (--anyway overrides). Prose @Name injection is filtered
2795
+ // (dropped, not blocked) at its own site below.
2796
+ if (!rest.includes('--anyway')) {
2797
+ for (const m of mentions) {
2798
+ const seed = offTopicSubBotSeed(m.open_id);
2799
+ if (seed) {
2800
+ console.error(`⚠️ ${m.open_id}${m.name ? `(${m.name})` : ''} 是 botmux dispatch 派进子话题 ${seed} 的子 bot——\n` +
2801
+ `它的会话在那条子话题里:在主群 @ 它收不到,反而会另起一个无上下文的新会话。\n` +
2802
+ `要跟它说,把消息发进它的子话题:\n` +
2803
+ ` botmux dispatch --into ${seed} --bot ${m.open_id} --brief "..."\n` +
2804
+ `(确属新会话/有意为之,加 --anyway 强发。)`);
2805
+ process.exit(2);
2806
+ }
2807
+ }
2808
+ }
2766
2809
  // Oncall addressing only meaningful for replies inside the session's own
2767
2810
  // chat — skip when publishing top-level or to a different chat. Treat
2768
2811
  // oncall as chat-level: in multi-daemon setups this session's bot may not
@@ -2858,11 +2901,31 @@ async function cmdSend(rest) {
2858
2901
  .flatMap(entry => [entry.botName, entry.cliId])
2859
2902
  .filter((name) => !!name)
2860
2903
  .map(name => name.toLowerCase()));
2904
+ // Bots actively in THIS conversation (thread root for thread-scope, chat for
2905
+ // chat-scope). Used to gate the type-generic `cliId` alias so prose "@codex"
2906
+ // resolves to the codex bot collaborating HERE, not every same-type bot
2907
+ // (the fan-out that pulled all Codex-named bots into a topic). See
2908
+ // eligibleAutoMentionAliases.
2909
+ const convoBotAppIds = new Set();
2910
+ for (const sess of loadSessions().values()) {
2911
+ if (sess.status !== 'active' || !sess.larkAppId)
2912
+ continue;
2913
+ const here = isChatScope
2914
+ ? sess.chatId === s.chatId
2915
+ : (!!s.rootMessageId && sess.rootMessageId === s.rootMessageId);
2916
+ if (here)
2917
+ convoBotAppIds.add(sess.larkAppId);
2918
+ }
2861
2919
  for (const entry of sortedEntries) {
2862
2920
  if (!entry.botName || entry.larkAppId === appId)
2863
2921
  continue;
2864
- const names = [entry.botName, entry.cliId]
2865
- .filter((name) => !!name && !selfAliases.has(name.toLowerCase()));
2922
+ const names = eligibleAutoMentionAliases({
2923
+ botName: entry.botName,
2924
+ cliId: entry.cliId ?? undefined,
2925
+ larkAppId: entry.larkAppId ?? undefined,
2926
+ selfAliases,
2927
+ convoBotAppIds,
2928
+ });
2866
2929
  for (const name of names) {
2867
2930
  const escName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2868
2931
  // Boundary: lookbehind blocks only ASCII word chars (so `user@Claude`
@@ -2884,6 +2947,16 @@ async function cmdSend(rest) {
2884
2947
  }
2885
2948
  if (alreadyMentioned.has(senderScopedId))
2886
2949
  break;
2950
+ // Footgun guard at the auto-injection source: don't turn a prose
2951
+ // `@OtherSubBot` into a real @ for a dispatched sub-bot that's off-topic
2952
+ // here — that would spawn a context-less session in the main chat (the
2953
+ // explicit guard above only saw --mention/--mention-back). Drop the
2954
+ // injection (don't block the whole send); --anyway forces it through.
2955
+ const injOffSeed = rest.includes('--anyway') ? null : offTopicSubBotSeed(senderScopedId);
2956
+ if (injOffSeed) {
2957
+ console.error(`[botmux send] 跳过正文 @${entry.botName} 自动注入:它是 dispatch 派进子话题 ${injOffSeed} 的子 bot、不在本会话——避免在主群另起无上下文会话(要强发加 --anyway)。`);
2958
+ break;
2959
+ }
2887
2960
  mentions.push({ open_id: senderScopedId, name: entry.botName });
2888
2961
  alreadyMentioned.add(senderScopedId);
2889
2962
  break;
@@ -3096,6 +3169,324 @@ async function cmdSend(rest) {
3096
3169
  process.exit(1);
3097
3170
  }
3098
3171
  }
3172
+ // ─── Dispatch subcommand (Phase 0: open a sub-project thread + assign bots) ───
3173
+ async function cmdDispatch(rest) {
3174
+ if (rest.includes('--help') || rest.includes('-h')) {
3175
+ console.log(`botmux dispatch — 开子项目话题、把 bot 拉进去协作(含 repo 预设 / 待命 / 追加)
3176
+
3177
+ 用法:
3178
+ 新开话题派活:
3179
+ botmux dispatch --title "子项目标题" --bot <open_id[:名字[:角色]]> [--bot ...] \\
3180
+ [--brief "简报" | --brief-file <path>] [--repo <工作目录>] [--standby]
3181
+ 往已有话题追加(激活待命 bot / 追加协调):
3182
+ botmux dispatch --into <话题根消息id> --bot <spec> [--bot ...] (--brief ... | --brief-file ...)
3183
+
3184
+ 说明:
3185
+ 新开话题: 发一条顶层「子项目」种子消息,在它线程里把 bot @ 进来各起独立会话。
3186
+ --repo: 先用 /repo 给每个子 bot 定好工作目录——spawn 时不弹「选仓库」卡、不用手点。
3187
+ --standby: 配合 --repo——只把 bot 拉起来定好目录待命(不派简报),之后用 --into 派具体任务。
3188
+ --into: 不建种子,直接回到已有话题线程 @ bot 追加一条。
3189
+ 返回 JSON(含 seedMessageId / threadRootId),供编排者登记 子项目↔话题。
3190
+
3191
+ 选项:
3192
+ --title <t> 子项目标题(新开话题时必填)
3193
+ --bot <spec> 指派的 bot,可重复;spec = open_id[:名字[:角色]]
3194
+ --brief <text> 子项目简报 / 追加内容
3195
+ --brief-file <path> 从文件读取简报
3196
+ --repo <path> 预设子 bot 工作目录(绝对路径,需在子 bot 所在机器上存在)
3197
+ --standby 仅 --repo 待命,不派简报
3198
+ --into <root_id> 回到已有话题线程追加(与 --title/种子互斥)
3199
+ --chat-id <id> 覆盖目标群(默认当前会话所在群)
3200
+ --session-id <id> 指定来源会话(默认自动推断)`);
3201
+ return;
3202
+ }
3203
+ process.env.SESSION_DATA_DIR ??= resolveDataDir();
3204
+ const sessionIdArg = argValue(rest, '--session-id');
3205
+ const title = argValue(rest, '--title') ?? '';
3206
+ const briefFile = argValue(rest, '--brief-file');
3207
+ const overrideChatId = argValue(rest, '--chat-id');
3208
+ const repo = argValue(rest, '--repo');
3209
+ const intoRoot = argValue(rest, '--into');
3210
+ const standby = rest.includes('--standby');
3211
+ const botSpecs = argValues(rest, '--bot');
3212
+ let brief = argValue(rest, '--brief') ?? '';
3213
+ if (briefFile) {
3214
+ if (!existsSync(briefFile)) {
3215
+ console.error(`文件不存在: ${briefFile}`);
3216
+ process.exit(1);
3217
+ }
3218
+ brief = readFileSync(briefFile, 'utf-8');
3219
+ }
3220
+ // Append the report-back protocol so the dispatched sub-bot reports via
3221
+ // `botmux report` (which routes to the orchestrator's OWN session) rather than
3222
+ // @-ing the orchestrator in its sub-topic — which has no orchestrator session
3223
+ // and would spawn a fresh, context-less one. Skipped for --standby (no brief).
3224
+ if (brief.trim()) {
3225
+ brief = brief.trimEnd() +
3226
+ '\n\n— 完成回报 —\n' +
3227
+ '干完后在本话题运行 `botmux report "子项目完成 + 产出位置/摘要"` 把结果回报给主编排会话;' +
3228
+ '不要在本话题 @ 主bot(那会另起一个没有上下文的新会话)。';
3229
+ }
3230
+ // ── Flag validation ──
3231
+ if (botSpecs.length === 0) {
3232
+ console.error('至少要用 --bot 指派一个 bot。用法见 botmux dispatch --help');
3233
+ process.exit(1);
3234
+ }
3235
+ if (standby && !repo) {
3236
+ console.error('--standby 需要配合 --repo(先定好工作目录把 bot 拉起待命)。');
3237
+ process.exit(1);
3238
+ }
3239
+ if (standby && intoRoot) {
3240
+ console.error('--standby 与 --into 不能同用。');
3241
+ process.exit(1);
3242
+ }
3243
+ if (!standby && !brief.trim()) {
3244
+ console.error('缺少简报。用 --brief 或 --brief-file 指定(仅 --standby 模式可省略)。');
3245
+ process.exit(1);
3246
+ }
3247
+ if (!intoRoot && !title.trim()) {
3248
+ console.error('新开话题需要 --title。往已有话题追加请用 --into <root_id>。');
3249
+ process.exit(1);
3250
+ }
3251
+ let bots;
3252
+ try {
3253
+ bots = botSpecs.map(parseDispatchBotSpec);
3254
+ }
3255
+ catch (err) {
3256
+ console.error(`--bot 解析失败: ${err.message}`);
3257
+ process.exit(1);
3258
+ }
3259
+ let built;
3260
+ try {
3261
+ built = buildDispatchMessages({ title: title.trim() || '子项目', brief, bots });
3262
+ }
3263
+ catch (err) {
3264
+ console.error(`dispatch 构建失败: ${err.message}`);
3265
+ process.exit(1);
3266
+ }
3267
+ const sid = sessionIdArg ?? findAncestorSessionId();
3268
+ if (!sid) {
3269
+ console.error('无法推断 session-id。请在 Lark 话题内的 CLI 会话中运行,或传 --session-id <id>。');
3270
+ process.exit(1);
3271
+ }
3272
+ const sessions = loadSessions();
3273
+ const s = sessions.get(sid);
3274
+ if (!s) {
3275
+ console.error(`未找到 session ${sid}`);
3276
+ process.exit(1);
3277
+ }
3278
+ if (!s.larkAppId) {
3279
+ console.error(`session ${sid} 缺少 larkAppId`);
3280
+ process.exit(1);
3281
+ }
3282
+ const targetChatId = overrideChatId ?? s.chatId;
3283
+ if (!targetChatId) {
3284
+ console.error(`session ${sid} 缺少 chatId,且未提供 --chat-id`);
3285
+ process.exit(1);
3286
+ }
3287
+ const { registerBot, loadBotConfigs } = await import('./bot-registry.js');
3288
+ try {
3289
+ for (const cfg of loadBotConfigs())
3290
+ registerBot(cfg);
3291
+ }
3292
+ catch { /* */ }
3293
+ const { sendMessage, replyMessage } = await import('./im/lark/client.js');
3294
+ const appId = s.larkAppId;
3295
+ const briefJson = JSON.stringify({ zh_cn: { title: '', content: built.threadContent } });
3296
+ try {
3297
+ // --into: append into an existing thread (activate standby bots / coordinate).
3298
+ if (intoRoot) {
3299
+ const kickoffId = await replyMessage(appId, intoRoot, briefJson, 'post', true);
3300
+ console.log(JSON.stringify({
3301
+ success: true, mode: 'into', threadRootId: intoRoot,
3302
+ kickoffMessageId: kickoffId, chatId: targetChatId, bots: built.mentionedOpenIds,
3303
+ }));
3304
+ return;
3305
+ }
3306
+ // New-thread mode.
3307
+ // 1. Seed (thread root) — top-level header; gives the thread something to hang off.
3308
+ const seedId = await sendMessage(appId, targetChatId, built.seedText, 'text');
3309
+ // Record the orchestrator's coords for this sub-topic, keyed by the seed
3310
+ // (which becomes every dispatched sub-bot's session.rootMessageId). The
3311
+ // sub-bot's `botmux report` looks this up to route its report back into the
3312
+ // orchestrator's OWN session. Lives in the shared data dir so every bot's
3313
+ // daemon (one-per-bot) can read it. Best-effort — report-back degrades to a
3314
+ // clear error if absent.
3315
+ try {
3316
+ const regPath = join(resolveDataDir(), 'orchestrate-dispatch.json');
3317
+ let reg = {};
3318
+ try {
3319
+ if (existsSync(regPath))
3320
+ reg = JSON.parse(readFileSync(regPath, 'utf-8'));
3321
+ }
3322
+ catch { /* corrupt → reset */ }
3323
+ reg[seedId] = {
3324
+ orchRoot: s.rootMessageId ?? '',
3325
+ orchChatId: s.chatId,
3326
+ orchScope: s.scope ?? 'thread',
3327
+ orchAppId: s.larkAppId,
3328
+ title: title.trim(),
3329
+ bots: built.mentionedOpenIds,
3330
+ };
3331
+ writeFileSync(regPath, JSON.stringify(reg, null, 2));
3332
+ }
3333
+ catch { /* registry is best-effort */ }
3334
+ // 2. Optional repo prime — a plain TEXT message "@bot /repo <path>" (like a
3335
+ // human types) so each sub-bot spawns idle in that dir (no repo-select
3336
+ // card). Text goes through resolveMentions cleanly; a structured post
3337
+ // drops the /repo arg in the live event. `/repo` is an existing command,
3338
+ // so this needs no change on the receiving bot's daemon.
3339
+ let primeId;
3340
+ if (repo) {
3341
+ const prime = buildRepoPrimeText({ path: repo, bots });
3342
+ primeId = await replyMessage(appId, seedId, prime.text, 'text', true);
3343
+ }
3344
+ // 3. Brief kickoff — reply_in_thread @-ing the bots so each spawns its own
3345
+ // thread-scoped session. Skipped in --standby (bots wait for a later --into).
3346
+ let kickoffId;
3347
+ if (!standby) {
3348
+ kickoffId = await replyMessage(appId, seedId, briefJson, 'post', true);
3349
+ }
3350
+ console.log(JSON.stringify({
3351
+ success: true,
3352
+ mode: standby ? 'standby' : 'dispatch',
3353
+ seedMessageId: seedId,
3354
+ threadRootId: seedId,
3355
+ primeMessageId: primeId,
3356
+ kickoffMessageId: kickoffId,
3357
+ repo: repo ?? null,
3358
+ chatId: targetChatId,
3359
+ bots: built.mentionedOpenIds,
3360
+ }));
3361
+ }
3362
+ catch (err) {
3363
+ console.error(`dispatch 失败: ${err.message}`);
3364
+ process.exit(1);
3365
+ }
3366
+ }
3367
+ /**
3368
+ * `botmux report` — a dispatched sub-bot reports progress/completion back to the
3369
+ * orchestrator that dispatched it.
3370
+ *
3371
+ * In 多话题协作模式 the sub-bot lives in its own sub-topic, where the orchestrator
3372
+ * has no session; @-ing the orchestrator there would spawn a fresh, context-less
3373
+ * one (申晗's #1 bug). Instead this routes the report INTO the orchestrator's own
3374
+ * thread (recorded by `botmux dispatch` in orchestrate-dispatch.json) and @-s the
3375
+ * orchestrator there, so its existing, context-rich session is the one that wakes.
3376
+ *
3377
+ * Coords: orchestrator open_id = the sub-bot session's quoteTargetSenderOpenId
3378
+ * (the dispatcher of the turn that opened this sub-topic); orchestrator thread =
3379
+ * the registry entry keyed by this sub-bot's session.rootMessageId (== the seed).
3380
+ */
3381
+ async function cmdReport(rest) {
3382
+ if (rest.includes('--help') || rest.includes('-h')) {
3383
+ console.log(`botmux report — 把子项目进展/完成回报给派活的主编排会话
3384
+
3385
+ 用法:
3386
+ botmux report "子项目X 完成,产出在 …"
3387
+ botmux report --content-file <path>
3388
+
3389
+ 说明:
3390
+ 「多话题协作模式」里你(子 bot)干完后不要在本话题 @ 主bot——本话题没有主bot的会话,
3391
+ @ 会另起一个无上下文的新会话。本命令把回报发回主编排会话所在的话题、并 @ 主编排 bot,
3392
+ 使其带完整上下文继续聚合。仅在被 botmux dispatch 派活的子项目会话里可用。
3393
+
3394
+ 选项:
3395
+ --content-file <path> 从文件读取回报内容
3396
+ --session-id <id> 指定来源会话(默认自动推断)`);
3397
+ return;
3398
+ }
3399
+ process.env.SESSION_DATA_DIR ??= resolveDataDir();
3400
+ const sessionIdArg = argValue(rest, '--session-id');
3401
+ let content = '';
3402
+ const contentFile = argValue(rest, '--content-file');
3403
+ if (contentFile) {
3404
+ if (!existsSync(contentFile)) {
3405
+ console.error(`文件不存在: ${contentFile}`);
3406
+ process.exit(1);
3407
+ }
3408
+ content = readFileSync(contentFile, 'utf-8');
3409
+ }
3410
+ else {
3411
+ const pos = positionals(rest);
3412
+ content = pos.length ? pos.join(' ') : await readStdin();
3413
+ }
3414
+ if (!content.trim()) {
3415
+ console.error('没有回报内容。用法: botmux report "子项目X 完成 + 产出位置"');
3416
+ process.exit(1);
3417
+ }
3418
+ const sid = sessionIdArg ?? findAncestorSessionId();
3419
+ if (!sid) {
3420
+ console.error('无法推断 session-id。请在被 dispatch 派活的会话里运行,或传 --session-id <id>。');
3421
+ process.exit(1);
3422
+ }
3423
+ const sessions = loadSessions();
3424
+ const s = sessions.get(sid);
3425
+ if (!s) {
3426
+ console.error(`未找到 session ${sid}`);
3427
+ process.exit(1);
3428
+ }
3429
+ if (!s.larkAppId) {
3430
+ console.error(`session ${sid} 缺少 larkAppId`);
3431
+ process.exit(1);
3432
+ }
3433
+ // Resolve the orchestrator coords: its thread/chat from the dispatch registry
3434
+ // (keyed by this sub-bot's thread root), its open_id from this session.
3435
+ const regPath = join(resolveDataDir(), 'orchestrate-dispatch.json');
3436
+ let reg = {};
3437
+ try {
3438
+ if (existsSync(regPath))
3439
+ reg = JSON.parse(readFileSync(regPath, 'utf-8'));
3440
+ }
3441
+ catch { /* */ }
3442
+ const entry = s.rootMessageId ? reg[s.rootMessageId] : undefined;
3443
+ // The orchestrator's open_id (sub-bot-app-scoped) is whoever created this
3444
+ // session = the dispatcher. Prefer `creatorOpenId` (set on EVERY creation path,
3445
+ // incl. a no-`/repo` foreign-bot kickoff auto-create where ownerOpenId is
3446
+ // nulled), then `ownerOpenId` (older sessions / `/repo` prime). NEVER
3447
+ // quoteTargetSenderOpenId alone: it tracks the *last* sender who @-ed this
3448
+ // sub-bot, so in a coder+reviewer topic it drifts to the reviewer (observed
3449
+ // live: the coder's report @-ed the reviewer, not the orchestrator). Keep it as
3450
+ // a last-ditch fallback only for pre-existing sessions that predate both fields.
3451
+ const orchOpenId = s.creatorOpenId ?? s.ownerOpenId ?? s.quoteTargetSenderOpenId;
3452
+ if (!entry || !orchOpenId) {
3453
+ console.error('当前会话不是被 botmux dispatch 派活的子项目会话(缺少主编排坐标)。\n' +
3454
+ '若确需回报,请改用 `botmux send` 或显式 @ 对应的人/ bot。');
3455
+ process.exit(1);
3456
+ }
3457
+ const { registerBot, loadBotConfigs } = await import('./bot-registry.js');
3458
+ try {
3459
+ for (const cfg of loadBotConfigs())
3460
+ registerBot(cfg);
3461
+ }
3462
+ catch { /* */ }
3463
+ const { sendMessage, replyMessage } = await import('./im/lark/client.js');
3464
+ const appId = s.larkAppId;
3465
+ const paras = buildReportContent({ orchOpenId, content });
3466
+ const postJson = JSON.stringify({ zh_cn: { title: '', content: paras } });
3467
+ try {
3468
+ let msgId;
3469
+ if (entry.orchScope === 'chat' || !entry.orchRoot) {
3470
+ // Orchestrator runs at chat scope (普通群整群一个会话) → post to the chat.
3471
+ msgId = await sendMessage(appId, entry.orchChatId, postJson, 'post');
3472
+ }
3473
+ else {
3474
+ // Orchestrator lives in its own thread → reply into it so its existing
3475
+ // session (anchored on orchRoot) is the one that receives the report.
3476
+ msgId = await replyMessage(appId, entry.orchRoot, postJson, 'post', true);
3477
+ }
3478
+ console.log(JSON.stringify({
3479
+ success: true,
3480
+ reportedTo: entry.orchRoot || entry.orchChatId,
3481
+ orchestrator: orchOpenId,
3482
+ messageId: msgId,
3483
+ }));
3484
+ }
3485
+ catch (err) {
3486
+ console.error(`report 失败: ${err.message}`);
3487
+ process.exit(1);
3488
+ }
3489
+ }
3099
3490
  // ─── Create-group subcommand ─────────────────────────────────────────────────
3100
3491
  async function cmdCreateGroup(rest) {
3101
3492
  if (rest.includes('--help') || rest.includes('-h')) {
@@ -3822,6 +4213,12 @@ switch (command) {
3822
4213
  case 'send':
3823
4214
  await cmdSend(process.argv.slice(3));
3824
4215
  break;
4216
+ case 'dispatch':
4217
+ await cmdDispatch(process.argv.slice(3));
4218
+ break;
4219
+ case 'report':
4220
+ await cmdReport(process.argv.slice(3));
4221
+ break;
3825
4222
  case 'create-group':
3826
4223
  await cmdCreateGroup(process.argv.slice(3));
3827
4224
  break;