Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs-site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,40 @@
> 相关:用 [角色与团队](#roles) 给每个 bot 设人设和能力标签;用 [一键建会话群](#group) 一句话拉多个 bot 进新群;用 [会话接力](#relay) 把会话搬到协作群。
</script>

<script type="text/markdown" data-doc="multi-topic">
# 多话题协作模式

[多机器人协作](#multi-bot) 的升级版:把一个大任务交给主 bot,它会自动把任务拆成多个子项目、在群里开多条话题、给每条话题派一组 bot 并行干活(常见「一个写、一个 review」),用一张飞书任务清单当共享进度板,最后收齐结果汇总给你。一个普通群就变成一个并行工作台。

## 怎么用

跟主 bot 说一句就行,比如:

> 「用多话题协作模式,把 ×× 拆成几个子项目并行做」
>
> 「你来当编排,协调几个 bot 并行推进这个需求」

接下来主 bot 会自动:

1. 把任务拆成几个子项目,先给你**一版分配方案**(每个子项目派哪些 bot),等你确认;
2. 你点头后,在群里开多条话题、把对应的 bot 拉进去并行开干;
3. 建一张**飞书任务清单**当进度板——你在飞书任务里一眼看到每个子项目的进度;
4. 各组干完后,主 bot 收齐、把结果汇总给你。

你全程只需要:**说需求 → 确认分配 → 看进度 / 收结果**。开话题、分工协作、互相回报这些都在 bot 之间自动完成,你不用操心。

## 前置条件

想让哪些 bot 参与协作,先把它们拉进这个群、确保能被 @ 到(见 [多机器人协作](#multi-bot))。其余的——在哪个仓库干活、怎么开话题派活——主 bot 都会自动处理,你不用预先配置(不需要先开 OnCall 之类的)。

## 效果

![多话题协作模式 · 群内多 bot 并行协作](https://magic-builder.tos-cn-beijing.volces.com/uploads/multi-topic-demo.jpg)

> 相关:[多机器人协作](#multi-bot) 是它的基础设施;用 [角色与团队](#roles) 给 bot 设角色(谁写、谁 review)。

</script>

<script type="text/markdown" data-doc="tmux">
# Tmux 会话常驻

Expand Down Expand Up @@ -1325,6 +1359,7 @@
['cards','实时流式卡片'],
['web-terminal','Web 终端'],
['multi-bot','多机器人协作'],
['multi-topic','多话题协作模式'],
['roles','角色与团队'],
['tmux','tmux 会话常驻'],
['adopt','会话接入 Adopt'],
Expand Down
56 changes: 33 additions & 23 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { createInterface } from 'node:readline';
import { createRequire } from 'node:module';
import { createHmac, randomBytes } from 'node:crypto';
import { validateWorkingDir } from './core/working-dir.js';
import { parseDispatchBotSpec, buildDispatchMessages, buildRepoPrimeText, buildReportContent, eligibleAutoMentionAliases, offTopicSubBotTopic } from './core/dispatch.js';
import { parseDispatchBotSpec, buildDispatchMessages, buildRepoPrimeText, buildReportContent, eligibleAutoMentionAliases, offTopicSubBotTopic, resolveReportTarget } from './core/dispatch.js';
import { enableAutostart, disableAutostart, autostartStatus, refreshAutostart } from './autostart.js';
import { tmuxEnv } from './setup/ensure-tmux.js';
import { writeBotsJsonAtomic as writeBotsAtomic } from './setup/bots-store.js';
Expand Down Expand Up @@ -252,6 +252,12 @@ function ecosystemConfig(): string {
// ad-hoc (e.g. `BOTMUX_MEMORY_DIAG_INTERVAL_MS=5000`) when chasing an
// RSS regression — turned off in master so logs stay quiet.
BOTMUX_MEMORY_DIAG_INTERVAL_MS: process.env.BOTMUX_MEMORY_DIAG_INTERVAL_MS ?? '0',
// Quiet restart (dev): when set, restore registers sessions but skips the
// tmux eager re-fork so restarts don't re-push unfinished-session cards.
// Pass the shell value through explicitly (default '0') so it overrides
// any stale value carried by the long-lived pm2 god daemon — letting an
// unset shell var truly turn quiet-restart back off.
BOTMUX_QUIET_RESTART: process.env.BOTMUX_QUIET_RESTART ?? '0',
},
}));

Expand Down Expand Up @@ -3516,24 +3522,26 @@ async function cmdReport(rest: string[]): Promise<void> {
if (!s) { console.error(`未找到 session ${sid}`); process.exit(1); }
if (!s.larkAppId) { console.error(`session ${sid} 缺少 larkAppId`); process.exit(1); }

// Resolve the orchestrator coords: its thread/chat from the dispatch registry
// (keyed by this sub-bot's thread root), its open_id from this session.
// Resolve where the report goes + who to @. Same-machine: the dispatch registry
// (keyed by this sub-bot's thread root) carries the orchestrator's exact coords.
// CROSS-MACHINE: the orchestrator is on another machine, so its registry isn't
// on THIS one — resolveReportTarget falls back to this sub-bot's own session
// (report top-level into its chat, @ the dispatcher via creatorOpenId). See
// resolveReportTarget / Session.creatorOpenId.
const regPath = join(resolveDataDir(), 'orchestrate-dispatch.json');
let reg: Record<string, any> = {};
try { if (existsSync(regPath)) reg = JSON.parse(readFileSync(regPath, 'utf-8')); } catch { /* */ }
const entry = s.rootMessageId ? reg[s.rootMessageId] : undefined;
// The orchestrator's open_id (sub-bot-app-scoped) is whoever created this
// session = the dispatcher. Prefer `creatorOpenId` (set on EVERY creation path,
// incl. a no-`/repo` foreign-bot kickoff auto-create where ownerOpenId is
// nulled), then `ownerOpenId` (older sessions / `/repo` prime). NEVER
// quoteTargetSenderOpenId alone: it tracks the *last* sender who @-ed this
// sub-bot, so in a coder+reviewer topic it drifts to the reviewer (observed
// live: the coder's report @-ed the reviewer, not the orchestrator). Keep it as
// a last-ditch fallback only for pre-existing sessions that predate both fields.
const orchOpenId = s.creatorOpenId ?? s.ownerOpenId ?? s.quoteTargetSenderOpenId;
if (!entry || !orchOpenId) {
const tgt = resolveReportTarget({
registryEntry: entry,
sessionChatId: s.chatId,
creatorOpenId: s.creatorOpenId,
ownerOpenId: s.ownerOpenId,
quoteTargetSenderOpenId: s.quoteTargetSenderOpenId,
});
if (!tgt.orchOpenId || !tgt.orchChatId) {
console.error(
'当前会话不是被 botmux dispatch 派活的子项目会话(缺少主编排坐标)。\n' +
'找不到主编排坐标:本会话没记录派活者(creatorOpenId/ownerOpenId 都空)或缺 chatId——大概不是被 botmux dispatch 派活的会话。\n' +
'若确需回报,请改用 `botmux send` 或显式 @ 对应的人/ bot。');
process.exit(1);
}
Expand All @@ -3543,23 +3551,25 @@ async function cmdReport(rest: string[]): Promise<void> {
const { sendMessage, replyMessage } = await import('./im/lark/client.js');
const appId = s.larkAppId!;

const paras = buildReportContent({ orchOpenId, content });
const paras = buildReportContent({ orchOpenId: tgt.orchOpenId, content });
const postJson = JSON.stringify({ zh_cn: { title: '', content: paras } });

try {
let msgId: string;
if (entry.orchScope === 'chat' || !entry.orchRoot) {
// Orchestrator runs at chat scope (普通群整群一个会话) → post to the chat.
msgId = await sendMessage(appId, entry.orchChatId, postJson, 'post');
if (tgt.orchScope === 'chat' || !tgt.orchRoot) {
// Orchestrator at chat scope, or cross-machine fallback → post top-level
// into the chat (the sub-topic's chat = the orchestrator's chat).
msgId = await sendMessage(appId, tgt.orchChatId, postJson, 'post');
} else {
// Orchestrator lives in its own thread → reply into it so its existing
// session (anchored on orchRoot) is the one that receives the report.
msgId = await replyMessage(appId, entry.orchRoot, postJson, 'post', true);
// Same-machine thread-scope orchestrator → reply into its thread so its
// existing context-rich session (anchored on orchRoot) receives the report.
msgId = await replyMessage(appId, tgt.orchRoot, postJson, 'post', true);
}
console.log(JSON.stringify({
success: true,
reportedTo: entry.orchRoot || entry.orchChatId,
orchestrator: orchOpenId,
reportedTo: tgt.orchRoot || tgt.orchChatId,
orchestrator: tgt.orchOpenId,
viaRegistry: !!entry,
messageId: msgId,
}));
} catch (err: any) {
Expand Down
6 changes: 6 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ export const config = {
cliId: (process.env.CLI_ID ?? 'claude-code') as import('./adapters/cli/types.js').CliId,
cliPathOverride: process.env.CLI_PATH,
backendType: (process.env.BACKEND_TYPE ?? detectDefaultBackend()) as 'pty' | 'tmux',
/** Quiet restart (dev): skip the tmux backend's eager re-fork of restored
* sessions on startup, so repeated local restarts don't re-push streaming
* cards for unfinished sessions. Sessions resume lazily on the next
* message. Set `BOTMUX_QUIET_RESTART=1` in the dev shell to default it on;
* production leaves it unset (eager re-attach keeps live cards updating). */
quietRestart: ['1', 'true'].includes((process.env.BOTMUX_QUIET_RESTART ?? '').toLowerCase()),
workingDir: (process.env.WORKING_DIR ?? '~').split(',').map(s => s.trim()).filter(Boolean)[0] || '~',
workingDirs: (process.env.WORKING_DIR ?? '~').split(',').map(s => s.trim()).filter(Boolean),
allowedUsers: (process.env.ALLOWED_USERS ?? '').split(',').map(s => s.trim()).filter(Boolean),
Expand Down
33 changes: 33 additions & 0 deletions src/core/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,39 @@ export function findSubBotTopic(input: {
return null;
}

/**
* Resolve where a `botmux report` should go + who to @, so report-back works
* even when the orchestrator is on a DIFFERENT machine.
*
* Same-machine: the dispatch registry (orchestrate-dispatch.json) is local, so
* `registryEntry` carries the orchestrator's exact coords (incl. orchRoot for a
* thread-scope orchestrator). Cross-machine: the foreign sub-bot's daemon never
* wrote that registry, so `registryEntry` is undefined — but everything needed
* for the common case is on the sub-bot's OWN session: the report goes top-level
* into the chat the sub-topic lives in (= the orchestrator's chat) and @-s the
* orchestrator (creatorOpenId, captured from the dispatch @). So we fall back to
* `{ orchChatId: sessionChatId, orchScope: 'chat', orchRoot: '' }`.
*
* orchOpenId prefers `creatorOpenId` (stable, set on every session-creation path
* incl. foreign-bot auto-create), then `ownerOpenId`, then the drifting
* `quoteTargetSenderOpenId` as a last resort.
*/
export function resolveReportTarget(input: {
registryEntry?: { orchChatId?: string; orchScope?: string; orchRoot?: string };
sessionChatId?: string;
creatorOpenId?: string;
ownerOpenId?: string;
quoteTargetSenderOpenId?: string;
}): { orchChatId?: string; orchScope: string; orchRoot: string; orchOpenId?: string } {
const e = input.registryEntry;
return {
orchChatId: e?.orchChatId ?? input.sessionChatId,
orchScope: e?.orchScope ?? 'chat',
orchRoot: e?.orchRoot ?? '',
orchOpenId: input.creatorOpenId ?? input.ownerOpenId ?? input.quoteTargetSenderOpenId,
};
}

/**
* The footgun check shared by `botmux send`'s explicit-mention guard AND its
* prose `@Name` auto-injection: returns the sub-topic seed if `mentionOpenId` is
Expand Down
24 changes: 21 additions & 3 deletions src/core/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,22 @@ export function rememberLastCliInput(ds: DaemonSession, userPrompt: string, cliI

// ─── Session restore ─────────────────────────────────────────────────────────

/**
* Whether daemon restore should eagerly re-fork workers to re-attach surviving
* tmux panes (which re-renders — or re-posts — each session's streaming card in
* its Lark thread). True only on the tmux backend, and suppressed by
* quiet-restart (`BOTMUX_QUIET_RESTART=1`) so local-dev restarts don't re-push
* cards for unfinished sessions. When suppressed, sessions are still registered
* and resume lazily — re-attaching the surviving tmux on the next real message
* via `selectSessionBackend` — exactly like the pty backend already does.
*/
export function shouldAutoForkOnRestore(
backendType: 'pty' | 'tmux',
quietRestart: boolean,
): boolean {
return backendType === 'tmux' && !quietRestart;
}

export async function restoreActiveSessions(activeSessions: Map<string, DaemonSession>): Promise<void> {
const sessions = sessionStore.listSessions();
const active = sessions.filter(s => s.status === 'active');
Expand Down Expand Up @@ -640,8 +656,10 @@ export async function restoreActiveSessions(activeSessions: Map<string, DaemonSe
logger.debug(`Registered session ${session.sessionId} (scope: ${scope}, anchor: ${anchor})`);
}

// Tmux mode: auto-fork workers for sessions with surviving tmux sessions
if (config.daemon.backendType === 'tmux') {
// Tmux mode: auto-fork workers for sessions with surviving tmux sessions.
// Skipped under quiet-restart (dev) — sessions resume lazily on the next
// message instead of re-pushing their cards on every restart.
if (shouldAutoForkOnRestore(config.daemon.backendType, config.daemon.quietRestart)) {
for (const [, ds] of activeSessions) {
const tmuxName = TmuxBackend.sessionName(ds.session.sessionId);
if (!TmuxBackend.hasSession(tmuxName)) continue;
Expand Down Expand Up @@ -669,7 +687,7 @@ export async function restoreActiveSessions(activeSessions: Map<string, DaemonSe
}
}

logger.info(`Restored ${active.length} session(s)${config.daemon.backendType === 'tmux' ? '' : ', waiting for messages to resume'}`);
logger.info(`Restored ${active.length} session(s)${shouldAutoForkOnRestore(config.daemon.backendType, config.daemon.quietRestart) ? '' : ', waiting for messages to resume'}`);
}

/**
Expand Down
16 changes: 14 additions & 2 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import { EventLog as WorkflowEventLog } from './workflows/events/append.js';
import { replay as replayWorkflow } from './workflows/events/replay.js';
import { isBotMentioned, probeBotOpenId, startLarkEventDispatcher, writeBotInfoFile, canOperate, evaluateTalk, grantCommandRestriction, isKnownPeerBot, checkRequiredScopes, type RoutingContext, type TalkEvaluation } from './im/lark/event-dispatcher.js';
import { learnFromMentions, resolveSender, flushIdentityCacheSync } from './im/lark/identity-cache.js';
import { enrichSenderName } from './im/lark/sender-name-fallback.js';
import { renderSenderTag } from './core/session-manager.js';
import { markSessionActivity } from './core/session-activity.js';
import { WorkflowEventWatcher, handleWorkflowFanoutEvent } from './workflows/fanout.js';
Expand Down Expand Up @@ -1973,7 +1974,14 @@ async function handleNewTopic(data: any, ctx: RoutingContext): Promise<void> {
// Resolve sender identity for <sender> tag injection. The first call to
// resolveSender for an unseen open_id may await contact.v3.user.get with a
// short budget; subsequent calls hit the cache and are sync-fast.
const newTopicSender = await resolveSender(larkAppId, senderOpenId, parsed.senderType);
// enrichSenderName adds the chat-members + proactive-@ fallbacks (blocking)
// so a first-turn from an out-of-scope user still carries a name; it no-ops
// for bots and already-named senders.
const newTopicSender = await enrichSenderName(
larkAppId,
await resolveSender(larkAppId, senderOpenId, parsed.senderType),
{ chatId, scope, anchor },
);

refreshCliVersion(botCfg.cliId, botCfg.cliPathOverride);

Expand Down Expand Up @@ -2358,12 +2366,16 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void>
const getThreadSender = async (): Promise<typeof threadSenderCached> => {
if (threadSenderResolved) return threadSenderCached;
threadSenderResolved = true;
threadSenderCached = await resolveSender(
const resolved = await resolveSender(
larkAppId,
senderOpenIdForPrefix,
parsed.senderType,
isForeignBot ? { type: 'bot', name: foreignBotName !== 'Bot' ? foreignBotName : undefined } : undefined,
);
const probeChatId = ctxChatId ?? data?.message?.chat_id;
threadSenderCached = probeChatId
? await enrichSenderName(larkAppId, resolved, { chatId: probeChatId, scope, anchor })
: resolved;
return threadSenderCached;
};

Expand Down
47 changes: 47 additions & 0 deletions src/im/lark/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,53 @@ export async function listChatMemberOpenIds(larkAppId: string, chatId: string):
return openIds;
}

/**
* List chat members as (open_id, name) pairs — the name source that does NOT
* require 通讯录可见范围 (contact:user.base:readonly). Used as a sender-name
* fallback when contact.v3.user.get returns 41050 for an out-of-scope user:
* the sender is by definition a member of the chat it just posted in, so a
* single members page usually resolves it.
*
* Best-effort by design (UNLIKE `listChatMemberOpenIds`, which throws): a
* failure here just means "couldn't learn the name this way", and the caller
* falls through to the next resolution layer. So we swallow errors → [].
*
* `maxPages` defaults to 1: members pages are 100 max and pagination is serial
* (~0.4s/page), so walking a 5000-member group would blow the sender-resolve
* budget. One page covers typical topic/project groups; larger groups fall
* through to the proactive-@ probe.
*/
export async function listChatMembersWithNames(
larkAppId: string,
chatId: string,
maxPages = 1,
): Promise<Array<{ openId: string; name: string }>> {
try {
const c = getBotClient(larkAppId);
const out: Array<{ openId: string; name: string }> = [];
let pageToken: string | undefined;
for (let page = 0; page < maxPages; page++) {
const params: Record<string, string> = { member_id_type: 'open_id', page_size: '100' };
if (pageToken) params.page_token = pageToken;
const res = await larkGet(c, `/open-apis/im/v1/chats/${encodeURIComponent(chatId)}/members`, params);
if (res.code !== 0) break;
for (const it of (res.data?.items ?? [])) {
const openId = it?.member_id;
const name = it?.name;
if (typeof openId === 'string' && openId && typeof name === 'string' && name) {
out.push({ openId, name });
}
}
if (!res.data?.has_more || !res.data?.page_token) break;
pageToken = res.data.page_token;
}
return out;
} catch (err) {
logger.debug(`listChatMembersWithNames(${chatId}) failed: ${err}`);
return [];
}
}

/**
* Resolve a chat's display name (the user-facing group title). Returns `null`
* on any failure (chatId is unknown to this bot, network error, bot not in
Expand Down
Loading