chrome-ai-bridge 2.3.1 → 2.3.3

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.
@@ -58,7 +58,30 @@ export function getSessionConfig() {
58
58
  };
59
59
  return {
60
60
  sessionTtlMinutes: raw.ttl > 0 ? raw.ttl : 30,
61
- maxAgents: raw.max > 0 ? Math.floor(raw.max) : 10,
61
+ maxAgents: raw.max > 0 ? Math.floor(raw.max) : 20,
62
62
  cleanupIntervalMinutes: raw.interval > 0 ? raw.interval : 5,
63
63
  };
64
64
  }
65
+ /**
66
+ * Get IPC overload protection settings from environment variables or defaults.
67
+ */
68
+ export function getIpcGuardConfig() {
69
+ const raw = {
70
+ maxSessions: Number(process.env.CAI_IPC_MAX_SESSIONS),
71
+ maxQueue: Number(process.env.CAI_IPC_MAX_QUEUE),
72
+ queueWaitTimeoutMs: Number(process.env.CAI_IPC_QUEUE_WAIT_TIMEOUT_MS),
73
+ sessionIdleMs: Number(process.env.CAI_IPC_SESSION_IDLE_MS),
74
+ startupDelayJitterMs: Number(process.env.CAI_STARTUP_DELAY_JITTER_MS),
75
+ startupProcessThreshold: Number(process.env.CAI_STARTUP_PROCESS_THRESHOLD),
76
+ };
77
+ return {
78
+ maxSessions: raw.maxSessions > 0 ? Math.floor(raw.maxSessions) : 16,
79
+ maxQueue: raw.maxQueue > 0 ? Math.floor(raw.maxQueue) : 64,
80
+ queueWaitTimeoutMs: raw.queueWaitTimeoutMs > 0 ? Math.floor(raw.queueWaitTimeoutMs) : 10_000,
81
+ sessionIdleMs: raw.sessionIdleMs > 0 ? Math.floor(raw.sessionIdleMs) : 300_000,
82
+ startupDelayJitterMs: raw.startupDelayJitterMs > 0 ? Math.floor(raw.startupDelayJitterMs) : 1_500,
83
+ startupProcessThreshold: raw.startupProcessThreshold > 0
84
+ ? Math.floor(raw.startupProcessThreshold)
85
+ : 8,
86
+ };
87
+ }
package/build/src/main.js CHANGED
@@ -15,6 +15,7 @@
15
15
  import assert from 'node:assert';
16
16
  import fs from 'node:fs';
17
17
  import path from 'node:path';
18
+ import { execFileSync } from 'node:child_process';
18
19
  import { randomUUID } from 'node:crypto';
19
20
  import http from 'node:http';
20
21
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
@@ -31,8 +32,8 @@ import { getFastContext } from './fast-cdp/fast-context.js';
31
32
  import { cleanupAllConnections } from './fast-cdp/fast-chat.js';
32
33
  import { generateAgentId, setAgentId } from './fast-cdp/agent-context.js';
33
34
  import { cleanupStaleSessions } from './fast-cdp/session-manager.js';
34
- import { getSessionConfig, IPC_CONFIG } from './config.js';
35
- import { acquireLock, releaseLock, killSiblings, checkExistingPrimary, updateLockPort } from './process-lock.js';
35
+ import { getIpcGuardConfig, getSessionConfig, IPC_CONFIG } from './config.js';
36
+ import { releaseLock, tryAcquireLockSafe, checkExistingPrimary, updateLockPort } from './process-lock.js';
36
37
  import { checkPrimaryHealth, startProxyMode } from './stdio-http-proxy.js';
