copilot-liku-cli 0.0.4 → 0.0.8

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 (46) 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 +7 -4
  9. package/src/main/agents/orchestrator.js +13 -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/verifier.js +201 -0
  14. package/src/main/ai-service.js +349 -35
  15. package/src/main/index.js +680 -110
  16. package/src/main/inspect-service.js +24 -1
  17. package/src/main/python-bridge.js +395 -0
  18. package/src/main/system-automation.js +849 -131
  19. package/src/main/ui-automation/core/ui-provider.js +99 -0
  20. package/src/main/ui-automation/core/uia-host.js +214 -0
  21. package/src/main/ui-automation/index.js +30 -0
  22. package/src/main/ui-automation/interactions/element-click.js +6 -6
  23. package/src/main/ui-automation/interactions/high-level.js +28 -6
  24. package/src/main/ui-automation/interactions/index.js +21 -0
  25. package/src/main/ui-automation/interactions/pattern-actions.js +236 -0
  26. package/src/main/ui-automation/window/index.js +6 -0
  27. package/src/main/ui-automation/window/manager.js +173 -26
  28. package/src/main/ui-watcher.js +401 -58
  29. package/src/main/visual-awareness.js +18 -1
  30. package/src/native/windows-uia/Program.cs +89 -0
  31. package/src/native/windows-uia/build.ps1 +24 -0
  32. package/src/native/windows-uia-dotnet/Program.cs +920 -0
  33. package/src/native/windows-uia-dotnet/WindowsUIA.csproj +11 -0
  34. package/src/native/windows-uia-dotnet/build.ps1 +24 -0
  35. package/src/renderer/chat/chat.js +915 -671
  36. package/src/renderer/chat/index.html +2 -4
  37. package/src/renderer/chat/preload.js +8 -1
  38. package/src/renderer/overlay/overlay.js +157 -8
  39. package/src/renderer/overlay/preload.js +4 -0
  40. package/src/shared/inspect-types.js +82 -6
  41. package/ARCHITECTURE.md +0 -411
  42. package/CONFIGURATION.md +0 -302
  43. package/CONTRIBUTING.md +0 -225
  44. package/ELECTRON_README.md +0 -121
  45. package/PROJECT_STATUS.md +0 -229
  46. package/TESTING.md +0 -274
@@ -16,6 +16,15 @@ 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
+ };
19
28
 
