# 7-15 [process] 页面 AI ACP Agent Runtime 统一抽象层 v1

> 创建时间：2026-05-17
>
> 当前状态：`DONE`
>
> 2026-05-21 Batch J 口径补充：
> - 本稿的 ACP runtime 核心实现已完成并在当前页面 AI 主链中作为默认 runtime 边界使用；Hermes HTTP proxy 默认关闭，只在显式 compat 开关下保留。
> - Step 15（多会话压力测试）、Step 16（旧 HTTP proxy cleanup）、Step 17（Reasonix cache benchmark）不再压在本稿内继续推进，已拆到 `design/07-ai/process/7-34-acp-runtime-cleanup-availability-stability-tail-v1.md`。
> - `page_ai_workflow` 仍是 debug / fast path 兼容门面，不是 local-first 普通 Markdown 的默认 AI 编辑主路径；它必须继续走共享 mnote tool executor。
>
> 本稿目的：
> 1. 在 mnote-web 中引入 ACP（Agent Client Protocol）作为统一 agent runtime 抽象层
> 2. 使 Hermes（当前）与 Reasonix（缓存优先）可互换，前端下拉切换
> 3. 褪去当前 `hermes_client.rs` 中的 Hermes-HTTPS-proxy 硬编码，改为 ACP JSON-RPC 通用连接器
> 4. 复用现有参考代码，最小化重复实现工作
>
> 关联文档：
> - `/mnt/Data1T/mnote/recycle/design/07-ai/retired-http-hermes/7-5-hermes-client-proxy-contract-v1.md`（已退役历史背景）
> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/reference/7-17-acp-session-convex-sharing-contract-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-vscode-main/`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/DeepSeek-Reasonix-main/`

---

## 1. 问题描述

### 1.1 当前架构

```
浏览器 (页面 AI 面板)
  │ SSE
  ▼
mnote-web (Rust Axum)
  │
  ├─ hermes_client.rs (3845 行)
  │    └─ HTTP proxy → Hermes HTTPS gateway API
  │          post /api/hermes/runs
  │          get  /api/hermes/runs/{id}/events
  │
  ├─ hermes_tools.rs (mnote.doc.* / mnote.block.*)
  │    └─ Rust 兼容工具实现，通过 HTTP/Convex 或本地代理读写文档
  │
  └─ page_ai_workflow.rs (兼容门面)
       └─ local-first 下不再是主路径
```

**问题：**
1. `hermes_client.rs` 与 Hermes HTTPS API 的字段、鉴权、错误码硬耦合——换 agent runtime 需重写整个模块
2. Hermes HTTP 协议没有标准化，Reasonix、Claude Code、Cline 各自用不同的 HTTP 接口
3. 前端 SSE 事件格式（`tool.started` / `message.delta` / `run.completed`）也是 mnote 私有定制的
4. 没有「运行时选择器」——切换 agent 需要改环境变量重启 mnote-web
5. `hermes_client.rs` 中大量代码（3845 行）是做 Hermes 专属的 HTTP proxy、session 管理、profile 路由——这些应该在统一抽象层中解决

### 1.2 目标架构

```
浏览器 (页面 AI 面板)
  │ SSE (前端不变)
  ▼
mnote-web (Rust Axum)
  │
  ├─ ACP Session Manager (统一层，新增 ~800 行)
  │    ├─ 运行时选择器 (profile → agent runtime 映射)
  │    │    ├─ Hermes:   spawn("hermes", ["acp"])
  │    │    └─ Reasonix: spawn("node", ["reasonix-acp.mjs"])
  │    ├─ ACP JSON-RPC 2.0 client (通用实现)
  │    │    ├─ session/new
  │    │    ├─ session/prompt
  │    │    ├─ session/cancel
  │    │    └─ session/update ≫ SSE 转发
  │    └─ 代理层：向下游工具通知
  │
  ├─ hermes_tools.rs (兼容层)
  │    └─ mnote.doc.* / mnote.block.* / mnote.page.*
  │
  └─ page_ai_workflow.rs (兼容保留)
       └─ local-first 普通正文编辑默认不经过它
```

ACP 是整个架构的支点——它是一个**开放协议**，不是某个产品的私有接口。

> 2026-05-18 local-first 口径补充：
>
> - 目标不是把 Hermes / Reasonix 再包进一层重型 MNote 工具系统，而是让它们尽量像在 VSCode 中那样直接面对授权后的本地工作区。
> - MNote 主要负责：页面定位、白名单目录授权、ACP 会话管理、审计、文件变化同步到 tiptap / File Tree / Page Aggregate。
> - local-first 普通 Markdown 编辑默认不要求 runtime 调 `mnote.doc.markdown_edit`；兼容工具只为 cloud / remote / 复杂结构场景保留。

---

## 2. ACP 协议标准

ACP（Agent Client Protocol）是一个基于 JSON-RPC 2.0 的、面向 AI agent runtime 的标准通信协议。同时被 Reasonix (`src/acp/`) 和 Hermes (`hermes acp` CLI) 实现。

### 2.1 传输层

NDJSON over stdio（默认），也可用 TCP/Unix socket。每行一个完整的 JSON 对象。

### 2.2 协议方法

| 方向 | 方法 | 用途 |
|------|------|------|
| Client → Server | `session/new` | 创建一个会话线程 |
| Client → Server | `session/prompt` | 发送用户输入，等待 agent 完成 |
| Client → Server (notification) | `session/cancel` | 中断正在运行的 prompt |
| Server → Client (notification) | `session/update` | 推送实时状态变更 |
| Server → Client (request) | `session/request_permission` | 请求用户审批工具调用 |

### 2.3 `session/update` 事件类型

| `sessionUpdate` 值 | 含义 | 字段 |
|---|---|---|
| `agent_message_chunk` | 模型生成文本增量 | `content: { type: "text", text: "…" }` |
| `agent_thought_chunk` | 模型推理/思考过程 | `content: { type: "text", text: "…" }` |
| `tool_call` | 工具调用开始 | `toolCallId`, `title`, `kind: "read"|"edit"|"search"|"execute"|"other"`, `status: "pending"` |
| `tool_call_update` | 工具状态变更 | `toolCallId`, `status: "in_progress"|"completed"|"failed"`, `content` |
| `usage_update` | 上下文用量更新 | `used: number`, `size: number` |
| `session_info_update` | 会话元信息 | `title: string` |

### 2.4 对比 mnote 当前 SSE 格式

| mnote 当前事件 | ACP 对应事件 | 备注 |
|---|---|---|
| `message.delta` | `agent_message_chunk` | 几乎 1:1 |
| `tool.started` | `tool_call` + `kind` | 前者多了 `preview` 字段 |
| `tool.completed` | `tool_call_update` + `status: "completed"` | 前者多了 `duration` |
| `run.completed` | `session/update` 不再发事件，prompt 返回 | 语义等价 |
| `run.failed` | `tool_call_update` + `status: "failed"` | 语义等价 |
| 无 | `agent_thought_chunk` | 当前页面 AI 未显示思考过程，新增能力 |
| 无 | `usage_update` | 可展示 token 用量 |

**结论：** 前端桥接层只需做一个事件名映射 + 字段适配（~50 行），即可对接 ACP。

---

## 3. 参考代码分析 — 可复用部分

### 3.1 Hermes VSCode 扩展 (`hermes-vscode-main/`)

这是**最完整的 ACP 客户端参考实现**，可以直接指导 mnote-web 的 Rust ACP 客户端设计。

