Skip to content
Open
326 changes: 326 additions & 0 deletions docs/design/2026-08-02-session-fork-bot-clone-design.md

Large diffs are not rendered by default.

15 changes: 14 additions & 1 deletion src/adapters/cli/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -828,10 +828,23 @@ export function createClaudeFamilyAdapter(variant: ClaudeFamilyVariant, rawBin:
return discoverClaudeFamilySessions(variant.dataDir, limit, exclude);
},

buildArgs({ sessionId, resume, resumeSessionId, botName, botOpenId, locale, model, disableCliBypass, skillPluginDir }) {
buildArgs({ sessionId, resume, resumeSessionId, forkSession, botName, botOpenId, locale, model, disableCliBypass, skillPluginDir }) {
const args: string[] = [];
if (resume) {
args.push('--resume', resumeSessionId ?? sessionId);
// Session fork: resume the source transcript but write forward into a
// fresh CLI-minted session id, leaving the source untouched. Claude's
// interactive `/fork` refuses when the session was launched with
// restriction flags (skip-permissions / custom system prompt / tool
// allowlist), but the cold-start `--fork-session` flag does NOT — botmux
// re-passes those same flags to the forked spawn, so the copy runs with
// identical restrictions and the anti-privilege-escalation guard never
// fires (verified 2026-08-02, claude 2.1.220). Claude mints the new id
// and rewrites the copy's internal per-line ids itself; botmux reads the
// new id back from the fresh transcript (resolveJsonlFromPid).
if (forkSession) {
args.push('--fork-session');
}
} else {
args.push('--session-id', sessionId);
}
Expand Down
9 changes: 7 additions & 2 deletions src/adapters/cli/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter {
authPaths: ['~/.codex'],
get resolvedBin(): string { return (cachedBin ??= resolveCommand(rawBin)); },

buildArgs({ sessionId, resume, resumeSessionId, workingDir, model, reasoningEffort, disableCliBypass, readIsolation, remoteWsUrl, remoteThreadId }) {
buildArgs({ sessionId, resume, resumeSessionId, forkSession, workingDir, model, reasoningEffort, disableCliBypass, readIsolation, remoteWsUrl, remoteThreadId }) {
// Hybrid RPC input mode: attach this TUI to the botmux-owned app-server
// thread. User input is delivered out-of-band via JSON-RPC (turn/start,
// see codex-rpc-engine + worker), so the pane is a pure viewer — no paste
Expand Down Expand Up @@ -219,8 +219,13 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter {
const codexSessionId = resume
? resumeSessionId ?? latestCodexSessionForBotmuxSession(sessionId)
: undefined;
// Session fork: `codex fork <id>` copies the source rollout up to its tip
// into a NEW rollout + session id (session_meta records forked_from_id),
// leaving the source rollout untouched. Unlike Claude, Codex has no
// privilege-escalation guard on fork. Falls back to plain `resume` when we
// somehow lack a source id (nothing to fork from).
const codexArgs = codexSessionId
? ['resume', ...baseArgs, codexSessionId]
? [forkSession ? 'fork' : 'resume', ...baseArgs, codexSessionId]
: freshArgs;
return codexArgs;
},
Expand Down
6 changes: 6 additions & 0 deletions src/adapters/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ export interface CliAdapter {
workingDir?: string;
/** CLI-native session id used for resume when it differs from botmux's session id. */
resumeSessionId?: string;
/** When true, resume the `resumeSessionId` transcript but write forward into a
* NEW CLI-native session id instead of the resumed one, leaving the source
* transcript untouched — the native "fork/branch a session" primitive
* (Claude `--fork-session`, `codex fork`). Only meaningful with resume=true
* and a resumeSessionId; adapters whose CLI lacks the primitive ignore it. */
forkSession?: boolean;
initialPrompt?: string;
botName?: string;
botOpenId?: string;
Expand Down
188 changes: 188 additions & 0 deletions src/core/command-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3319,6 +3319,194 @@ export async function handleCommand(
break;
}

case '/fork': {
// Session fork (Bot 分身): non-destructive copy of a running session
// into a SECOND independent session at a new anchor; source untouched.
// PR1 scope: `--create <群名> @bot` (fork to a freshly-created group).
// The no-arg picker (fork into the current group) is a follow-up.
const argsLine = message.content.replace(/^\/fork\s*/i, '').trim();
const forkAppId = larkAppId ?? ds?.larkAppId;
if (!forkAppId) {
await sessionReply(rootId, t('cmd.fork.no_bot', undefined, loc));
break;
}
if (!ds) {
await sessionReply(rootId, t('cmd.fork.no_session', undefined, loc));
break;
}
const forkSenderOpenId = message.senderId;
if (!forkSenderOpenId) {
await sessionReply(rootId, t('cmd.fork.no_sender', undefined, loc));
break;
}
// Owner-only.
if (ds.session.ownerOpenId && ds.session.ownerOpenId !== forkSenderOpenId) {
await sessionReply(rootId, t('cmd.fork.not_owner', undefined, loc));
break;
}
// Capability gate — refuse non-forkable backends up front with a clear,
// typed message (mirrors the design doc §4 refusal). Cheap check before
// we create any group.
const { isForkCapableSession } = await import('./worker-pool.js');
if (!isForkCapableSession(ds)) {
const cliName = getCliDisplayName((ds.session.cliId ?? getBot(forkAppId).config.cliId ?? 'claude-code') as CliId);
await sessionReply(rootId, t('cmd.fork.unsupported_backend', { cli: cliName }, loc));
break;
}

if (!/^--create\b/i.test(argsLine)) {
// No-arg picker path ("fork into an existing group you pick") is not
// built yet. Don't reuse the no-session copy — the user often DOES
// have a session here (that's exactly the confusing case). Tell them
// the picker is pending and point at the working --create form.
await sessionReply(rootId, t('cmd.fork.picker_pending', undefined, loc));
break;
}

// ── /fork --create <群名> @bot ──────────────────────────────────────
const afterFlag = argsLine.replace(/^--create\s*/i, '').trim();

// Front guards (fork needs a clean, real, idle source — same as relay).
// These MUST run before createGroupWithBots, otherwise a refusal (e.g.
// /fork typed at a chat top-level where ds is an empty scratch, while
// the real session lives in a 话题) leaves an orphan empty group.
if (ds.session.adoptedFrom) {
await sessionReply(rootId, t('cmd.fork.adopt_not_forkable', undefined, loc));
break;
}
if (ds.pendingRepo) {
await sessionReply(rootId, t('cmd.fork.not_started_yet', undefined, loc));
break;
}
// Real, resumable source session? A bare /fork scratch (worker:null, no
// persisted CLI markers) is not forkable — most commonly this fires when
// /fork was invoked at the group top-level while the session lives in a
// 话题 (thread-scope). Refuse BEFORE creating any group.
const { isRelayableRealSession: forkIsRealSession } = await import('./worker-pool.js');
if (!forkIsRealSession(ds)) {
await sessionReply(rootId, t('cmd.fork.no_source_here', undefined, loc));
break;
}
// Idle check up front — mid-turn source can't be forked cleanly.
const forkSt = ds.lastScreenStatus;
if (ds.worker && !ds.worker.killed && forkSt !== 'idle' && forkSt !== 'limited') {
await sessionReply(rootId, t('cmd.fork.mid_turn', undefined, loc));
break;
}

// Resolve the bot to invite into the new group. Fork copies THIS
// session's transcript, so the child MUST run the same bot as the
// source — i.e. the invited bot is always this bot. Therefore:
// • no @mention → default to the current bot (the common "fork myself
// to a new group" case — no need to @ the bot you're already talking to);
// • an explicit @mention → must resolve to THIS bot, else refuse.
const forkSourceIsP2p = ds.chatType === 'p2p';
const targetBotAppId = forkAppId;
let targetBotName = botDisplayName(forkAppId);
if (!forkSourceIsP2p) {
const forkMentions = message.mentions ?? [];
const knownBotNames = globalKnownBotNames();
const forkBotMentions = forkMentions.filter(m => m.name && knownBotNames.has(m.name.toLowerCase()));
// Only validate WHEN the user explicitly @'d a bot. An explicit
// mention that resolves to a DIFFERENT bot is a real error (fork can't
// hand this session's transcript to another CLI). No mention → just
// use the current bot.
if (forkBotMentions.length > 0) {
const firstBot = forkBotMentions[0];
const myOpenId = getBotOpenId(forkAppId);
const myName = getBot(forkAppId).botName?.toLowerCase();
const mentionIsThisBot =
(!!myOpenId && firstBot.openId === myOpenId) ||
(!myOpenId && !!myName && firstBot.name?.toLowerCase() === myName);
if (!mentionIsThisBot) {
await sessionReply(rootId, t('cmd.fork.wrong_bot', undefined, loc));
break;
}
}
}

// Group name = first non-empty line after --create (mention text stripped).
let forkRawArgs = afterFlag;
for (const m of (message.mentions ?? [])) {
if (m.name) forkRawArgs = forkRawArgs.split(`@${m.name}`).join(' ');
}
const forkFirstLine = forkRawArgs.split(/\r?\n/).map(s => s.trim()).find(Boolean) ?? '';
const FORK_MAX_NAME = 50;
let forkGroupName: string;
if (forkFirstLine) {
forkGroupName = forkFirstLine.length > FORK_MAX_NAME ? forkFirstLine.slice(0, FORK_MAX_NAME) + '…' : forkFirstLine;
} else {
const src = ds.session.title || ds.session.sessionId.substring(0, 8);
forkGroupName = `🔱 ${src}`.slice(0, FORK_MAX_NAME);
}

// Create the new chat (single bot + the invoking user).
let forkChatId: string;
let forkInviteLink: string;
try {
const { createGroupWithBots } = await import('../services/group-creator.js');
const result = await createGroupWithBots({
creatorLarkAppId: forkAppId,
larkAppIds: [targetBotAppId],
name: forkGroupName,
userOpenIds: [forkSenderOpenId],
transferOwnerTo: forkSenderOpenId,
});
forkChatId = result.chatId;
const applink = chatAppLink(result.chatId, normalizeBrand(getBot(forkAppId).config.brand));
forkInviteLink = result.shareLink ?? applink;
} catch (err: any) {
logger.error(`[${logTag}] /fork --create: createGroup failed: ${err?.message ?? err}`);
await sessionReply(rootId, t('cmd.fork.failed', { error: err?.message ?? String(err) }, loc));
break;
}

// Fork the session into the new chat (chat-scope, group). The new chat
// is empty by construction, so no target-anchor conflict. Source is
// never touched.
const { forkSession } = await import('./worker-pool.js');
const forkResult = await forkSession(ds.session.sessionId, forkChatId, forkChatId, 'group', 'chat');
if (!forkResult.ok) {
// Residual-orphan cleanup: the front guards already ran before
// createGroupWithBots, so this only fires on a narrow TOCTOU race
// (source went busy / closed in the sub-second between guard and
// fork). Best-effort disband the just-created empty group so a failed
// fork never leaves an orphan chat. May fail if ownership already
// transferred to the user (transferOwnerTo) — then we just tell them.
let orphanCleaned = false;
try {
const { disbandChat } = await import('../services/groups-store.js');
const dis = await disbandChat(forkAppId, forkChatId);
orphanCleaned = dis.ok;
if (!dis.ok) logger.warn(`[${logTag}] /fork --create: orphan group ${forkChatId} disband failed: ${dis.error}`);
} catch (e: any) {
logger.warn(`[${logTag}] /fork --create: orphan group ${forkChatId} disband threw: ${e?.message ?? e}`);
}
const errKey = forkResult.error === 'worker_busy' ? 'cmd.fork.mid_turn'
: forkResult.error === 'adopt_not_forkable' ? 'cmd.fork.adopt_not_forkable'
: forkResult.error === 'fork_unsupported_backend' ? 'cmd.fork.unsupported_backend'
: forkResult.error === 'not_started_yet' ? 'cmd.fork.not_started_yet'
: undefined;
if (errKey === 'cmd.fork.unsupported_backend') {
const cliName = getCliDisplayName((ds.session.cliId ?? getBot(forkAppId).config.cliId ?? 'claude-code') as CliId);
await sessionReply(rootId, t(errKey, { cli: cliName }, loc));
} else if (errKey) {
await sessionReply(rootId, t(errKey, undefined, loc));
} else {
await sessionReply(rootId, t('cmd.fork.failed', { error: forkResult.error }, loc));
}
if (!orphanCleaned) {
await sessionReply(rootId, t('cmd.fork.orphan_group_left', { name: forkGroupName }, loc));
}
logger.warn(`[${logTag}] /fork --create: forkSession failed (${forkResult.error}); new chat ${forkChatId} ${orphanCleaned ? 'disbanded' : 'LEFT (disband failed)'}`);
break;
}

await sessionReply(rootId, t('cmd.fork.created', { name: forkGroupName, link: forkInviteLink }, loc));
logger.info(`[${logTag}] /fork --create completed: chat=${forkChatId} child=${forkResult.childSessionId.substring(0, 8)} bot=${targetBotAppId} (source ${ds.session.sessionId.substring(0, 8)} untouched)`);
break;
}

case '/card': {
// Existing-session path. New topics route /card via handleCardCommand at
// the router (so no phantom session is created). off/on work without a
Expand Down
2 changes: 1 addition & 1 deletion src/core/passthrough-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* chat) rather than relayed to the CLI. Used both for routing and to reject
* `customPassthroughCommands` entries that would shadow a daemon command.
*/
export const DAEMON_COMMANDS = new Set(['/close', '/restart', '/status', '/help', '/cd', '/repo', '/rename', '/schedule', '/role', '/botconfig', '/skills', '/pair', '/login', '/adopt', '/detach', '/disconnect', '/oncall', '/group', '/g', '/relay', '/card', '/term', '/list-slash-command', '/slash', '/subscribe-lark-doc', '/watch-comment', '/vc', '/insight', '/dashboard', '/vc-auth']);
export const DAEMON_COMMANDS = new Set(['/close', '/restart', '/status', '/help', '/cd', '/repo', '/rename', '/schedule', '/role', '/botconfig', '/skills', '/pair', '/login', '/adopt', '/detach', '/disconnect', '/oncall', '/group', '/g', '/relay', '/fork', '/card', '/term', '/list-slash-command', '/slash', '/subscribe-lark-doc', '/watch-comment', '/vc', '/insight', '/dashboard', '/vc-auth']);

/**
* Slash commands that are forwarded verbatim to the underlying CLI (e.g.
Expand Down
Loading