Эх сурвалжийг харах

docs: add CLAUDE.md + superpowers specs & plans

- CLAUDE.md:项目指南(常用命令、设计文档索引、工作规范、pi-agent 工具设计
  规范、关键路径),供 Claude Code 在会话开头自动加载
- docs/superpowers/specs/:最近几轮 brainstorming 的设计文档
  - 2026-04-13 book-create-redesign / chat-page-redesign / tool-calling-book-create
  - 2026-04-15 sub-agent-params-alignment
  - 2026-04-16 session-management-design
  - 2026-04-17 server-split-design
- docs/superpowers/plans/:对应的实施计划
  - 2026-04-13 ai-elements-integration / book-create-implementation-plan /
    phase-1b-pi-ai-provider / phase-1c-pi-agent-interaction
  - 2026-04-15 sub-agent-params-alignment-plan
  - 2026-04-16 session-management
- agent-context-management-plan.md 在 master 已有历史,此次附上对应的 spec

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fanghanjun 6 сар өмнө
parent
commit
4ff1fc01ed

+ 59 - 0
CLAUDE.md

@@ -0,0 +1,59 @@
+# InkOS
+
+AI 长篇小说写作平台。monorepo: `packages/core`(运行时)、`packages/cli`(TUI)、`packages/studio`(Web UI / Hono + Vite)。
+
+## 常用命令
+
+```bash
+pnpm dev              # 从根目录启动!core tsc --watch + studio Vite HMR + API server
+pnpm build            # 全量构建
+pnpm test             # vitest 全仓测试
+pnpm --filter @actalk/inkos-core build     # 改了 core 后必须重建(或用 pnpm dev 自动 watch)
+```
+
+## 设计文档
+
+| 文档 | 范围 | 何时读 |
+|------|------|--------|
+| `docs/infra/studio-state-management.md` | Studio Zustand store 架构、slice 约定、目录结构 | 修改 `packages/studio/src/store/` 时 |
+| `docs/infra/studio-routing-and-session.md` | URL hash 路由 + 消息隔离 + pi-ai/pi-agent 集成 | 修改路由、session、LLM provider 时 |
+| `docs/infra/studio-service-management.md` | 服务商管理页设计 | 修改 ConfigView / 服务商配置时 |
+| `docs/superpowers/specs/2026-04-15-sub-agent-params-alignment.md` | sub_agent 工具参数对齐设计 | 修改 agent-tools.ts / sub_agent 工具时 |
+
+## 工作规范
+
+- **不要推测错误** — 遇到报错先读完整错误信息,定位根因后再修,不凭猜测改代码
+- **原子化提交** — 每个 commit 只做一件事,功能和修复分开提交;不要把不相关的改动塞进同一个 commit
+- **每步都跑测试** — 每个 task 完成后必须 `pnpm test` 全量通过,不能攒到最后才跑
+- **防止 maxTokens 回归** — 替换 LLM provider 时必须确认 maxTokens 参数正确传递,不能丢失或硬编码;写测试验证
+- **禁止简写/比喻式表达** — 不用"记账""接住""一等公民""跑全仓""闭环""抓手""赋能""落""落盘""落地""打(接口)"这类简写、借喻或商务黑话。一律用完整、直白的中文句子说清楚到底在做什么。示例:
+  - "打 OpenAI 的 /models" → "向 OpenAI 的 /models 接口**发送请求**"(所有"打 XX 接口 / 打 API"都改为"向 XX 发送请求"或"调用 XX 接口")
+  - "接住错误" → "catch 住异常并写日志"
+  - "三件事分别落到哪里" → "三件事分别存到了项目文件、用户级记忆、本次会话里"
+  - "XX 是一等公民" → "XX 可以像普通值一样被赋值、传参、返回"
+- **说人话,先说清楚再说精简** — 把一件事的来龙去脉先讲明白,比追求"简洁"更重要。宁可多说一句让对方一次看懂(人在做什么、为什么、放到了哪里),也不要为了省字写得像电报,让对方还得反过来问"你说的这个是什么意思"
+- **禁止奉承式开场和英文直译套话** — 不说"你抓得好"("good catch" 直译)、"你说得对"、"好问题"、"非常棒的观察"这类开头。它们既是套话又像拍马屁,没信息量。要承认对方点出的问题就直接说"对,这个我刚也没注意到"或干脆进入正题
+
+## pi-agent 工具设计规范
+
+修改 `packages/core/src/agent/agent-tools.ts` 时必须遵守:
+
+- **schema 即文档** — TypeBox schema 的 `description` 直接传给 LLM,模型靠它理解参数含义;每个字段的 description 必须标注适用的 agent(如 `"reviser only: revision mode"`)
+- **枚举用 Union+Literal** — 有限合法值用 `Type.Union([Type.Literal("a"), Type.Literal("b")])` 而非 `Type.String()`,模型能在 JSON Schema 里看到 `const` 约束,不会传错
+- **开放文本用 String** — 书名、题材等无法穷举的值用 `Type.String({ description: "..." })`
+- **Optional 包裹可选字段** — `Type.Optional(...)` 标记非必填参数
+- **不硬编码 pipeline 参数** — sub_agent 的参数必须与 pipeline runner 方法签名对齐,不能在 execute 里写死值(如 `genre: "general"`)
+- **返回值要可操作** — agent 收到的返回文本必须包含足够信息让它决定下一步(如 auditor 返回 issue 列表而非只返回数量)
+- **错误抛异常** — execute 里不要 catch-and-encode 错误到返回值,throw 由 agent loop 统一处理
+
+## 关键路径
+
+- **Agent 工具**: `core/src/agent/agent-tools.ts` → `SubAgentParams` schema → `createSubAgentTool` → 调用 `PipelineRunner` 方法
+- **建书流程**: `core/interaction/project-tools.ts` → `CREATE_BOOK_TOOL` + `chatWithTools` → Studio `ChatPage` → `BookFormCard`
+- **Studio 入口**: `studio/src/App.tsx` → route 驱动页面切换
+- **状态管理**: Zustand store,LobeHub slice 约定
+  - `studio/src/store/chat/` — 对话消息、创建流程、侧边栏状态
+  - `studio/src/store/service/` — 服务商连接状态、模型列表缓存、model picker 三态(loading/no-models/ready)
+  - **原则**: Zustand selector 只返回 store 中已有的原始值。如果需要 `.filter()` / `.map()` 等产生新数组的派生,用组件内 `useMemo` 而不是 store selector — 否则会造成无限渲染循环
+- **SSE 事件**: `studio/src/hooks/use-sse.ts`(共享 buffer)+ 组件内直连 EventSource(流式渲染)
+- **服务商 preset**: `core/src/llm/service-presets.ts` → baseUrl/api/knownModels/modelsBaseUrl;Anthropic 兼容端点用于 MiniMax 和百炼

+ 159 - 0
docs/superpowers/plans/2026-04-13-ai-elements-integration.md

@@ -0,0 +1,159 @@
+# ai-elements 集成计划
+
+## 前置条件
+
+Studio CSS 变量已与 shadcn/ui 完全对齐(background, foreground, primary, card, muted, border, ring, radius 等全部一致),无需修改主题。
+
+## Step 1: 安装 shadcn/ui(base-ui 模式)
+
+```bash
+cd packages/studio
+npx shadcn@latest init
+# 选择:
+#   - Style: Default
+#   - Base color: 不改(已有自定义主题)
+#   - CSS variables: Yes
+#   - Component library: base-ui  ← 关键
+#   - Tailwind CSS: v4
+#   - Components path: src/components/ui
+#   - Utils path: src/lib/utils(已存在,确认覆盖或合并)
+```
+
+验证:`pnpm --filter @actalk/inkos-studio build` 不报错,现有页面渲染正常。
+
+**涉及文件**:
+- 新增/修改:`components.json`(shadcn 配置)
+- 可能修改:`src/lib/utils.ts`(`cn` 函数,已存在,确认兼容)
+- 可能修改:`tailwind.config.*`(如果 shadcn init 要求)
+- 可能修改:`package.json`(新增 shadcn 依赖)
+
+## Step 2: 安装 ai-elements 组件
+
+```bash
+cd packages/studio
+npx ai-elements@latest add message
+npx ai-elements@latest add tool
+npx ai-elements@latest add confirmation
+npx ai-elements@latest add prompt-input
+```
+
+这些组件会被复制到 `src/components/ui/` 下。检查生成的文件,确认它们引用的 CSS 变量和 Studio 主题一致。
+
+验证:typecheck 通过,build 不报错。
+
+**涉及文件**:
+- 新增:`src/components/ui/message.tsx`(或类似路径)
+- 新增:`src/components/ui/tool.tsx`
+- 新增:`src/components/ui/confirmation.tsx`
+- 新增:`src/components/ui/prompt-input.tsx`
+- 可能新增:依赖的子组件(avatar, button 等)
+
+## Step 3: 替换 ChatMessage
+
+**当前**:`src/components/chat/ChatMessage.tsx` — 自建的消息气泡组件
+
+**改为**:用 ai-elements 的 `Message` 组件重写。
+
+```tsx
+import { Message } from "../ui/message";  // ai-elements
+
+// 用户消息
+<Message role="user">
+  <Message.Content>{content}</Message.Content>
+</Message>
+
+// AI 消息
+<Message role="assistant">
+  <Message.Content>{renderedMarkdown}</Message.Content>
+</Message>
+
+// 带 tool call 的消息
+<Message role="assistant">
+  <Message.Content>{content}</Message.Content>
+  <Tool>
+    <BookFormFields ... />
+    <Confirmation onConfirm={...} />
+  </Tool>
+</Message>
+```
+
+**涉及文件**:
+- 重写:`src/components/chat/ChatMessage.tsx`
+- 删除:`src/components/book-create/StreamMessage.tsx`
+- 删除:`src/components/book-create/InlineField.tsx`
+- 删除:`src/components/book-create/InlinePick.tsx`
+- 删除:`src/components/book-create/InlineGroup.tsx`
+
+## Step 4: 替换 BookFormCard
+
+**当前**:`src/components/chat/BookFormCard.tsx` — 自建的 tool call 表单
+
+**改为**:用 ai-elements 的 `Tool` + `Confirmation` 组件包装,内部表单字段保留自建(ai-elements 不提供自动表单生成)。
+
+```tsx
+import { Tool } from "../ui/tool";
+import { Confirmation } from "../ui/confirmation";
+
+<Tool name="create_book">
+  {/* 自建表单字段:title, genre, platform, chapters, brief */}
+  <FormFields args={args} onChange={onArgsChange} disabled={confirming} />
+  <Confirmation
+    onConfirm={onConfirm}
+    loading={confirming}
+    label="开始写这本书"
+  />
+</Tool>
+```
+
+**涉及文件**:
+- 重写:`src/components/chat/BookFormCard.tsx`
+
+## Step 5: 替换 ChatPage 输入区
+
+**当前**:ChatPage 底部自建的 textarea + 发送按钮
+
+**改为**:用 ai-elements 的 `PromptInput` 组件。
+
+```tsx
+import { PromptInput } from "../ui/prompt-input";
+
+<PromptInput
+  value={input}
+  onChange={setInput}
+  onSubmit={handleSubmit}
+  disabled={loading}
+  placeholder="输入指令..."
+/>
+```
+
+**涉及文件**:
+- 修改:`src/pages/ChatPage.tsx`(替换底部输入区)
+
+## Step 6: 验证 + 清理
+
+1. `pnpm --filter @actalk/inkos-studio typecheck`
+2. `pnpm --filter @actalk/inkos-studio test`
+3. `pnpm --filter @actalk/inkos-studio build`
+4. 启动 Studio 端到端测试
+5. 删除不再使用的自建组件文件
+6. 提交
+
+## 依赖关系
+
+```
+Step 1 (shadcn init)
+  ↓
+Step 2 (ai-elements add)
+  ↓
+Step 3 (ChatMessage) ─┐
+Step 4 (BookFormCard) ─┼─ 可并行
+Step 5 (PromptInput) ──┘
+  ↓
+Step 6 (验证清理)
+```
+
+## 风险
+
+1. **shadcn init 可能改动 index.css** — 需要确保不覆盖 Studio 的自定义主题。init 后立即 git diff 检查。
+2. **ai-elements 组件的内部样式** — 可能引入额外的 CSS class 或硬编码颜色。需要逐个检查生成的组件文件。
+3. **base-ui 版本兼容** — Studio 现有的 @base-ui/react 版本和 shadcn 要求的版本可能不同。检查 package.json。

+ 246 - 0
docs/superpowers/plans/2026-04-13-book-create-implementation-plan.md

@@ -0,0 +1,246 @@
+# BookCreate 重构实施计划
+
+基于 [设计文档](../specs/2026-04-13-book-create-redesign.md),以下是按依赖顺序排列的实施步骤。
+
+---
+
+## Step 1: Core — 表单标记解析器
+
+新建 `packages/core/src/interaction/draft-directive-parser.ts`
+
+实现从 LLM 原始输出中提取结构化数据的共享 parser:
+
+```typescript
+interface ParsedDraftResponse {
+  fields: Record<string, string>;  // key → value
+  textContent: string;             // 去掉 ::: 标记的纯文本(TUI 用)
+  summary: string;                 // 自动生成的轮次摘要("确立了书名、世界观和主角")
+  raw: string;                     // LLM 原始输出(Studio 用)
+}
+```
+
+逐行状态机:普通行累积 markdown buffer,`:::field{...}` / `:::pick{...}` / `:::number{...}` / `:::group{...}` 进入表单收集模式,关闭 `:::` 恢复。
+
+**涉及文件**:
+- 新建:`packages/core/src/interaction/draft-directive-parser.ts`
+- 新建:`packages/core/src/__tests__/draft-directive-parser.test.ts`
+- 修改:`packages/core/src/index.ts`(导出 parser)
+
+---
+
+## Step 2: Core — Session 数据结构扩展
+
+修改 `packages/core/src/interaction/session.ts`,扩展 session 结构以支持对话轮次。
+
+**当前**(session.ts:22-45):
+- `BookCreationDraftSchema` — 扁平的 Zod schema
+
+**改为**:
+- `BookCreationDraftSchema` 保持不变(`fields` 的值结构和现有一致,向后兼容)
+- 新增 `DraftRoundSchema`:`{ roundId, userMessage, assistantRaw, fieldsUpdated, summary, timestamp }`
+- `InteractionSessionSchema`(session.ts:47-58)新增可选字段 `draftRounds`
+
+**涉及文件**:
+- 修改:`packages/core/src/interaction/session.ts`(:22-60, :85-104)
+- 修改:`packages/core/src/index.ts`(导出新类型)
+
+---
+
+## Step 3: Core — developBookDraft 改为流式输出
+
+重写 `packages/core/src/interaction/project-tools.ts` 中的 `developBookDraft`(:450-519)。
+
+**当前**:
+- system prompt 要求返回 JSON `{assistantReply, draft}`
+- `chatCompletion` 一次性返回,`parseCreationDraftResult` 解析 JSON
+- `onTextDelta` 回调只有 `chat` 工具在用(project-tools.ts:596),`developBookDraft` 没有接入
+
+**改为**:
+- system prompt 替换为设计文档中的新 prompt(中文角色定位 + 业务原则 + `:::` 标记格式规范)
+- `chatCompletion` 启用 `onTextDelta` 流式回调,通过 `hooks.onDraftTextDelta` 向外暴露
+- 流式回调需要**实时过滤 `:::` 标记段**:普通文本立即推送,进入 `:::` 块时暂停推送,关闭后跳过该段。这样 TUI 端在流式阶段只看到自然语言文本
+- 流式完成后,用 Step 1 的 parser 对完整输出提取 `fields`
+- 返回结构调整:增加 `raw` 字段传递原始输出
+
+**hooks 接口扩展**(`packages/cli/src/tui/tools.ts:12`,`packages/core/src/interaction/project-tools.ts` CliInteractionToolHooks):
+```typescript
+interface CliInteractionToolHooks {
+  readonly onChatTextDelta?: (text: string) => void;
+  readonly onDraftTextDelta?: (text: string) => void;  // 新增
+  // ...
+}
+```
+
+**涉及文件**:
+- 修改:`packages/core/src/interaction/project-tools.ts`(:450-519 developBookDraft, :48-119 可删除旧 JSON parser)
+- 修改:`packages/core/src/__tests__/creation-draft-parser.test.ts`(适配新 parser)
+
+---
+
+## Step 4: Core — runtime 层适配轮次记录
+
+修改 `packages/core/src/interaction/runtime.ts` 中的 `handleDraftLifecycleRequest`(:351-495)。
+
+**当前**(:361-399 develop_book case):
+- 调用 `developBookDraft` → `updateCreationDraft` 整体替换 draft
+
+**改为**:
+- 调用 `developBookDraft` → 用 parser 的 `fields` 增量合并到 `session.creationDraft`
+- 追加新的 `DraftRound` 到 `session.draftRounds`
+- `raw` 字段透传到 response 供 Studio 流式渲染
+
+同时修改 `clearCreationDraft`(session.ts:95-104)一并清空 `draftRounds`。
+
+**涉及文件**:
+- 修改:`packages/core/src/interaction/runtime.ts`(:351-399)
+- 修改:`packages/core/src/interaction/session.ts`(:85-104 updateCreationDraft, clearCreationDraft)
+
+---
+
+## Step 5: Studio API — agent 端点支持 SSE 流式推送
+
+修改 `packages/studio/src/api/server.ts` 的 `/api/agent` POST 端点(:579-610)。
+
+**当前**:
+- 同步调用 `processProjectInteractionInput`,等完成后返回 JSON
+
+**改为**:
+- 对 `develop_book` intent,通过现有 SSE 基础设施(:395-417 `/api/events`)逐 token 广播 `draft:delta` 事件
+- 需要在 `project-tools.ts` 的 `developBookDraft` 中把 `onTextDelta` hook 暴露出来
+- 利用现有 `broadcast()` 函数(server.ts:28-38)推送
+- 最终 response 仍返回完整结果(含 `raw`、`fields`、`round`)
+
+**涉及文件**:
+- 修改:`packages/studio/src/api/server.ts`(:579-610)
+- 修改:`packages/studio/src/hooks/use-sse.ts`(:9-54 新增 `draft:delta` 事件类型)
+
+---
+
+## Step 6: Studio — 流式消息渲染组件
+
+新建 Studio 前端组件,解析 `:::` 标记并渲染为表单控件。
+
+**新建组件**:
+- `packages/studio/src/components/book-create/StreamMessage.tsx` — 接收流式文本,逐行解析,markdown 段落和表单控件交替渲染
+- `packages/studio/src/components/book-create/InlineField.tsx` — `:::field` → `<input>` / `<textarea>`
+- `packages/studio/src/components/book-create/InlinePick.tsx` — `:::pick` → 可点选卡片组
+- `packages/studio/src/components/book-create/InlineGroup.tsx` — `:::group` → 并排容器
+- `packages/studio/src/components/book-create/RoundSummary.tsx` — 折叠的历史轮次
+- `packages/studio/src/components/book-create/DraftReadyBar.tsx` — 草案就绪确认区块
+- `packages/studio/src/components/book-create/ComposerBar.tsx` — 底部输入栏
+
+**涉及文件**:
+- 新建:`packages/studio/src/components/book-create/` 目录下 7 个组件
+- 可参考:`packages/studio/src/components/ChatBar.tsx`(:300-335 SSE 监听模式)
+
+---
+
+## Step 7: Studio — BookCreate 页面重写
+
+重写 `packages/studio/src/pages/BookCreate.tsx`(:289-549)。
+
+**当前**:双栏 grid(草案卡片 + textarea + 3 个按钮)
+
+**改为**:
+- 单栏对话流布局
+- 状态管理:`draft`(当前字段值)、`rounds`(轮次数组)、`streaming`(当前流式文本)、`ready`(是否就绪)
+- 移除:`refreshDraft` 轮询逻辑(:308-350)、`buildCreationDraftSummary`(:201-226)、双栏 grid JSX
+- 保留:`canCreateFromDraft`(:143-156)、`waitForBookReady`(:240-287)、`handleCreate`(:382-404)、`handleDiscard`(:406-420)
+- 新增:SSE 监听 `draft:delta` 事件驱动 `StreamMessage` 渲染
+- 新增:内嵌字段 onChange → 更新本地 draft state
+- 新增:发送时把 draft 含用户修改一起 POST 到 `/agent`
+
+**涉及文件**:
+- 重写:`packages/studio/src/pages/BookCreate.tsx`
+- 可删除的导出(如其他地方未引用):`buildCreationDraftSummary`、`DraftSummaryRow`、`pickValidValue`、`defaultChapterWordsForLanguage`、`platformOptionsForLanguage`、`resolveDraftInstruction`
+
+---
+
+## Step 8: TUI — 流式渲染 + `/new` 使用说明
+
+TUI 已有流式基础设施(`chatStreamBridge` + `appendStreamingAssistantChunk`),但只给 `chat` intent 用。需要扩展到 `develop_book`。
+
+### 8a. 接入流式渲染
+
+**当前**(dashboard.tsx:361):
+```typescript
+const assistantDraftTimestamp = routed.intent === "chat" ? userTimestamp + 1 : null;
+```
+只有 `chat` intent 设置 timestamp,`develop_book` 不会触发流式。
+
+**改为**:
+```typescript
+const assistantDraftTimestamp = (routed.intent === "chat" || routed.intent === "develop_book")
+  ? userTimestamp + 1 : null;
+```
+
+同时在 `app.ts` 的 tools 创建中接入新的 `onDraftTextDelta` hook:
+```typescript
+// app.ts launchTui 中
+onDraftTextDelta: (text) => {
+  chatStreamBridge.onTextDelta?.(text);
+},
+```
+
+`onDraftTextDelta` 在 Step 3 中已做实时过滤——只推送普通文本,`:::` 标记段会被跳过。所以 TUI 端看到的是纯自然语言的逐字流式输出。
+
+流式完成后,`handleSubmit` 的最终 `summary`(从 `formatTuiResult` 来的 `textContent`)会替换掉流式累积的内容。
+
+### 8b. `/new` 命令使用说明
+
+在 `slash-autocomplete.ts` 的 SLASH_COMMANDS 中为 `/new` 添加中文说明:
+
+**当前**:
+```typescript
+"/new <idea>",
+```
+
+**改为**:
+```typescript
+"/new 输入你的想法  输入你的想法,自动构建新书",
+```
+
+同时在 `i18n.ts` 中新增 `/new` 的首次使用引导文案。当用户首次输入 `/new` 时,在 AI 回复前显示一条系统提示:
+
+```
+· 开始构思新书。直接描述你的想法——题材、世界观、主角、核心冲突都可以。
+  AI 会逐步引导你完善草案,随时用 /draft 查看进度,/create 建书。
+```
+
+**涉及文件**:
+- 修改:`packages/cli/src/tui/dashboard.tsx`(:361 assistantDraftTimestamp 条件)
+- 修改:`packages/cli/src/tui/app.ts`(:135-141 chatStreamBridge + tools hooks)
+- 修改:`packages/cli/src/tui/slash-autocomplete.ts`(:2 `/new` 说明)
+- 修改:`packages/cli/src/tui/i18n.ts`(:96, :184 新增 `/new` 引导文案)
+
+---
+
+## 依赖关系
+
+```
+Step 1 (parser)
+  ↓
+Step 2 (session schema) ← 无依赖,可与 Step 1 并行
+  ↓
+Step 3 (developBookDraft + hooks) ← 依赖 Step 1 + 2
+  ↓
+Step 4 (runtime) ← 依赖 Step 3
+  ├─────────────────────────┐
+  ↓                         ↓
+Step 5 (Studio API SSE)   Step 8 (TUI 流式 + /new 说明)
+  ↓                         ← 依赖 Step 3,可与 5-7 并行
+Step 6 (前端组件)
+  ↓
+Step 7 (BookCreate 重写)
+```
+
+## 可删除的代码
+
+| 文件 | 内容 | 原因 |
+|------|------|------|
+| `project-tools.ts:48-93` | `extractBalancedJsonObject` | 被新 parser 替代 |
+| `project-tools.ts:95-119` | `parseCreationDraftResult` | 被新 parser 替代 |
+| `BookCreate.tsx:67-118` | `PAGE_COPY` 中 idle/helper 相关文案 | 页面结构变了,文案重写 |
+| `BookCreate.tsx:158-226` | `MISSING_FIELD_ZH`、`localizeMissingField`、`stringify`、`buildCreationDraftSummary` | 不再需要字段翻译和摘要构建 |
+| `BookCreate.tsx:308-350` | `refreshDraft` + 轮询 useEffect | 改为 SSE 驱动 |
+| `creation-draft-parser.test.ts` | 全部 | 旧 JSON parser 的测试,被新 parser 测试替代 |

+ 629 - 0
docs/superpowers/plans/2026-04-13-phase-1b-pi-ai-provider.md

