lazy-gravity 0.0.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -7
- package/dist/bin/cli.js +18 -18
- package/dist/bin/commands/doctor.js +25 -19
- package/dist/bin/commands/start.js +25 -2
- package/dist/bot/index.js +445 -126
- package/dist/commands/joinCommandHandler.js +302 -0
- package/dist/commands/joinDetachCommandHandler.js +285 -0
- package/dist/commands/registerSlashCommands.js +40 -0
- package/dist/commands/workspaceCommandHandler.js +17 -28
- package/dist/database/chatSessionRepository.js +10 -0
- package/dist/database/userPreferenceRepository.js +72 -0
- package/dist/events/interactionCreateHandler.js +338 -30
- package/dist/events/messageCreateHandler.js +161 -47
- package/dist/services/antigravityLauncher.js +4 -3
- package/dist/services/approvalDetector.js +7 -0
- package/dist/services/assistantDomExtractor.js +339 -0
- package/dist/services/cdpBridgeManager.js +323 -39
- package/dist/services/cdpConnectionPool.js +117 -33
- package/dist/services/cdpService.js +149 -53
- package/dist/services/chatSessionService.js +229 -8
- package/dist/services/errorPopupDetector.js +271 -0
- package/dist/services/planningDetector.js +318 -0
- package/dist/services/responseMonitor.js +308 -70
- package/dist/services/retryStore.js +46 -0
- package/dist/services/updateCheckService.js +147 -0
- package/dist/services/userMessageDetector.js +221 -0
- package/dist/ui/buttonUtils.js +33 -0
- package/dist/ui/modeUi.js +11 -1
- package/dist/ui/modelsUi.js +24 -13
- package/dist/ui/outputUi.js +30 -0
- package/dist/ui/projectListUi.js +83 -0
- package/dist/ui/sessionPickerUi.js +48 -0
- package/dist/utils/antigravityPaths.js +94 -0
- package/dist/utils/configLoader.js +18 -0
- package/dist/utils/discordButtonUtils.js +33 -0
- package/dist/utils/discordFormatter.js +149 -16
- package/dist/utils/htmlToDiscordMarkdown.js +184 -0
- package/dist/utils/logBuffer.js +47 -0
- package/dist/utils/logFileTransport.js +147 -0
- package/dist/utils/logger.js +86 -21
- package/dist/utils/pathUtils.js +57 -0
- package/dist/utils/plainTextFormatter.js +70 -0
- package/dist/utils/processLogBuffer.js +4 -0
- package/package.json +4 -4
|
@@ -30,6 +30,118 @@ const GET_CHAT_TITLE_SCRIPT = `(() => {
|
|
|
30
30
|
const hasActiveChat = title.length > 0 && title !== 'Agent';
|
|
31
31
|
return { title: title || '(Untitled)', hasActiveChat };
|
|
32
32
|
})()`;
|
|
33
|
+
/**
|
|
34
|
+
* Script to find the Past Conversations button and return its coordinates.
|
|
35
|
+
* We use coordinates so that the actual click is done via CDP Input.dispatchMouseEvent,
|
|
36
|
+
* which works reliably in Electron (DOM .click() can be ignored).
|
|
37
|
+
*
|
|
38
|
+
* Returns: { found: boolean, x: number, y: number }
|
|
39
|
+
*/
|
|
40
|
+
const FIND_PAST_CONVERSATIONS_BUTTON_SCRIPT = `(() => {
|
|
41
|
+
const isVisible = (el) => !!el && el instanceof HTMLElement && el.offsetParent !== null;
|
|
42
|
+
const getRect = (el) => {
|
|
43
|
+
const rect = el.getBoundingClientRect();
|
|
44
|
+
return { found: true, x: Math.round(rect.x + rect.width / 2), y: Math.round(rect.y + rect.height / 2) };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Strategy 1 (primary): data-past-conversations-toggle attribute
|
|
48
|
+
const toggle = document.querySelector('[data-past-conversations-toggle]');
|
|
49
|
+
if (toggle && isVisible(toggle)) return getRect(toggle);
|
|
50
|
+
|
|
51
|
+
// Strategy 2: data-tooltip-id containing "history"
|
|
52
|
+
const tooltipEls = Array.from(document.querySelectorAll('[data-tooltip-id]'));
|
|
53
|
+
for (const el of tooltipEls) {
|
|
54
|
+
if (!isVisible(el)) continue;
|
|
55
|
+
const tid = (el.getAttribute('data-tooltip-id') || '').toLowerCase();
|
|
56
|
+
if (tid.includes('history') || tid.includes('past-conversations')) {
|
|
57
|
+
return getRect(el);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Strategy 3: SVG with lucide-history class
|
|
62
|
+
const icons = Array.from(document.querySelectorAll('svg.lucide-history, svg[class*="lucide-history"]'));
|
|
63
|
+
for (const icon of icons) {
|
|
64
|
+
const parent = icon.closest('a, button, [role="button"], div[class*="cursor-pointer"]');
|
|
65
|
+
const target = parent instanceof HTMLElement && isVisible(parent) ? parent : icon;
|
|
66
|
+
if (isVisible(target)) return getRect(target);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { found: false, x: 0, y: 0 };
|
|
70
|
+
})()`;
|
|
71
|
+
/**
|
|
72
|
+
* Script to scrape session items from the open Past Conversations panel.
|
|
73
|
+
* Expects the panel to already be visible.
|
|
74
|
+
*
|
|
75
|
+
* Returns: { sessions: SessionListItem[] }
|
|
76
|
+
*/
|
|
77
|
+
const SCRAPE_PAST_CONVERSATIONS_SCRIPT = `(() => {
|
|
78
|
+
const isVisible = (el) => !!el && el instanceof HTMLElement && el.offsetParent !== null;
|
|
79
|
+
const normalize = (text) => (text || '').trim();
|
|
80
|
+
|
|
81
|
+
const items = [];
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
|
|
84
|
+
// Find the scrollable conversation list container
|
|
85
|
+
const containers = Array.from(document.querySelectorAll('div[class*="overflow-auto"], div[class*="overflow-y-scroll"]'));
|
|
86
|
+
const container = containers.find((c) => isVisible(c) && c.querySelectorAll('div[class*="cursor-pointer"]').length > 0) || document;
|
|
87
|
+
|
|
88
|
+
// Detect the "Other Conversations" section boundary.
|
|
89
|
+
// Sessions below this header belong to other projects and must be excluded.
|
|
90
|
+
let boundaryTop = Infinity;
|
|
91
|
+
const headerCandidates = container.querySelectorAll('div[class*="text-xs"][class*="opacity"]');
|
|
92
|
+
for (const el of headerCandidates) {
|
|
93
|
+
if (!isVisible(el)) continue;
|
|
94
|
+
const t = normalize(el.textContent || '');
|
|
95
|
+
if (/^Other\\s+Conversations?$/i.test(t)) {
|
|
96
|
+
boundaryTop = el.getBoundingClientRect().top;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Each session row is a div with cursor-pointer
|
|
102
|
+
const rows = Array.from(container.querySelectorAll('div[class*="cursor-pointer"]'));
|
|
103
|
+
for (const row of rows) {
|
|
104
|
+
if (!isVisible(row)) continue;
|
|
105
|
+
// Skip rows that are below the "Other Conversations" boundary
|
|
106
|
+
if (row.getBoundingClientRect().top >= boundaryTop) continue;
|
|
107
|
+
// Find the session title — nested span within the row
|
|
108
|
+
const spans = Array.from(row.querySelectorAll('span.text-sm span, span.text-sm'));
|
|
109
|
+
let title = '';
|
|
110
|
+
for (const span of spans) {
|
|
111
|
+
const t = normalize(span.textContent || '');
|
|
112
|
+
// Skip timestamp labels like "1 hr ago", "7 mins ago"
|
|
113
|
+
if (/^\\d+\\s+(min|hr|hour|day|sec|week|month|year)s?\\s+ago$/i.test(t)) continue;
|
|
114
|
+
// Skip very short or action-like labels
|
|
115
|
+
if (t.length < 2 || t.length > 200) continue;
|
|
116
|
+
if (/^(show\\s+\\d+\\s+more|new|past|history|settings|close|menu)\\b/i.test(t)) continue;
|
|
117
|
+
title = t;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
if (!title || seen.has(title)) continue;
|
|
121
|
+
seen.add(title);
|
|
122
|
+
// Detect if this is the active/current session (has focusBackground class)
|
|
123
|
+
const isActive = /focusBackground/i.test(row.className || '');
|
|
124
|
+
items.push({ title, isActive });
|
|
125
|
+
}
|
|
126
|
+
return { sessions: items };
|
|
127
|
+
})()`;
|
|
128
|
+
/**
|
|
129
|
+
* Script to find the "Show N more..." link and return its coordinates.
|
|
130
|
+
* Returns: { found: boolean, x: number, y: number }
|
|
131
|
+
*/
|
|
132
|
+
const FIND_SHOW_MORE_BUTTON_SCRIPT = `(() => {
|
|
133
|
+
const isVisible = (el) => !!el && el instanceof HTMLElement && el.offsetParent !== null;
|
|
134
|
+
const els = Array.from(document.querySelectorAll('div, span'));
|
|
135
|
+
for (const el of els) {
|
|
136
|
+
if (!isVisible(el)) continue;
|
|
137
|
+
const text = (el.textContent || '').trim();
|
|
138
|
+
if (/^Show\\s+\\d+\\s+more/i.test(text)) {
|
|
139
|
+
const rect = el.getBoundingClientRect();
|
|
140
|
+
return { found: true, x: Math.round(rect.x + rect.width / 2), y: Math.round(rect.y + rect.height / 2) };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { found: false, x: 0, y: 0 };
|
|
144
|
+
})()`;
|
|
33
145
|
/**
|
|
34
146
|
* Build a script that activates an existing chat in the side panel by its title.
|
|
35
147
|
* Uses broad selector fallbacks because Antigravity's DOM structure can vary across versions.
|
|
@@ -235,14 +347,35 @@ function buildActivateViaPastConversationsScript(title) {
|
|
|
235
347
|
};
|
|
236
348
|
|
|
237
349
|
return (async () => {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
350
|
+
// Primary: click via data-past-conversations-toggle attribute
|
|
351
|
+
let opened = false;
|
|
352
|
+
const toggleBtn = document.querySelector('[data-past-conversations-toggle]');
|
|
353
|
+
if (toggleBtn && isVisible(toggleBtn)) {
|
|
354
|
+
const clickable = getClickable(toggleBtn);
|
|
355
|
+
if (clickable) { clickable.click(); opened = true; }
|
|
356
|
+
}
|
|
357
|
+
if (!opened) {
|
|
358
|
+
// Fallback: data-tooltip-id containing "history"
|
|
359
|
+
const tooltipEls = asArray(document.querySelectorAll('[data-tooltip-id]'));
|
|
360
|
+
for (const el of tooltipEls) {
|
|
361
|
+
if (!isVisible(el)) continue;
|
|
362
|
+
const tid = normalize(el.getAttribute('data-tooltip-id') || '');
|
|
363
|
+
if (tid.includes('history') || tid.includes('past-conversations')) {
|
|
364
|
+
const cl = getClickable(el);
|
|
365
|
+
if (cl) { cl.click(); opened = true; break; }
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (!opened) {
|
|
370
|
+
opened = clickByPatterns([
|
|
371
|
+
'past conversations',
|
|
372
|
+
'past conversation',
|
|
373
|
+
'conversation history',
|
|
374
|
+
'past chats',
|
|
375
|
+
'過去の会話',
|
|
376
|
+
'chat history',
|
|
377
|
+
]);
|
|
378
|
+
}
|
|
246
379
|
if (!opened) {
|
|
247
380
|
opened = clickIconHistoryButton();
|
|
248
381
|
}
|
|
@@ -286,6 +419,94 @@ function buildActivateViaPastConversationsScript(title) {
|
|
|
286
419
|
class ChatSessionService {
|
|
287
420
|
static ACTIVATE_SESSION_MAX_WAIT_MS = 30000;
|
|
288
421
|
static ACTIVATE_SESSION_RETRY_INTERVAL_MS = 800;
|
|
422
|
+
static LIST_SESSIONS_TARGET = 20;
|
|
423
|
+
/**
|
|
424
|
+
* List recent sessions by opening the Past Conversations panel.
|
|
425
|
+
*
|
|
426
|
+
* Flow (all clicks via CDP Input.dispatchMouseEvent for Electron compatibility):
|
|
427
|
+
* 1. Find Past Conversations button coordinates
|
|
428
|
+
* 2. Click it via CDP mouse events
|
|
429
|
+
* 3. Wait for panel to render
|
|
430
|
+
* 4. Scrape visible sessions
|
|
431
|
+
* 5. If < TARGET sessions, find & click "Show N more..."
|
|
432
|
+
* 6. Re-scrape
|
|
433
|
+
* 7. Close panel with Escape key
|
|
434
|
+
*
|
|
435
|
+
* @param cdpService CdpService instance to use
|
|
436
|
+
* @returns Array of session list items (empty array on failure)
|
|
437
|
+
*/
|
|
438
|
+
async listAllSessions(cdpService) {
|
|
439
|
+
try {
|
|
440
|
+
// Step 1: Find Past Conversations button
|
|
441
|
+
const btnState = await this.evaluateOnAnyContext(cdpService, FIND_PAST_CONVERSATIONS_BUTTON_SCRIPT, false);
|
|
442
|
+
if (!btnState?.found) {
|
|
443
|
+
return [];
|
|
444
|
+
}
|
|
445
|
+
// Step 2: Click via CDP mouse events (reliable in Electron)
|
|
446
|
+
await this.cdpMouseClick(cdpService, btnState.x, btnState.y);
|
|
447
|
+
// Step 3: Wait for panel to render
|
|
448
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
449
|
+
// Step 4: Scrape sessions
|
|
450
|
+
let scrapeResult = await this.evaluateOnAnyContext(cdpService, SCRAPE_PAST_CONVERSATIONS_SCRIPT, false);
|
|
451
|
+
let sessions = scrapeResult?.sessions ?? [];
|
|
452
|
+
// Step 5: If fewer than TARGET, click "Show N more..."
|
|
453
|
+
if (sessions.length < ChatSessionService.LIST_SESSIONS_TARGET) {
|
|
454
|
+
const showMoreState = await this.evaluateOnAnyContext(cdpService, FIND_SHOW_MORE_BUTTON_SCRIPT, false);
|
|
455
|
+
if (showMoreState?.found) {
|
|
456
|
+
await this.cdpMouseClick(cdpService, showMoreState.x, showMoreState.y);
|
|
457
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
458
|
+
// Step 6: Re-scrape
|
|
459
|
+
scrapeResult = await this.evaluateOnAnyContext(cdpService, SCRAPE_PAST_CONVERSATIONS_SCRIPT, false);
|
|
460
|
+
sessions = scrapeResult?.sessions ?? [];
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
// Step 7: Close panel with Escape
|
|
464
|
+
await cdpService.call('Input.dispatchKeyEvent', {
|
|
465
|
+
type: 'keyDown', key: 'Escape', code: 'Escape',
|
|
466
|
+
windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27,
|
|
467
|
+
});
|
|
468
|
+
await cdpService.call('Input.dispatchKeyEvent', {
|
|
469
|
+
type: 'keyUp', key: 'Escape', code: 'Escape',
|
|
470
|
+
windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27,
|
|
471
|
+
});
|
|
472
|
+
return sessions.slice(0, ChatSessionService.LIST_SESSIONS_TARGET);
|
|
473
|
+
}
|
|
474
|
+
catch (_) {
|
|
475
|
+
return [];
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Evaluate a script on the first context that returns a truthy value.
|
|
480
|
+
*/
|
|
481
|
+
async evaluateOnAnyContext(cdpService, expression, awaitPromise) {
|
|
482
|
+
const contexts = cdpService.getContexts();
|
|
483
|
+
for (const ctx of contexts) {
|
|
484
|
+
try {
|
|
485
|
+
const result = await cdpService.call('Runtime.evaluate', {
|
|
486
|
+
expression, returnByValue: true, awaitPromise, contextId: ctx.id,
|
|
487
|
+
});
|
|
488
|
+
const value = result?.result?.value;
|
|
489
|
+
if (value)
|
|
490
|
+
return value;
|
|
491
|
+
}
|
|
492
|
+
catch (_) { /* try next context */ }
|
|
493
|
+
}
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Click at coordinates via CDP Input.dispatchMouseEvent.
|
|
498
|
+
*/
|
|
499
|
+
async cdpMouseClick(cdpService, x, y) {
|
|
500
|
+
await cdpService.call('Input.dispatchMouseEvent', {
|
|
501
|
+
type: 'mouseMoved', x, y,
|
|
502
|
+
});
|
|
503
|
+
await cdpService.call('Input.dispatchMouseEvent', {
|
|
504
|
+
type: 'mousePressed', x, y, button: 'left', clickCount: 1,
|
|
505
|
+
});
|
|
506
|
+
await cdpService.call('Input.dispatchMouseEvent', {
|
|
507
|
+
type: 'mouseReleased', x, y, button: 'left', clickCount: 1,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
289
510
|
/**
|
|
290
511
|
* Start a new chat session in the Antigravity UI.
|
|
291
512
|
*
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ErrorPopupDetector = void 0;
|
|
4
|
+
const logger_1 = require("../utils/logger");
|
|
5
|
+
const approvalDetector_1 = require("./approvalDetector");
|
|
6
|
+
/**
|
|
7
|
+
* Detection script for the Antigravity UI error popup.
|
|
8
|
+
*
|
|
9
|
+
* Looks for dialog/modal containers containing error-related text patterns
|
|
10
|
+
* like "agent terminated", "error", "failed", etc. and extracts popup info.
|
|
11
|
+
*/
|
|
12
|
+
const DETECT_ERROR_POPUP_SCRIPT = `(() => {
|
|
13
|
+
const ERROR_PATTERNS = [
|
|
14
|
+
'agent terminated',
|
|
15
|
+
'terminated due to error',
|
|
16
|
+
'unexpected error',
|
|
17
|
+
'something went wrong',
|
|
18
|
+
'an error occurred',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const normalize = (text) => (text || '').toLowerCase().replace(/\\s+/g, ' ').trim();
|
|
22
|
+
|
|
23
|
+
// Try dialog/modal first
|
|
24
|
+
const dialogs = Array.from(document.querySelectorAll(
|
|
25
|
+
'[role="dialog"], [role="alertdialog"], .modal, .dialog'
|
|
26
|
+
)).filter(el => el.offsetParent !== null || el.getAttribute('aria-modal') === 'true');
|
|
27
|
+
|
|
28
|
+
// Fallback: look for fixed/absolute positioned overlays
|
|
29
|
+
if (dialogs.length === 0) {
|
|
30
|
+
const overlays = Array.from(document.querySelectorAll('div[class*="fixed"], div[class*="absolute"]'))
|
|
31
|
+
.filter(el => {
|
|
32
|
+
const style = window.getComputedStyle(el);
|
|
33
|
+
return (style.position === 'fixed' || style.position === 'absolute')
|
|
34
|
+
&& style.zIndex && parseInt(style.zIndex, 10) > 10
|
|
35
|
+
&& el.querySelector('button');
|
|
36
|
+
});
|
|
37
|
+
dialogs.push(...overlays);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const dialog of dialogs) {
|
|
41
|
+
const fullText = normalize(dialog.textContent || '');
|
|
42
|
+
const isError = ERROR_PATTERNS.some(p => fullText.includes(p));
|
|
43
|
+
if (!isError) continue;
|
|
44
|
+
|
|
45
|
+
// Extract title from heading elements or first prominent text
|
|
46
|
+
const headingEl = dialog.querySelector('h1, h2, h3, h4, [class*="title"], [class*="heading"]');
|
|
47
|
+
const title = headingEl ? (headingEl.textContent || '').trim() : '';
|
|
48
|
+
|
|
49
|
+
// Extract body text (excluding button text and title)
|
|
50
|
+
const allButtons = Array.from(dialog.querySelectorAll('button'))
|
|
51
|
+
.filter(btn => btn.offsetParent !== null);
|
|
52
|
+
const buttonTexts = new Set(allButtons.map(btn => (btn.textContent || '').trim()));
|
|
53
|
+
|
|
54
|
+
const bodyParts = [];
|
|
55
|
+
const walker = document.createTreeWalker(dialog, NodeFilter.SHOW_TEXT);
|
|
56
|
+
let node;
|
|
57
|
+
while ((node = walker.nextNode())) {
|
|
58
|
+
const text = (node.textContent || '').trim();
|
|
59
|
+
if (!text) continue;
|
|
60
|
+
if (buttonTexts.has(text)) continue;
|
|
61
|
+
if (text === title) continue;
|
|
62
|
+
bodyParts.push(text);
|
|
63
|
+
}
|
|
64
|
+
const body = bodyParts.join(' ').slice(0, 1000);
|
|
65
|
+
|
|
66
|
+
const buttons = allButtons.map(btn => (btn.textContent || '').trim()).filter(t => t.length > 0);
|
|
67
|
+
|
|
68
|
+
if (buttons.length === 0) continue;
|
|
69
|
+
|
|
70
|
+
return { title: title || 'Error', body, buttons };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
})()`;
|
|
75
|
+
/**
|
|
76
|
+
* Read clipboard content via navigator.clipboard.readText().
|
|
77
|
+
* Requires awaitPromise=true since clipboard API returns a Promise.
|
|
78
|
+
*/
|
|
79
|
+
const READ_CLIPBOARD_SCRIPT = `(async () => {
|
|
80
|
+
try {
|
|
81
|
+
const text = await navigator.clipboard.readText();
|
|
82
|
+
return text || null;
|
|
83
|
+
} catch (e) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
})()`;
|
|
87
|
+
/**
|
|
88
|
+
* Detects error popup dialogs (e.g. "Agent terminated due to error") in the
|
|
89
|
+
* Antigravity UI via polling.
|
|
90
|
+
*
|
|
91
|
+
* Follows the same polling pattern as PlanningDetector / ApprovalDetector:
|
|
92
|
+
* - start()/stop() lifecycle
|
|
93
|
+
* - Duplicate notification prevention via lastDetectedKey
|
|
94
|
+
* - Cooldown to suppress rapid re-detection
|
|
95
|
+
* - CDP error tolerance (continues polling on error)
|
|
96
|
+
*/
|
|
97
|
+
class ErrorPopupDetector {
|
|
98
|
+
cdpService;
|
|
99
|
+
pollIntervalMs;
|
|
100
|
+
onErrorPopup;
|
|
101
|
+
onResolved;
|
|
102
|
+
pollTimer = null;
|
|
103
|
+
isRunning = false;
|
|
104
|
+
/** Key of the last detected error popup (for duplicate notification prevention) */
|
|
105
|
+
lastDetectedKey = null;
|
|
106
|
+
/** Full ErrorPopupInfo from the last detection */
|
|
107
|
+
lastDetectedInfo = null;
|
|
108
|
+
/** Timestamp of last notification (for cooldown-based dedup) */
|
|
109
|
+
lastNotifiedAt = 0;
|
|
110
|
+
/** Cooldown period in ms to suppress duplicate notifications (10s for error popups) */
|
|
111
|
+
static COOLDOWN_MS = 10000;
|
|
112
|
+
constructor(options) {
|
|
113
|
+
this.cdpService = options.cdpService;
|
|
114
|
+
this.pollIntervalMs = options.pollIntervalMs ?? 3000;
|
|
115
|
+
this.onErrorPopup = options.onErrorPopup;
|
|
116
|
+
this.onResolved = options.onResolved;
|
|
117
|
+
}
|
|
118
|
+
/** Start monitoring. */
|
|
119
|
+
start() {
|
|
120
|
+
if (this.isRunning)
|
|
121
|
+
return;
|
|
122
|
+
this.isRunning = true;
|
|
123
|
+
this.lastDetectedKey = null;
|
|
124
|
+
this.lastDetectedInfo = null;
|
|
125
|
+
this.lastNotifiedAt = 0;
|
|
126
|
+
this.schedulePoll();
|
|
127
|
+
}
|
|
128
|
+
/** Stop monitoring. */
|
|
129
|
+
async stop() {
|
|
130
|
+
this.isRunning = false;
|
|
131
|
+
if (this.pollTimer) {
|
|
132
|
+
clearTimeout(this.pollTimer);
|
|
133
|
+
this.pollTimer = null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Return the last detected error popup info. Returns null if nothing has been detected. */
|
|
137
|
+
getLastDetectedInfo() {
|
|
138
|
+
return this.lastDetectedInfo;
|
|
139
|
+
}
|
|
140
|
+
/** Returns whether monitoring is currently active. */
|
|
141
|
+
isActive() {
|
|
142
|
+
return this.isRunning;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Click the Dismiss button via CDP.
|
|
146
|
+
* @returns true if click succeeded
|
|
147
|
+
*/
|
|
148
|
+
async clickDismissButton() {
|
|
149
|
+
return this.clickButton('Dismiss');
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Click the "Copy debug info" button via CDP.
|
|
153
|
+
* @returns true if click succeeded
|
|
154
|
+
*/
|
|
155
|
+
async clickCopyDebugInfoButton() {
|
|
156
|
+
return this.clickButton('Copy debug info');
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Click the Retry button via CDP.
|
|
160
|
+
* @returns true if click succeeded
|
|
161
|
+
*/
|
|
162
|
+
async clickRetryButton() {
|
|
163
|
+
return this.clickButton('Retry');
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Read clipboard content from the browser via navigator.clipboard.readText().
|
|
167
|
+
* Should be called after clickCopyDebugInfoButton() with a short delay.
|
|
168
|
+
* @returns Clipboard text or null if unavailable
|
|
169
|
+
*/
|
|
170
|
+
async readClipboard() {
|
|
171
|
+
try {
|
|
172
|
+
const result = await this.runEvaluateScript(READ_CLIPBOARD_SCRIPT, true);
|
|
173
|
+
return typeof result === 'string' ? result : null;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
logger_1.logger.error('[ErrorPopupDetector] Error reading clipboard:', error);
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** Schedule the next poll. */
|
|
181
|
+
schedulePoll() {
|
|
182
|
+
if (!this.isRunning)
|
|
183
|
+
return;
|
|
184
|
+
this.pollTimer = setTimeout(async () => {
|
|
185
|
+
await this.poll();
|
|
186
|
+
if (this.isRunning) {
|
|
187
|
+
this.schedulePoll();
|
|
188
|
+
}
|
|
189
|
+
}, this.pollIntervalMs);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Single poll iteration:
|
|
193
|
+
* 1. Detect error popup from DOM (with contextId)
|
|
194
|
+
* 2. Notify via callback only on new detection (prevent duplicates)
|
|
195
|
+
* 3. Reset lastDetectedKey / lastDetectedInfo when popup disappears
|
|
196
|
+
*/
|
|
197
|
+
async poll() {
|
|
198
|
+
try {
|
|
199
|
+
const contextId = this.cdpService.getPrimaryContextId();
|
|
200
|
+
const callParams = {
|
|
201
|
+
expression: DETECT_ERROR_POPUP_SCRIPT,
|
|
202
|
+
returnByValue: true,
|
|
203
|
+
awaitPromise: false,
|
|
204
|
+
};
|
|
205
|
+
if (contextId !== null) {
|
|
206
|
+
callParams.contextId = contextId;
|
|
207
|
+
}
|
|
208
|
+
const result = await this.cdpService.call('Runtime.evaluate', callParams);
|
|
209
|
+
const info = result?.result?.value ?? null;
|
|
210
|
+
if (info) {
|
|
211
|
+
// Duplicate prevention: use title + body snippet as key
|
|
212
|
+
const key = `${info.title}::${info.body.slice(0, 100)}`;
|
|
213
|
+
const now = Date.now();
|
|
214
|
+
const withinCooldown = (now - this.lastNotifiedAt) < ErrorPopupDetector.COOLDOWN_MS;
|
|
215
|
+
if (key !== this.lastDetectedKey && !withinCooldown) {
|
|
216
|
+
this.lastDetectedKey = key;
|
|
217
|
+
this.lastDetectedInfo = info;
|
|
218
|
+
this.lastNotifiedAt = now;
|
|
219
|
+
this.onErrorPopup(info);
|
|
220
|
+
}
|
|
221
|
+
else if (key === this.lastDetectedKey) {
|
|
222
|
+
// Same key -- update stored info silently
|
|
223
|
+
this.lastDetectedInfo = info;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
// Reset when popup disappears (prepare for next detection)
|
|
228
|
+
const wasDetected = this.lastDetectedKey !== null;
|
|
229
|
+
this.lastDetectedKey = null;
|
|
230
|
+
this.lastDetectedInfo = null;
|
|
231
|
+
if (wasDetected && this.onResolved) {
|
|
232
|
+
this.onResolved();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
// Ignore CDP errors and continue monitoring
|
|
238
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
239
|
+
if (message.includes('WebSocket is not connected')) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
logger_1.logger.error('[ErrorPopupDetector] Error during polling:', error);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/** Internal click handler using buildClickScript from approvalDetector. */
|
|
246
|
+
async clickButton(buttonText) {
|
|
247
|
+
try {
|
|
248
|
+
const result = await this.runEvaluateScript((0, approvalDetector_1.buildClickScript)(buttonText));
|
|
249
|
+
return result?.ok === true;
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
logger_1.logger.error('[ErrorPopupDetector] Error while clicking button:', error);
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** Execute Runtime.evaluate with contextId and return result.value. */
|
|
257
|
+
async runEvaluateScript(expression, awaitPromise = false) {
|
|
258
|
+
const contextId = this.cdpService.getPrimaryContextId();
|
|
259
|
+
const callParams = {
|
|
260
|
+
expression,
|
|
261
|
+
returnByValue: true,
|
|
262
|
+
awaitPromise,
|
|
263
|
+
};
|
|
264
|
+
if (contextId !== null) {
|
|
265
|
+
callParams.contextId = contextId;
|
|
266
|
+
}
|
|
267
|
+
const result = await this.cdpService.call('Runtime.evaluate', callParams);
|
|
268
|
+
return result?.result?.value;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
exports.ErrorPopupDetector = ErrorPopupDetector;
|