# Neta Desktop Op — 通用桌面 GUI Agent(weixin-uia 替代 + Layer 2 基础)

> Status: Draft **v4** 2026-05-14(双 Agent 架构:Reply Agent + Desktop Agent 显式分离,桌面操作以 Tool 形式暴露)
> Owner: 与 lixin 共识
> 取代:`2026-05-09-wechat-uia-channel-design.md`(UIA 在微信 4.1.9.54 验证彻底无效)
> 关联:`2026-05-13-weixin-archive-sync-design.md`(微信消息读路径已落地,本 spec 不动)
> Phase 0 PoC 报告:`docs/superpowers/followups/2026-05-14-visual-agent-poc-raw.json`

## 0. 版本演进

| 版本 | 日期 | 变更要点 |
|---|---|---|
| v1 | 上午 | 首版 spec,UIA 路线被放弃,改纯视觉,内嵌 netaclaw |
| v2 | 下午 | 架构师 review 14 条修订(短回路 ReAct / fire-and-forget / model_channel 集成 / WeixinChannelMutex / final_text 入库 / Phase 0 PoC 风险前置等) |
| v3 | 晚 | Phase 0 PoC 已通过(100% 1 次,核心链路验证)。架构师再 review 后通用化:从"微信回复工具"重定位为"通用桌面 GUI Agent",WeixinAdapter 是第一个 application adapter |
| **v4** | 当晚 | **双 Agent 显式分离 + 桌面操作以 Tool 形式暴露**。架构师再 review:reply agent 不再"自动发"finalContent,改为通过 `delegate_task` 委托 desktop agent;desktop agent 是普通 NetaClaw Agent,toolset=`weixin_desktop`;tool 同步等待 DesktopOpService 执行完成;删除 `weixin_db.replyToGroup` 占位与 `agent_channel.ts` 自动发送块。 |

### v3 → v4 主要变更(8 项,来自双 agent 架构决议)

| # | 变更 | 解决问题 |
|---|---|---|
| **H1** | **频道绑定 2 个 NetaClaw Agent**:`channel.agentId`(对话 reply agent,沿用)+ `channel.config.weixinReply.desktopAgentId`(★ 新,桌面操作 desktop agent) | reply agent 不应承担"如何操作桌面"的细节;未来不同应用可有专属 agent(微信操作 agent / Excel 操作 agent / 浏览器操作 agent...) |
| **H2** | **桌面操作以 Tool 形式暴露**(`weixin_send_text` 等),注册到 toolset=`weixin_desktop`;desktop agent 通过该 toolset 启用 | 复用 NetaClaw 现有 tool 治理 / 配置体系,不引入新抽象;每个 application 对应一个 toolset,扩展自然 |
| **H3** | **删除 `weixin_db.replyToGroup` 占位方法** + **删除 `agent_channel.ts` 的 weixin-db 自动发送分支(行 585-608)** | v3 之前 reply agent 出 finalContent 后,系统层自动调 `replyToGroup` 发到群里(占位 throw not-implemented)。v4 reply agent 必须主动 `delegate_task` 给 desktop agent;weixin_db 只剩读路径职责(WalWatcher / IncrementalReader / health),写路径不属于它 |
| **H4** | **Tool 同步等待**:`DesktopOpService.runAndWait(task, timeoutMs=60000)` 新接口,tool 内部 enqueue + 等待执行完 + 返回 `{ok, error?}` | desktop agent 能拿到真实发送结果,失败可重试 / 降级;符合 ReAct 闭环 |
| **H5** | **不引入 `WeixinReplyHelper`**(v3 设计的 helper 是替换 replyToGroup 的桥,v4 chain 不经过它):`weixin_send_text` tool execute **直接组装 DesktopTask + 调 `DesktopOpService.runAndWait`** | 多余错误代码;少一层抽象,职责更清晰 |
| **H6** | **Desktop Agent 必须配置 multimodal model + `weixin_desktop` toolset + 操作类 prompt**,系统不自动创建"隐式 agent";若 `weixinReply.enabled=true` 但 `desktopAgentId` 未配置,channel 校验时报错 | desktop agent 是普通 NetaClaw Agent,管理员后台可改 prompt / tool / model;未来 ExcelAgent / BrowserAgent 同模式 |
| **H7** | **防 Loop**:Desktop Agent 的 toolset **不含 `delegate_task`**(默认 `weixin_desktop` + `interaction`),从 tool 层面阻断"desktop agent 委托回 reply agent"的环 | 双 agent 委托链单向,避免无限递归 |
| **H8** | **`channel.config.weixinReply.modelChannelId` 字段移除** —— VLM 模型不再在 channel 层配,而是从 desktop agent 自己的 `agent.modelChannelId` 取(因为 desktop agent 已经是普通 NetaClaw Agent,本来就有 modelChannelId) | 避免双重配置;符合"desktop agent 是普通 agent"的定位 |

### v2 → v3 主要变更(9 项,均来自架构师再 review)

| # | 变更 | 解决问题 |
|---|---|---|
| **G1** | 模块从 `modules/netaclaw/runtime/visual_agent/` 迁出到 **`modules/desktop_op/`**,与 netaclaw 平级 | 通用桌面 Agent 不应是 AI 引擎子模块 |
| **G2** | TaskContext 通用化:`target` 改成 `{ appId, conversation/file/url/etc }` 由 adapter 解释 | roomName 微信化 |
| **G3** | **AppAdapter 注册式**:接口存在,MVP 只实现 `WeixinAdapter`,Layer 2 加 ExcelAdapter / BrowserAdapter | 不同应用前置准备/验证逻辑各异 |
| **G4** | 留口:Layer 2 把 desktop_op 注册成 NetaClaw agent 的 tool(本 spec 不实现) | AI agent 能主动调用桌面操作 |
| **G5** | 全局 `desktop_op_config` 表(单行)放共享配置;channel 上仅留 `enabled` + 引用 | 配置层级错乱 |
| **G6** | 命名 `desktop_op`(目录 / 表 / 类前缀全改) | "visual agent" 与 NetaClaw "agent" 概念冲突 |
| **G7** | **SafetyGuard**:应用白名单 + 危险动作硬黑名单(Delete / Win+R / Alt+F4 / shell 命令) | 通用化后能误删文件 / 关机等 |
| **G8** | `WeixinChannelMutex` 升级为 **全局 `DesktopMutex`**(系统只有一块屏幕,任意时刻一个 task 占键鼠) | per-channel 锁不够 |
| **G9** | 留口:Layer 2 暴露 admin HTTP `POST /admin/desktop_op/run-task` 任意触发 | 通用化后需要外部 pull |

### Phase 0 PoC 已经验证(2026-05-14 实跑)

- **模型**: `doubao-seed-2-0-pro-260215`(火山引擎 model_channel id=2)
- **链路**: node-screenshots(captureImageSync)→ Seed 2.0 Pro vision JSON → koffi+nut.js+clip.exe → 文件传输助手实际收到 `[probe-1-1778742201823]`
- **2 次 VLM 调用 / 条**(verify-in-chat + verify-sent),JSON action schema(不用 UI-TARS DSL)
- **单次耗时 ~23s,token ~3800 ≈ ¥0.014/条**
- **关键发现**:
  - 微信主窗口 title 是中文 `微信`(hwnd=135372),英文 `Weixin` 是子窗口 → 找窗口要用 node-screenshots `appName==='Weixin'` 取 isMinimized=false 中面积最大的
  - `node-screenshots` 的 Image 对象会缓存,**截两次必须重新 enumerate 取新 Window 实例**
  - 微信全局搜索 Ctrl+F 第一项是"公众号"不是文件传输助手 → **导航不能直接 Enter**,要 adapter preFlight 处理(MVP 先要求用户手动定位到目标对话)
  - clipboardy v5 是 ESM,不能 require → 改用 `child_process` 调 Windows 自带 `clip.exe`(中文要 UTF-16 LE BOM)