| 文件 | 内容 | 可复用方式 |
|---|---|---|
| `src/acpClient.ts` | ACP JSON-RPC 2.0 客户端：spawn 子进程、读写 NDJSON、处理分帧、请求/响应/通知路由 [acpClient.ts:30-220] | **逻辑移植到 Rust** — `tokio::process::Command` spawn + `BufReader` 按行读取 + 请求 ID 映射表 |
| `src/sessionManager.ts` | 会话生命周期管理：session/new → session/prompt → session/cancel，去重，事件派发 [sessionManager.ts:37-294] | **逻辑移植到 Rust** — `HashMap<String, Session>` 管理活跃会话 |
| `src/protocol.ts` | ACP 事件的类型解析：文本提取、去重、tool call 解析、usage 解析 [protocol.ts:1-120] | **直接指导 Rust struct 设计** |
| `src/chatPanel.ts` | VSCode WebviewView 桥接：ACP 事件 → webview HTML 渲染 [chatPanel.ts:1-524] | **UI 架构参考** — mnote 页面 AI 面板已存在，只需适配事件格式 |
| `src/webview/main.ts` | webview 端事件处理：消息渲染、工具展示、todo 面板 [webview/main.ts:1-531] | **UI 交互参考** — tool call 显示方式、todo overlay |
| `src/webview/renderers.ts` | 工具调用格式化、历史加载 [renderers.ts:1-170] | **前端渲染参考** |

**核心设计复用：**
```typescript
// acpClient.ts 的精髓：请求-响应匹配
class AcpClient {
  private pending = new Map<number, PendingRequest>();
  private nextId = 1;

  async sendRequest(method: string, params: unknown): Promise<unknown> {
    const id = this.nextId++;
    return new Promise((resolve, reject) => {
      this.pending.set(id, { resolve, reject });
      this.output.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
    });
  }
}
```

### 3.2 Reasonix ACP server (`DeepSeek-Reasonix-main/`)

| 文件 | 内容 | 可复用方式 |
|---|---|---|
| `src/acp/server.ts` | ACP JSON-RPC 2.0 server 类 [acp/server.ts:1-150] | **理解对端协议** — Reasonix 作为 server 时的行为 |
| `src/acp/protocol.ts` | ACP 类型定义 + 工具函数 [acp/protocol.ts:1-80] | **协议规格参考** |
| `src/acp/dispatch.ts` | 内核事件 → ACP `session/update` 映射 [acp/dispatch.ts:55-112] | **事件映射参考** — Reasonix 的 `kernel event → ACP` 与 mnote 的 `ACP → SSE` 是互逆过程 |
| `src/acp/gates.ts` | 权限审批逻辑 [gates.ts:1-6353] | **可选参考** — 页面 AI 的 tool call 审批 |
| `src/cli/commands/acp.ts` | `reasonix acp` CLI 命令，将 `CacheFirstLoop` + toolset 包装为 ACP server [acp.ts:1-339] | **Reasonix 侧入口** — 启动 Reasonix ACP 的参考实现 |
| `desktop/src/protocol.ts` | 桌面客户端的 UI 事件协议 [desktop/protocol.ts:1-432] | **前端事件架构参考** — `ModelDeltaEvent`、`ToolPreparingEvent`、`ToolResultEvent` 等 25+ 事件类型的设计 |

### 3.3 复用策略

**不要「移植代码」——要「移植逻辑和接口形状」**。

Rust 端参考 `acpClient.ts` 的架构，但用 tokio async 重写。核心接口设计：

```rust
// Rust ACP client — 接口形状参考 acpClient.ts
pub struct AcpClient {
    child: tokio::process::Child,
    stdin: tokio::io::BufWriter<tokio::process::ChildStdin>,
    stdout: tokio::io::BufReader<tokio::process::ChildStdout>,
    pending: HashMap<u64, PendingRequest>,
    next_id: u64,
}

impl AcpClient {
    pub async fn spawn(bin: &str, args: &[&str]) -> Result<Self>;
    pub async fn send_request<P, R>(&mut self, method: &str, params: P) -> Result<R>;
    pub async fn send_notification(&mut self, method: &str, params: Value);
    pub fn on_notification(&mut self, handler: impl Fn(String, Value));
}
```

---

## 4. 架构设计

### 4.1 运行时选择器

```rust
// mnote-web 配置
struct AcpRuntimeConfig {
    name: String,          // "hermes" | "reasonix"
    bin: String,           // "hermes" | "node"
    args: Vec<String>,     // ["acp"] | ["reasonix-acp.mjs"]
    default_model: Option<String>,
    env: HashMap<String, String>,
}
```

mnote-web 支持多个运行时配置，用户通过 profile 选择：

```
profile "default" → runtime "hermes"   (spawn hermes acp)
profile "reasonix" → runtime "reasonix" (spawn node reasonix-acp-wrapper.mjs)
```

前端获取可用运行时列表：`GET /api/hermes/client/profiles`（现有接口，扩展字段）

### 4.1.1 白名单目录即 runtime 权限边界

对 local-first 而言，真正重要的不是再做一套“文档工具能力矩阵”，而是把 workspace 白名单准确传给 runtime：

```text
登录用户
  -> 解析 access-policy.json / owner / admin / grant
  -> 得到 allowedRoots = ["/mnt/Data1T/Mnote_data/users/.../my-space", ...]
  -> 启动 Hermes / Reasonix 时把 allowedRoots / cwd / read-write scope 传入 runtime
  -> runtime 直接在这些目录里工作
```

这与 VSCode / Codex 的工作模型一致：

- runtime 看到的是一个受限 workspace，而不是一堆抽象页面 API。
- 页面 AI 只额外提供“当前文件是谁”以及可选选区信息。
- 对 `.md` 的普通编辑不强制走 `mnote.doc.markdown_edit`。
- 一旦文件写回磁盘，MNote 再负责把变化同步回前端显示。

### 4.2 会话生命周期 (ACP Session Manager)

```
用户发送消息
  │
  ├─ POST /api/hermes/client/sessions → 创建 session
  │    ├─ ACP session/new → 得到 sessionId
  │    └─ 返回 { sessionId, ... }
  │
  ├─ POST /api/hermes/client/runs → 开始 run（现有接口）
  │    ├─ ACP session/prompt → 下发用户输入 + 页面上下文
  │    └─ 返回 { runId }
  │
  ├─ GET /api/hermes/client/events/{runId} → SSE 流
  │    ├─ ACP session/update 的 6 种事件 → SSE 映射
  │    ├─ agent_message_chunk  → { event: "message.delta", delta: ... }
  │    ├─ tool_call           → { event: "tool.started", tool: ..., kind: ... }
  │    ├─ tool_call_update    → { event: "tool.completed", ... }
  │    ├─ agent_thought_chunk → { event: "thought.delta", delta: ... }  (新增)
  │    ├─ usage_update        → { event: "usage.updated", usage: ... }  (新增)
  │    └─ session/update 转 ACP → prompt 返回 → SSE event "run.completed"
  │
  └─ POST /api/hermes/client/runs/{runId}/abort → 取消
       └─ ACP session/cancel
```

### 4.3 工具桥接

当前 `hermes_tools.rs` 中注册的 mnote 工具（`mnote.doc.fetch`、`mnote.doc.markdown_edit`、`mnote.block.*`、`mnote.page.*`）对 ACP 来说只是一组 HTTP 兼容端点，而不是 local-first 普通 Markdown 编辑的唯一主路径。

对于 Reasonix 作为 runtime 的场景，仍需要一个 Reasonix-side 的工具注册包装脚本，将 mnote 兼容工具注册到 `ToolRegistry`；但 local-first 默认应优先让 runtime 直接拿到授权文件引用，在受限 cwd 中使用自身成熟的 patch/diff/文件编辑能力。

```typescript
// reasonix-acp-wrapper.mjs — ACP 包装层
// 参考: DeepSeek-Reasonix-main/src/cli/commands/acp.ts (整个文件, ~339 行)
import { AcpServer } from 'reasonix/acp/server';
import { DeepSeekClient, CacheFirstLoop, ToolRegistry } from 'reasonix';

// 注册 mnote 工具 — 工具实现调用 mnote-web HTTP API
const tools = new ToolRegistry();
tools.define({
  name: "mnote.doc.fetch",
  description: "阅读当前文档的 markdown 内容",
  parameters: { ... },
  call: async (args) => {
    // 调 mnote-web Rust HTTP API
    return fetch(`${MNOTE_WEB_URL}/api/hermes/tools/mnote/call`, {
      method: 'POST', body: JSON.stringify({ toolName: "mnote.doc.fetch", args })
    }).then(r => r.json());
  },
  parallelSafe: false,
});

// 启动 ACP server — 参考 acp.ts 的 acpCommand 函数
const server = new AcpServer();
const client = new DeepSeekClient({ apiKey: process.env.DEEPSEEK_API_KEY });
let sessions = new Map();

server.onRequest("session/new", async (params) => {
  const loop = new CacheFirstLoop({ client, tools, ... });
  // ... 参考 acp.ts 第 220-330 行
});
```

