@agent360/browser-mcp 1.13.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.
@@ -0,0 +1,1281 @@
1
+ /**
2
+ * Agent360 Browser MCP — Background Service Worker
3
+ *
4
+ * Handles Chrome API calls relayed from the offscreen document.
5
+ * Each MCP session (port) gets its own Chrome Tab Group with color coding.
6
+ * Tabs are isolated per session — no cross-session interference.
7
+ */
8
+
9
+ // ── Session Tab Management ─────────────────────────────────────────────────
10
+
11
+ const SESSION_COLORS = ['blue', 'green', 'yellow', 'red', 'pink', 'purple', 'cyan', 'orange'];
12
+ const sessions = new Map(); // port → { tabIds: Set, groupId: number|null, color: string, label: string }
13
+ let sessionsLoaded = false;
14
+
15
+ // Restore sessions from storage (service workers lose in-memory state on suspend)
16
+ async function restoreSessions() {
17
+ if (sessionsLoaded) return;
18
+ sessionsLoaded = true;
19
+ const { sessions: saved } = await chrome.storage.local.get({ sessions: {} });
20
+ for (const [port, data] of Object.entries(saved)) {
21
+ // Verify tabs still exist
22
+ const validTabIds = new Set();
23
+ for (const tabId of (data.tabIds || [])) {
24
+ try {
25
+ await chrome.tabs.get(tabId);
26
+ validTabIds.add(tabId);
27
+ } catch {} // tab no longer exists
28
+ }
29
+ if (validTabIds.size > 0) {
30
+ const activeTabId = data.activeTabId && validTabIds.has(data.activeTabId) ? data.activeTabId : null;
31
+ sessions.set(Number(port), {
32
+ tabIds: validTabIds,
33
+ activeTabId,
34
+ groupId: data.groupId || null,
35
+ color: data.color || SESSION_COLORS[sessions.size % SESSION_COLORS.length],
36
+ label: data.label || `Claude ${sessions.size + 1}`,
37
+ });
38
+ }
39
+ }
40
+ }
41
+
42
+ function getSession(port) {
43
+ if (!sessions.has(port)) {
44
+ const idx = sessions.size % SESSION_COLORS.length;
45
+ sessions.set(port, {
46
+ tabIds: new Set(),
47
+ activeTabId: null,
48
+ groupId: null,
49
+ color: SESSION_COLORS[idx],
50
+ label: `Claude ${sessions.size + 1}`,
51
+ });
52
+ }
53
+ return sessions.get(port);
54
+ }
55
+
56
+ async function addTabToSession(port, tabId) {
57
+ const session = getSession(port);
58
+ session.tabIds.add(tabId);
59
+
60
+ try {
61
+ if (session.groupId !== null) {
62
+ try {
63
+ await chrome.tabs.group({ tabIds: [tabId], groupId: session.groupId });
64
+ } catch {
65
+ // Group no longer valid — will create new one below
66
+ session.groupId = null;
67
+ }
68
+ }
69
+
70
+ if (session.groupId === null) {
71
+ const validTabIds = [...session.tabIds].filter(id => {
72
+ try { return id; } catch { return false; }
73
+ });
74
+ const groupId = await chrome.tabs.group({ tabIds: validTabIds });
75
+ session.groupId = groupId;
76
+ await chrome.tabGroups.update(groupId, {
77
+ title: session.label,
78
+ color: session.color,
79
+ collapsed: false,
80
+ });
81
+ }
82
+ } catch (e) {
83
+ console.warn('[MCP] Tab group error:', e.message);
84
+ }
85
+
86
+ persistSessions();
87
+ }
88
+
89
+ async function releaseSession(port) {
90
+ const session = sessions.get(port);
91
+ if (!session) return;
92
+
93
+ // Detach debugger + close all session tabs
94
+ const tabIds = [...session.tabIds];
95
+ for (const tabId of tabIds) {
96
+ debuggerForceDetach(tabId);
97
+ try {
98
+ await chrome.tabs.remove(tabId);
99
+ } catch {} // tab may already be closed
100
+ }
101
+
102
+ sessions.delete(port);
103
+ persistSessions();
104
+ }
105
+
106
+ function persistSessions() {
107
+ const data = {};
108
+ for (const [port, session] of sessions) {
109
+ data[port] = {
110
+ tabIds: [...session.tabIds],
111
+ activeTabId: session.activeTabId,
112
+ groupId: session.groupId,
113
+ color: session.color,
114
+ label: session.label,
115
+ };
116
+ }
117
+ chrome.storage.local.set({ sessions: data });
118
+ }
119
+
120
+ // Get the active tab for this session (last navigated), or create one.
121
+ // IMPORTANT: Also activates the tab so Chrome APIs (captureVisibleTab,
122
+ // executeScript) operate on the correct tab, not whatever the user is viewing.
123
+ async function getSessionTab(port, activate = true) {
124
+ const session = getSession(port);
125
+ let target = null;
126
+
127
+ // Prefer the active (last navigated) tab
128
+ if (session.activeTabId) {
129
+ try {
130
+ const tab = await chrome.tabs.get(session.activeTabId);
131
+ if (tab && !tab.url.startsWith('chrome://') && !tab.url.startsWith('about:')) {
132
+ target = tab;
133
+ }
134
+ } catch {
135
+ session.activeTabId = null;
136
+ session.tabIds.delete(session.activeTabId);
137
+ }
138
+ }
139
+
140
+ // Fallback: any usable session tab
141
+ if (!target) {
142
+ for (const tabId of session.tabIds) {
143
+ try {
144
+ const tab = await chrome.tabs.get(tabId);
145
+ if (tab && !tab.url.startsWith('chrome://') && !tab.url.startsWith('about:')) {
146
+ session.activeTabId = tabId;
147
+ target = tab;
148
+ break;
149
+ }
150
+ } catch {
151
+ session.tabIds.delete(tabId);
152
+ }
153
+ }
154
+ }
155
+
156
+ // No usable tab — create one
157
+ if (!target) {
158
+ target = await chrome.tabs.create({ url: 'about:blank', active: false });
159
+ await addTabToSession(port, target.id);
160
+ return target;
161
+ }
162
+
163
+ // Activate the tab so Chrome APIs target it (not whatever user is viewing)
164
+ if (activate && !target.active) {
165
+ await chrome.tabs.update(target.id, { active: true });
166
+ // Brief wait for Chrome to render the tab
167
+ await new Promise(r => setTimeout(r, 150));
168
+ target = await chrome.tabs.get(target.id);
169
+ }
170
+
171
+ return target;
172
+ }
173
+
174
+ // ── Chrome Debugger API Helpers (CSP-bypass for Google, Stripe, Slack) ─────
175
+
176
+ // Track which tabs have debugger attached to avoid repeated attach/detach
177
+ const debuggerAttached = new Set();
178
+
179
+ async function debuggerAttach(tabId) {
180
+ if (debuggerAttached.has(tabId)) return;
181
+ try {
182
+ await chrome.debugger.attach({ tabId }, '1.3');
183
+ debuggerAttached.add(tabId);
184
+ } catch (e) {
185
+ if (e.message?.includes('Already attached')) {
186
+ debuggerAttached.add(tabId);
187
+ } else {
188
+ throw e;
189
+ }
190
+ }
191
+ }
192
+
193
+ async function debuggerDetach(tabId) {
194
+ // Don't detach immediately — keep attached for subsequent commands.
195
+ // Will be cleaned up when tab closes or session ends.
196
+ }
197
+
198
+ function debuggerForceDetach(tabId) {
199
+ if (!debuggerAttached.has(tabId)) return;
200
+ debuggerAttached.delete(tabId);
201
+ try {
202
+ chrome.debugger.detach({ tabId });
203
+ } catch {}
204
+ }
205
+
206
+ // Clean up debugger + session refs when tabs close
207
+ chrome.tabs.onRemoved.addListener((tabId) => {
208
+ debuggerAttached.delete(tabId);
209
+ for (const [, session] of sessions) {
210
+ session.tabIds.delete(tabId);
211
+ }
212
+ });
213
+
214
+ async function debuggerType(tabId, text) {
215
+ await debuggerAttach(tabId);
216
+ try {
217
+ for (let i = 0; i < text.length; i++) {
218
+ const char = text[i];
219
+ await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
220
+ type: 'keyDown',
221
+ text: char,
222
+ key: char,
223
+ code: `Key${char.toUpperCase()}`,
224
+ unmodifiedText: char,
225
+ });
226
+ await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
227
+ type: 'keyUp',
228
+ key: char,
229
+ code: `Key${char.toUpperCase()}`,
230
+ });
231
+ // Human-like typing: random 30-120ms, occasional longer pause
232
+ const pause = (i > 0 && i % (7 + Math.floor(Math.random() * 5)) === 0)
233
+ ? 150 + Math.random() * 200 // thinking pause every ~10 chars
234
+ : 30 + Math.random() * 90; // normal keystroke
235
+ await new Promise(r => setTimeout(r, pause));
236
+ }
237
+ } finally {
238
+ await debuggerDetach(tabId);
239
+ }
240
+ }
241
+
242
+ async function debuggerClick(tabId, x, y) {
243
+ await debuggerAttach(tabId);
244
+ try {
245
+ // 1. mouseMoved first (triggers hover state, required by some frameworks)
246
+ await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchMouseEvent', {
247
+ type: 'mouseMoved', x, y,
248
+ });
249
+ await new Promise(r => setTimeout(r, 30));
250
+ // 2. mousePressed + mouseReleased (fires trusted mousedown/mouseup)
251
+ await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchMouseEvent', {
252
+ type: 'mousePressed', x, y, button: 'left', clickCount: 1,
253
+ });
254
+ await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchMouseEvent', {
255
+ type: 'mouseReleased', x, y, button: 'left', clickCount: 1,
256
+ });
257
+ // 3. CDP doesn't synthesize 'click' event from mousePressed/mouseReleased.
258
+ // React/Angular listen on 'click', not mouseup. Fire it via JS as backup.
259
+ await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
260
+ expression: `document.elementFromPoint(${x}, ${y})?.click()`,
261
+ });
262
+ } finally {
263
+ await debuggerDetach(tabId);
264
+ }
265
+ }
266
+
267
+ async function debuggerFocus(tabId, selector) {
268
+ await debuggerAttach(tabId);
269
+ try {
270
+ const { root } = await chrome.debugger.sendCommand({ tabId }, 'DOM.getDocument', {});
271
+ const { nodeId } = await chrome.debugger.sendCommand({ tabId }, 'DOM.querySelector', {
272
+ nodeId: root.nodeId, selector,
273
+ });
274
+ if (!nodeId) throw new Error('Element not found: ' + selector);
275
+ await chrome.debugger.sendCommand({ tabId }, 'DOM.focus', { nodeId });
276
+ return nodeId;
277
+ } catch (e) {
278
+ await debuggerDetach(tabId);
279
+ throw e;
280
+ }
281
+ }
282
+
283
+ async function debuggerFill(tabId, selector, value) {
284
+ // Check if element is contenteditable (rich text editors: LinkedIn, Slack)
285
+ const isContentEditable = await debuggerEval(tabId, `
286
+ (function() {
287
+ const el = document.querySelector(${JSON.stringify(selector)});
288
+ return el?.isContentEditable || el?.getAttribute('contenteditable') === 'true';
289
+ })()
290
+ `);
291
+
292
+ if (isContentEditable) {
293
+ // Rich text editors (Quill, ProseMirror, Slate, Draft.js) maintain internal
294
+ // state. Key events get ignored. execCommand('insertText') fires proper
295
+ // InputEvent that these editors handle correctly.
296
+ await debuggerEval(tabId, `
297
+ (function() {
298
+ const el = document.querySelector(${JSON.stringify(selector)});
299
+ el.focus();
300
+ // Select all existing content and delete it
301
+ document.execCommand('selectAll', false, null);
302
+ document.execCommand('delete', false, null);
303
+ // Insert new text — fires InputEvent with inputType='insertText'
304
+ document.execCommand('insertText', false, ${JSON.stringify(value)});
305
+ })()
306
+ `);
307
+ return;
308
+ }
309
+
310
+ // Standard input/textarea — focus, clear, type
311
+ await debuggerFocus(tabId, selector);
312
+ await debuggerAttach(tabId);
313
+ try {
314
+ // Ctrl+A to select all, then Backspace to clear
315
+ await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
316
+ type: 'keyDown', key: 'a', code: 'KeyA', modifiers: 2,
317
+ });
318
+ await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
319
+ type: 'keyUp', key: 'a', code: 'KeyA',
320
+ });
321
+ await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
322
+ type: 'keyDown', key: 'Backspace', code: 'Backspace',
323
+ });
324
+ await chrome.debugger.sendCommand({ tabId }, 'Input.dispatchKeyEvent', {
325
+ type: 'keyUp', key: 'Backspace', code: 'Backspace',
326
+ });
327
+ } finally {
328
+ await debuggerDetach(tabId);
329
+ }
330
+ await debuggerType(tabId, value);
331
+ }
332
+
333
+ async function debuggerEval(tabId, expression) {
334
+ await debuggerAttach(tabId);
335
+ try {
336
+ const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
337
+ expression,
338
+ returnByValue: true,
339
+ });
340
+ if (result.exceptionDetails) {
341
+ throw new Error(result.exceptionDetails.text || 'Script execution failed');
342
+ }
343
+ return result.result?.value;
344
+ } finally {
345
+ await debuggerDetach(tabId);
346
+ }
347
+ }
348
+
349
+ // Try executeScript first, fall back to debugger on CSP error
350
+ async function safeExecuteScript(tabId, func, args = [], world = 'MAIN') {
351
+ try {
352
+ const [result] = await chrome.scripting.executeScript({
353
+ target: { tabId },
354
+ func,
355
+ args,
356
+ ...(world === 'MAIN' ? { world: 'MAIN' } : {}),
357
+ });
358
+ return { result: result.result, usedDebugger: false };
359
+ } catch (e) {
360
+ if (e.message?.includes('Content Security Policy') || e.message?.includes('unsafe-eval')) {
361
+ // CSP blocked — this is expected on Google, Stripe, Slack
362
+ return { cspBlocked: true };
363
+ }
364
+ throw e;
365
+ }
366
+ }
367
+
368
+ // ── Smart Selector Resolution ─────────────────────────────────────────────
369
+ // Supports CSS selectors AND text-based selectors:
370
+ // "button:text(Get started)" → finds button containing "Get started"
371
+ // "#my-id" → standard CSS selector
372
+ // "text=Submit" → any element containing "Submit"
373
+
374
+ function buildTextFinderJS(textPattern, tagFilter) {
375
+ const escaped = JSON.stringify(textPattern);
376
+ const tagCheck = tagFilter ? `&& el.tagName === ${JSON.stringify(tagFilter.toUpperCase())}` : '';
377
+ return `(function() {
378
+ const text = ${escaped};
379
+ // Collect all elements including inside shadow DOM
380
+ function collectAll(root, results) {
381
+ for (const el of root.querySelectorAll('*')) {
382
+ results.push(el);
383
+ if (el.shadowRoot) collectAll(el.shadowRoot, results);
384
+ }
385
+ return results;
386
+ }
387
+ const all = collectAll(document, []);
388
+ // Exact match first (prefer leaf nodes)
389
+ for (const el of all) {
390
+ if (el.children.length > 3) continue;
391
+ const t = el.textContent?.trim();
392
+ if (t === text ${tagCheck}) {
393
+ return el;
394
+ }
395
+ }
396
+ // Partial match fallback
397
+ for (const el of all) {
398
+ if (el.children.length > 3) continue;
399
+ const t = el.textContent?.trim();
400
+ if (t && t.includes(text) ${tagCheck}) {
401
+ return el;
402
+ }
403
+ }
404
+ return null;
405
+ })()`;
406
+ }
407
+
408
+ function parseSelector(selector) {
409
+ // "button:text(Get started)" → { tag: 'button', text: 'Get started' }
410
+ const tagTextMatch = selector.match(/^(\w+):text\((.+)\)$/);
411
+ if (tagTextMatch) return { type: 'text', tag: tagTextMatch[1], text: tagTextMatch[2] };
412
+
413
+ // "text=Submit" → { text: 'Submit' }
414
+ if (selector.startsWith('text=')) return { type: 'text', tag: null, text: selector.slice(5) };
415
+
416
+ // Standard CSS selector
417
+ return { type: 'css', selector };
418
+ }
419
+
420
+ async function resolveElement(tabId, selectorStr) {
421
+ const parsed = parseSelector(selectorStr);
422
+
423
+ if (parsed.type === 'css') {
424
+ // Standard CSS with shadow DOM traversal — try executeScript first, debugger fallback
425
+ const deepQueryFn = (sel) => {
426
+ function queryDeep(root, s) {
427
+ const el = root.querySelector(s);
428
+ if (el) return el;
429
+ for (const node of root.querySelectorAll('*')) {
430
+ if (node.shadowRoot) {
431
+ const found = queryDeep(node.shadowRoot, s);
432
+ if (found) return found;
433
+ }
434
+ }
435
+ return null;
436
+ }
437
+ const el = queryDeep(document, sel);
438
+ if (!el) return null;
439
+ el.scrollIntoView({ block: 'center', behavior: 'instant' });
440
+ const r = el.getBoundingClientRect();
441
+ return { x: r.x + r.width / 2, y: r.y + r.height / 2, tag: el.tagName, found: true };
442
+ };
443
+
444
+ const scriptResult = await safeExecuteScript(tabId, deepQueryFn, [parsed.selector]);
445
+
446
+ if (scriptResult.cspBlocked) {
447
+ const sel = JSON.stringify(parsed.selector);
448
+ const result = await debuggerEval(tabId, `
449
+ (function() {
450
+ function queryDeep(root, s) {
451
+ const el = root.querySelector(s);
452
+ if (el) return el;
453
+ for (const node of root.querySelectorAll('*')) {
454
+ if (node.shadowRoot) { const f = queryDeep(node.shadowRoot, s); if (f) return f; }
455
+ }
456
+ return null;
457
+ }
458
+ const el = queryDeep(document, ${sel});
459
+ if (!el) return null;
460
+ el.scrollIntoView({ block: 'center', behavior: 'instant' });
461
+ const r = el.getBoundingClientRect();
462
+ return { x: r.x + r.width/2, y: r.y + r.height/2, tag: el.tagName, found: true };
463
+ })()
464
+ `);
465
+ return result ? { ...result, method: 'debugger' } : null;
466
+ }
467
+ return scriptResult.result;
468
+ }
469
+
470
+ // Text-based selector — always use debugger (more reliable, no CSP issues)
471
+ const finderJS = buildTextFinderJS(parsed.text, parsed.tag);
472
+ const result = await debuggerEval(tabId, `
473
+ (function() {
474
+ const el = ${finderJS};
475
+ if (!el) return null;
476
+ el.scrollIntoView({ block: 'center', behavior: 'instant' });
477
+ const r = el.getBoundingClientRect();
478
+ return { x: r.x + r.width/2, y: r.y + r.height/2, tag: el.tagName, text: el.textContent?.trim().slice(0, 80), found: true };
479
+ })()
480
+ `);
481
+ return result ? { ...result, method: 'debugger' } : null;
482
+ }
483
+
484
+ // ── Offscreen Document Setup ───────────────────────────────────────────────
485
+
486
+ async function ensureOffscreen() {
487
+ const existing = await chrome.offscreen.hasDocument();
488
+ if (!existing) {
489
+ await chrome.offscreen.createDocument({
490
+ url: 'offscreen.html',
491
+ reasons: ['WORKERS'],
492
+ justification: 'Maintain persistent WebSocket connection to local MCP server',
493
+ });
494
+ }
495
+ }
496
+
497
+ // ── Action Logging ─────────────────────────────────────────────────────────
498
+
499
+ const SENSITIVE = new Set(['get_cookies', 'get_local_storage', 'execute_script', 'extract_token']);
500
+
501
+ function logAction(port, method, params) {
502
+ const category = SENSITIVE.has(method) ? 'sensitive' : 'safe';
503
+ const session = sessions.get(port);
504
+ const entry = {
505
+ time: Date.now(),
506
+ method,
507
+ params: JSON.stringify(params).slice(0, 200),
508
+ category,
509
+ session: session?.label || `Port ${port}`,
510
+ color: session?.color || 'grey',
511
+ };
512
+ chrome.storage.local.get({ actionLog: [] }, ({ actionLog }) => {
513
+ actionLog.unshift(entry);
514
+ if (actionLog.length > 50) actionLog.length = 50;
515
+ chrome.storage.local.set({ actionLog });
516
+ });
517
+ }
518
+
519
+ // ── Message Handler — receives commands from offscreen.js ──────────────────
520
+
521
+ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
522
+ if (msg.type === 'mcp_command') {
523
+ const port = msg.port;
524
+ logAction(port, msg.method, msg.params);
525
+ // Restore sessions from storage (service worker may have restarted)
526
+ restoreSessions().then(() => {
527
+ dispatch(port, msg.method, msg.params)
528
+ .then(result => sendResponse(result))
529
+ .catch(err => sendResponse({ __error: err.message || String(err) }));
530
+ });
531
+ return true; // async response
532
+ }
533
+
534
+ if (msg.type === 'session_disconnect') {
535
+ releaseSession(msg.port);
536
+ return;
537
+ }
538
+
539
+ if (msg.type === 'reconnect') {
540
+ chrome.offscreen.hasDocument().then(exists => {
541
+ if (exists) chrome.offscreen.closeDocument().then(() => ensureOffscreen());
542
+ else ensureOffscreen();
543
+ });
544
+ return;
545
+ }
546
+
547
+ if (msg.type === 'ws_status') {
548
+ const count = msg.count || (msg.connected ? 1 : 0);
549
+ chrome.action.setBadgeText({ text: count > 0 ? String(count) : '' });
550
+ if (count > 0) {
551
+ chrome.action.setBadgeBackgroundColor({ color: '#22c55e' });
552
+ }
553
+ chrome.storage.local.set({
554
+ mcpConnected: msg.connected,
555
+ mcpCount: count,
556
+ mcpPorts: msg.ports || [],
557
+ });
558
+ return;
559
+ }
560
+ });
561
+
562
+ // ── OAuth Popup Interception ─────────────────────────────────────────────────
563
+
564
+ const OAUTH_DOMAINS = ['accounts.google.com', 'login.microsoftonline.com', 'github.com/login/oauth', 'slack.com/oauth', 'app.hubspot.com/oauth'];
565
+
566
+ let lastCreatedTabId = null;
567
+
568
+ chrome.tabs.onCreated.addListener(async (tab) => {
569
+ lastCreatedTabId = tab.id;
570
+
571
+ // Auto-claim OAuth popups for the session that opened them
572
+ if (tab.pendingUrl || tab.url) {
573
+ const url = tab.pendingUrl || tab.url;
574
+ const isOAuth = OAUTH_DOMAINS.some(d => url.includes(d));
575
+ if (isOAuth) {
576
+ for (const [port, session] of sessions) {
577
+ if (tab.openerTabId && session.tabIds.has(tab.openerTabId)) {
578
+ await addTabToSession(port, tab.id);
579
+ session.activeTabId = tab.id;
580
+ persistSessions();
581
+ break;
582
+ }
583
+ }
584
+ }
585
+ }
586
+ });
587
+
588
+ // ── CAPTCHA Detection ────────────────────────────────────────────────────────
589
+
590
+ async function detectCaptcha(tabId) {
591
+ try {
592
+ return await debuggerEval(tabId, `
593
+ (function() {
594
+ if (document.querySelector('iframe[src*="hcaptcha.com"]') || document.querySelector('.h-captcha')) return 'hcaptcha';
595
+ if (document.querySelector('iframe[src*="recaptcha"]') || document.querySelector('.g-recaptcha')) return 'recaptcha';
596
+ if (document.querySelector('iframe[src*="challenges.cloudflare.com"]') || document.querySelector('.cf-turnstile')) return 'turnstile';
597
+ if (document.documentElement.innerHTML.includes('challenge-platform')) return 'challenge';
598
+ return null;
599
+ })()
600
+ `);
601
+ } catch { return null; }
602
+ }
603
+
604
+ // ── Deep Shadow DOM Query ────────────────────────────────────────────────────
605
+ // querySelectorDeep: finds elements inside shadow DOMs (Shopify, Salesforce, etc.)
606
+
607
+ function buildDeepQueryJS(selector) {
608
+ return `(function() {
609
+ function queryDeep(root, sel) {
610
+ const el = root.querySelector(sel);
611
+ if (el) return el;
612
+ for (const node of root.querySelectorAll('*')) {
613
+ if (node.shadowRoot) {
614
+ const found = queryDeep(node.shadowRoot, sel);
615
+ if (found) return found;
616
+ }
617
+ }
618
+ return null;
619
+ }
620
+ return queryDeep(document, ${JSON.stringify(selector)});
621
+ })()`;
622
+ }
623
+
624
+ // ── Command Dispatcher ──────────────────────────────────────────────────────
625
+
626
+ async function dispatch(port, method, params) {
627
+ switch (method) {
628
+ case 'navigate': {
629
+ const session = getSession(port);
630
+ let tab = await getSessionTab(port);
631
+
632
+ // Always reuse the active tab — navigate in place, don't create new tabs
633
+ // Only create new tab if explicitly requested via new_tab param
634
+ if (params.new_tab) {
635
+ tab = await chrome.tabs.create({ url: params.url, active: true });
636
+ await addTabToSession(port, tab.id);
637
+ } else {
638
+ await chrome.tabs.update(tab.id, { url: params.url });
639
+ }
640
+
641
+ // Wait for load
642
+ await new Promise(resolve => {
643
+ const listener = (tabId, info) => {
644
+ if (tabId === tab.id && info.status === 'complete') {
645
+ chrome.tabs.onUpdated.removeListener(listener);
646
+ resolve();
647
+ }
648
+ };
649
+ chrome.tabs.onUpdated.addListener(listener);
650
+ setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, 15000);
651
+ });
652
+
653
+ // Set as active tab for this session
654
+ session.activeTabId = tab.id;
655
+ persistSessions();
656
+ const updated = await chrome.tabs.get(tab.id);
657
+
658
+ // Check for CAPTCHA after navigation
659
+ const captcha = await detectCaptcha(tab.id);
660
+ const result = { title: updated.title, url: updated.url, tab_id: tab.id, session: session.label };
661
+ if (captcha) {
662
+ result.captcha_detected = captcha;
663
+ result.hint = `CAPTCHA (${captcha}) detected. Use browser_ask_user to ask the user to solve it, then retry.`;
664
+ }
665
+ return result;
666
+ }
667
+
668
+ case 'get_page_content': {
669
+ const tab = await getSessionTab(port);
670
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot access chrome:// pages');
671
+ const format = params.format || 'text';
672
+ const scriptResult = await safeExecuteScript(tab.id, (fmt) => fmt === 'html' ? document.documentElement.outerHTML : document.body.innerText, [format]);
673
+ if (!scriptResult.cspBlocked) {
674
+ return { content: scriptResult.result, url: tab.url, title: tab.title };
675
+ }
676
+ // CSP fallback
677
+ const content = await debuggerEval(tab.id, format === 'html' ? 'document.documentElement.outerHTML' : 'document.body.innerText');
678
+ return { content, url: tab.url, title: tab.title, method: 'debugger' };
679
+ }
680
+
681
+ case 'screenshot': {
682
+ const tab = await getSessionTab(port);
683
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot screenshot chrome:// pages');
684
+ // Use debugger Page.captureScreenshot as PRIMARY method.
685
+ // captureVisibleTab requires active tab in active window — fails when
686
+ // user is in terminal. Debugger works regardless of tab focus.
687
+ try {
688
+ await debuggerAttach(tab.id);
689
+ const { data } = await chrome.debugger.sendCommand({ tabId: tab.id }, 'Page.captureScreenshot', {
690
+ format: 'png',
691
+ });
692
+ return { image: 'data:image/png;base64,' + data };
693
+ } catch {
694
+ // Debugger failed — fall back to captureVisibleTab (needs active tab)
695
+ try {
696
+ await chrome.tabs.update(tab.id, { active: true });
697
+ await new Promise(r => setTimeout(r, 150));
698
+ const dataUrl = await chrome.tabs.captureVisibleTab(null, { format: 'png' });
699
+ return { image: dataUrl };
700
+ } catch (e) {
701
+ throw new Error('Screenshot failed: ' + e.message);
702
+ }
703
+ }
704
+ }
705
+
706
+ case 'execute_script': {
707
+ const tab = await getSessionTab(port);
708
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot execute scripts on chrome:// pages');
709
+ try {
710
+ const [result] = await chrome.scripting.executeScript({
711
+ target: { tabId: tab.id },
712
+ func: new Function('return (' + params.code + ')'),
713
+ world: 'MAIN',
714
+ });
715
+ return { result: result.result };
716
+ } catch (e) {
717
+ if (e.message?.includes('Content Security Policy') || e.message?.includes('unsafe-eval')) {
718
+ // CSP blocked — fall back to debugger Runtime.evaluate
719
+ const value = await debuggerEval(tab.id, params.code);
720
+ return { result: value, method: 'debugger' };
721
+ }
722
+ throw e;
723
+ }
724
+ }
725
+
726
+ case 'click': {
727
+ const tab = await getSessionTab(port);
728
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
729
+
730
+ // Resolve element (supports CSS + text selectors, auto-scrolls)
731
+ const el = await resolveElement(tab.id, params.selector);
732
+ if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
733
+
734
+ // Always use debugger mouse events — works on all sites including SPAs
735
+ await debuggerClick(tab.id, el.x, el.y);
736
+ return { ok: true, method: el.method || 'debugger', tag: el.tag, text: el.text };
737
+ }
738
+
739
+ case 'fill': {
740
+ const tab = await getSessionTab(port);
741
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
742
+ const parsed = parseSelector(params.selector);
743
+
744
+ // For text-based selectors, click the element first then type
745
+ if (parsed.type === 'text') {
746
+ const el = await resolveElement(tab.id, params.selector);
747
+ if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
748
+ await debuggerClick(tab.id, el.x, el.y);
749
+ await new Promise(r => setTimeout(r, 100));
750
+ await debuggerType(tab.id, params.value);
751
+ return { ok: true, method: 'debugger' };
752
+ }
753
+
754
+ // Always use debugger for input/textarea — React/Angular/Vue need real keyboard events
755
+ try {
756
+ await debuggerFill(tab.id, parsed.selector, params.value);
757
+ return { ok: true, method: 'debugger' };
758
+ } catch (e) {
759
+ // Fallback to executeScript if debugger fails
760
+ const scriptResult = await safeExecuteScript(tab.id, (sel, val) => {
761
+ const el = document.querySelector(sel);
762
+ if (!el) return { ok: false, error: 'Element not found: ' + sel };
763
+ el.scrollIntoView({ block: 'center', behavior: 'instant' });
764
+ el.focus();
765
+ el.value = val;
766
+ el.dispatchEvent(new Event('input', { bubbles: true }));
767
+ el.dispatchEvent(new Event('change', { bubbles: true }));
768
+ return { ok: true };
769
+ }, [parsed.selector, params.value]);
770
+ if (!scriptResult.cspBlocked) return scriptResult.result;
771
+ return { ok: false, error: e.message, method: 'debugger' };
772
+ }
773
+ }
774
+
775
+ case 'wait': {
776
+ const tab = await getSessionTab(port);
777
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
778
+ const timeout = params.timeout || 10000;
779
+ const sel = params.selector;
780
+ const start = Date.now();
781
+ while (Date.now() - start < timeout) {
782
+ // Text-based selectors use debugger directly
783
+ if (sel.startsWith('text=') || sel.match(/^\w+:text\(/)) {
784
+ const el = await resolveElement(tab.id, sel);
785
+ if (el) return { found: true, method: 'debugger' };
786
+ } else {
787
+ const scriptResult = await safeExecuteScript(tab.id, (s) => !!document.querySelector(s), [sel]);
788
+ if (scriptResult.cspBlocked) {
789
+ const found = await debuggerEval(tab.id, `!!document.querySelector(${JSON.stringify(sel)})`);
790
+ if (found) return { found: true, method: 'debugger' };
791
+ } else if (scriptResult.result) {
792
+ return { found: true };
793
+ }
794
+ }
795
+ await new Promise(r => setTimeout(r, 500));
796
+ }
797
+ return { found: false };
798
+ }
799
+
800
+ case 'press_key': {
801
+ const tab = await getSessionTab(port);
802
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
803
+ const key = params.key; // e.g. "Enter", "Tab", "Escape", "ArrowDown"
804
+ const modifiers = (params.ctrl ? 2 : 0) | (params.alt ? 1 : 0) | (params.shift ? 8 : 0) | (params.meta ? 4 : 0);
805
+
806
+ await debuggerAttach(tab.id);
807
+ try {
808
+ await chrome.debugger.sendCommand({ tabId: tab.id }, 'Input.dispatchKeyEvent', {
809
+ type: 'keyDown',
810
+ key,
811
+ code: params.code || key,
812
+ modifiers,
813
+ text: key.length === 1 ? key : '',
814
+ });
815
+ await chrome.debugger.sendCommand({ tabId: tab.id }, 'Input.dispatchKeyEvent', {
816
+ type: 'keyUp',
817
+ key,
818
+ code: params.code || key,
819
+ modifiers,
820
+ });
821
+ } finally {
822
+ await debuggerDetach(tab.id);
823
+ }
824
+ return { ok: true, key };
825
+ }
826
+
827
+ case 'scroll': {
828
+ const tab = await getSessionTab(port);
829
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
830
+ // Scroll to element or by pixels
831
+ if (params.selector) {
832
+ const el = await resolveElement(tab.id, params.selector);
833
+ if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
834
+ return { ok: true, scrolled_to: params.selector };
835
+ }
836
+ // Scroll by pixels
837
+ const dx = params.x || 0;
838
+ const dy = params.y || 0;
839
+ await debuggerEval(tab.id, `window.scrollBy(${dx}, ${dy})`);
840
+ return { ok: true, scrolled: { x: dx, y: dy } };
841
+ }
842
+
843
+ case 'hover': {
844
+ const tab = await getSessionTab(port);
845
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
846
+ const el = await resolveElement(tab.id, params.selector);
847
+ if (!el) return { ok: false, error: 'Element not found: ' + params.selector };
848
+ await debuggerAttach(tab.id);
849
+ try {
850
+ await chrome.debugger.sendCommand({ tabId: tab.id }, 'Input.dispatchMouseEvent', {
851
+ type: 'mouseMoved', x: el.x, y: el.y,
852
+ });
853
+ // Hold hover for duration (default 500ms) so menus/tooltips appear
854
+ await new Promise(r => setTimeout(r, params.duration || 500));
855
+ } finally {
856
+ await debuggerDetach(tab.id);
857
+ }
858
+ return { ok: true, tag: el.tag, text: el.text };
859
+ }
860
+
861
+ case 'select_option': {
862
+ const tab = await getSessionTab(port);
863
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
864
+
865
+ // Strategy: handle native <select> and custom dropdowns differently
866
+ const isNativeSelect = await debuggerEval(tab.id, `
867
+ (function() {
868
+ const el = document.querySelector(${JSON.stringify(params.selector)});
869
+ return el?.tagName === 'SELECT';
870
+ })()
871
+ `);
872
+
873
+ if (isNativeSelect) {
874
+ // Native <select> — set value directly
875
+ await debuggerEval(tab.id, `
876
+ (function() {
877
+ const sel = document.querySelector(${JSON.stringify(params.selector)});
878
+ const opt = Array.from(sel.options).find(o => o.text.includes(${JSON.stringify(params.option)}) || o.value === ${JSON.stringify(params.option)});
879
+ if (opt) {
880
+ sel.value = opt.value;
881
+ sel.dispatchEvent(new Event('change', { bubbles: true }));
882
+ sel.dispatchEvent(new Event('input', { bubbles: true }));
883
+ }
884
+ return !!opt;
885
+ })()
886
+ `);
887
+ return { ok: true, type: 'native_select' };
888
+ }
889
+
890
+ // Custom dropdown (Angular Material, React Select, etc.)
891
+ // Step 1: Click the trigger to open
892
+ const trigger = await resolveElement(tab.id, params.selector);
893
+ if (!trigger) return { ok: false, error: 'Dropdown trigger not found: ' + params.selector };
894
+ await debuggerClick(tab.id, trigger.x, trigger.y);
895
+
896
+ // Step 2: Wait for options to appear
897
+ await new Promise(r => setTimeout(r, params.wait || 300));
898
+
899
+ // Step 3: Find and click the option by text
900
+ const option = await resolveElement(tab.id, `text=${params.option}`);
901
+ if (!option) return { ok: false, error: 'Option not found: ' + params.option };
902
+ await debuggerClick(tab.id, option.x, option.y);
903
+
904
+ return { ok: true, type: 'custom_dropdown', selected: params.option };
905
+ }
906
+
907
+ case 'handle_dialog': {
908
+ // Auto-handle JS alert/confirm/prompt dialogs
909
+ // Must be set up BEFORE the dialog appears
910
+ const tab = await getSessionTab(port);
911
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
912
+ const action = params.action || 'accept'; // accept, dismiss
913
+ const promptText = params.text || '';
914
+
915
+ await debuggerAttach(tab.id);
916
+ try {
917
+ // Enable page events to catch dialogs
918
+ await chrome.debugger.sendCommand({ tabId: tab.id }, 'Page.enable', {});
919
+
920
+ // Wait for dialog to appear (or handle existing one)
921
+ const result = await new Promise((resolve) => {
922
+ const timeout = setTimeout(() => {
923
+ chrome.debugger.onEvent.removeListener(listener);
924
+ resolve({ ok: false, error: 'No dialog appeared within timeout' });
925
+ }, params.timeout || 10000);
926
+
927
+ const listener = (source, method, eventParams) => {
928
+ if (source.tabId !== tab.id || method !== 'Page.javascriptDialogOpening') return;
929
+ chrome.debugger.onEvent.removeListener(listener);
930
+ clearTimeout(timeout);
931
+
932
+ chrome.debugger.sendCommand({ tabId: tab.id }, 'Page.handleJavaScriptDialog', {
933
+ accept: action === 'accept',
934
+ promptText: promptText,
935
+ }).then(() => {
936
+ resolve({
937
+ ok: true,
938
+ dialog_type: eventParams.type,
939
+ message: eventParams.message,
940
+ action,
941
+ });
942
+ }).catch(e => resolve({ ok: false, error: e.message }));
943
+ };
944
+ chrome.debugger.onEvent.addListener(listener);
945
+ });
946
+
947
+ return result;
948
+ } finally {
949
+ await debuggerDetach(tab.id);
950
+ }
951
+ }
952
+
953
+ case 'wait_for_network': {
954
+ const tab = await getSessionTab(port);
955
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot interact with chrome:// pages');
956
+ const urlPattern = params.url_pattern || '';
957
+ const timeout = params.timeout || 15000;
958
+
959
+ await debuggerAttach(tab.id);
960
+ try {
961
+ await chrome.debugger.sendCommand({ tabId: tab.id }, 'Network.enable', {});
962
+
963
+ const result = await new Promise((resolve) => {
964
+ const timer = setTimeout(() => {
965
+ chrome.debugger.onEvent.removeListener(listener);
966
+ resolve({ ok: false, error: 'No matching request within timeout' });
967
+ }, timeout);
968
+
969
+ const listener = (source, method, eventParams) => {
970
+ if (source.tabId !== tab.id) return;
971
+
972
+ if (method === 'Network.responseReceived') {
973
+ const url = eventParams.response?.url || '';
974
+ const status = eventParams.response?.status;
975
+ // Match by pattern (substring match) or return any if no pattern
976
+ if (!urlPattern || url.includes(urlPattern)) {
977
+ chrome.debugger.onEvent.removeListener(listener);
978
+ clearTimeout(timer);
979
+ // Try to get response body
980
+ chrome.debugger.sendCommand({ tabId: tab.id }, 'Network.getResponseBody', {
981
+ requestId: eventParams.requestId,
982
+ }).then(bodyResult => {
983
+ resolve({
984
+ ok: true,
985
+ url,
986
+ status,
987
+ method: eventParams.response?.requestHeaders?.[':method'] || 'GET',
988
+ body: bodyResult?.body?.substring(0, 5000) || null,
989
+ });
990
+ }).catch(() => {
991
+ resolve({
992
+ ok: true,
993
+ url,
994
+ status,
995
+ method: eventParams.response?.requestHeaders?.[':method'] || 'GET',
996
+ body: null,
997
+ });
998
+ });
999
+ }
1000
+ }
1001
+ };
1002
+ chrome.debugger.onEvent.addListener(listener);
1003
+ });
1004
+
1005
+ await chrome.debugger.sendCommand({ tabId: tab.id }, 'Network.disable', {});
1006
+ return result;
1007
+ } finally {
1008
+ await debuggerDetach(tab.id);
1009
+ }
1010
+ }
1011
+
1012
+ case 'fetch': {
1013
+ // HTTP requests from background — NOT subject to CORS
1014
+ const options = {
1015
+ method: params.method || 'GET',
1016
+ headers: params.headers || {},
1017
+ };
1018
+ if (params.body) options.body = typeof params.body === 'string' ? params.body : JSON.stringify(params.body);
1019
+ try {
1020
+ const resp = await fetch(params.url, options);
1021
+ const text = await resp.text();
1022
+ let json = null;
1023
+ try { json = JSON.parse(text); } catch {}
1024
+ return { ok: resp.ok, status: resp.status, body: json || text };
1025
+ } catch (e) {
1026
+ return { ok: false, error: e.message };
1027
+ }
1028
+ }
1029
+
1030
+ case 'list_tabs': {
1031
+ // Return only this session's tabs
1032
+ const session = getSession(port);
1033
+ const tabs = [];
1034
+ for (const tabId of session.tabIds) {
1035
+ try {
1036
+ const tab = await chrome.tabs.get(tabId);
1037
+ tabs.push({ id: tab.id, url: tab.url, title: tab.title, active: tab.active });
1038
+ } catch {
1039
+ session.tabIds.delete(tabId);
1040
+ }
1041
+ }
1042
+ return { tabs, session: session.label, color: session.color };
1043
+ }
1044
+
1045
+ case 'get_cookies': {
1046
+ const cookies = await chrome.cookies.getAll({ domain: params.domain });
1047
+ return { cookies: cookies.map(c => ({ name: c.name, value: c.value, domain: c.domain, path: c.path })) };
1048
+ }
1049
+
1050
+ case 'get_local_storage': {
1051
+ const tab = await getSessionTab(port);
1052
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot access chrome:// pages');
1053
+ const scriptResult = await safeExecuteScript(tab.id, (key) => key ? localStorage.getItem(key) : JSON.stringify(Object.fromEntries(Object.entries(localStorage))), [params.key || null]);
1054
+ if (!scriptResult.cspBlocked) {
1055
+ return { value: scriptResult.result };
1056
+ }
1057
+ const expr = params.key
1058
+ ? `localStorage.getItem(${JSON.stringify(params.key)})`
1059
+ : `JSON.stringify(Object.fromEntries(Object.entries(localStorage)))`;
1060
+ const value = await debuggerEval(tab.id, expr);
1061
+ return { value, method: 'debugger' };
1062
+ }
1063
+
1064
+ case 'ask_user': {
1065
+ const tab = await getSessionTab(port);
1066
+ const timeout = params.timeout || 120000;
1067
+ const fields = params.fields || [];
1068
+ const hasFields = fields.length > 0;
1069
+ const session = getSession(port);
1070
+
1071
+ // Activate tab + alert badge
1072
+ await chrome.tabs.update(tab.id, { active: true });
1073
+ chrome.action.setBadgeText({ text: '!' });
1074
+ chrome.action.setBadgeBackgroundColor({ color: '#f59e0b' });
1075
+ const notifId = 'mcp-ask-' + Date.now();
1076
+ chrome.notifications.create(notifId, {
1077
+ type: 'basic',
1078
+ iconUrl: 'icons/icon-128.png',
1079
+ title: `${session.label} — Action Required`,
1080
+ message: params.message,
1081
+ requireInteraction: true,
1082
+ silent: false,
1083
+ priority: 2,
1084
+ });
1085
+
1086
+ const [result] = await chrome.scripting.executeScript({
1087
+ target: { tabId: tab.id },
1088
+ func: (message, title, fields, hasFields, timeout, sessionLabel) => {
1089
+ return new Promise((resolve) => {
1090
+ document.getElementById('a360-overlay')?.remove();
1091
+
1092
+ // Notification sound — short pleasant chime
1093
+ try {
1094
+ const ctx = new AudioContext();
1095
+ const osc = ctx.createOscillator();
1096
+ const gain = ctx.createGain();
1097
+ osc.connect(gain);
1098
+ gain.connect(ctx.destination);
1099
+ osc.frequency.value = 880;
1100
+ osc.type = 'sine';
1101
+ gain.gain.setValueAtTime(0.3, ctx.currentTime);
1102
+ gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4);
1103
+ osc.start(ctx.currentTime);
1104
+ osc.stop(ctx.currentTime + 0.4);
1105
+ // Second tone (higher, pleasant ding-dong)
1106
+ setTimeout(() => {
1107
+ const osc2 = ctx.createOscillator();
1108
+ const gain2 = ctx.createGain();
1109
+ osc2.connect(gain2);
1110
+ gain2.connect(ctx.destination);
1111
+ osc2.frequency.value = 1320;
1112
+ osc2.type = 'sine';
1113
+ gain2.gain.setValueAtTime(0.2, ctx.currentTime);
1114
+ gain2.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
1115
+ osc2.start(ctx.currentTime);
1116
+ osc2.stop(ctx.currentTime + 0.3);
1117
+ }, 150);
1118
+ } catch {}
1119
+
1120
+ // Inject animation keyframes
1121
+ if (!document.getElementById('a360-styles')) {
1122
+ const style = document.createElement('style');
1123
+ style.id = 'a360-styles';
1124
+ style.textContent = `
1125
+ @keyframes a360-fade-in { from { opacity: 0; } to { opacity: 1; } }
1126
+ @keyframes a360-slide-up { from { opacity: 0; transform: translateY(30px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } }
1127
+ `;
1128
+ document.head.appendChild(style);
1129
+ }
1130
+
1131
+ const overlay = document.createElement('div');
1132
+ overlay.id = 'a360-overlay';
1133
+ overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);z-index:2147483647;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,sans-serif;animation:a360-fade-in 0.3s ease-out';
1134
+
1135
+ const card = document.createElement('div');
1136
+ card.style.cssText = 'background:#1e293b;border-radius:12px;padding:24px;max-width:420px;width:90%;color:#e2e8f0;box-shadow:0 20px 60px rgba(0,0,0,0.5);animation:a360-slide-up 0.4s ease-out';
1137
+
1138
+ const h = document.createElement('div');
1139
+ h.style.cssText = 'font-size:14px;font-weight:600;color:#3b82f6;margin-bottom:4px';
1140
+ h.textContent = title || 'Agent360 — Action Required';
1141
+ card.appendChild(h);
1142
+ const badge = document.createElement('div');
1143
+ badge.style.cssText = 'font-size:10px;color:#94a3b8;margin-bottom:12px';
1144
+ badge.textContent = sessionLabel;
1145
+ card.appendChild(badge);
1146
+ const msg = document.createElement('div');
1147
+ msg.style.cssText = 'font-size:13px;color:#cbd5e1;margin-bottom:16px;line-height:1.5';
1148
+ msg.textContent = message;
1149
+ card.appendChild(msg);
1150
+ const inputs = {};
1151
+ if (hasFields) {
1152
+ fields.forEach(f => {
1153
+ const label = document.createElement('label');
1154
+ label.style.cssText = 'display:block;font-size:11px;color:#94a3b8;margin-bottom:4px;margin-top:8px';
1155
+ label.textContent = f.label || f.name;
1156
+ card.appendChild(label);
1157
+ const input = document.createElement('input');
1158
+ input.type = f.type || 'text';
1159
+ input.placeholder = f.label || f.name;
1160
+ input.style.cssText = 'width:100%;padding:8px 10px;background:#0f172a;border:1px solid #334155;border-radius:6px;color:#e2e8f0;font-size:13px;outline:none;box-sizing:border-box';
1161
+ input.addEventListener('focus', () => input.style.borderColor = '#3b82f6');
1162
+ input.addEventListener('blur', () => input.style.borderColor = '#334155');
1163
+ card.appendChild(input);
1164
+ inputs[f.name] = input;
1165
+ });
1166
+ }
1167
+ const btnRow = document.createElement('div');
1168
+ btnRow.style.cssText = 'display:flex;gap:8px;margin-top:16px';
1169
+ const doneBtn = document.createElement('button');
1170
+ doneBtn.textContent = hasFields ? 'Submit' : '✓ Done';
1171
+ doneBtn.style.cssText = 'flex:1;padding:10px;background:#3b82f6;color:white;border:none;border-radius:6px;font-size:13px;cursor:pointer;font-weight:500';
1172
+ doneBtn.addEventListener('click', () => {
1173
+ const values = {};
1174
+ Object.entries(inputs).forEach(([k, el]) => values[k] = el.value);
1175
+ overlay.remove();
1176
+ resolve({ acknowledged: true, action: 'done', values });
1177
+ });
1178
+ const skipBtn = document.createElement('button');
1179
+ skipBtn.textContent = '✗ Skip';
1180
+ skipBtn.style.cssText = 'flex:1;padding:10px;background:#334155;color:#94a3b8;border:none;border-radius:6px;font-size:13px;cursor:pointer';
1181
+ skipBtn.addEventListener('click', () => { overlay.remove(); resolve({ acknowledged: true, action: 'skip', values: {} }); });
1182
+ btnRow.appendChild(doneBtn);
1183
+ btnRow.appendChild(skipBtn);
1184
+ card.appendChild(btnRow);
1185
+ overlay.appendChild(card);
1186
+ document.body.appendChild(overlay);
1187
+ const firstInput = Object.values(inputs)[0];
1188
+ if (firstInput) setTimeout(() => firstInput.focus(), 100);
1189
+ card.addEventListener('keydown', (e) => { if (e.key === 'Enter') doneBtn.click(); });
1190
+ setTimeout(() => { if (document.getElementById('a360-overlay')) { overlay.remove(); resolve({ acknowledged: false, action: 'timeout', values: {} }); } }, timeout);
1191
+ });
1192
+ },
1193
+ args: [params.message, params.title, fields, hasFields, timeout, session.label],
1194
+ world: 'MAIN',
1195
+ });
1196
+
1197
+ // Restore badge
1198
+ const count = sessions.size;
1199
+ chrome.action.setBadgeText({ text: count > 0 ? String(count) : '' });
1200
+ chrome.action.setBadgeBackgroundColor({ color: '#22c55e' });
1201
+ chrome.notifications.clear(notifId);
1202
+ return result.result;
1203
+ }
1204
+
1205
+ case 'select_frame': {
1206
+ const tab = await getSessionTab(port);
1207
+ if (tab.url.startsWith('chrome://')) throw new Error('Cannot access chrome:// pages');
1208
+ const frameIndex = params.frame_index ?? 0;
1209
+ const frames = await chrome.webNavigation.getAllFrames({ tabId: tab.id });
1210
+ if (!frames || frameIndex >= frames.length) {
1211
+ return { error: `Frame ${frameIndex} not found. Available: ${frames?.length || 0} frames`, frames: frames?.map((f, i) => ({ index: i, url: f.url })) };
1212
+ }
1213
+ const frameId = frames[frameIndex].frameId;
1214
+ const code = params.code || 'document.body.innerText.slice(0, 5000)';
1215
+ const [result] = await chrome.scripting.executeScript({
1216
+ target: { tabId: tab.id, frameIds: [frameId] },
1217
+ func: new Function('return (' + code + ')'),
1218
+ world: 'MAIN',
1219
+ });
1220
+ return { result: result.result, frame_url: frames[frameIndex].url };
1221
+ }
1222
+
1223
+ case 'list_frames': {
1224
+ const tab = await getSessionTab(port);
1225
+ const frames = await chrome.webNavigation.getAllFrames({ tabId: tab.id });
1226
+ return { frames: frames?.map((f, i) => ({ index: i, url: f.url, frame_id: f.frameId, parent_frame_id: f.parentFrameId })) || [] };
1227
+ }
1228
+
1229
+ case 'get_new_tab': {
1230
+ if (!lastCreatedTabId) return { error: 'No new tab detected' };
1231
+ try {
1232
+ const tab = await chrome.tabs.get(lastCreatedTabId);
1233
+ // Claim the new tab for this session
1234
+ await addTabToSession(port, tab.id);
1235
+ return { id: tab.id, url: tab.url, title: tab.title };
1236
+ } catch {
1237
+ return { error: 'Tab no longer exists' };
1238
+ }
1239
+ }
1240
+
1241
+ case 'switch_tab': {
1242
+ const session = getSession(port);
1243
+ if (!session.tabIds.has(params.tab_id)) {
1244
+ throw new Error(`Tab ${params.tab_id} does not belong to this session (${session.label})`);
1245
+ }
1246
+ const tab = await chrome.tabs.update(params.tab_id, { active: true });
1247
+ session.activeTabId = tab.id;
1248
+ persistSessions();
1249
+ return { id: tab.id, url: tab.url, title: tab.title };
1250
+ }
1251
+
1252
+ case 'close_tab': {
1253
+ const session = getSession(port);
1254
+ const tabId = params.tab_id;
1255
+ if (!session.tabIds.has(tabId)) {
1256
+ throw new Error(`Tab ${tabId} does not belong to this session (${session.label})`);
1257
+ }
1258
+ await chrome.tabs.remove(tabId);
1259
+ session.tabIds.delete(tabId);
1260
+ if (session.activeTabId === tabId) session.activeTabId = null;
1261
+ persistSessions();
1262
+ return { ok: true, remaining: session.tabIds.size };
1263
+ }
1264
+
1265
+ default:
1266
+ throw new Error('Unknown method: ' + method);
1267
+ }
1268
+ }
1269
+
1270
+ // ── Start ───────────────────────────────────────────────────────────────────
1271
+ ensureOffscreen().catch(console.error);
1272
+
1273
+ chrome.runtime.onStartup.addListener(() => ensureOffscreen().catch(console.error));
1274
+ chrome.runtime.onInstalled.addListener(() => ensureOffscreen().catch(console.error));
1275
+
1276
+ chrome.alarms.create('ensure-offscreen', { periodInMinutes: 1 });
1277
+ chrome.alarms.onAlarm.addListener((alarm) => {
1278
+ if (alarm.name === 'ensure-offscreen') {
1279
+ ensureOffscreen().catch(console.error);
1280
+ }
1281
+ });