现在我已经全面理解了 sproutclaw 的 webui 扩展架构。让我整理一下这个架构：

**webui 扩展架构（位于 `.pi/agent/extensions/webui/`）**

1. **扩展入口** `index.ts`：注册 `/webui` 命令，控制 webui 服务的启动/停止/重载
2. **扩展后端** `backend/main.ts`：独立的 HTTP 服务器，内嵌 pi RPC 子进程
3. **前端** `frontend/`：React + Vite 构建的 SPA
4. **通信协议**：前端通过 HTTP API + SSE（EventSource）与后端通信；后端通过 JSONL over stdin/stdout 与 pi RPC 子进程通信

**三层架构**
```
浏览器(React SPA)
  ↓ HTTP API + SSE EventSource
Go/Node HTTP 后端 (backend/main.ts)
  ↓ spawn child process
  ↓ JSONL over stdin/stdout
pi --mode rpc (coding-agent)
  ↓ 内部 AgentSession
  ↓ LLM API calls
```

**后端 HTTP API 路由：**
- `POST /api/chat` - 发送 prompt
- `POST /api/steer` - 发送 steer
- `POST /api/follow-up` - 发送 follow-up
- `POST /api/bash` - 执行 bash
- `GET /api/events` - SSE 事件流
- `POST /api/messages` - 获取消息
- `POST /api/abort` - 中止
- `POST /api/abort-retry` - 中止重试
- `POST /api/new-session` - 新建会话
- `GET /api/sessions` - 会话列表
- `POST /api/sessions/history` - 会话历史
- `POST /api/sessions/delete` - 删除会话
- `POST /api/sessions/pin` - 置顶会话
- `POST /api/sessions/load` - 加载会话
- `POST /api/sessions/activate` - 激活会话
- `POST /api/sessions/name` - 重命名会话
- `GET /api/session-state` - 会话状态
- `GET /api/models` - 模型列表
- `POST /api/model` - 设置模型
- `POST /api/thinking` - 设置 thinking level
- `GET /api/commands` - 命令列表
- `GET /api/settings` - 设置列表
- `POST /api/settings/skills/toggle` - 切换技能
- `POST /api/settings/extensions/toggle` - 切换扩展
- `POST /api/settings/mcp/server/toggle` - 切换 MCP 服务器
- `POST /api/settings/mcp/tool/toggle` - 切换 MCP 工具
- `POST /api/settings/reload` - 重新加载
- `POST /api/settings/models-config` - 设置模型配置
- `POST /api/settings/system-prompt` - 设置系统提示
- `POST /api/settings/avatars` - 设置头像
- `GET /api/environment` - 环境信息
- `POST /api/extension-ui-response` - 扩展 UI 响应

**数据库** `backend/db/index.ts` - SQLite 数据库存储 webui 配置

**前端 React 组件**
- `ChatContext` - 管理聊天状态、SSE 连接、消息处理
- `App.tsx` - 应用入口
- 各种 UI 组件

现在用户问的是：如果把这个后端重构成 Go 后端该怎么做，效果如何。

**Go 重构方案：**

后端的核心工作其实非常明确：
1. HTTP 服务（静态文件 + API）
2. spawn Node.js 子进程 (`pi --mode rpc`)
3. JSONL 协议转发
4. SSE 事件广播
5. SQLite 数据库操作
6. 文件系统操作（session 管理、配置读写）

这个完全可以用 Go 来做。Go 的 `net/http` 做 HTTP 服务、`os/exec` 做子进程管理、自定义 `bufio.Scanner` 做 JSONL 解析（注意不能按 Unicode 行分隔符分割）、`github.com/mattn/go-sqlite3` 做 SQLite。

但要注意：
1. 前端是 React SPA，需要 `frontend/dist/` 静态文件服务
2. SSE 事件流需要正确处理
3. JSONL 严格按 `\n` 分割
4. 子进程生命周期管理

这个工作量是中等（2-3周），不是巨大的。因为核心 Agent 逻辑仍然在 Node.js 的 `pi --mode rpc` 中，Go 只是做 HTTP 桥接层。

让我写一份完整的分析报告。

write

# SproutClaw WebUI 扩展后端深度分析 & Go 重构方案

## 一、正确找到目标

你指的 WebUI 是 **`.pi/agent/extensions/webui/`**，这是 sproutclaw 的一个**本地扩展插件**，不是 `packages/web-ui/` 那个前端组件库。

## 二、WebUI 扩展的三层架构

```
┌─────────────────────────┐
│  浏览器 (React SPA)      │  ← frontend/src/  React + Vite
│  EventSource(/api/events) │
│  fetch(/api/chat)         │
└───────────┬─────────────┘
            │ HTTP + SSE
┌───────────▼─────────────┐
│  HTTP 后端 (Node.js)    │  ← backend/main.ts  + backend/http/server.ts
│  - 静态文件服务          │
│  - REST API 路由         │
│  - SSE 事件广播          │
│  - 子进程管理            │
└───────────┬─────────────┘
            │ spawn child_process
            │ JSONL over stdin/stdout
┌───────────▼─────────────┐
│  pi --mode rpc           │  ← packages/coding-agent/dist/cli.js
│  (Agent 引擎)            │
│  - 工具执行              │
│  - LLM 通信              │
│  - Session 管理          │
└─────────────────────────┘
```

## 三、扩展入口（`index.ts`）

注册 `/webui` 命令控制后端服务：

```
/webui on        → 启动 HTTP 服务（默认端口 19133）
/webui off       → 停止 HTTP 服务
/webui reload    → 重载服务
/webui on 8080   → 指定端口
```

启动流程：
1. 检查前端是否已构建（`frontend/dist/index.html`），未构建则自动 `npm run build:web`
2. 用 `tsx backend/main.ts --port 19133` 启动子进程
3. 探测端口确认启动成功
4. 支持 systemd 模式（注册 systemd service 保活）

## 四、后端核心（`backend/main.ts`）

```typescript
const piClient = createPiClient(paths, onExit);
const ctx: WebUiContext = {
  config: { paths, port },
  rpc: {
    sendCmd,           // 发送 RPC 命令并等待响应
    submitPrompt,      // 发送 prompt（异步，不等待响应）
    sendExtensionUiResponse,  // 回复扩展 UI 请求
    getRunSnapshot,    // 获取当前运行快照
    connectSseClient,  // 连接 SSE 客户端
    removeSseClient,   // 移除 SSE 客户端
  },
  db: webuiDb,        // SQLite 配置数据库
};
const server = createWebUiServer(ctx);
server.listen(port, "0.0.0.0");
```

### 4.1 RPC 客户端（`backend/rpc/pi-client.ts`）

核心逻辑：spawn `pi --mode rpc` 子进程，通过 **stdin/stdout JSONL** 通信。

```
spawn("node", ["packages/coding-agent/dist/cli.js", "--mode", "rpc"])
  ↓ stdin.write(JSON.stringify({type:"prompt", message:"Hello", id:"req_1"}) + "\n")
  ↓ stdout.on("data", chunk => 按 \n 分割 JSON 行)
```

**关键机制：**

1. **命令发送**（`sendCmd`）：写入 JSONL 行，分配 `req_` 前缀 ID，放入 `pending` Map，等待 `response` 类型消息匹配 ID
2. **Prompt 发送**（`submitPrompt`）：异步发送，不等待响应，Agent 事件通过 SSE 推送
3. **事件广播**（`broadcastSse`）：将 Agent 事件通过 SSE 推送给所有浏览器客户端
4. **事件缓冲**（`turnEventBuffer`）：在 `agent_start` 到 `agent_end` 之间缓冲所有事件，新连接时可 replay
5. **stderr 收集**：收集 pi 子进程的 stderr 用于调试

**事件类型缓冲列表：**
```
agent_start, agent_end, message_start, message_update, message_end,
tool_execution_start, tool_execution_update, tool_execution_end,
compaction_start, compaction_end, queue_update, auto_retry_start,
auto_retry_end, bash_update
```

### 4.2 HTTP 服务（`backend/http/server.ts`）

```typescript
createServer((req, res) => {
  if (pathname.startsWith("/api/")) applyCorsHeaders(req, res);
  if (GET && !pathname.startsWith("/api/")) serveStatic(publicDir, ...);
  if (handleChatRoute(req, res, ctx, pathname)) return;
  if (handleExtensionUiRoute(req, res, ctx, pathname)) return;
  if (handleSessionsRoute(req, res, ctx, pathname)) return;
  if (handleModelsRoute(req, res, ctx, pathname)) return;
  if (handleWebuiConfigRoute(req, res, ctx, pathname, url)) return;
  if (handleSettingsRoute(req, res, ctx, pathname)) return;
  if (handleCommandsRoute(req, res, ctx, pathname)) return;
  res.writeHead(404); res.end("Not found");
});
```

### 4.3 完整 HTTP API 路由表

