@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.
- package/node_modules/@xrkseek/core-agent-loop/dist/index.d.ts +6 -0
- package/node_modules/@xrkseek/core-agent-loop/dist/index.d.ts.map +1 -1
- package/node_modules/@xrkseek/core-agent-loop/dist/index.js +3 -0
- package/node_modules/@xrkseek/core-agent-loop/dist/index.js.map +1 -1
- package/node_modules/@xrkseek/core-agent-loop/dist/tool-result-bound.d.ts +7 -2
- package/node_modules/@xrkseek/core-agent-loop/dist/tool-result-bound.d.ts.map +1 -1
- package/node_modules/@xrkseek/core-agent-loop/dist/tool-result-bound.js +7 -3
- package/node_modules/@xrkseek/core-agent-loop/dist/tool-result-bound.js.map +1 -1
- package/node_modules/@xrkseek/core-session/dist/compaction.d.ts +9 -2
- package/node_modules/@xrkseek/core-session/dist/compaction.d.ts.map +1 -1
- package/node_modules/@xrkseek/core-session/dist/compaction.js +9 -2
- package/node_modules/@xrkseek/core-session/dist/compaction.js.map +1 -1
- package/node_modules/@xrkseek/core-session/dist/drain-hub.d.ts +2 -2
- package/node_modules/@xrkseek/core-session/dist/drain-hub.d.ts.map +1 -1
- package/node_modules/@xrkseek/core-session/dist/drain-hub.js +2 -2
- package/node_modules/@xrkseek/core-session/dist/drain-hub.js.map +1 -1
- package/node_modules/@xrkseek/core-session/dist/index.d.ts +1 -1
- package/node_modules/@xrkseek/core-session/dist/index.d.ts.map +1 -1
- package/node_modules/@xrkseek/core-session/dist/index.js.map +1 -1
- package/node_modules/@xrkseek/core-session/dist/latch.d.ts +25 -3
- package/node_modules/@xrkseek/core-session/dist/latch.d.ts.map +1 -1
- package/node_modules/@xrkseek/core-session/dist/latch.js +33 -12
- package/node_modules/@xrkseek/core-session/dist/latch.js.map +1 -1
- package/node_modules/@xrkseek/exec-computer-use/dist/index.d.ts +2 -2
- package/node_modules/@xrkseek/exec-computer-use/dist/index.d.ts.map +1 -1
- package/node_modules/@xrkseek/exec-computer-use/dist/index.js +2 -2
- package/node_modules/@xrkseek/exec-computer-use/dist/index.js.map +1 -1
- package/node_modules/@xrkseek/exec-computer-use/dist/keys.d.ts +5 -0
- package/node_modules/@xrkseek/exec-computer-use/dist/keys.d.ts.map +1 -1
- package/node_modules/@xrkseek/exec-computer-use/dist/keys.js +10 -0
- package/node_modules/@xrkseek/exec-computer-use/dist/keys.js.map +1 -1
- package/node_modules/@xrkseek/exec-computer-use/dist/windows-uia.d.ts +15 -0
- package/node_modules/@xrkseek/exec-computer-use/dist/windows-uia.d.ts.map +1 -1
- package/node_modules/@xrkseek/exec-computer-use/dist/windows-uia.js +379 -122
- package/node_modules/@xrkseek/exec-computer-use/dist/windows-uia.js.map +1 -1
- package/node_modules/@xrkseek/exec-shell/dist/index.d.ts +8 -1
- package/node_modules/@xrkseek/exec-shell/dist/index.d.ts.map +1 -1
- package/node_modules/@xrkseek/exec-shell/dist/index.js +3 -1
- package/node_modules/@xrkseek/exec-shell/dist/index.js.map +1 -1
- package/node_modules/@xrkseek/preset-harness/dist/tsconfig.tsbuildinfo +1 -1
- package/node_modules/@xrkseek/preset-minimal/dist/tsconfig.tsbuildinfo +1 -1
- package/node_modules/@xrkseek/preset-server/dist/tsconfig.tsbuildinfo +1 -1
- package/node_modules/@xrkseek/server-face/dist/context.d.ts +2 -2
- package/node_modules/@xrkseek/server-face/dist/context.d.ts.map +1 -1
- package/node_modules/@xrkseek/server-face/dist/handlers/session.d.ts.map +1 -1
- package/node_modules/@xrkseek/server-face/dist/handlers/session.js +31 -5
- package/node_modules/@xrkseek/server-face/dist/handlers/session.js.map +1 -1
- package/node_modules/@xrkseek/server-face/dist/subagent-tools.d.ts.map +1 -1
- package/node_modules/@xrkseek/server-face/dist/subagent-tools.js +32 -1
- package/node_modules/@xrkseek/server-face/dist/subagent-tools.js.map +1 -1
- package/node_modules/@xrkseek/server-host/dist/drain-status.d.ts +2 -2
- package/node_modules/@xrkseek/server-host/dist/drain-status.d.ts.map +1 -1
- package/node_modules/@xrkseek/server-host/dist/drain-status.js +2 -2
- package/node_modules/@xrkseek/server-host/dist/drain-status.js.map +1 -1
- package/node_modules/@xrkseek/server-host/dist/index.d.ts +16 -0
- package/node_modules/@xrkseek/server-host/dist/index.d.ts.map +1 -1
- package/node_modules/@xrkseek/server-host/dist/index.js +91 -6
- package/node_modules/@xrkseek/server-host/dist/index.js.map +1 -1
- package/node_modules/@xrkseek/server-http/dist/dsh-compat/audit-community-client.d.ts +13 -0
- package/node_modules/@xrkseek/server-http/dist/dsh-compat/audit-community-client.d.ts.map +1 -1
- package/node_modules/@xrkseek/server-http/dist/dsh-compat/audit-community-client.js +24 -0
- package/node_modules/@xrkseek/server-http/dist/dsh-compat/audit-community-client.js.map +1 -1
- package/node_modules/@xrkseek/server-http/dist/dsh-compat/dsh-client-scan.d.ts +1 -0
- package/node_modules/@xrkseek/server-http/dist/dsh-compat/dsh-client-scan.d.ts.map +1 -1
- package/node_modules/@xrkseek/server-http/dist/dsh-compat/dsh-client-scan.js +18 -2
- package/node_modules/@xrkseek/server-http/dist/dsh-compat/dsh-client-scan.js.map +1 -1
- package/node_modules/@xrkseek/server-http/dist/dsh-compat/dsh-compat-matrix.d.ts +6 -0
- package/node_modules/@xrkseek/server-http/dist/dsh-compat/dsh-compat-matrix.d.ts.map +1 -1
- package/node_modules/@xrkseek/server-http/dist/dsh-compat/dsh-compat-matrix.js +26 -1
- package/node_modules/@xrkseek/server-http/dist/dsh-compat/dsh-compat-matrix.js.map +1 -1
- package/node_modules/@xrkseek/spill/dist/index.d.ts +1 -1
- package/node_modules/@xrkseek/spill/dist/index.d.ts.map +1 -1
- package/node_modules/@xrkseek/spill/dist/index.js +1 -1
- package/node_modules/@xrkseek/spill/dist/index.js.map +1 -1
- package/node_modules/@xrkseek/spill/dist/policy.d.ts +35 -0
- package/node_modules/@xrkseek/spill/dist/policy.d.ts.map +1 -1
- package/node_modules/@xrkseek/spill/dist/policy.js +45 -4
- package/node_modules/@xrkseek/spill/dist/policy.js.map +1 -1
- 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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 = ${
|
|
56
|
-
$
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
$
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
$
|
|
67
|
-
$
|
|
68
|
-
|
|
69
|
-
$
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
$
|
|
77
|
-
$
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
129
|
-
const textLit =
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
$
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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 =
|
|
363
|
+
const keysLit = psStr(keysSendWait);
|
|
151
364
|
const focusBlock = runtimeId
|
|
152
365
|
? `
|
|
153
|
-
$ids = @((${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
$
|
|
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 =
|
|
213
|
-
$y =
|
|
214
|
-
|
|
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 =
|
|
247
|
-
|
|
248
|
-
$
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
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
|
-
...(
|
|
514
|
+
...(hasBounds
|
|
275
515
|
? {
|
|
276
516
|
bounds: {
|
|
277
|
-
x: e.x
|
|
278
|
-
y: e.y
|
|
279
|
-
width: e.width
|
|
280
|
-
height: e.height
|
|
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
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
}
|