# SPEC: Mind CLI 无状态原子化改造

> **版本**: v1.1（已实施）
> **日期**: 2026-07-01
> **状态**: ✅ Phase 1-4 已完成并部署，附加 Cloud→CLI 链路优化
> **适用范围**: MindOS_CLI (`mindcli/` 薄壳层，不含 `_vendor/`) + Cloud 端 bridge
> **前置依赖**: `SPEC_mindos_next_cli.md` v2.3（设计哲学与三铁律）
> **参照模式**: `hermes-overlay/infra/pipelines/anyfile2md.py`（无状态管线组合器）
> **当前版本号**: mindcli 0.2.1 / vendor hermes@16f9d020

---

## 一、改造动机

### 1.1 改造前状态：有状态单体

> 以下描述的是改造前的状态（v0.1.0），保留作为设计依据。改造后的实际状态见 §四。

`mindcli/` 薄壳层（7 个文件 / 1633 行）原是**进程级有状态单体**，具体表现：

| 模块 | 状态性 | 行数 | 问题 |
|:---|:---|---:|:---|
| `recorder.py` | 模块级单例 `_recorder` + `_running` 互斥 | 436 | **一进程只能一路录音**，双工模式不可用 |
| `tunnel.py` | 模块级单例 `_tunnel_client`，反向写 `health._tunnel_status` | 242 | 循环耦合，状态经全局变量传递 |
| `health.py` | 模块级全局 `_loop` / `_tunnel_status` / `_tool_count` | 278 | 状态枢纽 + 端口 8660 绑定 |
| `managed_mcp.py` | 实例级状态，经 tunnel 单例间接唯一 | 183 | 随 tunnel 存活，无法独立测试 |
| `cli.py` | 自身无状态，但 `mind chat` 拉起 vendor TUI | 272 | `_vendor/cli.py` 有 7 个模块级全局 + `sys.exit()` |
| `capability.py` | 无状态纯函数 | 79 | ✅ 已达标 |
| `service.py` | 无状态纯函数 | 116 | ✅ 已达标 |

### 1.2 与设计目标的偏差

对照 `SPEC_mindos_next_cli.md` 的三铁律：

| 铁律 | 设计要求 | 当前实现 | 偏差 |
|:---|:---|:---|:---:|
| **A：决策权归云端** | CLI 的 LLM 调用 100% 走 Cloud Gateway（JWT 计费） | `mind chat` 拉起 vendor TUI，本地持有 `conversation_history` + SQLite session，`sys.exit()` 杀进程 | 🔴 端有自己的脑 |
| **B：能力报告义务** | CLI 连接时报告能力 | `capability.py` 无状态扫描 | ✅ 已达标 |
| **C：连接即受管/断开即自治** | 两种模式互斥，边界清晰 | tunnel 单例 + recorder 单例耦合在同一进程，断开后 recorder 无受管方 | 🟡 边界模糊 |

### 1.3 最严重的功能性后果

**双工模式在代码层面不可能实现。**

设计文档 §Phase4 明确要求：

> 双工模式 = 浏览器 mic session A + CLI system session B，两个独立 ASR session。

但当前 `recorder.py` 的实现：

```python
# recorder.py:35-43
_recorder: "SystemRecorder | None" = None

def get_recorder() -> "SystemRecorder":
    global _recorder
    if _recorder is None:
        _recorder = SystemRecorder()
    return _recorder

# recorder.py:110-111（start 方法内）
if self._running:
    return {"error": "录音已在进行中"}
```

单例 + `_running` 互斥 = 第二路录音直接返回 error。双工模式不可能。

### 1.4 参照模式：anyfile2md

`hermes-overlay/infra/pipelines/anyfile2md.py`（177 行）是项目内已验证的"无状态管线组合器"范式：

```
infra/atoms/        ← 原子（text_sniffer / vlm_ocr / page_rasterizer ...）
  ↑ 组合
infra/pipelines/    ← 无状态管线（anyfile2md: parse() / parseLocal() / isSupported()）
  ↑ 调用
platforms/*_sse     ← SSE 子类（绑定 SSE 推送 + DB + 积分等状态化职责）
```

