chrome-ai-bridge 2.3.10 → 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.
@@ -170,6 +170,8 @@ class RelayConnection {
170
170
  }
171
171
  if (message.method === 'reloadExtension') {
172
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(() => {});
173
175
  // Delay reload to allow response to be sent first
174
176
  setTimeout(() => {
175
177
  logInfo('reload', 'Calling chrome.runtime.reload()');
@@ -731,7 +733,7 @@ const DISCOVERY_INTERVAL_MS = {
731
733
  };
732
734
  const FAST_TO_NORMAL_EMPTY_STREAK = 5;
733
735
  const NORMAL_TO_IDLE_EMPTY_STREAK = 20;
734
- const ACTIVE_TO_IDLE_EMPTY_STREAK = 3;
736
+ const ACTIVE_TO_IDLE_EMPTY_STREAK = 10;
735
737
  let lastSuccessfulPort = null;
736
738
  const lastRelayByPort = new Map();
737
739
 
@@ -745,8 +747,23 @@ let emptyDiscoveryStreak = 0;
745
747
  let keepAliveActive = false;
746
748
 
747
749
  // リロード時クールダウン: 5秒間は「新しいrelay」検出をスキップ
750
+ // ただし reloadExtension コマンド経由のリロード時はスキップしない
748
751
  const extensionStartTime = Date.now();
749
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(() => {});
750
767
 
751
768
  // ユーザー操作によるDiscoveryかどうかのフラグ
752
769
  // Chrome起動時やService Worker再起動時はfalse、アイコンクリック時のみtrue
@@ -937,15 +954,15 @@ async function autoConnectRelay(best) {
937
954
 
938
955
  // tabUrl があれば、connect.html を開かずに直接接続
939
956
  // preferredTabId があれば優先的に使用
957
+ const requestedNewTab = Boolean(best?.data?.newTab);
940
958
  let targetTabId;
941
959
  try {
942
960
  // autoConnectRelay経由の場合はフォーカスしない(active: false)
943
- // リロード時に勝手にタブがフォーカスされる問題を防ぐ
944
- // newTab: false に固定 - 自動接続では既存タブを優先してタブスパムを防止
961
+ // newTab: relay の要求を尊重する(MCP サーバーが newTab: true を指定した場合は新規タブ作成を許可)
945
962
  targetTabId = await tabShareExtension._resolveTabId(
946
963
  tabUrl,
947
964
  preferredTabId,
948
- false, // newTab: false - always prefer existing tabs in auto-connect
965
+ requestedNewTab,
949
966
  false, // active: false - 自動接続時はタブをフォーカスしない
950
967
  );
951
968
  } catch (error) {
@@ -957,8 +974,23 @@ async function autoConnectRelay(best) {
957
974
  return false;
958
975
  }
959
976
  if (tabShareExtension._activeConnections?.has(targetTabId)) {
960
- logInfo('auto-connect', 'Tab already connected', {targetTabId});
961
- return true; // 既に接続済み
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
+ }
962
994
  }
963
995
 
964
996
  const targetTab = await chrome.tabs.get(targetTabId).catch(() => null);
@@ -1009,8 +1041,9 @@ async function autoOpenConnectUi() {
1009
1041
  };
1010
1042
 
1011
1043
  // リロード直後はタブを開かない(既存MCPサーバーとの再接続を防ぐ)
1044
+ // ただし reloadExtension コマンド経由の場合はスキップしない
1012
1045
  const elapsed = Date.now() - extensionStartTime;
1013
- if (elapsed < COOLDOWN_MS) {
1046
+ if (elapsed < COOLDOWN_MS && !cooldownDisabled) {
1014
1047
  logDebug('discovery', `Cooldown active (${elapsed}ms < ${COOLDOWN_MS}ms), skipping`);
1015
1048
  result.skippedCooldown = true;
1016
1049
  return result;
@@ -1038,7 +1071,7 @@ async function autoOpenConnectUi() {
1038
1071
  continue;
1039
1072
  }
1040
1073
 
1041
- logInfo('discovery', 'New relay detected', {port, tabUrl: data.tabUrl, wsUrl: data.wsUrl});
1074
+ logInfo('discovery', 'New relay detected', {port, tabUrl: data.tabUrl, wsUrl: data.wsUrl, startedAt});
1042
1075
  lastRelayByPort.set(port, {
1043
1076
  wsUrl: data.wsUrl,
1044
1077
  startedAt,
@@ -1047,14 +1080,34 @@ async function autoOpenConnectUi() {
1047
1080
  newRelays.push({port, data});
1048
1081
  }
1049
1082
 
1050
- result.newRelayCount = newRelays.length;
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
+ }
1051
1102
 
1052
- if (newRelays.length > 0) {
1053
- logInfo('discovery', `Processing ${newRelays.length} new relay(s)`);
1103
+ result.newRelayCount = dedupedRelays.length;
1104
+
1105
+ if (dedupedRelays.length > 0) {
1106
+ logInfo('discovery', `Processing ${dedupedRelays.length} new relay(s)`);
1054
1107
  }
1055
1108
 
1056
1109
  // 全ての新しい relay を処理(並列ではなく順次)
1057
- for (const relay of newRelays) {
1110
+ for (const relay of dedupedRelays) {
1058
1111
  logInfo('discovery', 'Processing relay', {port: relay.port, tabUrl: relay.data.tabUrl});
1059
1112
  debugLog('Processing new relay:', relay.port, relay.data.tabUrl);
1060
1113
  let ok = false;
@@ -1259,5 +1312,7 @@ chrome.runtime.onStartup.addListener(() => {
1259
1312
  scheduleDiscovery();
1260
1313
  });
1261
1314
  scheduleDiscovery(); // Start immediately
1315
+ // Ensure keepAlive is active from the start to prevent SW termination during discovery
1316
+ ensureKeepAliveAlarm('startup');
1262
1317
 
1263
1318
  logInfo('background', 'Extension loaded (discovery active)');
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "chrome-ai-bridge Extension",
4
- "version": "2.0.23",
4
+ "version": "2.0.28",
5
5
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqDLiSB+b/gbnQ4zWRP65jnd27KmzpjJyR1JAQIjCD/dNORzTgk6+G0TjYEDZLIDceKHzGJudqnwq4q9g3T1eJ0SECZNXnaoE00WkgXfAUSQn6cmmXR3aQGFky/zbCmxnkRa0vYupxszlhw0yrlSZrIJd/weWF75Byh0zJfZ84kqDDhaj7TlB5laHICnoSLmPTif4mQcUW9oOKmAJPriPw4CWATKZsrQ4X46djxefSmfbqYfb9rttAqJVst40gO0Gsl6GOGxMHMds5Cl9GELc0dI3Gpobw07hQldZb8TeyilI/SnOaeS3HPtrp+KyEgRu8SgRdlrvuq6DeEZsP+kK7wIDAQAB",
6
6
  "description": "Bridge between Chrome tabs and chrome-ai-bridge MCP server",
7
7
  "permissions": ["debugger", "activeTab", "tabs", "storage", "alarms"],
@@ -55,6 +55,12 @@ export class RelayServer extends EventEmitter {
55
55
  private discoveryServer: http.Server | null = null;
56
56
  private discoveryPort: number | null = null;
57
57
  private keepAliveTimer: ReturnType<typeof setInterval> | null = null;
58
+ private _lastDiscoveryOptions: {
59
+ tabUrl?: string;
60
+ tabId?: number;
61
+ newTab?: boolean;
62
+ allowTabTakeover?: boolean;
63
+ } = {};
58
64
 
59
65
  constructor(options: RelayServerOptions = {}) {
60
66
  super();
@@ -219,6 +225,9 @@ export class RelayServer extends EventEmitter {
219
225
  this.ready = true;
220
226
  debugLog(`[RelayServer] Connection ready for tab ${this.tabId}`);
221
227
  this.emit('ready', this.tabId);
228
+ // Release discovery port after WebSocket is established.
229
+ // 1-second grace period lets Extension finish processing the response.
230
+ setTimeout(() => this.stopDiscoveryServer(), 1000);
222
231
  break;
223
232
  case 'pong':
224
233
  debugLog('[RelayServer] Received keep-alive pong');
@@ -333,6 +342,7 @@ export class RelayServer extends EventEmitter {
333
342
  newTab?: boolean;
334
343
  allowTabTakeover?: boolean;
335
344
  } = {}): Promise<number | null> {
345
+ this._lastDiscoveryOptions = options;
336
346
  const ports = [38765, 38766, 38767, 38768, 38769, 38770, 38771, 38772, 38773, 38774, 38775];
337
347
  const wsUrl = this.getConnectionURL();
338
348
 
@@ -404,6 +414,34 @@ export class RelayServer extends EventEmitter {
404
414
  return null;
405
415
  }
406
416
 
417
+ /**
418
+ * Release the discovery HTTP server (port).
419
+ * Called automatically after the Extension WebSocket connects (ready event).
420
+ * The port becomes available for other sessions.
421
+ */
422
+ stopDiscoveryServer(): void {
423
+ if (this.discoveryServer) {
424
+ this.discoveryServer.close();
425
+ debugLog(`[RelayServer] Discovery server released (port ${this.discoveryPort})`);
426
+ this.discoveryServer = null;
427
+ this.discoveryPort = null;
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Re-acquire a discovery port (e.g. after WebSocket disconnect for reconnection).
433
+ * Uses the same options as the last startDiscoveryServer() call.
434
+ */
435
+ async restartDiscoveryServer(options?: {
436
+ tabUrl?: string;
437
+ tabId?: number;
438
+ newTab?: boolean;
439
+ allowTabTakeover?: boolean;
440
+ }): Promise<number | null> {
441
+ this.stopDiscoveryServer();
442
+ return this.startDiscoveryServer(options || this._lastDiscoveryOptions);
443
+ }
444
+
407
445
  /**
408
446
  * Stop server
409
447
  */
@@ -425,11 +463,7 @@ export class RelayServer extends EventEmitter {
425
463
  new Error('RELAY_STOPPED: Relay stopped before request completion'),
426
464
  );
427
465
 
428
- if (this.discoveryServer) {
429
- this.discoveryServer.close();
430
- this.discoveryServer = null;
431
- this.discoveryPort = null;
432
- }
466
+ this.stopDiscoveryServer();
433
467
 
434
468
  if (this.wss) {
435
469
  return new Promise((resolve) => {
@@ -29,6 +29,7 @@ export class RelayServer extends EventEmitter {
29
29
  discoveryServer = null;
30
30
  discoveryPort = null;
31
31
  keepAliveTimer = null;
32
+ _lastDiscoveryOptions = {};
32
33
  constructor(options = {}) {
33
34
  super();
34
35
  this.host = options.host || '127.0.0.1';
@@ -172,6 +173,9 @@ export class RelayServer extends EventEmitter {
172
173
  this.ready = true;
173
174
  debugLog(`[RelayServer] Connection ready for tab ${this.tabId}`);
174
175
  this.emit('ready', this.tabId);
176
+ // Release discovery port after WebSocket is established.
177
+ // 1-second grace period lets Extension finish processing the response.
178
+ setTimeout(() => this.stopDiscoveryServer(), 1000);
175
179
  break;
176
180
  case 'pong':
177
181
  debugLog('[RelayServer] Received keep-alive pong');
@@ -269,6 +273,7 @@ export class RelayServer extends EventEmitter {
269
273
  * Extension polls this endpoint when user clicks the extension icon.
270
274
  */
271
275
  async startDiscoveryServer(options = {}) {
276
+ this._lastDiscoveryOptions = options;
272
277
  const ports = [38765, 38766, 38767, 38768, 38769, 38770, 38771, 38772, 38773, 38774, 38775];
273
278
  const wsUrl = this.getConnectionURL();
274
279
  for (const port of ports) {
@@ -332,6 +337,27 @@ export class RelayServer extends EventEmitter {
332
337
  debugLog('[RelayServer] Could not start discovery server on any port');
333
338
  return null;
334
339
  }
340
+ /**
341
+ * Release the discovery HTTP server (port).
342
+ * Called automatically after the Extension WebSocket connects (ready event).
343
+ * The port becomes available for other sessions.
344
+ */
345
+ stopDiscoveryServer() {
346
+ if (this.discoveryServer) {
347
+ this.discoveryServer.close();
348
+ debugLog(`[RelayServer] Discovery server released (port ${this.discoveryPort})`);
349
+ this.discoveryServer = null;
350
+ this.discoveryPort = null;
351
+ }
352
+ }
353
+ /**
354
+ * Re-acquire a discovery port (e.g. after WebSocket disconnect for reconnection).
355
+ * Uses the same options as the last startDiscoveryServer() call.
356
+ */
357
+ async restartDiscoveryServer(options) {
358
+ this.stopDiscoveryServer();
359
+ return this.startDiscoveryServer(options || this._lastDiscoveryOptions);
360
+ }
335
361
  /**
336
362
  * Stop server
337
363
  */
@@ -349,11 +375,7 @@ export class RelayServer extends EventEmitter {
349
375
  this.ready = false;
350
376
  this.tabId = null;
351
377
  this.rejectPendingRequests(new Error('RELAY_STOPPED: Relay stopped before request completion'));
352
- if (this.discoveryServer) {
353
- this.discoveryServer.close();
354
- this.discoveryServer = null;
355
- this.discoveryPort = null;
356
- }
378
+ this.stopDiscoveryServer();
357
379
  if (this.wss) {
358
380
  return new Promise((resolve) => {
359
381
  this.wss.close(() => {
@@ -5,7 +5,10 @@ import { RelayServer } from '../extension/relay-server.js';
5
5
  import { logRelay, logExtension, logInfo, logError } from './mcp-logger.js';
6
6
  // Stable extension ID (from manifest.json key)
7
7
  const EXTENSION_ID = 'ibjplbopgmcacpmfpnaeoloepdhenlbm';
8
- const ENABLE_WAKE_CONNECT_PAGE = process.env.CAI_ENABLE_WAKE_CONNECT_PAGE !== '0';
8
+ // Wake connect page disabled by default — it opens a chrome-extension:// URL
9
+ // that gets ERR_BLOCKED_BY_CLIENT and annoys users. Discovery polling is the
10
+ // primary mechanism; wake is only useful in rare edge cases.
11
+ const ENABLE_WAKE_CONNECT_PAGE = process.env.CAI_ENABLE_WAKE_CONNECT_PAGE === '1';
9
12
  /**
10
13
  * Get Chrome executable path for current platform
11
14
  */
@@ -2032,16 +2032,20 @@ async function askChatGPTFastInternal(question, debug) {
2032
2032
  summary: interceptor.getSummary(),
2033
2033
  });
2034
2034
  // Hybrid: prefer network text (primary), DOM as fallback
2035
+ // Use network if it captured anything and is at least 50% of DOM length
2036
+ // (avoids using truncated network text when DOM has the full answer)
2035
2037
  let hybridAnswer = finalAnswer;
2036
2038
  let answerSource = 'dom';
2037
- if (networkResult.text.length > 50) {
2039
+ const netLen = networkResult.text.length;
2040
+ const domLen = finalAnswer.length;
2041
+ if (netLen > 0 && (domLen === 0 || netLen >= domLen * 0.5)) {
2038
2042
  hybridAnswer = networkResult.text;
2039
2043
  answerSource = 'network';
2040
2044
  }
2041
2045
  logInfo('chatgpt', 'Answer source selected', {
2042
2046
  source: answerSource,
2043
- networkLen: networkResult.text.length,
2044
- domLen: finalAnswer.length,
2047
+ networkLen: netLen,
2048
+ domLen,
2045
2049
  });
2046
2050
  return { answer: hybridAnswer, timings: fullTimings, debug: debugInfo };
2047
2051
  }
@@ -3030,17 +3034,20 @@ async function askGeminiFastInternal(question, debug) {
3030
3034
  });
3031
3035
  // Hybrid: prefer network text (primary), DOM as fallback
3032
3036
  // Normalize network text with same Gemini-specific cleanup as DOM text
3037
+ // Use network if it captured anything and is at least 50% of DOM length
3033
3038
  const networkNormalized = normalizeGeminiResponse(networkResult.text, question);
3034
3039
  let hybridAnswer = normalized;
3035
3040
  let answerSource = 'dom';
3036
- if (networkNormalized.length > 50) {
3041
+ const netLen = networkNormalized.length;
3042
+ const domLen = normalized.length;
3043
+ if (netLen > 0 && (domLen === 0 || netLen >= domLen * 0.5)) {
3037
3044
  hybridAnswer = networkNormalized;
3038
3045
  answerSource = 'network';
3039
3046
  }
3040
3047
  logInfo('gemini', 'Answer source selected', {
3041
3048
  source: answerSource,
3042
- networkLen: networkNormalized.length,
3043
- domLen: normalized.length,
3049
+ networkLen: netLen,
3050
+ domLen,
3044
3051
  });
3045
3052
  return { answer: hybridAnswer, timings: fullTimings, debug: debugInfo };
3046
3053
  }
@@ -146,8 +146,15 @@ export class NetworkInterceptor {
146
146
  const isSSE = contentType.includes('text/event-stream');
147
147
  if (isResponseUrl(url) || isSSE) {
148
148
  this.pendingBodies.add(requestId);
149
- this.fetchResponseBody(requestId, url).catch(() => {
150
- // Best-effort; failures are expected for some responses
149
+ this.fetchResponseBody(requestId, url).catch((err) => {
150
+ console.error(`[NetworkInterceptor] fetchResponseBody error for ${url.slice(0, 80)}: ${err instanceof Error ? err.message : String(err)}`);
151
+ });
152
+ }
153
+ else if (!url) {
154
+ // Speculative capture: requestWillBeSent was missed (tab reuse scenario)
155
+ // Try fetching body and check if it looks like ChatGPT SSE or Gemini response
156
+ this.speculativeFetchBody(requestId).catch(() => {
157
+ // Best-effort; silent failure expected
151
158
  });
152
159
  }
153
160
  });
@@ -163,40 +170,87 @@ export class NetworkInterceptor {
163
170
  this.client.on(event, wrappedHandler);
164
171
  }
165
172
  async fetchResponseBody(requestId, url) {
166
- try {
167
- const result = await this.client.send('Network.getResponseBody', { requestId });
168
- if (result?.body) {
169
- let data;
170
- if (result.base64Encoded) {
171
- try {
172
- data = Buffer.from(result.body, 'base64').toString('utf-8');
173
+ const maxRetries = 2;
174
+ const retryDelayMs = 500;
175
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
176
+ try {
177
+ const result = await this.client.send('Network.getResponseBody', { requestId });
178
+ if (result?.body) {
179
+ let data;
180
+ if (result.base64Encoded) {
181
+ try {
182
+ data = Buffer.from(result.body, 'base64').toString('utf-8');
183
+ }
184
+ catch (decodeErr) {
185
+ console.error(`[NetworkInterceptor] Base64 decode failed for ${url.slice(0, 80)}: ${decodeErr instanceof Error ? decodeErr.message : String(decodeErr)}`);
186
+ this.pendingBodies.delete(requestId);
187
+ return;
188
+ }
173
189
  }
174
- catch (decodeErr) {
175
- console.error(`[NetworkInterceptor] Base64 decode failed for ${url.slice(0, 80)}: ${decodeErr instanceof Error ? decodeErr.message : String(decodeErr)}`);
176
- return;
190
+ else {
191
+ data = result.body;
177
192
  }
193
+ this.frames.push({
194
+ timestamp: Date.now() / 1000,
195
+ type: 'fetch-body',
196
+ requestId,
197
+ url,
198
+ data,
199
+ });
200
+ console.error(`[NetworkInterceptor] Body captured: ${url.slice(0, 80)} (${data.length} bytes)`);
201
+ }
202
+ this.pendingBodies.delete(requestId);
203
+ return;
204
+ }
205
+ catch (err) {
206
+ const msg = err instanceof Error ? err.message : String(err);
207
+ const isRetryable = msg.includes('No resource') || msg.includes('No data found');
208
+ if (isRetryable && attempt < maxRetries) {
209
+ console.error(`[NetworkInterceptor] getResponseBody retry ${attempt + 1}/${maxRetries} for ${url.slice(0, 80)}`);
210
+ await new Promise(r => setTimeout(r, retryDelayMs));
211
+ continue;
212
+ }
213
+ if (!isRetryable) {
214
+ console.error(`[NetworkInterceptor] getResponseBody failed for ${url.slice(0, 80)}: ${msg}`);
178
215
  }
179
216
  else {
180
- data = result.body;
217
+ console.error(`[NetworkInterceptor] getResponseBody failed after ${maxRetries} retries for ${url.slice(0, 80)}: ${msg}`);
181
218
  }
219
+ this.pendingBodies.delete(requestId);
220
+ return;
221
+ }
222
+ }
223
+ this.pendingBodies.delete(requestId);
224
+ }
225
+ /**
226
+ * Speculative body fetch for requests where requestWillBeSent was missed
227
+ * (e.g., tab reuse). Checks if the body looks like a ChatGPT or Gemini response.
228
+ */
229
+ async speculativeFetchBody(requestId) {
230
+ try {
231
+ const result = await this.client.send('Network.getResponseBody', { requestId });
232
+ if (!result?.body)
233
+ return;
234
+ const data = result.base64Encoded
235
+ ? Buffer.from(result.body, 'base64').toString('utf-8')
236
+ : result.body;
237
+ // Check if body looks like ChatGPT SSE (contains "data: " lines and [DONE])
238
+ const isChatGPTSSE = data.includes('data: ') && data.includes('[DONE]');
239
+ // Check if body looks like Gemini response (starts with )]}')
240
+ const isGemini = data.startsWith(")]}'");
241
+ if (isChatGPTSSE || isGemini) {
182
242
  this.frames.push({
183
243
  timestamp: Date.now() / 1000,
184
244
  type: 'fetch-body',
185
245
  requestId,
186
- url,
246
+ url: '<speculative>',
187
247
  data,
188
248
  });
249
+ console.error(`[NetworkInterceptor] Speculative capture hit: ${isChatGPTSSE ? 'ChatGPT SSE' : 'Gemini'} (${data.length} bytes)`);
189
250
  }
190
251
  }
191
- catch (err) {
192
- // Common: "No resource with given identifier" for redirects/cancelled requests
193
- const msg = err instanceof Error ? err.message : String(err);
194
- if (!msg.includes('No resource') && !msg.includes('No data found')) {
195
- console.error(`[NetworkInterceptor] getResponseBody failed for ${url.slice(0, 80)}: ${msg}`);
196
- }
197
- }
198
- finally {
199
- this.pendingBodies.delete(requestId);
252
+ catch {
253
+ // Silent: speculative capture is best-effort
200
254
  }
201
255
  }
202
256
  stopCapture() {
@@ -212,14 +266,30 @@ export class NetworkInterceptor {
212
266
  }
213
267
  /**
214
268
  * Wait for all pending response body fetches, then stop capture.
269
+ * IMPORTANT: capturing remains true during the wait so that late-arriving
270
+ * loadingFinished events are still processed (this was the root cause of
271
+ * textLength=0 — setting capturing=false first dropped those events).
215
272
  */
216
- async stopCaptureAndWait(timeoutMs = 3000) {
217
- this.capturing = false; // Stop receiving new events
218
- // Wait for pending body fetches
273
+ async stopCaptureAndWait(timeoutMs = 15000) {
274
+ // Phase 1: Wait for pending body fetches (capturing stays true)
219
275
  const deadline = Date.now() + timeoutMs;
220
276
  while (this.pendingBodies.size > 0 && Date.now() < deadline) {
221
277
  await new Promise(r => setTimeout(r, 100));
222
278
  }
279
+ // Phase 2: Grace period — if no frames captured yet, wait for late loadingFinished
280
+ if (this.frames.length === 0) {
281
+ const graceDeadline = Math.min(Date.now() + 3000, deadline);
282
+ console.error('[NetworkInterceptor] No frames yet, waiting grace period for late events...');
283
+ while (this.frames.length === 0 && Date.now() < graceDeadline) {
284
+ await new Promise(r => setTimeout(r, 100));
285
+ // Also wait for any new pending bodies that appeared during grace
286
+ while (this.pendingBodies.size > 0 && Date.now() < graceDeadline) {
287
+ await new Promise(r => setTimeout(r, 100));
288
+ }
289
+ }
290
+ }
291
+ // Phase 3: Now stop capturing
292
+ this.capturing = false;
223
293
  // Warn if pending bodies remain after timeout
224
294
  if (this.pendingBodies.size > 0) {
225
295
  console.warn(`[NetworkInterceptor] Timeout: ${this.pendingBodies.size} pending bodies abandoned (requestIds: ${[...this.pendingBodies].slice(0, 3).join(', ')}${this.pendingBodies.size > 3 ? '...' : ''})`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrome-ai-bridge",
3
- "version": "2.3.10",
3
+ "version": "2.4.0",
4
4
  "description": "MCP server bridging Chrome extension and AI assistants (ChatGPT, Gemini). Extension-only mode - no Puppeteer.",
5
5
  "type": "module",
6
6
  "bin": "./scripts/cli.mjs",