botmux 2.51.0 → 2.51.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -54,6 +54,7 @@ Compared to OpenClaw-style approaches built on Agent SDKs:
54
54
  | Multi-CLI Support | 6 CLIs, switch with one config (Claude Code / Codex / Cursor / Gemini / OpenCode / Antigravity) | Tied to a single SDK, cannot switch CLIs |
55
55
  | Web Terminal | Interactive full terminal, mobile shortcut toolbar, phone/desktop/Lark tri-screen sync | Usually web chat UI or read-only output |
56
56
  | Multi-Bot Collaboration | Multiple bots in same group via @mention routing, isolated processes, different CLIs sparring | Usually single bot |
57
+ | Multi-Topic Collaboration | A lead bot auto-splits the task, opens multiple topics, and dispatches several bots to work in parallel (coder + reviewer), with a Lark task list as the shared progress board | Usually manual one-by-one assignment, no unified progress board |
57
58
  | Terminal Access | tmux attach directly into the CLI process, same as local dev experience | No direct terminal access |
58
59
  | Installation | `npm install -g botmux`, 5-min Lark setup | Easy to install, but more configuration needed |
59
60
 
@@ -197,6 +198,34 @@ On mobile/tablet, a floating shortcut toolbar provides Esc, Ctrl+C, Tab, arrow k
197
198
 
198
199
  Run multiple Lark bots on a single machine, each mapped to a different CLI. In the same group chat, messages are routed via @mention — each bot gets its own isolated CLI process. With a single bot in the group, it responds automatically without @. In a regular (non-topic) group, `@<bot1> @<bot2> /t xxx` spawns one independent thread per mentioned bot anchored at the same message. Send `@<bot1> @<bot2> /introduce` once so they register each other's open_id; afterwards each bot can explicitly @-mention the others from within its own session (see [§ Slash Commands](#slash-commands)).
199
200
 
201
+ ### Multi-Topic Collaboration
202
+
203
+ The next level up from "Multi-Bot Collaboration": a lead bot (the **orchestrator**) splits one large task into multiple **sub-projects**, **automatically opens several topics** in the group, dispatches a team of bots into each topic to drive it in parallel (commonly "one writes the code + one reviews"), uses a single **Lark task list** as the shared progress board everyone reads from, and finally collects the results and aggregates them. A single regular group becomes a parallel workbench, and you can see overall progress at a glance from the Lark task panel.
204
+
205
+ **How it runs** — the `botmux-orchestrate` skill walks the orchestrator through the full flow:
206
+
207
+ > Split into sub-projects → propose a "sub-project ↔ bot" assignment → send it to you for **a single approval** (confirmable via card) → create the Lark task list → open each topic and dispatch → collect the reports → aggregate
208
+
209
+ Under the hood, dispatching is done by `botmux dispatch`: it seeds a topic in the group and @-mentions the chosen bots, spawning an independent session for each.
210
+
211
+ ```bash
212
+ botmux dispatch --title "Implement login module" \
213
+ --bot "ou_xxx:Alice:coder" --bot "ou_yyy:Bob:reviewer" \
214
+ --repo /path/to/repo --brief-file /tmp/brief.md
215
+ ```
216
+
217
+ - `--repo <dir>` — presets each sub-bot's working directory (absolute path, must exist on the sub-bot's machine), so the session spawns straight into it and **skips the "select repo" card**.
218
+ - `--standby` — **must be paired with `--repo`** (and cannot be combined with `--into`): sends `/repo` once to bring the bot up in the given directory on standby without a brief; activate it later with `--into ... --brief(-file)`.
219
+ - `--into <topic root>` — return to an existing topic and append one message (activate standby bots / add coordination); still requires `--bot`, and outside standby mode must carry `--brief` or `--brief-file`.
220
+
221
+ When a sub-bot finishes, it reports progress/completion back with `botmux report` from inside its own sub-topic. This routes the report into the orchestrator's **own** session (which still holds full context) instead of @-mentioning the orchestrator inside the sub-topic — where it has no session and the @ would spawn a fresh, context-less one. The orchestrator then aggregates the collected reports.
222
+
223
+ **Collaboration boundaries:**
224
+
225
+ - **"Own" bots in the same deployment trust each other** — the orchestrator can run operate-level commands like `/repo` directly against them (same conversation permissions as your own bots). Authorization for external bots is two-tiered: `/grant @bot` only grants "talk / be spawned by chat-scope" permission (talk-only — it does not touch `allowedUsers` and cannot run operate-level commands); to let an external bot run operate-level commands like `/repo`, add it to `allowedUsers` (or grant operate-level access later). `/introduce` only handles discovery / registering open_id and **grants no permissions**.
226
+ - Sub-bots must already be in the group and @-mentionable (i.e. have the `im:message.group_at_msg.include_bot` permission).
227
+ - A single topic can hold multiple bots, and they @-mention each other to collaborate within the topic (e.g. the coder @-mentions the reviewer once the code is done).
228
+
200
229
  ### Tmux Persistence