**关键特征**：
- 管线层无 `self`、无类属性、无模块级可变状态。
- 文件头声明："管线只做编排，不做逻辑。无状态：不写 DB、不推 SSE、不管 session。"
- 进度通知通过 `onProgress` 回调注入，管线本身不依赖它。
- 状态化职责（DB / SSE / 积分）全部下沉到调用方 SSE 子类。

Mind CLI 应照此分层：**把 recorder/tunnel/managed_mcp 的核心逻辑抽成无状态管线，状态化职责上交给 health server 调用方。**

---

## 二、改造目标

### 2.1 不改的

- **不把 MindCLI 变成 bookqa_sse 那样的独立无状态 SSE 原子。** MindCLI 是 Cloud 的执行节点（设计文档 §1.1），依赖 Cloud Gateway 做 LLM 调用、依赖 Cloud ASR 做转写。它的"连接态"必须存在，不可能做到 `stateless: True`。
- **不碰 `_vendor/` 内部。** vendor 快照是只读副本，内部重构应在 hermes 上游做，快照只消费。
- **不改 `capability.py` 和 `service.py`。** 已是无状态纯函数，达标。

### 2.2 要改的

| # | 改造对象 | 目标 | 价值 |
|:---:|:---|:---|:---|
| 1 | `recorder.py` | 拆成 `pipelines/audio_capture.py`（无状态）+ health handler（有状态调用方） | **解锁双工模式** |
| 2 | `tunnel.py` | 拆成 `pipelines/tunnel_session.py`（无状态）+ health handler（持有 handle） | **消除循环耦合** |
| 3 | `managed_mcp.py` | 抽出 `pipelines/tool_proxy.py`（无状态执行）+ 实例级白名单 | 解耦生命周期 |
| 4 | `cli.py` 的 `chat`/`ask` | 走 `run_agent` headless 接口，不拉 vendor TUI | **修正铁律 A 偏差** |

### 2.3 改造后的分层架构

```
Layer 3: 调用方（有状态，进程级）
  mindcli/health.py — HTTP handler
    持有: _tunnel_handle, _active_captures: dict[chatId, CaptureHandle]
    职责: 端口绑定、生命周期管理、状态聚合
  ↓ 调用
Layer 2: 无状态管线（新增，类比 infra/pipelines/）
  mindcli/pipelines/audio_capture.py   — capture(source, ws_ep, on_text) -> CaptureHandle
  mindcli/pipelines/tunnel_session.py  — connect(url, jwt, on_dispatch, on_status) -> TunnelHandle
  mindcli/pipelines/tool_proxy.py      — execute(tool, params, approved_set) -> dict
  ↓ 组合
Layer 1: 原子（无状态函数，已存在）
  mindcli/capability.py — scan_capabilities() -> dict
  mindcli/service.py    — install_service() / uninstall_service()
  managed_mcp executors — _exec_terminal / _exec_file_read / ...（纯 subprocess/IO）
```

---

## 三、详细设计

### 3.1 第一刀：recorder.py → pipelines/audio_capture.py

**当前问题**（436 行）：

```
recorder.py
├── _recorder 全局单例 (L35)
├── get_recorder() 工厂 (L38)
├── SystemRecorder 类 (L46)
│   ├── __init__: _running / _ws / _source / _chat_id / _buf / _loop ... (L54)
│   ├── start(): if _running: return error (L89, L110)
│   ├── stop() (L154)
│   ├── _start_mic() (L184)
│   ├── _start_system_audio() + _on_system_audio() (L206, L277)
│   ├── _stop_capture() (L295)
│   ├── _push_loop() (L316)
│   └── _ws_recv_loop() (L352)
├── _sync_get_sharable_content() (L375)  ← macOS 权限检测，无状态
├── _SCStreamDelegate 定义 (L397-432)   ← pyobjc 类定义，无状态
```

**拆分方案**：