工具的实际执行路径：

```
agent → tool call → (通过 TCP/localhost HTTP) → mnote-web Rust hermes_tools.rs
                                                      → Convex / 文档系统
```

**不需要在 Rust 侧重新注册工具到 Reasonix。** mnote-web 的工具 HTTP 端点 (`/api/hermes/tools/mnote/call`) 仍可保留，只通过 ACP 换掉了 agent runtime；但这些端点主要承担 cloud / remote agent / compat fallback，而不是把所有本地文件编辑都重新包成 mnote 工具。

### 4.3.1 local-first 默认工作流

local-first 页面 AI 的默认工作流应是：

```text
当前页面 URL / documentId
  -> MNote 解析出真实 markdown 文件路径
  -> MNote 校验该路径是否落在 runtime allowedRoots 白名单内
  -> 把 currentFile / selection / allowedRoots 传给 runtime
  -> runtime 直接读写该文件
  -> watcher / refresh 触发前端 page aggregate 与 tiptap 更新
```

只有在以下情况，runtime 才需要走 mnote 兼容工具：

- runtime 本身无法直接访问本地文件
- 当前 source 是 cloud / sync replica
- 当前对象不是普通 markdown，而是 mindmap / table / 资源块 / 分享受限对象
- 需要显式审计某种结构化操作

### 4.4 前端 SSE 扩展

当前前端 SSE 事件格式 (`HermesRunEvent`)：

```typescript
type HermesRunEvent =
  | { event: "tool.started"; tool: string; preview?: string | null }
  | { event: "tool.completed"; tool: string; duration?: number; error?: boolean }
  | { event: "message.delta"; delta: string }
  | { event: "run.completed"; output?: string; usage?: Record<string, unknown> }
  | { event: "run.failed"; error?: string };
```

新增字段（向后兼容）：

```typescript
type HermesRunEvent = /* 原有 5 种 */ | {
  event: "thought.delta";        // 新增 — 思考过程
  delta: string;
} | {
  event: "run.completed";        // 扩展 — 新增缓存指标
  output?: string;
  usage?: Record<string, unknown>;
  cacheHitRate?: number;          // 新增：Reasonix 缓存命中率
  cacheHitTokens?: number;        // 新增
};
```

前端 AiAgentPanel 接到 `thought.delta` 后，可渲染在独立区域（参考 hermes-vscode-main 的 `agent_thought_chunk` 处理）。

### 4.5 Profile 体系扩展

当前 `hermes_client.rs` 的 profile 机制（`configured_upstream_for_profile`）是 Hermes-HTTPS 专有的。需要扩展为通用运行时 profile：

```rust
struct AgentProfile {
    name: String,
    runtime_type: RuntimeType,  // Hermes | Reasonix | ACPGeneric
    runtime_config: AcpRuntimeConfig,
    api_key: Option<String>,
    models: Vec<ModelConfig>,
    default_model: Option<String>,
}
```

当前已存在的 `hermes_client.rs` profile 相关端点不需要大改——profile 切换逻辑不变，只是 profile 的数据结构增加了 `runtimeType` 字段。

---

## 5. 褪去历史负担 — 模块重构路线

### 阶段 0：现状（当前）

```
hermes_client.rs (3845 行)
├─ HTTP proxy 逻辑 (proxy_json, proxy_stream)
├─ Hermes API 硬编码 (create_run, stream_events)
├─ Session/profile/Skills/工具管理
├─ Memory 管理
└─ 各种配置和鉴权
```

### 阶段 1：新增 ACP 客户端，并行运行

新增文件：
- `acp_client.rs` — ACP JSON-RPC 2.0 客户端（参考 hermes-vscode-main `acpClient.ts`）
- `acp_session_manager.rs` — 会话生命周期管理（参考 hermes-vscode-main `sessionManager.ts`）
- `acp_runtime.rs` — 运行时管理（spawn、健康检查、切换）

`hermes_client.rs` 中原有的 HTTP proxy 逻辑标志为 `#[deprecated]`，前端通过 profile 选择使用 ACP 还是旧 HTTP proxy。

### 阶段 2：前端运行时选择器

AiAgentPanel 增加 runtime 切换下拉框，实际只是切换 mnote-web 内部使用的 profile。

### 阶段 3：存量迁移

- `list_sessions` → ACP `session/new` + 本地记录
- `create_session` → ACP `session/new`
- `create_run` + `stream_events` → ACP `session/prompt` + `session/update` 事件映射
- `abort_run` → ACP `session/cancel`
- `list_profiles` → 扩展为包含 runtime_type 字段
- `gateway_health` → 改为 ACP runtime 健康检查（spawn + PING）
- `list_tools` → 迁移到 `acp_session_manager.rs`

### 阶段 4：退役旧代码

当所有 profile 都已迁移到 ACP 后，`hermes_client.rs` 中原来的 HTTP proxy 代码可以删掉。Profile 的 `upstream_url` 字段不再需要——运行时由 `bin + args` 定义。

---

## 6. 实现计划

### 6.1 Rust ACP 客户端 (`acp_client.rs`)

**参考：** `hermes-vscode-main/src/acpClient.ts`

核心接口：

```rust
use tokio::process::{Command, Child};
use tokio::io::{BufReader, BufWriter, AsyncBufReadExt, AsyncWriteExt};
use serde_json::Value;
use std::collections::HashMap;

type PendingMap = HashMap<u64, tokio::sync::oneshot::Sender<Result<Value, AcpError>>>;

pub struct AcpClient {
    child: Child,
    writer: BufWriter<ChildStdin>,
    pending: Arc<Mutex<PendingMap>>,
    next_id: AtomicU64,
}

impl AcpClient {
    /// Spawn ACP subprocess
    /// 参考: acpClient.ts line 50-80 (spawn + stdio setup)
    pub async fn spawn(bin: &str, args: &[&str]) -> Result<Self> {
        let mut child = Command::new(bin)
            .args(args)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::inherit())
            .spawn()?;

        let writer = BufWriter::new(child.stdin.take().unwrap());
        let reader = BufReader::new(child.stdout.take().unwrap());
        let pending = Arc::new(Mutex::new(HashMap::new()));

        // 后台读取 stdout 行
        let p = pending.clone();
        tokio::spawn(async move {
            let mut lines = reader.lines();
            while let Ok(Some(line)) = lines.next_line().await {
                if line.trim().is_empty() { continue; }
                if let Ok(msg) = serde_json::from_str::<Value>(&line) {
                    // 参考 acpClient.ts line 120-180 (onData 解析逻辑)
                    if let Some(id) = msg.get("id").and_then(|v| v.as_u64()) {
                        // 响应 → 匹配 pending
                        if let Some(tx) = p.lock().unwrap().remove(&id) {
                            let _ = tx.send(Ok(msg));
                        }
                    } else if let Some(method) = msg.get("method").and_then(|v| v.as_str()) {
                        // 通知 → 调用 onNotification
                    }
                }
            }
        });
        Ok(Self { child, writer, pending, next_id: AtomicU64::new(1) })
    }

    /// Send JSON-RPC request, await response
    /// 参考: acpClient.ts line 95-110 (sendRequest)
    pub async fn request<P: Serialize, R: DeserializeOwned>(
        &self, method: &str, params: P
    ) -> Result<R> {
        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
        let (tx, rx) = tokio::sync::oneshot::channel();
        self.pending.lock().unwrap().insert(id, tx);

        let req = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
        self.writer.write_all(format!("{}\n", serde_json::to_string(&req)?).as_bytes()).await?;
        self.writer.flush().await?;

        match rx.await {
            Ok(Ok(val)) => serde_json::from_value(val).map_err(Into::into),
            _ => Err(AcpError::Timeout),
        }
    }
}
```

