lazy-gravity 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -0
  3. package/dist/bin/cli.js +79 -0
  4. package/dist/bin/commands/doctor.js +156 -0
  5. package/dist/bin/commands/open.js +145 -0
  6. package/dist/bin/commands/setup.js +366 -0
  7. package/dist/bin/commands/start.js +15 -0
  8. package/dist/bot/index.js +914 -0
  9. package/dist/commands/chatCommandHandler.js +145 -0
  10. package/dist/commands/cleanupCommandHandler.js +396 -0
  11. package/dist/commands/messageParser.js +28 -0
  12. package/dist/commands/registerSlashCommands.js +149 -0
  13. package/dist/commands/slashCommandHandler.js +104 -0
  14. package/dist/commands/workspaceCommandHandler.js +230 -0
  15. package/dist/database/chatSessionRepository.js +88 -0
  16. package/dist/database/scheduleRepository.js +119 -0
  17. package/dist/database/templateRepository.js +103 -0
  18. package/dist/database/workspaceBindingRepository.js +109 -0
  19. package/dist/events/interactionCreateHandler.js +286 -0
  20. package/dist/events/messageCreateHandler.js +154 -0
  21. package/dist/index.js +10 -0
  22. package/dist/middleware/auth.js +10 -0
  23. package/dist/middleware/sanitize.js +20 -0
  24. package/dist/services/antigravityLauncher.js +89 -0
  25. package/dist/services/approvalDetector.js +384 -0
  26. package/dist/services/autoAcceptService.js +80 -0
  27. package/dist/services/cdpBridgeManager.js +204 -0
  28. package/dist/services/cdpConnectionPool.js +157 -0
  29. package/dist/services/cdpService.js +1311 -0
  30. package/dist/services/channelManager.js +118 -0
  31. package/dist/services/chatSessionService.js +516 -0
  32. package/dist/services/modeService.js +73 -0
  33. package/dist/services/modelService.js +63 -0
  34. package/dist/services/processManager.js +61 -0
  35. package/dist/services/progressSender.js +61 -0
  36. package/dist/services/promptDispatcher.js +17 -0
  37. package/dist/services/quotaService.js +185 -0
  38. package/dist/services/responseMonitor.js +645 -0
  39. package/dist/services/scheduleService.js +134 -0
  40. package/dist/services/screenshotService.js +85 -0
  41. package/dist/services/titleGeneratorService.js +113 -0
  42. package/dist/services/workspaceService.js +64 -0
  43. package/dist/ui/autoAcceptUi.js +34 -0
  44. package/dist/ui/modeUi.js +34 -0
  45. package/dist/ui/modelsUi.js +97 -0
  46. package/dist/ui/screenshotUi.js +51 -0
  47. package/dist/ui/templateUi.js +67 -0
  48. package/dist/utils/cdpPorts.js +5 -0
  49. package/dist/utils/config.js +20 -0
  50. package/dist/utils/configLoader.js +160 -0
  51. package/dist/utils/discordFormatter.js +167 -0
  52. package/dist/utils/i18n.js +77 -0
  53. package/dist/utils/imageHandler.js +154 -0
  54. package/dist/utils/lockfile.js +113 -0
  55. package/dist/utils/logger.js +32 -0
  56. package/dist/utils/logo.js +13 -0
  57. package/dist/utils/metadataExtractor.js +15 -0
  58. package/dist/utils/processLogBuffer.js +98 -0
  59. package/dist/utils/streamMessageFormatter.js +90 -0
  60. package/package.json +73 -5
