@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 CHANGED
@@ -7,7 +7,7 @@
7
7
  **Give DeepSeek Harness hands and eyes on your desktop.**
8
8
 
9
9
  [![awesome · DSH plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com/p/momasiku/dsh-pilot/)
10
- [![npm](https://img.shields.io/npm/v/@momasiku/dsh-pilot?label=npm)](https://www.npmjs.com/package/@momasiku/dsh-pilot)
10
+ [![npm](https://img.shields.io/npm/v/@momasiku%2Fdsh-pilot?label=npm)](https://www.npmjs.com/package/@momasiku/dsh-pilot)
11
11
  [![compat](https://github.com/momasiku/dsh-pilot/actions/workflows/compat.yml/badge.svg)](https://github.com/momasiku/dsh-pilot/actions/workflows/compat.yml)
12
12
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](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
+ ![dsh-pilot drawing a mechanical plate in AutoCAD: the model types REC and the corner coordinates, the inner outline, five circles, the centre lines and two linear dimensions into the command line, then zooms to extents](https://raw.githubusercontent.com/momasiku/dsh-pilot/main/assets/dsh-pilot-cad.gif)
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
+ ![dsh-pilot renaming a column in LibreOffice Calc: focus the workbook, move the pointer to C1, click, paste "Q3 收入", press Enter, Ctrl+S — all in one desktop_sequence call](https://raw.githubusercontent.com/momasiku/dsh-pilot/main/assets/dsh-pilot-excel.gif)
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
+ ![dsh-pilot modelling in Blender: the viewport is cleared, a cube is added, and subdivision turns it into a sphere, then the model orbits to check it](https://raw.githubusercontent.com/momasiku/dsh-pilot/main/assets/dsh-pilot-blender.gif)
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
+ ![dsh-pilot 在 AutoCAD 里画一块机械板:模型把 REC、角点坐标、内轮廓、五个圆、中心线和两道线性标注依次输入命令行,最后缩放到范围](https://raw.githubusercontent.com/momasiku/dsh-pilot/main/assets/dsh-pilot-cad.gif)
579
+
580
+ **模型自己画出来的 CAD 图。** 用的就是 AutoCAD 自己的命令行——`REC` → `0,0` → `240,150`、内轮廓、五个圆孔、中心线、两道尺寸,**AutoCAD 里什么都没装**。
581
+
582
+ ![dsh-pilot 在 LibreOffice Calc 里改一列表头:聚焦工作簿、鼠标移到 C1、点击、粘贴「Q3 收入」、回车、Ctrl+S —— 全部在一次 desktop_sequence 调用里完成](https://raw.githubusercontent.com/momasiku/dsh-pilot/main/assets/dsh-pilot-excel.gif)
583
+
584
+ **一次调用改完一张表。** `desktop_sequence` 聚焦工作簿、把鼠标移到 C1、点击、粘贴「Q3 收入」、回车、保存——整串动作只花一次往返。
585
+
586
+ ![dsh-pilot 在 Blender 里建模:先清空视口,加一个立方体,再用细分曲面把它磨成一个球,最后轨道旋转检查](https://raw.githubusercontent.com/momasiku/dsh-pilot/main/assets/dsh-pilot-blender.gif)
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
- if (config.useWorker === false || !actionWorker.usable()) {
490
- return runHelper(actionScript(), params, options.signal, options.timeoutMs);
491
- }
492
- const cancelPath = join(scratchDir(), `cancel-${nextToken()}.txt`);
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
- return await actionWorker.request({ ...params, cancelFile: cancelPath }, options);
495
- } catch (error) {
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
- rmSync(cancelPath, { force: true });
503
- } catch {
504
- // A leftover cancel file is harmless: the token is unique.
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') throw new Error(`step ${position} (focus) needs title`);
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
- /** A wait that still honours an interrupted turn. */
1182
- function abortableWait(ms, signal) {
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
- const timer = setTimeout(resolvePromise, Math.max(0, ms));
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
- for (const [offset, step] of steps.entries()) {
1589
- if (exec?.signal?.aborted) throw new Error('desktop_sequence: the batch was cancelled');
1590
- const stepStarted = Date.now();
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
- const entry = await runSequenceStep(step, config, options);
1593
- results.push({ index: offset + 1, ...entry, ...(step.risk === undefined ? {} : { risk: step.risk }) });
1594
- } catch (error) {
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);