PoC 脚本和 raw 报告已 commit 到仓库。

---

## 1. 背景与目标

### 1.1 UIA 路线为什么必须放弃

详见 `2026-05-09-wechat-uia-channel-design.md` 顶部 OBSOLETE 标记和 `tools/uia_probe/` PoC。要点:微信 4.1.9.54 全 Qt 自绘 + `MMUIRenderSubWindowHW` 硬件加速,UIA 树只有 3 个节点 0 个交互控件,讲述人 / 注册表 / 事件订阅全部无效。

### 1.2 新方案:Desktop Op 通用桌面 Agent

```
   [Windows 桌面应用] (微信 / Office / 浏览器 / ERP 都行,通用)
            ▲
            │ ① Adapter.findWindow() + activate()  (各应用各自实现)
            │
            ▼
   [node-screenshots] 截屏(每次重新 enumerate, 避缓存)
            │
            ▼
   [VLM via model_channel] (默认 doubao-seed-2-0-pro,可换 UI-TARS / Sonnet)
            │
            ▼
   [Parser] (UI-TARS DSL / 通用 JSON / Claude tool format) 注册式按 provider
            │
            ▼
   [SafetyGuard] 应用白名单 + 危险动作黑名单 校验
            │
            ▼
   [DesktopMutex] 全局键鼠锁(任意时刻只一个 task 操作)
            │
            ▼
   [InputController] nut.js 鼠标 + clip.exe 中文剪贴板
            │
            ▼
   [Adapter.verifyResult()] 截屏 + VLM 验证(adapter 自定义)
```

### 1.3 本 spec 目标(★ v4 重写)

打通 **"双 Agent 桌面任务"** 端到端最小可用路径:

```
[群消息 db 增量] → onInbound → reply agent (channel.agentId) ReAct
                                  ↓ 主动调 delegate_task
                              desktop agent (channel.config.weixinReply.desktopAgentId) ReAct
                                  ↓ 主动调 weixin_send_text tool
                              DesktopOpService.runAndWait(task)
                                  ↓
                              DesktopOpRuntime + WeixinAdapter + DesktopMutex
                                  ↓ 截屏 + VLM(走 desktop agent 的 modelChannel) + 鼠键 + verify
                              返回 { ok, error? }
```

MVP 唯一实现 `WeixinAdapter` + `weixin_send_text` tool;**删除 `weixin_db.replyToGroup` 占位** + **删除 `agent_channel.ts` 自动发送块**(reply agent 必须主动 delegate)。Layer 2 其他 Adapter / Toolset 零拆架构追加。

### 1.4 双 Agent 模型(★ v4 新增,核心设计)

#### 1.4.1 职责分工

| Agent | 配置位置 | 职责 | 典型 prompt | 典型 toolset | 典型 model |
|---|---|---|---|---|---|
| **Reply Agent**(对话 agent)| `channel.agentId`(沿用) | 理解群消息 + 决定怎么回复 + 决定要不要 delegate 给 desktop agent | "你是 XX 客服,看到群消息后判断..." | `base` + `interaction` + `crew`(含 `delegate_task`) | 普通文本 LLM(可不带 vision) |
| **Desktop Agent**(桌面操作 agent)| `channel.config.weixinReply.desktopAgentId`(★ 新) | 接受 reply agent 的委托任务,通过 `weixin_send_text` 等 tool 完成"在群里发消息" | "你是微信桌面操作助手,接到任务后用 weixin_* 工具完成发送..." | `weixin_desktop` + `interaction`(★ 不给 `delegate_task` 防 loop) | multimodal VLM(火山 Seed-2.0-pro,因为 tool 内部 VLM 验证用同一 modelChannel) |

#### 1.4.2 为什么是两个独立 Agent 而不是 Tool + 单 Agent

- **上下文隔离**:reply agent 关心"客户在问什么",desktop agent 关心"如何点鼠标",两份 context 不应互相污染
- **可独立调优**:微信操作出 bug 时只改 desktop agent prompt 不影响 reply 逻辑
- **可扩展性**:Layer 2 加 Excel 自动化时新增 `excelAgent`,channel 编辑页选择即可,reply agent 一行代码不用动
- **可观测性**:两个 agent 各自有 session 历史,审计独立

#### 1.4.3 防 Loop 设计

- Desktop agent 的 toolset **必须不含 `delegate_task`/`delegate_parallel`**(管理后台保存 desktop agent 时校验)
- Reply agent → desktop agent 是单向委托链,desktop agent 不能再 delegate 任何其他 agent
- `delegate_task` tool 调用时附 metadata `{delegationDepth: N}`,N > 1 直接拒绝(双保险)

### 1.5 显式 out-of-scope (MVP)

- ❌ 其他 Application Adapter(Excel / Browser / ERP)— 接口留口,MVP 只 Weixin
- ❌ NetaClaw agent 注册成 tool(G4)— 留口,MVP 不实现
- ❌ admin HTTP `/run-task` 外部触发(G9)— 留口,MVP 内部调用
- ❌ 自托管 vLLM
- ❌ 多模型 ensemble / fallback chain
- ❌ C# 独立桥进程(纯 Node 内嵌)
- ❌ 图片/视频/语音/文件发送
- ❌ @某人 / 引用回复(ActionStep schema 留口 `{type:'mention'}`)
- ❌ 危险动作二次确认 UI(MVP 仅硬黑名单)
- ❌ 录像审计回放
- ❌ 贝塞尔曲线鼠标轨迹

## 2. 用户视角

### 2.1 微信场景接入(★ v4 重写)

```
1. db 增量监听:WalWatcher(weixin_db.ts 读路径,不动)
        ↓ onInbound(channelId, pseudo)
2. agent_channel.routeInboundMessage(基本不动,但 weixin-db 分支删除自动发送块)
        ↓
3. reply agent ReAct(channel.agentId,沿用现有 agent_executor)
        ↓ 主动决策:
        ↓   - 这条消息要不要回复?(不回复直接结束 ReAct,finalContent 不发任何地方)
        ↓   - 要回复的话,内容是什么?
        ↓   - 调 delegate_task tool
4. delegate_task({ agentId: channel.config.weixinReply.desktopAgentId, task: "在群 ABC 发送文字: 你好" })
        ↓
5. desktop agent ReAct(普通 NetaClaw Agent,toolset='weixin_desktop')
        ↓ 主动决策:
        ↓   - 用哪个 tool?(MVP 只有 weixin_send_text)
        ↓   - 调 weixin_send_text({ roomName: 'ABC', text: '你好' })
6. weixin_send_text tool execute
        ↓ 组装 DesktopTask
        ↓ await DesktopOpService.runAndWait(task, 60000)
        ↓ 返回 { ok, error? } 给 desktop agent
7. desktop agent 拿到 tool 结果决定:
        - 成功 → 结束 ReAct,返回 "已发送" 给 reply agent
        - 失败 → 选择 retry / clarify / abort,返回相应内容
8. reply agent 拿到 delegate_task 返回值继续 ReAct(通常直接结束)
```

整个链路 reply agent 同步 await delegate,delegate 内部 await tool,tool 内部 await runAndWait,**端到端同步**,但桌面操作内部用 mutex 串行不阻塞读路径。

senderQueue 不再参与微信回复链路 — v3/v2 的 "fire-and-forget 进 senderQueue" 决策只适用于 reply agent 也是 fire-and-forget 的旧设计,v4 不需要。

### 2.2 用户感知

