chrome-ai-bridge 2.3.4 → 2.3.6

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.
@@ -7,22 +7,31 @@ export class Mutex {
7
7
  static Guard = class Guard {
8
8
  #mutex;
9
9
  #onRelease;
10
+ #released = false;
10
11
  constructor(mutex, onRelease) {
11
12
  this.#mutex = mutex;
12
13
  this.#onRelease = onRelease;
13
14
  }
14
15
  dispose() {
16
+ if (this.#released) {
17
+ return;
18
+ }
19
+ this.#released = true;
15
20
  this.#onRelease?.();
16
21
  return this.#mutex.release();
17
22
  }
18
23
  };
19
- #locked = false;
24
+ #maxConcurrency;
25
+ #active = 0;
20
26
  #acquirers = [];
27
+ constructor(maxConcurrency = 1) {
28
+ this.#maxConcurrency = Math.max(1, Math.floor(maxConcurrency));
29
+ }
21
30
  // This is FIFO.
22
31
  async acquire(onRelease) {
23
- if (!this.#locked) {
24
- this.#locked = true;
25
- return new Mutex.Guard(this);
32
+ if (this.#active < this.#maxConcurrency) {
33
+ this.#active += 1;
34
+ return new Mutex.Guard(this, onRelease);
26
35
  }
27
36
  const { resolve, promise } = Promise.withResolvers();
28
37
  this.#acquirers.push(resolve);
@@ -32,9 +41,10 @@ export class Mutex {
32
41
  release() {
33
42
  const resolve = this.#acquirers.shift();
34
43
  if (!resolve) {
35
- this.#locked = false;
44
+ this.#active = Math.max(0, this.#active - 1);
36
45
  return;
37
46
  }
47
+ // Hand the same concurrency slot to the next waiter.
38
48
  resolve();
39
49
  }
40
50
  }
@@ -68,22 +68,26 @@ export function getSessionConfig() {
68
68
  export function getIpcGuardConfig() {
69
69
  const raw = {
70
70
  maxSessions: Number(process.env.CAI_IPC_MAX_SESSIONS),
71
+ reservedInitSlots: Number(process.env.CAI_IPC_RESERVED_INIT_SLOTS),
71
72
  maxQueue: Number(process.env.CAI_IPC_MAX_QUEUE),
72
73
  queueWaitTimeoutMs: Number(process.env.CAI_IPC_QUEUE_WAIT_TIMEOUT_MS),
73
74
  sessionIdleMs: Number(process.env.CAI_IPC_SESSION_IDLE_MS),
74
75
  startupDelayJitterMs: Number(process.env.CAI_STARTUP_DELAY_JITTER_MS),
75
76
  startupProcessThreshold: Number(process.env.CAI_STARTUP_PROCESS_THRESHOLD),
76
77
  primaryIdleMs: Number(process.env.CAI_PRIMARY_IDLE_MS),
78
+ execMaxConcurrency: Number(process.env.CAI_EXEC_MAX_CONCURRENCY),
77
79
  };
78
80
  return {
79
- maxSessions: raw.maxSessions > 0 ? Math.floor(raw.maxSessions) : 6,
81
+ maxSessions: raw.maxSessions > 0 ? Math.floor(raw.maxSessions) : 20,
82
+ reservedInitSlots: raw.reservedInitSlots >= 0 ? Math.floor(raw.reservedInitSlots) : 2,
80
83
  maxQueue: raw.maxQueue > 0 ? Math.floor(raw.maxQueue) : 64,
81
- queueWaitTimeoutMs: raw.queueWaitTimeoutMs > 0 ? Math.floor(raw.queueWaitTimeoutMs) : 10_000,
82
- sessionIdleMs: raw.sessionIdleMs > 0 ? Math.floor(raw.sessionIdleMs) : 300_000,
84
+ queueWaitTimeoutMs: raw.queueWaitTimeoutMs > 0 ? Math.floor(raw.queueWaitTimeoutMs) : 45_000,
85
+ sessionIdleMs: raw.sessionIdleMs > 0 ? Math.floor(raw.sessionIdleMs) : 120_000,
83
86
  startupDelayJitterMs: raw.startupDelayJitterMs > 0 ? Math.floor(raw.startupDelayJitterMs) : 1_500,
84
87
  startupProcessThreshold: raw.startupProcessThreshold > 0
85
88
  ? Math.floor(raw.startupProcessThreshold)
86
89
  : 8,
87
90
  primaryIdleMs: raw.primaryIdleMs > 0 ? Math.floor(raw.primaryIdleMs) : 300_000,
91
+ execMaxConcurrency: raw.execMaxConcurrency > 0 ? Math.floor(raw.execMaxConcurrency) : 3,
88
92
  };
89
93
  }
@@ -92,7 +92,13 @@ export class RelayServer extends EventEmitter {
92
92
  ws.on('message', (data) => {
93
93
  this.handleMessage(data.toString());
94
94
  });
95
+ // Guard: only update state if this socket is still the current one.
96
+ // Prevents a stale socket's close event from corrupting a newer connection.
95
97
  ws.on('close', () => {
98
+ if (this.ws !== ws) {
99
+ debugLog('[RelayServer] Stale socket closed (ignored — already replaced)');
100
+ return;
101
+ }
96
102
  debugLog('[RelayServer] Extension disconnected');
97
103
  this.stopKeepAlive();
98
104
  this.rejectPendingRequests(new Error('RELAY_DISCONNECTED: Extension socket closed before request completion'));
@@ -330,10 +336,18 @@ export class RelayServer extends EventEmitter {
330
336
  * Stop server
331
337
  */
332
338
  async stop() {
339
+ this.stopKeepAlive();
333
340
  if (this.ws) {
334
- this.ws.close();
341
+ try {
342
+ this.ws.close();
343
+ }
344
+ catch {
345
+ // ignore close errors
346
+ }
335
347
  this.ws = null;
336
348
  }
349
+ this.ready = false;
350
+ this.tabId = null;
337
351
  this.rejectPendingRequests(new Error('RELAY_STOPPED: Relay stopped before request completion'));
338
352
  if (this.discoveryServer) {
339
353
  this.discoveryServer.close();
@@ -343,6 +357,7 @@ export class RelayServer extends EventEmitter {
343
357
  if (this.wss) {
344
358
  return new Promise((resolve) => {
345
359
  this.wss.close(() => {
360
+ this.wss = null;
346
361
  debugLog('[RelayServer] Server stopped');
347
362
  resolve();
348
363
  });
@@ -166,22 +166,52 @@ async function rotateHistoryIfNeeded() {
166
166
  }
167
167
  /**
168
168
  * キャッシュされたGeminiクライアントをクリア(リトライ用)
169
+ * @deprecated Use resetConnection('gemini') instead
169
170
  */
170
- export function clearGeminiClient() {
171
- const client = getClientFromAgent('gemini');
172
- const relay = getRelayFromAgent('gemini');
171
+ export async function clearGeminiClient() {
172
+ await resetConnection('gemini');
173
+ }
174
+ /**
175
+ * 指定 kind の接続を協調的にクリーンアップする。
176
+ * RelayServer・CdpClient・SessionManager・CDP リスナーを一括リセット。
177
+ * 接続失敗時のリトライ前に呼ぶことで「スティッキーな障害状態」を防ぐ。
178
+ */
179
+ export async function resetConnection(kind) {
180
+ const label = kind === 'chatgpt' ? 'ChatGPT' : 'Gemini';
181
+ console.error(`[fast-cdp] resetConnection(${kind}) — coordinated cleanup start`);
182
+ // 1. CdpClient: all CDP event listeners removed
183
+ const client = getClientFromAgent(kind);
173
184
  if (client) {
174
- console.error('[Gemini] Cached client cleared');
185
+ try {
186
+ client.removeAllCdpListeners();
187
+ }
188
+ catch {
189
+ // ignore
190
+ }
191
+ console.error(`[${label}] CdpClient listeners removed`);
175
192
  }
193
+ // 2. RelayServer: stop + reference clear (await to ensure port is released)
194
+ const relay = getRelayFromAgent(kind);
176
195
  if (relay) {
177
196
  try {
178
- relay.stop();
197
+ await relay.stop();
179
198
  }
180
199
  catch {
181
200
  // ignore stop errors
182
201
  }
202
+ console.error(`[${label}] RelayServer stopped`);
183
203
  }
184
- setClientForAgent('gemini', null, null);
204
+ // 3. Agent connection reference clear
205
+ setClientForAgent(kind, null, null);
206
+ console.error(`[${label}] Agent connection references cleared`);
207
+ // 4. Session info clear (await to prevent write race on retry)
208
+ try {
209
+ await clearAgentSession(kind);
210
+ }
211
+ catch {
212
+ // ignore session clear errors
213
+ }
214
+ console.error(`[fast-cdp] resetConnection(${kind}) — cleanup complete`);
185
215
  }
186
216
  /**
187
217
  * 全接続をクリーンアップ(プロセス終了時用)
@@ -367,7 +397,9 @@ async function createConnection(kind) {
367
397
  logWarn('fast-chat', `${kind} existing tab not found`, {
368
398
  error: error instanceof Error ? error.message : String(error),
369
399
  });
370
- console.error(`[fast-cdp] ${kind} existing tab not found, will create new tab`);
400
+ console.error(`[fast-cdp] ${kind} existing tab not found, resetting before new tab`);
401
+ // 再利用失敗 → stale 参照をクリアしてから新規タブへ
402
+ await resetConnection(kind);
371
403
  }
372
404
  }
373
405
  // 新しいタブを作成
@@ -422,7 +454,9 @@ async function createConnection(kind) {
422
454
  });
423
455
  console.error(`[fast-cdp] ${kind} new tab attempt ${attempt + 1} failed:`, lastError.message);
424
456
  if (attempt < 1) {
425
- console.error(`[fast-cdp] Retrying in 1s...`);
457
+ // リトライ前に協調クリーンアップして stale 状態を排除
458
+ console.error(`[fast-cdp] Resetting ${kind} connection before retry...`);
459
+ await resetConnection(kind);
426
460
  await new Promise(r => setTimeout(r, 1000));
427
461
  }
428
462
  }
@@ -446,22 +480,10 @@ export async function getClient(kind) {
446
480
  console.error(`[fast-cdp] Reusing healthy ${kind} connection`);
447
481
  return existing;
448
482
  }
449
- // 接続が切れている → 古いRelayServerをクリーンアップ
483
+ // 接続が切れている → 協調クリーンアップして再接続
450
484
  logConnectionState(kind, 'reconnecting');
451
- console.error(`[fast-cdp] ${kind} connection lost, reconnecting...`);
452
- // 古いRelayServerを停止
453
- const oldRelay = getRelayFromAgent(kind);
454
- if (oldRelay) {
455
- logInfo('fast-chat', `Stopping stale ${kind} RelayServer`);
456
- console.error(`[fast-cdp] Stopping stale ${kind} RelayServer`);
457
- await oldRelay.stop().catch((err) => {
458
- logWarn('fast-chat', `Failed to stop stale ${kind} RelayServer`, {
459
- error: err instanceof Error ? err.message : String(err),
460
- });
461
- });
462
- }
463
- // キャッシュをクリア
464
- setClientForAgent(kind, null, null);
485
+ console.error(`[fast-cdp] ${kind} connection lost, performing coordinated reset...`);
486
+ await resetConnection(kind);
465
487
  }
466
488
  // 新しい接続を作成
467
489
  return await createConnection(kind);
@@ -2205,9 +2227,8 @@ async function askGeminiFastInternal(question, debug) {
2205
2227
  const stuckCheckResult = await checkGeminiStuckState(client);
2206
2228
  if (stuckCheckResult.isStuck) {
2207
2229
  console.error(`[Gemini] Existing chat appears stuck (stop button detected for ${stuckCheckResult.waitedMs}ms). Clearing session and retrying.`);
2208
- // セッションとクライアントをクリア
2209
- await clearAgentSession('gemini');
2210
- clearGeminiClient();
2230
+ // 協調クリーンアップ(RelayServer + Client + Session を一括リセット)
2231
+ await resetConnection('gemini');
2211
2232
  // エラーを投げて、呼び出し元でリトライを促す
2212
2233
  throw new Error('GEMINI_STUCK_EXISTING_CHAT: Previous chat appears stuck (stop button visible). Session cleared, please retry.');
2213
2234
  }
@@ -2475,9 +2496,8 @@ async function askGeminiFastInternal(question, debug) {
2475
2496
  // 30秒以上停止ボタンが検出され続けている場合、セッションをクリアして新規チャットに切り替え
2476
2497
  if (stopButtonConsecutiveCount >= forceNewChatThreshold) {
2477
2498
  console.error(`[Gemini] Stop button detected for ${forceNewChatThreshold * 0.5}s - clearing session and forcing new chat`);
2478
- // セッションとクライアントをクリアして新規チャットを強制
2479
- await clearAgentSession('gemini');
2480
- clearGeminiClient();
2499
+ // 協調クリーンアップ(RelayServer + Client + Session を一括リセット)
2500
+ await resetConnection('gemini');
2481
2501
  // エラーを投げて再試行を促す
2482
2502
  throw new Error('GEMINI_STUCK_STOP_BUTTON: Previous response appears stuck. Session cleared, please retry.');
2483
2503
  }
package/build/src/main.js CHANGED
@@ -68,6 +68,8 @@ const HEALTH_CHECK_INTERVAL_MS = 500;
68
68
  const ipcGuardConfig = getIpcGuardConfig();
69
69
  const instanceId = randomUUID();
70
70
  let becamePrimary = false;
71
+ let stdinClosed = false;
72
+ let getActiveIpcSessionCount = () => 0;
71
73
  function countLocalBridgeInstances() {
72
74
  try {
73
75
  const output = execFileSync('ps', ['-axo', 'command'], {
@@ -170,7 +172,7 @@ const logDisclaimers = () => {
170
172
  Make sure the chrome-ai-bridge extension is installed and Chrome is running.
171
173
  Available tools: ask_chatgpt_web, ask_gemini_web, ask_chatgpt_gemini_web, take_cdp_snapshot, get_page_dom, ask_gemini_image`);
172
174
  };
173
- const toolMutex = new Mutex();
175
+ const toolMutex = new Mutex(ipcGuardConfig.execMaxConcurrency);
174
176
  function registerTool(tool) {
175
177
  server.registerTool(tool.name, {
176
178
  description: tool.description,
@@ -260,7 +262,9 @@ logDisclaimers();
260
262
  const ipcSessionLastActivity = new Map();
261
263
  const initQueue = [];
262
264
  let initializingCount = 0;
263
- const getSessionLoad = () => Object.keys(ipcTransports).length + initializingCount;
265
+ const getActiveSessionCount = () => Object.keys(ipcTransports).length;
266
+ getActiveIpcSessionCount = getActiveSessionCount;
267
+ const getSessionLoad = () => getActiveSessionCount() + initializingCount;
264
268
  const touchIpcSession = (sessionId) => {
265
269
  ipcSessionLastActivity.set(sessionId, Date.now());
266
270
  };
@@ -270,6 +274,7 @@ logDisclaimers();
270
274
  }
271
275
  ipcSessionLastActivity.delete(sessionId);
272
276
  drainInitQueue();
277
+ maybeShutdownAfterStdinClose('ipc session cleanup');
273
278
  };
274
279
  function sendJsonRpcError(res, code, message, id = null, statusCode = 400) {
275
280
  res.writeHead(statusCode).end(JSON.stringify({
@@ -291,6 +296,10 @@ logDisclaimers();
291
296
  if (getSessionLoad() < ipcGuardConfig.maxSessions) {
292
297
  return;
293
298
  }
299
+ const initWaiterLimit = Math.max(0, Math.min(ipcGuardConfig.reservedInitSlots, ipcGuardConfig.maxQueue));
300
+ if (initQueue.length >= initWaiterLimit) {
301
+ throw new Error('SERVER_CAPACITY_EXCEEDED');
302
+ }
294
303
  if (initQueue.length >= ipcGuardConfig.maxQueue) {
295
304
  throw new Error('SERVER_QUEUE_FULL');
296
305
  }
@@ -339,9 +348,11 @@ logDisclaimers();
339
348
  pid: process.pid,
340
349
  version,
341
350
  instanceId,
342
- activeSessions: Object.keys(ipcTransports).length,
351
+ activeSessions: getActiveSessionCount(),
343
352
  queuedInitializations: initQueue.length,
344
353
  sessionCapacity: ipcGuardConfig.maxSessions,
354
+ reservedInitSlots: ipcGuardConfig.reservedInitSlots,
355
+ execMaxConcurrency: ipcGuardConfig.execMaxConcurrency,
345
356
  }));
346
357
  return;
347
358
  }
@@ -385,7 +396,10 @@ logDisclaimers();
385
396
  }
386
397
  catch (error) {
387
398
  const message = error instanceof Error ? error.message : 'SERVER_BUSY_TIMEOUT';
388
- if (message === 'SERVER_QUEUE_FULL') {
399
+ if (message === 'SERVER_CAPACITY_EXCEEDED') {
400
+ sendJsonRpcError(res, -32003, message, null, 503);
401
+ }
402
+ else if (message === 'SERVER_QUEUE_FULL') {
389
403
  sendJsonRpcError(res, -32002, message, null, 503);
390
404
  }
391
405
  else {
@@ -491,7 +505,7 @@ logDisclaimers();
491
505
  ipcServer.listen(IPC_CONFIG.port, IPC_CONFIG.host, onListening);
492
506
  // Primary idle auto-exit: exit when no activity and no active IPC sessions
493
507
  const primaryIdleCheckTimer = setInterval(() => {
494
- const activeSessionCount = Object.keys(ipcTransports).length;
508
+ const activeSessionCount = getActiveSessionCount();
495
509
  if (Date.now() - primaryLastActivityAt > ipcGuardConfig.primaryIdleMs &&
496
510
  activeSessionCount === 0) {
497
511
  logger(`[main] Primary idle for ${Math.round((Date.now() - primaryLastActivityAt) / 1000)}s with 0 active sessions. Auto-exiting.`);
@@ -540,9 +554,32 @@ async function shutdown(reason) {
540
554
  clearTimeout(forceExitTimer);
541
555
  process.exit(0);
542
556
  }
557
+ function maybeShutdownAfterStdinClose(trigger) {
558
+ if (!stdinClosed || isShuttingDown) {
559
+ return;
560
+ }
561
+ const activeSessionCount = getActiveIpcSessionCount();
562
+ if (activeSessionCount > 0) {
563
+ return;
564
+ }
565
+ logger(`[main] ${trigger}: stdin already closed and all IPC sessions drained. Shutting down.`);
566
+ void shutdown('stdin closed (all IPC sessions drained)');
567
+ }
568
+ function handleStdinClosed(reason) {
569
+ stdinClosed = true;
570
+ if (isShuttingDown) {
571
+ return;
572
+ }
573
+ const activeSessionCount = getActiveIpcSessionCount();
574
+ if (activeSessionCount > 0) {
575
+ logger(`[main] ${reason}: deferring shutdown while ${activeSessionCount} IPC session(s) remain.`);
576
+ return;
577
+ }
578
+ void shutdown(reason);
579
+ }
543
580
  // stdin close = Claude Code disconnected (most reliable on Windows too)
544
- process.stdin.on('end', () => shutdown('stdin ended'));
545
- process.stdin.on('close', () => shutdown('stdin closed'));
581
+ process.stdin.on('end', () => handleStdinClosed('stdin ended'));
582
+ process.stdin.on('close', () => handleStdinClosed('stdin closed'));
546
583
  // Signal handlers
547
584
  process.on('SIGTERM', () => shutdown('SIGTERM'));
548
585
  process.on('SIGINT', () => shutdown('SIGINT'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrome-ai-bridge",
3
- "version": "2.3.4",
3
+ "version": "2.3.6",
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",
package/scripts/cli.mjs CHANGED
@@ -2,24 +2,49 @@
2
2
  /**
3
3
  * CLI Entry Point for chrome-ai-bridge
4
4
  *
5
- * This entrypoint runs the MCP server in-process to avoid spawning an extra
6
- * wrapper process per client (important for multi-pane usage).
5
+ * Launches the MCP server with browser-globals mock in a child process.
6
+ *
7
+ * Why child process:
8
+ * - main.js may intentionally enter a never-returning proxy path.
9
+ * - awaiting dynamic import(main.js) in-process can trigger unsettled
10
+ * top-level-await warnings and startup failure in that path.
7
11
  */
8
12
 
9
13
  import path from 'node:path';
10
14
  import process from 'node:process';
11
- import {fileURLToPath, pathToFileURL} from 'node:url';
15
+ import {spawn} from 'node:child_process';
16
+ import {fileURLToPath} from 'node:url';
12
17
 
13
18
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
19
  const mockPath = path.join(__dirname, 'browser-globals-mock.mjs');
15
20
  const mainPath = path.join(__dirname, '..', 'build', 'src', 'main.js');
16
21
 
17
- try {
18
- // Ensure browser globals are defined before loading main server modules.
19
- await import(pathToFileURL(mockPath).href);
20
- await import(pathToFileURL(mainPath).href);
21
- } catch (error) {
22
- const message = error instanceof Error ? error.stack || error.message : String(error);
23
- console.error(`[cli] Failed to start chrome-ai-bridge: ${message}`);
24
- process.exit(1);
25
- }
22
+ const child = spawn(
23
+ process.execPath,
24
+ [
25
+ '--import',
26
+ mockPath,
27
+ mainPath,
28
+ ...process.argv.slice(2),
29
+ ],
30
+ {
31
+ stdio: 'inherit',
32
+ env: process.env,
33
+ },
34
+ );
35
+
36
+ child.on('exit', (code, signal) => {
37
+ if (signal) {
38
+ process.exit(1);
39
+ }
40
+ process.exit(code ?? 0);
41
+ });
42
+
43
+ process.on('exit', () => {
44
+ if (!child.killed) {
45
+ child.kill('SIGTERM');
46
+ }
47
+ });
48
+
49
+ process.on('SIGTERM', () => child?.kill('SIGTERM'));
50
+ process.on('SIGINT', () => child?.kill('SIGINT'));