| 路由 | 方法 | 功能 | 后端动作 |
|------|------|------|----------|
| `/api/chat` | POST | 发送消息 | `submitPrompt()` → SSE 推送 |
| `/api/steer` | POST | 发送 steer | `sendCmd({type:"steer"})` |
| `/api/follow-up` | POST | 发送 follow_up | `sendCmd({type:"follow_up"})` |
| `/api/bash` | POST | 执行 bash | `sendCmd({type:"bash"})` |
| `/api/events` | GET | SSE 事件流 | `connectSseClient(res)` |
| `/api/messages` | POST | 获取所有消息 | `sendCmd({type:"get_messages"})` |
| `/api/abort` | POST | 中止运行 | `sendCmd({type:"abort"})` |
| `/api/abort-retry` | POST | 中止重试 | `sendCmd({type:"abort_retry"})` |
| `/api/new-session` | POST | 新建会话 | `sendCmd({type:"new_session"})` |
| `/api/sessions` | GET | 会话列表 | 读取文件系统 sessionsDir |
| `/api/sessions/history` | POST | 会话历史 | 读取 JSONL 文件 |
| `/api/sessions/delete` | POST | 删除会话 | 删除 JSONL 文件 |
| `/api/sessions/pin` | POST | 置顶会话 | 写入 SQLite db |
| `/api/sessions/load` | POST | 加载会话 | `sendCmd({type:"switch_session"})` |
| `/api/sessions/activate` | POST | 激活会话 | `sendCmd({type:"switch_session"})` + `get_state` |
| `/api/sessions/name` | POST | 重命名会话 | 写入 SQLite db + `sendCmd({type:"set_session_name"})` |
| `/api/session-state` | GET | 当前会话状态 | `sendCmd({type:"get_state"})` |
| `/api/models` | GET | 可用模型列表 | `sendCmd({type:"get_available_models"})` |
| `/api/model` | POST | 设置模型 | `sendCmd({type:"set_model"})` |
| `/api/thinking` | POST | 设置 thinking level | `sendCmd({type:"set_thinking_level"})` |
| `/api/commands` | GET | 可用命令列表 | `sendCmd({type:"get_commands"})` |
| `/api/settings` | GET | 设置面板数据 | 读取 SQLite + 文件系统 |
| `/api/settings/skills/toggle` | POST | 切换技能 | 修改 settings.json + reload |
| `/api/settings/extensions/toggle` | POST | 切换扩展 | 修改 settings.json + reload |
| `/api/settings/mcp/server/toggle` | POST | 切换 MCP 服务器 | 修改 mcp.json + reload |
| `/api/settings/mcp/tool/toggle` | POST | 切换 MCP 工具 | 修改 mcp.json + reload |
| `/api/settings/reload` | POST | 重载 Agent | `sendCmd({type:"reload"})` |
| `/api/settings/models-config` | POST | 保存模型配置 | 写入 models.json + reload |
| `/api/settings/system-prompt` | POST | 保存系统提示 | 写入 AGENTS.md + reload |
| `/api/settings/avatars` | POST | 保存头像设置 | 写入 SQLite db |
| `/api/environment` | GET | 环境信息 | 读取 os/process 信息 |
| `/api/extension-ui-response` | POST | 回复扩展 UI | `sendExtensionUiResponse()` |

### 4.4 数据库（`backend/db/index.ts`）

SQLite 数据库，存储：
- 用户头像设置（`avatars` 表）
- 会话置顶状态（`pinned_sessions` 表）
- WebUI 配置

数据库路径：`~/.pi/agent/webui.db`

### 4.5 静态文件服务

- `frontend/dist/` 目录：Vite 构建的 React SPA
- `index.html` 作为 fallback（SPA 路由）

## 五、前端 React 应用

### 5.1 前端 API 层（`frontend/src/api/`）

```typescript
// 基础 fetch 封装
function apiFetch<T>(url: string, init?: RequestInit): Promise<T>
function apiPost<T>(url: string, body?: unknown): Promise<T>
function apiGet<T>(url: string): Promise<T>

// SSE 连接
const eventSource = new EventSource("/api/events");
eventSource.onmessage = (e) => handleSseEvent(JSON.parse(e.data));
```

### 5.2 ChatContext（核心状态管理）

`ChatContext` 是前端最复杂的部分，处理：

1. **SSE 连接管理**：自动重连、事件分发
2. **消息状态机**：处理 `agent_start` → `message_update` → `message_end` → `agent_end` 的完整事件流
3. **会话管理**：加载、切换、新建、删除、重命名会话
4. **模型管理**：获取模型列表、切换模型
5. **Thinking 管理**：切换 thinking level
6. **扩展 UI**：处理 `extension_ui_request` 弹窗
7. **Bash 命令**：发送 `!command` 到后端
8. **Slash 命令**：处理 `/copy`、`/new` 等命令
9. **消息去重**：处理 streaming 消息的重复问题
10. **Optimistic UI**：用户消息先显示，再等待后端确认

### 5.3 前端事件处理

```
connected               → 连接成功，重放 replay 事件
prompt_rejected         → prompt 被拒绝，移除 optimistic 用户消息
agent_start             → 设置 isStreaming=true
message_start (user)    → 确认用户消息（替换 optimistic）
message_update (assistant) → 更新流式 assistant 消息
message_end (assistant) → 最终化 assistant 消息
message_end (toolResult) → 显示工具结果
tool_execution_start    → 显示 🔧 工具调用标签
tool_execution_update   → 更新工具执行进度
tool_execution_end      → 显示工具执行结果（✅/❌）
compaction_start        → 显示压缩中
compaction_end          → 显示压缩摘要
queue_update            → 更新 pending 队列
auto_retry_start        → 显示重试倒计时
auto_retry_end          → 清除重试状态
extension_ui_request     → 显示扩展弹窗（select/confirm/input/editor）
extension_error         → 显示扩展错误
bash_update             → 更新 bash 输出
agent_end               → 设置 isStreaming=false，刷新会话列表
```

## 六、如果重构成 Go 后端

### 6.1 方案：Go 替代 Node.js HTTP 后端

**核心思路**：保留前端 React SPA 和 `pi --mode rpc` 子进程，只把中间的 HTTP 后端从 Node.js 换成 Go。

```
浏览器(React SPA)
  ↓ HTTP + SSE (完全不变)
Go HTTP 后端
  ↓ os/exec 启动子进程
  ↓ JSONL over stdin/stdout
pi --mode rpc (Node.js)
```

### 6.2 Go 后端需要实现什么

| 模块 | 复杂度 | 说明 |
|------|--------|------|
| **HTTP 服务** | 低 | `net/http` + `http.FileServer` 静态文件服务 |
| **REST API 路由** | 中 | 约 30 个路由，大部分是透传 RPC 命令 |
| **SSE 事件广播** | 中 | `text/event-stream` 格式，多客户端广播 |
| **JSONL 协议** | 中 | **关键**：必须用自定义分割器，按 `\n` 严格分割，不能用 `bufio.Scanner`（Unicode 行分隔符会破坏 JSON） |
| **子进程管理** | 中 | `os/exec` spawn `node dist/cli.js --mode rpc`，管理生命周期、stderr 收集、信号处理 |
| **命令队列** | 中 | `req_` 前缀 ID 分配，pending map 等待 response，超时处理 |
| **事件缓冲** | 中 | `agent_start` ~ `agent_end` 期间缓冲事件，新 SSE 连接时 replay |
| **SQLite** | 低 | `github.com/mattn/go-sqlite3` 或 `modernc.org/sqlite`，avatars 和 pinned_sessions 表 |
| **文件系统操作** | 低 | 读取 `~/.pi/agent/sessions/` 下的 JSONL 文件，解析会话列表和历史 |
| **CORS** | 低 | 简单的跨域头处理 |
| **Settings 管理** | 中 | 读写 `settings.json`、`models.json`、`mcp.json`、`AGENTS.md` |
| **Session 管理** | 中 | 扫描 sessions 目录、解析 JSONL、CRUD 操作 |

### 6.3 关键代码设计

#### JSONL 解析（核心难点）

```go
// 不能用 bufio.Scanner，因为 JSON 内部可能包含 Unicode 行分隔符
// 必须严格按 \n 分割
func readJsonlLine(reader io.Reader) ([]byte, error) {
    var buf bytes.Buffer
    b := make([]byte, 1)
    for {
        n, err := reader.Read(b)
        if n > 0 {
            if b[0] == '\n' {
                return buf.Bytes(), nil
            }
            buf.WriteByte(b[0])
        }
        if err != nil {
            if buf.Len() > 0 {
                return buf.Bytes(), nil
            }
            return nil, err
        }
    }
}
```

#### RPC 客户端

```go
type PiClient struct {
    cmd        *exec.Cmd
    stdin      io.WriteCloser
    stdout     io.ReadCloser
    stderr     io.ReadCloser
    pending    map[string]chan rpcResponse
    sseClients map[http.ResponseWriter]bool
    eventBuffer []map[string]interface{}
    isStreaming bool
    mu         sync.Mutex
    reqId      int
}

func (c *PiClient) SendCmd(cmd map[string]interface{}) (map[string]interface{}, error) {
    c.mu.Lock()
    id := fmt.Sprintf("req_%d", c.reqId)
    c.reqId++
    ch := make(chan rpcResponse, 1)
    c.pending[id] = ch
    c.mu.Unlock()

    cmd["id"] = id
    line, _ := json.Marshal(cmd)
    c.stdin.Write(append(line, '\n'))

    select {
    case resp := <-ch:
        return resp.data, resp.err
    case <-time.After(60 * time.Second):
        return nil, fmt.Errorf("timeout")
    }
}

func (c *PiClient) BroadcastSse(msg map[string]interface{}) {
    data := fmt.Sprintf("data: %s\n\n", mustJson(msg))
    for client := range c.sseClients {
        client.Write([]byte(data))
    }
}
```