对最终用户**完全透明** — 群消息进来 → AI 处理 → 自动回复出现在群里(约 5-40s 延迟,2 agent + VLM 调用相加)。

管理后台:
- **频道编辑页**(微信场景)新增 "自动回复" 区块:
  - 启用开关
  - **对话 Agent 下拉**(就是 `channel.agentId`,沿用)
  - **桌面操作 Agent 下拉**(★ 新,`channel.config.weixinReply.desktopAgentId`,列表过滤 `toolset.includes('weixin_desktop')` 的 agent)
  - 风控 / 水印 / 限频
  - 风险提示文案
- **NetaClaw Agent 管理**(沿用):管理员可创建"桌面操作 Agent"型 agent:挑 toolset = `weixin_desktop` + `interaction`、挑 multimodal modelChannel(Seed-2.0-pro)、写 prompt(MVP 提供默认模板)
- **系统设置**(新增,Layer 2 完善):全局 `desktop_op_config` 表 — 应用白名单、危险按键、全局限频默认值
- **审计页面**(后续):`desktop_op_action_log` 列表

### 2.3 Phase 0 PoC 暴露的"导航问题"(★ v4 强调影响)

PoC 验证发现:微信全局搜索 Ctrl+F 第一项常是"公众号"而非用户搜的目标 → **MVP WeixinAdapter 不依赖 Ctrl+F**。

WeixinAdapter 的 preFlightCheck 策略:
1. **首版**:**要求 task.target.conversation 已经被用户手动定位为当前对话**(简化版,先把核心闭环跑通)
2. **后续**:adapter 内部用方向键 + Enter / 视觉点击列表项导航,VLM 验证位置

**★ MVP 严重限制(必须文档化告知用户)**:
- **单对话假设**:MVP 期间,微信必须保持在"目标群对话"页面,desktop agent 不会自动切换对话
- **多群消息并发到达**:若 2 个监听中的群同时有消息进来:
  - 全局 DesktopMutex 串行 → 第二条任务排队等第一条完成
  - 第二条任务执行时,微信当前对话页面**仍是第一条目标群**(因为没人切对话)
  - WeixinAdapter.preFlightCheck VLM 验证"当前对话是不是第二条目标群?" → **失败,抛 precondition-failed**
  - desktop agent 收到失败 → 按 prompt 决定不重试 → reply agent 收到失败 → 第二条消息**实际无法发送**
- **运维建议**:MVP 阶段建议每个 backend 实例**只监听一个微信群**(在 channel 上配 group 白名单只填 1 个);多群场景留给 Layer 2 用 adapter 自动切换对话能力解决

错误反馈表(MVP):

| 失败场景 | 行为 |
|---|---|
| Adapter findWindow 返回 null | 重试 ≤ 3 次(每次 activate),失败记 `window-not-found` |
| Adapter preFlightCheck 失败(对话不对) | 记 `precondition-failed: <reason>`,不重试 |
| SafetyGuard 拒绝 appId | 记 `app-not-allowed`,不重试 |
| SafetyGuard 拒绝危险动作 | 记 `dangerous-action-blocked`,中断当前 task |
| 截屏失败 | 重试 ≤ 3 次 + 重 enumerate Window |
| VLM API 限速/超时 | 退避重试 ≤ 3 次,记 `model-failed` |
| 模型坐标超截图范围 | 记 `model-hallucinated`,丢弃 |
| Adapter verifyResult 失败 | 重试 1 次,仍失败记 `verify-failed` |
| 用户在用电脑(前台非目标 app) | 让位 sleep 5s × 3 次 |
| 用户后台禁用 enabled | AbortSignal 中断 |
| pending queue 全局 > 50 / per-app > 20 | 丢弃最老 + 记 `queue-overflow` |

## 3. 整体架构

### 3.1 进程模型

不变(v2 决策):Node backend 进程内嵌,无 C# 桥。bootstrap 入口调 `ensureDpiAware()` 声明 Per-Monitor v2。

### 3.2 模块分层(★ v4 重组,删除 helper,新增 weixin_desktop toolset)

```
modules/
├── desktop_op/                              ← 新增,通用桌面 Agent runtime
│   ├── runtime/
│   │   ├── types.ts                  通用 DesktopTask / ActionStep / TaskResult / AdapterContext
│   │   ├── dpi.ts
│   │   ├── screenshot.ts             node-screenshots 包装(每次 enumerate)
│   │   ├── input.ts                  nut.js + clip.exe(child_process)
│   │   ├── window_locator.ts         通用窗口工具(adapter 共用)
│   │   ├── desktop_mutex.ts          ★ 全局键鼠锁
│   │   ├── safety_guard.ts           ★ 应用白名单 + 危险动作黑名单
│   │   ├── rate_limiter.ts           per-app / per-target / daily
│   │   ├── parser/
│   │   │   ├── parser.ts             interface
│   │   │   ├── json_action_parser.ts MVP 默认(Seed 2.0 Pro JSON 输出)
│   │   │   └── registry.ts           按 modelChannel.providerType 选
│   │   ├── adapters/                 ★ AppAdapter 注册式
│   │   │   ├── adapter.ts            interface AppAdapter
│   │   │   ├── weixin_adapter.ts     MVP 唯一
│   │   │   └── registry.ts           按 task.appId 选
│   │   ├── vlm_client.ts             OpenAI 兼容多模态(走 model_channel,从 desktop agent 传入 modelChannelId)
│   │   ├── action_executor.ts
│   │   └── runtime.ts                DesktopOpRuntime: runTask(task, abort)
│   ├── service/
│   │   └── desktop_op.ts             DesktopOpService: enqueue / runAndWait / per-app worker / abortByFilter
│   ├── entity/
│   │   ├── desktop_op_action_log.ts
│   │   └── desktop_op_config.ts      全局配置表(单行,不含 default_model_channel_id 因为 model 走 agent)
│   └── controller/admin/
│       ├── desktop_op_action_log.ts  /list /info
│       └── desktop_op_config.ts      get / update
│
└── netaclaw/                                ← 既有
    ├── tools/builtin/
    │   └── weixin_send_text.ts       ★ v4 新增 — toolset='weixin_desktop',execute 内部调 DesktopOpService.runAndWait
    ├── tools/catalog.ts              ← 修改 import 加 weixin_send_text + 加 TOOLSET_WEIXIN_DESKTOP 常量
    └── service/
        ├── weixin_db.ts              ← 修改:删除 replyToGroup 方法(行 166-171)+ 修改类注释
        └── agent_channel.ts          ← 修改:删除 weixin-db 分支自动发送块(行 585-608)+ delete cascade abort
```

**v3 → v4 文件变更摘要**:
- ❌ 删除 plan v3 的 `modules/netaclaw/service/weixin_reply_helper.ts` 计划文件(不创建)
- ❌ 删除 `weixin_db.replyToGroup` 方法(占位)
- ❌ 删除 `agent_channel.ts:585-608` 自动发送块
- ✅ 新增 `modules/netaclaw/tools/builtin/weixin_send_text.ts`(toolset='weixin_desktop' 的 tool)
- ✅ 修改 `modules/netaclaw/tools/catalog.ts` 注册新 toolset
- ⚙ 修改 `desktop_op/service/desktop_op.ts` 加 `runAndWait` 方法(同步等待)

### 3.3 ReAct 拓扑(v3 简化:adapter 主导)

`DesktopOpRuntime.runTask(task, abort)` 内部:

```ts
async runTask(task, abort) {
  abort.throwIfAborted();
  this.safety.validateAppId(task.appId);
  this.safety.validateTaskShape(task);
  const adapter = this.adapterRegistry.get(task.appId);

  const win = await adapter.findWindow(task.target);
  if (!win) throw new Error('window-not-found');
  await this.windowLocator.activate(win);
  await this.delay(500);
  abort.throwIfAborted();

  const ctx = this.buildAdapterContext(task, win);
  await adapter.preFlightCheck(task, ctx);

  const steps = await adapter.buildSteps(task);
  for (const step of steps) {
    abort.throwIfAborted();
    this.safety.validateAction(step);
    await this.actionExecutor.execute(step);
    await this.delay(jitter(200, 800));
  }

  const ok = await adapter.verifyResult(task, ctx);
  if (!ok) throw new Error('verify-failed');

  return { ok: true, modelCalls: ctx.modelCalls, steps: steps.length, durationMs: ... };
}
```

`adapter.preFlightCheck` / `verifyResult` 可调 VLM(adapter 在 ctx 上能访问 vlmClient + parserRegistry),也可以纯规则。WeixinAdapter MVP:

```ts
class WeixinAdapter implements AppAdapter {
  appId = 'weixin';
  supportedActions = ['send-text'];

  async findWindow(target) {
    return windowLocator.findByAppName('Weixin', { skipMinimized: true, largest: true });
  }

  async preFlightCheck(task, ctx) {
    const shot = await ctx.screenshot.capture(ctx.window);
    const ok = await ctx.vlm.verifyState(
      ctx, `当前微信打开的聊天顶部标题是不是 "${task.target.conversation}"?`, shot,
    );
    if (!ok) throw new Error('precondition-failed: not in target chat. Please open the chat manually first (MVP limitation).');
  }

  async buildSteps(task) {
    return [
      { type: 'clipboard-write', text: task.params.text },
      { type: 'hotkey', key: 'ctrl+v' },
      { type: 'wait', ms: 400 },
      { type: 'hotkey', key: 'enter' },
      { type: 'wait', ms: 800 },
    ];
  }

  async verifyResult(task, ctx) {
    const shot = await ctx.screenshot.capture(ctx.window);
    return ctx.vlm.verifyState(
      ctx, `右下角最新一条己方消息是否包含 "${task.params.text.slice(0, 50)}" ?`, shot,
    );
  }

  queueKey(target) { return target.conversation ?? 'default'; }
}
```

### 3.4 中文输入(沿用 v2 + PoC 验证修订)

**clipboardy v5 是 ESM,Node CJS 调用失败** — 改用 `child_process.spawnSync('clip.exe', { input: utf16leBomBuffer })`(PoC 已验)。

后续可上 `koffi` 调 Win32 `OpenClipboard / SetClipboardData(CF_UNICODETEXT, ...)`,避免 spawn 开销 — 留作优化。

### 3.5 全局 DesktopMutex(★ v3 升级)

```ts
@Provide() @Scope(ScopeEnum.Singleton)
export class DesktopMutex {
  private busy: { taskId: string; appId: string } | null = null;
  private waiters: Array<{ resolve: () => void; taskId: string; appId: string }> = [];

  async acquire(taskId: string, appId: string): Promise<() => void> {
    if (this.busy) {
      await new Promise<void>(r => this.waiters.push({ resolve: r, taskId, appId }));
    }
    this.busy = { taskId, appId };
    return () => {
      this.busy = null;
      const next = this.waiters.shift();
      next?.resolve();
    };
  }
}
```

理由:系统只有一块屏幕、一对键鼠,任意时刻只能一个 task 操作前台。`weixin-archive sync` **不需要这把锁**(只读 SQLite 不占前台),其内部的 `WeixinChannelMutex` 保留(防同 channel 并发同步)。

### 3.6 SafetyGuard(★ v3 新增)

```ts
@Provide() @Scope(ScopeEnum.Singleton)
export class SafetyGuard {
  private allowedApps = new Set<string>(['weixin']);
  private dangerousKeys = new Set<string>([
    'delete', 'win+r', 'alt+f4', 'win+l',
    'ctrl+alt+delete', 'ctrl+shift+esc',
  ]);
  private dangerousActionTypes = new Set<string>(['shell-command', 'kill-process']);

  validateAppId(appId: string): void {
    if (!this.allowedApps.has(appId)) throw new Error('app-not-allowed: ' + appId);
  }

  validateAction(step: ActionStep): void {
    if (step.type === 'hotkey' && this.dangerousKeys.has(step.key.toLowerCase())) {
      throw new Error('dangerous-key-blocked: ' + step.key);
    }
    if (this.dangerousActionTypes.has(step.type as any)) {
      throw new Error('dangerous-action-blocked: ' + step.type);
    }
  }

  async loadConfig(cfg: DesktopOpConfig): Promise<void> {
    this.allowedApps = new Set(cfg.allowedApps ?? ['weixin']);
    if (cfg.extraDangerousKeys) for (const k of cfg.extraDangerousKeys) this.dangerousKeys.add(k);
  }
}
```

### 3.7 DesktopOpService.runAndWait(★ v4 新增,同步等待 API)

```ts
@Provide() @Scope(ScopeEnum.Singleton)
export class DesktopOpService {
  /**
   * 同步等待版本 — tool execute 调用入口。
   * 内部:enqueue + 等 worker 真正执行完 + 返回结果。
   * 超时(默认 60s)抛 task-timeout。失败抛 status 对应的 Error。
   */
  async runAndWait(task: DesktopTask, timeoutMs = 60000): Promise<{ ok: true; taskId: string; modelCalls: number; steps: number; durationMs: number }>;

  /** fire-and-forget(Layer 2 留口) — 不等结果 */
  enqueue(task: DesktopTask): { taskId: string; queuePosition: number };

  /** cascade 取消 */
  abortByFilter(filter: (task: DesktopTask) => boolean, reason: string): void;
}
```

实现:`runAndWait` 内部维护 `Map<taskId, { resolve, reject, timer }>`,workerLoop 完成时根据 status 调用 `resolve({ok:true, ...})` 或 `reject(new Error(status))`;timer 超时调 `reject(task-timeout)` + 取消该 task。

### 3.8 后台 worker + per-app 队列(★ v3 设计沿用,v4 加 runAndWait 回调)

```ts
@Provide() @Scope(ScopeEnum.Singleton)
export class DesktopOpService {
  // queue key = appId + adapter.queueKey(target), 同 conversation/file 串行
  private readonly queues = new Map<string, DesktopTask[]>();
  private readonly workers = new Map<string, Promise<void>>();
  private readonly aborters = new Map<string, AbortController>();

  enqueue(task: DesktopTask): { taskId: string; queuePosition: number } {
    const key = this.queueKey(task);
    const q = this.queues.get(key) ?? [];
    if (q.length >= 20) {
      const dropped = q.shift()!;
      this.logRepo.save({ ...dropped, status: 'queue-overflow' });
    }
    q.push(task);
    this.queues.set(key, q);
    this.ensureWorker(key);
    return { taskId: task.id, queuePosition: q.length };
  }

  private async workerLoop(key: string) {
    while (true) {
      const q = this.queues.get(key);
      if (!q || q.length === 0) return;
      const task = q.shift()!;
      const abort = new AbortController();
      this.aborters.set(task.id, abort);
      try {
        const release = await this.desktopMutex.acquire(task.id, task.appId);
        try {
          const result = await this.runtime.runTask(task, abort.signal);
          this.logRepo.save({ ...task, ...result, status: 'success' });
        } finally { release(); }
      } catch (err: any) {
        this.logRepo.save({ ...task, status: errStatus(err), error: err.message,
          abortedReason: abort.signal.aborted ? abort.signal.reason : null });
      } finally {
        this.aborters.delete(task.id);
      }
    }
  }

  abortByFilter(filter: (task: DesktopTask) => boolean, reason: string): void {
    for (const [key, q] of this.queues.entries()) {
      const remaining = q.filter(t => {
        if (!filter(t)) return true;
        this.logRepo.save({ ...t, status: 'aborted', abortedReason: reason });
        return false;
      });
      this.queues.set(key, remaining);
    }
    // 正在跑的也取消(简化:全 abort)
    for (const abort of this.aborters.values()) abort.abort(reason);
  }

  private queueKey(task: DesktopTask): string {
    const adapter = this.adapterRegistry.get(task.appId);
    return `${task.appId}:${adapter.queueKey(task.target)}`;
  }
}
```