**工作量：** ~200 行 Rust。核心逻辑直接映射自 hermes-vscode-main 的 `acpClient.ts`。

### 6.2 ACP Session Manager (`acp_session_manager.rs`)

**参考：** `hermes-vscode-main/src/sessionManager.ts`

```rust
struct AcpSession {
    id: String,
    runtime: AcpRuntimeConfig,
    client: AcpClient,
    state: SessionState,
    run_handle: Option<JoinHandle<()>>,
}

pub struct AcpSessionManager {
    runtimes: HashMap<String, AcpRuntimeConfig>,
    sessions: HashMap<String, AcpSession>,
    active_profile: String,
}
```

接口：

| 方法 | 对应 ACP | 参考 |
|---|---|---|
| `create_session(profile, page_context)` | `session/new` | sessionManager.ts `start()` / `sendPrompt()` |
| `run_prompt(session_id, prompt)` | `session/prompt` | sessionManager.ts `sendPrompt()` |
| `cancel(session_id)` | `session/cancel` | sessionManager.ts `cancel()` |
| `on_update(handler)` | `session/update` | sessionManager.ts `handleUpdate()` |
| `switch_runtime(profile)` | — | 切换 `active_profile`，刷新 runtime |

**工作量：** ~300 行 Rust。

### 6.3 Reasonix ACP wrapper (`reasonix-acp-wrapper.mjs`)

**参考：** `DeepSeek-Reasonix-main/src/cli/commands/acp.ts`

```typescript
// 参考 acp.ts 的 acpCommand() 函数 — ~147 行核心逻辑
// 1. 创建 AcpServer
// 2. 注册 mnote 工具 (调 mnote-web HTTP)
// 3. onRequest("session/new") → 创建 CacheFirstLoop
// 4. onRequest("session/prompt") → loop.run() + dispatchKernelEvent
// 5. onNotification("session/cancel") → aborter.abort()
```

**工作量：** ~150 行 TypeScript。直接改编自 `acp.ts` 已有代码。

### 6.4 桥接层 — ACP → SSE

**参考：** `hermes-vscode-main/src/sessionManager.ts` 的 `handleUpdate()` 方法

当前 `stream_events` 输出 SSE。改为 ACP 后：

```rust
// 在 new AcpSessionManager().on_update() 中
fn on_acp_update(update: SessionUpdateParams) -> Option<SseEvent> {
    match update.update.sessionUpdate {
        "agent_message_chunk" => Some(SseEvent {
            event: "message.delta",
            data: json!({ "delta": extract_text(&update) }),
        }),
        "tool_call" => Some(SseEvent {
            event: "tool.started",
            data: json!({ "tool": update.title, "kind": update.kind }),
        }),
        // ... 其余事件映射
    }
}
```

**工作量：** ~60 行 Rust。

### 6.5 前端运行时选择器

AiAgentPanel 增加下拉框 + 切换逻辑：

```tsx
<select value={activeProfile} onChange={switchProfile}>
  <option value="default">Hermes（默认）</option>
  <option value="reasonix">Reasonix（缓存优先）</option>
</select>
```

`switchProfile` 调 `PUT /api/hermes/client/profiles/active`（已有接口）。

**工作量：** ~50 行 TypeScript。

---

## 7. 与现有设计的关系

### 7.1 对 7-5 (Hermes client proxy 合同) 的影响

7-5 规定的路由路径不变：

| 路由 | 当前实现 | ACP 后 |
|------|----------|--------|
| `GET  /client/sessions` | Hermes HTTP proxy | ACP `session/new` 历史 |
| `POST /client/sessions` | 本地生成 sessionId | 本地生成 + ACP `session/new` |
| `POST /client/runs` | Hermes HTTP run | ACP `session/prompt` |
| `GET  /client/events/{runId}` | Hermes SSE 流 | ACP `session/update` → SSE |
| `POST /client/runs/{runId}/abort` | Hermes HTTP abort | ACP `session/cancel` |

前端看到的 HTTP 接口不变，后端实现透明切换。

### 7.2 对 7-14 (markdown 编辑收敛) 的影响

7-14 的最新口径是：local-first 普通 Markdown 编辑优先走“授权文件引用 + agent 原生 patch/diff”，`mnote.doc.markdown_edit` / `mnote.block.*` 退到兼容与辅助层。ACP 只是换掉 driver（从 Hermes 换成 Reasonix），不改变这个权限与执行边界。

### 7.3 对 `page_ai_workflow.rs` 的影响

最新口径下，`block_edit_workflow` 只保留为兼容门面；local-first 普通正文编辑不应再依赖它。ACP 主要服务 agent runtime 选择、权限隔离、事件桥接和审计。

---

## 8. 风险与缓解

| 风险 | 概率 | 缓解 |
|------|------|------|
| Reasonix ACP server 的 tool call 调用 mnote-web HTTP 有延迟 | 中 | 工具调用走 localhost TCP，延迟 <1ms |
| Reasonix `CacheFirstLoop` 与 mnote 页面上下文的兼容性 | 低 | ACP `session/new` 传 `pageContext`，system prompt 在 Reasonix wrapper 中注入 |
| 两个运行时并行维护增加心智负担 | 中 | 过渡期后退役旧 Hermes HTTP proxy，只保留 ACP |
| ACP 协议字段差异（Hermes vs Reasonix camelCase/snake_case） | 低 | 在桥接层做一次字段映射即可 |
| Task 7-12 说「mnote 不再建设独立 AI agent runtime」 | 不冲突 | ACP 层不是 agent runtime，是 runtime 抽象接口。mnote 仍然不建设 runtime，只是可以选接不同的 runtime |

---

## 9. 开放问题

- Reasonix 的 `DeepSeekClient` 需要的 API key 如何注入？环境变量？mnote-web 配置？
  - **已决定**：通过 `reasonix-acp-wrapper.mjs` 的环境变量 `DEEPSEEK_API_KEY` 注入
- 本地 `.md` 文件的 tool 实现（`mnote.doc.fetch` 的本地变体）是否也在同一套 ACP 中？
- Phase C（流式 review/apply）的审批事件（`session/request_permission`）是否需要先加入 ACP 层？当前跳过，等 Phase C 再扩展。

---

## 11. 当前实现状态

### ✅ 已完成（2026-05-17）

#### Phase A — Rust ACP 基础设施

| Step | 文件 | 状态 | 测试 |
|------|------|------|------|
| 3 | `acp_client.rs` | 编译通过，~487 行 | 6 单元测试通过，含 `initialize` 握手 |
| 4 | `acp_types.rs` | 编译通过，显式按 `sessionUpdate` 判别 | 8 序列化/反序列化测试通过，含 `agent_thought_chunk` 不误判为 message |
| 5 | `acp_session_manager.rs` | 编译通过，~530 行 | 5 测试通过（含事件派发、文本去重、thought → `ThoughtDelta`） |
| 6 | `acp_runtime.rs` | 编译通过，~330 行 | 6 测试通过（含真实 Hermes CLI 连接） |
| 7 | `acp_bridge.rs` | 编译通过，~260 行 | ACP→SSE 事件映射，含 `ThoughtDelta` → `thought.delta` 单测 |
| 8 | `lib.rs` 模块注册 | 编译通过 | 5 个 ACP 模块声明 |
| 9 | `AppState` 集成 | 编译通过 | `AcpRuntimeManager` 挂入 `AppState` |

#### Phase B — 后端集成

| Step | 文件 | 改动 | 验证 |
|------|------|------|------|
| 7 | `hermes_client.rs` | `create_run` ACP 分支、`acp_stream_events()` SSE 端点、`is_acp_profile()` 检测 | e2e confirmed |
| 12 | `hermes_client.rs` | Profile 扩展 `configured_runtime_for_profile()` | — |
| 12 | `hermes_client.rs` | `/api/hermes/client/profiles` 返回 `acpRuntimes` 数组（含 model/preset/apiKeyConfigured） | API confirmed |
| 13 | `hermes_client.rs` | `configured_upstream_for_profile()` 标 `#[deprecated]`（后因 caller warning 移除） | — |