#### SSE 路由

```go
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")
    w.WriteHeader(200)

    // 发送连接成功 + replay 缓冲事件
    snapshot := s.piClient.GetRunSnapshot()
    fmt.Fprintf(w, "data: %s\n\n", mustJson(map[string]interface{}{
        "type": "connected",
        "isStreaming": snapshot.IsStreaming,
        "replay": snapshot.Replay,
    }))
    w.(http.Flusher).Flush()

    s.piClient.AddSseClient(w)
    defer s.piClient.RemoveSseClient(w)

    // 等待客户端断开
    <-r.Context().Done()
}
```

### 6.4 工作量估算

| 模块 | 预估代码量 | 预估时间 |
|------|-----------|----------|
| 项目结构 + 配置 | 200 行 | 0.5 天 |
| HTTP 服务 + 路由 | 300 行 | 1 天 |
| RPC 客户端（JSONL + SSE） | 400 行 | 2 天 |
| API 路由实现（30 个） | 600 行 | 2 天 |
| SQLite 数据库 | 200 行 | 0.5 天 |
| Session 文件管理 | 300 行 | 1 天 |
| Settings 文件管理 | 200 行 | 0.5 天 |
| 静态文件服务 | 50 行 | 0.2 天 |
| 测试 + 调试 | - | 2 天 |
| **总计** | **~2250 行** | **~10 天** |

### 6.5 效果评估

| 维度 | 效果 |
|------|------|
| **性能** | 显著提升。Go 的 HTTP 服务比 Node.js 轻量，内存占用低（约 5-10MB vs 50-100MB），启动快 |
| **部署** | 单二进制文件，无需 tsx/Node.js 运行时（但 pi RPC 子进程仍需 Node.js） |
| **并发** | Go 的 goroutine 处理 SSE 客户端比 Node.js 更可靠，不易出现 event loop 阻塞 |
| **稳定性** | 子进程崩溃时 Go 可以更快检测和重启 |
| **前端兼容性** | 100% 兼容，前端代码完全不需要改动 |
| **功能完整性** | 100% 保留，所有 Agent 能力通过 pi RPC 子进程继承 |
| **扩展生态** | 完全保留，TypeScript 扩展仍然通过 pi RPC 运行 |
| **开发成本** | 中等（~2 周），主要是 JSONL 协议和 SSE 广播的精细实现 |
| **维护成本** | 低，Go 后端是纯桥接层，核心逻辑在 pi RPC 中 |
| **风险** | JSONL 分割严格性、SSE 事件顺序、子进程信号转发 |

### 6.6 需要特别注意的点

1. **JSONL 严格分割**：绝不能使用 `bufio.Scanner`，必须逐字节扫描找 `\n`
2. **SSE 格式**：每个事件必须是 `data: {json}\n\n`，双换行
3. **子进程信号**：`SIGTERM` 要转发给 pi 子进程，避免僵尸进程
4. **事件缓冲**：`agent_start` 到 `agent_end` 之间的事件必须缓冲，新 SSE 连接时 replay
5. **请求 ID 匹配**：`pending` map 的并发安全，超时清理
6. **前端构建**：`frontend/dist/` 仍需由 Vite 构建，Go 只负责 serve 静态文件

## 七、总结

| 问题 | 答案 |
|------|------|
| WebUI 后端是什么？ | `.pi/agent/extensions/webui/backend/` 下的 Node.js HTTP 服务，spawn `pi --mode rpc` 子进程 |
| 前端怎么通信？ | HTTP REST API + SSE EventSource |
| 后端怎么通信？ | JSONL over stdin/stdout |
| 能否重构成 Go？ | **完全可以**，Go 替代 HTTP 桥接层，保留 pi RPC 子进程 |
| 工作量 | 约 2 周，~2250 行 Go 代码 |
| 效果 | 性能提升、内存降低、部署简化、单二进制 |
| 前端需改动？ | **不需要**，API 协议完全一致 |
| pi RPC 需改动？ | **不需要**，它是被 spawn 的子进程，Go 只替代了 HTTP 桥接 |


Successfully wrote 12728 bytes to /shumengya/docs/sproutclaw-webui-backend-analysis-v2.md


用户没有进一步的消息，我已经完成了深度分析并生成了报告。让我给用户一个简洁的总结。

已纠正目标！找到的是 **`.pi/agent/extensions/webui/`**，这才是 sproutclaw 的 WebUI 扩展插件。

完整分析报告已保存到 `/shumengya/docs/sproutclaw-webui-backend-analysis-v2.md`。

## 核心架构（三层）

```
浏览器(React SPA)
  ↓ HTTP REST API + SSE EventSource
Go/Node HTTP 后端（backend/main.ts）
  ↓ spawn child_process
  ↓ JSONL over stdin/stdout
pi --mode rpc（Agent 引擎）
```

## 后端做了什么

- **HTTP 服务**：30+ 个 REST API 路由 + SSE 事件流
- **RPC 桥接**：spawn `pi --mode rpc`，通过 JSONL 发送命令/接收事件
- **事件广播**：将 Agent 事件通过 SSE 推送给所有浏览器客户端
- **数据管理**：SQLite（头像、置顶）+ 文件系统（sessions JSONL、settings.json、models.json）

## 前端通信方式

前端用 `EventSource("/api/events")` 接收 SSE 事件流，用 `fetch` 调用 REST API。事件类型包括：
- `agent_start/end` → 运行状态
- `message_start/update/end` → 消息流
- `tool_execution_start/update/end` → 工具执行
- `compaction_start/end` → 上下文压缩
- `extension_ui_request` → 扩展弹窗
- `bash_update` → Bash 输出

## Go 重构方案

**完全可行，且效果优秀：**

- 工作量：约 **2 周**，~2250 行 Go 代码
- 前端：**0 改动**，API 协议完全一致
- pi RPC 子进程：**0 改动**，Go 只是替代了 HTTP 桥接层

**Go 需要实现：**
1. `net/http` 服务 + 静态文件 serve
2. `os/exec` 子进程管理（spawn `pi --mode rpc`）
3. **JSONL 协议**（关键：逐字节按 `\n` 分割，不能用 `bufio.Scanner`）
4. SSE 多客户端广播 + 事件缓冲 replay
5. SQLite（avatars + pinned_sessions）
6. 文件系统操作（sessions 目录、settings.json 等）

**效果：**
- 内存占用从 50-100MB 降到 5-10MB
- 单二进制部署，启动更快
- goroutine 处理 SSE 并发更可靠

如果你想开始重构，我可以直接帮你写 Go 的 `main.go` 骨架，包括 `PiClient`（RPC 客户端）、`Server`（HTTP 服务）、和 `EventBroadcaster`（SSE 广播）。


目前我就觉得webui太大了不应该只作为sproutclaw的一个插件扩展而是单独拿出来标准的互联网前后端项目后端启动一个sproutclaw agent所以 目前的你给我讲一下目前的后端与sproutclaw的RPC通信详细过程


用户想要详细理解当前 WebUI 后端与 sproutclaw 的 RPC 通信过程。我需要深入分析 `backend/rpc/pi-client.ts` 这个核心文件，以及相关的 JSONL 协议、事件流、命令响应机制等。

让我再仔细读一下 pi-client.ts 和相关的 jsonl 处理文件，以及 rpc-types.ts，来给出最详细的通信过程说明。


import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { ServerResponse } from "node:http";
import { resolvePiRpcLaunch, type WebUiPaths } from "../config/paths.ts";
import type { RunSnapshot, SendCmd, SubmitPromptOptions } from "../types/context.ts";

export interface PiClient {
	sendCmd: SendCmd;
	submitPrompt: (options: SubmitPromptOptions) => void;
	sendExtensionUiResponse: (id: string, response: Record<string, unknown>) => void;
	getRunSnapshot: () => RunSnapshot;
	connectSseClient: (res: ServerResponse) => void;
	removeSseClient: (res: ServerResponse) => void;
	child: ChildProcessWithoutNullStreams;
}

const BUFFERED_EVENT_TYPES = new Set([
	"agent_start",
	"agent_end",
	"message_start",
	"message_update",
	"message_end",
	"tool_execution_start",
	"tool_execution_update",
	"tool_execution_end",
	"compaction_start",
	"compaction_end",
	"queue_update",
	"auto_retry_start",
	"auto_retry_end",
	"bash_update",
]);

const DEFAULT_CMD_TIMEOUT_MS = 60_000;
const PROMPT_PREFLIGHT_TIMEOUT_MS = 30_000;
const PI_STDERR_BUFFER_MAX = 8192;

function formatSpawnArgs(args: string[]): string {
	return args.map((arg) => JSON.stringify(arg)).join(" ");
}

