chrome-ai-bridge 2.0.19 → 2.3.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,214 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+ /**
7
+ * Session Manager for Agent Teams support.
8
+ *
9
+ * Manages per-agent sessions with:
10
+ * - V2 session format with agent isolation
11
+ * - Automatic migration from V1 format
12
+ * - TTL-based cleanup of stale sessions
13
+ */
14
+ import fs from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ import { getSessionConfig } from '../config.js';
17
+ import { getAgentId, hasAgentId } from './agent-context.js';
18
+ /**
19
+ * Get the session file path.
20
+ * Uses project-local .local/ directory.
21
+ */
22
+ function getSessionPath() {
23
+ return path.join(process.cwd(), '.local', 'chrome-ai-bridge', 'sessions.json');
24
+ }
25
+ /**
26
+ * Load raw sessions from file (any version).
27
+ */
28
+ async function loadRawSessions() {
29
+ const sessionPath = getSessionPath();
30
+ try {
31
+ const data = await fs.readFile(sessionPath, 'utf-8');
32
+ return JSON.parse(data);
33
+ }
34
+ catch (err) {
35
+ const code = err.code;
36
+ if (code !== 'ENOENT') {
37
+ // File exists but is corrupted or unreadable
38
+ console.error(`[session-manager] Failed to load ${sessionPath}: ${err instanceof Error ? err.message : String(err)}`);
39
+ }
40
+ return { projects: {} };
41
+ }
42
+ }
43
+ /**
44
+ * Save sessions to file.
45
+ */
46
+ async function saveRawSessions(sessions) {
47
+ const targetPath = getSessionPath();
48
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
49
+ await fs.writeFile(targetPath, JSON.stringify(sessions, null, 2), 'utf-8');
50
+ }
51
+ /**
52
+ * Check if sessions are V2 format.
53
+ */
54
+ function isV2Format(sessions) {
55
+ return 'version' in sessions && sessions.version === 2;
56
+ }
57
+ /**
58
+ * Migrate V1 sessions to V2 format.
59
+ * Creates a "legacy-default" agent from V1 project data.
60
+ */
61
+ async function migrateToV2(v1) {
62
+ const config = getSessionConfig();
63
+ const v2 = {
64
+ version: 2,
65
+ agents: {},
66
+ config: {
67
+ sessionTtlMinutes: config.sessionTtlMinutes,
68
+ maxAgents: config.maxAgents,
69
+ },
70
+ };
71
+ // Migrate each project as a legacy agent
72
+ for (const [projectName, projectSessions] of Object.entries(v1.projects)) {
73
+ // Sanitize project name for use as agent ID key
74
+ const safeName = projectName.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
75
+ const agentId = `legacy-${safeName}`;
76
+ v2.agents[agentId] = {
77
+ lastAccess: new Date().toISOString(),
78
+ chatgpt: projectSessions.chatgpt || null,
79
+ gemini: projectSessions.gemini || null,
80
+ };
81
+ }
82
+ console.error(`[session-manager] Migrated ${Object.keys(v1.projects).length} projects to V2 format`);
83
+ return v2;
84
+ }
85
+ /**
86
+ * Load sessions, auto-migrating if needed.
87
+ */
88
+ export async function loadSessions() {
89
+ const raw = await loadRawSessions();
90
+ if (isV2Format(raw)) {
91
+ return raw;
92
+ }
93
+ // Migrate V1 to V2
94
+ const v2 = await migrateToV2(raw);
95
+ await saveRawSessions(v2);
96
+ return v2;
97
+ }
98
+ /**
99
+ * Get or create session for the current agent.
100
+ * Always updates lastAccess to keep session alive for TTL.
101
+ */
102
+ export async function getAgentSession() {
103
+ const agentId = hasAgentId() ? getAgentId() : 'default';
104
+ const sessions = await loadSessions();
105
+ let needsSave = false;
106
+ if (!sessions.agents[agentId]) {
107
+ sessions.agents[agentId] = {
108
+ lastAccess: new Date().toISOString(),
109
+ chatgpt: null,
110
+ gemini: null,
111
+ };
112
+ needsSave = true;
113
+ }
114
+ else {
115
+ // Update lastAccess for existing sessions (keeps TTL alive)
116
+ sessions.agents[agentId].lastAccess = new Date().toISOString();
117
+ needsSave = true;
118
+ }
119
+ if (needsSave) {
120
+ await saveRawSessions(sessions);
121
+ }
122
+ return sessions.agents[agentId];
123
+ }
124
+ /**
125
+ * Save session for the current agent.
126
+ */
127
+ export async function saveAgentSession(kind, url, tabId) {
128
+ const agentId = hasAgentId() ? getAgentId() : 'default';
129
+ const sessions = await loadSessions();
130
+ if (!sessions.agents[agentId]) {
131
+ sessions.agents[agentId] = {
132
+ lastAccess: new Date().toISOString(),
133
+ chatgpt: null,
134
+ gemini: null,
135
+ };
136
+ }
137
+ const session = sessions.agents[agentId];
138
+ session.lastAccess = new Date().toISOString();
139
+ session[kind] = {
140
+ url,
141
+ tabId,
142
+ lastUsed: new Date().toISOString(),
143
+ };
144
+ await saveRawSessions(sessions);
145
+ }
146
+ /**
147
+ * Clear session for the current agent.
148
+ */
149
+ export async function clearAgentSession(kind) {
150
+ const agentId = hasAgentId() ? getAgentId() : 'default';
151
+ const sessions = await loadSessions();
152
+ if (sessions.agents[agentId]) {
153
+ sessions.agents[agentId][kind] = null;
154
+ await saveRawSessions(sessions);
155
+ console.error(`[session-manager] Cleared ${kind} session for agent: ${agentId}`);
156
+ }
157
+ }
158
+ /**
159
+ * Remove stale sessions that exceed TTL.
160
+ *
161
+ * @returns Number of agents removed
162
+ */
163
+ export async function cleanupStaleSessions() {
164
+ const config = getSessionConfig();
165
+ const sessions = await loadSessions();
166
+ const now = Date.now();
167
+ const ttlMs = config.sessionTtlMinutes * 60 * 1000;
168
+ let removedCount = 0;
169
+ for (const [agentId, session] of Object.entries(sessions.agents)) {
170
+ const lastAccess = new Date(session.lastAccess).getTime();
171
+ const age = now - lastAccess;
172
+ if (age > ttlMs) {
173
+ delete sessions.agents[agentId];
174
+ removedCount++;
175
+ console.error(`[session-manager] Removed stale agent: ${agentId} (${Math.round(age / 60000)}min old)`);
176
+ }
177
+ }
178
+ // Enforce maxAgents limit
179
+ const agentIds = Object.keys(sessions.agents);
180
+ if (agentIds.length > config.maxAgents) {
181
+ // Sort by lastAccess (oldest first)
182
+ const sorted = agentIds.sort((a, b) => {
183
+ const aTime = new Date(sessions.agents[a].lastAccess).getTime();
184
+ const bTime = new Date(sessions.agents[b].lastAccess).getTime();
185
+ return aTime - bTime;
186
+ });
187
+ // Remove oldest until under limit
188
+ const toRemove = sorted.slice(0, agentIds.length - config.maxAgents);
189
+ for (const agentId of toRemove) {
190
+ delete sessions.agents[agentId];
191
+ removedCount++;
192
+ console.error(`[session-manager] Removed agent (over limit): ${agentId}`);
193
+ }
194
+ }
195
+ if (removedCount > 0) {
196
+ await saveRawSessions(sessions);
197
+ }
198
+ return removedCount;
199
+ }
200
+ /**
201
+ * Get preferred session (URL and tabId) for the current agent.
202
+ * Used by fast-chat.ts for connection reuse.
203
+ */
204
+ export async function getPreferredSessionV2(kind) {
205
+ const session = await getAgentSession();
206
+ const entry = session[kind];
207
+ if (entry && typeof entry.url === 'string' && entry.url.length > 0) {
208
+ return {
209
+ url: entry.url,
210
+ tabId: typeof entry.tabId === 'number' ? entry.tabId : undefined,
211
+ };
212
+ }
213
+ return { url: null };
214
+ }
package/build/src/main.js CHANGED
@@ -8,6 +8,9 @@
8
8
  *