```
mindcli/pipelines/audio_capture.py  (新增，~200 行，无状态)
  ├── async def capture(source, ws_endpoint, on_text) -> CaptureHandle
  ├── class CaptureHandle:  ← 轻量句柄，非单例
  │     async def stop() -> dict
  │     def status() -> dict
  ├── def check_permissions() -> bool  ← 从 _sync_get_sharable_content 迁入
  └── _SCStreamDelegate (pyobjc 类，从 recorder.py 迁入)

mindcli/health.py  (修改，有状态调用方)
  ├── _active_captures: dict[str, CaptureHandle] = {}  ← 按 chatId 索引，可多实例
  ├── /record/start handler:
  │     handle = await capture(source, ws_ep, on_text)
  │     _active_captures[chatId] = handle
  └── /record/stop handler:
        handle = _active_captures.pop(chatId)
        await handle.stop()
```

**核心变化**：

```python
# ── 改造前（recorder.py，单例 + 互斥）─────────────
_recorder = None

def get_recorder():
    global _recorder
    if _recorder is None:
        _recorder = SystemRecorder()
    return _recorder

class SystemRecorder:
    async def start(self, ...):
        if self._running:
            return {"error": "录音已在进行中"}
        # ...

# ── 改造后（pipelines/audio_capture.py，无状态）───
async def capture(
    source: str,               # "system" | "mic"
    ws_endpoint: str,          # Cloud ASR WebSocket URL
    on_text: Callable,         # 回调：转写结果
) -> CaptureHandle:
    """启动一路音频采集，返回独立句柄。无单例、无互斥。

    可多次调用，每次返回独立 handle，双工模式 = 两个 handle 并存。
    """
    handle = CaptureHandle(source, ws_endpoint, on_text)
    await handle._start()
    return handle

class CaptureHandle:
    """一路音频采集的句柄。生命周期由调用方管理。"""
    # 不持有全局状态，不是单例
```

**双工模式验证**：

```python
# health.py 的 /record/start handler
handle_a = await capture("mic", ws_ep, on_text)      # 浏览器 mic
_active_captures["chatA"] = handle_a

handle_b = await capture("system", ws_ep, on_text)   # CLI 系统音频
_active_captures["chatB"] = handle_b
# ✅ 两路独立并存，符合设计文档 §Phase4
```

### 3.2 第二刀：tunnel.py → pipelines/tunnel_session.py

**当前问题**（242 行）：

```
tunnel.py
├── TunnelClient 类 (L23)
│   ├── __init__: _jwt / _ws / _managed_mcp / 3 个后台 Task (L34)
│   ├── activate(jwt, url) (L70)
│   ├── disconnect() (L92)
│   ├── _connect_loop() (L115)    ← 重连退避
│   ├── _connect() (L139)         ← 握手 + 能力上报
│   ├── _run() (L188)             ← 消息循环
│   └── _handle_tool_call(msg) (L208)
├── _tunnel_client = TunnelClient()  (L237)  ← 模块级单例
└── get_tunnel_client() (L240)
```

**循环耦合**：`tunnel.py:184` 反向 `from mindcli.health import set_tunnel_status`，写 `health._tunnel_status` 全局变量。

**拆分方案**：

```
mindcli/pipelines/tunnel_session.py  (新增，~150 行，无状态)
  ├── async def connect(url, jwt, on_dispatch, on_status) -> TunnelHandle
  ├── class TunnelHandle:
  │     async def disconnect()
  │     def status() -> str
  │     def update_approved(tools)
  └── （重连/心跳/消息循环逻辑迁入，状态通过 on_status 回调通知）

mindcli/health.py  (修改)
  ├── _tunnel_handle: TunnelHandle | None = None
  ├── /tunnel/activate handler:
  │     _tunnel_handle = await connect(url, jwt,
  │         on_dispatch=self._dispatch_tool_call,
  │         on_status=self._on_tunnel_status)  ← 回调更新状态，不写全局变量
  └── def _on_tunnel_status(self, status, tools=0):
        _tunnel_status = status  ← health 自己的局部状态
```

**核心变化**：