function summarizeStderr(buffer: string): string {
	const trimmed = buffer.trim();
	if (!trimmed) return "(无 stderr 输出)";
	const lines = trimmed.split(/\r?\n/).filter(Boolean);
	return lines.slice(-8).join("\n");
}

export function createPiClient(paths: WebUiPaths, onExit: (code: number | null) => void): PiClient {
	const piLaunch = resolvePiRpcLaunch(paths);
	console.log(
		`[webui] 启动 pi RPC (${piLaunch.mode}): ${piLaunch.command} ${formatSpawnArgs(piLaunch.args)}`,
	);

	const pi = spawn(piLaunch.command, piLaunch.args, {
		cwd: paths.repoRoot,
		stdio: ["pipe", "pipe", "pipe"],
		env: {
			...process.env,
			PI_CODING_AGENT_DIR: paths.agentDir,
		},
	});

	// Keep stdin pipe open; closing it on Windows can trigger RPC shutdown via stdin "end".
	pi.stdin.write("");

	let stderrBuffer = "";
	pi.stderr.on("data", (data: Buffer) => {
		const chunk = data.toString();
		stderrBuffer = (stderrBuffer + chunk).slice(-PI_STDERR_BUFFER_MAX);
		process.stderr.write(`[pi] ${data}`);
	});
	pi.on("exit", (code) => {
		console.log(`[webui] pi 退出, code=${code}`);
		if (code !== 0 && code !== null) {
			console.error(`[webui] pi RPC 启动失败 (code=${code}):\n${summarizeStderr(stderrBuffer)}`);
		}
		onExit(code);
	});

	let buffer = "";
	const pending = new Map<string, { resolve: (value: any) => void; reject: (error: Error) => void }>();
	const sseClients = new Set<ServerResponse>();
	let reqId = 0;

	let isStreaming = false;
	let sessionFile: string | undefined;
	let turnEventBuffer: Record<string, unknown>[] = [];

	function getRunSnapshot(): RunSnapshot {
		return {
			isStreaming,
			sessionFile,
			replay: [...turnEventBuffer],
		};
	}

	function broadcastSse(msg: Record<string, unknown>): void {
		const data = `data: ${JSON.stringify(msg)}\n\n`;
		for (const res of sseClients) res.write(data);
	}

	function trackAgentEvent(msg: Record<string, unknown>): void {
		const type = msg.type;
		if (typeof type !== "string") return;

		if (type === "agent_start") {
			isStreaming = true;
			turnEventBuffer = [msg];
			return;
		}

		if (type === "agent_end") {
			turnEventBuffer.push(msg);
			isStreaming = false;
			turnEventBuffer = [];
			return;
		}

		if (isStreaming && BUFFERED_EVENT_TYPES.has(type)) {
			turnEventBuffer.push(msg);
		}
	}

	function registerPending(
		id: string,
		handlers: { resolve: (value: any) => void; reject: (error: Error) => void },
		timeoutMs: number,
		timeoutLabel: string,
	): void {
		pending.set(id, handlers);
		setTimeout(() => {
			if (!pending.has(id)) return;
			pending.delete(id);
			handlers.reject(new Error(`命令超时: ${timeoutLabel}`));
		}, timeoutMs);
	}

	function onLine(line: string): void {
		if (!line.trim()) return;
		try {
			const msg = JSON.parse(line) as Record<string, unknown>;
			if (msg.type === "response" && msg.id && pending.has(String(msg.id))) {
				const id = String(msg.id);
				const p = pending.get(id)!;
				pending.delete(id);
				if (msg.command === "prompt" && msg.success === false) {
					broadcastSse({
						type: "prompt_rejected",
						error: String(msg.error || "prompt rejected"),
					});
				}
				if (msg.command === "get_state" && msg.success === true) {
					const data = msg.data as { sessionFile?: string } | undefined;
					if (data?.sessionFile) {
						sessionFile = data.sessionFile;
					}
				}
				p.resolve(msg);
				return;
			}
			trackAgentEvent(msg);
			broadcastSse(msg);
		} catch {
			/* ignore non-JSON lines */
		}
	}

	pi.stdout.on("data", (chunk: Buffer) => {
		buffer += chunk.toString();
		const lines = buffer.split("\n");
		buffer = lines.pop() || "";
		for (const line of lines) onLine(line);
	});

	const sendCmd: SendCmd = (command) =>
		new Promise((resolvePromise, reject) => {
			const id = `req_${++reqId}`;
			const line = JSON.stringify({ ...command, id }) + "\n";
			registerPending(
				id,
				{ resolve: resolvePromise, reject },
				DEFAULT_CMD_TIMEOUT_MS,
				String(command.type),
			);
			pi.stdin.write(line);
		});

	function submitPrompt(options: SubmitPromptOptions): void {
		const id = `req_${++reqId}`;
		const payload: Record<string, unknown> = {
			type: "prompt",
			message: options.message,
			images: options.images,
			id,
		};
		if (options.streamingBehavior) {
			payload.streamingBehavior = options.streamingBehavior;
		}
		const line = JSON.stringify(payload) + "\n";
		registerPending(
			id,
			{
				resolve: () => {
					/* preflight success: agent events arrive via SSE */
				},
				reject: (err) => {
					broadcastSse({ type: "prompt_rejected", error: err.message });
				},
			},
			PROMPT_PREFLIGHT_TIMEOUT_MS,
			"prompt",
		);
		pi.stdin.write(line);
	}

	function sendExtensionUiResponse(id: string, response: Record<string, unknown>): void {
		const line = JSON.stringify({ type: "extension_ui_response", id, ...response }) + "\n";
		pi.stdin.write(line);
	}

	function connectSseClient(res: ServerResponse): void {
		const snapshot = getRunSnapshot();
		res.write(
			`data: ${JSON.stringify({
				type: "connected",
				isStreaming: snapshot.isStreaming,
				replay: snapshot.replay,
			})}\n\n`,
		);
		sseClients.add(res);
	}

	return {
		sendCmd,
		submitPrompt,
		sendExtensionUiResponse,
		getRunSnapshot,
		connectSseClient,
		removeSseClient: (res) => sseClients.delete(res),
		child: pi,
	};
}


import type { Readable } from "node:stream";
import { StringDecoder } from "node:string_decoder";

/**
 * Serialize a single strict JSONL record.
 *
 * Framing is LF-only. Payload strings may contain other Unicode separators such as
 * U+2028 and U+2029. Clients must split records on `\n` only.
 */
export function serializeJsonLine(value: unknown): string {
	return `${JSON.stringify(value)}\n`;
}

/**
 * Attach an LF-only JSONL reader to a stream.
 *
 * This intentionally does not use Node readline. Readline splits on additional
 * Unicode separators that are valid inside JSON strings and therefore does not
 * implement strict JSONL framing.
 */
export function attachJsonlLineReader(stream: Readable, onLine: (line: string) => void): () => void {
	const decoder = new StringDecoder("utf8");
	let buffer = "";

	const emitLine = (line: string) => {
		onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
	};

	const onData = (chunk: string | Buffer) => {
		buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);

		while (true) {
			const newlineIndex = buffer.indexOf("\n");
			if (newlineIndex === -1) {
				return;
			}

			emitLine(buffer.slice(0, newlineIndex));
			buffer = buffer.slice(newlineIndex + 1);
		}
	};

	const onEnd = () => {
		buffer += decoder.end();
		if (buffer.length > 0) {
			emitLine(buffer);
			buffer = "";
		}
	};

	stream.on("data", onData);
	stream.on("end", onEnd);

	return () => {
		stream.off("data", onData);
		stream.off("end", onEnd);
	};
}


/**
 * RPC protocol types for headless operation.
 *
 * Commands are sent as JSON lines on stdin.
 * Responses and events are emitted as JSON lines on stdout.
 */

import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { ImageContent, Model } from "@earendil-works/pi-ai";
import type { SessionStats } from "../../core/agent-session.ts";
import type { BashResult } from "../../core/bash-executor.ts";
import type { CompactionResult } from "../../core/compaction/index.ts";
import type { SourceInfo } from "../../core/source-info.ts";

// ============================================================================
// RPC Commands (stdin)
// ============================================================================

