GPU_GUARD_MONOREPO/docs/superpowers/specs/2026-05-14-neta-desktop-op-design.md
2026-05-20 21:39:12 +08:00

1030 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.33.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 已是门禁