@lardexx/arenacode 2.6.2 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -134,6 +134,32 @@ so the port reproduces it byte for byte:
134
134
 
135
135
  The exact strings are checked by `test/parity.js` against the ones extracted from `ArenaCode-x64.exe`.
136
136
 
137
+ **Computer Agent: mouse, keyboard and screen.** The three modes' `ui` commands now run in the terminal client too,
138
+ from the same bridge request the desktop app answers:
139
+
140
+ | Command | What it does here |
141
+ |---|---|
142
+ | `ui control` | `move`, `click`, `dblclick`, `rightclick`, `down`, `up`, `drag` (smooth, 12 steps), `scroll` |
143
+ | `ui type` | clicks the field first when the task gives `x,y`, otherwise types into the focused field; the text is pasted, so accents and emoji survive |
144
+ | `ui keys` | `ctrl+s`, `alt+f4`, `win`, `enter`, `f5`, `ctrl+shift+esc` … |
145
+ | `ui app` | `launch` (Start menu on Windows, `open -a` on macOS, `gtk-launch` on Linux), `switch`, `resize` |
146
+ | `ui shot` | full-screen PNG, uploaded to the bridge workspace and returned as `file` (+ `image` base64 when small) |
147
+ | `ui state` | windows with position, size and process id, plus screen and system |
148
+ | `ui apps` | the Start menu (Windows), `/Applications` (macOS), `.desktop` entries (Linux) |
149
+ | `ui multi-select`, `ui multi-edit` | built from the primitives above |
150
+
151
+ No extra install on **Windows**: PowerShell + `user32.dll` + the .NET UI Automation assemblies. The desktop client
152
+ ships a `windows-use.dll` (exports `wu_call_json`, `wu_free`, `wu_version`) for the same job; the terminal client
153
+ reproduces its behaviour instead of depending on it — the same JSON keys (`bounding_box`, `center`, `control_type`,
154
+ `interactive_elements_text`, `scrollable_elements`, `screenshot_png_base64`), the same ideas: the UI Automation tree of
155
+ the focused window for `ui state`, **unicode typing through `SendInput`** so the clipboard is untouched, extended-key
156
+ flags for arrows/navigation, fuzzy window matching (`ui app --name chrome` finds *"Google Chrome — New Tab"*), and
157
+ numbered labels drawn on the elements for `ui shot --annotated`.
158
+
159
+ **macOS**: grant Accessibility to your terminal, and `brew install cliclick` for the mouse. **Linux** (X11):
160
+ `sudo apt install xdotool wmctrl imagemagick`; on Wayland `xdotool` cannot inject input, so Computer Agent needs
161
+ XWayland. On macOS/Linux the accessibility tree is not read: `ui state` returns windows and geometry as elements.
162
+
137
163
  **Ask and Proceed / Proceed.** The desktop's Settings switch, `ctrl+x a` here (also `/ask`, `/proceed`,
138
164
  `--proceed`, and `arenacode config set approval proceed`). The default, **Ask and Proceed**, asks before commands,
139
165
  file changes and plans; in Computer mode it asks once, on the plan, and after that the commands run on their own
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lardexx/arenacode",
3
- "version": "2.6.2",
3
+ "version": "2.8.0",
4
4
  "description": "Terminal client for ArenaCode: talks to an arena-bridge, streams the Agent Mode timeline, and runs local commands and file operations on your PC. Node port of the ArenaCode desktop client.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -19,7 +19,7 @@
19
19
  "LICENSE"
20
20
  ],
21
21
  "scripts": {
22
- "test": "node test/integration.js && node test/parity.js"
22
+ "test": "node test/integration.js && node test/parity.js && node test/automation.js"
23
23
  },
