copilot-liku-cli 0.0.4 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/QUICKSTART.md +24 -0
  2. package/README.md +85 -33
  3. package/package.json +23 -14
  4. package/scripts/postinstall.js +63 -0
  5. package/src/cli/commands/window.js +66 -0
  6. package/src/main/agents/base-agent.js +15 -7
  7. package/src/main/agents/builder.js +211 -0
  8. package/src/main/agents/index.js +12 -4
  9. package/src/main/agents/orchestrator.js +40 -0
  10. package/src/main/agents/producer.js +891 -0
  11. package/src/main/agents/researcher.js +78 -0
  12. package/src/main/agents/state-manager.js +134 -2
  13. package/src/main/agents/trace-writer.js +83 -0
  14. package/src/main/agents/verifier.js +201 -0
  15. package/src/main/ai-service.js +673 -66
  16. package/src/main/index.js +682 -110
  17. package/src/main/inspect-service.js +24 -1
  18. package/src/main/python-bridge.js +395 -0
  19. package/src/main/system-automation.js +934 -133
  20. package/src/main/ui-automation/core/ui-provider.js +99 -0
  21. package/src/main/ui-automation/core/uia-host.js +214 -0
  22. package/src/main/ui-automation/index.js +30 -0
  23. package/src/main/ui-automation/interactions/element-click.js +6 -6
  24. package/src/main/ui-automation/interactions/high-level.js +28 -6
  25. package/src/main/ui-automation/interactions/index.js +21 -0
  26. package/src/main/ui-automation/interactions/pattern-actions.js +236 -0
  27. package/src/main/ui-automation/window/index.js +6 -0
  28. package/src/main/ui-automation/window/manager.js +173 -26
  29. package/src/main/ui-watcher.js +420 -56
  30. package/src/main/visual-awareness.js +18 -1
  31. package/src/native/windows-uia/Program.cs +89 -0
  32. package/src/native/windows-uia/build.ps1 +24 -0
  33. package/src/native/windows-uia-dotnet/Program.cs +920 -0
  34. package/src/native/windows-uia-dotnet/WindowsUIA.csproj +11 -0
  35. package/src/native/windows-uia-dotnet/build.ps1 +24 -0
  36. package/src/renderer/chat/chat.js +943 -671
  37. package/src/renderer/chat/index.html +39 -4
  38. package/src/renderer/chat/preload.js +8 -1
  39. package/src/renderer/overlay/overlay.js +157 -8
  40. package/src/renderer/overlay/preload.js +4 -0
  41. package/src/shared/inspect-types.js +82 -6
  42. package/ARCHITECTURE.md +0 -411
  43. package/CONFIGURATION.md +0 -302
  44. package/CONTRIBUTING.md +0 -225
  45. package/ELECTRON_README.md +0 -121
  46. package/PROJECT_STATUS.md +0 -229
  47. package/TESTING.md +0 -274
@@ -16,6 +16,24 @@ const os = require('os');
16
16
  const path = require('path');
17
17
  const fs = require('fs');
18
18
  const EventEmitter = require('events');
19
+ const { getSharedUIAHost } = require('./ui-automation/core/uia-host');
20
+
21
+ // Watcher mode state machine
22
+ const MODE = {
23
+ POLLING: 'POLLING',
24
+ STARTING_EVENTS: 'STARTING_EVENTS',
25
+ EVENT_MODE: 'EVENT_MODE',
26
+ FALLBACK: 'FALLBACK' // polling after event failure, auto-retry after 30s
27
+ };
28
+
29
+ // Sensitive process denylist — when the active window belongs to one of these,
30
+ // omit element names/text from AI context to prevent prompt leakage.
31
+ const REDACTED_PROCESSES = new Set([
32
+ 'keepassxc', 'keepass', '1password', 'bitwarden', 'lastpass', 'dashlane',
33
+ 'enpass', 'roboform', 'nordpass', // password managers
34
+ 'mstsc', 'vmconnect', 'putty', 'winscp', // remote/admin tools
35
+ 'powershell_ise', // admin consoles
36
+ ]);
19
37
 