理由:**per-app+target 队列**(同对话串行,不同对话/不同 app 物理上仍被全局 mutex 串行,但 queue 层 allow 并发让短任务不被长任务堵)。

### 3.8 故障域(沿用 v2)

(略,通用化措辞)

## 4. 数据模型

### 4.1 `desktop_op_config`(全局,单行,★ v4 移除 default_model_channel_id)

```sql
CREATE TABLE desktop_op_config (
  id           INT AUTO_INCREMENT PRIMARY KEY,
  allowed_apps               JSON NOT NULL,        -- ['weixin', 'excel', ...]
  extra_dangerous_keys       JSON,
  global_per_min             INT NOT NULL DEFAULT 30,
  global_per_day             INT NOT NULL DEFAULT 1000,
  default_watermark          VARCHAR(32) DEFAULT 'suffix',
  updated_at                 DATETIME NOT NULL
);
```

**v3 → v4 字段变更**:
- ❌ 移除 `default_model_channel_id` — model 走 desktop agent 的 `agent.modelChannelId`,全局默认无用

### 4.2 `channel.config.weixinReply`(★ v4 加 desktopAgentId,移除 modelChannelId)

```ts
config: {
  weixinReply?: {
    enabled: boolean;                  // 默认 false
    desktopAgentId: number;            // ★ v4 必填:桌面操作 NetaClaw Agent ID(toolset 须含 'weixin_desktop')
    perGroupPerMinute?: number;        // 默认 3
    perChannelPerMinute?: number;      // 默认 10
    safeMode?: boolean;                // 默认 true
    dailyLimit?: number;               // 默认 100
    watermark?: 'none' | 'suffix' | 'zero-width';
  }
}
```

**v3 → v4 字段变更**:
- ❌ 移除 `modelChannelId` — VLM 模型从 desktop agent 自己的 `agent.modelChannelId` 取(desktop agent 已经是普通 NetaClaw Agent,本来就配 model)
- ✅ 新增 `desktopAgentId` — 必填(`enabled=true` 时校验,后端在 `agent_channel.update` 时验)
- ✅ 校验规则:
  - `desktopAgentId` 对应的 agent 必须存在
  - 该 agent 的 toolset 必须含 `weixin_desktop`(防误配)
  - 该 agent 的 toolset 不能含 `delegate_task` / `delegate_parallel`(防 loop)

### 4.2.1 Reply Agent / Desktop Agent 配置约束(★ v4,实际字段名以 `entity/agent.ts` 为准)

**实际 entity 字段**(见 `modules/netaclaw/entity/agent.ts:50-78`):
- `agent.toolsets: string[]` — **复数**,数组(我们要求 数组**包含** 某个 toolset)
- `agent.tools.perTool[toolName].allowInSubagent: boolean` — tool 在 subagent context 能否调用
- `agent.tools.perTool[toolName].workerRoutingStrategy: 'auto' | 'force-local' | 'force-main-process-proxy' | 'force-disabled'` — tool 在 subprocess subagent 模式下的路由策略
- `agent.subagentConfig.allowedPresetAgentIds: number[]` — 该 agent 作为 supervisor 时能 delegate 到哪些 preset agent

**精确校验规则**(`agent_channel.update` 时,Task 15.5):

- Reply agent (`channel.agentId`):
  - `agent.toolsets` 数组必须包含 **`'crew'`**(`delegate_task` 注册在 toolset='crew',见 `tools/builtin/delegate_task.ts:115`)
  - 若 channel `weixinReply.enabled=true` 但 reply agent 的 `toolsets` 不含 `'crew'` → 校验失败,提示 "对话 Agent 必须启用 crew toolset 以委托给桌面 Agent"
  - **推荐配置**(MVP 不强制,但前端 hint):`agent.subagentConfig.allowedPresetAgentIds = [desktopAgentId]`,限定 reply agent 只能 delegate 到约定的 desktop agent

- Desktop agent (`channel.config.weixinReply.desktopAgentId`):
  - `agent.toolsets` 数组必须包含 **`'weixin_desktop'`**
  - `agent.toolsets` 数组 **不**能包含 `'crew'`(防 loop:desktop agent 不能再 delegate)
  - **必须自动配置**(`agent_channel.update` 保存时若未配置则自动补齐 + 前端校验):
    - `agent.tools.perTool['weixin_send_text'].allowInSubagent = true`(否则 subagent 跑不了这个 tool)
    - `agent.tools.perTool['weixin_send_text'].workerRoutingStrategy = 'force-main-process-proxy'`(关键!即使 subagent 模式切到 subprocess,tool 也 proxy 回 main process 执行 → DesktopMutex 单 process 实例完全有效)
  - 校验失败时报错:"桌面操作 Agent 必须仅含 weixin_desktop 且不含 crew"
  - 该 agent 的 `modelChannelId` 对应 model_channel 应是 multimodal 模型(前端给提示,后端不强制 — 因为 prompt 写得好普通 LLM 也能跑)
  - **`buildRestrictedPrompt`**(`service/subagent.ts:166-186`)已经在 subagent prompt 自动注入英文 "You cannot delegate further subagents" — 这是 prompt 层防 loop 双保险

### 4.2.2 bizContext 透传机制(★ v4 新增,含 currentAgent 注入)

桌面工具需要 `channelId` / `roomName` 业务上下文,也需要"当前调用 tool 的 agent"的 `modelChannelId` 给 DesktopOp 内部 VLM 调用。MVP 采用扩展 `NetaToolRuntimeContext` 自动透传:

```ts
// modules/netaclaw/tools/runtime_context.ts(扩展)
export interface NetaToolRuntimeBizContext {
  channelId?: number;
  roomName?: string;
  // 限制只能是 JSON-safe 类型(primitive / array / plain object)
  // inject 函数运行时校验 JSON.stringify(value) 必须成功
  [k: string]: string | number | boolean | null | undefined | object | any[];
}

export interface NetaToolRuntimeCurrentAgent {
  id: number;
  name: string;
  modelChannelId: number | null;
  toolsets: string[];
}

export interface NetaToolRuntimeContext {
  sessionCwd?: string | null;
  workspaceRoots?: string[];
  bizContext?: NetaToolRuntimeBizContext;        // ★ v4 新增
  currentAgent?: NetaToolRuntimeCurrentAgent;    // ★ v4 新增(替代 D1 中的 currentAgentModelChannelId)
}
```

**注入点 1**:`agent_channel.handleInboundMessage` 调用 reply agent 前,在 `AgentRunParams.runtime` 加:
```ts
runtime: {
  sessionCwd: ...,
  workspaceRoots: ...,
  bizContext: { channelId: channel.id, roomName },
  currentAgent: { id: replyAgent.id, modelChannelId: replyAgent.modelChannelId, ... },
}
```

**注入点 2**:`NetaClawSubagentService.runPreparedExecution` 调 `agentRunner` 时,**继承 parent 的 bizContext** 同时**替换 currentAgent 为 subagent 自己**:
```ts
runtime: {
  ...parentRuntime,          // 含 bizContext(channelId/roomName 不变)
  currentAgent: { id: subagentEntity.id, modelChannelId: subagentEntity.modelChannelId, ... },  // 替换
}
```