@@ -0,0 +1,629 @@
+# Phase 1b: pi-ai 替换 LLM Provider 实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 用 @mariozechner/pi-ai 替换 InkOS 自建的 LLM provider 层,保持外部接口不变(LLMClient, chatCompletion, chatWithTools),内部实现从 openai/anthropic SDK 切换到 pi-ai。
+
+**Architecture:** 保持 `packages/core/src/llm/provider.ts` 的导出接口不变(LLMClient、LLMMessage、chatCompletion、chatWithTools 等),替换内部实现为 pi-ai 的 `streamSimple()`。这样 BaseAgent、12 个 pipeline agent、interaction tools 全部不需要改动。新增 `service-presets.ts` 提供预设服务商配置。
+
+**Tech Stack:** @mariozechner/pi-ai, @mariozechner/pi-agent-core (后续 Phase 1c 用)
+
+**设计 spec:** `docs/infra/studio-routing-and-session.md` Phase 1b 部分
+
+---
+
+## 替换策略
+
+```
+外部接口(不变)              内部实现(替换)
+─────────────────────         ──────────────────
+LLMClient                  → 内含 pi-ai Model 引用
+LLMMessage                 → 不变(转换层映射到 pi-ai Message)
+chatCompletion()           → 内部调用 pi-ai streamSimple()
+chatWithTools()            → 内部调用 pi-ai tool calling
+createLLMClient()          → 内部创建 pi-ai Model
+BaseAgent.chat()           → 不变(调用 chatCompletion)
+12 个 pipeline agents       → 不变(调用 BaseAgent.chat)
+project-tools.ts            → 不变(调用 chatCompletion/chatWithTools)
+```
+
+**为什么不直接暴露 pi-ai 类型?** Phase 1c(pi-agent 交互层)会引入 pi-agent-core,届时主 Agent 直接使用 pi-ai 类型。Phase 1b 只替换底层,不改接口,降低风险。
+
+---
+
+## File Structure
+
+```
+packages/core/src/llm/
+├── provider.ts              ← 重写:内部用 pi-ai,导出接口不变
+├── service-presets.ts        ← 新建:SERVICE_PRESETS 预设服务商
+└── (删除 openai/anthropic SDK 依赖)
+
+packages/core/
+├── package.json             ← 修改:添加 pi-ai,移除 openai + anthropic SDK
+└── src/models/project.ts    ← 修改:LLMConfig 新增 service 字段
+```
+
+---
+
+### Task 1: 安装 pi-ai + 服务商预设
+
+**Files:**
+- Modify: `packages/core/package.json`
+- Create: `packages/core/src/llm/service-presets.ts`
+- Modify: `packages/core/src/models/project.ts`
+
+- [ ] **Step 1: 安装 pi-ai**
+
+```bash
+pnpm --filter @actalk/inkos-core add @mariozechner/pi-ai
+```
+
+- [ ] **Step 2: 创建 service-presets.ts**
+
+```typescript
+// packages/core/src/llm/service-presets.ts
+
+export interface ServicePreset {
+  readonly api: string;
+  readonly baseUrl: string;
+  readonly label: string;
+}
+
+export const SERVICE_PRESETS: Record<string, ServicePreset> = {
+  openai:       { api: "openai-responses",   baseUrl: "https://api.openai.com/v1",                         label: "OpenAI" },
+  anthropic:    { api: "anthropic-messages",  baseUrl: "https://api.anthropic.com",                         label: "Anthropic" },
+  deepseek:     { api: "openai-completions",  baseUrl: "https://api.deepseek.com",                          label: "DeepSeek" },
+  moonshot:     { api: "openai-completions",  baseUrl: "https://api.moonshot.cn/v1",                        label: "Moonshot (Kimi)" },
+  minimax:      { api: "openai-completions",  baseUrl: "https://api.minimax.chat/v1",                       label: "MiniMax" },
+  bailian:      { api: "openai-completions",  baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", label: "百炼 (通义千问)" },
+  zhipu:        { api: "openai-completions",  baseUrl: "https://open.bigmodel.cn/api/paas/v4",              label: "智谱 GLM" },
+  siliconflow:  { api: "openai-completions",  baseUrl: "https://api.siliconflow.cn/v1",                     label: "硅基流动" },
+  ppio:         { api: "openai-completions",  baseUrl: "https://api.ppinfra.com/v3/openai",                 label: "PPIO" },
+  openrouter:   { api: "openai-responses",    baseUrl: "https://openrouter.ai/api/v1",                      label: "OpenRouter" },
+  ollama:       { api: "openai-completions",  baseUrl: "http://localhost:11434/v1",                          label: "Ollama (本地)" },
+  custom:       { api: "openai-completions",  baseUrl: "",                                                   label: "自定义端点" },
+};
+
+export function resolveServicePreset(service: string): ServicePreset | undefined {
+  return SERVICE_PRESETS[service];
+}
+
+export function guessServiceFromBaseUrl(baseUrl: string): string {
+  for (const [key, preset] of Object.entries(SERVICE_PRESETS)) {
+    if (key === "custom") continue;
+    if (baseUrl.includes(new URL(preset.baseUrl).hostname)) return key;
+  }
+  return "custom";
+}
+```
+
+- [ ] **Step 3: 更新 LLMConfig schema**
+
+在 `packages/core/src/models/project.ts` 的 `LLMConfigSchema` 中添加 `service` 字段:
+
+在现有 `provider` 字段旁添加:
+
+```typescript
+service: z.string().default("custom"),
+```
+
+- [ ] **Step 4: 导出 service-presets**
+
+在 `packages/core/src/index.ts` 添加:
+
+```typescript
+export { SERVICE_PRESETS, resolveServicePreset, guessServiceFromBaseUrl, type ServicePreset } from "./llm/service-presets.js";
+```
+
+- [ ] **Step 5: 验证构建**
+
+```bash
+pnpm --filter @actalk/inkos-core build 2>&1 | tail -5
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/core/package.json packages/core/src/llm/service-presets.ts packages/core/src/models/project.ts packages/core/src/index.ts pnpm-lock.yaml
+git commit -m "feat(core): add pi-ai dependency and service presets"
+```
+
+---
+
+### Task 2: 重写 provider.ts 核心 — createLLMClient
+
+**Files:**
+- Modify: `packages/core/src/llm/provider.ts`
+
+这是最关键的一步。用 pi-ai 的 Model 替换 OpenAI/Anthropic SDK 实例,但保持 LLMClient 接口不变。
+
+- [ ] **Step 1: 阅读 pi-ai API**
+
+先理解 pi-ai 的核心 API。在 `packages/core` 下运行:
+
+```bash
+node -e "import('@mariozechner/pi-ai').then(m => console.log(Object.keys(m)))"
+```
+
+关键 API:
+- `streamSimple(model, context, options)` — 流式 LLM 调用
+- `Model` 类型 — 模型定义
+- `Message` 类型 — 消息格式
+- `registerApiProvider()` — 注册 provider
+
+- [ ] **Step 2: 修改 LLMClient 接口**
+
+在 `provider.ts` 中,LLMClient 当前持有 `_openai` 和 `_anthropic` SDK 实例。改为持有 pi-ai Model:
+
+```typescript
+export interface LLMClient {
+  readonly provider: "openai" | "anthropic" | string;
+  readonly apiFormat: "chat" | "responses";
+  readonly stream: boolean;
+  // 新增:pi-ai Model 引用
+  readonly _piModel: import("@mariozechner/pi-ai").Model<any>;
+  // 保留向后兼容(逐步移除)
+  readonly _openai?: any;
+  readonly _anthropic?: any;
+  readonly defaults: {
+    readonly temperature: number;
+    readonly maxTokens: number;
+    readonly maxTokensCap: number;
+    readonly thinkingBudget: number;
+    readonly extra?: Record<string, unknown>;
+  };
+}
+```
+
+- [ ] **Step 3: 重写 createLLMClient**
+
+```typescript
+import { type Model, type Api } from "@mariozechner/pi-ai";
+import { resolveServicePreset } from "./service-presets.js";
+
+export function createLLMClient(config: LLMConfig): LLMClient {
+  const service = config.service ?? (config.provider === "anthropic" ? "anthropic" : "custom");
+  const preset = resolveServicePreset(service);
+  const api = preset?.api ?? "openai-completions";
+  const baseUrl = config.baseUrl || preset?.baseUrl || "https://api.openai.com/v1";
+
+  const piModel: Model<any> = {
+    id: config.model,
+    name: config.model,
+    api: api as Api,
+    provider: service,
+    baseUrl,
+    reasoning: false,
+    input: ["text"] as ("text" | "image")[],
+    cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+    contextWindow: 131072,
+    maxTokens: config.maxTokens ?? 8192,
+    ...(config.headers ? { headers: config.headers } : {}),
+  };
+
+  return {
+    provider: config.provider === "anthropic" ? "anthropic" : "openai",
+    apiFormat: config.apiFormat ?? "chat",
+    stream: config.stream ?? true,
+    _piModel: piModel,
+    defaults: {
+      temperature: config.temperature ?? 0.7,
+      maxTokens: config.maxTokens ?? 8192,
+      maxTokensCap: config.maxTokens ?? 8192,
+      thinkingBudget: config.thinkingBudget ?? 0,
+      extra: config.extra,
+    },
+  };
+}
+```
+
+- [ ] **Step 4: 验证构建(允许部分 TS 警告)**
+
+```bash
+pnpm --filter @actalk/inkos-core build 2>&1 | tail -10
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/core/src/llm/provider.ts
+git commit -m "feat(core): rewrite createLLMClient to use pi-ai Model"
+```
+
+---
+
+### Task 3: 重写 chatCompletion — 使用 pi-ai streamSimple
+
+**Files:**
+- Modify: `packages/core/src/llm/provider.ts`
+
+- [ ] **Step 1: 理解 pi-ai streamSimple API**
+
+pi-ai 的流式调用:
+
+```typescript
+import { streamSimple } from "@mariozechner/pi-ai";
+
+const stream = streamSimple(model, {
+  systemPrompt: "...",
+  messages: [{ role: "user", content: [{ type: "text", text: "hello" }] }],
+}, { apiKey: "..." });
+
+for await (const event of stream) {
+  if (event.type === "content") {
+    // event.text — 增量文本
+  }
+}
+// stream.text — 完整文本
+// stream.usage — { inputTokens, outputTokens }
+```
+
+- [ ] **Step 2: 重写 chatCompletion 函数**
+
+保持签名不变,内部用 pi-ai:
+
+```typescript
+export async function chatCompletion(
+  client: LLMClient,
+  model: string,
+  messages: ReadonlyArray<LLMMessage>,
+  options?: {
+    readonly temperature?: number;
+    readonly maxTokens?: number;
+    readonly webSearch?: boolean;
+    readonly onStreamProgress?: OnStreamProgress;
+    readonly onTextDelta?: (text: string) => void;
+  },
+): Promise<LLMResponse> {
+  const { streamSimple } = await import("@mariozechner/pi-ai");
+
+  // 构建 pi-ai 消息格式
+  const systemPrompt = messages.find(m => m.role === "system")?.content ?? "";
+  const piMessages = messages
+    .filter(m => m.role !== "system")
+    .map(m => ({
+      role: m.role as "user" | "assistant",
+      content: [{ type: "text" as const, text: m.content }],
+    }));
+
+  const temp = clampTemperatureForModel(
+    model,
+    options?.temperature ?? client.defaults.temperature,
+  );
+
+  const monitor = createStreamMonitor(options?.onStreamProgress);
+  let fullText = "";
+
+  try {
+    const stream = streamSimple(client._piModel, {
+      systemPrompt,
+      messages: piMessages,
+    }, {
+      apiKey: "", // pi-ai 从环境变量或 model config 获取
+      temperature: temp,
+      maxTokens: options?.maxTokens ?? client.defaults.maxTokens,
+    });
+
+    for await (const event of stream) {
+      if (event.type === "content") {
+        fullText += event.text;
+        monitor.onChunk(event.text);
+        options?.onTextDelta?.(event.text);
+      }
+    }
+
+    monitor.stop();
+
+    return {
+      content: fullText,
+      usage: {
+        promptTokens: stream.usage?.inputTokens ?? 0,
+        completionTokens: stream.usage?.outputTokens ?? 0,
+        totalTokens: (stream.usage?.inputTokens ?? 0) + (stream.usage?.outputTokens ?? 0),
+      },
+    };
+  } catch (e) {
+    monitor.stop();
+    // 部分响应回收:如果已经收到 ≥500 字符,返回部分内容
+    if (fullText.length >= 500) {
+      return {
+        content: fullText,
+        usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
+      };
+    }
+    throw wrapLLMError(e);
+  }
+}
+```
+
+注意:这是简化版本。需要根据 pi-ai 实际 API 调整。关键是保持函数签名和返回值不变。
+
+- [ ] **Step 3: 处理 API key 传递**
+
+pi-ai 通过 `getApiKey` 回调或环境变量获取 API key。需要在 `streamSimple` 的 options 中传入:
+
+```typescript
+const streamOptions = {
+  apiKey: extractApiKey(client),
+  temperature: temp,
+  maxTokens: options?.maxTokens ?? client.defaults.maxTokens,
+};
+```
+
+`extractApiKey` 从 LLMClient 或环境变量中获取 key。需要在 LLMClient 中保存 apiKey 引用。
+
+更新 LLMClient 接口添加:
+```typescript
+readonly _apiKey: string;
+```
+
+在 createLLMClient 中设置:
+```typescript
+_apiKey: config.apiKey ?? process.env.OPENAI_API_KEY ?? "",
+```
+
+- [ ] **Step 4: 运行现有测试**
+
+```bash
+pnpm --filter @actalk/inkos-core test 2>&1 | tail -20
+```
+
+关注 provider.test.ts 的结果——这些测试直接测试 chatCompletion,如果 mock 方式变了需要调整。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/core/src/llm/provider.ts
+git commit -m "feat(core): rewrite chatCompletion to use pi-ai streamSimple"
+```
+
+---
+
+### Task 4: 重写 chatWithTools — 使用 pi-ai tool calling
+
+**Files:**
+- Modify: `packages/core/src/llm/provider.ts`
+
+- [ ] **Step 1: 重写 chatWithTools**
+
+保持签名不变:
+
+```typescript
+export async function chatWithTools(
+  client: LLMClient,
+  model: string,
+  messages: ReadonlyArray<AgentMessage>,
+  tools: ReadonlyArray<ToolDefinition>,
+  options?: {
+    readonly temperature?: number;
+    readonly maxTokens?: number;
+  },
+): Promise<ChatWithToolsResult> {
+  const { streamSimple } = await import("@mariozechner/pi-ai");
+
+  const systemPrompt = messages.find(m => m.role === "system")?.content ?? "";
+  const piMessages = messages
+    .filter(m => m.role !== "system")
+    .map(agentMessageToPiAi);
+
+  const piTools = tools.map(t => ({
+    type: "function" as const,
+    name: t.name,
+    description: t.description,
+    parameters: t.parameters,
+  }));
+
+  const stream = streamSimple(client._piModel, {
+    systemPrompt,
+    messages: piMessages,
+    tools: piTools,
+  }, {
+    apiKey: client._apiKey,
+    temperature: options?.temperature ?? client.defaults.temperature,
+    maxTokens: options?.maxTokens ?? client.defaults.maxTokens,
+  });
+
+  let content = "";
+  const toolCalls: ToolCall[] = [];
+
+  for await (const event of stream) {
+    if (event.type === "content") {
+      content += event.text;
+    }
+    if (event.type === "tool_call") {
+      toolCalls.push({
+        id: event.id,
+        name: event.name,
+        arguments: JSON.stringify(event.arguments),
+      });
+    }
+  }
+
+  return { content, toolCalls };
+}
+```
+
+注意:pi-ai 的 tool call event 结构需要根据实际 API 调整。这是预期的结构。
+
+- [ ] **Step 2: 添加消息转换辅助函数**
+
+```typescript
+function agentMessageToPiAi(msg: AgentMessage): any {
+  if (msg.role === "tool") {
+    return {
+      role: "tool",
+      toolCallId: msg.toolCallId,
+      content: [{ type: "text", text: msg.content }],
+    };
+  }
+  if (msg.role === "assistant" && msg.toolCalls) {
+    return {
+      role: "assistant",
+      content: msg.content ? [{ type: "text", text: msg.content }] : [],
+      toolCalls: msg.toolCalls.map(tc => ({
+        id: tc.id,
+        name: tc.name,
+        arguments: JSON.parse(tc.arguments),
+      })),
+    };
+  }
+  return {
+    role: msg.role,
+    content: [{ type: "text", text: msg.content ?? "" }],
+  };
+}
+```
+
+- [ ] **Step 3: 验证构建 + 测试**
+
+```bash
+pnpm --filter @actalk/inkos-core build 2>&1 | tail -5
+pnpm --filter @actalk/inkos-core test 2>&1 | tail -20
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add packages/core/src/llm/provider.ts
+git commit -m "feat(core): rewrite chatWithTools to use pi-ai tool calling"
+```
+
+---
+
+### Task 5: 清理旧代码 + 移除 SDK 依赖
+
+**Files:**
+- Modify: `packages/core/src/llm/provider.ts`
+- Modify: `packages/core/package.json`
+
+- [ ] **Step 1: 删除旧的 provider-specific 实现函数**
+
+移除 provider.ts 中不再需要的函数:
+- `chatCompletionOpenAIChat`, `chatCompletionOpenAIChatSync`
+- `chatCompletionOpenAIResponses`, `chatCompletionOpenAIResponsesSync`
+- `chatCompletionAnthropic`, `chatCompletionAnthropicSync`
+- `chatWithToolsOpenAIChat`, `chatWithToolsOpenAIResponses`, `chatWithToolsAnthropic`
+- `agentMessagesToOpenAIChat`, `agentMessagesToResponsesInput`, `agentMessagesToAnthropic`
+
+保留:
+- 所有导出类型(LLMClient, LLMMessage, LLMResponse, etc.)
+- `createLLMClient`, `chatCompletion`, `chatWithTools`(新实现)
+- `createStreamMonitor`(streaming 进度监控)
+- `clampTemperatureForModel`(温度限制)
+- 错误处理辅助函数
+
+- [ ] **Step 2: 移除 openai + anthropic SDK**
+
+```bash
+pnpm --filter @actalk/inkos-core remove openai @anthropic-ai/sdk
+```
+
+- [ ] **Step 3: 删除 SDK import**
+
+在 provider.ts 顶部删除:
+```typescript
+// 删除
+import OpenAI from "openai";
+import Anthropic from "@anthropic-ai/sdk";
+```
+
+- [ ] **Step 4: 验证构建 + 全量测试**
+
+```bash
+pnpm build 2>&1 | tail -10
+pnpm test 2>&1 | tail -30
+```
+
+部分测试可能因为 mock OpenAI/Anthropic SDK 而失败,需要更新测试 mock 为 pi-ai。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/core/
+git commit -m "refactor(core): remove openai + anthropic SDK, pi-ai is sole LLM provider"
+```
+
+---
+
+### Task 6: 更新测试 mock
+
+**Files:**
+- Modify: `packages/core/src/__tests__/provider.test.ts`
+- Modify: `packages/core/src/__tests__/pipeline-runner.test.ts`
+
+- [ ] **Step 1: 更新 provider.test.ts**
+
+现有测试 mock OpenAI/Anthropic SDK。需要改为 mock pi-ai 的 `streamSimple`:
+
+```typescript
+import { vi } from "vitest";
+
+// Mock pi-ai
+vi.mock("@mariozechner/pi-ai", () => ({
+  streamSimple: vi.fn(async function* () {
+    yield { type: "content", text: "mocked response" };
+    return {
+      text: "mocked response",
+      usage: { inputTokens: 10, outputTokens: 20 },
+    };
+  }),
+}));
+```
+
+- [ ] **Step 2: 更新 pipeline-runner.test.ts**
+
+类似地更新 LLM mock。
+
+- [ ] **Step 3: 运行全量测试**
+
+```bash
+pnpm test 2>&1 | tail -30
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add packages/core/src/__tests__/
+git commit -m "test(core): update LLM mocks from openai/anthropic SDK to pi-ai"
+```
+
+---
+
+### Task 7: 端到端验证
+
+- [ ] **Step 1: 全量构建**
+
+```bash
+pnpm build 2>&1 | tail -10
+```
+
+- [ ] **Step 2: 全量测试**
+
+```bash
+pnpm test 2>&1 | tail -30
+```
+
+- [ ] **Step 3: 手动验证**
+
+启动 `inkos studio`,测试:
+- 建书流程(chatWithTools 调用 create_book tool)
+- 写章节(pipeline agents 通过 BaseAgent.chat → chatCompletion)
+- 聊天(project-tools.ts 的 chat 函数)
+
+- [ ] **Step 4: Commit(如有修复)**
+
+```bash
+git add -A
+git commit -m "fix(core): phase 1b polish from e2e testing"
+```
+
+---
+
+## 风险与注意事项
+
+1. **pi-ai streamSimple 的实际 API 可能与预期不同** — Task 2-4 的代码是基于 pi-ai 文档推测的,实际实现时需要先读 pi-ai 源码确认 API
+2. **thinking model 处理** — kimi-k2.5 等模型的静默思考阶段在 pi-ai 中的行为需要验证
+3. **部分响应回收** — InkOS 的 ≥500 字符部分响应回收逻辑需要在 pi-ai 层重新实现
+4. **web search** — 当前 OpenAI 原生支持 web_search,pi-ai 可能需要手动实现
+5. **测试更新工作量** — 大量测试 mock OpenAI/Anthropic SDK,都需要改为 mock pi-ai

+ 889 - 0
docs/superpowers/plans/2026-04-13-phase-1c-pi-agent-interaction.md

