@xrkseek/harness 0.4.1 → 0.4.3

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.
Files changed (79) hide show
  1. package/node_modules/@xrkseek/core-agent-loop/dist/index.d.ts +6 -0
  2. package/node_modules/@xrkseek/core-agent-loop/dist/index.d.ts.map +1 -1
  3. package/node_modules/@xrkseek/core-agent-loop/dist/index.js +3 -0
  4. package/node_modules/@xrkseek/core-agent-loop/dist/index.js.map +1 -1
  5. package/node_modules/@xrkseek/core-agent-loop/dist/tool-result-bound.d.ts +7 -2
  6. package/node_modules/@xrkseek/core-agent-loop/dist/tool-result-bound.d.ts.map +1 -1
  7. package/node_modules/@xrkseek/core-agent-loop/dist/tool-result-bound.js +7 -3
  8. package/node_modules/@xrkseek/core-agent-loop/dist/tool-result-bound.js.map +1 -1
  9. package/node_modules/@xrkseek/core-session/dist/compaction.d.ts +9 -2
  10. package/node_modules/@xrkseek/core-session/dist/compaction.d.ts.map +1 -1
  11. package/node_modules/@xrkseek/core-session/dist/compaction.js +9 -2
  12. package/node_modules/@xrkseek/core-session/dist/compaction.js.map +1 -1
  13. package/node_modules/@xrkseek/core-session/dist/drain-hub.d.ts +2 -2
  14. package/node_modules/@xrkseek/core-session/dist/drain-hub.d.ts.map +1 -1
  15. package/node_modules/@xrkseek/core-session/dist/drain-hub.js +2 -2
  16. package/node_modules/@xrkseek/core-session/dist/drain-hub.js.map +1 -1
  17. package/node_modules/@xrkseek/core-session/dist/index.d.ts +1 -1
  18. package/node_modules/@xrkseek/core-session/dist/index.d.ts.map +1 -1
  19. package/node_modules/@xrkseek/core-session/dist/index.js.map +1 -1
  20. package/node_modules/@xrkseek/core-session/dist/latch.d.ts +25 -3
  21. package/node_modules/@xrkseek/core-session/dist/latch.d.ts.map +1 -1
  22. package/node_modules/@xrkseek/core-session/dist/latch.js +33 -12
  23. package/node_modules/@xrkseek/core-session/dist/latch.js.map +1 -1
  24. package/node_modules/@xrkseek/exec-computer-use/dist/index.d.ts +2 -2
  25. package/node_modules/@xrkseek/exec-computer-use/dist/index.d.ts.map +1 -1
  26. package/node_modules/@xrkseek/exec-computer-use/dist/index.js +2 -2
  27. package/node_modules/@xrkseek/exec-computer-use/dist/index.js.map +1 -1
  28. package/node_modules/@xrkseek/exec-computer-use/dist/keys.d.ts +5 -0
  29. package/node_modules/@xrkseek/exec-computer-use/dist/keys.d.ts.map +1 -1
  30. package/node_modules/@xrkseek/exec-computer-use/dist/keys.js +10 -0
  31. package/node_modules/@xrkseek/exec-computer-use/dist/keys.js.map +1 -1
  32. package/node_modules/@xrkseek/exec-computer-use/dist/windows-uia.d.ts +15 -0
  33. package/node_modules/@xrkseek/exec-computer-use/dist/windows-uia.d.ts.map +1 -1
  34. package/node_modules/@xrkseek/exec-computer-use/dist/windows-uia.js +379 -122
  35. package/node_modules/@xrkseek/exec-computer-use/dist/windows-uia.js.map +1 -1
  36. package/node_modules/@xrkseek/exec-shell/dist/index.d.ts +8 -1
  37. package/node_modules/@xrkseek/exec-shell/dist/index.d.ts.map +1 -1
  38. package/node_modules/@xrkseek/exec-shell/dist/index.js +3 -1
  39. package/node_modules/@xrkseek/exec-shell/dist/index.js.map +1 -1
  40. package/node_modules/@xrkseek/preset-harness/dist/tsconfig.tsbuildinfo +1 -1
  41. package/node_modules/@xrkseek/preset-minimal/dist/tsconfig.tsbuildinfo +1 -1
  42. package/node_modules/@xrkseek/preset-server/dist/tsconfig.tsbuildinfo +1 -1
  43. package/node_modules/@xrkseek/server-face/dist/context.d.ts +2 -2
  44. package/node_modules/@xrkseek/server-face/dist/context.d.ts.map +1 -1
  45. package/node_modules/@xrkseek/server-face/dist/handlers/session.d.ts.map +1 -1
  46. package/node_modules/@xrkseek/server-face/dist/handlers/session.js +31 -5
  47. package/node_modules/@xrkseek/server-face/dist/handlers/session.js.map +1 -1
  48. package/node_modules/@xrkseek/server-face/dist/subagent-tools.d.ts.map +1 -1
  49. package/node_modules/@xrkseek/server-face/dist/subagent-tools.js +32 -1
  50. package/node_modules/@xrkseek/server-face/dist/subagent-tools.js.map +1 -1
  51. package/node_modules/@xrkseek/server-host/dist/drain-status.d.ts +2 -2
  52. package/node_modules/@xrkseek/server-host/dist/drain-status.d.ts.map +1 -1
  53. package/node_modules/@xrkseek/server-host/dist/drain-status.js +2 -2
  54. package/node_modules/@xrkseek/server-host/dist/drain-status.js.map +1 -1
  55. package/node_modules/@xrkseek/server-host/dist/index.d.ts +16 -0
  56. package/node_modules/@xrkseek/server-host/dist/index.d.ts.map +1 -1
  57. package/node_modules/@xrkseek/server-host/dist/index.js +91 -6
  58. package/node_modules/@xrkseek/server-host/dist/index.js.map +1 -1
  59. package/node_modules/@xrkseek/server-http/dist/dsh-compat/audit-community-client.d.ts +13 -0
  60. package/node_modules/@xrkseek/server-http/dist/dsh-compat/audit-community-client.d.ts.map +1 -1
  61. package/node_modules/@xrkseek/server-http/dist/dsh-compat/audit-community-client.js +24 -0
  62. package/node_modules/@xrkseek/server-http/dist/dsh-compat/audit-community-client.js.map +1 -1
  63. package/node_modules/@xrkseek/server-http/dist/dsh-compat/dsh-client-scan.d.ts +1 -0
  64. package/node_modules/@xrkseek/server-http/dist/dsh-compat/dsh-client-scan.d.ts.map +1 -1
  65. package/node_modules/@xrkseek/server-http/dist/dsh-compat/dsh-client-scan.js +18 -2
  66. package/node_modules/@xrkseek/server-http/dist/dsh-compat/dsh-client-scan.js.map +1 -1
  67. package/node_modules/@xrkseek/server-http/dist/dsh-compat/dsh-compat-matrix.d.ts +6 -0
  68. package/node_modules/@xrkseek/server-http/dist/dsh-compat/dsh-compat-matrix.d.ts.map +1 -1
  69. package/node_modules/@xrkseek/server-http/dist/dsh-compat/dsh-compat-matrix.js +26 -1
  70. package/node_modules/@xrkseek/server-http/dist/dsh-compat/dsh-compat-matrix.js.map +1 -1
  71. package/node_modules/@xrkseek/spill/dist/index.d.ts +1 -1
  72. package/node_modules/@xrkseek/spill/dist/index.d.ts.map +1 -1
  73. package/node_modules/@xrkseek/spill/dist/index.js +1 -1
  74. package/node_modules/@xrkseek/spill/dist/index.js.map +1 -1
  75. package/node_modules/@xrkseek/spill/dist/policy.d.ts +35 -0
  76. package/node_modules/@xrkseek/spill/dist/policy.d.ts.map +1 -1
  77. package/node_modules/@xrkseek/spill/dist/policy.js +45 -4
  78. package/node_modules/@xrkseek/spill/dist/policy.js.map +1 -1
  79. package/package.json +1 -1