**Subprocess 模式继承**:`process_runner.ts` 的 IPC envelope 把 runtime JSON 序列化随 task 发到 worker process,worker 在内部 `agent_executor` 里照样 `injectToolRuntimeContext` 到 tool args(校验 JSON-safe 失败则报 'biz-context-not-serializable')。

**Tool 端读取**:`weixin_send_text` execute 时通过 `readToolRuntimeContext(params)`:
- `bizContext.channelId / bizContext.roomName` — 业务上下文,优先用;fallback 用 params 显式传入的(LLM 在 goal 里写的兜底)
- `currentAgent.modelChannelId` — DesktopTask 的 modelChannelId 字段从这里取,**这就是 desktop agent 自己配置的 model**

这样既保证默认行为正确(LLM 不传 channelId 也能跑),又允许显式覆盖,且解决了 "tool 如何拿到当前调用 agent 的 modelChannelId" 的问题。

### 4.3 `desktop_op_action_log`(★ 改名 + 通用化)

```sql
CREATE TABLE desktop_op_action_log (
  id              BIGINT AUTO_INCREMENT PRIMARY KEY,
  task_id         VARCHAR(64) NOT NULL,
  app_id          VARCHAR(32) NOT NULL,
  target_json     JSON,                       -- target 完整 JSON
  action_type     VARCHAR(32) NOT NULL,
  params_preview  VARCHAR(200),
  final_text      TEXT,
  channel_id      BIGINT,                     -- 微信场景关联
  room_name       VARCHAR(128),
  model_channel_id BIGINT,
  model_calls     INT NOT NULL DEFAULT 0,
  steps           INT NOT NULL DEFAULT 0,
  duration_ms     INT NOT NULL DEFAULT 0,
  status          VARCHAR(32) NOT NULL,
  error           TEXT,
  aborted_reason  VARCHAR(64),
  created_at      DATETIME NOT NULL,
  INDEX idx_app_time (app_id, created_at DESC),
  INDEX idx_channel_time (channel_id, created_at DESC),
  INDEX idx_status_time (status, created_at DESC)
);
```

截图默认不入库;`process.env.NETA_DESKTOP_OP_DEBUG=1` 时落盘。

## 5. 接口契约

### 5.1 类型(modules/desktop_op/runtime/types.ts)

```ts
export interface DesktopTask {
  id: string;
  appId: string;
  target: any;                 // adapter 自定义结构
  actionType: string;
  params: any;
  modelChannelId?: number;
  maxSteps?: number;
  enqueuedAt: number;
}

export type ActionStep =
  | { type: 'click'; x: number; y: number; thought?: string }
  | { type: 'hotkey'; key: string; thought?: string }
  | { type: 'clipboard-write'; text: string }
  | { type: 'type'; text: string; thought?: string }
  | { type: 'wait'; ms: number }
  | { type: 'mention'; wxid: string }
  | { type: 'finished' }
  | { type: 'failed'; reason: string };

export interface TaskResult { ok: boolean; modelCalls: number; steps: number; durationMs: number; }
```

### 5.2 AppAdapter 接口

```ts
export interface AdapterContext {
  window: WindowHandle;
  screenshot: Screenshooter;
  input: InputController;
  vlm: VlmClient;
  parser: Parser;
  logger: ILogger;
  task: DesktopTask;
  modelCalls: number;          // runtime 维护
}

export interface AppAdapter {
  appId: string;
  supportedActions: string[];
  findWindow(target: any): Promise<WindowHandle | null>;
  preFlightCheck(task: DesktopTask, ctx: AdapterContext): Promise<void>;
  buildSteps(task: DesktopTask): Promise<ActionStep[]>;
  verifyResult(task: DesktopTask, ctx: AdapterContext): Promise<boolean>;
  queueKey(target: any): string;
}
```

### 5.3 主服务(★ v4 加 runAndWait)

```ts
@Provide() @Scope(ScopeEnum.Singleton)
export class DesktopOpService {
  /** ★ v4 新增:同步等待入口,tool execute 调用 */
  async runAndWait(task: DesktopTask, timeoutMs = 60000):
    Promise<{ ok: true; taskId: string; modelCalls: number; steps: number; durationMs: number }>;

  /** fire-and-forget(Layer 2 / 后台任务用)*/
  enqueue(task: DesktopTask): { taskId: string; queuePosition: number };

  /** cascade 取消 */
  abortByFilter(filter: (task: DesktopTask) => boolean, reason: string): void;
}
```

### 5.4 weixin_desktop Toolset(★ v4 新增,取代 v3 WeixinReplyHelper)

**Toolset 常量**:在 `modules/netaclaw/tools/catalog.ts` 加 `export const TOOLSET_WEIXIN_DESKTOP = 'weixin_desktop' as const;`

**Tool 列表(MVP)**:

| Tool 名 | 参数 schema | 实现要点 |
|---|---|---|
| `weixin_send_text` | `{ roomName: string, text: string, channelId: number }` | execute:校验 `text.length > 0 && text.length < 2000`;查 channel(用 channelId)拿 `weixinReply.watermark` 加水印;组装 DesktopTask `{appId:'weixin', target:{conversation:roomName, channelId, roomName}, actionType:'send-text', params:{text:finalText, originalText:text}, modelChannelId: <agent.modelChannelId>, enqueuedAt: Date.now(), id: randomUUID()}`;`await desktopOpService.runAndWait(task, 60000)`;成功返回 `textResult('已在群 X 发送: Y')`,失败 throw |

**实现位置**:`modules/netaclaw/tools/builtin/weixin_send_text.ts`

**注册**:`tools/catalog.ts` 增加 `import './builtin/weixin_send_text.js';`

**modelChannelId 获取**:tool execute 需要从"当前调用 tool 的 agent"取 modelChannelId — 现有 tool 调用上下文里通过 `ctx.agentId` / `ctx.modelChannelId` 注入(具体实现参见 `tool_resolver.ts` 现有约定)。

**channelId 来源**:reply agent delegate_task 时把 `channelId` 写到任务上下文 / 输入参数,desktop agent 在调用 `weixin_send_text` 时把它从 prompt 上下文取出传入。MVP 简化:reply agent 的 prompt 模板里说明"请把 channelId 包含在任务描述里"。Layer 2 改成通过 NetaClaw 的 sessionContext 自动透传。

### 5.5 已删除项(★ v4)

- ❌ `WeixinReplyHelper`(v3 设计)— 不创建
- ❌ `weixin_db.replyToGroup`(占位 + v3 委托设计)— 删除整个方法
- ❌ `agent_channel.ts:585-608` 自动发送块 — 删除整个分支

## 6. 前端

### 6.1 频道编辑页(微信场景,★ v4 重写:双 Agent 选择)

```
[微信自动回复]
  自动回复:        ( ) 禁用    ( ) 启用

  对话 Agent:     [ 下拉: agent.list({}) ]   ← channel.agentId
                   说明: 负责理解群消息+决定如何委托给桌面 agent
                   ⚠️ 该 agent 的 toolset 必须含 'delegate_task'

  桌面操作 Agent:  [ 下拉: agent.list({tooledWith: 'weixin_desktop'}) ]   ← channel.config.weixinReply.desktopAgentId
                   说明: 负责接受委托并通过微信桌面工具发送消息
                   ⚠️ 该 agent 的 toolset 必须含 'weixin_desktop' 且不含 'delegate_task'

  小号安全模式:    [×]
  每天上限:        [ 100 ]
  每群每分钟:      [ 3 ]
  消息水印:        ( ) 不加  (•) 后缀 ' —AI'  ( ) 零宽空格
  风险提示:        "..."
```