export type RpcCommand =
	// Prompting
	| { id?: string; type: "prompt"; message: string; images?: ImageContent[]; streamingBehavior?: "steer" | "followUp" }
	| { id?: string; type: "steer"; message: string; images?: ImageContent[] }
	| { id?: string; type: "follow_up"; message: string; images?: ImageContent[] }
	| { id?: string; type: "abort" }
	| { id?: string; type: "new_session"; parentSession?: string }

	// State
	| { id?: string; type: "get_state" }
	| { id?: string; type: "reload" }

	// Model
	| { id?: string; type: "set_model"; provider: string; modelId: string }
	| { id?: string; type: "cycle_model" }
	| { id?: string; type: "get_available_models" }

	// Thinking
	| { id?: string; type: "set_thinking_level"; level: ThinkingLevel }
	| { id?: string; type: "cycle_thinking_level" }

	// Queue modes
	| { id?: string; type: "set_steering_mode"; mode: "all" | "one-at-a-time" }
	| { id?: string; type: "set_follow_up_mode"; mode: "all" | "one-at-a-time" }

	// Compaction
	| { id?: string; type: "compact"; customInstructions?: string }
	| { id?: string; type: "set_auto_compaction"; enabled: boolean }

	// Retry
	| { id?: string; type: "set_auto_retry"; enabled: boolean }
	| { id?: string; type: "abort_retry" }

	// Bash
	| { id?: string; type: "bash"; command: string; excludeFromContext?: boolean }
	| { id?: string; type: "abort_bash" }

	// Session
	| { id?: string; type: "get_session_stats" }
	| { id?: string; type: "export_html"; outputPath?: string }
	| { id?: string; type: "switch_session"; sessionPath: string; cwdOverride?: string }
	| { id?: string; type: "fork"; entryId: string }
	| { id?: string; type: "clone" }
	| { id?: string; type: "get_fork_messages" }
	| { id?: string; type: "get_last_assistant_text" }
	| { id?: string; type: "set_session_name"; name: string }

	// Messages
	| { id?: string; type: "get_messages" }

	// Commands (available for invocation via prompt)
	| { id?: string; type: "get_commands" }
	| { id?: string; type: "get_extensions" };

// ============================================================================
// RPC Slash Command (for get_commands response)
// ============================================================================

/** A command available for invocation via prompt */
export interface RpcSlashCommand {
	/** Command name (without leading slash) */
	name: string;
	/** Human-readable description */
	description?: string;
	/** What kind of command this is */
	source: "extension" | "prompt" | "skill";
	/** Source metadata for the owning resource */
	sourceInfo: SourceInfo;
}

/** A loaded extension with basic inventory metadata */
export interface RpcExtensionInfo {
	name: string;
	path: string;
	resolvedPath: string;
	sourceInfo: SourceInfo;
	commands: string[];
	tools: string[];
	flags: string[];
	shortcuts: string[];
	handlers: string[];
}

// ============================================================================
// RPC State
// ============================================================================

export interface RpcSessionState {
	model?: Model<any>;
	thinkingLevel: ThinkingLevel;
	isStreaming: boolean;
	isCompacting: boolean;
	steeringMode: "all" | "one-at-a-time";
	followUpMode: "all" | "one-at-a-time";
	sessionFile?: string;
	sessionId: string;
	sessionName?: string;
	autoCompactionEnabled: boolean;
	messageCount: number;
	pendingMessageCount: number;
	/** Mirrors {@link AgentSession.turnIndex} (ticks every turn_end, resets agent_start). */
	turnIndex: number;
	stats: SessionStats;
}

// ============================================================================
// RPC Responses (stdout)
// ============================================================================

// Success responses with data
export type RpcResponse =
	// Prompting (async - events follow)
	| { id?: string; type: "response"; command: "prompt"; success: true }
	| { id?: string; type: "response"; command: "steer"; success: true }
	| { id?: string; type: "response"; command: "follow_up"; success: true }
	| { id?: string; type: "response"; command: "abort"; success: true }
	| { id?: string; type: "response"; command: "new_session"; success: true; data: { cancelled: boolean } }

	// State
	| { id?: string; type: "response"; command: "get_state"; success: true; data: RpcSessionState }
	| { id?: string; type: "response"; command: "reload"; success: true }

	// Model
	| {
			id?: string;
			type: "response";
			command: "set_model";
			success: true;
			data: Model<any>;
	  }
	| {
			id?: string;
			type: "response";
			command: "cycle_model";
			success: true;
			data: { model: Model<any>; thinkingLevel: ThinkingLevel; isScoped: boolean } | null;
	  }
	| {
			id?: string;
			type: "response";
			command: "get_available_models";
			success: true;
			data: { models: Model<any>[] };
	  }

	// Thinking
	| { id?: string; type: "response"; command: "set_thinking_level"; success: true }
	| {
			id?: string;
			type: "response";
			command: "cycle_thinking_level";
			success: true;
			data: { level: ThinkingLevel } | null;
	  }

	// Queue modes
	| { id?: string; type: "response"; command: "set_steering_mode"; success: true }
	| { id?: string; type: "response"; command: "set_follow_up_mode"; success: true }

	// Compaction
	| { id?: string; type: "response"; command: "compact"; success: true; data: CompactionResult }
	| { id?: string; type: "response"; command: "set_auto_compaction"; success: true }

	// Retry
	| { id?: string; type: "response"; command: "set_auto_retry"; success: true }
	| { id?: string; type: "response"; command: "abort_retry"; success: true }

	// Bash
	| { id?: string; type: "response"; command: "bash"; success: true; data: BashResult }
	| { id?: string; type: "response"; command: "abort_bash"; success: true }

	// Session
	| { id?: string; type: "response"; command: "get_session_stats"; success: true; data: SessionStats }
	| { id?: string; type: "response"; command: "export_html"; success: true; data: { path: string } }
	| { id?: string; type: "response"; command: "switch_session"; success: true; data: { cancelled: boolean } }
	| { id?: string; type: "response"; command: "fork"; success: true; data: { text: string; cancelled: boolean } }
	| { id?: string; type: "response"; command: "clone"; success: true; data: { cancelled: boolean } }
	| {
			id?: string;
			type: "response";
			command: "get_fork_messages";
			success: true;
			data: { messages: Array<{ entryId: string; text: string }> };
	  }
	| {
			id?: string;
			type: "response";
			command: "get_last_assistant_text";
			success: true;
			data: { text: string | null };
	  }
	| { id?: string; type: "response"; command: "set_session_name"; success: true }

	// Messages
	| { id?: string; type: "response"; command: "get_messages"; success: true; data: { messages: AgentMessage[] } }

	// Commands
	| {
			id?: string;
			type: "response";
			command: "get_commands";
			success: true;
			data: { commands: RpcSlashCommand[] };
	  }
	| {
			id?: string;
			type: "response";
			command: "get_extensions";
			success: true;
			data: { extensions: RpcExtensionInfo[]; errors: Array<{ path: string; error: string }> };
	  }

	// Error response (any command can fail)
	| { id?: string; type: "response"; command: string; success: false; error: string };

// ============================================================================
// Extension UI Events (stdout)
// ============================================================================

/** Emitted when an extension needs user input */
export type RpcExtensionUIRequest =
	| { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[]; timeout?: number }
	| { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string; timeout?: number }
	| {
			type: "extension_ui_request";
			id: string;
			method: "input";
			title: string;
			placeholder?: string;
			timeout?: number;
	  }
	| { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string }
	| {
			type: "extension_ui_request";
			id: string;
			method: "notify";
			message: string;
			notifyType?: "info" | "warning" | "error";
	  }
	| {
			type: "extension_ui_request";
			id: string;
			method: "setStatus";
			statusKey: string;
			statusText: string | undefined;
	  }
	| {
			type: "extension_ui_request";
			id: string;
			method: "setWidget";
			widgetKey: string;
			widgetLines: string[] | undefined;
			widgetPlacement?: "aboveEditor" | "belowEditor";
	  }
	| { type: "extension_ui_request"; id: string; method: "setTitle"; title: string }
	| { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string };

// ============================================================================
// Extension UI Commands (stdin)
// ============================================================================

/** Response to an extension UI request */
export type RpcExtensionUIResponse =
	| { type: "extension_ui_response"; id: string; value: string }
	| { type: "extension_ui_response"; id: string; confirmed: boolean }
	| { type: "extension_ui_response"; id: string; cancelled: true };

// ============================================================================
// Helper type for extracting command types
// ============================================================================

export type RpcCommandType = RpcCommand["type"];


用户想要一个极其详细的、深入的RPC通信过程讲解。我需要从以下几个维度展开：

1. **启动阶段**：后端如何spawn子进程
2. **数据通道**：stdin/stdout/stderr 三个管道的作用
3. **JSONL协议**：严格的分割规则（为什么不能用通用行读取器）
4. **命令发送**：`sendCmd` vs `submitPrompt` 的区别
5. **响应匹配**：pending map + ID 匹配机制
6. **事件流**：stdout 上的两种数据类型（response vs event）
7. **事件缓冲**：turnEventBuffer 的replay机制
8. **扩展UI**：双向请求-响应
9. **错误处理**：stderr 收集、超时、子进程崩溃
10. **完整的通信时序图**

让我用中文写一份非常详细的文档。

# SproutClaw WebUI 后端与 RPC 通信深度详解

## 一、总体架构

WebUI 后端与 SproutClaw 的通信是**单向主从 + 双向事件**的混合模型：

```
┌─────────────────┐
│   WebUI 后端     │  ← 主控方（Master）
│  (Node.js/Go)   │
│                 │
│  stdin  ───────┼──────►  ┌─────────────┐
│  stdout ◄──────┼───────  │  pi --mode  │
│  stderr ◄──────┼───────  │     rpc     │  ← 从属方（Slave）
│                 │         └─────────────┘
│  SSE ◄─────────┼───────  浏览器
└─────────────────┘
```