#### Phase C — Reasonix ACP Wrapper

| Step | 文件 | 状态 | 说明 |
|------|------|------|------|
| 10 | `scripts/reasonix-acp-wrapper.mjs` | ~400 行 | 自包含 NDJSON JSON-RPC 2.0 服务器，无依赖 `AcpServer` |
| — | API key 加载 | `loadApiKey()` 先读 `DEEPSEEK_API_KEY`，再读 `~/.reasonix/config.json` 的 `apiKey`，最后兼容 `~/.reasonix/config.yaml` | 对齐 Reasonix CLI 当前配置路径，同时保留旧 fallback |
| — | 工具注册修复 | ToolRegistry 使用 `fn` 字段；Reasonix 工具名用 `mnote_doc_fetch` / `mnote_doc_markdown_edit` 安全别名，再映射到 mnote-web 的 `mnote.doc.*` HTTP 工具 | 对齐 `DeepSeek-Reasonix-main/src/tools.ts`，避免 dotted tool name 与错误 `call` 字段导致工具不可调 |
| — | 事件处理修复 | `ev.role` 替代 `ev.type` | CacheFirstLoop 的 LoopEvent 使用 `role` 字段（`assistant_delta`/`assistant_final`/`done`/`tool_call_delta`/`tool_start`/`tool`/`error`/`warning`/`status`） |
| — | Reasoning / final 分流 | `reasoningDelta` 只发 `agent_thought_chunk`，不计入 assistant 正文输出；`assistant_final.content` 仍可在无 delta 正文时补发 | 避免只收到 reasoning 后吞掉最终正文 |

#### Phase D — 前端集成（Rust SSR）

| 改动 | 文件 | 说明 |
|------|------|------|
| ACP 下拉选择器 | `layout.rs` | Agent 标签页新增 `<select data-page-ai-acp-runtime>`；2026-05-18 起只保留 ACP · Hermes / ACP · Reasonix，移除“默认 (Hermes HTTP)”选项 |
| 状态存储 | `layout.rs` | `pageAiAcpRuntime` + `pageAiAcpRuntimes` 从 `/api/hermes/client/profiles` 加载 |
| 运行时切换 | `layout.rs` | `acpRuntime` 只表示运行时/传输层；Hermes ACP 继续保留当前 Hermes profile，Reasonix ACP 使用 `profile=reasonix` |
| UI 自适应 | `layout.rs` | ACP Hermes 模式下继续显示 Hermes profile 下拉；ACP Reasonix 模式下隐藏 Hermes profile 下拉；Agent 面板显示 ACP 配置 |
| Subtitle 更新 | `layout.rs` | 选择 ACP 后标题栏显示 `ACP · Reasonix` 或 `ACP · Hermes` |

#### Phase E — 浏览器验证 Skill

| 文件 | 状态 | 说明 |
|------|------|------|
| `/home/lix/.codex/skills/page-ai-browser-verify/SKILL.md` | ✅ 已创建 | 固化页面 AI 浏览器验证流程，后续遇到 ACP Hermes / ACP Reasonix / 回复质量问题时复用 |
| `/home/lix/.codex/skills/page-ai-browser-verify/scripts/verify_mnote_page_ai_acp.js` | ✅ 已创建 | 真实登录、创建临时页、切换 ACP Hermes/Reasonix、捕获 `/api/hermes/client/runs`、截图，并断言 assistant 正文去空白后严格等于 marker |

验证命令：

```bash
node /home/lix/.codex/skills/page-ai-browser-verify/scripts/verify_mnote_page_ai_acp.js
```

最新证据（2026-05-17）：

| 项 | 路径 |
|---|---|
| 结构化结果 | `/mnt/Data1T/mnote/tmp/page-ai-acp-browser-UAYwyM/result.json` |
| ACP Hermes 截图 | `/mnt/Data1T/mnote/tmp/page-ai-acp-browser-UAYwyM/01-acp-hermes-reply.png` |
| ACP Reasonix 截图 | `/mnt/Data1T/mnote/tmp/page-ai-acp-browser-UAYwyM/02-acp-reasonix-reply.png` |

验收标准已收紧：不能只判断 UI “包含 marker”；必须确认最终 assistant 正文是干净最终答案，不能出现推理解释、工具说明、`Theuserisasking...` 这类 glued reasoning 文本。

### 🐛 问题与解决状态（2026-05-17）

#### 问题 1：ACP Reasonix SSE 流返回空（`stop_reason=Error`）

| 项 | 详情 |
|---|---|
| **状态** | ✅ 已修复；后端 SSE 探针与前端真实浏览器验证均通过 |
| **原现象** | `ACP prompt completed: stop_reason=Error`，耗时 ~300ms，SSE 流无任何 `message.delta` 事件 |
| **已排除** | ✅ API key 有效（`CacheFirstLoop.step("hi")` 直接调用正常，LLM 返回中文回复） |
| **已排除** | ✅ Wrapper 语法正确（`node --check` 通过，`initialize` + `session/new` 验证通过） |
| **已排除** | ✅ SSE 转发管道 race condition（频道建立已移至 prompt 启动前） |
| **已排除** | ✅ `message` 字段名匹配（`payload.get("message")` 修正） |
| **根因** | `scripts/reasonix-acp-wrapper.mjs` 与 Reasonix 当前 API 不匹配：`ToolRegistry.register()` 需要 `fn` 而不是 `call`；`CacheFirstLoop.step()` 产出的是 `ev.role`，不是旧的 `ev.type`；本机 Reasonix API key 存在 `~/.reasonix/config.json`，旧 wrapper 只读 YAML |
| **已修复** | wrapper 改为读取 `config.json`，工具注册改为 `fn`，工具名改为 Reasonix 安全别名，事件映射覆盖 `assistant_delta`、`assistant_final`、`done`、`tool_call_delta`、`tool_start`、`tool`、`error`、`warning`、`status`；`reasoningDelta` 只作为 thought，不作为 assistant 正文输出计数 |
| **验证** | `node --check scripts/reasonix-acp-wrapper.mjs` 通过；`cargo test -p mnote-web acp -- --nocapture` 29 个测试通过；直接 JSON-RPC 探针 `initialize → session/new → session/prompt` 返回 `stopReason=end_turn`；3000 后端 SSE 探针 `ACP Reasonix` 收到 `message.delta` + `run.completed`；真实浏览器验证见 `tmp/page-ai-acp-browser-UAYwyM/` |

#### 问题 1b：ACP Hermes profile 选择丢失

| 项 | 详情 |
|---|---|
| **状态** | ✅ 已修复，后端真实 SSE 探针已通过 |
| **原现象** | 选择 `ACP · Hermes` 后，前端把 `pageAiAcpRuntime` 当成 `profile` 发送，导致 profile 固定为 `hermes`，无法沿用既有 Hermes profile 选择 |
| **根因** | `profile` 与 `acpRuntime` 两个概念混用：`profile` 应表示 Hermes agent/profile（如 `default`、`mnoteai`），`acpRuntime` 才表示运行时传输层（`hermes` / `reasonix`） |
| **已修复** | 前端 `create_run` 发送 `{ profile: pageAiRunProfile(), acpRuntime }`；ACP Hermes 保留 profile 下拉；后端按 `acpRuntime` 进入 ACP 分支，并按本次 `profile` 启动 `hermes -p <profile> acp` |
| **兼容处理** | ACP Hermes subprocess 会从所选 profile 的 `model.api_key` / `providers.<provider>.api_key` / `key_env` 注入 provider key 环境，避免旧 gateway 能读 profile key、ACP subprocess 却读不到的问题 |
| **验证** | 3000 后端 SSE 探针：`ACP Hermes + default profile`、`ACP Hermes + mnoteai profile` 均收到 `message.delta` + `run.completed`，没有 `run.failed`；浏览器请求体确认 Hermes 为 `{ profile: "mnoteai", acpRuntime: "hermes" }` |

#### 问题 1c：浏览器截图显示 reasoning / thought 被当作最终回复

