kiro-mobile-bridge 1.0.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/src/server.js ADDED
@@ -0,0 +1,2672 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Kiro Mobile Bridge Server
4
+ *
5
+ * A simple mobile web interface for monitoring Kiro IDE agent sessions from your phone over LAN.
6
+ * Captures snapshots of the chat interface via CDP and lets you send messages remotely.
7
+ */
8
+
9
+ import express from 'express';
10
+ import { createServer } from 'http';
11
+ import http from 'http';
12
+ import { WebSocketServer, WebSocket } from 'ws';
13
+ import { networkInterfaces } from 'os';
14
+ import { fileURLToPath } from 'url';
15
+ import { dirname, join } from 'path';
16
+ import crypto from 'crypto';
17
+
18
+ const __filename = fileURLToPath(import.meta.url);
19
+ const __dirname = dirname(__filename);
20
+
21
+ // Configuration
22
+ const PORT = process.env.PORT || 3000;
23
+ const CDP_PORTS = [9000, 9001, 9002, 9003, 9222, 9229];
24
+
25
+ // State management
26
+ const cascades = new Map(); // cascadeId -> { id, cdp, metadata, snapshot, css, snapshotHash, terminal, sidebar, editor }
27
+ const mainWindowCDP = { connection: null, id: null }; // Separate CDP connection for main VS Code window
28
+
29
+ // =============================================================================
30
+ // CDP Connection Helpers (Task 2)
31
+ // =============================================================================
32
+
33
+ /**
34
+ * Fetch JSON from a CDP endpoint
35
+ * @param {number} port - The port to fetch from
36
+ * @param {string} path - The path to fetch (default: /json/list)
37
+ * @returns {Promise<any>} - Parsed JSON response
38
+ */
39
+ function fetchCDPTargets(port, path = '/json/list') {
40
+ return new Promise((resolve, reject) => {
41
+ const url = `http://127.0.0.1:${port}${path}`;
42
+
43
+ const req = http.get(url, { timeout: 2000 }, (res) => {
44
+ let data = '';
45
+ res.on('data', chunk => data += chunk);
46
+ res.on('end', () => {
47
+ try {
48
+ resolve(JSON.parse(data));
49
+ } catch (e) {
50
+ reject(new Error(`Failed to parse JSON from ${url}: ${e.message}`));
51
+ }
52
+ });
53
+ });
54
+
55
+ req.on('error', (err) => {
56
+ reject(new Error(`Failed to fetch ${url}: ${err.message}`));
57
+ });
58
+
59
+ req.on('timeout', () => {
60
+ req.destroy();
61
+ reject(new Error(`Timeout fetching ${url}`));
62
+ });
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Create a CDP connection to a target
68
+ * @param {string} wsUrl - WebSocket debugger URL
69
+ * @returns {Promise<CDPConnection>} - CDP connection object
70
+ *
71
+ * @typedef {Object} CDPConnection
72
+ * @property {WebSocket} ws - The WebSocket connection
73
+ * @property {function(string, object): Promise<any>} call - Send CDP command
74
+ * @property {Array<{id: number, name: string, origin: string}>} contexts - Runtime execution contexts
75
+ * @property {number|null} rootContextId - Main context ID for evaluation
76
+ * @property {function(): void} close - Close the connection
77
+ */
78
+ function connectToCDP(wsUrl) {
79
+ return new Promise((resolve, reject) => {
80
+ const ws = new WebSocket(wsUrl);
81
+ let idCounter = 1;
82
+ const pendingCalls = new Map(); // id -> { resolve, reject }
83
+ const contexts = [];
84
+ let rootContextId = null;
85
+ let isConnected = false;
86
+
87
+ // Handle incoming messages
88
+ ws.on('message', (rawMsg) => {
89
+ try {
90
+ const msg = JSON.parse(rawMsg.toString());
91
+
92
+ // Handle CDP events
93
+ if (msg.method === 'Runtime.executionContextCreated') {
94
+ const ctx = msg.params.context;
95
+ contexts.push(ctx);
96
+
97
+ // Track the main/root context (usually the first one or one with specific origin)
98
+ // The root context typically has origin matching the page or is the first created
99
+ if (rootContextId === null || ctx.auxData?.isDefault) {
100
+ rootContextId = ctx.id;
101
+ }
102
+
103
+ console.log(`[CDP] Context created: id=${ctx.id}, name="${ctx.name}", origin="${ctx.origin}"`);
104
+ }
105
+
106
+ if (msg.method === 'Runtime.executionContextDestroyed') {
107
+ const ctxId = msg.params.executionContextId;
108
+ const idx = contexts.findIndex(c => c.id === ctxId);
109
+ if (idx !== -1) {
110
+ contexts.splice(idx, 1);
111
+ console.log(`[CDP] Context destroyed: id=${ctxId}`);
112
+ }
113
+ if (rootContextId === ctxId) {
114
+ rootContextId = contexts.length > 0 ? contexts[0].id : null;
115
+ }
116
+ }
117
+
118
+ if (msg.method === 'Runtime.executionContextsCleared') {
119
+ contexts.length = 0;
120
+ rootContextId = null;
121
+ console.log('[CDP] All contexts cleared');
122
+ }
123
+
124
+ // Handle responses to our calls
125
+ if (msg.id !== undefined && pendingCalls.has(msg.id)) {
126
+ const { resolve: res, reject: rej } = pendingCalls.get(msg.id);
127
+ pendingCalls.delete(msg.id);
128
+
129
+ if (msg.error) {
130
+ rej(new Error(`CDP Error: ${msg.error.message} (code: ${msg.error.code})`));
131
+ } else {
132
+ res(msg.result);
133
+ }
134
+ }
135
+ } catch (e) {
136
+ console.error('[CDP] Failed to parse message:', e.message);
137
+ }
138
+ });
139
+
140
+ ws.on('open', async () => {
141
+ isConnected = true;
142
+ console.log(`[CDP] Connected to ${wsUrl}`);
143
+
144
+ // Create the CDP connection object
145
+ const cdp = {
146
+ ws,
147
+ contexts,
148
+ get rootContextId() { return rootContextId; },
149
+
150
+ /**
151
+ * Send a CDP command and wait for response
152
+ * @param {string} method - CDP method name
153
+ * @param {object} params - Method parameters
154
+ * @returns {Promise<any>} - CDP response result
155
+ */
156
+ call(method, params = {}) {
157
+ return new Promise((res, rej) => {
158
+ if (!isConnected) {
159
+ rej(new Error('CDP connection is closed'));
160
+ return;
161
+ }
162
+
163
+ const id = idCounter++;
164
+ pendingCalls.set(id, { resolve: res, reject: rej });
165
+
166
+ const message = JSON.stringify({ id, method, params });
167
+ ws.send(message);
168
+
169
+ // Timeout for calls (10 seconds)
170
+ setTimeout(() => {
171
+ if (pendingCalls.has(id)) {
172
+ pendingCalls.delete(id);
173
+ rej(new Error(`CDP call timeout: ${method}`));
174
+ }
175
+ }, 10000);
176
+ });
177
+ },
178
+
179
+ /**
180
+ * Close the CDP connection
181
+ */
182
+ close() {
183
+ isConnected = false;
184
+ // Reject all pending calls
185
+ for (const [id, { reject }] of pendingCalls) {
186
+ reject(new Error('CDP connection closed'));
187
+ }
188
+ pendingCalls.clear();
189
+ ws.terminate();
190
+ }
191
+ };
192
+
193
+ try {
194
+ // Enable Runtime to receive execution context events
195
+ await cdp.call('Runtime.enable', {});
196
+
197
+ // Wait a bit for contexts to be discovered
198
+ await new Promise(r => setTimeout(r, 300));
199
+
200
+ console.log(`[CDP] Runtime enabled, found ${contexts.length} context(s)`);
201
+ resolve(cdp);
202
+ } catch (err) {
203
+ cdp.close();
204
+ reject(err);
205
+ }
206
+ });
207
+
208
+ ws.on('error', (err) => {
209
+ console.error(`[CDP] WebSocket error: ${err.message}`);
210
+ isConnected = false;
211
+ reject(err);
212
+ });
213
+
214
+ ws.on('close', () => {
215
+ console.log('[CDP] Connection closed');
216
+ isConnected = false;
217
+ // Reject all pending calls
218
+ for (const [id, { reject }] of pendingCalls) {
219
+ reject(new Error('CDP connection closed'));
220
+ }
221
+ pendingCalls.clear();
222
+ });
223
+ });
224
+ }
225
+
226
+ /**
227
+ * Generate a unique ID for a cascade based on WebSocket URL
228
+ * @param {string} wsUrl - WebSocket debugger URL
229
+ * @returns {string} - Hash ID
230
+ */
231
+ function generateCascadeId(wsUrl) {
232
+ return crypto.createHash('md5').update(wsUrl).digest('hex').substring(0, 8);
233
+ }
234
+
235
+ // =============================================================================
236
+ // Snapshot Capture (Task 4)
237
+ // =============================================================================
238
+
239
+ /**
240
+ * Compute a simple hash of content for change detection
241
+ * @param {string} content - Content to hash
242
+ * @returns {string} - Hash string
243
+ */
244
+ function computeHash(content) {
245
+ return crypto.createHash('md5').update(content).digest('hex');
246
+ }
247
+
248
+ /**
249
+ * Extract chat metadata (title, active state) from the page via CDP
250
+ * @param {CDPConnection} cdp - CDP connection
251
+ * @returns {Promise<{chatTitle: string, isActive: boolean}>}
252
+ */
253
+ async function captureMetadata(cdp) {
254
+ if (!cdp.rootContextId) {
255
+ return { chatTitle: '', isActive: false };
256
+ }
257
+
258
+ const script = `
259
+ (function() {
260
+ // Try to find chat title from various possible elements
261
+ let chatTitle = '';
262
+ let isActive = false;
263
+
264
+ // Look for chat title in common locations
265
+ // Kiro might have title in header, tab, or specific element
266
+ const titleSelectors = [
267
+ '.chat-title',
268
+ '.conversation-title',
269
+ '[data-testid="chat-title"]',
270
+ '.chat-header h1',
271
+ '.chat-header h2',
272
+ '.chat-header .title'
273
+ ];
274
+
275
+ for (const selector of titleSelectors) {
276
+ const el = document.querySelector(selector);
277
+ if (el && el.textContent) {
278
+ chatTitle = el.textContent.trim();
279
+ break;
280
+ }
281
+ }
282
+
283
+ // Check if chat is active (has recent activity or is focused)
284
+ // Look for typing indicators, loading states, or recent messages
285
+ const activeIndicators = [
286
+ '.typing-indicator',
287
+ '.loading-indicator',
288
+ '[data-loading="true"]',
289
+ '.chat-loading'
290
+ ];
291
+
292
+ for (const selector of activeIndicators) {
293
+ if (document.querySelector(selector)) {
294
+ isActive = true;
295
+ break;
296
+ }
297
+ }
298
+
299
+ // Also check if document is focused
300
+ isActive = isActive || document.hasFocus();
301
+
302
+ return { chatTitle, isActive };
303
+ })()
304
+ `;
305
+
306
+ try {
307
+ const result = await cdp.call('Runtime.evaluate', {
308
+ expression: script,
309
+ contextId: cdp.rootContextId,
310
+ returnByValue: true
311
+ });
312
+
313
+ if (result.result && result.result.value) {
314
+ return result.result.value;
315
+ }
316
+ } catch (err) {
317
+ console.error('[Snapshot] Failed to capture metadata:', err.message);
318
+ }
319
+
320
+ return { chatTitle: '', isActive: false };
321
+ }
322
+
323
+ /**
324
+ * Capture CSS styles from the page (run once per connection)
325
+ * Gathers all stylesheets and CSS variables, returns CSS string
326
+ * @param {CDPConnection} cdp - CDP connection
327
+ * @returns {Promise<string>} - Combined CSS string
328
+ */
329
+ async function captureCSS(cdp) {
330
+ if (!cdp.rootContextId) {
331
+ return '';
332
+ }
333
+
334
+ const script = `
335
+ (function() {
336
+ let css = '';
337
+
338
+ // VS Code webviews use nested iframes - look for #active-frame
339
+ let targetDoc = document;
340
+ const activeFrame = document.getElementById('active-frame');
341
+ if (activeFrame && activeFrame.contentDocument) {
342
+ targetDoc = activeFrame.contentDocument;
343
+ }
344
+
345
+ // First, capture all CSS custom properties (variables) from :root/html/body
346
+ // These are needed because VS Code styles use var(--vscode-*) extensively
347
+ const rootEl = targetDoc.documentElement;
348
+ const bodyEl = targetDoc.body;
349
+ const rootStyles = window.getComputedStyle(rootEl);
350
+ const bodyStyles = window.getComputedStyle(bodyEl);
351
+
352
+ let cssVars = ':root {\\n';
353
+
354
+ // Get all CSS properties and filter for custom properties (start with --)
355
+ const allProps = [];
356
+ for (let i = 0; i < rootStyles.length; i++) {
357
+ allProps.push(rootStyles[i]);
358
+ }
359
+
360
+ // Also check for VS Code specific variables by iterating stylesheets
361
+ for (const sheet of targetDoc.styleSheets) {
362
+ try {
363
+ if (sheet.cssRules) {
364
+ for (const rule of sheet.cssRules) {
365
+ if (rule.style) {
366
+ for (let i = 0; i < rule.style.length; i++) {
367
+ const prop = rule.style[i];
368
+ if (prop.startsWith('--') && !allProps.includes(prop)) {
369
+ allProps.push(prop);
370
+ }
371
+ }
372
+ }
373
+ }
374
+ }
375
+ } catch (e) {}
376
+ }
377
+
378
+ // Get computed values for all custom properties
379
+ for (const prop of allProps) {
380
+ if (prop.startsWith('--')) {
381
+ const value = rootStyles.getPropertyValue(prop).trim();
382
+ if (value) {
383
+ cssVars += ' ' + prop + ': ' + value + ';\\n';
384
+ }
385
+ }
386
+ }
387
+ cssVars += '}\\n\\n';
388
+
389
+ css += cssVars;
390
+
391
+ // Gather all stylesheets from target document
392
+ for (const sheet of targetDoc.styleSheets) {
393
+ try {
394
+ if (sheet.cssRules) {
395
+ for (const rule of sheet.cssRules) {
396
+ css += rule.cssText + '\\n';
397
+ }
398
+ }
399
+ } catch (e) {
400
+ // Cross-origin stylesheets will throw
401
+ }
402
+ }
403
+
404
+ // Also gather inline styles from <style> tags
405
+ const styleTags = targetDoc.querySelectorAll('style');
406
+ for (const tag of styleTags) {
407
+ css += tag.textContent + '\\n';
408
+ }
409
+
410
+ return css;
411
+ })()
412
+ `;
413
+
414
+ try {
415
+ const result = await cdp.call('Runtime.evaluate', {
416
+ expression: script,
417
+ contextId: cdp.rootContextId,
418
+ returnByValue: true
419
+ });
420
+
421
+ if (result.result && result.result.value) {
422
+ return result.result.value;
423
+ }
424
+ } catch (err) {
425
+ console.error('[Snapshot] Failed to capture CSS:', err.message);
426
+ }
427
+
428
+ return '';
429
+ }
430
+
431
+ /**
432
+ * Capture HTML snapshot of the chat interface
433
+ * @param {CDPConnection} cdp - CDP connection
434
+ * @returns {Promise<{html: string, bodyBg: string, bodyColor: string} | null>}
435
+ */
436
+ async function captureSnapshot(cdp) {
437
+ if (!cdp.rootContextId) {
438
+ console.log('[Snapshot] No rootContextId available');
439
+ return null;
440
+ }
441
+
442
+ const script = `
443
+ (function() {
444
+ const debug = {
445
+ hasActiveFrame: false,
446
+ activeFrameAccessible: false,
447
+ bodyExists: false,
448
+ selectorsChecked: [],
449
+ foundElement: null,
450
+ htmlLength: 0
451
+ };
452
+
453
+ // VS Code webviews use nested iframes - look for #active-frame
454
+ let targetDoc = document;
455
+ let targetBody = document.body;
456
+
457
+ debug.bodyExists = !!targetBody;
458
+
459
+ const activeFrame = document.getElementById('active-frame');
460
+ debug.hasActiveFrame = !!activeFrame;
461
+ if (activeFrame && activeFrame.contentDocument) {
462
+ debug.activeFrameAccessible = true;
463
+ targetDoc = activeFrame.contentDocument;
464
+ targetBody = targetDoc.body;
465
+ }
466
+
467
+ if (!targetBody) {
468
+ return { html: '<div style="padding:20px;color:#888;">No content found</div>', bodyBg: '', bodyColor: '', debug };
469
+ }
470
+
471
+ // Get body styles
472
+ const bodyStyles = window.getComputedStyle(targetBody);
473
+ const bodyBg = bodyStyles.backgroundColor || '';
474
+ const bodyColor = bodyStyles.color || '';
475
+
476
+ // Look for the main content container
477
+ const chatSelectors = [
478
+ '#root',
479
+ '#app',
480
+ '.app',
481
+ 'main',
482
+ '[class*="chat"]',
483
+ '[class*="message"]',
484
+ 'body > div'
485
+ ];
486
+
487
+ let chatElement = null;
488
+ for (const selector of chatSelectors) {
489
+ const el = targetDoc.querySelector(selector);
490
+ const len = el ? el.innerHTML.length : 0;
491
+ debug.selectorsChecked.push({ selector, found: !!el, htmlLength: len });
492
+ if (el && len > 50) {
493
+ chatElement = el;
494
+ debug.foundElement = selector;
495
+ break;
496
+ }
497
+ }
498
+
499
+ if (!chatElement) {
500
+ chatElement = targetBody;
501
+ debug.foundElement = 'body (fallback)';
502
+ }
503
+
504
+ debug.htmlLength = chatElement.innerHTML.length;
505
+
506
+ // Scroll chat container to bottom to show latest messages
507
+ const scrollContainers = targetDoc.querySelectorAll('[class*="scroll"], [style*="overflow"]');
508
+ for (const container of scrollContainers) {
509
+ if (container.scrollHeight > container.clientHeight) {
510
+ container.scrollTop = container.scrollHeight;
511
+ }
512
+ }
513
+
514
+ // Remove tooltips, popovers, and other overlay elements before capture
515
+ // IMPORTANT: Don't remove dropdown buttons (model selector), only dropdown menus/panels
516
+ const elementsToRemove = [
517
+ '[role="tooltip"]',
518
+ '[data-tooltip]',
519
+ '[class*="tooltip"]:not(button):not([role="button"])',
520
+ '[class*="Tooltip"]:not(button):not([role="button"])',
521
+ '[class*="popover"]:not(button):not([role="button"])',
522
+ '[class*="Popover"]:not(button):not([role="button"])',
523
+ '[class*="dropdown-menu"]',
524
+ '[class*="dropdownMenu"]',
525
+ '[class*="DropdownMenu"]',
526
+ '[class*="dropdown-content"]',
527
+ '[class*="dropdownContent"]',
528
+ '[class*="menu"]:not([role="menubar"]):not([class*="menubar"]):not(button):not([role="button"])',
529
+ '[class*="overlay"]:not(button):not([role="button"])',
530
+ '[class*="Overlay"]:not(button):not([role="button"])',
531
+ '[class*="modal"]',
532
+ '[class*="Modal"]',
533
+ '[style*="position: fixed"]:not(button):not([role="button"]):not([class*="input"]):not([class*="chat"])',
534
+ '[style*="position:fixed"]:not(button):not([role="button"]):not([class*="input"]):not([class*="chat"])'
535
+ ];
536
+
537
+ elementsToRemove.forEach(selector => {
538
+ try {
539
+ chatElement.querySelectorAll(selector).forEach(el => {
540
+ // Don't remove if it's a main content element or important UI component
541
+ const isMainContent = el.closest('#root > div:first-child');
542
+ const isTooltip = el.matches('[role="tooltip"], [class*="tooltip"], [class*="Tooltip"]');
543
+ const isImportantUI = el.matches('[class*="model"], [class*="Model"], [class*="context"], [class*="Context"], [class*="input"], [class*="Input"], [class*="selector"], [class*="Selector"], button, [role="button"]');
544
+
545
+ if (isTooltip || (!isMainContent && !isImportantUI)) {
546
+ el.remove();
547
+ }
548
+ });
549
+ } catch(e) {}
550
+ });
551
+
552
+ // Before cloning, inline computed styles for SVGs (currentColor fix)
553
+ const originalSvgs = chatElement.querySelectorAll('svg');
554
+ for (const svg of originalSvgs) {
555
+ try {
556
+ const computedColor = window.getComputedStyle(svg).color || window.getComputedStyle(svg.parentElement).color;
557
+ if (computedColor && computedColor !== 'rgba(0, 0, 0, 0)') {
558
+ svg.querySelectorAll('[fill="currentColor"]').forEach(el => el.setAttribute('fill', computedColor));
559
+ svg.querySelectorAll('[stroke="currentColor"]').forEach(el => el.setAttribute('stroke', computedColor));
560
+ if (svg.getAttribute('fill') === 'currentColor') svg.setAttribute('fill', computedColor);
561
+ if (svg.getAttribute('stroke') === 'currentColor') svg.setAttribute('stroke', computedColor);
562
+ if (!svg.getAttribute('fill') && !svg.getAttribute('stroke')) svg.style.color = computedColor;
563
+ }
564
+ } catch(e) {}
565
+ }
566
+
567
+ // Clone and return
568
+ const clone = chatElement.cloneNode(true);
569
+
570
+ return {
571
+ html: clone.outerHTML,
572
+ bodyBg,
573
+ bodyColor,
574
+ debug
575
+ };
576
+ })()
577
+ `;
578
+
579
+ try {
580
+ const result = await cdp.call('Runtime.evaluate', {
581
+ expression: script,
582
+ contextId: cdp.rootContextId,
583
+ returnByValue: true
584
+ });
585
+
586
+ if (result.result && result.result.value) {
587
+ return result.result.value;
588
+ }
589
+ } catch (err) {
590
+ console.error('[Snapshot] Failed to capture HTML:', err.message);
591
+ }
592
+
593
+ return null;
594
+ }
595
+
596
+ /**
597
+ * Capture Terminal panel HTML snapshot
598
+ * @param {CDPConnection} cdp - CDP connection
599
+ * @returns {Promise<{html: string, hasContent: boolean} | null>}
600
+ */
601
+ async function captureTerminal(cdp) {
602
+ if (!cdp.rootContextId) return null;
603
+
604
+ // VS Code terminal uses xterm.js which renders to canvas
605
+ // We need to access the accessibility buffer or use xterm's serialize addon
606
+ const script = `
607
+ (function() {
608
+ let targetDoc = document;
609
+ let textContent = '';
610
+ let terminalTabs = [];
611
+
612
+ // Find the terminal panel area
613
+ const terminalPanel = targetDoc.querySelector('.terminal-outer-container, .integrated-terminal, [class*="terminal-wrapper"]');
614
+
615
+ // Try to get terminal tabs/instances
616
+ const tabElements = targetDoc.querySelectorAll('.terminal-tab, .single-terminal-tab, [class*="terminal-tabs"] .tab');
617
+ tabElements.forEach((tab, i) => {
618
+ const label = tab.textContent?.trim() || tab.getAttribute('aria-label') || '';
619
+ const isActive = tab.classList.contains('active') || tab.getAttribute('aria-selected') === 'true';
620
+ if (label) {
621
+ terminalTabs.push({ label, isActive, index: i });
622
+ }
623
+ });
624
+
625
+ // Method 1: xterm accessibility tree (most reliable)
626
+ const xtermAccessibility = targetDoc.querySelector('.xterm-accessibility-tree, .xterm-accessibility');
627
+ if (xtermAccessibility) {
628
+ // Get all rows from accessibility tree
629
+ const rows = xtermAccessibility.querySelectorAll('[role="listitem"], div[style*="position"]');
630
+ const lines = [];
631
+ rows.forEach(row => {
632
+ const rowText = row.textContent || '';
633
+ // Filter out screen reader toggle text
634
+ if (rowText && !rowText.includes('Toggle Screen Reader') && rowText.trim()) {
635
+ lines.push(rowText);
636
+ }
637
+ });
638
+ if (lines.length > 0) {
639
+ textContent = lines.join('\\n');
640
+ }
641
+ }
642
+
643
+ // Method 2: xterm screen buffer via textarea (fallback)
644
+ if (!textContent.trim()) {
645
+ const xtermTextarea = targetDoc.querySelector('.xterm-helper-textarea');
646
+ if (xtermTextarea && xtermTextarea.value) {
647
+ textContent = xtermTextarea.value;
648
+ }
649
+ }
650
+
651
+ // Method 3: Look for terminal rows with actual content
652
+ if (!textContent.trim()) {
653
+ const xtermRows = targetDoc.querySelectorAll('.xterm-rows > div, .xterm-screen .xterm-rows span');
654
+ const lines = [];
655
+ xtermRows.forEach(row => {
656
+ const spans = row.querySelectorAll('span');
657
+ let lineText = '';
658
+ spans.forEach(span => {
659
+ lineText += span.textContent || '';
660
+ });
661
+ if (!lineText) lineText = row.textContent || '';
662
+ if (lineText.trim()) {
663
+ lines.push(lineText);
664
+ }
665
+ });
666
+ if (lines.length > 0) {
667
+ textContent = lines.join('\\n');
668
+ }
669
+ }
670
+
671
+ // Method 4: Try OUTPUT panel (HTML-based, not canvas)
672
+ if (!textContent.trim()) {
673
+ const outputPanel = targetDoc.querySelector('.output-view, .repl-input-wrapper, [class*="output"]');
674
+ if (outputPanel) {
675
+ const outputText = outputPanel.textContent || '';
676
+ if (outputText.trim()) {
677
+ textContent = outputText;
678
+ }
679
+ }
680
+ }
681
+
682
+ // Method 5: Problems panel
683
+ if (!textContent.trim()) {
684
+ const problemsPanel = targetDoc.querySelector('.markers-panel, [class*="problems"]');
685
+ if (problemsPanel) {
686
+ textContent = problemsPanel.textContent || '';
687
+ }
688
+ }
689
+
690
+ // Method 6: Debug Console
691
+ if (!textContent.trim()) {
692
+ const debugConsole = targetDoc.querySelector('.debug-console, .repl, [class*="debug-console"]');
693
+ if (debugConsole) {
694
+ textContent = debugConsole.textContent || '';
695
+ }
696
+ }
697
+
698
+ // Method 7: Any panel content in bottom area
699
+ if (!textContent.trim()) {
700
+ const panelContent = targetDoc.querySelector('.panel .content, .panel-body, .pane-body');
701
+ if (panelContent) {
702
+ const panelText = panelContent.textContent || '';
703
+ if (panelText.trim() && panelText.length > 20) {
704
+ textContent = panelText;
705
+ }
706
+ }
707
+ }
708
+
709
+ // Clean up the text
710
+ textContent = textContent.trim();
711
+
712
+ // Build HTML representation
713
+ let html = '';
714
+ if (terminalTabs.length > 0) {
715
+ html += '<div class="terminal-tabs">';
716
+ terminalTabs.forEach(tab => {
717
+ html += '<span class="terminal-tab-item' + (tab.isActive ? ' active' : '') + '">' + tab.label + '</span>';
718
+ });
719
+ html += '</div>';
720
+ }
721
+
722
+ if (textContent) {
723
+ html += '<pre class="terminal-output">' + textContent.replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</pre>';
724
+ }
725
+
726
+ return {
727
+ html: html,
728
+ textContent: textContent,
729
+ hasContent: textContent.length > 0,
730
+ tabs: terminalTabs
731
+ };
732
+ })()
733
+ `;
734
+
735
+ try {
736
+ const result = await cdp.call('Runtime.evaluate', {
737
+ expression: script,
738
+ contextId: cdp.rootContextId,
739
+ returnByValue: true
740
+ });
741
+ if (result.result && result.result.value) {
742
+ const data = result.result.value;
743
+ if (data.hasContent) {
744
+ console.log(`[Terminal] Captured ${data.textContent.length} chars of output`);
745
+ }
746
+ return data;
747
+ }
748
+ } catch (err) {
749
+ console.error('[Terminal] Failed to capture:', err.message);
750
+ }
751
+ return null;
752
+ }
753
+
754
+ /**
755
+ * Capture Sidebar panel HTML snapshot (File Explorer + Kiro panels)
756
+ * @param {CDPConnection} cdp - CDP connection
757
+ * @returns {Promise<{html: string, files: Array, kiroPanels: Array} | null>}
758
+ */
759
+ async function captureSidebar(cdp) {
760
+ if (!cdp.rootContextId) return null;
761
+
762
+ const script = `
763
+ (function() {
764
+ let targetDoc = document;
765
+ const activeFrame = document.getElementById('active-frame');
766
+ if (activeFrame && activeFrame.contentDocument) {
767
+ targetDoc = activeFrame.contentDocument;
768
+ }
769
+
770
+ const result = {
771
+ html: '',
772
+ files: [],
773
+ kiroPanels: [],
774
+ hasContent: false
775
+ };
776
+
777
+ // Look for file explorer / sidebar
778
+ const sidebarSelectors = [
779
+ '.sidebar',
780
+ '.explorer-viewlet',
781
+ '[class*="sidebar"]',
782
+ '.activitybar + .part',
783
+ '.monaco-workbench .part.sidebar',
784
+ '[data-testid="sidebar"]',
785
+ '.composite.viewlet'
786
+ ];
787
+
788
+ let sidebarElement = null;
789
+ for (const selector of sidebarSelectors) {
790
+ const el = targetDoc.querySelector(selector);
791
+ if (el && el.innerHTML.length > 50) {
792
+ sidebarElement = el;
793
+ break;
794
+ }
795
+ }
796
+
797
+ // Extract file tree structure
798
+ const fileTreeSelectors = [
799
+ '.monaco-list-row',
800
+ '.explorer-item',
801
+ '[class*="tree-row"]',
802
+ '.file-icon-themable-tree .monaco-list-row'
803
+ ];
804
+
805
+ for (const selector of fileTreeSelectors) {
806
+ const items = targetDoc.querySelectorAll(selector);
807
+ if (items.length > 0) {
808
+ items.forEach(item => {
809
+ const label = item.querySelector('.label-name, .monaco-icon-label-container, [class*="label"]');
810
+ const icon = item.querySelector('.file-icon, .folder-icon, [class*="icon"]');
811
+ const isFolder = item.classList.contains('folder') ||
812
+ item.querySelector('.folder-icon') !== null ||
813
+ item.getAttribute('aria-expanded') !== null;
814
+ const isExpanded = item.getAttribute('aria-expanded') === 'true';
815
+ const depth = parseInt(item.style.paddingLeft || item.style.textIndent || '0') / 8 || 0;
816
+
817
+ if (label && label.textContent) {
818
+ result.files.push({
819
+ name: label.textContent.trim(),
820
+ isFolder,
821
+ isExpanded,
822
+ depth: Math.floor(depth)
823
+ });
824
+ }
825
+ });
826
+ break;
827
+ }
828
+ }
829
+
830
+ // Look for Kiro-specific panels (specs, hooks, steering)
831
+ const kiroPanelSelectors = [
832
+ '[class*="kiro"]',
833
+ '[data-testid*="kiro"]',
834
+ '.specs-panel',
835
+ '.hooks-panel',
836
+ '.steering-panel'
837
+ ];
838
+
839
+ for (const selector of kiroPanelSelectors) {
840
+ const panels = targetDoc.querySelectorAll(selector);
841
+ panels.forEach(panel => {
842
+ const title = panel.querySelector('h2, h3, .title, [class*="title"]');
843
+ if (title && title.textContent) {
844
+ result.kiroPanels.push({
845
+ title: title.textContent.trim(),
846
+ html: panel.outerHTML.substring(0, 5000) // Limit size
847
+ });
848
+ }
849
+ });
850
+ }
851
+
852
+ if (sidebarElement) {
853
+ result.html = sidebarElement.outerHTML;
854
+ result.hasContent = true;
855
+ }
856
+
857
+ result.hasContent = result.hasContent || result.files.length > 0 || result.kiroPanels.length > 0;
858
+
859
+ return result;
860
+ })()
861
+ `;
862
+
863
+ try {
864
+ const result = await cdp.call('Runtime.evaluate', {
865
+ expression: script,
866
+ contextId: cdp.rootContextId,
867
+ returnByValue: true
868
+ });
869
+ if (result.result && result.result.value) return result.result.value;
870
+ } catch (err) {
871
+ console.error('[Sidebar] Failed to capture:', err.message);
872
+ }
873
+ return null;
874
+ }
875
+
876
+ /**
877
+ * Capture Editor panel HTML snapshot (currently open file)
878
+ * @param {CDPConnection} cdp - CDP connection
879
+ * @returns {Promise<{html: string, fileName: string, language: string, content: string} | null>}
880
+ */
881
+ async function captureEditor(cdp) {
882
+ if (!cdp.rootContextId) return null;
883
+
884
+ const script = `
885
+ (function() {
886
+ let targetDoc = document;
887
+ const activeFrame = document.getElementById('active-frame');
888
+ if (activeFrame && activeFrame.contentDocument) {
889
+ targetDoc = activeFrame.contentDocument;
890
+ }
891
+
892
+ const result = {
893
+ html: '',
894
+ fileName: '',
895
+ language: '',
896
+ content: '',
897
+ lineCount: 0,
898
+ hasContent: false
899
+ };
900
+
901
+ // Get active tab / file name - try multiple selectors
902
+ const tabSelectors = [
903
+ '.tab.active .label-name',
904
+ '.tab.active .monaco-icon-label-container .label-name',
905
+ '.tab.selected .monaco-icon-label',
906
+ '[class*="tab"][class*="active"] .label-name',
907
+ '.editor-group-container .tab.active',
908
+ '.tabs-container .tab.active',
909
+ '.tab.active',
910
+ '[role="tab"][aria-selected="true"]'
911
+ ];
912
+
913
+ for (const selector of tabSelectors) {
914
+ try {
915
+ const tab = targetDoc.querySelector(selector);
916
+ if (tab && tab.textContent) {
917
+ result.fileName = tab.textContent.trim().split('\\n')[0].trim();
918
+ if (result.fileName) break;
919
+ }
920
+ } catch(e) {}
921
+ }
922
+
923
+ // Try to get content from Monaco editor's internal model (best approach)
924
+ try {
925
+ // Look for Monaco editor instance
926
+ const monacoEditors = targetDoc.querySelectorAll('.monaco-editor');
927
+ for (const editorEl of monacoEditors) {
928
+ // Try to access the editor instance through VS Code's API
929
+ const editorInstance = editorEl.__vscode_editor__ ||
930
+ editorEl._editor ||
931
+ (window.monaco && window.monaco.editor.getEditors && window.monaco.editor.getEditors()[0]);
932
+
933
+ if (editorInstance && editorInstance.getModel) {
934
+ const model = editorInstance.getModel();
935
+ if (model) {
936
+ result.content = model.getValue();
937
+ result.lineCount = model.getLineCount();
938
+ result.language = model.getLanguageId ? model.getLanguageId() : (model.getModeId ? model.getModeId() : '');
939
+ result.hasContent = true;
940
+ break;
941
+ }
942
+ }
943
+ }
944
+ } catch(e) {
945
+ console.log('Monaco API access failed:', e);
946
+ }
947
+
948
+ // Fallback: Try to get content from textarea (some editors use this)
949
+ if (!result.content) {
950
+ try {
951
+ const textareas = targetDoc.querySelectorAll('textarea.inputarea, textarea[class*="input"]');
952
+ for (const ta of textareas) {
953
+ if (ta.value && ta.value.length > 10) {
954
+ result.content = ta.value;
955
+ result.hasContent = true;
956
+ break;
957
+ }
958
+ }
959
+ } catch(e) {}
960
+ }
961
+
962
+ // Fallback: Extract from visible view-lines (limited to what's rendered)
963
+ if (!result.content) {
964
+ const editorSelectors = [
965
+ '.monaco-editor .view-lines',
966
+ '.monaco-editor',
967
+ '.lines-content'
968
+ ];
969
+
970
+ let viewLinesElement = null;
971
+ let editorElement = null;
972
+ for (const selector of editorSelectors) {
973
+ try {
974
+ const el = targetDoc.querySelector(selector);
975
+ if (el) {
976
+ viewLinesElement = el.querySelector('.view-lines') || el;
977
+ editorElement = el.closest('.monaco-editor') || el;
978
+ break;
979
+ }
980
+ } catch(e) {}
981
+ }
982
+
983
+ if (viewLinesElement) {
984
+ const lines = viewLinesElement.querySelectorAll('.view-line');
985
+
986
+ // Try to get line numbers from the line number gutter
987
+ const lineNumberElements = editorElement ?
988
+ editorElement.querySelectorAll('.line-numbers, .margin-view-overlays .line-numbers') : [];
989
+
990
+ if (lines.length > 0) {
991
+ // Create a map of line number to content
992
+ const lineMap = new Map();
993
+ let minLineNum = Infinity;
994
+ let maxLineNum = 0;
995
+
996
+ // Try to match lines with their line numbers from the gutter
997
+ const lineNumMap = new Map();
998
+ lineNumberElements.forEach(ln => {
999
+ const top = parseFloat(ln.style.top) || 0;
1000
+ const num = parseInt(ln.textContent, 10);
1001
+ if (!isNaN(num)) {
1002
+ lineNumMap.set(Math.round(top), num);
1003
+ }
1004
+ });
1005
+
1006
+ lines.forEach(line => {
1007
+ const top = parseFloat(line.style.top) || 0;
1008
+ const roundedTop = Math.round(top);
1009
+
1010
+ // Try to get line number from gutter, or calculate from position
1011
+ let lineNum = lineNumMap.get(roundedTop);
1012
+ if (!lineNum) {
1013
+ // Fallback: calculate from top position (19px line height)
1014
+ const lineHeight = 19;
1015
+ lineNum = Math.round(top / lineHeight) + 1;
1016
+ }
1017
+
1018
+ const text = line.textContent || '';
1019
+ lineMap.set(lineNum, text);
1020
+ minLineNum = Math.min(minLineNum, lineNum);
1021
+ maxLineNum = Math.max(maxLineNum, lineNum);
1022
+ });
1023
+
1024
+ // Build content from line map, starting from minLineNum
1025
+ let codeContent = '';
1026
+ const startLine = Math.max(1, minLineNum);
1027
+ for (let i = startLine; i <= Math.min(maxLineNum, startLine + 500); i++) {
1028
+ codeContent += (lineMap.get(i) || '') + '\\n';
1029
+ }
1030
+
1031
+ result.content = codeContent;
1032
+ result.lineCount = maxLineNum;
1033
+ result.startLine = startLine;
1034
+ result.hasContent = codeContent.trim().length > 0;
1035
+
1036
+ // Mark as partial if we don't start from line 1
1037
+ if (startLine > 1) {
1038
+ result.isPartial = true;
1039
+ result.note = 'Showing lines ' + startLine + '-' + maxLineNum + '. Scroll in Kiro to see other parts.';
1040
+ }
1041
+ }
1042
+ }
1043
+ }
1044
+
1045
+ // Get language from editor element if not already set
1046
+ if (!result.language) {
1047
+ try {
1048
+ const monacoEditor = targetDoc.querySelector('.monaco-editor');
1049
+ if (monacoEditor) {
1050
+ const modeId = monacoEditor.getAttribute('data-mode-id');
1051
+ if (modeId) result.language = modeId;
1052
+
1053
+ const langMatch = monacoEditor.className.match(/\\b(typescript|javascript|python|java|html|css|json|markdown|yaml|xml|sql|go|rust|c|cpp|csharp)\\b/i);
1054
+ if (langMatch) result.language = langMatch[1].toLowerCase();
1055
+ }
1056
+ } catch(e) {}
1057
+ }
1058
+
1059
+ // Fallback: detect language from filename
1060
+ if (!result.language && result.fileName) {
1061
+ const ext = result.fileName.split('.').pop()?.toLowerCase();
1062
+ const extMap = {
1063
+ 'ts': 'typescript', 'tsx': 'typescript',
1064
+ 'js': 'javascript', 'jsx': 'javascript',
1065
+ 'py': 'python', 'java': 'java',
1066
+ 'html': 'html', 'css': 'css',
1067
+ 'json': 'json', 'md': 'markdown',
1068
+ 'yaml': 'yaml', 'yml': 'yaml',
1069
+ 'xml': 'xml', 'sql': 'sql',
1070
+ 'go': 'go', 'rs': 'rust',
1071
+ 'c': 'c', 'cpp': 'cpp', 'h': 'c',
1072
+ 'cs': 'csharp', 'rb': 'ruby',
1073
+ 'php': 'php', 'sh': 'bash'
1074
+ };
1075
+ result.language = extMap[ext] || ext || '';
1076
+ }
1077
+
1078
+ // Add note about partial content
1079
+ if (result.hasContent && result.lineCount < 50) {
1080
+ result.isPartial = true;
1081
+ result.note = 'Showing visible lines only. Scroll in Kiro to see more.';
1082
+ }
1083
+
1084
+ return result;
1085
+ })()
1086
+ `;
1087
+
1088
+ try {
1089
+ const result = await cdp.call('Runtime.evaluate', {
1090
+ expression: script,
1091
+ contextId: cdp.rootContextId,
1092
+ returnByValue: true
1093
+ });
1094
+ if (result.result && result.result.value) return result.result.value;
1095
+ } catch (err) {
1096
+ console.error('[Editor] Failed to capture:', err.message);
1097
+ }
1098
+ return null;
1099
+ }
1100
+
1101
+ /**
1102
+ * Alternative: Read file content directly from filesystem
1103
+ * This is more reliable than trying to scrape Monaco editor
1104
+ */
1105
+ async function readFileContent(filePath, workspaceRoot) {
1106
+ const fs = await import('fs/promises');
1107
+ const path = await import('path');
1108
+
1109
+ try {
1110
+ // Try to resolve the file path
1111
+ let fullPath = filePath;
1112
+ if (!path.isAbsolute(filePath) && workspaceRoot) {
1113
+ fullPath = path.join(workspaceRoot, filePath);
1114
+ }
1115
+
1116
+ const content = await fs.readFile(fullPath, 'utf-8');
1117
+ return content;
1118
+ } catch (err) {
1119
+ console.error('[ReadFile] Failed to read:', err.message);
1120
+ return null;
1121
+ }
1122
+ }
1123
+
1124
+ /**
1125
+ * Poll all cascades for snapshot changes
1126
+ * Captures snapshots, compares hashes, and broadcasts updates on change
1127
+ */
1128
+ async function pollSnapshots() {
1129
+ for (const [cascadeId, cascade] of cascades) {
1130
+ try {
1131
+ const cdp = cascade.cdp;
1132
+
1133
+ // Capture CSS once if not already captured
1134
+ if (cascade.css === null) {
1135
+ console.log(`[Snapshot] Capturing CSS for cascade ${cascadeId}...`);
1136
+ cascade.css = await captureCSS(cdp);
1137
+ console.log(`[Snapshot] CSS captured: ${cascade.css.length} chars`);
1138
+ }
1139
+
1140
+ // Capture metadata
1141
+ const metadata = await captureMetadata(cdp);
1142
+ cascade.metadata.chatTitle = metadata.chatTitle || cascade.metadata.chatTitle;
1143
+ cascade.metadata.isActive = metadata.isActive;
1144
+
1145
+ // Capture HTML snapshot (chat) from Kiro Agent webview
1146
+ const snapshot = await captureSnapshot(cdp);
1147
+
1148
+ if (snapshot) {
1149
+ // Log debug info for troubleshooting
1150
+ if (snapshot.debug) {
1151
+ console.log(`[Snapshot] Debug for ${cascadeId}:`, JSON.stringify(snapshot.debug, null, 2));
1152
+ }
1153
+
1154
+ const newHash = computeHash(snapshot.html);
1155
+ if (newHash !== cascade.snapshotHash) {
1156
+ console.log(`[Snapshot] Chat content changed for cascade ${cascadeId} (${snapshot.html.length} chars)`);
1157
+ cascade.snapshot = snapshot;
1158
+ cascade.snapshotHash = newHash;
1159
+ broadcastSnapshotUpdate(cascadeId, 'chat');
1160
+ }
1161
+ } else {
1162
+ console.log(`[Snapshot] captureSnapshot returned null for cascade ${cascadeId}`);
1163
+ }
1164
+
1165
+ // Use main window CDP for terminal/sidebar/editor
1166
+ const mainCDP = mainWindowCDP.connection;
1167
+ if (mainCDP && mainCDP.rootContextId) {
1168
+ // Capture Terminal snapshot from main window
1169
+ const terminal = await captureTerminal(mainCDP);
1170
+ if (terminal && terminal.hasContent) {
1171
+ const termHash = computeHash(terminal.html || terminal.textContent || '');
1172
+ if (termHash !== cascade.terminalHash) {
1173
+ console.log(`[Snapshot] Terminal content changed for cascade ${cascadeId}`);
1174
+ cascade.terminal = terminal;
1175
+ cascade.terminalHash = termHash;
1176
+ broadcastSnapshotUpdate(cascadeId, 'terminal');
1177
+ }
1178
+ }
1179
+
1180
+ // Capture Sidebar snapshot from main window
1181
+ const sidebar = await captureSidebar(mainCDP);
1182
+ if (sidebar && sidebar.hasContent) {
1183
+ const sideHash = computeHash(JSON.stringify(sidebar.files) + sidebar.html);
1184
+ if (sideHash !== cascade.sidebarHash) {
1185
+ console.log(`[Snapshot] Sidebar content changed for cascade ${cascadeId}`);
1186
+ cascade.sidebar = sidebar;
1187
+ cascade.sidebarHash = sideHash;
1188
+ broadcastSnapshotUpdate(cascadeId, 'sidebar');
1189
+ }
1190
+ }
1191
+
1192
+ // Capture Editor snapshot from main window
1193
+ const editor = await captureEditor(mainCDP);
1194
+ if (editor && editor.hasContent) {
1195
+ const editorHash = computeHash(editor.content + editor.fileName);
1196
+ if (editorHash !== cascade.editorHash) {
1197
+ console.log(`[Snapshot] Editor content changed for cascade ${cascadeId}`);
1198
+ cascade.editor = editor;
1199
+ cascade.editorHash = editorHash;
1200
+ broadcastSnapshotUpdate(cascadeId, 'editor');
1201
+ }
1202
+ } else if (cascade.editor && cascade.editor.hasContent) {
1203
+ // Clear stale editor data when no file is open
1204
+ console.log(`[Snapshot] Editor closed/no file open for cascade ${cascadeId}`);
1205
+ cascade.editor = { hasContent: false, fileName: '', content: '' };
1206
+ cascade.editorHash = '';
1207
+ broadcastSnapshotUpdate(cascadeId, 'editor');
1208
+ }
1209
+ } else if (!mainCDP) {
1210
+ // Main window not connected yet - this is normal during startup
1211
+ }
1212
+ } catch (err) {
1213
+ console.error(`[Snapshot] Error polling cascade ${cascadeId}:`, err.message);
1214
+ }
1215
+ }
1216
+ }
1217
+
1218
+ /**
1219
+ * Broadcast a snapshot update notification to all connected WebSocket clients
1220
+ * @param {string} cascadeId - ID of the cascade that was updated
1221
+ * @param {string} panel - Panel type that was updated (chat, terminal, sidebar, editor)
1222
+ */
1223
+ function broadcastSnapshotUpdate(cascadeId, panel = 'chat') {
1224
+ const message = JSON.stringify({
1225
+ type: 'snapshot_update',
1226
+ cascadeId,
1227
+ panel
1228
+ });
1229
+
1230
+ for (const client of wss.clients) {
1231
+ if (client.readyState === WebSocket.OPEN) {
1232
+ client.send(message);
1233
+ }
1234
+ }
1235
+ }
1236
+
1237
+ // =============================================================================
1238
+ // Message Injection (Task 6)
1239
+ // =============================================================================
1240
+
1241
+ /**
1242
+ * CDP script to inject a message into the chat input and send it.
1243
+ *
1244
+ * This script:
1245
+ * 1. Finds the chat input element (contenteditable div or textarea)
1246
+ * 2. Inserts the message text into the input
1247
+ * 3. Triggers the send button click or dispatches Enter key event
1248
+ *
1249
+ * @param {string} messageText - The message to inject (will be escaped)
1250
+ * @returns {string} - JavaScript expression to evaluate in the page context
1251
+ */
1252
+ function createInjectMessageScript(messageText) {
1253
+ // Escape the message for safe inclusion in JavaScript string
1254
+ const escapedMessage = messageText
1255
+ .replace(/\\/g, '\\\\')
1256
+ .replace(/'/g, "\\'")
1257
+ .replace(/\n/g, '\\n')
1258
+ .replace(/\r/g, '\\r');
1259
+
1260
+ return `(async () => {
1261
+ const text = '${escapedMessage}';
1262
+
1263
+ // VS Code webviews use nested iframes - look for #active-frame
1264
+ let targetDoc = document;
1265
+ const activeFrame = document.getElementById('active-frame');
1266
+ if (activeFrame && activeFrame.contentDocument) {
1267
+ targetDoc = activeFrame.contentDocument;
1268
+ }
1269
+
1270
+ // 6.1 Find input element (contenteditable or textarea)
1271
+ // Try Kiro's Lexical editor first (contenteditable div)
1272
+ let editors = [...targetDoc.querySelectorAll('[data-lexical-editor="true"][contenteditable="true"][role="textbox"]')]
1273
+ .filter(el => el.offsetParent !== null);
1274
+ let editor = editors.at(-1);
1275
+
1276
+ // Fallback: try any contenteditable in the cascade area
1277
+ if (!editor) {
1278
+ editors = [...targetDoc.querySelectorAll('#cascade [contenteditable="true"]')]
1279
+ .filter(el => el.offsetParent !== null);
1280
+ editor = editors.at(-1);
1281
+ }
1282
+
1283
+ // Fallback: try any contenteditable
1284
+ if (!editor) {
1285
+ editors = [...targetDoc.querySelectorAll('[contenteditable="true"]')]
1286
+ .filter(el => el.offsetParent !== null);
1287
+ editor = editors.at(-1);
1288
+ }
1289
+
1290
+ // Fallback: try textarea
1291
+ if (!editor) {
1292
+ const textareas = [...targetDoc.querySelectorAll('textarea')]
1293
+ .filter(el => el.offsetParent !== null);
1294
+ editor = textareas.at(-1);
1295
+ }
1296
+
1297
+ if (!editor) {
1298
+ return { ok: false, error: 'editor_not_found', message: 'Could not find chat input element' };
1299
+ }
1300
+
1301
+ const isTextarea = editor.tagName.toLowerCase() === 'textarea';
1302
+
1303
+ // 6.2 Insert text into input element
1304
+ editor.focus();
1305
+
1306
+ if (isTextarea) {
1307
+ // For textarea, set value directly and dispatch input event
1308
+ editor.value = text;
1309
+ editor.dispatchEvent(new Event('input', { bubbles: true }));
1310
+ editor.dispatchEvent(new Event('change', { bubbles: true }));
1311
+ } else {
1312
+ // For contenteditable, use execCommand or fallback to direct manipulation
1313
+ // First, select all and delete existing content
1314
+ targetDoc.execCommand?.('selectAll', false, null);
1315
+ targetDoc.execCommand?.('delete', false, null);
1316
+
1317
+ // Try insertText command
1318
+ let inserted = false;
1319
+ try {
1320
+ inserted = !!targetDoc.execCommand?.('insertText', false, text);
1321
+ } catch (e) {
1322
+ inserted = false;
1323
+ }
1324
+
1325
+ // Fallback: set textContent and dispatch events
1326
+ if (!inserted) {
1327
+ editor.textContent = text;
1328
+ editor.dispatchEvent(new InputEvent('beforeinput', {
1329
+ bubbles: true,
1330
+ inputType: 'insertText',
1331
+ data: text
1332
+ }));
1333
+ editor.dispatchEvent(new InputEvent('input', {
1334
+ bubbles: true,
1335
+ inputType: 'insertText',
1336
+ data: text
1337
+ }));
1338
+ }
1339
+ }
1340
+
1341
+ // Wait for React/framework to process the input
1342
+ await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
1343
+
1344
+ // 6.3 Trigger send button click or Enter key
1345
+ // Try to find the send button (arrow-right icon button)
1346
+ const submitButton = targetDoc.querySelector('svg.lucide-arrow-right')?.closest('button');
1347
+
1348
+ if (submitButton && !submitButton.disabled) {
1349
+ submitButton.click();
1350
+ return { ok: true, method: 'click_submit', inputType: isTextarea ? 'textarea' : 'contenteditable' };
1351
+ }
1352
+
1353
+ // Fallback: try other common send button patterns
1354
+ const altSubmitButtons = [
1355
+ targetDoc.querySelector('[data-tooltip-id*="send"]')?.closest('button'),
1356
+ targetDoc.querySelector('button[type="submit"]'),
1357
+ targetDoc.querySelector('button[aria-label*="send" i]'),
1358
+ targetDoc.querySelector('button[aria-label*="submit" i]')
1359
+ ].filter(btn => btn && !btn.disabled && btn.offsetParent !== null);
1360
+
1361
+ if (altSubmitButtons.length > 0) {
1362
+ altSubmitButtons[0].click();
1363
+ return { ok: true, method: 'click_alt_submit', inputType: isTextarea ? 'textarea' : 'contenteditable' };
1364
+ }
1365
+
1366
+ // Last resort: dispatch Enter key event
1367
+ editor.dispatchEvent(new KeyboardEvent('keydown', {
1368
+ bubbles: true,
1369
+ key: 'Enter',
1370
+ code: 'Enter',
1371
+ keyCode: 13,
1372
+ which: 13
1373
+ }));
1374
+ editor.dispatchEvent(new KeyboardEvent('keypress', {
1375
+ bubbles: true,
1376
+ key: 'Enter',
1377
+ code: 'Enter',
1378
+ keyCode: 13,
1379
+ which: 13
1380
+ }));
1381
+ editor.dispatchEvent(new KeyboardEvent('keyup', {
1382
+ bubbles: true,
1383
+ key: 'Enter',
1384
+ code: 'Enter',
1385
+ keyCode: 13,
1386
+ which: 13
1387
+ }));
1388
+
1389
+ return {
1390
+ ok: true,
1391
+ method: 'enter_key',
1392
+ inputType: isTextarea ? 'textarea' : 'contenteditable',
1393
+ submitButtonFound: !!submitButton,
1394
+ submitButtonDisabled: submitButton?.disabled ?? null
1395
+ };
1396
+ })()`;
1397
+ }
1398
+
1399
+ /**
1400
+ * Inject a message into the chat via CDP
1401
+ *
1402
+ * @param {CDPConnection} cdp - CDP connection object
1403
+ * @param {string} message - Message text to inject
1404
+ * @returns {Promise<{success: boolean, method?: string, error?: string}>}
1405
+ */
1406
+ async function injectMessage(cdp, message) {
1407
+ if (!cdp.rootContextId) {
1408
+ return { success: false, error: 'No execution context available' };
1409
+ }
1410
+
1411
+ const script = createInjectMessageScript(message);
1412
+
1413
+ try {
1414
+ const result = await cdp.call('Runtime.evaluate', {
1415
+ expression: script,
1416
+ contextId: cdp.rootContextId,
1417
+ returnByValue: true,
1418
+ awaitPromise: true
1419
+ });
1420
+
1421
+ if (result.exceptionDetails) {
1422
+ const errorMsg = result.exceptionDetails.exception?.description ||
1423
+ result.exceptionDetails.text ||
1424
+ 'Unknown error';
1425
+ console.error('[Inject] Script exception:', errorMsg);
1426
+ return { success: false, error: errorMsg };
1427
+ }
1428
+
1429
+ const value = result.result?.value;
1430
+ if (!value) {
1431
+ return { success: false, error: 'No result from injection script' };
1432
+ }
1433
+
1434
+ if (value.ok) {
1435
+ console.log(`[Inject] Message sent via ${value.method} (${value.inputType})`);
1436
+ return {
1437
+ success: true,
1438
+ method: value.method,
1439
+ inputType: value.inputType
1440
+ };
1441
+ } else {
1442
+ console.error('[Inject] Failed:', value.error, value.message);
1443
+ return { success: false, error: value.message || value.error };
1444
+ }
1445
+ } catch (err) {
1446
+ console.error('[Inject] CDP call failed:', err.message);
1447
+ return { success: false, error: err.message };
1448
+ }
1449
+ }
1450
+
1451
+ // =============================================================================
1452
+ // Discovery Loop (Task 3)
1453
+ // =============================================================================
1454
+
1455
+ /**
1456
+ * Discover CDP targets across all configured ports
1457
+ * Scans ports 9000-9003, connects to:
1458
+ * 1. Kiro Agent webview (for chat)
1459
+ * 2. Main VS Code window (for terminal, sidebar, editor)
1460
+ */
1461
+ async function discoverTargets() {
1462
+ console.log('[Discovery] Scanning for CDP targets...');
1463
+
1464
+ // Track which cascade IDs we find in this scan
1465
+ const foundCascadeIds = new Set();
1466
+ let foundMainWindow = false;
1467
+
1468
+ // 3.1 Scan all CDP ports for targets
1469
+ for (const port of CDP_PORTS) {
1470
+ try {
1471
+ const targets = await fetchCDPTargets(port);
1472
+
1473
+ // Debug: log all targets found on this port
1474
+ console.log(`[Discovery] Port ${port}: Found ${targets.length} target(s)`);
1475
+ targets.forEach((t, i) => {
1476
+ console.log(` [${i}] type="${t.type}" title="${t.title?.substring(0, 40)}" url="${t.url?.substring(0, 50)}..."`);
1477
+ });
1478
+
1479
+ // Find the main VS Code window (type: page, url starts with vscode-file://)
1480
+ const mainWindowTarget = targets.find(target => {
1481
+ const url = (target.url || '').toLowerCase();
1482
+ return target.type === 'page' &&
1483
+ (url.startsWith('vscode-file://') || url.includes('workbench')) &&
1484
+ target.webSocketDebuggerUrl;
1485
+ });
1486
+
1487
+ // Connect to main window for terminal/sidebar/editor
1488
+ if (mainWindowTarget && !mainWindowCDP.connection) {
1489
+ console.log(`[Discovery] Found main VS Code window: ${mainWindowTarget.title}`);
1490
+ try {
1491
+ const cdp = await connectToCDP(mainWindowTarget.webSocketDebuggerUrl);
1492
+ mainWindowCDP.connection = cdp;
1493
+ mainWindowCDP.id = generateCascadeId(mainWindowTarget.webSocketDebuggerUrl);
1494
+ foundMainWindow = true;
1495
+ console.log(`[Discovery] Connected to main window: ${mainWindowCDP.id}`);
1496
+
1497
+ // Set up disconnect handler
1498
+ cdp.ws.on('close', () => {
1499
+ console.log(`[Discovery] Main window disconnected`);
1500
+ mainWindowCDP.connection = null;
1501
+ mainWindowCDP.id = null;
1502
+ });
1503
+ } catch (err) {
1504
+ console.error(`[Discovery] Failed to connect to main window: ${err.message}`);
1505
+ }
1506
+ } else if (mainWindowTarget) {
1507
+ foundMainWindow = true;
1508
+ }
1509
+
1510
+ // Find Kiro Agent webview (for chat)
1511
+ const kiroAgentTargets = targets.filter(target => {
1512
+ const url = (target.url || '').toLowerCase();
1513
+ return (url.includes('kiroagent') || url.includes('vscode-webview')) &&
1514
+ target.webSocketDebuggerUrl &&
1515
+ target.type !== 'page';
1516
+ });
1517
+
1518
+ for (const target of kiroAgentTargets) {
1519
+ const wsUrl = target.webSocketDebuggerUrl;
1520
+ const cascadeId = generateCascadeId(wsUrl);
1521
+ foundCascadeIds.add(cascadeId);
1522
+
1523
+ // 3.3 Connect to new targets, reuse existing connections
1524
+ if (!cascades.has(cascadeId)) {
1525
+ console.log(`[Discovery] Found new Kiro Agent: ${target.title} (${cascadeId})`);
1526
+
1527
+ try {
1528
+ const cdp = await connectToCDP(wsUrl);
1529
+
1530
+ // Create cascade object
1531
+ const cascade = {
1532
+ id: cascadeId,
1533
+ cdp,
1534
+ metadata: {
1535
+ windowTitle: target.title || 'Unknown',
1536
+ chatTitle: '',
1537
+ isActive: true
1538
+ },
1539
+ snapshot: null,
1540
+ css: null,
1541
+ snapshotHash: null,
1542
+ // Panel snapshots (populated from main window)
1543
+ terminal: null,
1544
+ terminalHash: null,
1545
+ sidebar: null,
1546
+ sidebarHash: null,
1547
+ editor: null,
1548
+ editorHash: null
1549
+ };
1550
+
1551
+ cascades.set(cascadeId, cascade);
1552
+ console.log(`[Discovery] Connected to cascade: ${cascadeId}`);
1553
+
1554
+ // Set up disconnect handler
1555
+ cdp.ws.on('close', () => {
1556
+ console.log(`[Discovery] Cascade disconnected: ${cascadeId}`);
1557
+ cascades.delete(cascadeId);
1558
+ broadcastCascadeList();
1559
+ });
1560
+
1561
+ // Broadcast updated cascade list to all clients
1562
+ broadcastCascadeList();
1563
+
1564
+ } catch (err) {
1565
+ console.error(`[Discovery] Failed to connect to ${cascadeId}: ${err.message}`);
1566
+ }
1567
+ } else {
1568
+ // Update metadata for existing connection
1569
+ const cascade = cascades.get(cascadeId);
1570
+ cascade.metadata.windowTitle = target.title || cascade.metadata.windowTitle;
1571
+ }
1572
+ }
1573
+ } catch (err) {
1574
+ // Port not available or no CDP server
1575
+ console.log(`[Discovery] Port ${port}: ${err.message}`);
1576
+ }
1577
+ }
1578
+
1579
+ // 3.4 Clean up disconnected targets
1580
+ for (const [cascadeId, cascade] of cascades) {
1581
+ if (!foundCascadeIds.has(cascadeId)) {
1582
+ console.log(`[Discovery] Target no longer available: ${cascadeId}`);
1583
+ try {
1584
+ cascade.cdp.close();
1585
+ } catch (e) {}
1586
+ cascades.delete(cascadeId);
1587
+ broadcastCascadeList();
1588
+ }
1589
+ }
1590
+
1591
+ console.log(`[Discovery] Active cascades: ${cascades.size}`);
1592
+ }
1593
+
1594
+ /**
1595
+ * Broadcast the current cascade list to all connected WebSocket clients
1596
+ */
1597
+ function broadcastCascadeList() {
1598
+ const cascadeList = Array.from(cascades.values()).map(c => ({
1599
+ id: c.id,
1600
+ title: c.metadata.chatTitle || c.metadata.windowTitle,
1601
+ window: c.metadata.windowTitle,
1602
+ active: c.metadata.isActive
1603
+ }));
1604
+
1605
+ const message = JSON.stringify({
1606
+ type: 'cascade_list',
1607
+ cascades: cascadeList
1608
+ });
1609
+
1610
+ for (const client of wss.clients) {
1611
+ if (client.readyState === WebSocket.OPEN) {
1612
+ client.send(message);
1613
+ }
1614
+ }
1615
+ }
1616
+
1617
+ // Get local IP address for display
1618
+ function getLocalIP() {
1619
+ const interfaces = networkInterfaces();
1620
+ for (const name of Object.keys(interfaces)) {
1621
+ for (const iface of interfaces[name]) {
1622
+ // Skip internal and non-IPv4 addresses
1623
+ if (iface.family === 'IPv4' && !iface.internal) {
1624
+ return iface.address;
1625
+ }
1626
+ }
1627
+ }
1628
+ return 'localhost';
1629
+ }
1630
+
1631
+ // Express app setup
1632
+ const app = express();
1633
+ app.use(express.json());
1634
+
1635
+ // Serve static files from public directory
1636
+ app.use(express.static(join(__dirname, 'public')));
1637
+
1638
+ // =============================================================================
1639
+ // REST API Endpoints (Task 5)
1640
+ // =============================================================================
1641
+
1642
+ /**
1643
+ * GET /cascades - List active chat sessions
1644
+ * Returns array of { id, title, window, active }
1645
+ */
1646
+ app.get('/cascades', (req, res) => {
1647
+ const cascadeList = Array.from(cascades.values()).map(c => ({
1648
+ id: c.id,
1649
+ title: c.metadata?.chatTitle || c.metadata?.windowTitle || 'Unknown',
1650
+ window: c.metadata?.windowTitle || 'Unknown',
1651
+ active: c.metadata?.isActive || false
1652
+ }));
1653
+ res.json(cascadeList);
1654
+ });
1655
+
1656
+ /**
1657
+ * GET /snapshot/:id - Get HTML snapshot for a specific cascade
1658
+ * Returns snapshot object { html, bodyBg, bodyColor } or 404
1659
+ */
1660
+ app.get('/snapshot/:id', (req, res) => {
1661
+ const cascade = cascades.get(req.params.id);
1662
+ if (!cascade) {
1663
+ return res.status(404).json({ error: 'Cascade not found' });
1664
+ }
1665
+ if (!cascade.snapshot) {
1666
+ return res.status(404).json({ error: 'No snapshot available' });
1667
+ }
1668
+ res.json(cascade.snapshot);
1669
+ });
1670
+
1671
+ /**
1672
+ * GET /snapshot - Get snapshot of first active cascade (convenience endpoint)
1673
+ * Returns snapshot object or 404 if no cascades available
1674
+ */
1675
+ app.get('/snapshot', (req, res) => {
1676
+ const firstCascade = cascades.values().next().value;
1677
+ if (!firstCascade) {
1678
+ return res.status(404).json({ error: 'No cascades available' });
1679
+ }
1680
+ if (!firstCascade.snapshot) {
1681
+ return res.status(404).json({ error: 'No snapshot available' });
1682
+ }
1683
+ res.json(firstCascade.snapshot);
1684
+ });
1685
+
1686
+ /**
1687
+ * GET /debug/:id - Debug endpoint to discover DOM structure
1688
+ * Returns list of potential chat elements
1689
+ */
1690
+ app.get('/debug/:id', async (req, res) => {
1691
+ const cascade = cascades.get(req.params.id);
1692
+ if (!cascade) {
1693
+ return res.status(404).json({ error: 'Cascade not found' });
1694
+ }
1695
+
1696
+ // Instead of looking at DOM, let's list ALL CDP targets
1697
+ const results = [];
1698
+
1699
+ for (const port of CDP_PORTS) {
1700
+ try {
1701
+ const targets = await fetchCDPTargets(port);
1702
+ targets.forEach(t => {
1703
+ results.push({
1704
+ port,
1705
+ type: t.type,
1706
+ title: t.title,
1707
+ url: t.url?.substring(0, 100),
1708
+ hasWsUrl: !!t.webSocketDebuggerUrl
1709
+ });
1710
+ });
1711
+ } catch (e) {
1712
+ // ignore
1713
+ }
1714
+ }
1715
+
1716
+ res.json(results);
1717
+ });
1718
+
1719
+ /**
1720
+ * GET /dom/:id - Debug endpoint to see actual DOM content
1721
+ */
1722
+ app.get('/dom/:id', async (req, res) => {
1723
+ const cascade = cascades.get(req.params.id);
1724
+ if (!cascade) {
1725
+ return res.status(404).json({ error: 'Cascade not found' });
1726
+ }
1727
+
1728
+ const script = `
1729
+ (function() {
1730
+ // Check for nested iframe (VS Code webview pattern)
1731
+ const activeFrame = document.getElementById('active-frame');
1732
+ if (activeFrame && activeFrame.contentDocument) {
1733
+ const innerBody = activeFrame.contentDocument.body;
1734
+ return {
1735
+ type: 'nested-iframe',
1736
+ url: window.location.href,
1737
+ innerURL: activeFrame.src,
1738
+ innerBodyHTML: innerBody ? innerBody.innerHTML.substring(0, 5000) : 'no inner body',
1739
+ innerBodyChildCount: innerBody ? innerBody.children.length : 0,
1740
+ innerDivs: innerBody ? Array.from(innerBody.querySelectorAll('div')).slice(0, 30).map(d => ({
1741
+ id: d.id,
1742
+ className: d.className?.substring?.(0, 100) || '',
1743
+ childCount: d.children.length
1744
+ })) : []
1745
+ };
1746
+ }
1747
+
1748
+ return {
1749
+ type: 'direct',
1750
+ url: window.location.href,
1751
+ title: document.title,
1752
+ bodyHTML: document.body ? document.body.innerHTML.substring(0, 5000) : 'no body',
1753
+ bodyChildCount: document.body ? document.body.children.length : 0,
1754
+ hasActiveFrame: !!activeFrame,
1755
+ activeFrameSrc: activeFrame?.src
1756
+ };
1757
+ })()
1758
+ `;
1759
+
1760
+ try {
1761
+ const result = await cascade.cdp.call('Runtime.evaluate', {
1762
+ expression: script,
1763
+ contextId: cascade.cdp.rootContextId,
1764
+ returnByValue: true
1765
+ });
1766
+
1767
+ res.json(result.result?.value || { error: 'no result' });
1768
+ } catch (err) {
1769
+ res.status(500).json({ error: err.message });
1770
+ }
1771
+ });
1772
+
1773
+ /**
1774
+ * GET /styles/:id - Get CSS for a specific cascade
1775
+ * Returns CSS string or 404
1776
+ */
1777
+ app.get('/styles/:id', (req, res) => {
1778
+ const cascade = cascades.get(req.params.id);
1779
+ if (!cascade) {
1780
+ return res.status(404).json({ error: 'Cascade not found' });
1781
+ }
1782
+ if (!cascade.css) {
1783
+ return res.status(404).json({ error: 'No styles available' });
1784
+ }
1785
+ res.type('text/css').send(cascade.css);
1786
+ });
1787
+
1788
+ /**
1789
+ * GET /terminal/:id - Get Terminal snapshot for a specific cascade
1790
+ * Returns terminal object { html, textContent, hasContent } or 404
1791
+ */
1792
+ app.get('/terminal/:id', (req, res) => {
1793
+ const cascade = cascades.get(req.params.id);
1794
+ if (!cascade) {
1795
+ return res.status(404).json({ error: 'Cascade not found' });
1796
+ }
1797
+ if (!cascade.terminal || !cascade.terminal.hasContent) {
1798
+ return res.status(404).json({ error: 'No terminal content available' });
1799
+ }
1800
+ res.json(cascade.terminal);
1801
+ });
1802
+
1803
+ /**
1804
+ * GET /sidebar/:id - Get Sidebar snapshot for a specific cascade
1805
+ * Returns sidebar object { html, files, kiroPanels, hasContent } or 404
1806
+ */
1807
+ app.get('/sidebar/:id', (req, res) => {
1808
+ const cascade = cascades.get(req.params.id);
1809
+ if (!cascade) {
1810
+ return res.status(404).json({ error: 'Cascade not found' });
1811
+ }
1812
+ if (!cascade.sidebar || !cascade.sidebar.hasContent) {
1813
+ return res.status(404).json({ error: 'No sidebar content available' });
1814
+ }
1815
+ res.json(cascade.sidebar);
1816
+ });
1817
+
1818
+ /**
1819
+ * GET /editor/:id - Get Editor snapshot for a specific cascade
1820
+ * Returns editor object { html, fileName, language, content, lineCount, hasContent } or 404
1821
+ */
1822
+ app.get('/editor/:id', (req, res) => {
1823
+ const cascade = cascades.get(req.params.id);
1824
+ if (!cascade) {
1825
+ return res.status(404).json({ error: 'Cascade not found' });
1826
+ }
1827
+ if (!cascade.editor || !cascade.editor.hasContent) {
1828
+ return res.status(404).json({ error: 'No editor content available' });
1829
+ }
1830
+ res.json(cascade.editor);
1831
+ });
1832
+
1833
+ /**
1834
+ * POST /readFile/:id - Read a file directly from the filesystem
1835
+ * Body: { filePath: string }
1836
+ * Returns { content, fileName, language, lineCount, hasContent: true }
1837
+ * This bypasses Monaco's virtual scrolling limitation by reading the file directly
1838
+ */
1839
+ app.post('/readFile/:id', async (req, res) => {
1840
+ const cascade = cascades.get(req.params.id);
1841
+ if (!cascade) {
1842
+ return res.status(404).json({ error: 'Cascade not found' });
1843
+ }
1844
+
1845
+ const { filePath } = req.body;
1846
+ if (!filePath || typeof filePath !== 'string') {
1847
+ return res.status(400).json({ error: 'filePath is required' });
1848
+ }
1849
+
1850
+ console.log(`[ReadFile] Reading file: ${filePath}`);
1851
+
1852
+ try {
1853
+ const fs = await import('fs/promises');
1854
+ const path = await import('path');
1855
+
1856
+ // Helper function to recursively find a file by name
1857
+ async function findFileRecursive(dir, fileName, maxDepth = 4, currentDepth = 0) {
1858
+ if (currentDepth > maxDepth) return null;
1859
+
1860
+ try {
1861
+ const entries = await fs.readdir(dir, { withFileTypes: true });
1862
+
1863
+ // First check if file exists directly in this directory
1864
+ for (const entry of entries) {
1865
+ if (entry.isFile() && entry.name === fileName) {
1866
+ return path.join(dir, entry.name);
1867
+ }
1868
+ }
1869
+
1870
+ // Then search subdirectories (skip node_modules, .git, etc.)
1871
+ for (const entry of entries) {
1872
+ if (entry.isDirectory() &&
1873
+ !entry.name.startsWith('.') &&
1874
+ entry.name !== 'node_modules' &&
1875
+ entry.name !== 'dist' &&
1876
+ entry.name !== 'build' &&
1877
+ entry.name !== '.next') {
1878
+ const found = await findFileRecursive(
1879
+ path.join(dir, entry.name),
1880
+ fileName,
1881
+ maxDepth,
1882
+ currentDepth + 1
1883
+ );
1884
+ if (found) return found;
1885
+ }
1886
+ }
1887
+ } catch (e) {
1888
+ // Directory not accessible
1889
+ }
1890
+ return null;
1891
+ }
1892
+
1893
+ // Try to get workspace root from VS Code via CDP
1894
+ let workspaceRoot = null;
1895
+ if (mainWindowCDP.connection && mainWindowCDP.connection.rootContextId) {
1896
+ try {
1897
+ const wsScript = `
1898
+ (function() {
1899
+ // Try to get workspace folder from VS Code API
1900
+ if (typeof acquireVsCodeApi !== 'undefined') {
1901
+ return null; // Can't access workspace from webview
1902
+ }
1903
+
1904
+ // Try to find workspace path from window title or breadcrumbs
1905
+ let targetDoc = document;
1906
+ const activeFrame = document.getElementById('active-frame');
1907
+ if (activeFrame && activeFrame.contentDocument) {
1908
+ targetDoc = activeFrame.contentDocument;
1909
+ }
1910
+
1911
+ // Look for breadcrumb path
1912
+ const breadcrumb = targetDoc.querySelector('.monaco-breadcrumbs, .breadcrumbs-control');
1913
+ if (breadcrumb) {
1914
+ const parts = breadcrumb.textContent.split(/[/\\\\]/);
1915
+ if (parts.length > 1) {
1916
+ return parts.slice(0, -1).join('/');
1917
+ }
1918
+ }
1919
+
1920
+ // Try to get from window title
1921
+ const title = document.title || '';
1922
+ const match = title.match(/- ([A-Za-z]:[^-]+|\/[^-]+)/);
1923
+ if (match) {
1924
+ return match[1].trim();
1925
+ }
1926
+
1927
+ return null;
1928
+ })()
1929
+ `;
1930
+
1931
+ const wsResult = await mainWindowCDP.connection.call('Runtime.evaluate', {
1932
+ expression: wsScript,
1933
+ contextId: mainWindowCDP.connection.rootContextId,
1934
+ returnByValue: true
1935
+ });
1936
+
1937
+ if (wsResult.result && wsResult.result.value) {
1938
+ workspaceRoot = wsResult.result.value;
1939
+ }
1940
+ } catch (e) {
1941
+ console.log('[ReadFile] Could not get workspace root from CDP:', e.message);
1942
+ }
1943
+ }
1944
+
1945
+ // Try multiple possible paths
1946
+ const possiblePaths = [];
1947
+ const fileName = path.basename(filePath);
1948
+
1949
+ // If path is absolute, use it directly
1950
+ if (path.isAbsolute(filePath)) {
1951
+ possiblePaths.push(filePath);
1952
+ } else {
1953
+ // Try relative to workspace root if we have it
1954
+ if (workspaceRoot) {
1955
+ possiblePaths.push(path.join(workspaceRoot, filePath));
1956
+ }
1957
+
1958
+ // Try relative to common workspace locations
1959
+ const commonRoots = [
1960
+ process.cwd(),
1961
+ path.dirname(__dirname), // Parent of kiro-mobile-bridge
1962
+ path.join(path.dirname(__dirname), '..'), // Two levels up
1963
+ ];
1964
+
1965
+ for (const root of commonRoots) {
1966
+ possiblePaths.push(path.join(root, filePath));
1967
+ // Also try in public subdirectory (common for web projects)
1968
+ possiblePaths.push(path.join(root, 'public', filePath));
1969
+ possiblePaths.push(path.join(root, 'src', filePath));
1970
+ }
1971
+ }
1972
+
1973
+ // Try each path until we find the file
1974
+ let content = null;
1975
+ let foundPath = null;
1976
+
1977
+ for (const tryPath of possiblePaths) {
1978
+ try {
1979
+ content = await fs.readFile(tryPath, 'utf-8');
1980
+ foundPath = tryPath;
1981
+ console.log(`[ReadFile] Found file at: ${tryPath}`);
1982
+ break;
1983
+ } catch (e) {
1984
+ // File not found at this path, try next
1985
+ }
1986
+ }
1987
+
1988
+ // If still not found, do a recursive search from workspace roots
1989
+ if (!content) {
1990
+ console.log(`[ReadFile] Direct paths failed, searching recursively for: ${fileName}`);
1991
+ const searchRoots = [
1992
+ process.cwd(),
1993
+ path.dirname(__dirname),
1994
+ ];
1995
+
1996
+ for (const root of searchRoots) {
1997
+ foundPath = await findFileRecursive(root, fileName);
1998
+ if (foundPath) {
1999
+ try {
2000
+ content = await fs.readFile(foundPath, 'utf-8');
2001
+ console.log(`[ReadFile] Found file via recursive search: ${foundPath}`);
2002
+ break;
2003
+ } catch (e) {
2004
+ foundPath = null;
2005
+ }
2006
+ }
2007
+ }
2008
+ }
2009
+
2010
+ if (!content) {
2011
+ console.log(`[ReadFile] File not found. Tried paths:`, possiblePaths);
2012
+ return res.status(404).json({ error: 'File not found', triedPaths: possiblePaths, searchedFor: fileName });
2013
+ }
2014
+
2015
+ // Detect language from file extension
2016
+ const ext = path.extname(filePath).toLowerCase().slice(1);
2017
+ const extMap = {
2018
+ 'ts': 'typescript', 'tsx': 'typescript',
2019
+ 'js': 'javascript', 'jsx': 'javascript',
2020
+ 'py': 'python', 'java': 'java',
2021
+ 'html': 'html', 'css': 'css',
2022
+ 'json': 'json', 'md': 'markdown',
2023
+ 'yaml': 'yaml', 'yml': 'yaml',
2024
+ 'xml': 'xml', 'sql': 'sql',
2025
+ 'go': 'go', 'rs': 'rust',
2026
+ 'c': 'c', 'cpp': 'cpp', 'h': 'c',
2027
+ 'cs': 'csharp', 'rb': 'ruby',
2028
+ 'php': 'php', 'sh': 'bash',
2029
+ 'vue': 'vue', 'svelte': 'svelte',
2030
+ 'cob': 'cobol', 'cbl': 'cobol'
2031
+ };
2032
+
2033
+ const language = extMap[ext] || ext || '';
2034
+ const lines = content.split('\n');
2035
+
2036
+ res.json({
2037
+ content,
2038
+ fileName: path.basename(filePath),
2039
+ fullPath: foundPath,
2040
+ language,
2041
+ lineCount: lines.length,
2042
+ hasContent: true,
2043
+ startLine: 1,
2044
+ isPartial: false
2045
+ });
2046
+
2047
+ } catch (err) {
2048
+ console.error(`[ReadFile] Error:`, err.message);
2049
+ res.status(500).json({ error: err.message });
2050
+ }
2051
+ });
2052
+
2053
+ /**
2054
+ * GET /files/:id - List all code files in the workspace
2055
+ * Returns { files: [{ name, path, language }] }
2056
+ * Scans workspace directory for code files (filtered by extension)
2057
+ */
2058
+ app.get('/files/:id', async (req, res) => {
2059
+ const cascade = cascades.get(req.params.id);
2060
+ if (!cascade) {
2061
+ return res.status(404).json({ error: 'Cascade not found' });
2062
+ }
2063
+
2064
+ console.log(`[Files] Listing workspace files`);
2065
+
2066
+ try {
2067
+ const fs = await import('fs/promises');
2068
+ const path = await import('path');
2069
+
2070
+ // Code file extensions to include
2071
+ const codeExtensions = new Set([
2072
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
2073
+ '.py', '.java', '.go', '.rs', '.rb', '.php',
2074
+ '.html', '.css', '.scss', '.sass', '.less',
2075
+ '.json', '.yaml', '.yml', '.xml', '.toml',
2076
+ '.md', '.mdx', '.txt',
2077
+ '.sql', '.graphql', '.gql',
2078
+ '.sh', '.bash', '.zsh', '.ps1', '.bat', '.cmd',
2079
+ '.c', '.cpp', '.h', '.hpp', '.cs',
2080
+ '.vue', '.svelte', '.astro',
2081
+ '.env', '.gitignore', '.dockerignore',
2082
+ '.cob', '.cbl'
2083
+ ]);
2084
+
2085
+ // Extension to language mapping
2086
+ const extToLang = {
2087
+ '.ts': 'typescript', '.tsx': 'typescript',
2088
+ '.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript',
2089
+ '.py': 'python', '.java': 'java', '.go': 'go', '.rs': 'rust',
2090
+ '.rb': 'ruby', '.php': 'php',
2091
+ '.html': 'html', '.css': 'css', '.scss': 'scss', '.sass': 'sass', '.less': 'less',
2092
+ '.json': 'json', '.yaml': 'yaml', '.yml': 'yaml', '.xml': 'xml', '.toml': 'toml',
2093
+ '.md': 'markdown', '.mdx': 'markdown',
2094
+ '.sql': 'sql', '.graphql': 'graphql', '.gql': 'graphql',
2095
+ '.sh': 'bash', '.bash': 'bash', '.zsh': 'zsh', '.ps1': 'powershell', '.bat': 'batch', '.cmd': 'batch',
2096
+ '.c': 'c', '.cpp': 'cpp', '.h': 'c', '.hpp': 'cpp', '.cs': 'csharp',
2097
+ '.vue': 'vue', '.svelte': 'svelte', '.astro': 'astro',
2098
+ '.cob': 'cobol', '.cbl': 'cobol'
2099
+ };
2100
+
2101
+ const files = [];
2102
+ // Use parent directory as workspace root (kiro-mobile-bridge is inside the workspace)
2103
+ const workspaceRoot = path.dirname(__dirname);
2104
+
2105
+ // Recursive function to collect files
2106
+ async function collectFiles(dir, relativePath = '', depth = 0) {
2107
+ if (depth > 5) return; // Max depth
2108
+
2109
+ try {
2110
+ const entries = await fs.readdir(dir, { withFileTypes: true });
2111
+
2112
+ for (const entry of entries) {
2113
+ // Skip hidden files/folders and common non-code directories
2114
+ if (entry.name.startsWith('.') ||
2115
+ entry.name === 'node_modules' ||
2116
+ entry.name === 'dist' ||
2117
+ entry.name === 'build' ||
2118
+ entry.name === '.next' ||
2119
+ entry.name === '__pycache__' ||
2120
+ entry.name === 'venv' ||
2121
+ entry.name === 'coverage') {
2122
+ continue;
2123
+ }
2124
+
2125
+ const entryPath = path.join(dir, entry.name);
2126
+ const entryRelative = relativePath ? `${relativePath}/${entry.name}` : entry.name;
2127
+
2128
+ if (entry.isFile()) {
2129
+ const ext = path.extname(entry.name).toLowerCase();
2130
+ if (codeExtensions.has(ext) || entry.name === 'Dockerfile' || entry.name === 'Makefile') {
2131
+ files.push({
2132
+ name: entry.name,
2133
+ path: entryRelative,
2134
+ language: extToLang[ext] || ext.slice(1) || 'text'
2135
+ });
2136
+ }
2137
+ } else if (entry.isDirectory()) {
2138
+ await collectFiles(entryPath, entryRelative, depth + 1);
2139
+ }
2140
+ }
2141
+ } catch (e) {
2142
+ // Directory not accessible
2143
+ }
2144
+ }
2145
+
2146
+ await collectFiles(workspaceRoot);
2147
+
2148
+ // Sort files: by path for easier browsing
2149
+ files.sort((a, b) => a.path.localeCompare(b.path));
2150
+
2151
+ console.log(`[Files] Found ${files.length} code files`);
2152
+ res.json({ files, workspaceRoot });
2153
+
2154
+ } catch (err) {
2155
+ console.error(`[Files] Error:`, err.message);
2156
+ res.status(500).json({ error: err.message });
2157
+ }
2158
+ });
2159
+
2160
+ /**
2161
+ * POST /openFile/:id - Open a file in the Kiro editor
2162
+ * Body: { filePath: string }
2163
+ * Uses VS Code command to open the file
2164
+ */
2165
+ app.post('/openFile/:id', async (req, res) => {
2166
+ const cascade = cascades.get(req.params.id);
2167
+ if (!cascade) {
2168
+ return res.status(404).json({ error: 'Cascade not found' });
2169
+ }
2170
+
2171
+ const { filePath } = req.body;
2172
+ if (!filePath || typeof filePath !== 'string') {
2173
+ return res.status(400).json({ error: 'filePath is required' });
2174
+ }
2175
+
2176
+ // Use main window CDP to execute VS Code command
2177
+ if (!mainWindowCDP.connection || !mainWindowCDP.connection.rootContextId) {
2178
+ return res.status(503).json({ error: 'Main window CDP connection not available' });
2179
+ }
2180
+
2181
+ console.log(`[OpenFile] Opening file: ${filePath}`);
2182
+
2183
+ try {
2184
+ const cdp = mainWindowCDP.connection;
2185
+
2186
+ // Execute VS Code command to open file via the command palette API
2187
+ const script = `
2188
+ (function() {
2189
+ const filePath = ${JSON.stringify(filePath)};
2190
+
2191
+ // Try to find and use VS Code API
2192
+ if (typeof acquireVsCodeApi !== 'undefined') {
2193
+ const vscode = acquireVsCodeApi();
2194
+ vscode.postMessage({ command: 'openFile', path: filePath });
2195
+ return { success: true, method: 'vscodeApi' };
2196
+ }
2197
+
2198
+ // Try clicking on file link in the chat if it exists
2199
+ let targetDoc = document;
2200
+ const activeFrame = document.getElementById('active-frame');
2201
+ if (activeFrame && activeFrame.contentDocument) {
2202
+ targetDoc = activeFrame.contentDocument;
2203
+ }
2204
+
2205
+ // Look for file links that match the path
2206
+ const fileLinks = targetDoc.querySelectorAll('a[href], [data-path], [class*="file"], [class*="link"]');
2207
+ for (const link of fileLinks) {
2208
+ const text = link.textContent || '';
2209
+ const href = link.getAttribute('href') || '';
2210
+ const dataPath = link.getAttribute('data-path') || '';
2211
+
2212
+ if (text.includes(filePath) || href.includes(filePath) || dataPath.includes(filePath)) {
2213
+ link.click();
2214
+ return { success: true, method: 'linkClick', element: text.substring(0, 50) };
2215
+ }
2216
+ }
2217
+
2218
+ // Try keyboard shortcut Ctrl+P to open quick open, then type filename
2219
+ // This is a fallback that simulates user behavior
2220
+ return { success: false, error: 'Could not find file link' };
2221
+ })()
2222
+ `;
2223
+
2224
+ const evalResult = await cdp.call('Runtime.evaluate', {
2225
+ expression: script,
2226
+ contextId: cdp.rootContextId,
2227
+ returnByValue: true,
2228
+ awaitPromise: false
2229
+ });
2230
+
2231
+ if (evalResult.result && evalResult.result.value) {
2232
+ res.json(evalResult.result.value);
2233
+ } else {
2234
+ res.json({ success: false, error: 'Script execution returned no result' });
2235
+ }
2236
+ } catch (err) {
2237
+ console.error(`[OpenFile] Error:`, err.message);
2238
+ res.status(500).json({ success: false, error: err.message });
2239
+ }
2240
+ });
2241
+
2242
+ /**
2243
+ * POST /send/:id - Send message to a cascade
2244
+ * Body: { message: string }
2245
+ * Injects the message into the chat via CDP (Task 6)
2246
+ */
2247
+ app.post('/send/:id', async (req, res) => {
2248
+ const cascade = cascades.get(req.params.id);
2249
+ if (!cascade) {
2250
+ return res.status(404).json({ error: 'Cascade not found' });
2251
+ }
2252
+
2253
+ const { message } = req.body;
2254
+ if (!message || typeof message !== 'string') {
2255
+ return res.status(400).json({ error: 'Message is required' });
2256
+ }
2257
+
2258
+ if (!cascade.cdp) {
2259
+ return res.status(503).json({ error: 'CDP connection not available' });
2260
+ }
2261
+
2262
+ console.log(`[Send] Injecting message to cascade ${req.params.id}: ${message.substring(0, 50)}${message.length > 50 ? '...' : ''}`);
2263
+
2264
+ try {
2265
+ const result = await injectMessage(cascade.cdp, message);
2266
+
2267
+ if (result.success) {
2268
+ res.json({
2269
+ success: true,
2270
+ method: result.method,
2271
+ inputType: result.inputType
2272
+ });
2273
+ } else {
2274
+ res.status(500).json({
2275
+ success: false,
2276
+ error: result.error || 'Message injection failed'
2277
+ });
2278
+ }
2279
+ } catch (err) {
2280
+ console.error(`[Send] Error injecting message:`, err.message);
2281
+ res.status(500).json({
2282
+ success: false,
2283
+ error: err.message
2284
+ });
2285
+ }
2286
+ });
2287
+
2288
+ /**
2289
+ * POST /click/:id - Click an element in the Kiro UI
2290
+ * Body: { tag, text, ariaLabel, title, role, className, id, relativeX, relativeY }
2291
+ * Finds and clicks the matching element via CDP
2292
+ */
2293
+ app.post('/click/:id', async (req, res) => {
2294
+ const cascade = cascades.get(req.params.id);
2295
+ if (!cascade) {
2296
+ return res.status(404).json({ error: 'Cascade not found' });
2297
+ }
2298
+
2299
+ if (!cascade.cdp || !cascade.cdp.rootContextId) {
2300
+ return res.status(503).json({ error: 'CDP connection not available' });
2301
+ }
2302
+
2303
+ const clickInfo = req.body;
2304
+ console.log(`[Click] Attempting click:`, clickInfo.text?.substring(0, 30) || clickInfo.ariaLabel || clickInfo.tag);
2305
+
2306
+ try {
2307
+ const result = await clickElement(cascade.cdp, clickInfo);
2308
+ res.json(result);
2309
+ } catch (err) {
2310
+ console.error(`[Click] Error:`, err.message);
2311
+ res.status(500).json({ success: false, error: err.message });
2312
+ }
2313
+ });
2314
+
2315
+ /**
2316
+ * Click an element in the Kiro UI via CDP using native mouse events
2317
+ */
2318
+ async function clickElement(cdp, clickInfo) {
2319
+ // First, find the element and get its coordinates
2320
+ const findScript = `
2321
+ (function() {
2322
+ let targetDoc = document;
2323
+ const activeFrame = document.getElementById('active-frame');
2324
+ if (activeFrame && activeFrame.contentDocument) {
2325
+ targetDoc = activeFrame.contentDocument;
2326
+ }
2327
+
2328
+ const info = ${JSON.stringify(clickInfo)};
2329
+ let element = null;
2330
+ let matchMethod = '';
2331
+ let isTabClick = info.isTab || info.role === 'tab';
2332
+ let isCloseButton = info.isCloseButton || (info.ariaLabel && info.ariaLabel.toLowerCase() === 'close');
2333
+ let isToggle = info.isToggle || info.role === 'switch';
2334
+ let isDropdown = info.isDropdown || info.ariaHaspopup;
2335
+
2336
+ // Handle toggle/switch clicks
2337
+ if (isToggle && !element) {
2338
+ // Find by toggle ID first
2339
+ if (info.toggleId) {
2340
+ element = targetDoc.getElementById(info.toggleId);
2341
+ if (element) matchMethod = 'toggle-id';
2342
+ }
2343
+ // Find by label text
2344
+ if (!element && info.text) {
2345
+ const toggles = targetDoc.querySelectorAll('.kiro-toggle-switch, [role="switch"]');
2346
+ for (const t of toggles) {
2347
+ const label = t.querySelector('label') || t.closest('.kiro-toggle-switch')?.querySelector('label');
2348
+ if (label && label.textContent.trim().toLowerCase().includes(info.text.toLowerCase())) {
2349
+ element = t.querySelector('input') || t;
2350
+ matchMethod = 'toggle-label';
2351
+ break;
2352
+ }
2353
+ }
2354
+ }
2355
+ // Fallback: find any toggle switch
2356
+ if (!element) {
2357
+ element = targetDoc.querySelector('#autonomy-mode-toggle-switch, .kiro-toggle-switch input, [role="switch"]');
2358
+ if (element) matchMethod = 'toggle-fallback';
2359
+ }
2360
+ }
2361
+
2362
+ // Handle dropdown clicks
2363
+ if (isDropdown && !element) {
2364
+ // Find dropdown by text content
2365
+ if (info.text) {
2366
+ const dropdowns = targetDoc.querySelectorAll('.kiro-dropdown-trigger, [aria-haspopup="true"], [aria-haspopup="listbox"]');
2367
+ for (const d of dropdowns) {
2368
+ if (d.textContent.trim().toLowerCase().includes(info.text.toLowerCase())) {
2369
+ element = d;
2370
+ matchMethod = 'dropdown-text';
2371
+ break;
2372
+ }
2373
+ }
2374
+ }
2375
+ // Fallback: find any dropdown trigger
2376
+ if (!element) {
2377
+ element = targetDoc.querySelector('.kiro-dropdown-trigger, [aria-haspopup="true"]');
2378
+ if (element) matchMethod = 'dropdown-fallback';
2379
+ }
2380
+ }
2381
+
2382
+ // Handle close button clicks explicitly
2383
+ if (isCloseButton) {
2384
+ const closeButtons = targetDoc.querySelectorAll('[aria-label="close"], .kiro-tabs-item-close, [class*="close"]');
2385
+ for (const btn of closeButtons) {
2386
+ // Find the close button in the currently selected tab or matching context
2387
+ const parentTab = btn.closest('[role="tab"]');
2388
+ if (parentTab && parentTab.getAttribute('aria-selected') === 'true') {
2389
+ element = btn;
2390
+ matchMethod = 'close-button-selected-tab';
2391
+ break;
2392
+ }
2393
+ }
2394
+ // If no selected tab close button, find any close button
2395
+ if (!element && closeButtons.length > 0) {
2396
+ element = closeButtons[0];
2397
+ matchMethod = 'close-button-first';
2398
+ }
2399
+ }
2400
+
2401
+ // Handle file link clicks - find and click file references in chat
2402
+ if (info.isFileLink && info.filePath && !element) {
2403
+ const filePath = info.filePath;
2404
+ const fileName = filePath.split('/').pop().split('\\\\').pop();
2405
+
2406
+ // Look for file links in the chat
2407
+ const fileSelectors = [
2408
+ 'a[href*="' + fileName + '"]',
2409
+ '[data-path*="' + fileName + '"]',
2410
+ 'code',
2411
+ 'span',
2412
+ '[class*="file"]',
2413
+ '[class*="link"]',
2414
+ '[class*="path"]'
2415
+ ];
2416
+
2417
+ for (const selector of fileSelectors) {
2418
+ const candidates = targetDoc.querySelectorAll(selector);
2419
+ for (const el of candidates) {
2420
+ const text = (el.textContent || '').trim();
2421
+ const dataPath = el.getAttribute('data-path') || '';
2422
+ const href = el.getAttribute('href') || '';
2423
+
2424
+ if (text.includes(filePath) || text.includes(fileName) ||
2425
+ dataPath.includes(filePath) || dataPath.includes(fileName) ||
2426
+ href.includes(filePath) || href.includes(fileName)) {
2427
+ element = el;
2428
+ matchMethod = 'file-link-' + selector.split('[')[0];
2429
+ break;
2430
+ }
2431
+ }
2432
+ if (element) break;
2433
+ }
2434
+ }
2435
+
2436
+ // For tabs, find by label text and click the tab itself (not close button)
2437
+ if (isTabClick && !element) {
2438
+ const allTabs = targetDoc.querySelectorAll('[role="tab"]');
2439
+ const searchText = (info.tabLabel || info.text || '').trim().toLowerCase();
2440
+
2441
+ for (const tab of allTabs) {
2442
+ const labelEl = tab.querySelector('.kiro-tabs-item-label, [class*="label"]');
2443
+ const tabText = labelEl ? labelEl.textContent.trim().toLowerCase() : tab.textContent.trim().toLowerCase();
2444
+
2445
+ // Match by label text
2446
+ if (searchText && (tabText.includes(searchText) || searchText.includes(tabText))) {
2447
+ element = tab;
2448
+ matchMethod = 'tab-label-match';
2449
+ break;
2450
+ }
2451
+ }
2452
+ }
2453
+
2454
+ // 1. Try by aria-label (skip for tabs and close buttons, already handled)
2455
+ if (!isTabClick && !isCloseButton && info.ariaLabel && !element) {
2456
+ try {
2457
+ // Exclude close buttons
2458
+ const candidates = targetDoc.querySelectorAll('[aria-label="' + info.ariaLabel.replace(/"/g, '\\\\"') + '"]');
2459
+ for (const c of candidates) {
2460
+ const label = (c.getAttribute('aria-label') || '').toLowerCase();
2461
+ if (!label.includes('close') && !label.includes('delete') && !label.includes('remove')) {
2462
+ element = c;
2463
+ matchMethod = 'aria-label';
2464
+ break;
2465
+ }
2466
+ }
2467
+ } catch(e) {}
2468
+ }
2469
+
2470
+ // 2. Try by title
2471
+ if (info.title && !element) {
2472
+ try {
2473
+ element = targetDoc.querySelector('[title="' + info.title.replace(/"/g, '\\\\"') + '"]');
2474
+ if (element) matchMethod = 'title';
2475
+ } catch(e) {}
2476
+ }
2477
+
2478
+ // 3. Try by text content - search all clickable elements
2479
+ if (info.text && info.text.trim() && !element) {
2480
+ const searchText = info.text.trim();
2481
+ const allElements = targetDoc.querySelectorAll('button, [role="button"], [role="tab"], [role="menuitem"], [role="switch"], a, [tabindex="0"], [class*="button"], [class*="btn"]');
2482
+ for (const el of allElements) {
2483
+ // Skip close buttons unless explicitly looking for one
2484
+ if (!isCloseButton) {
2485
+ const ariaLabel = (el.getAttribute('aria-label') || '').toLowerCase();
2486
+ if (ariaLabel.includes('close') || ariaLabel.includes('delete')) continue;
2487
+ if (el.classList.contains('kiro-tabs-item-close')) continue;
2488
+ }
2489
+
2490
+ const elText = (el.textContent || '').trim();
2491
+ if (elText === searchText || (elText.length > 0 && searchText.includes(elText)) || (searchText.length > 0 && elText.includes(searchText))) {
2492
+ element = el;
2493
+ matchMethod = 'text-content';
2494
+ break;
2495
+ }
2496
+ }
2497
+ }
2498
+
2499
+ // 4. Try by partial aria-label match
2500
+ if (info.ariaLabel && !element && !isCloseButton) {
2501
+ const allWithAria = targetDoc.querySelectorAll('[aria-label]');
2502
+ for (const el of allWithAria) {
2503
+ const label = el.getAttribute('aria-label') || '';
2504
+ // Skip close buttons
2505
+ if (label.toLowerCase().includes('close') || label.toLowerCase().includes('delete')) continue;
2506
+
2507
+ if (label.toLowerCase().includes(info.ariaLabel.toLowerCase()) || info.ariaLabel.toLowerCase().includes(label.toLowerCase())) {
2508
+ element = el;
2509
+ matchMethod = 'aria-label-partial';
2510
+ break;
2511
+ }
2512
+ }
2513
+ }
2514
+
2515
+ // 5. Try by role
2516
+ if (info.role && !element) {
2517
+ const candidates = targetDoc.querySelectorAll('[role="' + info.role + '"]');
2518
+ if (info.text) {
2519
+ for (const c of candidates) {
2520
+ if ((c.textContent || '').includes(info.text.substring(0, 15))) {
2521
+ element = c;
2522
+ matchMethod = 'role+text';
2523
+ break;
2524
+ }
2525
+ }
2526
+ }
2527
+ }
2528
+
2529
+ if (!element) {
2530
+ return { found: false, error: 'Element not found' };
2531
+ }
2532
+
2533
+ // Scroll element into view first
2534
+ element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
2535
+
2536
+ // Get element's bounding rect for coordinate-based clicking
2537
+ const rect = element.getBoundingClientRect();
2538
+
2539
+ // For tabs, click on the LEFT side (on the label area) to avoid close button
2540
+ let x, y;
2541
+ if (isTabClick && !isCloseButton) {
2542
+ // Find the label element and click on it
2543
+ const labelEl = element.querySelector('.kiro-tabs-item-label, [class*="label"]');
2544
+ if (labelEl) {
2545
+ const labelRect = labelEl.getBoundingClientRect();
2546
+ x = labelRect.left + labelRect.width / 2;
2547
+ y = labelRect.top + labelRect.height / 2;
2548
+ } else {
2549
+ // Fallback: click 30% from left edge
2550
+ x = rect.left + rect.width * 0.3;
2551
+ y = rect.top + rect.height / 2;
2552
+ }
2553
+ } else {
2554
+ x = rect.left + rect.width / 2;
2555
+ y = rect.top + rect.height / 2;
2556
+ }
2557
+
2558
+ return {
2559
+ found: true,
2560
+ matchMethod,
2561
+ x: Math.round(x),
2562
+ y: Math.round(y),
2563
+ tag: element.tagName,
2564
+ isTab: isTabClick,
2565
+ isCloseButton: isCloseButton
2566
+ };
2567
+ })()
2568
+ `;
2569
+
2570
+ try {
2571
+ // Step 1: Find element and get coordinates
2572
+ const findResult = await cdp.call('Runtime.evaluate', {
2573
+ expression: findScript,
2574
+ contextId: cdp.rootContextId,
2575
+ returnByValue: true
2576
+ });
2577
+
2578
+ const elementInfo = findResult.result?.value;
2579
+ if (!elementInfo || !elementInfo.found) {
2580
+ console.log('[Click] Element not found:', clickInfo.ariaLabel || clickInfo.text);
2581
+ return { success: false, error: 'Element not found' };
2582
+ }
2583
+
2584
+ console.log('[Click] Found element at', elementInfo.x, elementInfo.y, 'via', elementInfo.matchMethod);
2585
+
2586
+ // Step 2: Use CDP Input.dispatchMouseEvent for native click
2587
+ // This works better with React/VS Code components
2588
+ await cdp.call('Input.dispatchMouseEvent', {
2589
+ type: 'mousePressed',
2590
+ x: elementInfo.x,
2591
+ y: elementInfo.y,
2592
+ button: 'left',
2593
+ clickCount: 1
2594
+ });
2595
+
2596
+ await cdp.call('Input.dispatchMouseEvent', {
2597
+ type: 'mouseReleased',
2598
+ x: elementInfo.x,
2599
+ y: elementInfo.y,
2600
+ button: 'left',
2601
+ clickCount: 1
2602
+ });
2603
+
2604
+ return { success: true, matchMethod: elementInfo.matchMethod, x: elementInfo.x, y: elementInfo.y };
2605
+
2606
+ } catch (err) {
2607
+ console.error('[Click] CDP error:', err.message);
2608
+ return { success: false, error: err.message };
2609
+ }
2610
+ }
2611
+
2612
+ // Create HTTP server
2613
+ const httpServer = createServer(app);
2614
+
2615
+ // =============================================================================
2616
+ // WebSocket Server (Task 7)
2617
+ // =============================================================================
2618
+
2619
+ const wss = new WebSocketServer({ server: httpServer });
2620
+
2621
+ // 7.1 WebSocketServer attached to HTTP server (done above)
2622
+ // 7.2 Send cascade list on client connect
2623
+ // 7.3 Broadcast snapshot updates when content changes (handled by broadcastSnapshotUpdate)
2624
+
2625
+ wss.on('connection', (ws, req) => {
2626
+ const clientIP = req.socket.remoteAddress || 'unknown';
2627
+ console.log(`[WebSocket] Client connected from ${clientIP}`);
2628
+
2629
+ // 7.2 Send current cascade list immediately on connect
2630
+ const cascadeList = Array.from(cascades.values()).map(c => ({
2631
+ id: c.id,
2632
+ title: c.metadata?.chatTitle || c.metadata?.windowTitle || 'Unknown',
2633
+ window: c.metadata?.windowTitle || 'Unknown',
2634
+ active: c.metadata?.isActive || false
2635
+ }));
2636
+
2637
+ ws.send(JSON.stringify({
2638
+ type: 'cascade_list',
2639
+ cascades: cascadeList
2640
+ }));
2641
+
2642
+ // Handle client disconnect
2643
+ ws.on('close', () => {
2644
+ console.log(`[WebSocket] Client disconnected from ${clientIP}`);
2645
+ });
2646
+
2647
+ // Handle errors
2648
+ ws.on('error', (err) => {
2649
+ console.error(`[WebSocket] Error from ${clientIP}:`, err.message);
2650
+ });
2651
+ });
2652
+
2653
+ // Start server
2654
+ httpServer.listen(PORT, () => {
2655
+ const localIP = getLocalIP();
2656
+ console.log('');
2657
+ console.log('🌉 Kiro Mobile Bridge');
2658
+ console.log('─────────────────────');
2659
+ console.log(`Local: http://localhost:${PORT}`);
2660
+ console.log(`Network: http://${localIP}:${PORT}`);
2661
+ console.log('');
2662
+ console.log('Open the Network URL on your phone to monitor Kiro.');
2663
+ console.log('');
2664
+ console.log('');
2665
+
2666
+ // 3.5 Run discovery on startup and every 10 seconds
2667
+ discoverTargets();
2668
+ setInterval(discoverTargets, 10000);
2669
+
2670
+ // 4.5 Run snapshot polling every 1 second for faster updates
2671
+ setInterval(pollSnapshots, 1000);
2672
+ });