9
9
  * This MCP server provides ChatGPT/Gemini integration via Chrome extension.
10
10
  * Puppeteer has been removed - all browser interaction is via WebSocket relay.
11
+ *
12
+ * Multi-client: The first instance becomes Primary (stdio + IPC HTTP).
13
+ * Subsequent instances become Proxies that forward stdio to the Primary via HTTP.
11
14
  */
12
15
  import assert from 'node:assert';
13
16
  import fs from 'node:fs';
@@ -26,6 +29,11 @@ import { ToolRegistry, PluginLoader } from './plugin-api.js';
26
29
  import { registerOptionalTools, WEB_LLM_TOOLS_INFO, } from './tools/optional-tools.js';
27
30
  import { getFastContext } from './fast-cdp/fast-context.js';
28
31
  import { cleanupAllConnections } from './fast-cdp/fast-chat.js';
32
+ import { generateAgentId, setAgentId } from './fast-cdp/agent-context.js';
33
+ 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';
36
+ import { checkPrimaryHealth, startProxyMode } from './stdio-http-proxy.js';
29
37
  function readPackageJson() {
30
38
  const currentDir = import.meta.dirname;
31
39
  const packageJsonPath = path.join(currentDir, '..', '..', 'package.json');
@@ -45,6 +53,45 @@ const version = readPackageJson().version ?? 'unknown';
45
53
  export const args = parseArguments(version);
46
54
  const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined;
47
55
  logger(`Starting Chrome AI Bridge v${version} (Extension-only mode)`);
56
+ // Initialize agent ID for Agent Teams support
57
+ const agentId = generateAgentId();
58
+ 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
68
+ }
69
+ logger(`[main] Primary (port=${existingPrimary.port}) not healthy. Starting as Primary.`);
70
+ }
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)`);
76
+ }
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);
81
+ // Start session cleanup timer
82
+ const sessionConfig = getSessionConfig();
83
+ const cleanupTimer = setInterval(async () => {
84
+ try {
85
+ const removed = await cleanupStaleSessions();
86
+ if (removed > 0) {
87
+ logger(`[session] Cleaned up ${removed} stale sessions`);
88
+ }
89
+ }
90
+ catch (error) {
91
+ logger(`[session] Cleanup error: ${error instanceof Error ? error.message : String(error)}`);
92
+ }
93
+ }, sessionConfig.cleanupIntervalMinutes * 60 * 1000);
94
+ cleanupTimer.unref(); // Don't keep process alive for cleanup
48
95
  const server = new McpServer({
49
96
  name: 'chrome-ai-bridge',
50
97
  title: 'Chrome AI Bridge - ChatGPT/Gemini via Extension',
@@ -141,6 +188,145 @@ const transport = new StdioServerTransport();
141
188
  await server.connect(transport);
142
189
  logger('Chrome AI Bridge MCP Server connected');
143
190
  logDisclaimers();
191
+ // ─── IPC HTTP server (for proxy clients) ───
192
+ {
193
+ const ipcTransports = {};
194
+ const ipcServer = http.createServer(async (req, res) => {
195
+ if (!req.url || !req.method) {
196
+ res.writeHead(400).end();
197
+ return;
198
+ }
199
+ const url = new URL(req.url, `http://${IPC_CONFIG.host}:${IPC_CONFIG.port}`);
200
+ // Health endpoint
201
+ if (url.pathname === IPC_CONFIG.healthPath) {
202
+ res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ status: 'ok', pid: process.pid, version, instanceId }));
203
+ return;
204
+ }
205
+ // MCP endpoint
206
+ if (url.pathname !== IPC_CONFIG.mcpPath) {
207
+ res.writeHead(404).end();
208
+ return;
209
+ }
210
+ // CORS for local usage
211
+ res.setHeader('Access-Control-Allow-Origin', '*');
212
+ res.setHeader('Access-Control-Allow-Headers', 'content-type,mcp-session-id');
213
+ res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id');
214
+ if (req.method === 'OPTIONS') {
215
+ res.writeHead(204).end();
216
+ return;
217
+ }
218
+ const sessionId = req.headers['mcp-session-id'];
219
+ if (req.method === 'POST') {
220
+ let body = '';
221
+ req.on('data', chunk => {
222
+ body += chunk;
223
+ });
224
+ req.on('end', async () => {
225
+ let json;
226
+ try {
227
+ json = body ? JSON.parse(body) : null;
228
+ }
229
+ catch {
230
+ res.writeHead(400).end(JSON.stringify({
231
+ jsonrpc: '2.0',
232
+ error: { code: -32700, message: 'Parse error' },
233
+ id: null,
234
+ }));
235
+ return;
236
+ }
237
+ let ipcTransport;
238
+ if (sessionId && ipcTransports[sessionId]) {
239
+ ipcTransport = ipcTransports[sessionId];
240
+ }
241
+ 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];
251
+ }
252
+ };
253
+ await server.connect(ipcTransport);
254
+ }
255
+ 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
+ }));
264
+ return;
265
+ }
266
+ try {
267
+ await ipcTransport.handleRequest(req, res, json);
268
+ }
269
+ catch (error) {
270
+ if (!res.headersSent) {
271
+ res.writeHead(500).end(JSON.stringify({
272
+ jsonrpc: '2.0',
273
+ error: {
274
+ code: -32603,
275
+ message: error instanceof Error
276
+ ? error.message
277
+ : String(error),
278
+ },
279
+ id: null,
280
+ }));
281
+ }
282
+ }
283
+ });
284
+ return;
285
+ }
286
+ if (req.method === 'GET' || req.method === 'DELETE') {
287
+ if (!sessionId || !ipcTransports[sessionId]) {
288
+ res.writeHead(400).end('Invalid or missing session ID');
289
+ return;
290
+ }
291
+ try {
292
+ await ipcTransports[sessionId].handleRequest(req, res);
293
+ }
294
+ catch (error) {
295
+ if (!res.headersSent) {
296
+ res.writeHead(500).end(JSON.stringify({
297
+ jsonrpc: '2.0',
298
+ error: {
299
+ code: -32603,
300
+ message: error instanceof Error ? error.message : String(error),
301
+ },
302
+ id: null,
303
+ }));
304
+ }
305
+ }
306
+ return;
307
+ }
308
+ res.writeHead(405).end();
309
+ });
310
+ function onListening() {
311
+ const addr = ipcServer.address();
312
+ const actualPort = typeof addr === 'object' && addr ? addr.port : IPC_CONFIG.port;
313
+ if (actualPort !== IPC_CONFIG.port) {
314
+ logger(`[ipc] Configured port ${IPC_CONFIG.port} was unavailable. Using dynamic port ${actualPort}.`);
315
+ updateLockPort(actualPort);
316
+ }
317
+ logger(`[ipc] IPC HTTP listening on http://${IPC_CONFIG.host}:${actualPort} (health: ${IPC_CONFIG.healthPath}, mcp: ${IPC_CONFIG.mcpPath})`);
318
+ }
319
+ ipcServer.on('error', (err) => {
320
+ if (err.code === 'EADDRINUSE') {
321
+ logger(`[ipc] Port ${IPC_CONFIG.port} in use. Retrying with dynamic port...`);
322
+ ipcServer.listen(0, IPC_CONFIG.host, onListening);
323
+ }
324
+ else {
325
+ logger(`[ipc] IPC server error: ${err.message}`);
326
+ }
327
+ });
328
+ ipcServer.listen(IPC_CONFIG.port, IPC_CONFIG.host, onListening);
329
+ }
144
330
  // Graceful shutdown handler with timeout