**前端校验逻辑**:
- enabled=true 且 desktopAgentId 未选 → 表单错误
- 对话 Agent 与桌面操作 Agent **不能是同一个**(虽然技术上可行,但语义上不对,前端提醒)
- 提交后端校验:reply agent toolset 含 `delegate_task`、desktop agent toolset 含 `weixin_desktop` 且不含 `delegate_task`(后端 channel.update 时校验)

### 6.2 NetaClaw Agent 管理(沿用,新增"桌面操作 Agent"模板)

管理员创建 desktop agent 时:
- toolset 选 `weixin_desktop` + `interaction`(不选 `crew`/`delegate_*`)
- modelChannel 选 multimodal(火山 Seed-2.0-pro)
- prompt 用默认模板(参见 §7.2.2)

### 6.3 系统设置 → Desktop Op(MVP 用默认值,完整 UI 留 Layer 2)

```
[Desktop Op 全局设置]
  应用白名单:     [ chip 列表: weixin (+) ]
  额外危险按键:   [ chip 列表 ]
  全局每分钟上限: [ 30 ]
  全局每天上限:   [ 1000 ]
  默认水印:       [ none / suffix / zero-width ]
```

## 7. 模型与外部依赖

### 7.1 model 走 model_channel(★ v4 model 来自 desktop agent)

`vlm_client` 根据 `task.modelChannelId` 查 model_channel 拿凭据,根据 providerType 用 parserRegistry 选 Parser。

**modelChannelId 来源链(v4)**:
```
weixin_send_text tool execute(从 tool 调用上下文取 desktop agent 的 modelChannelId)
       ↓
组装 DesktopTask.modelChannelId = <desktop agent.modelChannelId>
       ↓
DesktopOpService.runAndWait(task) → DesktopOpRuntime.runTask
       ↓
VlmClient 用 task.modelChannelId 取凭据
```

**MVP 默认 Parser**: `JsonActionParser`(不是 UI-TARS DSL)。Phase 0 PoC 已验证 Seed 2.0 Pro 输出 JSON action 可解析。Layer 2 加 `UITarsParser`。

### 7.2 prompt 模板

#### 7.2.1 Parser System Prompt(adapter ctx 注入)

`JsonActionParser.buildSystemPrompt(task, adapterCtx)` 由 adapter 协助拼接(不同 app 上下文不同)。仅用于 adapter.preFlightCheck / verifyResult 的 VLM 调用,因为 MVP 的 buildSteps 是硬编码,不需要 VLM 推理 next action。

#### 7.2.2 Desktop Agent 默认 Prompt 模板(★ v4 新增)

```
你是"微信桌面操作助手 Agent",你的工作是接受其他 Agent 委托,使用桌面工具在微信窗口中完成具体操作。

你可用的工具:
- weixin_send_text({roomName, text, channelId?}):在指定群里发送一段文字
  - channelId 可不填,系统会自动从 bizContext 注入当前对话的 channelId

操作原则:
1. 任务描述必须包含目标群名(roomName)和要发的文本(text);channelId 一般不需要你显式传(自动透传)
2. 调用 weixin_send_text 前先确认 text 不为空、不超 2000 字
3. tool 调用成功 → 简明回复"已发送";失败 → 分析 error 决定:
   - "window-not-found" → 报错"微信未开,无法发送",不重试
   - "precondition-failed" → 报错"当前微信对话不是目标群,请手动定位后重试",不重试(MVP 不自动切对话)
   - "verify-failed" → 重试 1 次,仍失败报错
   - "model-failed" → 重试 1 次,仍失败报错
   - "task-timeout" → 报错"桌面操作超时(60s)",不重试
   - "dangerous-action-blocked" / "app-not-allowed" → 报错让人介入,不重试
   - 其他 → 直接报错

注意:
- 你不主动写营销/客服内容,只机械执行 reply agent 委托的任务
- 你不调用 delegate_* 工具(toolset 没给你这个权限)
- 一次任务通常只调用 1 次 weixin_send_text 即完成
- 你看不到 reply agent 的对话历史(subagent context 隔离),所有上下文都在 goal/context 字段里
```

#### 7.2.3 Reply Agent Prompt 增量提示(管理员自己写,模板示例)

```
当你需要在微信群里回复时,使用 delegate_task 工具委托给"桌面操作 Agent":
  delegate_task({
    agentId: <weixinReply.desktopAgentId>,
    task: "请在群 <roomName>(channelId=<id>)里发送以下文字: <text>"
  })
不要直接输出 finalContent 期望系统自动发送 — finalContent 仅用于内部记录。
```

### 7.3 成本估算(沿用 v2,PoC 实测校验)

PoC 实测:Seed 2.0 Pro 单条 ~3800 tokens ≈ ¥0.014。100 群 × 10 条/天 ≈ ¥14/天。

### 7.4 Node.js 依赖

| 包 | 用途 | 备注 |
|---|---|---|
| `node-screenshots` | 截屏 | Rust napi-rs;**每次重新 enumerate**(PoC 验证有缓存) |
| `@nut-tree-fork/nut-js` | 键鼠 | fork 版,原版商业化 |
| `koffi` | Win32 FFI | DPI / FindWindow / SetForegroundWindow |
| ~~`clipboardy`~~ | ~~剪贴板~~ | **不再用**(v5 是 ESM 与 CJS 不兼容) |
| `child_process.spawnSync('clip.exe')` | 中文剪贴板 | UTF-16 LE BOM,内置无依赖 |

### 7.5 高 DPI

bootstrap 调 `SetProcessDpiAwarenessContext(-4)` (PER_MONITOR_AWARE_V2)。

## 8. 测试策略

### 8.0 Phase 0.5 PoC(★ v4 新增):Subagent IPC 验证(< 30 min)

**目标**:确认 NetaClaw subagent tool 执行在哪个 process,以决定 DesktopMutex 单进程是否够用。

**做法**:
1. 写一个临时 builtin tool `_debug_pid`:execute 时返回 `process.pid` + `process.argv`
2. 启动后端,新建 reply agent(toolset=`crew` + `interaction`)和 desktop agent(toolset=`_debug_pid` + `interaction`)
3. 在 chat 界面让 reply agent 调 `delegate_task({mode:'preset', agentId:<desktop>, goal:'run _debug_pid'})`
4. 看 subagent 调 `_debug_pid` 返回的 pid 是否等于 backend main process pid
   - **等于** → tool 在 parent process(IPC proxy)→ DesktopMutex 单实例足够 ✅
   - **不等于** → tool 在 subagent process → 需要把 mutex 提到跨 process(file lock 等),改 Task 5
5. 删除 _debug_pid tool 收尾

**门禁**:在 Phase A Task 5(DesktopMutex)开工前必须做完。

### 8.1 Phase 0 PoC ✅ 已通过

详见 `docs/superpowers/followups/2026-05-14-visual-agent-poc-raw.json`。

### 8.2 单元测试(可跨平台)

| 测试 | 覆盖 |
|---|---|
| `parser/json_action_parser.test.ts` | **20+ fixtures** 来自 Phase 0 PoC raw 输出 + 后续稳定性测试 |
| `runtime/rate_limiter.test.ts` | per-app / per-target / daily |
| `runtime/desktop_mutex.test.ts` | 全局串行 |
| `runtime/safety_guard.test.ts` | 白名单 / 黑名单 / 配置加载 |
| `runtime/runtime.test.ts`(mock 全部) | happy / preFlightCheck 失败 / verifyResult 失败 / Abort / SafetyGuard 拦截 |
| `runtime/adapters/weixin_adapter.test.ts`(mock VLM)| preFlightCheck / buildSteps / verifyResult |
| `service/desktop_op.test.ts` | enqueue / 队列 / abortByFilter / queue-overflow / 全局 mutex 等待 |
| `service/weixin_reply_helper.test.ts` | enabled 检查 / model 默认值 / watermark / 入队 |
| `service/weixin_db.test.ts` 修改 | replyToGroup loginStatus + enabled 短路 |

