dsh-win-cua 0.1.0 → 0.2.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Wesley Wang
3
+ Copyright (c) 2026 WJPGKZ
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # dsh-win32-cua
1
+ # dsh-win-cua
2
2
 
3
3
  Windows computer-use for DeepSeek Harness: **real** mouse and keyboard input plus screen
4
4
  capture, with no external driver.
@@ -19,18 +19,30 @@ the virtual screen; the primary display's top-left is `(0, 0)`.
19
19
 
20
20
  ## Tools
21
21
 
22
- - `cua_screenshot` — capture the virtual screen to a PNG, return path and size
23
- - `cua_mouse_move` — move the pointer
24
- - `cua_mouse_click` — click / double-click / right / middle, optionally at x,y
25
- - `cua_scroll` — wheel
22
+ **Look**
23
+ - `cua_screenshot` — capture the whole virtual screen to a PNG
24
+ - `cua_screenshot_window` — capture one window by handle (`PrintWindow`, screen-blit fallback)
25
+ - `cua_zoom` — capture a region and upscale it x1–x4 for reading small UI text
26
+ - `cua_windows` — visible top-level windows with bounds and flags
27
+ - `cua_app_list` — same list enriched with the process executable name
28
+ - `cua_cursor` — pointer position and virtual-screen geometry
29
+
30
+ **Act**
31
+ - `cua_mouse_move` — move the pointer along a human path
32
+ - `cua_mouse_click` — click / double / right / middle, optionally at x,y
33
+ - `cua_drag` — press, travel and release with the button held
34
+ - `cua_scroll` — wheel, vertical or horizontal
26
35
  - `cua_key` — one key with optional modifiers
27
- - `cua_type` — type text
28
- - `cua_windows` — visible top-level windows
29
- - `cua_focus_window` — restore and focus a window
30
- - `cua_cursor` — pointer position and screen geometry
36
+ - `cua_type` — type text (unicode, incl. surrogate pairs)
37
+
38
+ **Window & app**
39
+ - `cua_focus_window` — restore and foreground a window
40
+ - `cua_wait` — sleep and/or poll until a window title appears
41
+ - `cua_app_launch` — launch an executable without a shell
31
42
 
32
43
  Every tool declares `output { schema, render }` — the DSH tool registry rejects raw
33
- definitions without it, so keep that shape when adding tools.
44
+ definitions without it, so keep that shape when adding tools. Read-only tools are marked
45
+ `readOnly` and register as `isConcurrencySafe`.
34
46
 
35
47
  ## Config
36
48
 
@@ -39,16 +51,36 @@ Set in this bundle's `cordis.patch.yml` under `config`:
39
51
  | Key | Default | Meaning |
40
52
  |---|---|---|
41
53
  | `humanize` | `true` | Pointer travels a smoothstep path, typing and clicking keep a human cadence |
42
- | `keepScreenshots` | `30` | Newest screenshots kept per capture directory |
54
+ | `keepScreenshots` | `30` | Newest captures kept per directory |
43
55
  | `moveSteps` | `16` | Max pointer steps for a humanized move |
44
56
  | `keyDelayMs` | `[12, 34]` | Per-character delay range for humanized typing |
45
57
  | `clickSettleMs` | `[60, 140]` | Pause between the pointer arriving and the button press |
58
+ | `zoomFactor` | `2` | Default `cua_zoom` upscale factor (hard cap 4) |
59
+ | `zoomMaxPixels` | `24000000` | Largest upscaled image allowed |
60
+ | `maxWaitMs` | `10000` | Upper bound for `cua_wait` |
61
+ | `allowLaunch` | `true` | Set `false` to disable `cua_app_launch` |
46
62
 
47
63
  Pointer targets are clamped into the virtual screen, and astral code points (emoji,
48
64
  CJK extension B) are sent as surrogate pairs so `KEYEVENTF_UNICODE` types them correctly.
49
65
 
66
+ ## No external driver
67
+
68
+ Unlike `dsh-computer-use` (which shells out to the `cua-driver` binary) this plugin calls
69
+ Win32 through koffi **inside the host process**: nothing to install, no daemon, no
70
+ `CUA_DRIVER_BIN`. The trade-off is honest: without the driver there is no UIA/accessibility
71
+ tree, so targeting is pixel-based — use `cua_zoom` / `cua_screenshot_window` to read a
72
+ window precisely before clicking.
73
+
50
74
  ## Install
51
75
 
76
+ From npm:
77
+
78
+ ```powershell
79
+ dsh plugin --profile <profile> add dsh-win-cua
80
+ ```
81
+
82
+ Or from a checkout / local directory:
83
+
52
84
  ```powershell
53
85
  dsh plugin --profile <profile> add <this directory>
54
86
  ```
@@ -57,3 +89,19 @@ Or in the Desktop app: Plugin Manager → install bundle → pick this directory
57
89
 
58
90
  Windows only. `SendInput` injects into the session whose input queue is active — a window
59
91
  on a locked or disconnected desktop receives nothing.