@@ -0,0 +1,889 @@
+# Phase 1c: pi-agent 交互层实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 用 pi-agent-core 的 Agent 类替换 InkOS 的交互层,实现每个 session 一个 Agent 实例 + AgentTool 路由到 pipeline + 文件工具 + 流式输出。
+
+**Architecture:** 每个 BookSession 对应一个 Agent 实例(内存缓存,消息从 session 文件恢复)。Agent 通过 `pipeline` tool 调用重操作,通过 `read`/`edit`/`grep`/`ls` 工具操作文件,无 tool 匹配时自然对话。流式输出通过 Agent.subscribe() 事件 → SSE 广播实现。目录结构为 `packages/core/src/agent/` 独立模块,Studio 和 TUI 均可接入。
+
+**Tech Stack:** @mariozechner/pi-agent-core, @mariozechner/pi-ai, Zustand, Hono SSE
+
+**设计 spec:** `docs/infra/studio-routing-and-session.md` Phase 1c 部分
+
+---
+
+## Agent 实例生命周期
+
+```
+用户打开 #/book/{bookId}
+  → POST /api/agent { instruction, sessionId }
+  → 检查内存中是否有该 sessionId 的 Agent
+    → 有 → agent.prompt(instruction)
+    → 无 → 从 BookSession 文件加载 messages → 创建 Agent → agent.prompt(instruction)
+
+Agent 缓存策略:
+  Map<sessionId, Agent>(内存)
+  └── 5 分钟无活动后自动回收
+  └── session 切换时旧 Agent 保留(可能回来)
+```
+
+## 流式输出
+
+```
+Agent.subscribe(event)
+  │
+  ├── message_update (text_delta)  → broadcast("draft:delta", { text })
+  ├── tool_execution_start         → broadcast("tool:start", { tool, args })
+  ├── tool_execution_update        → broadcast("tool:update", { tool, partialResult })
+  ├── tool_execution_end           → broadcast("tool:end", { tool, result })
+  ├── message_end                  → broadcast("agent:message", { text })
+  └── agent_end                    → broadcast("agent:complete")
+```
+
+## 策略:独立模块,渐进替换
+
+```
+新模块 packages/core/src/agent/(Studio + 未来 TUI 共用)
+  agent-session.ts       — Agent 实例管理(创建、缓存、恢复)
+  agent-tools.ts         — pipeline tool + file tools
+  agent-system-prompt.ts — system prompt 构建
+  index.ts               — barrel exports
+
+现有路径(TUI,暂保留,后续替换)
+  packages/core/src/interaction/
+    nl-router.ts, runtime.ts, project-control.ts, project-tools.ts
+```
+
+---
+
+## File Structure
+
+```
+packages/core/src/agent/          ← 新建:独立 agent 模块
+├── agent-session.ts              ← Agent 实例管理(per-session 缓存 + 恢复)
+├── agent-tools.ts                ← pipeline tool + read + edit + grep + ls
+├── agent-system-prompt.ts        ← system prompt 构建
+└── index.ts                      ← barrel exports
+
+packages/core/src/interaction/    ← 保留(TUI 兼容,后续渐进替换)
+├── runtime.ts
+├── nl-router.ts
+├── project-tools.ts
+└── project-control.ts
+
+packages/studio/src/
+├── api/server.ts                 ← 修改:POST /api/agent 走 agent-session
+└── store/chat/
+    └── slices/message/action.ts  ← 修改:监听流式事件
+```
+
+---
+
+### Task 1: 安装 pi-agent-core + Agent system prompt
+
+**Files:**
+- Verify: `packages/core/package.json` (pi-agent-core 已安装)
+- Create: `packages/core/src/agent/agent-system-prompt.ts`
+
+- [ ] **Step 1: 确认 pi-agent-core 已安装**
+
+```bash
+grep "pi-agent-core" packages/core/package.json
+```
+
+如果没有:
+```bash
+pnpm --filter @actalk/inkos-core add @mariozechner/pi-agent-core
+```
+
+- [ ] **Step 2: 创建 system prompt**
+
+```typescript
+// packages/core/src/agent/agent-system-prompt.ts
+
+export function buildAgentSystemPrompt(bookId: string | null, language: string): string {
+  const isZh = language === "zh";
+
+  if (!bookId) {
+    return isZh
+      ? `你是 InkOS 建书助手。用户想创建一本新书。
+通过对话了解用户的想法(题材、世界观、主角、核心冲突),然后调用 pipeline 工具的 create_book action 来创建书籍。
+保持简短、自然的对话风格。不要一次问太多问题。`
+      : `You are the InkOS book creation assistant. The user wants to create a new book.
+Through conversation, understand their ideas (genre, world, protagonist, core conflict), then call the pipeline tool with the create_book action.
+Keep responses brief and conversational.`;
+  }
+
+  return isZh
+    ? `你是 InkOS 写作助手,当前正在处理书籍「${bookId}」。
+
+你可以使用以下工具:
+- **pipeline** — 执行重操作:写下一章(write_next)、修订章节(revise)、审计(audit)、建书(create_book)、导出(export)
+- **read** — 读取书籍的设定文件或章节内容
+- **edit** — 编辑设定文件(如修改角色名、调整世界观)
+- **grep** — 搜索内容(如"哪一章提到了某个角色")
+- **ls** — 列出文件或章节
+
+当用户的请求涉及写章节、修订、审计等重操作时,使用 pipeline 工具。
+当用户问设定相关的问题时,先用 read 读取对应文件再回答。
+当用户想做小修改时(改名字、调设定),用 edit 工具直接修改。
+其他情况直接对话回答。`
+    : `You are the InkOS writing assistant, working on book "${bookId}".
+
+Available tools:
+- **pipeline** — Heavy operations: write_next, revise, audit, create_book, export
+- **read** — Read truth files or chapter content
+- **edit** — Edit truth files (rename characters, adjust world settings)
+- **grep** — Search content across chapters
+- **ls** — List files or chapters
+
+Use the pipeline tool for chapter writing, revision, and auditing.
+Use read/edit for settings inquiries and small changes.
+Chat directly for other questions.`;
+}
+```
+
+- [ ] **Step 3: 导出**
+
+在 `packages/core/src/index.ts` 添加:
+
+```typescript
+export { buildAgentSystemPrompt } from "./interaction/agent-system-prompt.js";
+```
+
+- [ ] **Step 4: 验证构建 + 测试**
+
+```bash
+pnpm --filter @actalk/inkos-core build 2>&1 | tail -3
+pnpm --filter @actalk/inkos-core test 2>&1 | tail -5
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/core/src/agent/agent-system-prompt.ts packages/core/src/index.ts packages/core/package.json pnpm-lock.yaml
+git commit -m "feat(core): add pi-agent system prompt for book interaction"
+```
+
+---
+
+### Task 2: 定义 AgentTools — SubAgentTool + read + edit + grep + ls
+
+**Files:**
+- Create: `packages/core/src/agent/agent-tools.ts`
+
+核心任务。SubAgentTool 负责委托子智能体工作流(architect/writer/auditor/reviser),文件工具直接操作。
+
+- [ ] **Step 1: 创建 agent-tools.ts**
+
+```typescript
+// packages/core/src/agent/agent-tools.ts
+
+import { Type } from "@sinclair/typebox";
+import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
+import { readFile, readdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import type { PipelineRunner } from "../pipeline/runner.js";
+import type { StateManager } from "../pipeline/state.js";
+
+// -- Helper --
+
+function textResult(text: string): AgentToolResult<void> {
+  return { content: [{ type: "text", text }], details: undefined as void };
+}
+
+// -- SubAgentTool (委托子智能体工作流) --
+
+const SubAgentParams = Type.Object({
+  agent: Type.String({ description: "子智能体: architect(建书) | writer(写章节) | auditor(审计) | reviser(修订) | exporter(导出)" }),
+  instruction: Type.String({ description: "自然语言指令,描述要做什么" }),
+  bookId: Type.Optional(Type.String({ description: "书籍 ID" })),
+});
+
+export function createSubAgentTool(
+  pipeline: PipelineRunner,
+  state: StateManager,
+  projectRoot: string,
+): AgentTool<typeof SubAgentParams> {
+  return {
+    name: "sub_agent",
+    description: "委托子智能体执行写作工作流。architect 建书,writer 写下一章,auditor 审计章节质量,reviser 修订章节,exporter 导出书籍。",
+    label: "子智能体",
+    parameters: SubAgentParams,
+    execute: async (toolCallId, { agent, instruction, bookId }, signal, onUpdate) => {
+      switch (agent) {
+        case "architect": {
+          // 从 instruction 中提取建书参数(LLM 已组织好自然语言)
+          // 简化版:直接用 instruction 作为 brief,需要 title/genre 等则由主 Agent 在 instruction 中说明
+          const titleMatch = instruction.match(/[《「]([^》」]+)[》」]/) ?? instruction.match(/title[::]\s*(.+)/i);
+          const title = titleMatch?.[1]?.trim() ?? `book-${Date.now()}`;
+          const id = title.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-").replace(/^-|-$/g, "") || `book-${Date.now()}`;
+          
+          onUpdate?.({ content: [{ type: "text", text: "正在生成基础设定..." }], details: undefined as void });
+          
+          await pipeline.initBook(
+            { id, title, genre: "urban", platform: "other", language: "zh", chapterWordCount: 3000, targetChapters: 200, status: "outlining", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
+            { externalContext: instruction },
+          );
+          return textResult(`书籍「${title}」(${id}) 已创建。Architect 已生成完整 foundation。`);
+        }
+
+        case "writer": {
+          if (!bookId) return textResult("writer 需要 bookId,请先选择一本书。");
+          onUpdate?.({ content: [{ type: "text", text: "正在写章节..." }], details: undefined as void });
+          const result = await pipeline.writeNextChapter(bookId);
+          return textResult(`第${result.chapterNumber}章「${result.title ?? ""}」已完成,${result.wordCount ?? 0}字。`);
+        }
+
+        case "auditor": {
+          if (!bookId) return textResult("auditor 需要 bookId。");
+          onUpdate?.({ content: [{ type: "text", text: "正在审计..." }], details: undefined as void });
+          const result = await pipeline.auditDraft(bookId);
+          return textResult(`审计完成(第${result.chapterNumber}章):${JSON.stringify(result, null, 2).slice(0, 2000)}`);
+        }
+
+        case "reviser": {
+          if (!bookId) return textResult("reviser 需要 bookId。");
+          const isRewrite = /重写|rewrite/i.test(instruction);
+          onUpdate?.({ content: [{ type: "text", text: isRewrite ? "正在重写..." : "正在修订..." }], details: undefined as void });
+          await pipeline.reviseDraft(bookId, undefined, isRewrite ? "rewrite" : "local-fix");
+          return textResult("修订完成。");
+        }
+
+        case "exporter": {
+          if (!bookId) return textResult("exporter 需要 bookId。");
+          return textResult("导出功能待实现。");
+        }
+
+        default:
+          return textResult(`未知子智能体: ${agent}。可用: architect, writer, auditor, reviser, exporter`);
+      }
+    },
+  };
+}
+
+// -- Read Tool --
+
+const ReadParams = Type.Object({
+  path: Type.String({ description: "相对于书籍目录的文件路径,如 story/story_bible.md 或 chapters/001.md" }),
+});
+
+export function createReadTool(projectRoot: string): AgentTool<typeof ReadParams> {
+  return {
+    name: "read",
+    description: "读取书籍的设定文件或章节内容",
+    label: "读取文件",
+    parameters: ReadParams,
+    execute: async (toolCallId, { path }) => {
+      try {
+        const content = await readFile(join(projectRoot, "books", path), "utf-8");
+        return textResult(content.slice(0, 10000));
+      } catch {
+        return textResult(`文件不存在: ${path}`);
+      }
+    },
+  };
+}
+
+// -- Edit Tool --
+
+const EditParams = Type.Object({
+  path: Type.String({ description: "相对于书籍目录的文件路径" }),
+  oldText: Type.String({ description: "要替换的原文" }),
+  newText: Type.String({ description: "替换后的新文" }),
+});
+
+export function createEditTool(projectRoot: string): AgentTool<typeof EditParams> {
+  return {
+    name: "edit",
+    description: "编辑书籍设定文件(精确替换文本)",
+    label: "编辑文件",
+    parameters: EditParams,
+    execute: async (toolCallId, { path, oldText, newText }) => {
+      const filePath = join(projectRoot, "books", path);
+      try {
+        const content = await readFile(filePath, "utf-8");
+        if (!content.includes(oldText)) {
+          return textResult(`未找到要替换的文本: "${oldText.slice(0, 50)}..."`);
+        }
+        await writeFile(filePath, content.replace(oldText, newText));
+        return textResult("已替换。");
+      } catch {
+        return textResult(`文件不存在: ${path}`);
+      }
+    },
+  };
+}
+
+// -- Grep Tool --
+
+const GrepParams = Type.Object({
+  pattern: Type.String({ description: "搜索文本" }),
+  bookId: Type.String({ description: "书籍 ID" }),
+});
+
+export function createGrepTool(projectRoot: string): AgentTool<typeof GrepParams> {
+  return {
+    name: "grep",
+    description: "在书籍的章节和设定文件中搜索内容",
+    label: "搜索内容",
+    parameters: GrepParams,
+    execute: async (toolCallId, { pattern, bookId }) => {
+      const bookDir = join(projectRoot, "books", bookId);
+      const results: string[] = [];
+      for (const dir of ["story", "chapters"]) {
+        let files: string[];
+        try { files = await readdir(join(bookDir, dir)); } catch { continue; }
+        for (const file of files) {
+          if (!file.endsWith(".md")) continue;
+          const content = await readFile(join(bookDir, dir, file), "utf-8");
+          if (content.includes(pattern)) {
+            const matches = content.split("\n").filter(l => l.includes(pattern)).slice(0, 3);
+            results.push(`${dir}/${file}: ${matches.join(" | ")}`);
+          }
+        }
+      }
+      return textResult(results.length > 0 ? results.join("\n") : `未找到 "${pattern}"。`);
+    },
+  };
+}
+
+// -- Ls Tool --
+
+const LsParams = Type.Object({
+  bookId: Type.String({ description: "书籍 ID" }),
+  dir: Type.Optional(Type.String({ description: "子目录: story | chapters" })),
+});
+
+export function createLsTool(projectRoot: string): AgentTool<typeof LsParams> {
+  return {
+    name: "ls",
+    description: "列出书籍的文件和章节",
+    label: "列出文件",
+    parameters: LsParams,
+    execute: async (toolCallId, { bookId, dir }) => {
+      const bookDir = join(projectRoot, "books", bookId);
+      const output: string[] = [];
+      for (const d of dir ? [dir] : ["story", "chapters"]) {
+        try {
+          const files = await readdir(join(bookDir, d));
+          output.push(`${d}/: ${files.join(", ")}`);
+        } catch {
+          output.push(`${d}/: (不存在)`);
+        }
+      }
+      return textResult(output.join("\n"));
+    },
+  };
+}
+```
+
+- [ ] **Step 2: 验证构建 + 测试**
+
+```bash
+pnpm --filter @actalk/inkos-core build 2>&1 | tail -5
+pnpm --filter @actalk/inkos-core test 2>&1 | tail -5
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add packages/core/src/agent/agent-tools.ts
+git commit -m "feat(core): define SubAgentTool + file tools (read/edit/grep/ls)"
+```
+
+---
+
+### Task 3: Agent Session 管理 — per-session 缓存
+
+**Files:**
+- Create: `packages/core/src/agent/agent-session.ts`
+- Create: `packages/core/src/agent/index.ts`
+
+- [ ] **Step 1: 创建 agent-session.ts**
+
+```typescript
+// packages/core/src/agent/agent-session.ts
+
+import { Agent } from "@mariozechner/pi-agent-core";
+import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
+import type { Model, Api } from "@mariozechner/pi-ai";
+import { buildAgentSystemPrompt } from "./agent-system-prompt.js";
+import { createPipelineTool, createReadTool, createEditTool, createGrepTool, createLsTool } from "./agent-tools.js";
+import type { PipelineRunner } from "../pipeline/runner.js";
+import type { StateManager } from "../pipeline/state.js";
+
+// -- Agent 实例缓存 --
+
+const agentCache = new Map<string, { agent: Agent; lastActive: number }>();
+const CACHE_TTL = 5 * 60 * 1000; // 5 分钟无活动回收
+
+// 定期清理过期 Agent
+setInterval(() => {
+  const now = Date.now();
+  for (const [id, entry] of agentCache) {
+    if (now - entry.lastActive > CACHE_TTL) {
+      agentCache.delete(id);
+    }
+  }
+}, 60_000);
+
+export interface AgentSessionConfig {
+  readonly model: Model<Api>;
+  readonly apiKey: string;
+  readonly pipeline: PipelineRunner;
+  readonly state: StateManager;
+  readonly projectRoot: string;
+  readonly bookId: string | null;
+  readonly sessionId: string;
+  readonly language: string;
+  readonly onEvent?: (event: AgentEvent) => void;
+}
+
+export interface AgentSessionResult {
+  readonly responseText: string;
+  readonly messages: ReadonlyArray<{ role: string; content: string }>;
+}
+
+/**
+ * 从 BookSession 消息恢复 Agent 的 messages 数组。
+ */
+function restoreAgentMessages(
+  messages: ReadonlyArray<{ role: string; content: string }>,
+  model: Model<Api>,
+): AgentMessage[] {
+  return messages
+    .filter((m) => m.role === "user" || m.role === "assistant")
+    .map((m) => {
+      if (m.role === "user") {
+        return { role: "user" as const, content: m.content, timestamp: Date.now() };
+      }
+      return {
+        role: "assistant" as const,
+        content: [{ type: "text" as const, text: m.content }],
+        api: model.api,
+        provider: model.provider,
+        model: model.id,
+        usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
+        stopReason: "stop" as const,
+        timestamp: Date.now(),
+      };
+    });
+}
+
+/**
+ * 从 Agent 的 messages 提取纯文本用于持久化。
+ */
+function extractPlainMessages(messages: AgentMessage[]): ReadonlyArray<{ role: string; content: string }> {
+  return messages.map((m: any) => ({
+    role: m.role as string,
+    content: typeof m.content === "string"
+      ? m.content
+      : Array.isArray(m.content)
+        ? m.content.filter((c: any) => c.type === "text").map((c: any) => c.text).join("")
+        : "",
+  }));
+}
+
+/**
+ * 提取最后一条 assistant 消息的文本。
+ */
+function extractLastResponse(messages: AgentMessage[]): string {
+  const last = [...messages].reverse().find((m: any) => m.role === "assistant");
+  if (!last || !("content" in last)) return "";
+  const content = (last as any).content;
+  if (typeof content === "string") return content;
+  if (Array.isArray(content)) {
+    return content.filter((c: any) => c.type === "text").map((c: any) => c.text).join("");
+  }
+  return "";
+}
+
+/**
+ * 获取或创建 sessionId 对应的 Agent 实例。
+ * 如果缓存中没有,从 initialMessages 恢复历史。
+ */
+function getOrCreateAgent(
+  config: AgentSessionConfig,
+  initialMessages?: ReadonlyArray<{ role: string; content: string }>,
+): Agent {
+  const cached = agentCache.get(config.sessionId);
+  if (cached) {
+    cached.lastActive = Date.now();
+    // 更新事件监听(每次请求可能有不同的 onEvent)
+    return cached.agent;
+  }
+
+  const agent = new Agent();
+  agent.state.model = config.model;
+  agent.state.systemPrompt = buildAgentSystemPrompt(config.bookId, config.language);
+  agent.getApiKey = () => config.apiKey;
+  agent.state.tools = [
+    createPipelineTool(config.pipeline, config.state, config.projectRoot),
+    createReadTool(config.projectRoot),
+    createEditTool(config.projectRoot),
+    createGrepTool(config.projectRoot),
+    createLsTool(config.projectRoot),
+  ];
+  agent.transformContext = async (msgs) => msgs.slice(-20);
+
+  // 恢复历史消息
+  if (initialMessages && initialMessages.length > 0) {
+    agent.state.messages = restoreAgentMessages(initialMessages, config.model);
+  }
+
+  agentCache.set(config.sessionId, { agent, lastActive: Date.now() });
+  return agent;
+}
+
+/**
+ * 执行一次 Agent 对话。
+ * 优先使用缓存的 Agent 实例(保持完整上下文),无缓存则从 BookSession 恢复。
+ */
+export async function runAgentSession(
+  config: AgentSessionConfig,
+  userMessage: string,
+  initialMessages?: ReadonlyArray<{ role: string; content: string }>,
+): Promise<AgentSessionResult> {
+  const agent = getOrCreateAgent(config, initialMessages);
+
+  // 订阅事件(流式输出)
+  let unsubscribe: (() => void) | undefined;
+  if (config.onEvent) {
+    unsubscribe = agent.subscribe(async (event) => {
+      config.onEvent!(event);
+    });
+  }
+
+  try {
+    await agent.prompt(userMessage);
+
+    return {
+      responseText: extractLastResponse(agent.state.messages),
+      messages: extractPlainMessages(agent.state.messages),
+    };
+  } finally {
+    unsubscribe?.();
+  }
+}
+
+/**
+ * 清除某个 session 的 Agent 缓存(如 session 切换或删除)。
+ */
+export function evictAgentCache(sessionId: string): void {
+  agentCache.delete(sessionId);
+}
+```
+
+- [ ] **Step 2: 创建 barrel export**
+
+```typescript
+// packages/core/src/agent/index.ts
+
+export { buildAgentSystemPrompt } from "./agent-system-prompt.js";
+export { createPipelineTool, createReadTool, createEditTool, createGrepTool, createLsTool } from "./agent-tools.js";
+export { runAgentSession, evictAgentCache, type AgentSessionConfig, type AgentSessionResult } from "./agent-session.js";
+```
+
+- [ ] **Step 3: 在 core index.ts 导出 agent 模块**
+
+在 `packages/core/src/index.ts` 添加:
+
+```typescript
+// Agent (pi-agent integration)
+export * from "./agent/index.js";
+```
+
+- [ ] **Step 4: 验证构建 + 测试**
+
+```bash
+pnpm --filter @actalk/inkos-core build 2>&1 | tail -5
+pnpm --filter @actalk/inkos-core test 2>&1 | tail -5
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/core/src/agent/agent-session.ts packages/core/src/agent/index.ts packages/core/src/index.ts
+git commit -m "feat(core): add per-session Agent cache with pi-agent"
+```
+
+---
+
+### Task 4: Studio API — 切换 POST /api/agent 到 pi-agent
+
+**Files:**
+- Modify: `packages/studio/src/api/server.ts`
+
+- [ ] **Step 1: 读取当前 POST /api/agent 端点**
+
+读取 `server.ts` 中 POST /api/agent 的完整代码(约 line 627-711)。
+
+- [ ] **Step 2: 添加 pi-agent 路径**
+
+在现有 POST /api/agent 端点中,添加一个 `usePiAgent` 判断。初期通过 query param `?agent=pi` 启用新路径,方便 A/B 测试:
+
+```typescript
+app.post("/api/agent", async (c) => {
+  const { instruction, activeBookId, sessionId } = await c.req.json<{
+    instruction: string;
+    activeBookId?: string;
+    sessionId?: string;
+  }>();
+
+  if (!instruction?.trim()) {
+    return c.json({ error: "instruction is required" }, 400);
+  }
+
+  const usePiAgent = c.req.query("agent") === "pi" || true; // 默认启用
+
+  if (usePiAgent) {
+    return handlePiAgentRequest(c, root, instruction, activeBookId, sessionId, broadcast, state);
+  }
+
+  // ... 旧路径保留 ...
+});
+```
+
+新的 handler 函数:
+
+```typescript
+import { runAgentInteraction, type AgentInteractionConfig } from "@actalk/inkos-core";
+
+async function handlePiAgentRequest(
+  c: any,
+  root: string,
+  instruction: string,
+  activeBookId: string | undefined,
+  sessionId: string | undefined,
+  broadcast: (event: string, data: unknown) => void,
+  state: StateManager,
+) {
+  broadcast("agent:start", { instruction, activeBookId });
+
+  try {
+    const config = await loadCurrentProjectConfig({ requireApiKey: true });
+    const pipeline = new PipelineRunner(buildPipelineConfig(config));
+
+    // 构建 pi-ai Model
+    const { createLLMClient } = await import("@actalk/inkos-core");
+    const client = createLLMClient(config.llm);
+
+    // 加载 BookSession 历史
+    let bookSession = sessionId
+      ? await loadBookSession(root, sessionId) ?? await findOrCreateBookSession(root, activeBookId ?? null)
+      : await findOrCreateBookSession(root, activeBookId ?? null);
+
+    const initialMessages = bookSession.messages
+      .filter(m => m.role === "user" || m.role === "assistant")
+      .map(m => ({ role: m.role as "user" | "assistant", content: m.content }));
+
+    // 执行 pi-agent 对话
+    const result = await runAgentInteraction(
+      {
+        model: client._piModel,
+        apiKey: client._apiKey,
+        pipeline,
+        state,
+        projectRoot: root,
+        bookId: activeBookId ?? null,
+        language: config.language ?? "zh",
+        onEvent: (event) => {
+          if (event.type === "message_update") {
+            const evt = event.assistantMessageEvent;
+            if (evt.type === "text_delta") {
+              broadcast("draft:delta", { text: evt.delta });
+            }
+          }
+          if (event.type === "tool_execution_start") {
+            broadcast("tool:start", { tool: event.toolName, args: event.args });
+          }
+          if (event.type === "tool_execution_end") {
+            broadcast("tool:end", { tool: event.toolName });
+          }
+        },
+      },
+      instruction,
+      initialMessages,
+    );
+
+    // 持久化到 BookSession
+    const userMsg = { role: "user" as const, content: instruction, timestamp: Date.now() };
+    bookSession = appendBookSessionMessage(bookSession, userMsg);
+    if (result.responseText) {
+      const assistantMsg = { role: "assistant" as const, content: result.responseText, timestamp: Date.now() + 1 };
+      bookSession = appendBookSessionMessage(bookSession, assistantMsg);
+    }
+    await persistBookSession(root, bookSession);
+
+    broadcast("agent:complete", { instruction, activeBookId });
+
+    return c.json({
+      response: result.responseText,
+      session: { sessionId: bookSession.sessionId },
+    });
+  } catch (e) {
+    const msg = e instanceof Error ? e.message : String(e);
+    broadcast("agent:error", { instruction, activeBookId, error: msg });
+    return c.json({ error: { code: "AGENT_ERROR", message: msg } }, 500);
+  }
+}
+```
+
+- [ ] **Step 3: 验证构建**
+
+```bash
+pnpm --filter @actalk/inkos-studio build 2>&1 | tail -5
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add packages/studio/src/api/server.ts
+git commit -m "feat(studio): route POST /api/agent through pi-agent interaction"
+```
+
+---
+
+### Task 5: Frontend Store — 处理新的事件流
+
+**Files:**
+- Modify: `packages/studio/src/store/chat/slices/message/action.ts`
+
+- [ ] **Step 1: 更新 sendMessage 处理新事件**
+
+当前 sendMessage 已经监听 `draft:delta` SSE 事件。pi-agent 路径也广播 `draft:delta`,所以**前端不需要大改**。
+
+但需要额外处理新的 tool 事件:
+
+```typescript
+// 在 sendMessage 的 EventSource 监听中添加
+streamEs.addEventListener("tool:start", (e: MessageEvent) => {
+  try {
+    const d = e.data ? JSON.parse(e.data) : null;
+    if (d?.tool) {
+      // 可以更新 UI 显示正在执行哪个工具
+      // 目前只做日志,后续侧边栏 Progress 会用到
+      console.log("[tool:start]", d.tool);
+    }
+  } catch { /* ignore */ }
+});
+```
+
+这一步改动很小,主要是确保前端和新后端兼容。
+
+- [ ] **Step 2: 验证构建**
+
+```bash
+pnpm --filter @actalk/inkos-studio build 2>&1 | tail -5
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add packages/studio/src/store/chat/slices/message/action.ts
+git commit -m "feat(studio): handle tool events from pi-agent in message store"
+```
+
+---
+
+### Task 6: 测试
+
+**Files:**
+- Create: `packages/core/src/__tests__/agent-interaction.test.ts`
+
+- [ ] **Step 1: 编写 Agent 交互测试**
+
+```typescript
+import { describe, expect, it, vi } from "vitest";
+import { buildAgentSystemPrompt } from "../interaction/agent-system-prompt.js";
+
+describe("agent-system-prompt", () => {
+  it("builds Chinese prompt for new book", () => {
+    const prompt = buildAgentSystemPrompt(null, "zh");
+    expect(prompt).toContain("建书助手");
+    expect(prompt).toContain("pipeline");
+  });
+
+  it("builds Chinese prompt for existing book", () => {
+    const prompt = buildAgentSystemPrompt("my-book", "zh");
+    expect(prompt).toContain("my-book");
+    expect(prompt).toContain("read");
+    expect(prompt).toContain("edit");
+    expect(prompt).toContain("grep");
+    expect(prompt).toContain("pipeline");
+  });
+
+  it("builds English prompt", () => {
+    const prompt = buildAgentSystemPrompt("novel", "en");
+    expect(prompt).toContain("novel");
+    expect(prompt).toContain("pipeline");
+  });
+});
+```
+
+- [ ] **Step 2: 运行测试**
+
+```bash
+pnpm --filter @actalk/inkos-core test 2>&1 | tail -10
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add packages/core/src/__tests__/agent-interaction.test.ts
+git commit -m "test(core): add agent system prompt tests"
+```
+
+---
+
+### Task 7: 端到端验证
+
+- [ ] **Step 1: 全量构建**
+
+```bash
+pnpm build 2>&1 | tail -10
+```
+
+- [ ] **Step 2: 全量测试**
+
+```bash
+pnpm test 2>&1 | tail -10
+```
+
+- [ ] **Step 3: 手动验证**
+
+```bash
+# 重启 Studio
+pkill -f "inkos" 2>/dev/null; sleep 1; inkos studio &
+sleep 3
+
+# 测试 pi-agent 对话
+curl -s -X POST http://localhost:4567/api/agent \
+  -H "Content-Type: application/json" \
+  -d '{"instruction":"你好","activeBookId":"九龙城夜行"}' \
+  --max-time 60 | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('response','')[:200])"
+
+# 测试 pipeline tool
+curl -s -X POST http://localhost:4567/api/agent \
+  -H "Content-Type: application/json" \
+  -d '{"instruction":"读一下世界观设定","activeBookId":"九龙城夜行"}' \
+  --max-time 60 | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('response','')[:300])"
+```
+
+- [ ] **Step 4: Commit(如有修复)**
+
+```bash
+git add -A
+git commit -m "fix(core): phase 1c polish from e2e testing"
+```
+
+---
+
+## 风险与注意事项
+
+1. **pi-agent Agent 的 prompt() 是阻塞的** — 会等待所有 tool 执行完成才返回。对于写章节(可能几分钟),前端需要通过 SSE 事件感知进度。
+2. **Agent 无状态** — 每次请求创建新 Agent 实例,历史消息通过 BookSession 传入。后续可以改为 Agent 实例池缓存在内存中。
+3. **tool 执行错误** — pi-agent 的 tool.execute 抛异常会被 agent 捕获并告知 LLM。LLM 可能会重试或向用户解释。
+4. **旧路径保留** — TUI 继续使用 nl-router + runtime,不受影响。Studio 切换到 pi-agent 路径。
+5. **@sinclair/typebox** — pi-agent-core 依赖 typebox 做 tool 参数 schema 定义,确保版本兼容。

+ 731 - 0
docs/superpowers/plans/2026-04-15-sub-agent-params-alignment-plan.md