24
24
  "keywords": [
25
25
  "arenacode",
@@ -0,0 +1,993 @@
1
+ 'use strict';
2
+
3
+ // Computer Agent automation for the terminal client: mouse, keyboard, screen,
4
+ // windows and Start-menu apps. One small interface, three backends:
5
+ //
6
+ // Windows PowerShell + user32 (no extra install)
7
+ // macOS osascript (System Events) + screencapture, cliclick for the mouse
8
+ // Linux xdotool + ImageMagick/scrot/grim for screenshots
9
+ //
10
+ // Every function resolves to plain JSON, the same shapes the desktop client
11
+ // returns to the bridge: { ok: true, ... } or { ok: false, error }.
12
+
13
+ const fs = require('node:fs');
14
+ const os = require('node:os');
15
+ const path = require('node:path');
16
+ const { spawnSync } = require('node:child_process');
17
+
18
+ const isWin = process.platform === 'win32';
19
+ const isMac = process.platform === 'darwin';
20
+ const isLinux = process.platform === 'linux';
21
+
22
+ function fail(error, extra = {}) {
23
+ return { ok: false, error, ...extra };
24
+ }
25
+
26
+ function which(cmd) {
27
+ const probe = isWin ? 'where' : 'which';
28
+ const res = spawnSync(probe, [cmd], { encoding: 'utf8', windowsHide: true });
29
+ return res.status === 0 && String(res.stdout).trim() !== '';
30
+ }
31
+
32
+ function run(cmd, args, opts = {}) {
33
+ const res = spawnSync(cmd, args, {
34
+ input: opts.input,
35
+ encoding: 'utf8',
36
+ timeout: opts.timeout || 30000,
37
+ maxBuffer: 64 * 1024 * 1024,
38
+ windowsHide: true,
39
+ });
40
+ return {
41
+ ok: res.status === 0,
42
+ status: res.status,
43
+ stdout: res.stdout || '',
44
+ stderr: res.stderr || '',
45
+ error: res.error ? res.error.message : '',
46
+ };
47
+ }
48
+
49
+ // --------------------------------------------------------------------------- //
50
+ // Windows
51
+ // --------------------------------------------------------------------------- //
52
+
53
+ const PS_ENGINE = String.raw`
54
+ $ErrorActionPreference = 'Stop'
55
+ $req = ([Console]::In.ReadToEnd() | ConvertFrom-Json)
56
+ Add-Type -AssemblyName System.Drawing
57
+ Add-Type -AssemblyName System.Windows.Forms
58
+ Add-Type -AssemblyName UIAutomationClient
59
+ Add-Type -AssemblyName UIAutomationTypes
60
+ Add-Type -TypeDefinition @'
61
+ using System;
62
+ using System.Text;
63
+ using System.Runtime.InteropServices;
64
+ public class AC {
65
+ [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
66
+ [DllImport("user32.dll")] public static extern void mouse_event(uint flags, int dx, int dy, int data, UIntPtr extra);
67
+ [DllImport("user32.dll")] public static extern void keybd_event(byte vk, byte scan, uint flags, UIntPtr extra);
68
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
69
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
70
+ [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
71
+ [DllImport("user32.dll")] public static extern bool MoveWindow(IntPtr h, int x, int y, int w, int ht, bool repaint);
72
+ [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
73
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
74
+ [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
75
+ [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
76
+ }
77
+ '@
78
+
79
+ $AC_TYPES = @(
80
+ [System.Windows.Automation.ControlType]::Button,
81
+ [System.Windows.Automation.ControlType]::CheckBox,
82
+ [System.Windows.Automation.ControlType]::ComboBox,
83
+ [System.Windows.Automation.ControlType]::Custom,
84
+ [System.Windows.Automation.ControlType]::DataItem,
85
+ [System.Windows.Automation.ControlType]::Edit,
86
+ [System.Windows.Automation.ControlType]::Hyperlink,
87
+ [System.Windows.Automation.ControlType]::ListItem,
88
+ [System.Windows.Automation.ControlType]::MenuItem,
89
+ [System.Windows.Automation.ControlType]::RadioButton,
90
+ [System.Windows.Automation.ControlType]::ScrollBar,
91
+ [System.Windows.Automation.ControlType]::Slider,
92
+ [System.Windows.Automation.ControlType]::Spinner,
93
+ [System.Windows.Automation.ControlType]::SplitButton,
94
+ [System.Windows.Automation.ControlType]::Tab,
95
+ [System.Windows.Automation.ControlType]::TabItem,
96
+ [System.Windows.Automation.ControlType]::Text,
97
+ [System.Windows.Automation.ControlType]::ToolBar,
98
+ [System.Windows.Automation.ControlType]::TreeItem
99
+ )
100
+
101
+ function Get-ACWindow($h) {
102
+ $hh = [IntPtr][int64]$h
103
+ $r = New-Object AC+RECT
104
+ [void][AC]::GetWindowRect($hh, [ref]$r)
105
+ $sb = New-Object System.Text.StringBuilder 1024
106
+ [void][AC]::GetWindowText($hh, $sb, 1024)
107
+ return [pscustomobject]@{
108
+ name = $sb.ToString()
109
+ handle = $hh.ToInt64()
110
+ hwnd = $hh.ToInt64()
111
+ x = $r.Left; y = $r.Top
112
+ width = [Math]::Max(0, $r.Right - $r.Left); height = [Math]::Max(0, $r.Bottom - $r.Top)
113
+ status = $(if ([AC]::IsIconic($hh)) { 'minimized' } else { 'normal' })
114
+ }
115
+ }
116
+
117
+ function Get-ACWindows {
118
+ $list = New-Object System.Collections.ArrayList
119
+ foreach ($p in (Get-Process | Where-Object { $_.MainWindowHandle -ne 0 })) {
120
+ try {
121
+ $title = [string]$p.MainWindowTitle
122
+ if ($title.Trim().Length -eq 0) { continue }
123
+ $w = Get-ACWindow $p.MainWindowHandle
124
+ $w | Add-Member -NotePropertyName process_id -NotePropertyValue ([int]$p.Id)
125
+ $w | Add-Member -NotePropertyName app -NotePropertyValue ([string]$p.ProcessName)
126
+ [void]$list.Add($w)
127
+ } catch { }
128
+ }
129
+ return $list
130
+ }
131
+
132
+ function Get-ACScreen {
133
+ $b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
134
+ return @{ width = $b.Width; height = $b.Height }
135
+ }
136
+
137
+ function Send-ACKeys($pairs) {
138
+ foreach ($k in $pairs) {
139
+ $flags = 0
140
+ if ($k.flags -eq 2) { $flags = $flags -bor 2 }
141
+ if ($k.ext) { $flags = $flags -bor 1 }
142
+ [AC]::keybd_event([byte]$k.vk, 0, $flags, [UIntPtr]::Zero)
143
+ Start-Sleep -Milliseconds 12
144
+ }
145
+ }
146
+
147
+ # windows-use types through SendInput unicode events: the clipboard is never touched
148
+ function Send-ACUnicode($text) {
149
+ $typed = 0
150
+ foreach ($ch in $text.ToCharArray()) {
151
+ if ($ch -eq [char]10) {
152
+ [AC]::keybd_event(0x0D, 0, 0, [UIntPtr]::Zero)
153
+ [AC]::keybd_event(0x0D, 0, 2, [UIntPtr]::Zero)
154
+ $typed++
155
+ continue
156
+ }
157
+ if ($ch -eq [char]13) { continue }
158
+ [AC]::keybd_event(0, [byte][char]$ch, 4, [UIntPtr]::Zero)
159
+ [AC]::keybd_event(0, [byte][char]$ch, 6, [UIntPtr]::Zero)
160
+ $typed++
161
+ Start-Sleep -Milliseconds 3
162
+ }
163
+ return $typed
164
+ }
165
+
166
+ function Get-ACElements($hwnd, $interactiveOnly) {
167
+ $handle = [IntPtr][int64]$hwnd
168
+ $root = $null
169
+ if ($handle -ne [IntPtr]::Zero) {
170
+ $root = [System.Windows.Automation.AutomationElement]::FromHandle($handle)
171
+ } else {
172
+ $root = [System.Windows.Automation.AutomationElement]::FocusedElement
173
+ while ($root -ne $null -and $root.Current.NativeWindowHandle -eq 0) {
174
+ $walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
175
+ $root = $walker.GetParent($root)
176
+ }
177
+ }
178
+ if ($root -eq $null) { return @() }
179
+
180
+ $conditions = @()
181
+ foreach ($ct in $AC_TYPES) {
182
+ $conditions += New-Object System.Windows.Automation.PropertyCondition(
183
+ [System.Windows.Automation.AutomationElement]::ControlTypeProperty, $ct)
184
+ }
185
+ $cond = New-Object System.Windows.Automation.OrCondition $conditions
186
+ $found = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $cond)
187
+
188
+ $out = New-Object System.Collections.ArrayList
189
+ $n = 0
190
+ foreach ($el in $found) {
191
+ if ($n -ge 120) { break }
192
+ try {
193
+ $cur = $el.Current
194
+ if ($cur.IsOffscreen) { continue }
195
+ $rect = $cur.BoundingRectangle
196
+ if ($rect.Width -le 0 -or $rect.Height -le 0) { continue }
197
+ $name = [string]$cur.Name
198
+ $ctype = [string]$cur.ControlType.ProgrammaticName -replace '^ControlType\.', ''
199
+ if ($interactiveOnly -and $ctype -eq 'Text' -and $name.Trim().Length -eq 0) { continue }
200
+ $val = ''
201
+ try {
202
+ $vp = $null
203
+ if ($el.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$vp)) { $val = [string]$vp.Current.Value }
204
+ } catch { $val = '' }
205
+ $scrollable = $false
206
+ try {
207
+ $sp = $null
208
+ if ($el.TryGetCurrentPattern([System.Windows.Automation.ScrollPattern]::Pattern, [ref]$sp)) {
209
+ $scrollable = [bool]$sp.Current.VerticallyScrollable -or [bool]$sp.Current.HorizontallyScrollable
210
+ }
211
+ } catch { }
212
+ [void]$out.Add([pscustomobject]@{
213
+ name = $name
214
+ control_type = $ctype
215
+ kind = $ctype
216
+ bounding_box = @{ left = [int]$rect.X; top = [int]$rect.Y; right = [int]($rect.X + $rect.Width); bottom = [int]($rect.Y + $rect.Height) }
217
+ center = @{ x = [int]($rect.X + $rect.Width / 2); y = [int]($rect.Y + $rect.Height / 2) }
218
+ value = $val
219
+ scrollable = $scrollable
220
+ enabled = [bool]$cur.IsEnabled
221
+ automation_id = [string]$cur.AutomationId
222
+ })
223
+ $n++
224
+ } catch { }
225
+ }
226
+ return $out
227
+ }
228
+
229
+ $out = @{ ok = $true }
230
+ switch ($req.action) {
231
+ 'screen' { $s = Get-ACScreen; $out.width = $s.width; $out.height = $s.height }
232
+ 'windows' { $out.windows = @(Get-ACWindows) }
233
+ 'active' {
234
+ $hh = [AC]::GetForegroundWindow()
235
+ $w = Get-ACWindow $hh.ToInt64()
236
+ $out.name = $w.name
237
+ $out.handle = $w.handle
238
+ }
239
+ 'focus' {
240
+ $hh = [IntPtr][int64]$req.handle
241
+ [void][AC]::ShowWindow($hh, 9)
242
+ [void][AC]::SetForegroundWindow($hh)
243
+ Start-Sleep -Milliseconds 200
244
+ $w = Get-ACWindow $req.handle
245
+ $out.name = $w.name
246
+ }
247
+ 'resize' {
248
+ $hh = [IntPtr][int64]$req.handle
249
+ [void][AC]::MoveWindow($hh, [int]$req.x, [int]$req.y, [int]$req.width, [int]$req.height, $true)
250
+ $w = Get-ACWindow $req.handle
251
+ $out.name = $w.name
252
+ }
253
+ 'launch' {
254
+ $name = [string]$req.name
255
+ $done = $false
256
+ try { Start-Process -FilePath $name -ErrorAction Stop; $done = $true } catch { $done = $false }
257
+ if (-not $done) {
258
+ $hit = $null
259
+ try { $hit = Get-StartApps | Where-Object { $_.Name -like "*$name*" } | Select-Object -First 1 } catch { $hit = $null }
260
+ if ($hit) { Start-Process 'explorer.exe' "shell:AppsFolder\$($hit.AppID)"; $done = $true }
261
+ else {
262
+ $exe = Get-Command $name -ErrorAction SilentlyContinue
263
+ if ($exe) { Start-Process $exe.Source; $done = $true }
264
+ }
265
+ }
266
+ if ($done) { Start-Sleep -Milliseconds 900; $out.name = $name } else { $out = @{ ok = $false; error = "app not found: $name" } }
267
+ }
268
+ 'move' { [void][AC]::SetCursorPos([int]$req.x, [int]$req.y) }
269
+ 'click' {
270
+ [void][AC]::SetCursorPos([int]$req.x, [int]$req.y)
271
+ Start-Sleep -Milliseconds 40
272
+ $down = 2; $up = 4
273
+ if ($req.button -eq 'right') { $down = 8; $up = 16 }
274
+ elseif ($req.button -eq 'middle') { $down = 32; $up = 64 }
275
+ $times = [int]$req.count; if ($times -lt 1) { $times = 1 }
276
+ for ($i = 0; $i -lt $times; $i++) {
277
+ [AC]::mouse_event($down, 0, 0, 0, [UIntPtr]::Zero)
278
+ Start-Sleep -Milliseconds 20
279
+ [AC]::mouse_event($up, 0, 0, 0, [UIntPtr]::Zero)
280
+ Start-Sleep -Milliseconds 60
281
+ }
282
+ }
283
+ 'down' {
284
+ [void][AC]::SetCursorPos([int]$req.x, [int]$req.y)
285
+ $flag = 2; if ($req.button -eq 'right') { $flag = 8 } elseif ($req.button -eq 'middle') { $flag = 32 }
286
+ [AC]::mouse_event($flag, 0, 0, 0, [UIntPtr]::Zero)
287
+ }
288
+ 'up' {
289
+ $flag = 4; if ($req.button -eq 'right') { $flag = 16 } elseif ($req.button -eq 'middle') { $flag = 64 }
290
+ if ($null -ne $req.x) { [void][AC]::SetCursorPos([int]$req.x, [int]$req.y) }
291
+ [AC]::mouse_event($flag, 0, 0, 0, [UIntPtr]::Zero)
292
+ }
293
+ 'drag' {
294
+ [void][AC]::SetCursorPos([int]$req.x, [int]$req.y)
295
+ Start-Sleep -Milliseconds 60
296
+ [AC]::mouse_event(2, 0, 0, 0, [UIntPtr]::Zero)
297
+ $steps = 12
298
+ for ($i = 1; $i -le $steps; $i++) {
299
+ $nx = [int]($req.x + (($req.x2 - $req.x) * $i / $steps))
300
+ $ny = [int]($req.y + (($req.y2 - $req.y) * $i / $steps))
301
+ [void][AC]::SetCursorPos($nx, $ny)
302
+ Start-Sleep -Milliseconds 15
303
+ }
304
+ [AC]::mouse_event(4, 0, 0, 0, [UIntPtr]::Zero)
305
+ }
306
+ 'scroll' {
307
+ if ($null -ne $req.x -and $null -ne $req.y) { [void][AC]::SetCursorPos([int]$req.x, [int]$req.y); Start-Sleep -Milliseconds 40 }
308
+ $amount = [int]$req.amount
309
+ $dir = 120; if ($amount -lt 0) { $dir = -120 }
310
+ for ($i = 0; $i -lt [Math]::Abs($amount); $i++) {
311
+ [AC]::mouse_event(0x0800, 0, 0, $dir, [UIntPtr]::Zero)
312
+ Start-Sleep -Milliseconds 25
313
+ }
314
+ }
315
+ 'keys' { Send-ACKeys $req.sequence }
316
+ 'type' {
317
+ if ($null -ne $req.x -and $null -ne $req.y) {
318
+ [void][AC]::SetCursorPos([int]$req.x, [int]$req.y)
319
+ Start-Sleep -Milliseconds 60
320
+ [AC]::mouse_event(2, 0, 0, 0, [UIntPtr]::Zero)
321
+ [AC]::mouse_event(4, 0, 0, 0, [UIntPtr]::Zero)
322
+ Start-Sleep -Milliseconds 150
323
+ }
324
+ $out.typed = (Send-ACUnicode ([string]$req.text))
325
+ }
326
+ 'shot' {
327
+ $s = Get-ACScreen
328
+ $bmp = New-Object System.Drawing.Bitmap $s.width, $s.height
329
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
330
+ $g.CopyFromScreen(0, 0, 0, 0, $bmp.Size)
331
+ if ($req.annotate) {
332
+ $fg = $null
333
+ try { $fg = [AC]::GetForegroundWindow().ToInt64() } catch { $fg = 0 }
334
+ $els = @(Get-ACElements $fg $true)
335
+ $font = New-Object System.Drawing.Font('Segoe UI', 9, [System.Drawing.FontStyle]::Bold)
336
+ $brush = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(230, 250, 178, 131))
337
+ $pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(200, 250, 178, 131)), 1
338
+ $i = 1
339
+ foreach ($e in $els) {
340
+ $bb = $e.bounding_box
341
+ $g.DrawRectangle($pen, $bb.left, $bb.top, [Math]::Max(1, $bb.right - $bb.left), [Math]::Max(1, $bb.bottom - $bb.top))
342
+ $g.FillRectangle($brush, $bb.left, [Math]::Max(0, $bb.top - 14), 18, 14)
343
+ $g.DrawString([string]$i, $font, (New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::Black)), $bb.left + 2, [Math]::Max(0, $bb.top - 14))
344
+ $i++
345
+ }
346
+ $pen.Dispose(); $brush.Dispose(); $font.Dispose()
347
+ $out.elements = $els.Count
348
+ }
349
+ $bmp.Save($req.path, [System.Drawing.Imaging.ImageFormat]::Png)
350
+ $g.Dispose(); $bmp.Dispose()
351
+ $out.width = $s.width; $out.height = $s.height; $out.file = $req.path
352
+ }
353
+ 'apps' {
354
+ try { $out.apps = @(Get-StartApps | ForEach-Object { @{ name = $_.Name; app_id = $_.AppID } }) }
355
+ catch { $out.apps = @() }
356
+ }
357
+ 'state' {
358
+ $hh = $null
359
+ if ($null -ne $req.handle -and [int64]$req.handle -ne 0) { $hh = [int64]$req.handle } else { $hh = [AC]::GetForegroundWindow().ToInt64() }
360
+ $els = @(Get-ACElements $hh $true)
361
+ $interactive = @()
362
+ $scrollable = @()
363
+ $lines = New-Object System.Collections.ArrayList
364
+ $i = 1
365
+ foreach ($e in $els) {
366
+ if ($e.scrollable) { $scrollable += $e } else { $interactive += $e }
367
+ [void]$lines.Add("$i. $($e.control_type) '$($e.name)' at ($($e.center.x), $($e.center.y))")
368
+ $i++
369
+ }
370
+ $out.windows = @(Get-ACWindows)
371
+ $out.active_window = (Get-ACWindow $hh)
372
+ $out.interactive_elements = $interactive
373
+ $out.scrollable_elements = $scrollable
374
+ $out.interactive_elements_text = ($lines -join [Environment]::NewLine)
375
+ $out.scrollable_elements_text = (($scrollable | ForEach-Object { "$($_.control_type) '$($_.name)' at ($($_.center.x), $($_.center.y))" }) -join [Environment]::NewLine)
376
+ $out.system = @{
377
+ windows_version = [string](Get-CimInstance Win32_OperatingSystem).Caption
378
+ os_version = [Environment]::OSVersion.Version.ToString()
379
+ screen_size = (Get-ACScreen)
380
+ user_account_type = 'Local Account'
381
+ default_language = (Get-Culture).Name
382
+ dpi_scaling = 1
383
+ }
384
+ $out.metadata = @{ kind = 'ui-automation'; depth = 'descendants'; all_desktops = $false }
385
+ }
386
+ 'sysinfo' {
387
+ $out.system = @{
388
+ windows_version = [string](Get-CimInstance Win32_OperatingSystem).Caption
389
+ os_version = [Environment]::OSVersion.Version.ToString()
390
+ screen_size = (Get-ACScreen)
391
+ user_account_type = 'Local Account'
392
+ default_language = (Get-Culture).Name
393
+ dpi_scaling = 1
394
+ }
395
+ }
396
+ default { $out = @{ ok = $false; error = "unknown action: $($req.action)" } }
397
+ }
398
+ $out | ConvertTo-Json -Depth 8 -Compress
399
+ `;
400
+ let psEnginePath = null;
401
+ function psEngine() {
402
+ if (psEnginePath && fs.existsSync(psEnginePath)) return psEnginePath;
403
+ const dir = path.join(os.tmpdir(), 'arenacode-automation');
404
+ fs.mkdirSync(dir, { recursive: true });
405
+ const file = path.join(dir, 'engine.ps1');
406
+ fs.writeFileSync(file, PS_ENGINE, 'utf8');
407
+ psEnginePath = file;
408
+ return file;
409
+ }
410
+
411
+ function winRun(request, timeout = 30000) {
412
+ const shell = which('powershell') ? 'powershell' : 'pwsh';
413
+ if (!which(shell)) return fail('PowerShell not found');
414
+ const res = run(
415
+ shell,
416
+ ['-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-File', psEngine()],
417
+ { input: JSON.stringify(request), timeout }
418
+ );
419
+ if (!res.ok && !res.stdout.trim()) {
420
+ return fail(res.stderr.trim() || res.error || `PowerShell exit ${res.status}`);
421
+ }
422
+ try {
423
+ return JSON.parse(res.stdout.trim().split('\n').pop());
424
+ } catch {
425
+ return fail(`unreadable answer from PowerShell: ${res.stdout.slice(0, 200)}`);
426
+ }
427
+ }
428
+
429
+ // --------------------------------------------------------------------------- //
430
+ // macOS
431
+ // --------------------------------------------------------------------------- //
432
+
433
+ function osa(script, timeout = 20000) {
434
+ const res = run('osascript', ['-e', script], { timeout });
435
+ return { ok: res.ok, out: res.stdout.trim(), error: res.stderr.trim() || res.error };
436
+ }
437
+
438
+ function macScreen() {
439
+ const res = osa('tell application "Finder" to get bounds of window of desktop');
440
+ const nums = String(res.out)
441
+ .split(',')
442
+ .map((n) => Number(n.trim()));
443
+ if (nums.length === 4 && nums.every((n) => Number.isFinite(n))) {
444
+ return { width: nums[2] - nums[0], height: nums[3] - nums[1] };
445
+ }
446
+ return { width: 1440, height: 900 };
447
+ }
448
+
449
+ function macWindows() {
450
+ const res = osa(
451
+ 'tell application "System Events" to set out to ""\n' +
452
+ 'repeat with p in (every process whose background only is false)\n' +
453
+ ' repeat with w in (every window of p)\n' +
454
+ ' set out to out & (name of p) & tab & (name of w) & tab & (item 1 of (position of w)) & tab & (item 2 of (position of w)) & tab & (item 1 of (size of w)) & tab & (item 2 of (size of w)) & linefeed\n' +
455
+ ' end repeat\n' +
456
+ 'end repeat\n' +
457
+ 'return out'
458
+ );
459
+ if (!res.ok) return [];
460
+ return String(res.out)
461
+ .split('\n')
462
+ .filter(Boolean)
463
+ .map((line) => {
464
+ const [app, title, x, y, w, h] = line.split('\t');
465
+ return {
466
+ name: title || app,
467
+ app,
468
+ x: Number(x) || 0,
469
+ y: Number(y) || 0,
470
+ width: Number(w) || 0,
471
+ height: Number(h) || 0,
472
+ process_id: 0,
473
+ status: 'normal',
474
+ };
475
+ });
476
+ }
477
+
478
+ function macKeys(sequence, text) {
479
+ // sequence comes as vk pairs; on macOS it is easier to send the original combo
480
+ const script = text
481
+ ? `tell application "System Events" to keystroke ${JSON.stringify(text)}`
482
+ : `tell application "System Events" to keystroke ${JSON.stringify(sequence)}`;
483
+ return osa(script);
484
+ }
485
+
486
+ // --------------------------------------------------------------------------- //
487
+ // Linux
488
+ // --------------------------------------------------------------------------- //
489
+
490
+ function linuxScreen() {
491
+ const res = run('xdotool', ['getdisplaygeometry']);
492
+ if (res.ok) {
493
+ const [w, h] = res.stdout.trim().split(/\s+/).map(Number);
494
+ if (w && h) return { width: w, height: h };
495
+ }
496
+ const xr = run('xrandr', ['--current']);
497
+ const m = /current (\d+) x (\d+)/.exec(xr.stdout);
498
+ if (m) return { width: Number(m[1]), height: Number(m[2]) };
499
+ return { width: 1280, height: 800 };
500
+ }
501
+
502
+ function linuxWindows() {
503
+ const res = run('wmctrl', ['-lG']);
504
+ const out = [];
505
+ if (res.ok) {
506
+ for (const line of res.stdout.split('\n').filter(Boolean)) {
507
+ const parts = line.split(/\s+/);
508
+ const x = Number(parts[2]);
509
+ const y = Number(parts[3]);
510
+ const width = Number(parts[4]);
511
+ const height = Number(parts[5]);
512
+ const name = parts.slice(7).join(' ');
513
+ out.push({ name, x, y, width, height, process_id: 0, status: 'normal' });
514
+ }
515
+ }
516
+ return out;
517
+ }
518
+
519
+ // --------------------------------------------------------------------------- //
520
+ // public interface
521
+ // --------------------------------------------------------------------------- //
522
+
523
+ const IMAGE_TOOLS = [
524
+ ['gnome-screenshot', (p) => ['-f', p]],
525
+ ['scrot', (p) => [p]],
526
+ ['import', (p) => ['-window', 'root', p]],
527
+ ['grim', (p) => [p]],
528
+ ['spectacle', (p) => ['-b', '-n', '-o', p]],
529
+ ];
530
+
531
+ const VK = {
532
+ ctrl: 0x11, control: 0x11, alt: 0x12, option: 0x12, shift: 0x10, win: 0x5b, super: 0x5b,
533
+ meta: 0x5b, cmd: 0x5b, command: 0x5b,
534
+ enter: 0x0d, return: 0x0d, tab: 0x09, esc: 0x1b, escape: 0x1b, space: 0x20, spacebar: 0x20,
535
+ backspace: 0x08, bs: 0x08, delete: 0x2e, del: 0x2e, insert: 0x2d, ins: 0x2d,
536
+ home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22, pgup: 0x21, pgdn: 0x22,
537
+ up: 0x26, down: 0x28, left: 0x25, right: 0x27, arrowup: 0x26, arrowdown: 0x28,
538
+ arrowleft: 0x25, arrowright: 0x27,
539
+ capslock: 0x14, numlock: 0x90, scrolllock: 0x91, printscreen: 0x2c, prtsc: 0x2c,
540
+ pause: 0x13, menu: 0x5d, contextmenu: 0x5d, apps: 0x5d,
541
+ plus: 0xbb, minus: 0xbd, comma: 0xbc, period: 0xbe, slash: 0xbf, backslash: 0xdc,
542
+ semicolon: 0xba, quote: 0xde, bracketleft: 0xdb, bracketright: 0xdd, backtick: 0xc0,
543
+ numpad0: 0x60, numpad1: 0x61, numpad2: 0x62, numpad3: 0x63, numpad4: 0x64,
544
+ numpad5: 0x65, numpad6: 0x66, numpad7: 0x67, numpad8: 0x68, numpad9: 0x69,
545
+ };
546
+ for (let i = 1; i <= 24; i += 1) VK[`f${i}`] = 0x6f + i;
547
+
548
+ // keys that need KEYEVENTF_EXTENDEDKEY, like windows-use's kScExtendedVks
549
+ const EXTENDED = new Set([
550
+ 0x2e, 0x2d, 0x24, 0x23, 0x21, 0x22, 0x26, 0x28, 0x25, 0x27, 0x5b, 0x5d, 0x2c, 0x90, 0x5d,
551
+ 0x6f + 11, 0x6f + 12,
552
+ ]);
553
+ const MODIFIERS = new Set(['ctrl', 'control', 'alt', 'option', 'shift', 'win', 'super', 'meta', 'cmd', 'command']);
554
+
555
+ function keyboardSequence(combo) {
556
+ // "ctrl+shift+s" -> [{ vk, flags, ext }] for keybd_event, the call windows-use makes too
557
+ const parts = String(combo || '')
558
+ .toLowerCase()
559
+ .split('+')
560
+ .map((p) => p.trim())
561
+ .filter(Boolean);
562
+ if (!parts.length) return [];
563
+ let repeat = 1;
564
+ const modVks = [];
565
+ const mainVks = [];
566
+ for (const key of parts) {
567
+ if (key === 'repeat' || /^x\d+$/.test(key)) {
568
+ repeat = key === 'repeat' ? 2 : Number(key.slice(1));
569
+ continue;
570
+ }
571
+ let vk = VK[key];
572
+ if (vk === undefined && key.length === 1) vk = key.toUpperCase().charCodeAt(0);
573
+ if (vk === undefined) continue;
574
+ if (MODIFIERS.has(key)) modVks.push(vk);
575
+ else mainVks.push(vk);
576
+ }
577
+ const seq = [];
578
+ const times = Math.max(1, Math.min(50, repeat));
579
+ for (let i = 0; i < times; i += 1) {
580
+ if (!mainVks.length && modVks.length) {
581
+ // "win" on its own: press and release the modifier itself
582
+ const only = modVks[modVks.length - 1];
583
+ seq.push({ vk: only, flags: 0, ext: EXTENDED.has(only) });
584
+ seq.push({ vk: only, flags: 2, ext: EXTENDED.has(only) });
585
+ continue;
586
+ }
587
+ for (const vk of modVks) seq.push({ vk, flags: 0, ext: EXTENDED.has(vk) });
588
+ for (const vk of mainVks) {
589
+ seq.push({ vk, flags: 0, ext: EXTENDED.has(vk) });
590
+ seq.push({ vk, flags: 2, ext: EXTENDED.has(vk) });
591
+ }
592
+ for (const vk of [...modVks].reverse()) seq.push({ vk, flags: 2, ext: EXTENDED.has(vk) });
593
+ }
594
+ return seq;
595
+ }
596
+
597
+ // windows-use matches window names with fuzzy scores (wu::fuzzy::WRatio); the same
598
+ // idea keeps `ui app --name chrome` working when the title is "Google Chrome — …".
599
+ const fuzzyScore = (needle, haystack) => {
600
+ const a = String(needle || '').toLowerCase().trim();
601
+ const b = String(haystack || '').toLowerCase().trim();
602
+ if (!a || !b) return 0;
603
+ if (a === b) return 1;
604
+ if (b === a || b.startsWith(a)) return 0.95;
605
+ if (b.includes(a)) return 0.85;
606
+ const tokens = a.split(/\s+/).filter(Boolean);
607
+ const words = b.split(/\s+/).filter(Boolean);
608
+ const hits = tokens.filter((t) => words.some((w) => w.startsWith(t) || t.startsWith(w))).length;
609
+ if (hits) return 0.6 + (0.2 * hits) / tokens.length;
610
+ // character-level overlap, same spirit as partial_ratio
611
+ const chars = new Set(b.replace(/\s+/g, ''));
612
+ const overlap = [...a.replace(/\s+/g, '')].filter((c) => chars.has(c)).length;
613
+ return 0.4 * (overlap / Math.max(1, a.replace(/\s+/g, '').length));
614
+ };
615
+
616
+ const AVOIDED = [/arenacode/i, /arena code/i];
617
+
618
+ function pickWindow(list, name) {
619
+ const candidates = (list || []).filter((w) => w && w.name && !AVOIDED.some((re) => re.test(w.name)));
620
+ let best = null;
621
+ for (const w of candidates) {
622
+ const score = fuzzyScore(name, w.name);
623
+ if (score >= 0.6 && (!best || score > best.score)) best = { ...w, score };
624
+ }
625
+ return best;
626
+ }
627
+
628
+ const automation = {
629
+ platform: process.platform,
630
+
631
+ available() {
632
+ if (isWin) return { ok: true, backend: 'windows', detail: 'PowerShell + user32' };
633
+ if (isMac) {
634
+ const mouse = which('cliclick');
635
+ return {
636
+ ok: true,
637
+ backend: 'macos',
638
+ detail: mouse ? 'osascript + cliclick' : 'osascript (mouse needs cliclick: brew install cliclick)',
639
+ mouse: Boolean(mouse),
640
+ };
641
+ }
642
+ if (isLinux) {
643
+ const xdotool = which('xdotool');
644
+ if (!xdotool) {
645
+ return { ok: false, backend: 'linux', error: 'xdotool not installed: sudo apt install xdotool (or dnf/pacman)' };
646
+ }
647
+ return { ok: true, backend: 'linux', detail: 'xdotool' };
648
+ }
649
+ return { ok: false, backend: null, error: `unsupported platform: ${process.platform}` };
650
+ },
651
+
652
+ screenSize() {
653
+ if (isWin) {
654
+ const r = winRun({ action: 'screen' });
655
+ return r.ok ? { ok: true, width: r.width, height: r.height } : fail(r.error);
656
+ }
657
+ if (isMac) {
658
+ const s = macScreen();
659
+ return { ok: true, ...s };
660
+ }
661
+ const s = linuxScreen();
662
+ return { ok: true, ...s };
663
+ },
664
+
665
+ windows() {
666
+ if (isWin) {
667
+ const r = winRun({ action: 'windows' }, 45000);
668
+ if (!r.ok) return fail(r.error);
669
+ return { ok: true, windows: r.windows || [] };
670
+ }
671
+ const list = isMac ? macWindows() : linuxWindows();
672
+ return { ok: true, windows: list };
673
+ },
674
+
675
+ activeWindow() {
676
+ if (isWin) {
677
+ const r = winRun({ action: 'active' });
678
+ return r.ok ? { ok: true, name: r.name, handle: r.handle } : fail(r.error);
679
+ }
680
+ const w = this.windows();
681
+ const first = w.ok && w.windows.length ? w.windows[0] : null;
682
+ return { ok: true, name: first ? first.name : '', handle: first ? first.handle : null };
683
+ },
684
+
685
+ /** Finds the window the user means, the way windows-use does: best fuzzy match. */
686
+ findWindow(name) {
687
+ const list = this.windows();
688
+ if (!list.ok) return list;
689
+ const hit = pickWindow(list.windows, name);
690
+ if (!hit) return { ok: false, error: `window not found: ${name}` };
691
+ return { ok: true, window: hit };
692
+ },
693
+
694
+ launchApp(name) {
695
+ if (isWin) {
696
+ const r = winRun({ action: 'launch', name }, 45000);
697
+ return r.ok ? { ok: true, app: name } : fail(r.error);
698
+ }
699
+ if (isMac) {
700
+ const res = run('open', ['-a', name], { timeout: 30000 });
701
+ return res.ok ? { ok: true, app: name } : fail(res.stderr.trim() || `open -a ${name} failed`);
702
+ }
703
+ const direct = run('sh', ['-c', `command -v ${JSON.stringify(name)} >/dev/null && setsid ${JSON.stringify(name)} >/dev/null 2>&1 &`]);
704
+ if (direct.ok) return { ok: true, app: name };
705
+ const app = run('gtk-launch', [name]);
706
+ if (app.ok) return { ok: true, app: name };
707
+ return fail(`app not found: ${name}`);
708
+ },
709
+
710
+ focusWindow(name) {
711
+ if (isWin) {
712
+ const found = this.findWindow(name);
713
+ if (!found.ok) return found;
714
+ const r = winRun({ action: 'focus', handle: found.window.handle });
715
+ return r.ok ? { ok: true, name: r.name, handle: found.window.handle, matched: found.window.name } : fail(r.error);
716
+ }
717
+ if (isMac) {
718
+ const res = osa(`tell application "System Events" to set frontmost of process ${JSON.stringify(name)} to true`);
719
+ return res.ok ? { ok: true, name } : fail(res.error);
720
+ }
721
+ const res = run('wmctrl', ['-a', name]);
722
+ return res.ok ? { ok: true, name } : fail(res.stderr.trim() || `window not found: ${name}`);
723
+ },
724
+
725
+ resizeWindow(name, x, y, width, height) {
726
+ if (isWin) {
727
+ const found = this.findWindow(name);
728
+ if (!found.ok) return found;
729
+ const r = winRun({
730
+ action: 'resize',
731
+ handle: found.window.handle,
732
+ x,
733
+ y,
734
+ width,
735
+ height,
736
+ });
737
+ return r.ok ? { ok: true, name: r.name, x, y, width, height } : fail(r.error);
738
+ }
739
+ if (isMac) {
740
+ const res = osa(
741
+ `tell application "System Events" to tell process ${JSON.stringify(name)} to set {position, size} of window 1 to {{${x}, ${y}}, {${width}, ${height}}}`
742
+ );
743
+ return res.ok ? { ok: true, name, x, y, width, height } : fail(res.error);
744
+ }
745
+ const res = run('wmctrl', ['-r', name, '-e', `0,${x},${y},${width},${height}`]);
746
+ return res.ok ? { ok: true, name, x, y, width, height } : fail(res.stderr.trim() || 'resize failed');
747
+ },
748
+
749
+ /** The UI Automation tree of the focused window: what the desktop's windows-use returns. */
750
+ state(handle = null) {
751
+ if (isWin) {
752
+ const r = winRun({ action: 'state', handle }, 60000);
753
+ if (!r.ok) return fail(r.error);
754
+ return {
755
+ ok: true,
756
+ windows: r.windows || [],
757
+ active_window: r.active_window || null,
758
+ interactive_elements: r.interactive_elements || [],
759
+ scrollable_elements: r.scrollable_elements || [],
760
+ interactive_elements_text: r.interactive_elements_text || '',
761
+ scrollable_elements_text: r.scrollable_elements_text || '',
762
+ system: r.system || {},
763
+ metadata: r.metadata || {},
764
+ };
765
+ }
766
+ // no accessibility tree on the other platforms: windows, geometry and pixels
767
+ const wins = this.windows();
768
+ const screen = this.screenSize();
769
+ const active = wins.ok && wins.windows.length ? wins.windows[0] : null;
770
+ const elements = (wins.ok ? wins.windows : []).map((w) => ({
771
+ name: w.name,
772
+ control_type: 'Window',
773
+ kind: 'Window',
774
+ bounding_box: { left: w.x, top: w.y, right: w.x + w.width, bottom: w.y + w.height },
775
+ center: { x: w.x + Math.floor(w.width / 2), y: w.y + Math.floor(w.height / 2) },
776
+ value: '',
777
+ enabled: true,
778
+ automation_id: '',
779
+ }));
780
+ return {
781
+ ok: true,
782
+ windows: wins.ok ? wins.windows : [],
783
+ active_window: active,
784
+ interactive_elements: elements,
785
+ scrollable_elements: [],
786
+ interactive_elements_text: elements
787
+ .map((e, i) => `${i + 1}. ${e.control_type} '${e.name}' at (${e.center.x}, ${e.center.y})`)
788
+ .join('\n'),
789
+ scrollable_elements_text: '',
790
+ system: this.sysinfo().system,
791
+ metadata: { kind: 'windows-only-list', depth: 'top-level', all_desktops: false },
792
+ };
793
+ },
794
+
795
+ click(x, y, { button = 'left', count = 1 } = {}) {
796
+ if (isWin) {
797
+ const r = winRun({ action: 'click', x, y, button, count });
798
+ return r.ok ? { ok: true, x, y, button, count } : fail(r.error);
799
+ }
800
+ if (isMac) {
801
+ if (!which('cliclick')) return fail('mouse needs cliclick: brew install cliclick');
802
+ const arg = `${button === 'right' ? 'rc' : 'c'}:${x},${y}`;
803
+ const rounds = Math.max(1, count);
804
+ for (let i = 0; i < rounds; i += 1) {
805
+ const res = run('cliclick', [arg]);
806
+ if (!res.ok) return fail(res.stderr.trim() || 'cliclick failed');
807
+ }
808
+ return { ok: true, x, y, button, count };
809
+ }
810
+ const btn = button === 'right' ? '3' : button === 'middle' ? '2' : '1';
811
+ const res = run('xdotool', ['mousemove', String(x), String(y), 'click', '--repeat', String(Math.max(1, count)), btn]);
812
+ return res.ok ? { ok: true, x, y, button, count } : fail(res.stderr.trim() || 'xdotool click failed');
813
+ },
814
+
815
+ moveMouse(x, y) {
816
+ if (isWin) {
817
+ const r = winRun({ action: 'move', x, y });
818
+ return r.ok ? { ok: true, x, y } : fail(r.error);
819
+ }
820
+ if (isMac) {
821
+ if (!which('cliclick')) return fail('mouse needs cliclick: brew install cliclick');
822
+ const res = run('cliclick', [`m:${x},${y}`]);
823
+ return res.ok ? { ok: true, x, y } : fail(res.stderr.trim() || 'cliclick failed');
824
+ }
825
+ const res = run('xdotool', ['mousemove', String(x), String(y)]);
826
+ return res.ok ? { ok: true, x, y } : fail(res.stderr.trim() || 'xdotool failed');
827
+ },
828
+
829
+ mouseButton(x, y, button, down) {
830
+ if (isWin) {
831
+ const r = winRun({ action: down ? 'down' : 'up', x, y, button });
832
+ return r.ok ? { ok: true, x, y, button } : fail(r.error);
833
+ }
834
+ if (isMac) {
835
+ if (!which('cliclick')) return fail('mouse needs cliclick: brew install cliclick');
836
+ const arg = `dd:${x},${y}`;
837
+ if (down) return run('cliclick', [arg]).ok ? { ok: true } : fail('cliclick failed');
838
+ return run('cliclick', [`du:${x},${y}`]).ok ? { ok: true } : fail('cliclick failed');
839
+ }
840
+ const btn = button === 'right' ? '3' : button === 'middle' ? '2' : '1';
841
+ const res = run('xdotool', [down ? 'mousedown' : 'mouseup', btn]);
842
+ return res.ok ? { ok: true } : fail(res.stderr.trim() || 'xdotool failed');
843
+ },
844
+
845
+ drag(x, y, x2, y2) {
846
+ if (isWin) {
847
+ const r = winRun({ action: 'drag', x, y, x2, y2 });
848
+ return r.ok ? { ok: true, from: [x, y], to: [x2, y2] } : fail(r.error);
849
+ }
850
+ if (isMac) {
851
+ if (!which('cliclick')) return fail('mouse needs cliclick: brew install cliclick');
852
+ const res = run('cliclick', [`dd:${x},${y}`, `dm:${x2},${y2}`, `du:${x2},${y2}`]);
853
+ return res.ok ? { ok: true, from: [x, y], to: [x2, y2] } : fail(res.stderr.trim() || 'cliclick failed');
854
+ }
855
+ const res = run('xdotool', ['mousemove', String(x), String(y), 'mousedown', '1', 'mousemove', String(x2), String(y2), 'mouseup', '1']);
856
+ return res.ok ? { ok: true, from: [x, y], to: [x2, y2] } : fail(res.stderr.trim() || 'xdotool failed');
857
+ },
858
+
859
+ scroll(amount, x = null, y = null) {
860
+ const clicks = Math.trunc(amount) || 1;
861
+ if (isWin) {
862
+ const r = winRun({ action: 'scroll', amount: clicks, x, y });
863
+ return r.ok ? { ok: true, amount: clicks } : fail(r.error);
864
+ }
865
+ const button = clicks > 0 ? '4' : '5';
866
+ const args = [];
867
+ if (x !== null && y !== null) args.push('mousemove', String(x), String(y));
868
+ args.push('click', '--repeat', String(Math.abs(clicks)), button);
869
+ if (isMac) {
870
+ if (!which('cliclick')) return fail('mouse needs cliclick: brew install cliclick');
871
+ const res = run('cliclick', [`w:${clicks > 0 ? 'up' : 'down'}`]);
872
+ return res.ok ? { ok: true, amount: clicks } : fail(res.stderr.trim());
873
+ }
874
+ const res = run('xdotool', args);
875
+ return res.ok ? { ok: true, amount: clicks } : fail(res.stderr.trim() || 'xdotool failed');
876
+ },
877
+
878
+ typeText(text, x = null, y = null) {
879
+ if (isWin) {
880
+ const r = winRun({ action: 'type', text, x, y }, 60000);
881
+ return r.ok ? { ok: true, chars: text.length, typed: r.typed ?? text.length } : fail(r.error);
882
+ }
883
+ if (x !== null && y !== null) this.click(x, y);
884
+ if (isMac) {
885
+ const res = osa(`tell application "System Events" to keystroke ${JSON.stringify(text)}`);
886
+ return res.ok ? { ok: true, chars: text.length, typed: text.length } : fail(res.error);
887
+ }
888
+ const res = run('xdotool', ['type', '--clearmodifiers', '--delay', '12', text], { timeout: 120000 });
889
+ return res.ok ? { ok: true, chars: text.length, typed: text.length } : fail(res.stderr.trim() || 'xdotool type failed');
890
+ },
891
+
892
+ pressKeys(combo) {
893
+ const sequence = keyboardSequence(combo);
894
+ if (!sequence.length) return fail(`unreadable key combination: ${combo}`);
895
+ if (isWin) {
896
+ const r = winRun({ action: 'keys', sequence });
897
+ return r.ok ? { ok: true, keys: combo } : fail(r.error);
898
+ }
899
+ if (isMac) {
900
+ const res = macKeys(combo);
901
+ return res.ok ? { ok: true, keys: combo } : fail(res.error);
902
+ }
903
+ const mapped = String(combo)
904
+ .toLowerCase()
905
+ .split('+')
906
+ .map((p) => (p === 'ctrl' ? 'ctrl' : p))
907
+ .join('+');
908
+ const res = run('xdotool', ['key', '--clearmodifiers', mapped]);
909
+ return res.ok ? { ok: true, keys: combo } : fail(res.stderr.trim() || 'xdotool key failed');
910
+ },
911
+
912
+ screenshot(dest, { annotate = false } = {}) {
913
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
914
+ if (isWin) {
915
+ const r = winRun({ action: 'shot', path: dest, annotate }, 90000);
916
+ return r.ok
917
+ ? { ok: true, file: dest, width: r.width, height: r.height, mime: 'image/png', annotated: Boolean(annotate), elements: r.elements || 0 }
918
+ : fail(r.error);
919
+ }
920
+ if (isMac) {
921
+ const res = run('screencapture', ['-x', '-t', 'png', dest], { timeout: 30000 });
922
+ if (!res.ok) return fail(res.stderr.trim() || 'screencapture failed');
923
+ const size = this.screenSize();
924
+ return { ok: true, file: dest, width: size.width, height: size.height, mime: 'image/png' };
925
+ }
926
+ for (const [tool, args] of IMAGE_TOOLS) {
927
+ if (!which(tool)) continue;
928
+ const res = run(tool, args(dest), { timeout: 30000 });
929
+ if (res.ok && fs.existsSync(dest)) {
930
+ const size = this.screenSize();
931
+ return { ok: true, file: dest, width: size.width, height: size.height, mime: 'image/png' };
932
+ }
933
+ }
934
+ return fail('no screenshot tool found: install imagemagick (import), scrot, grim or gnome-screenshot');
935
+ },
936
+
937
+ listApps() {
938
+ if (isWin) {
939
+ const r = winRun({ action: 'apps' }, 60000);
940
+ return r.ok ? { ok: true, apps: r.apps || [] } : fail(r.error);
941
+ }
942
+ const out = [];
943
+ if (isMac) {
944
+ for (const dir of ['/Applications', '/System/Applications']) {
945
+ try {
946
+ for (const entry of fs.readdirSync(dir)) {
947
+ if (entry.endsWith('.app')) out.push({ name: entry.replace(/\.app$/, ''), app_id: path.join(dir, entry) });
948
+ }
949
+ } catch {
950
+ /* ignore */
951
+ }
952
+ }
953
+ return { ok: true, apps: out };
954
+ }
955
+ const dirs = ['/usr/share/applications', '/usr/local/share/applications', path.join(os.homedir(), '.local/share/applications')];
956
+ for (const dir of dirs) {
957
+ let files = [];
958
+ try {
959
+ files = fs.readdirSync(dir).filter((f) => f.endsWith('.desktop'));
960
+ } catch {
961
+ continue;
962
+ }
963
+ for (const f of files) {
964
+ try {
965
+ const text = fs.readFileSync(path.join(dir, f), 'utf8');
966
+ if (/^NoDisplay=true/m.test(text)) continue;
967
+ const name = /^Name=(.+)$/m.exec(text);
968
+ if (name) out.push({ name: name[1].trim(), app_id: f.replace(/\.desktop$/, '') });
969
+ } catch {
970
+ /* ignore */
971
+ }
972
+ }
973
+ }
974
+ return { ok: true, apps: out };
975
+ },
976
+
977
+ sysinfo() {
978
+ const screen = this.screenSize();
979
+ return {
980
+ ok: true,
981
+ system: {
982
+ windows_version: isWin ? os.release() : `${process.platform} ${os.release()}`,
983
+ os_version: os.release(),
984
+ screen_size: { width: screen.width, height: screen.height },
985
+ user_account_type: 'Local Account',
986
+ default_language: process.env.LANG || 'en',
987
+ dpi_scaling: 1,
988
+ },
989
+ };
990
+ },
991
+ };
992
+
993
+ module.exports = { ...automation, keyboardSequence, fuzzyScore, pickWindow };
package/src/local.js CHANGED
@@ -1,6 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const { decide } = require('./approval');
4
+ const automation = require('./automation');
5
+ const { stamp } = require('./clipboard');
4
6
  const session = require('./session');
5
7
 
6
8
  const fs = require('node:fs');
@@ -501,17 +503,161 @@ async function handleLocalUi(bridge, taskId, request, opts) {
501
503
  }
502
504
 
503
505
  // control / type / keystroke / screenshot / app / state / apps / multi_*
504
- const error = t(
505
- lang,
506
- `operazione "${op}" non disponibile nel client da terminale: serve il client grafico ArenaCode`,
507
- `operation "${op}" is not available in the terminal client: the ArenaCode desktop client is required`,
508
- `la operación "${op}" no está disponible en el cliente de terminal: requiere el cliente gráfico ArenaCode`,
509
- );
510
- await bridge.localResult(taskId, { rid, output: JSON.stringify({ ok: false, error }), code: 1 });
511
- write(` ${c.red('✗')} ${c.gray(op)} ${c.gray('— ' + error)}`);
506
+ const result = await runUiAction(bridge, taskId, request, opts);
507
+ const ok = result && result.ok !== false;
508
+ await bridge.localResult(taskId, {
509
+ rid,
510
+ output: JSON.stringify(result),
511
+ code: ok ? 0 : 1,
512
+ });
513
+ const line = ok
514
+ ? `${c.green('✓')} ${c.gray(op)}${request.action ? c.gray(` ${request.action}`) : ''}`
515
+ : `${c.red('✗')} ${c.gray(op)} ${c.gray('— ' + (result && result.error ? result.error : 'failed'))}`;
516
+ write(` ${line}`);
512
517
  return true;
513
518
  }