92
+
93
+ ## Changelog
94
+
95
+ ### 0.2.0
96
+
97
+ - New look tools: `cua_screenshot_window` (PrintWindow with screen-blit fallback),
98
+ `cua_zoom` (region capture upscaled x1–x4), `cua_app_list` (windows with process names).
99
+ - New act tools: `cua_drag`, `cua_wait`, `cua_app_launch` (disabled with `allowLaunch: false`).
100
+ - `humanize` is now a real config knob: smoothstep pointer path, per-character typing
101
+ delay and click settle range, with `moveSteps` / `keyDelayMs` / `clickSettleMs` tuning.
102
+ - PNG encoder, screenshot retention (`keepScreenshots`) and coordinate clamping tightened.
103
+
104
+ ### 0.1.0
105
+
106
+ First public release: screenshot, cursor, window list/focus, mouse move/click/scroll,
107
+ key press and unicode typing.
package/cordis.patch.yml CHANGED
@@ -4,4 +4,7 @@
4
4
  config:
5
5
  humanize: true
6
6
  keepScreenshots: 30
7
+ zoomFactor: 2
8
+ maxWaitMs: 10000
9
+ allowLaunch: true
7
10
 
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @local/dsh-win32-cua — Windows computer-use for DeepSeek Harness.
2
+ * dsh-win-cua — Windows computer-use for DeepSeek Harness.
3
3
  *
4
4
  * Real mouse and keyboard input through user32 `SendInput`, screen capture through
5
5
  * GDI `BitBlt` (CAPTUREBLT so layered windows are included), and a top-level window
@@ -11,7 +11,8 @@
11
11
  */
12
12
  import { deflateSync } from 'node:zlib';
13
13
  import { createRequire } from 'node:module';
14
- import { mkdirSync, writeFileSync, readdirSync, unlinkSync } from 'node:fs';
14
+ import { mkdirSync, writeFileSync, readdirSync, unlinkSync, existsSync } from 'node:fs';
15
+ import { spawn } from 'node:child_process';
15
16
  import { join } from 'node:path';
16
17
  import { tmpdir } from 'node:os';
17
18
 