```python
# ── 改造前（tunnel.py，单例 + 反向写 health 全局）──
_tunnel_client = TunnelClient()

class TunnelClient:
    async def _connect(self):
        # ...
        from mindcli.health import set_tunnel_status  # ← 反向耦合
        set_tunnel_status("connected", len(tools))

# ── 改造后（pipelines/tunnel_session.py，回调注入）─
async def connect(
    url: str,
    jwt: str,
    on_dispatch: Callable,   # 工具调用回调
    on_status: Callable,     # 状态变更回调
) -> TunnelHandle:
    """建立 Tunnel 连接，状态变更通过 on_status 回调通知。

    不持有全局状态，不反向 import 调用方模块。
    """
    handle = TunnelHandle(url, jwt, on_dispatch, on_status)
    await handle._start()
    return handle
```

### 3.3 第三刀：managed_mcp.py → pipelines/tool_proxy.py

**当前状态**（183 行）：已接近无状态，白名单是实例级 `_approved: set[str]`，executors 是纯 subprocess/IO。但通过 `tunnel._managed_mcp` 间接成为进程级唯一实例。

**拆分方案**（改动最小）：

```
mindcli/pipelines/tool_proxy.py  (新增，~120 行)
  ├── async def execute(tool_name, params, approved_set) -> dict
  ├── _EXECUTORS: dict[str, Callable]  ← 从 managed_mcp._register_executors 迁入
  └── def is_approved(tool_name, approved_set) -> bool

mindcli/health.py 或 tunnel handle 持有 approved_set
```

executor 函数（`_exec_terminal` / `_exec_file_read` / `_exec_file_write` / `_exec_grep` / `_exec_file_ops` / `_exec_code`）本身就是纯 subprocess/IO，直接迁入即可。

### 3.4 第四刀：cli.py chat/ask 走 run_agent headless

**当前问题**：

```python
# cli.py:30-43（chat 命令）
@main.command()
def chat(model, skills, resume):
    from cli import main as hermes_main  # ← _vendor/cli.py 的 10000 行 TUI
    hermes_main(**kwargs)               # ← sys.exit() 杀进程
```

`_vendor/cli.py` 的 `main()`：
- 写 `os.environ["HERMES_INTERACTIVE"] = "1"`
- 注册 `atexit` 钩子
- 修改模块级 `_active_agent_ref` / `_active_worktree` / `_cleanup_done`
- 单查询模式 `sys.exit(0)` / `sys.exit(1)`

**不可当作无状态函数调用。**

**改造方案**：

```python
# cli.py 改造后
@main.command()
def chat(model, skills, resume):
    """交互式 Chat — 走 Cloud Gateway（JWT 计费）。"""
    from run_agent import AIAgent  # ← headless，不拉 TUI

    agent = AIAgent(
        model=model or None,
        base_url=_get_cloud_gateway_url(),  # 指向 Cloud Gateway
        api_key=_get_jwt(),                 # JWT from tunnel handle
    )
    agent.run_interactive(skills=skills, resume=resume)

@main.command()
def ask(question, model, fmt):
    """单次查询 — 走 Cloud Gateway（JWT 计费）。"""
    from run_agent import AIAgent

    agent = AIAgent(
        model=model or None,
        base_url=_get_cloud_gateway_url(),
        api_key=_get_jwt(),
    )
    result = agent.run_query(question)
    _output(result, fmt)
```

**vendor `cli.py` 的 TUI 只在"离线独立模式"（铁律 C 的断开即自治）时才拉起**，作为 `mind chat --offline` 的备选路径。

### 3.5 改造后文件结构（实际）

```
mindcli/
├── __init__.py                    # 22 行，__version__ = "0.2.1"
├── __main__.py                    # 5 行，不变
├── cli.py                         # 437 行，chat/ask 走 run_agent + --offline 备选
├── health.py                      # 334 行，持有 _tunnel_handle + _active_captures
├── service.py                     # 116 行，不变
├── capability.py                  # 86 行，_get_vendor_commit 修复为逐行解析
├── pipelines/                     # ★ 新增
│   ├── __init__.py                # 10 行
│   ├── audio_capture.py           # 460 行，capture() + CaptureHandle（双工可用）
│   ├── tunnel_session.py          # 274 行，connect() + TunnelHandle（回调解耦）
│   └── tool_proxy.py              # 183 行，ToolProxy（非单例）
├── _vendor/                       # 不变（vendor hermes@16f9d020）
└── (已删除)
    ├── recorder.py                # → 逻辑迁入 pipelines/audio_capture.py
    ├── tunnel.py                  # → 逻辑迁入 pipelines/tunnel_session.py
    └── managed_mcp.py             # → 逻辑迁入 pipelines/tool_proxy.py
```