514
519
 
520
+ /** Computer Agent actions: mouse, keyboard, screen, windows, apps. */
521
+ async function runUiAction(bridge, taskId, request, opts) {
522
+ const { op, rid } = request;
523
+ const lang = opts.lang;
524
+ const toolkit = automation.available();
525
+ const json = (text) => {
526
+ try {
527
+ return JSON.parse(text || '{}');
528
+ } catch {
529
+ return {};
530
+ }
531
+ };
532
+
533
+ if (op === 'state') {
534
+ // the desktop's windows-use returns the UI Automation tree; so do we on Windows
535
+ const state = automation.state();
536
+ if (!state.ok) return state;
537
+ return state;
538
+ }
539
+
540
+ if (op === 'sysinfo') {
541
+ const info = automation.sysinfo();
542
+ return {
543
+ ...info,
544
+ os: `${process.platform} ${os.release()}`,
545
+ hostname: os.hostname(),
546
+ arch: os.arch(),
547
+ cpus: os.cpus().length,
548
+ memory_mb: Math.round(os.totalmem() / 1048576),
549
+ node: process.version,
550
+ };
551
+ }
552
+
553
+ if (op === 'apps') return automation.listApps();
554
+
555
+ if (!toolkit.ok) {
556
+ return {
557
+ ok: false,
558
+ error:
559
+ toolkit.error ||
560
+ t(lang, 'automazione non disponibile su questo sistema', 'automation is not available on this system', 'la automatización no está disponible en este sistema'),
561
+ };
562
+ }
563
+
564
+ if (op === 'control') {
565
+ const { action, x, y, x2, y2, amount, button } = request;
566
+ switch (action) {
567
+ case 'move':
568
+ return automation.moveMouse(x, y);
569
+ case 'click':
570
+ return automation.click(x, y, { button: button || 'left', count: 1 });
571
+ case 'dblclick':
572
+ return automation.click(x, y, { button: button || 'left', count: 2 });
573
+ case 'rightclick':
574
+ return automation.click(x, y, { button: 'right', count: 1 });
575
+ case 'down':
576
+ return automation.mouseButton(x, y, button || 'left', true);
577
+ case 'up':
578
+ return automation.mouseButton(x, y, button || 'left', false);
579
+ case 'drag':
580
+ return automation.drag(x, y, x2, y2);
581
+ case 'scroll':
582
+ return automation.scroll(amount ?? 1, x ?? null, y ?? null);
583
+ default:
584
+ return { ok: false, error: `unknown control action: ${action}` };
585
+ }
586
+ }
587
+
588
+ if (op === 'keystroke') {
589
+ return automation.pressKeys(request.keys || '');
590
+ }
591
+
592
+ if (op === 'type') {
593
+ let text = request.text;
594
+ if (request.payload || text === undefined) {
595
+ const res = await bridge.uiPayload(taskId, rid);
596
+ text = res.status === 200 ? (res.json && (res.json.text ?? res.json.payload ?? '')) : '';
597
+ if (!text) return { ok: false, error: 'the typed text could not be read from the bridge' };
598
+ }
599
+ return automation.typeText(String(text), request.x ?? null, request.y ?? null);
600
+ }
601
+
602
+ if (op === 'app') {
603
+ const name = request.text || request.name || '';
604
+ const action = request.action || 'launch';
605
+ if (action === 'launch') return automation.launchApp(name);
606
+ if (action === 'switch') return automation.focusWindow(name);
607
+ if (action === 'resize') {
608
+ return automation.resizeWindow(name, request.x ?? 0, request.y ?? 0, request.x2 ?? 800, request.y2 ?? 600);
609
+ }
610
+ return { ok: false, error: `unknown app action: ${action}` };
611
+ }
612
+
613
+ if (op === 'screenshot') {
614
+ const dir = path.join(os.tmpdir(), 'arenacode-shots');
615
+ fs.mkdirSync(dir, { recursive: true });
616
+ const file = path.join(dir, `shot-${stamp()}.png`);
617
+ const shot = automation.screenshot(file, { annotate: Boolean(request.annotated) });
618
+ if (!shot.ok) return shot;
619
+ const buf = fs.readFileSync(shot.file);
620
+ // the agent runs in the sandbox: the image travels through the bridge workspace
621
+ const up = await bridge.upload(`uploads/${stamp()}/shot-${stamp()}.png`, buf);
622
+ const remote = up.status === 200 ? up.json.path : null;
623
+ const out = {
624
+ ok: true,
625
+ mime: 'image/png',
626
+ width: shot.width,
627
+ height: shot.height,
628
+ file: remote || shot.file,
629
+ local_file: shot.file,
630
+ annotated: Boolean(request.annotated),
631
+ };
632
+ if (buf.length <= 2 * 1024 * 1024) out.image = buf.toString('base64');
633
+ return out;
634
+ }
635
+
636
+ if (op === 'multi_select') {
637
+ const { elements = [], press_ctrl = true } = json(request.args_json);
638
+ const modifier = press_ctrl ? automation.pressKeys('ctrl') : null;
639
+ if (modifier && !modifier.ok) return modifier;
640
+ for (const [x, y] of elements) {
641
+ const r = automation.click(x, y, { button: 'left', count: 1 });
642
+ if (!r.ok) return r;
643
+ }
644
+ return { ok: true, selected: elements.length };
645
+ }
646
+
647
+ if (op === 'multi_edit') {
648
+ const { elements = [] } = json(request.args_json);
649
+ let typed = 0;
650
+ for (const [x, y, text] of elements) {
651
+ const r = automation.typeText(String(text), x, y);
652
+ if (!r.ok) return r;
653
+ typed += 1;
654
+ }
655
+ return { ok: true, fields: typed };
656
+ }
657
+
658
+ return { ok: false, error: `unknown ui operation: ${op}` };
659
+ }
660
+
515
661
  function available() {
516
662
  return os.release();
517
663
  }