| 项 | 详情 |
|---|---|
| **状态** | ✅ 已修复，严格浏览器验证通过 |
| **原现象** | 旧截图 `/mnt/Data1T/mnote/tmp/page-ai-acp-browser-mp9ofvbc/01-acp-hermes-reply.png` 中，AI 气泡显示 `Theusersentabrowserverificationstring...`；Reasonix 旧 `result.json` 也出现 `Theuserisaskingmetorespond...ACP_REASONIX...`，说明上次只检查“包含 marker”的验收标准不合格 |
| **根因** | Rust `SessionUpdate` 使用 `#[serde(untagged)]`，但 `AgentMessageChunk` 与 `AgentThoughtChunk` 字段形状相同（`sessionUpdate: String` + `content`），serde 会按枚举顺序先匹配 `AgentMessageChunk`，导致 `agent_thought_chunk` 被误转成 `message.delta` |
| **已修复** | `acp_types.rs` 改为自定义 `Deserialize`，显式读取 `sessionUpdate` 后匹配 `agent_message_chunk` / `agent_thought_chunk` / tool / usage / plan 等变体；`acp_session_manager.rs` 补 `agent_thought_chunk` → `ThoughtDelta` 单测；`acp_bridge.rs` 补 `ThoughtDelta` → `thought.delta` 单测 |
| **验证** | `cargo test -p mnote-web acp -- --nocapture`：29 passed；浏览器 skill 严格断言 Hermes / Reasonix 最新 assistant 正文分别严格等于 `ACP_HERMES_BROWSER_OK_mp9pfqf6`、`ACP_REASONIX_BROWSER_OK_mp9pfqf6`；截图见 `tmp/page-ai-acp-browser-UAYwyM/` |
| **后续规则** | 页面 AI 浏览器验证必须同时看截图与正文断言；不能只用 DOM 包含 marker 作为通过条件 |

#### 问题 2：Hermes HTTP 路径 502

| 项 | 详情 |
|---|---|
| **现象** | `Hermes upstream 连接失败: error sending request for url (http://127.0.0.1:8644/v1/runs)` |
| **判断** | 这是旧 Hermes HTTP proxy 路径的环境/profile 配置问题，不是 ACP Reasonix wrapper 问题 |
| **原因** | `configured_upstream_for_profile()` 优先读取 `MNOTE_WEB_HERMES_UPSTREAM_URL` 或 profile 的 `API_SERVER_PORT`，当前解析到了 8644；实际 Hermes gateway 端口应与本机服务一致（设计稿预期为 8642） |
| **解决** | 只读确认当前 mnote-web 启动环境和 Hermes profile 配置；将 `MNOTE_WEB_HERMES_UPSTREAM_URL` 或对应 profile `API_SERVER_PORT` 调整到实际 gateway 端口；不要在 ACP runtime 层硬编码端口 |

#### 问题 3：ACP 模式 Skills 面板内容区分（次要项）

| 项 | 详情 |
|---|---|
| **状态** | 🟡 次要项；不阻塞当前首要目标（ACP Hermes / ACP Reasonix 正常回复） |
| **纠正** | Reasonix skills 不是空态；本机真实目录包含 `/home/lix/.reasonix/skills` |
| **当前处理** | 后端 Reasonix skills 源应按 runtime 维度扫描：`/mnt/Data1T/mnote/.reasonix/skills`、`/mnt/Data1T/mnote/.agents/skills`、`/home/lix/.reasonix/skills`、`/home/lix/.agents/skills` |
| **边界** | Skills 展示只是可见信息；当前还不能据此认为这些 skills 都已经注入 Reasonix ACP runtime 的工具系统 |
| **下一步** | 后续若要把 Reasonix skills 变成可执行能力，需要明确 Reasonix skill schema → ToolRegistry 注册规则；当前优先保持只读展示与不误导 |

#### 问题 4：ACP Hermes 未复用 soul/user/memory 可编辑 UI

| 项 | 详情 |
|---|---|
| **用户反馈** | ACP Hermes 页面中看不到 Hermes HTTP 已有的 `SOUL.md` / `USER.md` / `MEMORY.md` 编辑卡片 |
| **根因** | `layout.rs` 在 `isAcp` 分支把 Agent 面板替换成 runtime info 卡片，并且 `if (agentPanel && !isAcp)` 才渲染 memory 编辑器；因此 ACP Hermes 被误归入 Reasonix 风格的 runtime 信息态 |
| **参考结论** | `hermes-vscode-main` 本身没有 soul/user/memory 模块；这部分应复用 mnote 现有 Hermes HTTP BFF：`/api/hermes/client/profile-memory` |
| **已修复** | ACP Hermes (`pageAiAcpRuntime === "hermes"`) 继续显示与 Hermes HTTP 相同的三张可编辑 memory 卡片；ACP Reasonix 仍显示 runtime info；Hermes profile 下拉继续保留 |
| **边界** | 未额外把 memory 文本硬塞入 ACP prompt，避免和 `hermes -p <profile> acp` 自身 profile 加载逻辑重复；当前需求以 UI 可见、可编辑、可保存为准 |

#### 问题 5：页面 AI 会话历史刷新丢失

| 项 | 详情 |
|---|---|
| **用户反馈** | 当前会话历史刷新后没有了，不清楚真源在哪里 |
| **根因** | 前端运行期真源是 `pageUiState.pageAiSessions/pageAiMessages`；旧 `localStorage` 只保存 `activeSessionId/activeProfileName`，不保存 `messages`；ACP run 也未落 Hermes HTTP session export |
| **参考结论** | `hermes-vscode-main/src/sessionStore.ts` 把 `ChatSession[]`、`messages`、`acpSessionId` 存在 VSCode `workspaceState`，最多 20 个 session、每 session 300 条消息 |
| **已修复** | 短期真源明确为“按文档隔离的浏览器 localStorage”：`hermes_page_ai_session:<documentId>` 现在保存 `version/activeSessionId/activeProfileName/activeAcpRuntime/sessions/messages`，每 session 最多 300 条消息 |
| **验证方法** | 浏览器验证 skill 已增加 Hermes 回复后 reload，再打开页面 AI，确认 marker 回复仍可见 |
| **长期方案** | 后续应把页面 AI 会话统一落后端 session store，并让 ACP session id 支持 `session/load` 恢复；localStorage 只保留本地缓存与离线恢复 |

#### 问题 6：ACP 工具调用信息和流式输出不完整

| 项 | 详情 |
|---|---|
| **用户反馈** | 与 `hermes-vscode-main` 相比，缺工具调用信息（需可折叠、默认折叠），缺流式输出 |
| **根因** | ACP `message.delta` 已到前端，但旧 UI 只累加到局部 `assistantText`，stream 完成后才 push assistant message；工具事件虽然会生成 tool message，但卡片是普通 div，未折叠，且 ACP bridge 丢掉了 `rawInput/content/status` |
| **参考结论** | `hermes-vscode-main` 的关键是协议层 text dedup、UI 层 `pendingText/currentAgentText` 流式占位、`data-tool-id` 原地更新工具状态；参考实现本身不是折叠 UI，因此 mnote 在其基础上新增 `<details>` |
| **已修复** | 前端收到首个 `message.delta` 时创建 `streaming=true` assistant 消息并持续更新；完成后标记为普通 assistant 消息并持久化；工具卡改为 `<details>`，默认折叠，summary 显示工具名/状态/kind/call id，展开显示参数/结果/trace |
| **后端补齐** | `AcpSessionEvent::ToolCall` 保留 `raw_input`，`ToolCallUpdate` 保留 `content`；`acp_bridge` SSE 输出 `status/input/output`，让前端可展示工具参数和结果摘要 |
| **验证** | `cargo test -p mnote-web acp -- --nocapture` 已覆盖 `acp_tool_events_keep_detail_for_collapsible_ui`；浏览器验证 skill 会记录 `sawStreaming` 并保留截图 |

### 📋 待完成