20
29
  class UIWatcher extends EventEmitter {
21
30
  constructor(options = {}) {
@@ -23,8 +32,8 @@ class UIWatcher extends EventEmitter {
23
32
 
24
33
  this.options = {
25
34
  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
35
+ focusedWindowOnly: options.focusedWindowOnly ?? false, // scan all visible windows by default
36
+ maxElements: options.maxElements || 300, // increased limit for desktop scan
28
37
  minConfidence: options.minConfidence || 0.3, // filter low-confidence elements
29
38
  enabled: false,
30
39
  ...options
@@ -55,6 +64,13 @@ class UIWatcher extends EventEmitter {
55
64
  this.psProcess = null;
56
65
  this.psQueue = [];
57
66
  this.psReady = false;
67
+
68
+ // Phase 4: event-driven mode
69
+ this._mode = MODE.POLLING;
70
+ this._healthCheckTimer = null;
71
+ this._lastEventTs = 0;
72
+ this._fallbackRetryTimer = null;
73
+ this._uiaEventHandler = null;
58
74
  }
59
75
 
60
76
  /**
@@ -162,6 +178,7 @@ class UIWatcher extends EventEmitter {
162
178
 
163
179
  /**
164
180
  * Get the currently active/focused window
181
+ * Uses file-based script execution for reliable parsing
165
182
  */
166
183
  async getActiveWindow() {
167
184
  const script = `
@@ -194,10 +211,24 @@ $proc = Get-Process -Id $processId -ErrorAction SilentlyContinue
194
211
  } | ConvertTo-Json -Compress
195
212
  `;
196
213
 
214
+ // Use file-based execution for reliable parsing
215
+ const tempFile = path.join(os.tmpdir(), `liku-activewin-${Date.now()}.ps1`);
216
+
197
217
  return new Promise((resolve, reject) => {
198
- exec(`powershell -NoProfile -Command "${script.replace(/"/g, '\\"').replace(/\n/g, ' ')}"`,
199
- { encoding: 'utf8', timeout: 2000 },
218
+ // Write script to temp file
219
+ try {
220
+ fs.writeFileSync(tempFile, script, 'utf8');
221
+ } catch (e) {
222
+ resolve(null);
223
+ return;
224
+ }
225
+
226
+ exec(`powershell -NoProfile -ExecutionPolicy Bypass -File "${tempFile}"`,
227
+ { encoding: 'utf8', timeout: 3000 },
200
228
  (error, stdout, stderr) => {
229
+ // Clean up temp file
230
+ try { fs.unlinkSync(tempFile); } catch {}
231
+
201
232
  if (error) {
202
233
  resolve(null);
203
234
  return;
@@ -214,14 +245,16 @@ $proc = Get-Process -Id $processId -ErrorAction SilentlyContinue
214
245
 
215
246
  /**
216
247
  * Detect UI elements using Windows UI Automation
248
+ * Uses file-based script execution for reliable parsing
217
249
  */
218
250
  async detectElements(activeWindow) {
219
251
  // Build scope filter based on active window
220
252
  const windowFilter = this.options.focusedWindowOnly && activeWindow
221
- ? `$targetWindow = "${(activeWindow.title || '').replace(/"/g, '\\"')}"`
253
+ ? `$targetWindow = "${(activeWindow.title || '').replace(/"/g, '`"')}"`
222
254
  : '$targetWindow = ""';
223
255
 
224
256
  const script = `
257
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
225
258
  Add-Type -AssemblyName UIAutomationClient
226
259
  Add-Type -AssemblyName UIAutomationTypes
227
260
 
@@ -229,47 +262,33 @@ ${windowFilter}
229
262
  $maxElements = ${this.options.maxElements}
230
263
 
231
264
  $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
265
  $condition = [System.Windows.Automation.Condition]::TrueCondition
243
- $elements = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $condition)
244
-
245
266
  $results = @()
246
267
  $count = 0
247
268
 
248
- foreach ($el in $elements) {
249
- if ($count -ge $maxElements) { break }
269
+ function Add-Element($el, $rootHwnd) {
250
270
  try {
251
271
  $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 }
272
+ if ($rect.Width -le 0 -or $rect.Height -le 0) { return $null }
273
+ if ($rect.X -lt -10000 -or $rect.Y -lt -10000) { return $null }
254
274
 
255
275
  $name = $el.Current.Name
256
- $ctrlType = $el.Current.ControlType.ProgrammaticName -replace 'ControlType\\.', ''
276
+ if ($name) { $name = $name -replace '[\\r\\n\\t]', ' ' }
277
+
278
+ $ctrlType = $el.Current.ControlType.ProgrammaticName -replace 'ControlType\\.',''
257
279
  $autoId = $el.Current.AutomationId
258
- $className = $el.Current.ClassName
259
- $isEnabled = $el.Current.IsEnabled
280
+ if ($autoId) { $autoId = $autoId -replace '[\\r\\n\\t]', ' ' }
260
281
 
261
282
  # Skip elements with no useful identifying info
262
- if ([string]::IsNullOrWhiteSpace($name) -and [string]::IsNullOrWhiteSpace($autoId)) { continue }
283
+ if ([string]::IsNullOrWhiteSpace($name) -and [string]::IsNullOrWhiteSpace($autoId)) { return $null }
263
284
 
264
- # Generate a unique ID
265
- $id = "$ctrlType|$name|$autoId|$([int]$rect.X)|$([int]$rect.Y)"
266
-
267
- $results += @{
268
- id = $id
285
+ return @{
286
+ id = "$ctrlType|$name|$autoId|$([int]$rect.X)|$([int]$rect.Y)"
269
287
  name = $name
270
288
  type = $ctrlType
271
289
  automationId = $autoId
272
- className = $className
290
+ className = $el.Current.ClassName
291
+ windowHandle = $rootHwnd
273
292
  bounds = @{
274
293
  x = [int]$rect.X
275
294
  y = [int]$rect.Y
@@ -280,28 +299,99 @@ foreach ($el in $elements) {
280
299
  x = [int]($rect.X + $rect.Width / 2)
281
300
  y = [int]($rect.Y + $rect.Height / 2)
282
301
  }
283
- isEnabled = $isEnabled
302
+ isEnabled = $el.Current.IsEnabled
303
+ }
304
+ } catch { return $null }
305
+ }
306
+
307
+ if ($targetWindow -ne "") {
308
+ # FOCUSED WINDOW MODE
309
+ $nameCondition = New-Object System.Windows.Automation.PropertyCondition(
310
+ [System.Windows.Automation.AutomationElement]::NameProperty, $targetWindow
311
+ )
312
+ $targetEl = $root.FindFirst([System.Windows.Automation.TreeScope]::Children, $nameCondition)
313
+
314
+ if ($targetEl) {
315
+ $targetHwnd = 0
316
+ try { $targetHwnd = $targetEl.Current.NativeWindowHandle } catch {}
317
+
318
+ $elements = $targetEl.FindAll([System.Windows.Automation.TreeScope]::Descendants, $condition)
319
+ foreach ($el in $elements) {
320
+ if ($count -ge $maxElements) { break }
321
+ $data = Add-Element $el $targetHwnd
322
+ if ($data) { $results += $data; $count++ }
323
+ }
324
+ }
325
+ } else {
326
+ # GLOBAL DESKTOP MODE (Iterate Windows)
327
+ # Get all top-level windows first
328
+ $windows = $root.FindAll([System.Windows.Automation.TreeScope]::Children, $condition)
329
+
330
+ foreach ($win in $windows) {
331
+ if ($count -ge $maxElements) { break }
332
+
333
+ $winHwnd = 0
334
+ try { $winHwnd = $win.Current.NativeWindowHandle } catch {}
335
+
336
+ # Add window itself
337
+ $winData = Add-Element $win $winHwnd
338
+ if ($winData) { $results += $winData; $count++ }
339
+
340
+ # Only process descendants for visible windows that have size
341
+ if ($winData) {
342
+ # Limit descendants per window to avoid starving other windows
343
+ $winElements = $win.FindAll([System.Windows.Automation.TreeScope]::Descendants, $condition)
344
+ $winCount = 0
345
+ foreach ($el in $winElements) {
346
+ if ($count -ge $maxElements) { break }
347
+ # Limit per window (e.g. 50% of remaining budget or fixed 50)
348
+ if ($winCount -ge 50) { break }
349
+
350
+ $data = Add-Element $el $winHwnd
351
+ if ($data) {
352
+ $results += $data
353
+ $count++
354
+ $winCount++
355
+ }
356
+ }
284
357
  }
285
- $count++
286
- } catch {}
358
+ }
287
359
  }
288
360
 
289
361
  $results | ConvertTo-Json -Depth 4 -Compress
290
362
  `;
291
363
 
364
+ // Use file-based execution for reliable parsing (inline -Command breaks on complex scripts)
365
+ const tempFile = path.join(os.tmpdir(), `liku-detect-${Date.now()}.ps1`);
366
+
292
367
  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 },
368
+ // Write script to temp file
369
+ try {
370
+ fs.writeFileSync(tempFile, script, 'utf8');
371
+ } catch (e) {
372
+ resolve([]);
373
+ return;
374
+ }
375
+
376
+ exec(`powershell -NoProfile -ExecutionPolicy Bypass -File "${tempFile}"`,
377
+ { encoding: 'utf8', timeout: 8000, maxBuffer: 10 * 1024 * 1024 },
295
378
  (error, stdout, stderr) => {
379
+ // Clean up temp file
380
+ try { fs.unlinkSync(tempFile); } catch {}
381
+
296
382
  if (error) {
383
+ console.error('[UI-WATCHER] PowerShell detection error:', error.message);
297
384
  resolve([]);
298
385
  return;
299
386
  }
387
+
300
388
  try {
301
389
  let elements = JSON.parse(stdout.trim() || '[]');
302
390
  if (!Array.isArray(elements)) elements = elements ? [elements] : [];
303
391
  resolve(elements);
304
392
  } catch (e) {
393
+ console.error('[UI-WATCHER] JSON Parse failed:', e.message);
394
+ console.error('[UI-WATCHER] STDOUT preview:', stdout.trim().substring(0, 200));
305
395
  resolve([]);
306
396
  }
307
397
  }
@@ -348,40 +438,46 @@ $results | ConvertTo-Json -Depth 4 -Compress
348
438
  const { elements, activeWindow, lastUpdate } = this.cache;
349
439
  const age = Date.now() - lastUpdate;
350
440
 
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
- });
358
-
359
- // Build context string
441
+ // Build context string with window hierarchy
360
442
  let context = `\n## Live UI State (${age}ms ago)\n`;
361
443
 
362
444
  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`;
445
+ context += `**Focused Window**: ${activeWindow.title || 'Unknown'} (${activeWindow.processName})\n`;
446
+ context += `**Cursor**: (${activeWindow.bounds.x}, ${activeWindow.bounds.y}) ${activeWindow.bounds.width}x${activeWindow.bounds.height}\n\n`;
365
447
  }
366
448
 
367
- // List interactive elements (buttons, text fields, etc.)
368
- const interactiveTypes = ['Button', 'Edit', 'ComboBox', 'CheckBox', 'RadioButton', 'MenuItem', 'ListItem', 'TabItem', 'Hyperlink'];
369
-
370
- context += `**Interactive Elements** (${elements.length} total):\n`;
449
+ context += `**Visible Context** (${elements.length} elements detected):\n`;
371
450
 
372
451
  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`;
452
+ const limit = 300;
453
+
454
+ // Important interactive types to highlight
455
+ const importantTypes = ['Button', 'Edit', 'ComboBox', 'CheckBox', 'RadioButton', 'MenuItem', 'ListItem', 'TabItem', 'Hyperlink', 'Window'];
456
+
457
+ for (let i = 0; i < elements.length; i++) {
458
+ if (listed >= limit) break;
459
+
460
+ const el = elements[i];
461
+ const name = el.name || el.automationId || '[unnamed]';
462
+
463
+ // Handle Window headers
464
+ if (el.type === 'Window') {
465
+ context += `\n[WIN] **Window**: "${name}" (Handle: ${el.windowHandle || 0})\n`;
379
466
  listed++;
467
+ continue;
380
468
  }
469
+
470
+ // Skip boring layout elements unless they have a name
471
+ if (!importantTypes.includes(el.type) && !name && name !== '[unnamed]') continue;
472
+
473
+ // Format element line with index for robust referencing
474
+ const status = el.isEnabled ? '' : ' (disabled)';
475
+ context += `- [${i+1}] ${el.type}: "${name}" at (${el.center.x}, ${el.center.y})${status}\n`;
476
+ listed++;
381
477
  }
382
478
 
383
479
  if (elements.length > listed) {
384
- context += `... and ${elements.length - listed} more elements\n`;
480
+ context += `\n... and ${elements.length - listed} more elements (showing first ${limit})\n`;
385
481
  }
386
482
 
387
483
  return context;
@@ -465,6 +561,13 @@ $results | ConvertTo-Json -Depth 4 -Compress
465
561
  };
466
562
  }
467
563
 
564
+ /**
565
+ * Check if watcher is running (alias for isPolling)
566
+ */
567
+ get isRunning() {
568
+ return this.isPolling;
569
+ }
570
+
468
571
  /**
469
572
  * Clean up
470
573
  */
@@ -482,9 +585,249 @@ $results | ConvertTo-Json -Depth 4 -Compress
482
585
  * Destroy watcher
483
586
  */
484
587
  destroy() {
588
+ this.stopEventMode();
485
589
  this.stop();
486
590
  this.removeAllListeners();
487
591
  }
592
+
593
+ // ── Phase 4: Event-driven mode ──────────────────────────────────────
594
+
595
+ /** Current watcher mode */
596
+ get mode() { return this._mode; }
597
+
598
+ /**
599
+ * Switch to event-driven mode — subscribes to .NET UIA events,
600
+ * stops PowerShell polling, sets up health check timer.
601
+ */
602
+ async startEventMode() {
603
+ if (this._mode === MODE.EVENT_MODE || this._mode === MODE.STARTING_EVENTS) return;
604
+
605
+ console.log('[UI-WATCHER] Switching to EVENT mode');
606
+ this._mode = MODE.STARTING_EVENTS;
607
+
608
+ // Stop polling — events will drive updates
609
+ if (this.pollTimer) {
610
+ clearInterval(this.pollTimer);
611
+ this.pollTimer = null;
612
+ }
613
+
614
+ try {
615
+ const host = getSharedUIAHost();
616
+
617
+ // Attach event handler (idempotent — remove first if exists)
618
+ this._detachEventHandler();
619
+ this._uiaEventHandler = (evt) => this._onUiaEvent(evt);
620
+ host.on('uia-event', this._uiaEventHandler);
621
+
622
+ const resp = await host.subscribeEvents();
623
+
624
+ // Seed cache with initial snapshot
625
+ if (resp.initial) {
626
+ const elements = resp.initial.elements || [];
627
+ const activeWindow = resp.initial.activeWindow || null;
628
+
629
+ const diff = this.calculateDiff(elements);
630
+ this.cache = {
631
+ elements,
632
+ activeWindow,
633
+ lastUpdate: Date.now(),
634
+ updateCount: this.cache.updateCount + 1
635
+ };
636
+
637
+ this.emit('poll-complete', {
638
+ elements,
639
+ activeWindow,
640
+ pollTime: 0,
641
+ hasChanges: diff.hasChanges,
642
+ source: 'event-initial'
643
+ });
644
+ }
645
+
646
+ this._mode = MODE.EVENT_MODE;
647
+ this._lastEventTs = Date.now();
648
+ this._startHealthCheck();
649
+
650
+ console.log('[UI-WATCHER] EVENT mode active');
651
+ this.emit('mode-changed', MODE.EVENT_MODE);
652
+ } catch (err) {
653
+ console.error('[UI-WATCHER] Failed to start event mode:', err.message);
654
+ this._mode = MODE.POLLING;
655
+ // Fall back to polling
656
+ this._restartPolling();
657
+ }
658
+ }
659
+
660
+ /**
661
+ * Switch back to polling mode — unsubscribes events, restarts poll timer.
662
+ */
663
+ async stopEventMode() {
664
+ if (this._mode !== MODE.EVENT_MODE && this._mode !== MODE.STARTING_EVENTS && this._mode !== MODE.FALLBACK) return;
665
+
666
+ console.log('[UI-WATCHER] Switching back to POLLING mode');
667
+
668
+ this._stopHealthCheck();
669
+ this._detachEventHandler();
670
+
671
+ if (this._fallbackRetryTimer) {
672
+ clearTimeout(this._fallbackRetryTimer);
673
+ this._fallbackRetryTimer = null;
674
+ }
675
+
676
+ try {
677
+ const host = getSharedUIAHost();
678
+ await host.unsubscribeEvents();
679
+ } catch { /* ignore — host may be dead */ }
680
+
681
+ this._mode = MODE.POLLING;
682
+
683
+ // Restart polling if watcher should be active
684
+ if (this.isPolling || this.options.enabled) {
685
+ this._restartPolling();
686
+ }
687
+
688
+ this.emit('mode-changed', MODE.POLLING);
689
+ }
690
+
691
+ /** Handle incoming UIA event from the .NET host */
692
+ _onUiaEvent(evt) {
693
+ this._lastEventTs = Date.now();
694
+
695
+ switch (evt.event) {
696
+ case 'focusChanged': {
697
+ // New window — update active window, await structureChanged for elements
698
+ if (evt.data?.activeWindow) {
699
+ this.cache.activeWindow = evt.data.activeWindow;
700
+ }
701
+ break;
702
+ }
703
+ case 'structureChanged': {
704
+ // Full element refresh
705
+ const elements = evt.data?.elements || [];
706
+ const diff = this.calculateDiff(elements);
707
+ this.cache = {
708
+ elements,
709
+ activeWindow: this.cache.activeWindow,
710
+ lastUpdate: Date.now(),
711
+ updateCount: this.cache.updateCount + 1
712
+ };
713
+
714
+ if (diff.hasChanges) {
715
+ this.emit('ui-changed', {
716
+ added: diff.added,
717
+ removed: diff.removed,
718
+ changed: diff.changed,
719
+ activeWindow: this.cache.activeWindow,
720
+ elementCount: elements.length
721
+ });
722
+ }
723
+
724
+ this.emit('poll-complete', {
725
+ elements,
726
+ activeWindow: this.cache.activeWindow,
727
+ pollTime: 0,
728
+ hasChanges: diff.hasChanges,
729
+ source: 'event-structure'
730
+ });
731
+ break;
732
+ }
733
+ case 'propertyChanged': {
734
+ // Incremental property patches — merge into cache
735
+ const changed = evt.data?.elements || [];
736
+ if (changed.length === 0) break;
737
+
738
+ const map = new Map(this.cache.elements.map(e => [e.id, e]));
739
+ let patchCount = 0;
740
+
741
+ for (const patch of changed) {
742
+ if (map.has(patch.id)) {
743
+ Object.assign(map.get(patch.id), patch);
744
+ patchCount++;
745
+ } else {
746
+ // New element appeared via property event — add it
747
+ map.set(patch.id, patch);
748
+ patchCount++;
749
+ }
750
+ }
751
+
752
+ if (patchCount > 0) {
753
+ const elements = Array.from(map.values());
754
+ this.cache.elements = elements;
755
+ this.cache.lastUpdate = Date.now();
756
+
757
+ this.emit('poll-complete', {
758
+ elements,
759
+ activeWindow: this.cache.activeWindow,
760
+ pollTime: 0,
761
+ hasChanges: true,
762
+ source: 'event-property'
763
+ });
764
+ }
765
+ break;
766
+ }
767
+ case 'error':
768
+ console.error('[UI-WATCHER] .NET event error:', evt.data?.error);
769
+ break;
770
+ }
771
+ }
772
+
773
+ /** Health check: if no events for 10s while in event mode, fall back to polling */
774
+ _startHealthCheck() {
775
+ this._stopHealthCheck();
776
+ this._healthCheckTimer = setInterval(() => {
777
+ if (this._mode !== MODE.EVENT_MODE) return;
778
+ const elapsed = Date.now() - this._lastEventTs;
779
+ if (elapsed > 10000) {
780
+ console.warn('[UI-WATCHER] No events for 10s — falling back to polling');
781
+ this._fallbackToPolling();
782
+ }
783
+ }, 5000);
784
+ }
785
+
786
+ _stopHealthCheck() {
787
+ if (this._healthCheckTimer) {
788
+ clearInterval(this._healthCheckTimer);
789
+ this._healthCheckTimer = null;
790
+ }
791
+ }
792
+
793
+ /** Fall back to polling and schedule a retry */
794
+ _fallbackToPolling() {
795
+ this._stopHealthCheck();
796
+ this._mode = MODE.FALLBACK;
797
+ this._restartPolling();
798
+ this.emit('mode-changed', MODE.FALLBACK);
799
+
800
+ // Auto-retry event mode after 30s
801
+ this._fallbackRetryTimer = setTimeout(async () => {
802
+ this._fallbackRetryTimer = null;
803
+ if (this._mode === MODE.FALLBACK) {
804
+ console.log('[UI-WATCHER] Retrying event mode after fallback');
805
+ await this.startEventMode();
806
+ }
807
+ }, 30000);
808
+ }
809
+
810
+ _restartPolling() {
811
+ if (this.pollTimer) {
812
+ clearInterval(this.pollTimer);
813
+ this.pollTimer = null;
814
+ }
815
+ this.isPolling = true;
816
+ this.options.enabled = true;
817
+ this.pollTimer = setInterval(() => {
818
+ if (!this.pollInProgress) this.poll();
819
+ }, this.options.pollInterval);
820
+ }
821
+
822
+ _detachEventHandler() {
823
+ if (this._uiaEventHandler) {
824
+ try {
825
+ const host = getSharedUIAHost();
826
+ host.removeListener('uia-event', this._uiaEventHandler);
827
+ } catch { /* ignore */ }
828
+ this._uiaEventHandler = null;
829
+ }
830
+ }
488
831
  }
489
832
 
490
833
  // 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