@@ -536,6 +682,7 @@ async function handleLocalFile(bridge, taskId, request, opts) {
536
682
  }
537
683
 
538
684
  module.exports = {
685
+ runUiAction,
539
686
  REFUSED,
540
687
  resolveInRoot,
541
688
  runShell,
package/src/tui/app.js CHANGED
@@ -1048,10 +1048,17 @@ class App {
1048
1048
  item.state = 'done';
1049
1049
  return;
1050
1050
  }
1051
- const error = `operation "${op}" is not available in the terminal client: the ArenaCode desktop client is required`;
1052
- await this.bridge.localResult(taskId, { rid: payload.rid, output: JSON.stringify({ ok: false, error }), code: 1 });
1053
- item.state = 'failed';
1054
- item.meta = 'needs the desktop client';
1051
+ // mouse, keyboard, screen, windows and apps: the automation backends
1052
+ const { runUiAction } = require('../local');
1053
+ const result = await runUiAction(this.bridge, taskId, payload, { lang: this.lang });
1054
+ const ok = result && result.ok !== false;
1055
+ await this.bridge.localResult(taskId, {
1056
+ rid: payload.rid,
1057
+ output: JSON.stringify(result),
1058
+ code: ok ? 0 : 1,
1059
+ });
1060
+ item.state = ok ? 'done' : 'failed';
1061
+ if (!ok && result && result.error) item.detail = String(result.error);
1055
1062
  }
