@momasiku/dsh-pilot 0.5.10 → 0.5.11

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
@@ -116,6 +116,60 @@ is byte-identical to the previous one of the same session, the image is not atta
116
116
  (or stored) again — the result says `unchanged: true` and the coordinates from the
117
117
  frame the model already has still apply. `forceImage: true` overrides it.
118
118
 
119
+ ## Reading the control tree
120
+
121
+ A screenshot costs a guess: the model finds a button in pixels, estimates its centre,
122
+ clicks, and takes another frame to see whether the guess was right. Windows exposes the
123
+ same control through UI Automation as a role, a name and a rectangle, so the click
124
+ becomes the centre of that rectangle and nothing has to be looked at.
125
+
126
+ ```js
127
+ desktop_control({ action: 'controls', title: 'Notepad' })
128
+ // 4 [Button] '最小化' @ 887,1 45x39
129
+ // 14 [Button] '新建会话' @ 12,54 256x24
130
+ // 23 [Edit] '搜索会话名称' @ 204,238 4x20 offscreen
131
+
132
+ desktop_control({ action: 'click', target: 14, title: 'Notepad' })
133
+ ```
134
+
135
+ **The tree is not a faster screenshot; it is a different trade.**
136
+
137
+ | | screenshot | control tree |
138
+ | --- | --- | --- |
139
+ | one read | ~150 ms, ~1k tokens | ~130-300 ms, ~850 tokens (filtered) |
140
+ | click target | estimated from pixels | the control's exact rectangle |
141
+ | state | "looks disabled" | `enabled`, `offscreen`, `HasKeyboardFocus`, `IsSelected`, value |
142
+ | availability | always | **not always** - see below |
143
+
144
+ Per call the tree is slower or level. Per task it is much faster, because one read
145
+ resolves several targets without another frame and without another round trip.
146
+
147
+ **It does not always exist.** A canvas, a drawing surface, a custom-painted list and an
148
+ Electron window that publishes nothing all give an empty or useless tree. That is why
149
+ the screenshot path is still there and still the fallback: read the tree first, take a
150
+ frame when the tree is empty or when you need to see what is drawn rather than what is
151
+ declared.
152
+
153
+ **Three things worth knowing.**
154
+
155
+ - An index addresses one row of one listing. It is resolved against a fresh read at
156
+ click time, and the worker remembers the listing it handed out, so a row that changed
157
+ identity fails loudly instead of being clicked by position:
158
+ `the control at index 4 is not the one that was listed: expected [Button] 'OK', found [Button] 'Cancel'`.
159
+ - `meaningfulOnly` (default `true`) returns only the roles a person can operate. On a
160
+ real 673-element window that is 77 elements and ~850 tokens instead of 9k - the rest
161
+ is layout panes with no name, no state and nothing to click. Pass `false` for the
162
+ whole tree.
163
+ - `includeValues` is off by default: structure is not content. Password fields are
164
+ masked even when it is on.
165
+
166
+ **When the thing you want has no tree of its own** - a chat transcript, a canvas, a
167
+ custom-painted panel - do not look for it. Look for what surrounds it and subtract: the
168
+ composer is an `Edit` with a rectangle, the window has a rectangle, so the transcript is
169
+ what is left between them. Or skip the geometry: select the item in the tree, confirm
170
+ with `HasKeyboardFocus` that the keyboard is where you want it, type, press Enter. That
171
+ last step is something a screenshot cannot do - it can show a caret, not a promise.
172
+
119
173
  ## Why it is safe
120
174
 
121
175
  Giving a model hands is the part worth getting right:
@@ -178,6 +232,26 @@ and what `dsh plugin add` checks before it installs anything. Requirements:
178
232
 
179
233
  ### Recent changes
180
234
 
235
+ **0.5.11** — the control tree, and two fixes that make a batch honest.
236
+
237
+ - **`controls` reads the window's UI Automation tree**, and `target` clicks a listed
238
+ control's exact rectangle, so a named control no longer has to be found in pixels.
239
+ It takes no screenshot, so it pays no settle. Measured on a 673-element window:
240
+ **0.65 s** and **~850 tokens** filtered (77 operable elements) against ~9k
241
+ unfiltered. The worker remembers the listing it handed out, so an index whose row
242
+ changed identity fails instead of clicking whatever took its place.
243
+ - **The settle was paid even when nothing was captured** (0.5.9). It exists so a frame
244
+ is not taken mid-repaint; `desktop_control` applied it on every call, capturing or
245
+ not. Measured **814 ms -> 12 ms** for a non-capturing action.
246
+ - **A refused `focus` was reported as success** (0.5.9). The step recorded a note, said
247
+ `ok`, and the rest of the batch went to whichever window was actually in front. It
248
+ now checks the foreground window after the attempt — the state, not
249
+ `SetForegroundWindow`'s return value — and fails, naming both. `tolerant: true`
250
+ restores the old behaviour for a caller whose coordinates are right either way.
251
+ - **The peer range now accepts the 0.2.1 host generation** (0.5.10). The 0.2.1 tuple
252
+ needed its own prerelease comparator: a prerelease only satisfies a range when a
253
+ comparator carries a prerelease tag on the same `major.minor.patch`.
254
+
181
255
  **0.5.8** — two fixes, and the package is now on npm as