**核心原则**：
- 后端是**唯一的发起者**（通过 stdin 发送命令）
- pi 是**唯一的响应者**（通过 stdout 输出响应和事件）
- stderr 仅用于**调试日志**，不承载协议数据
- 所有通信都是**异步非阻塞**的

---

## 二、启动阶段：子进程 Spawn

### 2.1 启动命令

```typescript
const piLaunch = resolvePiRpcLaunch(paths);
// piLaunch = { command: "node", args: ["dist/cli.js", "--mode", "rpc"], mode: "dist" }

const pi = spawn(piLaunch.command, piLaunch.args, {
    cwd: paths.repoRoot,
    stdio: ["pipe", "pipe", "pipe"],  // [stdin, stdout, stderr]
    env: {
        ...process.env,
        PI_CODING_AGENT_DIR: paths.agentDir,
    },
});
```

**stdio 配置** `"pipe"` 表示创建管道，后端和 pi 进程之间有三个独立的管道：

| 管道 | 方向 | 后端操作 | pi 操作 | 用途 |
|------|------|----------|---------|------|
| stdin | 后端 → pi | `write()` | `read()` | 发送命令 |
| stdout | pi → 后端 | `read()` | `write()` | 接收响应和事件 |
| stderr | pi → 后端 | `read()` | `write()` | 调试日志 |

### 2.2 启动后立即做的事情

```typescript
// 1. 保持 stdin 管道打开（Windows 上关闭 stdin 会触发 pi 的 stdin "end" 事件导致 shutdown）
pi.stdin.write("");

// 2. 监听 stderr
pi.stderr.on("data", (data: Buffer) => {
    stderrBuffer = (stderrBuffer + data.toString()).slice(-8192);
    process.stderr.write(`[pi] ${data}`);  // 转发到后端 stderr
});

// 3. 监听进程退出
pi.on("exit", (code) => {
    onExit(code);  // 后端收到后通常会关闭整个 WebUI 服务
});
```

---

## 三、JSONL 协议：最底层的通信格式

### 3.1 协议定义

JSONL（JSON Lines）协议规则：**严格的 LF（`\n`）分隔**。

```
{JSON对象1}\n{JSON对象2}\n{JSON对象3}\n
```

**绝对不能用通用行读取器**（如 Node.js 的 `readline` 或 Go 的 `bufio.Scanner`），因为它们会把 Unicode 行分隔符 `U+2028`（Line Separator）和 `U+2029`（Paragraph Separator）也当作换行符，而这些字符在 JSON 字符串中是合法的。

### 3.2 后端的分割实现

```typescript
// 正确的实现：逐字节扫描，只认 \n
pi.stdout.on("data", (chunk: Buffer) => {
    buffer += chunk.toString();
    const lines = buffer.split("\n");
    buffer = lines.pop() || "";  // 最后一行可能不完整，保留到下一次
    for (const line of lines) onLine(line);
});
```

**关键细节**：
- `buffer` 是跨 chunk 的累积缓冲区
- 每次 `split("\n")` 后，`pop()` 取出最后一行（可能是未完成的）
- 如果 stdout 被 flush，`buffer` 最终会被清空

### 3.3 pi 端的分割实现（sproutclaw 源码）

```typescript
// packages/coding-agent/src/modes/rpc/jsonl.ts

export function serializeJsonLine(value: unknown): string {
    return `${JSON.stringify(value)}\n`;
}

export function attachJsonlLineReader(stream: Readable, onLine: (line: string) => void): () => void {
    const decoder = new StringDecoder("utf8");
    let buffer = "";

    const onData = (chunk: string | Buffer) => {
        buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);
        while (true) {
            const newlineIndex = buffer.indexOf("\n");
            if (newlineIndex === -1) return;
            const line = buffer.slice(0, newlineIndex);
            // 处理 \r\n 兼容
            onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
            buffer = buffer.slice(newlineIndex + 1);
        }
    };

    stream.on("data", onData);
    return () => { stream.off("data", onData); };
}
```

**注意**：pi 端也**不使用** `readline`，而是用 `StringDecoder` 处理 UTF-8 分片，用 `indexOf("\n")` 严格分割。

---

## 四、stdout 上的两种数据：Response vs Event

pi 通过 stdout 输出的每一行 JSON 都属于两种类型之一：

### 4.1 Response（响应）

```json
{"id": "req_1", "type": "response", "command": "get_state", "success": true, "data": {...}}
{"id": "req_2", "type": "response", "command": "prompt", "success": true}
{"id": "req_3", "type": "response", "command": "set_model", "success": false, "error": "Model not found"}
```

**特征**：
- 有 `id` 字段（匹配发送时的命令 ID）
- `type: "response"`
- 有 `command` 字段（对应命令类型）
- `success: true/false`
- 可选 `data` 或 `error`

### 4.2 Event（事件）

```json
{"type": "agent_start"}
{"type": "message_update", "message": {...}, "assistantMessageEvent": {...}}
{"type": "tool_execution_start", "toolCallId": "call_1", "toolName": "read", "args": {...}}
{"type": "agent_end", "messages": [...], "willRetry": false}
```

**特征**：
- 无 `id` 字段
- `type` 是事件类型（不是 `"response"`）
- 是 pi 主动推送的，不是对某个命令的响应

### 4.3 Extension UI Request（扩展 UI 请求）

```json
{"type": "extension_ui_request", "id": "ui_1", "method": "confirm", "title": "Delete?", "message": "..."}
```

**特征**：
- 有 `id` 字段（用于匹配前端回复）
- `type: "extension_ui_request"`
- 是 pi 主动发起的请求，不是对命令的响应

---

## 五、命令发送机制详解

### 5.1 sendCmd：同步等待响应

```typescript
const sendCmd = (command) => new Promise((resolve, reject) => {
    const id = `req_${++reqId}`;  // 自增 ID
    const line = JSON.stringify({ ...command, id }) + "\n";

    // 1. 注册 pending
    registerPending(id, { resolve, reject }, 60_000, command.type);

    // 2. 写入 stdin
    pi.stdin.write(line);
});
```

**完整流程**：

```
后端                                               pi
  │                                                │
  │ 1. 生成 req_1                                  │
  │ 2. pending[req_1] = {resolve, reject}          │
  │ 3. 设置 60s 超时定时器                        │
  │                                                │
  │ 4. stdin.write({type:"get_state",id:"req_1"}\n) │
  ├───────────────────────────────────────────────►│
  │                                                │
  │ 5. 等待 stdout 数据...                          │
  │                                                │
  │ 6. stdout: {id:"req_1",type:"response",...}    │
  │◄───────────────────────────────────────────────│
  │                                                │
  │ 7. pending 中匹配到 req_1                       │
  │ 8. clearTimeout(超时定时器)                     │
  │ 9. pending.delete(req_1)                      │
  │ 10. resolve(response)                          │
  │                                                │
  │ 11. 调用方拿到响应数据                          │
```

**超时处理**：
```typescript
function registerPending(id, handlers, timeoutMs, label) {
    pending.set(id, handlers);
    setTimeout(() => {
        if (!pending.has(id)) return;
        pending.delete(id);
        handlers.reject(new Error(`命令超时: ${label}`));
    }, timeoutMs);
}
```

### 5.2 submitPrompt：发送后不等待，事件通过 SSE

```typescript
function submitPrompt(options) {
    const id = `req_${++reqId}`;
    const payload = {
        type: "prompt",
        message: options.message,
        images: options.images,
        streamingBehavior: options.streamingBehavior,
        id,
    };
    const line = JSON.stringify(payload) + "\n";

    registerPending(id, {
        resolve: () => {
            // preflight 成功：agent 事件通过 SSE 推送
        },
        reject: (err) => {
            // preflight 失败：通过 SSE 广播错误
            broadcastSse({ type: "prompt_rejected", error: err.message });
        },
    }, 30_000, "prompt");

    pi.stdin.write(line);
}
```

**关键区别**：

| | sendCmd | submitPrompt |
|---|---|---|
| 用途 | 状态查询、模型切换、设置更改 | 发送用户消息 |
| 等待 | 等待 `response` 并返回 | 只等待 `preflight`（30s） |
| 后续数据 | 无 | 大量 agent 事件通过 SSE |
| resolve 时机 | 收到 response | preflight 成功 |
| reject 处理 | 返回错误 | 广播 `prompt_rejected` |

**preflight 机制**：pi 的 `prompt` 命令在收到后会先做一次 preflight（验证模型、API Key 等），如果通过才启动 agent 循环。preflight 通过后，response 立即返回，但 agent 事件才开始流式输出。

---

## 六、事件接收与分发详解

### 6.1 接收流水线

```
pi.stdout chunk1 ──┐
pi.stdout chunk2 ──┼──► buffer 累积 ──► split("\n") ──► onLine(line) ──► 解析 JSON
pi.stdout chunk3 ──┘
```

### 6.2 onLine 处理逻辑

