dsh-gui-control 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +209 -2
- package/assets/icon-180.png +0 -0
- package/assets/screenshot-bilibili.png +0 -0
- package/assets/screenshot-calc.png +0 -0
- package/assets/screenshot-sheet.png +0 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +8 -0
- package/lib/index.js +346 -0
- package/lib/scripts/DshGui.cs +267 -0
- package/lib/scripts/DshUia.cs +212 -0
- package/lib/scripts/gui-anchors.ps1 +146 -0
- package/lib/scripts/gui-daemon.log +4 -0
- package/lib/scripts/gui-daemon.ps1 +89 -0
- package/lib/scripts/gui-lib.ps1 +68 -0
- package/lib/scripts/gui-ocr.ps1 +71 -0
- package/lib/scripts/gui-ops.ps1 +915 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -3
- package/screenshots.json +5 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-gui-control contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,210 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<img src="https://raw.githubusercontent.com/seetown/dsh-gui-control/main/assets/icon-180.png" width="88" alt="dsh-gui-control">
|
|
4
|
+
|
|
5
|
+
# dsh-gui-control
|
|
6
|
+
|
|
7
|
+
**给 DeepSeek Harness 的 Windows 桌面操控:不是「帮我点一下」,而是「以后这个活都交给你」。**
|
|
8
|
+
|
|
9
|
+
<p>
|
|
10
|
+
<a href="https://www.npmjs.com/package/dsh-gui-control"><img alt="npm" src="https://img.shields.io/npm/v/dsh-gui-control.svg?color=cb3837"></a>
|
|
11
|
+
<a href="https://www.npmjs.com/package/dsh-gui-control"><img alt="downloads" src="https://img.shields.io/npm/dm/dsh-gui-control.svg"></a>
|
|
12
|
+
<img alt="platform" src="https://img.shields.io/badge/platform-Windows-0078d4.svg">
|
|
13
|
+
<img alt="license" src="https://img.shields.io/badge/license-MIT-blue.svg">
|
|
14
|
+
<a href="https://github.com/topics/dsh-plugin"><img alt="dsh-plugin" src="https://img.shields.io/badge/topic-dsh--plugin-6f42c1.svg"></a>
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
[中文说明](#中文说明) · [English](#english)
|
|
18
|
+
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 中文说明
|
|
24
|
+
|
|
25
|
+
### 它和别的桌面 Agent 有什么不同
|
|
26
|
+
|
|
27
|
+
大多数桌面自动化解决的是**一次性操作**:看一眼屏幕、点一下、再看一眼。这个插件多做一层——**把一套动作固化成任务文件**,跑的时候模型完全不参与。
|
|
28
|
+
|
|
29
|
+
| | 常见做法 | dsh-gui-control |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| 感知 | 截图 → 模型猜坐标 | **无障碍树优先**,按控件名定位;树不可用时才截图 |
|
|
32
|
+
| 一次任务的往返 | 每步一次模型调用 | 一次调用跑完整个任务文件 |
|
|
33
|
+
| 失败之后 | 不知道点没点上 | **投递判定**:没发出去 / 可能发出去了 / 已验证 |
|
|
34
|
+
| 重复执行 | 每次重跑一遍 | 任务文件可复用,带条件等待与重试 |
|
|
35
|
+
|
|
36
|
+
### 演示
|
|
37
|
+
|
|
38
|
+
**① 打开 B 站 → 搜番剧 → 播到指定集数**
|
|
39
|
+
|
|
40
|
+

|
|
41
|
+
|
|
42
|
+
**② 语义点击:鼠标位移 0 像素**(直接调用控件的 Invoke,鼠标不动、窗口在后台也能点)
|
|
43
|
+
|
|
44
|
+

|
|
45
|
+
|
|
46
|
+
**③ 填一列数据**(17 步 / 0.9 秒 / 一次调用)
|
|
47
|
+
|
|
48
|
+

|
|
49
|
+
|
|
50
|
+
### 安装
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
dsh plugin add dsh-gui-control
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
或从源码:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
dsh plugin add seetown/dsh-gui-control
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
要求:Windows 10 1809+、PowerShell 5.1、DeepSeek Harness 0.1.5-rc.2 ~ 0.2.x。装完不需要重启。
|
|
63
|
+
|
|
64
|
+
### 三个工具
|
|
65
|
+
|
|
66
|
+
| 工具 | 作用 |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `gui_snapshot` | 读窗口的无障碍树(文本,精确、便宜、与位置无关),或带屏幕坐标刻度尺的截图 |
|
|
69
|
+
| `gui_act` | 执行一个动作,带身份校验和投递判定 |
|
|
70
|
+
| `gui_task` | 跑声明式任务文件:一次调用完成多步,带条件、重试、风险闸门 |
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
gui_snapshot { window: "Notepad", mode: "tree" }
|
|
74
|
+
→ Butto 关闭标签页 / Docum 文本编辑器 / MenuI 文件 ...
|
|
75
|
+
|
|
76
|
+
gui_act { action: "click_text", window: "Notepad", text: "最大化" }
|
|
77
|
+
→ click ok via InvokePattern | verified: text appeared
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**优先用 `click_text` 而不是坐标**:它按控件名定位,鼠标不动,窗口布局变了也不会点错。
|
|
81
|
+
|
|
82
|
+
### 任务文件
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"name": "fill-sheet",
|
|
87
|
+
"steps": [
|
|
88
|
+
{ "do": "activate", "win": "工作簿1" },
|
|
89
|
+
{ "do": "type", "text": "项目" },
|
|
90
|
+
{ "do": "key", "key": "tab" },
|
|
91
|
+
{ "do": "type", "text": "金额" },
|
|
92
|
+
{ "do": "key", "key": "enter",
|
|
93
|
+
"until": { "op": "pathexists", "text": "C:\\out\\report.xlsx" }, "untilTimeout": 10 }
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
每步字段:`do` + 该动作参数,外加 `until`(轮询到条件成立才算过)、`untilTimeout`、`retry`、`risk`。
|
|
99
|
+
|
|
100
|
+
- `risk: "high"` 的步骤默认**拒绝执行**,必须显式 `confirm: true`
|
|
101
|
+
- `dryRun: true` 只返回计划,不碰桌面
|
|
102
|
+
|
|
103
|
+
### 投递语义
|
|
104
|
+
|
|
105
|
+
每个动作返回三选一:
|
|
106
|
+
|
|
107
|
+
| 判定 | 含义 | 能否重试 |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `not_delivered` | 什么都没发出去(派发前就被拒) | ✅ 安全 |
|
|
110
|
+
| `delivered_unverified` | 发出去了,效果未确认 | ❌ 不能盲目重放 |
|
|
111
|
+
| `delivered_verified` | 发出去了,且后置条件验证通过 | ❌ |
|
|
112
|
+
|
|
113
|
+
这一条决定了「发消息 / 删文件 / 提交表单」这类不可逆操作是否安全。
|
|
114
|
+
|
|
115
|
+
### 安全
|
|
116
|
+
|
|
117
|
+
- 只监听 `127.0.0.1:38471`,不对外暴露
|
|
118
|
+
- 密码字段一律输出 `<redacted>`
|
|
119
|
+
- 高风险步骤需要显式确认
|
|
120
|
+
- 结果不明时**绝不自动重试**
|
|
121
|
+
|
|
122
|
+
> 这个插件能操控整台机器的鼠标键盘。装之前请知情,只在可信环境使用。
|
|
123
|
+
|
|
124
|
+
### 实测数据
|
|
125
|
+
|
|
126
|
+
| 操作 | 耗时 |
|
|
127
|
+
|---|---|
|
|
128
|
+
| 单步动作(常驻进程) | 10.8 ms |
|
|
129
|
+
| 语义点击(UIA 树) | 26–34 ms |
|
|
130
|
+
| 紧凑树读取 | 86 ms,865 字符(比完整 dump 省 88%) |
|
|
131
|
+
| 7 步任务(含启动计算器) | 680 ms,一次调用 |
|
|
132
|
+
| 桌面建 3 个文件夹 | 3.45 s,3/3 成功 |
|
|
133
|
+
| WPS 填 2×3 表格 | 0.9 s,17 步 |
|
|
134
|
+
|
|
135
|
+
首次调用会预热常驻进程(约 1 秒),之后每个动作是一次 loopback 往返。
|
|
136
|
+
|
|
137
|
+
### 已知限制
|
|
138
|
+
|
|
139
|
+
- **仅 Windows**(Win32 + UI Automation)
|
|
140
|
+
- 需要 PowerShell 5.1
|
|
141
|
+
- 画布类应用(WPS 表格、游戏、自绘列表)没有无障碍树,只能截图 + OCR,准确率下降
|
|
142
|
+
- 网页任务目前基于截图;CDP 通道已实现但尚未接入工具
|
|
143
|
+
|
|
144
|
+
### Roadmap
|
|
145
|
+
|
|
146
|
+
- [ ] 截图自动编号(Set-of-Marks),把坐标估计降级成编号分类
|
|
147
|
+
- [ ] 接入 CDP:网页任务按选择器操作,而不是按坐标
|
|
148
|
+
- [ ] 任务库与意图路由:一句话命中现成任务
|
|
149
|
+
- [ ] macOS / Linux 适配
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## English
|
|
154
|
+
|
|
155
|
+
**Windows desktop control for DeepSeek Harness.** Not "click that button for me" — "this job is yours from now on."
|
|
156
|
+
|
|
157
|
+
Most desktop agents do one-off operations: look, click, look again. This one adds a layer underneath: **it turns a sequence into a task file** that runs with no model in the loop.
|
|
158
|
+
|
|
159
|
+
### What makes it different
|
|
160
|
+
|
|
161
|
+
| | Typical approach | dsh-gui-control |
|
|
162
|
+
|---|---|---|
|
|
163
|
+
| Perception | Screenshot, then the model guesses coordinates | **Accessibility tree first**, addressed by control name; screenshots only when no tree exists |
|
|
164
|
+
| Round trips per task | One model call per step | One call runs the whole task file |
|
|
165
|
+
| After a failure | Unknown whether it landed | **Delivery verdict**: not delivered / may have landed / verified |
|
|
166
|
+
| Repeating a flow | Replay it by hand | Task files are reusable, with conditions and retries |
|
|
167
|
+
|
|
168
|
+
### Install
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
dsh plugin add dsh-gui-control
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Windows 10 1809+, PowerShell 5.1, DeepSeek Harness 0.1.5-rc.2 – 0.2.x. No restart needed.
|
|
175
|
+
|
|
176
|
+
### Three tools
|
|
177
|
+
|
|
178
|
+
| Tool | Purpose |
|
|
179
|
+
|---|---|
|
|
180
|
+
| `gui_snapshot` | Read a window's accessibility tree as text, or a screenshot with a screen-coordinate ruler |
|
|
181
|
+
| `gui_act` | One action, with an identity check and a delivery verdict |
|
|
182
|
+
| `gui_task` | Run a declarative task file: many steps in one call, with conditions, retries and a risk gate |
|
|
183
|
+
|
|
184
|
+
### Delivery semantics
|
|
185
|
+
|
|
186
|
+
| Verdict | Meaning | Retry |
|
|
187
|
+
|---|---|---|
|
|
188
|
+
| `not_delivered` | Nothing was sent | ✅ safe |
|
|
189
|
+
| `delivered_unverified` | Input may have landed, effect unconfirmed | ❌ never blind-retry |
|
|
190
|
+
| `delivered_verified` | Landed, and the post-condition held | ❌ |
|
|
191
|
+
|
|
192
|
+
That distinction is what makes "send a message / delete a file / submit a form" safe to automate.
|
|
193
|
+
|
|
194
|
+
### Security
|
|
195
|
+
|
|
196
|
+
Loopback-only (`127.0.0.1:38471`), password fields are redacted, high-risk steps require explicit confirmation, and an uncertain outcome is never retried automatically.
|
|
197
|
+
|
|
198
|
+
> This plugin drives the real mouse and keyboard of the whole machine. Install it knowingly, in a trusted environment.
|
|
199
|
+
|
|
200
|
+
### Limitations
|
|
201
|
+
|
|
202
|
+
Windows only; PowerShell 5.1 required; canvas-like apps (spreadsheets, games, self-drawn lists) expose no accessibility tree and fall back to screenshot + OCR with lower accuracy; web tasks currently use screenshots (the CDP path exists but is not wired into the tools yet).
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
<div align="center">
|
|
207
|
+
|
|
208
|
+
MIT © [seetown](https://github.com/seetown)
|
|
209
|
+
|
|
210
|
+
</div>
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Desktop control">
|
|
2
|
+
<rect x="4" y="8" width="56" height="40" rx="5" fill="none" stroke="#3b82f6" stroke-width="4"/>
|
|
3
|
+
<line x1="4" y1="18" x2="60" y2="18" stroke="#3b82f6" stroke-width="3"/>
|
|
4
|
+
<circle cx="11" cy="13" r="2.2" fill="#3b82f6"/>
|
|
5
|
+
<circle cx="18" cy="13" r="2.2" fill="#3b82f6"/>
|
|
6
|
+
<path d="M30 28 L30 50 L36 44 L40 53 L45 51 L41 42 L49 42 Z" fill="#f59e0b" stroke="#0f172a" stroke-width="2" stroke-linejoin="round"/>
|
|
7
|
+
<line x1="22" y1="56" x2="42" y2="56" stroke="#3b82f6" stroke-width="4" stroke-linecap="round"/>
|
|
8
|
+
</svg>
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-gui-control — Windows desktop control for DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* The heavy lifting lives in a long-lived PowerShell daemon shipped under lib/scripts.
|
|
5
|
+
* It compiles the Win32/UIA helpers once and then serves operations over loopback TCP,
|
|
6
|
+
* so a click costs ~10 ms instead of the ~700 ms of starting PowerShell and recompiling.
|
|
7
|
+
*
|
|
8
|
+
* This module only exposes three model-facing tools and keeps the surface small on
|
|
9
|
+
* purpose: read, act, or run a declarative task.
|
|
10
|
+
*/
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import net from 'node:net';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
18
|
+
|
|
19
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const SCRIPTS = join(HERE, 'scripts');
|
|
21
|
+
const PORT = 38471;
|
|
22
|
+
const HOST = '127.0.0.1';
|
|
23
|
+
|
|
24
|
+
const isWindows = process.platform === 'win32';
|
|
25
|
+
|
|
26
|
+
/** Resolve PowerShell explicitly: the host process PATH is not guaranteed to include it. */
|
|
27
|
+
const POWERSHELL = (() => {
|
|
28
|
+
const systemRoot = process.env.SystemRoot ?? process.env.windir ?? 'C:\\Windows';
|
|
29
|
+
const full = join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
30
|
+
return existsSync(full) ? full : 'powershell.exe';
|
|
31
|
+
})();
|
|
32
|
+
|
|
33
|
+
let launchPromise = null;
|
|
34
|
+
|
|
35
|
+
function text(value) {
|
|
36
|
+
return [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value) }];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function assertWindows() {
|
|
40
|
+
if (!isWindows) {
|
|
41
|
+
throw new Error('dsh-gui-control only works on Windows; the native helpers are Win32 and UI Automation based.');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function pingDaemon(timeoutMs = 400) {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
const socket = net.createConnection({ host: HOST, port: PORT });
|
|
48
|
+
const done = (ok) => {
|
|
49
|
+
socket.removeAllListeners();
|
|
50
|
+
socket.destroy();
|
|
51
|
+
resolve(ok);
|
|
52
|
+
};
|
|
53
|
+
socket.setTimeout(timeoutMs);
|
|
54
|
+
socket.once('connect', () => done(true));
|
|
55
|
+
socket.once('timeout', () => done(false));
|
|
56
|
+
socket.once('error', () => done(false));
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Start the daemon once per process; concurrent callers share one launch. */
|
|
61
|
+
function ensureDaemon() {
|
|
62
|
+
if (launchPromise !== null) return launchPromise;
|
|
63
|
+
launchPromise = (async () => {
|
|
64
|
+
if (await pingDaemon()) return;
|
|
65
|
+
// Launch through Start-Process: a bare detached spawn of PowerShell can exit
|
|
66
|
+
// immediately on Windows depending on console inheritance from the host.
|
|
67
|
+
const daemon = join(SCRIPTS, 'gui-daemon.ps1');
|
|
68
|
+
const quote = (value) => `'${String(value).replace(/'/g, "''")}'`;
|
|
69
|
+
const args = ['-NoProfile', '-STA', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', daemon, '-Port', String(PORT)];
|
|
70
|
+
const command = `Start-Process -FilePath ${quote(POWERSHELL)} -ArgumentList @(${args.map(quote).join(',')}) -WindowStyle Hidden`;
|
|
71
|
+
try {
|
|
72
|
+
execFileSync(POWERSHELL, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command], { stdio: 'ignore', timeout: 15000, windowsHide: true });
|
|
73
|
+
} catch (error) {
|
|
74
|
+
launchPromise = null;
|
|
75
|
+
throw new Error(`Could not launch the GUI daemon: ${error.message}`);
|
|
76
|
+
}
|
|
77
|
+
for (let i = 0; i < 120; i += 1) {
|
|
78
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
79
|
+
if (await pingDaemon()) return;
|
|
80
|
+
}
|
|
81
|
+
launchPromise = null;
|
|
82
|
+
throw new Error('The GUI daemon did not start. Check that PowerShell 5.1 is available and see lib/scripts/gui-daemon.log.');
|
|
83
|
+
})();
|
|
84
|
+
return launchPromise;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One request, one response line. Resolves with the daemon envelope. */
|
|
88
|
+
function callDaemon(op, timeoutMs = 20000) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const socket = net.createConnection({ host: HOST, port: PORT });
|
|
91
|
+
let buffer = '';
|
|
92
|
+
let settled = false;
|
|
93
|
+
const finish = (fn, value) => {
|
|
94
|
+
if (settled) return;
|
|
95
|
+
settled = true;
|
|
96
|
+
socket.destroy();
|
|
97
|
+
fn(value);
|
|
98
|
+
};
|
|
99
|
+
socket.setEncoding('utf8');
|
|
100
|
+
socket.setTimeout(timeoutMs);
|
|
101
|
+
socket.once('connect', () => socket.write(`${JSON.stringify(op)}\n`));
|
|
102
|
+
socket.on('data', (chunk) => {
|
|
103
|
+
buffer += chunk;
|
|
104
|
+
const nl = buffer.indexOf('\n');
|
|
105
|
+
if (nl === -1) return;
|
|
106
|
+
const line = buffer.slice(0, nl);
|
|
107
|
+
try {
|
|
108
|
+
finish(resolve, JSON.parse(line));
|
|
109
|
+
} catch (error) {
|
|
110
|
+
finish(reject, new Error(`Bad daemon response: ${line.slice(0, 200)}`));
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
socket.once('timeout', () => finish(reject, new Error(`GUI operation timed out after ${timeoutMs} ms (${op.op}).`)));
|
|
114
|
+
socket.once('error', (error) => finish(reject, error));
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Call, and if the daemon died since the last call, relaunch it once and retry. */
|
|
119
|
+
async function callResilient(op, timeoutMs) {
|
|
120
|
+
await ensureDaemon();
|
|
121
|
+
try {
|
|
122
|
+
return await callDaemon(op, timeoutMs);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
const code = error?.code;
|
|
125
|
+
if (code !== 'ECONNREFUSED' && code !== 'EPIPE' && code !== 'ECONNRESET') throw error;
|
|
126
|
+
launchPromise = null;
|
|
127
|
+
await ensureDaemon();
|
|
128
|
+
return callDaemon(op, timeoutMs);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function run(op, timeoutMs) {
|
|
133
|
+
const envelope = await callResilient(op, timeoutMs);
|
|
134
|
+
if (envelope.ok !== true) {
|
|
135
|
+
const parts = [String(envelope.error ?? 'operation failed')];
|
|
136
|
+
if (envelope.delivery !== undefined) parts.push(`delivery=${envelope.delivery}`);
|
|
137
|
+
if (envelope.retry !== undefined) parts.push(`retry=${envelope.retry}`);
|
|
138
|
+
throw new Error(parts.join(' '));
|
|
139
|
+
}
|
|
140
|
+
return envelope.result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Compact tree read: one line per control, deduped, no rectangles. */
|
|
144
|
+
function formatTree(lines) {
|
|
145
|
+
if (!Array.isArray(lines) || lines.length === 0) return '(no controls)';
|
|
146
|
+
return lines.join('\n');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function actOp(args) {
|
|
150
|
+
const action = String(args.action ?? '').toLowerCase();
|
|
151
|
+
const base = {};
|
|
152
|
+
if (typeof args.window === 'string' && args.window.length > 0) base.win = args.window;
|
|
153
|
+
if (Number.isFinite(args.x) && Number.isFinite(args.y)) {
|
|
154
|
+
base.x = Math.round(args.x);
|
|
155
|
+
base.y = Math.round(args.y);
|
|
156
|
+
}
|
|
157
|
+
switch (action) {
|
|
158
|
+
case 'click':
|
|
159
|
+
case 'double_click':
|
|
160
|
+
case 'right_click': {
|
|
161
|
+
const out = { op: 'click', ...base };
|
|
162
|
+
if (action === 'double_click') out.count = 2;
|
|
163
|
+
if (action === 'right_click') out.button = 'right';
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
case 'type':
|
|
167
|
+
return { op: 'type', ...base, text: String(args.text ?? '') };
|
|
168
|
+
case 'key':
|
|
169
|
+
return { op: 'key', ...base, key: String(args.key ?? '') };
|
|
170
|
+
case 'hotkey':
|
|
171
|
+
return { op: 'hotkey', ...base, key: String(args.key ?? '') };
|
|
172
|
+
case 'scroll':
|
|
173
|
+
return { op: 'scroll', ...base, amount: Number(args.amount ?? -3) };
|
|
174
|
+
case 'focus':
|
|
175
|
+
return { op: 'activate', win: String(args.window ?? '') };
|
|
176
|
+
case 'click_text': {
|
|
177
|
+
// Semantic click: locate the control by name instead of by coordinate.
|
|
178
|
+
return { op: 'uia-click', win: String(args.window ?? ''), text: String(args.text ?? ''), type: args.controlType };
|
|
179
|
+
}
|
|
180
|
+
default:
|
|
181
|
+
throw new Error(`Unknown action "${action}". Use click, double_click, right_click, type, key, hotkey, scroll, focus, or click_text.`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const SNAPSHOT = {
|
|
186
|
+
name: 'gui_snapshot',
|
|
187
|
+
description: [
|
|
188
|
+
'Read a Windows window as text: its accessibility tree (control names, roles, bounds-free) or a cropped screenshot with a coordinate ruler.',
|
|
189
|
+
'Use the tree first — it is exact, cheap, and position independent. Fall back to a screenshot only when the window publishes no tree (a canvas, a game, or a self-drawn list).',
|
|
190
|
+
'Always snapshot before acting, and again afterwards to confirm what changed.',
|
|
191
|
+
'A screenshot is returned as a file path; read that file with the image reader. The ruler labels are screen coordinates, so you can pass them straight back to gui_act.',
|
|
192
|
+
].join(' '),
|
|
193
|
+
parameters: {
|
|
194
|
+
window: { type: 'string', description: 'Window title or process-name substring, case-insensitive (for example "Notepad" or "计算器").' },
|
|
195
|
+
mode: { type: 'string', enum: ['tree', 'shot'], description: 'tree (default): control names as text. shot: PNG with a screen-coordinate ruler.' },
|
|
196
|
+
lines: { type: 'number', description: 'Maximum control lines to return (default 40, tree mode only).' },
|
|
197
|
+
chars: { type: 'number', description: 'Truncate each control name to this many characters (default 60, tree mode only).' },
|
|
198
|
+
region: { type: 'string', description: "Screenshot crop in screen pixels as 'x,y,width,height' (shot mode). Defaults to the whole window." },
|
|
199
|
+
},
|
|
200
|
+
output: {
|
|
201
|
+
schema: { type: 'string' },
|
|
202
|
+
render: (_args, value) => text(value),
|
|
203
|
+
},
|
|
204
|
+
isConcurrencySafe: () => true,
|
|
205
|
+
async execute(args) {
|
|
206
|
+
assertWindows();
|
|
207
|
+
const window = typeof args.window === 'string' ? args.window : '';
|
|
208
|
+
if (window.length === 0) throw new Error('gui_snapshot needs a window (title or process substring).');
|
|
209
|
+
const mode = args.mode === 'shot' ? 'shot' : 'tree';
|
|
210
|
+
if (mode === 'tree') {
|
|
211
|
+
const lines = await run({ op: 'uia-mini', win: window, lines: args.lines ?? 40, chars: args.chars ?? 60 });
|
|
212
|
+
return formatTree(lines);
|
|
213
|
+
}
|
|
214
|
+
const op = { op: 'shot', win: window, rulers: true, path: join(tmpdir(), `gui-shot-${Date.now()}.png`) };
|
|
215
|
+
if (typeof args.region === 'string' && args.region.length > 0) {
|
|
216
|
+
const nums = args.region.split(',').map((v) => Number(v.trim()));
|
|
217
|
+
if (nums.length === 4 && nums.every((n) => Number.isFinite(n))) {
|
|
218
|
+
[op.x, op.y, op.w, op.h] = nums;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const shot = await run(op);
|
|
222
|
+
return `shot ${shot.w}x${shot.h} at ${shot.x},${shot.y} scale ${shot.scale}\n${shot.path}\nRead the file to see it. Ruler labels are screen coordinates.`;
|
|
223
|
+
},
|
|
224
|
+
presentCall(args) {
|
|
225
|
+
return { card: 'generic', title: `Snapshot ${args.window ?? 'window'}`, kind: 'read' };
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const ACT = {
|
|
230
|
+
name: 'gui_act',
|
|
231
|
+
description: [
|
|
232
|
+
'Perform ONE desktop action and optionally verify its effect.',
|
|
233
|
+
'Prefer click_text over click with coordinates: it addresses the control by name, so the mouse does not move and a window layout change cannot make it hit the wrong thing.',
|
|
234
|
+
'Every action reports a delivery verdict. "not_delivered" means nothing was sent and a retry is safe; "delivered_unverified" means the input may have landed, so repeating it risks a duplicate — verify first, never blind-retry.',
|
|
235
|
+
'For more than two steps use gui_task instead: one call, conditions, and a single failure report.',
|
|
236
|
+
].join(' '),
|
|
237
|
+
parameters: {
|
|
238
|
+
action: {
|
|
239
|
+
type: 'string',
|
|
240
|
+
enum: ['click', 'double_click', 'right_click', 'type', 'key', 'hotkey', 'scroll', 'focus', 'click_text'],
|
|
241
|
+
description: 'click_text = click the control whose name matches text (recommended). key/hotkey take a key name such as enter or ctrl+s. focus brings a window to the front.',
|
|
242
|
+
},
|
|
243
|
+
window: { type: 'string', description: 'Target window (title or process substring). Required for click_text, focus, click, type, and key.' },
|
|
244
|
+
text: { type: 'string', description: 'Text to type, or the control name for click_text.' },
|
|
245
|
+
x: { type: 'number', description: 'Screen x for click/double_click/right_click (use only when no control name exists).' },
|
|
246
|
+
y: { type: 'number', description: 'Screen y for click/double_click/right_click.' },
|
|
247
|
+
key: { type: 'string', description: "Key or chord: 'enter', 'tab', 'esc', 'f5', 'ctrl+s', 'alt+tab'." },
|
|
248
|
+
amount: { type: 'number', description: 'Scroll notches; negative scrolls down (default -3).' },
|
|
249
|
+
controlType: { type: 'string', description: 'Optional control type filter for click_text, for example Button or TabItem.' },
|
|
250
|
+
verifyText: { type: 'string', description: 'After acting, poll until this text appears in the window (proves the action landed).' },
|
|
251
|
+
verifyTimeout: { type: 'number', description: 'Seconds to poll for verifyText (default 4).' },
|
|
252
|
+
},
|
|
253
|
+
output: {
|
|
254
|
+
schema: { type: 'string' },
|
|
255
|
+
render: (_args, value) => text(value),
|
|
256
|
+
},
|
|
257
|
+
isConcurrencySafe: () => false,
|
|
258
|
+
async execute(args) {
|
|
259
|
+
assertWindows();
|
|
260
|
+
const op = actOp(args);
|
|
261
|
+
const envelope = await callResilient(op);
|
|
262
|
+
if (envelope.ok !== true) {
|
|
263
|
+
const parts = [String(envelope.error ?? 'action failed')];
|
|
264
|
+
if (envelope.delivery !== undefined) parts.push(`delivery=${envelope.delivery}`);
|
|
265
|
+
if (envelope.retry !== undefined) parts.push(`retry=${envelope.retry}`);
|
|
266
|
+
throw new Error(parts.join(' '));
|
|
267
|
+
}
|
|
268
|
+
let verified = 'unverified';
|
|
269
|
+
if (typeof args.verifyText === 'string' && args.verifyText.length > 0) {
|
|
270
|
+
verified = await verifyText(args.window, args.verifyText, (args.verifyTimeout ?? 4) * 1000);
|
|
271
|
+
}
|
|
272
|
+
const how = envelope.result?.how !== undefined ? ` via ${envelope.result.how}` : '';
|
|
273
|
+
return `${op.op} ok${how}${verified === 'ok' ? ' | verified: text appeared' : ''}${verified === 'timeout' ? ' | delivery=delivered_unverified: text never appeared, do NOT blind-retry' : ''}`;
|
|
274
|
+
},
|
|
275
|
+
presentCall(args) {
|
|
276
|
+
return { card: 'generic', title: `${args.action ?? 'act'} ${args.window ?? ''}`.trim(), kind: 'execute' };
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
async function verifyText(window, needle, timeoutMs) {
|
|
281
|
+
const deadline = Date.now() + timeoutMs;
|
|
282
|
+
while (Date.now() < deadline) {
|
|
283
|
+
try {
|
|
284
|
+
const envelope = await callResilient({ op: 'uia-get', win: String(window ?? ''), text: needle }, 5000);
|
|
285
|
+
if (envelope.ok === true) return 'ok';
|
|
286
|
+
} catch {
|
|
287
|
+
// keep polling
|
|
288
|
+
}
|
|
289
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
290
|
+
}
|
|
291
|
+
return 'timeout';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const TASK = {
|
|
295
|
+
name: 'gui_task',
|
|
296
|
+
description: [
|
|
297
|
+
'Run a declarative task file: an ordered list of desktop steps executed inside the daemon, with no model in the loop.',
|
|
298
|
+
'Each step names an operation plus its parameters and may add "until" (a condition polled until it holds), "untilTimeout", "retry", and "risk".',
|
|
299
|
+
'A task returns per-step timing and a delivery verdict, so one call replaces a long back-and-forth of single actions.',
|
|
300
|
+
'Pass dryRun to see the plan without touching the desktop. Steps marked risk "high" are refused unless confirm is true.',
|
|
301
|
+
'Example step: { "do": "uia-click", "win": "Calculator", "text": "7", "until": { "op": "uia-get", "win": "Calculator", "text": "Display is 7" } }',
|
|
302
|
+
].join(' '),
|
|
303
|
+
parameters: {
|
|
304
|
+
file: { type: 'string', description: 'Absolute path to the task JSON file.' },
|
|
305
|
+
dryRun: { type: 'boolean', description: 'Return the step plan without executing anything.' },
|
|
306
|
+
confirm: { type: 'boolean', description: 'Allow steps marked risk high (otherwise they are refused before dispatch).' },
|
|
307
|
+
},
|
|
308
|
+
output: {
|
|
309
|
+
schema: { type: 'string' },
|
|
310
|
+
render: (_args, value) => text(value),
|
|
311
|
+
},
|
|
312
|
+
isConcurrencySafe: () => false,
|
|
313
|
+
async execute(args) {
|
|
314
|
+
assertWindows();
|
|
315
|
+
const file = typeof args.file === 'string' ? args.file : '';
|
|
316
|
+
if (file.length === 0) throw new Error('gui_task needs file (absolute path to a task JSON).');
|
|
317
|
+
const op = { op: 'task', file, dryRun: args.dryRun === true, confirm: args.confirm === true };
|
|
318
|
+
const envelope = await callResilient(op, 600000);
|
|
319
|
+
const result = envelope.result ?? {};
|
|
320
|
+
if (result.dryRun === true) {
|
|
321
|
+
const plan = (result.steps ?? []).map((step) => `${String(step.n).padStart(2)}. ${String(step.do).padEnd(10)} risk=${step.risk ?? 'low'}`);
|
|
322
|
+
return [`task ${result.name ?? ''} plan (dry run, nothing executed)`, ...plan].join('\n');
|
|
323
|
+
}
|
|
324
|
+
const lines = [];
|
|
325
|
+
for (const step of result.steps ?? []) {
|
|
326
|
+
const flag = step.ok ? 'ok ' : 'FAIL';
|
|
327
|
+
const extra = step.note ? ` ${step.note}` : '';
|
|
328
|
+
lines.push(`${String(step.n).padStart(2)}. ${flag} ${String(step.do).padEnd(10)} ${String(step.ms).padStart(6)} ms ${step.delivery ?? ''}${extra}`.trimEnd());
|
|
329
|
+
}
|
|
330
|
+
const head = envelope.ok === true
|
|
331
|
+
? `task ${result.name ?? ''} ok in ${result.total ?? '?'} ms`
|
|
332
|
+
: `task failed: ${envelope.error}${envelope.delivery ? ` delivery=${envelope.delivery}` : ''}${envelope.retry ? ` retry=${envelope.retry}` : ''}`;
|
|
333
|
+
return [head, ...lines].join('\n');
|
|
334
|
+
},
|
|
335
|
+
presentCall(args) {
|
|
336
|
+
return { card: 'generic', title: `Run task ${String(args.file ?? '').split(/[\\/]/).pop() ?? ''}`, kind: 'execute' };
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
export const inject = ['tools'];
|
|
341
|
+
|
|
342
|
+
export function apply(ctx) {
|
|
343
|
+
ctx.effect(() => ctx.tools.register(defineTool(SNAPSHOT)));
|
|
344
|
+
ctx.effect(() => ctx.tools.register(defineTool(ACT)));
|
|
345
|
+
ctx.effect(() => ctx.tools.register(defineTool(TASK)));
|
|
346
|
+
}
|