**薄壳净行数变化**：

| 改造前 | 改造后 |
|:---|:---|
| 1633 行（7 文件） | 1928 行（10 文件，含 pipelines/ 4 个新文件） |

净增约 295 行——增量来自 `cli.py` 的 headless chat/ask 逻辑、pipx 自动更新、以及 pipelines 层的工厂函数接口。单例/工厂/全局变量的模板代码被消除，同时新增"双工模式可用""循环耦合消除""Cloud Gateway 路由"三个能力。

---

## 四、改造后的状态边界

### 4.1 状态归属表

| 状态 | 改造前归属 | 改造后归属 | 改善 |
|:---|:---|:---|:---|
| 端口 8660 + event loop | `health._loop` (全局) | `health._loop` (不变) | — |
| Tunnel 连接 + JWT | `tunnel._tunnel_client` (单例) | `health._tunnel_handle` (调用方持有) | 消除循环耦合 |
| Tunnel 状态字符串 | `health._tunnel_status` (被 tunnel 反向写) | `health._tunnel_status` (on_status 回调写) | 单向数据流 |
| 录音句柄 | `recorder._recorder` (单例 + 互斥) | `health._active_captures[chatId]` (dict，可多实例) | **双工可用** |
| 工具白名单 | `tunnel._managed_mcp._approved` | `health._approved_set` 或 tunnel handle 持有 | 生命周期独立 |
| LLM 会话 | vendor `cli.py` 的 `HermesCLI.agent` + SQLite | `run_agent.AIAgent`（headless，走 Cloud Gateway） | **铁律 A 合规** |

### 4.2 依赖图对比

**改造前**（有循环）：

```
health.py ⇄ tunnel.py   ← 经全局变量双向耦合
health.py → recorder.py  ← 经单例耦合
tunnel.py → managed_mcp  ← 经单例间接耦合
cli.py → _vendor/cli.py  ← 拉起重状态 TUI
```

**改造后**（单向，无环）：

```
health.py → pipelines/tunnel_session.py   (调用 connect，持 handle)
health.py → pipelines/audio_capture.py    (调用 capture，持 handle)
health.py → pipelines/tool_proxy.py       (调用 execute，持 approved_set)
pipelines/* → capability.py / executors   (只组合原子，不反向 import health)
cli.py → run_agent.AIAgent                (headless，走 Cloud Gateway)
```

### 4.3 铁律合规对照

| 铁律 | 改造前 | 改造后 |
|:---|:---:|:---:|
| A：决策权归云端 | 🔴 端有自己的脑（vendor TUI + 本地 session） | ✅ chat/ask 走 run_agent + Cloud Gateway JWT |
| B：能力报告义务 | ✅ | ✅（不变） |
| C：连接即受管/断开即自治 | 🟡 边界模糊 | ✅ tunnel handle + capture handle 生命周期由 health 管理，断开即释放 |

---

## 五、实施路线图（已完成）

> 执行顺序调整为 Phase 3 → Phase 1 → Phase 2 → Phase 4（Phase 2 的 tunnel_session import Phase 3 的 ToolProxy）。

### Phase 3: tool_proxy 管线 ✅

```
✅ 从 managed_mcp.py 抽出 ToolProxy → pipelines/tool_proxy.py
✅ 白名单 set 由调用方传入，不再经 tunnel 单例
✅ 删除 managed_mcp.py（逻辑已迁出）
✅ 验证：工具调用正常，白名单过滤生效
```

### Phase 1: audio_capture 管线 + 双工解锁 ✅