@@ -1,17 +1,79 @@
1
1
  /**
2
2
  * Windows UI Automation Provider — AX snapshot + Invoke/ValuePattern input.
3
- * Delivery is "uia" (not cua-driver background SPI). Opt-in via XRK_COMPUTER_USE=1.
3
+ * Delivery is "uia" (not cua-driver background SPI). Opt-in via XRK_COMPUTER_USE=1
4
+ * or Settings → Plugins → Computer use (mode=uia).
4
5
  */
5
6
  import { spawn } from "node:child_process";
7
+ import { existsSync } from "node:fs";
8
+ import { join } from "node:path";
6
9
  import { buildCaptureResult, DEFAULT_MAX_ELEMENTS } from "./format.js";
7
- import { mapKeysToSendKeys } from "./keys.js";
10
+ import { escapeSendKeys, mapKeysToSendKeys } from "./keys.js";
8
11
  import { ComputerUseError, } from "./types.js";
9
- async function defaultRunPowerShell(script, signal) {
12
+ /** UIA calls block forever when the target app stops pumping messages. */
13
+ export const DEFAULT_POWERSHELL_TIMEOUT_MS = 25_000;
14
+ /**
15
+ * Windows PowerShell 5.1 writes stdout in the OEM codepage (GBK on zh-CN), which
16
+ * mangles every CJK `Name` when decoded as UTF-8. The prelude pins both streams
17
+ * to UTF-8 so the JSON payload survives round-trip.
18
+ */
19
+ const PS_PRELUDE = `
20
+ $ErrorActionPreference = 'Stop'
21
+ try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}
22
+ try { [Console]::ErrorEncoding = [System.Text.Encoding]::UTF8 } catch {}
23
+ Add-Type -AssemblyName UIAutomationClient
24
+ Add-Type -AssemblyName UIAutomationTypes
25
+ function XrkSafeInt {
26
+ param([double]$Value)
27
+ if ([double]::IsNaN($Value) -or [double]::IsInfinity($Value)) { return $null }
28
+ if ($Value -lt [int]::MinValue -or $Value -gt [int]::MaxValue) { return $null }
29
+ return [int][Math]::Round($Value, [System.MidpointRounding]::AwayFromZero)
30
+ }
31
+ function XrkTopWindows {
32
+ $cond = New-Object System.Windows.Automation.PropertyCondition(
33
+ [System.Windows.Automation.AutomationElement]::ControlTypeProperty,
34
+ [System.Windows.Automation.ControlType]::Window)
35
+ return [System.Windows.Automation.AutomationElement]::RootElement.FindAll(
36
+ [System.Windows.Automation.TreeScope]::Children, $cond)
37
+ }
38
+ function XrkProcName([int]$targetPid) {
39
+ try { return (Get-Process -Id $targetPid -ErrorAction SilentlyContinue).ProcessName } catch { return '' }
40
+ }
41
+ function XrkMatch([string]$hay, [string]$needle) {
42
+ if ([string]::IsNullOrWhiteSpace($needle)) { return $true }
43
+ if ([string]::IsNullOrEmpty($hay)) { return $false }
44
+ return ($hay.IndexOf($needle, [System.StringComparison]::OrdinalIgnoreCase) -ge 0)
45
+ }
46
+ `;
47
+ /**
48
+ * `powershell.exe` is resolvable only when the parent process inherited the
49
+ * WindowsPowerShell directory on PATH. A GUI/service parent (Desktop host, a
50
+ * vitest worker) often has a trimmed PATH and the spawn then dies with ENOENT,
51
+ * which reads to the model as "computer use is broken". Windows PowerShell 5.1
52
+ * is deliberate: the UIAutomation client assemblies ship with it, `pwsh` 7 has
53
+ * them only if the optional module is installed.
54
+ */
55
+ function powerShellExe() {
56
+ const root = process.env["SystemRoot"] ?? process.env["WINDIR"] ?? "C:\\Windows";
57
+ const fixed = join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
58
+ return existsSync(fixed) ? fixed : "powershell.exe";
59
+ }
60
+ async function defaultRunPowerShell(script, signal, timeoutMs = DEFAULT_POWERSHELL_TIMEOUT_MS) {
10
61
  return await new Promise((resolve, reject) => {
11
- const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { windowsHide: true });
62
+ const child = spawn(powerShellExe(), ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { windowsHide: true });
12
63
  let stdout = "";
13
64
  let stderr = "";
65
+ let settled = false;
66
+ const timer = setTimeout(() => {
67
+ if (settled)
68
+ return;
69
+ settled = true;
70
+ child.kill();
71
+ reject(new ComputerUseError(`UIA script timed out after ${timeoutMs}ms (target app may be busy or not pumping UIA messages)`, "COMPUTER_USE_BACKEND"));
72
+ }, Math.max(1_000, timeoutMs));
14
73
  const onAbort = () => {
74
+ if (settled)
75
+ return;
76
+ settled = true;
15
77
  child.kill();
16
78
  reject(new ComputerUseError("aborted", "COMPUTER_USE_BACKEND"));
17
79
  };
@@ -23,13 +85,22 @@ async function defaultRunPowerShell(script, signal) {
23
85
  stderr += chunk.toString("utf8");
24
86
  });
25
87
  child.on("error", (err) => {
88
+ if (settled)
89
+ return;
90
+ settled = true;
91
+ clearTimeout(timer);
26
92
  signal?.removeEventListener("abort", onAbort);
27
93
  reject(new ComputerUseError(`powershell failed: ${err.message}`, "COMPUTER_USE_BACKEND"));
28
94
  });
29
95
  child.on("close", (code) => {
96
+ if (settled)
97
+ return;
98
+ settled = true;
99
+ clearTimeout(timer);
30
100
  signal?.removeEventListener("abort", onAbort);
31
101
  if (code !== 0) {
32
- reject(new ComputerUseError(`powershell exit ${code}: ${stderr.trim() || stdout.trim() || "no output"}`, "COMPUTER_USE_BACKEND"));
102
+ const detail = (stderr.trim() || stdout.trim() || "no output").slice(-600);
103
+ reject(new ComputerUseError(`powershell exit ${code}: ${detail}`, "COMPUTER_USE_BACKEND"));
33
104
  return;
34
105
  }
35
106
  resolve(stdout.trim());
@@ -41,74 +112,162 @@ function parseJson(raw, label) {
41
112
  return JSON.parse(raw);
42
113
  }
43
114
  catch {
44
- throw new ComputerUseError(`invalid ${label} JSON from UIA backend`, "COMPUTER_USE_BACKEND");
115
+ const snippet = raw.trim().slice(0, 240) || "(empty stdout)";
116
+ throw new ComputerUseError(`invalid ${label} JSON from UIA backend: ${snippet}`, "COMPUTER_USE_BACKEND");
45
117
  }
46
118
  }
47
- /** Compact PowerShell: walk focused (or named) window for ControlType+Name. */
48
- function captureScript(appFilter, maxElements) {
49
- const appLit = JSON.stringify(appFilter ?? "");
50
- return `
51
- Add-Type -AssemblyName UIAutomationClient
52
- Add-Type -AssemblyName UIAutomationTypes
53
- $ErrorActionPreference = 'Stop'
119
+ /**
120
+ * Embed a string as a PowerShell single-quoted literal. `JSON.stringify` emits
121
+ * double quotes, where PowerShell expands `$` and a stray `'` breaks the script.
122
+ */
123
+ function psStr(value) {
124
+ return `'${value.replace(/'/g, "''")}'`;
125
+ }
126
+ /**
127
+ * Walk one top-level window (by app/title match, else the focused window) and
128
+ * emit ControlType+Name elements as JSON.
129
+ */
130
+ export function captureScript(options = {}) {
131
+ const appLit = psStr(options.app?.trim() ?? "");
132
+ const max = Math.max(1, Math.floor(options.maxElements ?? DEFAULT_MAX_ELEMENTS));
133
+ const keepOffscreen = options.includeOffscreen ? "$true" : "$false";
134
+ return `${PS_PRELUDE}
54
135
  $appFilter = ${appLit}
55
- $max = ${maxElements}
56
- $root = [System.Windows.Automation.AutomationElement]::FocusedElement
57
- if (-not $root) { $root = [System.Windows.Automation.AutomationElement]::RootElement }
136
+ $max = ${max}
137
+ $keepOffscreen = ${keepOffscreen}
138
+ $maxDepth = 60
139
+ # Cross-process UIA calls are expensive; bound the walk so a giant browser tree
140
+ # cannot outrun the PowerShell timeout.
141
+ $visitCap = [Math]::Max(300, $max * 12 + 200)
58
142
  $walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
59
- $win = $root
60
- while ($win -and $win.Current.ControlType -ne [System.Windows.Automation.ControlType]::Window) {
61
- $parent = $walker.GetParent($win)
62
- if (-not $parent) { break }
63
- $win = $parent
64
- }
65
- if (-not $win) { $win = $root }
66
- $title = $win.Current.Name
67
- $app = $win.Current.Name
68
- if ($appFilter -ne '' -and $title -notlike "*$appFilter*" -and $app -notlike "*$appFilter*") {
69
- $cond = New-Object System.Windows.Automation.PropertyCondition(
70
- [System.Windows.Automation.AutomationElement]::NameProperty, $appFilter)
71
- $found = [System.Windows.Automation.AutomationElement]::RootElement.FindFirst(
72
- [System.Windows.Automation.TreeScope]::Children, $cond)
73
- if ($found) { $win = $found; $title = $win.Current.Name; $app = $win.Current.Name }
143
+
144
+ $focusedWin = $null
145
+ try {
146
+ $fe = [System.Windows.Automation.AutomationElement]::FocusedElement
147
+ $cur = $fe
148
+ while ($cur -and $cur.Current.ControlType -ne [System.Windows.Automation.ControlType]::Window) {
149
+ $p = $walker.GetParent($cur)
150
+ if (-not $p) { break }
151
+ $cur = $p
152
+ }
153
+ if ($cur) { $focusedWin = $cur }
154
+ } catch {}
155
+
156
+ $all = XrkTopWindows
157
+ $win = $null
158
+ $byProc = $null
159
+ $byTitle = $null
160
+ $candidates = New-Object System.Collections.Generic.List[string]
161
+ foreach ($w in $all) {
162
+ $t = ''
163
+ try { $t = $w.Current.Name } catch {}
164
+ $pn = XrkProcName $w.Current.ProcessId
165
+ $cn = ''
166
+ try { $cn = $w.Current.ClassName } catch {}
167
+ if (-not [string]::IsNullOrWhiteSpace($t)) { $candidates.Add($t + ' [' + $pn + ']') }
168
+ if ($appFilter -eq '') {
169
+ if (-not $win -and $focusedWin -and $w.Current.NativeWindowHandle -eq $focusedWin.Current.NativeWindowHandle) {
170
+ $win = $w
171
+ } elseif (-not $win -and -not [string]::IsNullOrWhiteSpace($t)) {
172
+ $win = $w
173
+ }
174
+ continue
175
+ }
176
+ # A process/class hit beats a title that merely mentions the app (a browser tab
177
+ # titled "...QQ..." must not shadow the real QQ window).
178
+ if (-not $byProc -and ((XrkMatch $pn $appFilter) -or (XrkMatch $cn $appFilter))) { $byProc = $w }
179
+ if (-not $byTitle -and (XrkMatch $t $appFilter)) { $byTitle = $w }
180
+ }
181
+ if ($appFilter -ne '' -and -not $win) {
182
+ if ($focusedWin) {
183
+ $ft = ''
184
+ try { $ft = $focusedWin.Current.Name } catch {}
185
+ $fpn = XrkProcName $focusedWin.Current.ProcessId
186
+ if ((XrkMatch $fpn $appFilter) -or (XrkMatch $ft $appFilter)) { $win = $focusedWin }
187
+ }
188
+ if (-not $win) { $win = $byProc }
189
+ if (-not $win) { $win = $byTitle }
190
+ }
191
+ if (-not $win) {
192
+ @{ error = 'app-not-found'; app = ''; windowTitle = ''; elements = @(); candidates = $candidates } | ConvertTo-Json -Depth 6 -Compress
193
+ return
74
194
  }
195
+
196
+ $title = ''
197
+ try { $title = $win.Current.Name } catch {}
198
+ $targetPid = $win.Current.ProcessId
199
+ $appLabel = XrkProcName $targetPid
200
+ if ([string]::IsNullOrWhiteSpace($appLabel)) { $appLabel = $title }
201
+
75
202
  $elements = New-Object System.Collections.Generic.List[object]
76
- $stack = New-Object System.Collections.Stack
77
- $stack.Push($win)
203
+ $seen = New-Object System.Collections.Generic.HashSet[string]
204
+ $queue = New-Object System.Collections.Generic.Queue[object]
205
+ $queue.Enqueue(@{ el = $win; d = 0 })
78
206
  $idx = 0
79
- while ($stack.Count -gt 0 -and $idx -lt $max) {
80
- $el = $stack.Pop()
81
- $child = $walker.GetFirstChild($el)
82
- while ($child) {
83
- $stack.Push($child)
84
- $child = $walker.GetNextSibling($child)
207
+ $visited = 0
208
+ $dropped = 0
209
+ $exhausted = $true
210
+ while ($queue.Count -gt 0 -and $idx -lt $max -and $visited -lt $visitCap) {
211
+ $item = $queue.Dequeue()
212
+ $el = $item.el
213
+ $depth = $item.d
214
+ $visited++
215
+ $rid = ''
216
+ try { $rid = ($el.GetRuntimeId() -join ',') } catch {}
217
+ if ($rid -ne '' -and -not $seen.Add($rid)) { continue }
218
+ $ct = ''
219
+ try { $ct = $el.Current.ControlType.ProgrammaticName } catch {}
220
+ $name = ''
221
+ try { $name = $el.Current.Name } catch {}
222
+ # Always expand (bounded by depth): gating expansion on $depth -gt 0 meant the
223
+ # root window never yielded children and every capture came back empty.
224
+ if ($depth -lt $maxDepth) {
225
+ try {
226
+ $child = $walker.GetFirstChild($el)
227
+ $guard = 0
228
+ while ($child -and $guard -lt 500 -and ($visited + $guard) -lt $visitCap) {
229
+ $queue.Enqueue(@{ el = $child; d = ($depth + 1) })
230
+ $guard++
231
+ $child = $walker.GetNextSibling($child)
232
+ }
233
+ } catch { $dropped++ }
85
234
  }
86
- $ct = $el.Current.ControlType.ProgrammaticName
87
- $name = $el.Current.Name
88
- if ([string]::IsNullOrWhiteSpace($name) -and [string]::IsNullOrWhiteSpace($ct)) { continue }
89
- if ($ct -eq 'ControlType.Window' -or $ct -eq 'ControlType.Pane') { continue }
90
- $idx++
91
- $r = $el.Current.BoundingRectangle
92
- $elements.Add([pscustomobject]@{
93
- index = $idx
94
- role = ($ct -replace '^ControlType\\.','')
95
- name = ($(if ($name) { $name } else { $ct }))
96
- x = [int]$r.X; y = [int]$r.Y; width = [int]$r.Width; height = [int]$r.Height
97
- runtimeId = ($el.GetRuntimeId() -join ',')
98
- })
99
- }
100
- @{ app = $app; windowTitle = $title; elements = $elements } | ConvertTo-Json -Depth 5 -Compress
235
+ try {
236
+ if ($el.Current.ProcessId -ne $targetPid) { continue }
237
+ $role = ($ct -replace '^ControlType\\.','')
238
+ if ($depth -eq 0) { continue }
239
+ if (($role -eq 'Window' -or $role -eq 'Pane') -and [string]::IsNullOrWhiteSpace($name)) { continue }
240
+ $r = $el.Current.BoundingRectangle
241
+ $x = XrkSafeInt $r.X
242
+ $y = XrkSafeInt $r.Y
243
+ $wd = XrkSafeInt $r.Width
244
+ $ht = XrkSafeInt $r.Height
245
+ $offscreen = ($null -eq $x -or $null -eq $y -or $null -eq $wd -or $null -eq $ht -or $wd -le 0 -or $ht -le 0)
246
+ if ($offscreen -and -not $keepOffscreen) { continue }
247
+ if ([string]::IsNullOrWhiteSpace($name) -and [string]::IsNullOrWhiteSpace($role)) { continue }
248
+ $idx++
249
+ $display = $name
250
+ if ([string]::IsNullOrWhiteSpace($display)) { $display = $ct }
251
+ $elements.Add([pscustomobject]@{
252
+ index = $idx
253
+ role = $role
254
+ name = $display
255
+ x = $x; y = $y; width = $wd; height = $ht
256
+ runtimeId = $rid
257
+ })
258
+ } catch { $dropped++ }
259
+ }
260
+ if ($queue.Count -gt 0 -and $visited -ge $visitCap) { $exhausted = $false }
261
+ @{ app = $appLabel; windowTitle = $title; elements = $elements; visited = $visited; dropped = $dropped; exhausted = $exhausted } | ConvertTo-Json -Depth 6 -Compress
101
262
  `.trim();
102
263
  }
264
+ /** Re-resolve an element by runtime id; Invoke → Select → focus + mouse click. */
103
265
  function clickScript(runtimeId) {
104
- const idLit = JSON.stringify(runtimeId);
105
- return `
106
- Add-Type -AssemblyName UIAutomationClient
107
- Add-Type -AssemblyName UIAutomationTypes
108
- $ErrorActionPreference = 'Stop'
266
+ const idLit = psStr(runtimeId);
267
+ return `${PS_PRELUDE}
109
268
  $ids = @((${idLit}) -split ',' | ForEach-Object { [int]$_ })
110
269
  $el = [System.Windows.Automation.AutomationElement]::AutomationElementFromRuntimeId($ids)
111
- if (-not $el) { throw 'element not found' }
270
+ if (-not $el) { throw 'element not found (window changed? capture again)' }
112
271
  $inv = $null
113
272
  if ($el.TryGetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern, [ref]$inv)) {
114
273
  $inv.Invoke()
@@ -121,71 +280,116 @@ if ($el.TryGetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::P
121
280
  'clicked-select'
122
281
  return
123
282
  }
124
- throw 'element is not invokable via UIA'
283
+ $box = $el.Current.BoundingRectangle
284
+ if ($null -eq (XrkSafeInt $box.X) -or $null -eq (XrkSafeInt $box.Y)) {
285
+ throw 'element is not invokable via UIA and has no on-screen bounds to click'
286
+ }
287
+ Add-Type -AssemblyName System.Windows.Forms
288
+ Add-Type -AssemblyName System.Drawing
289
+ Add-Type @"
290
+ using System;
291
+ using System.Runtime.InteropServices;
292
+ public static class XrkClick {
293
+ [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
294
+ [DllImport("user32.dll")] public static extern void mouse_event(uint f, uint dx, uint dy, int data, UIntPtr extra);
295
+ public const uint LEFTDOWN = 0x0002;
296
+ public const uint LEFTUP = 0x0004;
297
+ }
298
+ "@
299
+ try { $el.SetFocus() } catch {}
300
+ Start-Sleep -Milliseconds 40
301
+ $r = $el.Current.BoundingRectangle
302
+ $x = XrkSafeInt ($r.X + $r.Width / 2)
303
+ $y = XrkSafeInt ($r.Y + $r.Height / 2)
304
+ [void][XrkClick]::SetCursorPos($x, $y)
305
+ [void][XrkClick]::mouse_event([XrkClick]::LEFTDOWN, 0, 0, 0, [UIntPtr]::Zero)
306
+ Start-Sleep -Milliseconds 30
307
+ [void][XrkClick]::mouse_event([XrkClick]::LEFTUP, 0, 0, 0, [UIntPtr]::Zero)
308
+ 'clicked-point'
125
309
  `.trim();
126
310
  }
311
+ /**
312
+ * ValuePattern first, then clipboard paste (CJK-safe), then escaped SendKeys.
313
+ * Raw SendKeys mangles `+^%~()` and drops non-ASCII, so the caller passes both
314
+ * the literal text and a SendKeys-escaped copy.
315
+ */
127
316
  function typeScript(runtimeId, text) {
128
- const idLit = JSON.stringify(runtimeId);
129
- const textLit = JSON.stringify(text);
130
- return `
131
- Add-Type -AssemblyName UIAutomationClient
132
- Add-Type -AssemblyName UIAutomationTypes
317
+ const idLit = psStr(runtimeId);
318
+ const textLit = psStr(text);
319
+ const keysLit = psStr(escapeSendKeys(text));
320
+ return `${PS_PRELUDE}
133
321
  Add-Type -AssemblyName System.Windows.Forms
134
- $ErrorActionPreference = 'Stop'
322
+ $text = ${textLit}
323
+ $escaped = ${keysLit}
135
324
  $ids = @((${idLit}) -split ',' | ForEach-Object { [int]$_ })
136
325
  $el = [System.Windows.Automation.AutomationElement]::AutomationElementFromRuntimeId($ids)
137
- if (-not $el) { throw 'element not found' }
326
+ if (-not $el) { throw 'element not found (window changed? capture again)' }
138
327
  $val = $null
139
328
  if ($el.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$val)) {
140
- $val.SetValue(${textLit})
141
- 'typed-value'
142
- return
329
+ try {
330
+ if (-not $val.Current.IsReadOnly) {
331
+ $val.SetValue($text)
332
+ 'typed-value'
333
+ return
334
+ }
335
+ } catch {}
336
+ }
337
+ try { $el.SetFocus() } catch {}
338
+ Start-Sleep -Milliseconds 40
339
+ $pasted = $false
340
+ $old = $null
341
+ $clipReadable = $false
342
+ try { $old = Get-Clipboard -ErrorAction Stop; $clipReadable = $true } catch {
343
+ try { $old = Get-Clipboard -Raw -ErrorAction Stop; $clipReadable = $true } catch {}
344
+ }
345
+ try {
346
+ Set-Clipboard -Value $text
347
+ [System.Windows.Forms.SendKeys]::SendWait('^v')
348
+ Start-Sleep -Milliseconds 30
349
+ $pasted = $true
350
+ } catch {}
351
+ if ($pasted -and $clipReadable) {
352
+ try {
353
+ if ($null -ne $old) { Set-Clipboard -Value ($old -join [Environment]::NewLine) } else { Set-Clipboard -Value '' }
354
+ } catch {}
355
+ }
356
+ if ($pasted) { 'typed-paste' } else {
357
+ [System.Windows.Forms.SendKeys]::SendWait($escaped)
358
+ 'typed-sendkeys'
143
359
  }
144
- $el.SetFocus()
145
- [System.Windows.Forms.SendKeys]::SendWait(${textLit})
146
- 'typed-sendkeys'
147
360
  `.trim();
148
361
  }
149
362
  function keyScript(keysSendWait, runtimeId) {
150
- const keysLit = JSON.stringify(keysSendWait);
363
+ const keysLit = psStr(keysSendWait);
151
364
  const focusBlock = runtimeId
152
365
  ? `
153
- $ids = @((${JSON.stringify(runtimeId)}) -split ',' | ForEach-Object { [int]$_ })
366
+ $ids = @((${psStr(runtimeId)}) -split ',' | ForEach-Object { [int]$_ })
154
367
  $el = [System.Windows.Automation.AutomationElement]::AutomationElementFromRuntimeId($ids)
155
- if (-not $el) { throw 'element not found' }
156
- $el.SetFocus()
368
+ if (-not $el) { throw 'element not found (window changed? capture again)' }
369
+ try { $el.SetFocus() } catch {}
157
370
  Start-Sleep -Milliseconds 40
158
371
  `
159
372
  : "";
160
- return `
161
- Add-Type -AssemblyName UIAutomationClient
162
- Add-Type -AssemblyName UIAutomationTypes
163
- Add-Type -AssemblyName System.Windows.Forms
164
- $ErrorActionPreference = 'Stop'
165
- ${focusBlock}[System.Windows.Forms.SendKeys]::SendWait(${keysLit})
373
+ return `${PS_PRELUDE}${focusBlock}Add-Type -AssemblyName System.Windows.Forms
374
+ [System.Windows.Forms.SendKeys]::SendWait(${keysLit})
166
375
  'keyed'
167
376
  `.trim();
168
377
  }
169
378
  function scrollScript(options) {
170
379
  const amount = Math.max(1, Math.min(50, Math.floor(options.amount)));
171
380
  const dir = options.direction;
172
- const idLit = options.runtimeId
173
- ? JSON.stringify(options.runtimeId)
174
- : '""';
175
- return `
176
- Add-Type -AssemblyName UIAutomationClient
177
- Add-Type -AssemblyName UIAutomationTypes
381
+ const idLit = options.runtimeId ? psStr(options.runtimeId) : "''";
382
+ return `${PS_PRELUDE}
178
383
  Add-Type -AssemblyName System.Windows.Forms
179
384
  Add-Type -AssemblyName System.Drawing
180
- $ErrorActionPreference = 'Stop'
181
- $dir = ${JSON.stringify(dir)}
385
+ $dir = ${psStr(dir)}
182
386
  $amount = ${amount}
183
387
  $runtimeId = ${idLit}
184
388
  $el = $null
185
389
  if ($runtimeId -ne '') {
186
390
  $ids = @(($runtimeId) -split ',' | ForEach-Object { [int]$_ })
187
391
  $el = [System.Windows.Automation.AutomationElement]::AutomationElementFromRuntimeId($ids)
188
- if (-not $el) { throw 'element not found' }
392
+ if (-not $el) { throw 'element not found (window changed? capture again)' }
189
393
  }
190
394
  if ($el) {
191
395
  $scroll = $null
@@ -206,12 +410,14 @@ if ($el) {
206
410
  'scrolled-pattern'
207
411
  return
208
412
  }
209
- $el.SetFocus()
413
+ try { $el.SetFocus() } catch {}
210
414
  Start-Sleep -Milliseconds 40
211
415
  $r = $el.Current.BoundingRectangle
212
- $x = [int]($r.X + $r.Width / 2)
213
- $y = [int]($r.Y + $r.Height / 2)
214
- [System.Windows.Forms.Cursor]::Position = New-Object System.Drawing.Point($x, $y)
416
+ $x = XrkSafeInt ($r.X + $r.Width / 2)
417
+ $y = XrkSafeInt ($r.Y + $r.Height / 2)
418
+ if ($null -ne $x -and $null -ne $y) {
419
+ [System.Windows.Forms.Cursor]::Position = New-Object System.Drawing.Point($x, $y)
420
+ }
215
421
  }
216
422
  Add-Type @"
217
423
  using System;
@@ -231,53 +437,87 @@ if ($dir -eq 'left' -or $dir -eq 'right') { $flags = [XrkMouseWheel]::MOUSEEVENT
231
437
  'scrolled-wheel'
232
438
  `.trim();
233
439
  }
440
+ /**
441
+ * Wrap the list in an object and hand the `List` straight to ConvertTo-Json:
442
+ * a bare piped array serializes as `{"value":[…],"Count":n}` (which reads back as
443
+ * one window), and `@($list)` around a Generic List makes ConvertTo-Json throw
444
+ * "Argument types do not match".
445
+ */
234
446
  function listWindowsScript() {
235
- return `
236
- Add-Type -AssemblyName UIAutomationClient
237
- Add-Type -AssemblyName UIAutomationTypes
238
- $ErrorActionPreference = 'Stop'
447
+ return `${PS_PRELUDE}
239
448
  $root = [System.Windows.Automation.AutomationElement]::RootElement
240
449
  $cond = New-Object System.Windows.Automation.PropertyCondition(
241
450
  [System.Windows.Automation.AutomationElement]::ControlTypeProperty,
242
451
  [System.Windows.Automation.ControlType]::Window)
243
452
  $wins = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $cond)
244
- $list = @()
453
+ $list = New-Object System.Collections.Generic.List[object]
245
454
  foreach ($w in $wins) {
246
- $n = $w.Current.Name
247
- if ([string]::IsNullOrWhiteSpace($n)) { continue }
248
- $list += [pscustomobject]@{ title = $n; pid = $w.Current.ProcessId; app = $n }
455
+ $n = ''
456
+ try { $n = $w.Current.Name } catch {}
457
+ $proc = XrkProcName $w.Current.ProcessId
458
+ if ([string]::IsNullOrWhiteSpace($n) -and [string]::IsNullOrWhiteSpace($proc)) { continue }
459
+ $list.Add([pscustomobject]@{ title = $n; pid = $w.Current.ProcessId; app = $proc })
249
460
  }
250
- ,@($list) | ConvertTo-Json -Compress
461
+ @{ windows = $list } | ConvertTo-Json -Depth 5 -Compress
251
462
  `.trim();
252
463
  }
464
+ function finite(...values) {
465
+ return values.every((v) => typeof v === "number" && Number.isFinite(v));
466
+ }
467
+ /** PowerShell can emit `{}` or `{"value":…}` where an array was expected. */
468
+ function payloadList(value) {
469
+ return Array.isArray(value) ? value : [];
470
+ }
253
471
  /**
254
472
  * Windows UI Automation Provider. Requires a desktop session; opt-in with XRK_COMPUTER_USE=1.
255
473
  */
256
474
  export function createWindowsUiAutomationProvider(options = {}) {
257
- const run = options.runPowerShell ?? defaultRunPowerShell;
475
+ const run = options.runPowerShell ??
476
+ ((script, signal) => defaultRunPowerShell(script, signal, options.timeoutMs));
258
477
  let tokens = new Map();
478
+ async function runCapture(request, signal, includeOffscreen) {
479
+ const max = request?.maxElements ?? DEFAULT_MAX_ELEMENTS;
480
+ const raw = await run(captureScript({
481
+ ...(request?.app !== undefined ? { app: request.app } : {}),
482
+ maxElements: max,
483
+ includeOffscreen,
484
+ }), signal);
485
+ return parseJson(raw, "capture");
486
+ }
259
487
  return {
260
488
  providerId: "windows-uia",
261
489
  delivery: "uia",
262
490
  async capture(request, signal) {
263
- const max = request?.maxElements ?? DEFAULT_MAX_ELEMENTS;
264
- const raw = await run(captureScript(request?.app, max), signal);
265
- const payload = parseJson(raw, "capture");
491
+ let payload = await runCapture(request, signal, false);
492
+ // Chromium / Electron builds its UIA tree lazily and many widgets report an
493
+ // empty BoundingRectangle on the first pass — retry once keeping offscreen
494
+ // nodes instead of handing the model a 0-element snapshot.
495
+ if (!payload.error && payloadList(payload.elements).length === 0) {
496
+ const retry = await runCapture(request, signal, true);
497
+ if (!retry.error)
498
+ payload = retry;
499
+ }
500
+ if (payload.error === "app-not-found") {
501
+ const wanted = String(request?.app ?? "").trim();
502
+ const candidates = payloadList(payload.candidates).slice(0, 8).join("; ");
503
+ throw new ComputerUseError(`no window matches "${wanted}"${candidates ? ` — open windows: ${candidates}` : ""}`, "COMPUTER_USE_BAD_ARGS");
504
+ }
266
505
  tokens = new Map();
267
- const elements = (payload.elements ?? []).map((e) => {
506
+ const elements = payloadList(payload.elements).map((e) => {
268
507
  if (e.runtimeId)
269
508
  tokens.set(e.index, e.runtimeId);
509
+ const hasBounds = finite(e.x, e.y, e.width, e.height);
270
510
  return {
271
511
  index: e.index,
272
512
  role: e.role || "unknown",
273
513
  name: e.name || "",
274
- ...(e.x !== undefined
514
+ ...(hasBounds
275
515
  ? {
276
516
  bounds: {
277
- x: e.x ?? 0,
278
- y: e.y ?? 0,
279
- width: e.width ?? 0,
280
- height: e.height ?? 0,
517
+ x: e.x,
518
+ y: e.y,
519
+ width: e.width,
520
+ height: e.height,
281
521
  },
282
522
  }
283
523
  : {}),
@@ -287,15 +527,25 @@ export function createWindowsUiAutomationProvider(options = {}) {
287
527
  };
288
528
  });
289
529
  const mode = request?.mode ?? "ax";
290
- const note = mode === "vision" || mode === "som"
291
- ? "windows-uia has no screenshot overlay yet; AX elements only (delivery=uia, not background SPI)"
292
- : "delivery=uia (UI Automation; not cua-driver background SPI)";
530
+ const notes = [
531
+ mode === "vision" || mode === "som"
532
+ ? "windows-uia has no screenshot overlay yet; AX elements only (delivery=uia, not background SPI)"
533
+ : "delivery=uia (UI Automation; not cua-driver background SPI)",
534
+ ];
535
+ if (elements.length === 0) {
536
+ notes.push(payload.exhausted === false
537
+ ? "visit budget ran out before any matching node — raise maxElements (the window is large, not empty)"
538
+ : "this window exposed no usable UIA elements (custom-drawn or accessibility-disabled apps expose none) — fall back to action=key, or browser_* when the target is a web page");
539
+ }
540
+ else if (payload.exhausted === false) {
541
+ notes.push(`tree truncated at ${elements.length} of more nodes; raise maxElements for more`);
542
+ }
293
543
  return buildCaptureResult({
294
544
  mode: mode === "vision" ? "ax" : mode,
295
545
  app: payload.app ?? "",
296
546
  windowTitle: payload.windowTitle ?? "",
297
547
  elements,
298
- note,
548
+ note: notes.join(" | "),
299
549
  });
300
550
  },
301
551
  async act(request, signal) {
@@ -357,7 +607,7 @@ export function createWindowsUiAutomationProvider(options = {}) {
357
607
  }
358
608
  const runtimeId = tokens.get(request.element);
359
609
  if (!runtimeId) {
360
- throw new ComputerUseError(`stale or unknown element [${request.element}] — capture again`, "COMPUTER_USE_STALE_ELEMENT");
610
+ throw new ComputerUseError(`stale or unknown element [${request.element}] — capture again (elements with no runtime id cannot be targeted)`, "COMPUTER_USE_STALE_ELEMENT");
361
611
  }
362
612
  if (request.action === "click") {
363
613
  const out = await run(clickScript(runtimeId), signal);
@@ -382,7 +632,14 @@ export function createWindowsUiAutomationProvider(options = {}) {
382
632
  async listWindows(signal) {
383
633
  const raw = await run(listWindowsScript(), signal);
384
634
  const parsed = parseJson(raw, "windows");
385
- return Array.isArray(parsed) ? parsed : [parsed];
635
+ const list = Array.isArray(parsed)
636
+ ? parsed
637
+ : payloadList(parsed?.windows);
638
+ return list.map((w) => ({
639
+ title: String(w.title ?? ""),
640
+ ...(w.pid !== undefined ? { pid: w.pid } : {}),
641
+ ...(w.app ? { app: w.app } : {}),
642
+ }));
386
643
  },
387
644
  };
388
645
  }