```typescript
function onLine(line: string): void {
    if (!line.trim()) return;
    try {
        const msg = JSON.parse(line);

        // 情况 1：Response（匹配 pending）
        if (msg.type === "response" && msg.id && pending.has(msg.id)) {
            const p = pending.get(msg.id);
            pending.delete(msg.id);

            // 特殊处理：prompt 失败
            if (msg.command === "prompt" && msg.success === false) {
                broadcastSse({ type: "prompt_rejected", error: msg.error });
            }

            // 特殊处理：get_state 成功，提取 sessionFile
            if (msg.command === "get_state" && msg.success) {
                sessionFile = msg.data?.sessionFile;
            }

            p.resolve(msg);
            return;
        }

        // 情况 2：Event（直接广播到 SSE）
        trackAgentEvent(msg);   // 缓冲
        broadcastSse(msg);     // 广播到所有浏览器
    } catch {
        // 忽略非 JSON 行
    }
}
```

### 6.3 事件广播（SSE）

```typescript
function broadcastSse(msg: Record<string, unknown>): void {
    const data = `data: ${JSON.stringify(msg)}\n\n`;
    for (const res of sseClients) {
        res.write(data);
    }
}
```

**SSE 格式**：
```
data: {"type":"agent_start"}\n\n
data: {"type":"message_update","message":{...}}\n\n
data: {"type":"agent_end","messages":[...]}\n\n
```

**关键**：
- 每个事件以 `data: {json}\n\n` 格式发送
- 双 `\n\n` 是 SSE 协议的事件分隔符
- 所有浏览器客户端同时收到相同的事件流

---

## 七、事件缓冲与 Replay 机制

### 7.1 为什么需要缓冲

浏览器通过 SSE 连接到 `/api/events`。如果浏览器在 agent 运行过程中刷新页面，或者连接断开重连，需要恢复当前运行状态。

### 7.2 缓冲策略

```typescript
const BUFFERED_EVENT_TYPES = new Set([
    "agent_start", "agent_end",
    "message_start", "message_update", "message_end",
    "tool_execution_start", "tool_execution_update", "tool_execution_end",
    "compaction_start", "compaction_end",
    "queue_update", "auto_retry_start", "auto_retry_end",
    "bash_update",
]);

let isStreaming = false;
let turnEventBuffer: Record<string, unknown>[] = [];

function trackAgentEvent(msg) {
    const type = msg.type;
    if (type === "agent_start") {
        isStreaming = true;
        turnEventBuffer = [msg];  // 开始新缓冲
        return;
    }
    if (type === "agent_end") {
        turnEventBuffer.push(msg);
        isStreaming = false;
        turnEventBuffer = [];     // 清空缓冲
        return;
    }
    if (isStreaming && BUFFERED_EVENT_TYPES.has(type)) {
        turnEventBuffer.push(msg);
    }
}
```

### 7.3 新连接时的 Replay

```typescript
function connectSseClient(res: ServerResponse): void {
    const snapshot = getRunSnapshot();
    // 1. 发送 connected 事件，包含 replay
    res.write(`data: ${JSON.stringify({
        type: "connected",
        isStreaming: snapshot.isStreaming,
        replay: snapshot.replay,
    })}\n\n`);
    sseClients.add(res);
}
```

**场景示例**：

```
T1: agent_start ──► buffer=[agent_start]
T2: message_start ──► buffer=[agent_start, message_start]
T3: message_update ──► buffer=[..., message_update]
T4: 浏览器刷新 → 断开 SSE
T5: 浏览器重连 → connectSseClient 发送 connected + replay
     → 浏览器收到 [agent_start, message_start, message_update, ...]
T6: 后续事件继续广播
T7: agent_end ──► buffer=[]
```

---

## 八、扩展 UI 请求：双向通信

### 8.1 流程

```
pi 扩展（在 pi 进程内运行）
  │
  │ 调用 pi.ui.confirm("Delete?", "Are you sure?")
  │
  │ 1. pi 生成 extension_ui_request
  │ 2. stdout: {"type":"extension_ui_request","id":"ui_1","method":"confirm",...}
  │
  ├──────────────────────────────────────────────► 后端
  │                                                │
  │                                                │ 3. broadcastSse(msg)
  │                                                │ 4. 浏览器收到 confirm 弹窗
  │                                                │
  │ 5. 用户点击"确认"                                  │
  │                                                │
  │ 6. 浏览器 POST /api/extension-ui-response      │
  │                                                │
  │ 7. 后端调用 sendExtensionUiResponse("ui_1", {confirmed:true})
  │                                                │
  │ 8. stdin: {"type":"extension_ui_response","id":"ui_1","confirmed":true}
  ├──────────────────────────────────────────────► pi
  │
  │ 9. pi 扩展收到确认结果，继续执行
```

### 8.2 后端代码

```typescript
// 转发到浏览器
function broadcastSse(msg) {
    // msg = {type:"extension_ui_request", id:"ui_1", method:"confirm", ...}
    for (const res of sseClients) res.write(...);
}

// 接收浏览器回复
function sendExtensionUiResponse(id, response) {
    const line = JSON.stringify({ type: "extension_ui_response", id, ...response }) + "\n";
    pi.stdin.write(line);
}
```

### 8.3 支持的 UI 方法

| method | pi 请求 | 浏览器行为 | 浏览器回复 |
|--------|---------|-----------|----------|
| `select` | `title, options[]` | 显示下拉选择 | `{value: string}` |
| `confirm` | `title, message` | 显示确认对话框 | `{confirmed: boolean}` |
| `input` | `title, placeholder` | 显示输入框 | `{value: string}` |
| `editor` | `title, prefill` | 显示文本编辑器 | `{value: string}` 或 `{cancelled: true}` |
| `notify` | `message, notifyType` | 显示通知 toast | 无（fire-and-forget） |
| `setStatus` | `statusKey, statusText` | 更新状态栏 | 无 |
| `setWidget` | `widgetKey, widgetLines[]` | 显示小部件 | 无 |
| `setTitle` | `title` | 设置页面标题 | 无 |
| `set_editor_text` | `text` | 设置编辑器文本 | 无 |

---

## 九、完整的 Prompt 通信时序图

### 9.1 正常对话

```
浏览器          后端              pi --mode rpc
  │              │                    │
  │ POST /api/chat                   │
  │ {message:"Hello"}                │
  ├─────────────►│                    │
  │              │ submitPrompt()      │
  │              │ stdin: {type:"prompt",id:"req_1",message:"Hello"}\n
  │              ├────────────────────►│
  │              │                    │
  │              │ stdout: {id:"req_1",type:"response",command:"prompt",success:true}
  │              │◄───────────────────│
  │              │ resolve pending     │
  │              │                    │
  │              │ stdout: {type:"agent_start"}\n
  │              │◄───────────────────│
  │              │ broadcastSse()    │
  │              │                    │
  │◄─────────────│ data: {type:"agent_start"}\n\n
  │              │                    │
  │              │ stdout: {type:"message_start",message:{role:"user",...}}\n
  │              │◄───────────────────│
  │◄─────────────│ data: {type:"message_start"}...\n\n
  │              │                    │
  │              │ stdout: {type:"message_start",message:{role:"assistant",...}}\n
  │              │◄───────────────────│
  │◄─────────────│ data: {type:"message_start"}...\n\n
  │              │                    │
  │              │ stdout: {type:"message_update",message:{...},assistantMessageEvent:{type:"text_delta",delta:"H"}}\n
  │              │◄───────────────────│
  │◄─────────────│ data: {type:"message_update"}...\n\n
  │              │                    │
  │              │ stdout: {type:"message_update",...delta:"ello"}\n
  │              │◄───────────────────│
  │◄─────────────│ data: {type:"message_update"}...\n\n
  │              │                    │
  │              │ stdout: {type:"message_end",message:{role:"assistant",content:"Hello!"}}\n
  │              │◄───────────────────│
  │◄─────────────│ data: {type:"message_end"}...\n\n
  │              │                    │
  │              │ stdout: {type:"turn_end",message:{...},toolResults:[]}\n
  │              │◄───────────────────│
  │◄─────────────│ data: {type:"turn_end"}...\n\n
  │              │                    │
  │              │ stdout: {type:"agent_end",messages:[...]}\n
  │              │◄───────────────────│
  │◄─────────────│ data: {type:"agent_end"}...\n\n
  │              │                    │
```

### 9.2 带工具调用的对话

```
浏览器          后端              pi --mode rpc
  │              │                    │
  │ POST /api/chat "Read file.txt"  │
  ├─────────────►│                    │
  │              │ submitPrompt()      │
  │              ├────────────────────►│
  │              │                    │
  │              │ [agent_start]       │
  │              │ [message_start user]│
  │              │ [message_end user]  │
  │              │                    │
  │              │ [message_start assistant with toolCall]
  │              │ [message_end assistant]
  │              │                    │
  │              │ [tool_execution_start toolCallId="call_1" toolName="read" args={path:"file.txt"}]
  │              │◄───────────────────│
  │◄─────────────│ data: tool_execution_start
  │              │                    │
  │              │ [tool_execution_end toolCallId="call_1" result={content:[...]} isError:false]
  │              │◄───────────────────│
  │◄─────────────│ data: tool_execution_end
  │              │                    │
  │              │ [message_start toolResult]
  │              │ [message_end toolResult]
  │              │ [turn_end]
  │              │                    │
  │              │ [message_start assistant]  ← LLM 回应工具结果
  │              │ [message_update ...]
  │              │ [message_end assistant]
  │              │ [turn_end]
  │              │ [agent_end]
  │              │                    │
```