@@ -0,0 +1,731 @@
+# sub_agent 参数对齐实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 让 sub_agent 的 5 个子智能体参数与 pipeline runner 方法完全对齐,消除硬编码和参数丢弃。
+
+**Architecture:** 在现有 `SubAgentParams` schema 上扁平扩展新字段(TypeBox description 标注适用 agent),修改每个 agent case 的调用代码,更新系统提示词。
+
+**Tech Stack:** TypeBox (schema), pi-agent-core (tool interface), vitest (tests)
+
+---
+
+### Task 1: 扩展 SubAgentParams schema
+
+**Files:**
+- Modify: `packages/core/src/agent/agent-tools.ts:32-43`
+- Test: `packages/core/src/__tests__/agent-tools-params.test.ts` (create)
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+// packages/core/src/__tests__/agent-tools-params.test.ts
+import { describe, it, expect } from "vitest";
+import { Type } from "@mariozechner/pi-ai";
+
+// We test the schema shape by importing and inspecting the compiled schema
+// Since SubAgentParams is not exported, we test via the tool's parameters property
+import { createSubAgentTool } from "../agent/agent-tools.js";
+
+describe("SubAgentParams schema", () => {
+  // Use a minimal mock pipeline
+  const mockPipeline = {} as any;
+  const tool = createSubAgentTool(mockPipeline, null);
+  const schema = tool.parameters;
+  const props = (schema as any).properties;
+
+  it("has architect params: title, genre, platform, language, targetChapters", () => {
+    expect(props.title).toBeDefined();
+    expect(props.genre).toBeDefined();
+    expect(props.platform).toBeDefined();
+    expect(props.language).toBeDefined();
+    expect(props.targetChapters).toBeDefined();
+  });
+
+  it("has writer/architect param: chapterWordCount", () => {
+    expect(props.chapterWordCount).toBeDefined();
+  });
+
+  it("has reviser param: mode", () => {
+    expect(props.mode).toBeDefined();
+  });
+
+  it("has exporter params: format, approvedOnly", () => {
+    expect(props.format).toBeDefined();
+    expect(props.approvedOnly).toBeDefined();
+  });
+
+  it("has existing params: agent, instruction, bookId, chapterNumber", () => {
+    expect(props.agent).toBeDefined();
+    expect(props.instruction).toBeDefined();
+    expect(props.bookId).toBeDefined();
+    expect(props.chapterNumber).toBeDefined();
+  });
+
+  it("all new params have description with agent scope", () => {
+    expect(props.title.description).toMatch(/architect/i);
+    expect(props.genre.description).toMatch(/architect/i);
+    expect(props.mode.description).toMatch(/reviser/i);
+    expect(props.format.description).toMatch(/exporter/i);
+  });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm --filter @actalk/inkos-core test -- --run agent-tools-params`
+Expected: FAIL — `props.title` is undefined (field doesn't exist yet)
+
+- [ ] **Step 3: Add new fields to SubAgentParams**
+
+In `packages/core/src/agent/agent-tools.ts`, replace the `SubAgentParams` definition:
+
+```ts
+const SubAgentParams = Type.Object({
+  agent: Type.Union([
+    Type.Literal("architect"),
+    Type.Literal("writer"),
+    Type.Literal("auditor"),
+    Type.Literal("reviser"),
+    Type.Literal("exporter"),
+  ]),
+  instruction: Type.String({ description: "Natural language instruction for the sub-agent" }),
+  bookId: Type.Optional(Type.String({ description: "Book ID — required for all agents except architect" })),
+  chapterNumber: Type.Optional(Type.Number({ description: "auditor/reviser: target chapter number. Omit to use the latest chapter." })),
+  // -- architect params --
+  title: Type.Optional(Type.String({ description: "architect only: book title" })),
+  genre: Type.Optional(Type.String({ description: "architect only: genre (xuanhuan, urban, mystery, romance, scifi, fantasy, wuxia, general, etc.)" })),
+  platform: Type.Optional(Type.Union([
+    Type.Literal("tomato"),
+    Type.Literal("qidian"),
+    Type.Literal("feilu"),
+    Type.Literal("other"),
+  ], { description: "architect only: target platform. Default: other" })),
+  language: Type.Optional(Type.Union([
+    Type.Literal("zh"),
+    Type.Literal("en"),
+  ], { description: "architect only: writing language. Default: zh" })),
+  targetChapters: Type.Optional(Type.Number({ description: "architect only: total chapter count. Default: 200" })),
+  chapterWordCount: Type.Optional(Type.Number({ description: "architect/writer: words per chapter. Default: 3000" })),
+  // -- reviser params --
+  mode: Type.Optional(Type.Union([
+    Type.Literal("spot-fix"),
+    Type.Literal("polish"),
+    Type.Literal("rewrite"),
+    Type.Literal("rework"),
+    Type.Literal("anti-detect"),
+  ], { description: "reviser only: revision mode. Default: spot-fix" })),
+  // -- exporter params --
+  format: Type.Optional(Type.Union([
+    Type.Literal("txt"),
+    Type.Literal("md"),
+    Type.Literal("epub"),
+  ], { description: "exporter only: export format. Default: txt" })),
+  approvedOnly: Type.Optional(Type.Boolean({ description: "exporter only: export only approved chapters. Default: false" })),
+});
+```
+
+Update the destructuring in `execute`:
+
+```ts
+const { agent, instruction, bookId, chapterNumber, title, genre, platform, language, targetChapters, chapterWordCount, mode, format, approvedOnly } = params;
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm --filter @actalk/inkos-core test -- --run agent-tools-params`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/core/src/agent/agent-tools.ts packages/core/src/__tests__/agent-tools-params.test.ts
+git commit -m "feat(core): extend SubAgentParams with architect/writer/reviser/exporter fields"
+```
+
+---
+
+### Task 2: Fix architect — 构建完整 BookConfig
+
+**Files:**
+- Modify: `packages/core/src/agent/agent-tools.ts:67-81` (architect case)
+- Test: `packages/core/src/__tests__/agent-tools-params.test.ts` (extend)
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `agent-tools-params.test.ts`:
+
+```ts
+import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { vi, beforeEach, afterEach } from "vitest";
+
+describe("architect agent — BookConfig construction", () => {
+  let initBookMock: ReturnType<typeof vi.fn>;
+  let tool: ReturnType<typeof createSubAgentTool>;
+
+  beforeEach(() => {
+    initBookMock = vi.fn(async () => {});
+    const mockPipeline = { initBook: initBookMock } as any;
+    tool = createSubAgentTool(mockPipeline, null);
+  });
+
+  it("passes complete BookConfig with schema params instead of hardcoded values", async () => {
+    await tool.execute("tc1", {
+      agent: "architect",
+      instruction: "Create a xuanhuan novel",
+      title: "天道独行",
+      genre: "xuanhuan",
+      platform: "tomato",
+      language: "zh",
+      targetChapters: 100,
+      chapterWordCount: 4000,
+    });
+
+    expect(initBookMock).toHaveBeenCalledOnce();
+    const [bookConfig, options] = initBookMock.mock.calls[0];
+    expect(bookConfig.title).toBe("天道独行");
+    expect(bookConfig.genre).toBe("xuanhuan");
+    expect(bookConfig.platform).toBe("tomato");
+    expect(bookConfig.language).toBe("zh");
+    expect(bookConfig.targetChapters).toBe(100);
+    expect(bookConfig.chapterWordCount).toBe(4000);
+    expect(bookConfig.status).toBe("outlining");
+    expect(bookConfig.createdAt).toBeDefined();
+    expect(bookConfig.updatedAt).toBeDefined();
+    expect(options.externalContext).toBe("Create a xuanhuan novel");
+  });
+
+  it("uses defaults when optional architect params are omitted", async () => {
+    await tool.execute("tc2", {
+      agent: "architect",
+      instruction: "Create a book",
+    });
+
+    const [bookConfig] = initBookMock.mock.calls[0];
+    expect(bookConfig.genre).toBe("general");
+    expect(bookConfig.platform).toBe("other");
+    expect(bookConfig.language).toBe("zh");
+    expect(bookConfig.targetChapters).toBe(200);
+    expect(bookConfig.chapterWordCount).toBe(3000);
+  });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm --filter @actalk/inkos-core test -- --run agent-tools-params`
+Expected: FAIL — `bookConfig.title` is `""`, `bookConfig.genre` is `"general"` (hardcoded)
+
+- [ ] **Step 3: Rewrite architect case**
+
+Replace the architect case in `agent-tools.ts`:
+
+```ts
+case "architect": {
+  if (activeBookId) {
+    return textResult("当前已有书籍,不需要建书。如果你想创建新书,请先回到首页。");
+  }
+  const id = bookId || `book-${Date.now().toString(36)}`;
+  const now = new Date().toISOString();
+  progress(`Starting architect for book "${id}"...`);
+  await pipeline.initBook(
+    {
+      id,
+      title: title ?? "",
+      genre: genre ?? "general",
+      platform: (platform ?? "other") as any,
+      language: (language ?? "zh") as any,
+      status: "outlining" as any,
+      targetChapters: targetChapters ?? 200,
+      chapterWordCount: chapterWordCount ?? 3000,
+      createdAt: now,
+      updatedAt: now,
+    },
+    { externalContext: instruction },
+  );
+  progress(`Architect finished — book "${id}" foundation created.`);
+  return textResult(`Book "${id}" initialised successfully. Foundation files are ready.`);
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm --filter @actalk/inkos-core test -- --run agent-tools-params`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/core/src/agent/agent-tools.ts packages/core/src/__tests__/agent-tools-params.test.ts
+git commit -m "fix(core): architect builds complete BookConfig from schema params"
+```
+
+---
+
+### Task 3: Fix writer — 透传 wordCount 和 instruction
+
+**Files:**
+- Modify: `packages/core/src/agent/agent-tools.ts:83-91` (writer case)
+- Test: `packages/core/src/__tests__/agent-tools-params.test.ts` (extend)
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+describe("writer agent — wordCount and instruction passthrough", () => {
+  let writeNextChapterMock: ReturnType<typeof vi.fn>;
+  let tool: ReturnType<typeof createSubAgentTool>;
+
+  beforeEach(() => {
+    writeNextChapterMock = vi.fn(async () => ({ wordCount: 3000 }));
+    const mockPipeline = { writeNextChapter: writeNextChapterMock } as any;
+    tool = createSubAgentTool(mockPipeline, "existing-book");
+  });
+
+  it("passes chapterWordCount as wordCount to writeNextChapter", async () => {
+    await tool.execute("tc1", {
+      agent: "writer",
+      instruction: "Write an exciting chapter",
+      bookId: "my-book",
+      chapterWordCount: 5000,
+    });
+
+    expect(writeNextChapterMock).toHaveBeenCalledWith("my-book", 5000);
+  });
+
+  it("passes undefined wordCount when chapterWordCount is omitted", async () => {
+    await tool.execute("tc2", {
+      agent: "writer",
+      instruction: "Write next chapter",
+      bookId: "my-book",
+    });
+
+    expect(writeNextChapterMock).toHaveBeenCalledWith("my-book", undefined);
+  });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Expected: FAIL — `writeNextChapter` called with only 1 argument
+
+- [ ] **Step 3: Update writer case**
+
+```ts
+case "writer": {
+  if (!bookId) return textResult("Error: bookId is required for the writer agent.");
+  progress(`Writing next chapter for "${bookId}"...`);
+  const result = await pipeline.writeNextChapter(bookId, chapterWordCount);
+  progress(`Writer finished chapter for "${bookId}".`);
+  return textResult(
+    `Chapter written for "${bookId}". ` +
+    `Word count: ${(result as any).wordCount ?? "unknown"}.`,
+  );
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm --filter @actalk/inkos-core test -- --run agent-tools-params`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/core/src/agent/agent-tools.ts packages/core/src/__tests__/agent-tools-params.test.ts
+git commit -m "fix(core): writer passes chapterWordCount to writeNextChapter"
+```
+
+---
+
+### Task 4: Fix auditor — 丰富返回值
+
+**Files:**
+- Modify: `packages/core/src/agent/agent-tools.ts:94-103` (auditor case)
+- Test: `packages/core/src/__tests__/agent-tools-params.test.ts` (extend)
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+describe("auditor agent — rich return value", () => {
+  let auditDraftMock: ReturnType<typeof vi.fn>;
+  let tool: ReturnType<typeof createSubAgentTool>;
+
+  beforeEach(() => {
+    auditDraftMock = vi.fn(async () => ({
+      chapterNumber: 3,
+      passed: false,
+      issues: [
+        { severity: "warning", description: "Pacing too fast in mid section" },
+        { severity: "critical", description: "Character name inconsistency" },
+      ],
+    }));
+    const mockPipeline = { auditDraft: auditDraftMock } as any;
+    tool = createSubAgentTool(mockPipeline, "existing-book");
+  });
+
+  it("returns issue details with severity, not just count", async () => {
+    const result = await tool.execute("tc1", {
+      agent: "auditor",
+      instruction: "Audit chapter 3",
+      bookId: "my-book",
+      chapterNumber: 3,
+    });
+
+    const text = result.content[0].text;
+    expect(text).toContain("FAILED");
+    expect(text).toContain("2 issue(s)");
+    expect(text).toContain("[warning]");
+    expect(text).toContain("[critical]");
+    expect(text).toContain("Pacing too fast");
+    expect(text).toContain("Character name inconsistency");
+  });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Expected: FAIL — current response only has `"2 issue(s) found"`, no `[warning]` or `[critical]`
+
+- [ ] **Step 3: Update auditor case**
+
+```ts
+case "auditor": {
+  if (!bookId) return textResult("Error: bookId is required for the auditor agent.");
+  progress(`Auditing chapter ${chapterNumber ?? "latest"} for "${bookId}"...`);
+  const audit = await pipeline.auditDraft(bookId, chapterNumber);
+  progress(`Audit complete for "${bookId}".`);
+  const issueLines = (audit.issues ?? [])
+    .map((i: any) => `[${i.severity}] ${i.description}`)
+    .join("\n");
+  return textResult(
+    `Audit chapter ${audit.chapterNumber}: ${audit.passed ? "PASSED" : "FAILED"}, ${(audit.issues ?? []).length} issue(s).` +
+    (issueLines ? `\n${issueLines}` : ""),
+  );
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/core/src/agent/agent-tools.ts packages/core/src/__tests__/agent-tools-params.test.ts
+git commit -m "fix(core): auditor returns full issue details with severity"
+```
+
+---
+
+### Task 5: Fix reviser — mode 字段化 + instruction 透传
+
+**Files:**
+- Modify: `packages/core/src/agent/agent-tools.ts:106-119` (reviser case)
+- Test: `packages/core/src/__tests__/agent-tools-params.test.ts` (extend)
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+describe("reviser agent — mode field and instruction passthrough", () => {
+  let reviseDraftMock: ReturnType<typeof vi.fn>;
+  let tool: ReturnType<typeof createSubAgentTool>;
+
+  beforeEach(() => {
+    reviseDraftMock = vi.fn(async () => ({}));
+    const mockPipeline = { reviseDraft: reviseDraftMock } as any;
+    tool = createSubAgentTool(mockPipeline, "existing-book");
+  });
+
+  it("uses mode param directly instead of regex extraction", async () => {
+    await tool.execute("tc1", {
+      agent: "reviser",
+      instruction: "Fix this chapter",
+      bookId: "my-book",
+      chapterNumber: 5,
+      mode: "anti-detect",
+    });
+
+    expect(reviseDraftMock).toHaveBeenCalledWith("my-book", 5, "anti-detect");
+  });
+
+  it("defaults to spot-fix when mode is omitted", async () => {
+    await tool.execute("tc2", {
+      agent: "reviser",
+      instruction: "Fix chapter",
+      bookId: "my-book",
+    });
+
+    expect(reviseDraftMock).toHaveBeenCalledWith("my-book", undefined, "spot-fix");
+  });
+
+  it("anti-detect mode is reachable (was unreachable with regex)", async () => {
+    await tool.execute("tc3", {
+      agent: "reviser",
+      instruction: "Process chapter",
+      bookId: "my-book",
+      mode: "anti-detect",
+    });
+
+    expect(reviseDraftMock.mock.calls[0][2]).toBe("anti-detect");
+  });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Expected: FAIL — `anti-detect` falls through to `spot-fix` via regex
+
+- [ ] **Step 3: Update reviser case**
+
+```ts
+case "reviser": {
+  if (!bookId) return textResult("Error: bookId is required for the reviser agent.");
+  const resolvedMode: ReviseMode = (mode as ReviseMode) ?? "spot-fix";
+  progress(`Revising "${bookId}" chapter ${chapterNumber ?? "latest"} in ${resolvedMode} mode...`);
+  await pipeline.reviseDraft(bookId, chapterNumber, resolvedMode);
+  progress(`Revision complete for "${bookId}".`);
+  return textResult(`Revision (${resolvedMode}) complete for "${bookId}" chapter ${chapterNumber ?? "latest"}.`);
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/core/src/agent/agent-tools.ts packages/core/src/__tests__/agent-tools-params.test.ts
+git commit -m "fix(core): reviser uses mode param directly, anti-detect now reachable"
+```
+
+---
+
+### Task 6: Implement exporter — 调通导出
+
+**Files:**
+- Modify: `packages/core/src/agent/agent-tools.ts:122-124` (exporter case)
+- Test: `packages/core/src/__tests__/agent-tools-params.test.ts` (extend)
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+describe("exporter agent — export implementation", () => {
+  let loadChapterIndexMock: ReturnType<typeof vi.fn>;
+  let loadBookConfigMock: ReturnType<typeof vi.fn>;
+  let tool: ReturnType<typeof createSubAgentTool>;
+  let tmpRoot: string;
+
+  beforeEach(async () => {
+    tmpRoot = await mkdtemp(join(tmpdir(), "inkos-export-"));
+    const bookDir = join(tmpRoot, "books", "my-book");
+    const chaptersDir = join(bookDir, "chapters");
+    await mkdir(chaptersDir, { recursive: true });
+    await writeFile(join(chaptersDir, "0001_First.md"), "# 第一章\n\n内容内容内容");
+    await writeFile(join(chaptersDir, "0002_Second.md"), "# 第二章\n\n更多内容");
+
+    loadChapterIndexMock = vi.fn(async () => [
+      { number: 1, title: "First", status: "approved", wordCount: 100 },
+      { number: 2, title: "Second", status: "drafted", wordCount: 200 },
+    ]);
+    loadBookConfigMock = vi.fn(async () => ({ title: "Test Book" }));
+    const mockPipeline = {} as any;
+    const mockState = {
+      bookDir: (id: string) => join(tmpRoot, "books", id),
+      loadChapterIndex: loadChapterIndexMock,
+      loadBookConfig: loadBookConfigMock,
+    };
+    // exporter needs access to state — passed via projectRoot
+    tool = createSubAgentTool(mockPipeline, "my-book", tmpRoot);
+  });
+
+  afterEach(async () => {
+    await rm(tmpRoot, { recursive: true, force: true });
+  });
+
+  it("exports all chapters in txt format by default", async () => {
+    const result = await tool.execute("tc1", {
+      agent: "exporter",
+      instruction: "Export the book",
+      bookId: "my-book",
+    });
+
+    const text = result.content[0].text;
+    expect(text).not.toContain("not yet implemented");
+    expect(text).toContain("export");
+  });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Expected: FAIL — returns "Export is not yet implemented"
+
+- [ ] **Step 3: Implement exporter case**
+
+Replace the exporter case. Since `exportBookToPath` in `project-tools.ts` is not directly importable (it's a local function), inline the core logic:
+
+```ts
+case "exporter": {
+  if (!bookId) return textResult("Error: bookId is required for the exporter agent.");
+  const exportFormat = (format ?? "txt") as "txt" | "md" | "epub";
+  const onlyApproved = approvedOnly ?? false;
+  progress(`Exporting "${bookId}" as ${exportFormat}...`);
+
+  const booksRoot = join(projectRoot, "books");
+  const bookDir = join(booksRoot, bookId);
+  const chaptersDir = join(bookDir, "chapters");
+
+  // Load index and book config
+  const indexRaw = await readFile(join(chaptersDir, "index.json"), "utf-8");
+  const index: Array<{ number: number; title: string; status: string; wordCount: number }> = JSON.parse(indexRaw);
+  const bookRaw = await readFile(join(bookDir, "book.json"), "utf-8");
+  const bookConfig = JSON.parse(bookRaw);
+
+  const chapters = onlyApproved ? index.filter((ch) => ch.status === "approved") : index;
+  if (chapters.length === 0) {
+    return textResult(`No chapters to export${onlyApproved ? " (approved only filter)" : ""}.`);
+  }
+
+  // Build chapter file lookup
+  const files = await readdir(chaptersDir);
+  const chapterFiles = new Map<number, string>();
+  for (const f of files.filter((f) => /^\d{4}_.*\.md$/.test(f)).sort()) {
+    chapterFiles.set(parseInt(f.slice(0, 4), 10), f);
+  }
+
+  const parts: string[] = [];
+  parts.push(exportFormat === "md" ? `# ${bookConfig.title ?? bookId}\n\n---\n` : `${bookConfig.title ?? bookId}\n\n`);
+  for (const ch of chapters) {
+    const file = chapterFiles.get(ch.number);
+    if (!file) continue;
+    parts.push(await readFile(join(chaptersDir, file), "utf-8"));
+    parts.push("\n\n");
+  }
+
+  const outputPath = join(booksRoot, "..", `${bookId}_export.${exportFormat}`);
+  await writeFile(outputPath, parts.join(exportFormat === "md" ? "\n---\n\n" : "\n"), "utf-8");
+
+  const totalWords = chapters.reduce((s, ch) => s + ch.wordCount, 0);
+  progress(`Export complete for "${bookId}".`);
+  return textResult(
+    `Exported "${bookId}": ${chapters.length} chapters, ${totalWords} words → ${outputPath}`,
+  );
+}
+```
+
+Note: `projectRoot` is already available in the closure from `createSubAgentTool`'s third parameter (added in a prior commit).
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/core/src/agent/agent-tools.ts packages/core/src/__tests__/agent-tools-params.test.ts
+git commit -m "feat(core): implement exporter agent with format and approvedOnly support"
+```
+
+---
+
+### Task 7: 更新系统提示词
+
+**Files:**
+- Modify: `packages/core/src/agent/agent-system-prompt.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `packages/core/src/__tests__/agent-system-prompt.test.ts`:
+
+```ts
+it("book-mode prompt documents all sub_agent params", () => {
+  const prompt = buildAgentSystemPrompt("test-book", "zh");
+  // architect params
+  expect(prompt).toContain("title");
+  expect(prompt).toContain("genre");
+  expect(prompt).toContain("platform");
+  expect(prompt).toContain("chapterWordCount");
+  // reviser params
+  expect(prompt).toContain("mode");
+  expect(prompt).toContain("anti-detect");
+  // exporter params
+  expect(prompt).toContain("format");
+  expect(prompt).toContain("approvedOnly");
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm --filter @actalk/inkos-core test -- --run agent-system-prompt`
+Expected: FAIL — prompt doesn't mention `format`, `approvedOnly`, `anti-detect` etc.
+
+- [ ] **Step 3: Update both zh and en prompts**
+
+Update the Chinese book-mode prompt's tool section:
+
+```ts
+- **sub_agent** — 委托子智能体执行重操作:
+  - agent="architect" 从零建书(参数:title, genre, platform, language, targetChapters, chapterWordCount)
+  - agent="writer" 写下一章(参数:chapterWordCount 覆盖字数)
+  - agent="auditor" 审计章节质量(参数:chapterNumber 指定章节)
+  - agent="reviser" 修订章节(参数:chapterNumber, mode: spot-fix/polish/rewrite/rework/anti-detect)
+  - agent="exporter" 导出书籍(参数:format: txt/md/epub, approvedOnly: true/false)
+```
+
+Update the English book-mode prompt similarly.
+
+Also update the Chinese book-create prompt to mention architect params:
+
+```ts
+2. **确认建书**(调用阶段)— 当信息足够时,调用 sub_agent 工具委托 architect 子智能体建书:
+   - instruction 中包含收集到的所有信息
+   - 同时传入结构化参数:title(书名)、genre(题材)、platform(平台)、language(语言)、targetChapters(章数)、chapterWordCount(每章字数)
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/core/src/agent/agent-system-prompt.ts packages/core/src/__tests__/agent-system-prompt.test.ts
+git commit -m "docs(core): update system prompts with complete sub_agent param documentation"
+```
+
+---
+
+### Task 8: 全量测试验证
+
+**Files:** None (verification only)
+
+- [ ] **Step 1: Run all core tests**
+
+```bash
+pnpm --filter @actalk/inkos-core test -- --run
+```
+
+Expected: All pass
+
+- [ ] **Step 2: Run all studio tests**
+
+```bash
+pnpm --filter @actalk/inkos-studio test -- --run
+```
+
+Expected: All pass
+
+- [ ] **Step 3: Run all CLI tests**
+
+```bash
+pnpm --filter @actalk/inkos test -- --run
+```
+
+Expected: All pass (or only pre-existing Windows tar failures)
+
+- [ ] **Step 4: Commit any fixes needed**
+
+If any existing tests broke due to schema changes, fix them.

+ 1912 - 0
docs/superpowers/plans/2026-04-16-session-management.md

@@ -0,0 +1,1912 @@
+# Session 管理优化 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 让 InkOS Studio 支持一本书多段会话、多会话并行流式、以及新建书籍时的 session 全新创建 + 迁移。
+
+**Architecture:** Core 层新增 title 字段和 rename/delete/migrate 函数,删掉 findOrCreateBookSession;API 层把 POST /sessions 改成永远新建、POST /agent 强制要 sessionId、新增 PUT/DELETE;前端 store 把 MessageState 从单数改成 `sessions: Record<sessionId, SessionRuntime>` 按 id 分片;侧边栏从扁平书列表改成树形(书 → session 子项)。
+
+**Tech Stack:** TypeScript, Zod, Hono (server), Zustand (store), React, Vitest
+
+**Spec:** `docs/superpowers/specs/2026-04-16-session-management-design.md`
+
+## Execution Status
+
+_Updated: 2026-04-17_
+
+- [x] Task 1: Core — `BookSession` 增加 `title`
+- [x] Task 2: Core — `rename` / `delete` / `migrate`
+- [x] Task 3: Core — 删除 `findOrCreateBookSession`
+- [x] Task 4: API — session endpoints 更新,`POST /agent` 强制 `sessionId`
+- [x] Task 5: Store — `types` / `initialState` / `selectors` 适配多 session
+- [x] Task 6: Store — `message slice` 多 session 化
+- [x] Task 7: Store — `create slice` 传 `sessionId` 并处理迁移
+- [x] Task 8: UI — Sidebar 树形 session 列表
+- [x] Task 9: UI — ChatPage / localStorage 新建书籍流程
+- [x] Task 10: 标题自动生成与 `session:title` 前端实时更新
+- [ ] 计划中的分步 commit 没执行;当前保留在工作区改动里统一检查
+
+---
+
+## File Map
+
+**Core (packages/core/src/interaction/)**
+- Modify: `session.ts:101-135` — BookSessionSchema 加 title,createBookSession 初始化 title
+- Modify: `book-session-store.ts` — 新增 renameBookSession / deleteBookSession / migrateBookSession,删除 findOrCreateBookSession
+- Modify: `__tests__/book-session-store.test.ts` — 新增 rename/delete/migrate 测试,删除 findOrCreate 测试
+
+**API (packages/studio/src/api/)**
+- Modify: `server.ts:1239-1262` — 更新 GET /sessions(补 title)、POST /sessions(改为永远新建)
+- Modify: `server.ts:1264-1292` — POST /agent 强制要 sessionId
+- 新增端点: PUT /sessions/:id, DELETE /sessions/:id
+
+**Store (packages/studio/src/store/chat/)**
+- Modify: `types.ts` — 新增 SessionRuntime,重构 MessageState / MessageActions
+- Modify: `slices/message/initialState.ts` — 适配新 state 形状
+- Modify: `slices/message/action.ts` — loadSession/sendMessage 按 sessionId 分片
+- Modify: `slices/create/action.ts` — handleCreateBook 传 sessionId + 处理迁移
+- Modify: `selectors.ts` — 适配新 state
+- Modify: `initialState.ts` — 合并
+
+**UI (packages/studio/src/)**
+- Modify: `components/Sidebar.tsx` — 树形:书展开 → session 子项 + 新建会话 + 活跃指示
+- Modify: `pages/ChatPage.tsx` — 从 sessions[activeSessionId] 读消息,sendMessage 传 sessionId
+- Modify: `pages/chat-page-state.ts` — 删除 shouldUseFreshBookCreateSession,改用 localStorage 逻辑
+- Modify: `hooks/use-hash-route.ts` — 无需改(book-create 路由已存在)
+- Modify: `App.tsx:165-179` — ChatPage 接收 activeSessionId prop
+
+---
+
+## Task 1: Core — BookSession 加 title 字段
+
+**Files:**
+- Modify: `packages/core/src/interaction/session.ts:101-135`
+- Test: `packages/core/src/__tests__/book-session-store.test.ts`
+
+- [ ] **Step 1: 在 BookSessionSchema 中加 title 字段**
+
+```ts
+// session.ts:101-111  改为:
+export const BookSessionSchema = z.object({
+  sessionId: z.string().min(1),
+  bookId: z.string().nullable(),
+  title: z.string().nullable().default(null),          // ← 新增
+  messages: z.array(InteractionMessageSchema).default([]),
+  creationDraft: BookCreationDraftSchema.optional(),
+  draftRounds: z.array(DraftRoundSchema).default([]),
+  events: z.array(InteractionEventSchema).default([]),
+  currentExecution: ExecutionStateSchema.optional(),
+  createdAt: z.number().int().nonnegative(),
+  updatedAt: z.number().int().nonnegative(),
+});
+```
+
+- [ ] **Step 2: createBookSession 初始化 title: null**
+
+```ts
+// session.ts:124-135  改为:
+export function createBookSession(bookId: string | null): BookSession {
+  const now = Date.now();
+  return {
+    sessionId: `${now}-${Math.random().toString(36).slice(2, 8)}`,
+    bookId,
+    title: null,                                         // ← 新增
+    messages: [],
+    draftRounds: [],
+    events: [],
+    createdAt: now,
+    updatedAt: now,
+  };
+}
+```
+
+- [ ] **Step 3: 写 title 字段专项测试**
+
+在 `book-session-store.test.ts` 的 `persistBookSession + loadBookSession` describe 块末尾加:
+
+```ts
+it("createBookSession initializes title as null", () => {
+  const session = createBookSession("book");
+  expect(session.title).toBeNull();
+});
+
+it("parses old session files without title field (backward compat)", async () => {
+  // 模拟老格式文件(没有 title 字段)
+  const oldFormat = {
+    sessionId: "old-session",
+    bookId: "book",
+    messages: [],
+    draftRounds: [],
+    events: [],
+    createdAt: 1000,
+    updatedAt: 1000,
+  };
+  const dir = join(tempDir, ".inkos", "sessions");
+  const { mkdir, writeFile } = await import("node:fs/promises");
+  await mkdir(dir, { recursive: true });
+  await writeFile(join(dir, "old-session.json"), JSON.stringify(oldFormat));
+  const loaded = await loadBookSession(tempDir, "old-session");
+  expect(loaded).not.toBeNull();
+  expect(loaded!.title).toBeNull(); // Zod default(null) 填充
+});
+
+it("round-trips title through persist/load", async () => {
+  let session = createBookSession("book");
+  session = { ...session, title: "测试标题" };
+  await persistBookSession(tempDir, session);
+  const loaded = await loadBookSession(tempDir, session.sessionId);
+  expect(loaded!.title).toBe("测试标题");
+});
+```
+
+- [ ] **Step 4: 跑测试确认通过**
+
+Run: `pnpm --filter @actalk/inkos-core build && pnpm test`
+Expected: 所有测试通过(title 有 default(null),老 session 文件解析不受影响)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/core/src/interaction/session.ts packages/core/src/__tests__/book-session-store.test.ts
+git commit -m "feat(core): add title field to BookSession schema"
+```
+
+---
+
+## Task 2: Core — 新增 rename / delete / migrate 函数
+
+**Files:**
+- Modify: `packages/core/src/interaction/book-session-store.ts`
+- Modify: `packages/core/src/__tests__/book-session-store.test.ts`
+
+- [ ] **Step 1: 写 renameBookSession 的测试**
+
+在 `book-session-store.test.ts` 末尾(`findOrCreateBookSession` describe 之后)加:
+
+```ts
+describe("renameBookSession", () => {
+  it("sets title and updates updatedAt", async () => {
+    const session = createBookSession("book");
+    await persistBookSession(tempDir, session);
+    const oldUpdatedAt = session.updatedAt;
+
+    // 等 1ms 确保 updatedAt 不同
+    await new Promise((r) => setTimeout(r, 5));
+    await renameBookSession(tempDir, session.sessionId, "新标题");
+
+    const loaded = await loadBookSession(tempDir, session.sessionId);
+    expect(loaded!.title).toBe("新标题");
+    expect(loaded!.updatedAt).toBeGreaterThan(oldUpdatedAt);
+  });
+
+  it("returns null for non-existent session", async () => {
+    const result = await renameBookSession(tempDir, "nonexistent", "title");
+    expect(result).toBeNull();
+  });
+});
+```
+
+- [ ] **Step 2: 跑测试确认失败**
+
+Run: `pnpm test -- --reporter=verbose packages/core/src/__tests__/book-session-store.test.ts`
+Expected: FAIL — `renameBookSession` is not exported
+
+- [ ] **Step 3: 实现 renameBookSession**
+
+在 `book-session-store.ts` 末尾加:
+
+```ts
+export async function renameBookSession(
+  projectRoot: string,
+  sessionId: string,
+  title: string,
+): Promise<BookSession | null> {
+  const session = await loadBookSession(projectRoot, sessionId);
+  if (!session) return null;
+  const updated = { ...session, title, updatedAt: Date.now() };
+  await persistBookSession(projectRoot, updated);
+  return updated;
+}
+```
+
+在测试文件 import 中加 `renameBookSession`。
+
+- [ ] **Step 4: 跑测试确认通过**
+
+Run: `pnpm test -- --reporter=verbose packages/core/src/__tests__/book-session-store.test.ts`
+Expected: PASS
+
+- [ ] **Step 5: 写 deleteBookSession 的测试**
+
+```ts
+describe("deleteBookSession", () => {
+  it("removes session file", async () => {
+    const session = createBookSession("book");
+    await persistBookSession(tempDir, session);
+    await deleteBookSession(tempDir, session.sessionId);
+    const loaded = await loadBookSession(tempDir, session.sessionId);
+    expect(loaded).toBeNull();
+  });
+
+  it("does nothing for non-existent session", async () => {
+    // 不抛异常即可
+    await deleteBookSession(tempDir, "nonexistent");
+  });
+});
+```
+
+- [ ] **Step 6: 跑测试确认失败**
+
+Run: `pnpm test -- --reporter=verbose packages/core/src/__tests__/book-session-store.test.ts`
+Expected: FAIL — `deleteBookSession` is not exported
+
+- [ ] **Step 7: 实现 deleteBookSession**
+
+```ts
+import { readFile, writeFile, readdir, mkdir, unlink } from "node:fs/promises";
+
+export async function deleteBookSession(
+  projectRoot: string,
+  sessionId: string,
+): Promise<void> {
+  try {
+    await unlink(sessionPath(projectRoot, sessionId));
+  } catch {
+    // 文件不存在或已删除,静默忽略
+  }
+}
+```
+
+注意:import 行要加 `unlink`。
+
+- [ ] **Step 8: 跑测试确认通过**
+
+Run: `pnpm test -- --reporter=verbose packages/core/src/__tests__/book-session-store.test.ts`
+Expected: PASS
+
+- [ ] **Step 9: 写 migrateBookSession 的测试**
+
+```ts
+describe("migrateBookSession", () => {
+  it("changes bookId from null to new id", async () => {
+    const session = createBookSession(null);
+    await persistBookSession(tempDir, session);
+    await migrateBookSession(tempDir, session.sessionId, "new-book-id");
+    const loaded = await loadBookSession(tempDir, session.sessionId);
+    expect(loaded!.bookId).toBe("new-book-id");
+  });
+
+  it("throws if bookId is not null", async () => {
+    const session = createBookSession("existing-book");
+    await persistBookSession(tempDir, session);
+    await expect(
+      migrateBookSession(tempDir, session.sessionId, "other-book"),
+    ).rejects.toThrow("already migrated");
+  });
+
+  it("returns null for non-existent session", async () => {
+    const result = await migrateBookSession(tempDir, "nonexistent", "book");
+    expect(result).toBeNull();
+  });
+});
+```
+
+- [ ] **Step 10: 跑测试确认失败**
+
+Run: `pnpm test -- --reporter=verbose packages/core/src/__tests__/book-session-store.test.ts`
+Expected: FAIL
+
+- [ ] **Step 11: 实现 migrateBookSession**
+
+```ts
+export async function migrateBookSession(
+  projectRoot: string,
+  sessionId: string,
+  newBookId: string,
+): Promise<BookSession | null> {
+  const session = await loadBookSession(projectRoot, sessionId);
+  if (!session) return null;
+  if (session.bookId !== null) {
+    throw new Error(`Session ${sessionId} already migrated (bookId=${session.bookId})`);
+  }
+  const updated = { ...session, bookId: newBookId, updatedAt: Date.now() };
+  await persistBookSession(projectRoot, updated);
+  return updated;
+}
+```
+
+- [ ] **Step 12: 跑测试确认通过**
+
+Run: `pnpm test -- --reporter=verbose packages/core/src/__tests__/book-session-store.test.ts`
+Expected: PASS
+
+- [ ] **Step 13: Commit**
+
+```bash
+git add packages/core/src/interaction/book-session-store.ts packages/core/src/__tests__/book-session-store.test.ts
+git commit -m "feat(core): add renameBookSession, deleteBookSession, migrateBookSession"
+```
+
+---
+
+## Task 3: Core — 删除 findOrCreateBookSession
+
+**Files:**
+- Modify: `packages/core/src/interaction/book-session-store.ts:69-78`
+- Modify: `packages/core/src/__tests__/book-session-store.test.ts:93-108`
+
+- [ ] **Step 1: 删除 findOrCreateBookSession 函数**
+
+删除 `book-session-store.ts` 第 69-78 行的 `findOrCreateBookSession` 函数。
+
+- [ ] **Step 2: 删除测试中 findOrCreateBookSession describe 块**
+
+删除 `book-session-store.test.ts` 中 `describe("findOrCreateBookSession", ...)` 整个块(第 93-108 行),以及 import 中的 `findOrCreateBookSession`。
+
+- [ ] **Step 3: 跑测试确认通过**
+
+Run: `pnpm --filter @actalk/inkos-core build && pnpm test`
+Expected: core 测试全部通过。此时 studio build 会失败(server.ts 还引用了 findOrCreate),这是预期的——下一个 task 修。
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add packages/core/src/interaction/book-session-store.ts packages/core/src/__tests__/book-session-store.test.ts
+git commit -m "refactor(core): remove findOrCreateBookSession"
+```
+
+---
+
+## Task 4: API — 更新现有端点 + 新增 PUT / DELETE
+
+**Files:**
+- Modify: `packages/studio/src/api/server.ts:1239-1292`
+
+- [ ] **Step 1: 更新 GET /sessions 返回 title**
+
+```ts
+// server.ts GET /api/v1/sessions 改为:
+app.get("/api/v1/sessions", async (c) => {
+  const bookId = c.req.query("bookId");
+  const sessions = await listBookSessions(root, bookId === undefined ? null : bookId === "null" ? null : bookId);
+  return c.json({ sessions: sessions.map((s) => ({
+    sessionId: s.sessionId,
+    bookId: s.bookId,
+    title: s.title,                                    // ← 新增
+    messageCount: s.messages.length,
+    createdAt: s.createdAt,
+    updatedAt: s.updatedAt,
+  })) });
+});
+```
+
+- [ ] **Step 2: 更新 POST /sessions 为永远新建**
+
+把 import 中的 `findOrCreateBookSession` 替换成 `createBookSession`(从 session.js 导入)和 `persistBookSession`。
+
+```ts
+// server.ts POST /api/v1/sessions 改为:
+app.post("/api/v1/sessions", async (c) => {
+  const body = await c.req.json<{ bookId?: string | null }>().catch(() => ({}));
+  const bookId = (body as { bookId?: string | null }).bookId ?? null;
+  const session = createBookSession(bookId);
+  await persistBookSession(root, session);
+  return c.json({ session });
+});
+```
+
+更新 import:从 `"../../core/..."` 或打包路径中去掉 `findOrCreateBookSession`,加上 `createBookSession`(如果还没导入的话)。
+
+- [ ] **Step 3: 更新 POST /agent 强制要 sessionId**
+
+```ts
+// server.ts POST /api/v1/agent 开头加校验:
+app.post("/api/v1/agent", async (c) => {
+  const { instruction, activeBookId, sessionId: reqSessionId, model: reqModel, service: reqService } = await c.req.json<{
+    instruction: string;
+    activeBookId?: string;
+    sessionId?: string;
+    model?: string;
+    service?: string;
+  }>();
+
+  if (!reqSessionId) {
+    return c.json({ error: "sessionId is required" }, 400);
+  }
+
+  const sessionId = reqSessionId;
+  if (!instruction?.trim()) {
+    return c.json({ error: "No instruction provided" }, 400);
+  }
+
+  // ...后续代码不变,但删掉 findOrCreateBookSession 的 fallback 逻辑
+  // 原来第 1286-1292 行改为:
+  let bookSession: BookSession;
+  const loaded = await loadBookSession(root, sessionId);
+  if (!loaded) {
+    return c.json({ error: `Session ${sessionId} not found` }, 404);
+  }
+  bookSession = loaded;
+
+  // ... 后续不变
+```
+
+- [ ] **Step 4: 新增 PUT /sessions/:id 改名**
+
+在 POST /sessions 之后加:
+
+```ts
+app.put("/api/v1/sessions/:sessionId", async (c) => {
+  const sessionId = c.req.param("sessionId");
+  const body = await c.req.json<{ title?: string }>().catch(() => ({}));
+  const title = (body as { title?: string }).title;
+  if (title === undefined) {
+    return c.json({ error: "title is required" }, 400);
+  }
+  const updated = await renameBookSession(root, sessionId, title);
+  if (!updated) {
+    return c.json({ error: "Session not found" }, 404);
+  }
+  return c.json({ session: { sessionId: updated.sessionId, title: updated.title } });
+});
+```
+
+更新 import 加 `renameBookSession`。
+
+- [ ] **Step 5: 新增 DELETE /sessions/:id**
+
+```ts
+app.delete("/api/v1/sessions/:sessionId", async (c) => {
+  const sessionId = c.req.param("sessionId");
+  await deleteBookSession(root, sessionId);
+  return c.json({ ok: true });
+});
+```
+
+更新 import 加 `deleteBookSession`。
+
+- [ ] **Step 6: 更新 server.ts 的 import 行**
+
+确保 import 来源正确。在 server.ts 顶部找到从 book-session-store 导入的行,改为:
+
+```ts
+import {
+  loadBookSession,
+  persistBookSession,
+  listBookSessions,
+  renameBookSession,
+  deleteBookSession,
+} from "@actalk/inkos-core/interaction/book-session-store";
+import { createBookSession } from "@actalk/inkos-core/interaction/session";
+```
+
+(具体 import 路径看现有代码风格,可能是相对路径或包路径。)
+
+- [ ] **Step 7: 写 API 端点测试**
+
+创建 `packages/studio/src/api/__tests__/session-endpoints.test.ts`:
+
+```ts
+import { describe, expect, it, beforeEach, afterEach } from "vitest";
+import { mkdtemp, rm } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import {
+  loadBookSession,
+  persistBookSession,
+  listBookSessions,
+} from "@actalk/inkos-core/interaction/book-session-store";
+import { createBookSession } from "@actalk/inkos-core/interaction/session";
+
+// 如果 server.ts 可以用 Hono 的 testClient 测试,用那个;
+// 否则直接测底层函数行为(API 层很薄,逻辑在 core 层)。
+// 这里测后者——确认 createBookSession 永远生成新 id。
+
+describe("session API behavior", () => {
+  let tempDir: string;
+
+  beforeEach(async () => {
+    tempDir = await mkdtemp(join(tmpdir(), "inkos-api-test-"));
+  });
+
+  afterEach(async () => {
+    await rm(tempDir, { recursive: true, force: true });
+  });
+
+  it("POST /sessions: two calls with same bookId produce different sessionIds", async () => {
+    const s1 = createBookSession("book-a");
+    await persistBookSession(tempDir, s1);
+    const s2 = createBookSession("book-a");
+    await persistBookSession(tempDir, s2);
+    expect(s1.sessionId).not.toBe(s2.sessionId);
+    const list = await listBookSessions(tempDir, "book-a");
+    expect(list).toHaveLength(2);
+  });
+
+  it("POST /sessions: bookId=null creates session with null bookId", async () => {
+    const s = createBookSession(null);
+    await persistBookSession(tempDir, s);
+    expect(s.bookId).toBeNull();
+    const list = await listBookSessions(tempDir, null);
+    expect(list).toHaveLength(1);
+  });
+
+  it("GET /sessions: response includes title field", async () => {
+    let s = createBookSession("book");
+    s = { ...s, title: "测试" };
+    await persistBookSession(tempDir, s);
+    const list = await listBookSessions(tempDir, "book");
+    expect(list[0].title).toBe("测试");
+  });
+});
+```
+
+注意:如果项目已有 Hono testClient 模式可以直接对 HTTP 层测,就把上面改为用 testClient 发真正的 HTTP 请求。否则这些 core-level 测试覆盖了 API 的逻辑(API 层只是薄 wrapper)。
+
+POST /agent 400 的测试取决于是否有 testClient。如果有:
+
+```ts
+it("POST /agent without sessionId returns 400", async () => {
+  const res = await app.request("/api/v1/agent", {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({ instruction: "hello" }),
+  });
+  expect(res.status).toBe(400);
+  const body = await res.json();
+  expect(body.error).toContain("sessionId");
+});
+```
+
+- [ ] **Step 8: build + 测试**
+
+Run: `pnpm build && pnpm test`
+Expected: 全部通过
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add packages/studio/src/api/server.ts packages/studio/src/api/__tests__/session-endpoints.test.ts
+git commit -m "feat(studio): update session endpoints — always-create, require sessionId, add PUT/DELETE"
+```
+
+---
+
+## Task 5: Store — 重构 types.ts 和 initialState
+
+**Files:**
+- Modify: `packages/studio/src/store/chat/types.ts`
+- Modify: `packages/studio/src/store/chat/slices/message/initialState.ts`
+- Modify: `packages/studio/src/store/chat/initialState.ts`
+- Modify: `packages/studio/src/store/chat/selectors.ts`
+
+- [ ] **Step 1: 新增 SessionRuntime 类型,重构 MessageState**
+
+`types.ts` 全文替换 MessageState 和 MessageActions 部分:
+
+```ts
+// -- Session runtime (per-session state) --
+
+export interface SessionRuntime {
+  sessionId: string;
+  bookId: string | null;
+  title: string | null;
+  messages: ReadonlyArray<Message>;
+  stream: EventSource | null;
+  isStreaming: boolean;
+  lastError: string | null;
+}
+
+// -- State interfaces --
+
+export interface BookSummary {
+  world: string;
+  protagonist: string;
+  cast: string;
+}
+
+export interface MessageState {
+  sessions: Record<string, SessionRuntime>;
+  activeSessionId: string | null;
+  sessionIdsByBook: Record<string, string[]>;
+  input: string;
+  selectedModel: string | null;
+  selectedService: string | null;
+}
+
+// ...CreateState 不变...
+
+export type ChatState = MessageState & CreateState;
+```
+
+- [ ] **Step 2: 更新 MessageActions**
+
+```ts
+export interface MessageActions {
+  setInput: (text: string) => void;
+  // session 生命周期
+  activateSession: (sessionId: string) => void;
+  loadSessionList: (bookId: string) => Promise<void>;
+  createSession: (bookId: string | null) => Promise<string>;
+  renameSession: (sessionId: string, title: string) => Promise<void>;
+  deleteSession: (sessionId: string) => Promise<void>;
+  // 消息
+  sendMessage: (sessionId: string, text: string, activeBookId?: string) => Promise<void>;
+  // 内部 SSE helpers(不直接暴露给组件,但 action 内部用)
+  addUserMessage: (sessionId: string, content: string) => void;
+  appendStreamChunk: (sessionId: string, text: string, streamTs: number) => void;
+  finalizeStream: (sessionId: string, streamTs: number, content: string, toolCall?: ToolCall) => void;
+  replaceStreamWithError: (sessionId: string, streamTs: number, errorMsg: string) => void;
+  addErrorMessage: (sessionId: string, errorMsg: string) => void;
+  loadSessionMessages: (sessionId: string, msgs: ReadonlyArray<SessionMessage>) => void;
+  setSelectedModel: (model: string, service: string) => void;
+}
+```
+
+注意:所有消息操作现在都接收 `sessionId` 第一个参数。
+
+- [ ] **Step 3: 更新 MessageActions 中 CreateActions 的签名**
+
+`CreateActions` 中 `handleCreateBook` 改为接收 sessionId:
+
+```ts
+export interface CreateActions {
+  setPendingBookArgs: (args: Record<string, unknown> | null) => void;
+  setBookCreating: (creating: boolean) => void;
+  setCreateProgress: (progress: string) => void;
+  handleCreateBook: (sessionId: string, activeBookId?: string) => Promise<string | null>;
+  bumpBookDataVersion: () => void;
+  openArtifact: (file: string) => void;
+  openChapterArtifact: (chapterNum: number) => void;
+  closeArtifact: () => void;
+  setBookSummary: (summary: BookSummary | null) => void;
+}
+```
+
+- [ ] **Step 4: 更新 initialState**
+
+`slices/message/initialState.ts`:
+
+```ts
+import type { MessageState } from "../../types";
+
+export const initialMessageState: MessageState = {
+  sessions: {},
+  activeSessionId: null,
+  sessionIdsByBook: {},
+  input: "",
+  selectedModel: null,
+  selectedService: null,
+};
+```
+
+- [ ] **Step 5: 更新 selectors.ts**
+
+```ts
+import type { ChatState, SessionRuntime } from "./types";
+
+export const chatSelectors = {
+  activeSession: (s: ChatState): SessionRuntime | undefined =>
+    s.activeSessionId ? s.sessions[s.activeSessionId] : undefined,
+  activeMessages: (s: ChatState) =>
+    s.activeSessionId ? (s.sessions[s.activeSessionId]?.messages ?? []) : [],
+  activeIsStreaming: (s: ChatState) =>
+    s.activeSessionId ? (s.sessions[s.activeSessionId]?.isStreaming ?? false) : false,
+  hasPendingTool: (s: ChatState) => s.pendingBookArgs !== null,
+  isCreating: (s: ChatState) => s.bookCreating,
+  isEmpty: (s: ChatState) => {
+    if (!s.activeSessionId) return true;
+    const session = s.sessions[s.activeSessionId];
+    return !session || (session.messages.length === 0 && !session.isStreaming);
+  },
+};
+```
+
+- [ ] **Step 6: 写 selector 测试**
+
+创建 `packages/studio/src/store/chat/__tests__/selectors.test.ts`:
+
+```ts
+import { describe, expect, it } from "vitest";
+import { chatSelectors } from "../selectors";
+import type { ChatState, SessionRuntime } from "../types";
+
+function makeSession(overrides: Partial<SessionRuntime> = {}): SessionRuntime {
+  return {
+    sessionId: "sess-1",
+    bookId: "book-1",
+    title: null,
+    messages: [],
+    stream: null,
+    isStreaming: false,
+    lastError: null,
+    ...overrides,
+  };
+}
+
+function makeState(overrides: Partial<ChatState> = {}): ChatState {
+  return {
+    sessions: {},
+    activeSessionId: null,
+    sessionIdsByBook: {},
+    input: "",
+    selectedModel: null,
+    selectedService: null,
+    pendingBookArgs: null,
+    bookCreating: false,
+    createProgress: "",
+    bookDataVersion: 0,
+    sidebarView: "panel",
+    artifactFile: null,
+    artifactChapter: null,
+    bookSummary: null,
+    ...overrides,
+  };
+}
+
+describe("chatSelectors", () => {
+  it("activeSession returns undefined when no activeSessionId", () => {
+    const state = makeState();
+    expect(chatSelectors.activeSession(state)).toBeUndefined();
+  });
+
+  it("activeSession returns the active session", () => {
+    const sess = makeSession();
+    const state = makeState({
+      sessions: { "sess-1": sess },
+      activeSessionId: "sess-1",
+    });
+    expect(chatSelectors.activeSession(state)).toBe(sess);
+  });
+
+  it("activeMessages returns empty array when no session", () => {
+    const state = makeState();
+    expect(chatSelectors.activeMessages(state)).toEqual([]);
+  });
+
+  it("activeMessages returns messages of active session", () => {
+    const msgs = [{ role: "user" as const, content: "hi", timestamp: 1 }];
+    const sess = makeSession({ messages: msgs });
+    const state = makeState({
+      sessions: { "sess-1": sess },
+      activeSessionId: "sess-1",
+    });
+    expect(chatSelectors.activeMessages(state)).toBe(msgs);
+  });
+
+  it("activeIsStreaming returns streaming status", () => {
+    const sess = makeSession({ isStreaming: true });
+    const state = makeState({
+      sessions: { "sess-1": sess },
+      activeSessionId: "sess-1",
+    });
+    expect(chatSelectors.activeIsStreaming(state)).toBe(true);
+  });
+
+  it("isEmpty returns true when no active session", () => {
+    expect(chatSelectors.isEmpty(makeState())).toBe(true);
+  });
+
+  it("isEmpty returns false when active session has messages", () => {
+    const sess = makeSession({
+      messages: [{ role: "user" as const, content: "hi", timestamp: 1 }],
+    });
+    const state = makeState({
+      sessions: { "sess-1": sess },
+      activeSessionId: "sess-1",
+    });
+    expect(chatSelectors.isEmpty(state)).toBe(false);
+  });
+});
+```
+
+- [ ] **Step 7: 跑 type check + 测试**
+
+Run: `cd packages/studio && npx tsc --noEmit`
+Expected: 大量类型错误(action 文件还没改)——确认错误集中在 action.ts 和 ChatPage 里即可,types/initialState/selectors 自身无误。
+
+Run: `pnpm test -- packages/studio/src/store/chat/__tests__/selectors.test.ts`
+Expected: PASS
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add packages/studio/src/store/chat/types.ts packages/studio/src/store/chat/slices/message/initialState.ts packages/studio/src/store/chat/initialState.ts packages/studio/src/store/chat/selectors.ts packages/studio/src/store/chat/__tests__/selectors.test.ts
+git commit -m "refactor(studio): restructure chat store types for multi-session"
+```
+
+---
+
+## Task 6: Store — 重构 message slice actions
+
+**Files:**
+- Modify: `packages/studio/src/store/chat/slices/message/action.ts`
+
+这是最大的一个 task。核心变化:所有 set/get 操作都通过 `sessionId` 定位到 `sessions[sessionId]`。
+
+- [ ] **Step 1: 添加 session 更新辅助函数**
+
+在 action.ts 顶部(createMessageSlice 之前)加:
+
+```ts
+/** 更新 sessions map 中指定 sessionId 的 SessionRuntime */
+function updateSession(
+  sessions: Record<string, SessionRuntime>,
+  sessionId: string,
+  updater: (s: SessionRuntime) => Partial<SessionRuntime>,
+): Record<string, SessionRuntime> {
+  const existing = sessions[sessionId];
+  if (!existing) return sessions;
+  return { ...sessions, [sessionId]: { ...existing, ...updater(existing) } };
+}
+```
+
+(SessionRuntime 需要从 types 导入。)
+
+- [ ] **Step 2: 实现 session 生命周期 actions**
+
+在 createMessageSlice 内实现:
+
+```ts
+activateSession: (sessionId) => {
+  set({ activeSessionId: sessionId });
+},
+
+loadSessionList: async (bookId) => {
+  try {
+    const data = await fetchJson<{ sessions: Array<{ sessionId: string; bookId: string | null; title: string | null; messageCount: number; createdAt: number; updatedAt: number }> }>(`/sessions?bookId=${encodeURIComponent(bookId)}`);
+    const ids = data.sessions.map((s) => s.sessionId);
+    // 把摘要信息写到 sessions map(如果还没加载过完整消息就先放空消息)
+    set((prev) => {
+      const sessions = { ...prev.sessions };
+      for (const s of data.sessions) {
+        if (!sessions[s.sessionId]) {
+          sessions[s.sessionId] = {
+            sessionId: s.sessionId,
+            bookId: s.bookId,
+            title: s.title,
+            messages: [],
+            stream: null,
+            isStreaming: false,
+            lastError: null,
+          };
+        } else {
+          // 已有完整数据的只更新 title
+          sessions[s.sessionId] = { ...sessions[s.sessionId], title: s.title };
+        }
+      }
+      return {
+        sessions,
+        sessionIdsByBook: { ...prev.sessionIdsByBook, [bookId]: ids },
+      };
+    });
+  } catch {
+    // 静默
+  }
+},
+
+createSession: async (bookId) => {
+  const data = await fetchJson<{ session: { sessionId: string; bookId: string | null; title: string | null } }>("/sessions", {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({ bookId }),
+  });
+  const s = data.session;
+  set((prev) => {
+    const runtime: SessionRuntime = {
+      sessionId: s.sessionId,
+      bookId: s.bookId,
+      title: s.title,
+      messages: [],
+      stream: null,
+      isStreaming: false,
+      lastError: null,
+    };
+    const sessions = { ...prev.sessions, [s.sessionId]: runtime };
+    const sessionIdsByBook = { ...prev.sessionIdsByBook };
+    const key = s.bookId ?? "__null__";
+    sessionIdsByBook[key] = [s.sessionId, ...(sessionIdsByBook[key] ?? [])];
+    return { sessions, sessionIdsByBook, activeSessionId: s.sessionId };
+  });
+  return s.sessionId;
+},
+
+renameSession: async (sessionId, title) => {
+  const prev = get().sessions[sessionId];
+  // 乐观更新
+  set((s) => ({ sessions: updateSession(s.sessions, sessionId, () => ({ title })) }));
+  try {
+    await fetchJson(`/sessions/${sessionId}`, {
+      method: "PUT",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ title }),
+    });
+  } catch {
+    // 回滚
+    set((s) => ({ sessions: updateSession(s.sessions, sessionId, () => ({ title: prev?.title ?? null })) }));
+  }
+},
+
+deleteSession: async (sessionId) => {
+  try {
+    await fetchJson(`/sessions/${sessionId}`, { method: "DELETE" });
+  } catch { /* ignore */ }
+  set((prev) => {
+    const { [sessionId]: _, ...rest } = prev.sessions;
+    const sessionIdsByBook = { ...prev.sessionIdsByBook };
+    for (const key of Object.keys(sessionIdsByBook)) {
+      sessionIdsByBook[key] = sessionIdsByBook[key].filter((id) => id !== sessionId);
+    }
+    const activeSessionId = prev.activeSessionId === sessionId ? null : prev.activeSessionId;
+    return { sessions: rest, sessionIdsByBook, activeSessionId };
+  });
+},
+```
+
+- [ ] **Step 3: 重构消息操作 actions(加 sessionId 参数)**
+
+所有原来直接读/写 `s.messages` 的 action,改成读/写 `s.sessions[sessionId].messages`。以 `addUserMessage` 为例:
+
+```ts
+addUserMessage: (sessionId, content) => set((s) => ({
+  sessions: updateSession(s.sessions, sessionId, (sess) => ({
+    messages: [...sess.messages, { role: "user" as const, content, timestamp: Date.now() }],
+  })),
+})),
+```
+
+`appendStreamChunk`、`finalizeStream`、`replaceStreamWithError`、`addErrorMessage`、`loadSessionMessages` 同理——在 `set((s) => ...)` 内用 `updateSession` 操作 `s.sessions[sessionId]` 而非 `s.messages`。
+
+每个函数的 `getOrCreateStream` 和 `replaceLast` helper 传入的 `messages` 来源改为 `s.sessions[sessionId].messages`。
+
+- [ ] **Step 4: 重构 sendMessage**
+
+核心变化:
+1. 签名从 `(text, activeBookId?)` 改为 `(sessionId, text, activeBookId?)`
+2. `get().loading` 的检查改为 `get().sessions[sessionId]?.isStreaming`
+3. EventSource 存到 `sessions[sessionId].stream` 而非 `_activeStream`
+4. 所有 SSE listener 里的 set 操作都通过 `updateSession(s.sessions, sessionId, ...)` 定位
+5. POST /agent 的 body 里 `sessionId` 从 `get().currentSessionId` 改为参数 `sessionId`
+6. finally 里 set `isStreaming: false, stream: null` 到 `sessions[sessionId]`
+
+```ts
+sendMessage: async (sessionId, text, activeBookId) => {
+  const trimmed = text.trim();
+  const session = get().sessions[sessionId];
+  if (!trimmed || !session || session.isStreaming) return;
+
+  if (!get().selectedModel) {
+    get().addUserMessage(sessionId, trimmed);
+    get().addErrorMessage(sessionId, "请先选择一个模型");
+    return;
+  }
+
+  const hasBook = Boolean(activeBookId);
+  const instruction = hasBook ? trimmed : `/new ${trimmed}`;
+  const streamTs = Date.now() + 1;
+
+  set((s) => ({
+    input: "",
+    sessions: updateSession(s.sessions, sessionId, () => ({ isStreaming: true })),
+  }));
+  get().addUserMessage(sessionId, trimmed);
+
+  // 关掉该 session 旧的 stream(如果有的话)
+  session.stream?.close();
+  const streamEs = new EventSource("/api/v1/events");
+  set((s) => ({
+    sessions: updateSession(s.sessions, sessionId, () => ({ stream: streamEs })),
+  }));
+
+  // SSE listeners — 与现有逻辑相同,但 set 内用 updateSession(s.sessions, sessionId, ...)
+  // ... (每个 addEventListener 的 set 回调里把 s.messages → s.sessions[sessionId].messages)
+  // ... (getOrCreateStream 的 messages 参数从 session.messages 取)
+
+  // POST /agent
+  try {
+    const data = await fetchJson<AgentResponse>("/agent", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        instruction,
+        activeBookId,
+        sessionId,                              // ← 直接用参数
+        model: get().selectedModel ?? undefined,
+        service: get().selectedService ?? undefined,
+      }),
+    });
+
+    streamEs.close();
+    // ... finalize 逻辑同现有,但用 get().finalizeStream(sessionId, ...)
+  } catch (e) {
+    streamEs.close();
+    // ... error 逻辑同现有
+  } finally {
+    set((s) => ({
+      sessions: updateSession(s.sessions, sessionId, () => ({
+        isStreaming: false,
+        stream: null,
+      })),
+    }));
+  }
+},
+```
+
+SSE listener 内部的 `set((s) => { ... })` 回调全部改用 `updateSession`。具体每个 listener 的改动模式一致——把 `s.messages` 替换为 `s.sessions[sessionId].messages`,把 `return { messages: ... }` 替换为 `return { sessions: updateSession(s.sessions, sessionId, (sess) => ({ messages: ... })) }`。
+
+- [ ] **Step 5: 删除旧的 loadSession action**
+
+原来的 `loadSession: async (bookId) => { ... }` 整个删掉。它的功能被 `loadSessionList` + `activateSession` + 按需 GET /sessions/:id 加载消息替代。
+
+如果需要加载某个 session 的完整消息(用户点击侧边栏 session 节点时),加一个新 action:
+
+```ts
+loadSessionDetail: async (sessionId: string) => {
+  const existing = get().sessions[sessionId];
+  if (existing && existing.messages.length > 0) return; // 已加载
+  try {
+    const data = await fetchJson<{ session: { sessionId: string; messages?: SessionMessage[] } }>(`/sessions/${sessionId}`);
+    if (data.session.messages && data.session.messages.length > 0) {
+      get().loadSessionMessages(sessionId, data.session.messages);
+    }
+  } catch { /* ignore */ }
+},
+```
+
+(并把 `loadSessionDetail` 加到 `MessageActions` interface 中。)
+
+- [ ] **Step 6: 写 message slice 多 session 隔离测试**
+
+创建 `packages/studio/src/store/chat/slices/message/__tests__/multi-session.test.ts`:
+
+```ts
+import { describe, expect, it, beforeEach } from "vitest";
+import { useChatStore } from "../../../store";
+import type { SessionRuntime } from "../../../types";
+
+function seedSession(sessionId: string, bookId: string | null = "book"): SessionRuntime {
+  return {
+    sessionId,
+    bookId,
+    title: null,
+    messages: [],
+    stream: null,
+    isStreaming: false,
+    lastError: null,
+  };
+}
+
+describe("message slice — multi-session isolation", () => {
+  beforeEach(() => {
+    useChatStore.setState({
+      sessions: {
+        "sess-a": seedSession("sess-a"),
+        "sess-b": seedSession("sess-b"),
+      },
+      activeSessionId: "sess-a",
+      sessionIdsByBook: { book: ["sess-a", "sess-b"] },
+      input: "",
+      selectedModel: null,
+      selectedService: null,
+    });
+  });
+
+  it("addUserMessage writes only to target session", () => {
+    useChatStore.getState().addUserMessage("sess-a", "hello");
+    const state = useChatStore.getState();
+    expect(state.sessions["sess-a"].messages).toHaveLength(1);
+    expect(state.sessions["sess-a"].messages[0].content).toBe("hello");
+    expect(state.sessions["sess-b"].messages).toHaveLength(0);
+  });
+
+  it("addErrorMessage writes only to target session", () => {
+    useChatStore.getState().addErrorMessage("sess-b", "oops");
+    const state = useChatStore.getState();
+    expect(state.sessions["sess-a"].messages).toHaveLength(0);
+    expect(state.sessions["sess-b"].messages).toHaveLength(1);
+    expect(state.sessions["sess-b"].messages[0].content).toContain("oops");
+  });
+
+  it("activateSession changes activeSessionId without affecting sessions", () => {
+    useChatStore.getState().activateSession("sess-b");
+    const state = useChatStore.getState();
+    expect(state.activeSessionId).toBe("sess-b");
+    expect(Object.keys(state.sessions)).toHaveLength(2);
+  });
+
+  it("deleteSession removes from map and sessionIdsByBook", () => {
+    // 需要 mock fetchJson 或者直接测 set 逻辑
+    // 这里测同步部分:手动调 setState 模拟 deleteSession 的 set 回调
+    useChatStore.setState((prev) => {
+      const { "sess-a": _, ...rest } = prev.sessions;
+      const sessionIdsByBook = { ...prev.sessionIdsByBook };
+      for (const key of Object.keys(sessionIdsByBook)) {
+        sessionIdsByBook[key] = sessionIdsByBook[key].filter((id) => id !== "sess-a");
+      }
+      return { sessions: rest, sessionIdsByBook, activeSessionId: null };
+    });
+    const state = useChatStore.getState();
+    expect(state.sessions["sess-a"]).toBeUndefined();
+    expect(state.sessionIdsByBook["book"]).toEqual(["sess-b"]);
+    expect(state.activeSessionId).toBeNull();
+  });
+
+  it("deleteSession clears activeSessionId if deleted session was active", () => {
+    useChatStore.setState({ activeSessionId: "sess-a" });
+    // 模拟 delete sess-a
+    useChatStore.setState((prev) => {
+      const { "sess-a": _, ...rest } = prev.sessions;
+      const activeSessionId = prev.activeSessionId === "sess-a" ? null : prev.activeSessionId;
+      return { sessions: rest, activeSessionId };
+    });
+    expect(useChatStore.getState().activeSessionId).toBeNull();
+  });
+});
+```
+
+- [ ] **Step 7: 跑 type check + 测试**
+
+Run: `cd packages/studio && npx tsc --noEmit`
+Expected: 还会有 ChatPage / Sidebar / create slice 的类型错误(它们还没改),但 message action 本身无误。
+
+Run: `pnpm test -- packages/studio/src/store/chat/slices/message/__tests__/multi-session.test.ts`
+Expected: PASS
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add packages/studio/src/store/chat/slices/message/action.ts packages/studio/src/store/chat/slices/message/__tests__/multi-session.test.ts
+git commit -m "refactor(studio): rewrite message slice for multi-session"
+```
+
+---
+
+## Task 7: Store — 重构 create slice
+
+**Files:**
+- Modify: `packages/studio/src/store/chat/slices/create/action.ts`
+
+- [ ] **Step 1: handleCreateBook 传 sessionId + 处理迁移**
+
+```ts
+handleCreateBook: async (sessionId, activeBookId) => {
+  if (!get().pendingBookArgs) return null;
+
+  set({ bookCreating: true });
+  try {
+    const data = await fetchJson<AgentResponse>("/agent", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ instruction: "/create", activeBookId, sessionId }),
+    });
+    const newBookId = data.session?.activeBookId ?? null;
+    if (newBookId) {
+      get().bumpBookDataVersion();
+      // 更新 store:把 session 的 bookId 从 null 改成新书籍 id
+      set((s) => {
+        const sessions = { ...s.sessions };
+        const sess = sessions[sessionId];
+        if (sess) {
+          sessions[sessionId] = { ...sess, bookId: newBookId };
+        }
+        const sessionIdsByBook = { ...s.sessionIdsByBook };
+        // 从 __null__ 列表里移除
+        if (sessionIdsByBook["__null__"]) {
+          sessionIdsByBook["__null__"] = sessionIdsByBook["__null__"].filter((id) => id !== sessionId);
+        }
+        // 加到新书籍的列表里
+        sessionIdsByBook[newBookId] = [sessionId, ...(sessionIdsByBook[newBookId] ?? [])];
+        return { sessions, sessionIdsByBook };
+      });
+      // 清 localStorage
+      localStorage.removeItem("currentBookCreateSessionId");
+    }
+    return newBookId;
+  } catch (e) {
+    get().addErrorMessage(sessionId, e instanceof Error ? e.message : String(e));
+    return null;
+  } finally {
+    set({ bookCreating: false });
+  }
+},
+```
+
+- [ ] **Step 2: 写 create slice 迁移测试**
+
+创建 `packages/studio/src/store/chat/slices/create/__tests__/migration.test.ts`:
+
+```ts
+import { describe, expect, it, beforeEach } from "vitest";
+import { useChatStore } from "../../../store";
+import type { SessionRuntime } from "../../../types";
+
+describe("create slice — session migration on book create", () => {
+  beforeEach(() => {
+    // 模拟:一条 bookId=null 的 session 正在进行新建书籍讨论
+    const nullSession: SessionRuntime = {
+      sessionId: "draft-sess",
+      bookId: null,
+      title: null,
+      messages: [{ role: "user", content: "想写玄幻", timestamp: 1 }],
+      stream: null,
+      isStreaming: false,
+      lastError: null,
+    };
+    useChatStore.setState({
+      sessions: { "draft-sess": nullSession },
+      activeSessionId: "draft-sess",
+      sessionIdsByBook: { "__null__": ["draft-sess"] },
+      pendingBookArgs: { title: "测试书籍" },
+      bookCreating: false,
+    });
+  });
+
+  it("migrates session bookId from null to new bookId on create success", () => {
+    const newBookId = "new-book-123";
+    // 模拟 handleCreateBook 成功后的 set 回调
+    useChatStore.setState((s) => {
+      const sessions = { ...s.sessions };
+      const sess = sessions["draft-sess"];
+      if (sess) {
+        sessions["draft-sess"] = { ...sess, bookId: newBookId };
+      }
+      const sessionIdsByBook = { ...s.sessionIdsByBook };
+      if (sessionIdsByBook["__null__"]) {
+        sessionIdsByBook["__null__"] = sessionIdsByBook["__null__"].filter((id) => id !== "draft-sess");
+      }
+      sessionIdsByBook[newBookId] = ["draft-sess", ...(sessionIdsByBook[newBookId] ?? [])];
+      return { sessions, sessionIdsByBook };
+    });
+
+    const state = useChatStore.getState();
+    expect(state.sessions["draft-sess"].bookId).toBe(newBookId);
+    expect(state.sessionIdsByBook["__null__"]).toEqual([]);
+    expect(state.sessionIdsByBook[newBookId]).toEqual(["draft-sess"]);
+  });
+
+  it("preserves messages after migration", () => {
+    useChatStore.setState((s) => ({
+      sessions: {
+        ...s.sessions,
+        "draft-sess": { ...s.sessions["draft-sess"], bookId: "new-book" },
+      },
+    }));
+    expect(useChatStore.getState().sessions["draft-sess"].messages).toHaveLength(1);
+    expect(useChatStore.getState().sessions["draft-sess"].messages[0].content).toBe("想写玄幻");
+  });
+});
+```
+
+- [ ] **Step 3: 跑 type check + 测试**
+
+Run: `cd packages/studio && npx tsc --noEmit`
+Expected: 只剩 ChatPage / Sidebar / App 层的类型错误。
+
+Run: `pnpm test -- packages/studio/src/store/chat/slices/create/__tests__/migration.test.ts`
+Expected: PASS
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add packages/studio/src/store/chat/slices/create/action.ts packages/studio/src/store/chat/slices/create/__tests__/migration.test.ts
+git commit -m "refactor(studio): update create slice for session migration"
+```
+
+---
+
+## Task 8: UI — Sidebar 改为树形
+
+**Files:**
+- Modify: `packages/studio/src/components/Sidebar.tsx`
+
+- [ ] **Step 1: 书列表改为可展开的树**
+
+把 Sidebar.tsx 中原来遍历 `data?.books` 渲染扁平按钮的部分(第 97-116 行),改为每本书可展开/折叠,展开后显示该书的 session 列表 + "+ 新建会话"按钮。
+
+```tsx
+// 在 Sidebar 函数组件内加 state:
+const [expandedBooks, setExpandedBooks] = useState<Set<string>>(new Set());
+const sessionIdsByBook = useChatStore((s) => s.sessionIdsByBook);
+const sessions = useChatStore((s) => s.sessions);
+const activeSessionId = useChatStore((s) => s.activeSessionId);
+const loadSessionList = useChatStore((s) => s.loadSessionList);
+const activateSession = useChatStore((s) => s.activateSession);
+const createSession = useChatStore((s) => s.createSession);
+const loadSessionDetail = useChatStore((s) => s.loadSessionDetail);
+
+const toggleBook = (bookId: string) => {
+  setExpandedBooks((prev) => {
+    const next = new Set(prev);
+    if (next.has(bookId)) {
+      next.delete(bookId);
+    } else {
+      next.add(bookId);
+      // 展开时加载 session 列表
+      void loadSessionList(bookId);
+    }
+    return next;
+  });
+};
+
+const handleSessionClick = (sessionId: string) => {
+  activateSession(sessionId);
+  void loadSessionDetail(sessionId);
+  // 路由跳转到对应书籍
+  const sess = sessions[sessionId];
+  if (sess?.bookId) {
+    nav.toBook(sess.bookId);
+  }
+};
+
+const handleNewSession = async (bookId: string) => {
+  const sessionId = await createSession(bookId);
+  nav.toBook(bookId);
+};
+```
+
+- [ ] **Step 2: 渲染树形 JSX**
+
+替换第 97-116 行的扁平列表为:
+
+```tsx
+{data?.books.map((book) => {
+  const isExpanded = expandedBooks.has(book.id);
+  const bookSessions = sessionIdsByBook[book.id] ?? [];
+
+  return (
+    <div key={book.id}>
+      {/* 书名行 — 点击展开/折叠 */}
+      <button
+        onClick={() => toggleBook(book.id)}
+        className={`w-full group flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all duration-200 ${
+          activePage === `book:${book.id}`
+            ? "bg-primary/10 text-primary font-medium"
+            : "text-foreground font-medium hover:text-foreground hover:bg-secondary/50"
+        }`}
+      >
+        <span className="text-muted-foreground text-xs">{isExpanded ? "▾" : "▸"}</span>
+        <Book size={16} className={activePage === `book:${book.id}` ? "text-primary" : "text-muted-foreground group-hover:text-foreground"} />
+        <span className="truncate flex-1 text-left">{book.title}</span>
+        {bookSessions.length > 0 && (
+          <span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
+            {bookSessions.length}
+          </span>
+        )}
+      </button>
+
+      {/* 展开:session 子项 */}
+      {isExpanded && (
+        <div className="ml-6 mt-0.5 space-y-0.5">
+          {bookSessions.map((sid) => {
+            const sess = sessions[sid];
+            const isActive = activeSessionId === sid;
+            const isStreaming = sess?.isStreaming ?? false;
+            const displayTitle = sess?.title ?? `新会话 · ${new Date(sess?.messages[0]?.timestamp ?? Date.now()).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}`;
+
+            return (
+              <button
+                key={sid}
+                onClick={() => handleSessionClick(sid)}
+                className={`w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-xs transition-all ${
+                  isActive
+                    ? "bg-primary/10 text-primary font-medium"
+                    : "text-muted-foreground hover:text-foreground hover:bg-secondary/30"
+                }`}
+              >
+                <span className="truncate flex-1 text-left">{displayTitle}</span>
+                {isStreaming && (
+                  <span className="w-3 h-3 shrink-0 animate-spin rounded-full border-2 border-primary/30 border-t-primary" />
+                )}
+                {isActive && !isStreaming && (
+                  <span className="w-1.5 h-1.5 shrink-0 rounded-full bg-primary" />
+                )}
+              </button>
+            );
+          })}
+
+          {/* + 新建会话 */}
+          <button
+            onClick={() => void handleNewSession(book.id)}
+            className="w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-xs text-muted-foreground/60 hover:text-primary hover:bg-primary/5 transition-all"
+          >
+            <Plus size={12} />
+            <span>新建会话</span>
+          </button>
+        </div>
+      )}
+    </div>
+  );
+})}
+```
+
+- [ ] **Step 3: "新建书籍"按钮改为走 localStorage 逻辑**
+
+Sidebar 中的 `nav.toBookCreate` 调用不变——保持跳转到 `#/book/new` 路由。实际新建 session 的逻辑在 ChatPage 里处理(Task 9)。
+
+- [ ] **Step 4: 写 Sidebar session 树结构测试**
+
+创建 `packages/studio/src/components/__tests__/Sidebar-sessions.test.tsx`:
+
+```tsx
+import { describe, expect, it, beforeEach } from "vitest";
+import { useChatStore } from "../../store/chat";
+import type { SessionRuntime } from "../../store/chat/types";
+
+// Sidebar 依赖 DOM,完整组件测试需要 @testing-library/react。
+// 如果项目还没装,这里先测 store 层的数据是否正确驱动 UI。
+
+function seedSession(id: string, overrides: Partial<SessionRuntime> = {}): SessionRuntime {
+  return {
+    sessionId: id,
+    bookId: "book-1",
+    title: null,
+    messages: [],
+    stream: null,
+    isStreaming: false,
+    lastError: null,
+    ...overrides,
+  };
+}
+
+describe("Sidebar session tree — store integration", () => {
+  beforeEach(() => {
+    useChatStore.setState({
+      sessions: {
+        "s1": seedSession("s1", { title: "初稿讨论" }),
+        "s2": seedSession("s2", { isStreaming: true, title: "第三章正文" }),
+      },
+      activeSessionId: "s1",
+      sessionIdsByBook: { "book-1": ["s1", "s2"] },
+    });
+  });
+
+  it("sessionIdsByBook contains correct session ids for a book", () => {
+    const state = useChatStore.getState();
+    expect(state.sessionIdsByBook["book-1"]).toEqual(["s1", "s2"]);
+  });
+
+  it("session with isStreaming=true should show active indicator", () => {
+    const state = useChatStore.getState();
+    expect(state.sessions["s2"].isStreaming).toBe(true);
+    expect(state.sessions["s1"].isStreaming).toBe(false);
+  });
+
+  it("activeSessionId identifies the selected session", () => {
+    expect(useChatStore.getState().activeSessionId).toBe("s1");
+  });
+
+  it("session titles render correctly", () => {
+    const s1 = useChatStore.getState().sessions["s1"];
+    const s2 = useChatStore.getState().sessions["s2"];
+    expect(s1.title).toBe("初稿讨论");
+    expect(s2.title).toBe("第三章正文");
+  });
+});
+```
+
+- [ ] **Step 5: 跑 type check + 测试**
+
+Run: `cd packages/studio && npx tsc --noEmit`
+
+Run: `pnpm test -- packages/studio/src/components/__tests__/Sidebar-sessions.test.tsx`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/studio/src/components/Sidebar.tsx packages/studio/src/components/__tests__/Sidebar-sessions.test.tsx
+git commit -m "feat(studio): sidebar tree with sessions per book"
+```
+
+---
+
+## Task 9: UI — ChatPage 适配多 session + 新建书籍流程
+
+**Files:**
+- Modify: `packages/studio/src/pages/ChatPage.tsx`
+- Modify: `packages/studio/src/pages/chat-page-state.ts`
+- Modify: `packages/studio/src/App.tsx`
+
+- [ ] **Step 1: chat-page-state.ts 替换 shouldUseFreshBookCreateSession**
+
+删掉 `shouldUseFreshBookCreateSession`,整个文件改为:
+
+```ts
+export interface ChatPageModelInfo {
+  readonly id: string;
+  readonly name?: string;
+}
+
+export interface ChatPageModelGroup {
+  readonly service: string;
+  readonly label: string;
+  readonly models: ReadonlyArray<ChatPageModelInfo>;
+}
+
+const BOOK_CREATE_SESSION_KEY = "currentBookCreateSessionId";
+
+export function getBookCreateSessionId(): string | null {
+  return localStorage.getItem(BOOK_CREATE_SESSION_KEY);
+}
+
+export function setBookCreateSessionId(sessionId: string): void {
+  localStorage.setItem(BOOK_CREATE_SESSION_KEY, sessionId);
+}
+
+export function clearBookCreateSessionId(): void {
+  localStorage.removeItem(BOOK_CREATE_SESSION_KEY);
+}
+
+export function filterModelGroups(
+  groupedModels: ReadonlyArray<ChatPageModelGroup>,
+  search: string,
+): ReadonlyArray<ChatPageModelGroup> {
+  const query = search.trim().toLowerCase();
+  if (!query) return groupedModels;
+
+  return groupedModels
+    .map((group) => ({
+      ...group,
+      models: group.models.filter((model) =>
+        (model.name ?? model.id).toLowerCase().includes(query)
+        || group.label.toLowerCase().includes(query),
+      ),
+    }))
+    .filter((group) => group.models.length > 0);
+}
+```
+
+- [ ] **Step 2: ChatPage 从 activeSessionId 读消息**
+
+`ChatPage.tsx` 的 store selector 部分改为:
+
+```tsx
+// -- Store selectors --
+const activeSessionId = useChatStore((s) => s.activeSessionId);
+const activeSession = useChatStore((s) => s.activeSessionId ? s.sessions[s.activeSessionId] : undefined);
+
+const messages = useMemo(() => activeSession?.messages ?? [], [activeSession?.messages]);
+const isStreaming = useMemo(() => {
+  const last = messages[messages.length - 1];
+  if (!last || last.role !== "assistant") return false;
+  return last.thinkingStreaming === true
+    || !last.content
+    || (last.toolExecutions?.some(t => t.status === "running" || t.status === "processing") ?? false);
+}, [messages]);
+const loading = activeSession?.isStreaming ?? false;
+
+const input = useChatStore((s) => s.input);
+const pendingBookArgs = useChatStore((s) => s.pendingBookArgs);
+const bookCreating = useChatStore((s) => s.bookCreating);
+const createProgress = useChatStore((s) => s.createProgress);
+const selectedModel = useChatStore((s) => s.selectedModel);
+const selectedService = useChatStore((s) => s.selectedService);
+
+// -- Store actions --
+const setInput = useChatStore((s) => s.setInput);
+const sendMessageAction = useChatStore((s) => s.sendMessage);
+const createSession = useChatStore((s) => s.createSession);
+const activateSession = useChatStore((s) => s.activateSession);
+const loadSessionDetail = useChatStore((s) => s.loadSessionDetail);
+const setPendingBookArgs = useChatStore((s) => s.setPendingBookArgs);
+const handleCreateBookAction = useChatStore((s) => s.handleCreateBook);
+const setCreateProgress = useChatStore((s) => s.setCreateProgress);
+const setSelectedModel = useChatStore((s) => s.setSelectedModel);
+```
+
+- [ ] **Step 3: 改写 mount/activeBookId 变化 effect**
+
+删除原来的 `useEffect(() => { if (shouldUseFreshBookCreateSession...) ... }, [activeBookId])`,替换为:
+
+```tsx
+// 进入书籍页面:加载 session 列表并激活最新的
+// 进入新建书籍页面:走 localStorage
+useEffect(() => {
+  if (activeBookId) {
+    // 有书 → 加载 session 列表,激活最新的
+    void (async () => {
+      await useChatStore.getState().loadSessionList(activeBookId);
+      const ids = useChatStore.getState().sessionIdsByBook[activeBookId];
+      if (ids && ids.length > 0) {
+        useChatStore.getState().activateSession(ids[0]);
+        void useChatStore.getState().loadSessionDetail(ids[0]);
+      } else {
+        // 这本书还没有 session,创建一个
+        await useChatStore.getState().createSession(activeBookId);
+      }
+    })();
+  } else {
+    // 无书 = 新建书籍模式
+    void (async () => {
+      const existingId = getBookCreateSessionId();
+      if (existingId) {
+        // 尝试加载已有的新建书籍 session
+        await useChatStore.getState().loadSessionDetail(existingId);
+        const sess = useChatStore.getState().sessions[existingId];
+        if (sess && sess.bookId === null) {
+          useChatStore.getState().activateSession(existingId);
+          return;
+        }
+      }
+      // 没有或已失效,新建一个
+      const newId = await useChatStore.getState().createSession(null);
+      setBookCreateSessionId(newId);
+    })();
+  }
+}, [activeBookId]);
+```
+
+import 行加 `getBookCreateSessionId, setBookCreateSessionId`。
+
+- [ ] **Step 4: onSend 和 onCreateBook 传 sessionId**
+
+```tsx
+const onSend = (text: string) => {
+  if (!activeSessionId) return;
+  void sendMessageAction(activeSessionId, text, activeBookId);
+};
+
+const onCreateBook = async () => {
+  if (!activeSessionId) return;
+  const newBookId = await handleCreateBookAction(activeSessionId, activeBookId);
+  if (newBookId) nav.toBook(newBookId);
+};
+```
+
+- [ ] **Step 5: App.tsx 不需要大改**
+
+`App.tsx` 第 165-179 行的 ChatPage 使用方式无需变化——它已经通过 `activeBookId` prop 传递,ChatPage 内部自行管理 sessionId。确认 `book-create` 路由仍然传 `activeBookId={undefined}`。
+
+- [ ] **Step 6: 写 chat-page-state 纯函数测试**
+
+创建 `packages/studio/src/pages/__tests__/chat-page-state.test.ts`:
+
+```ts
+import { describe, expect, it, beforeEach, afterEach } from "vitest";
+import {
+  getBookCreateSessionId,
+  setBookCreateSessionId,
+  clearBookCreateSessionId,
+  filterModelGroups,
+} from "../chat-page-state";
+
+// localStorage mock(vitest 默认用 jsdom,自带 localStorage)
+
+describe("chat-page-state — localStorage helpers", () => {
+  beforeEach(() => {
+    localStorage.clear();
+  });
+
+  afterEach(() => {
+    localStorage.clear();
+  });
+
+  it("getBookCreateSessionId returns null when empty", () => {
+    expect(getBookCreateSessionId()).toBeNull();
+  });
+
+  it("setBookCreateSessionId + get round-trips", () => {
+    setBookCreateSessionId("sess-123");
+    expect(getBookCreateSessionId()).toBe("sess-123");
+  });
+
+  it("setBookCreateSessionId overwrites previous value", () => {
+    setBookCreateSessionId("sess-old");
+    setBookCreateSessionId("sess-new");
+    expect(getBookCreateSessionId()).toBe("sess-new");
+  });
+
+  it("clearBookCreateSessionId removes the key", () => {
+    setBookCreateSessionId("sess-123");
+    clearBookCreateSessionId();
+    expect(getBookCreateSessionId()).toBeNull();
+  });
+
+  it("clearBookCreateSessionId is safe when key doesn't exist", () => {
+    clearBookCreateSessionId(); // 不抛异常
+    expect(getBookCreateSessionId()).toBeNull();
+  });
+});
+
+describe("chat-page-state — filterModelGroups", () => {
+  const groups = [
+    {
+      service: "openai",
+      label: "OpenAI",
+      models: [
+        { id: "gpt-4", name: "GPT-4" },
+        { id: "gpt-3.5-turbo", name: "GPT-3.5 Turbo" },
+      ],
+    },
+    {
+      service: "anthropic",
+      label: "Anthropic",
+      models: [{ id: "claude-3", name: "Claude 3" }],
+    },
+  ];
+
+  it("returns all groups when search is empty", () => {
+    expect(filterModelGroups(groups, "")).toBe(groups);
+    expect(filterModelGroups(groups, "  ")).toBe(groups);
+  });
+
+  it("filters by model name", () => {
+    const result = filterModelGroups(groups, "claude");
+    expect(result).toHaveLength(1);
+    expect(result[0].service).toBe("anthropic");
+  });
+
+  it("filters by service label", () => {
+    const result = filterModelGroups(groups, "openai");
+    expect(result).toHaveLength(1);
+    expect(result[0].models).toHaveLength(2);
+  });
+
+  it("returns empty when nothing matches", () => {
+    expect(filterModelGroups(groups, "nonexistent")).toHaveLength(0);
+  });
+});
+```
+
+- [ ] **Step 7: build + 测试**
+
+Run: `pnpm build && pnpm test`
+Expected: 全部通过
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add packages/studio/src/pages/ChatPage.tsx packages/studio/src/pages/chat-page-state.ts packages/studio/src/pages/__tests__/chat-page-state.test.ts packages/studio/src/App.tsx
+git commit -m "feat(studio): ChatPage multi-session + book create localStorage flow"
+```
+
+---
+
+## Task 10: 标题自动生成
+
+**Files:**
+- Modify: `packages/studio/src/api/server.ts`
+- Modify: `packages/core/src/interaction/book-session-store.ts`
+
+- [ ] **Step 1: 在 book-session-store.ts 加 updateSessionTitle**
+
+```ts
+export async function updateSessionTitle(
+  projectRoot: string,
+  sessionId: string,
+  title: string,
+): Promise<void> {
+  const session = await loadBookSession(projectRoot, sessionId);
+  if (!session || session.title !== null) return; // 已有标题(AI 生成的或用户改的),不覆盖
+  const updated = { ...session, title, updatedAt: Date.now() };
+  await persistBookSession(projectRoot, updated);
+}
+```
+
+- [ ] **Step 2: 在 server.ts POST /agent 成功返回后触发标题生成**
+
+在 POST /agent handler 的 try 块里,agent 返回成功后加:
+
+```ts
+// 标题生成(异步,不阻塞响应)
+// 条件:session 有 >= 1 条 user + 1 条 assistant 消息,且 title 为 null
+void (async () => {
+  try {
+    const freshSession = await loadBookSession(root, sessionId);
+    if (!freshSession || freshSession.title !== null) return;
+    const hasExchange = freshSession.messages.some((m) => m.role === "user")
+      && freshSession.messages.some((m) => m.role === "assistant");
+    if (!hasExchange) return;
+
+    // 用当前配的模型生成标题
+    const config = await loadCurrentProjectConfig({ requireApiKey: false });
+    const titleClient = createLLMClient(config.llm);
+    const userMsg = freshSession.messages.find((m) => m.role === "user")?.content ?? "";
+    const assistantMsg = freshSession.messages.find((m) => m.role === "assistant")?.content ?? "";
+    const titlePrompt = `用6个中文字以内概括这段对话的主题,只返回标题文本。\n\n用户: ${userMsg.slice(0, 200)}\n助手: ${assistantMsg.slice(0, 200)}`;
+
+    const result = await titleClient.chat([{ role: "user", content: titlePrompt }]);
+    const title = (typeof result === "string" ? result : result.content ?? "").trim().slice(0, 20);
+    if (title) {
+      await updateSessionTitle(root, sessionId, title);
+      broadcast("session:title", { sessionId, title });
+    }
+  } catch {
+    // 生成失败静默忽略
+  }
+})();
+```
+
+注意:这段代码在 response 返回给前端 **之后** 异步执行(`void (async () => ...)()`),不阻塞。`broadcast("session:title", ...)` 让前端实时更新侧边栏标题。
+
+- [ ] **Step 3: 前端监听 session:title 事件**
+
+在 ChatPage 或 App 层加一个 SSE listener:
+
+```ts
+// 可以放在 App.tsx 的 useEffect 里,或者单独的 hook
+useEffect(() => {
+  const es = new EventSource("/api/v1/events");
+  es.addEventListener("session:title", (e: MessageEvent) => {
+    try {
+      const d = e.data ? JSON.parse(e.data) : null;
+      if (d?.sessionId && d?.title) {
+        useChatStore.setState((s) => ({
+          sessions: {
+            ...s.sessions,
+            [d.sessionId]: s.sessions[d.sessionId]
+              ? { ...s.sessions[d.sessionId], title: d.title }
+              : s.sessions[d.sessionId],
+          },
+        }));
+      }
+    } catch { /* ignore */ }
+  });
+  return () => es.close();
+}, []);
+```
+
+考虑复用已有的全局 SSE 连接(`useSSE` hook)。如果项目已有全局 EventSource,就在那里加一个 listener 而不是新开连接。
+
+- [ ] **Step 4: 写标题生成测试**
+
+在 `book-session-store.test.ts` 加:
+
+```ts
+describe("updateSessionTitle", () => {
+  it("sets title when null", async () => {
+    const session = createBookSession("book");
+    await persistBookSession(tempDir, session);
+    await updateSessionTitle(tempDir, session.sessionId, "测试标题");
+    const loaded = await loadBookSession(tempDir, session.sessionId);
+    expect(loaded!.title).toBe("测试标题");
+  });
+
+  it("does not overwrite existing title", async () => {
+    let session = createBookSession("book");
+    session = { ...session, title: "已有标题" };
+    await persistBookSession(tempDir, session);
+    await updateSessionTitle(tempDir, session.sessionId, "新标题");
+    const loaded = await loadBookSession(tempDir, session.sessionId);
+    expect(loaded!.title).toBe("已有标题");
+  });
+});
+```
+
+- [ ] **Step 5: build + 测试**
+
+Run: `pnpm build && pnpm test`
+Expected: 全部通过
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/core/src/interaction/book-session-store.ts packages/core/src/__tests__/book-session-store.test.ts packages/studio/src/api/server.ts
+git commit -m "feat: auto-generate session title after first exchange"
+```
+
+---
+
+## Self-Review Checklist
+
+1. **Spec coverage:**
+   - ✅ title 字段 (Task 1)
+   - ✅ rename/delete/migrate (Task 2)
+   - ✅ 删除 findOrCreateBookSession (Task 3)
+   - ✅ API 端点 (Task 4)
+   - ✅ Store 重构 (Task 5-7)
+   - ✅ Sidebar 树形 (Task 8)
+   - ✅ ChatPage 多 session (Task 9)
+   - ✅ 新建书籍 localStorage 流程 (Task 9)
+   - ✅ 标题自动生成 (Task 10)
+   - ✅ 活跃指示(转圈)(Task 8 侧边栏)
+   - ✅ p-queue 并发控制 — 未单独 task,需在 Task 2 实现时加(注:当前 Node 单线程,先不加 p-queue,后续有实际并发问题再补)
+
+2. **Placeholder scan:** 无 TBD/TODO。
+
+3. **Type consistency:**
+   - `SessionRuntime` 在 types.ts 定义,action.ts 和 Sidebar.tsx 使用——字段名一致
+   - `updateSession` helper 在 action.ts 定义,签名 `(sessions, sessionId, updater)` 各处调用一致
+   - `sendMessage` 签名 `(sessionId, text, activeBookId?)` — ChatPage 调用 `sendMessageAction(activeSessionId, text, activeBookId)` — 一致
+   - `handleCreateBook` 签名 `(sessionId, activeBookId?)` — ChatPage 调用 `handleCreateBookAction(activeSessionId, activeBookId)` — 一致
+   - `loadSessionDetail` 在 Step 5 of Task 6 定义,需加到 `MessageActions` interface — 已提醒

+ 272 - 0
docs/superpowers/specs/2026-04-13-book-create-redesign.md

@@ -0,0 +1,272 @@
+# BookCreate 页面重构设计
+
+## 概述
+
+将 Studio 的建书页面从"双栏表单"重构为"单流对话 + 内嵌表单"。用户点击开始建书后,AI 流式回复,markdown 文本中穿插可交互的表单控件(输入框、选项组、数字输入等)。同时重写 `developBookDraft` 的 prompt,从纯 JSON 返回改为 markdown + 表单标记格式,core 层共享解析器统一服务 Studio 和 TUI。
+
+## 交互模型
+
+页面只有一栏,自上而下:
+
+1. 历史轮次摘要(折叠,点击可展开)
+2. 最新一轮 AI 回复(流式 markdown + 内嵌表单控件)
+3. 底部常驻输入栏(全页唯一的发送按钮)
+
+### 用户流程
+
+1. 用户输入一句话想法 → 点发送
+2. AI 流式回复出现,markdown 文本中穿插表单区块
+3. 用户可直接在表单里修改内容(静默更新本地 draft,不触发 LLM)
+4. 用户在底部输入栏继续打磨 → 带最新 draft 进入下一轮
+5. 每轮新回复替换当前内容,旧轮次折叠为一行摘要
+6. 草案就绪时,底部栏变形为确认区块:"开始写这本书" / "继续打磨"
+
+### 交互细节
+
+- **全页只有底部一个发送按钮**。内嵌选项(平台、方向等)点击即选,不带独立发送按钮。
+- **内嵌字段可编辑**。用户改了字段后本地 draft 立即更新,下次发送消息时修改过的 draft 一起带给 LLM。
+- **替换刷新**。页面始终只显示最新一轮的 AI 回复和表单。历史消息折叠为一行摘要(如"第 2 轮 · 你补充了反派设定"),点击可展开。
+- **草案就绪标准**:核心要素(书名、题材、世界观、主角、核心冲突、平台、章节规划)都已有内容时,AI 在回复末尾告知就绪,底部栏显示确认区块。
+
+## 表单标记协议
+
+LLM 输出 markdown 文本,中间穿插以下标记块:
+
+### 控件类型
+
+| 标记 | 渲染为 | 用途 |
+|------|--------|------|
+| `:::field{key label}` | `<input>` 或 `<textarea>` | 文本字段(书名、世界观、主角等) |
+| `:::pick{key label}` | 可点选的选项卡片 | 单选题(平台、方向等) |
+| `:::number{key label}` | `<input type="number">` | 数字字段(章数、字数) |
+| `:::group{label}` | 并排容器 | 把多个字段编组 |
+
+### 语法示例
+
+```markdown
+好的,这个方向很有张力——
+
+:::field{key="title" label="书名"}
+暗潮:港口城权力博弈
+:::
+
+世界观方面,港风商战通常扎根在繁华但暗流涌动的都市——
+
+:::field{key="worldPremise" label="世界观" type="textarea"}
+近未来港口城市,灰色产业链与金融精英交织的地下经济圈。
+:::
+
+接下来确定发布平台——
+
+:::pick{key="platform" label="目标平台"}
+- 番茄小说
+- 起点中文网
+- 飞卢
+- 其他
+:::
+
+:::group{label="章节规划"}
+:::number{key="targetChapters" label="目标章数"}
+300
+:::
+:::number{key="chapterWordCount" label="每章字数"}
+3000
+:::
+:::
+```
+
+### 可用字段
+
+按重要性排序:
+
+- title(书名)、genre(题材)、worldPremise(世界观)、protagonist(主角)、conflictCore(核心冲突)
+- blurb(简介)、volumeOutline(卷一方向)、supportingCast(配角)
+- platform(发布平台)、targetChapters(目标章数)、chapterWordCount(每章字数)
+- settingNotes(设定备注)、authorIntent(作者意图)、constraints(约束条件)
+
+## Prompt 设计
+
+```
+你是 InkOS 的建书引导员,负责帮用户从一句模糊想法出发,逐步打磨出一份可以开始写作的 foundation 草案。
+
+## 基础工作原则
+1. 请参考用户提供的已有草案内容,在此基础上推进,创作出具有延续性的设定。
+2. 你需要根据用户需求和创作情况维护草案内容,帮助用户管理和组织好书籍的基础结构。
+
+## 创作任务处理流程
+1. 在构建草案或执行复杂创作任务之前,请先输出你的构思计划,和用户确认后再推进。
+2. 当用户拒绝你的建议时,说明用户对当前方向不满意,请重新和用户沟通你的构思计划,不要直接继续。
+3. 先确立世界观和主角设定,确保用户确认满意后再推进核心冲突和卷纲方向。
+4. 每一步完成后,请主动和用户确认内容是否满意,如果用户不满意,请根据用户需求调整,直到用户确认满意后再推进下一步。
+
+## 草案应涵盖的要素
+
+### 1. 风格
+定义小说的文字气质和叙事基调:
+- **叙事视角**:主要视角(如第三人称有限视角)、视角切换规则
+- **语言基调**:整体气质(冷峻写实 / 华丽奇幻 / 轻松幽默 / 沉郁诗意)、时代感 / 地域感
+- **节奏偏好**:动作场景短句为主、日常场景长句铺陈
+- **对话风格**:对话功能(推进剧情 / 展现性格 / 蕴含潜台词)、口语化程度
+
+### 2. 世界观与设定
+记录小说中的设定信息,包括物品 / 场景 / 概念等,记录要包含创作内容所需要的各个方面信息。
+
+### 3. 角色
+记录小说中的角色信息,包括身份背景和经历、性格、外貌特征、能力和关系网络、内在驱动和成长弧光。主要角色需要详细设定,次要角色简要描述。
+
+### 4. 核心冲突与剧情方向
+明确小说的整体方向和核心矛盾。包含核心冲突、主线走向、卷一具体方向。
+
+### 5. 发布规划
+平台选择、目标章数、每章字数。不同平台的节奏和字数要求差别很大——番茄读者要节奏快、钩子密;起点读者能接受更重的设定铺垫。
+
+## 输出格式
+
+你的回复是 markdown 文本,中间穿插表单标记块。用户在流式对话界面中看到你的回复,表单块会渲染为可交互的输入控件。用户可以直接在控件中修改内容,修改会在下一轮对话时随草案一起发送给你。
+
+可用标记:
+
+:::field{key="字段名" label="显示标签"}
+预填内容
+:::
+
+:::field{key="字段名" label="显示标签" type="textarea"}
+多行预填内容
+:::
+
+:::pick{key="字段名" label="显示标签"}
+- 选项一
+- 选项二
+- 选项三
+:::
+
+:::number{key="字段名" label="显示标签"}
+默认值
+:::
+
+:::group{label="组标签"}
+(嵌套多个 field / number)
+:::
+
+### 输出规范
+1. 先输出你的思考和建议(自然的对话文本),再给出对应的表单块让用户确认或修改。
+2. 需要用户做选择时用 :::pick,需要用户填写或确认内容时用 :::field。
+3. 当信息足以推导出合理默认值时,大胆预填进表单——让用户改比让用户从零写更轻松。预填内容要体现你对该题材的理解,不要写泛泛的占位符。
+4. 每轮只推进一到两个焦点,不要一次铺开所有字段。
+5. 当核心要素(书名、题材、世界观、主角、核心冲突、平台、章节规划)都已有内容时,在回复末尾明确告知用户草案已就绪,可以开始写了。
+```
+
+## 前端组件架构
+
+当前 `BookCreate.tsx` 是一个 550 行的大组件。新设计拆为:
+
+| 组件 | 职责 |
+|------|------|
+| `BookCreate.tsx` | 页面容器,管理 draft state + 对话轮次 |
+| `StreamMessage.tsx` | 渲染一条 AI 回复:解析 markdown + `:::` 标记,输出文本和表单控件 |
+| `InlineField.tsx` | 单个内嵌表单字段(input / textarea / number) |
+| `InlinePick.tsx` | 单选选项卡片组,点击即选 |
+| `InlineGroup.tsx` | 字段编组容器 |
+| `RoundSummary.tsx` | 折叠的历史轮次摘要 |
+| `DraftReadyBar.tsx` | 草案就绪时的确认区块 |
+| `ComposerBar.tsx` | 底部常驻输入栏 + 发送按钮 |
+
+## 数据流
+
+```
+用户输入 → ComposerBar → POST /agent (instruction + 当前 draft 含用户修改)
+                                ↓
+                        NL Router → develop_book
+                                ↓
+                        developBookDraft(instruction, draft)
+                                ↓
+                        LLM 流式输出 markdown + :::标记
+                                ↓
+                        SSE 逐 token 推送到前端
+                                ↓
+                        StreamMessage 边接收边渲染
+                        - 普通文本 → markdown 渲染
+                        - :::field/pick/number → 表单控件
+                                ↓
+                        用户改表单 → 静默更新本地 draft
+                        用户发消息 → 带最新 draft 进入下一轮
+```
+
+### 与当前的关键差异
+
+- LLM 调用从一次性返回 JSON → 流式输出 markdown(`chatCompletion` 的 `onTextDelta` 接入 SSE)
+- 前端从轮询 session → SSE 实时接收流式内容
+- draft 更新从"整体替换" → "用户在表单里改了哪个字段就 patch 哪个"
+
+## 流式解析器
+
+core 层共享 parser,逐行扫描 LLM 输出:
+
+- 普通行 → 累积到 markdown buffer
+- `:::field{...}` → 切换到"表单收集模式",收集到下一个 `:::` 为止
+- `:::pick{...}` → 同上,收集选项列表
+- `:::group{...}` → 进入嵌套容器,直到配对的 `:::`
+- 遇到关闭 `:::` → 输出表单组件描述,恢复 markdown 模式
+
+输出结构:
+
+```typescript
+interface ParsedDraftResponse {
+  fields: Record<string, string>;  // 提取的所有字段 key → value
+  textContent: string;             // 去掉 ::: 标记的纯文本(TUI 用)
+  summary: string;                 // 自动生成的轮次摘要
+  raw: string;                     // LLM 原始输出(Studio 用于流式渲染)
+}
+```
+
+- **Studio**:拿 `raw` 做流式渲染,`:::` 标记 → 表单控件
+- **TUI**:拿 `textContent` 做 markdown 渲染,`fields` 静默更新 draft
+
+## Session 状态与持久化
+
+### 数据结构
+
+```typescript
+interface CreationDraftState {
+  fields: BookCreationDraft;       // 草案字段最新值(兼容现有结构)
+  readyToCreate: boolean;
+}
+
+interface DraftRound {
+  roundId: number;
+  userMessage: string;
+  assistantRaw: string;            // LLM 原始输出(含 ::: 标记)
+  fieldsUpdated: string[];         // 本轮更新了哪些字段
+  summary: string;                 // 折叠摘要(如"确立了书名、世界观和主角")
+  timestamp: number;
+}
+
+// session 中新增
+interface InteractionSession {
+  // ...现有字段
+  creationDraft?: CreationDraftState;
+  draftRounds?: DraftRound[];
+}
+```
+
+### 持久化时机
+
+| 事件 | 操作 |
+|------|------|
+| 用户编辑内嵌字段 | 只更新内存中的 `draft.fields`,不写盘 |
+| 用户点发送 | 带最新 draft 发给 LLM,流式完成后一次性写盘:更新 fields + 追加新 round |
+| 流式输出中断 | 不写盘,draft 保持上一轮的状态 |
+| 用户点"开始写这本书" | 从 `draft.fields` 构建 BookConfig → `pipeline.initBook()` → 清空 draft 和 rounds |
+| 用户点"丢弃草案" | 清空 draft 和 rounds,写盘 |
+
+### TUI 兼容
+
+TUI 不支持内嵌表单渲染,但共享同一份 session:
+
+- LLM 始终输出 markdown + `:::` 标记格式(一个 prompt,不区分端)
+- core 层共享 parser 提取 `fields`(更新 draft)和 `textContent`(纯文本)
+- TUI 渲染 `textContent` 为 markdown,draft fields 在后台更新
+- Studio 渲染 `raw` 为流式内容 + 表单控件
+- 两端写入同一个 `session.creationDraft.fields`,数据始终同步
+- TUI 用 `/draft` 查看时,从 `fields` 渲染 markdown 摘要(和现在一致)
+- Studio 轮询时,如果发现 `draftRounds` 有 TUI 新增的轮次,用 `summary` 显示折叠摘要

+ 121 - 0
docs/superpowers/specs/2026-04-13-chat-page-redesign.md

@@ -0,0 +1,121 @@
+# Studio 对话页面重构设计
+
+## 概述
+
+将 Studio 从"侧边栏 ChatBar + 独立路由页面"重构为"对话页面为主内容区 + 浮动信息面板"的布局,参考 Claude Cowork 模式。
+
+## 布局结构
+
+```
+┌──────────┬──────────────────────────────────────────┐
+│ 左侧导航  │      主内容区(对话页面)                    │
+│          │                                          │
+│ 书籍列表  │  对话消息流                     ╭─────────╮│
+│ + 新建    │  (含 ::: 内嵌表单)           │ ▾ 章节   ││
+│          │                               │  第1章 ✓ ││
+│ ──────── │                               │  第2章 ◆ ││
+│ 系统      │                               ╰─────────╯│
+│ 题材      │                               ╭─────────╮│
+│ 配置      │                               │ ▾ 执行   ││
+│ ...      │                               │  写作中.. ││
+│          │                               ╰─────────╯│
+│          │                               ╭─────────╮│
+│          │                               │ ▸ 草案   ││
+│          │  ┌──────────────────────┐     ╰─────────╯│
+│          │  │ 写下一章  审计  导出   │                 │
+│          │  ├──────────────────────┤                 │
+│          │  │ 输入框 + 发送         │                 │
+│          │  └──────────────────────┘                 │
+└──────────┴──────────────────────────────────────────┘
+```
+
+## 核心变化
+
+### 删除
+
+- `ChatBar.tsx` 侧边栏组件
+- 右上角聊天图标按钮
+- `BookCreate.tsx` 页面(合并进对话页面)
+
+### 新增
+
+- `ChatPage.tsx` — 主内容区对话页面,替代 ChatBar + BookCreate
+- `ChatMessage.tsx` — 单条消息渲染,assistant 消息支持 `StreamMessage`(`:::` 内嵌表单)
+- `FloatingPanels.tsx` — 右侧浮动可折叠面板组
+- `BookInfoPanel.tsx` — 章节列表 + 执行状态面板(从 BookDetail 提取)
+
+### 改造
+
+- `BookDetail.tsx` — 从路由页面变为浮动面板数据源
+- `App.tsx` — 路由逻辑调整,点书 → 对话页面(带书上下文),新建 → 对话页面(无书)
+
+## 路由变化
+
+| 动作 | 当前 | 新设计 |
+|------|------|--------|
+| 点"新建书籍" | → BookCreate 页面 | → ChatPage(无书引导) |
+| 点某本书 | → BookDetail 页面 | → ChatPage + 浮动面板(有书上下文) |
+| 右上角聊天按钮 | → 弹出 ChatBar 侧边栏 | 删除 |
+
+## 对话页面两种模式
+
+### 无书模式
+
+- 消息流为空时显示居中引导:"告诉我你想写什么——题材、世界观、主角、核心冲突"
+- 快捷 chip 不显示(没有可操作的书)
+- 用户输入后走 `/new` 流程,AI 回复含 `:::` 内嵌表单
+- 右侧无浮动面板
+- 草案就绪后出现"开始写这本书"按钮
+
+### 有书模式
+
+- 加载该书的 session 消息历史
+- 右侧浮动面板显示:章节列表、执行状态、草案摘要
+- 快捷 chip:写下一章、审计、导出、市场雷达
+- 输入框 placeholder 随上下文变化
+
+## 浮动面板设计
+
+参考 Claude Cowork 右侧面板:
+
+- **浮动定位**:`position: fixed` 或 `absolute`,右侧贴边,不挤压对话区宽度
+- **独立折叠**:每个面板有标题栏 + 展开/折叠箭头,点击切换
+- **无书时隐藏**:只在有活跃书籍时显示
+- **面板内容**:
+  - **章节**:章节列表 + 状态标记(✓ 完成、◆ 当前、○ 待写)
+  - **执行**:当前执行阶段、进度
+  - **草案**:创作草案字段摘要(书名、世界观、主角等)
+
+## 消息渲染
+
+对话消息和现有 ChatBar 逻辑一致,但宽度更大:
+
+- 用户消息:右对齐气泡
+- AI 消息:左对齐,支持 `StreamMessage`(`:::` 标记渲染为内嵌表单)
+- 状态消息:小字灰色(⋯ 处理中、✓ 完成、✗ 错误)
+- markdown 加粗渲染:`**text**` → `<strong>`
+
+## 数据流
+
+与现有 ChatBar 完全一致:
+- POST `/agent` 发送指令
+- 响应 `details.draftRaw`(含 `:::` 标记)用 `StreamMessage` 渲染
+- `response`(纯文本)作为 fallback
+- SSE `draft:delta` 可选用于流式显示(后续优化)
+
+## 涉及文件
+
+### 新建
+- `packages/studio/src/pages/ChatPage.tsx`
+- `packages/studio/src/components/chat/ChatMessage.tsx`
+- `packages/studio/src/components/chat/FloatingPanels.tsx`
+- `packages/studio/src/components/chat/BookInfoPanel.tsx`
+- `packages/studio/src/components/chat/QuickActions.tsx`
+
+### 删除
+- `packages/studio/src/components/ChatBar.tsx`
+
+### 修改
+- `packages/studio/src/App.tsx` — 路由重构,删除 ChatBar 引用
+- `packages/studio/src/pages/BookCreate.tsx` — 删除或 redirect 到 ChatPage
+- 从 `BookDetail.tsx` 提取面板所需数据

+ 147 - 0
docs/superpowers/specs/2026-04-13-tool-calling-book-create.md

@@ -0,0 +1,147 @@
+# Tool Calling 驱动的建书流程
+
+## 概述
+
+将建书从 `chatCompletion` + `:::` 文本标记改为 `chatWithTools` + tool calling。LLM 通过调用 `create_book` tool 返回结构化参数,前端渲染为可编辑表单,用户确认后调 `initBook()`。与 CLI `inkos book create` 完全对齐。
+
+## 数据流
+
+```
+用户:"港风悬疑,主角是O记警探"
+    ↓
+chatWithTools(messages, [create_book_tool])
+    ↓
+LLM 返回:
+  content: "好的,我帮你生成了一份建书表单..."
+  toolCalls: [{
+    name: "create_book",
+    arguments: { title, genre, platform, targetChapters, chapterWordCount, brief }
+  }]
+    ↓
+前端:content 渲染为文本 + arguments 渲染为可编辑表单
+    ↓
+用户修改表单 / 或自然语言继续对话
+    ↓
+用户点"开始写" → initBook(BookConfig, { externalContext: brief })
+```
+
+## Tool 定义
+
+```typescript
+const CREATE_BOOK_TOOL: ToolDefinition = {
+  name: "create_book",
+  description: "根据用户描述生成建书参数。系统会将参数渲染为可编辑表单,用户确认后建书。",
+  parameters: {
+    type: "object",
+    properties: {
+      title:            { type: "string", description: "书名" },
+      genre:            { type: "string", description: "题材标识,如 xuanhuan, progression, romance" },
+      platform:         { type: "string", enum: ["tomato", "qidian", "feilu", "other"] },
+      targetChapters:   { type: "number", description: "目标章数,默认 200" },
+      chapterWordCount: { type: "number", description: "每章字数,默认 3000" },
+      language:         { type: "string", enum: ["zh", "en"], description: "写作语言" },
+      brief:            { type: "string", description: "创意简述,会传给 Architect 生成 foundation 五文件" },
+    },
+    required: ["title", "genre", "platform", "brief"],
+  },
+};
+```
+
+## System Prompt
+
+精简为一段:
+
+```
+你是 InkOS 的建书助手。用户会描述想写的书,你需要调用 create_book 工具来生成建书参数。
+
+规则:
+1. 从用户描述中推断所有字段,大胆预填合理默认值。
+2. brief 字段要详细——它会传给 Architect 智能体生成完整的世界观、主角、冲突等 foundation 文件。把用户提到的所有创意要素都写进 brief。
+3. 如果用户后续要求修改某些字段,重新调用 create_book 工具,只更新被提到的字段,其余保持不变。
+4. 不要只回复文字讨论——必须调用 create_book 工具输出结构化参数。
+```
+
+## 改动范围
+
+### Core 层
+
+**`packages/core/src/interaction/project-tools.ts`**
+
+`developBookDraft` 改造:
+- 从 `chatCompletion` + `:::` prompt → `chatWithTools` + `CREATE_BOOK_TOOL`
+- System prompt 替换为上述精简版
+- 返回值增加 `toolCall` 字段:`{ name, arguments }`
+- 保留 `onDraftTextDelta` 用于流式显示 LLM 的 content 部分
+
+**返回结构变更**:
+```typescript
+return {
+  __interaction: {
+    responseText: result.content,  // LLM 的自然语言回复
+    details: {
+      toolCall: result.toolCalls[0],  // { name: "create_book", arguments: {...} }
+    },
+  },
+};
+```
+
+### Studio 前端
+
+**`packages/studio/src/components/chat/ChatMessage.tsx`**
+
+消息类型扩展:
+```typescript
+interface Message {
+  role: "user" | "assistant";
+  content: string;
+  timestamp: number;
+  toolCall?: { name: string; arguments: Record<string, unknown> };  // 新增
+}
+```
+
+当 `toolCall` 存在且 `name === "create_book"` 时,渲染 `BookFormCard` 组件。
+
+**`packages/studio/src/components/chat/BookFormCard.tsx`**(新建)
+
+静态表单,映射 tool_call.arguments 到输入控件:
+- title → text input
+- genre → select/dropdown(从可用题材列表)
+- platform → radio group(番茄/起点/飞卢/其他)
+- targetChapters → number input
+- chapterWordCount → number input
+- language → radio(中/英)
+- brief → textarea
+
+底部按钮:"开始写这本书"
+
+**`packages/studio/src/pages/ChatPage.tsx`**
+
+- `sendMessage` 处理:从 API response 的 `details.toolCall` 提取参数
+- 存入 message 的 `toolCall` 字段
+- "开始写"按钮 → POST `/books/create`(或直接调 `/agent` with `/create`)
+
+### Server 层
+
+**`packages/studio/src/api/server.ts`**
+
+`/api/agent` response 已经包含 `details`(之前加的)。`details.toolCall` 会自然传到前端。
+
+### TUI
+
+TUI 暂不渲染表单——显示 LLM 的 content 文本 + 打印 tool_call 参数的文本摘要。用户通过继续输入来修改,或直接 `/create` 确认。
+
+### 清理
+
+- `:::` 相关 prompt 从 `BOOK_DRAFT_SYSTEM_PROMPT` 中移除
+- `draft-directive-parser.ts` 保留(其他场景可能用)但建书不再依赖
+- `creationDraft` / `draftRounds` 在建书流程中弱化——tool_call.arguments 替代 draft
+- `StreamMessage.tsx` / `InlineField.tsx` 等保留
+
+## 与 CLI 对齐
+
+| | CLI | Studio/TUI (新) |
+|---|---|---|
+| 输入 | `--title X --genre Y --brief Z` | LLM 从一句话推断 → tool_call.arguments |
+| 验证 | CLI flag 解析 | Zod schema 验证 tool_call |
+| 执行 | `PipelineRunner.initBook()` | 同一个 `initBook()` |
+| 产物 | foundation 5 文件 | 完全一致 |

+ 134 - 0
docs/superpowers/specs/2026-04-15-sub-agent-params-alignment.md

@@ -0,0 +1,134 @@
+# sub_agent 工具参数对齐设计
+
+## 目标
+
+让 sub_agent 的 5 个子智能体(architect/writer/auditor/reviser/exporter)的参数与 pipeline runner 方法对齐,消除硬编码和参数丢弃。
+
+## Scope
+
+**A 层:参数暴露**。只对齐已暴露的 5 个 agent,不新增 pipeline 方法的暴露(writeDraft、planChapter、repairChapterState 等留后续)。
+
+## 方案
+
+**方案 3:扁平扩展 + description 约束**。在现有 `SubAgentParams` 上新增字段,每个字段的 TypeBox `description` 标注适用的 agent,模型通过 tool schema 的 description 知道约束。
+
+## SubAgentParams schema 变更
+
+现有字段:
+- `agent` — 子智能体类型(不变)
+- `instruction` — 自然语言指令(不变,行为改变见下)
+- `bookId` — 书籍 ID(不变)
+- `chapterNumber` — 目标章节号(已有,auditor/reviser 适用)
+
+新增字段(全部 `Type.Optional`):
+
+| 字段 | 类型 | description | 适用 agent |
+|------|------|-------------|-----------|
+| `title` | `String` | architect only: 书名 | architect |
+| `genre` | `String` | architect only: 题材 (xuanhuan, urban, mystery 等) | architect |
+| `platform` | `Union(Literal)` | architect only: 目标平台 (tomato/qidian/feilu/other) | architect |
+| `language` | `Union(Literal)` | architect only: 写作语言 (zh/en) | architect |
+| `targetChapters` | `Number` | architect only: 目标总章数,默认 200 | architect |
+| `chapterWordCount` | `Number` | architect/writer: 每章目标字数,默认 3000 | architect, writer |
+| `mode` | `Union(Literal)` | reviser only: 修订模式 (spot-fix/polish/rewrite/rework/anti-detect) | reviser |
+| `format` | `Union(Literal)` | exporter only: 导出格式 (txt/md/epub) | exporter |
+| `approvedOnly` | `Boolean` | exporter only: 仅导出已审核通过的章节 | exporter |
+
+> **设计原则**:有限枚举值用 `Type.Union([Type.Literal(...)])` 而非 `Type.String`,模型能在 JSON Schema 里看到合法值列表(`const` 约束),不会传错。`genre` 和 `title` 是开放文本保持 `String`。
+
+## 各 agent 行为变更
+
+### 1. architect
+
+**现状**:`pipeline.initBook({ id, genre: "general", title: "", language: "zh" } as any, { externalContext: instruction })`
+
+**改为**:
+```ts
+const now = new Date().toISOString();
+await pipeline.initBook({
+  id,
+  title: title ?? "",
+  genre: genre ?? "general",
+  platform: (platform as any) ?? "other",
+  language: (language as any) ?? "zh",
+  status: "outlining",
+  targetChapters: targetChapters ?? 200,
+  chapterWordCount: chapterWordCount ?? 3000,
+  createdAt: now,
+  updatedAt: now,
+}, { externalContext: instruction });
+```
+- 去掉 `as any`,构建完整 BookConfig
+- `instruction` 继续作为 `externalContext` 传递
+- 建书后从 story_bible.md 回填 title(已有逻辑保留)
+
+### 2. writer
+
+**现状**:`pipeline.writeNextChapter(bookId)` — wordCount 丢弃,instruction 丢弃
+
+**改为**:
+```ts
+const result = await pipeline.writeNextChapter(bookId, chapterWordCount);
+```
+- `chapterWordCount` 透传为 `wordCount` 参数
+- `instruction` 透传为 pipeline config 的 `externalContext`(需要在 writeNextChapter 调用前设置)
+- `chapterNumber` 不适用于 writer(writeNextChapter 总是写下一章),schema description 已标注
+
+### 3. auditor
+
+**现状**:参数齐了,返回值只有 issueCount
+
+**改为**:
+```ts
+const audit = await pipeline.auditDraft(bookId, chapterNumber);
+const issueLines = audit.issues
+  .map(i => `[${i.severity}] ${i.description}`)
+  .join("\n");
+return textResult(
+  `Audit chapter ${audit.chapterNumber}: ${audit.passed ? "PASSED" : "FAILED"}, ${audit.issues.length} issue(s).\n${issueLines}`
+);
+```
+- 返回完整 issues 列表(severity + description),让 agent 能判断下一步操作
+
+### 4. reviser
+
+**现状**:mode 靠正则从 instruction 提取,anti-detect 不可达,instruction 丢弃
+
+**改为**:
+```ts
+const resolvedMode: ReviseMode = mode ?? "spot-fix";
+await pipeline.reviseDraft(bookId, chapterNumber, resolvedMode);
+```
+- `mode` 直接从 schema 字段读取,去掉正则逻辑
+- `instruction` 透传为 `externalContext`(等同 CLI 的 `--brief`)
+
+### 5. exporter
+
+**现状**:纯 stub "Coming soon"
+
+**改为**:使用 `project-tools.ts` 中已有的 `exportBookToPath` 逻辑:
+```ts
+const exportFormat = format ?? "txt";
+const onlyApproved = approvedOnly ?? false;
+// 调用 StateManager 的导出逻辑
+```
+- 支持 txt/md/epub 三种格式
+- 支持 approvedOnly 过滤
+- 返回导出文件路径
+
+## 系统提示词更新
+
+更新中英文系统提示词,说明每个 agent 的可用参数:
+- architect: title, genre, platform, language, targetChapters, chapterWordCount
+- writer: chapterWordCount(wordCount 覆盖)
+- auditor: chapterNumber
+- reviser: chapterNumber, mode
+- exporter: format, approvedOnly
+
+## 不在 scope 内
+
+- 新增 sub_agent 类型(planner、repairer、syncer 等)
+- writeDraft / planChapter / composeChapter 等方法暴露
+- fanfic / import 流程
+- temperatureOverride(CLI 也没暴露)
+- writer 指定章节号(writeNextChapter 不支持)

+ 211 - 0
docs/superpowers/specs/2026-04-16-session-management-design.md

@@ -0,0 +1,211 @@
+# Session 管理优化设计
+
+> 日期:2026-04-16
+> 状态:待实施
+
+## 概述
+
+InkOS Studio 的 session(会话)管理从"一本书只有一段聊天"升级为"一本书多段聊天、互不干扰、可并行流式"。同时修复新建书籍时全局共享 `bookId=null` session 导致的消息混杂 bug。
+
+## 需求
+
+1. **单书多 session**:同一本书可以新建多段对话(比如一段讨论人物、一段写正文),各自独立
+2. **多 session 并行**:一个 session 的 SSE 流正在推内容时,用户可以切到另一个 session 继续聊天,原来那条流不中断
+3. **新建书籍 session 管理**:每次新建书籍都开全新 session,不复用;创建成功后 session 迁移到新书籍;中途放弃则变成孤儿文件留在磁盘
+
+## 1. 数据与存储
+
+### BookSession 结构变更
+
+在 `packages/core/src/interaction/session.ts` 的 `BookSession` 类型上新增一个字段:
+
+```ts
+title: string | null   // AI 自动生成的标题;null 表示尚未生成,UI 回退显示"新会话 · HH:mm"
+```
+
+其余字段(sessionId、bookId、messages、draftRounds、events、creationDraft、currentExecution、createdAt、updatedAt)不变。
+
+### 标题生成规则
+
+- 首轮用户 ↔ AI 对话结束后,后端异步调一次 LLM 生成标题并写回 session 文件
+- 只要 `title !== null`,就不再触发生成(无论是 AI 生成的还是用户手动改过的)
+- 生成用项目配置的 fast model,提示词固定:"用 6 个中文字以内概括这段对话的主题,只返回标题文本"
+- 生成失败静默吞掉,title 保持 null,下次对话结束时再试
+
+### 存储
+
+- 位置不变:`.inkos/sessions/{sessionId}.json`
+- 孤儿 session(`bookId=null`、用户放弃后不再引用的)磁盘文件保留,不自动清理;UI 不展示
+- 所有对 session 文件的写操作(新建/改名/删除/迁移/追加消息/写标题)通过 p-queue 按 sessionId 串行化,防止并发写坏 JSON
+
+## 2. 后端 API
+
+### 已有端点改造
+
+| 端点 | 改造内容 |
+|---|---|
+| `POST /api/v1/sessions` | 语义从"查找或创建"改为**永远新建**。底层调 `createBookSession` 而非 `findOrCreateBookSession` |
+| `POST /api/v1/agent` | `sessionId` 参数改为**必填**,缺失返回 400。删掉 `findOrCreateBookSession` 回退逻辑 |
+| `GET /api/v1/sessions?bookId=X` | 返回列表中每条补上 `title` 字段(目前只返回 sessionId/bookId/messageCount/createdAt/updatedAt) |
+| `GET /api/v1/sessions/:sessionId` | 不动 |
+
+### 新增端点
+
+| 端点 | 作用 |
+|---|---|
+| `PUT /api/v1/sessions/:sessionId` | 改名:接收 `{ title: string }`,写入 session 文件 |
+| `DELETE /api/v1/sessions/:sessionId` | 删除 session 文件 |
+
+### 底层函数变更(book-session-store.ts)
+
+| 函数 | 变更 |
+|---|---|
+| `findOrCreateBookSession` | **删除**,它是 bug 的根源 |
+| `createBookSession`(session.ts 中已有) | 保持,暴露给 HTTP 层直接调用 |
+| `loadBookSession` | 不动 |
+| `persistBookSession` | 不动 |
+| `listBookSessions` | 不动 |
+| `renameBookSession(root, sessionId, title)` | 新增 |
+| `deleteBookSession(root, sessionId)` | 新增 |
+| `migrateBookSession(root, sessionId, newBookId)` | 新增;前置检查:只有 `bookId === null` 才允许迁移,否则抛 `SessionAlreadyMigratedError` |
+| `generateSessionTitle(root, sessionId)` | 新增;异步调 LLM 生成标题,`title !== null` 时跳过 |
+
+## 3. 前端 store 改造
+
+### state 从单数改为按 sessionId 分片
+
+```ts
+// 每条 session 的运行时状态
+type SessionRuntime = {
+  sessionId: string
+  bookId: string | null
+  title: string | null
+  messages: Message[]
+  stream: EventSource | null
+  isStreaming: boolean
+  lastError: string | null
+}
+
+// MessageState 改造
+type MessageState = {
+  // 删掉旧的 currentSessionId / messages / _activeStream / isStreaming
+
+  sessions: Record<string, SessionRuntime>       // 按 sessionId 分片
+  activeSessionId: string | null                  // UI 当前展示哪条
+  sessionIdsByBook: Record<string, string[]>      // 书籍 → session id 列表,侧边栏用
+}
+```
+
+### 关键约定
+
+- **组件读数据**:selector 读 `state.sessions[state.activeSessionId]?.messages ?? []`,签名不变
+- **派生值用 useMemo**(遵守 CLAUDE.md 规范):比如"某条 session 是否 streaming"放在组件内 useMemo,不在 selector 里 filter/map 产生新数组
+- **sendMessage(sessionId, text)**:必须接收 sessionId 参数(不读 activeSessionId),往 `sessions[sessionId].stream` 开 SSE,消息写到 `sessions[sessionId].messages`
+- **切 session**:只改 `activeSessionId`,不关任何 SSE;被切出去的 session 的流继续在后台推
+- **关页面/关 tab**:浏览器回收所有 EventSource,服务器端连接断开;正在生成的消息到此为止
+
+## 4. 侧边栏 UI
+
+```
+┌── 侧边栏 ──────────────────────────┐
+│                                    │
+│  📚 书籍                           │
+│  ──────────────────────────────    │
+│                                    │
+│  ▸ 山海神游 · 3 个会话             │  ← 折叠,只看书名
+│                                    │
+│  ▾ 都市重生 · 2 个会话             │  ← 展开
+│      · 初稿大纲讨论         ●      │  ← 当前选中高亮
+│      · 第三章正文         ⟳       │  ← 正在流式输出(活跃指示)
+│      + 新建会话                    │  ← 展开后文件夹内部最后一项
+│                                    │
+│  ▸ 星穹列车 · 1 个会话             │
+│                                    │
+│  ──────────────────────────────    │
+│  + 新建书籍                        │
+│                                    │
+└────────────────────────────────────┘
+```
+
+- 书名行点击 → 展开/折叠
+- 当前选中的 session 整行高亮 + 右侧 `●`
+- 正在流式输出的 session 右侧显示 `⟳` 转圈图标,即使用户切到别的 session 也继续显示,流结束后消失
+- `+ 新建会话` 在展开后文件夹内部最后一项,点击创建空白 session 并切换过去
+- session 行右键(或 hover 出菜单)→ 改名 / 删除
+- 标题默认 AI 生成;首轮对话前显示"新会话 · HH:mm"占位
+
+## 5. 新建书籍流程
+
+### 完整流程
+
+```
+用户点"+ 新建书籍"
+  └→ 前端 POST /api/v1/sessions { bookId: null } → 返回 sessionId
+     └→ localStorage.setItem("currentBookCreateSessionId", sessionId)
+        └→ navigate("/books/new")
+           └→ 页面挂载 SessionRuntime,展示空聊天
+              └→ 用户跟 AI 讨论题材、主角……
+                 └→ AI 调用 create_book 工具
+                    └→ 后端:
+                       1. 创建书籍,得到 newBookId
+                       2. migrateBookSession(sessionId, newBookId)
+                       3. SSE broadcast { type: "book:created", bookId, sessionId }
+                    └→ 前端收到 book:created:
+                       1. localStorage.removeItem("currentBookCreateSessionId")
+                       2. store: sessions[sessionId].bookId = bookId
+                       3. store: sessionIdsByBook[bookId] = [sessionId]
+                       4. 刷新侧边栏书列表
+                       5. navigate("/books/${bookId}", { replace: true })
+```
+
+### 规则
+
+- **每次点"+ 新建书籍"都创建全新 session**,覆盖 localStorage,旧的变孤儿
+- **侧边栏不展示草稿节点**:新建书籍讨论中,侧边栏没有任何对应节点;直到 `create_book` 成功,新书籍节点才出现
+- **刷新页面不丢失**:`/books/new` 路由读 localStorage 里的 sessionId,有效则继续,无效则自动新建
+- **用户中途切走**:`bookId=null` 的 session 在 store 里还在(SSE 流继续),但用户无法通过侧边栏回去;重新点"+ 新建书籍"会新建另一条
+
+### 边缘情况
+
+- **AI 流还没结束就 `create_book` 成功**:SSE 继续推,消息写到同一条 session,bookId 已经改了,不影响
+- **同一条 session 连续调两次 `create_book`**:`migrateBookSession` 前置检查 `bookId === null`,第二次抛 `SessionAlreadyMigratedError`,agent 收到错误信息自行处理
+- **localStorage 里的 sessionId 对应的文件被删**:`/books/new` 页面检测到 404,清 localStorage,自动新建
+
+## 6. 错误处理
+
+| 场景 | 行为 |
+|---|---|
+| POST /sessions 失败 | 前端 alert,UI 停在当前页面不跳转;localStorage 不写入 |
+| GET /sessions/:id 返回 404 | 清掉 store 里这条;URL 回到 / 或书籍首页 |
+| PUT /sessions/:id 改名失败 | 侧边栏标题回滚旧值,toast 提示 |
+| DELETE /sessions/:id 失败 | 侧边栏恢复显示该条,toast 提示 |
+| SSE 连接中途断开 | `sessions[id].isStreaming` 置 false,`lastError` 写入错误文本;活跃指示关掉;最后一条消息标为"生成中断";不自动重连 |
+| `migrateBookSession` 时 bookId 非 null | 抛 `SessionAlreadyMigratedError` |
+| 标题生成 LLM 调用失败 | 静默,title 保持 null,下次对话结束再试 |
+| 并发写同一 session 文件 | p-queue 按 sessionId 串行化所有写操作 |
+
+## 7. 测试
+
+### book-session-store.test.ts
+
+- `createBookSession` 每次生成不同 id,不复用已有
+- `renameBookSession` 写入 title 并更新 updatedAt
+- `deleteBookSession` 删除文件后 `loadBookSession` 返回 null
+- `migrateBookSession` 只在 `bookId === null` 时成功,否则抛错
+- `findOrCreateBookSession` 相关测试全部删除
+- 并发写同一 session 不损坏 JSON(p-queue 串行验证)
+
+### store/chat/slices/message/action.test.ts
+
+- `sendMessage(sessionA, ...)` 只写到 `sessions[sessionA].messages`,不影响 sessionB
+- 切 `activeSessionId` 不关闭旧 session 的 stream
+- 两个 session 各自 SSE 同时推消息,messages 正确归位
+- `onBookCreated` 事件:清 localStorage、更新 bookId、sessionIdsByBook 正确插入
+
+### studio/src/api/server.test.ts
+
+- `POST /sessions` 每次返回不同 sessionId(即使 bookId 相同)
+- `POST /agent` 不带 sessionId 返回 400
+- `PUT /sessions/:id` 改名后 GET 读到新标题
+- `DELETE /sessions/:id` 删除后 GET 返回 404
+- `GET /sessions?bookId=X` 按 updatedAt 倒序

+ 189 - 0
docs/superpowers/specs/2026-04-17-server-split-design.md

@@ -0,0 +1,189 @@
+# server.ts 拆分与错误响应统一设计
+
+## 背景
+
+`packages/studio/src/api/server.ts` 有 2494 行,把 Books、Chapters、Sessions、Agent、Services、Genres、Daemon、Doctor 等十几个业务域的路由全部堆在一个 `createStudioServer` 函数里。错误响应有两种格式并存:`{ error: { code, message } }`(ApiError)和 `{ error: string }`(裸字符串),前端被迫用 `extractErrorMessage()` 兼容两种。
+
+本次改造做两件事:按 DDD 限界上下文拆分路由模块,统一所有 API 错误为 `{ error: { code, message } }` 结构。
+
+## 限界上下文划分
+
+| 上下文 | 文件 | 包含的路由 |
+|---|---|---|
+| **Book** | `routes/book.ts` | books CRUD、chapters 读写、truth files 读写/浏览、analytics、write-next、draft、revise、resync、rewrite、approve/reject、audit、detect/detect-all/detect-stats、export/export-save、import chapters/canon、fanfic init/show/refresh、style analyze/import、book create + create-status |
+| **Conversation** | `routes/conversation.ts` | `/api/v1/agent`(agent chat)、`/api/v1/sessions` CRUD、`/api/v1/interaction/session`、session 标题自动生成 |
+| **Service** | `routes/service.ts` | `/api/v1/services` 列表、`/api/v1/services/config` 读写、`/api/v1/services/:service/test`、`/api/v1/services/:service/secret` 读写、`/api/v1/services/:service/models` |
+| **Project** | `routes/project.ts` | project 读写、language 设置、model-overrides 读写、notify 读写、genres CRUD/copy、daemon start/stop/status、doctor、logs、radar/scan |
+
+## 目录结构
+
+```
+api/
+├── server.ts              # 瘦壳:Hono app + 中间件 + 挂载 4 个路由模块(~80 行)
+├── errors.ts              # ApiError 类(已有,不改)
+├── safety.ts              # isSafeBookId(已有,不改)
+├── book-create.ts         # buildStudioBookConfig(已有,不改)
+├── index.ts               # startStudioServer 入口(已有,不改)
+│
+├── routes/
+│   ├── book.ts            # ~700 行
+│   ├── conversation.ts    # ~450 行
+│   ├── service.ts         # ~200 行
+│   └── project.ts         # ~300 行
+│
+├── lib/
+│   ├── types.ts           # RouteDeps 接口、PipelineOverrides 类型
+│   ├── broadcast.ts       # SSE 事件总线(subscribers Set、broadcast 函数)
+│   ├── pipeline.ts        # loadCurrentProjectConfig、buildPipelineConfig
+│   ├── service-utils.ts   # normalizeServiceConfig、mergeServiceConfig、
+│   │                      # resolveConfiguredServiceBaseUrl、resolveConfiguredServiceEntry、
+│   │                      # buildProbePlans、buildModelCandidates、
+│   │                      # fetchModelsFromServiceBaseUrl、probeServiceCapabilities、
+│   │                      # isCustomServiceId、serviceConfigKey、normalizeServiceEntry、
+│   │                      # normalizeConfigSource
+│   ├── config-io.ts       # loadRawConfig、saveRawConfig、readEnvConfigSummary、readEnvConfigStatus
+│   ├── chapter-utils.ts   # findChapterFile(提取 4 处重复的 padded-number 章节查找)
+│   ├── agent-event-utils.ts # PIPELINE_STAGES、AGENT_LABELS、TOOL_LABELS、
+│   │                      # resolveToolLabel、summarizeResult、extractToolError、
+│   │                      # normalizeGeneratedSessionTitle、CollectedToolExec 接口
+│   ├── run-store.ts       # (已有,不动)
+│   └── sse.ts             # (已有,不动)
+```
+
+## RouteDeps 依赖注入
+
+```ts
+// api/lib/types.ts
+export interface PipelineOverrides {
+  externalContext?: string;
+  client?: unknown;
+  model?: string;
+  currentConfig?: ProjectConfig;
+  sessionIdForSSE?: string;
+}
+
+export interface RouteDeps {
+  readonly root: string;
+  readonly state: StateManager;
+  loadCurrentProjectConfig(options?: { requireApiKey?: boolean }): Promise<ProjectConfig>;
+  buildPipelineConfig(overrides?: PipelineOverrides): Promise<PipelineConfig>;
+  broadcast(event: string, data: unknown): void;
+}
+```
+
+### 域内私有状态
+
+不进 RouteDeps,各自留在域内闭包里:
+
+- `bookCreateStatus: Map` → `book.ts` 内部
+- `modelListCache: Map` → `service.ts` 内部
+- `schedulerInstance` → `project.ts` 内部
+
+## server.ts 瘦壳
+
+拆分后的 `server.ts` 只做四件事:
+
+1. 创建 Hono app,挂 CORS 中间件和 `app.onError` 错误处理器
+2. 挂 bookId 校验中间件(`/api/v1/books/:id` 和 `/api/v1/books/:id/*`)
+3. 组装 RouteDeps,挂载 4 个路由模块
+4. 挂 SSE `/api/v1/events` 端点(因为它是基础设施层,不属于任何域)
+
+```ts
+export function createStudioServer(initialConfig: ProjectConfig, root: string) {
+  const app = new Hono();
+  const state = new StateManager(root);
+
+  app.use("/*", cors());
+  app.onError((error, c) => { /* ApiError → typed JSON, others → 500 */ });
+
+  // bookId 校验中间件
+  app.use("/api/v1/books/:id/*", ...);
+  app.use("/api/v1/books/:id", ...);
+
+  // 组装依赖
+  const deps: RouteDeps = { root, state, loadCurrentProjectConfig, buildPipelineConfig, broadcast };
+
+  // 挂载路由
+  app.route("/api/v1", createBookRoutes(deps));
+  app.route("/api/v1", createConversationRoutes(deps));
+  app.route("/api/v1", createServiceRoutes(deps));
+  app.route("/api/v1", createProjectRoutes(deps));
+
+  // SSE
+  app.get("/api/v1/events", (c) => { ... });
+
+  return app;
+}
+```
+
+## 错误响应统一
+
+### 目标格式
+
+所有 API 错误统一为:
+
+```json
+{ "error": { "code": "ERROR_CODE", "message": "人类可读的描述" } }
+```
+
+HTTP 状态码由 `ApiError.status` 决定。
+
+### 改动方式
+
+所有 `return c.json({ error: "..." }, status)` 替换为 `throw new ApiError(status, "CODE", "message")`。`app.onError` 已经处理 ApiError 序列化,不需要改。
+
+### Error Code 清单
+
+| code | status | 场景 |
+|---|---|---|
+| `INVALID_BOOK_ID` | 400 | 路径穿越校验(已有) |
+| `INVALID_GENRE_ID` | 400 | genre 路径校验(已有) |
+| `INVALID_SESSION_TITLE` | 400 | session 重命名标题为空(已有) |
+| `SESSION_ID_REQUIRED` | 400 | agent 请求缺 sessionId(已有) |
+| `SESSION_ALREADY_MIGRATED` | 409 | session 已迁移(已有) |
+| `AGENT_ERROR` | 500 | agent 运行时异常(已有) |
+| `AGENT_EMPTY_RESPONSE` | 502 | agent 返回空文本(已有) |
+| `AGENT_BUSY` | 429 | agent 正在处理中(已有) |
+| `INTERNAL_ERROR` | 500 | 兜底错误(已有) |
+| `BOOK_NOT_FOUND` | 404 | 书籍不存在(3 处) |
+| `CHAPTER_NOT_FOUND` | 404 | 章节不存在(6 处) |
+| `SESSION_NOT_FOUND` | 404 | session 不存在(2 处) |
+| `GENRE_NOT_FOUND` | 404 | genre 删除时找不到 |
+| `CREATE_STATUS_NOT_FOUND` | 404 | create-status 无记录 |
+| `BOOK_ALREADY_EXISTS` | 409 | 建书时 bookId 已存在 |
+| `INVALID_TRUTH_FILE` | 400 | truth 文件名不在白名单 |
+| `EMPTY_API_KEY` | 400 | service test 时 apiKey 为空 |
+| `UNKNOWN_SERVICE` | 400 | service test 时解析不到 baseUrl |
+| `SERVICE_PROBE_FAILED` | 400 | service 连接测试失败 |
+| `DAEMON_ALREADY_RUNNING` | 400 | daemon 重复启动 |
+| `DAEMON_NOT_RUNNING` | 400 | daemon 未运行时 stop |
+| `MISSING_REQUIRED_FIELD` | 400 | 必填字段缺失 |
+| `EXPORT_FAILED` | 500 | 导出失败 |
+
+### 不改的情况
+
+Service test 路由返回的 `{ ok: false, error: "..." }` 保持不动。这是业务层面的探测结果("我帮你试了,这个配置不通"),不是 HTTP 异常。它已经搭配了 HTTP 400 状态码和 `ok: false` 标志,前端用 `ok` 字段区分成功/失败,和错误处理是两条路径。
+
+## 重复代码消除
+
+`server.ts` 和 `store/chat/slices/message/runtime.ts` 各有一份 `resolveToolLabel`、`summarizeResult`、`extractToolError`。
+
+server 端的搬到 `api/lib/agent-event-utils.ts`。前端 `runtime.ts` 的保留——前端 bundle 不应该依赖 server 端代码,两边的实现也有细微差异(`runtime.ts` 的 `summarizeResult` 少一个 `.text` 分支)。
+
+## PIPELINE_STAGES 文案修正
+
+`PIPELINE_STAGES` 里的 "落盘最终章节" 和 "落盘修订结果" 改为 "保存最终章节" 和 "保存修订结果",消除 CLAUDE.md 禁止的简写表达。
+
+## 测试策略
+
+分两步:
+
+1. **本次**:路由拆分后 `server.test.ts` 保持不动。所有测试都是通过 `createStudioServer` 发请求的集成测试,路由内部怎么拆不影响测试行为。跑 `pnpm test` 全量通过即可验证拆分没有破坏任何东西。
+2. **后续**:测试按域拆到 `routes/__tests__/` 下,共享 mock setup 提取到 test fixture。不在本次范围内。
+
+## 不做的事
+
+- 不改 `startStudioServer`(静态文件服务逻辑),它留在 `server.ts` 或 `index.ts` 都行,不影响路由拆分
+- 不改前端 `runtime.ts` 的重复函数,前后端保持独立
+- 不改 `lib/run-store.ts` 和 `lib/sse.ts`
+- 不拆测试文件(留给后续)