@ai-agent-forge/plugin-desktop-windows-input 0.88.0 → 0.88.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.
@@ -19,6 +19,12 @@ const SUPPORTED_ACTIONS = [
19
19
  "key",
20
20
  "scroll",
21
21
  ];
22
+ /** Contract limit: a delivered screenshot must stay at or below 4 MiB decoded. */
23
+ const DESKTOP_SCREENSHOT_MAX_DECODED_BYTES = 4 * 1024 * 1024;
24
+ /** Spawn buffer headroom above the contract limit so the limit is enforced here, not by maxBuffer. */
25
+ const SCREENSHOT_SPAWN_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
26
+ /** Screens wider than this are scaled down proportionally before PNG encoding. */
27
+ const DESKTOP_SCREENSHOT_MAX_WIDTH_PX = 1280;
22
28
  const error = (code, message, causeCode) => ({
23
29
  version: 1,
24
30
  code,
@@ -38,6 +44,7 @@ const rectangle = (raw) => ({
38
44
  width: Number.isFinite(raw.width) ? Number(raw.width) : 0,
39
45
  height: Number.isFinite(raw.height) ? Number(raw.height) : 0,
40
46
  });
47
+ const screenshotDimension = (value) => typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value > 0 ? value : 0;
41
48
  const QUERY_SCRIPT = `
42
49
  try {
43
50
  Add-Type -AssemblyName UIAutomationClient -ErrorAction Stop
@@ -226,29 +233,48 @@ public static class AgentForgeNativeText {
226
233
  } catch { throw 'Target text update failed' }
227
234
  }
228
235
  } elseif ($payload.action.kind -eq 'key') {
229
- $keyText = [string]$payload.action.key
230
- $hasSendKeysSyntax = $keyText -match '[+%^{}]'
231
- if (-not $hasSendKeysSyntax -and $controlHandle -gt 0) {
232
- # Plain text with a real control handle: deliver synchronously through
233
- # SendMessage WM_CHAR. Queued input (SendInput / WScript SendKeys) never
234
- # reaches GUI windows in non-interactive or remote desktop sessions, while
235
- # synchronous SendMessage does — the same path that type (WM_SETTEXT) and
236
- # click (BM_CLICK) already use. Keyboard focus is not required.
236
+ # Real keyboard-queue input: keybd_event posts to the same queue as the
237
+ # physical keyboard. The native-file-picker flow depends on this — the
238
+ # dialog is focused but invisible to UIA and not addressable by window
239
+ # messages sent to the owner window. Named keys and Ctrl/Alt/Shift
240
+ # combinations go through virtual-key codes; anything else must be a
241
+ # single character.
242
+ try {
237
243
  Add-Type @'
238
244
  using System;
239
245
  using System.Runtime.InteropServices;
240
- public static class AgentForgeNativeKey {
241
- [DllImport("user32.dll", SetLastError=true)] public static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
246
+ public static class AgentForgeNativeKeyQueue {
247
+ [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo);
242
248
  }
243
249
  '@
244
- foreach ($ch in $keyText.ToCharArray()) {
245
- [AgentForgeNativeKey]::SendMessage([IntPtr]$controlHandle, 0x0102, [IntPtr][char]$ch, [IntPtr]::Zero) | Out-Null
246
- }
247
- } else {
248
- if ($payload.target.controlId) { $target.SetFocus() }
249
- $wshell = New-Object -ComObject WScript.Shell
250
- $wshell.SendKeys($keyText)
251
- }
250
+ } catch {}
251
+ $vkMap = @{
252
+ 'enter'=0x0D; 'return'=0x0D; 'tab'=0x09; 'escape'=0x1B; 'esc'=0x1B;
253
+ 'backspace'=0x08; 'delete'=0x2E; 'space'=0x20;
254
+ 'up'=0x26; 'down'=0x28; 'left'=0x25; 'right'=0x27;
255
+ 'arrowup'=0x26; 'arrowdown'=0x28; 'arrowleft'=0x25; 'arrowright'=0x27;
256
+ 'home'=0x24; 'end'=0x23; 'pageup'=0x21; 'pagedown'=0x22; 'insert'=0x2D
257
+ }
258
+ $modMap = @{ 'ctrl'=0x11; 'control'=0x11; 'alt'=0x12; 'shift'=0x10; 'meta'=0x5B; 'win'=0x5B; 'cmd'=0x5B }
259
+ $keyText = ([string]$payload.action.key).Trim()
260
+ $mods = @()
261
+ $base = $null
262
+ foreach ($p in ($keyText -split '+')) {
263
+ $lp = $p.ToLower()
264
+ if ($modMap.ContainsKey($lp)) { $mods += $modMap[$lp] } else { $base = $lp }
265
+ }
266
+ $vk = $null
267
+ if ($null -ne $base -and $vkMap.ContainsKey($base)) { $vk = $vkMap[$base] }
268
+ elseif ($null -ne $base -and $base.Length -eq 1) {
269
+ $upper = [char]::ToUpper($base[0])
270
+ $code = [int]$upper
271
+ if (($code -ge 0x20 -and $code -le 0x5A) -or ($code -ge 0x30 -and $code -le 0x39)) { $vk = $code }
272
+ }
273
+ if ($null -eq $vk) { throw ('Unsupported key: ' + $keyText) }
274
+ foreach ($m in $mods) { [AgentForgeNativeKeyQueue]::keybd_event([byte]$m, 0, 0, [UIntPtr]::Zero) }
275
+ [AgentForgeNativeKeyQueue]::keybd_event([byte]$vk, 0, 0, [UIntPtr]::Zero)
276
+ [AgentForgeNativeKeyQueue]::keybd_event([byte]$vk, 0, 2, [UIntPtr]::Zero)
277
+ for ($i = $mods.Count - 1; $i -ge 0; $i--) { [AgentForgeNativeKeyQueue]::keybd_event([byte]$mods[$i], 0, 2, [UIntPtr]::Zero) }
252
278
  } elseif ($payload.action.kind -eq 'scroll') {
253
279
  Add-Type @'
254
280
  using System;
@@ -282,6 +308,128 @@ public static class AgentForgeNativeScroll {
282
308
  $reason = if ($message -match 'ValuePattern|LegacyIAccessiblePattern') { 'value_pattern_unavailable' } elseif ($message -match 'InvokePattern') { 'invoke_pattern_unavailable' } elseif ($message -match 'SetFocus') { 'focus_failed' } else { 'uia_action_exception' }
283
309
  [pscustomobject]@{ ok=$false; code=$code; reason=$reason } | ConvertTo-Json -Compress
284
310
  }`;
311
+ /**
312
+ * Foreground (no-target) type/key: real keyboard-queue input for surfaces UIA
313
+ * cannot see — the native file dialog's focused name box being the canonical
314
+ * case. Named keys go through virtual-key codes with modifier support; other
315
+ * text is typed as VK_PACKET unicode events.
316
+ */
317
+ const FOREGROUND_INPUT_SCRIPT = `
318
+ try {
319
+ $payload = $env:AGENT_FORGE_UIA_INPUT | ConvertFrom-Json
320
+ try {
321
+ Add-Type @'
322
+ using System;
323
+ using System.Runtime.InteropServices;
324
+ public static class AgentForgeForegroundKeyQueue {
325
+ [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo);
326
+ }
327
+ '@
328
+ } catch {}
329
+ $vkMap = @{
330
+ 'enter'=0x0D; 'return'=0x0D; 'tab'=0x09; 'escape'=0x1B; 'esc'=0x1B;
331
+ 'backspace'=0x08; 'delete'=0x2E; 'space'=0x20;
332
+ 'up'=0x26; 'down'=0x28; 'left'=0x25; 'right'=0x27;
333
+ 'arrowup'=0x26; 'arrowdown'=0x28; 'arrowleft'=0x25; 'arrowright'=0x27;
334
+ 'home'=0x24; 'end'=0x23; 'pageup'=0x21; 'pagedown'=0x22; 'insert'=0x2D
335
+ }
336
+ $modMap = @{ 'ctrl'=0x11; 'control'=0x11; 'alt'=0x12; 'shift'=0x10; 'meta'=0x5B; 'win'=0x5B; 'cmd'=0x5B }
337
+ if ($payload.action.kind -eq 'type') {
338
+ foreach ($ch in ([string]$payload.action.text).ToCharArray()) {
339
+ [AgentForgeForegroundKeyQueue]::keybd_event(0xE7, [byte][int]$ch, 0, [UIntPtr]::Zero)
340
+ [AgentForgeForegroundKeyQueue]::keybd_event(0xE7, [byte][int]$ch, 2, [UIntPtr]::Zero)
341
+ }
342
+ } elseif ($payload.action.kind -eq 'key') {
343
+ $keyText = ([string]$payload.action.key).Trim()
344
+ $mods = @()
345
+ $base = $null
346
+ foreach ($p in ($keyText -split '\\+')) {
347
+ $lp = $p.ToLower()
348
+ if ($modMap.ContainsKey($lp)) { $mods += $modMap[$lp] } else { $base = $lp }
349
+ }
350
+ $vk = $null
351
+ if ($null -ne $base -and $vkMap.ContainsKey($base)) { $vk = $vkMap[$base] }
352
+ elseif ($null -ne $base -and $base.Length -eq 1) {
353
+ $upper = [char]::ToUpper($base[0])
354
+ $code = [int]$upper
355
+ if (($code -ge 0x20 -and $code -le 0x5A) -or ($code -ge 0x30 -and $code -le 0x39)) { $vk = $code }
356
+ }
357
+ if ($null -eq $vk) { throw ('Unsupported key: ' + $keyText) }
358
+ foreach ($m in $mods) { [AgentForgeForegroundKeyQueue]::keybd_event([byte]$m, 0, 0, [UIntPtr]::Zero) }
359
+ [AgentForgeForegroundKeyQueue]::keybd_event([byte]$vk, 0, 0, [UIntPtr]::Zero)
360
+ [AgentForgeForegroundKeyQueue]::keybd_event([byte]$vk, 0, 2, [UIntPtr]::Zero)
361
+ for ($i = $mods.Count - 1; $i -ge 0; $i--) { [AgentForgeForegroundKeyQueue]::keybd_event([byte]$mods[$i], 0, 2, [UIntPtr]::Zero) }
362
+ } else { throw 'Foreground input supports only type and key' }
363
+ [pscustomobject]@{ ok=$true } | ConvertTo-Json -Compress
364
+ } catch {
365
+ [pscustomobject]@{ ok=$false; code='action_failed'; reason=[string]$_.Exception.Message } | ConvertTo-Json -Compress
366
+ }`;
367
+ const COORDINATE_CLICK_SCRIPT = `
368
+ try {
369
+ $payload = $env:AGENT_FORGE_UIA_INPUT | ConvertFrom-Json
370
+ $x = [int]$payload.target.x
371
+ $y = [int]$payload.target.y
372
+ Add-Type @'
373
+ using System;
374
+ using System.Runtime.InteropServices;
375
+ public static class AgentForgeCoordinateClick {
376
+ [DllImport("user32.dll", SetLastError=true)]
377
+ public static extern bool SetCursorPos(int X, int Y);
378
+ [DllImport("user32.dll")]
379
+ public static extern void mouse_event(uint dwFlags, int dx, int dy, int dwData, UIntPtr dwExtraInfo);
380
+ }
381
+ '@
382
+ if (-not [AgentForgeCoordinateClick]::SetCursorPos($x, $y)) { throw 'Coordinate click could not move the cursor' }
383
+ [AgentForgeCoordinateClick]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero)
384
+ [AgentForgeCoordinateClick]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero)
385
+ [pscustomobject]@{ ok=$true } | ConvertTo-Json -Compress
386
+ } catch {
387
+ [pscustomobject]@{ ok=$false; reason='uia_action_exception' } | ConvertTo-Json -Compress
388
+ }`;
389
+ const SCREENSHOT_SCRIPT = `
390
+ try {
391
+ Add-Type @'
392
+ using System;
393
+ using System.Runtime.InteropServices;
394
+ public static class AgentForgeScreenDpi {
395
+ [DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
396
+ }
397
+ '@
398
+ [void][AgentForgeScreenDpi]::SetProcessDPIAware()
399
+ Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
400
+ Add-Type -AssemblyName System.Drawing -ErrorAction Stop
401
+ $bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
402
+ $originalWidth = [int]$bounds.Width
403
+ $originalHeight = [int]$bounds.Height
404
+ if ($originalWidth -le 0 -or $originalHeight -le 0) { throw 'Primary display bounds are unavailable' }
405
+ $width = if ($originalWidth -gt ${DESKTOP_SCREENSHOT_MAX_WIDTH_PX}) { ${DESKTOP_SCREENSHOT_MAX_WIDTH_PX} } else { $originalWidth }
406
+ $height = [int][math]::Round([double]$originalHeight * $width / $originalWidth)
407
+ if ($height -lt 1) { $height = 1 }
408
+ $source = New-Object System.Drawing.Bitmap($originalWidth, $originalHeight)
409
+ $target = $source
410
+ try {
411
+ $graphics = [System.Drawing.Graphics]::FromImage($source)
412
+ try { $graphics.CopyFromScreen($bounds.X, $bounds.Y, 0, 0, [System.Drawing.Size]::new($originalWidth, $originalHeight)) } finally { $graphics.Dispose() }
413
+ if ($width -ne $originalWidth -or $height -ne $originalHeight) {
414
+ $target = New-Object System.Drawing.Bitmap($width, $height)
415
+ $resized = [System.Drawing.Graphics]::FromImage($target)
416
+ try {
417
+ $resized.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
418
+ $resized.DrawImage($source, 0, 0, $width, $height)
419
+ } finally { $resized.Dispose() }
420
+ }
421
+ $stream = New-Object System.IO.MemoryStream
422
+ try {
423
+ $target.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
424
+ [pscustomobject]@{ ok=$true; data=[Convert]::ToBase64String($stream.ToArray()); width=$width; height=$height; originalWidth=$originalWidth; originalHeight=$originalHeight } | ConvertTo-Json -Compress -Depth 4
425
+ } finally { $stream.Dispose() }
426
+ } finally {
427
+ if (-not [object]::ReferenceEquals($target, $source)) { $target.Dispose() }
428
+ $source.Dispose()
429
+ }
430
+ } catch {
431
+ [pscustomobject]@{ ok=$false; reason='screenshot_exception' } | ConvertTo-Json -Compress -Depth 4
432
+ }`;
285
433
  const parseJson = (stdout) => {
286
434
  try {
287
435
  const parsed = JSON.parse(stdout.trim());
@@ -390,23 +538,49 @@ export class WindowsUiAutomationDriver {
390
538
  };
391
539
  if (!SUPPORTED_ACTIONS.includes(request.action.kind))
392
540
  return unsupported(request.operationId, "The selected Windows UI Automation adapter does not support this action", "action-unsupported");
393
- if (request.action.target === undefined)
541
+ const postconditions = request.postconditions ?? [];
542
+ if (postconditions.length > 0 && request.reobserveAfterAction === false)
543
+ return this.failed(request.operationId, "reobserve_required", "Postconditions require an action re-observation");
544
+ // Coordinate clicks address raw screen pixels; they bypass observed target
545
+ // identity entirely and are click-only by contract.
546
+ if (request.action.target !== undefined && request.action.target.kind === "coordinates") {
547
+ if (request.action.kind !== "click")
548
+ return unsupported(request.operationId, "The selected Windows UI Automation adapter does not support this action", "action-unsupported");
549
+ const raw = this.run(COORDINATE_CLICK_SCRIPT, {
550
+ action: request.action,
551
+ target: request.action.target,
552
+ });
553
+ return this.completeAction(request, postconditions, raw);
554
+ }
555
+ // No observed target: type/key go to the real keyboard queue addressed at
556
+ // the current focus — the only path that reaches UIA-invisible surfaces
557
+ // like the native file dialog.
558
+ if (request.action.target === undefined) {
559
+ if (request.action.kind === "type" && typeof request.action.text === "string") {
560
+ const raw = this.run(FOREGROUND_INPUT_SCRIPT, { action: request.action });
561
+ return this.completeAction(request, postconditions, raw);
562
+ }
563
+ if (request.action.kind === "key" && typeof request.action.key === "string") {
564
+ const raw = this.run(FOREGROUND_INPUT_SCRIPT, { action: request.action });
565
+ return this.completeAction(request, postconditions, raw);
566
+ }
394
567
  return this.failed(request.operationId, "target_required", "This desktop action requires an observed target");
568
+ }
395
569
  if (request.action.kind === "type" && typeof request.action.text !== "string")
396
570
  return this.failed(request.operationId, "text_required", "Type action requires text");
397
571
  if (request.action.kind === "key" && typeof request.action.key !== "string")
398
572
  return this.failed(request.operationId, "key_required", "Key action requires a key sequence");
399
573
  if (request.action.kind === "scroll" && typeof request.action.text !== "string")
400
574
  return this.failed(request.operationId, "direction_required", "Scroll action requires an up/down direction");
401
- const postconditions = request.postconditions ?? [];
402
- if (postconditions.length > 0 && request.reobserveAfterAction === false)
403
- return this.failed(request.operationId, "reobserve_required", "Postconditions require an action re-observation");
404
575
  const target = this.resolveTarget(request.sessionId, request.action.target);
405
576
  if (target === undefined)
406
577
  return this.failed(request.operationId, this.observedSessions.has(request.sessionId) ? "stale_target" : "target_not_found", this.observedSessions.has(request.sessionId)
407
578
  ? "Target identity is stale; reobserve the desktop before retrying"
408
579
  : "Target identity was not observed by UI Automation");
409
580
  const raw = this.run(ACTION_SCRIPT, { action: request.action, target: target ?? {} });
581
+ return this.completeAction(request, postconditions, raw);
582
+ }
583
+ completeAction(request, postconditions, raw) {
410
584
  if (raw === undefined)
411
585
  return unsupported(request.operationId, "Windows UI Automation action failed");
412
586
  if (raw.ok !== true) {
@@ -464,7 +638,63 @@ export class WindowsUiAutomationDriver {
464
638
  this.invalidateObservation(sessionId);
465
639
  return stopped;
466
640
  }
467
- run(script, payload) {
641
+ /** Capture the primary display through GDI; the PNG is scaled to a 1280px width cap. */
642
+ screenshot() {
643
+ if (this.platform !== "win32")
644
+ return {
645
+ version: 1,
646
+ source: "windows-gdi",
647
+ status: "unsupported",
648
+ error: error("platform_unavailable", "Windows GDI screen capture requires win32", "platform-unavailable"),
649
+ };
650
+ const raw = this.run(SCREENSHOT_SCRIPT, undefined, SCREENSHOT_SPAWN_MAX_BUFFER_BYTES);
651
+ if (raw === undefined)
652
+ return {
653
+ version: 1,
654
+ source: "windows-gdi",
655
+ status: "failed",
656
+ error: error("screenshot_failed", "Windows GDI screen capture failed", "screenshot-capture-unavailable"),
657
+ };
658
+ if (raw.ok !== true)
659
+ return {
660
+ version: 1,
661
+ source: "windows-gdi",
662
+ status: "failed",
663
+ error: error("screenshot_failed", "Windows GDI screen capture failed", "screenshot-exception"),
664
+ };
665
+ const data = typeof raw.data === "string" ? raw.data : "";
666
+ const dimensions = {
667
+ width: screenshotDimension(raw.width),
668
+ height: screenshotDimension(raw.height),
669
+ originalWidth: screenshotDimension(raw.originalWidth),
670
+ originalHeight: screenshotDimension(raw.originalHeight),
671
+ };
672
+ if (Math.floor((data.length * 3) / 4) > DESKTOP_SCREENSHOT_MAX_DECODED_BYTES)
673
+ return {
674
+ version: 1,
675
+ source: "windows-gdi",
676
+ status: "failed",
677
+ error: error("screenshot-too-large", "The captured screenshot exceeds the 4 MiB decoded-size limit", "screenshot-too-large"),
678
+ };
679
+ if (data.length === 0 || Object.values(dimensions).some((value) => value <= 0))
680
+ return {
681
+ version: 1,
682
+ source: "windows-gdi",
683
+ status: "failed",
684
+ error: error("screenshot_failed", "Windows GDI screen capture returned an invalid payload", "screenshot-invalid-payload"),
685
+ };
686
+ return {
687
+ version: 1,
688
+ source: "windows-gdi",
689
+ status: "succeeded",
690
+ mimeType: "image/png",
691
+ data,
692
+ ...dimensions,
693
+ scale: dimensions.originalWidth / dimensions.width,
694
+ capturedAt: new Date().toISOString(),
695
+ };
696
+ }
697
+ run(script, payload, maxBuffer) {
468
698
  if (this.platform !== "win32")
469
699
  return undefined;
470
700
  const env = {};
@@ -475,7 +705,13 @@ export class WindowsUiAutomationDriver {
475
705
  }
476
706
  if (payload !== undefined)
477
707
  env.AGENT_FORGE_UIA_INPUT = JSON.stringify(payload);
478
- const result = this.spawn(this.command, ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { encoding: "utf8", timeout: this.timeoutMs, windowsHide: true, env });
708
+ const result = this.spawn(this.command, ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], {
709
+ encoding: "utf8",
710
+ timeout: this.timeoutMs,
711
+ windowsHide: true,
712
+ env,
713
+ ...(maxBuffer === undefined ? {} : { maxBuffer }),
714
+ });
479
715
  if (result.error !== undefined || result.status !== 0)
480
716
  return undefined;
481
717
  return parseJson(result.stdout);
@@ -563,6 +799,8 @@ export class WindowsUiAutomationDriver {
563
799
  return observation;
564
800
  }
565
801
  resolveTarget(sessionId, target) {
802
+ if (target.kind === "coordinates")
803
+ return undefined;
566
804
  const identity = target.kind === "control" ? target.controlId : target.windowId;
567
805
  const record = this.targets.get(this.targetKey(sessionId, target.kind, identity));
568
806
  if (record === undefined ||
@@ -612,6 +850,8 @@ export class WindowsUiAutomationDriver {
612
850
  return false;
613
851
  return window.title === condition.expected;
614
852
  }
853
+ if (target.kind !== "control")
854
+ return false;
615
855
  const control = observation.controls.find((item) => {
616
856
  if (item.controlId !== target.controlId)
617
857
  return false;
@@ -674,6 +914,18 @@ export class DesktopWindowsAdapter {
674
914
  stop(sessionId) {
675
915
  return this.driver.stop(sessionId);
676
916
  }
917
+ /** Delegates to the driver when it implements DesktopScreenshotDriverV1; structured unsupported otherwise. */
918
+ screenshot() {
919
+ const driver = this.driver;
920
+ if (typeof driver.screenshot !== "function")
921
+ return {
922
+ version: 1,
923
+ source: "windows-gdi",
924
+ status: "unsupported",
925
+ error: error("driver_unavailable", "The selected desktop driver does not support screenshots", "screenshot-driver-unavailable"),
926
+ };
927
+ return driver.screenshot();
928
+ }
677
929
  replay(request) {
678
930
  const mismatchedOperationId = findDesktopReplaySessionMismatch(request);
679
931
  if (mismatchedOperationId !== undefined)