201
230
 
202
231
  When tmux is installed, botmux automatically uses it. CLI processes persist inside tmux sessions — all features work unchanged.
@@ -498,6 +527,8 @@ When `~/.botmux/bots.json` already exists, `botmux setup` can add a bot, reconfi
498
527
  | `botmux autostart disable` | Unregister boot-time autostart |
499
528
  | `botmux autostart status` | Show autostart status |
500
529
  | `botmux dashboard` | Print a fresh Web Dashboard URL (rotates the token; previous URL becomes invalid) |
530
+ | `botmux dispatch` | Open a sub-project topic and @-mention the chosen bots to spawn their sessions (the dispatch command for [Multi-Topic Collaboration](#multi-topic-collaboration); supports `--title`, repeatable `--bot`, `--brief` / `--brief-file`, `--repo`, `--standby`, `--into`, with `--chat-id` / `--session-id` as advanced overrides; see `botmux dispatch --help`) |
531
+ | `botmux report` | From inside a dispatched sub-project session, report progress/completion back to the orchestrator's own session ([Multi-Topic Collaboration](#multi-topic-collaboration); routes the report into the orchestrator's context-rich thread instead of @-mentioning it in the sub-topic; `--content-file` to read the report from a file; see `botmux report --help`) |
501
532
 
502
533
  ### Boot-time Autostart
503
534
 
package/README.md CHANGED
@@ -58,6 +58,7 @@ botmux 不重新实现 Agent 能力,而是直接桥接已有的 AI 编程 CLI
58
58
  | 多 CLI 支持 | 6 种 CLI 一键切换(Claude Code / Codex / Cursor / Gemini / OpenCode / Antigravity) | 绑定单一 SDK,无法切换 CLI |
59
59
  | Web 终端 | 可交互的完整终端,移动端快捷键工具栏,手机/电脑/飞书三端同步 | 通常仅 Web 聊天界面或只读输出 |
60
60
  | 多机器人协作 | 多 bot 同群 @mention 路由,独立进程隔离,不同 CLI 赛博斗蛐蛐 | 通常单机器人 |
61
+ | 多话题协作模式 | 主 bot 自动拆任务、开多话题、派多 bot 并行协作(coder+reviewer),飞书任务清单当共享进度板 | 通常需人工逐个分派、无统一进度板 |
61
62
  | 终端直连 | tmux attach 直接进入 CLI 进程,和本地开发体验一致 | 无法直接操作底层终端 |
62
63
  | 安装部署 | `npm install -g botmux`,5 分钟飞书配置即可使用 | 安装简单,但配置项较多 |
63
64
 
@@ -86,6 +87,32 @@ botmux 不重新实现 Agent 能力,而是直接桥接已有的 AI 编程 CLI
86
87
 
87
88
  同一台机器上可运行多个飞书机器人,每个机器人可对应不同的 CLI。同一群聊中通过 @mention 路由消息,仅有一个机器人时无需 @ 自动响应;多机器人时 `@<bot1> @<bot2> /t xxx` 可让每个被 @ 的机器人在同一条消息上各自独立开新话题。先发一次 `@<bot1> @<bot2> /introduce` 让它们互相登记 open_id,之后各 bot 就能在自己的会话里显式 @mention 对方协作(见 [§ 斜杠命令](#斜杠命令))。
88
89
 
90
+ ### 多话题协作模式
91
+
92
+ 「多机器人协作」的升级版:主 bot(**编排者**)把一个大任务拆成多个**子项目**,在群里**自动开多条话题**,每条话题派一组 bot 并行推进(常见「一个写代码 + 一个 review」),用一张**飞书任务清单**当所有人共享的进度板,最后由主 bot 收齐汇总。一个普通群就是一个并行工作台,你在飞书任务面板一眼看完成度。
93
+
94
+ **怎么跑** —— `botmux-orchestrate` skill 教编排者走完整流程:
95
+
96
+ > 拆子项目 → 提一版「子项目 ↔ bot」分配 → 发给你**一次审批**(可用卡片确认) → 建飞书任务清单 → 逐个开话题派活 → 收齐回报 → 汇总
97
+
98
+ 底层派活靠 `botmux dispatch`:在群里种一条话题、把指定 bot @ 进去各起独立会话。
99
+
100
+ ```bash
101
+ botmux dispatch --title "实现登录模块" \
102
+ --bot "ou_xxx:Alice:coder" --bot "ou_yyy:Bob:reviewer" \
103
+ --repo /path/to/repo --brief-file /tmp/brief.md
104
+ ```
105
+
106
+ - `--repo <目录>` —— 预设子 bot 的工作目录(绝对路径,需在子 bot 所在机器上存在),起会话直接进去、**免手点「选仓库」卡**。
107
+ - `--standby` —— **必须配 `--repo`**(且不能与 `--into` 同用):只发一次 `/repo` 把 bot 拉起到指定目录待命、不派简报,之后用 `--into ... --brief(-file)` 激活派活。
108
+ - `--into <话题root>` —— 回到已有话题追加一条(激活待命的 bot / 追加协调);仍需 `--bot`,且非 standby 时必须带 `--brief` 或 `--brief-file`。
109
+
110
+ **协作边界**:
111
+
112
+ - **同部署的「自家」bot 之间互信** —— 编排者能直接对它们跑 `/repo` 等 operate 级命令(与自家 bot 的对话权限一致)。外部 bot 的授权分两层:`/grant @bot` 只给「对话 / 被 chat-scope 拉起」的权限(talk-only,不碰 `allowedUsers`、跑不了 operate 级命令);要让外部 bot 跑 `/repo` 等 operate 级命令,需把它列进 `allowedUsers`(或后续 operate 级授权)。`/introduce` 只负责发现 / 登记 open_id,**不授予任何权限**。
113
+ - 子 bot 须已在群里、可被 @(具备 `im:message.group_at_msg.include_bot` 权限)。
114
+ - 一条话题可放多个 bot,它们在话题内互相 @ 协作(如 coder 写完 @ reviewer 审)。
115
+
89
116
  ### Tmux 会话常驻
90
117
 
91
118
  安装 tmux 后自动启用。CLI 进程常驻在 tmux session 内,所有功能不受影响。
@@ -418,6 +445,7 @@ PersonalAgent 默认配好事件订阅 + bot 能力,正常情况下不用动
418
445
  | `botmux autostart disable` | 注销开机自启 |
419
446
  | `botmux autostart status` | 查看自启状态 |
420
447
  | `botmux dashboard` | 输出一次 Web Dashboard URL(每次刷 token,旧链接立即失效) |
448
+ | `botmux dispatch` | 开一条子项目话题、把指定 bot @ 进去拉起会话([多话题协作模式](#多话题协作模式)的派活命令;支持 `--repo`/`--standby`/`--into`,`--chat-id`/`--session-id` 为高级覆盖项,见 `botmux dispatch --help`) |
421
449
 
422
450
  ### Workflow 子命令(实验性运维)
423
451
 
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAo/GA;;;;;;;;;;;GAWG;AACH,wBAAsB,OAAO,CAC3B,OAAO,EAAE,OAAO,EAChB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,EACvC,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,OAAO,qBAAqB,EAAE,SAAS,CAAC,EAC9F,KAAK,EAAE,MAAM,EACb,mBAAmB,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,2BAA2B,EAAE,UAAU,GAAG,IAAI,CAAC,GACzF,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CA+F7B"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AA02HA;;;;;;;;;;;GAWG;AACH,wBAAsB,OAAO,CAC3B,OAAO,EAAE,OAAO,EAChB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,EACvC,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,OAAO,qBAAqB,EAAE,SAAS,CAAC,EAC9F,KAAK,EAAE,MAAM,EACb,mBAAmB,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,2BAA2B,EAAE,UAAU,GAAG,IAAI,CAAC,GACzF,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CA+F7B"}
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;