chrome-ai-bridge 2.3.5 → 2.3.7

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
  }
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.5",
3
+ "version": "2.3.7",
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'));