@@ -50,6 +51,7 @@ function loadNative() {
50
51
 
51
52
  const user32 = koffi.load('user32.dll');
52
53
  const gdi32 = koffi.load('gdi32.dll');
54
+ const kernel32 = koffi.load('kernel32.dll');
53
55
 
54
56
  koffi.alias('HANDLE', 'void *');
55
57
  koffi.alias('HWND', 'HANDLE');
@@ -105,6 +107,11 @@ function loadNative() {
105
107
  DeleteDC: gdi32.func('bool __stdcall DeleteDC(HANDLE dc)'),
106
108
  BitBlt: gdi32.func('bool __stdcall BitBlt(HANDLE dst, int x, int y, int w, int h, HANDLE src, int sx, int sy, uint32_t rop)'),
107
109
  GetDIBits: gdi32.func('int __stdcall GetDIBits(HANDLE dc, HANDLE bmp, uint32_t start, uint32_t lines, void *bits, _Inout_ BITMAPINFOHEADER *bmi, uint32_t usage)'),
110
+ PrintWindow: user32.func('bool __stdcall PrintWindow(HWND hwnd, HANDLE dc, uint32_t flags)'),
111
+ GetWindowDC: user32.func('HANDLE __stdcall GetWindowDC(HWND hwnd)'),
112
+ OpenProcess: kernel32.func('HANDLE __stdcall OpenProcess(uint32_t access, bool inherit, uint32_t pid)'),
113
+ QueryFullProcessImageNameW: kernel32.func('bool __stdcall QueryFullProcessImageNameW(HANDLE proc, uint32_t flags, _Out_ uint16_t *buf, _Inout_ uint32_t *size)'),
114
+ CloseHandle: kernel32.func('bool __stdcall CloseHandle(HANDLE handle)'),
108
115
  structs: { POINT, RECT, INPUT, BITMAPINFOHEADER },
109
116
  };
110
117
  try {
@@ -134,6 +141,10 @@ const KEYEVENTF_KEYUP = 0x0002;
134
141
  const KEYEVENTF_UNICODE = 0x0004;
135
142
  const MAX_COORD = 65535;
136
143
  const SW_RESTORE = 9;
144
+ const MOUSEEVENTF_HWHEEL = 0x01000;
145
+ const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
146
+ const PW_RENDERFULLCONTENT = 0x00000002;
147
+ const MAX_CAPTURE_PIXELS = 40_000_000;
137
148
 
138
149
  /** Behaviour defaults; the host may override them through cordis.patch.yml config. */
139
150
  const DEFAULTS = {
@@ -142,18 +153,35 @@ const DEFAULTS = {
142
153
  moveSteps: 16,
143
154
  keyDelayMs: [12, 34],
144
155
  clickSettleMs: [60, 140],
156
+ zoomFactor: 2,
157
+ zoomMaxPixels: 24_000_000,
158
+ maxWaitMs: 10_000,
159
+ allowLaunch: true,
145
160
  };
146
161
  const CFG = { ...DEFAULTS };
147
162
 
148
163
  const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
149
164
  const between = ([min, max]) => min + Math.random() * Math.max(0, max - min);
150
165
 
166
+ /**
167
+ * Raw `tools.register()` does not validate arguments the way `defineTool` does,
168
+ * so every coordinate has to be checked here — otherwise a missing number turns
169
+ * into a NaN deep inside Buffer.alloc instead of a message the model can act on.
170
+ */
171
+ function requireNumber(value, name) {
172
+ const n = Number(value);
173
+ if (!Number.isFinite(n)) throw new Error(`${name} must be a finite number (received ${JSON.stringify(value)})`);
174
+ return n;
175
+ }
176
+
151
177
  /** Clamp a pointer target into the virtual screen so a bad coordinate cannot fly off. */
152
178
  function clampPoint(A, x, y) {
153
179
  const screen = virtualScreen(A);
180
+ const px = Math.round(requireNumber(x, 'x'));
181
+ const py = Math.round(requireNumber(y, 'y'));
154
182
  return {
155
- x: Math.min(Math.max(Math.round(x), screen.x), screen.x + Math.max(0, screen.width - 1)),
156
- y: Math.min(Math.max(Math.round(y), screen.y), screen.y + Math.max(0, screen.height - 1)),
183
+ x: Math.min(Math.max(px, screen.x), screen.x + Math.max(0, screen.width - 1)),
184
+ y: Math.min(Math.max(py, screen.y), screen.y + Math.max(0, screen.height - 1)),
157
185
  };
158
186
  }
159
187
 
@@ -213,30 +241,37 @@ function toAbsolute(A, x, y) {
213
241
  };
214
242
  }
215
243
 
216
- /** Grab the whole virtual screen as raw BGRA rows. */
217
- function captureScreen(A) {
218
- const screen = virtualScreen(A);
219
- const width = Math.max(1, screen.width);
220
- const height = Math.max(1, screen.height);
244
+ /** Pull BGRA rows out of a selected bitmap. */
245
+ function readBits(A, dc, bmp, width, height) {
246
+ if (width * height > MAX_CAPTURE_PIXELS) throw new Error(`capture too large: ${width}x${height}`);
247
+ const bmi = {
248
+ biSize: A.koffi.sizeof(A.structs.BITMAPINFOHEADER),
249
+ biWidth: width, biHeight: -height, biPlanes: 1, biBitCount: 32,
250
+ biCompression: 0, biSizeImage: 0, biXPelsPerMeter: 0, biYPelsPerMeter: 0,
251
+ biClrUsed: 0, biClrImportant: 0,
252
+ };
253
+ const buffer = Buffer.alloc(width * height * 4);
254
+ const lines = A.GetDIBits(dc, bmp, 0, height, buffer, bmi, 0);
255
+ if (lines !== height) throw new Error(`GetDIBits returned ${lines}/${height} lines`);
256
+ return { width, height, data: buffer };
257
+ }
258
+
259
+ /** BitBlt a screen rectangle (virtual-screen coordinates) into a fresh memory bitmap. */
260
+ function captureRect(A, sx, sy, width, height) {
261
+ const left = Math.round(requireNumber(sx, 'x'));
262
+ const top = Math.round(requireNumber(sy, 'y'));
263
+ const w = Math.max(1, Math.round(requireNumber(width, 'width')));
264
+ const h = Math.max(1, Math.round(requireNumber(height, 'height')));
221
265
  const srcDc = A.GetDC(null);
222
266
  if (!srcDc) throw new Error('GetDC returned null');
223
267
  const memDc = A.CreateCompatibleDC(srcDc);
224
- const bmp = A.CreateCompatibleBitmap(srcDc, width, height);
268
+ const bmp = A.CreateCompatibleBitmap(srcDc, w, h);
225
269
  const old = A.SelectObject(memDc, bmp);
226
270
  try {
227
- if (!A.BitBlt(memDc, 0, 0, width, height, srcDc, screen.x, screen.y, SRCCOPY | CAPTUREBLT)) {
271
+ if (!A.BitBlt(memDc, 0, 0, w, h, srcDc, left, top, SRCCOPY | CAPTUREBLT)) {
228
272
  throw new Error('BitBlt failed');
229
273
  }
230
- const bmi = {
231
- biSize: A.koffi.sizeof(A.structs.BITMAPINFOHEADER),
232
- biWidth: width, biHeight: -height, biPlanes: 1, biBitCount: 32,
233
- biCompression: 0, biSizeImage: 0, biXPelsPerMeter: 0, biYPelsPerMeter: 0,
234
- biClrUsed: 0, biClrImportant: 0,
235
- };
236
- const buffer = Buffer.alloc(width * height * 4);
237
- const lines = A.GetDIBits(memDc, bmp, 0, height, buffer, bmi, 0);
238
- if (lines !== height) throw new Error(`GetDIBits returned ${lines}/${height} lines`);
239
- return { width, height, data: buffer };
274
+ return readBits(A, memDc, bmp, w, h);
240
275
  } finally {
241
276
  A.SelectObject(memDc, old);
242
277
  A.DeleteObject(bmp);
@@ -245,6 +280,74 @@ function captureScreen(A) {
245
280
  }
246
281
  }
247
282
 
283
+ /** Grab the whole virtual screen as raw BGRA rows. */
284
+ function captureScreen(A) {
285
+ const screen = virtualScreen(A);
286
+ return captureRect(A, screen.x, screen.y, screen.width, screen.height);
287
+ }
288
+
289
+ /**
290
+ * Capture one window. PrintWindow asks the window to render itself into our DC
291
+ * (so occluded windows still come out right); when the window refuses, fall back
292
+ * to blitting its on-screen rectangle.
293
+ */
294
+ function captureWindow(A, hwnd) {
295
+ const rect = { left: 0, top: 0, right: 0, bottom: 0 };
296
+ if (!A.GetWindowRect(hwnd, rect)) throw new Error(`GetWindowRect failed for handle ${hwnd}`);
297
+ const width = Math.max(1, rect.right - rect.left);
298
+ const height = Math.max(1, rect.bottom - rect.top);
299
+ const bounds = { x: rect.left, y: rect.top, width, height };
300
+ if (width * height > MAX_CAPTURE_PIXELS) throw new Error(`window too large to capture: ${width}x${height}`);
301
+ const winDc = A.GetWindowDC(hwnd);
302
+ if (!winDc) throw new Error(`GetWindowDC returned null for handle ${hwnd}`);
303
+ const memDc = A.CreateCompatibleDC(winDc);
304
+ const bmp = A.CreateCompatibleBitmap(winDc, width, height);
305
+ const old = A.SelectObject(memDc, bmp);
306
+ try {
307
+ const printed = A.PrintWindow(hwnd, memDc, PW_RENDERFULLCONTENT);
308
+ if (!printed) {
309
+ const screenDc = A.GetDC(null);
310
+ if (!screenDc) throw new Error('GetDC returned null');
311
+ try {
312
+ if (!A.BitBlt(memDc, 0, 0, width, height, screenDc, bounds.x, bounds.y, SRCCOPY | CAPTUREBLT)) {
313
+ throw new Error('window capture failed (PrintWindow and BitBlt both refused)');
314
+ }
315
+ } finally {
316
+ A.ReleaseDC(null, screenDc);
317
+ }
318
+ }
319
+ return { ...readBits(A, memDc, bmp, width, height), bounds, printed: Boolean(printed) };
320
+ } finally {
321
+ A.SelectObject(memDc, old);
322
+ A.DeleteObject(bmp);
323
+ A.DeleteDC(memDc);
324
+ A.ReleaseDC(hwnd, winDc);
325
+ }
326
+ }
327
+
328
+ /** Nearest-neighbour upscale: crisp pixels, which is what reading small UI text needs. */
329
+ function scaleBgra({ width, height, data }, factor) {
330
+ const scale = Math.max(1, Math.floor(factor));
331
+ if (scale === 1) return { width, height, data };
332
+ const ow = width * scale;
333
+ const oh = height * scale;
334
+ if (ow * oh > MAX_CAPTURE_PIXELS) throw new Error(`zoomed image too large: ${ow}x${oh}`);
335
+ const out = Buffer.alloc(ow * oh * 4);
336
+ for (let y = 0; y < oh; y += 1) {
337
+ const rowBase = Math.min(height - 1, Math.floor(y / scale)) * width;
338
+ const target = y * ow;
339
+ for (let x = 0; x < ow; x += 1) {
340
+ const s = (rowBase + Math.min(width - 1, Math.floor(x / scale))) * 4;
341
+ const t = (target + x) * 4;
342
+ out[t] = data[s];
343
+ out[t + 1] = data[s + 1];
344
+ out[t + 2] = data[s + 2];
345
+ out[t + 3] = data[s + 3];
346
+ }
347
+ }
348
+ return { width: ow, height: oh, data: out };
349
+ }
350
+
248
351
  const CRC_TABLE = (() => {
249
352
  const table = new Uint32Array(256);
250
353
  for (let n = 0; n < 256; n += 1) {
@@ -439,25 +542,30 @@ function clickMouse(A, button = 'left', double = false) {
439
542
  }
440
543
  }
441
544
 
442
- /** Scroll the wheel; positive is up. */
443
- function scrollWheel(A, amount) {
444
- const delta = Math.round(Number(amount) || 0);
545
+ /** Scroll the wheel; positive is up (or right on the horizontal axis). */
546
+ function scrollWheel(A, amount, axis = 'vertical') {
547
+ const delta = Math.round(requireNumber(amount, 'amount'));
445
548
  if (!delta) return;
446
- sendInput(A, mouseInput({ data: delta, flags: MOUSEEVENTF_WHEEL }));
549
+ const flag = String(axis).toLowerCase() === 'horizontal' ? MOUSEEVENTF_HWHEEL : MOUSEEVENTF_WHEEL;
550
+ sendInput(A, mouseInput({ data: delta, flags: flag }));
447
551
  }
448
552
 
449
- /** Screen capture to a PNG on disk, keeping only the newest screenshots. */
450
- function screenshot(A, directory) {
553
+ /** Encode one frame to PNG on disk, then prune old captures. */
554
+ function savePng(frame, directory, prefix = 'screen') {
451
555
  const target = directory || SCREEN_DIR;
452
556
  mkdirSync(target, { recursive: true });
453
- const frame = captureScreen(A);
454
557
  const png = encodePng(frame);
455
- const path = join(target, `screen-${Date.now()}.png`);
558
+ const path = join(target, `${prefix}-${Date.now()}.png`);
456
559
  writeFileSync(path, png);
457
560
  pruneScreens(target);
458
561
  return { path, width: frame.width, height: frame.height, bytes: png.length };
459
562
  }
460
563
 
564
+ /** Screen capture to a PNG on disk, keeping only the newest screenshots. */
565
+ function screenshot(A, directory) {
566
+ return savePng(captureScreen(A), directory);
567
+ }
568
+
461
569
  /** Retention is best effort: a locked or unreadable file must not fail the capture. */
462
570
  function pruneScreens(directory) {
463
571
  const keep = Number(CFG.keepScreenshots);
@@ -485,6 +593,84 @@ function cursorPosition(A) {
485
593
  return { x: point.x, y: point.y };
486
594
  }
487
595
 
596
+ /** Executable file name behind a process id, or '' when it cannot be read. */
597
+ function exeName(A, pid) {
598
+ if (!pid) return '';
599
+ const handle = A.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
600
+ if (!handle) return '';
601
+ try {
602
+ const size = [512];
603
+ const buffer = Buffer.alloc(512 * 2);
604
+ if (!A.QueryFullProcessImageNameW(handle, 0, buffer, size)) return '';
605
+ const full = buffer.toString('utf16le', 0, Math.max(0, size[0]) * 2);
606
+ return full.split(/[\\/]/).pop() || full;
607
+ } finally {
608
+ A.CloseHandle(handle);
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Press, travel and release like a hand: the button stays down for the whole
614
+ * path so drag targets that watch hover state still see it.
615
+ */
616
+ async function dragMouse(A, fromX, fromY, toX, toY, humanize) {
617
+ const start = clampPoint(A, fromX, fromY);
618
+ const end = clampPoint(A, toX, toY);
619
+ await moveMouseHuman(A, start.x, start.y, humanize);
620
+ if (humanize) await sleep(between([40, 90]));
621
+ sendInput(A, mouseInput({ flags: MOUSEEVENTF_LEFTDOWN }));
622
+ try {
623
+ if (humanize) {
624
+ const steps = Math.max(6, Math.min(CFG.moveSteps * 2, Math.round(Math.hypot(end.x - start.x, end.y - start.y) / 35)));
625
+ for (let i = 1; i <= steps; i += 1) {
626
+ const t = i / steps;
627
+ const eased = t * t * (3 - 2 * t);
628
+ moveMouse(A, start.x + (end.x - start.x) * eased, start.y + (end.y - start.y) * eased);
629
+ await sleep(8 + Math.random() * 10);
630
+ }
631
+ }
632
+ moveMouse(A, end.x, end.y);
633
+ if (humanize) await sleep(between([50, 110]));
634
+ } finally {
635
+ sendInput(A, mouseInput({ flags: MOUSEEVENTF_LEFTUP }));
636
+ }
637
+ return { from: start, to: end };
638
+ }
639
+
640
+ /** Sleep, and optionally poll until a window whose title contains `title` exists. */
641
+ async function waitFor(A, { ms, title, timeoutMs }) {
642
+ const budget = Math.max(0, Math.min(timeoutMs === undefined ? CFG.maxWaitMs : requireNumber(timeoutMs, 'timeoutMs'), CFG.maxWaitMs));
643
+ const started = Date.now();
644
+ const sleepBudget = Math.min(ms === undefined ? 0 : Math.max(0, requireNumber(ms, 'ms')), budget);
645
+ if (sleepBudget > 0) await sleep(sleepBudget);
646
+ if (!title) return { waitedMs: Date.now() - started, matched: false, timedOut: false };
647
+ const needle = String(title).toLowerCase();
648
+ while (Date.now() - started < budget) {
649
+ if (listWindows(A).some((w) => w.title.toLowerCase().includes(needle))) {
650
+ return { waitedMs: Date.now() - started, matched: true, timedOut: false };
651
+ }
652
+ await sleep(150);
653
+ }
654
+ return { waitedMs: Date.now() - started, matched: false, timedOut: true };
655
+ }
656
+
657
+ /** Resolve an executable name or path without ever going through a shell. */
658
+ function resolveExe(name) {
659
+ const raw = String(name || '').trim();
660
+ if (!raw) throw new Error('empty executable name');
661
+ if (/[\\/]/.test(raw)) {
662
+ if (!existsSync(raw)) throw new Error(`executable not found: ${raw}`);
663
+ return raw;
664
+ }
665
+ const exe = raw.toLowerCase().endsWith('.exe') ? raw : `${raw}.exe`;
666
+ const dirs = (process.env.PATH || '').split(';').filter(Boolean);
667
+ for (const dir of dirs) {
668
+ const candidate = join(dir, exe);
669
+ if (existsSync(candidate)) return candidate;
670
+ }
671
+ throw new Error(`executable not found on PATH: ${raw} (pass an absolute path instead)`);
672
+ }
673
+
488
674
  /**
489
675
  * JSON-Schema helpers for raw `tools.register()` definitions. The host registry
490
676
  * requires every tool to carry `output { schema, render }` and a lossless-JSON
@@ -501,6 +687,35 @@ const num = (description) => ({ type: 'number', description });
501
687
  const bool = (description) => ({ type: 'boolean', description });
502
688
  const text = (value) => [{ type: 'text', text: String(value) }];
503
689
 
690
+ /** Reusable output-schema fragments so every object node declares its fields. */
691
+ const point = (description) => ({
692
+ type: 'object',
693
+ description,
694
+ properties: { x: num('X in virtual-screen pixels.'), y: num('Y in virtual-screen pixels.') },
695
+ });
696
+ const rect = (description) => ({
697
+ type: 'object',
698
+ description,
699
+ properties: {
700
+ x: num('Left in virtual-screen pixels.'),
701
+ y: num('Top in virtual-screen pixels.'),
702
+ width: num('Width in pixels.'),
703
+ height: num('Height in pixels.'),
704
+ },
705
+ });
706
+ const windowItem = (description) => ({
707
+ type: 'object',
708
+ description,
709
+ properties: {
710
+ handle: { type: 'integer', description: 'Window handle; pass it to cua_focus_window.' },
711
+ title: str('Window title.'),
712
+ pid: { type: 'integer', description: 'Owner process id.' },
713
+ active: bool('Is the foreground window.'),
714
+ minimized: bool('Is minimized.'),
715
+ bounds: rect('Window rect on the virtual screen.'),
716
+ },
717
+ });
718
+
504
719
  const TOOL_DECLS = [
505
720
  {
506
721
  name: 'cua_screenshot',
@@ -519,6 +734,7 @@ const TOOL_DECLS = [
519
734
  }),
520
735
  render: (_args, v) => text(`Screenshot saved: ${v.path} (${v.width}x${v.height}, ${v.bytes} bytes). Read this file to view it.`),
521
736
  },
737
+ readOnly: true,
522
738
  },
523
739
  {
524
740
  name: 'cua_mouse_move',
@@ -571,12 +787,13 @@ const TOOL_DECLS = [
571
787
  },
572
788
  {
573
789
  name: 'cua_scroll',
574
- description: 'Turn the real mouse wheel at the pointer position; positive scrolls up.',
790
+ description: 'Turn the real mouse wheel at the pointer position; positive scrolls up (or right on the horizontal axis).',
575
791
  parameters: obj({
576
792
  amount: num('Wheel delta (120 per notch).'),
793
+ axis: { type: 'string', enum: ['vertical', 'horizontal'], description: 'Scroll axis; defaults to vertical.' },
577
794
  }, ['amount']),
578
795
  run: (A, args) => {
579
- scrollWheel(A, args.amount);
796
+ scrollWheel(A, args.amount, args.axis);
580
797
  return { ok: true };
581
798
  },
582
799
  out: {
@@ -623,13 +840,14 @@ const TOOL_DECLS = [
623
840
  run: (A) => ({ windows: listWindows(A) }),
624
841
  out: {
625
842
  schema: obj({
626
- windows: { type: 'array', items: { type: 'object' }, description: 'Visible top-level windows.' },
843
+ windows: { type: 'array', items: windowItem('One visible window.'), description: 'Visible top-level windows.' },
627
844
  }),
628
845
  render: (_args, v) => text(
629
846
  `Found ${v.windows.length} window(s):\n` + v.windows.map((w) =>
630
847
  `#${w.handle}${w.active ? ' [active]' : ''}${w.minimized ? ' [minimized]' : ''} "${w.title}" pid=${w.pid} ${w.bounds.width}x${w.bounds.height} @ (${w.bounds.x},${w.bounds.y})`
631
848
  ).join('\n')),
632
849
  },
850
+ readOnly: true,
633
851
  },
634
852
  {
635
853
  name: 'cua_focus_window',
@@ -638,7 +856,7 @@ const TOOL_DECLS = [
638
856
  handle: num('Window handle from cua_windows.'),
639
857
  }, ['handle']),
640
858
  run: (A, args) => {
641
- const hwnd = A.koffi.address(args.handle);
859
+ const hwnd = A.koffi.address(requireNumber(args.handle, 'handle'));
642
860
  A.ShowWindow(hwnd, SW_RESTORE);
643
861
  A.SetForegroundWindow(hwnd);
644
862
  A.BringWindowToTop(hwnd);
@@ -658,10 +876,183 @@ const TOOL_DECLS = [
658
876
  schema: obj({
659
877
  x: num('Pointer X.'),
660
878
  y: num('Pointer Y.'),
661
- screen: { type: 'object', description: 'Virtual-screen geometry.' },
879
+ screen: rect('Virtual-screen geometry.'),
662
880
  }),
663
881
  render: (_args, v) => text(`Cursor at (${v.x}, ${v.y}); virtual screen: ${JSON.stringify(v.screen)}.`),
664
882
  },
883
+ readOnly: true,
884
+ },
885
+ {
886
+ name: 'cua_zoom',
887
+ description:
888
+ 'Capture a screen region and upscale it so small text, icons and menu items become readable. Coordinates are virtual-screen pixels.',
889
+ parameters: obj({
890
+ x: num('Region left in virtual-screen pixels.'),
891
+ y: num('Region top in virtual-screen pixels.'),
892
+ width: num('Region width in pixels.'),
893
+ height: num('Region height in pixels.'),
894
+ factor: num('Integer upscale factor (default 2, max 4).'),
895
+ directory: str('Optional output directory for the PNG.'),
896
+ }, ['x', 'y', 'width', 'height']),
897
+ run: (A, args) => {
898
+ const screen = virtualScreen(A);
899
+ const x = Math.max(screen.x, Math.round(args.x));
900
+ const y = Math.max(screen.y, Math.round(args.y));
901
+ const right = Math.min(screen.x + screen.width, Math.round(args.x + args.width));
902
+ const bottom = Math.min(screen.y + screen.height, Math.round(args.y + args.height));
903
+ const w = right - x;
904
+ const h = bottom - y;
905
+ if (w <= 0 || h <= 0) throw new Error('zoom region falls outside the virtual screen');
906
+ let factor = Math.max(1, Math.min(4, Math.floor(Number(args.factor) || CFG.zoomFactor)));
907
+ while (factor > 1 && w * factor * h * factor > CFG.zoomMaxPixels) factor -= 1;
908
+ const scaled = scaleBgra(captureRect(A, x, y, w, h), factor);
909
+ const saved = savePng(scaled, args.directory, 'zoom');
910
+ return { ...saved, factor, source: { x, y, width: w, height: h } };
911
+ },
912
+ out: {
913
+ schema: obj({
914
+ path: str('Absolute path of the written PNG.'),
915
+ width: { type: 'integer', description: 'Saved width in pixels.' },
916
+ height: { type: 'integer', description: 'Saved height in pixels.' },
917
+ bytes: { type: 'integer', description: 'PNG size in bytes.' },
918
+ factor: { type: 'integer', description: 'Upscale factor actually used.' },
919
+ source: rect('Captured region in virtual-screen pixels.'),
920
+ }),
921
+ render: (_args, v) => text(
922
+ `Zoom saved: ${v.path} — ${v.source.width}x${v.source.height} at (${v.source.x},${v.source.y}) scaled x${v.factor} to ${v.width}x${v.height}.`),
923
+ },
924
+ readOnly: true,
925
+ },
926
+ {
927
+ name: 'cua_screenshot_window',
928
+ description:
929
+ 'Capture one window by handle. Uses PrintWindow so occluded windows still render correctly, with a screen blit fallback.',
930
+ parameters: obj({
931
+ handle: num('Window handle from cua_windows.'),
932
+ directory: str('Optional output directory for the PNG.'),
933
+ }, ['handle']),
934
+ run: (A, args) => {
935
+ const hwnd = A.koffi.address(requireNumber(args.handle, 'handle'));
936
+ const frame = captureWindow(A, hwnd);
937
+ const saved = savePng(frame, args.directory, 'window');
938
+ return { ...saved, bounds: frame.bounds, printed: frame.printed };
939
+ },
940
+ out: {
941
+ schema: obj({
942
+ path: str('Absolute path of the written PNG.'),
943
+ width: { type: 'integer', description: 'Image width in pixels.' },
944
+ height: { type: 'integer', description: 'Image height in pixels.' },
945
+ bytes: { type: 'integer', description: 'PNG size in bytes.' },
946
+ printed: bool('True when PrintWindow rendered it; false means screen fallback.'),
947
+ bounds: rect('Window rect on the virtual screen.'),
948
+ }),
949
+ render: (_args, v) => text(
950
+ `Window captured (${v.printed ? 'PrintWindow' : 'screen fallback'}): ${v.path} — ${v.width}x${v.height} at (${v.bounds.x},${v.bounds.y}).`),
951
+ },
952
+ readOnly: true,
953
+ },
954
+ {
955
+ name: 'cua_drag',
956
+ description:
957
+ 'Drag with the real left button held down: press at fromX/fromY, travel, release at toX/toY. Use it for sliders, selections, window moves and drag-and-drop.',
958
+ parameters: obj({
959
+ fromX: num('Press X in virtual-screen pixels.'),
960
+ fromY: num('Press Y in virtual-screen pixels.'),
961
+ toX: num('Release X in virtual-screen pixels.'),
962
+ toY: num('Release Y in virtual-screen pixels.'),
963
+ }, ['fromX', 'fromY', 'toX', 'toY']),
964
+ run: async (A, args) => {
965
+ const moved = await dragMouse(A, args.fromX, args.fromY, args.toX, args.toY, CFG.humanize);
966
+ return { ok: true, ...moved };
967
+ },
968
+ out: {
969
+ schema: obj({
970
+ ok: bool('Always true.'),
971
+ from: point('Actual press point after clamping.'),
972
+ to: point('Actual release point after clamping.'),
973
+ }),
974
+ render: (_args, v) => text(`Dragged (${v.from.x}, ${v.from.y}) -> (${v.to.x}, ${v.to.y}).`),
975
+ },
976
+ },
977
+ {
978
+ name: 'cua_wait',
979
+ description:
980
+ 'Wait for the desktop to settle: sleep for a number of milliseconds and/or poll until a window whose title contains the given text exists.',
981
+ parameters: obj({
982
+ ms: num('Milliseconds to sleep first (default 0).'),
983
+ title: str('Optional window-title substring to wait for, case-insensitive.'),
984
+ timeoutMs: num('Upper bound for the wait; capped by the plugin config.'),
985
+ }),
986
+ run: async (A, args) => waitFor(A, args),
987
+ out: {
988
+ schema: obj({
989
+ waitedMs: { type: 'integer', description: 'Actual time spent waiting.' },
990
+ matched: bool('True when the title appeared.'),
991
+ timedOut: bool('True when the title never appeared in time.'),
992
+ }),
993
+ render: (_args, v) => text(
994
+ v.matched ? `Window appeared after ${v.waitedMs} ms.`
995
+ : v.timedOut ? `Timed out after ${v.waitedMs} ms waiting for the window.`
996
+ : `Waited ${v.waitedMs} ms.`),
997
+ },
998
+ readOnly: true,
999
+ timeoutMs: 15_000,
1000
+ },
1001
+ {
1002
+ name: 'cua_app_list',
1003
+ description: 'List visible top-level windows with their process executable name, so you can tell apps apart and pick a launch target.',
1004
+ parameters: obj({}),
1005
+ run: (A) => ({ apps: listWindows(A).map((w) => ({ ...w, process: exeName(A, w.pid) })) }),
1006
+ out: {
1007
+ schema: obj({
1008
+ apps: {
1009
+ type: 'array',
1010
+ items: {
1011
+ type: 'object',
1012
+ description: 'One window with its process executable.',
1013
+ properties: {
1014
+ handle: { type: 'integer', description: 'Window handle.' },
1015
+ title: str('Window title.'),
1016
+ pid: { type: 'integer', description: 'Owner process id.' },
1017
+ process: str('Process executable name, e.g. notepad.exe.'),
1018
+ active: bool('Is the foreground window.'),
1019
+ minimized: bool('Is minimized.'),
1020
+ bounds: rect('Window rect on the virtual screen.'),
1021
+ },
1022
+ },
1023
+ description: 'Windows with process names.',
1024
+ },
1025
+ }),
1026
+ render: (_args, v) => text(
1027
+ `${v.apps.length} app window(s):\n` + v.apps.map((a) =>
1028
+ `#${a.handle} ${a.process || '?'} (pid ${a.pid})${a.active ? ' [active]' : ''}${a.minimized ? ' [minimized]' : ''} "${a.title}"`).join('\n')),
1029
+ },
1030
+ readOnly: true,
1031
+ },
1032
+ {
1033
+ name: 'cua_app_launch',
1034
+ description:
1035
+ 'Launch an application by executable name (resolved on PATH) or absolute path. Spawns directly without a shell, so no quoting games.',
1036
+ parameters: obj({
1037
+ exe: str('Executable name such as "notepad" / "calc", or an absolute .exe path.'),
1038
+ args: { type: 'array', items: { type: 'string' }, description: 'Command-line arguments.' },
1039
+ }, ['exe']),
1040
+ run: (A, args) => {
1041
+ if (CFG.allowLaunch !== true) throw new Error('cua_app_launch is disabled by config (allowLaunch: false)');
1042
+ const exe = resolveExe(args.exe);
1043
+ const argv = Array.isArray(args.args) ? args.args.map(String) : [];
1044
+ const child = spawn(exe, argv, { detached: true, stdio: 'ignore', windowsHide: false });
1045
+ child.unref();
1046
+ return { ok: true, exe, pid: child.pid ?? 0 };
1047
+ },
1048
+ out: {
1049
+ schema: obj({
1050
+ ok: bool('Always true.'),
1051
+ exe: str('Resolved executable path.'),
1052
+ pid: { type: 'integer', description: 'Process id of the launched app.' },
1053
+ }),
1054
+ render: (_args, v) => text(`Launched ${v.exe} (pid ${v.pid}).`),
1055
+ },
665
1056
  },
666
1057
  ];
667
1058
 
@@ -675,9 +1066,12 @@ export const inject = ['tools'];
675
1066
  * Config knobs (all optional, from this bundle's cordis.patch.yml):
676
1067
  * - `humanize` (default true): move the pointer along a smoothstep path and
677
1068
  * type/click at a human cadence instead of teleporting instantly.
678
- * - `keepScreenshots` (default 30): newest screenshots kept per directory.
1069
+ * - `keepScreenshots` (default 30): newest captures kept per directory.
679
1070
  * - `moveSteps` (default 16), `keyDelayMs` ([12, 34]), `clickSettleMs` ([60, 140]):
680
1071
  * fine-tuning for the humanized cadence.
1072
+ * - `zoomFactor` (default 2), `zoomMaxPixels` (default 24e6): `cua_zoom` limits.
1073
+ * - `maxWaitMs` (default 10000): ceiling for `cua_wait`.
1074
+ * - `allowLaunch` (default true): set false to disable `cua_app_launch`.
681
1075
  */
682
1076
  export function apply(ctx, config = {}) {
683
1077
  for (const key of Object.keys(DEFAULTS)) {
@@ -689,6 +1083,9 @@ export function apply(ctx, config = {}) {
689
1083
  description: decl.description,
690
1084
  parameters: decl.parameters,
691
1085
  output: decl.out,
1086
+ presentCall: (args) => ({ card: 'generic', title: decl.name, kind: 'other', rawInput: args ?? {} }),
1087
+ ...decl.readOnly === true ? { isConcurrencySafe: () => true } : {},
1088
+ ...decl.timeoutMs !== undefined ? { timeoutMs: decl.timeoutMs } : {},
692
1089
  async execute(args = {}) {
693
1090
  return decl.run(loadNative(), args ?? {});
694
1091
  },
package/package.json CHANGED
@@ -1,10 +1,22 @@
1
1
  {
2
2
  "name": "dsh-win-cua",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Windows computer-use for DSH: real mouse and keyboard input through Win32 SendInput, screen capture through GDI BitBlt, window control. No external driver.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "license": "MIT",
8
+ "author": "WJPGKZ",
9
+ "homepage": "https://github.com/WJPGKZ/dsh-win-cua#readme",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/WJPGKZ/dsh-win-cua.git"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/WJPGKZ/dsh-win-cua/issues"
16
+ },
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
8
20
  "keywords": [
9
21
  "dsh",
10
22
  "dsh-plugin",
@@ -32,4 +44,4 @@
32
44
  "node": ">=20"
33
45
  }
34
46
  }
35
- }
47
+ }