20
38
  class UIWatcher extends EventEmitter {
21
39
  constructor(options = {}) {
@@ -23,8 +41,8 @@ class UIWatcher extends EventEmitter {
23
41
 
24
42
  this.options = {
25
43
  pollInterval: options.pollInterval || 400, // ms between polls
26
- focusedWindowOnly: options.focusedWindowOnly ?? true, // only scan active window
27
- maxElements: options.maxElements || 200, // limit results for performance
44
+ focusedWindowOnly: options.focusedWindowOnly ?? false, // scan all visible windows by default
45
+ maxElements: options.maxElements || 300, // increased limit for desktop scan
28
46
  minConfidence: options.minConfidence || 0.3, // filter low-confidence elements
29
47
  enabled: false,
30
48
  ...options
@@ -55,6 +73,13 @@ class UIWatcher extends EventEmitter {
55
73
  this.psProcess = null;
56
74
  this.psQueue = [];
57
75
  this.psReady = false;
76
+
77
+ // Phase 4: event-driven mode
78
+ this._mode = MODE.POLLING;
79
+ this._healthCheckTimer = null;
80
+ this._lastEventTs = 0;
81
+ this._fallbackRetryTimer = null;
82
+ this._uiaEventHandler = null;
58
83
  }
59
84
 
60
85
  /**
@@ -162,6 +187,7 @@ class UIWatcher extends EventEmitter {
162
187
 
163
188
  /**
164
189
  * Get the currently active/focused window
190
+ * Uses file-based script execution for reliable parsing
165
191
  */
166
192
  async getActiveWindow() {
167
193
  const script = `
@@ -194,10 +220,24 @@ $proc = Get-Process -Id $processId -ErrorAction SilentlyContinue
194
220
  } | ConvertTo-Json -Compress
195
221
  `;
196
222
 
223
+ // Use file-based execution for reliable parsing
224
+ const tempFile = path.join(os.tmpdir(), `liku-activewin-${Date.now()}.ps1`);
225
+
197
226
  return new Promise((resolve, reject) => {
198
- exec(`powershell -NoProfile -Command "${script.replace(/"/g, '\\"').replace(/\n/g, ' ')}"`,
199
- { encoding: 'utf8', timeout: 2000 },
227
+ // Write script to temp file
228
+ try {
229
+ fs.writeFileSync(tempFile, script, 'utf8');
230
+ } catch (e) {
231
+ resolve(null);
232
+ return;
233
+ }
234
+
235
+ exec(`powershell -NoProfile -ExecutionPolicy Bypass -File "${tempFile}"`,
236
+ { encoding: 'utf8', timeout: 3000 },
200
237
  (error, stdout, stderr) => {
238
+ // Clean up temp file
239
+ try { fs.unlinkSync(tempFile); } catch {}
240
+
201
241
  if (error) {
202
242
  resolve(null);
203
243
  return;
@@ -214,14 +254,16 @@ $proc = Get-Process -Id $processId -ErrorAction SilentlyContinue
214
254
 
215
255
  /**
216
256
  * Detect UI elements using Windows UI Automation
257
+ * Uses file-based script execution for reliable parsing
217
258
  */
218
259
  async detectElements(activeWindow) {
219
260
  // Build scope filter based on active window
220
261
  const windowFilter = this.options.focusedWindowOnly && activeWindow
221
- ? `$targetWindow = "${(activeWindow.title || '').replace(/"/g, '\\"')}"`
262
+ ? `$targetWindow = "${(activeWindow.title || '').replace(/"/g, '`"')}"`
222
263
  : '$targetWindow = ""';
223
264
 
224
265
  const script = `
266
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
225
267
  Add-Type -AssemblyName UIAutomationClient
226
268
  Add-Type -AssemblyName UIAutomationTypes
227
269
 
@@ -229,47 +271,33 @@ ${windowFilter}
229
271
  $maxElements = ${this.options.maxElements}
230
272
 
231
273
  $root = [System.Windows.Automation.AutomationElement]::RootElement
232
-
233
- # If targeting specific window, find it first
234
- if ($targetWindow -ne "") {
235
- $nameCondition = New-Object System.Windows.Automation.PropertyCondition(
236
- [System.Windows.Automation.AutomationElement]::NameProperty, $targetWindow
237
- )
238
- $targetEl = $root.FindFirst([System.Windows.Automation.TreeScope]::Children, $nameCondition)
239
- if ($targetEl) { $root = $targetEl }
240
- }
241
-
242
274
  $condition = [System.Windows.Automation.Condition]::TrueCondition
243
- $elements = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $condition)
244
-
245
275
  $results = @()
246
276
  $count = 0
247
277
 
248
- foreach ($el in $elements) {
249
- if ($count -ge $maxElements) { break }
278
+ function Add-Element($el, $rootHwnd) {
250
279
  try {
251
280
  $rect = $el.Current.BoundingRectangle
252
- if ($rect.Width -le 0 -or $rect.Height -le 0) { continue }
253
- if ($rect.X -lt -10000 -or $rect.Y -lt -10000) { continue }
281
+ if ($rect.Width -le 0 -or $rect.Height -le 0) { return $null }
282
+ if ($rect.X -lt -10000 -or $rect.Y -lt -10000) { return $null }
254
283
 
255
284
  $name = $el.Current.Name
256
- $ctrlType = $el.Current.ControlType.ProgrammaticName -replace 'ControlType\\.', ''
285
+ if ($name) { $name = $name -replace '[\\r\\n\\t]', ' ' }
286
+
287
+ $ctrlType = $el.Current.ControlType.ProgrammaticName -replace 'ControlType\\.',''
257
288
  $autoId = $el.Current.AutomationId
258
- $className = $el.Current.ClassName
259
- $isEnabled = $el.Current.IsEnabled
289
+ if ($autoId) { $autoId = $autoId -replace '[\\r\\n\\t]', ' ' }
260
290
 
261
291
  # Skip elements with no useful identifying info
262
- if ([string]::IsNullOrWhiteSpace($name) -and [string]::IsNullOrWhiteSpace($autoId)) { continue }
263
-
264
- # Generate a unique ID
265
- $id = "$ctrlType|$name|$autoId|$([int]$rect.X)|$([int]$rect.Y)"
292
+ if ([string]::IsNullOrWhiteSpace($name) -and [string]::IsNullOrWhiteSpace($autoId)) { return $null }
266
293
 
267
- $results += @{
268
- id = $id
294
+ return @{
295
+ id = "$ctrlType|$name|$autoId|$([int]$rect.X)|$([int]$rect.Y)"
269
296
  name = $name
270
297
  type = $ctrlType
271
298
  automationId = $autoId
272
- className = $className
299
+ className = $el.Current.ClassName
300
+ windowHandle = $rootHwnd
273
301
  bounds = @{
274
302
  x = [int]$rect.X
275
303
  y = [int]$rect.Y
@@ -280,28 +308,99 @@ foreach ($el in $elements) {
280
308
  x = [int]($rect.X + $rect.Width / 2)
281
309
  y = [int]($rect.Y + $rect.Height / 2)
282
310
  }
283
- isEnabled = $isEnabled
311
+ isEnabled = $el.Current.IsEnabled
312
+ }
313
+ } catch { return $null }
314
+ }
315
+
316
+ if ($targetWindow -ne "") {
317
+ # FOCUSED WINDOW MODE
318
+ $nameCondition = New-Object System.Windows.Automation.PropertyCondition(
319
+ [System.Windows.Automation.AutomationElement]::NameProperty, $targetWindow
320
+ )
321
+ $targetEl = $root.FindFirst([System.Windows.Automation.TreeScope]::Children, $nameCondition)
322
+
323
+ if ($targetEl) {
324
+ $targetHwnd = 0
325
+ try { $targetHwnd = $targetEl.Current.NativeWindowHandle } catch {}
326
+
327
+ $elements = $targetEl.FindAll([System.Windows.Automation.TreeScope]::Descendants, $condition)
328
+ foreach ($el in $elements) {
329
+ if ($count -ge $maxElements) { break }
330
+ $data = Add-Element $el $targetHwnd
331
+ if ($data) { $results += $data; $count++ }
332
+ }
333
+ }
334
+ } else {
335
+ # GLOBAL DESKTOP MODE (Iterate Windows)
336
+ # Get all top-level windows first
337
+ $windows = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $condition)
338
+
339
+ foreach ($win in $windows) {
340
+ if ($count -ge $maxElements) { break }
341
+
342
+ $winHwnd = 0
343
+ try { $winHwnd = $win.Current.NativeWindowHandle } catch {}
344
+
345
+ # Add window itself
346
+ $winData = Add-Element $win $winHwnd
347
+ if ($winData) { $results += $winData; $count++ }
348
+
349
+ # Only process descendants for visible windows that have size
350
+ if ($winData) {
351
+ # Limit descendants per window to avoid starving other windows
352
+ $winElements = $win.FindAll([System.Windows.Automation.TreeScope]::Descendants, $condition)
353
+ $winCount = 0
354
+ foreach ($el in $winElements) {
355
+ if ($count -ge $maxElements) { break }
356
+ # Limit per window (e.g. 50% of remaining budget or fixed 50)
357
+ if ($winCount -ge 50) { break }
358
+
359
+ $data = Add-Element $el $winHwnd
360
+ if ($data) {
361
+ $results += $data
362
+ $count++
363
+ $winCount++
364
+ }
365
+ }
284
366
  }
285
- $count++
286
- } catch {}
367
+ }
287
368
  }
288
369
 
289
370
  $results | ConvertTo-Json -Depth 4 -Compress
290
371
  `;
291
372
 
373
+ // Use file-based execution for reliable parsing (inline -Command breaks on complex scripts)
374
+ const tempFile = path.join(os.tmpdir(), `liku-detect-${Date.now()}.ps1`);
375
+
292
376
  return new Promise((resolve, reject) => {
293
- exec(`powershell -NoProfile -Command "${script.replace(/"/g, '\\"').replace(/\n/g, ' ')}"`,
294
- { encoding: 'utf8', timeout: 5000, maxBuffer: 10 * 1024 * 1024 },
377
+ // Write script to temp file
378
+ try {
379
+ fs.writeFileSync(tempFile, script, 'utf8');
380
+ } catch (e) {
381
+ resolve([]);
382
+ return;
383
+ }
384
+
385
+ exec(`powershell -NoProfile -ExecutionPolicy Bypass -File "${tempFile}"`,
386
+ { encoding: 'utf8', timeout: 8000, maxBuffer: 10 * 1024 * 1024 },
295
387
  (error, stdout, stderr) => {
388
+ // Clean up temp file
389
+ try { fs.unlinkSync(tempFile); } catch {}
390
+
296
391
  if (error) {
392
+ console.error('[UI-WATCHER] PowerShell detection error:', error.message);
297
393
  resolve([]);
298
394
  return;
299
395
  }
396
+
300
397
  try {
301
398
  let elements = JSON.parse(stdout.trim() || '[]');
302
399
  if (!Array.isArray(elements)) elements = elements ? [elements] : [];
303
400
  resolve(elements);
304
401
  } catch (e) {
402
+ console.error('[UI-WATCHER] JSON Parse failed:', e.message);
403
+ console.error('[UI-WATCHER] STDOUT preview:', stdout.trim().substring(0, 200));
305
404
  resolve([]);
306
405
  }
307
406
  }
@@ -348,40 +447,58 @@ $results | ConvertTo-Json -Depth 4 -Compress
348
447
  const { elements, activeWindow, lastUpdate } = this.cache;
349
448
  const age = Date.now() - lastUpdate;
350
449
 
351
- // Group elements by type for cleaner context
352
- const byType = {};
353
- elements.forEach(el => {
354
- const type = el.type || 'Unknown';
355
- if (!byType[type]) byType[type] = [];
356
- byType[type].push(el);
357
- });
450
+ // Redaction: if the focused window belongs to a sensitive process,
451
+ // suppress element names to avoid leaking passwords/secrets to the LLM.
452
+ const processLower = (activeWindow?.processName || '').toLowerCase();
453
+ const redacted = REDACTED_PROCESSES.has(processLower);
358
454
 
359
- // Build context string
455
+ // Build context string with window hierarchy
360
456
  let context = `\n## Live UI State (${age}ms ago)\n`;
361
457
 
362
458
  if (activeWindow) {
363
- context += `**Active Window**: ${activeWindow.title || 'Unknown'} (${activeWindow.processName})\n`;
364
- context += `**Window Bounds**: (${activeWindow.bounds.x}, ${activeWindow.bounds.y}) ${activeWindow.bounds.width}x${activeWindow.bounds.height}\n\n`;
459
+ const title = redacted ? '[REDACTED — sensitive application]' : (activeWindow.title || 'Unknown');
460
+ context += `**Focused Window**: ${title} (${activeWindow.processName})\n`;
461
+ context += `**Cursor**: (${activeWindow.bounds.x}, ${activeWindow.bounds.y}) ${activeWindow.bounds.width}x${activeWindow.bounds.height}\n\n`;
365
462
  }
366
463
 
367
- // List interactive elements (buttons, text fields, etc.)
368
- const interactiveTypes = ['Button', 'Edit', 'ComboBox', 'CheckBox', 'RadioButton', 'MenuItem', 'ListItem', 'TabItem', 'Hyperlink'];
464
+ if (redacted) {
465
+ context += `**⚠ Privacy mode active** element names hidden because the focused application handles sensitive data.\n`;
466
+ context += `You can still take screenshots or wait for the user to switch windows.\n`;
467
+ return context;
468
+ }
369
469
 
370
- context += `**Interactive Elements** (${elements.length} total):\n`;
470
+ context += `**Visible Context** (${elements.length} elements detected):\n`;
371
471
 
372
472
  let listed = 0;
373
- for (const type of interactiveTypes) {
374
- const typeElements = byType[type] || [];
375
- for (const el of typeElements.slice(0, 10)) { // Limit per type
376
- if (listed >= 30) break; // Total limit
377
- const name = el.name || el.automationId || '[unnamed]';
378
- context += `- **${type}**: "${name}" at (${el.center.x}, ${el.center.y})${el.isEnabled ? '' : ' [disabled]'}\n`;
473
+ const limit = 300;
474
+
475
+ // Important interactive types to highlight
476
+ const importantTypes = ['Button', 'Edit', 'ComboBox', 'CheckBox', 'RadioButton', 'MenuItem', 'ListItem', 'TabItem', 'Hyperlink', 'Window'];
477
+
478
+ for (let i = 0; i < elements.length; i++) {
479
+ if (listed >= limit) break;
480
+
481
+ const el = elements[i];
482
+ const name = el.name || el.automationId || '[unnamed]';
483
+
484
+ // Handle Window headers
485
+ if (el.type === 'Window') {
486
+ context += `\n[WIN] **Window**: "${name}" (Handle: ${el.windowHandle || 0})\n`;
379
487
  listed++;
488
+ continue;
380
489
  }
490
+
491
+ // Skip boring layout elements unless they have a name
492
+ if (!importantTypes.includes(el.type) && !name && name !== '[unnamed]') continue;
493
+
494
+ // Format element line with index for robust referencing
495
+ const status = el.isEnabled ? '' : ' (disabled)';
496
+ context += `- [${i+1}] ${el.type}: "${name}" at (${el.center.x}, ${el.center.y})${status}\n`;
497
+ listed++;
381
498
  }
382
499
 
383
500
  if (elements.length > listed) {
384
- context += `... and ${elements.length - listed} more elements\n`;
501
+ context += `\n... and ${elements.length - listed} more elements (showing first ${limit})\n`;
385
502
  }
386
503
 
387
504
  return context;
@@ -465,6 +582,13 @@ $results | ConvertTo-Json -Depth 4 -Compress
465
582
  };
466
583
  }
467
584
 
585
+ /**
586
+ * Check if watcher is running (alias for isPolling)
587
+ */
588
+ get isRunning() {
589
+ return this.isPolling;
590
+ }
591
+
468
592
  /**
469
593
  * Clean up
470
594
  */
@@ -482,9 +606,249 @@ $results | ConvertTo-Json -Depth 4 -Compress
482
606
  * Destroy watcher
483
607
  */
484
608
  destroy() {
609
+ this.stopEventMode();
485
610
  this.stop();
486
611
  this.removeAllListeners();
487
612
  }
613
+
614
+ // ── Phase 4: Event-driven mode ──────────────────────────────────────
615
+
616
+ /** Current watcher mode */
617
+ get mode() { return this._mode; }
618
+
619
+ /**
620
+ * Switch to event-driven mode — subscribes to .NET UIA events,
621
+ * stops PowerShell polling, sets up health check timer.
622
+ */
623
+ async startEventMode() {
624
+ if (this._mode === MODE.EVENT_MODE || this._mode === MODE.STARTING_EVENTS) return;
625
+
626
+ console.log('[UI-WATCHER] Switching to EVENT mode');
627
+ this._mode = MODE.STARTING_EVENTS;
628
+
629
+ // Stop polling — events will drive updates
630
+ if (this.pollTimer) {
631
+ clearInterval(this.pollTimer);
632
+ this.pollTimer = null;
633
+ }
634
+
635
+ try {
636
+ const host = getSharedUIAHost();
637
+
638
+ // Attach event handler (idempotent — remove first if exists)
639
+ this._detachEventHandler();
640
+ this._uiaEventHandler = (evt) => this._onUiaEvent(evt);
641
+ host.on('uia-event', this._uiaEventHandler);
642
+
643
+ const resp = await host.subscribeEvents();
644
+
645
+ // Seed cache with initial snapshot
646
+ if (resp.initial) {
647
+ const elements = resp.initial.elements || [];
648
+ const activeWindow = resp.initial.activeWindow || null;
649
+
650
+ const diff = this.calculateDiff(elements);
651
+ this.cache = {
652
+ elements,
653
+ activeWindow,
654
+ lastUpdate: Date.now(),
655
+ updateCount: this.cache.updateCount + 1
656
+ };
657
+
658
+ this.emit('poll-complete', {
659
+ elements,
660
+ activeWindow,
661
+ pollTime: 0,
662
+ hasChanges: diff.hasChanges,
663
+ source: 'event-initial'
664
+ });
665
+ }
666
+
667
+ this._mode = MODE.EVENT_MODE;
668
+ this._lastEventTs = Date.now();
669
+ this._startHealthCheck();
670
+
671
+ console.log('[UI-WATCHER] EVENT mode active');
672
+ this.emit('mode-changed', MODE.EVENT_MODE);
673
+ } catch (err) {
674
+ console.error('[UI-WATCHER] Failed to start event mode:', err.message);
675
+ this._mode = MODE.POLLING;
676
+ // Fall back to polling
677
+ this._restartPolling();
678
+ }
679
+ }
680
+
681
+ /**
682
+ * Switch back to polling mode — unsubscribes events, restarts poll timer.
683
+ */
684
+ async stopEventMode() {
685
+ if (this._mode !== MODE.EVENT_MODE && this._mode !== MODE.STARTING_EVENTS && this._mode !== MODE.FALLBACK) return;
686
+
687
+ console.log('[UI-WATCHER] Switching back to POLLING mode');
688
+
689
+ this._stopHealthCheck();
690
+ this._detachEventHandler();
691
+
692
+ if (this._fallbackRetryTimer) {
693
+ clearTimeout(this._fallbackRetryTimer);
694
+ this._fallbackRetryTimer = null;
695
+ }
696
+
697
+ try {
698
+ const host = getSharedUIAHost();
699
+ await host.unsubscribeEvents();
700
+ } catch { /* ignore — host may be dead */ }
701
+
702
+ this._mode = MODE.POLLING;
703
+
704
+ // Restart polling if watcher should be active
705
+ if (this.isPolling || this.options.enabled) {
706
+ this._restartPolling();
707
+ }
708
+
709
+ this.emit('mode-changed', MODE.POLLING);
710
+ }
711
+
712
+ /** Handle incoming UIA event from the .NET host */
713
+ _onUiaEvent(evt) {
714
+ this._lastEventTs = Date.now();
715
+
716
+ switch (evt.event) {
717
+ case 'focusChanged': {
718
+ // New window — update active window, await structureChanged for elements
719
+ if (evt.data?.activeWindow) {
720
+ this.cache.activeWindow = evt.data.activeWindow;
721
+ }
722
+ break;
723
+ }
724
+ case 'structureChanged': {
725
+ // Full element refresh
726
+ const elements = evt.data?.elements || [];
727
+ const diff = this.calculateDiff(elements);
728
+ this.cache = {
729
+ elements,
730
+ activeWindow: this.cache.activeWindow,
731
+ lastUpdate: Date.now(),
732
+ updateCount: this.cache.updateCount + 1
733
+ };
734
+
735
+ if (diff.hasChanges) {
736
+ this.emit('ui-changed', {
737
+ added: diff.added,
738
+ removed: diff.removed,
739
+ changed: diff.changed,
740
+ activeWindow: this.cache.activeWindow,
741
+ elementCount: elements.length
742
+ });
743
+ }
744
+
745
+ this.emit('poll-complete', {
746
+ elements,
747
+ activeWindow: this.cache.activeWindow,
748
+ pollTime: 0,
749
+ hasChanges: diff.hasChanges,
750
+ source: 'event-structure'
751
+ });
752
+ break;
753
+ }
754
+ case 'propertyChanged': {
755
+ // Incremental property patches — merge into cache
756
+ const changed = evt.data?.elements || [];
757
+ if (changed.length === 0) break;
758
+
759
+ const map = new Map(this.cache.elements.map(e => [e.id, e]));
760
+ let patchCount = 0;
761
+
762
+ for (const patch of changed) {
763
+ if (map.has(patch.id)) {
764
+ Object.assign(map.get(patch.id), patch);
765
+ patchCount++;
766
+ } else {
767
+ // New element appeared via property event — add it
768
+ map.set(patch.id, patch);
769
+ patchCount++;
770
+ }
771
+ }
772
+
773
+ if (patchCount > 0) {
774
+ const elements = Array.from(map.values());
775
+ this.cache.elements = elements;
776
+ this.cache.lastUpdate = Date.now();
777
+
778
+ this.emit('poll-complete', {
779
+ elements,
780
+ activeWindow: this.cache.activeWindow,
781
+ pollTime: 0,
782
+ hasChanges: true,
783
+ source: 'event-property'
784
+ });
785
+ }
786
+ break;
787
+ }
788
+ case 'error':
789
+ console.error('[UI-WATCHER] .NET event error:', evt.data?.error);
790
+ break;
791
+ }
792
+ }
793
+
794
+ /** Health check: if no events for 10s while in event mode, fall back to polling */
795
+ _startHealthCheck() {
796
+ this._stopHealthCheck();
797
+ this._healthCheckTimer = setInterval(() => {
798
+ if (this._mode !== MODE.EVENT_MODE) return;
799
+ const elapsed = Date.now() - this._lastEventTs;
800
+ if (elapsed > 10000) {
801
+ console.warn('[UI-WATCHER] No events for 10s — falling back to polling');
802
+ this._fallbackToPolling();
803
+ }
804
+ }, 5000);
805
+ }
806
+
807
+ _stopHealthCheck() {
808
+ if (this._healthCheckTimer) {
809
+ clearInterval(this._healthCheckTimer);
810
+ this._healthCheckTimer = null;
811
+ }
812
+ }
813
+
814
+ /** Fall back to polling and schedule a retry */
815
+ _fallbackToPolling() {
816
+ this._stopHealthCheck();
817
+ this._mode = MODE.FALLBACK;
818
+ this._restartPolling();
819
+ this.emit('mode-changed', MODE.FALLBACK);
820
+
821
+ // Auto-retry event mode after 30s
822
+ this._fallbackRetryTimer = setTimeout(async () => {
823
+ this._fallbackRetryTimer = null;
824
+ if (this._mode === MODE.FALLBACK) {
825
+ console.log('[UI-WATCHER] Retrying event mode after fallback');
826
+ await this.startEventMode();
827
+ }
828
+ }, 30000);
829
+ }
830
+
831
+ _restartPolling() {
832
+ if (this.pollTimer) {
833
+ clearInterval(this.pollTimer);
834
+ this.pollTimer = null;
835
+ }
836
+ this.isPolling = true;
837
+ this.options.enabled = true;
838
+ this.pollTimer = setInterval(() => {
839
+ if (!this.pollInProgress) this.poll();
840
+ }, this.options.pollInterval);
841
+ }
842
+
843
+ _detachEventHandler() {
844
+ if (this._uiaEventHandler) {
845
+ try {
846
+ const host = getSharedUIAHost();
847
+ host.removeListener('uia-event', this._uiaEventHandler);
848
+ } catch { /* ignore */ }
849
+ this._uiaEventHandler = null;
850
+ }
851
+ }
488
852
  }
489
853
 
490
854
  // Singleton instance
@@ -7,6 +7,7 @@ const { exec } = require('child_process');
7
7
  const path = require('path');
8
8
  const fs = require('fs');
9
9
  const os = require('os');
10
+ const { getSharedUIAHost } = require('./ui-automation/core/uia-host');
10
11
 
11
12
  // ===== STATE =====
12
13
  let previousScreenshot = null;
@@ -457,13 +458,29 @@ $elements | ConvertTo-Json -Depth 10
457
458
  }
458
459
 
459
460
  /**
460
- * Find UI element at specific coordinates
461
+ * Find UI element at specific coordinates.
462
+ * Fast path: persistent .NET UIA host (~5-20ms).
463
+ * Fallback: PowerShell one-shot (~200-500ms).
461
464
  */
462
465
  async function findElementAtPoint(x, y) {
463
466
  if (process.platform !== 'win32') {
464
467
  return { error: 'UI Automation only available on Windows' };
465
468
  }
466
469
 
470
+ // Fast path — .NET host (persistent process, JSONL protocol)
471
+ try {
472
+ const host = getSharedUIAHost();
473
+ const el = await host.elementFromPoint(x, y);
474
+ return {
475
+ ...el,
476
+ queryPoint: { x, y },
477
+ timestamp: Date.now()
478
+ };
479
+ } catch (hostErr) {
480
+ // Fall through to PowerShell path
481
+ }
482
+
483
+ // Fallback — PowerShell (spawns new process each call)
467
484
  const psScript = `
468
485
  Add-Type -AssemblyName UIAutomationClient
469
486
  Add-Type -AssemblyName UIAutomationTypes