### 8.3 CI 政策(沿用 v2)

单元测试 CI 必过门禁;E2E 不阻塞 CI,合并前 owner 手动跑附 log。

### 8.4 E2E 手工冒烟(plan 末尾 task)

详见 plan v3。

## 9. 里程碑

详见 plan v3:`docs/superpowers/plans/2026-05-14-neta-desktop-op.md`

## 10. 已对齐的关键决策

### 10.1 v3 → v4 关键架构决策

| 决策 | v3 | v4 | 理由 |
|---|---|---|---|
| **Reply 与 Desktop 职责划分** | reply agent 出 finalContent → 系统自动发 | reply agent 通过 `delegate_task` 主动委托 desktop agent | 上下文隔离、可独立调优、可扩展到 ExcelAgent / BrowserAgent |
| **Desktop Op 暴露形式** | service 直接被 helper 调 | 普通 NetaClaw Tool(`weixin_send_text`),注册到 toolset=`weixin_desktop` | 复用现有 tool 治理 / 配置 |
| **Channel 配置 Agent 数量** | 1 个(channel.agentId) | 2 个(channel.agentId + channel.config.weixinReply.desktopAgentId) | 显式分离两种职责 |
| **Tool 调用语义** | (无,helper 自动 fire-and-forget) | 同步等待 `DesktopOpService.runAndWait(task, 60s)` | desktop agent 拿到真实结果可重试 / 降级 |
| **WeixinReplyHelper** | 新增(v3 plan Task 14) | ❌ 不引入,tool 直接调 service | 多余抽象层 |
| **weixin_db.replyToGroup** | v3 plan Task 15 实现(委托 helper) | ❌ **整方法删除** | 占位 + 多余;weixin_db 只剩读路径职责 |
| **agent_channel.ts 自动发送块** | v3 plan 不动 | ❌ **整块删除**(行 585-608) | reply agent 必须主动 delegate;不再有"系统自动发"路径 |
| **VLM 模型来源** | 全局 default_model_channel_id 或 channel.config.weixinReply.modelChannelId | desktop agent 自己的 `agent.modelChannelId`(因为 desktop agent 已是普通 NetaClaw Agent) | 避免双重配置 |
| **防 Loop** | (无) | desktop agent 不给 `delegate_task` tool + 后端校验 | 单向委托链 |

### 10.2 沿用 v3 决策(不变)

| 决策 | 状态 | 理由 |
|---|---|---|
| 模块归属 `modules/desktop_op/` 顶层 | 沿用 | 通用化 |
| 任务 schema `DesktopTask{appId, target, actionType, params}` | 沿用 | 通用化 |
| AppAdapter 注册式 | 沿用 | 多应用零拆架构 |
| Parser:MVP JSON action 主(PoC 验证) | 沿用 | 现有模型决定 |
| 全局 DesktopMutex | 沿用 | 系统单屏 |
| SafetyGuard 白名单+黑名单 | 沿用 | 通用化 |
| 全局 desktop_op_config(★ 移除 default_model_channel_id) | 沿用 | 多 app 共享 |
| 进程模型 Node 内嵌 | 沿用 | 简单可靠 |
| AbortSignal 中断协议 | 沿用 | 取消可控 |
| final_text 全文落审计 | 沿用 | 可回溯 |
| Watermark | 沿用 | 灰度 |
| **archive sync 监听不动** | **沿用** | 用户明确;WeixinChannelMutex 在 archive 内部保留 |
| clip.exe 代替 clipboardy v5 | 沿用 | ESM 不兼容(PoC 验证) |
| 截图每次 enumerate | 沿用 | node-screenshots 缓存(PoC 验证) |
| 找微信窗口用 node-screenshots appName='Weixin' 取最大 | 沿用 | 主窗口 title 中文(PoC 验证) |
| Phase 0 PoC 已完成 | 沿用 | 100% 1 次 |
| CI 政策(E2E 不阻塞) | 沿用 | 微信版本依赖 |
| weixin-uia spec OBSOLETE | 沿用 | UIA 路线放弃 |

## 11. 风险与兜底

| 风险 | 概率 | 兜底(v3) |
|---|---|---|
| ★ MVP 只验单次,5/20 次稳定性未知 | 中 | E2E 必跑 N=20,< 80% 调 prompt / 换 UI-TARS |
| 微信版本升级改 UI | 高 | 视觉天然抗变 + adapter VLM 重看 |
| 火山限速/涨价/下线 | 中 | model_channel 可切 |
| 微信反自动化封号 | 中 | safeMode + 频限 + 间隔 + 队列 + 让位 + watermark + 文档警告 |
| **通用化引入误操作风险** | 中 | SafetyGuard 默认只 weixin,扩 app 显式加,危险按键硬黑名单 |
| **adapter 抽象过早** | 低 | MVP 只 1 adapter,接口按 Weixin 设计,Layer 2 演进 |
| node-screenshots 缓存 | 已知 | 每次 enumerate(已修)|
| nut.js UAC 拒收 | 低 | 检测前台非目标即跳过 |
| 高 DPI 错位 | 中 | bootstrap PER_MONITOR_AWARE_V2 |
| 用户在用电脑 | 中 | 让位 |
| 中文 IME 吞字 | 已知 | clip.exe + Ctrl+V(PoC 验证) |
| 队列积压 | 中 | per-target 上限 20 + 丢最老 |
| VLM JSON 不稳 | 中 | Parser 容错 + 20+ fixture + 解析失败视为 model-failed |
| 客户群出事难回溯 | 中 | final_text 全文 + watermark + 审计 |
| koffi/nut.js/adapter 经验 | 中 | Phase A 拆细 + Phase 0 PoC 已踩主要坑 |
| **多 app 并发被全局锁堵** | 低 | MVP 只 weixin;Layer 2 评估 per-window 锁 |
| 测试小号未养号 | 高 | 立项当天启动 |

## 12. 后续路线(Layer 2)

- 第 2 个 Adapter:Excel / Browser / 钉钉
- agent_executor tool 注册(G4)
- admin HTTP `POST /run-task`(G9)
- 危险动作二次确认 UI(G7 增强)
- Sonnet 4.6 / UI-TARS-1.5 fallback chain
- 自托管 vLLM
- 截图录像审计 + Web 回放
- 贝塞尔曲线鼠标 + 多账号轮换
- 跨平台:Mac (MacAdapter) / Linux
- 导航能力增强:WeixinAdapter 自动定位对话(MVP 要求手动)
- ActionStep 扩展:mention / reply-to / screenshot-region

## 13. Self-Review

- [x] Placeholder 扫描:无 TBD
- [x] 内部一致性:接口 §5 与服务 §3.3/§3.7 一致
- [x] Scope:11 项 out-of-scope + 11 项 Layer 2 留白
- [x] 决策有理由:§10
- [x] 风险有兜底:§11
- [x] **v2 review 14 条 + v3 review 9 条全覆盖**(见 §0)
- [x] **Phase 0 PoC 已通过,实测发现的坑全部反映到 §10/§7.4**
- [x] 与现有架构衔接:weixin-archive sync 不动 / model_channel 复用 / agent_channel.routeInbound 不动
- [x] 旧 spec:weixin-uia OBSOLETE
- [x] **通用化定位明确**:模块/API/锁/安全/配置全按通用桌面 Agent 设计,WeixinAdapter 是第一个实现
- [x] 模型可配置:全局 desktop_op_config + channel 上 enabled
- [x] 风险前置:Phase 0 PoC 已是门禁