| Step | 工作 | 前置 | 估算 |
|------|------|------|------|
| — | 旧 Hermes HTTP proxy 502 环境配置确认 | 非 ACP 路径，仅影响旧 gateway | ~15min |
| — | ACP 模式 Skills 可执行注入设计（Reasonix skill schema → ToolRegistry） | 问题3 | ~半天 |
| 15 | 压力测试：多会话并发、进程管理稳定性 | Step 14 | ~半天 |
| 16 | 退役旧 HTTP proxy 代码 | Step 14 稳定后 | ~1 天 |
| 17 | 基准测试：Reasonix cache hit rate vs Hermes | Step 10 | ~半天 |

---

## 10. 详细执行 Checklist（顺序执行）

以下 checklist 按依赖关系排序，每个 step 标注了**参考文件**（可直接读的代码）、**产出文件**、**验证方法**。执行时从 step-1 开始，完成后由 AI 调用 `todo_write` 标记进度后进入下一步。

### [x] Step 1：读取参考代码，熟悉 ACP 协议细节

> ✅ 完成。已阅读 `acpClient.ts`（spawn/request/notification 模式）、`protocol.ts`（6 种 session/update 变体）、`acp.ts`（CacheFirstLoop + Eventizer 集成）。确认 ACP 使用 camelCase JSON 字段、NDJSON 流式传输。

| **参考** | `reference-code/hermes-vscode-main/src/acpClient.ts`、`reference-code/DeepSeek-Reasonix-main/src/acp/protocol.ts`、`reference-code/hermes-vscode-main/src/protocol.ts` |

### [x] Step 2：确认 `run_command` 的 cwd 与项目根一致

> ✅ 完成。cwd = `/mnt/Data1T/mnote`，所有路径相对此目录。

### [x] Step 3：创建 `acp_client.rs` — ACP JSON-RPC 2.0 客户端

> ✅ 完成。`rust/crates/mnote-web/src/acp_client.rs`（~487 行）。实现了 `AcpClient` 结构体，含 `spawn()`、`request()`、`notification()`、`on_notification()`、`close()`。后台 tokio task 处理 NDJSON 行读取，`pending: HashMap<u64, oneshot::Sender>` 路由响应。**6 个单元测试通过**（含 `initialize` 握手 mock）。

| **产出** | `rust/crates/mnote-web/src/acp_client.rs` |

### [x] Step 4：创建 ACP 协议类型定义 — `acp_types.rs`

> ✅ 完成。`rust/crates/mnote-web/src/acp_types.rs`（~440 行）。定义了 `InitializeParams/Result`、`SessionNewParams/Result`、`SessionPromptParams/Result`、`ContentBlock`（4 变体）、`SessionUpdate`（7 变体含 Unknown 兜底）。全部 camelCase JSON。**7 个序列化往返测试通过**。

| **产出** | `rust/crates/mnote-web/src/acp_types.rs` |

### [x] Step 5：创建 `acp_session_manager.rs` — 会话生命周期管理

> ✅ 完成。`rust/crates/mnote-web/src/acp_session_manager.rs`（~530 行）。`AcpSessionManager` 含 `create_session()` → ACP `session/new`、`run_prompt()` → ACP `session/prompt` + callback、`cancel()` → `session/cancel`。文本去重（4 种模式匹配）、事件枚举（7 种 variants）。**5 个单元测试通过**（含事件派发、去重逻辑、thought chunk 映射）。

| **产出** | `rust/crates/mnote-web/src/acp_session_manager.rs` |

### [x] Step 6：创建 `acp_runtime.rs` — 运行时管理

> ✅ 完成。`rust/crates/mnote-web/src/acp_runtime.rs`（~330 行）。`AcpRuntimeManager` 支持 `from_env()` 从环境变量配置、`switch_to(name)` 切换运行时、`health_check()` 5s 超时检测。Reasonix wrapper 路径通过 `CARGO_MANIFEST_DIR` 自动解析绝对路径。**6 个单元测试通过**（含真实 Hermes CLI 连接）。

| **产出** | `rust/crates/mnote-web/src/acp_runtime.rs` |

### [x] Step 7：集成 ACP Session Manager 到 Hermes routes

> ✅ 完成。`hermes_client.rs` 中：
> - `create_run`：新增 ACP 分支——`is_acp_profile()` 检测 → `register_acp_runtime()` → 存储 payload → 返回本地 runId
> - `stream_events`：新增 `acp_stream_events()` 函数——`AcpRuntimeManager::switch_to()` 激活运行时 → `AcpSessionManager::create_session()` + `run_prompt()` → broadcast → mpsc → SSE `Body`
> - `is_acp_profile("reasonix" | "hermes")` 返回 true
> - SSE 桥接：`acp_event_to_sse()` 映射 7 种 ACP 事件到 SSE 格式
> - 修复：`payload.get("message")` 替代错误的 `payload.get("input")`
> - 修复：SSE 转发管道先于 prompt 建立（消除 race condition）

| **产出** | `acp_bridge.rs`（~260 行）+ `hermes_client.rs` 修改 |

### [x] Step 8：编辑全局 Router 添加 ACP 模块

> ✅ 完成。`lib.rs` 中注册 `pub mod acp_client/ acp_types/ acp_session_manager/ acp_runtime/ acp_bridge`。

### [x] Step 9：AppState 改造 — 加入 AcpRuntimeManager

> ✅ 完成。`app.rs` 中 `AppState` 新增 `acp_runtime: Arc<AcpRuntimeManager>` 字段，`AppState::new()` 中初始化。

### [x] Step 10：创建 Reasonix ACP wrapper 脚本

> ✅ 完成。`scripts/reasonix-acp-wrapper.mjs`（~400 行）。自包含 NDJSON JSON-RPC 2.0 服务器（无依赖 `AcpServer`）。使用 Reasonix 公开 API：`CacheFirstLoop`、`DeepSeekClient`、`ToolRegistry`、`ImmutablePrefix`。注册 `mnote.doc.fetch` 和 `mnote.doc.markdown_edit` 工具（工具调用 HTTP mnote-web tool API）。
>
> 关键修复：
> - `ev.role` 替代错误的 `ev.type`（CacheFirstLoop 使用 `role` 字段）
> - `ToolRegistry.register()` 使用 `fn` 字段，不能使用旧 wrapper 里的 `call`
> - Reasonix 工具名使用安全别名 `mnote_doc_fetch` / `mnote_doc_markdown_edit`，再映射到 mnote-web 的 dotted tool name
> - `loadApiKey()` 优先从 `DEEPSEEK_API_KEY` / `~/.reasonix/config.json` 读取 API key，并兼容 `~/.reasonix/config.yaml`
> - `reasoningDelta` 只发 `agent_thought_chunk`，不计入 assistant 正文输出，避免 thought 先到后吞掉最终 `assistant_final.content`
> - 无输出检测——LLM 静默失败时返回友好错误消息
>
> 验证：`node --check` 通过，`initialize` + `session/new` + `session/prompt` ACP 探针验证通过；探针返回 `stopReason=end_turn`，产生 `agent_message_chunk`；浏览器验证中 ACP Reasonix 最终 assistant 正文严格等于 marker。

| **产出** | `scripts/reasonix-acp-wrapper.mjs` |

### [x] Step 11：添加运行时选择器的前端支持

> ✅ 完成（Rust SSR 侧）。`layout.rs` 中：
> - Agent 标签页新增 `<select data-page-ai-acp-runtime>` 下拉框（3 选项：默认/ACP·Hermes/ACP·Reasonix）
> - 状态：`pageAiAcpRuntime` + `pageAiAcpRuntimes`（从 `/api/hermes/client/profiles` 的 `acpRuntimes` 字段加载）
> - `acpRuntime` 与 `profile` 分离：ACP Hermes 保留 Hermes profile 下拉，ACP Reasonix 隐藏 Hermes profile 下拉
> - ACP Hermes 复用 Hermes HTTP 的 `SOUL.md` / `USER.md` / `MEMORY.md` 可编辑 UI；ACP Reasonix 保留 runtime info 卡片
> - 页面 AI 会话历史短期真源为按文档隔离的 localStorage，保存 session 列表、messages、active profile、active ACP runtime
> - `message.delta` 进入正在输出的 assistant bubble；工具调用卡片使用 `<details>` 默认折叠展示参数/结果/trace
> - 标题栏更新为 `ACP · Reasonix` 或 `ACP · Hermes`
> - `create_run` 同时发送 `profile` 与 `acpRuntime`；后端用 `acpRuntime` 判断是否走 ACP，用 `profile` 选择 Hermes profile
>
> 注：Thought Delta 可视化渲染尚未实现，当前阶段允许页面不展示 thought；硬要求是 `agent_thought_chunk` 不能进入最终 assistant 正文。

