dsh-win-cua 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,35 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wesley Wang
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.
22
+
23
+ ---
24
+
25
+ Third-party notices
26
+
27
+ koffi (https://www.npmjs.com/package/koffi) is loaded at runtime from the host
28
+ DSH profile; it is not bundled with this package. koffi is distributed under
29
+ the MIT license by its own authors.
30
+
31
+ The Win32 and GDI function signatures and the per-monitor DPI awareness setup
32
+ follow the publicly documented Windows API. The approach of driving those APIs
33
+ through koffi was learned from Coopanion's Cortico desktop-pet extension
34
+ (https://github.com/Pal-AI-Lab/Coopanion), which is not copied here; this file
35
+ is an independent implementation of the same public API surface.
package/NOTICE.md ADDED
@@ -0,0 +1,42 @@
1
+ # Third-party notices
2
+
3
+ `dsh-win32-cua` loads and drives Windows system libraries. It ships no
4
+ third-party code of its own beyond what is listed here.
5
+
6
+ ## koffi
7
+
8
+ - Package: [`koffi`](https://www.npmjs.com/package/koffi)
9
+ - License: MIT
10
+ - Use: resolved at runtime from the DSH profile's `node_modules`, never bundled.
11
+ - Why: koffi is the FFI layer that calls `user32.dll` and `gdi32.dll` from Node.
12
+
13
+ ## Windows API
14
+
15
+ `SendInput`, `SetCursorPos`, `GetCursorPos`, `GetSystemMetrics`, `EnumWindows`,
16
+ `GetWindowTextW`, `GetWindowRect`, `GetWindowThreadProcessId`,
17
+ `GetForegroundWindow`, `SetForegroundWindow`, `ShowWindow`, `BringWindowToTop`,
18
+ `GetDC`, `ReleaseDC`, `SetProcessDpiAwarenessContext` (user32) and `BitBlt`,
19
+ `CreateCompatibleDC`, `CreateCompatibleBitmap`, `SelectObject`, `DeleteObject`,
20
+ `DeleteDC`, `GetDIBits` (gdi32) are documented public Windows APIs. The
21
+ signatures in `index.js` follow the Microsoft documentation; no proprietary
22
+ header or binary is redistributed.
23
+
24
+ ## Coopanion (inspiration, not code)
25
+
26
+ - Project: [Pal-AI-Lab/Coopanion](https://github.com/Pal-AI-Lab/Coopanion)
27
+ - Relationship: the **approach** of driving Win32 through koffi, capturing the
28
+ screen with `BitBlt | CAPTUREBLT`, and declaring per-monitor DPI awareness was
29
+ learned by reading Coopanion's Cortico `desktop-pet` extension
30
+ (`src/cua/engine/win32.ts`).
31
+ - What was **not** taken: no source file, no type declaration, no string, no
32
+ struct layout and no code path is copied from that project. `index.js` here is
33
+ an independent implementation written against the public Win32 documentation,
34
+ with its own PNG encoder, its own key table and its own tool surface.
35
+ - Coopanion is used as a reference for what is possible, not as a dependency.
36
+
37
+ ## DSH
38
+
39
+ - Project: [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)
40
+ - The `dsh.bundle` / `cordis.patch.yml` bundle format and the `ctx.tools`
41
+ registration contract follow the DSH plugin documentation shipped with the
42
+ DSH agent preset skills.
package/README.md CHANGED
@@ -1,3 +1,59 @@
1
- # Temporary Holding Version
1
+ # dsh-win32-cua
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Windows computer-use for DeepSeek Harness: **real** mouse and keyboard input plus screen
4
+ capture, with no external driver.
5
+
6
+ ## How it works
7
+
8
+ | Capability | Win32 API |
9
+ |---|---|
10
+ | Mouse move, click, wheel | `user32!SendInput` (INPUT_MOUSE) |
11
+ | Keyboard taps and shortcuts | `user32!SendInput` (INPUT_KEYBOARD) |
12
+ | Type literal text (incl. CJK) | `SendInput` with `KEYEVENTF_UNICODE` |
13
+ | Screen capture | `gdi32!BitBlt` + `CAPTUREBLT` (layered windows included) |
14
+ | Window list / focus | `user32!EnumWindows`, `ShowWindow`, `SetForegroundWindow` |
15
+ | DPI | `SetProcessDpiAwarenessContext(-4)` — per-monitor v2 |
16
+
17
+ FFI is [koffi](https://www.npmjs.com/package/koffi). Coordinates are physical pixels of
18
+ the virtual screen; the primary display's top-left is `(0, 0)`.
19
+
20
+ ## Tools
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
26
+ - `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
31
+
32
+ Every tool declares `output { schema, render }` — the DSH tool registry rejects raw
33
+ definitions without it, so keep that shape when adding tools.
34
+
35
+ ## Config
36
+
37
+ Set in this bundle's `cordis.patch.yml` under `config`:
38
+
39
+ | Key | Default | Meaning |
40
+ |---|---|---|
41
+ | `humanize` | `true` | Pointer travels a smoothstep path, typing and clicking keep a human cadence |
42
+ | `keepScreenshots` | `30` | Newest screenshots kept per capture directory |
43
+ | `moveSteps` | `16` | Max pointer steps for a humanized move |
44
+ | `keyDelayMs` | `[12, 34]` | Per-character delay range for humanized typing |
45
+ | `clickSettleMs` | `[60, 140]` | Pause between the pointer arriving and the button press |
46
+
47
+ Pointer targets are clamped into the virtual screen, and astral code points (emoji,
48
+ CJK extension B) are sent as surrogate pairs so `KEYEVENTF_UNICODE` types them correctly.
49
+
50
+ ## Install
51
+
52
+ ```powershell
53
+ dsh plugin --profile <profile> add <this directory>
54
+ ```
55
+
56
+ Or in the Desktop app: Plugin Manager → install bundle → pick this directory.
57
+
58
+ Windows only. `SendInput` injects into the session whose input queue is active — a window
59
+ on a locked or disconnected desktop receives nothing.
@@ -0,0 +1,7 @@
1
+ - insert:
2
+ - id: win32-cua
3
+ name: 'dsh-win-cua'
4
+ config:
5
+ humanize: true
6
+ keepScreenshots: 30
7
+
package/icon.svg ADDED
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2
+ <rect x="4" y="8" width="56" height="40" rx="5" fill="#1f2a3a" stroke="#4da3ff" stroke-width="2.5"/>
3
+ <path d="M10 18h14M10 26h22M10 34h10" stroke="#4da3ff" stroke-width="2.5" stroke-linecap="round"/>
4
+ <path d="M38 22l12 7-5 1.5 3.2 6.2-3.4 1.6-3-6.1-3.6 3.4z" fill="#7ee0a1" stroke="#e8f4ff" stroke-width="1.4" stroke-linejoin="round"/>
5
+ <rect x="22" y="50" width="20" height="5" rx="2.5" fill="#4da3ff"/>
6
+ </svg>
package/index.js ADDED
@@ -0,0 +1,698 @@
1
+ /**
2
+ * @local/dsh-win32-cua — Windows computer-use for DeepSeek Harness.
3
+ *
4
+ * Real mouse and keyboard input through user32 `SendInput`, screen capture through
5
+ * GDI `BitBlt` (CAPTUREBLT so layered windows are included), and a top-level window
6
+ * list through `EnumWindows`. The FFI layer is koffi, the same approach Coopanion's
7
+ * desktop-pet uses; no external driver process is involved.
8
+ *
9
+ * Coordinates are physical pixels of the virtual screen (primary top-left is 0,0),
10
+ * because the process declares itself per-monitor DPI aware before anything else.
11
+ */
12
+ import { deflateSync } from 'node:zlib';
13
+ import { createRequire } from 'node:module';
14
+ import { mkdirSync, writeFileSync, readdirSync, unlinkSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { tmpdir } from 'node:os';
17
+
18
+ const SCREEN_DIR = join(tmpdir(), 'dsh-cua-screens');
19
+
20
+ /**
21
+ * Resolve koffi from wherever this profile keeps its packages. A bundle installed by
22
+ * link lives outside the profile, so a plain `import 'koffi'` cannot see it; the host
23
+ * exposes DSH_PROFILE_DIR for exactly that case. The fallbacks cover running from the
24
+ * profile itself and a checkout next to it.
25
+ */
26
+ function loadKoffi() {
27
+ const candidates = [
28
+ process.env.DSH_PROFILE_DIR && join(process.env.DSH_PROFILE_DIR, 'node_modules', 'koffi'),
29
+ join(process.cwd(), 'node_modules', 'koffi'),
30
+ join(import.meta.dirname, 'node_modules', 'koffi'),
31
+ ].filter(Boolean);
32
+ const errors = [];
33
+ for (const candidate of candidates) {
34
+ try {
35
+ return createRequire(join(candidate, 'noop.cjs'))(candidate);
36
+ } catch (error) {
37
+ errors.push(`${candidate}: ${error.message}`);
38
+ }
39
+ }
40
+ throw new Error(`cannot load koffi (tried ${candidates.length} locations): ${errors.join(' | ')}`);
41
+ }
42
+
43
+ let native = null;
44
+
45
+ /** Load Win32 through koffi once. */
46
+ function loadNative() {
47
+ if (native) return native;
48
+ if (process.platform !== 'win32') throw new Error('dsh-win32-cua only works on Windows');
49
+ const koffi = loadKoffi();
50
+
51
+ const user32 = koffi.load('user32.dll');
52
+ const gdi32 = koffi.load('gdi32.dll');
53
+
54
+ koffi.alias('HANDLE', 'void *');
55
+ koffi.alias('HWND', 'HANDLE');
56
+
57
+ const POINT = koffi.struct('POINT', { x: 'long', y: 'long' });
58
+ const RECT = koffi.struct('RECT', { left: 'long', top: 'long', right: 'long', bottom: 'long' });
59
+ const MOUSEINPUT = koffi.struct('MOUSEINPUT', {
60
+ dx: 'long', dy: 'long', mouseData: 'uint32_t', dwFlags: 'uint32_t',
61
+ time: 'uint32_t', dwExtraInfo: 'uintptr_t',
62
+ });
63
+ const KEYBDINPUT = koffi.struct('KEYBDINPUT', {
64
+ wVk: 'uint16_t', wScan: 'uint16_t', dwFlags: 'uint32_t',
65
+ time: 'uint32_t', dwExtraInfo: 'uintptr_t',
66
+ });
67
+ const HARDWAREINPUT = koffi.struct('HARDWAREINPUT', {
68
+ uMsg: 'uint32_t', wParamL: 'uint16_t', wParamH: 'uint16_t',
69
+ });
70
+ const INPUT = koffi.struct('INPUT', {
71
+ type: 'uint32_t',
72
+ u: koffi.union({ mi: MOUSEINPUT, ki: KEYBDINPUT, hi: HARDWAREINPUT }),
73
+ });
74
+ const BITMAPINFOHEADER = koffi.struct('BITMAPINFOHEADER', {
75
+ biSize: 'uint32_t', biWidth: 'int32_t', biHeight: 'int32_t',
76
+ biPlanes: 'uint16_t', biBitCount: 'uint16_t', biCompression: 'uint32_t',
77
+ biSizeImage: 'uint32_t', biXPelsPerMeter: 'int32_t', biYPelsPerMeter: 'int32_t',
78
+ biClrUsed: 'uint32_t', biClrImportant: 'uint32_t',
79
+ });
80
+ const EnumWindowsProc = koffi.proto('bool __stdcall EnumWindowsProc(HWND hwnd, intptr_t lParam)');
81
+
82
+ const api = {
83
+ koffi,
84
+ SetProcessDpiAwarenessContext: user32.func('bool __stdcall SetProcessDpiAwarenessContext(intptr_t value)'),
85
+ GetSystemMetrics: user32.func('int __stdcall GetSystemMetrics(int index)'),
86
+ SetCursorPos: user32.func('bool __stdcall SetCursorPos(int x, int y)'),
87
+ GetCursorPos: user32.func('bool __stdcall GetCursorPos(_Out_ POINT *p)'),
88
+ SendInput: user32.func('uint32_t __stdcall SendInput(uint32_t count, INPUT *inputs, int size)'),
89
+ GetDC: user32.func('HANDLE __stdcall GetDC(HWND hwnd)'),
90
+ ReleaseDC: user32.func('int __stdcall ReleaseDC(HWND hwnd, HANDLE dc)'),
91
+ EnumWindows: user32.func('bool __stdcall EnumWindows(EnumWindowsProc *cb, intptr_t lParam)'),
92
+ IsWindowVisible: user32.func('bool __stdcall IsWindowVisible(HWND hwnd)'),
93
+ IsIconic: user32.func('bool __stdcall IsIconic(HWND hwnd)'),
94
+ GetWindowTextW: user32.func('int __stdcall GetWindowTextW(HWND hwnd, _Out_ uint16_t *buf, int max)'),
95
+ GetWindowRect: user32.func('bool __stdcall GetWindowRect(HWND hwnd, _Out_ RECT *rect)'),
96
+ GetWindowThreadProcessId: user32.func('uint32_t __stdcall GetWindowThreadProcessId(HWND hwnd, _Out_ uint32_t *pid)'),
97
+ GetForegroundWindow: user32.func('HWND __stdcall GetForegroundWindow()'),
98
+ SetForegroundWindow: user32.func('bool __stdcall SetForegroundWindow(HWND hwnd)'),
99
+ ShowWindow: user32.func('bool __stdcall ShowWindow(HWND hwnd, int cmd)'),
100
+ BringWindowToTop: user32.func('bool __stdcall BringWindowToTop(HWND hwnd)'),
101
+ CreateCompatibleDC: gdi32.func('HANDLE __stdcall CreateCompatibleDC(HANDLE dc)'),
102
+ CreateCompatibleBitmap: gdi32.func('HANDLE __stdcall CreateCompatibleBitmap(HANDLE dc, int w, int h)'),
103
+ SelectObject: gdi32.func('HANDLE __stdcall SelectObject(HANDLE dc, HANDLE obj)'),
104
+ DeleteObject: gdi32.func('bool __stdcall DeleteObject(HANDLE obj)'),
105
+ DeleteDC: gdi32.func('bool __stdcall DeleteDC(HANDLE dc)'),
106
+ 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
+ GetDIBits: gdi32.func('int __stdcall GetDIBits(HANDLE dc, HANDLE bmp, uint32_t start, uint32_t lines, void *bits, _Inout_ BITMAPINFOHEADER *bmi, uint32_t usage)'),
108
+ structs: { POINT, RECT, INPUT, BITMAPINFOHEADER },
109
+ };
110
+ try {
111
+ // -4 = DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2
112
+ api.SetProcessDpiAwarenessContext(-4);
113
+ } catch {
114
+ /* older Windows: best effort */
115
+ }
116
+ native = api;
117
+ return api;
118
+ }
119
+
120
+ const SRCCOPY = 0x00cc0020;
121
+ const CAPTUREBLT = 0x40000000;
122
+ const INPUT_MOUSE = 0;
123
+ const INPUT_KEYBOARD = 1;
124
+ const MOUSEEVENTF_MOVE = 0x0001;
125
+ const MOUSEEVENTF_LEFTDOWN = 0x0002;
126
+ const MOUSEEVENTF_LEFTUP = 0x0004;
127
+ const MOUSEEVENTF_RIGHTDOWN = 0x0008;
128
+ const MOUSEEVENTF_RIGHTUP = 0x0010;
129
+ const MOUSEEVENTF_MIDDLEDOWN = 0x0020;
130
+ const MOUSEEVENTF_MIDDLEUP = 0x0040;
131
+ const MOUSEEVENTF_WHEEL = 0x0800;
132
+ const MOUSEEVENTF_ABSOLUTE = 0x8000;
133
+ const KEYEVENTF_KEYUP = 0x0002;
134
+ const KEYEVENTF_UNICODE = 0x0004;
135
+ const MAX_COORD = 65535;
136
+ const SW_RESTORE = 9;
137
+
138
+ /** Behaviour defaults; the host may override them through cordis.patch.yml config. */
139
+ const DEFAULTS = {
140
+ humanize: true,
141
+ keepScreenshots: 30,
142
+ moveSteps: 16,
143
+ keyDelayMs: [12, 34],
144
+ clickSettleMs: [60, 140],
145
+ };
146
+ const CFG = { ...DEFAULTS };
147
+
148
+ const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
149
+ const between = ([min, max]) => min + Math.random() * Math.max(0, max - min);
150
+
151
+ /** Clamp a pointer target into the virtual screen so a bad coordinate cannot fly off. */
152
+ function clampPoint(A, x, y) {
153
+ const screen = virtualScreen(A);
154
+ 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)),
157
+ };
158
+ }
159
+
160
+ /**
161
+ * UTF-16 code units one KEYEVENTF_UNICODE scan needs. Astral code points
162
+ * (emoji, CJK extensions) travel as a surrogate pair; sending the raw code
163
+ * point would truncate to 16 bits and type the wrong glyph.
164
+ */
165
+ function unicodeUnits(code) {
166
+ if (code <= 0xffff) return [code];
167
+ const rest = code - 0x10000;
168
+ return [0xd800 + ((rest >> 10) & 0x3ff), 0xdc00 + (rest & 0x3ff)];
169
+ }
170
+
171
+ /** Build a mouse INPUT record. */
172
+ function mouseInput({ dx = 0, dy = 0, data = 0, flags = 0 }) {
173
+ return {
174
+ type: INPUT_MOUSE,
175
+ u: { mi: { dx, dy, mouseData: data, dwFlags: flags, time: 0, dwExtraInfo: 0 } },
176
+ };
177
+ }
178
+
179
+ /** Build a keyboard INPUT record. */
180
+ function keyInput(vk, flags, scan = 0) {
181
+ return {
182
+ type: INPUT_KEYBOARD,
183
+ u: { ki: { wVk: vk, wScan: scan, dwFlags: flags, time: 0, dwExtraInfo: 0 } },
184
+ };
185
+ }
186
+
187
+ /** Send INPUT records; koffi converts a plain array of structs in place. */
188
+ function sendInputs(A, records) {
189
+ const sent = A.SendInput(records.length, records, A.koffi.sizeof(A.structs.INPUT));
190
+ if (sent !== records.length) throw new Error(`SendInput accepted ${sent}/${records.length} records`);
191
+ }
192
+
193
+ function sendInput(A, record) {
194
+ sendInputs(A, [record]);
195
+ }
196
+
197
+ /** Virtual-screen origin and size in physical pixels. */
198
+ function virtualScreen(A) {
199
+ return {
200
+ x: A.GetSystemMetrics(76),
201
+ y: A.GetSystemMetrics(77),
202
+ width: A.GetSystemMetrics(78),
203
+ height: A.GetSystemMetrics(79),
204
+ };
205
+ }
206
+
207
+ /** Screen pixels to the 0..65535 absolute space. */
208
+ function toAbsolute(A, x, y) {
209
+ const screen = virtualScreen(A);
210
+ return {
211
+ ax: Math.round(((x - screen.x) * MAX_COORD) / Math.max(1, screen.width - 1)),
212
+ ay: Math.round(((y - screen.y) * MAX_COORD) / Math.max(1, screen.height - 1)),
213
+ };
214
+ }
215
+
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);
221
+ const srcDc = A.GetDC(null);
222
+ if (!srcDc) throw new Error('GetDC returned null');
223
+ const memDc = A.CreateCompatibleDC(srcDc);
224
+ const bmp = A.CreateCompatibleBitmap(srcDc, width, height);
225
+ const old = A.SelectObject(memDc, bmp);
226
+ try {
227
+ if (!A.BitBlt(memDc, 0, 0, width, height, srcDc, screen.x, screen.y, SRCCOPY | CAPTUREBLT)) {
228
+ throw new Error('BitBlt failed');
229
+ }
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 };
240
+ } finally {
241
+ A.SelectObject(memDc, old);
242
+ A.DeleteObject(bmp);
243
+ A.DeleteDC(memDc);
244
+ A.ReleaseDC(null, srcDc);
245
+ }
246
+ }
247
+
248
+ const CRC_TABLE = (() => {
249
+ const table = new Uint32Array(256);
250
+ for (let n = 0; n < 256; n += 1) {
251
+ let c = n;
252
+ for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
253
+ table[n] = c >>> 0;
254
+ }
255
+ return table;
256
+ })();
257
+
258
+ function crc32(buffer) {
259
+ let c = 0xffffffff;
260
+ for (let i = 0; i < buffer.length; i += 1) c = CRC_TABLE[(c ^ buffer[i]) & 0xff] ^ (c >>> 8);
261
+ return (c ^ 0xffffffff) >>> 0;
262
+ }
263
+
264
+ function pngChunk(type, payload) {
265
+ const length = Buffer.alloc(4);
266
+ length.writeUInt32BE(payload.length, 0);
267
+ const body = Buffer.concat([Buffer.from(type, 'ascii'), payload]);
268
+ const crc = Buffer.alloc(4);
269
+ crc.writeUInt32BE(crc32(body), 0);
270
+ return Buffer.concat([length, body, crc]);
271
+ }
272
+
273
+ /** Encode BGRA rows as an RGBA PNG without an image library. */
274
+ function encodePng({ width, height, data }) {
275
+ const stride = width * 4;
276
+ const raw = Buffer.alloc((stride + 1) * height);
277
+ for (let y = 0; y < height; y += 1) {
278
+ const target = y * (stride + 1);
279
+ raw[target] = 0;
280
+ const source = y * stride;
281
+ for (let x = 0; x < width; x += 1) {
282
+ const s = source + x * 4;
283
+ const t = target + 1 + x * 4;
284
+ raw[t] = data[s + 2];
285
+ raw[t + 1] = data[s + 1];
286
+ raw[t + 2] = data[s];
287
+ raw[t + 3] = 255;
288
+ }
289
+ }
290
+ const header = Buffer.alloc(13);
291
+ header.writeUInt32BE(width, 0);
292
+ header.writeUInt32BE(height, 4);
293
+ header[8] = 8;
294
+ header[9] = 6;
295
+ return Buffer.concat([
296
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
297
+ pngChunk('IHDR', header),
298
+ pngChunk('IDAT', deflateSync(raw, { level: 6 })),
299
+ pngChunk('IEND', Buffer.alloc(0)),
300
+ ]);
301
+ }
302
+
303
+ /** Read a wide window title. */
304
+ function windowTitle(A, hwnd) {
305
+ const buffer = Buffer.alloc(512 * 2);
306
+ const length = A.GetWindowTextW(hwnd, buffer, 512);
307
+ return length > 0 ? buffer.toString('utf16le', 0, length * 2) : '';
308
+ }
309
+
310
+ /** Every visible top-level window with a title. */
311
+ function listWindows(A) {
312
+ const windows = [];
313
+ const foreground = Number(A.koffi.address(A.GetForegroundWindow()));
314
+ const proc = A.koffi.register((hwnd) => {
315
+ if (!A.IsWindowVisible(hwnd)) return true;
316
+ const title = windowTitle(A, hwnd);
317
+ if (!title) return true;
318
+ const rect = { left: 0, top: 0, right: 0, bottom: 0 };
319
+ A.GetWindowRect(hwnd, rect);
320
+ const pid = [0];
321
+ A.GetWindowThreadProcessId(hwnd, pid);
322
+ const handle = Number(A.koffi.address(hwnd));
323
+ windows.push({
324
+ handle,
325
+ title,
326
+ pid: pid[0],
327
+ minimized: Boolean(A.IsIconic(hwnd)),
328
+ active: handle === foreground,
329
+ bounds: {
330
+ x: rect.left,
331
+ y: rect.top,
332
+ width: rect.right - rect.left,
333
+ height: rect.bottom - rect.top,
334
+ },
335
+ });
336
+ return true;
337
+ }, A.koffi.pointer('EnumWindowsProc'));
338
+ try {
339
+ A.EnumWindows(proc, 0);
340
+ } finally {
341
+ A.koffi.unregister(proc);
342
+ }
343
+ return windows;
344
+ }
345
+
346
+ /** Virtual-key names a model is likely to use. */
347
+ const VIRTUAL_KEYS = {
348
+ enter: 0x0d, return: 0x0d, esc: 0x1b, escape: 0x1b, tab: 0x09, space: 0x20,
349
+ backspace: 0x08, delete: 0x2e, del: 0x2e, insert: 0x2d, home: 0x24, end: 0x23,
350
+ pageup: 0x21, pagedown: 0x22, up: 0x26, down: 0x28, left: 0x25, right: 0x27,
351
+ shift: 0x10, ctrl: 0x11, control: 0x11, alt: 0x12, win: 0x5b, meta: 0x5b,
352
+ capslock: 0x14, numlock: 0x90, scrolllock: 0x91, printscreen: 0x2c, pause: 0x13,
353
+ f1: 0x70, f2: 0x71, f3: 0x72, f4: 0x73, f5: 0x74, f6: 0x75,
354
+ f7: 0x76, f8: 0x77, f9: 0x78, f10: 0x79, f11: 0x7a, f12: 0x7b,
355
+ };
356
+ for (let i = 0; i < 26; i += 1) VIRTUAL_KEYS[String.fromCharCode(97 + i)] = 0x41 + i;
357
+ for (let i = 0; i <= 9; i += 1) VIRTUAL_KEYS[String(i)] = 0x30 + i;
358
+
359
+ function resolveKey(name) {
360
+ const key = String(name || '').trim().toLowerCase();
361
+ if (!key) throw new Error('empty key name');
362
+ const code = VIRTUAL_KEYS[key];
363
+ if (code !== undefined) return code;
364
+ throw new Error(`unknown key: ${name}`);
365
+ }
366
+
367
+ /** Tap one key, optionally with modifiers held. */
368
+ function tapKey(A, key, modifiers = []) {
369
+ const mods = modifiers.map(resolveKey);
370
+ for (const code of mods) sendInput(A, keyInput(code, 0));
371
+ const code = resolveKey(key);
372
+ sendInput(A, keyInput(code, 0));
373
+ sendInput(A, keyInput(code, KEYEVENTF_KEYUP));
374
+ for (const mod of mods.reverse()) sendInput(A, keyInput(mod, KEYEVENTF_KEYUP));
375
+ }
376
+
377
+ /** Type literal text through unicode keyboard events at a human cadence. */
378
+ async function typeText(A, text, humanize) {
379
+ for (const char of String(text)) {
380
+ const code = char.codePointAt(0);
381
+ if (code === undefined) continue;
382
+ if (code === 10 || code === 13) {
383
+ sendInput(A, keyInput(0x0d, 0));
384
+ sendInput(A, keyInput(0x0d, KEYEVENTF_KEYUP));
385
+ if (humanize) await sleep(between(CFG.keyDelayMs));
386
+ continue;
387
+ }
388
+ if (code < 0x20) continue;
389
+ for (const unit of unicodeUnits(code)) {
390
+ sendInput(A, keyInput(0, KEYEVENTF_UNICODE, unit));
391
+ sendInput(A, keyInput(0, KEYEVENTF_UNICODE | KEYEVENTF_KEYUP, unit));
392
+ }
393
+ if (humanize) await sleep(between(CFG.keyDelayMs));
394
+ }
395
+ }
396
+
397
+ /** Move the pointer to a screen pixel. */
398
+ function moveMouse(A, x, y) {
399
+ const { ax, ay } = toAbsolute(A, Math.round(x), Math.round(y));
400
+ sendInput(A, mouseInput({ dx: ax, dy: ay, flags: MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE }));
401
+ }
402
+
403
+ /**
404
+ * Move the pointer like a hand does: a smoothstep path in a few steps instead of
405
+ * one teleport, so hover states, tooltips and drag targets behave realistically.
406
+ * @returns the clamped destination actually used.
407
+ */
408
+ async function moveMouseHuman(A, x, y, humanize) {
409
+ const target = clampPoint(A, x, y);
410
+ if (!humanize) {
411
+ moveMouse(A, target.x, target.y);
412
+ return target;
413
+ }
414
+ const from = cursorPosition(A);
415
+ const distance = Math.hypot(target.x - from.x, target.y - from.y);
416
+ const steps = Math.max(4, Math.min(CFG.moveSteps, Math.round(distance / 55)));
417
+ for (let i = 1; i <= steps; i += 1) {
418
+ const t = i / steps;
419
+ const eased = t * t * (3 - 2 * t);
420
+ moveMouse(A, from.x + (target.x - from.x) * eased, from.y + (target.y - from.y) * eased);
421
+ await sleep(6 + Math.random() * 9);
422
+ }
423
+ moveMouse(A, target.x, target.y);
424
+ return target;
425
+ }
426
+
427
+ /** Press and release a mouse button. */
428
+ function clickMouse(A, button = 'left', double = false) {
429
+ const table = {
430
+ left: [MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP],
431
+ right: [MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP],
432
+ middle: [MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP],
433
+ };
434
+ const pair = table[String(button).toLowerCase()];
435
+ if (!pair) throw new Error(`unknown mouse button: ${button}`);
436
+ for (let i = 0; i < (double ? 2 : 1); i += 1) {
437
+ sendInput(A, mouseInput({ flags: pair[0] }));
438
+ sendInput(A, mouseInput({ flags: pair[1] }));
439
+ }
440
+ }
441
+
442
+ /** Scroll the wheel; positive is up. */
443
+ function scrollWheel(A, amount) {
444
+ const delta = Math.round(Number(amount) || 0);
445
+ if (!delta) return;
446
+ sendInput(A, mouseInput({ data: delta, flags: MOUSEEVENTF_WHEEL }));
447
+ }
448
+
449
+ /** Screen capture to a PNG on disk, keeping only the newest screenshots. */
450
+ function screenshot(A, directory) {
451
+ const target = directory || SCREEN_DIR;
452
+ mkdirSync(target, { recursive: true });
453
+ const frame = captureScreen(A);
454
+ const png = encodePng(frame);
455
+ const path = join(target, `screen-${Date.now()}.png`);
456
+ writeFileSync(path, png);
457
+ pruneScreens(target);
458
+ return { path, width: frame.width, height: frame.height, bytes: png.length };
459
+ }
460
+
461
+ /** Retention is best effort: a locked or unreadable file must not fail the capture. */
462
+ function pruneScreens(directory) {
463
+ const keep = Number(CFG.keepScreenshots);
464
+ if (!Number.isFinite(keep) || keep <= 0) return;
465
+ try {
466
+ const names = readdirSync(directory)
467
+ .filter((name) => name.startsWith('screen-') && name.endsWith('.png'))
468
+ .sort();
469
+ for (const name of names.slice(0, Math.max(0, names.length - keep))) {
470
+ try {
471
+ unlinkSync(join(directory, name));
472
+ } catch {
473
+ /* keep going */
474
+ }
475
+ }
476
+ } catch {
477
+ /* retention is best effort */
478
+ }
479
+ }
480
+
481
+ /** Read the pointer position. */
482
+ function cursorPosition(A) {
483
+ const point = { x: 0, y: 0 };
484
+ A.GetCursorPos(point);
485
+ return { x: point.x, y: point.y };
486
+ }
487
+
488
+ /**
489
+ * JSON-Schema helpers for raw `tools.register()` definitions. The host registry
490
+ * requires every tool to carry `output { schema, render }` and a lossless-JSON
491
+ * object-root `parameters` schema; annotations (`description`, `title`) are the
492
+ * only extra keywords allowed on a node.
493
+ */
494
+ const obj = (properties = {}, required = []) => ({
495
+ type: 'object',
496
+ properties,
497
+ ...(required.length ? { required } : {}),
498
+ });
499
+ const str = (description) => ({ type: 'string', description });
500
+ const num = (description) => ({ type: 'number', description });
501
+ const bool = (description) => ({ type: 'boolean', description });
502
+ const text = (value) => [{ type: 'text', text: String(value) }];
503
+
504
+ const TOOL_DECLS = [
505
+ {
506
+ name: 'cua_screenshot',
507
+ description:
508
+ 'Capture the whole virtual screen to a PNG file and return its path and pixel size. Look before clicking so you act on what is really on screen.',
509
+ parameters: obj({
510
+ directory: str('Optional absolute directory for the PNG; defaults to a temp folder.'),
511
+ }),
512
+ run: (A, args) => screenshot(A, args.directory),
513
+ out: {
514
+ schema: obj({
515
+ path: str('Absolute path of the written PNG.'),
516
+ width: { type: 'integer', description: 'Capture width in pixels.' },
517
+ height: { type: 'integer', description: 'Capture height in pixels.' },
518
+ bytes: { type: 'integer', description: 'PNG size in bytes.' },
519
+ }),
520
+ render: (_args, v) => text(`Screenshot saved: ${v.path} (${v.width}x${v.height}, ${v.bytes} bytes). Read this file to view it.`),
521
+ },
522
+ },
523
+ {
524
+ name: 'cua_mouse_move',
525
+ description: 'Move the real mouse pointer to a screen pixel (virtual-screen coordinates).',
526
+ parameters: obj({
527
+ x: num('Screen X in pixels.'),
528
+ y: num('Screen Y in pixels.'),
529
+ }, ['x', 'y']),
530
+ run: async (A, args) => {
531
+ const point = await moveMouseHuman(A, args.x, args.y, CFG.humanize);
532
+ return { ok: true, ...point };
533
+ },
534
+ out: {
535
+ schema: obj({ ok: bool('Always true.'), x: num('Pointer X.'), y: num('Pointer Y.') }),
536
+ render: (_args, v) => text(`Pointer moved to (${v.x}, ${v.y}).`),
537
+ },
538
+ },
539
+ {
540
+ name: 'cua_mouse_click',
541
+ description:
542
+ 'Click a real mouse button, optionally moving to x/y first. Double clicks repeat the press-release pair.',
543
+ parameters: obj({
544
+ x: num('Optional screen X; omit to click where the pointer is.'),
545
+ y: num('Optional screen Y; omit to click where the pointer is.'),
546
+ button: { type: 'string', enum: ['left', 'right', 'middle'], description: 'Mouse button; defaults to left.' },
547
+ double: bool('Double click when true.'),
548
+ }),
549
+ run: async (A, args) => {
550
+ if (typeof args.x === 'number' && typeof args.y === 'number') await moveMouseHuman(A, args.x, args.y, CFG.humanize);
551
+ if (CFG.humanize) await sleep(between(CFG.clickSettleMs));
552
+ const double = Boolean(args.double);
553
+ clickMouse(A, args.button, false);
554
+ if (double) {
555
+ // A real double click needs a visible gap; two immediate pairs collapse into one.
556
+ await sleep(between([50, 90]));
557
+ clickMouse(A, args.button, false);
558
+ }
559
+ return { ok: true, ...cursorPosition(A), button: args.button || 'left', double };
560
+ },
561
+ out: {
562
+ schema: obj({
563
+ ok: bool('Always true.'),
564
+ button: str('Button used.'),
565
+ x: num('Pointer X.'),
566
+ y: num('Pointer Y.'),
567
+ double: bool('Whether the click was doubled.'),
568
+ }),
569
+ render: (_args, v) => text(`${v.double ? 'Double-clicked' : 'Clicked'} ${v.button} button at (${v.x}, ${v.y}).`),
570
+ },
571
+ },
572
+ {
573
+ name: 'cua_scroll',
574
+ description: 'Turn the real mouse wheel at the pointer position; positive scrolls up.',
575
+ parameters: obj({
576
+ amount: num('Wheel delta (120 per notch).'),
577
+ }, ['amount']),
578
+ run: (A, args) => {
579
+ scrollWheel(A, args.amount);
580
+ return { ok: true };
581
+ },
582
+ out: {
583
+ schema: obj({ ok: bool('Always true.') }),
584
+ render: (_args, v) => text(`Wheel scrolled (${v.ok ? 'sent' : 'failed'}).`),
585
+ },
586
+ },
587
+ {
588
+ name: 'cua_key',
589
+ description:
590
+ 'Press a real key with optional modifiers. Key names: enter, esc, tab, space, backspace, delete, arrows, f1-f12, letters, digits. Modifiers: ctrl, shift, alt, win.',
591
+ parameters: obj({
592
+ key: str('Key to press, e.g. "enter", "a", "f5".'),
593
+ modifiers: { type: 'array', items: { type: 'string' }, description: 'Modifiers to hold, e.g. ["ctrl"].' },
594
+ }, ['key']),
595
+ run: (A, args) => {
596
+ tapKey(A, args.key, args.modifiers || []);
597
+ return { ok: true };
598
+ },
599
+ out: {
600
+ schema: obj({ ok: bool('Always true.') }),
601
+ render: (_args, v) => text(`Key press sent (${v.ok ? 'ok' : 'failed'}).`),
602
+ },
603
+ },
604
+ {
605
+ name: 'cua_type',
606
+ description: 'Type literal text into the focused window through real unicode key events.',
607
+ parameters: obj({
608
+ text: str('Text to type.'),
609
+ }, ['text']),
610
+ run: async (A, args) => {
611
+ await typeText(A, args.text, CFG.humanize);
612
+ return { ok: true, chars: [...String(args.text)].length };
613
+ },
614
+ out: {
615
+ schema: obj({ ok: bool('Always true.'), chars: { type: 'integer', description: 'Characters typed.' } }),
616
+ render: (_args, v) => text(`Typed ${v.chars} characters.`),
617
+ },
618
+ },
619
+ {
620
+ name: 'cua_windows',
621
+ description: 'List visible top-level windows: handle, title, process id, bounds, active and minimized flags.',
622
+ parameters: obj({}),
623
+ run: (A) => ({ windows: listWindows(A) }),
624
+ out: {
625
+ schema: obj({
626
+ windows: { type: 'array', items: { type: 'object' }, description: 'Visible top-level windows.' },
627
+ }),
628
+ render: (_args, v) => text(
629
+ `Found ${v.windows.length} window(s):\n` + v.windows.map((w) =>
630
+ `#${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
+ ).join('\n')),
632
+ },
633
+ },
634
+ {
635
+ name: 'cua_focus_window',
636
+ description: 'Restore a minimized window and bring it to the foreground by its handle from cua_windows.',
637
+ parameters: obj({
638
+ handle: num('Window handle from cua_windows.'),
639
+ }, ['handle']),
640
+ run: (A, args) => {
641
+ const hwnd = A.koffi.address(args.handle);
642
+ A.ShowWindow(hwnd, SW_RESTORE);
643
+ A.SetForegroundWindow(hwnd);
644
+ A.BringWindowToTop(hwnd);
645
+ return { ok: true, handle: args.handle };
646
+ },
647
+ out: {
648
+ schema: obj({ ok: bool('Always true.'), handle: num('Focused window handle.') }),
649
+ render: (_args, v) => text(`Window ${v.handle} brought to the foreground.`),
650
+ },
651
+ },
652
+ {
653
+ name: 'cua_cursor',
654
+ description: 'Report the current pointer position and the virtual-screen geometry.',
655
+ parameters: obj({}),
656
+ run: (A) => ({ ...cursorPosition(A), screen: virtualScreen(A) }),
657
+ out: {
658
+ schema: obj({
659
+ x: num('Pointer X.'),
660
+ y: num('Pointer Y.'),
661
+ screen: { type: 'object', description: 'Virtual-screen geometry.' },
662
+ }),
663
+ render: (_args, v) => text(`Cursor at (${v.x}, ${v.y}); virtual screen: ${JSON.stringify(v.screen)}.`),
664
+ },
665
+ },
666
+ ];
667
+
668
+ export const name = 'dsh-win-cua';
669
+
670
+ export const inject = ['tools'];
671
+
672
+ /**
673
+ * Register the CUA tools on the host.
674
+ *
675
+ * Config knobs (all optional, from this bundle's cordis.patch.yml):
676
+ * - `humanize` (default true): move the pointer along a smoothstep path and
677
+ * type/click at a human cadence instead of teleporting instantly.
678
+ * - `keepScreenshots` (default 30): newest screenshots kept per directory.
679
+ * - `moveSteps` (default 16), `keyDelayMs` ([12, 34]), `clickSettleMs` ([60, 140]):
680
+ * fine-tuning for the humanized cadence.
681
+ */
682
+ export function apply(ctx, config = {}) {
683
+ for (const key of Object.keys(DEFAULTS)) {
684
+ if (config[key] !== undefined) CFG[key] = config[key];
685
+ }
686
+ for (const decl of TOOL_DECLS) {
687
+ const tool = {
688
+ name: decl.name,
689
+ description: decl.description,
690
+ parameters: decl.parameters,
691
+ output: decl.out,
692
+ async execute(args = {}) {
693
+ return decl.run(loadNative(), args ?? {});
694
+ },
695
+ };
696
+ ctx.effect(() => ctx.tools.register(tool));
697
+ }
698
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Win32 Computer Use",
4
+ "description": "Real mouse and keyboard control plus screen capture on Windows, through Win32 SendInput and GDI BitBlt. No external driver."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Win32 电脑操作",
4
+ "description": "用 Win32 的 SendInput 和 GDI BitBlt 在 Windows 上真实移动鼠标、点击、敲键盘和截屏,不需要任何外部驱动。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "dsh-win-cua",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "main": "index.js",
7
+ "license": "MIT",
8
+ "keywords": [
9
+ "dsh",
10
+ "dsh-plugin",
11
+ "computer-use",
12
+ "win32",
13
+ "sendinput",
14
+ "bitblt",
15
+ "automation",
16
+ "windows"
17
+ ],
18
+ "files": [
19
+ "index.js",
20
+ "cordis.patch.yml",
21
+ "locale/*.json",
22
+ "icon.svg",
23
+ "LICENSE",
24
+ "NOTICE.md",
25
+ "README.md"
26
+ ],
27
+ "dsh": {
28
+ "bundle": {
29
+ "patch": "cordis.patch.yml"
30
+ },
31
+ "compatibility": {
32
+ "node": ">=20"
33
+ }
34
+ }
35
+ }