From 23aa1bac1291b4a80abc20f01c2c2bef721bdb3b Mon Sep 17 00:00:00 2001 From: Xiaoxue Sun <54162759+xiaoxueSunn@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:14:04 +0800 Subject: [PATCH 1/8] feat(fork): forkSession() core primitive + native CLI fork adapters Non-destructive sibling of transferSession: fork a running session into a second independent botmux session at a different anchor, source untouched. - forkSession() (worker-pool): reuse transferSession front guards, drop all destructive steps (freeze/kill/delete/rewrite source), mint a child session and forkWorker(resume=true) it. - Native CLI fork threading: adapter buildArgs gains forkSession flag -> Claude --fork-session, Codex fork subcommand. One-shot Session.pendingForkSession marker cleared once the child's own cliSessionId is persisted. - Capability gate isForkCapableSession: Claude family + Codex terminal only; codex-app / Codex-under-RPC / remote backends refused. - lineage title + forkedFrom provenance on the child. Command wiring (/fork case + picker card) and real-env acceptance still pending. Co-Authored-By: Claude --- ...026-08-02-session-fork-bot-clone-design.md | 285 ++++++++++++++++++ src/adapters/cli/claude-code.ts | 15 +- src/adapters/cli/codex.ts | 9 +- src/adapters/cli/types.ts | 6 + src/core/passthrough-commands.ts | 2 +- src/core/worker-pool.ts | 208 +++++++++++++ src/types.ts | 12 +- src/worker.ts | 6 + 8 files changed, 538 insertions(+), 5 deletions(-) create mode 100644 docs/design/2026-08-02-session-fork-bot-clone-design.md diff --git a/docs/design/2026-08-02-session-fork-bot-clone-design.md b/docs/design/2026-08-02-session-fork-bot-clone-design.md new file mode 100644 index 000000000..abbcd3c95 --- /dev/null +++ b/docs/design/2026-08-02-session-fork-bot-clone-design.md @@ -0,0 +1,285 @@ +# botmux 会话 Fork / Branch(Bot 分身 & 断点)设计文档 + +> 状态:设计定稿(PR1 待开工 / PR2 待产品讨论) +> 作者:Relay-Claude +> 基线:`origin/master` @ `d441baac` +> 关联:`/relay`(最近亲)、`transferSession`(worker-pool.ts:1787) +> 分支:`feat/session-fork` + +--- + +## 0. 一句话结论 + +在 botmux 里引入 **`/fork`**:把一个正在跑的会话,在**当前上下文节点**复制出一条**带完整记忆的独立会话**,源会话原封不动照常跑。 + +- **技术上**:能依赖 CLI 原生 fork 就依赖原生(Claude `--fork-session`、`codex fork`),botmux 只做 ① 后端能力适配、② 与现有框架的"套壳关联"(新建会话壳 + IM anchor 分裂 + resume)。botmux **不碰上下文文件**。 +- **产品上**:`/fork` 有**两种模式**,同一个底层原语、按"当前位置有没有地方落"自动分流: + + - **并行分身模式(Fork)**——落到别的群/话题,两条会话并行。对齐官方 `/branch` 语义、无破坏性 → **PR1,先落地**。 + - **原地切换模式(Branch)**——非话题群没法并行时,关掉当前会话、在新分支上继续(可恢复)。是自创用法、有风险 → **PR2,先与仓库管理员对齐产品原型再做**。 + +> 实测已确认(2026-08-02):Claude 冷启动 `--resume <父id> --fork-session`(带 botmux 全部限制 flag)**不触发交互式 /fork 的提权门禁**,正常 fork、继承上下文、父不动、内部逐行 id 由 CLI 官方改写;`codex fork` 用户实测正常。两个主力后端都能纯用原生原语。 + +--- + +## 1. 背景与需求 + +### 1.1 触发场景(用户原话提炼) + +> 在 A 群聊一个任务,任务还在跑;B 群也要用它积累的上下文继续对话。A 群原会话继续、不受影响;B 不需要把新进展同步回 A(**单向**)。 + +### 1.2 现有能力的缺口 + +| 能力 | 行为 | 为什么不够 | +|-|-|-| +| `/relay` | 把会话**搬迁**到另一个群 | 是**移动**:原会话被冻结、worker 被 kill、路由改写,原地不能继续。用户要"A 还继续" | +| `/t 新话题` | 开全新会话 | **不继承**任何上下文 | +| `resume` | 恢复**已关闭**的会话 | 不是复制,且要求先关原会话 | +| 手工 handoff | 会话把状态写文件、新会话读 | 只继承**摘要**,丢失逐字工具输出;且要人工操作 | + +**结论**:用户要的"非破坏性 + 带完整上下文 + 单向复制",现有能力无一覆盖。仓库里也**没有任何 fork/branch/断点相关的提交或在途 PR**(已核查 git 历史 + 全部 PR 到 #702),是一块空白。 + +### 1.3 定位 + +**会话 fork = 从某一时刻切一份带记忆的独立会话,父子各走各的、互不影响。** 不是"实时镜像"(无双向同步),不是"移动"(源不消失)。落在 fork 语义里最简单的一档:单向、非破坏性。 + +### 1.4 明确不做:原地 rewind + +调研过官方做法(见附录 A):Claude Code 有 `/rewind`(每个 prompt 自动检查点、可逐步回退代码/对话)。但它粒度过细、要双轨追踪 + 一堆边界(bash/subagent/符号链接不追踪),**透传到 botmux 成本高**。本设计**不做原地 rewind**,只做"分支式"——由人主动在干净点切一份。 + +| | 分支式(**做**) | 原地 rewind(**不做**) | +|-|-|-| +| 行为 | 复制出第二条会话,原的继续 | 把同一条倒带回更早点、丢弃之后 | +| 类比 | git branch、`codex fork` | git reset、ChatGPT 编辑重发 | +| 原生 | `--fork-session` / `codex fork` 天然支持 | 原生 fork 都不做,需截断 transcript,重且危险 | + +--- + +## 2. 核心概念:Fork 与 Branch 是同一原语的两种模式 + +这是整个设计的主心骨。**底层只有一个"复制一条会话"的原语(`forkSession`),Fork 和 Branch 只是它的两种落地形态**,由一条物理约束决定走哪种: + +> **botmux 铁律:一个 anchor(群 / 话题)对同一个 bot 只能挂一个活跃会话**(`activeSessions` 以 `sessionKey(anchor, larkAppId)` 为 key,入站消息一个 anchor 只 resolve 出一个会话)。 + +所以关键就看 **`/fork` 时当前位置有没有"空闲 anchor"给分身落**: + +| 你在哪 `/fork` | 有空闲 anchor? | 行为 | 概念 | 交付 | +|-|-|-|-|-| +| 话题群本群 | ✅ 能开新话题 | 就地开新话题放**并行**分身 | **Fork** | PR1 | +| 别的群 / `--create` 新群 | ✅ 别处/新群 | 落到别处**并行**分身 | **Fork** | PR1 | +| **非话题群本群** | ❌ 唯一 anchor 被占 | 问你要不要 **close 当前、切到新分支**(可恢复) | **Branch** | PR2 | + +### 2.1 两种模式的本质区别 + +| | 并行分身模式(Fork) | 原地切换模式(Branch) | +|-|-|-| +| 落点 | 不同群/话题 | 同群(非话题群),同一 anchor | +| 并行性 | 父子**同时活跃**、各聊各的 | 同一时刻**只一条活**,其余休眠 | +| 源会话 | 原封不动继续 | 被 **close**(可恢复),让位给新分支 | +| 切换 | 不用切,各群各聊 | 靠 close + resume 在分支间**切换**(像 git checkout) | +| 破坏性 | **无**(源不动) | **有**(关掉当前会话) | +| 官方背书 | = 官方 `/branch` 语义 | 自创组合,无原生对应 | + +### 2.2 关于"断点":它就是原地切换模式(Branch) + +用户最初的"打断点"诉求(怕后续操作弄脏上下文,先存一档)——本质就是原地切换模式(Branch): + +- **断点可以有多个**:每次 fork 都是独立 session(独立 id + 独立 transcript),磁盘上不互相覆盖,能存任意多个。 +- **同群同一时刻只激活一条**:受 anchor 铁律约束,多个断点不能在同一群同时挂着。 +- **恢复靠"切换"不是"并列"**:每个断点都能恢复,但恢复到某群前要先 close 当前那条——完全是 git branch 的心智(多分支都存着,工作区一次 checkout 一条)。 + +> 所以"断点只能一个 / 只能恢复一次"是误解:**断点可存多个、一次激活一条、靠切换**。想同时看多个断点 → 把它们 fork 到不同群/话题,那就变回并行分身模式(Fork)了。 + +### 2.3 两种模式的信息打通(非话题群"双出路卡") + +Fork 和 Branch 在**非话题群 `/fork`** 这个岔路口交汇。此处不是硬拒绝,而是给用户两条路(PR2 完整形态): + +> **本群是普通群,放不下并行分身。你可以:** +> ① **关闭当前会话、在新分支继续(Branch,可恢复)** — 适合"存个断点、换条干净的走" +> ② **Fork 到新群(保留当前会话并行)** — 适合"两边都要" + +一张卡把两条出路给全,用户当场选,不用记规则。**话题群 / 别的群 `/fork` 直接走 Fork,根本不出现这张卡。** + +> PR1 阶段:非话题群暂时只给"② Fork 到新群"这一条出路 + 一句"原地断点 Branch 待后续版本";PR2 再把 ① 那条路径接上(含二次确认与风险提示,见 §7)。 + +--- + +## 3. 使用路径与卡片流(PR1 · 并行分身模式 Fork) + +设计原则:**贴着 `/relay` 已有的寻址方式**,用户不学新范式。 + +### 3.1 路径 A:`/fork --create <群名> @bot` — 分身到新建群 + +对标 `/relay --create`。在源会话处输入 → botmux `createGroupWithBots` 建群拉 bot → 复制上下文到新 sessionId 落新群、发新会话卡 → **源会话不动** → 用户去新群 `@bot` 直接聊,带完整上下文。 +(私聊变体:不 @ 任何人 → fork 到该 bot 单聊。) + +### 3.2 路径 B:在目标群 B 里 `/fork` — 分身到已有群 + +对标 `/relay` 选择器。B 群 `/fork` → 弹**选择器卡**(列本人名下可 fork 的活跃会话)→ 选中确认 → 上下文复制进 B 群成新会话,源不动。文案为"复制/分身"(区别于 relay 的"搬迁")。 + +### 3.3 路径 C(话题群本群):`/fork` 开新话题 + +话题群里 `/fork` → 本群开一个新话题放分身,原话题会话不动。 + +### 3.4 卡片流全景 + +| 场景 | 命令/位置 | 弹卡 | 结果 | 交付 | +|-|-|-|-|-| +| 建新群 | `/fork --create <群名> @bot` | 不弹选择器 | 新群 1 张会话卡 + M1;源不动 | PR1 | +| 到已有群 | 在 B 群 `/fork` | **选择器卡** | 选中确认 → B 群新会话卡;源不动 | PR1 | +| 话题群本群 | `@bot /fork` | 不弹卡 | 本群新话题会话卡;原话题不动 | PR1 | +| 非话题群本群 | `@bot /fork` | PR1:仅"Fork 到新群"引导;PR2:**双出路卡** | 见 §2.3 | PR1 部分 / PR2 | + +> **新会话卡本身就是"创建成功"信号**,不再单发"已创建"文本。只有"到已有群选择器"和"非话题群双出路卡"才弹操作卡。 + +### 3.5 血缘与区分标题 + +fork 出的 child 自动写区分标题 `🔱 <原标题>`(source=`system`,复用 `updateSessionTitle`)+ 血缘字段 `forkedFrom`。避免父子在选择器/看板里标题雷同分不清。父是谁放 provenance 字段(详情里看),不塞进标题以免过长。 + +--- + +## 4. 能力门控(单层:按后端能力,不加 per-bot 开关) + +`/fork` 是 opt-in 命令,不用不触发,所以**不需要 per-bot 开关**。唯一判断是"这个后端物理上能不能 fork",内置在命令里: + +| 后端 | 上下文存储 | Fork | 说明 | +|-|-|-|-| +| Claude 系(claude-code/seed/relay/aiden) | 本地 `.jsonl` | ✅ | 原生 `--fork-session`(已实测) | +| Codex CLI 终端模式 | 本地 `rollout-*.jsonl` | ✅ | 原生 `codex fork`(已实测) | +| CoCo | 本地目录 | ⚠️ 后续 | 目录拷贝可行,但共享全局 history.jsonl | +| Codex App / Codex CLI 开了 Hybrid RPC | app-server 进程 + SQLite 活会话 | ❌ 默认关 | 拷 rollout 让 app-server 认新 thread 未验证 | +| Riff 等纯远端 | 远端沙箱 | ❌ | 本地无可复制 rollout(可选走原生 parentTaskId,后续) | + +**不支持的后端输 `/fork`**:复用现成 typed-refusal 范式,handler 开头判 `cliId`,回一句明确中文并 break,**不误跑、不污染会话**: + +> `ℹ️ 当前 {cli} 会话走 app-server 活会话,暂不支持 fork(目前仅 Claude 系 / Codex 终端模式)。` + +--- + +## 5. 技术方案(优雅 = 最大化复用 transferSession + CLI 原生 fork) + +### 5.1 核心洞察:Fork = transferSession 的非破坏性兄弟 + +`transferSession`(worker-pool.ts:1787,即 `/relay` 底层)已经把"跨群/跨话题重新落一个会话"的全部机制写好了。**但 relay 是"移动同一个会话壳"(sessionId 不变、CLI 进程不新建、只改路由 + kill 旧 pane 重 attach)**。Fork 要做的是它的**非破坏性 + 复制版**: + +| transferSession 步骤 | Fork 是否保留 | +|-|-| +| 前置守卫(not_started / adopt / busy / vc-receiver / anchor 冲突) | ✅ 全保留 | +| 冻结源卡片 `buildRelayedFrozenCard` | ❌ 不做(源会话继续) | +| `killWorker(源)` | ❌ 不做(源 worker 继续跑) | +| `activeSessionsRegistry.delete(源 anchor)` | ❌ 不做(源留在注册表) | +| 改写路由字段 | ❌ 不改源,改**新建的 child** | +| `setActiveSessionSafe` + `forkWorker(resume=true)` | ✅ 但作用于 **child** | + +### 5.2 双 id 模型(为什么 native fork 能干净接入) + +一个会话记两个 id(session-store.ts:360):`sessionId`(botmux 自己的壳 id)与 `cliSessionId`(CLI 原生 id,随 resume/rotation 更新)。`persistCliSessionId()`(worker.ts:6131)在 CLI 换 id 时把新原生 id 写回 `cliSessionId`,壳 id 不变。所以 **native fork 由 CLI mint 的新原生 id,botmux 只需回读并存进 child 的 `cliSessionId`,与壳 id 解耦**——与现有 Codex 实现同构。 + +### 5.3 `forkSession()` 步骤(新增于 worker-pool.ts) + +1. **能力门控**:判 `cliId`,不支持返回 typed error(上层转 refusal)。 +2. **前置守卫**:复用 transferSession 的 not_started / adopt / busy / vc-receiver / 目标 anchor 冲突。 +3. **mint 子会话**:`sessionStore.createSession` 出新 UUID 作 childSessionId;记 `forkedFrom`。 +4. **继承上下文(crux)——优先 CLI 原生 fork**: + + - Claude 系:spawn 传 `--resume --fork-session`;CLI 自己 mint 新 id、改写内部逐行 id、父文件不动。botmux 回读 childCliSessionId 并持久化。 + - Codex 终端:`codex fork `;新建 rollout、拷父到分叉点、记 `forked_from_id`。 + - 兜底(仅个别变体不支持时):手动 `atomicCopyClaudeResumeTranscript` + `checkResumeTargetExists`。 +5. **构造 child DaemonSession**:按 restore 模板建 `worker:null` child,路由字段设成**目标 anchor**,`streamCardId=undefined`(发新卡);**源 ds 完全不碰**。 +6. **注册 + resume-spawn**:`setActiveSessionSafe(目标 anchor, childDs)` → `forkWorker(childDs,'',resume=true)`。child 独立 pane/webPort/workerGeneration。 + +### 5.4 命令接线 + 复用清单 + +- `passthrough-commands.ts`:`/fork` 加进 `DAEMON_COMMANDS`;`command-handler.ts`:加 `case '/fork'`,参数解析仿 `/relay`。dispatch 现有路由无需新接线。 + +| 复用件 | 位置 | +|-|-| +| 守卫 / anchor 冲突 / 路由骨架 | `transferSession` worker-pool.ts:1787 | +| 建群拉 bot | `createGroupWithBots` | +| 目标 anchor/scope 解析 | `resolveRelayTargetRouting` | +| CLI 原生 fork(首选) | `--fork-session` / `codex fork` | +| transcript 定位 / 拷贝 / 校验(仅兜底) | `resolveSessionTranscriptPath` / `atomicCopyClaudeResumeTranscript` / `checkResumeTargetExists` | +| childCliSessionId 回读 | `resolveJsonlFromPid` / native-title 机制 | +| worker 重连 | `forkWorker(resume=true)` | +| typed refusal | 现有 relay/insight/rename + i18n | + +**真正新增**:一个 `forkSession()`、一个 `/fork` command case、选择器确认分支、若干 i18n。**上下文复制交给 CLI 原生,botmux 不重造。** + +--- + +## 6. 边界情况(实现必须处理) + +1. **源会话 mid-turn**:切点不完整 → 复用 `worker_busy` 守卫拒绝,提示等 idle。(全场景) +2. **目标 anchor 占用校验**(per-bot,`sessionKey` 带 larkAppId,同群不同 bot 互不冲突): + + - `--create` 新群:正常空群不撞;万一拉入 bot 已在新群有会话 → 拒绝并如实报告"未落入",不留半吊子。 + - 选择器到已有群:目标群已有该 bot 会话 → **必拒**,提示先 close。 + - 话题群新话题:新 anchor 天然不撞。 + - 关键区分:跨群"目标已有会话 → 拒绝,不覆盖别人";非话题群本群"已有会话 → 提示 close 换"(原地切换模式 Branch,PR2)。 +3. **权限**:只有会话发起人(ownerOpenId)能 fork(复用 relay picker 门)。 +4. **连续 fork / 卡片堆叠**:靠 `🔱` 血缘标题 + 来源标注区分;注意选择器别被自己 fork 出的一堆占满。 +5. **【原地切换模式 Branch / PR2】确认卡 TOCTOU**:弹卡到点击间源状态可能变(busy / 被 close / anchor 易主)→ **点击时刻重校验**,不凭旧状态执行(照抄 relay confirm)。 +6. **【原地切换模式 Branch / PR2】顺序安全(最危险)**:必须**先 fork 起成功、再 close 原会话**;先 close 后 fork 失败 = 用户两头空。失败须能把原会话恢复回来。 +7. **【原地切换模式 Branch / PR2】恢复撞 anchor**:点「▶️恢复会话」时本群已有活会话 → 命中 `resume_anchor_occupied` 被拒;恢复卡文案须提示"先 close 当前"。 + +--- + +## 7. 交付拆分 + +### PR1 — 并行分身模式 Fork(跨群 / 跨话题并行分身)· 先做,可独立落地 + +- **范围**:`/fork --create <群名> @bot`(建新群)+ `/fork` 选择器(到已有群)+ 话题群本群开新话题;非话题群暂给"Fork 到新群"引导。 +- **后端**:Claude 系 `--fork-session` + Codex 终端 `codex fork`;app-server / 不支持后端 typed-refusal。 +- **实现**:`forkSession()` = transferSession 非破坏性兄弟 + CLI 原生 fork;child 自动 `🔱` 标题 + `forkedFrom` 血缘。 +- **验收(真实流程,非实现细节)**:A 群 fork 到 B 群 → ① B 群分身记得 fork 前完整上下文 → ② A 群原会话照常跑不受影响 → ③ A、B 双活并发各写各的不串扰。 +- **依据**:对齐官方 `/branch` 语义、无破坏性、与 relay 框架同构,不需产品拍板即可推进。 + +### PR2 — 原地切换模式 Branch(非话题群 close-换-fork 断点)· 先与仓库管理员对齐产品原型再做 + +- **玩法**:非话题群 `/fork` 弹**双出路卡**(§2.3);选"关闭当前、在新分支继续"→ close 当前会话(自动留「▶️恢复会话」卡)→ 同 anchor 起新分支。零新指令,复用现成 close + 恢复卡。 +- **为何单独 PR**:Codex(`resume/fork/archive/delete/unarchive`)与 Claude(`--fork-session`)**都无原生 checkpoint/restore**,这是自创组合,方向须先与上游/产品对齐;且它**会关掉用户当前会话**,有破坏性。 +- **必须向用户披露的风险**(自创用法,不能默默引导): + + 1. **非官方**:CLI 升级 / botmux 会话模型重构可能失效,不保证长期稳定。 + 2. **不能并行**:同 anchor 一次一条活,回原会话须先 close 当前(`resume_anchor_occupied`)——与"分身并行"不同。 + 3. **恢复非事务级**:走 CLI resume,transcript 清理 / CLI 版本变化可能失败;"大概率能回",非可靠存档。 + 4. **心智易混**:是"换一条、旧的睡了",不是又多个分身。 +- **落法**:双出路卡内嵌风险提示("⚠️ 实验性用法:关闭的会话可尝试恢复但不保证成功,且本群同时只能有一个活跃会话"),知情后选择。 +- **复用**:底层仍是 PR1 的 `forkSession` + 现成 close/恢复卡,PR1 落地后 PR2 主要加"双出路卡 + close-then-fork 编排 + 风险文案"。 + +### Fast-follow + +CoCo 目录拷贝;Riff 原生 parentTaskId 分支;`/fork` 同话题快速分身。 + +### 明确不做 + +原地 rewind(§1.4);双向同步;app-server 活会话字节级 fork;PR1 阶段任何 close-换-fork 行为(归 PR2)。 + +--- + +## 8. 待确认 + +1. 命令名 `/fork`(还是 `/clone`)? +2. PR1 后端范围(Claude 系 + Codex 终端)、app-server 默认 refusal,是否 OK? +3. PR2 的双出路卡形态与风险文案,是否需要先出一版给管理员评审? + +--- + +## 附录 A:官方/主流"保护上下文"方案调研(2026-08-02) + +| 方案 | 谁有 | 作用 | 与本设计关系 | +|-|-|-|-| +| `/rewind` 检查点 | Claude Code | 每 prompt 自动打点,可只回退对话/代码 | 粒度太细、透传复杂,**不做** | +| `/branch` / `--fork-session` | Claude Code | 复制对话到新会话、原会话不动 | **= 我们的并行分身模式(Fork)**,官方背书 | +| subagent 隔离 | Claude Code / Codex | 脏活丢给子 agent,只带结论回主上下文 | 更优的"预防"思路,未来可借鉴 | +| `/clear/compact` plan mode | Claude Code | 上下文卫生常规手段 | 辅助 | +| `codex fork` / `resume` | Codex | fork 出新 rollout、resume 历史 | **= 我们的并行分身模式(Fork)**(Codex 侧) | +| `/undo` + git | Aider | 回退 git commit、清对话 | 偏 git,无会话分支 | + +要点:Fork/branch 是 Claude Code 较独有的成套设计;Codex 有 fork 无 rewind;Aider 只有 git undo。**我们做的并行分身模式(Fork)有官方语义背书;原地切换模式(Branch,close-换-fork)是自创,故需产品讨论 + 风险披露。** + +## 附录 B:关键实测结论(2026-08-02) + +- Claude 交互式 TUI `/fork` 在带限制 flag 时**拒绝**(提权保护);但**冷启动**`claude --resume --fork-session `**不拒绝**,正常 fork、继承上下文(暗号复制成功)、父文件 8→8 行不变、新文件内部逐行 id 全改写为新 id。→ Claude 路径用原生 `--fork-session` 成立,物理拷贝降为兜底。 +- `codex fork` 用户实测:新 rollout id、`forked_from_id` 血缘、父不动、前缀一致后分岔。→ Codex 路径成立。 +- **唯一待真实环境验证**:父子双活并发写(两个 worker 同时各写各文件不 clobber)——单机 CLI 复现不了,PR1 验收必测。 diff --git a/src/adapters/cli/claude-code.ts b/src/adapters/cli/claude-code.ts index a50ab92bd..ae935a341 100644 --- a/src/adapters/cli/claude-code.ts +++ b/src/adapters/cli/claude-code.ts @@ -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); } diff --git a/src/adapters/cli/codex.ts b/src/adapters/cli/codex.ts index 002c1ac11..7ff9defee 100644 --- a/src/adapters/cli/codex.ts +++ b/src/adapters/cli/codex.ts @@ -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 @@ -219,8 +219,13 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter { const codexSessionId = resume ? resumeSessionId ?? latestCodexSessionForBotmuxSession(sessionId) : undefined; + // Session fork: `codex fork ` 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; }, diff --git a/src/adapters/cli/types.ts b/src/adapters/cli/types.ts index d64be3689..bceabaea9 100644 --- a/src/adapters/cli/types.ts +++ b/src/adapters/cli/types.ts @@ -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; diff --git a/src/core/passthrough-commands.ts b/src/core/passthrough-commands.ts index df1a935c4..aaefa817c 100644 --- a/src/core/passthrough-commands.ts +++ b/src/core/passthrough-commands.ts @@ -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. diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index dc797c3df..804d20682 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -3306,6 +3306,200 @@ export async function transferSession( } } +/** Backends whose conversation state is a local, copyable transcript file and + * whose CLI exposes a native "fork/branch this session" primitive that botmux + * can drive at cold spawn (Claude family: `--fork-session`; Codex terminal: + * `codex fork `). App-server backends (codex-app, or a codex CLI running in + * Hybrid RPC mode) keep state in a live app-server process + SQLite and have no + * byte-level fork we can reproduce — they are refused. Riff / other pure-remote + * backends have no local rollout to fork either. */ +const FORK_CAPABLE_CLI_IDS: ReadonlySet = new Set([ + 'claude-code', 'seed', 'relay', 'aiden', 'codex', +]); + +/** True when this session can be byte-level forked via a CLI-native primitive. + * Refuses codex-app outright, and refuses a plain `codex` session that is + * running in Hybrid RPC mode (its live thread lives in the app-server, not a + * forkable local rollout). */ +export function isForkCapableSession(ds: DaemonSession): boolean { + const botCfg = getBot(ds.larkAppId).config; + const cliId = sessionCliId(ds, botCfg); + if (!FORK_CAPABLE_CLI_IDS.has(cliId)) return false; + // Codex terminal mode is forkable; Codex under Hybrid RPC input is not (the + // thread is an app-server live session, no local rollout to `codex fork`). + if (cliId === 'codex' && (botCfg.codexRpcInput === true || config.codexRpcInputDefault)) { + return false; + } + return true; +} + +/** + * Fork a session: create a SECOND, independent botmux session that inherits the + * source's full context at the current node, landing at a different anchor + * (another group / topic). The source session is left completely untouched and + * keeps running — this is the non-destructive sibling of {@link transferSession} + * (relay MOVES one session shell; fork COPIES into a new shell). + * + * Context inheritance is delegated to the CLI's native fork primitive + * (`--fork-session` / `codex fork`) via the child's one-shot + * `pendingForkSession` marker: the child's first spawn resumes the SOURCE's + * CLI-native transcript but writes forward into a fresh CLI-minted id. botmux + * never copies transcript bytes itself. + * + * Shares transferSession's front guards (mid-turn / adopt / pendingRepo / + * vc-receiver / target-anchor occupancy) but performs NONE of its destructive + * steps (no source card freeze, no worker detach, no source registry delete, no + * source routing rewrite). + */ +export async function forkSession( + sessionId: string, + targetChatId: string, + targetRootMessageId: string, + targetChatType: 'group' | 'p2p', + targetScope: 'thread' | 'chat', + opts?: { forkWorkerImpl?: typeof forkWorker }, +): Promise<{ ok: true; childSessionId: string } | { ok: false; error: string }> { + if ((targetChatType as string) !== 'group' && (targetChatType as string) !== 'p2p') { + return { ok: false, error: 'target_chat_type_unsupported' }; + } + const ds = findActiveBySessionId(sessionId); + if (!ds) return { ok: false, error: 'session_not_active' }; + + // ── Capability gate: only byte-level-forkable backends (§ design doc §4) ── + if (!isForkCapableSession(ds)) return { ok: false, error: 'fork_unsupported_backend' }; + + // ── Front guards (mirror transferSession; a fork needs a clean, complete + // source node exactly as a relay does) ── + if (ds.session.vcMeetingReceiver) return { ok: false, error: 'vc_receiver_not_forkable' }; + if (ds.pendingRepo) return { ok: false, error: 'not_started_yet' }; + if (!isRelayableRealSession(ds)) return { ok: false, error: 'not_started_yet' }; + if (ds.session.adoptedFrom) return { ok: false, error: 'adopt_not_forkable' }; + if (isSessionLifecycleInFlight(ds)) return { ok: false, error: 'worker_busy' }; + const st = ds.lastScreenStatus; + if (ds.worker && !ds.worker.killed && st !== 'idle' && st !== 'limited') { + return { ok: false, error: 'worker_busy' }; + } + if (currentDeviceIsolationFreezeLease()) return { ok: false, error: 'worker_busy' }; + + // The source's CLI-native id is what we fork from. Without it there is no + // transcript node to inherit (should be present for any real session). + const srcCliSessionId = ds.session.cliSessionId; + if (!srcCliSessionId) return { ok: false, error: 'not_started_yet' }; + + // ── Target anchor occupancy (per-bot; sessionKey carries larkAppId) ── + const sourceAnchor = sessionAnchorId(ds); + const targetAnchor = targetScope === 'chat' ? targetChatId : targetRootMessageId; + if (targetAnchor === sourceAnchor) return { ok: false, error: 'same_anchor' }; + const targetKey = sessionKey(targetAnchor, ds.larkAppId); + if (activeSessionsRegistry) { + const scratchesToClose: string[] = []; + for (const existing of activeSessionsRegistry.values()) { + if (existing === ds) continue; + if (existing.larkAppId !== ds.larkAppId) continue; + if (sessionAnchorId(existing) !== targetAnchor) continue; + if (isDisposableCommandScratch(existing)) { + scratchesToClose.push(existing.session.sessionId); + continue; + } + return { ok: false, error: 'target_chat_has_session' }; + } + for (const sid of scratchesToClose) await closeSession(sid); + const occupant = activeSessionsRegistry.get(targetKey); + if (occupant && occupant !== ds) return { ok: false, error: 'target_chat_has_session' }; + } + + // ── Mint the child session row (new botmux sessionId) ── + const parentTitle = ds.session.title || ''; + const childTitle = parentTitle ? `🔱 ${parentTitle}` : '🔱 分身'; + const childSession = sessionStore.createSession( + targetChatId, + targetRootMessageId, + childTitle, + targetChatType, + targetScope, + ); + // Provenance + fork wiring. cliSessionId points at the SOURCE's CLI id: the + // child's first spawn resumes it and forks forward (pendingForkSession), then + // the worker persists the child's own new id and clears the marker. + childSession.forkedFrom = ds.session.sessionId; + childSession.pendingForkSession = true; + childSession.cliSessionId = srcCliSessionId; + childSession.cliId = ds.session.cliId; + childSession.workingDir = ds.session.workingDir; + childSession.ownerOpenId = ds.session.ownerOpenId; + childSession.backendType = ds.session.backendType; + childSession.nativeSessionTitle = childTitle; + childSession.nativeSessionTitleUserDefined = true; + sessionStore.updateSession(childSession); + + // ── Build the child runtime DaemonSession (mirrors the restore-path literal; + // worker:null → forkWorker cold-spawns a fresh worker for it) ── + const childDs: DaemonSession = { + session: childSession, + worker: null, + workerPort: null, + workerToken: null, + larkAppId: ds.larkAppId, + chatId: targetChatId, + chatType: targetChatType, + scope: targetScope, + spawnedAt: ds.spawnedAt, + cliVersion: getCurrentCliVersion(), + lastMessageAt: Date.now(), + hasHistory: true, // forked child resumes (forks) prior history on first spawn + workingDir: ds.session.workingDir, + ownerOpenId: ds.session.ownerOpenId, + // Fresh card in the target anchor — never inherit the source's card id. + streamCardId: undefined, + streamCardNonce: undefined, + displayMode: ds.displayMode ?? 'hidden', + suppressRecoveryCard: false, + }; + + if (activeSessionsRegistry) { + if (!(await setActiveSessionSafe(activeSessionsRegistry, targetKey, childDs))) { + // Target slot was taken between the guard and here — roll back the child + // row so it doesn't linger as a ghost-active session. + await closeSession(childSession.sessionId).catch(() => { /* best effort */ }); + return { ok: false, error: 'target_chat_has_session' }; + } + } + + dashboardEventBus.publish({ + type: 'session.update', + body: { + sessionId: childSession.sessionId, + patch: { + chatId: targetChatId, + rootMessageId: targetRootMessageId, + scope: targetScope, + chatType: targetChatType, + }, + }, + }); + + // Cold-spawn the child worker with resume=true → the adapter sees + // pendingForkSession and passes the native fork flag (--fork-session / + // codex fork). The SOURCE ds is never touched. + const fkw = opts?.forkWorkerImpl ?? forkWorker; + try { + fkw(childDs, '', /*resume*/true); + } catch (err) { + logger.error( + `[${childSession.sessionId.substring(0, 8)}] fork child worker spawn failed: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + await closeSession(childSession.sessionId).catch(() => { /* best effort */ }); + return { ok: false, error: 'fork_spawn_failed' }; + } + + logger.info( + `[${sessionId.substring(0, 8)}] forked → child ${childSession.sessionId.substring(0, 8)} ` + + `at anchor ${targetAnchor.substring(0, 8)} (source untouched)`, + ); + return { ok: true, childSessionId: childSession.sessionId }; +} + // ─── Fork worker ──────────────────────────────────────────────────────────── /** True if `p` resolves (via realpath) to the user's home dir. Used to exclude @@ -3804,6 +3998,12 @@ export function forkWorker( prompt, ...(promptCodexAppInput ? { promptCodexAppInput } : {}), resume, + // One-shot native fork intent (see Session.pendingForkSession). Only the + // child's FIRST spawn resumes the SOURCE transcript (cliSessionId still + // points at the parent's CLI id here) while forking forward into a new id; + // the worker clears the marker + persists the child's own new id, so a + // later refork resumes the child normally (pendingForkSession=false). + forkSession: ds.session.pendingForkSession === true, cliSessionId: ds.session.cliSessionId, ownerOpenId: ds.ownerOpenId, webPort: ds.session.webPort, @@ -4598,6 +4798,14 @@ function setupWorkerHandlers( case 'cli_session_id': { const wasLocalCliOpenReady = isLocalCliOpenReady(ds, { cliId: effectiveCliId }); ds.session.cliSessionId = msg.cliSessionId; + // One-shot native fork completed: the child now has its OWN CLI-native + // id (Claude/Codex minted it during --fork-session / codex fork). Clear + // the pending-fork marker so any later refork resumes THIS transcript + // instead of re-forking the parent's again. + if (ds.session.pendingForkSession) { + ds.session.pendingForkSession = undefined; + if (ds.initConfig) ds.initConfig.forkSession = false; + } if (ds.adoptedFrom) ds.adoptedFrom.sessionId = msg.cliSessionId; if (ds.session.adoptedFrom) ds.session.adoptedFrom.sessionId = msg.cliSessionId; sessionStore.updateSession(ds.session); diff --git a/src/types.ts b/src/types.ts index f9f3f5432..c7923be89 100644 --- a/src/types.ts +++ b/src/types.ts @@ -354,6 +354,16 @@ export interface Session { whiteboardId?: string; /** CLI-native resume id when it differs from botmux's sessionId (for example Codex thread id). */ cliSessionId?: string; + /** Provenance: the botmux sessionId this session was forked from (`/fork`). + * Purely informational — surfaced in UI/pickers so a fork is distinguishable + * from its parent. Does not affect routing or lifecycle. */ + forkedFrom?: string; + /** One-shot fork intent for the child's FIRST spawn: resume `cliSessionId` + * (the source's CLI-native transcript) but write forward into a new CLI-minted + * id via the native fork primitive (Claude `--fork-session` / `codex fork`). + * The worker clears it after the fork spawns and persists the child's own new + * `cliSessionId`, so later re-forks resume the child's transcript normally. */ + pendingForkSession?: boolean; /** * Set true when the idle-worker sweeper suspends this session over the per-bot * live cap: the worker AND the backing tmux/herdr/zellij/zmx session (+ CLI) were @@ -622,7 +632,7 @@ export interface CliTurnPayload { /** Messages sent from Daemon to Worker */ export type DaemonToWorker = - | { type: 'init'; sessionId: string; chatId: string; chatType?: 'group' | 'p2p'; rootMessageId: string; workingDir: string; cliId: string; cliPathOverride?: string; wrapperCli?: string; launchShell?: string; model?: string; reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'; disableCliBypass?: boolean; codexRpcInput?: boolean; startupCommands?: string[]; env?: Record; sandbox?: boolean; sandboxPaths?: { readWrite?: string[]; readOnly?: string[]; deny?: string[] }; sandboxHidePaths?: string[]; sandboxReadonlyPaths?: string[]; sandboxNetwork?: boolean; readIsolation?: boolean; readDenyExtraPaths?: string[]; daemonBootId?: string; backendType: BackendType; persistentBackendTarget?: PersistentBackendTarget; backendConfig?: RiffBackendConfig; riffParentTaskId?: string; riffRepoDirs?: string[]; deferredScheduleRun?: Session['deferredScheduleRun']; nativeSessionTitle?: string; nativeSessionTitlePrompt?: string; prompt: string; promptCodexAppInput?: CodexAppTurnInput; resume?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; apiOnly?: boolean; loadedBotsConfigPath?: string; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; dispatchAttempt?: number; vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin; pluginBindings?: string[]; skillPolicy?: BotSkillPolicy; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean; runnerBuildId?: string; persistedRunnerBuildId?: string; restartAttemptId?: string } + | { type: 'init'; sessionId: string; chatId: string; chatType?: 'group' | 'p2p'; rootMessageId: string; workingDir: string; cliId: string; cliPathOverride?: string; wrapperCli?: string; launchShell?: string; model?: string; reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'; disableCliBypass?: boolean; codexRpcInput?: boolean; startupCommands?: string[]; env?: Record; sandbox?: boolean; sandboxPaths?: { readWrite?: string[]; readOnly?: string[]; deny?: string[] }; sandboxHidePaths?: string[]; sandboxReadonlyPaths?: string[]; sandboxNetwork?: boolean; readIsolation?: boolean; readDenyExtraPaths?: string[]; daemonBootId?: string; backendType: BackendType; persistentBackendTarget?: PersistentBackendTarget; backendConfig?: RiffBackendConfig; riffParentTaskId?: string; riffRepoDirs?: string[]; deferredScheduleRun?: Session['deferredScheduleRun']; nativeSessionTitle?: string; nativeSessionTitlePrompt?: string; prompt: string; promptCodexAppInput?: CodexAppTurnInput; resume?: boolean; forkSession?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; apiOnly?: boolean; loadedBotsConfigPath?: string; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; dispatchAttempt?: number; vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin; pluginBindings?: string[]; skillPolicy?: BotSkillPolicy; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean; runnerBuildId?: string; persistedRunnerBuildId?: string; restartAttemptId?: string } | { type: 'message'; content: string; codexAppInput?: CodexAppTurnInput; nativeSessionTitle?: string; nativeSessionTitlePrompt?: string; turnId?: string; dispatchAttempt?: number; vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin } /** Literal slash-command passthrough. `followUpContent` rides along so the * worker enqueues it strictly AFTER the slash command's Enter — two separate diff --git a/src/worker.ts b/src/worker.ts index d83ca4b36..96cc2b580 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -8377,6 +8377,12 @@ async function spawnCli( resume: effectiveResume, workingDir: buildArgsWorkingDir, resumeSessionId: effectiveCliSessionId, + // Native session fork (Claude --fork-session / codex fork): resume the + // source transcript but branch into a fresh CLI-minted id. Only on the + // child's first spawn (cfg.forkSession) AND only when we actually resume — + // if the resume target was dropped (fallBackToFresh), there is nothing to + // fork from, so a fresh session is spawned instead. + forkSession: cfg.forkSession === true && effectiveResume, initialPrompt: preparedInitialPrompt, botName: cfg.botName, botOpenId: cfg.botOpenId, From 836b7ef7aaf1353c3a37babbf44622cfed930e4b Mon Sep 17 00:00:00 2001 From: Xiaoxue Sun <54162759+xiaoxueSunn@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:20:11 +0800 Subject: [PATCH 2/8] feat(fork): wire /fork --create command + i18n Single-bot fork-to-new-group entry (fork is single-session, so unlike /relay --create there is no multi-bot leader election / peer coordination): - /fork case in command-handler: owner check, capability gate (isForkCapableSession) with typed refusal, resolve the single @-bot (must be this bot, since fork copies this session's transcript), createGroupWithBots, then forkSession into the new chat. Source session left untouched. - cmd.fork.* i18n (zh + en): no_bot / no_sender / no_session / not_owner / no_mentions / resolve_failed / unsupported_backend / mid_turn / not_started_yet / adopt_not_forkable / created / failed. - No-arg picker path guides to --create for now (picker is a follow-up). Co-Authored-By: Claude --- src/core/command-handler.ts | 158 ++++++++++++++++++++++++++++++++++++ src/i18n/en.ts | 14 ++++ src/i18n/zh.ts | 14 ++++ 3 files changed, 186 insertions(+) diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index e6d65f89a..79aaaa502 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -3319,6 +3319,164 @@ 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 — deferred to the follow-up. For now guide the + // user to the working entry point so the command never silently + // no-ops. (Non-topic-group in-place fork = Branch, PR2.) + await sessionReply(rootId, t('cmd.fork.no_session', 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). + 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; + } + + // Resolve the single target bot to invite. p2p source: no roster / no + // mention gate — the source bot is the sole participant. Group source: + // require exactly the @-mentioned bot (fork is single-session, so unlike + // relay --create there is no multi-bot leader election / peer coord). + const forkSourceIsP2p = ds.chatType === 'p2p'; + let 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())); + if (forkBotMentions.length === 0) { + await sessionReply(rootId, t('cmd.fork.no_mentions', undefined, loc)); + break; + } + // Resolve the FIRST @-bot to a larkAppId via the source chat roster. + const firstBot = forkBotMentions[0]; + let members: Awaited> = []; + try { + members = await listChatBotMembers(forkAppId, ds.chatId); + } catch (e: any) { + logger.warn(`[${logTag}] /fork --create: failed to list source chat members: ${e?.message ?? e}`); + } + const mem = firstBot.openId ? members.find(m => m.openId === firstBot.openId) : undefined; + if (!mem || !mem.larkAppId) { + await sessionReply(rootId, t('cmd.fork.resolve_failed', undefined, loc)); + break; + } + targetBotAppId = mem.larkAppId; + targetBotName = mem.displayName || botDisplayName(mem.larkAppId); + // Fork copies THIS session's transcript — the forked child must run + // the SAME cli as the source, so the invited bot must be this bot. + if (targetBotAppId !== forkAppId) { + await sessionReply(rootId, t('cmd.fork.resolve_failed', 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) { + 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)); + } + logger.warn(`[${logTag}] /fork --create: forkSession failed (${forkResult.error}); new chat ${forkChatId} left empty`); + 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 diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 4c9cd7942..c586d3a94 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -498,6 +498,20 @@ export const messages: Record = { 'cmd.relay.report_peer_failed': '• {bot} — relay failed: {error}', 'cmd.relay.failed': '⚠️ /relay --create failed: {error}', + // ─── /fork (session clone) ───────────────────────────────────────────────── + 'cmd.fork.no_bot': '⚠️ Could not determine the bot identity, /fork cancelled.', + 'cmd.fork.no_sender': '⚠️ Could not resolve sender open_id, /fork cancelled.', + 'cmd.fork.no_session': '⚠️ /fork must be invoked inside a thread with an existing session.', + 'cmd.fork.not_owner': '⚠️ Only the session owner can fork it.', + 'cmd.fork.no_mentions': '⚠️ /fork --create must @ a bot as the member of the new group the clone lands in.', + 'cmd.fork.resolve_failed': '⚠️ Could not resolve the @-mentioned bot, /fork cancelled. Please retry.', + 'cmd.fork.unsupported_backend': 'ℹ️ The current {cli} session does not support fork yet (only Claude family / Codex terminal mode; Codex App, RPC-enabled Codex, and pure-remote backends run an app-server live session with no byte-level copy).', + 'cmd.fork.mid_turn': '⚠️ The session is mid-turn and cannot be forked. Wait until it is idle, then /fork.', + 'cmd.fork.not_started_yet': '⚠️ The session has not really started (no repo / CLI not up / no context) and cannot be forked.', + 'cmd.fork.adopt_not_forkable': '⚠️ This session was adopted from an external CLI and cannot be forked.', + 'cmd.fork.created': '✅ Created group "{name}" and forked a clone (the source session is left running untouched)\n👉 {link}\n\nThe clone carries the full context at fork time — go to the new group and @ the bot to continue.', + 'cmd.fork.failed': '⚠️ /fork --create failed: {error}', + // ─── /schedule ─────────────────────────────────────────────────────────── 'schedule.empty_with_examples': 'No scheduled tasks yet.\n\nExamples:\n/schedule daily 17:50 summarize today\'s AI news\n/schedule weekdays 9:00 check service status\n/schedule mondays 10:00 generate weekly report', 'schedule.list_header': 'Scheduled tasks ({count}):', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 26e5068eb..69d96dd4f 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -501,6 +501,20 @@ export const messages: Record = { 'cmd.relay.report_peer_failed': '• {bot} — 接力失败:{error}', 'cmd.relay.failed': '⚠️ /relay --create 失败:{error}', + // ─── /fork(会话分身)───────────────────────────────────────────────────── + 'cmd.fork.no_bot': '⚠️ 无法确定机器人身份,/fork 取消。', + 'cmd.fork.no_sender': '⚠️ 无法获取发起人 open_id,/fork 取消。', + 'cmd.fork.no_session': '⚠️ /fork 必须在一个已有会话的话题里发起。', + 'cmd.fork.not_owner': '⚠️ 只有会话发起人能 fork 它。', + 'cmd.fork.no_mentions': '⚠️ /fork --create 必须 @ 一个机器人作为分身落地的新群成员。', + 'cmd.fork.resolve_failed': '⚠️ 无法解析被 @ 的机器人,/fork 取消。请稍后重试。', + 'cmd.fork.unsupported_backend': 'ℹ️ 当前 {cli} 会话暂不支持 fork(目前仅 Claude 系 / Codex 终端模式;Codex App、开启 RPC 的 Codex、纯远端后端走 app-server 活会话,无法字节级复制)。', + 'cmd.fork.mid_turn': '⚠️ 会话正在处理中(mid-turn),无法 fork。请等它空闲(idle)后再发 /fork。', + 'cmd.fork.not_started_yet': '⚠️ 会话还没真正跑起来(没选仓库 / CLI 没起 / 无上下文),无法 fork。', + 'cmd.fork.adopt_not_forkable': '⚠️ 该会话是 /adopt 接入的外部会话,无法 fork。', + 'cmd.fork.created': '✅ 已创建群「{name}」并 fork 出分身(源会话原样保留继续)\n👉 {link}\n\n分身带着 fork 时刻的完整上下文,去新群 @ 机器人即可继续。', + 'cmd.fork.failed': '⚠️ /fork --create 失败:{error}', + // ─── /schedule ─────────────────────────────────────────────────────────── 'schedule.empty_with_examples': '暂无定时任务。\n\n用法示例:\n/schedule 每日17:50 帮我看看今天AI圈有什么新闻\n/schedule 工作日每天9:00 检查服务状态\n/schedule 每周一10:00 生成周报', 'schedule.list_header': '定时任务列表 ({count}):', From 484dd219306e61d93c4c86feecd88484a9c6f575 Mon Sep 17 00:00:00 2001 From: Xiaoxue Sun <54162759+xiaoxueSunn@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:14:40 +0800 Subject: [PATCH 3/8] fix(fork): guard real source session before creating the group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-env test (seed0630) surfaced two issues: - Orphan group bug: /fork --create ran createGroupWithBots before checking the source was a real, resumable session, so a refusal (e.g. /fork typed at the group top-level while the session lives in a 话题) left an empty orphan group. Move the real-session + idle guards ahead of group creation. - Clearer refusal: new cmd.fork.no_source_here tells the user to invoke /fork inside the thread the session lives in, not the group top level. Co-Authored-By: Claude --- src/core/command-handler.ts | 18 ++++++++++++++++++ src/i18n/en.ts | 1 + src/i18n/zh.ts | 1 + 3 files changed, 20 insertions(+) diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index 79aaaa502..ac22faede 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -3366,6 +3366,9 @@ export async function handleCommand( 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; @@ -3374,6 +3377,21 @@ export async function handleCommand( 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 single target bot to invite. p2p source: no roster / no // mention gate — the source bot is the sole participant. Group source: diff --git a/src/i18n/en.ts b/src/i18n/en.ts index c586d3a94..929a42beb 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -502,6 +502,7 @@ export const messages: Record = { 'cmd.fork.no_bot': '⚠️ Could not determine the bot identity, /fork cancelled.', 'cmd.fork.no_sender': '⚠️ Could not resolve sender open_id, /fork cancelled.', 'cmd.fork.no_session': '⚠️ /fork must be invoked inside a thread with an existing session.', + 'cmd.fork.no_source_here': '⚠️ No active session to fork here. Invoke /fork **inside the thread the session lives in** (the topic where you normally @ the bot), not at the group top level.', 'cmd.fork.not_owner': '⚠️ Only the session owner can fork it.', 'cmd.fork.no_mentions': '⚠️ /fork --create must @ a bot as the member of the new group the clone lands in.', 'cmd.fork.resolve_failed': '⚠️ Could not resolve the @-mentioned bot, /fork cancelled. Please retry.', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 69d96dd4f..f8de821a6 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -505,6 +505,7 @@ export const messages: Record = { 'cmd.fork.no_bot': '⚠️ 无法确定机器人身份,/fork 取消。', 'cmd.fork.no_sender': '⚠️ 无法获取发起人 open_id,/fork 取消。', 'cmd.fork.no_session': '⚠️ /fork 必须在一个已有会话的话题里发起。', + 'cmd.fork.no_source_here': '⚠️ 这里没有可 fork 的活跃会话。/fork 要在**会话所在的那个话题里**发起(就是你平时 @ 机器人聊天的那条话题),不要在群顶层发。', 'cmd.fork.not_owner': '⚠️ 只有会话发起人能 fork 它。', 'cmd.fork.no_mentions': '⚠️ /fork --create 必须 @ 一个机器人作为分身落地的新群成员。', 'cmd.fork.resolve_failed': '⚠️ 无法解析被 @ 的机器人,/fork 取消。请稍后重试。', From e99801ad0d5d30edde61832bfa4ea42f7b2e81e8 Mon Sep 17 00:00:00 2001 From: Xiaoxue Sun <54162759+xiaoxueSunn@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:02:11 +0800 Subject: [PATCH 4/8] fix(fork): clean up orphan group on post-create failure + clearer bare-/fork message - Orphan-group cleanup: if forkSession fails after createGroupWithBots (narrow TOCTOU race), best-effort disbandChat the just-created empty group so no failure path leaves an orphan chat; if disband fails (ownership transferred to the user), tell them explicitly via cmd.fork.orphan_group_left. - Clearer bare /fork: the no-arg picker path is not built yet, but the old copy reused cmd.fork.no_session ("must be invoked in a thread with a session"), which is misleading when the user DOES have a session. New cmd.fork.picker_pending says the picker is pending and points at /fork --create. Co-Authored-By: Claude --- src/core/command-handler.ts | 29 ++++++++++++++++++++++++----- src/i18n/en.ts | 2 ++ src/i18n/zh.ts | 2 ++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index ac22faede..cc4338b18 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -3355,10 +3355,11 @@ export async function handleCommand( } if (!/^--create\b/i.test(argsLine)) { - // No-arg / picker path — deferred to the follow-up. For now guide the - // user to the working entry point so the command never silently - // no-ops. (Non-topic-group in-place fork = Branch, PR2.) - await sessionReply(rootId, t('cmd.fork.no_session', undefined, loc)); + // 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; } @@ -3473,6 +3474,21 @@ export async function handleCommand( 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' @@ -3486,7 +3502,10 @@ export async function handleCommand( } else { await sessionReply(rootId, t('cmd.fork.failed', { error: forkResult.error }, loc)); } - logger.warn(`[${logTag}] /fork --create: forkSession failed (${forkResult.error}); new chat ${forkChatId} left empty`); + 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; } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 929a42beb..89562c99d 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -512,6 +512,8 @@ export const messages: Record = { 'cmd.fork.adopt_not_forkable': '⚠️ This session was adopted from an external CLI and cannot be forked.', 'cmd.fork.created': '✅ Created group "{name}" and forked a clone (the source session is left running untouched)\n👉 {link}\n\nThe clone carries the full context at fork time — go to the new group and @ the bot to continue.', 'cmd.fork.failed': '⚠️ /fork --create failed: {error}', + 'cmd.fork.picker_pending': 'ℹ️ Bare /fork (pick a session from a picker and fork it into this group) is not built yet. For now use `/fork --create @bot` — it creates a new group and forks a clone of the current session into it, leaving the source untouched.', + 'cmd.fork.orphan_group_left': '⚠️ Fork did not succeed, but the new group "{name}" was already created (auto-disband failed, likely because ownership was transferred to you). You can delete this empty group manually.', // ─── /schedule ─────────────────────────────────────────────────────────── 'schedule.empty_with_examples': 'No scheduled tasks yet.\n\nExamples:\n/schedule daily 17:50 summarize today\'s AI news\n/schedule weekdays 9:00 check service status\n/schedule mondays 10:00 generate weekly report', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index f8de821a6..ae19f4abd 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -515,6 +515,8 @@ export const messages: Record = { 'cmd.fork.adopt_not_forkable': '⚠️ 该会话是 /adopt 接入的外部会话,无法 fork。', 'cmd.fork.created': '✅ 已创建群「{name}」并 fork 出分身(源会话原样保留继续)\n👉 {link}\n\n分身带着 fork 时刻的完整上下文,去新群 @ 机器人即可继续。', 'cmd.fork.failed': '⚠️ /fork --create 失败:{error}', + 'cmd.fork.picker_pending': 'ℹ️ 不带参数的 /fork(在选择器里挑一个会话 fork 进本群)还没做。当前请用 `/fork --create <新群名> @机器人` —— 会新建一个群、把当前会话 fork 一份分身放进去,源会话保持不动。', + 'cmd.fork.orphan_group_left': '⚠️ fork 没成功,但新群「{name}」已经建出来了(我尝试自动解散失败,可能因为群主已转给你)。这个空群可以手动删掉。', // ─── /schedule ─────────────────────────────────────────────────────────── 'schedule.empty_with_examples': '暂无定时任务。\n\n用法示例:\n/schedule 每日17:50 帮我看看今天AI圈有什么新闻\n/schedule 工作日每天9:00 检查服务状态\n/schedule 每周一10:00 生成周报', From 90d08bd379baef4187fb43b24f36fa6e410bee2b Mon Sep 17 00:00:00 2001 From: Xiaoxue Sun <54162759+xiaoxueSunn@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:10:23 +0800 Subject: [PATCH 5/8] fix(fork): make bare-/fork message product-facing, not internal status Word it as a product message (in-place fork not supported here, use --create, in-group Branch coming later) instead of exposing "not built yet". Co-Authored-By: Claude --- src/i18n/en.ts | 2 +- src/i18n/zh.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 89562c99d..e84710360 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -512,7 +512,7 @@ export const messages: Record = { 'cmd.fork.adopt_not_forkable': '⚠️ This session was adopted from an external CLI and cannot be forked.', 'cmd.fork.created': '✅ Created group "{name}" and forked a clone (the source session is left running untouched)\n👉 {link}\n\nThe clone carries the full context at fork time — go to the new group and @ the bot to continue.', 'cmd.fork.failed': '⚠️ /fork --create failed: {error}', - 'cmd.fork.picker_pending': 'ℹ️ Bare /fork (pick a session from a picker and fork it into this group) is not built yet. For now use `/fork --create @bot` — it creates a new group and forks a clone of the current session into it, leaving the source untouched.', + 'cmd.fork.picker_pending': 'ℹ️ Forking in place isn\'t supported in this group. Use `/fork --create @bot` — it creates a new group and forks a clone of the current session into it, leaving the source untouched. (Creating a Branch in place, in this group, is coming later.)', 'cmd.fork.orphan_group_left': '⚠️ Fork did not succeed, but the new group "{name}" was already created (auto-disband failed, likely because ownership was transferred to you). You can delete this empty group manually.', // ─── /schedule ─────────────────────────────────────────────────────────── diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index ae19f4abd..a9df4fe63 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -515,7 +515,7 @@ export const messages: Record = { 'cmd.fork.adopt_not_forkable': '⚠️ 该会话是 /adopt 接入的外部会话,无法 fork。', 'cmd.fork.created': '✅ 已创建群「{name}」并 fork 出分身(源会话原样保留继续)\n👉 {link}\n\n分身带着 fork 时刻的完整上下文,去新群 @ 机器人即可继续。', 'cmd.fork.failed': '⚠️ /fork --create 失败:{error}', - 'cmd.fork.picker_pending': 'ℹ️ 不带参数的 /fork(在选择器里挑一个会话 fork 进本群)还没做。当前请用 `/fork --create <新群名> @机器人` —— 会新建一个群、把当前会话 fork 一份分身放进去,源会话保持不动。', + 'cmd.fork.picker_pending': 'ℹ️ 本群暂不支持在原地 fork。请用 `/fork --create <新群名> @机器人` —— 会新建一个群、把当前会话 fork 一份分身放进去,源会话保持不动。(在本群原地创建 Branch 的能力后续提供。)', 'cmd.fork.orphan_group_left': '⚠️ fork 没成功,但新群「{name}」已经建出来了(我尝试自动解散失败,可能因为群主已转给你)。这个空群可以手动删掉。', // ─── /schedule ─────────────────────────────────────────────────────────── From dedcc363631ce072a83ac7d810ef0b30078449e8 Mon Sep 17 00:00:00 2001 From: Xiaoxue Sun <54162759+xiaoxueSunn@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:55:36 +0800 Subject: [PATCH 6/8] fix(fork): default /fork --create to current bot, drop redundant @self MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fork copies THIS session, so the invited bot is always the current bot. No @mention now defaults to the current bot (common "fork myself to a new group" case — no need to @ the bot you are already talking to). An explicit @mention that resolves to a DIFFERENT bot is refused (cmd.fork.wrong_bot). Co-Authored-By: Claude --- src/core/command-handler.ts | 51 ++++++++++++++++--------------------- src/i18n/en.ts | 1 + src/i18n/zh.ts | 1 + 3 files changed, 24 insertions(+), 29 deletions(-) diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index cc4338b18..54a404ccb 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -3394,41 +3394,34 @@ export async function handleCommand( break; } - // Resolve the single target bot to invite. p2p source: no roster / no - // mention gate — the source bot is the sole participant. Group source: - // require exactly the @-mentioned bot (fork is single-session, so unlike - // relay --create there is no multi-bot leader election / peer coord). + // 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'; - let targetBotAppId = forkAppId; + 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())); - if (forkBotMentions.length === 0) { - await sessionReply(rootId, t('cmd.fork.no_mentions', undefined, loc)); - break; - } - // Resolve the FIRST @-bot to a larkAppId via the source chat roster. - const firstBot = forkBotMentions[0]; - let members: Awaited> = []; - try { - members = await listChatBotMembers(forkAppId, ds.chatId); - } catch (e: any) { - logger.warn(`[${logTag}] /fork --create: failed to list source chat members: ${e?.message ?? e}`); - } - const mem = firstBot.openId ? members.find(m => m.openId === firstBot.openId) : undefined; - if (!mem || !mem.larkAppId) { - await sessionReply(rootId, t('cmd.fork.resolve_failed', undefined, loc)); - break; - } - targetBotAppId = mem.larkAppId; - targetBotName = mem.displayName || botDisplayName(mem.larkAppId); - // Fork copies THIS session's transcript — the forked child must run - // the SAME cli as the source, so the invited bot must be this bot. - if (targetBotAppId !== forkAppId) { - await sessionReply(rootId, t('cmd.fork.resolve_failed', undefined, loc)); - break; + // 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; + } } } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index e84710360..7a719e3f8 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -505,6 +505,7 @@ export const messages: Record = { 'cmd.fork.no_source_here': '⚠️ No active session to fork here. Invoke /fork **inside the thread the session lives in** (the topic where you normally @ the bot), not at the group top level.', 'cmd.fork.not_owner': '⚠️ Only the session owner can fork it.', 'cmd.fork.no_mentions': '⚠️ /fork --create must @ a bot as the member of the new group the clone lands in.', + 'cmd.fork.wrong_bot': '⚠️ Fork can only clone the current session to **this same bot** (the clone must run the same CLI). No need to @ another bot; just `/fork --create ` — it defaults to the current bot.', 'cmd.fork.resolve_failed': '⚠️ Could not resolve the @-mentioned bot, /fork cancelled. Please retry.', 'cmd.fork.unsupported_backend': 'ℹ️ The current {cli} session does not support fork yet (only Claude family / Codex terminal mode; Codex App, RPC-enabled Codex, and pure-remote backends run an app-server live session with no byte-level copy).', 'cmd.fork.mid_turn': '⚠️ The session is mid-turn and cannot be forked. Wait until it is idle, then /fork.', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index a9df4fe63..1c7a727e4 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -508,6 +508,7 @@ export const messages: Record = { 'cmd.fork.no_source_here': '⚠️ 这里没有可 fork 的活跃会话。/fork 要在**会话所在的那个话题里**发起(就是你平时 @ 机器人聊天的那条话题),不要在群顶层发。', 'cmd.fork.not_owner': '⚠️ 只有会话发起人能 fork 它。', 'cmd.fork.no_mentions': '⚠️ /fork --create 必须 @ 一个机器人作为分身落地的新群成员。', + 'cmd.fork.wrong_bot': '⚠️ fork 只能把当前会话复制给**当前这个机器人**(分身要跑同一个 CLI)。不用 @ 别的机器人;直接 `/fork --create <新群名>` 即可,会默认用当前机器人。', 'cmd.fork.resolve_failed': '⚠️ 无法解析被 @ 的机器人,/fork 取消。请稍后重试。', 'cmd.fork.unsupported_backend': 'ℹ️ 当前 {cli} 会话暂不支持 fork(目前仅 Claude 系 / Codex 终端模式;Codex App、开启 RPC 的 Codex、纯远端后端走 app-server 活会话,无法字节级复制)。', 'cmd.fork.mid_turn': '⚠️ 会话正在处理中(mid-turn),无法 fork。请等它空闲(idle)后再发 /fork。', From 439baa8b4ebb24ec758abb10e5b60515aa6e9ccf Mon Sep 17 00:00:00 2001 From: Xiaoxue Sun <54162759+xiaoxueSunn@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:06:29 +0800 Subject: [PATCH 7/8] fix(fork): clear pendingForkSession in worker persist path to avoid race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon-side cli_session_id handler cleared the one-shot marker, but the worker process writes the sessions file directly in persistCliSessionId and, having reloaded the row from disk with the marker still set, its write raced and clobbered the clear — leaving pendingForkSession=true on the child, so a later refork would re-fork the parent instead of resuming the child. Clear it in the same worker-side write that sets cliSessionId. Co-Authored-By: Claude --- src/worker.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/worker.ts b/src/worker.ts index 96cc2b580..e4da383f5 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -6139,10 +6139,21 @@ function persistCliSessionId(cliSessionId: string): void { }); try { const session = sessionStore.getSession(sessionId); - if (!session || session.cliSessionId === cliSessionId) return; + if (!session) return; + // One-shot native fork completed: the child now has its own CLI-native id + // (Claude/Codex minted it during --fork-session / codex fork). Clear the + // pending-fork marker so a later refork resumes THIS transcript instead of + // re-forking the parent's again. Done HERE (worker process, same write that + // sets cliSessionId) rather than only in the daemon's cli_session_id + // handler — the worker writes the sessions file directly, and if it reloaded + // the row from disk (pendingForkSession still true) its write would race and + // clobber the daemon-side clear. + const forkMarkerNeedsClear = session.pendingForkSession === true; + if (session.cliSessionId === cliSessionId && !forkMarkerNeedsClear) return; session.cliSessionId = cliSessionId; + if (forkMarkerNeedsClear) session.pendingForkSession = undefined; sessionStore.updateSession(session); - log(`Persisted CLI session id: ${cliSessionId}`); + log(`Persisted CLI session id: ${cliSessionId}${forkMarkerNeedsClear ? ' (cleared pending-fork marker)' : ''}`); } catch (err: any) { log(`Failed to persist CLI session id: ${err.message}`); } From e935b74d0d90fbf5b80b1e86b516f287e5dff7f5 Mon Sep 17 00:00:00 2001 From: Xiaoxue Sun <54162759+xiaoxueSunn@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:21:27 +0800 Subject: [PATCH 8/8] docs(fork): add real-env E2E test records (Codex + Claude) appendix Co-Authored-By: Claude --- ...026-08-02-session-fork-bot-clone-design.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/design/2026-08-02-session-fork-bot-clone-design.md b/docs/design/2026-08-02-session-fork-bot-clone-design.md index abbcd3c95..af9239576 100644 --- a/docs/design/2026-08-02-session-fork-bot-clone-design.md +++ b/docs/design/2026-08-02-session-fork-bot-clone-design.md @@ -283,3 +283,44 @@ CoCo 目录拷贝;Riff 原生 parentTaskId 分支;`/fork` 同话题快速分 - Claude 交互式 TUI `/fork` 在带限制 flag 时**拒绝**(提权保护);但**冷启动**`claude --resume --fork-session `**不拒绝**,正常 fork、继承上下文(暗号复制成功)、父文件 8→8 行不变、新文件内部逐行 id 全改写为新 id。→ Claude 路径用原生 `--fork-session` 成立,物理拷贝降为兜底。 - `codex fork` 用户实测:新 rollout id、`forked_from_id` 血缘、父不动、前缀一致后分岔。→ Codex 路径成立。 - **唯一待真实环境验证**:父子双活并发写(两个 worker 同时各写各文件不 clobber)——单机 CLI 复现不了,PR1 验收必测。 + +## 附录 C:真实 botmux 端到端测试记录(2026-08-02) + +测试方式:把单个 bot 的 daemon 用 `stop-bot`/`start-bot` 隔离切到本分支 build,不影响其它 bot;发真实飞书消息,核对 daemon 日志 + on-disk transcript + 会话存储。 + +### Codex 端(seed0630 = `botmux-Seed-2.1-Pro-exp0630`,codex + super-relay-xhigh wrapper) + +| 验证点 | 结果 | +|-|-| +| `/fork --create` 命令进入 handler | ✅ `[3e355eca] Command: /fork` | +| 原生 `codex fork <源id>` 真正调用 | ✅ `codex fork … 019fc216-6b41-…`(wrapper 透传 fork 子命令)| +| 独立新 rollout + `forked_from_id` 血缘 | ✅ `rollout-…-019fc216-f7a4-…` 头含 `forked_from_id=019fc216-6b41-…` | +| 分身在新群答出暗号 | ✅ 答「暗号是:PINEAPPLE-八六三一」 | +| 源会话不动 | ✅ 源 rollout 5 处暗号命中未变、id 不变 | +| child 持久化自己新 id | ✅ `cliSessionId=019fc216-f7a4-…`、`forkedFrom=3e355eca` | +| Dashboard 父子并存 | ✅ 源「记住暗号…」+ 分身「🔱 记住暗号…·fork测试群」两条独立 | +| 同源多次 fork | ✅ 连续 fork 多个 child,各自独立、源始终不动 | + +### Claude 端(Relay-Claude2 = `cli_aae5948f82789ce4`,claude-code + super-relay-opus wrapper) + +| 验证点 | 结果 | +|-|-| +| `/fork --create` 命令进入 handler | ✅ `[06d7815b] Command: /fork` | +| 原生 `--fork-session` 真正调用 | ✅ `claude-super-relay-opus --resume 06d7815b… --fork-session …`(wrapper 透传)| +| 分身继承上下文 | ✅ child transcript `f473c3a7…` 中暗号 MANGO 出现 6 次 | +| 源会话不动 | ✅ 源 `06d7815b` 17 行、暗号 5 处未变 | +| child 拿自己新 id + 血缘 | ✅ `cliSessionId=f473c3a7…`、`forkedFrom=06d7815b`、`🔱` 标题 | + +### 测试中发现并修复的问题 + +| # | 问题 | 修复 | +|-|-|-| +| 1 | 建群后才做真实会话检查 → 拒绝时留空群 | 守卫全部提到建群前 | +| 2 | forkSession 建群后失败仍可能留空群(窄竞态)| 失败时 best-effort disband 空群 | +| 3 | 裸 `/fork` 复用"没会话"话术、误导 | 改为面向产品的"本群暂不支持原地 fork,用 --create"提示 | +| 4 | `/fork --create` 要求 @bot、当前 bot 还要 @ 自己一遍(冗余)| 无 @ 默认当前 bot;@ 别的 bot 才校验并拒 | +| 5 | child fork 后 `pendingForkSession` 未清(worker/daemon 抢写)| 在 worker 侧写 cliSessionId 的同一次写里清标记 | + +### 仍未在真实环境验证 + +- **父子双活并发写**:父会话与分身同时各发消息、各写各的 transcript 不互相 clobber。已顺序验证父子独立;并发压测未做(单机 CLI 复现不了,需两个 live worker 同时活跃)。**建议合并前或紧接着补一次并发验收。**