@@ -0,0 +1,1311 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.CdpService = void 0;
40
+ const logger_1 = require("../utils/logger");
41
+ const cdpPorts_1 = require("../utils/cdpPorts");
42
+ const events_1 = require("events");
43
+ const http = __importStar(require("http"));
44
+ const child_process_1 = require("child_process");
45
+ const ws_1 = __importDefault(require("ws"));
46
+ /** Antigravity UI DOM selector constants */
47
+ const SELECTORS = {
48
+ /** Chat input box: textbox excluding xterm */
49
+ CHAT_INPUT: 'div[role="textbox"]:not(.xterm-helper-textarea)',
50
+ /** Submit button search target tag */
51
+ SUBMIT_BUTTON_CONTAINER: 'button',
52
+ /** Submit icon SVG class candidates */
53
+ SUBMIT_BUTTON_SVG_CLASSES: ['lucide-arrow-right', 'lucide-arrow-up', 'lucide-send'],
54
+ /** Keyword to identify message injection target context */
55
+ CONTEXT_URL_KEYWORD: 'cascade-panel',
56
+ };
57
+ class CdpService extends events_1.EventEmitter {
58
+ ports;
59
+ isConnectedFlag = false;
60
+ ws = null;
61
+ contexts = [];
62
+ pendingCalls = new Map();
63
+ idCounter = 1;
64
+ cdpCallTimeout = 30000;
65
+ targetUrl = null;
66
+ /** Number of auto-reconnect attempts on disconnect */
67
+ maxReconnectAttempts;
68
+ /** Delay between reconnect attempts (ms) */
69
+ reconnectDelayMs;
70
+ /** Current reconnect attempt count */
71
+ reconnectAttemptCount = 0;
72
+ /** Reconnecting flag (prevents double connections) */
73
+ isReconnecting = false;
74
+ /** Currently connected workspace name */
75
+ currentWorkspaceName = null;
76
+ /** Last requested workspace path (used for deterministic reconnect) */
77
+ currentWorkspacePath = null;
78
+ /** Workspace switching flag (suppresses disconnected event) */
79
+ isSwitchingWorkspace = false;
80
+ constructor(options = {}) {
81
+ super();
82
+ this.ports = options.portsToScan || [...cdpPorts_1.CDP_PORTS];
83
+ if (options.cdpCallTimeout)
84
+ this.cdpCallTimeout = options.cdpCallTimeout;
85
+ this.maxReconnectAttempts = options.maxReconnectAttempts ?? 3;
86
+ this.reconnectDelayMs = options.reconnectDelayMs ?? 2000;
87
+ }
88
+ async getJson(url) {
89
+ return new Promise((resolve, reject) => {
90
+ http.get(url, (res) => {
91
+ let data = '';
92
+ res.on('data', chunk => data += chunk);
93
+ res.on('end', () => {
94
+ try {
95
+ resolve(JSON.parse(data));
96
+ }
97
+ catch (e) {
98
+ reject(e);
99
+ }
100
+ });
101
+ }).on('error', reject);
102
+ });
103
+ }
104
+ async discoverTarget() {
105
+ let allPages = [];
106
+ for (const port of this.ports) {
107
+ try {
108
+ const list = await this.getJson(`http://127.0.0.1:${port}/json/list`);
109
+ allPages.push(...list);
110
+ }
111
+ catch (e) {
112
+ // Ignore port not found
113
+ }
114
+ }
115
+ let target = allPages.find(t => t.type === 'page' &&
116
+ t.webSocketDebuggerUrl &&
117
+ !t.title?.includes('Launchpad') &&
118
+ !t.url?.includes('workbench-jetski-agent') &&
119
+ (t.url?.includes('workbench') || t.title?.includes('Antigravity') || t.title?.includes('Cascade')));
120
+ if (!target) {
121
+ target = allPages.find(t => t.webSocketDebuggerUrl &&
122
+ (t.url?.includes('workbench') || t.title?.includes('Antigravity') || t.title?.includes('Cascade')) &&
123
+ !t.title?.includes('Launchpad'));
124
+ }
125
+ if (!target) {
126
+ target = allPages.find(t => t.webSocketDebuggerUrl &&
127
+ (t.url?.includes('workbench') || t.title?.includes('Antigravity') || t.title?.includes('Cascade') || t.title?.includes('Launchpad')));
128
+ }
129
+ if (target && target.webSocketDebuggerUrl) {
130
+ this.targetUrl = target.webSocketDebuggerUrl;
131
+ // Extract workspace name from title (e.g., "ProjectName — Antigravity")
132
+ if (target.title && !this.currentWorkspaceName) {
133
+ const titleParts = target.title.split(/\\s[—–-]\\s/);
134
+ if (titleParts.length > 0) {
135
+ this.currentWorkspaceName = titleParts[0].trim();
136
+ }
137
+ }
138
+ return target.webSocketDebuggerUrl;
139
+ }
140
+ throw new Error('CDP target not found on any port.');
141
+ }
142
+ async connect() {
143
+ if (!this.targetUrl) {
144
+ await this.discoverTarget();
145
+ }
146
+ if (!this.targetUrl)
147
+ throw new Error('Target URL not established.');
148
+ this.ws = new ws_1.default(this.targetUrl);
149
+ await new Promise((resolve, reject) => {
150
+ if (!this.ws)
151
+ return reject(new Error('WebSocket not initialized'));
152
+ this.ws.on('open', () => {
153
+ this.isConnectedFlag = true;
154
+ resolve();
155
+ });
156
+ this.ws.on('error', reject);
157
+ });
158
+ this.ws.on('message', (msg) => {
159
+ try {
160
+ const data = JSON.parse(msg.toString());
161
+ if (data.id !== undefined && this.pendingCalls.has(data.id)) {
162
+ const { resolve, reject, timeoutId } = this.pendingCalls.get(data.id);
163
+ clearTimeout(timeoutId);
164
+ this.pendingCalls.delete(data.id);
165
+ if (data.error)
166
+ reject(data.error);
167
+ else
168
+ resolve(data.result);
169
+ }
170
+ if (data.method === 'Runtime.executionContextCreated') {
171
+ this.contexts.push(data.params.context);
172
+ }
173
+ if (data.method === 'Runtime.executionContextDestroyed') {
174
+ const idx = this.contexts.findIndex(c => c.id === data.params.executionContextId);
175
+ if (idx !== -1)
176
+ this.contexts.splice(idx, 1);
177
+ }
178
+ // Forward CDP events via EventEmitter (Network.*, Runtime.*, etc.)
179
+ if (data.method) {
180
+ this.emit(data.method, data.params);
181
+ }
182
+ }
183
+ catch (e) { }
184
+ });
185
+ this.ws.on('close', () => {
186
+ this.isConnectedFlag = false;
187
+ // Reject all unresolved pending calls to prevent memory leaks
188
+ this.clearPendingCalls(new Error('WebSocket disconnected'));
189
+ this.ws = null;
190
+ this.targetUrl = null;
191
+ // Suppress disconnected event and auto-reconnect during workspace switching
192
+ if (this.isSwitchingWorkspace)
193
+ return;
194
+ this.emit('disconnected');
195
+ // Attempt auto-reconnect (when maxReconnectAttempts > 0)
196
+ if (this.maxReconnectAttempts > 0 && !this.isReconnecting) {
197
+ this.tryReconnect();
198
+ }
199
+ });
200
+ // Initialize Runtime to get execution contexts
201
+ await this.call('Runtime.enable', {});
202
+ // Enable Network domain for event-based completion detection
203
+ try {
204
+ await this.call('Network.enable', {});
205
+ }
206
+ catch {
207
+ // Network.enable failure is non-fatal; polling fallback still works
208
+ logger_1.logger.warn('[CdpService] Network.enable failed — network event detection disabled');
209
+ }
210
+ }
211
+ async call(method, params = {}) {
212
+ if (!this.ws || this.ws.readyState !== ws_1.default.OPEN) {
213
+ throw new Error('WebSocket is not connected');
214
+ }
215
+ return new Promise((resolve, reject) => {
216
+ const id = this.idCounter++;
217
+ const timeoutId = setTimeout(() => {
218
+ if (this.pendingCalls.has(id)) {
219
+ this.pendingCalls.delete(id);
220
+ reject(new Error(`Timeout calling CDP method ${method}`));
221
+ }
222
+ }, this.cdpCallTimeout);
223
+ this.pendingCalls.set(id, { resolve, reject, timeoutId });
224
+ this.ws.send(JSON.stringify({ id, method, params }));
225
+ });
226
+ }
227
+ async disconnect() {
228
+ // Stop reconnection attempts
229
+ this.maxReconnectAttempts = 0;
230
+ if (this.ws) {
231
+ this.ws.close();
232
+ this.ws = null;
233
+ }
234
+ this.isConnectedFlag = false;
235
+ this.contexts = [];
236
+ this.currentWorkspacePath = null;
237
+ this.currentWorkspaceName = null;
238
+ this.clearPendingCalls(new Error('disconnect() was called'));
239
+ }
240
+ /**
241
+ * Return the currently connected workspace name.
242
+ */
243
+ getCurrentWorkspaceName() {
244
+ return this.currentWorkspaceName;
245
+ }
246
+ /**
247
+ * Discover and connect to the workbench page for the specified workspace.
248
+ * Does nothing if already connected to the correct page.
249
+ *
250
+ * @param workspacePath Full workspace path (e.g., /home/user/Code/MyProject)
251
+ * @returns true on successful connection
252
+ */
253
+ async discoverAndConnectForWorkspace(workspacePath) {
254
+ const workspaceDirName = workspacePath.split('/').filter(Boolean).pop() || '';
255
+ this.currentWorkspacePath = workspacePath;
256
+ // Re-validate existing connection before skipping reconnect.
257
+ if (this.isConnectedFlag && this.currentWorkspaceName === workspaceDirName) {
258
+ const stillMatched = await this.verifyCurrentWorkspace(workspaceDirName, workspacePath);
259
+ if (stillMatched) {
260
+ return true;
261
+ }
262
+ logger_1.logger.warn(`[CdpService] Workspace mismatch detected while reusing connection (expected="${workspaceDirName}"). Reconnecting...`);
263
+ }
264
+ this.isSwitchingWorkspace = true;
265
+ try {
266
+ return await this._discoverAndConnectForWorkspaceImpl(workspacePath, workspaceDirName);
267
+ }
268
+ finally {
269
+ this.isSwitchingWorkspace = false;
270
+ }
271
+ }
272
+ /**
273
+ * Verify whether the currently attached page still represents the expected workspace.
274
+ */
275
+ async verifyCurrentWorkspace(workspaceDirName, workspacePath) {
276
+ if (!this.ws || this.ws.readyState !== ws_1.default.OPEN || !this.isConnectedFlag) {
277
+ return false;
278
+ }
279
+ try {
280
+ const titleResult = await this.call('Runtime.evaluate', {
281
+ expression: 'document.title',
282
+ returnByValue: true,
283
+ });
284
+ const liveTitle = String(titleResult?.result?.value || '');
285
+ if (liveTitle.includes(workspaceDirName)) {
286
+ this.currentWorkspaceName = workspaceDirName;
287
+ return true;
288
+ }
289
+ }
290
+ catch {
291
+ // Fall through to folder-path probe.
292
+ }
293
+ return this.probeWorkspaceFolderPath(workspaceDirName, workspacePath);
294
+ }
295
+ async _discoverAndConnectForWorkspaceImpl(workspacePath, workspaceDirName) {
296
+ // Scan all ports to collect workbench pages
297
+ let pages = [];
298
+ let respondingPort = null;
299
+ for (const port of this.ports) {
300
+ try {
301
+ const list = await this.getJson(`http://127.0.0.1:${port}/json/list`);
302
+ pages.push(...list);
303
+ // Prioritize recording ports that contain workbench pages
304
+ const hasWorkbench = list.some((t) => t.url?.includes('workbench'));
305
+ if (hasWorkbench && respondingPort === null) {
306
+ respondingPort = port;
307
+ }
308
+ }
309
+ catch {
310
+ // No response from this port, next
311
+ }
312
+ }
313
+ if (respondingPort === null && pages.length > 0) {
314
+ // No workbench found but ports responded
315
+ respondingPort = this.ports[0]; // logging purposes
316
+ }
317
+ if (respondingPort === null) {
318
+ // Launch Antigravity if no port responds
319
+ return this.launchAndConnectWorkspace(workspacePath, workspaceDirName);
320
+ }
321
+ // Filter workbench pages only (exclude Launchpad, Manager, iframe, worker)
322
+ const workbenchPages = pages.filter((t) => t.type === 'page' &&
323
+ t.webSocketDebuggerUrl &&
324
+ !t.title?.includes('Launchpad') &&
325
+ !t.url?.includes('workbench-jetski-agent') &&
326
+ t.url?.includes('workbench'));
327
+ logger_1.logger.info(`[CdpService] Searching for workspace "${workspaceDirName}" (port=${respondingPort})... ${workbenchPages.length} workbench pages:`);
328
+ for (const p of workbenchPages) {
329
+ logger_1.logger.info(` - title="${p.title}" url=${p.url}`);
330
+ }
331
+ // 1. Title match (fast path)
332
+ const titleMatch = workbenchPages.find((t) => t.title?.includes(workspaceDirName));
333
+ if (titleMatch) {
334
+ return this.connectToPage(titleMatch, workspaceDirName);
335
+ }
336
+ // 2. Title match failed -> CDP probe (connect to each page and check document.title)
337
+ logger_1.logger.info(`[CdpService] Title match failed. Searching via CDP probe...`);
338
+ const probeResult = await this.probeWorkbenchPages(workbenchPages, workspaceDirName, workspacePath);
339
+ if (probeResult) {
340
+ return true;
341
+ }
342
+ // 3. If not found by probe either, launch a new window
343
+ return this.launchAndConnectWorkspace(workspacePath, workspaceDirName);
344
+ }
345
+ /**
346
+ * Connect to the specified page (skip if already connected).
347
+ */
348
+ async connectToPage(page, workspaceDirName) {
349
+ // No reconnection needed if already connected to the same URL
350
+ if (this.isConnectedFlag && this.targetUrl === page.webSocketDebuggerUrl) {
351
+ this.currentWorkspaceName = workspaceDirName;
352
+ return true;
353
+ }
354
+ this.disconnectQuietly();
355
+ this.targetUrl = page.webSocketDebuggerUrl;
356
+ await this.connect();
357
+ this.currentWorkspaceName = workspaceDirName;
358
+ logger_1.logger.info(`[CdpService] Connected to workspace "${workspaceDirName}"`);
359
+ return true;
360
+ }
361
+ /**
362
+ * Connect to each workbench page via CDP to get document.title and detect workspace name.
363
+ * Fallback when /json/list titles are stale or incomplete.
364
+ *
365
+ * If the title is "Untitled (Workspace)", verify workspace folder path via CDP.
366
+ *
367
+ * @param workbenchPages List of workbench pages
368
+ * @param workspaceDirName Workspace directory name
369
+ * @param workspacePath Full workspace path (for folder path matching)
370
+ */
371
+ async probeWorkbenchPages(workbenchPages, workspaceDirName, workspacePath) {
372
+ for (const page of workbenchPages) {
373
+ try {
374
+ // Temporarily connect to retrieve document.title
375
+ this.disconnectQuietly();
376
+ this.targetUrl = page.webSocketDebuggerUrl;
377
+ await this.connect();
378
+ const result = await this.call('Runtime.evaluate', {
379
+ expression: 'document.title',
380
+ returnByValue: true,
381
+ });
382
+ const liveTitle = result?.result?.value || '';
383
+ if (liveTitle.includes(workspaceDirName)) {
384
+ this.currentWorkspaceName = workspaceDirName;
385
+ logger_1.logger.info(`[CdpService] Probe success: detected "${workspaceDirName}"`);
386
+ return true;
387
+ }
388
+ // If title is "Untitled (Workspace)", verify by folder path
389
+ if (liveTitle.includes('Untitled') && workspacePath) {
390
+ const folderMatch = await this.probeWorkspaceFolderPath(workspaceDirName, workspacePath);
391
+ if (folderMatch) {
392
+ return true;
393
+ }
394
+ }
395
+ }
396
+ catch (e) {
397
+ logger_1.logger.warn(`[CdpService] Probe failed (page.id=${page.id}):`, e);
398
+ }
399
+ }
400
+ // Probe complete, not found -> return to disconnected state
401
+ this.disconnectQuietly();
402
+ return false;
403
+ }
404
+ /**
405
+ * Check if the currently connected page has the specified workspace folder open.
406
+ * In Antigravity (VS Code-based), info may be available from explorer views or APIs.
407
+ *
408
+ * Detects folder path via multiple approaches:
409
+ * 1. Check vscode.workspace.workspaceFolders via VS Code API
410
+ * 2. Check folder path display in DOM
411
+ * 3. Get workspace info from window.location.hash, etc.
412
+ */
413
+ async probeWorkspaceFolderPath(workspaceDirName, workspacePath) {
414
+ try {
415
+ // Instead of DOM/document.title, check folder parameter in page URL or
416
+ // folder name in explorer view
417
+ const expression = `(() => {
418
+ // Method 1: Check window title data attribute
419
+ const titleEl = document.querySelector('title');
420
+ if (titleEl && titleEl.textContent) {
421
+ const t = titleEl.textContent;
422
+ if (t !== document.title) return { found: true, source: 'title-element', value: t };
423
+ }
424
+
425
+ // Method 2: Check folder name in explorer view
426
+ const explorerItems = document.querySelectorAll('.explorer-item-label, .monaco-icon-label .label-name');
427
+ const folderNames = Array.from(explorerItems).map(e => (e.textContent || '').trim()).filter(Boolean);
428
+ if (folderNames.length > 0) return { found: true, source: 'explorer', value: folderNames.join(',') };
429
+
430
+ // Method 3: Get path from tab titles or breadcrumbs
431
+ const breadcrumbs = document.querySelectorAll('.breadcrumbs-view .folder-icon, .tabs-breadcrumbs .label-name');
432
+ const crumbs = Array.from(breadcrumbs).map(e => (e.textContent || '').trim()).filter(Boolean);
433
+ if (crumbs.length > 0) return { found: true, source: 'breadcrumbs', value: crumbs.join(',') };
434
+
435
+ // Method 4: Check body data-uri attribute, etc.
436
+ const bodyUri = document.body?.getAttribute('data-uri') || '';
437
+ if (bodyUri) return { found: true, source: 'data-uri', value: bodyUri };
438
+
439
+ return { found: false };
440
+ })()`;
441
+ const res = await this.call('Runtime.evaluate', {
442
+ expression,
443
+ returnByValue: true,
444
+ });
445
+ const value = res?.result?.value;
446
+ if (value?.found && value?.value) {
447
+ const detectedValue = value.value;
448
+ if (detectedValue.includes(workspaceDirName) ||
449
+ detectedValue.includes(workspacePath)) {
450
+ this.currentWorkspaceName = workspaceDirName;
451
+ logger_1.logger.info(`[CdpService] Folder path match success: "${workspaceDirName}"`);
452
+ return true;
453
+ }
454
+ }
455
+ // Additional fallback: check URL params (VS Code-based editors may have folder parameter)
456
+ const urlResult = await this.call('Runtime.evaluate', {
457
+ expression: 'window.location.href',
458
+ returnByValue: true,
459
+ });
460
+ const pageUrl = urlResult?.result?.value || '';
461
+ if (pageUrl.includes(encodeURIComponent(workspacePath)) || pageUrl.includes(workspaceDirName)) {
462
+ this.currentWorkspaceName = workspaceDirName;
463
+ logger_1.logger.info(`[CdpService] URL parameter match success: "${workspaceDirName}"`);
464
+ return true;
465
+ }
466
+ }
467
+ catch (e) {
468
+ logger_1.logger.warn(`[CdpService] Folder path probe failed:`, e);
469
+ }
470
+ return false;
471
+ }
472
+ /**
473
+ * Launch Antigravity and wait for a new workbench page to appear, then connect.
474
+ */
475
+ async launchAndConnectWorkspace(workspacePath, workspaceDirName) {
476
+ // Open as folder using Antigravity CLI (not as workspace mode).
477
+ // `open -a Antigravity` may open as workspace, resulting in title "Untitled (Workspace)".
478
+ // CLI --new-window opens as folder, immediately reflecting directory name in title.
479
+ const antigravityCli = '/Applications/Antigravity.app/Contents/Resources/app/bin/antigravity';
480
+ logger_1.logger.info(`[CdpService] Launching Antigravity: ${antigravityCli} --new-window ${workspacePath}`);
481
+ try {
482
+ await this.runCommand(antigravityCli, ['--new-window', workspacePath]);
483
+ }
484
+ catch (error) {
485
+ // Fall back to open -a if CLI not found
486
+ logger_1.logger.warn(`[CdpService] CLI launch failed, falling back to open -a: ${error?.message || String(error)}`);
487
+ await this.runCommand('open', ['-a', 'Antigravity', workspacePath]);
488
+ }
489
+ // Poll until a new workbench page appears (max 30 seconds)
490
+ const maxWaitMs = 30000;
491
+ const pollIntervalMs = 1000;
492
+ const startTime = Date.now();
493
+ /** Pre-launch workbench page IDs (for detecting new pages) */
494
+ let knownPageIds = new Set();
495
+ for (const port of this.ports) {
496
+ try {
497
+ const preLaunchPages = await this.getJson(`http://127.0.0.1:${port}/json/list`);
498
+ preLaunchPages.forEach((p) => {
499
+ if (p.id)
500
+ knownPageIds.add(p.id);
501
+ });
502
+ }
503
+ catch {
504
+ // No response from this port
505
+ }
506
+ }
507
+ while (Date.now() - startTime < maxWaitMs) {
508
+ await new Promise(r => setTimeout(r, pollIntervalMs));
509
+ let pages = [];
510
+ for (const port of this.ports) {
511
+ try {
512
+ const list = await this.getJson(`http://127.0.0.1:${port}/json/list`);
513
+ pages.push(...list);
514
+ }
515
+ catch {
516
+ // Next port
517
+ }
518
+ }
519
+ if (pages.length === 0)
520
+ continue;
521
+ const workbenchPages = pages.filter((t) => t.type === 'page' &&
522
+ t.webSocketDebuggerUrl &&
523
+ !t.title?.includes('Launchpad') &&
524
+ !t.url?.includes('workbench-jetski-agent') &&
525
+ t.url?.includes('workbench'));
526
+ // Title match
527
+ const titleMatch = workbenchPages.find((t) => t.title?.includes(workspaceDirName));
528
+ if (titleMatch) {
529
+ return this.connectToPage(titleMatch, workspaceDirName);
530
+ }
531
+ // CDP probe (also check folder path if title is not updated)
532
+ const probeResult = await this.probeWorkbenchPages(workbenchPages, workspaceDirName, workspacePath);
533
+ if (probeResult) {
534
+ return true;
535
+ }
536
+ // Fallback: connect to newly appeared "Untitled (Workspace)" page after launch
537
+ // If title update and folder path both fail, treat new page as target
538
+ if (Date.now() - startTime > 10000) {
539
+ const newUntitledPages = workbenchPages.filter((t) => !knownPageIds.has(t.id) &&
540
+ (t.title?.includes('Untitled') || t.title === ''));
541
+ if (newUntitledPages.length === 1) {
542
+ logger_1.logger.info(`[CdpService] New Untitled page detected. Connecting as "${workspaceDirName}" (page.id=${newUntitledPages[0].id})`);
543
+ return this.connectToPage(newUntitledPages[0], workspaceDirName);
544
+ }
545
+ }
546
+ }
547
+ throw new Error(`Workbench page for workspace "${workspaceDirName}" not found within ${maxWaitMs / 1000} seconds`);
548
+ }
549
+ async runCommand(command, args) {
550
+ await new Promise((resolve, reject) => {
551
+ const child = (0, child_process_1.spawn)(command, args, { stdio: 'ignore' });
552
+ child.once('error', (error) => {
553
+ reject(error);
554
+ });
555
+ child.once('close', (code) => {
556
+ if (code === 0) {
557
+ resolve();
558
+ return;
559
+ }
560
+ reject(new Error(`${command} exited with code ${code ?? 'unknown'}`));
561
+ });
562
+ });
563
+ }
564
+ /**
565
+ * Quietly disconnect the existing connection (no reconnect attempts).
566
+ * Used during workspace switching.
567
+ *
568
+ * Important: ws.close() fires close event asynchronously, so all listeners
569
+ * must be removed first to prevent targetUrl reset and tryReconnect()
570
+ * from reconnecting to a different workbench.
571
+ */
572
+ disconnectQuietly() {
573
+ if (this.ws) {
574
+ // Remove all listeners including close event handlers to prevent side effects
575
+ this.ws.removeAllListeners();
576
+ this.ws.close();
577
+ this.ws = null;
578
+ this.isConnectedFlag = false;
579
+ this.contexts = [];
580
+ this.clearPendingCalls(new Error('Disconnected for workspace switch'));
581
+ this.targetUrl = null;
582
+ }
583
+ }
584
+ /**
585
+ * Reject all unresolved pending calls to prevent memory leaks.
586
+ * (Step 12: Error handling)
587
+ * @param error Error to pass to reject
588
+ */
589
+ clearPendingCalls(error) {
590
+ for (const [, { reject, timeoutId }] of this.pendingCalls.entries()) {
591
+ clearTimeout(timeoutId);
592
+ reject(error);
593
+ }
594
+ this.pendingCalls.clear();
595
+ }
596
+ /**
597
+ * Attempt auto-reconnect after CDP disconnection.
598
+ * Fires 'reconnectFailed' event after maxReconnectAttempts failures.
599
+ * (Step 12: Error handling and timeout management)
600
+ */
601
+ async tryReconnect() {
602
+ if (this.isReconnecting)
603
+ return;
604
+ this.isReconnecting = true;
605
+ this.reconnectAttemptCount = 0;
606
+ while (this.reconnectAttemptCount < this.maxReconnectAttempts) {
607
+ this.reconnectAttemptCount++;
608
+ logger_1.logger.error(`[CdpService] Reconnect attempt ${this.reconnectAttemptCount}/${this.maxReconnectAttempts}...`);
609
+ // Add delay between attempts
610
+ await new Promise(r => setTimeout(r, this.reconnectDelayMs));
611
+ try {
612
+ this.contexts = [];
613
+ if (this.currentWorkspacePath) {
614
+ await this.discoverAndConnectForWorkspace(this.currentWorkspacePath);
615
+ }
616
+ else {
617
+ await this.discoverTarget();
618
+ await this.connect();
619
+ }
620
+ logger_1.logger.error('[CdpService] Reconnect succeeded.');
621
+ this.reconnectAttemptCount = 0;
622
+ this.isReconnecting = false;
623
+ this.emit('reconnected');
624
+ return;
625
+ }
626
+ catch (err) {
627
+ logger_1.logger.error('[CdpService] Reconnect failed:', err);
628
+ }
629
+ }
630
+ this.isReconnecting = false;
631
+ const finalError = new Error(`CDP reconnection failed ${this.maxReconnectAttempts} times. Manual restart required.`);
632
+ logger_1.logger.error('[CdpService]', finalError.message);
633
+ this.emit('reconnectFailed', finalError);
634
+ }
635
+ isConnected() {
636
+ return this.isConnectedFlag;
637
+ }
638
+ getContexts() {
639
+ return [...this.contexts];
640
+ }
641
+ /**
642
+ * Wait by polling until cascade-panel context becomes available.
643
+ * Right after Antigravity launch, contexts are created asynchronously even after Runtime.enable,
644
+ * so use this method to confirm readiness before DOM operations.
645
+ *
646
+ * @param timeoutMs Maximum wait time (ms). Default: 10000
647
+ * @param pollIntervalMs Polling interval (ms). Default: 500
648
+ * @returns true if cascade-panel context was found
649
+ */
650
+ async waitForCascadePanelReady(timeoutMs = 10000, pollIntervalMs = 500) {
651
+ const start = Date.now();
652
+ while (Date.now() - start < timeoutMs) {
653
+ const cascadeCtx = this.contexts.find(c => c.url && c.url.includes(SELECTORS.CONTEXT_URL_KEYWORD));
654
+ if (cascadeCtx) {
655
+ return true;
656
+ }
657
+ await new Promise(r => setTimeout(r, pollIntervalMs));
658
+ }
659
+ return false;
660
+ }
661
+ getPrimaryContextId() {
662
+ // Find cascade-panel context
663
+ const context = this.contexts.find(c => c.url && c.url.includes('cascade-panel'));
664
+ if (context)
665
+ return context.id;
666
+ // Fallback to Extension context or first one
667
+ const extContext = this.contexts.find(c => c.name && c.name.includes('Extension'));
668
+ if (extContext)
669
+ return extContext.id;
670
+ return this.contexts.length > 0 ? this.contexts[0].id : null;
671
+ }
672
+ /**
673
+ * Focus the chat input field.
674
+ */
675
+ async focusChatInput() {
676
+ const focusScript = `(() => {
677
+ const editors = Array.from(document.querySelectorAll('${SELECTORS.CHAT_INPUT}'));
678
+ const visible = editors.filter(el => el.offsetParent !== null);
679
+ const editor = visible[visible.length - 1];
680
+ if (!editor) return { ok: false, error: 'No editor found' };
681
+ editor.focus();
682
+ return { ok: true };
683
+ })()`;
684
+ for (const ctx of this.contexts) {
685
+ try {
686
+ const res = await this.call('Runtime.evaluate', {
687
+ expression: focusScript,
688
+ returnByValue: true,
689
+ contextId: ctx.id,
690
+ });
691
+ if (res?.result?.value?.ok) {
692
+ return { ok: true, contextId: ctx.id };
693
+ }
694
+ }
695
+ catch {
696
+ // Try next context
697
+ }
698
+ }
699
+ return { ok: false, error: 'Chat input field not found' };
700
+ }
701
+ /**
702
+ * Send Enter key to submit the message.
703
+ */
704
+ async pressEnterToSend() {
705
+ await this.call('Input.dispatchKeyEvent', {
706
+ type: 'keyDown',
707
+ key: 'Enter',
708
+ code: 'Enter',
709
+ windowsVirtualKeyCode: 13,
710
+ nativeVirtualKeyCode: 13,
711
+ });
712
+ await this.call('Input.dispatchKeyEvent', {
713
+ type: 'keyUp',
714
+ key: 'Enter',
715
+ code: 'Enter',
716
+ windowsVirtualKeyCode: 13,
717
+ nativeVirtualKeyCode: 13,
718
+ });
719
+ }
720
+ /**
721
+ * Detect file input in the UI and attach the specified files.
722
+ */
723
+ async attachImageFiles(filePaths, contextId) {
724
+ if (filePaths.length === 0)
725
+ return { ok: true };
726
+ await this.call('DOM.enable', {});
727
+ const locateInputScript = `(async () => {
728
+ const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
729
+ const visible = (el) => {
730
+ if (!el) return false;
731
+ if (el.offsetParent !== null) return true;
732
+ const style = window.getComputedStyle(el);
733
+ if (!style) return false;
734
+ if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
735
+ const rect = typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : null;
736
+ return !!rect && rect.width > 0 && rect.height > 0;
737
+ };
738
+ const normalize = (v) => (v || '').toLowerCase();
739
+ const hasImageAccept = (input) => {
740
+ const accept = normalize(input.getAttribute('accept'));
741
+ return !accept || accept.includes('image') || accept.includes('*/*');
742
+ };
743
+ const findInput = () => {
744
+ const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
745
+ const visibleInput = inputs.find(i => visible(i) && hasImageAccept(i));
746
+ if (visibleInput) return visibleInput;
747
+ return inputs.find(hasImageAccept) || null;
748
+ };
749
+
750
+ let input = findInput();
751
+ if (!input) {
752
+ const triggerKeywords = ['attach', 'upload', 'image', 'file', 'paperclip', 'plus'];
753
+ const triggers = Array.from(document.querySelectorAll('button, [role="button"]'))
754
+ .filter(visible)
755
+ .filter((el) => {
756
+ const text = normalize(el.textContent);
757
+ const aria = normalize(el.getAttribute('aria-label'));
758
+ const title = normalize(el.getAttribute('title'));
759
+ const cls = normalize(el.getAttribute('class'));
760
+ const all = [text, aria, title, cls].join(' ');
761
+ return triggerKeywords.some(k => all.includes(k));
762
+ })
763
+ .slice(-8);
764
+
765
+ for (const trigger of triggers) {
766
+ if (typeof trigger.click === 'function') {
767
+ trigger.click();
768
+ await wait(150);
769
+ input = findInput();
770
+ if (input) break;
771
+ }
772
+ }
773
+ }
774
+
775
+ if (!input) {
776
+ return { ok: false, error: 'Image upload input not found' };
777
+ }
778
+
779
+ const token = 'agclaw-upload-' + Math.random().toString(36).slice(2, 10);
780
+ input.setAttribute('data-agclaw-upload-token', token);
781
+ return { ok: true, token };
782
+ })()`;
783
+ const callParams = {
784
+ expression: locateInputScript,
785
+ returnByValue: true,
786
+ awaitPromise: true,
787
+ };
788
+ if (contextId !== undefined) {
789
+ callParams.contextId = contextId;
790
+ }
791
+ const locateResult = await this.call('Runtime.evaluate', callParams);
792
+ const locateValue = locateResult?.result?.value;
793
+ if (!locateValue?.ok || !locateValue?.token) {
794
+ return { ok: false, error: locateValue?.error || 'Failed to locate file input' };
795
+ }
796
+ const token = String(locateValue.token);
797
+ const documentResult = await this.call('DOM.getDocument', { depth: 1, pierce: true });
798
+ const rootNodeId = documentResult?.root?.nodeId;
799
+ if (!rootNodeId) {
800
+ return { ok: false, error: 'Failed to get DOM root' };
801
+ }
802
+ const selector = `input[data-agclaw-upload-token="${token}"]`;
803
+ const nodeResult = await this.call('DOM.querySelector', {
804
+ nodeId: rootNodeId,
805
+ selector,
806
+ });
807
+ const nodeId = nodeResult?.nodeId;
808
+ if (!nodeId) {
809
+ return { ok: false, error: 'Failed to get upload input node' };
810
+ }
811
+ await this.call('DOM.setFileInputFiles', {
812
+ nodeId,
813
+ files: filePaths,
814
+ });
815
+ const notifyScript = `(() => {
816
+ const input = document.querySelector('${selector}');
817
+ if (!input) return { ok: false, error: 'Image input not found' };
818
+ input.dispatchEvent(new Event('input', { bubbles: true }));
819
+ input.dispatchEvent(new Event('change', { bubbles: true }));
820
+ input.removeAttribute('data-agclaw-upload-token');
821
+ return { ok: true };
822
+ })()`;
823
+ await this.call('Runtime.evaluate', {
824
+ expression: notifyScript,
825
+ returnByValue: true,
826
+ awaitPromise: true,
827
+ ...(contextId !== undefined ? { contextId } : {}),
828
+ });
829
+ await new Promise(r => setTimeout(r, 250));
830
+ return { ok: true };
831
+ }
832
+ /**
833
+ * Inject and send the specified text into Antigravity's chat input field.
834
+ *
835
+ * Strategy:
836
+ * 1. Focus editor via Runtime.evaluate
837
+ * 2. Input text via CDP Input.insertText
838
+ * 3. Send via CDP Input.dispatchKeyEvent(Enter)
839
+ *
840
+ * Using CDP Input API instead of DOM manipulation ensures reliable
841
+ * delivery to Cascade panel's React/framework event handlers.
842
+ */
843
+ async injectMessage(text) {
844
+ if (!this.isConnectedFlag || !this.ws) {
845
+ throw new Error('Not connected to CDP. Call connect() first.');
846
+ }
847
+ const focusResult = await this.focusChatInput();
848
+ if (!focusResult.ok) {
849
+ return { ok: false, error: focusResult.error || 'Chat input field not found' };
850
+ }
851
+ // 1. Input text via CDP Input.insertText
852
+ await this.call('Input.insertText', { text });
853
+ await new Promise(r => setTimeout(r, 200));
854
+ // 2. Send via Enter key
855
+ await this.pressEnterToSend();
856
+ return { ok: true, method: 'enter', contextId: focusResult.contextId };
857
+ }
858
+ /**
859
+ * Attach image files to the UI and send the specified text.
860
+ */
861
+ async injectMessageWithImageFiles(text, imageFilePaths) {
862
+ if (!this.isConnectedFlag || !this.ws) {
863
+ throw new Error('Not connected to CDP. Call connect() first.');
864
+ }
865
+ const focusResult = await this.focusChatInput();
866
+ if (!focusResult.ok) {
867
+ return { ok: false, error: focusResult.error || 'Chat input field not found' };
868
+ }
869
+ const attachResult = await this.attachImageFiles(imageFilePaths, focusResult.contextId);
870
+ if (!attachResult.ok) {
871
+ return { ok: false, error: attachResult.error || 'Failed to attach images' };
872
+ }
873
+ await this.call('Input.insertText', { text });
874
+ await new Promise(r => setTimeout(r, 200));
875
+ await this.pressEnterToSend();
876
+ return { ok: true, method: 'enter', contextId: focusResult.contextId };
877
+ }
878
+ /**
879
+ * Extract images from the latest AI response.
880
+ */
881
+ async extractLatestResponseImages(maxImages = 4) {
882
+ if (!this.isConnectedFlag || !this.ws) {
883
+ return [];
884
+ }
885
+ const safeMaxImages = Math.max(1, Math.min(8, Math.floor(maxImages)));
886
+ const expression = `(async () => {
887
+ const maxImages = ${safeMaxImages};
888
+ const panel = document.querySelector('.antigravity-agent-side-panel');
889
+ const scope = panel || document;
890
+
891
+ const candidateSelectors = [
892
+ '.rendered-markdown',
893
+ '.leading-relaxed.select-text',
894
+ '.flex.flex-col.gap-y-3',
895
+ '[data-message-author-role="assistant"]',
896
+ '[data-message-role="assistant"]',
897
+ '[class*="assistant-message"]',
898
+ '[class*="message-content"]',
899
+ '[class*="markdown-body"]',
900
+ '.prose',
901
+ ];
902
+
903
+ const responseNodes = [];
904
+ const seenNodes = new Set();
905
+ for (const selector of candidateSelectors) {
906
+ const nodes = scope.querySelectorAll(selector);
907
+ for (const node of nodes) {
908
+ if (!node || seenNodes.has(node)) continue;
909
+ seenNodes.add(node);
910
+ responseNodes.push(node);
911
+ }
912
+ }
913
+
914
+ // Skip image extraction when no response nodes found (prevent UI icon false positives)
915
+ if (responseNodes.length === 0) return [];
916
+
917
+ const normalize = (value) => (value || '').toLowerCase();
918
+ const isLikelyUiImage = (img) => {
919
+ if (!img) return true;
920
+ const src = normalize(img.currentSrc || img.src || img.getAttribute('src') || '');
921
+ const alt = normalize(img.getAttribute('alt') || '');
922
+ const title = normalize(img.getAttribute('title') || '');
923
+ const cls = normalize(img.getAttribute('class') || '');
924
+ const blob = [src, alt, title, cls].join(' ');
925
+
926
+ if (blob.includes('icon') || blob.includes('avatar') || blob.includes('emoji')) return true;
927
+ if (blob.includes('thumb') || blob.includes('good') || blob.includes('bad')) return true;
928
+ if (src.startsWith('data:image/svg+xml')) return true;
929
+ if (img.closest('button, [role="button"], nav, header, footer, [class*="toolbar"], [class*="reaction"]')) return true;
930
+
931
+ const rect = typeof img.getBoundingClientRect === 'function' ? img.getBoundingClientRect() : null;
932
+ const w = Number(img.naturalWidth || img.width || rect?.width || 0);
933
+ const h = Number(img.naturalHeight || img.height || rect?.height || 0);
934
+ if (w < 96 || h < 96) return true;
935
+ if ((w * h) < 12000) return true;
936
+
937
+ return false;
938
+ };
939
+
940
+ const dedup = new Set();
941
+ const images = [];
942
+ for (let i = responseNodes.length - 1; i >= 0; i--) {
943
+ const node = responseNodes[i];
944
+ const nodeImages = Array.from(node.querySelectorAll('img'));
945
+ for (const img of nodeImages) {
946
+ if (isLikelyUiImage(img)) continue;
947
+ const key = (img.currentSrc || img.src || img.getAttribute('src') || '') + '|' + (img.getAttribute('alt') || '');
948
+ if (!key || dedup.has(key)) continue;
949
+ dedup.add(key);
950
+ images.push(img);
951
+ }
952
+ if (images.length >= maxImages) break;
953
+ }
954
+
955
+ if (images.length === 0) return [];
956
+ const picked = images.slice(-maxImages);
957
+
958
+ const normalizeFileName = (value, idx) => {
959
+ const raw = (value || '').trim();
960
+ const safe = raw.replace(/[^a-zA-Z0-9._-]/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
961
+ return safe || ('generated-image-' + (idx + 1));
962
+ };
963
+
964
+ const guessMimeType = (src) => {
965
+ if (!src) return 'image/png';
966
+ if (src.startsWith('data:')) {
967
+ const match = src.match(/^data:([^;]+);/);
968
+ return (match && match[1]) || 'image/png';
969
+ }
970
+ const lower = src.toLowerCase();
971
+ if (lower.includes('.jpg') || lower.includes('.jpeg')) return 'image/jpeg';
972
+ if (lower.includes('.webp')) return 'image/webp';
973
+ if (lower.includes('.gif')) return 'image/gif';
974
+ return 'image/png';
975
+ };
976
+
977
+ const blobToBase64 = (blob) => new Promise((resolve, reject) => {
978
+ const reader = new FileReader();
979
+ reader.onload = () => {
980
+ const value = typeof reader.result === 'string' ? reader.result : '';
981
+ const commaIndex = value.indexOf(',');
982
+ resolve(commaIndex >= 0 ? value.slice(commaIndex + 1) : value);
983
+ };
984
+ reader.onerror = () => reject(reader.error || new Error('read failed'));
985
+ reader.readAsDataURL(blob);
986
+ });
987
+
988
+ const result = [];
989
+ for (let i = 0; i < picked.length; i++) {
990
+ const img = picked[i];
991
+ const src = img.currentSrc || img.src || img.getAttribute('src') || '';
992
+ if (!src) continue;
993
+
994
+ const baseName = normalizeFileName(img.getAttribute('alt') || img.getAttribute('title'), i);
995
+ const mimeType = guessMimeType(src);
996
+ const extensionMap = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' };
997
+ const ext = extensionMap[mimeType] || 'png';
998
+ const name = baseName.includes('.') ? baseName : (baseName + '.' + ext);
999
+
1000
+ if (src.startsWith('data:')) {
1001
+ const commaIndex = src.indexOf(',');
1002
+ if (commaIndex > 0) {
1003
+ result.push({
1004
+ name,
1005
+ mimeType,
1006
+ base64Data: src.slice(commaIndex + 1),
1007
+ });
1008
+ }
1009
+ continue;
1010
+ }
1011
+
1012
+ try {
1013
+ const response = await fetch(src);
1014
+ if (!response.ok) throw new Error('fetch failed');
1015
+ const blob = await response.blob();
1016
+ const base64Data = await blobToBase64(blob);
1017
+ result.push({
1018
+ name,
1019
+ mimeType: blob.type || mimeType,
1020
+ base64Data,
1021
+ });
1022
+ } catch {
1023
+ result.push({
1024
+ name,
1025
+ mimeType,
1026
+ url: src,
1027
+ });
1028
+ }
1029
+ }
1030
+
1031
+ return result;
1032
+ })()`;
1033
+ try {
1034
+ const contextId = this.getPrimaryContextId();
1035
+ const callParams = {
1036
+ expression,
1037
+ returnByValue: true,
1038
+ awaitPromise: true,
1039
+ };
1040
+ if (contextId !== null) {
1041
+ callParams.contextId = contextId;
1042
+ }
1043
+ const response = await this.call('Runtime.evaluate', callParams);
1044
+ const value = response?.result?.value;
1045
+ if (!Array.isArray(value))
1046
+ return [];
1047
+ return value
1048
+ .filter((item) => item && typeof item === 'object' && typeof item.name === 'string')
1049
+ .map((item) => ({
1050
+ name: item.name,
1051
+ mimeType: typeof item.mimeType === 'string' ? item.mimeType : 'image/png',
1052
+ base64Data: typeof item.base64Data === 'string' ? item.base64Data : undefined,
1053
+ url: typeof item.url === 'string' ? item.url : undefined,
1054
+ }));
1055
+ }
1056
+ catch {
1057
+ return [];
1058
+ }
1059
+ }
1060
+ /**
1061
+ * Operate Antigravity UI mode dropdown to switch to the specified mode.
1062
+ * Two-step approach:
1063
+ * Step 1: Click mode toggle button ("Fast"/"Plan" + chevron icon) to open dropdown
1064
+ * Step 2: Select the target mode option from dropdown
1065
+ *
1066
+ * @param modeName Mode name to set (e.g., 'fast', 'plan')
1067
+ */
1068
+ async setUiMode(modeName) {
1069
+ if (!this.isConnectedFlag || !this.ws) {
1070
+ throw new Error('Not connected to CDP. Call connect() first.');
1071
+ }
1072
+ const safeMode = JSON.stringify(modeName);
1073
+ // Internal mode name -> Antigravity UI display name mapping
1074
+ const uiNameMap = JSON.stringify({ fast: 'Fast', plan: 'Planning' });
1075
+ // Build DOM manipulation script avoiding backticks in template literals
1076
+ const expression = '(async () => {'
1077
+ + ' const targetMode = ' + safeMode + ';'
1078
+ + ' const targetModeLower = targetMode.toLowerCase();'
1079
+ + ' const uiNameMap = ' + uiNameMap + ';'
1080
+ + ' const targetUiName = uiNameMap[targetModeLower] || targetMode;'
1081
+ + ' const targetUiNameLower = targetUiName.toLowerCase();'
1082
+ + ' const allBtns = Array.from(document.querySelectorAll("button"));'
1083
+ + ' const visibleBtns = allBtns.filter(b => b.offsetParent !== null);'
1084
+ // Step 1: Search for mode toggle button ("Fast"/"Planning" + chevron icon)
1085
+ + ' const knownModes = Object.values(uiNameMap).map(n => n.toLowerCase());'
1086
+ + ' const modeToggleBtn = visibleBtns.find(b => {'
1087
+ + ' const text = (b.textContent || "").trim().toLowerCase();'
1088
+ + ' const hasChevron = b.querySelector("svg[class*=\\"chevron\\"]");'
1089
+ + ' return knownModes.some(m => text === m) && hasChevron;'
1090
+ + ' });'
1091
+ + ' if (!modeToggleBtn) {'
1092
+ + ' return { ok: false, error: "Mode toggle button not found" };'
1093
+ + ' }'
1094
+ + ' const currentModeText = (modeToggleBtn.textContent || "").trim().toLowerCase();'
1095
+ // Do nothing if already on the target mode
1096
+ + ' if (currentModeText === targetUiNameLower) {'
1097
+ + ' return { ok: true, mode: targetUiName, alreadySelected: true };'
1098
+ + ' }'
1099
+ // Open dropdown
1100
+ + ' modeToggleBtn.click();'
1101
+ + ' await new Promise(r => setTimeout(r, 500));'
1102
+ // Step 2: Search for option by .font-medium text inside role="dialog"
1103
+ + ' const dialogs = Array.from(document.querySelectorAll("[role=\\"dialog\\"]"));'
1104
+ + ' const visibleDialog = dialogs.find(d => {'
1105
+ + ' const style = window.getComputedStyle(d);'
1106
+ + ' return style.visibility !== "hidden" && style.display !== "none";'
1107
+ + ' });'
1108
+ + ' let modeOption = null;'
1109
+ + ' if (visibleDialog) {'
1110
+ + ' const fontMediumEls = Array.from(visibleDialog.querySelectorAll(".font-medium"));'
1111
+ + ' const matchEl = fontMediumEls.find(el => {'
1112
+ + ' const text = (el.textContent || "").trim().toLowerCase();'
1113
+ + ' return text === targetUiNameLower;'
1114
+ + ' });'
1115
+ + ' if (matchEl) {'
1116
+ // Target the parent element of .font-medium (div with cursor-pointer) for clicking
1117
+ + ' modeOption = matchEl.closest("div.cursor-pointer") || matchEl.parentElement;'
1118
+ + ' }'
1119
+ + ' }'
1120
+ // Fallback when dialog not found: legacy selectors
1121
+ + ' if (!modeOption) {'
1122
+ + ' const fallbackEls = Array.from(document.querySelectorAll('
1123
+ + ' "div[class*=\\"cursor-pointer\\"]"'
1124
+ + ' )).filter(el => el.offsetParent !== null);'
1125
+ + ' modeOption = fallbackEls.find(el => {'
1126
+ + ' if (el === modeToggleBtn) return false;'
1127
+ + ' const fm = el.querySelector(".font-medium");'
1128
+ + ' if (fm) {'
1129
+ + ' const text = (fm.textContent || "").trim().toLowerCase();'
1130
+ + ' return text === targetUiNameLower;'
1131
+ + ' }'
1132
+ + ' return false;'
1133
+ + ' });'
1134
+ + ' }'
1135
+ + ' if (modeOption) {'
1136
+ + ' modeOption.click();'
1137
+ + ' await new Promise(r => setTimeout(r, 500));'
1138
+ // Verify: check if mode button text has changed
1139
+ + ' const updBtn = Array.from(document.querySelectorAll("button"))'
1140
+ + ' .filter(b => b.offsetParent !== null)'
1141
+ + ' .find(b => b.querySelector("svg[class*=\\"chevron\\"]") && knownModes.some(m => (b.textContent || "").trim().toLowerCase() === m));'
1142
+ + ' const newMode = updBtn ? (updBtn.textContent || "").trim() : "unknown";'
1143
+ + ' return { ok: true, mode: newMode };'
1144
+ + ' }'
1145
+ // Failed -> close dropdown
1146
+ + ' document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));'
1147
+ + ' await new Promise(r => setTimeout(r, 200));'
1148
+ + ' return { ok: false, error: "Mode option " + targetUiName + " not found in dropdown" };'
1149
+ + '})()';
1150
+ try {
1151
+ const contextId = this.getPrimaryContextId();
1152
+ const callParams = {
1153
+ expression,
1154
+ returnByValue: true,
1155
+ awaitPromise: true,
1156
+ };
1157
+ if (contextId !== null)
1158
+ callParams.contextId = contextId;
1159
+ const res = await this.call('Runtime.evaluate', callParams);
1160
+ const value = res?.result?.value;
1161
+ if (value?.ok) {
1162
+ return { ok: true, mode: value.mode };
1163
+ }
1164
+ return { ok: false, error: value?.error || 'UI operation failed (setUiMode)' };
1165
+ }
1166
+ catch (error) {
1167
+ return { ok: false, error: error?.message || String(error) };
1168
+ }
1169
+ }
1170
+ /**
1171
+ * Dynamically retrieve the list of available models from the Antigravity UI.
1172
+ */
1173
+ async getUiModels() {
1174
+ if (!this.isConnectedFlag || !this.ws) {
1175
+ throw new Error('Not connected to CDP.');
1176
+ }
1177
+ const expression = `(async () => {
1178
+ return Array.from(document.querySelectorAll('div.cursor-pointer'))
1179
+ .map(e => ({text: (e.textContent || '').trim().replace(/New$/, ''), class: e.className}))
1180
+ .filter(e => e.class.includes('px-2 py-1 flex items-center justify-between') || e.text.includes('Gemini') || e.text.includes('GPT') || e.text.includes('Claude'))
1181
+ .map(e => e.text);
1182
+ })()`;
1183
+ try {
1184
+ const contextId = this.getPrimaryContextId();
1185
+ const callParams = {
1186
+ expression,
1187
+ returnByValue: true,
1188
+ awaitPromise: true,
1189
+ };
1190
+ if (contextId !== null)
1191
+ callParams.contextId = contextId;
1192
+ const res = await this.call('Runtime.evaluate', callParams);
1193
+ const value = res?.result?.value;
1194
+ if (Array.isArray(value) && value.length > 0) {
1195
+ // remove duplicates
1196
+ return Array.from(new Set(value));
1197
+ }
1198
+ return [];
1199
+ }
1200
+ catch (error) {
1201
+ logger_1.logger.error('Failed to get UI models:', error);
1202
+ return [];
1203
+ }
1204
+ }
1205
+ /**
1206
+ * Get the currently selected model from the Antigravity UI.
1207
+ */
1208
+ async getCurrentModel() {
1209
+ if (!this.isConnectedFlag || !this.ws) {
1210
+ return null;
1211
+ }
1212
+ const expression = `(() => {
1213
+ return Array.from(document.querySelectorAll('div.cursor-pointer'))
1214
+ .find(e => e.className.includes('px-2 py-1 flex items-center justify-between') && e.className.includes('bg-gray-500/20'))
1215
+ ?.textContent?.trim().replace(/New$/, '') || null;
1216
+ })()`;
1217
+ try {
1218
+ const contextId = this.getPrimaryContextId();
1219
+ const res = await this.call('Runtime.evaluate', {
1220
+ expression, returnByValue: true, awaitPromise: true,
1221
+ contextId: contextId || undefined
1222
+ });
1223
+ return res?.result?.value || null;
1224
+ }
1225
+ catch (e) {
1226
+ return null;
1227
+ }
1228
+ }
1229
+ /**
1230
+ * Operate Antigravity UI model dropdown to switch to the specified model.
1231
+ * (Step 9: Model/mode switching UI sync)
1232
+ *
1233
+ * @param modelName Model name to set (e.g., 'gpt-4o', 'claude-3-opus')
1234
+ */
1235
+ async setUiModel(modelName) {
1236
+ if (!this.isConnectedFlag || !this.ws) {
1237
+ throw new Error('Not connected to CDP. Call connect() first.');
1238
+ }
1239
+ // DOM manipulation script: based on actual Antigravity UI DOM structure
1240
+ // Model list uses div.cursor-pointer elements with class 'px-2 py-1 flex items-center justify-between'
1241
+ // Currently selected has 'bg-gray-500/20', others have 'hover:bg-gray-500/10'
1242
+ // textContent may have "New" suffix
1243
+ const safeModel = JSON.stringify(modelName);
1244
+ const expression = `(async () => {
1245
+ const targetModel = ${safeModel};
1246
+
1247
+ // Get all items in the model list
1248
+ const modelItems = Array.from(document.querySelectorAll('div.cursor-pointer'))
1249
+ .filter(e => e.className.includes('px-2 py-1 flex items-center justify-between'));
1250
+
1251
+ if (modelItems.length === 0) {
1252
+ return { ok: false, error: 'Model list not found. The dropdown may not be open.' };
1253
+ }
1254
+
1255
+ // Match target model by name (compare after removing New suffix)
1256
+ const targetItem = modelItems.find(el => {
1257
+ const text = (el.textContent || '').trim().replace(/New$/, '').trim();
1258
+ return text === targetModel || text.toLowerCase() === targetModel.toLowerCase();
1259
+ });
1260
+
1261
+ if (!targetItem) {
1262
+ const available = modelItems.map(el => (el.textContent || '').trim().replace(/New$/, '').trim()).join(', ');
1263
+ return { ok: false, error: 'Model "' + targetModel + '" not found. Available: ' + available };
1264
+ }
1265
+
1266
+ // Check if already selected
1267
+ if (targetItem.className.includes('bg-gray-500/20') && !targetItem.className.includes('hover:bg-gray-500/20')) {
1268
+ return { ok: true, model: targetModel, alreadySelected: true };
1269
+ }
1270
+
1271
+ // Click to select model
1272
+ targetItem.click();
1273
+ await new Promise(r => setTimeout(r, 500));
1274
+
1275
+ // Verify selection was applied
1276
+ const updatedItems = Array.from(document.querySelectorAll('div.cursor-pointer'))
1277
+ .filter(e => e.className.includes('px-2 py-1 flex items-center justify-between'));
1278
+ const selectedItem = updatedItems.find(el => {
1279
+ const text = (el.textContent || '').trim().replace(/New$/, '').trim();
1280
+ return text === targetModel || text.toLowerCase() === targetModel.toLowerCase();
1281
+ });
1282
+
1283
+ if (selectedItem && selectedItem.className.includes('bg-gray-500/20') && !selectedItem.className.includes('hover:bg-gray-500/20')) {
1284
+ return { ok: true, model: targetModel, verified: true };
1285
+ }
1286
+
1287
+ // Click succeeded but verification failed
1288
+ return { ok: true, model: targetModel, verified: false };
1289
+ })()`;
1290
+ try {
1291
+ const contextId = this.getPrimaryContextId();
1292
+ const callParams = {
1293
+ expression,
1294
+ returnByValue: true,
1295
+ awaitPromise: true,
1296
+ };
1297
+ if (contextId !== null)
1298
+ callParams.contextId = contextId;
1299
+ const res = await this.call('Runtime.evaluate', callParams);
1300
+ const value = res?.result?.value;
1301
+ if (value?.ok) {
1302
+ return { ok: true, model: value.model };
1303
+ }
1304
+ return { ok: false, error: value?.error || 'UI operation failed (setUiModel)' };
1305
+ }
1306
+ catch (error) {
1307
+ return { ok: false, error: error?.message || String(error) };
1308
+ }
1309
+ }
1310
+ }
1311
+ exports.CdpService = CdpService;