| **产出** | `layout.rs` 修改 |

### [x] Step 12：profile 扩展 — 从 upstream URL 改为 runtime 配置

> ✅ 部分完成。
> - `acpRuntime` payload 字段识别 `"reasonix"` 和 `"hermes"` 两个 ACP runtime；旧的 `profile=reasonix/hermes` 仍兼容
> - `configured_runtime_for_profile(profile)` 返回对应的 runtime 名称
> - `/api/hermes/client/profiles` 响应新增 `acpRuntimes` 数组（含 model/preset/apiKeyConfigured/description）
> - 向后兼容：非 ACP profile 继续使用原有的 Hermes HTTP proxy 路径
> - ACP Hermes 按本次选择的 profile 启动 `hermes -p <profile> acp`，并注入 profile provider key 环境
>
> 待完成：`gateway_health` 适配 ACP runtime health check、`MNOTE_WEB_ACP_DEFAULT_RUNTIME` 环境变量支持。

| **产出** | `hermes_client.rs` 修改 |

### [x] Step 13：Hermes HTTP proxy 代码标为 deprecated

> ✅ 完成。`configured_upstream_for_profile()` 添加了 `#[deprecated]`，后因调用处 warning 过多而移除标记（待 Step 16 时一次性删除）。

### [x] Step 14：前端运行时切换验证 e2e

> ✅ 已通过。后端真实 SSE 探针与浏览器 UI 严格验证均通过。
>
> 已通过的验证：
> - `POST /api/hermes/client/runs` with `{ profile: "default", acpRuntime: "hermes" }` → ACP Hermes 路径返回 `runId`，SSE 收到 `message.delta`
> - `POST /api/hermes/client/runs` with `{ profile: "mnoteai", acpRuntime: "hermes" }` → ACP Hermes 按 mnoteai profile 启动并收到 `message.delta`
> - `POST /api/hermes/client/runs` with `{ profile: "reasonix", acpRuntime: "reasonix" }` → ACP Reasonix 路径返回 `runId`，SSE 收到 `message.delta`
> - `CacheFirstLoop.step("hi")` 直接调用 → LLM 返回正确中文回复
> - Wrapper `initialize` + `session/new` → 握手成功
> - Wrapper `session/prompt` → 返回 `stopReason=end_turn`，产生 `agent_message_chunk`
> - 浏览器验证 skill：创建临时页面，切换 `ACP · Hermes` + `mnoteai` profile，发送 marker prompt，最终 assistant 正文严格等于 `ACP_HERMES_BROWSER_OK_mp9pfqf6`
> - 浏览器验证 skill：切换 `ACP · Reasonix`，发送 marker prompt，最终 assistant 正文严格等于 `ACP_REASONIX_BROWSER_OK_mp9pfqf6`
> - 2026-05-17 追加：浏览器验证 skill 已扩展，检查 ACP Hermes memory 编辑器、Hermes 回复 reload 后历史恢复，并记录流式占位证据 `sawStreaming`
> - 2026-05-17 追加验证通过：ACP Hermes / ACP Reasonix 回复均严格等于 marker；两者 `sawStreaming=true`；Hermes 回复刷新后仍可见
>
> 证据：
> - `/mnt/Data1T/mnote/tmp/page-ai-acp-browser-UAYwyM/result.json`
> - `/mnt/Data1T/mnote/tmp/page-ai-acp-browser-UAYwyM/01-acp-hermes-reply.png`
> - `/mnt/Data1T/mnote/tmp/page-ai-acp-browser-UAYwyM/02-acp-reasonix-reply.png`
> - `/mnt/Data1T/mnote/tmp/page-ai-acp-browser-wcJzrj/result.json`
> - `/mnt/Data1T/mnote/tmp/page-ai-acp-browser-wcJzrj/00-acp-hermes-memory-ui.png`
> - `/mnt/Data1T/mnote/tmp/page-ai-acp-browser-wcJzrj/01-acp-hermes-reply.png`
> - `/mnt/Data1T/mnote/tmp/page-ai-acp-browser-wcJzrj/01b-acp-hermes-history-after-reload.png`
> - `/mnt/Data1T/mnote/tmp/page-ai-acp-browser-wcJzrj/02-acp-reasonix-reply.png`
>
> 仍未纳入本阶段：`thought.delta` 可视化渲染、`usage.updated` 展示。当前页面不显示 thought 是可接受行为；关键是 thought 不能误进 `message.delta`。

### [ ] Step 15：压力测试 — 确认同时多会话稳定性

> 待 Step 14 通过后执行。

### [ ] Step 16：退役旧的 Hermes HTTP proxy 代码

> 2026-05-18 已开始执行第一阶段退役：
>
> - 页面 AI 前端默认 `acpRuntime=reasonix`，不再以空 runtime 表示“默认 Hermes HTTP”；用户仍可在下拉中切换到 `ACP · Hermes`。
> - mnote-web 服务端默认把 `/api/hermes/client/runs` 的空 `acpRuntime` 归入 ACP 默认 runtime（默认 `reasonix`），避免继续落到 `configured_upstream_for_profile()` 的 HTTP proxy 分支。
> - `GET /api/hermes/client/gateway/health` 在 ACP 默认路径下返回 ACP transport 状态，不再探测 `8642/8644` HTTP gateway。
> - 旧 Hermes HTTP proxy 合同与 `/v1/runs` 默认主链设计稿已移入 `recycle/design/07-ai/retired-http-hermes/`，避免干扰后续 ACP 主线判断。
>
> 剩余工作：删除或进一步隔离 `hermes_client.rs` 内的 HTTP proxy 兼容分支；当前阶段只保留显式兼容开关，避免一次性删除影响 profile/memory/tools 管理能力。

### [ ] Step 17：基准测试 — Reasonix 缓存收益量化

> 待 Step 14 通过后执行。测试场景已设计，指标定义明确。

---

## 附录：文件依赖关系图

```
step-3  acp_client.rs
   │         depends on: none
   ▼
step-4  acp_types.rs                       step-6  acp_runtime.rs
   │         depends on: none                  │       depends on: serde_json
   ▼                                         ▼
step-5  acp_session_manager.rs  ←───────────┘
   │         depends on: acp_client, acp_types, acp_runtime
   ▼
step-7  hermes_client.rs 改造
   │         depends on: acp_session_manager
   ▼
step-8  mod.rs 添加模块
   │         depends on: step-3,4,5,6
   ▼
step-9  AppState 改造
   │         depends on: acp_runtime, acp_session_manager
   ▼
step-10 reasonix-acp-wrapper.mjs (独立, 可并行)
   │         depends on: npm reasonix 包
   ▼
step-11 AiAgentPanel.tsx 修改           step-12 profile 扩展
   │         depends on: step-7             │    depends on: hermes_client.rs
   ▼                                        ▼
step-13 标 deprecated                     (与 step-10 可并行)
   │
   ▼
step-14 e2e 验证
   │
   ├── step-15 压力测试
   │
   └── step-16 退役旧代码
       │
       └── step-17 基准测试
```

### 关键并行路径

```
step-3  ─→ step-5  ─→ step-7  ─→ step-11 ─→ step-14
                                           ↗
step-10 (Reasonix wrapper, 可并行)

step-4  ─→ step-5

step-6  ─→ step-5, step-9
```

### 每次 AI 执行前必须确认

1. `run_command pwd` → `/mnt/Data1T/mnote`（确认 cwd）
2. 写文件路径用相对路径 `rust/crates/...` 而非 `/mnt/Data1T/mnote/...`
3. 先 `search_content` / `read_file` 确认目标文件最新内容，避免 edit_file SEARCH 不匹配
4. 每个 step 完成后 `todo_write` 更新进度