37
38
  function readPackageJson() {
38
39
  const currentDir = import.meta.dirname;
@@ -56,28 +57,87 @@ logger(`Starting Chrome AI Bridge v${version} (Extension-only mode)`);
56
57
  // Initialize agent ID for Agent Teams support
57
58
  const agentId = generateAgentId();
58
59
  setAgentId(agentId);
59
- // ─── Multi-client routing ───
60
- // Check if a Primary instance is already running.
61
- // If yes and healthy, enter proxy mode (never returns).
62
- const existingPrimary = checkExistingPrimary();
63
- if (existingPrimary && existingPrimary.port > 0) {
64
- const healthy = await checkPrimaryHealth(existingPrimary.port);
65
- if (healthy) {
66
- logger(`[main] Primary is healthy (port=${existingPrimary.port}). Entering proxy mode.`);
67
- await startProxyMode(existingPrimary.port); // never returns
60
+ // ─── Multi-client routing with retry ───
61
+ // Handles concurrent startup of many processes (e.g. tproj 16-pane scenario).
62
+ // Each process tries to become Primary or fall back to Proxy mode,
63
+ // with exponential backoff + jitter to avoid thundering herd.
64
+ const MAX_STARTUP_ATTEMPTS = 5;
65
+ const BASE_DELAY_MS = 300;
66
+ const HEALTH_CHECK_RETRIES = 3;
67
+ const HEALTH_CHECK_INTERVAL_MS = 500;
68
+ const ipcGuardConfig = getIpcGuardConfig();
69
+ const instanceId = randomUUID();
70
+ let becamePrimary = false;
71
+ function countLocalBridgeInstances() {
72
+ try {
73
+ const output = execFileSync('ps', ['-axo', 'command'], {
74
+ encoding: 'utf8',
75
+ stdio: ['ignore', 'pipe', 'ignore'],
76
+ });
77
+ const lines = output.split('\n').filter(Boolean);
78
+ return lines.filter(line => line.includes('chrome-ai-bridge') &&
79
+ (line.includes('build/src/main.js') || line.includes('scripts/cli.mjs'))).length;
80
+ }
81
+ catch {
82
+ return 0;
68
83
  }
69
- logger(`[main] Primary (port=${existingPrimary.port}) not healthy. Starting as Primary.`);
70
84
  }
71
- // ─── Primary mode ───
72
- // Kill all stale sibling processes first
73
- const killed = await killSiblings();
74
- if (killed > 0) {
75
- logger(`[process-lock] Killed ${killed} stale sibling process(es)`);
85
+ async function applyStartupJitterIfNeeded() {
86
+ const instanceCount = countLocalBridgeInstances();
87
+ if (instanceCount < ipcGuardConfig.startupProcessThreshold) {
88
+ return;
89
+ }
90
+ const delayMs = Math.floor(Math.random() * ipcGuardConfig.startupDelayJitterMs);
91
+ logger(`[main] High startup concurrency detected (${instanceCount} processes). Applying jitter=${delayMs}ms.`);
92
+ await new Promise(resolve => setTimeout(resolve, delayMs));
76
93
  }
77
- // Generate a unique instance ID (survives PID reuse)
78
- const instanceId = randomUUID();
79
- // Acquire exclusive process lock (writes port + instanceId to lock file)
80
- await acquireLock(IPC_CONFIG.port, instanceId);
94
+ await applyStartupJitterIfNeeded();
95
+ for (let attempt = 0; attempt < MAX_STARTUP_ATTEMPTS; attempt++) {
96
+ // 1. Try to become Primary (non-throwing)
97
+ const lockAcquired = await tryAcquireLockSafe(IPC_CONFIG.port, instanceId);
98
+ if (lockAcquired) {
99
+ becamePrimary = true;
100
+ break;
101
+ }
102
+ // 2. Lock held by another process — try to connect as Proxy
103
+ const existingPrimary = checkExistingPrimary();
104
+ if (existingPrimary && existingPrimary.port > 0) {
105
+ for (let hc = 0; hc < HEALTH_CHECK_RETRIES; hc++) {
106
+ const healthy = await checkPrimaryHealth(existingPrimary.port);
107
+ if (healthy) {
108
+ logger(`[main] Primary is healthy (port=${existingPrimary.port}). Entering proxy mode.`);
109
+ await startProxyMode(existingPrimary.port); // never returns
110
+ }
111
+ if (hc < HEALTH_CHECK_RETRIES - 1) {
112
+ const jitter = Math.random() * HEALTH_CHECK_INTERVAL_MS;
113
+ await new Promise(r => setTimeout(r, HEALTH_CHECK_INTERVAL_MS + jitter));
114
+ }
115
+ }
116
+ logger(`[main] Primary (port=${existingPrimary.port}) not healthy after ${HEALTH_CHECK_RETRIES} retries.`);
117
+ }
118
+ // 3. Neither Primary nor Proxy — backoff with jitter and retry
119
+ if (attempt < MAX_STARTUP_ATTEMPTS - 1) {
120
+ const backoff = BASE_DELAY_MS * Math.pow(2, attempt);
121
+ const jitter = Math.random() * BASE_DELAY_MS;
122
+ const delay = backoff + jitter;
123
+ logger(`[main] Startup attempt ${attempt + 1}/${MAX_STARTUP_ATTEMPTS} failed. Retrying in ${Math.round(delay)}ms...`);
124
+ await new Promise(r => setTimeout(r, delay));
125
+ }
126
+ }
127
+ if (!becamePrimary) {
128
+ // Final fallback: one last proxy attempt before giving up
129
+ const existingPrimary = checkExistingPrimary();
130
+ if (existingPrimary && existingPrimary.port > 0) {
131
+ const healthy = await checkPrimaryHealth(existingPrimary.port);
132
+ if (healthy) {
133
+ logger(`[main] Final fallback: entering proxy mode (port=${existingPrimary.port}).`);
134
+ await startProxyMode(existingPrimary.port); // never returns
135
+ }
136
+ }
137
+ logger('[main] Failed to start as Primary or Proxy after all retries. Exiting.');
138
+ process.exit(1);
139
+ }
140
+ // ─── Primary mode ───
81
141
  // Start session cleanup timer
82
142
  const sessionConfig = getSessionConfig();
83
143
  const cleanupTimer = setInterval(async () => {
@@ -191,6 +251,75 @@ logDisclaimers();
191
251
  // ─── IPC HTTP server (for proxy clients) ───
192
252
  {
193
253
  const ipcTransports = {};
254
+ const ipcSessionLastActivity = new Map();
255
+ const initQueue = [];
256
+ let initializingCount = 0;
257
+ const getSessionLoad = () => Object.keys(ipcTransports).length + initializingCount;
258
+ const touchIpcSession = (sessionId) => {
259
+ ipcSessionLastActivity.set(sessionId, Date.now());
260
+ };
261
+ const cleanupIpcSession = (sessionId) => {
262
+ if (ipcTransports[sessionId]) {
263
+ delete ipcTransports[sessionId];
264
+ }
265
+ ipcSessionLastActivity.delete(sessionId);
266
+ drainInitQueue();
267
+ };
268
+ function sendJsonRpcError(res, code, message, id = null, statusCode = 400) {
269
+ res.writeHead(statusCode).end(JSON.stringify({
270
+ jsonrpc: '2.0',
271
+ error: { code, message },
272
+ id,
273
+ }));
274
+ }
275
+ function drainInitQueue() {
276
+ while (initQueue.length > 0 && getSessionLoad() < ipcGuardConfig.maxSessions) {
277
+ const waiter = initQueue.shift();
278
+ if (!waiter)
279
+ break;
280
+ clearTimeout(waiter.timeout);
281
+ waiter.resolve();
282
+ }
283
+ }
284
+ async function waitForInitCapacity() {
285
+ if (getSessionLoad() < ipcGuardConfig.maxSessions) {
286
+ return;
287
+ }
288
+ if (initQueue.length >= ipcGuardConfig.maxQueue) {
289
+ throw new Error('SERVER_QUEUE_FULL');
290
+ }
291
+ await new Promise((resolve, reject) => {
292
+ const timeout = setTimeout(() => {
293
+ const index = initQueue.findIndex(item => item.resolve === resolve);
294
+ if (index >= 0) {
295
+ initQueue.splice(index, 1);
296
+ }
297
+ reject(new Error('SERVER_BUSY_TIMEOUT'));
298
+ }, ipcGuardConfig.queueWaitTimeoutMs);
299
+ timeout.unref();
300
+ initQueue.push({ resolve, reject, timeout });
301
+ });
302
+ }
303
+ const idleCleanupTimer = setInterval(async () => {
304
+ const now = Date.now();
305
+ const staleSessionIds = Array.from(ipcSessionLastActivity.entries())
306
+ .filter(([, lastActivity]) => now - lastActivity > ipcGuardConfig.sessionIdleMs)
307
+ .map(([sessionId]) => sessionId);
308
+ if (staleSessionIds.length === 0) {
309
+ return;
310
+ }
311
+ logger(`[ipc] Closing ${staleSessionIds.length} idle session(s) older than ${ipcGuardConfig.sessionIdleMs}ms.`);
312
+ for (const staleSessionId of staleSessionIds) {
313
+ try {
314
+ await ipcTransports[staleSessionId]?.close();
315
+ }
316
+ catch {
317
+ // Ignore transport close errors and continue cleanup.
318
+ }
319
+ cleanupIpcSession(staleSessionId);
320
+ }
321
+ }, Math.max(10_000, Math.min(60_000, Math.floor(ipcGuardConfig.sessionIdleMs / 2))));
322
+ idleCleanupTimer.unref();
194
323
  const ipcServer = http.createServer(async (req, res) => {
195
324
  if (!req.url || !req.method) {
196
325
  res.writeHead(400).end();
@@ -199,7 +328,15 @@ logDisclaimers();
199
328
  const url = new URL(req.url, `http://${IPC_CONFIG.host}:${IPC_CONFIG.port}`);
200
329
  // Health endpoint
201
330
  if (url.pathname === IPC_CONFIG.healthPath) {
202
- res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ status: 'ok', pid: process.pid, version, instanceId }));
331
+ res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
332
+ status: 'ok',
333
+ pid: process.pid,
334
+ version,
335
+ instanceId,
336
+ activeSessions: Object.keys(ipcTransports).length,
337
+ queuedInitializations: initQueue.length,
338
+ sessionCapacity: ipcGuardConfig.maxSessions,
339
+ }));
203
340
  return;
204
341
  }
205
342
  // MCP endpoint
@@ -227,40 +364,54 @@ logDisclaimers();
227
364
  json = body ? JSON.parse(body) : null;
228
365
  }
229
366
  catch {
230
- res.writeHead(400).end(JSON.stringify({
231
- jsonrpc: '2.0',
232
- error: { code: -32700, message: 'Parse error' },
233
- id: null,
234
- }));
367
+ sendJsonRpcError(res, -32700, 'Parse error');
235
368
  return;
236
369
  }
237
370
  let ipcTransport;
238
371
  if (sessionId && ipcTransports[sessionId]) {
239
372
  ipcTransport = ipcTransports[sessionId];
373
+ touchIpcSession(sessionId);
240
374
  }
241
375
  else if (!sessionId && isInitializeRequest(json)) {
242
- ipcTransport = new StreamableHTTPServerTransport({
243
- sessionIdGenerator: () => randomUUID(),
244
- onsessioninitialized: newSessionId => {
245
- ipcTransports[newSessionId] = ipcTransport;
246
- },
247
- });
248
- ipcTransport.onclose = () => {
249
- if (ipcTransport?.sessionId) {
250
- delete ipcTransports[ipcTransport.sessionId];
376
+ try {
377
+ await waitForInitCapacity();
378
+ }
379
+ catch (error) {
380
+ const message = error instanceof Error ? error.message : 'SERVER_BUSY_TIMEOUT';
381
+ if (message === 'SERVER_QUEUE_FULL') {
382
+ sendJsonRpcError(res, -32002, message, null, 503);
251
383
  }
252
- };
253
- await server.connect(ipcTransport);
384
+ else {
385
+ sendJsonRpcError(res, -32001, message, null, 503);
386
+ }
387
+ return;
388
+ }
389
+ initializingCount++;
390
+ try {
391
+ ipcTransport = new StreamableHTTPServerTransport({
392
+ sessionIdGenerator: () => randomUUID(),
393
+ onsessioninitialized: newSessionId => {
394
+ ipcTransports[newSessionId] = ipcTransport;
395
+ touchIpcSession(newSessionId);
396
+ },
397
+ onsessionclosed: closedSessionId => {
398
+ cleanupIpcSession(closedSessionId);
399
+ },
400
+ });
401
+ ipcTransport.onclose = () => {
402
+ if (ipcTransport?.sessionId) {
403
+ cleanupIpcSession(ipcTransport.sessionId);
404
+ }
405
+ };
406
+ await server.connect(ipcTransport);
407
+ }
408
+ finally {
409
+ initializingCount = Math.max(0, initializingCount - 1);
410
+ drainInitQueue();
411
+ }
254
412
  }
255
413
  else {
256
- res.writeHead(400).end(JSON.stringify({
257
- jsonrpc: '2.0',
258
- error: {
259
- code: -32000,
260
- message: 'Bad Request: No valid session ID provided',
261
- },
262
- id: null,
263
- }));
414
+ sendJsonRpcError(res, -32000, 'Bad Request: No valid session ID provided');
264
415
  return;
265
416
  }
266
417
  try {
@@ -288,8 +439,12 @@ logDisclaimers();
288
439
  res.writeHead(400).end('Invalid or missing session ID');
289
440
  return;
290
441
  }
442
+ touchIpcSession(sessionId);
291
443
  try {
292
444
  await ipcTransports[sessionId].handleRequest(req, res);
445
+ if (req.method === 'DELETE') {
446
+ cleanupIpcSession(sessionId);
447
+ }
293
448
  }
294
449
  catch (error) {
295
450
  if (!res.headersSent) {
@@ -133,6 +133,30 @@ async function handleExistingLock() {
133
133
  logger(`[process-lock] Primary is alive (pid=${info.pid}, port=${info.port}). Cannot acquire lock.`);
134
134
  return false;
135
135
  }
136
+ /**
137
+ * Try to acquire lock without throwing on failure.
138
+ * Returns true if lock acquired, false if another process holds it.
139
+ * Used by the retry-based startup loop in main.ts.
140
+ */
141
+ export async function tryAcquireLockSafe(port, instanceId) {
142
+ const fd = tryCreateLock(port, instanceId);
143
+ if (fd !== null) {
144
+ lockFd = fd;
145
+ logger(`[process-lock] Lock acquired (pid=${process.pid}, port=${port}, instanceId=${instanceId.slice(0, 8)})`);
146
+ return true;
147
+ }
148
+ const canRetry = await handleExistingLock();
149
+ if (!canRetry) {
150
+ return false;
151
+ }
152
+ const fd2 = tryCreateLock(port, instanceId);
153
+ if (fd2 !== null) {
154
+ lockFd = fd2;
155
+ logger(`[process-lock] Lock acquired after cleanup (pid=${process.pid}, port=${port})`);
156
+ return true;
157
+ }
158
+ return false;
159
+ }
136
160
  /**
137
161
  * Acquire an exclusive process lock. Call once at startup for Primary mode.
138
162
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrome-ai-bridge",
3
- "version": "2.3.1",
3
+ "version": "2.3.3",
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,48 +2,24 @@
2
2
  /**
3
3
  * CLI Entry Point for chrome-ai-bridge
4
4
  *
5
- * This is the entry point when users run:
6
- * npx chrome-ai-bridge
7
- * chrome-ai-bridge (if globally installed)
8
- *
9
- * Launches the MCP server with browser globals mock:
10
- * - Loads browser-globals-mock.mjs BEFORE main.js
11
- * - Ensures chrome-devtools-frontend modules work in Node.js
12
- * - Simple execution: no wrapper, no hot-reload
5
+ * This entrypoint runs the MCP server in-process to avoid spawning an extra
6
+ * wrapper process per client (important for multi-pane usage).
13
7
  */
14
8
 
15
- import {spawn} from 'node:child_process';
16
- import process from 'node:process';
17
- import {fileURLToPath} from 'node:url';
18
9
  import path from 'node:path';
10
+ import process from 'node:process';
11
+ import {fileURLToPath, pathToFileURL} from 'node:url';
19
12
 
20
- // Resolve paths
21
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
14
  const mockPath = path.join(__dirname, 'browser-globals-mock.mjs');
23
15
  const mainPath = path.join(__dirname, '..', 'build', 'src', 'main.js');
24
16
 
25
- // Launch MCP server with --import flag
26
- const child = spawn(
27
- process.execPath,
28
- [
29
- '--import',
30
- mockPath,
31
- mainPath,
32
- ...process.argv.slice(2), // Forward CLI arguments
33
- ],
34
- {
35
- stdio: 'inherit',
36
- env: process.env,
37
- },
38
- );
39
-
40
- child.on('exit', (code, signal) => {
41
- if (signal) {
42
- process.exit(1);
43
- }
44
- process.exit(code ?? 0);
45
- });
46
-
47
- // Forward signals
48
- process.on('SIGTERM', () => child?.kill('SIGTERM'));
49
- process.on('SIGINT', () => child?.kill('SIGINT'));
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
+ }