chrome-ai-bridge 2.3.9 → 2.4.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,1318 @@
1
+ /**
2
+ * chrome-ai-bridge Extension Background Service Worker
3
+ * Playwright extension2-style flow:
4
+ * - connectToRelay -> establishes WS only
5
+ * - connectToTab -> binds a tab to that relay
6
+ * - attachToTab / forwardCDPCommand for CDP passthrough
7
+ */
8
+
9
+ // ============================================
10
+ // Logging System
11
+ // ============================================
12
+ const LOG_LEVEL = {DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3};
13
+ let currentLogLevel = LOG_LEVEL.DEBUG;
14
+
15
+ /**
16
+ * Enhanced logger with level, category, and Storage persistence
17
+ */
18
+ function log(level, category, message, data = {}) {
19
+ const timestamp = new Date().toISOString();
20
+ const levelName = Object.keys(LOG_LEVEL).find(k => LOG_LEVEL[k] === level) || 'INFO';
21
+ const entry = {timestamp, level: levelName, category, message, data};
22
+
23
+ // Console output
24
+ const prefix = `[${timestamp}] [${levelName}] [${category}]`;
25
+ if (level >= currentLogLevel) {
26
+ const dataStr = Object.keys(data).length > 0 ? JSON.stringify(data) : '';
27
+ console.log(prefix, message, dataStr);
28
+ }
29
+
30
+ // Save to Storage (async, fire-and-forget)
31
+ saveLogEntry(entry);
32
+ }
33
+
34
+ async function saveLogEntry(entry) {
35
+ try {
36
+ const result = await chrome.storage.local.get('logs');
37
+ const logs = result.logs || [];
38
+ logs.push(entry);
39
+ // Keep only last 100 entries
40
+ while (logs.length > 100) {
41
+ logs.shift();
42
+ }
43
+ await chrome.storage.local.set({logs});
44
+ } catch {
45
+ // Ignore storage errors
46
+ }
47
+ }
48
+
49
+ // Convenience functions
50
+ function logDebug(category, message, data) {
51
+ log(LOG_LEVEL.DEBUG, category, message, data);
52
+ }
53
+ function logInfo(category, message, data) {
54
+ log(LOG_LEVEL.INFO, category, message, data);
55
+ }
56
+ function logWarn(category, message, data) {
57
+ log(LOG_LEVEL.WARN, category, message, data);
58
+ }
59
+ function logError(category, message, data) {
60
+ log(LOG_LEVEL.ERROR, category, message, data);
61
+ }
62
+
63
+ // Legacy debug log (for compatibility)
64
+ function debugLog(...args) {
65
+ logDebug('general', args.join(' '));
66
+ console.log('[Extension]', ...args);
67
+ }
68
+
69
+ class RelayConnection {
70
+ constructor(ws) {
71
+ this._debuggee = {};
72
+ this._ws = ws;
73
+ this._closed = false;
74
+ this._tabPromise = new Promise(resolve => (this._tabPromiseResolve = resolve));
75
+ this._eventListener = this._onDebuggerEvent.bind(this);
76
+ this._detachListener = this._onDebuggerDetach.bind(this);
77
+ this._ws.onmessage = this._onMessage.bind(this);
78
+ this._ws.onclose = () => this._onClose();
79
+ chrome.debugger.onEvent.addListener(this._eventListener);
80
+ chrome.debugger.onDetach.addListener(this._detachListener);
81
+ }
82
+
83
+ setTabId(tabId) {
84
+ this._debuggee = {tabId};
85
+ this._tabPromiseResolve();
86
+ }
87
+
88
+ sendReady(tabId) {
89
+ this._sendMessage({
90
+ type: 'ready',
91
+ tabId,
92
+ });
93
+ }
94
+
95
+ close(message) {
96
+ if (
97
+ this._ws.readyState === WebSocket.OPEN ||
98
+ this._ws.readyState === WebSocket.CONNECTING
99
+ ) {
100
+ this._ws.close(1000, message);
101
+ }
102
+ this._onClose();
103
+ }
104
+
105
+ _onClose() {
106
+ if (this._closed) return;
107
+ this._closed = true;
108
+ chrome.debugger.onEvent.removeListener(this._eventListener);
109
+ chrome.debugger.onDetach.removeListener(this._detachListener);
110
+ chrome.debugger.detach(this._debuggee).catch(() => {});
111
+ if (this.onclose) this.onclose();
112
+ }
113
+
114
+ _onDebuggerEvent(source, method, params) {
115
+ if (source.tabId !== this._debuggee.tabId) return;
116
+ const sessionId = source.sessionId;
117
+ this._sendMessage({
118
+ method: 'forwardCDPEvent',
119
+ params: {
120
+ sessionId,
121
+ method,
122
+ params,
123
+ },
124
+ });
125
+ }
126
+
127
+ _onDebuggerDetach(source, reason) {
128
+ if (source.tabId !== this._debuggee.tabId) return;
129
+ this.close(`Debugger detached: ${reason}`);
130
+ this._debuggee = {};
131
+ }
132
+
133
+ _onMessage(event) {
134
+ this._onMessageAsync(event).catch(err =>
135
+ debugLog('Error handling message:', err),
136
+ );
137
+ }
138
+
139
+ async _onMessageAsync(event) {
140
+ let message;
141
+ try {
142
+ message = JSON.parse(event.data);
143
+ } catch (error) {
144
+ this._sendMessage({
145
+ error: {code: -32700, message: `Error parsing message: ${error.message}`},
146
+ });
147
+ return;
148
+ }
149
+
150
+ // Handle keep-alive ping from relay server
151
+ if (message.type === 'ping') {
152
+ this._sendMessage({ type: 'pong' });
153
+ logDebug('keepalive', 'Received ping, sent pong');
154
+ return;
155
+ }
156
+
157
+ const response = {id: message.id};
158
+ try {
159
+ response.result = await this._handleCommand(message);
160
+ } catch (error) {
161
+ response.error = error.message;
162
+ }
163
+ this._sendMessage(response);
164
+ }
165
+
166
+ async _handleCommand(message) {
167
+ if (message.method === 'getVersion') {
168
+ const manifest = chrome.runtime.getManifest();
169
+ return { version: manifest.version, name: manifest.name };
170
+ }
171
+ if (message.method === 'reloadExtension') {
172
+ logInfo('reload', 'reloadExtension command received');
173
+ // Set flag so the reloaded service worker skips cooldown
174
+ chrome.storage.local.set({ _reloadTriggered: Date.now() }).catch(() => {});
175
+ // Delay reload to allow response to be sent first
176
+ setTimeout(() => {
177
+ logInfo('reload', 'Calling chrome.runtime.reload()');
178
+ chrome.runtime.reload();
179
+ }, 100);
180
+ return { success: true, message: 'Extension reload initiated' };
181
+ }
182
+ if (message.method === 'attachToTab') {
183
+ await this._tabPromise;
184
+ debugLog('Attaching debugger to tab:', this._debuggee);
185
+
186
+ // デバッグ: アタッチ前にタブの状態を確認
187
+ try {
188
+ const tabInfo = await chrome.tabs.get(this._debuggee.tabId);
189
+ logInfo('attach', 'Tab info before attach', {
190
+ tabId: tabInfo.id,
191
+ url: tabInfo.url,
192
+ title: tabInfo.title,
193
+ status: tabInfo.status,
194
+ active: tabInfo.active,
195
+ });
196
+ } catch (e) {
197
+ logError('attach', 'Failed to get tab info', {error: e.message});
198
+ }
199
+
200
+ await chrome.debugger.attach(this._debuggee, '1.3');
201
+ const result = await chrome.debugger.sendCommand(
202
+ this._debuggee,
203
+ 'Target.getTargetInfo',
204
+ );
205
+ logInfo('attach', 'Target info after attach', {targetInfo: result?.targetInfo});
206
+ return {targetInfo: result?.targetInfo};
207
+ }
208
+ if (!this._debuggee.tabId) {
209
+ throw new Error(
210
+ 'No tab is connected. Please select a tab in the extension UI.',
211
+ );
212
+ }
213
+ if (message.method === 'forwardCDPCommand') {
214
+ const {sessionId, method, params} = message.params;
215
+ const debuggerSession = {...this._debuggee, sessionId};
216
+
217
+ logDebug('cdp', `Sending ${method}`, {
218
+ tabId: this._debuggee.tabId,
219
+ sessionId,
220
+ });
221
+ if (method === 'Runtime.evaluate') {
222
+ logDebug('cdp', `Runtime.evaluate expression`, {
223
+ expression: params?.expression?.slice(0, 120),
224
+ });
225
+ }
226
+
227
+ const result = await chrome.debugger.sendCommand(
228
+ debuggerSession,
229
+ method,
230
+ params,
231
+ );
232
+
233
+ logDebug('cdp', `Result of ${method}`, {
234
+ hasResult: result !== undefined,
235
+ });
236
+ if (method === 'Runtime.evaluate') {
237
+ logDebug('cdp', 'Runtime.evaluate value', {
238
+ value: result?.result?.value,
239
+ type: result?.result?.type,
240
+ subtype: result?.result?.subtype,
241
+ });
242
+ }
243
+
244
+ return result;
245
+ }
246
+ return {};
247
+ }
248
+
249
+ _sendMessage(message) {
250
+ if (this._ws.readyState === WebSocket.OPEN) {
251
+ this._ws.send(JSON.stringify(message));
252
+ }
253
+ }
254
+ }
255
+
256
+ class TabShareExtension {
257
+ constructor() {
258
+ this._activeConnections = new Map();
259
+ this._pendingTabSelection = new Map();
260
+ this._tabSessionOwners = new Map();
261
+ chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this));
262
+ chrome.tabs.onActivated.addListener(this._onTabActivated.bind(this));
263
+ chrome.tabs.onUpdated.addListener(this._onTabUpdated.bind(this));
264
+ chrome.runtime.onMessage.addListener(this._onMessage.bind(this));
265
+ }
266
+
267
+ _onMessage(message, sender, sendResponse) {
268
+ switch (message.type) {
269
+ case 'connectToRelay':
270
+ this._connectToRelay(
271
+ sender.tab?.id,
272
+ message.mcpRelayUrl,
273
+ message.sessionId,
274
+ ).then(
275
+ () => sendResponse({success: true}),
276
+ error => sendResponse({success: false, error: error.message}),
277
+ );
278
+ return true;
279
+ case 'getTabs':
280
+ this._getTabs().then(
281
+ tabs =>
282
+ sendResponse({
283
+ success: true,
284
+ tabs,
285
+ currentTabId: sender.tab?.id,
286
+ }),
287
+ error => sendResponse({success: false, error: error.message}),
288
+ );
289
+ return true;
290
+ case 'connectToTab':
291
+ this._connectTab(
292
+ sender.tab?.id,
293
+ message.tabId || sender.tab?.id,
294
+ message.windowId || sender.tab?.windowId,
295
+ message.mcpRelayUrl,
296
+ message.tabUrl,
297
+ message.newTab,
298
+ message.sessionId,
299
+ Boolean(message.allowTabTakeover),
300
+ ).then(
301
+ () => sendResponse({success: true}),
302
+ error => sendResponse({success: false, error: error.message}),
303
+ );
304
+ return true;
305
+ case 'disconnect':
306
+ this._disconnect(message.tabId).then(
307
+ () => sendResponse({success: true}),
308
+ error => sendResponse({success: false, error: error.message}),
309
+ );
310
+ return true;
311
+ case 'getDebugLogs':
312
+ this._getDebugLogs(message.filter, message.limit || 100).then(
313
+ payload => sendResponse({success: true, ...payload}),
314
+ error => sendResponse({success: false, error: error.message}),
315
+ );
316
+ return true;
317
+ case 'clearDebugLogs':
318
+ this._clearDebugLogs().then(
319
+ () => sendResponse({success: true}),
320
+ error => sendResponse({success: false, error: error.message}),
321
+ );
322
+ return true;
323
+ }
324
+ return false;
325
+ }
326
+
327
+ _getPendingKey(selectorTabId, sessionId) {
328
+ if (sessionId) {
329
+ return `session:${sessionId}`;
330
+ }
331
+ return `selector:${selectorTabId}`;
332
+ }
333
+
334
+ async _connectToRelay(selectorTabId, mcpRelayUrl, sessionId) {
335
+ if (!mcpRelayUrl) {
336
+ logError('relay', 'Missing relay URL');
337
+ throw new Error('Missing relay URL');
338
+ }
339
+ const pendingKey = this._getPendingKey(selectorTabId, sessionId);
340
+ const existingPending = this._pendingTabSelection.get(pendingKey);
341
+ if (existingPending?.connection) {
342
+ logInfo('relay', 'Replacing stale pending connection', {pendingKey, sessionId, selectorTabId});
343
+ existingPending.connection.close('Pending connection replaced');
344
+ this._pendingTabSelection.delete(pendingKey);
345
+ ensureKeepAliveAlarm('replace-stale-pending');
346
+ }
347
+ logInfo('relay', 'Connecting to relay', {mcpRelayUrl, selectorTabId, sessionId, pendingKey});
348
+
349
+ const openSocket = async attempt => {
350
+ const socket = new WebSocket(mcpRelayUrl);
351
+ await new Promise((resolve, reject) => {
352
+ let settled = false;
353
+ const finish = (handler) => {
354
+ if (settled) {
355
+ return;
356
+ }
357
+ settled = true;
358
+ clearTimeout(timeoutId);
359
+ handler();
360
+ };
361
+ const timeoutId = setTimeout(() => {
362
+ finish(() => {
363
+ try {
364
+ socket.close();
365
+ } catch {
366
+ // ignore
367
+ }
368
+ reject(new Error('WS_OPEN_TIMEOUT: Connection timeout'));
369
+ });
370
+ }, 5000);
371
+ socket.onopen = () => {
372
+ finish(resolve);
373
+ };
374
+ socket.onerror = () => {
375
+ finish(() => {
376
+ try {
377
+ socket.close();
378
+ } catch {
379
+ // ignore
380
+ }
381
+ reject(new Error(`WS_OPEN_ERROR: WebSocket error (attempt=${attempt + 1})`));
382
+ });
383
+ };
384
+ socket.onclose = () => {
385
+ finish(() => {
386
+ reject(new Error(`WS_OPEN_CLOSED: Socket closed before open (attempt=${attempt + 1})`));
387
+ });
388
+ };
389
+ });
390
+ return socket;
391
+ };
392
+ let socket;
393
+ let lastError;
394
+ const maxAttempts = 5;
395
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
396
+ logDebug('relay', `WebSocket attempt ${attempt + 1}/${maxAttempts}`, {mcpRelayUrl});
397
+ try {
398
+ socket = await openSocket(attempt);
399
+ logInfo('relay', 'WebSocket connected', {attempt: attempt + 1});
400
+ break;
401
+ } catch (error) {
402
+ lastError = error;
403
+ logWarn('relay', `WebSocket attempt ${attempt + 1} failed`, {error: error.message});
404
+ if (attempt < maxAttempts - 1) {
405
+ const baseDelay = Math.min(300 * (2 ** attempt), 3000);
406
+ const jitter = Math.floor(Math.random() * 200);
407
+ const waitMs = baseDelay + jitter;
408
+ await new Promise(resolve => setTimeout(resolve, waitMs));
409
+ }
410
+ }
411
+ }
412
+ if (!socket) {
413
+ logError('relay', 'All WebSocket attempts failed', {lastError: lastError?.message});
414
+ throw lastError || new Error('WebSocket error');
415
+ }
416
+ const connection = new RelayConnection(socket);
417
+ connection.onclose = () => {
418
+ logInfo('relay', 'Connection closed', {selectorTabId, sessionId, pendingKey});
419
+ this._pendingTabSelection.delete(pendingKey);
420
+ ensureKeepAliveAlarm('relay-connection-closed');
421
+ };
422
+ this._pendingTabSelection.set(pendingKey, {connection, sessionId, selectorTabId});
423
+ logInfo('relay', 'Relay connection established', {selectorTabId, sessionId, pendingKey});
424
+ ensureKeepAliveAlarm('relay-connected');
425
+ }
426
+
427
+ async _connectTab(
428
+ selectorTabId,
429
+ tabId,
430
+ windowId,
431
+ mcpRelayUrl,
432
+ tabUrl,
433
+ newTab,
434
+ sessionId,
435
+ allowTabTakeover = false,
436
+ ) {
437
+ const pendingKey = this._getPendingKey(selectorTabId, sessionId);
438
+ logInfo('connect', '_connectTab called', {
439
+ selectorTabId,
440
+ tabId,
441
+ tabUrl,
442
+ newTab,
443
+ sessionId,
444
+ allowTabTakeover,
445
+ pendingKey,
446
+ });
447
+
448
+ if (!tabId && tabUrl) {
449
+ logDebug('connect', 'Resolving tabId from URL', {tabUrl, newTab});
450
+ tabId = await this._resolveTabId(tabUrl, undefined, newTab);
451
+ }
452
+ if (!tabId) {
453
+ logError('connect', 'No tab selected');
454
+ throw new Error('No tab selected');
455
+ }
456
+
457
+ const ownerSessionId = this._tabSessionOwners.get(tabId);
458
+ if (
459
+ ownerSessionId &&
460
+ sessionId &&
461
+ ownerSessionId !== sessionId &&
462
+ !allowTabTakeover
463
+ ) {
464
+ logWarn('connect', 'Tab already owned by another session', {
465
+ tabId,
466
+ ownerSessionId,
467
+ requestedSessionId: sessionId,
468
+ });
469
+ throw new Error(
470
+ `TAB_LOCKED_BY_OTHER_SESSION: tabId=${tabId} ownerSessionId=${ownerSessionId}`,
471
+ );
472
+ }
473
+
474
+ const existingConnection = this._activeConnections.get(tabId);
475
+ if (existingConnection) {
476
+ logInfo('connect', 'Replacing existing connection', {tabId, sessionId});
477
+ existingConnection.close('Connection replaced for the same tab');
478
+ this._activeConnections.delete(tabId);
479
+ this._tabSessionOwners.delete(tabId);
480
+ await this._setConnectedTab(tabId, false);
481
+ }
482
+
483
+ const pending = this._pendingTabSelection.get(pendingKey);
484
+ if (!pending) {
485
+ logDebug('connect', 'No pending connection, creating relay', {selectorTabId, sessionId, mcpRelayUrl});
486
+ // If no pending connection, create one now.
487
+ await this._connectToRelay(selectorTabId, mcpRelayUrl, sessionId);
488
+ }
489
+ const newPending = this._pendingTabSelection.get(pendingKey);
490
+ if (!newPending) {
491
+ logError('connect', 'No active MCP relay connection');
492
+ throw new Error('No active MCP relay connection');
493
+ }
494
+
495
+ if (
496
+ sessionId &&
497
+ newPending.sessionId &&
498
+ newPending.sessionId !== sessionId
499
+ ) {
500
+ throw new Error(
501
+ `SESSION_MISMATCH_RELAY: expected=${sessionId} actual=${newPending.sessionId}`,
502
+ );
503
+ }
504
+
505
+ this._pendingTabSelection.delete(pendingKey);
506
+ ensureKeepAliveAlarm('pending-to-active-handoff');
507
+ const connection = newPending.connection;
508
+ connection.setTabId(tabId);
509
+ connection.sendReady(tabId);
510
+ connection.onclose = () => {
511
+ logInfo('connect', 'Tab connection closed', {tabId, sessionId});
512
+ this._activeConnections.delete(tabId);
513
+ const owner = this._tabSessionOwners.get(tabId);
514
+ if (!owner || owner === sessionId) {
515
+ this._tabSessionOwners.delete(tabId);
516
+ }
517
+ void this._setConnectedTab(tabId, false);
518
+ ensureKeepAliveAlarm('active-connection-closed');
519
+ };
520
+ this._activeConnections.set(tabId, connection);
521
+ this._tabSessionOwners.set(tabId, sessionId || `selector:${selectorTabId}`);
522
+ logInfo('connect', 'Tab connected successfully', {tabId, windowId, sessionId});
523
+ ensureKeepAliveAlarm('tab-connected');
524
+ // バッジのみ設定(フォーカスはMCPサーバー側が必要に応じて制御)
525
+ await this._setConnectedTab(tabId, true);
526
+ }
527
+
528
+ async _resolveTabId(tabUrl, tabId, newTab, active = true) {
529
+ logDebug('resolve', '_resolveTabId called', {tabUrl, tabId, newTab, active});
530
+
531
+ // デバッグ: 全タブの一覧を取得
532
+ const allTabs = await chrome.tabs.query({});
533
+ const tabSummary = allTabs.map(t => ({id: t.id, url: t.url?.slice(0, 60), active: t.active}));
534
+ logInfo('resolve', 'All tabs', {count: allTabs.length, tabs: tabSummary.slice(0, 10)});
535
+
536
+ // Priority 1: If tabId is provided, try to use it directly
537
+ // Note: newTab flag is ignored - always prefer existing tabs to prevent tab spam
538
+ if (tabId) {
539
+ try {
540
+ const tab = await chrome.tabs.get(tabId);
541
+ if (tab && tabUrl) {
542
+ const urlObj = new URL(tabUrl);
543
+ // Check if the tab's URL matches the expected hostname
544
+ if (tab.url && tab.url.includes(urlObj.hostname)) {
545
+ logInfo('resolve', 'Reusing tab by tabId', {tabId, url: tab.url});
546
+ return tabId;
547
+ }
548
+ logDebug('resolve', 'Tab URL mismatch, continuing search', {
549
+ tabId,
550
+ expectedHost: urlObj.hostname,
551
+ actualUrl: tab.url
552
+ });
553
+ }
554
+ } catch (error) {
555
+ logDebug('resolve', 'Tab not found by tabId (may be closed)', {tabId, error: error.message});
556
+ // Tab may have been closed, continue with URL-based search
557
+ }
558
+ }
559
+
560
+ // Priority 2: Search by URL pattern
561
+ try {
562
+ const urlObj = new URL(tabUrl);
563
+ const pattern = `*://${urlObj.hostname}${urlObj.pathname}*`;
564
+ const tabs = await chrome.tabs.query({url: pattern});
565
+ logDebug('resolve', `Found ${tabs.length} matching tabs`, {pattern, tabCount: tabs.length});
566
+ // Note: newTab flag is ignored - always prefer existing tabs to prevent tab spam
567
+ if (tabs.length) {
568
+ // Prefer active tab, then the most recently accessed
569
+ const activeTab = tabs.find(tab => tab.active);
570
+ const selectedTab = activeTab || tabs[0];
571
+ logInfo('resolve', 'Reusing existing tab by URL', {tabId: selectedTab.id, url: selectedTab.url});
572
+ return selectedTab.id;
573
+ }
574
+ } catch (error) {
575
+ logWarn('resolve', 'Error querying tabs', {error: error.message});
576
+ // ignore
577
+ }
578
+
579
+ // Priority 3: Create new tab
580
+ if (!tabUrl) {
581
+ logWarn('resolve', 'No tabUrl provided');
582
+ return undefined;
583
+ }
584
+ logInfo('resolve', 'Creating new tab', {url: tabUrl, active});
585
+ const created = await chrome.tabs.create({url: tabUrl, active});
586
+ logInfo('resolve', 'New tab created', {tabId: created.id, active});
587
+ return created.id;
588
+ }
589
+
590
+ async _getTabs() {
591
+ const tabs = await chrome.tabs.query({});
592
+ return tabs.filter(
593
+ tab =>
594
+ tab.url &&
595
+ !['chrome:', 'edge:', 'devtools:'].some(scheme =>
596
+ tab.url.startsWith(scheme),
597
+ ),
598
+ );
599
+ }
600
+
601
+ async _getDebugLogs(filter, limit) {
602
+ const result = await chrome.storage.local.get('logs');
603
+ const rawLogs = Array.isArray(result.logs) ? result.logs : [];
604
+ const normalized = rawLogs.map(logEntry => ({
605
+ ts: logEntry.timestamp || logEntry.ts || new Date().toISOString(),
606
+ category: logEntry.category || 'unknown',
607
+ message: logEntry.message || '',
608
+ data: logEntry.data ?? null,
609
+ level: logEntry.level || 'INFO',
610
+ }));
611
+
612
+ const filtered = filter
613
+ ? normalized.filter(logEntry => logEntry.category === filter)
614
+ : normalized;
615
+
616
+ const byCategory = {};
617
+ for (const logEntry of normalized) {
618
+ byCategory[logEntry.category] = (byCategory[logEntry.category] || 0) + 1;
619
+ }
620
+
621
+ return {
622
+ logs: filtered.slice(-limit),
623
+ stats: {
624
+ total: normalized.length,
625
+ byCategory,
626
+ },
627
+ state: {
628
+ activeConnections: Array.from(this._activeConnections.keys()),
629
+ pendingTabSelection: Array.from(this._pendingTabSelection.keys()),
630
+ tabSessionOwners: Object.fromEntries(this._tabSessionOwners.entries()),
631
+ },
632
+ };
633
+ }
634
+
635
+ async _clearDebugLogs() {
636
+ await chrome.storage.local.set({logs: []});
637
+ logInfo('debug', 'Debug logs cleared');
638
+ }
639
+
640
+ async _setConnectedTab(tabId, connected) {
641
+ if (!tabId) return;
642
+ try {
643
+ if (connected) {
644
+ await chrome.action.setBadgeText({tabId, text: '✓'});
645
+ await chrome.action.setBadgeBackgroundColor({
646
+ tabId,
647
+ color: '#4CAF50',
648
+ });
649
+ } else {
650
+ await chrome.action.setBadgeText({tabId, text: ''});
651
+ }
652
+ } catch {
653
+ // Tab no longer exists, ignore
654
+ }
655
+ }
656
+
657
+ async _disconnect(tabId) {
658
+ if (tabId) {
659
+ const connection = this._activeConnections.get(tabId);
660
+ if (connection) connection.close('User disconnected');
661
+ this._activeConnections.delete(tabId);
662
+ this._tabSessionOwners.delete(tabId);
663
+ await this._setConnectedTab(tabId, false);
664
+ ensureKeepAliveAlarm('disconnect-single-tab');
665
+ return;
666
+ }
667
+ for (const [connectedTabId, connection] of this._activeConnections) {
668
+ connection.close('User disconnected');
669
+ await this._setConnectedTab(connectedTabId, false);
670
+ this._tabSessionOwners.delete(connectedTabId);
671
+ }
672
+ this._activeConnections.clear();
673
+ this._pendingTabSelection.clear();
674
+ this._tabSessionOwners.clear();
675
+ ensureKeepAliveAlarm('disconnect-all');
676
+ }
677
+
678
+ _onTabRemoved(tabId) {
679
+ for (const [pendingKey, pending] of this._pendingTabSelection) {
680
+ if (pending.selectorTabId === tabId) {
681
+ this._pendingTabSelection.delete(pendingKey);
682
+ pending.connection.close('Browser tab closed');
683
+ ensureKeepAliveAlarm('pending-tab-removed');
684
+ }
685
+ }
686
+ const active = this._activeConnections.get(tabId);
687
+ if (active) {
688
+ active.close('Browser tab closed');
689
+ this._activeConnections.delete(tabId);
690
+ }
691
+ this._tabSessionOwners.delete(tabId);
692
+ ensureKeepAliveAlarm('active-tab-removed');
693
+ }
694
+
695
+ _onTabActivated(activeInfo) {
696
+ for (const [pendingKey, pending] of this._pendingTabSelection) {
697
+ if (typeof pending.selectorTabId !== 'number') continue;
698
+ if (pending.selectorTabId === activeInfo.tabId) continue;
699
+ if (!pending.timerId) {
700
+ pending.timerId = setTimeout(() => {
701
+ const existed = this._pendingTabSelection.delete(pendingKey);
702
+ if (existed) {
703
+ pending.connection.close('Tab inactive for 30 seconds');
704
+ chrome.tabs.sendMessage(pending.selectorTabId, {type: 'connectionTimeout'});
705
+ }
706
+ }, 30000);
707
+ }
708
+ }
709
+ }
710
+
711
+ _onTabUpdated(tabId) {
712
+ if (this._activeConnections.has(tabId)) {
713
+ void this._setConnectedTab(tabId, true);
714
+ }
715
+ }
716
+ }
717
+
718
+ const tabShareExtension = new TabShareExtension();
719
+
720
+ const DISCOVERY_ALARM = 'mcp-relay-discovery';
721
+ const KEEPALIVE_ALARM = 'keepAlive';
722
+ const KEEPALIVE_PERIOD_MINUTES = 0.5;
723
+ const DISCOVERY_PORTS = [38765, 38766, 38767, 38768, 38769, 38770, 38771, 38772, 38773, 38774, 38775];
724
+ const DISCOVERY_MODE = {
725
+ FAST: 'fast',
726
+ NORMAL: 'normal',
727
+ IDLE: 'idle',
728
+ };
729
+ const DISCOVERY_INTERVAL_MS = {
730
+ [DISCOVERY_MODE.FAST]: 500,
731
+ [DISCOVERY_MODE.NORMAL]: 3000,
732
+ [DISCOVERY_MODE.IDLE]: 15000,
733
+ };
734
+ const FAST_TO_NORMAL_EMPTY_STREAK = 5;
735
+ const NORMAL_TO_IDLE_EMPTY_STREAK = 20;
736
+ const ACTIVE_TO_IDLE_EMPTY_STREAK = 10;
737
+ let lastSuccessfulPort = null;
738
+ const lastRelayByPort = new Map();
739
+
740
+ // Interval管理: 重複防止
741
+ let discoveryIntervalId = null;
742
+
743
+ // 並列実行防止: autoOpenConnectUiが実行中かどうか
744
+ let isDiscoveryRunning = false;
745
+ let discoveryMode = DISCOVERY_MODE.FAST;
746
+ let emptyDiscoveryStreak = 0;
747
+ let keepAliveActive = false;
748
+
749
+ // リロード時クールダウン: 5秒間は「新しいrelay」検出をスキップ
750
+ // ただし reloadExtension コマンド経由のリロード時はスキップしない
751
+ const extensionStartTime = Date.now();
752
+ const COOLDOWN_MS = 5000;
753
+ let cooldownDisabled = false;
754
+
755
+ // Check if this is a reload triggered by reloadExtension command
756
+ chrome.storage.local.get('_reloadTriggered').then(result => {
757
+ if (result._reloadTriggered) {
758
+ const age = Date.now() - result._reloadTriggered;
759
+ if (age < 10000) { // Within 10 seconds of reload trigger
760
+ cooldownDisabled = true;
761
+ logInfo('discovery', 'Cooldown disabled (reloadExtension triggered)', {age});
762
+ }
763
+ // Clear the flag
764
+ chrome.storage.local.remove('_reloadTriggered').catch(() => {});
765
+ }
766
+ }).catch(() => {});
767
+
768
+ // ユーザー操作によるDiscoveryかどうかのフラグ
769
+ // Chrome起動時やService Worker再起動時はfalse、アイコンクリック時のみtrue
770
+ let userTriggeredDiscovery = false;
771
+ let userTriggeredDiscoveryUntil = 0;
772
+
773
+ function isUserTriggeredDiscoveryActive() {
774
+ if (!userTriggeredDiscovery) {
775
+ return false;
776
+ }
777
+ if (Date.now() > userTriggeredDiscoveryUntil) {
778
+ userTriggeredDiscovery = false;
779
+ userTriggeredDiscoveryUntil = 0;
780
+ return false;
781
+ }
782
+ return true;
783
+ }
784
+
785
+ function getConnectionCounts() {
786
+ return {
787
+ activeCount: tabShareExtension._activeConnections.size,
788
+ pendingCount: tabShareExtension._pendingTabSelection.size,
789
+ };
790
+ }
791
+
792
+ function shouldKeepAlive() {
793
+ const {activeCount, pendingCount} = getConnectionCounts();
794
+ return (
795
+ activeCount > 0 ||
796
+ pendingCount > 0 ||
797
+ discoveryIntervalId !== null ||
798
+ isDiscoveryRunning
799
+ );
800
+ }
801
+
802
+ function ensureKeepAliveAlarm(reason = 'state-change') {
803
+ const needed = shouldKeepAlive();
804
+ if (needed === keepAliveActive) {
805
+ return;
806
+ }
807
+ const {activeCount, pendingCount} = getConnectionCounts();
808
+ if (needed) {
809
+ chrome.alarms.create(KEEPALIVE_ALARM, {periodInMinutes: KEEPALIVE_PERIOD_MINUTES});
810
+ keepAliveActive = true;
811
+ logInfo('keepalive', 'Enabled keepAlive alarm', {reason, activeCount, pendingCount});
812
+ return;
813
+ }
814
+ chrome.alarms.clear(KEEPALIVE_ALARM).catch(() => {
815
+ // Ignore errors - alarm may not exist.
816
+ });
817
+ keepAliveActive = false;
818
+ logInfo('keepalive', 'Disabled keepAlive alarm', {reason, activeCount, pendingCount});
819
+ }
820
+
821
+ function getDiscoveryIntervalMs(mode = discoveryMode) {
822
+ return DISCOVERY_INTERVAL_MS[mode] || DISCOVERY_INTERVAL_MS[DISCOVERY_MODE.FAST];
823
+ }
824
+
825
+ function setDiscoveryMode(nextMode, reason) {
826
+ if (discoveryMode === nextMode) {
827
+ return;
828
+ }
829
+ const previousMode = discoveryMode;
830
+ discoveryMode = nextMode;
831
+ logInfo('discovery', 'Discovery mode changed', {
832
+ from: previousMode,
833
+ to: nextMode,
834
+ reason,
835
+ intervalMs: getDiscoveryIntervalMs(nextMode),
836
+ });
837
+ }
838
+
839
+ function getDiscoveryPortsByPriority() {
840
+ if (!lastSuccessfulPort || !DISCOVERY_PORTS.includes(lastSuccessfulPort)) {
841
+ return DISCOVERY_PORTS;
842
+ }
843
+ return [
844
+ lastSuccessfulPort,
845
+ ...DISCOVERY_PORTS.filter(port => port !== lastSuccessfulPort),
846
+ ];
847
+ }
848
+
849
+
850
+ function buildConnectUrl(
851
+ wsUrl,
852
+ tabUrl,
853
+ newTab,
854
+ autoMode = false,
855
+ sessionId,
856
+ allowTabTakeover = false,
857
+ ) {
858
+ const params = new URLSearchParams({mcpRelayUrl: wsUrl});
859
+ if (tabUrl) params.set('tabUrl', tabUrl);
860
+ if (newTab) params.set('newTab', 'true');
861
+ if (autoMode) params.set('auto', 'true');
862
+ if (sessionId) params.set('sessionId', sessionId);
863
+ if (allowTabTakeover) params.set('allowTabTakeover', 'true');
864
+ return chrome.runtime.getURL(`ui/connect.html?${params.toString()}`);
865
+ }
866
+
867
+ async function focusTab(tabId, windowId) {
868
+ try {
869
+ if (windowId) {
870
+ await chrome.windows.update(windowId, {focused: true});
871
+ }
872
+ await chrome.tabs.update(tabId, {active: true});
873
+ } catch {
874
+ // Ignore transient tab editing errors (e.g. user dragging tabs).
875
+ }
876
+ }
877
+
878
+ async function getExistingConnectTab() {
879
+ const connectBase = chrome.runtime.getURL('ui/connect.html');
880
+ const tabs = await chrome.tabs.query({url: `${connectBase}*`});
881
+ if (!tabs.length) return false;
882
+ const tab = tabs[0];
883
+ if (!tab?.id) return false;
884
+ return tab;
885
+ }
886
+
887
+ async function ensureConnectUiTab(
888
+ wsUrl,
889
+ tabUrl,
890
+ newTab,
891
+ autoMode = false,
892
+ sessionId,
893
+ allowTabTakeover = false,
894
+ ) {
895
+ const existing = await getExistingConnectTab();
896
+ if (existing?.id) {
897
+ await focusTab(existing.id, existing.windowId);
898
+ return existing;
899
+ }
900
+ const url = buildConnectUrl(
901
+ wsUrl,
902
+ tabUrl,
903
+ newTab,
904
+ autoMode,
905
+ sessionId,
906
+ allowTabTakeover,
907
+ );
908
+ const created = await chrome.tabs.create({url, active: true});
909
+ if (created?.id) {
910
+ await focusTab(created.id, created.windowId);
911
+ }
912
+ return created;
913
+ }
914
+
915
+ async function fetchRelayInfo(port, timeoutMs = 800) {
916
+ const discoveryUrl = `http://127.0.0.1:${port}/relay-info`;
917
+ let timer = null;
918
+ try {
919
+ const controller = new AbortController();
920
+ timer = setTimeout(() => controller.abort(), timeoutMs);
921
+ const res = await fetch(discoveryUrl, {signal: controller.signal});
922
+ if (!res.ok) return null;
923
+ const data = await res.json();
924
+ if (!data?.wsUrl) return null;
925
+ lastSuccessfulPort = port;
926
+ return data;
927
+ } catch {
928
+ return null;
929
+ } finally {
930
+ if (timer) {
931
+ clearTimeout(timer);
932
+ }
933
+ }
934
+ }
935
+
936
+ async function autoConnectRelay(best) {
937
+ const tabUrl = best?.data?.tabUrl;
938
+ const preferredTabId = best?.data?.tabId;
939
+ logDebug('auto-connect', 'autoConnectRelay called', {port: best?.port, tabUrl, tabId: preferredTabId, newTab: best?.data?.newTab});
940
+
941
+ if (!tabUrl) {
942
+ logDebug('auto-connect', 'No tabUrl, skipping');
943
+ return false; // tabUrl がなければ失敗
944
+ }
945
+
946
+ if (best?.port) {
947
+ const refreshed = await fetchRelayInfo(best.port, 400);
948
+ if (refreshed?.wsUrl) {
949
+ best.data = refreshed;
950
+ lastSuccessfulPort = best.port;
951
+ logDebug('auto-connect', 'Refreshed relay info', {wsUrl: refreshed.wsUrl, tabId: refreshed.tabId});
952
+ }
953
+ }
954
+
955
+ // tabUrl があれば、connect.html を開かずに直接接続
956
+ // preferredTabId があれば優先的に使用
957
+ const requestedNewTab = Boolean(best?.data?.newTab);
958
+ let targetTabId;
959
+ try {
960
+ // autoConnectRelay経由の場合はフォーカスしない(active: false)
961
+ // newTab: relay の要求を尊重する(MCP サーバーが newTab: true を指定した場合は新規タブ作成を許可)
962
+ targetTabId = await tabShareExtension._resolveTabId(
963
+ tabUrl,
964
+ preferredTabId,
965
+ requestedNewTab,
966
+ false, // active: false - 自動接続時はタブをフォーカスしない
967
+ );
968
+ } catch (error) {
969
+ logError('auto-connect', 'Failed to resolve tab', {tabUrl, tabId: preferredTabId, error: error.message});
970
+ return false;
971
+ }
972
+ if (!targetTabId) {
973
+ logWarn('auto-connect', 'No targetTabId resolved');
974
+ return false;
975
+ }
976
+ if (tabShareExtension._activeConnections?.has(targetTabId)) {
977
+ const existingSessionId = tabShareExtension._tabSessionOwners?.get(targetTabId);
978
+ const newSessionId = best?.data?.sessionId;
979
+ if (existingSessionId && newSessionId && existingSessionId !== newSessionId) {
980
+ // Different session wants the same tab — replace the old connection
981
+ logInfo('auto-connect', 'Replacing stale connection with newer session', {
982
+ targetTabId, oldSession: existingSessionId, newSession: newSessionId,
983
+ });
984
+ const oldConn = tabShareExtension._activeConnections.get(targetTabId);
985
+ if (oldConn) {
986
+ oldConn.close('Replaced by newer session');
987
+ tabShareExtension._activeConnections.delete(targetTabId);
988
+ tabShareExtension._tabSessionOwners.delete(targetTabId);
989
+ }
990
+ } else {
991
+ logInfo('auto-connect', 'Tab already connected', {targetTabId});
992
+ return true; // 同じセッションで接続済み
993
+ }
994
+ }
995
+
996
+ const targetTab = await chrome.tabs.get(targetTabId).catch(() => null);
997
+
998
+ const sessionId = best?.data?.sessionId || null;
999
+ // selectorId は後方互換のため保持。sessionId がある場合は session 軸を優先する。
1000
+ const selectorId = `auto:${best.data.wsUrl}`;
1001
+ logInfo('auto-connect', 'Attempting auto-connect', {
1002
+ selectorId,
1003
+ sessionId,
1004
+ targetTabId,
1005
+ wsUrl: best.data.wsUrl,
1006
+ });
1007
+
1008
+ try {
1009
+ await tabShareExtension._connectToRelay(selectorId, best.data.wsUrl, sessionId);
1010
+ await tabShareExtension._connectTab(
1011
+ selectorId,
1012
+ targetTabId,
1013
+ targetTab?.windowId,
1014
+ best.data.wsUrl,
1015
+ tabUrl,
1016
+ Boolean(best.data.newTab),
1017
+ sessionId,
1018
+ Boolean(best.data.allowTabTakeover),
1019
+ );
1020
+ logInfo('auto-connect', 'Auto-connect successful', {targetTabId, tabUrl});
1021
+ if (best?.port) {
1022
+ lastSuccessfulPort = best.port;
1023
+ }
1024
+ } catch (err) {
1025
+ logError('auto-connect', 'autoConnectRelay failed', {error: err.message, tabUrl});
1026
+ debugLog('autoConnectRelay failed:', err);
1027
+ if (best?.port) {
1028
+ lastRelayByPort.delete(best.port);
1029
+ }
1030
+ return false;
1031
+ }
1032
+ return true;
1033
+ }
1034
+
1035
+ async function autoOpenConnectUi() {
1036
+ const result = {
1037
+ skippedCooldown: false,
1038
+ newRelayCount: 0,
1039
+ successCount: 0,
1040
+ failureCount: 0,
1041
+ };
1042
+
1043
+ // リロード直後はタブを開かない(既存MCPサーバーとの再接続を防ぐ)
1044
+ // ただし reloadExtension コマンド経由の場合はスキップしない
1045
+ const elapsed = Date.now() - extensionStartTime;
1046
+ if (elapsed < COOLDOWN_MS && !cooldownDisabled) {
1047
+ logDebug('discovery', `Cooldown active (${elapsed}ms < ${COOLDOWN_MS}ms), skipping`);
1048
+ result.skippedCooldown = true;
1049
+ return result;
1050
+ }
1051
+
1052
+ // 複数の relay を同時にサポート(ChatGPT + Gemini)
1053
+ const newRelays = [];
1054
+ const portsToCheck = getDiscoveryPortsByPriority();
1055
+ for (const port of portsToCheck) {
1056
+ const timeoutMs = port === lastSuccessfulPort ? 250 : 800;
1057
+ const data = await fetchRelayInfo(port, timeoutMs);
1058
+ if (!data?.wsUrl) {
1059
+ continue;
1060
+ }
1061
+
1062
+ const last = lastRelayByPort.get(port);
1063
+ const startedAt = data.startedAt || 0;
1064
+ const instanceId = data.instanceId || '';
1065
+ if (
1066
+ last &&
1067
+ last.wsUrl === data.wsUrl &&
1068
+ last.startedAt === startedAt &&
1069
+ last.instanceId === instanceId
1070
+ ) {
1071
+ continue;
1072
+ }
1073
+
1074
+ logInfo('discovery', 'New relay detected', {port, tabUrl: data.tabUrl, wsUrl: data.wsUrl, startedAt});
1075
+ lastRelayByPort.set(port, {
1076
+ wsUrl: data.wsUrl,
1077
+ startedAt,
1078
+ instanceId,
1079
+ });
1080
+ newRelays.push({port, data});
1081
+ }
1082
+
1083
+ // Deduplicate: when multiple relays serve the same tabUrl, prefer the newest (by startedAt)
1084
+ // This prevents stale MCP servers from stealing connections from active ones
1085
+ const bestByTabUrl = new Map();
1086
+ for (const relay of newRelays) {
1087
+ const tabUrl = relay.data?.tabUrl || '';
1088
+ const existing = bestByTabUrl.get(tabUrl);
1089
+ const relayStartedAt = relay.data?.startedAt || 0;
1090
+ if (!existing || relayStartedAt > (existing.data?.startedAt || 0)) {
1091
+ bestByTabUrl.set(tabUrl, relay);
1092
+ }
1093
+ }
1094
+ const dedupedRelays = [...bestByTabUrl.values()];
1095
+ if (dedupedRelays.length < newRelays.length) {
1096
+ logInfo('discovery', 'Deduped relays by tabUrl (preferring newest)', {
1097
+ before: newRelays.length,
1098
+ after: dedupedRelays.length,
1099
+ dropped: newRelays.filter(r => !dedupedRelays.includes(r)).map(r => ({port: r.port, tabUrl: r.data?.tabUrl, startedAt: r.data?.startedAt})),
1100
+ });
1101
+ }
1102
+
1103
+ result.newRelayCount = dedupedRelays.length;
1104
+
1105
+ if (dedupedRelays.length > 0) {
1106
+ logInfo('discovery', `Processing ${dedupedRelays.length} new relay(s)`);
1107
+ }
1108
+
1109
+ // 全ての新しい relay を処理(並列ではなく順次)
1110
+ for (const relay of dedupedRelays) {
1111
+ logInfo('discovery', 'Processing relay', {port: relay.port, tabUrl: relay.data.tabUrl});
1112
+ debugLog('Processing new relay:', relay.port, relay.data.tabUrl);
1113
+ let ok = false;
1114
+ try {
1115
+ ok = await autoConnectRelay(relay);
1116
+ } catch (err) {
1117
+ logError('discovery', 'autoConnectRelay error', {error: err.message, port: relay.port});
1118
+ debugLog('autoConnectRelay error:', err);
1119
+ ok = false;
1120
+ }
1121
+ if (!ok) {
1122
+ result.failureCount += 1;
1123
+ const userTriggered = isUserTriggeredDiscoveryActive();
1124
+ // Only open connect.html when user explicitly clicked the extension icon
1125
+ // This prevents tab spam on Chrome restart, Service Worker restart, etc.
1126
+ if (userTriggered) {
1127
+ logInfo('discovery', 'Opening connect UI', {
1128
+ port: relay.port,
1129
+ tabUrl: relay.data.tabUrl
1130
+ });
1131
+ await ensureConnectUiTab(
1132
+ relay.data.wsUrl,
1133
+ relay.data.tabUrl || undefined,
1134
+ Boolean(relay.data.newTab),
1135
+ false,
1136
+ relay.data.sessionId || undefined,
1137
+ Boolean(relay.data.allowTabTakeover),
1138
+ );
1139
+ userTriggeredDiscovery = false; // Reset after opening
1140
+ userTriggeredDiscoveryUntil = 0;
1141
+ } else {
1142
+ logDebug('discovery', 'Skipping connect UI (auto mode)', {
1143
+ port: relay.port,
1144
+ tabUrl: relay.data.tabUrl
1145
+ });
1146
+ }
1147
+ continue;
1148
+ }
1149
+ result.successCount += 1;
1150
+ }
1151
+
1152
+ if (newRelays.length > 0) {
1153
+ userTriggeredDiscovery = false;
1154
+ userTriggeredDiscoveryUntil = 0;
1155
+ }
1156
+
1157
+ return result;
1158
+ }
1159
+
1160
+ // Discovery is now passive - only triggered by MCP server requests
1161
+ // The extension no longer auto-opens tabs on install/startup
1162
+ // MCPサーバーからの明示的な接続要求時のみ動作する
1163
+
1164
+ // Clear any existing discovery alarms from previous sessions
1165
+ // This prevents leftover alarms from auto-opening tabs
1166
+ chrome.alarms.clear(DISCOVERY_ALARM).then(() => {
1167
+ logInfo('background', 'Cleared existing discovery alarm (if any)');
1168
+ }).catch(() => {
1169
+ // Ignore errors - alarm may not exist
1170
+ });
1171
+
1172
+ function updateDiscoveryMode(result) {
1173
+ const {activeCount, pendingCount} = getConnectionCounts();
1174
+ const hasRelayActivity =
1175
+ result.newRelayCount > 0 ||
1176
+ result.successCount > 0 ||
1177
+ result.failureCount > 0;
1178
+
1179
+ if (pendingCount > 0 || hasRelayActivity) {
1180
+ emptyDiscoveryStreak = 0;
1181
+ setDiscoveryMode(
1182
+ DISCOVERY_MODE.FAST,
1183
+ pendingCount > 0 ? 'pending-connections' : 'relay-activity',
1184
+ );
1185
+ return;
1186
+ }
1187
+
1188
+ if (result.skippedCooldown) {
1189
+ setDiscoveryMode(DISCOVERY_MODE.FAST, 'cooldown');
1190
+ return;
1191
+ }
1192
+
1193
+ emptyDiscoveryStreak += 1;
1194
+
1195
+ if (activeCount > 0 && emptyDiscoveryStreak >= ACTIVE_TO_IDLE_EMPTY_STREAK) {
1196
+ setDiscoveryMode(DISCOVERY_MODE.IDLE, 'stable-active-connections');
1197
+ return;
1198
+ }
1199
+
1200
+ if (emptyDiscoveryStreak >= NORMAL_TO_IDLE_EMPTY_STREAK) {
1201
+ setDiscoveryMode(DISCOVERY_MODE.IDLE, 'long-idle');
1202
+ return;
1203
+ }
1204
+
1205
+ if (emptyDiscoveryStreak >= FAST_TO_NORMAL_EMPTY_STREAK) {
1206
+ setDiscoveryMode(DISCOVERY_MODE.NORMAL, 'no-new-relays');
1207
+ return;
1208
+ }
1209
+
1210
+ setDiscoveryMode(DISCOVERY_MODE.FAST, 'probing');
1211
+ }
1212
+
1213
+ function scheduleDiscoveryTick(delayMs) {
1214
+ if (discoveryIntervalId !== null) {
1215
+ return;
1216
+ }
1217
+ discoveryIntervalId = setTimeout(async () => {
1218
+ discoveryIntervalId = null;
1219
+
1220
+ if (isDiscoveryRunning) {
1221
+ scheduleDiscoveryTick(getDiscoveryIntervalMs());
1222
+ return;
1223
+ }
1224
+
1225
+ isDiscoveryRunning = true;
1226
+ try {
1227
+ const result = await autoOpenConnectUi();
1228
+ updateDiscoveryMode(result);
1229
+ } catch (error) {
1230
+ logWarn('discovery', 'Discovery cycle failed', {
1231
+ error: error?.message || String(error),
1232
+ });
1233
+ emptyDiscoveryStreak = 0;
1234
+ setDiscoveryMode(DISCOVERY_MODE.FAST, 'cycle-error');
1235
+ } finally {
1236
+ isDiscoveryRunning = false;
1237
+ ensureKeepAliveAlarm('discovery-cycle');
1238
+ }
1239
+
1240
+ scheduleDiscoveryTick(getDiscoveryIntervalMs());
1241
+ }, Math.max(0, delayMs));
1242
+ }
1243
+
1244
+ function scheduleDiscovery() {
1245
+ if (discoveryIntervalId !== null || isDiscoveryRunning) {
1246
+ logDebug('discovery', 'Discovery already scheduled', {
1247
+ mode: discoveryMode,
1248
+ intervalMs: getDiscoveryIntervalMs(),
1249
+ });
1250
+ return;
1251
+ }
1252
+
1253
+ logInfo('discovery', 'Starting discovery scheduler', {
1254
+ mode: discoveryMode,
1255
+ intervalMs: getDiscoveryIntervalMs(),
1256
+ });
1257
+ scheduleDiscoveryTick(0);
1258
+ ensureKeepAliveAlarm('discovery-scheduled');
1259
+ }
1260
+
1261
+ function kickDiscovery(reason) {
1262
+ emptyDiscoveryStreak = 0;
1263
+ setDiscoveryMode(DISCOVERY_MODE.FAST, reason);
1264
+ if (discoveryIntervalId !== null) {
1265
+ clearTimeout(discoveryIntervalId);
1266
+ discoveryIntervalId = null;
1267
+ }
1268
+ scheduleDiscovery();
1269
+ }
1270
+
1271
+ chrome.alarms.onAlarm.addListener((alarm) => {
1272
+ if (alarm.name === KEEPALIVE_ALARM) {
1273
+ const {activeCount, pendingCount} = getConnectionCounts();
1274
+ if (activeCount > 0 || pendingCount > 0) {
1275
+ logDebug('keepalive', 'Alarm triggered', {activeCount, pendingCount});
1276
+ }
1277
+
1278
+ if (!shouldKeepAlive()) {
1279
+ ensureKeepAliveAlarm('alarm-prune');
1280
+ return;
1281
+ }
1282
+
1283
+ if (discoveryIntervalId === null && !isDiscoveryRunning) {
1284
+ logInfo('keepalive', 'Re-arming discovery scheduler after wake');
1285
+ scheduleDiscovery();
1286
+ }
1287
+ }
1288
+ });
1289
+
1290
+ // Note: We no longer register an onAlarm listener for DISCOVERY_ALARM
1291
+ // The scheduleDiscovery function is only called on explicit MCP requests
1292
+
1293
+ // Discovery auto-starts on Chrome startup
1294
+ // connect.html only opens when user clicks the extension icon
1295
+ // This prevents tab spam on Chrome restart and Service Worker restart
1296
+
1297
+ // Start discovery when user clicks extension icon
1298
+ chrome.action.onClicked.addListener(() => {
1299
+ logInfo('action', 'Extension icon clicked - starting discovery');
1300
+ userTriggeredDiscovery = true; // ユーザーが明示的にトリガー
1301
+ userTriggeredDiscoveryUntil = Date.now() + 15000;
1302
+ kickDiscovery('user-click');
1303
+ });
1304
+
1305
+ // Auto-start discovery on install/startup
1306
+ chrome.runtime.onInstalled.addListener(() => {
1307
+ logInfo('background', 'Extension installed - starting discovery');
1308
+ scheduleDiscovery();
1309
+ });
1310
+ chrome.runtime.onStartup.addListener(() => {
1311
+ logInfo('background', 'Chrome started - starting discovery');
1312
+ scheduleDiscovery();
1313
+ });
1314
+ scheduleDiscovery(); // Start immediately
1315
+ // Ensure keepAlive is active from the start to prevent SW termination during discovery
1316
+ ensureKeepAliveAlarm('startup');
1317
+
1318
+ logInfo('background', 'Extension loaded (discovery active)');