1056
1063
  }
1057
1064
 
@@ -1276,13 +1283,14 @@ class App {
1276
1283
  })
1277
1284
  .map((line) => R.spaces(2) + line);
1278
1285
 
1279
- const bodyH = Math.max(1, H - prompt.lines.length - 2);
1286
+ const bodyH = Math.max(1, H - prompt.lines.length - 3);
1280
1287
  const maxScroll = Math.max(0, chat.length - bodyH);
1281
1288
  st.scroll = Math.min(Math.max(0, st.scroll), maxScroll);
1282
1289
  const start = Math.max(0, chat.length - bodyH - st.scroll);
1283
1290
  const visible = chat.slice(start, start + bodyH);
1284
1291
  while (visible.length < bodyH) visible.unshift('');
1285
1292
  frame.push(...visible);
1293
+ frame.push('');
1286
1294
  const promptStart = frame.length;
1287
1295
  frame.push(...prompt.lines);
1288
1296
  frame.push('');
@@ -1308,7 +1316,11 @@ class App {
1308
1316
  this.term.draw(frame);
1309
1317
 
1310
1318
  if (!cursor || st.overlay || st.approval || st.prompt) this.term.hideCursor();
1311
- else this.term.placeCursor(cursor.row, Math.min(W - 1, Math.max(0, cursor.col)));
1319
+ else {
1320
+ // frame rows/cols are 0-based, the terminal's CUP escape is 1-based
1321
+ const col = Math.min(W - 1, Math.max(0, cursor.col));
1322
+ this.term.placeCursor(cursor.row + 1, col + 1);
1323
+ }
1312
1324
 
1313
1325
  this.rendering = false;
1314
1326
  if (this.dirty) setTimeout(() => this.maybeRender(), 16);