```
✅ 创建 mindcli/pipelines/ 目录 + __init__.py
✅ 从 recorder.py 抽出 capture() / CaptureHandle / pyobjc delegate → pipelines/audio_capture.py
✅ 修改 health.py：/record/start 用 _active_captures[chatId] 替代 get_recorder()
✅ 修改 health.py：/record/stop 从 _active_captures pop 后调 handle.stop()（支持 ?chatId= 停单路）
✅ /record/status 返回所有活跃录音列表（双工可见）
✅ 删除 recorder.py（逻辑已迁出）
□ 验证：mind record start --source system + mind record start --source mic 并行成功（待实机）
□ 验证：mind record stop --chat-id 各自独立停止（待实机）
```

### Phase 2: tunnel_session 管线 + 解循环 ✅

```
✅ 从 tunnel.py 抽出 connect() / TunnelHandle → pipelines/tunnel_session.py
✅ on_status 回调替代反向 import health.set_tunnel_status
✅ 修改 health.py：/tunnel/activate 持有 _tunnel_handle
✅ 删除 tunnel.py（逻辑已迁出）
✅ 验证：mind tunnel connect → Cloud LLM 看到 local_* 工具
✅ 验证：断开后状态正确回退
```

### Phase 4: chat/ask 走 run_agent ✅

```
✅ cli.py chat/ask 改用 _vendor/run_agent.py 的 AIAgent headless 接口
✅ base_url 指向 Cloud Gateway，api_key 用 JWT
✅ vendor cli.py TUI 保留为 mind chat --offline 备选
□ 验证：mind ask "hello" → 走 Cloud Gateway → 积分扣费（待 JWT 链路通）
□ 验证：mind chat → 交互模式 → 走 Cloud Gateway（待 JWT 链路通）
```

**总计实际耗时 ~4 小时**（含调试 + 部署，远低于预估的 5 工作日）。

---

## 六、风险与回退

| 风险 | 等级 | 缓解 |
|:---|:---:|:---|
| pyobjc delegate 类迁移后权限引导断裂 | 🟡 | Phase 1 验证时首跑 `mind record start --source system`，确认 macOS 录屏权限弹窗正常 |
| run_agent headless 接口不支持 `--skills` | 🟡 | Phase 4 先验证 `AIAgent(model=, base_url=, api_key=)` 签名，必要时在 vendor 层薄封装 |
| Cloud Gateway JWT 链路未通 | 🟠 | Phase 4 可先保留 vendor TUI 路径作为 fallback，Gateway 通后再切 |
| 改造期间 _vendor 快照落后于 hermes 上游 | 🟢 | 改造不碰 _vendor，与 vendor 同步纪律正交 |

**回退策略**：每个 Phase 独立，可按 Phase 回退。`recorder.py` / `tunnel.py` / `managed_mcp.py` 在 git 历史中保留，任一 Phase 失败可 revert 对应 commit。

---

## 七、决策记录

| # | 决策 | 理由 |
|:---|:---|:---|
| D1 | 选拆 recorder 为第一刀 | 双工模式是设计文档 §Phase4 的核心卖点，当前代码层面不可用，价值最高 |
| D2 | 用回调注入替代全局变量 | 消除 health ⇄ tunnel 循环耦合，单向数据流 |
| D3 | chat/ask 走 run_agent 而非 vendor TUI | 修正铁律 A（决策权归云端），vendor TUI 的 `sys.exit()` 和 7 个全局变量使其不可当函数调用 |
| D4 | 不把 CLI 变成 SSE 无状态原子 | CLI 是执行节点，依赖 Cloud Gateway/ASR，连接态必须存在 |
| D5 | 保留 vendor cli.py 作为 `--offline` 备选 | 铁律 C（断开即自治）要求离线模式可用 |
| D6 | pipelines/ 目录而非 infra/ | MindOS_CLI 是独立包，不依赖 hermes-overlay；pipelines/ 是自包含的无状态层 |
| D7 | 执行顺序调整为 3→1→2→4 | Phase 2 的 tunnel_session 需 import Phase 3 的 ToolProxy，先做 3 解除依赖 |
| D8 | pipx 自动更新检测安装方式 | `pip install --upgrade` 不影响 pipx 隔离 venv；检测 `sys.executable` 路径含 `pipx/venvs` → `pipx reinstall` |
| D9 | 防降级保护 | 服务器 versions.json 未同步时，`_compare_versions` 阻止本地被降级 |
| D10 | cli_info 快速路径工具 | Bridge 握手时已缓存 capability_report（含 version），LLM 查版本直接从缓存返回（0ms vs 10s Tunnel 往返），不调 cli_terminal 试探 |
| D11 | handler 复用主 event loop | `set_bridge` 捕获 `_main_loop`，handler 用 `run_coroutine_threadsafe` 替代每次新建 `ThreadPoolExecutor + asyncio.run` |

