kiro-mobile-bridge 1.0.7 → 1.0.8

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