182
256
  [`@momasiku/dsh-pilot`](https://www.npmjs.com/package/@momasiku/dsh-pilot).
183
257
 
@@ -224,7 +298,7 @@ coordinate contract for that frame.
224
298
 
225
299
  | parameter | type | meaning |
226
300
  | --- | --- | --- |
227
- | `action` | string, required | `click`, `doubleClick`, `rightClick`, `middleClick`, `move`, `drag`, `scroll`, `type`, `key`, `focus`, `windows` |
301
+ | `action` | string, required | `click`, `doubleClick`, `rightClick`, `middleClick`, `move`, `drag`, `scroll`, `type`, `key`, `focus`, `windows`, `controls` |
228
302
  | `x`, `y` | integer | target in physical screen pixels; required by the pointer actions and by scroll-at-a-point |
229
303
  | `toX`, `toY` | integer | drag end point, or a nonzero value to make `scroll` horizontal |
230
304
  | `text` | string | text to insert for `type` |
@@ -236,6 +310,10 @@ coordinate contract for that frame.
236
310
  | `capture` | boolean | capture a fresh frame after acting (default `true`) |
237
311
  | `settleMs` | integer | wait before that capture so the screen can repaint (default 750) |
238
312
  | `forceImage` | boolean | attach the frame even when it repeats the previous frame of the session |
313
+ | `target` | integer | pointer actions: click the control at this index from a `controls` listing instead of `x`/`y` |
314
+ | `includeValues` | boolean | `controls`: also return each control's value (default `false`; passwords masked) |
315
+ | `meaningfulOnly` | boolean | `controls`: only operable roles (default `true`) |
316
+ | `tolerant` | boolean | `focus`: record a note instead of failing when Windows refuses the foreground |
239
317
 
240
318
  `type` delivers text as **one paste** (clipboard set, `Ctrl+V`, clipboard restored), so
241
319
  applications see a single insertion — multi-line text and CJK included — instead of a
@@ -243,6 +321,12 @@ burst of per-character keystrokes. `windows` returns every visible titled top-le
243
321
  window with its bounds, minimized state, and which is foreground: useful before
244
322
  clicking anything.
245
323
 
324
+ `controls` returns the window's control tree as one line per control — `index [Role]
325
+ 'Name' @ left,top widthxheight` plus `disabled`/`offscreen`/`value` — and takes no
326
+ screenshot (pass `capture: true` if you want one anyway). Feed an index back as
327
+ `target` to click that control's exact centre. See **Reading the control tree** above
328
+ for when a tree exists and when a frame is still the right tool.
329
+
246
330
  ### `desktop_sequence`
247
331
 
248
332
  | parameter | type | meaning |
@@ -447,6 +531,52 @@ DSH:desktop_sequence([focus "报表.xlsx", click 812,430, type "Q3 收入", ke
447
531
 
448
532
  **没变化的帧不重复发送**:每次截图都带 `frameHash`,与同会话上一帧逐字节相同时就不重复附图(也不重复存储),结果里写 `unchanged: true`,模型手上那张图的坐标继续有效;想看就传 `forceImage: true`。
449
533
 
534
+ ## 读控件树
535
+
536
+ 截图是要靠猜的:模型在像素里找到按钮、估个中心、点一下,再截一张图看猜没猜对。
537
+ 而 Windows 通过 UI Automation 直接把同一个控件交出来——角色、名字、矩形——于是点击
538
+ 就变成「那个矩形的中心」,什么也不用看。
539
+
540
+ ```js
541
+ desktop_control({ action: 'controls', title: '记事本' })
542
+ // 4 [Button] '最小化' @ 887,1 45x39
543
+ // 14 [Button] '新建会话' @ 12,54 256x24
544
+ // 23 [Edit] '搜索会话名称' @ 204,238 4x20 offscreen
545
+
546
+ desktop_control({ action: 'click', target: 14, title: '记事本' })
547
+ ```
548
+
549
+ **控件树不是更快的截图,是另一种取舍。**
550
+
551
+ | | 截图 | 控件树 |
552
+ | --- | --- | --- |
553
+ | 读一次 | ~150 ms,~1k token | ~130-300 ms,~850 token(过滤后) |
554
+ | 点击位置 | 从像素里估 | 控件的精确矩形 |
555
+ | 状态 | 「看着像禁用」 | `enabled`、`offscreen`、`HasKeyboardFocus`、`IsSelected`、值 |
556
+ | 可用性 | 永远有 | **不一定有**,见下 |
557
+
558
+ 按单次调用看,树更慢或打平;按单个任务看,树快得多——一次读取能定位好几个目标,
559
+ 既不用多截一张图,也不用多跑一轮推理。
560
+
561
+ **它不是总存在。** canvas、绘图面、自绘列表、以及什么都不上报的 Electron 窗口,
562
+ 给出的树要么是空的要么没意义。所以截图这条路还在,而且仍然是兜底:先读树,
563
+ 树为空、或者你需要看「画出来什么」而不是「声明了什么」时,再截一张图。
564
+
565
+ **三个要知道的点。**
566
+
567
+ - 索引只针对「某一次列表」。点击时会重新读一遍再解析,而且暖 worker 记着它发出去的
568
+ 那份列表,所以某一行换了身份会明确报错,而不是按位置点下去:
569
+ `the control at index 4 is not the one that was listed: expected [Button] 'OK', found [Button] 'Cancel'`。
570
+ - `meaningfulOnly`(默认 `true`)只返回人能操作的控件。在一个真实的 673 元素窗口上,
571
+ 这是 77 个元素、约 850 token,而不是 9k——其余全是没名字、没状态、点了也没反应的
572
+ 布局面板。要整棵树就传 `false`。
573
+ - `includeValues` 默认关闭:结构不是内容。即使打开,密码框也会被打码。
574
+
575
+ **当你要找的东西本身没有树**——聊天记录、画布、自绘面板——不要去找它。去找围着它的
576
+ 东西再做减法:输入框是个带矩形的 `Edit`,窗口有矩形,那么聊天区就是它们之间剩下的那块。
577
+ 或者干脆不用几何:在树里选中那一条,用 `HasKeyboardFocus` 确认键盘落点就是你要的地方,
578
+ 打字,回车。最后这一步是截图做不到的——截图能给你一根光标,给不了保证。
579
+
450
580
  ## 为什么安全
451
581
 
452
582
  给模型装上手,最该讲究的就是这一块:
@@ -486,6 +616,13 @@ dsh plugin --profile <你的profile> add "file:E:\path\to\dsh-pilot"
486
616
 
487
617
  ### 最近改动
488
618
 
619
+ **0.5.11** —— 控件树,外加两个让批量操作「不说谎」的修复。
620
+
621
+ - **`controls` 读窗口的 UI Automation 树**,`target` 点列表里那个控件的精确矩形,于是有名字的控件不必再去像素里找。它不截图,所以也不付 settle。在 673 个元素的窗口上实测:过滤后 **0.65 秒、约 850 token**(77 个可操作元素),不过滤约 9k。暖 worker 记着它发出去的那份列表,所以某一行换了身份会报错,而不是点到搬进来的那个。
622
+ - **不截图的动作也在付 settle**(0.5.9)。settle 本是为了「别在重绘中途取帧」,但 `desktop_control` 每次调用都付。实测不截图的动作用时 **814 ms → 12 ms**。
623
+ - **`focus` 被拒绝却报成功**(0.5.9)。那一步只记一条备注、仍然说 `ok`,于是批量剩下的步骤全打进了真正在前台的那个窗口。现在改成在尝试之后检查真实前台——看状态,不看 `SetForegroundWindow` 的返回值——不符就报错并说出两个窗口。`tolerant: true` 可以恢复旧行为,给「坐标反正都对」的调用方留口子。
624
+ - **peer 范围开始接受 0.2.1 世代**(0.5.10)。`0.2.1` 这个元组需要它自己的预发布比较符:预发布版本只有在某个比较符与它同属一个 `major.minor.patch`、且自身带预发布标签时才放行。
625
+
489
626
  **0.5.8** —— 两个修复,同时包已发布到 npm:[`@momasiku/dsh-pilot`](https://www.npmjs.com/package/@momasiku/dsh-pilot)。
490
627
 
491
628
  - **显示器几何会变陈旧,而且是静默的。** 原本显示器清单来自 `System.Windows.Forms.Screen.AllScreens`,这是静态缓存,只有进程处理 `WM_DISPLAYCHANGE` 时才刷新;而暖 worker 是没有消息泵的 `-NonInteractive` 读取循环,缓存永远不会失效。实测:存活 13 小时的 worker 报 `<screens>primary WinDisc 0,0,1600,900</screens>`,而同一份结果里的 `<image_size>` 是 `1024x768` —— 同一份结果两个答案,而坐标契约正是从这份帧里读的。现在每次截图都用 `EnumDisplayMonitors` 重新枚举,虚拟屏改用 `GetSystemMetrics`,两者都不缓存。
package/lib/index.js CHANGED
@@ -992,7 +992,7 @@ const POINTER_ACTIONS = new Set(['click', 'doubleClick', 'rightClick', 'middleCl
992
992
  const RISKS = ['low', 'medium', 'high'];
993
993
  const RISK_ORDER = { low: 0, medium: 1, high: 2 };
994
994
  /** Step fields forwarded verbatim to the action helper. */
995
- const STEP_FIELDS = ['x', 'y', 'toX', 'toY', 'amount', 'text', 'key', 'title', 'button', 'tolerant'];
995
+ const STEP_FIELDS = ['x', 'y', 'toX', 'toY', 'amount', 'text', 'key', 'title', 'button', 'tolerant', 'target', 'includeValues', 'meaningfulOnly'];
996
996
 
997
997
  const SEQUENCE_STEP_RESULT_SCHEMA = {
998
998
  type: 'object',
@@ -1252,7 +1252,8 @@ function apply(ctx, config) {
1252
1252
  ctx.tools.register(defineTool({
1253
1253
  name: 'desktop_control',
1254
1254
  description: [
1255
- 'Operate the live desktop: move the pointer, click, drag, scroll, type text, send keys, focus a window, or list windows.',
1255
+ 'Operate the live desktop: move the pointer, click, drag, scroll, type text, send keys, focus a window, read a window control tree, or list windows.',
1256
+ '`controls` is the cheap way to hit a named control: it returns each control role, name and rectangle, so a click becomes the centre of that rectangle (pass its index as target) instead of a guess at pixels. Reach for a screenshot when the window exposes no usable tree - a canvas, a drawing surface, or an Electron window that publishes nothing - or when you need to see what is drawn rather than what is declared.',
1256
1257
  'Coordinates are physical screen pixels and match screen_view images exactly, so read the target off a fresh screenshot and reuse those pixels verbatim.',
1257
1258
  'Each press, keystroke, and window focus goes to whatever is focused on the real desktop, so call screen_view first and re-check after acting when the result matters.',
1258
1259
  'Set capture true to receive a fresh image in the same result, which shows the effect of the action without a second round trip.',
@@ -1262,8 +1263,8 @@ function apply(ctx, config) {
1262
1263
  action: {
1263
1264
  type: 'string',
1264
1265
  required: true,
1265
- enum: ['click', 'doubleClick', 'rightClick', 'middleClick', 'move', 'drag', 'scroll', 'type', 'key', 'focus', 'windows'],
1266
- description: 'click | doubleClick | rightClick | middleClick | move | drag | scroll | type | key | focus | windows.'
1266
+ enum: ['click', 'doubleClick', 'rightClick', 'middleClick', 'move', 'drag', 'scroll', 'type', 'key', 'focus', 'windows', 'controls'],
1267
+ description: 'click | doubleClick | rightClick | middleClick | move | drag | scroll | type | key | focus | windows | controls.'
1267
1268
  },
1268
1269
  x: { type: 'integer', description: 'Target x in physical screen pixels; required for the pointer actions and for scroll-at-a-point.' },
1269
1270
  y: { type: 'integer', description: 'Target y in physical screen pixels; required for the pointer actions and for scroll-at-a-point.' },
@@ -1285,6 +1286,18 @@ function apply(ctx, config) {
1285
1286
  type: 'boolean',
1286
1287
  description: 'focus only: when Windows refuses to hand the window the foreground, record a note and continue instead of failing the step. Use it when the coordinates are right whichever window ends up in front.'
1287
1288
  },
1289
+ target: {
1290
+ type: 'integer',
1291
+ description: 'Click a control instead of a coordinate: the index number from a controls listing of the same window. The listing is re-read before the click, so an index that no longer resolves fails rather than hitting whatever moved into that slot.'
1292
+ },
1293
+ includeValues: {
1294
+ type: 'boolean',
1295
+ description: 'controls only: also return each control current value (edit boxes, text). Off by default, because a value is content rather than structure; password fields are always masked.'
1296
+ },
1297
+ meaningfulOnly: {
1298
+ type: 'boolean',
1299
+ description: 'controls only, default true: return just the controls a person can operate (buttons, edits, list rows, menus, tabs, check boxes...). Measured on a real 673-element window the filtered listing is 77 elements and about 850 tokens, against 9k for all of them, because the rest is layout panes with no name and nothing to click. Pass false when you need the whole tree - for example to see how a window is composed rather than what can be operated.'
1300
+ },
1288
1301
  capture: {
1289
1302
  type: 'boolean',
1290
1303
  description: 'Capture a fresh frame after the action and show it in this result. Default true.'
@@ -1319,6 +1332,10 @@ function apply(ctx, config) {
1319
1332
  notes: { type: 'array', items: { type: 'string' } },
1320
1333
  windows: { type: 'array', items: WINDOW_INFO_SCHEMA },
1321
1334
  windowCount: { type: 'integer' },
1335
+ controls: { type: 'array', items: { type: 'string' } },
1336
+ controlCount: { type: 'integer' },
1337
+ controlsTruncated: { type: 'boolean' },
1338
+ controlsWindow: { type: 'string' },
1322
1339
  actedAt: { type: 'string' },
1323
1340
  capture: CAPTURE_SCHEMA,
1324
1341
  capturePath: { type: 'string' },
@@ -1339,7 +1356,9 @@ function apply(ctx, config) {
1339
1356
  isConcurrencySafe: () => false,
1340
1357
  async execute(args, exec) {
1341
1358
  warmWorker(config);
1342
- const wantsCapture = args.capture !== false;
1359
+ // Reading the control tree is the alternative to taking a frame, so it does not
1360
+ // take one unless asked: a caller that wants both still gets both.
1361
+ const wantsCapture = args.capture === undefined ? args.action !== 'controls' : args.capture === true;
1343
1362
  if (wantsCapture) await assertImageCapableRoute(ctx, exec, 'desktop_control with capture');
1344
1363
 
1345
1364
  // The settle exists so a frame is not taken mid-repaint. With no frame to
@@ -1350,7 +1369,7 @@ function apply(ctx, config) {
1350
1369
  const explicitSettle = Number.isSafeInteger(args.settleMs);
1351
1370
  const settle = explicitSettle ? args.settleMs : (wantsCapture ? config.settleMs : 0);
1352
1371
  const params = { action: args.action, settleMs: settle };
1353
- for (const field of ['x', 'y', 'toX', 'toY', 'amount', 'text', 'key', 'title', 'button', 'tolerant']) {
1372
+ for (const field of ['x', 'y', 'toX', 'toY', 'amount', 'text', 'key', 'title', 'button', 'tolerant', 'target', 'includeValues', 'meaningfulOnly']) {
1354
1373
  if (args[field] !== undefined && args[field] !== null) params[field] = args[field];
1355
1374
  }
1356
1375
 
@@ -1359,7 +1378,7 @@ function apply(ctx, config) {
1359
1378
  // Copy the declared fields only: the helper's own bookkeeping keys
1360
1379
  // must not reach the closed output schema.
1361
1380
  const value = { action: args.action };
1362
- for (const field of ['cursor', 'foregroundTitle', 'foregroundClass', 'foregroundProcess', 'notes', 'windowCount', 'actedAt']) {
1381
+ for (const field of ['cursor', 'foregroundTitle', 'foregroundClass', 'foregroundProcess', 'notes', 'windowCount', 'actedAt', 'controls', 'controlCount', 'controlsTruncated', 'controlsWindow']) {
1363
1382
  if (acted[field] !== undefined) value[field] = acted[field];
1364
1383
  }
1365
1384
  // The list is projected, not copied: the sensor's window entries carry
@@ -10,10 +10,20 @@
10
10
  # a hashtable instead of being printed.
11
11
  $ErrorActionPreference = 'Stop'
12
12
 
13
+ # UI Automation: the control tree, for a caller that would rather read a control's
14
+ # name and rectangle than guess its pixels. Loaded from here so every entry script
15
+ # that dot-sources this file gets it for free.
16
+ . (Join-Path $PSScriptRoot '_dsh-uia.ps1')
17
+
13
18
  # Set by Write-ActionError so a caller can forward the structured payload a thrown
14
19
  # failure carries (cancellation is not just an error message).
15
20
  $script:ActionStopPayload = $null
16
21
 
22
+ # The last controls listing handed out, keyed by element index. A flat listing can
23
+ # keep its length while its rows change, so an index that still resolves can still
24
+ # point at a different control; this is what lets a click notice that.
25
+ $script:UiListing = $null
26
+
17
27
  function Write-ActionError([string] $message) {
18
28
  $script:ActionStopPayload = [ordered]@{ ok = $false; error = $message }
19
29
  throw [System.Exception]::new($message)
@@ -156,6 +166,59 @@ Execute one action. $Config is the request object the callers already build
156
166
  (action, x, y, toX, toY, amount, text, key, title, button, hwnd, cancelFile,
157
167
  settleMs). Returns the same shape the one-shot script used to print.
158
168
  #>
169
+ function Find-DshWindowHandle([string] $needle) {
170
+ # Foreground wins, otherwise the first visible match: the same rule focus uses,
171
+ # so a title means the same window to every action that takes one.
172
+ $lower = $needle.ToLowerInvariant()
173
+ $fallback = [IntPtr]::Zero
174
+ foreach ($handle in [DshInput]::TopLevelWindows()) {
175
+ if (-not [DshInput]::IsWindowVisible($handle)) { continue }
176
+ $title = [DshInput]::WindowTitle($handle)
177
+ if ($title.Length -eq 0) { continue }
178
+ $owner = [uint32] 0
179
+ $null = [DshInput]::GetWindowThreadProcessId($handle, [ref] $owner)
180
+ $processName = Get-ProcessNameSafe $owner
181
+ $hit = $title.ToLowerInvariant().Contains($lower)
182
+ if (-not $hit -and $null -ne $processName) { $hit = $processName.ToLowerInvariant().Contains($lower) }
183
+ if (-not $hit) { continue }
184
+ if ([DshInput]::IsForeground($handle)) { return $handle }
185
+ if ($fallback -eq [IntPtr]::Zero) { $fallback = $handle }
186
+ }
187
+ return $fallback
188
+ }
189
+
190
+ function Get-DshTargetPoint([string] $indexText, [IntPtr] $hwnd, [string] $title) {
191
+ # An index addresses a node only against the listing it came from. Resolving it
192
+ # again is what turns 'the button I saw' into 'the button that is there now',
193
+ # and an index that no longer resolves fails instead of clicking its successor.
194
+ $index = 0
195
+ if (-not [int]::TryParse($indexText, [ref] $index)) { Write-ActionError ('target must be the index of a control listed by ''controls'', got ''{0}''' -f $indexText) }
196
+ $handle = $hwnd
197
+ if ($handle -eq [IntPtr]::Zero) {
198
+ if ([string]::IsNullOrWhiteSpace($title)) { Write-ActionError ('target {0} needs a window: pass title or hwnd with it' -f $index) }
199
+ $handle = Find-DshWindowHandle $title
200
+ if ($handle -eq [IntPtr]::Zero) { Write-ActionError ('target {0} needs a window: no visible top-level window matches ''{1}''' -f $index, $title) }
201
+ }
202
+ $element = Resolve-DshUiaIndex -Handle $handle -Index $index
203
+ if ($null -eq $element) { Write-ActionError ('the tree changed: index {0} is past the end of the listing for ''{1}''. Read controls again.' -f $index, [DshInput]::WindowTitle($handle)) }
204
+ if ($null -ne $script:UiListing -and $script:UiListing.handle -eq [long] $handle -and $script:UiListing.items.ContainsKey($index)) {
205
+ # Compare identity, not just existence: a reorder keeps the index valid.
206
+ $gotRole = ''
207
+ $gotName = ''
208
+ try {
209
+ $gotRole = $element.Current.ControlType.ProgrammaticName -replace 'ControlType\.', ''
210
+ $gotName = [string] $element.Current.Name
211
+ } catch { }
212
+ $want = $script:UiListing.items[$index]
213
+ if ($gotRole -ne $want.role -or $gotName -ne $want.name) {
214
+ Write-ActionError ('the control at index {0} is not the one that was listed: expected [{1}] ''{2}'', found [{3}] ''{4}''. Read controls again.' -f $index, $want.role, $want.name, $gotRole, $gotName)
215
+ }
216
+ }
217
+ $centre = Get-DshUiaCentre -Element $element
218
+ if ($null -eq $centre) { Write-ActionError ('the control at index {0} has no on-screen rectangle - it may be scrolled out of view or collapsed' -f $index) }
219
+ return @($centre.x, $centre.y)
220
+ }
221
+
159
222
  function Invoke-DshAction {
160
223
  param([object] $Config)
161
224
 
@@ -164,6 +227,16 @@ function Invoke-DshAction {
164
227
  return $null
165
228
  }
166
229
 
230
+ # x/y, or the centre of the control a target path addresses. One entry point so
231
+ # every pointer action accepts both spellings without repeating the branch.
232
+ function Get-AnchoredPoint([string] $what) {
233
+ $targetPath = [string] (Get-Param 'target')
234
+ if (-not [string]::IsNullOrWhiteSpace($targetPath)) {
235
+ return Get-DshTargetPoint $targetPath $script:TargetHwnd $windowTitle
236
+ }
237
+ return Get-Point (Get-Param 'x') (Get-Param 'y') $what
238
+ }
239
+
167
240
  $script:CancelFile = [string] (Get-Param 'cancelFile')
168
241
 
169
242
  # Optional target window. When present, every input action re-asserts it as the
@@ -199,7 +272,7 @@ function Invoke-DshAction {
199
272
 
200
273
  switch ($action) {
201
274
  'click' {
202
- $point = Get-Point (Get-Param 'x') (Get-Param 'y') 'click'
275
+ $point = Get-AnchoredPoint 'click'
203
276
  $null = [DshInput]::SetCursorPos($point[0], $point[1])
204
277
  Start-Sleep -Milliseconds 80
205
278
  Assert-Alive
@@ -209,7 +282,7 @@ switch ($action) {
209
282
  [DshInput]::Button($button, $false)
210
283
  }
211
284
  'doubleClick' {
212
- $point = Get-Point (Get-Param 'x') (Get-Param 'y') 'doubleClick'
285
+ $point = Get-AnchoredPoint 'doubleClick'
213
286
  $null = [DshInput]::SetCursorPos($point[0], $point[1])
214
287
  Start-Sleep -Milliseconds 80
215
288
  Assert-Alive
@@ -221,7 +294,7 @@ switch ($action) {
221
294
  [DshInput]::Button($button, $true); Start-Sleep -Milliseconds 40; [DshInput]::Button($button, $false)
222
295
  }
223
296
  'rightClick' {
224
- $point = Get-Point (Get-Param 'x') (Get-Param 'y') 'rightClick'
297
+ $point = Get-AnchoredPoint 'rightClick'
225
298
  $null = [DshInput]::SetCursorPos($point[0], $point[1])
226
299
  Start-Sleep -Milliseconds 80
227
300
  Assert-Alive
@@ -231,7 +304,7 @@ switch ($action) {
231
304
  [DshInput]::Button('right', $false)
232
305
  }
233
306
  'middleClick' {
234
- $point = Get-Point (Get-Param 'x') (Get-Param 'y') 'middleClick'
307
+ $point = Get-AnchoredPoint 'middleClick'
235
308
  $null = [DshInput]::SetCursorPos($point[0], $point[1])
236
309
  Start-Sleep -Milliseconds 80
237
310
  Assert-Alive
@@ -241,7 +314,7 @@ switch ($action) {
241
314
  [DshInput]::Button('middle', $false)
242
315
  }
243
316
  'move' {
244
- $point = Get-Point (Get-Param 'x') (Get-Param 'y') 'move'
317
+ $point = Get-AnchoredPoint 'move'
245
318
  $null = [DshInput]::SetCursorPos($point[0], $point[1])
246
319
  # The move is queued, so an immediate read can still return the previous
247
320
  # position; wait (bounded) until the pointer is where the caller asked.
@@ -303,8 +376,47 @@ switch ($action) {
303
376
  catch { Write-ActionError "the keystroke '$keySpec' could not be delivered: $($_.Exception.Message)" }
304
377
  }
305
378
  }
306
- 'focus' {
307
- # A refused hand-off used to be a note: the step reported ok and the batch
379
+ 'controls' {
380
+ # Read the window's UI Automation tree and return it as text. This is the
381
+ # cheap half of seeing: a control's name and rectangle, no frame, no pixels.
382
+ # It returns its own result and returns early so it never pays the settle
383
+ # or the capture - the whole point is that no screenshot is needed.
384
+ $treeHandle = $script:TargetHwnd
385
+ if ($treeHandle -eq [IntPtr]::Zero) {
386
+ if ([string]::IsNullOrWhiteSpace($windowTitle)) { Write-ActionError 'controls requires a title, or an hwnd' }
387
+ $treeHandle = Find-DshWindowHandle $windowTitle
388
+ if ($treeHandle -eq [IntPtr]::Zero) { Write-ActionError ('no visible top-level window matches ''{0}''; pass an hwnd if the window is hidden to the tray' -f $windowTitle) }
389
+ }
390
+ $includeValues = $false
391
+ $ivRaw = Get-Param 'includeValues'
392
+ if ($null -ne $ivRaw) { $includeValues = [bool] $ivRaw }
393
+ $meaningfulOnly = $true
394
+ $moRaw = Get-Param 'meaningfulOnly'
395
+ if ($null -ne $moRaw) { $meaningfulOnly = [bool] $moRaw }
396
+ $tree = Get-DshUiaNodes -Handle $treeHandle -IncludeValues $includeValues -MeaningfulOnly $meaningfulOnly
397
+ if (-not $tree.ok) { Write-ActionError $tree.error }
398
+ $items = @{}
399
+ foreach ($n in $tree.nodes) { $items[[int] $n.index] = @{ role = [string] $n.role; name = [string] $n.name } }
400
+ $script:UiListing = @{ handle = [long] $treeHandle; items = $items }
401
+ $lines = Format-DshUiaNodes -Nodes $tree.nodes -Truncated $tree.truncated
402
+ $treeCursor = [DshInput]::Cursor()
403
+ $result = [ordered]@{
404
+ ok = $true
405
+ action = 'controls'
406
+ cursor = [ordered]@{ x = $treeCursor.X; y = $treeCursor.Y }
407
+ foregroundTitle = [DshInput]::ForegroundTitle()
408
+ foregroundClass = [DshInput]::ForegroundClass()
409
+ foregroundProcess = Get-ProcessNameSafe ([uint32] [DshInput]::ForegroundPid())
410
+ controls = $lines
411
+ controlCount = $tree.nodes.Count
412
+ controlsTruncated = [bool] $tree.truncated
413
+ controlsWindow = [DshInput]::WindowTitle($treeHandle)
414
+ notes = $notes
415
+ actedAt = [DateTimeOffset]::Now.ToString('o')
416
+ }
417
+ return $result
418
+ }
419
+ 'focus' { # A refused hand-off used to be a note: the step reported ok and the batch
308
420
  # carried on, so every later click and keystroke went to whatever window was
309
421
  # actually in front. That is the one failure this plugin must not report as
310
422
  # success, so it is an error now; tolerant keeps the old behaviour for a
@@ -0,0 +1,156 @@
1
+ # pilot / _dsh-uia.ps1 - read a window's UI Automation tree, and resolve a node by index.
2
+ #
3
+ # Why this exists: screen_view returns pixels. Finding a control in pixels costs the
4
+ # model a guess at coordinates plus another frame to confirm the guess. UI Automation
5
+ # hands over the control's role, name and bounding rectangle directly, so a click
6
+ # becomes 'the centre of this rect' instead of 'where I think that button is'.
7
+ #
8
+ # The read uses FindAll with a CacheRequest, not TreeWalker. Measured on a 674-node
9
+ # window: TreeWalker ignores the cache request and pays six or seven cross-process
10
+ # reads per node (~2.7 s), while FindAll populates every property in one call (~270 ms).
11
+ # The price is that a flat list has no parent/child structure, so a node is addressed
12
+ # by its position in that list rather than by a path.
13
+ #
14
+ # Nothing is kept between calls: an index only means anything against the listing it
15
+ # came from, and re-reading is the only way to find out that it changed.
16
+
17
+ $script:DshUiaReady = $false
18
+
19
+ function Initialize-DshUia {
20
+ if ($script:DshUiaReady) { return }
21
+ Add-Type -AssemblyName UIAutomationClient
22
+ Add-Type -AssemblyName UIAutomationTypes
23
+ $script:DshUiaReady = $true
24
+ }
25
+
26
+ <# Read one window's descendants. Every node carries the index that addresses it. #>
27
+ function Get-DshUiaNodes {
28
+ param(
29
+ [Parameter(Mandatory = $true)][IntPtr] $Handle,
30
+ [int] $MaxNodes = 2000,
31
+ [bool] $IncludeValues = $false,
32
+ [bool] $MeaningfulOnly = $true
33
+ )
34
+
35
+ Initialize-DshUia
36
+ $AE = [System.Windows.Automation.AutomationElement]
37
+
38
+ $root = $AE::FromHandle($Handle)
39
+ if ($null -eq $root) { return [ordered]@{ ok = $false; error = 'the window exposes no automation element'; nodes = @(); truncated = $false; total = 0 } }
40
+
41
+ $cache = New-Object System.Windows.Automation.CacheRequest
42
+ foreach ($prop in @(
43
+ $AE::ControlTypeProperty, $AE::NameProperty, $AE::BoundingRectangleProperty,
44
+ $AE::IsEnabledProperty, $AE::IsOffscreenProperty, $AE::IsPasswordProperty
45
+ )) { $cache.Add($prop) }
46
+
47
+ $collection = $null
48
+ $cache.Push()
49
+ try {
50
+ $collection = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, [System.Windows.Automation.Condition]::TrueCondition)
51
+ } catch {
52
+ return [ordered]@{ ok = $false; error = ('the tree could not be read: ' + $_.Exception.Message); nodes = @(); truncated = $false; total = 0 }
53
+ } finally {
54
+ $cache.Pop()
55
+ }
56
+
57
+ $total = $collection.Count
58
+ $limit = $total
59
+ $truncated = $false
60
+ if ($limit -gt $MaxNodes) { $limit = $MaxNodes; $truncated = $true }
61
+
62
+ # Roles a person can operate. Measured on a 673-element window: the filtered
63
+ $interactiveRoles = @('Button','Edit','ComboBox','ListItem','MenuItem','TabItem','CheckBox','RadioButton','Hyperlink','SplitButton','Slider','Spinner','TreeItem','DataItem')
64
+ $bulkRoles = @('DataItem','TreeItem','ListItem','MenuItem')
65
+
66
+ $nodes = New-Object System.Collections.Generic.List[object]
67
+ for ($i = 0; $i -lt $limit; $i++) {
68
+ $el = $collection[$i]
69
+ try {
70
+ $r = $el.Cached.BoundingRectangle
71
+ $left = 0; $top = 0; $w = 0; $h = 0; $hasRect = $false
72
+ if (-not $r.IsEmpty) { $left = [int] $r.X; $top = [int] $r.Y; $w = [int] $r.Width; $h = [int] $r.Height; $hasRect = $true }
73
+ $value = $null
74
+ if ($IncludeValues) {
75
+ if ($el.Cached.IsPassword) {
76
+ $value = '<password field>'
77
+ } else {
78
+ try {
79
+ $vp = $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern)
80
+ if ($null -ne $vp) { $value = [string] $vp.Current.Value }
81
+ } catch { }
82
+ }
83
+ }
84
+ $role = ($el.Cached.ControlType.ProgrammaticName -replace 'ControlType\.', '')
85
+ if ($MeaningfulOnly) {
86
+ if ($interactiveRoles -notcontains $role) { continue }
87
+ # A bulk container row with no name is addressable by index but carries
88
+ # nothing to reason about, and they are the bulk of a large list.
89
+ if ($bulkRoles -contains $role -and $name.Length -eq 0) { continue }
90
+ }
91
+ $nodes.Add([ordered]@{
92
+ index = $i
93
+ role = ($el.Cached.ControlType.ProgrammaticName -replace 'ControlType\.', '')
94
+ name = [string] $el.Cached.Name
95
+ left = $left; top = $top; width = $w; height = $h; hasRect = $hasRect
96
+ enabled = [bool] $el.Cached.IsEnabled
97
+ offscreen = [bool] $el.Cached.IsOffscreen
98
+ value = $value
99
+ })
100
+ } catch {
101
+ # A provider that throws mid-read must not abort the whole listing.
102
+ }
103
+ }
104
+
105
+ return [ordered]@{ ok = $true; nodes = $nodes; truncated = $truncated; total = $total }
106
+ }
107
+
108
+ <# Render the nodes as the text the model reads. One line per control. #>
109
+ function Format-DshUiaNodes {
110
+ param([object] $Nodes, [bool] $Truncated, [int] $Total)
111
+
112
+ $lines = New-Object System.Collections.Generic.List[string]
113
+ foreach ($n in $Nodes) {
114
+ $name = [string] $n.name
115
+ if ($name.Length -gt 200) { $name = $name.Substring(0, 200) + '...' }
116
+ $rect = '(no rect)'
117
+ if ($n.hasRect) { $rect = ('{0},{1} {2}x{3}' -f $n.left, $n.top, $n.width, $n.height) }
118
+ $bits = @()
119
+ if (-not $n.enabled) { $bits += 'disabled' }
120
+ if ($n.offscreen) { $bits += 'offscreen' }
121
+ if ($null -ne $n.value) { $bits += ('value=' + ($n.value -replace '\s+', ' ')) }
122
+ $tail = ''
123
+ if ($bits.Count -gt 0) { $tail = ' ' + ($bits -join ' ') }
124
+ $lines.Add(('{0} [{1}] ''{2}'' @ {3}{4}' -f $n.index, $n.role, $name, $rect, $tail))
125
+ }
126
+ if ($Truncated) { $lines.Add(('... truncated at the node cap ({0} elements in total)' -f $Total)) }
127
+ return $lines.ToArray()
128
+ }
129
+
130
+ <# Resolve an index back to an element by re-reading the same listing. #>
131
+ function Resolve-DshUiaIndex {
132
+ param([Parameter(Mandatory = $true)][IntPtr] $Handle, [Parameter(Mandatory = $true)][int] $Index)
133
+
134
+ Initialize-DshUia
135
+ $AE = [System.Windows.Automation.AutomationElement]
136
+ $root = $AE::FromHandle($Handle)
137
+ if ($null -eq $root) { return $null }
138
+ $all = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, [System.Windows.Automation.Condition]::TrueCondition)
139
+ if ($Index -lt 0 -or $Index -ge $all.Count) { return $null }
140
+ return $all[$Index]
141
+ }
142
+
143
+ <# Centre of an element's rectangle, or $null when it has no on-screen box. #>
144
+ function Get-DshUiaCentre {
145
+ param([Parameter(Mandatory = $true)][object] $Element)
146
+ try {
147
+ $r = $Element.Current.BoundingRectangle
148
+ if ($r.IsEmpty) { return $null }
149
+ if ($r.Width -le 0 -or $r.Height -le 0) { return $null }
150
+ return [pscustomobject]@{
151
+ x = [int] ($r.X + $r.Width / 2)
152
+ y = [int] ($r.Y + $r.Height / 2)
153
+ left = [int] $r.X; top = [int] $r.Y; width = [int] $r.Width; height = [int] $r.Height
154
+ }
155
+ } catch { return $null }
156
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momasiku/dsh-pilot",
3
- "version": "0.5.10",
3
+ "version": "0.5.11",
4
4
  "description": "Desktop vision and control for DeepSeek Harness - the whole Windows machine, any application, not browser automation: screen_view captures the live screen as an image the model can see, desktop_control drives the mouse, keyboard, and windows against the exact same pixel space; desktop_sequence runs a whole batch of actions in one call and looks once at the end.",
5
5
  "license": "MIT",
6
6
  "author": "momasiku",