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/README.md +9 -21
- package/package.json +1 -1
- package/src/public/index.html +1162 -1623
- package/src/routes/api.js +358 -0
- package/src/server.js +253 -2575
- package/src/services/cdp.js +156 -0
- package/src/services/click.js +282 -0
- package/src/services/message.js +206 -0
- package/src/services/snapshot.js +331 -0
- package/src/utils/hash.js +22 -0
- package/src/utils/network.js +20 -0
package/src/server.js
CHANGED
|
@@ -2,1365 +2,75 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Kiro Mobile Bridge Server
|
|
4
4
|
*
|
|
5
|
-
* A
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1357
|
-
|
|
68
|
+
for (const result of portResults) {
|
|
69
|
+
if (result.status !== 'fulfilled') continue;
|
|
70
|
+
const { port, targets } = result.value;
|
|
71
|
+
|
|
1358
72
|
try {
|
|
1359
|
-
|
|
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 =
|
|
86
|
+
mainWindowCDP.id = generateId(mainWindowTarget.webSocketDebuggerUrl);
|
|
1378
87
|
foundMainWindow = true;
|
|
1379
|
-
|
|
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
|
-
|
|
2037
|
-
|
|
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
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
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
|
-
|
|
2056
|
-
const
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
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
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
(
|
|
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
|
-
|
|
2186
|
-
|
|
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
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
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
|
-
|
|
2217
|
-
|
|
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
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
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
|
-
//
|
|
2326
|
-
if (
|
|
2327
|
-
|
|
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
|
-
//
|
|
2346
|
-
|
|
2347
|
-
|
|
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
|
-
//
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
const
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
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
|
-
//
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
const
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
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
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
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
|
-
|
|
2472
|
-
|
|
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
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
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
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
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
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
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
|
-
|
|
2627
|
-
|
|
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
|
-
//
|
|
324
|
+
// Server Startup
|
|
2631
325
|
// =============================================================================
|
|
2632
326
|
|
|
2633
|
-
const
|
|
327
|
+
const httpServer = createServer(app);
|
|
2634
328
|
|
|
2635
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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('
|
|
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
|
-
//
|
|
360
|
+
// Start discovery and polling
|
|
2681
361
|
discoverTargets();
|
|
2682
|
-
setInterval(discoverTargets,
|
|
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
|
});
|