### 9.3 浏览器刷新重连

```
浏览器          后端              pi --mode rpc
  │              │                    │
  │              │ [agent_start]       │
  │              │ [message_update]    │
  │              │ [message_update]    │
  │              │                    │
  │ 刷新页面 ────►│ 断开 SSE          │
  │              │                    │
  │ 重连 /api/events                 │
  ├─────────────►│ connectSseClient()  │
  │              │ 检查 isStreaming=true
  │              │ replay=[agent_start, message_update, message_update]
  │              │                    │
  │◄─────────────│ data: {type:"connected",isStreaming:true,replay:[...]}\n\n
  │              │                    │
  │              │ 后续事件继续广播     │
  │◄─────────────│ data: {type:"message_update"}...\n\n
  │              │                    │
```

---

## 十、错误处理机制

### 10.1 命令超时

```
后端发送命令 req_5 ──► 等待 60s ──► 未收到 response
  │
  └── 超时定时器触发 ──► pending.delete(req_5)
                       reject(Error("命令超时: get_state"))
                       调用方收到错误
```

### 10.2 Prompt Preflight 失败

```
后端发送 prompt req_1 ──► pi 验证失败
  │
  └── pi stdout: {id:"req_1",type:"response",command:"prompt",success:false,error:"No API key"}
      │
      └── 后端检测到 prompt + success=false
          └── broadcastSse({type:"prompt_rejected", error:"No API key"})
              └── 浏览器收到 prompt_rejected，移除 optimistic 用户消息
```

### 10.3 子进程崩溃

```
pi stdout 不再输出
  │
  └── pi.on("exit", (code) => {
      if (code !== 0) {
          console.error("pi RPC 启动失败:", stderrBuffer);
      }
      onExit(code);  // 后端关闭 HTTP 服务
  })
      └── 后端调用 shutdown() ──► 关闭 HTTP 服务
```

### 10.4 stderr 收集

```typescript
let stderrBuffer = "";
pi.stderr.on("data", (data) => {
    stderrBuffer = (stderrBuffer + data.toString()).slice(-8192);
    process.stderr.write(`[pi] ${data}`);
});
```

- 保留最近 8KB 的 stderr 输出
- 用于子进程崩溃时输出诊断信息
- 实时转发到后端 stderr（可在终端看到）

---

## 十一、通信状态机

```
                    ┌─────────────┐
                    │   未启动     │
                    └──────┬──────┘
                           │ spawn
                           ▼
                    ┌─────────────┐
              ┌────│   已连接     │
              │    │  (pending=0) │
              │    └──────┬──────┘
              │           │ sendCmd
              │           ▼
              │    ┌─────────────┐
              │    │  等待响应     │
              │    │ (pending>0)  │
              │    └──────┬──────┘
              │           │ response
              │           ▼
              └────┌─────────────┐
                   │  事件流中     │
                   │ (isStreaming) │
                   └──────┬──────┘
                          │ agent_end
                          ▼
                   ┌─────────────┐
                   │   空闲       │
                   └─────────────┘
                          │
                          │ pi exit
                          ▼
                   ┌─────────────┐
                   │   已断开     │
                   └─────────────┘
```

---

## 十二、数据流向总结

| 数据 | 方向 | 管道 | 格式 | 示例 |
|------|------|------|------|------|
| 命令 | 后端 → pi | stdin | JSONL | `{"type":"get_state","id":"req_1"}\n` |
| 响应 | pi → 后端 | stdout | JSONL | `{"id":"req_1","type":"response","success":true}\n` |
| 事件 | pi → 后端 | stdout | JSONL | `{"type":"agent_start"}\n` |
| 扩展 UI 请求 | pi → 后端 | stdout | JSONL | `{"type":"extension_ui_request","id":"ui_1","method":"confirm"}\n` |
| 扩展 UI 回复 | 后端 → pi | stdin | JSONL | `{"type":"extension_ui_response","id":"ui_1","confirmed":true}\n` |
| 调试日志 | pi → 后端 | stderr | 纯文本 | `[pi] Loaded 3 extensions` |
| Agent 事件 | 后端 → 浏览器 | HTTP SSE | SSE格式 | `data: {"type":"agent_start"}\n\n` |
| HTTP 请求 | 浏览器 → 后端 | HTTP | JSON | `POST /api/chat {message:"Hello"}` |

---

## 十三、重构 Go 时需要注意的 RPC 要点

1. **JSONL 严格分割**：逐字节扫描 `\n`，不能用 `bufio.Scanner` 或 `readline`
2. **pending map 并发安全**：读写 pending、reqId 需要加锁
3. **SSE 双换行**：每个事件必须是 `data: {json}\n\n`
4. **事件缓冲**：`agent_start` 到 `agent_end` 之间的所有事件需要缓冲
5. **connected replay**：新 SSE 连接时发送 `connected` + `replay`
6. **stdin 保持打开**：不要关闭 stdin 管道，否则 pi 会退出
7. **stderr 实时转发**：收集并转发 pi 的 stderr 用于调试
8. **子进程信号**：`SIGTERM` 要转发给 pi 子进程
9. **超时清理**：pending 中的超时请求需要清理，避免内存泄漏
10. **prompt 特殊处理**：`prompt` 的 response 失败时，要广播 `prompt_rejected`


Successfully wrote 20819 bytes to /shumengya/docs/sproutclaw-webui-rpc-communication-deep-dive.md


用户要求详细的 RPC 通信过程说明，我已经写了完整的深度文档。现在需要给出简洁的总结，并说明如果用户想把 WebUI 单独拿出来作为标准互联网项目，Go 后端应该如何设计。

用户的核心需求是：
1. 理解当前 RPC 通信的详细过程
2. 想把 WebUI 单独拿出来做独立项目
3. 后端启动 sproutclaw agent

我需要给出清晰的回答。

文档已保存到 `/shumengya/docs/sproutclaw-webui-rpc-communication-deep-dive.md`。

## 核心通信模型：JSONL over Stdio

WebUI 后端 spawn 一个 `pi --mode rpc` 子进程，通过 **三个管道** 通信：

| 管道 | 方向 | 数据 |
|------|------|------|
| **stdin** | 后端 → pi | 发送命令（JSONL） |
| **stdout** | pi → 后端 | 接收响应 + 事件（JSONL） |
| **stderr** | pi → 后端 | 调试日志（纯文本） |

## stdout 上的两种数据

pi 通过 stdout 输出两种 JSON：

1. **Response（响应）**：`{"id":"req_1","type":"response","command":"get_state","success":true,"data":{...}}`
   - 有 `id` 匹配 pending 中的命令
   - `sendCmd()` 等待的就是这个

2. **Event（事件）**：`{"type":"agent_start"}`、`{"type":"message_update",...}`、`{"type":"agent_end",...}`
   - 无 `id`，是 pi 主动推送的
   - 后端通过 SSE 广播给浏览器

## 关键机制

### 1. 命令-响应匹配
```typescript
pending = new Map()  // id → {resolve, reject}
// 发送时写入 pending
// 收到 response 时按 id 匹配 resolve
```

### 2. Prompt 特殊处理
`submitPrompt()` 发送 `prompt` 命令后，只等待 **preflight**（30秒），pi 验证通过后立即返回 `success: true`。真正的 Agent 事件流随后通过 stdout 推送到后端，后端再 SSE 广播给浏览器。

### 3. 事件缓冲 + Replay
后端在 `agent_start` 到 `agent_end` 之间缓冲所有事件。浏览器刷新重连时，后端发送 `connected` 事件 + `replay` 数组，让前端恢复当前运行状态。

### 4. 扩展 UI 双向通信
pi 扩展可以主动发起 `extension_ui_request`（弹窗/确认/输入），后端 SSE 广播到浏览器，浏览器回复后后端通过 stdin 发送 `extension_ui_response`。

---

## 如果你要把 WebUI 单独拿出来做独立项目

**正确的架构应该是：**

```go
┌─────────────────┐
│  浏览器(React)   │  ← 可以放到任何地方
│  EventSource/fetch│
└────────┬────────┘
         │ HTTP + SSE
┌────────▼────────┐
│  Go HTTP 后端    │  ← 独立部署，可管理多个 Agent
│  - REST API      │
│  - SSE 广播      │
│  - 子进程管理    │
└────────┬────────┘
         │ spawn 多个
┌────────▼────────┐
│  pi --mode rpc  │  ← 每个用户/会话一个 Agent
│  (per session)   │
└─────────────────┘
```

Go 后端需要实现的核心模块：

1. **AgentPool**：管理多个 `pi --mode rpc` 子进程（每个用户/会话一个）
2. **JSONL Parser**：逐字节按 `\n` 分割（不能用 `bufio.Scanner`）
3. **SSE Broadcaster**：`text/event-stream`，支持多客户端 + replay
4. **Command Router**：REST API → RPC 命令转发
5. **Session Manager**：关联用户 → Agent 进程 → 会话文件

如果你需要，我可以直接帮你写 Go 后端的骨架代码。