---

## 八、实施过程中发现并修复的 Bug

以下 bug 在改造过程中暴露，不属于原 SPEC 范围但必须修复：

| # | 文件 | Bug | 修复 | Commit |
|:---:|:---|:---|:---|:---|
| 1 | `pyproject.toml` | 缺 `[tool.setuptools.package-data]`，`VENDOR_COMMIT` 未打包进 pipx | 声明 `mindcli = ["_vendor/VENDOR_COMMIT", "_vendor/**/*"]` | `bcf586b` |
| 2 | `health.py` `_get_vendor_commit()` | `f.read().strip()` 返回整个文件内容而非 commit hash | 改为逐行 `commit:` 前缀解析 | `2075fe6` |
| 3 | `capability.py` `_get_vendor_commit()` | 同上（上报给 bridge 的 vendor 字段是多行文本） | 同上对齐 | `37a26ac` |
| 4 | `health.py` / `capability.py` | 读 `HERMES_COMMIT`（文件不存在），实际文件名 `VENDOR_COMMIT` | 统一为 `VENDOR_COMMIT` | `aecd81e` |
| 5 | `cli.py` `mind update` | `pip install --upgrade` 不影响 pipx 隔离 venv；裸 `pip` 在 venv PATH 里找不到 | 检测 pipx → `pipx reinstall`；非 pipx → `sys.executable -m pip` | `aecd81e` |
| 6 | `cli.py` `mind update` | 无版本比较，服务器 versions.json 未同步时误降级 | `_compare_versions` 防降级保护 | `aecd81e` |

---

## 九、Cloud→CLI 工具调用链路优化

### 9.1 问题

用户在云脑问"当前连接的 mindCLI 是什么版本"时，LLM 做 3 次 `cli_terminal` 工具调用试探（`which mindcli` / `pip show` / `ls ~/.hermes/`），每次往返 7-10s，总耗时 30s+ 逼近 300s 超时。

### 9.2 链路分析与超时层次

```
用户在云脑提问
  │  Nginx proxy_read_timeout = 3600s
  ▼
BaseSSEServer._handleChat()
  │  await _runChat(...) → _run_agent_task(fn, userId, chatId)
  │  asyncio.wait_for(timeout=MINDOS_CHAT_TIMEOUT_SECONDS=300)
  │  loop.run_in_executor → 同步线程池执行 agent.run_conversation()
  ▼
AIAgent.run_conversation()  max_iterations=90
  │  LLM 决定调 cli_terminal 工具
  ▼
cli_tunnel_tool._handler(args)
  │  ★ 旧：每次新建 ThreadPoolExecutor + asyncio.run（临时 loop）
  │  ★ 新：run_coroutine_threadsafe(coro, _main_loop)（复用主 loop）
  ▼
MindCLIBridge.dispatch_tool_call()
  │  asyncio.wait_for(future, timeout=TOOL_CALL_TIMEOUT=120)
  │  WebSocket → CLI → subprocess → 回流
  ▼
ToolProxy.execute()  subprocess timeout=30s
```

### 9.3 三层修复

| 层 | 改动 | 文件 | 效果 |
|:---|:---|:---|:---|
| **可观测性** | per-call 耗时日志 + `_call_stats` + `_run_agent_task` 记录对话总耗时/调用次数 | `mindcli_bridge.py` / `sse_server.py` | 每次对话后日志可见耗时分解 |
| **快速路径** | `cli_info` 工具直接从 Bridge `_capabilities` 缓存返回，不走 Tunnel | `cli_tunnel_tool.py` | "CLI 是什么版本"类查询 0ms 返回（vs 10s×3 次） |
| **handler 复用主 loop** | `set_bridge` 捕获 `_main_loop`，`run_coroutine_threadsafe` 替代临时 `asyncio.run` | `cli_tunnel_tool.py` | 消除临时 event loop 创建/销毁开销 |

