# 终端 REPL 会话切换(`/resume`)技术方案 - 状态:已评审(2026-11-06,用户确认按建议定案;未实施) - 日期:2026-11-06 - 需求来源:用户提案「能支持 resume 切换会话吗?」(飞书对话 2026-11-06;附图:REPL `/help` 命令面板,14 项命令中无会话切换) - 上游设计: - `docs/plan/2026-08-20-mao-agent-cli-technical-design.md`(CLI 总体设计;§「一进程一会话」是本方案要突破的显式简化,见 §1.4) - `docs/plan/2026-08-20-mao-agent-cli-ux-design.md`(§6.7 冷启动与 resume、welcome 文案) - `docs/plan/2026-06-11-session-management-design.md`(会话状态与列表口径) - 规模:中等(纯 `agent-cli` 客户端改动:REPL 命令 + 会话选择面板 + runner 会话重挂;**零后端改动、零数据库迁移**) - 关联文件: - `agent-cli/src/repl/repl.ts`(斜杠命令分发、启动续接逻辑) - `agent-cli/src/ui/slash-complete.ts`(命令唯一数据源) - `agent-cli/src/commands/chat.ts`(会话生命周期、`attached` / `createdSession` / `ensureSession`、本地执行器) - `agent-cli/src/session/session-runner.ts`(`attach` / `shutdown` / 回合状态) - `agent-cli/src/tui/{ink-renderer,modal-controller,types}.ts`(modal 队列与渲染) - `agent-cli/src/ui/{welcome,relative-time}.ts`(历史摘要、相对时间) - `agent-cli/src/local/{executor,trust}.ts`(本地工作区信任边界) ## 1. 需求背景 ### 1.1 现状与问题 用户在 REPL 中找不到「切换会话」的入口:`/help` 的 14 项斜杠命令(`slash-complete.ts` `SLASH_ITEMS`)不含会话切换,`/session` 只打印当前会话信息,并在末尾提示 `换会话: mao-agent resume | mao-agent ls`。 也就是说,换会话必须退出进程、重新启动: ```bash # 当前唯一路径 Ctrl+D(或 /exit) mao-agent ls # 找 id mao-agent resume # 指定会话;省略 id 取最近更新 ``` 问题: 1. **中断对话流**:正在讨论 A 问题,需要回看/继续 B 会话,必须退出重开;终端 scrollback 保留,但进程内状态(输入草稿、队列、模型选择)全部丢失。 2. **切换成本高**:`ls` 输出无交互选择,用户要手动抄 id;`resume` 只能取「最近更新」或精确 id,无法按关键词找。 3. **与桌面端体验割裂**:桌面端任务列表点一下就切换,CLI 是唯一需要「退出重进」的客户端。 ### 1.2 关键事实(设计建立在其上) | # | 事实 | 依据 | |---|---|---| | F1 | `SessionRunner.attach(session)` 已具备挂载任意会话的完整能力:`resetRound()`、重订阅 WS(`ws.subscribe(id)`)、等待会话快照(`waitForSnapshot(2000)`) | `session-runner.ts:109` | | F2 | `shutdown()` 是进程级收尾:`unsubscribe` + `markRead` + 关本地执行器 + 断 WS。因此「切换会话」不能复用 `shutdown`,需要新增「只换会话、不关进程」的操作 | `session-runner.ts:232` | | F3 | WS 是进程级单连接,会话订阅是逻辑层(`subscribe` / `unsubscribe`);断线重连时按 `subscribed` 集合自动重发订阅 | `ws-client.ts:133/138/222` | | F4 | `attach()` 不重置 `snapshotPhase` / `todos` / `lastContext`。若沿用旧值:`waitForSnapshot` 会因 `snapshotPhase != null` 立即返回(拿不到新快照);`/todo`、`/context` 显示上一会话数据 | `session-runner.ts:118/656/562/508` | | F5 | `resetRound()` 已覆盖本轮执行态(executionId、文本、usage、toolCalls、fileChanges、flags),但不清 `todos` / `lastContext` / `snapshotPhase` / `durationTimer` | `session-runner.ts:267` | | F6 | 消息发送把 REPL 当前 `modelId` 写入 `send_message.data.modelId`。切换后不同步 `opts.modelId` 会把新会话模型改成旧模型的 | `session-runner.ts` `sendMessage` | | F7 | `LocalExecutor` 的 `workspace` 构造时固定为启动工作区;服务端下发的 workspace 必须「等于它或位于其内部」——这是本地信任边界 | `local/executor.ts:122` `resolveWorkspace` | | F8 | 已定稿内容写入 Ink `` 后不可改写;`seedChrome` 有一次性守卫;`announce`(转瞬提示)与 `print`(留痕输出)是两个通道 | `ink-renderer.ts:180/304/309` | | F9 | 键盘事件走自研 `KeyDecoder`;modal 打开时事件先给 `modalQueue` 队头,不与输入框争抢 | `ink-renderer.ts` `dispatchKeys` | | F10 | `GET /sessions?status=ACTIVE&keyword=` 返回完整 `SessionVO`(`title/phase/executionMode/workspace/agentName/modelName/updatedAt/unread/running`),无分页;后端固定排除 `SUBAGENT` / `SIDE_TASK`,`keyword` 只按 `title LIKE` 匹配(**不搜消息内容**),排序 `updated_at DESC, id DESC` | `rest-client.ts:88`、`session.service.ts` `baseSessionListQuery` / `listSessions`(2026-11-06 核对) | | F11 | 实测真实账号活跃会话可达数百条(本次设计核对时为 477 条)→ 面板必须在客户端截断,不能全量铺开 | 生产只读核对(2026-11-06) | | F12 | 0.0.457 已把交互新建会话**延迟到第一条消息**才落库:切换时不存在需要回收的空会话,未落库的草稿直接废弃即可 | `chat.ts` `resolveSessionTarget` / `ensureSession`、`CHANGELOG.md 0.0.457` | | F13 | `cmdLs` 已有「按 `updatedAt` 倒序 + id 兜底」的排序与行格式先例;`formatRelativeTime`、`paletteWindow` 可直接复用 | `ls.ts`、`ui/relative-time.ts`、`ui/slash-complete.ts` | ### 1.3 目标与非目标 **目标** 1. REPL 内一条命令切换会话,不退出进程、不丢当前输入上下文。 2. 有可用的会话选择界面(最近会话列表 + 键盘选择),并支持按 id / 关键词直达。 3. 切换后状态完全对齐新会话:历史摘要、`/session`、`/todo`、`/context`、状态栏 Agent/模型/模式、下一条消息的 `modelId`。 4. 不破坏既有纪律:本地工作区信任边界、单 WS 连接、`` 渲染约束、运行中任务不串流。 **非目标** 1. 不做「多会话并行」:同一时刻仍只有一个活动会话,一个回合仍在原会话跑完。 2. 不做跨模式切换(LOCAL 进程 ↔ CLOUD 会话)——首版明确拒绝,见 D2。 3. 不做会话管理(重命名 / 删除 / 归档):仍走 `mao` CLI 或桌面端。 4. 不改后端接口与 `mao-agent resume` / `ls` 的进程级入口。 ### 1.4 为什么现在可以突破「一进程一会话」 上游设计明确写着: > **一个进程一个会话**(这是首版的显式简化)。切换会话请退出重开,避免多会话事件混流带来的状态机复杂度。 > —— `docs/plan/2026-08-20-mao-agent-cli-technical-design.md:282` 当时的主要顾虑是「多会话事件混流」。现在这条顾虑已经被现有架构消解: 1. **事件隔离已存在**:`handleWs` 里所有事件先过 `acceptEvent(evt, this.sessionId, this.executionId)`,sessionId 不匹配的事件直接丢弃(`session-runner.ts:375`)。切换后 `sessionId` 指向新会话,旧会话事件不会进入渲染链。 2. **订阅是显式的**:`WsClient` 维护 `subscribed` 集合,`unsubscribe(oldId)` 后服务端不再推旧会话事件,重连也不会重新订阅(`ws-client.ts:138/222`)。 3. **挂载能力已存在**:`attach()` 就是「给一个会话 id,把 runner 指向它」,`resume` 走的正是这条路。 4. **渲染纪律已收敛**:modal 队列、`` 定稿、`announce`/`print` 双通道都已定型,切换只需按既有方式产出反馈。 5. **空会话问题已解决**:0.0.457 的延迟创建让「切换时丢弃草稿」是零成本操作(F12)。 因此本方案不引入新的状态机,只是把「进程启动时选会话」扩展为「进程内可重选会话」,并把散落在 `chat.ts` 的会话状态收口成一个可复用的切换函数。 ## 2. 方案概述 在 REPL 内新增 `/resume` 斜杠命令: - **无参数** → 弹出「会话选择面板」(最近 20 条,↑↓ 选择、Enter 切换、Esc 取消)。 - **`/resume <纯数字>`** → 直接切换到该会话 id。 - **`/resume <其他文本>`** → 按关键词搜索(后端 `keyword` 参数): - 命中 0 条 → 提示; - 命中 1 条 → 直接切换; - 命中多条 → 弹面板。 切换成功后,进程继续留在 REPL,当前会话指针、渲染、状态栏、历史摘要全部对齐新会话;未发消息的草稿会话被直接废弃(不落库)。 ### 2.1 用户可见行为 **选择面板** ```text > /resume ╭──────────────────────────────────────────────────────────────╮ │ 切换会话(最近 20 条 · ↑↓ 选择 · Enter 确认 · Esc 取消) │ │ ❯ #2272 修复登录后 baseUrl 保存 梁子 3 分钟前 ● 运行中 │ │ #2270 mao-agent 切换 baseUrl 梁子 1 小时前 │ │ #2268 运营活动数据核对 小马 2 天前 │ │ #2261 周报数据汇总 梁子 3 天前 │ ╰──────────────────────────────────────────────────────────────╯ ``` **切换成功** ```text ── 已切换到会话 #2270 · mao-agent 切换 baseUrl ── ❯ 帮我看看 baseUrl 的默认值在哪 ⏺ 在 agent-cli/src/config/base-url.ts,登录时会…(摘要最近 3 轮) ``` - 状态栏即时变为新会话的 Agent / 模型 / 模式。 - `/session`、`/todo`、`/context` 反映新会话。 - 若目标会话正在运行:提示「该会话仍在执行,续接当前输出…」并进入等待(见 D4)。 ### 2.2 与既有行为的关系 | 入口 | 行为 | |---|---| | `mao-agent resume [id]` / `--resume` / `--continue` | 不变(进程级启动入口) | | `mao-agent ls` | 不变(`/resume` 面板与其排序口径一致) | | 打印模式 `-p` | 不涉及(无 REPL) | | 交互新建(0.0.457 延迟创建) | 兼容:未发消息的草稿在切换时直接废弃,不落库 | | `/session` | 文案从「退出重开」改为「/resume 本进程内切换」 | ## 3. 技术选型(决策表) | # | 决策点 | 定案 | 理由 | |---|---|---|---| | D1 | 命令名 | `/resume` | 与 `mao-agent resume` 同词,认知一致;`/switch` 与 `/model` 语义易混;`/session` 保持「查看」语义不扩展 | | D2 | 跨模式切换(LOCAL 进程 ↔ CLOUD 会话) | **首版禁止**:LOCAL 进程只列 LOCAL 会话、CLOUD 进程只列 CLOUD 会话;跨模式提示「退出后用 `mao-agent resume ` 打开」 | 进程的 `localMode` 决定本地执行器是否创建、banner/状态栏、`localExtras`(agentsMd/localSkills);跨模式要把这些全链路动态化,首版收益低、风险高 | | D3 | LOCAL 会话 workspace 边界 | 只允许切到「启动工作区或其内部」的会话(复用 `isWorkspaceWithin`);其他提示新开进程 | 与 `LocalExecutor.resolveWorkspace` 的信任边界一致(F7);workspace 不同则 executor 与 agentsMd 需重建,成本高 | | D4 | 切到运行中的会话 | **允许**并自动续接输出;但 `--if-running=fail` 时拒绝(尊重用户显式选择),`--if-running=cancel` 不沿用 | 启动路径已有完整先例(`repl.ts` `snapshotIsActive` 分支 → `waitForCurrentRun`);「切进去看进度/等它跑完」是合理用例。切换不应 cancel 别的客户端正在跑的任务 | | D5 | 切换的可见反馈 | **不清屏**:`print` 分隔行 + 历史摘要(最近 3 轮);banner 保持原样 | `` 不可改写(F8),清屏重挂(`clearAll`)会丢失 scrollback;分隔行足以澄清「现在是哪个会话」。清屏增强不做(§8 Q5) | | D6 | 列表数据与截断 | 面板显示按 `updatedAt DESC, id DESC` 重排后的最近 20 条(窗口滚动 8 行);与 `ls` 口径一致 | 客户端显式重排保证与 `mao-agent ls` 完全一致(后端当前已是同口径,重排可防未来后端排序变化);生产会话数百条(F11)必须截断 | | D7 | 面板形态 | 新增 `SessionPickController` + `PanelView kind: 'session-pick'`,复用 `modalQueue` | 与 ask / approval 同一套键盘分发与渲染预算(`buildPanel`),不引入新交互范式 | | D8 | 运行中守卫 | `runner.isRunning()`(本进程 busy)时拒绝切换;正在等待审批 / 提问同样拒绝(busy 覆盖) | 本进程 busy 时重挂会让消息/审批投递错乱 | | D9 | 切换失败回滚 | `switchTo` 保存原会话,新会话 attach 失败时 best-effort 回挂原会话并抛错;彻底失败提示重开进程 | 不留「半挂载」状态;attach 失败多为网络/服务端异常 | | D10 | 目标 = 当前会话 | 不重挂,提示「已经是当前会话」 | 避免无意义的退订/重订阅与快照等待 | | D11 | 历史摘要轮数 | 固定 3 轮(与 `resume` 默认一致);`--replay-full` 不放大切换行为 | 防止误切换刷屏;完整历史可用 `mao-agent resume --replay-full` 重开 | | D12 | 关键词搜索 | 复用 `GET /sessions?keyword=`;不做本地模糊匹配、不做分页加载 | 后端已有能力,避免两套匹配口径 | ## 4. 实现细节 ### 4.1 会话列表数据源与过滤 `chat.ts` 新增(注入给 REPL): ```ts const RESUME_PICK_LIMIT = 20; async function listSwitchableSessions(query?: string): Promise { const all = await rest.listSessions({ status: 'ACTIVE', keyword: query || undefined }); const currentId = runner.getSession()?.id; return all .filter((s) => s.id != null && s.id !== currentId) // D10:不显示当前会话 .filter((s) => (s.executionMode === 'LOCAL') === localMode) // D2:只列同模式 .filter((s) => !isLocalWorkspaceBlocked(s)) // D3:LOCAL 越界不列出 .sort(byUpdatedDesc) // D6:updatedAt DESC, id DESC .slice(0, RESUME_PICK_LIMIT); } ``` - `isLocalWorkspaceBlocked(s)`:`s.executionMode === 'LOCAL'` 且(`workspace` 为空 或 `!isWorkspaceWithin(s.workspace, localWorkspace)`)。 - `isWorkspaceWithin(candidate, localWorkspace)` 的入参顺序见 `local/sandbox.ts`:第一个参数是被检查路径。 - 过滤而不是「列出但拒绝」:避免用户选中后才报错;空结果时给出解释(§5)。 - 排序与 `cmdLs` 一致(`ls.ts`):`Date.parse(updatedAt) || 0` 倒序,id 倒序兜底。 - 关键词搜索直接透传 `keyword`(后端按**标题** `LIKE` 匹配,不搜消息内容),结果同样经过上面的过滤与截断;`/resume <关键词>` 命中数可能因过滤而变少,以过滤后为准。 ### 4.2 `SessionRunner.switchTo()`(新增) ```ts /** * 只换会话、不关进程:退订旧会话并把旧会话标记已读,然后挂载新会话。 * 与 shutdown() 的区别:不断 WS、不关本地执行器、不解除事件监听。 */ async switchTo(session: SessionVO): Promise { if (session.id == null) throw new CliError('会话缺少 id'); if (this.busy) throw new CliError('当前任务仍在执行。请等待结束或 /cancel 再切换会话。'); const prev = this.session; const prevId = this.sessionId; this.disarmMaxDuration(); this.snapshotPhase = null; // 关键:否则 attach 内 waitForSnapshot 立即返回(F4) this.todos = []; this.lastContext = {}; if (prevId && prevId !== session.id) { this.opts.ws.unsubscribe(prevId); try { await this.opts.rest.markRead(prevId); } catch { // 切换不因「标记已读」失败而中断 } } try { await this.attach(session); // 内含 resetRound + ws.on + connect + subscribe + waitForSnapshot } catch (err) { if (prev?.id != null) { try { await this.attach(prev); // D9:best-effort 回挂原会话 } catch { // 回滚也失败:保持未挂载状态,由调用方提示重开进程 } } throw err; } } ``` 要点: - **不调用 `shutdown()`**:它会关本地执行器与 WS(F2),切换只需要解绑会话。 - `snapshotPhase = null` 是必须的(F4),否则新会话的快照等待直接返回,`snapshotIsActive` 仍反映旧会话。 - `resetRound()` 由 `attach()` 调用,覆盖执行态(F5);`todos` / `lastContext` 由本方法显式清空。 - `ws.unsubscribe(prevId)` 必须在 `attach()` 之前——`attach` 内部只负责 `subscribe(newId)`,不会退订旧 id。 ### 4.3 `chat.ts`:`switchSession()`(新增,注入给 REPL) 守卫与刷新顺序固定,任何一步失败都不改变当前会话: ```ts async function switchSession(id: number): Promise { // 1. 本进程忙 → 拒绝(D8);REPL 层已先拦一道,这里兜底 if (runner.isRunning()) throw new CliError('当前任务仍在执行…'); // 2. 目标 = 当前会话(D10) if (runner.getSession()?.id === id) throw new CliError(`已经是当前会话 #${id}`); // 3. 取会话(同时完成归属校验;不存在 → 后端 code=3002) const next = await rest.getSession(id); // 4. 模式必须一致(D2) const nextLocal = next.executionMode === 'LOCAL'; if (nextLocal !== localMode) { throw new CliError( `无法切换:该会话是 ${next.executionMode ?? 'CLOUD'} 模式,当前进程是 ${localMode ? 'LOCAL' : 'CLOUD'} 模式。\n` + `请退出后用 mao-agent resume ${id} 打开。`, ); } // 5. LOCAL 会话的 workspace 必须在启动工作区内(D3) if (nextLocal) { const target = next.workspace ? path.resolve(next.workspace) : ''; if (!target || !isWorkspaceWithin(target, localWorkspace)) { throw new CliError( `无法切换:该会话的本地工作区是 ${next.workspace ?? '(空)'},不在当前工作区 ${localWorkspace} 内。\n` + `请退出后用 mao-agent resume ${id} 打开。`, ); } } // 6. --if-running=fail:尊重显式选择(D4) if (cfg.ifRunning === 'fail' && (next.running || isRunningPhase(next.phase))) { throw new CliError(`该会话仍在执行(--if-running=fail):#${id}`); } // 7. 真正切换(含回滚) await runner.switchTo(next); attached = true; // 草稿状态收口:后续 ensureSession 不再创建新会话 createdSession = undefined; // 8. 会话级外部状态同步 rememberLastSession(id); modelId = next.modelId ?? modelId; contextWindowTokens = resolveContextWindowTokens(next, modelsCache, modelId); inkRenderer?.setMeta({ agentName: next.agentName, modelName: next.modelName, executionMode: nextLocal ? 'LOCAL' : 'CLOUD', contextWindowTokens, }); // 9. 留痕输出:分隔行 + 最近 3 轮摘要(D5 / D11) const recent = await loadHistory(rest, id, false); inkRenderer?.print( `── 已切换到会话 #${id}${next.title ? ` · ${next.title}` : ''} ──` + (recent.length > 0 ? `\n${recent.join('\n')}` : ''), ); return next; } ``` 配套改动: - `modelId` 由 `const` 改 `let`;`listActiveModels()` 的结果保存为 `modelsCache`(切换后重算 `contextWindowTokens` 与 banner 模型名时复用,避免再发一次请求;`modelsCache` 为空数组时 `resolveContextWindowTokens` 按缺省窗口处理)。 - `isRunningPhase` 需从 `ws/event-filter` 导入到 `chat.ts`(当前未导入)。 - `attached` / `createdSession` 是 `cmdChat` 闭包变量,切换逻辑必须写在 `cmdChat` 内(不能只写在 REPL 层)——否则 0.0.457 的 `ensureSession` 会在切换后误建新会话。 - `next.modelId` 为空时保留原 `modelId`:新会话没有模型配置时沿用进程当前选择(与 `runPrompt(text, opts.modelId)` 的既有语义一致)。 - 历史摘要复用 `loadHistory(rest, id, false)`(`rest.listMessages(id, { roundLimit: 3 })` + `formatHistorySummary`)。 ### 4.4 `repl.ts`:`/resume` 分发 `ReplOptions` 新增(全部可选,便于单测与降级): ```ts /** 可切换会话列表(已按模式/工作区过滤、按最近排序、已截断)。query 为空取最近。 */ listSwitchableSessions?: (query?: string) => Promise; /** 切换会话(chat.ts 内完成守卫、重挂与状态刷新)。失败抛 CliError。 */ switchSession?: (id: number) => Promise; /** 会话选择面板;缺省时打印列表并提示 /resume (非 TUI 场景/单测)。 */ pickSession?: (items: SessionVO[]) => Promise; ``` `handleSlash` 新增分支: ```ts case 'resume': { if (!opts.switchSession || !opts.listSwitchableSessions) { io.print('当前模式不支持切换会话。', 'warn'); return; } if (opts.runner.isRunning()) { io.hint('当前任务还在执行:等它结束或 /cancel 后再切换。'); return; } const byId = /^\d+$/.test(arg); // /resume 123:直切,不拉列表 const items = byId ? [] : await opts.listSwitchableSessions(arg || undefined); let targetId: number | null = byId ? Number(arg) : null; if (!byId) { if (items.length === 1) { targetId = items[0].id!; // 关键词命中 1 条:直切 } else if (items.length > 1) { if (!opts.pickSession) { io.print(formatSessionRows(items)); // 无面板时打印列表,提示 /resume return; } targetId = await opts.pickSession(items); } } if (targetId == null) { if (!byId && items.length === 0) io.print(emptyHint(arg)); else io.hint('已取消。'); return; } try { const s = await opts.switchSession(targetId); opts.modelId = s.modelId ?? opts.modelId; // F6:下一条消息带新会话模型 io.hint(`已切换到会话 #${s.id}${s.title ? ` · ${s.title}` : ''}`); if (opts.runner.snapshotIsActive) await resumeActiveRun('该会话仍在执行,续接当前输出。可用 /cancel 中止。'); } catch (err) { io.print(err instanceof Error ? err.message : String(err), 'err'); } return; } ``` - `/resume <纯数字>` 直接 `switchSession`,跳过列表请求(少一次网络往返);数字形式不经过「模式/工作区过滤」,由 `switchSession` 的守卫给出具体原因。 - `formatSessionRows` / `emptyHint` 是实现时新增的小辅助函数(列表行格式化复用 `ls.ts` 的 `formatRow` 思路;空态文案区分「无会话」与「都被过滤」,见 §5 E11/E12)。 - 面板打开期间输入框由 modal 机制禁用(`app.tsx` 中 ``),不会出现并发输入;`handleSubmit` 本身串行。 - `/session` 的两处文案(未创建 / 已有会话)把 `换会话: mao-agent resume | mao-agent ls` 改为 `换会话: /resume(本进程内切换) | mao-agent resume `。 ### 4.5 `resumeActiveRun()` 抽取(启动段与切换后共用) 现有启动段逻辑(`repl.ts`): ```ts if (opts.firstPrompt) { if (opts.runner.snapshotIsActive) { hint('该会话仍在执行,先续接当前输出…'); await opts.runner.waitForCurrentRun(); } await handleSubmit(opts.firstPrompt); } else if (opts.runner.snapshotIsActive) { hint('该会话仍在执行,续接当前输出。可用 /cancel 中止。'); opts.renderer.startRound(); await opts.runner.waitForCurrentRun(); } ``` 改为: ```ts const resumeActiveRun = async (tip: string): Promise => { hint(tip); opts.renderer.startRound(); try { await opts.runner.waitForCurrentRun(); } catch (err) { print(err instanceof Error ? err.message : String(err), 'err'); } }; ``` 启动段与 `/resume` 成功后都调用它(提示文案按场景传入)。这样就复用了 `waitForCurrentRun` 已有的「等待占用方结束 / 不 cancel 对方 / 受 `--max-duration` 约束」语义。 ### 4.6 会话选择面板 **控制器**(`tui/modal-controller.ts` 新增,约 80 行): ```ts export interface SessionPickItem { id: number; title?: string | null; agentName?: string | null; updatedAt?: string | null; running?: boolean; unread?: boolean; } export class SessionPickController { private cursor = 0; constructor( readonly items: SessionPickItem[], private readonly onDone: (id: number | null) => void, private readonly maxRows = 8, ) {} get state(): SessionPickViewState { /* { kind: 'session-pick', items, cursor, offset, total } */ } handleKey(ev: KeyEvent): void { // up / k:cursor--(clamp);down / j:cursor++(clamp) // enter:onDone(items[cursor].id) // escape / ctrl+c:onDone(null) // (可选)char '1'..'9':快选第 N 条 } } ``` **渲染**(`ink-renderer.ts` 新增 `buildSessionPickPanel`): ```text 切换会话(最近 20 条 · ↑↓ 选择 · Enter 确认 · Esc 取消) ❯ #2272 修复登录后 baseUrl 保存 梁子 3 分钟前 ● 运行中 #2270 mao-agent 切换 baseUrl 梁子 1 小时前 #2268 运营活动数据核对 小马 2 天前 共 20 条 · 快速切换:/resume 或 /resume 关键词 ``` - 行内容:`#id` + 标题(按终端宽度裁剪,空标题显示「未命名会话」)+ Agent + `formatRelativeTime(updatedAt)` + 运行中/未读标记。 - 窗口滚动复用 `paletteWindow(items, cursor, 8)`。 - `types.ts`:`PanelView.kind` 增加 `'session-pick'`;新增 `SessionPickViewState`(含 `items/cursor/offset/total`)。 - `ink-renderer.ts`:`pickSession(items): Promise`(仿 `requestApproval`:入队 → 用户操作 → `closeModal` + resolve);`ModalEntry` 联合类型增加 `{ kind: 'session-pick'; controller }`;`buildPanel` 增加分支;`dispatchKeys` 无需改动(统一 `head.controller.handleKey`)。 ### 4.7 切换后的状态对照表 | 对象 | 刷新动作 | 备注 | |---|---|---| | `runner.session` / `sessionId` | `attach(next)` | 内含 `resetRound` + `subscribe` + 等待快照 | | `runner.snapshotPhase` / `todos` / `lastContext` | `switchTo` 显式重置 | F4 | | `runner.durationTimer` | `disarmMaxDuration()` | 防止跨会话超时误 cancel | | REPL `opts.modelId` | `= next.modelId ?? 原值` | F6,由 REPL 在切换成功后设置 | | 状态栏(Agent/模型/模式/上下文) | `inkRenderer.setMeta(...)` | 构造时传的是初始会话,必须显式更新 | | scrollback | `print` 分隔行 + 最近 3 轮摘要 | D5 / D11 | | `rememberLastSession` | 新会话 id | 影响后续 `--continue` | | `chat.ts` `attached` / `createdSession` | `true` / `undefined` | 草稿废弃,不落库 | | WS 订阅 | `unsubscribe(旧)` + `subscribe(新)` | `switchTo` + `attach` | | `/session` `/todo` `/context` | 无需改动 | 全部从 `runner` 取当前会话数据 | ## 5. 边界与错误处理 | # | 场景 | 行为 | |---|---|---| | E1 | 本进程任务运行中执行 `/resume` | 拒绝:「当前任务还在执行:等它结束或 /cancel 后再切换。」(REPL + `switchSession` 双保险) | | E2 | 目标就是当前会话 | 拒绝:「已经是当前会话 #id」(D10) | | E3 | 跨模式(LOCAL ↔ CLOUD) | 列表不展示;直切时拒绝并提示 `mao-agent resume `(D2) | | E4 | LOCAL 会话 workspace 越界 / 为空 | 列表不展示;直切时拒绝并给出两边路径(D3) | | E5 | 目标不存在 / 非本人 | 后端 `code=3002 会话不存在` 原样提示 | | E6 | 目标运行中,且 `--if-running=fail` | 拒绝(D4) | | E7 | 目标运行中(默认 / `wait`) | 允许;切换后 `snapshotIsActive` → 提示并 `waitForCurrentRun` 续接 | | E8 | 目标运行中,`--if-running=cancel` | 不沿用(切换不 cancel 别的客户端任务);按 `wait` 处理 | | E9 | `attach` 失败(断网 / 服务端异常) | 回滚原会话(best-effort)后抛错;回滚也失败 → 提示「请退出后重开 mao-agent」 | | E10 | 面板 Esc / Ctrl+C | 取消,不改变当前会话;提示「已取消。」 | | E11 | 列表为空(无会话) | 「没有可切换的会话。直接发消息继续当前会话。」 | | E12 | 列表为空(因模式/工作区过滤) | 「最近会话都与当前进程模式或工作区不匹配;用 mao-agent resume 另开进程打开。」 | | E13 | 关键词 0 命中 | 「没有匹配「xxx」的会话(按标题匹配)。」 | | E14 | 关键词命中 1 条 | 直接切换(不弹面板) | | E15 | 切换过程中用户继续输入 | 切换在 `handleSubmit` 内串行执行;面板期间输入框禁用(E9 既有机制) | | E16 | 切换后立即发消息 | `ensureSession` 因 `attached=true` 直接跳过;消息发到新会话 | | E17 | 切换后 Ctrl+D 退出 | `shutdown` 对新会话 `markRead` + 退订,无空白会话产生(F12 回归) | ## 6. 测试计划 ### 6.1 单元测试(vitest) 1. **`test/session-switch.spec.ts`(新增)** - `SessionRunner.switchTo`: - 正常切换:旧会话 `unsubscribe` + `markRead` 各一次、`subscribe(newId)`、`snapshotPhase` 重置、`todos/lastContext` 清空; - 目标 = 当前 → 不重复退订; - `busy` 时抛错; - `attach` 失败 → 回滚 `attach(prev)` 并抛原错; - `markRead` 抛错不影响切换。 - `listSwitchableSessions`(抽出纯函数 `filterSwitchableSessions(all, { currentId, localMode, localWorkspace })` 后直接测): - 排除当前会话;`LOCAL` / `CLOUD` 模式过滤; - LOCAL workspace 边界(等于 / 子目录通过,父目录 / 兄弟目录 / 空值拒绝); - 排序(updatedAt 倒序 + id 兜底)、截断 20 条; - keyword 透传。 - 守卫:`--if-running=fail` 拒绝;跨模式拒绝文案包含 `mao-agent resume`。 2. **`test/session-pick-controller.spec.ts`(新增)** - ↑↓ / j k 移动与边界 clamp;Enter 返回 id;Esc / Ctrl+C 返回 null;超过 8 条时 offset 跟随滚动;(若实现)数字快选。 3. **`test/slash-resume.spec.ts`(新增,驱动导出的 `handleSlash`)** - `/resume` → 调用 `listSwitchableSessions(undefined)` + `pickSession`; - `/resume 2270` → 直接 `switchSession(2270)`,不拉列表; - `/resume 关键词` → 1 条直切 / 多条弹面板 / 0 条提示; - 面板返回 null → 「已取消」且不调用 `switchSession`; - 运行中 → 拒绝且不调用 `switchSession`; - 切换成功后 `opts.modelId` 更新为 `s.modelId`; - `snapshotIsActive` → 调用 `waitForCurrentRun`。 4. **`test/session-defer.spec.ts`(回归增补)** - 草稿状态下切换成功后,`ensureSession` 不再创建会话(`attached=true`)。 ### 6.2 端到端冒烟(mock server + pty,沿用 0.0.457 的脚手架) | 场景 | 步骤 | 断言 | |---|---|---| | S1 面板切换 | `/resume` → ↓ → Enter → 发一条消息 | `GET /sessions?status=ACTIVE`、`GET /sessions/`、`PUT /sessions//read`、WS `subscribe` 帧含新 id、`send_message.sessionId = 新 id`、`data.modelId = 新会话模型` | | S2 直达切换 | `/resume ` | 不发列表请求;切换成功;`/session` 显示新 id | | S3 运行中拒绝 | 触发一个长任务后 `/resume` | 输出拒绝提示;无退订/重订阅帧 | | S4 草稿切换 | 打开后不发消息,直接 `/resume ` → 发消息 | 无 `POST /sessions`(草稿未落库,切换后不再创建);消息发到切换后的会话;不产生空白会话 | | S5 退出回归 | 切换后 Ctrl+D | 无新增空白会话;旧会话已 `read` | > 冒烟注意:`MAO_BASE_URL` / `MAO_TOKEN` 是云端 shell 的既有环境变量,跑 mock 时必须 `env -u` 隔离,否则请求会打到生产(本次 0.0.457 验证时踩过,已清理误建会话)。 ## 7. 兼容性与发布 - **纯客户端**:复用既有 `GET /sessions`、`GET /sessions/:id`、`PUT /sessions/:id/read`,无新接口、无迁移、无配置项。 - **老服务端兼容**:接口均已在生产使用(`ls` / `resume` 同源),无版本门槛。 - **回滚**:新命令为增量;出问题可回退 CLI 版本,服务端零感知。 - **文档**:`skills/mao-cli/reference/mao-agent.md`(斜杠命令表 + 交互 REPL 小节 + `/session` 文案)、`CHANGELOG.md` 顶部新版本(实施时按当时的下一版本号;截至本方案定稿远程已到 `0.0.458`)。 - **发布**:按本工作区既有流程——改完本地验证(typecheck / build / test / 冒烟)→ 用户确认 → 提交 push `origin/main`(是否部署另行确认)。 ## 8. 已确认事项(2026-11-06 评审结论:按建议定案) | # | 事项 | 结论 | 备注 | |---|---|---|---| | Q1 | 命令名与形态 | `/resume`(无参面板、数字直切、关键词搜索) | 与 `mao-agent resume` 同词 | | Q2 | 跨模式切换 | 首版禁止,提示另开进程 | D2 生效 | | Q3 | LOCAL workspace 边界 | 只允许「启动工作区或其内部」,越界不列出 | D3 生效;与本地信任边界一致 | | Q4 | 运行中会话 | 允许切换并自动续接;`--if-running=fail` 时拒绝 | D4 生效 | | Q5 | 切换可见反馈 | 不清屏 + 分隔行 + 最近 3 轮摘要 | D5 生效;清屏增强不做 | | Q6 | 关键词搜索 | 首版包含(按标题匹配) | D12 生效 | | Q7 | 面板数字快选 | 首版不做 | 后续有需要再评估 | | Q8 | 面板条数 / 窗口 | 20 条 / 8 行 | `RESUME_PICK_LIMIT = 20` | > 评审时间:2026-11-06;形式:飞书对话(「按你的建议来决策」)。实施状态:未开始(另行确认后开发)。 ## 9. 实施清单(文件级) | 文件 | 改动 | 预估行数 | |---|---|---| | `agent-cli/src/session/session-runner.ts` | 新增 `switchTo()`(状态重置 + 退订/已读 + 回滚) | +55 | | `agent-cli/src/commands/chat.ts` | `filterSwitchableSessions` / `listSwitchableSessions` / `switchSession`;`modelId` 改 `let`、缓存 models;注入 ReplOptions | +130 | | `agent-cli/src/repl/repl.ts` | `/resume` 分发;抽取 `resumeActiveRun`;`/session` 文案 | +90 | | `agent-cli/src/ui/slash-complete.ts` | `SLASH_ITEMS` 增加 `resume` | +3 | | `agent-cli/src/tui/modal-controller.ts` | `SessionPickController` | +80 | | `agent-cli/src/tui/types.ts` | `PanelView.kind` + `SessionPickViewState` | +15 | | `agent-cli/src/tui/ink-renderer.ts` | `pickSession` + `ModalEntry` + `buildSessionPickPanel` | +70 | | `agent-cli/test/*` | 3 个新 spec + `session-defer` 回归增补 | +350 | | `skills/mao-cli/reference/mao-agent.md` + `CHANGELOG.md` | 文档与发版说明 | +40 | 合计约 **830 行**(含测试),全部在 `agent-cli` 与文档内;**不改后端、不改数据库、不改桌面端**。 ## 10. 已知限制(首版) 1. **不跨模式**:LOCAL 进程看不到也切不到 CLOUD 会话(反之亦然),提示另开进程。 2. **不跨工作区**:LOCAL 进程只能切到启动工作区内的会话。 3. **banner 不重绘**:切换后旧 banner 仍留在 scrollback 顶部,靠分隔行澄清;清屏方案留待后续。 4. **不做会话管理**:重命名 / 删除 / 归档仍走 `mao` CLI 或桌面端。 5. **切到别的设备正在跑的 LOCAL 会话**:能看到输出并等待,但该会话的审批/提问需要原设备处理(CLI 不代为操作);若用户需要接管,应在同一工作区用 `resume` 重开。 6. **不同 Agent / 子目录 workspace**:切换生效的是会话自身绑定的 Agent;但 `agentsMdContent` 与 `localSkills` 仍来自本进程启动目录(与「同一进程只绑定一个工作区」的既有设计一致),不会按新会话的 workspace 重新读取。