145
331
  // Based on review: タイムアウト必須、強制終了タイマー必要
146
332
  let isShuttingDown = false;
@@ -159,6 +345,8 @@ async function shutdown(reason) {
159
345
  return;
160
346
  isShuttingDown = true;
161
347
  logger(`Shutting down: ${reason}`);
348
+ // Release lock early so a new instance can start immediately
349
+ releaseLock();
162
350
  // Force exit timer (5 seconds) - prevents zombie if cleanup hangs
163
351
  const forceExitTimer = setTimeout(() => {
164
352
  logger('Graceful shutdown timed out. Forcing exit.');
@@ -187,10 +375,12 @@ process.on('SIGTERM', () => shutdown('SIGTERM'));
187
375
  process.on('SIGINT', () => shutdown('SIGINT'));
188
376
  // Keep beforeExit for edge cases where stdin doesn't close
189
377
  process.on('beforeExit', () => {
378
+ releaseLock();
190
379
  if (logFile) {
191
380
  logFile.close();
192
381
  }
193
382
  });
383
+ // ─── Optional: User-configured external HTTP server (MCP_HTTP_PORT) ───
194
384
  const httpPortRaw = process.env.MCP_HTTP_PORT;
195
385
  if (httpPortRaw) {
196
386
  const httpPort = Number(httpPortRaw);