### 9.4 预期效果

| 场景 | 改造前 | 改造后 |
|:---|:---|:---|
| "CLI 是什么版本？" | LLM 调 3 次 `cli_terminal`，~30s | LLM 调 1 次 `cli_info`（缓存命中），~3-5s |
| 任意 `cli_terminal` 调用 | 每次新建临时 loop，~50-100ms 额外开销 | 复用主 loop，~5-10ms |

### 9.5 服务器端日志格式（改造后）

```
[Bridge] 能力报告: userId=11c1cece, cli_version=0.2.1, vendor=16f9d020, platform=Darwin, tools=6, mcp=0
[Bridge] → 11c1cece: cli_terminal (id=ab12cd34)
[Bridge] ← 11c1cece: cli_terminal 完成 (id=ab12cd34, 120ms)
[MindOSSSEServer] Chat 完成 userId=11c1cece chatId=chat_xxx 耗时=9.4s 工具调用=1次
```

---

## 附录：改造前后代码对照

### A.1 录音：单例 → 多实例

```python
# ═══ 改造前：recorder.py ════════════════════════════
_recorder: "SystemRecorder | None" = None

def get_recorder() -> "SystemRecorder":
    global _recorder
    if _recorder is None:
        _recorder = SystemRecorder()
    return _recorder

class SystemRecorder:
    async def start(self, source, ws_ep, chat_id, on_text):
        if self._running:                    # ← 互斥
            return {"error": "录音已在进行中"}
        # ...

# ═══ 改造后：pipelines/audio_capture.py ════════════
async def capture(
    source: str,
    ws_endpoint: str,
    on_text: Callable[[str], None],
) -> CaptureHandle:
    """启动一路采集，返回独立句柄。可多实例并存。"""
    handle = CaptureHandle(source, ws_endpoint, on_text)
    await handle._start()
    return handle

class CaptureHandle:
    """一路音频采集的句柄。生命周期由调用方管理。"""
    async def stop(self) -> dict: ...
    def status(self) -> dict: ...
```

### A.2 Tunnel：全局变量 → 回调注入

```python
# ═══ 改造前：tunnel.py ══════════════════════════════
_tunnel_client = TunnelClient()

class TunnelClient:
    async def _connect(self):
        # ...
        from mindcli.health import set_tunnel_status  # ← 反向耦合
        set_tunnel_status("connected", len(tools))

# ═══ 改造后：pipelines/tunnel_session.py ═══════════
async def connect(
    url: str,
    jwt: str,
    on_dispatch: Callable,
    on_status: Callable,    # ← 状态变更通过回调通知
) -> TunnelHandle:
    handle = TunnelHandle(url, jwt, on_dispatch, on_status)
    await handle._start()
    return handle

# health.py 调用方
_tunnel_handle = await connect(
    url, jwt,
    on_dispatch=self._dispatch_tool_call,
    on_status=lambda s, t=0: (_tunnel_status.__set_name__(...))  # 自己的状态自己写
)
```

### A.3 chat/ask：vendor TUI → run_agent headless

```python
# ═══ 改造前：cli.py ════════════════════════════════
@main.command()
def chat(model, skills, resume):
    from cli import main as hermes_main    # ← _vendor/cli.py 10000 行 TUI
    hermes_main(**kwargs)                  # ← sys.exit() 杀进程

# ═══ 改造后：cli.py ════════════════════════════════
@main.command()
def chat(model, skills, resume):
    from run_agent import AIAgent          # ← headless
    agent = AIAgent(
        model=model or None,
        base_url=_get_cloud_gateway_url(),
        api_key=_get_jwt(),
    )
    agent.run_interactive(skills=skills, resume=resume)
```

---

*最后更新：2026-07-01 v1.1（已实施）*
