@momasiku/dsh-pilot 0.5.16 → 0.6.0
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/README.md +86 -13
- package/lib/index.js +190 -42
- package/lib/scripts/_dsh-action.ps1 +434 -425
- package/lib/scripts/_dsh-capture.ps1 +1 -1
- package/lib/scripts/_dsh-overlay.ps1 +301 -0
- package/lib/scripts/_dsh-uia.ps1 +1 -1
- package/lib/scripts/_dsh-win32.ps1 +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
**Give DeepSeek Harness hands and eyes on your desktop.**
|
|
8
8
|
|
|
9
9
|
[](https://awesome-dsh-plugin.com/p/momasiku/dsh-pilot/)
|
|
10
|
-
[](https://www.npmjs.com/package/@momasiku/dsh-pilot)
|
|
11
11
|
[](https://github.com/momasiku/dsh-pilot/actions/workflows/compat.yml)
|
|
12
12
|
[](LICENSE)
|
|
13
13
|
|
|
@@ -23,18 +23,32 @@ touch it: you describe what is on screen, you take the screenshots, you do the
|
|
|
23
23
|
clicking, and you report what happened. **dsh-pilot closes that loop.** The model
|
|
24
24
|
looks at the real pixels and then operates them:
|
|
25
25
|
|
|
26
|
+
### Demos
|
|
27
|
+
|
|
28
|
+
<!-- Served from raw.githubusercontent.com, the same host every asset here uses. If
|
|
29
|
+
that host is slow where you read this, jsDelivr mirrors the same files:
|
|
30
|
+
https://cdn.jsdelivr.net/gh/momasiku/dsh-pilot@main/assets/dsh-pilot-cad.gif
|
|
31
|
+
https://cdn.jsdelivr.net/gh/momasiku/dsh-pilot@main/assets/dsh-pilot-excel.gif
|
|
32
|
+
https://cdn.jsdelivr.net/gh/momasiku/dsh-pilot@main/assets/dsh-pilot-blender.gif -->
|
|
33
|
+
|
|
34
|
+

|
|
35
|
+
|
|
36
|
+
**A CAD drawing, drawn by the model.** It drives AutoCAD's own command line — `REC` → `0,0` → `240,150`, the inner outline, five holes, centre lines and two dimensions — and installs nothing inside AutoCAD.
|
|
37
|
+
|
|
38
|
+

|
|
39
|
+
|
|
40
|
+
**A spreadsheet edit, in one call.** `desktop_sequence` focuses the workbook, moves the pointer to C1, clicks, pastes `Q3 收入`, presses Enter and saves — one round trip for the whole chain.
|
|
41
|
+
|
|
42
|
+

|
|
43
|
+
|
|
44
|
+
**A 3D model, built from nothing.** It clears the viewport, adds a cube, then `Ctrl+1` and `Ctrl+3` subdivision turn that cube into a sphere. Real Blender 4.2 on a machine with **no GPU** — driven through Mesa's software OpenGL, which the model set up itself.
|
|
45
|
+
|
|
26
46
|
| tool | what it does |
|
|
27
47
|
| --- | --- |
|
|
28
48
|
| **`screen_view`** | captures the live desktop and returns the PNG as a **real image block** the model can see — not OCR, not a text summary |
|
|
29
49
|
| **`desktop_control`** | moves the pointer, clicks, drags, scrolls, types, sends keys, focuses and lists windows — and can capture the result in the same call |
|
|
30
50
|
| **`desktop_sequence`** | runs a **whole batch** of those actions in one call and looks once at the end |
|
|
31
51
|
|
|
32
|
-
```text
|
|
33
|
-
You : 把浏览器里那个报表的第三列改名成 "Q3 收入",然后保存
|
|
34
|
-
DSH : desktop_sequence([focus "报表.xlsx", click 812,430, type "Q3 收入", key enter, key ctrl+s])
|
|
35
|
-
→ 1 round trip, 1 screenshot, per-step timings, done
|
|
36
|
-
```
|
|
37
|
-
|
|
38
52
|
## Why this exists
|
|
39
53
|
|
|
40
54
|
A model that cannot see the screen has to be told what is on it. A model that cannot
|
|
@@ -234,6 +248,51 @@ and what `dsh plugin add` checks before it installs anything. Requirements:
|
|
|
234
248
|
|
|
235
249
|
### Recent changes
|
|
236
250
|
|
|
251
|
+
**0.6.0** — a takeover line, a lit pointer, and titles that stop hiding characters.
|
|
252
|
+
|
|
253
|
+
- **The screen says who is driving.** While an action runs, one line of large
|
|
254
|
+
translucent type names the operator and the way out — `DSH 正在操作你的电脑 · 按 Esc
|
|
255
|
+
取消` — and a halo follows the cursor. No border: the first version drew one, and at
|
|
256
|
+
2560x1600 it was either invisible against a blue wallpaper or heavy enough to feel
|
|
257
|
+
like a frame. Sizes come from the virtual desktop, so a 4K wall and a 1080p laptop
|
|
258
|
+
get the same visual weight.
|
|
259
|
+
- **Esc cancels the batch, not the step.** The overlay stamps the cancel token the
|
|
260
|
+
action loop already polled; that token now belongs to the whole `desktop_sequence`,
|
|
261
|
+
so a `wait` in the middle is interrupted too, and the result says why:
|
|
262
|
+
`the person pressed Esc; the batch stopped where it was`. Measured: a 12 s batch with
|
|
263
|
+
Esc at 4.5 s returned after 7.1 s.
|
|
264
|
+
- **Titles are compared after stripping zero-width characters.** Edge's own title has
|
|
265
|
+
a U+200B inside `Microsoft Edge`; `focus "Microsoft Edge"` failed with "no visible
|
|
266
|
+
top-level window matches" on a window the person could read perfectly.
|
|
267
|
+
- **`focus` accepts an `hwnd` on its own**, as the description always promised.
|
|
268
|
+
- **Every helper script must keep its UTF-8 BOM** — `script-smoke` fails if one loses
|
|
269
|
+
it, because PowerShell 5.1 reads a BOM-less file as ANSI and the Chinese caption
|
|
270
|
+
renders as mojibake. This caught three other scripts that had already lost it.
|
|
271
|
+
- The pointer halo is drawn, never written: no `SetCursorPos` and no input hook, which
|
|
272
|
+
is why the ring never makes a person's own mouse feel laggy.
|
|
273
|
+
|
|
274
|
+
- **A takeover line and a lit pointer.** While an action runs, one line of large
|
|
275
|
+
translucent type names the operator and the way out — `DSH 正在操作你的电脑 · 按 Esc
|
|
276
|
+
取消` — and a halo follows the cursor. No border around the screen: the first version
|
|
277
|
+
drew one, and at 2560x1600 it was either invisible against a blue wallpaper or heavy
|
|
278
|
+
enough to feel like a frame. The type and the halo say the same thing without taking
|
|
279
|
+
the desktop hostage. Sizes come from the virtual desktop, so a 4K wall and a 1080p
|
|
280
|
+
laptop get the same visual weight.
|
|
281
|
+
- **Esc cancels the batch, not the step.** The overlay stamps the same cancel token the
|
|
282
|
+
action loop already polled; that token now belongs to the whole `desktop_sequence`,
|
|
283
|
+
so a `wait` in the middle is interrupted too, and the result says why:
|
|
284
|
+
`the person pressed Esc; the batch stopped where it was`. Measured: a 12 s batch with
|
|
285
|
+
Esc at 4.5 s returned after 7.1 s.
|
|
286
|
+
- **The pointer is lit, not taken over.** A halo tracks the cursor while the ring is up,
|
|
287
|
+
so it is obvious the movement is not yours. It is drawing, not the pointer: Windows
|
|
288
|
+
paints the real cursor above every window, so the halo sits under it and the pointer
|
|
289
|
+
reads as larger and lit without being replaced — and without the cursor being written,
|
|
290
|
+
which is what makes other agents' takeovers feel laggy. The halo lives in its own small
|
|
291
|
+
window rather than as a child of the ring: a child Grid with a runtime margin never
|
|
292
|
+
painted on this layered surface, while a window that is simply moved does.
|
|
293
|
+
- Fully permissioned sessions are not asked again — the ring is the statement, not a
|
|
294
|
+
prompt. `takeover: false` turns it off.
|
|
295
|
+
|
|
237
296
|
**0.5.14** — the coordinate contract stops guessing how big the image you see is.
|
|
238
297
|
|
|
239
298
|
- **The request pipeline resizes again after capture**, and this plugin never saw
|
|
@@ -508,18 +567,32 @@ DSH 原本只会"说":它能推理、能写代码、能规划,但**看不见
|
|
|
508
567
|
|
|
509
568
|
**dsh-pilot 把这个环闭上**:模型直接看真实像素,然后自己动手。
|
|
510
569
|
|
|
570
|
+
### 实例演示
|
|
571
|
+
|
|
572
|
+
<!-- 图片走 raw.githubusercontent.com(本仓库所有资源都用这个域名)。若在你那边较慢,
|
|
573
|
+
jsDelivr 上有同样的镜像:
|
|
574
|
+
https://cdn.jsdelivr.net/gh/momasiku/dsh-pilot@main/assets/dsh-pilot-cad.gif
|
|
575
|
+
https://cdn.jsdelivr.net/gh/momasiku/dsh-pilot@main/assets/dsh-pilot-excel.gif
|
|
576
|
+
https://cdn.jsdelivr.net/gh/momasiku/dsh-pilot@main/assets/dsh-pilot-blender.gif -->
|
|
577
|
+
|
|
578
|
+

|
|
579
|
+
|
|
580
|
+
**模型自己画出来的 CAD 图。** 用的就是 AutoCAD 自己的命令行——`REC` → `0,0` → `240,150`、内轮廓、五个圆孔、中心线、两道尺寸,**AutoCAD 里什么都没装**。
|
|
581
|
+
|
|
582
|
+

|
|
583
|
+
|
|
584
|
+
**一次调用改完一张表。** `desktop_sequence` 聚焦工作簿、把鼠标移到 C1、点击、粘贴「Q3 收入」、回车、保存——整串动作只花一次往返。
|
|
585
|
+
|
|
586
|
+

|
|
587
|
+
|
|
588
|
+
**从零建一个 3D 模型。** 清空视口 → 加一个立方体 → `Ctrl+1`、`Ctrl+3` 两级细分,方盒子被磨成一个球。真机上的 Blender 4.2,跑在**一台没有显卡的机器**上——靠 Mesa 的软件 OpenGL,而那套环境也是模型自己配好的。
|
|
589
|
+
|
|
511
590
|
| 工具 | 作用 |
|
|
512
591
|
| --- | --- |
|
|
513
592
|
| **`screen_view`** | 抓取当前屏幕,把 PNG 作为**真正的图像块**交给模型(不是 OCR、不是文字描述),连光标位置、前台窗口、各显示器范围一起给出 |
|
|
514
593
|
| **`desktop_control`** | 移动鼠标、单击/双击/右键、拖拽、滚轮、打字、按键、聚焦与列出窗口;可以在同一次调用里顺手截一张,让效果立刻可见 |
|
|
515
594
|
| **`desktop_sequence`** | **一次调用跑完一整串动作**,最后只看一眼结果 |
|
|
516
595
|
|
|
517
|
-
```text
|
|
518
|
-
你 :把浏览器里那张报表的第三列改名成 "Q3 收入",然后保存
|
|
519
|
-
DSH:desktop_sequence([focus "报表.xlsx", click 812,430, type "Q3 收入", key enter, key ctrl+s])
|
|
520
|
-
→ 1 次往返、1 张截图、每步耗时,完成
|
|
521
|
-
```
|
|
522
|
-
|
|
523
596
|
## 它到底解决了什么
|
|
524
597
|
|
|
525
598
|
桌面自动化的瓶颈**从来不是鼠标点得快不快**,而是 **"模型和电脑要来回说多少次话"**:
|
package/lib/index.js
CHANGED
|
@@ -45,7 +45,9 @@ const Config = z.object({
|
|
|
45
45
|
/** Draw coordinate rulers on every frame, so readings do not depend on counting pixels. */
|
|
46
46
|
rulers: z.boolean().default(true),
|
|
47
47
|
/** Also capture a frame when a tool result carries no image (still materializes the file). */
|
|
48
|
-
alwaysSaveFile: z.boolean().default(true)
|
|
48
|
+
alwaysSaveFile: z.boolean().default(true),
|
|
49
|
+
/** Ring the desktop and offer Esc while an action runs, so a person can see who is driving and stop it. */
|
|
50
|
+
takeover: z.boolean().default(true)
|
|
49
51
|
});
|
|
50
52
|
|
|
51
53
|
/** Services this plugin registers against. */
|
|
@@ -109,12 +111,12 @@ function nextToken() {
|
|
|
109
111
|
* @param timeoutMs - hard kill deadline in milliseconds.
|
|
110
112
|
* @returns the parsed JSON result object.
|
|
111
113
|
*/
|
|
112
|
-
function runHelper(script, params, signal, timeoutMs) {
|
|
114
|
+
function runHelper(script, params, signal, timeoutMs, cancelPathOverride) {
|
|
113
115
|
if (!existsSync(script)) throw new Error(`pilot: helper script is missing at ${script}`);
|
|
114
116
|
const dir = scratchDir();
|
|
115
117
|
const token = nextToken();
|
|
116
118
|
const paramsPath = join(dir, `params-${token}.json`);
|
|
117
|
-
const cancelPath = join(dir, `cancel-${token}.txt`);
|
|
119
|
+
const cancelPath = cancelPathOverride ?? join(dir, `cancel-${token}.txt`);
|
|
118
120
|
writeFileSync(paramsPath, JSON.stringify({ ...params, cancelFile: cancelPath }), 'utf8');
|
|
119
121
|
|
|
120
122
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
@@ -485,27 +487,116 @@ function warmWorker(config) {
|
|
|
485
487
|
* @param options - signal and timeout.
|
|
486
488
|
* @returns the parsed result.
|
|
487
489
|
*/
|
|
488
|
-
async function runAction(params, config, options) {
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
const
|
|
490
|
+
async function runAction(params, config, options, cancelPathOverride) {
|
|
491
|
+
const ownsRing = cancelPathOverride === undefined;
|
|
492
|
+
const cancelPath = cancelPathOverride ?? join(scratchDir(), `cancel-${nextToken()}.txt`);
|
|
493
|
+
const startedAt = Date.now();
|
|
494
|
+
const takeDir = ownsRing ? beginTakeover(cancelPath, config) : null;
|
|
493
495
|
try {
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
if (error?.transport === true) {
|
|
497
|
-
return runHelper(actionScript(), params, options.signal, options.timeoutMs);
|
|
496
|
+
if (config.useWorker === false || !actionWorker.usable()) {
|
|
497
|
+
return await runHelper(actionScript(), params, options.signal, options.timeoutMs, cancelPath);
|
|
498
498
|
}
|
|
499
|
-
throw error;
|
|
500
|
-
} finally {
|
|
501
499
|
try {
|
|
502
|
-
|
|
503
|
-
} catch {
|
|
504
|
-
|
|
500
|
+
return await actionWorker.request({ ...params, cancelFile: cancelPath }, options);
|
|
501
|
+
} catch (error) {
|
|
502
|
+
if (error?.transport === true) {
|
|
503
|
+
return await runHelper(actionScript(), params, options.signal, options.timeoutMs, cancelPath);
|
|
504
|
+
}
|
|
505
|
+
// An abort and an Esc both land here as "cancelled"; only one of them was
|
|
506
|
+
// a person deciding, and the model should be told which.
|
|
507
|
+
if (error?.cancelled === true && tookEsc(takeDir, startedAt)) {
|
|
508
|
+
throw Object.assign(new Error('pilot: the person pressed Esc and cancelled this action'), { cancelled: true });
|
|
509
|
+
}
|
|
510
|
+
throw error;
|
|
511
|
+
}
|
|
512
|
+
} finally {
|
|
513
|
+
endTakeover(takeDir);
|
|
514
|
+
if (ownsRing) {
|
|
515
|
+
try {
|
|
516
|
+
rmSync(cancelPath, { force: true });
|
|
517
|
+
} catch {
|
|
518
|
+
// A leftover cancel file is harmless: the token is unique.
|
|
519
|
+
}
|
|
505
520
|
}
|
|
506
521
|
}
|
|
507
522
|
}
|
|
508
523
|
|
|
524
|
+
/** The one overlay process, started the first time a takeover is worth showing. */
|
|
525
|
+
let overlayProcess = null;
|
|
526
|
+
|
|
527
|
+
/** Absolute path of the takeover overlay helper. */
|
|
528
|
+
function overlayScript() {
|
|
529
|
+
return join(packageRoot(), 'lib', 'scripts', '_dsh-overlay.ps1');
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Directory the overlay polls for its instructions. */
|
|
533
|
+
function takeoverDir() {
|
|
534
|
+
const dir = join(scratchDir(), 'takeover');
|
|
535
|
+
mkdirSync(dir, { recursive: true });
|
|
536
|
+
return dir;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Raise the takeover ring for one action and hand it the token to stamp.
|
|
541
|
+
*
|
|
542
|
+
* The ring is decoration plus one hotkey: it never writes the cursor and never
|
|
543
|
+
* installs an input hook, because either would add exactly the input lag that
|
|
544
|
+
* makes a person's own mouse feel broken while an agent drives. Esc writes the
|
|
545
|
+
* same "stop" the host writes on abort, so the loop stops between two steps.
|
|
546
|
+
*
|
|
547
|
+
* @param cancelPath - the cancel token this action polls.
|
|
548
|
+
* @param config - validated plugin configuration.
|
|
549
|
+
* @returns the directory to clear afterwards, or null when the ring is off.
|
|
550
|
+
*/
|
|
551
|
+
function beginTakeover(cancelPath, config) {
|
|
552
|
+
if (config.takeover === false) return null;
|
|
553
|
+
let dir;
|
|
554
|
+
try {
|
|
555
|
+
dir = takeoverDir();
|
|
556
|
+
writeFileSync(join(dir, 'show.txt'), cancelPath, 'utf8');
|
|
557
|
+
} catch {
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
if (overlayProcess !== null && overlayProcess.exitCode === null) return dir;
|
|
561
|
+
try {
|
|
562
|
+
overlayProcess = spawn(
|
|
563
|
+
'powershell.exe',
|
|
564
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', overlayScript(), '-StateDir', dir],
|
|
565
|
+
{ stdio: 'ignore', windowsHide: true }
|
|
566
|
+
);
|
|
567
|
+
overlayProcess.on('exit', () => {
|
|
568
|
+
overlayProcess = null;
|
|
569
|
+
});
|
|
570
|
+
overlayProcess.on('error', () => {
|
|
571
|
+
overlayProcess = null;
|
|
572
|
+
});
|
|
573
|
+
overlayProcess.unref();
|
|
574
|
+
} catch {
|
|
575
|
+
overlayProcess = null;
|
|
576
|
+
}
|
|
577
|
+
return dir;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Drop the ring: the host owns visibility, the overlay only polls the file. */
|
|
581
|
+
function endTakeover(dir) {
|
|
582
|
+
if (dir === null) return;
|
|
583
|
+
try {
|
|
584
|
+
rmSync(join(dir, 'show.txt'), { force: true });
|
|
585
|
+
} catch {
|
|
586
|
+
// Leaving it would keep the ring up; the next action rewrites it anyway.
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** True when the person pressed Esc during this action. */
|
|
591
|
+
function tookEsc(dir, sinceMs) {
|
|
592
|
+
if (dir === null) return false;
|
|
593
|
+
try {
|
|
594
|
+
return statSync(join(dir, 'esc.txt')).mtimeMs >= sinceMs;
|
|
595
|
+
} catch {
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
509
600
|
/**
|
|
510
601
|
* Run one capture: through the warm worker when it is available, otherwise
|
|
511
602
|
* through the one-shot probe.
|
|
@@ -1138,7 +1229,9 @@ function normalizeSequence(args, config) {
|
|
|
1138
1229
|
}
|
|
1139
1230
|
if (action === 'type' && typeof normalized.text !== 'string') throw new Error(`step ${position} (type) needs text`);
|
|
1140
1231
|
if (action === 'key' && typeof normalized.key !== 'string') throw new Error(`step ${position} (key) needs key`);
|
|
1141
|
-
if (action === 'focus' && typeof normalized.title !== 'string'
|
|
1232
|
+
if (action === 'focus' && typeof normalized.title !== 'string' && typeof normalized.hwnd !== 'string') {
|
|
1233
|
+
throw new Error(`step ${position} (focus) needs a title, or an hwnd`);
|
|
1234
|
+
}
|
|
1142
1235
|
if (action === 'wait' && !Number.isInteger(normalized.ms)) throw new Error(`step ${position} (wait) needs integer ms`);
|
|
1143
1236
|
if (normalized.risk !== undefined && !RISKS.includes(normalized.risk)) {
|
|
1144
1237
|
throw new Error(`step ${position} risk must be one of ${RISKS.join('/')}`);
|
|
@@ -1178,27 +1271,58 @@ function sequenceRisk(steps, declared, confirm) {
|
|
|
1178
1271
|
return effective;
|
|
1179
1272
|
}
|
|
1180
1273
|
|
|
1181
|
-
/**
|
|
1182
|
-
function
|
|
1274
|
+
/** True when the cancel token was stamped. */
|
|
1275
|
+
function stopRequested(cancelPath) {
|
|
1276
|
+
if (typeof cancelPath !== 'string' || cancelPath.length === 0) return false;
|
|
1277
|
+
try {
|
|
1278
|
+
return readFileSync(cancelPath, 'utf8').trim() === 'stop';
|
|
1279
|
+
} catch {
|
|
1280
|
+
return false;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* A wait that still honours an interrupted turn AND the person pressing Esc.
|
|
1286
|
+
* A plain timer would sit through a 900 ms settle after the takeover was already
|
|
1287
|
+
* called off, which is the one moment the ring most obviously lies.
|
|
1288
|
+
*/
|
|
1289
|
+
function abortableWait(ms, signal, cancelPath) {
|
|
1183
1290
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
1184
|
-
|
|
1291
|
+
let timer;
|
|
1292
|
+
const deadline = Date.now() + Math.max(0, ms);
|
|
1293
|
+
const tick = () => {
|
|
1294
|
+
if (signal?.aborted) {
|
|
1295
|
+
rejectPromise(new Error('pilot: the batch was cancelled during a wait'));
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
if (stopRequested(cancelPath)) {
|
|
1299
|
+
rejectPromise(Object.assign(new Error('pilot: the person pressed Esc and cancelled this batch'), { cancelled: true }));
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
const left = deadline - Date.now();
|
|
1303
|
+
if (left <= 0) {
|
|
1304
|
+
resolvePromise();
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
timer = setTimeout(tick, Math.min(100, left));
|
|
1308
|
+
};
|
|
1185
1309
|
signal?.addEventListener('abort', () => {
|
|
1186
1310
|
clearTimeout(timer);
|
|
1187
|
-
rejectPromise(new Error('pilot: the batch was cancelled during a wait'));
|
|
1188
1311
|
}, { once: true });
|
|
1312
|
+
tick();
|
|
1189
1313
|
});
|
|
1190
1314
|
}
|
|
1191
1315
|
|
|
1192
1316
|
/** Run one step: `wait` locally, everything else through the action helper. */
|
|
1193
|
-
async function runSequenceStep(step, config, options) {
|
|
1317
|
+
async function runSequenceStep(step, config, options, cancelPath) {
|
|
1194
1318
|
const started = Date.now();
|
|
1195
1319
|
if (step.action === 'wait') {
|
|
1196
|
-
await abortableWait(step.ms, options.signal);
|
|
1320
|
+
await abortableWait(step.ms, options.signal, cancelPath);
|
|
1197
1321
|
return { action: 'wait', ok: true, ms: Date.now() - started };
|
|
1198
1322
|
}
|
|
1199
1323
|
const params = { action: step.action, settleMs: Number.isSafeInteger(step.settleMs) ? step.settleMs : config.stepSettleMs };
|
|
1200
1324
|
for (const field of STEP_FIELDS) if (step[field] !== undefined) params[field] = step[field];
|
|
1201
|
-
const acted = await runAction(params, config, options);
|
|
1325
|
+
const acted = await runAction(params, config, options, cancelPath);
|
|
1202
1326
|
const entry = { action: step.action, ok: true, ms: Date.now() - started };
|
|
1203
1327
|
if (acted.cursor !== undefined) entry.cursor = acted.cursor;
|
|
1204
1328
|
if (acted.foregroundTitle !== undefined) entry.foregroundTitle = acted.foregroundTitle;
|
|
@@ -1584,24 +1708,47 @@ function apply(ctx, config) {
|
|
|
1584
1708
|
const stopOnError = args.stopOnError !== false;
|
|
1585
1709
|
const results = [];
|
|
1586
1710
|
let failed = 0;
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1711
|
+
// One token and one ring cover the whole batch, so Esc stops the batch rather
|
|
1712
|
+
// than the single step that happened to be running when the key landed.
|
|
1713
|
+
const batchCancel = join(scratchDir(), `cancel-${nextToken()}.txt`);
|
|
1714
|
+
const batchStarted = Date.now();
|
|
1715
|
+
const batchDir = beginTakeover(batchCancel, config);
|
|
1716
|
+
let cancelledByEsc = false;
|
|
1717
|
+
try {
|
|
1718
|
+
for (const [offset, step] of steps.entries()) {
|
|
1719
|
+
if (exec?.signal?.aborted) throw new Error('desktop_sequence: the batch was cancelled');
|
|
1720
|
+
if (stopRequested(batchCancel)) {
|
|
1721
|
+
cancelledByEsc = true;
|
|
1722
|
+
break;
|
|
1723
|
+
}
|
|
1724
|
+
const stepStarted = Date.now();
|
|
1725
|
+
try {
|
|
1726
|
+
const entry = await runSequenceStep(step, config, options, batchCancel);
|
|
1727
|
+
results.push({ index: offset + 1, ...entry, ...(step.risk === undefined ? {} : { risk: step.risk }) });
|
|
1728
|
+
} catch (error) {
|
|
1729
|
+
failed += 1;
|
|
1730
|
+
results.push({
|
|
1731
|
+
index: offset + 1,
|
|
1732
|
+
action: step.action,
|
|
1733
|
+
ok: false,
|
|
1734
|
+
ms: Date.now() - stepStarted,
|
|
1735
|
+
note: error instanceof Error ? error.message : String(error),
|
|
1736
|
+
...(step.risk === undefined ? {} : { risk: step.risk })
|
|
1737
|
+
});
|
|
1738
|
+
if (error?.cancelled === true) {
|
|
1739
|
+
cancelledByEsc = true;
|
|
1740
|
+
break;
|
|
1741
|
+
}
|
|
1742
|
+
if (stopOnError) break;
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
} finally {
|
|
1746
|
+
cancelledByEsc = cancelledByEsc || tookEsc(batchDir, batchStarted);
|
|
1747
|
+
endTakeover(batchDir);
|
|
1591
1748
|
try {
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
failed += 1;
|
|
1596
|
-
results.push({
|
|
1597
|
-
index: offset + 1,
|
|
1598
|
-
action: step.action,
|
|
1599
|
-
ok: false,
|
|
1600
|
-
ms: Date.now() - stepStarted,
|
|
1601
|
-
note: error instanceof Error ? error.message : String(error),
|
|
1602
|
-
...(step.risk === undefined ? {} : { risk: step.risk })
|
|
1603
|
-
});
|
|
1604
|
-
if (stopOnError) break;
|
|
1749
|
+
rmSync(batchCancel, { force: true });
|
|
1750
|
+
} catch {
|
|
1751
|
+
// The token is unique, so a leftover file cannot cancel anything else.
|
|
1605
1752
|
}
|
|
1606
1753
|
}
|
|
1607
1754
|
|
|
@@ -1615,6 +1762,7 @@ function apply(ctx, config) {
|
|
|
1615
1762
|
steps: results,
|
|
1616
1763
|
actedAt: new Date().toISOString()
|
|
1617
1764
|
};
|
|
1765
|
+
if (cancelledByEsc) value.notes = ['the person pressed Esc; the batch stopped where it was'];
|
|
1618
1766
|
const lastCursor = [...results].reverse().find((entry) => entry.cursor !== undefined);
|
|
1619
1767
|
if (lastCursor !== undefined) value.cursor = lastCursor.cursor;
|
|
1620
1768
|
const lastForeground = [...results].reverse().find((entry) => entry.foregroundTitle !== undefined);
|