chrome-ai-bridge 2.4.0 → 2.5.1
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.
- package/README.md +28 -40
- package/build/extension/README.md +10 -10
- package/build/extension/background.mjs +94 -26
- package/build/extension/manifest.json +2 -2
- package/build/extension/relay-server.ts +16 -2
- package/build/extension/ui/connect.html +1 -1
- package/build/extension/ui/connect.js +46 -10
- package/build/src/cli.js +6 -1
- package/build/src/config.js +2 -4
- package/build/src/extension/relay-server.js +20 -2
- package/build/src/fast-cdp/agent-context.js +2 -2
- package/build/src/fast-cdp/{mcp-logger.js → debug-logger.js} +11 -11
- package/build/src/fast-cdp/extension-raw.js +51 -5
- package/build/src/fast-cdp/fast-chat.js +124 -92
- package/build/src/logger.js +3 -3
- package/build/src/main.js +104 -568
- package/build/src/plugin-api.js +1 -1
- package/build/src/runtime-scope.js +1 -1
- package/build/src/tools/ai-helpers.js +72 -17
- package/build/src/tools/chatgpt-gemini-web.js +1 -1
- package/build/src/tools/chatgpt-web.js +7 -7
- package/build/src/tools/gemini-web.js +10 -22
- package/build/src/tools/optional-tools.js +8 -5
- package/package.json +17 -18
- package/scripts/cab +200 -0
- package/scripts/cli.mjs +1 -1
- package/build/src/McpResponse.js +0 -60
- package/build/src/stdio-http-proxy.js +0 -157
package/build/src/main.js
CHANGED
|
@@ -4,13 +4,11 @@
|
|
|
4
4
|
* SPDX-License-Identifier: Apache-2.0
|
|
5
5
|
*/
|
|
6
6
|
/**
|
|
7
|
-
* Chrome AI Bridge -
|
|
7
|
+
* Chrome AI Bridge - Daemon Mode (v3.0.0)
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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.
|
|
9
|
+
* Pure CLI/REST daemon for ChatGPT/Gemini integration via Chrome extension.
|
|
10
|
+
* Exposes /health and /api/ask endpoints.
|
|
11
|
+
* All interaction is via REST API or cab CLI.
|
|
14
12
|
*/
|
|
15
13
|
import assert from 'node:assert';
|
|
16
14
|
import fs from 'node:fs';
|
|
@@ -18,23 +16,17 @@ import path from 'node:path';
|
|
|
18
16
|
import { execFileSync } from 'node:child_process';
|
|
19
17
|
import { randomUUID } from 'node:crypto';
|
|
20
18
|
import http from 'node:http';
|
|
21
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
22
|
-
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
23
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
24
|
-
import { isInitializeRequest, SetLevelRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
25
19
|
import { parseArguments } from './cli.js';
|
|
26
20
|
import { logger, saveLogsToFile } from './logger.js';
|
|
27
|
-
import { McpResponse } from './McpResponse.js';
|
|
28
21
|
import { Mutex } from './Mutex.js';
|
|
29
|
-
import { ToolRegistry
|
|
22
|
+
import { ToolRegistry } from './plugin-api.js';
|
|
30
23
|
import { registerOptionalTools, WEB_LLM_TOOLS_INFO, } from './tools/optional-tools.js';
|
|
31
|
-
import { getFastContext } from './fast-cdp/fast-context.js';
|
|
32
24
|
import { cleanupAllConnections } from './fast-cdp/fast-chat.js';
|
|
25
|
+
import { askAI } from './tools/ai-helpers.js';
|
|
33
26
|
import { generateAgentId, setAgentId } from './fast-cdp/agent-context.js';
|
|
34
27
|
import { cleanupStaleSessions } from './fast-cdp/session-manager.js';
|
|
35
28
|
import { getIpcGuardConfig, getSessionConfig, IPC_CONFIG } from './config.js';
|
|
36
|
-
import { releaseLock, tryAcquireLockSafe,
|
|
37
|
-
import { checkPrimaryHealth, startProxyMode } from './stdio-http-proxy.js';
|
|
29
|
+
import { releaseLock, tryAcquireLockSafe, updateLockPort, getLockNamespace, cleanupOrphanBridgeProcesses, } from './process-lock.js';
|
|
38
30
|
function readPackageJson() {
|
|
39
31
|
const currentDir = import.meta.dirname;
|
|
40
32
|
const packageJsonPath = path.join(currentDir, '..', '..', 'package.json');
|
|
@@ -53,26 +45,18 @@ function readPackageJson() {
|
|
|
53
45
|
const version = readPackageJson().version ?? 'unknown';
|
|
54
46
|
export const args = parseArguments(version);
|
|
55
47
|
const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined;
|
|
56
|
-
logger(`Starting Chrome AI Bridge v${version} (
|
|
48
|
+
logger(`Starting Chrome AI Bridge v${version} (daemon mode)`);
|
|
57
49
|
logger(`[main] Runtime lock namespace: ${getLockNamespace()}`);
|
|
58
50
|
// Initialize agent ID for Agent Teams support
|
|
59
51
|
const agentId = generateAgentId();
|
|
60
52
|
setAgentId(agentId);
|
|
61
|
-
// ───
|
|
62
|
-
//
|
|
63
|
-
// Each process tries to become Primary or fall back to Proxy mode,
|
|
64
|
-
// with exponential backoff + jitter to avoid thundering herd.
|
|
53
|
+
// ─── Primary lock acquisition ───
|
|
54
|
+
// Only one daemon instance per namespace. If lock can't be acquired, exit.
|
|
65
55
|
const MAX_STARTUP_ATTEMPTS = 5;
|
|
66
56
|
const BASE_DELAY_MS = 300;
|
|
67
|
-
const HEALTH_CHECK_RETRIES = 3;
|
|
68
|
-
const HEALTH_CHECK_INTERVAL_MS = 500;
|
|
69
|
-
const PRIMARY_SELF_HEAL_MIN_AGE_MS = Number(process.env.CAI_PRIMARY_SELF_HEAL_MIN_AGE_MS || '20000');
|
|
70
57
|
const ipcGuardConfig = getIpcGuardConfig();
|
|
71
58
|
const instanceId = randomUUID();
|
|
72
59
|
let becamePrimary = false;
|
|
73
|
-
let attemptedPrimarySelfHeal = false;
|
|
74
|
-
let stdinClosed = false;
|
|
75
|
-
let getActiveIpcSessionCount = () => 0;
|
|
76
60
|
function countLocalBridgeInstances() {
|
|
77
61
|
try {
|
|
78
62
|
const output = execFileSync('ps', ['-axo', 'command'], {
|
|
@@ -98,52 +82,12 @@ async function applyStartupJitterIfNeeded() {
|
|
|
98
82
|
}
|
|
99
83
|
await applyStartupJitterIfNeeded();
|
|
100
84
|
for (let attempt = 0; attempt < MAX_STARTUP_ATTEMPTS; attempt++) {
|
|
101
|
-
// 1. Try to become Primary (non-throwing)
|
|
102
85
|
const lockAcquired = await tryAcquireLockSafe(IPC_CONFIG.port, instanceId);
|
|
103
86
|
if (lockAcquired) {
|
|
104
87
|
becamePrimary = true;
|
|
105
88
|
break;
|
|
106
89
|
}
|
|
107
|
-
//
|
|
108
|
-
const existingPrimary = checkExistingPrimary();
|
|
109
|
-
if (existingPrimary && existingPrimary.port > 0) {
|
|
110
|
-
for (let hc = 0; hc < HEALTH_CHECK_RETRIES; hc++) {
|
|
111
|
-
const healthy = await checkPrimaryHealth(existingPrimary.port);
|
|
112
|
-
if (healthy) {
|
|
113
|
-
logger(`[main] Primary is healthy (port=${existingPrimary.port}). Entering proxy mode.`);
|
|
114
|
-
await startProxyMode(existingPrimary.port); // never returns
|
|
115
|
-
}
|
|
116
|
-
if (hc < HEALTH_CHECK_RETRIES - 1) {
|
|
117
|
-
const jitter = Math.random() * HEALTH_CHECK_INTERVAL_MS;
|
|
118
|
-
await new Promise(r => setTimeout(r, HEALTH_CHECK_INTERVAL_MS + jitter));
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
logger(`[main] Primary (port=${existingPrimary.port}) not healthy after ${HEALTH_CHECK_RETRIES} retries.`);
|
|
122
|
-
const parsedStartedAt = existingPrimary.startedAt
|
|
123
|
-
? Date.parse(existingPrimary.startedAt)
|
|
124
|
-
: NaN;
|
|
125
|
-
const primaryAgeMs = Number.isFinite(parsedStartedAt)
|
|
126
|
-
? Math.max(0, Date.now() - parsedStartedAt)
|
|
127
|
-
: null;
|
|
128
|
-
if (!attemptedPrimarySelfHeal &&
|
|
129
|
-
existingPrimary.pid > 0 &&
|
|
130
|
-
(primaryAgeMs === null || primaryAgeMs >= PRIMARY_SELF_HEAL_MIN_AGE_MS)) {
|
|
131
|
-
attemptedPrimarySelfHeal = true;
|
|
132
|
-
logger(`[main] Attempting self-heal for unhealthy primary pid=${existingPrimary.pid} ageMs=${primaryAgeMs ?? 'unknown'}.`);
|
|
133
|
-
const terminated = await terminatePrimaryProcess(existingPrimary.pid);
|
|
134
|
-
if (terminated) {
|
|
135
|
-
logger('[main] Self-heal terminated unhealthy primary. Retrying startup immediately.');
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
logger('[main] Self-heal could not terminate unhealthy primary.');
|
|
139
|
-
}
|
|
140
|
-
else if (!attemptedPrimarySelfHeal &&
|
|
141
|
-
primaryAgeMs !== null &&
|
|
142
|
-
primaryAgeMs < PRIMARY_SELF_HEAL_MIN_AGE_MS) {
|
|
143
|
-
logger(`[main] Primary is unhealthy but still young (ageMs=${primaryAgeMs} < ${PRIMARY_SELF_HEAL_MIN_AGE_MS}). Skipping self-heal this round.`);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
// 3. Neither Primary nor Proxy — backoff with jitter and retry
|
|
90
|
+
// Lock held by another process — backoff with jitter and retry
|
|
147
91
|
if (attempt < MAX_STARTUP_ATTEMPTS - 1) {
|
|
148
92
|
const backoff = BASE_DELAY_MS * Math.pow(2, attempt);
|
|
149
93
|
const jitter = Math.random() * BASE_DELAY_MS;
|
|
@@ -153,20 +97,11 @@ for (let attempt = 0; attempt < MAX_STARTUP_ATTEMPTS; attempt++) {
|
|
|
153
97
|
}
|
|
154
98
|
}
|
|
155
99
|
if (!becamePrimary) {
|
|
156
|
-
|
|
157
|
-
const existingPrimary = checkExistingPrimary();
|
|
158
|
-
if (existingPrimary && existingPrimary.port > 0) {
|
|
159
|
-
const healthy = await checkPrimaryHealth(existingPrimary.port);
|
|
160
|
-
if (healthy) {
|
|
161
|
-
logger(`[main] Final fallback: entering proxy mode (port=${existingPrimary.port}).`);
|
|
162
|
-
await startProxyMode(existingPrimary.port); // never returns
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
logger('[main] Failed to start as Primary or Proxy after all retries. Exiting.');
|
|
100
|
+
logger('[main] Failed to acquire primary lock after all retries. Another instance is running. Exiting.');
|
|
166
101
|
process.exit(1);
|
|
167
102
|
}
|
|
168
103
|
// ─── Primary mode ───
|
|
169
|
-
// Idle auto-exit tracking
|
|
104
|
+
// Idle auto-exit tracking
|
|
170
105
|
let primaryLastActivityAt = Date.now();
|
|
171
106
|
const touchPrimaryActivity = () => {
|
|
172
107
|
primaryLastActivityAt = Date.now();
|
|
@@ -184,15 +119,7 @@ const cleanupTimer = setInterval(async () => {
|
|
|
184
119
|
logger(`[session] Cleanup error: ${error instanceof Error ? error.message : String(error)}`);
|
|
185
120
|
}
|
|
186
121
|
}, sessionConfig.cleanupIntervalMinutes * 60 * 1000);
|
|
187
|
-
cleanupTimer.unref();
|
|
188
|
-
const server = new McpServer({
|
|
189
|
-
name: 'chrome-ai-bridge',
|
|
190
|
-
title: 'Chrome AI Bridge - ChatGPT/Gemini via Extension',
|
|
191
|
-
version,
|
|
192
|
-
}, { capabilities: { logging: {} } });
|
|
193
|
-
server.server.setRequestHandler(SetLevelRequestSchema, () => {
|
|
194
|
-
return {};
|
|
195
|
-
});
|
|
122
|
+
cleanupTimer.unref();
|
|
196
123
|
const logDisclaimers = () => {
|
|
197
124
|
console.error(`chrome-ai-bridge connects to ChatGPT/Gemini via Chrome extension.
|
|
198
125
|
Make sure the chrome-ai-bridge extension is installed and Chrome is running.
|
|
@@ -230,368 +157,128 @@ async function maybeRunToolSelfCleanup() {
|
|
|
230
157
|
});
|
|
231
158
|
await toolSelfCleanupInFlight;
|
|
232
159
|
}
|
|
233
|
-
|
|
234
|
-
server.registerTool(tool.name, {
|
|
235
|
-
description: tool.description,
|
|
236
|
-
inputSchema: tool.schema,
|
|
237
|
-
annotations: tool.annotations,
|
|
238
|
-
}, async (params) => {
|
|
239
|
-
touchPrimaryActivity();
|
|
240
|
-
await maybeRunToolSelfCleanup();
|
|
241
|
-
const guard = await toolMutex.acquire();
|
|
242
|
-
try {
|
|
243
|
-
logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`);
|
|
244
|
-
// All tools use FastContext (extension-based, no Puppeteer)
|
|
245
|
-
const context = getFastContext();
|
|
246
|
-
const response = new McpResponse();
|
|
247
|
-
await tool.handler({
|
|
248
|
-
params,
|
|
249
|
-
}, response, context);
|
|
250
|
-
try {
|
|
251
|
-
const content = await response.handle(tool.name, context);
|
|
252
|
-
return {
|
|
253
|
-
content,
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
catch (error) {
|
|
257
|
-
const errorText = error instanceof Error ? error.message : String(error);
|
|
258
|
-
// Detect extension connection error
|
|
259
|
-
if (errorText.includes('Extension connection') ||
|
|
260
|
-
errorText.includes('timeout') ||
|
|
261
|
-
errorText.includes('disconnected')) {
|
|
262
|
-
return {
|
|
263
|
-
content: [
|
|
264
|
-
{
|
|
265
|
-
type: 'text',
|
|
266
|
-
text: `Extension connection lost or not available.\n\nMake sure:\n1. Chrome is running\n2. The chrome-ai-bridge extension is installed\n3. The extension is enabled\n\nError: ${errorText}`,
|
|
267
|
-
},
|
|
268
|
-
],
|
|
269
|
-
isError: true,
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
return {
|
|
273
|
-
content: [
|
|
274
|
-
{
|
|
275
|
-
type: 'text',
|
|
276
|
-
text: errorText,
|
|
277
|
-
},
|
|
278
|
-
],
|
|
279
|
-
isError: true,
|
|
280
|
-
};
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
finally {
|
|
284
|
-
guard.dispose();
|
|
285
|
-
}
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
// Use ToolRegistry for plugin architecture
|
|
160
|
+
// Register optional tools (for tool metadata / future use)
|
|
289
161
|
const toolRegistry = new ToolRegistry();
|
|
290
|
-
// Register optional tools (ChatGPT/Gemini via extension)
|
|
291
|
-
// Note: Core tools (Puppeteer-based) are no longer available in v2.0
|
|
292
162
|
const optionalCount = registerOptionalTools(toolRegistry);
|
|
293
163
|
if (optionalCount > 0) {
|
|
294
164
|
logger(`[tools] ${WEB_LLM_TOOLS_INFO.disclaimer}`);
|
|
295
165
|
}
|
|
296
|
-
// Load external plugins from MCP_PLUGINS environment variable
|
|
297
|
-
const pluginList = process.env.MCP_PLUGINS;
|
|
298
|
-
if (pluginList) {
|
|
299
|
-
const pluginLoader = new PluginLoader(toolRegistry, logger);
|
|
300
|
-
const { loaded, failed } = await pluginLoader.loadFromList(pluginList);
|
|
301
|
-
if (loaded.length > 0) {
|
|
302
|
-
logger(`[plugins] Successfully loaded: ${loaded.join(', ')}`);
|
|
303
|
-
}
|
|
304
|
-
if (failed.length > 0) {
|
|
305
|
-
logger(`[plugins] Failed to load: ${failed.join(', ')}`);
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
// Register all tools with MCP server
|
|
309
|
-
for (const tool of toolRegistry.getAll()) {
|
|
310
|
-
registerTool(tool);
|
|
311
|
-
}
|
|
312
166
|
logger(`[tools] Total registered: ${toolRegistry.size} tools`);
|
|
313
|
-
|
|
314
|
-
await server.connect(transport);
|
|
315
|
-
logger('Chrome AI Bridge MCP Server connected');
|
|
167
|
+
logger('Chrome AI Bridge starting in daemon mode (HTTP-only)');
|
|
316
168
|
logDisclaimers();
|
|
317
|
-
// ───
|
|
318
|
-
{
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
let initializingCount = 0;
|
|
323
|
-
const getActiveSessionCount = () => Object.keys(ipcTransports).length;
|
|
324
|
-
getActiveIpcSessionCount = getActiveSessionCount;
|
|
325
|
-
const getSessionLoad = () => getActiveSessionCount() + initializingCount;
|
|
326
|
-
const touchIpcSession = (sessionId) => {
|
|
327
|
-
ipcSessionLastActivity.set(sessionId, Date.now());
|
|
328
|
-
};
|
|
329
|
-
const cleanupIpcSession = (sessionId) => {
|
|
330
|
-
if (ipcTransports[sessionId]) {
|
|
331
|
-
delete ipcTransports[sessionId];
|
|
332
|
-
}
|
|
333
|
-
ipcSessionLastActivity.delete(sessionId);
|
|
334
|
-
drainInitQueue();
|
|
335
|
-
maybeShutdownAfterStdinClose('ipc session cleanup');
|
|
336
|
-
};
|
|
337
|
-
function sendJsonRpcError(res, code, message, id = null, statusCode = 400) {
|
|
338
|
-
res.writeHead(statusCode).end(JSON.stringify({
|
|
339
|
-
jsonrpc: '2.0',
|
|
340
|
-
error: { code, message },
|
|
341
|
-
id,
|
|
342
|
-
}));
|
|
169
|
+
// ─── HTTP server ───
|
|
170
|
+
const httpServer = http.createServer(async (req, res) => {
|
|
171
|
+
if (!req.url || !req.method) {
|
|
172
|
+
res.writeHead(400).end();
|
|
173
|
+
return;
|
|
343
174
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
175
|
+
const url = new URL(req.url, `http://${IPC_CONFIG.host}:${IPC_CONFIG.port}`);
|
|
176
|
+
// Health endpoint
|
|
177
|
+
if (url.pathname === IPC_CONFIG.healthPath) {
|
|
178
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
|
|
179
|
+
status: 'ok',
|
|
180
|
+
pid: process.pid,
|
|
181
|
+
version,
|
|
182
|
+
namespace: getLockNamespace(),
|
|
183
|
+
instanceId,
|
|
184
|
+
}));
|
|
185
|
+
return;
|
|
352
186
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
if (initQueue.length >= initWaiterLimit) {
|
|
359
|
-
throw new Error('SERVER_CAPACITY_EXCEEDED');
|
|
360
|
-
}
|
|
361
|
-
if (initQueue.length >= ipcGuardConfig.maxQueue) {
|
|
362
|
-
throw new Error('SERVER_QUEUE_FULL');
|
|
363
|
-
}
|
|
364
|
-
await new Promise((resolve, reject) => {
|
|
365
|
-
const timeout = setTimeout(() => {
|
|
366
|
-
const index = initQueue.findIndex(item => item.resolve === resolve);
|
|
367
|
-
if (index >= 0) {
|
|
368
|
-
initQueue.splice(index, 1);
|
|
369
|
-
}
|
|
370
|
-
reject(new Error('SERVER_BUSY_TIMEOUT'));
|
|
371
|
-
}, ipcGuardConfig.queueWaitTimeoutMs);
|
|
372
|
-
timeout.unref();
|
|
373
|
-
initQueue.push({ resolve, reject, timeout });
|
|
187
|
+
// REST API endpoint — call askAI() directly
|
|
188
|
+
if (url.pathname === '/api/ask' && req.method === 'POST') {
|
|
189
|
+
let body = '';
|
|
190
|
+
req.on('data', (chunk) => {
|
|
191
|
+
body += chunk;
|
|
374
192
|
});
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
193
|
+
req.on('end', async () => {
|
|
194
|
+
touchPrimaryActivity();
|
|
195
|
+
await maybeRunToolSelfCleanup();
|
|
196
|
+
let parsed;
|
|
197
|
+
try {
|
|
198
|
+
parsed = body ? JSON.parse(body) : {};
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, error: 'Invalid JSON' }));
|
|
383
202
|
return;
|
|
384
203
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
catch {
|
|
391
|
-
// Ignore transport close errors and continue cleanup.
|
|
392
|
-
}
|
|
393
|
-
cleanupIpcSession(staleSessionId);
|
|
204
|
+
const { target, question, debug: debugFlag, budgetMs: requestBudgetMs } = parsed;
|
|
205
|
+
const effectiveBudgetMs = requestBudgetMs ?? 120000;
|
|
206
|
+
if (!target || !question) {
|
|
207
|
+
res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, error: 'Missing required fields: target, question' }));
|
|
208
|
+
return;
|
|
394
209
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
else {
|
|
399
|
-
logger('[ipc] Idle session cleanup is disabled (CAI_IPC_SESSION_IDLE_MS=0).');
|
|
400
|
-
}
|
|
401
|
-
const ipcServer = http.createServer(async (req, res) => {
|
|
402
|
-
if (!req.url || !req.method) {
|
|
403
|
-
res.writeHead(400).end();
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
const url = new URL(req.url, `http://${IPC_CONFIG.host}:${IPC_CONFIG.port}`);
|
|
407
|
-
// Health endpoint
|
|
408
|
-
if (url.pathname === IPC_CONFIG.healthPath) {
|
|
409
|
-
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
|
|
410
|
-
status: 'ok',
|
|
411
|
-
pid: process.pid,
|
|
412
|
-
version,
|
|
413
|
-
namespace: getLockNamespace(),
|
|
414
|
-
instanceId,
|
|
415
|
-
activeSessions: getActiveSessionCount(),
|
|
416
|
-
queuedInitializations: initQueue.length,
|
|
417
|
-
sessionCapacity: ipcGuardConfig.maxSessions,
|
|
418
|
-
reservedInitSlots: ipcGuardConfig.reservedInitSlots,
|
|
419
|
-
execMaxConcurrency: ipcGuardConfig.execMaxConcurrency,
|
|
420
|
-
}));
|
|
421
|
-
return;
|
|
422
|
-
}
|
|
423
|
-
// MCP endpoint
|
|
424
|
-
if (url.pathname !== IPC_CONFIG.mcpPath) {
|
|
425
|
-
res.writeHead(404).end();
|
|
426
|
-
return;
|
|
427
|
-
}
|
|
428
|
-
// CORS for local usage
|
|
429
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
430
|
-
res.setHeader('Access-Control-Allow-Headers', 'content-type,mcp-session-id');
|
|
431
|
-
res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id');
|
|
432
|
-
if (req.method === 'OPTIONS') {
|
|
433
|
-
res.writeHead(204).end();
|
|
434
|
-
return;
|
|
435
|
-
}
|
|
436
|
-
const sessionId = req.headers['mcp-session-id'];
|
|
437
|
-
if (req.method === 'POST') {
|
|
438
|
-
let body = '';
|
|
439
|
-
req.on('data', chunk => {
|
|
440
|
-
body += chunk;
|
|
441
|
-
});
|
|
442
|
-
req.on('end', async () => {
|
|
443
|
-
let json;
|
|
444
|
-
try {
|
|
445
|
-
json = body ? JSON.parse(body) : null;
|
|
446
|
-
}
|
|
447
|
-
catch {
|
|
448
|
-
sendJsonRpcError(res, -32700, 'Parse error');
|
|
449
|
-
return;
|
|
450
|
-
}
|
|
451
|
-
let ipcTransport;
|
|
452
|
-
touchPrimaryActivity();
|
|
453
|
-
if (sessionId && ipcTransports[sessionId]) {
|
|
454
|
-
ipcTransport = ipcTransports[sessionId];
|
|
455
|
-
touchIpcSession(sessionId);
|
|
456
|
-
}
|
|
457
|
-
else if (!sessionId && isInitializeRequest(json)) {
|
|
458
|
-
try {
|
|
459
|
-
await waitForInitCapacity();
|
|
460
|
-
}
|
|
461
|
-
catch (error) {
|
|
462
|
-
const message = error instanceof Error ? error.message : 'SERVER_BUSY_TIMEOUT';
|
|
463
|
-
if (message === 'SERVER_CAPACITY_EXCEEDED') {
|
|
464
|
-
sendJsonRpcError(res, -32003, message, null, 503);
|
|
465
|
-
}
|
|
466
|
-
else if (message === 'SERVER_QUEUE_FULL') {
|
|
467
|
-
sendJsonRpcError(res, -32002, message, null, 503);
|
|
468
|
-
}
|
|
469
|
-
else {
|
|
470
|
-
sendJsonRpcError(res, -32001, message, null, 503);
|
|
471
|
-
}
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
474
|
-
initializingCount++;
|
|
475
|
-
try {
|
|
476
|
-
ipcTransport = new StreamableHTTPServerTransport({
|
|
477
|
-
sessionIdGenerator: () => randomUUID(),
|
|
478
|
-
onsessioninitialized: newSessionId => {
|
|
479
|
-
ipcTransports[newSessionId] = ipcTransport;
|
|
480
|
-
touchIpcSession(newSessionId);
|
|
481
|
-
},
|
|
482
|
-
onsessionclosed: closedSessionId => {
|
|
483
|
-
cleanupIpcSession(closedSessionId);
|
|
484
|
-
},
|
|
485
|
-
});
|
|
486
|
-
ipcTransport.onclose = () => {
|
|
487
|
-
if (ipcTransport?.sessionId) {
|
|
488
|
-
cleanupIpcSession(ipcTransport.sessionId);
|
|
489
|
-
}
|
|
490
|
-
};
|
|
491
|
-
await server.connect(ipcTransport);
|
|
492
|
-
}
|
|
493
|
-
finally {
|
|
494
|
-
initializingCount = Math.max(0, initializingCount - 1);
|
|
495
|
-
drainInitQueue();
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
else {
|
|
499
|
-
sendJsonRpcError(res, -32000, 'Bad Request: No valid session ID provided');
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
try {
|
|
503
|
-
await ipcTransport.handleRequest(req, res, json);
|
|
504
|
-
}
|
|
505
|
-
catch (error) {
|
|
506
|
-
if (!res.headersSent) {
|
|
507
|
-
res.writeHead(500).end(JSON.stringify({
|
|
508
|
-
jsonrpc: '2.0',
|
|
509
|
-
error: {
|
|
510
|
-
code: -32603,
|
|
511
|
-
message: error instanceof Error
|
|
512
|
-
? error.message
|
|
513
|
-
: String(error),
|
|
514
|
-
},
|
|
515
|
-
id: null,
|
|
516
|
-
}));
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
});
|
|
520
|
-
return;
|
|
521
|
-
}
|
|
522
|
-
if (req.method === 'GET' || req.method === 'DELETE') {
|
|
523
|
-
if (!sessionId || !ipcTransports[sessionId]) {
|
|
524
|
-
res.writeHead(400).end('Invalid or missing session ID');
|
|
210
|
+
const validTargets = ['chatgpt', 'gemini', 'both'];
|
|
211
|
+
if (!validTargets.includes(target)) {
|
|
212
|
+
res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, error: `Invalid target: ${target}. Must be one of: ${validTargets.join(', ')}` }));
|
|
525
213
|
return;
|
|
526
214
|
}
|
|
527
|
-
|
|
528
|
-
touchIpcSession(sessionId);
|
|
215
|
+
const guard = await toolMutex.acquire();
|
|
529
216
|
try {
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
217
|
+
if (target === 'both') {
|
|
218
|
+
const [chatgptResult, geminiResult] = await Promise.all([
|
|
219
|
+
askAI('chatgpt', question, debugFlag, effectiveBudgetMs),
|
|
220
|
+
askAI('gemini', question, debugFlag, effectiveBudgetMs),
|
|
221
|
+
]);
|
|
222
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, results: [chatgptResult, geminiResult] }));
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
const result = await askAI(target, question, debugFlag, effectiveBudgetMs);
|
|
226
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: result.success, results: [result] }));
|
|
533
227
|
}
|
|
534
228
|
}
|
|
535
229
|
catch (error) {
|
|
230
|
+
const errorText = error instanceof Error ? error.message : String(error);
|
|
536
231
|
if (!res.headersSent) {
|
|
537
|
-
res.writeHead(500).end(JSON.stringify({
|
|
538
|
-
jsonrpc: '2.0',
|
|
539
|
-
error: {
|
|
540
|
-
code: -32603,
|
|
541
|
-
message: error instanceof Error ? error.message : String(error),
|
|
542
|
-
},
|
|
543
|
-
id: null,
|
|
544
|
-
}));
|
|
232
|
+
res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, error: errorText }));
|
|
545
233
|
}
|
|
546
234
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
res.writeHead(405).end();
|
|
550
|
-
});
|
|
551
|
-
function onListening() {
|
|
552
|
-
const addr = ipcServer.address();
|
|
553
|
-
const actualPort = typeof addr === 'object' && addr ? addr.port : IPC_CONFIG.port;
|
|
554
|
-
if (actualPort !== IPC_CONFIG.port) {
|
|
555
|
-
logger(`[ipc] Configured port ${IPC_CONFIG.port} was unavailable. Using dynamic port ${actualPort}.`);
|
|
556
|
-
updateLockPort(actualPort);
|
|
557
|
-
}
|
|
558
|
-
logger(`[ipc] IPC HTTP listening on http://${IPC_CONFIG.host}:${actualPort} (health: ${IPC_CONFIG.healthPath}, mcp: ${IPC_CONFIG.mcpPath})`);
|
|
559
|
-
}
|
|
560
|
-
ipcServer.on('error', (err) => {
|
|
561
|
-
if (err.code === 'EADDRINUSE') {
|
|
562
|
-
logger(`[ipc] Port ${IPC_CONFIG.port} in use. Retrying with dynamic port...`);
|
|
563
|
-
ipcServer.listen(0, IPC_CONFIG.host, onListening);
|
|
564
|
-
}
|
|
565
|
-
else {
|
|
566
|
-
logger(`[ipc] IPC server error: ${err.message}`);
|
|
567
|
-
}
|
|
568
|
-
});
|
|
569
|
-
ipcServer.listen(IPC_CONFIG.port, IPC_CONFIG.host, onListening);
|
|
570
|
-
// Primary idle auto-exit: exit when no activity and no active IPC sessions
|
|
571
|
-
if (ipcGuardConfig.primaryIdleMs > 0) {
|
|
572
|
-
const primaryIdleCheckTimer = setInterval(() => {
|
|
573
|
-
const activeSessionCount = getActiveSessionCount();
|
|
574
|
-
if (Date.now() - primaryLastActivityAt > ipcGuardConfig.primaryIdleMs &&
|
|
575
|
-
activeSessionCount === 0) {
|
|
576
|
-
logger(`[main] Primary idle for ${Math.round((Date.now() - primaryLastActivityAt) / 1000)}s with 0 active sessions. Auto-exiting.`);
|
|
577
|
-
shutdown('idle timeout');
|
|
235
|
+
finally {
|
|
236
|
+
guard.dispose();
|
|
578
237
|
}
|
|
579
|
-
}
|
|
580
|
-
|
|
238
|
+
});
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
res.writeHead(404).end();
|
|
242
|
+
});
|
|
243
|
+
function onListening() {
|
|
244
|
+
const addr = httpServer.address();
|
|
245
|
+
const actualPort = typeof addr === 'object' && addr ? addr.port : IPC_CONFIG.port;
|
|
246
|
+
if (actualPort !== IPC_CONFIG.port) {
|
|
247
|
+
logger(`[http] Configured port ${IPC_CONFIG.port} was unavailable. Using dynamic port ${actualPort}.`);
|
|
248
|
+
updateLockPort(actualPort);
|
|
249
|
+
}
|
|
250
|
+
logger(`[http] HTTP listening on http://${IPC_CONFIG.host}:${actualPort} (health: ${IPC_CONFIG.healthPath}, api: /api/ask)`);
|
|
251
|
+
}
|
|
252
|
+
httpServer.on('error', (err) => {
|
|
253
|
+
if (err.code === 'EADDRINUSE') {
|
|
254
|
+
logger(`[http] Port ${IPC_CONFIG.port} in use. Retrying with dynamic port...`);
|
|
255
|
+
httpServer.listen(0, IPC_CONFIG.host, onListening);
|
|
581
256
|
}
|
|
582
257
|
else {
|
|
583
|
-
logger(
|
|
258
|
+
logger(`[http] HTTP server error: ${err.message}`);
|
|
584
259
|
}
|
|
260
|
+
});
|
|
261
|
+
httpServer.listen(IPC_CONFIG.port, IPC_CONFIG.host, onListening);
|
|
262
|
+
// Idle auto-exit
|
|
263
|
+
if (ipcGuardConfig.primaryIdleMs > 0) {
|
|
264
|
+
const primaryIdleCheckTimer = setInterval(() => {
|
|
265
|
+
if (Date.now() - primaryLastActivityAt > ipcGuardConfig.primaryIdleMs) {
|
|
266
|
+
logger(`[main] Primary idle for ${Math.round((Date.now() - primaryLastActivityAt) / 1000)}s. Auto-exiting.`);
|
|
267
|
+
shutdown('idle timeout');
|
|
268
|
+
}
|
|
269
|
+
}, 30_000);
|
|
270
|
+
primaryIdleCheckTimer.unref();
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
logger('[main] Primary idle auto-exit is disabled (CAI_PRIMARY_IDLE_MS=0).');
|
|
585
274
|
}
|
|
586
|
-
// Graceful shutdown
|
|
587
|
-
// Based on review: タイムアウト必須、強制終了タイマー必要
|
|
275
|
+
// ─── Graceful shutdown ───
|
|
588
276
|
let isShuttingDown = false;
|
|
589
277
|
function withTimeout(promise, ms, label) {
|
|
590
278
|
return new Promise((resolve, reject) => {
|
|
591
279
|
const timer = setTimeout(() => {
|
|
592
280
|
reject(new Error(`${label} timed out after ${ms}ms`));
|
|
593
281
|
}, ms);
|
|
594
|
-
// unref() prevents this timer from keeping the process alive
|
|
595
282
|
timer.unref();
|
|
596
283
|
promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
|
|
597
284
|
});
|
|
@@ -623,164 +310,13 @@ async function shutdown(reason) {
|
|
|
623
310
|
clearTimeout(forceExitTimer);
|
|
624
311
|
process.exit(0);
|
|
625
312
|
}
|
|
626
|
-
function maybeShutdownAfterStdinClose(trigger) {
|
|
627
|
-
if (!stdinClosed || isShuttingDown) {
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
630
|
-
const activeSessionCount = getActiveIpcSessionCount();
|
|
631
|
-
if (activeSessionCount > 0) {
|
|
632
|
-
return;
|
|
633
|
-
}
|
|
634
|
-
logger(`[main] ${trigger}: stdin already closed and all IPC sessions drained. Shutting down.`);
|
|
635
|
-
void shutdown('stdin closed (all IPC sessions drained)');
|
|
636
|
-
}
|
|
637
|
-
function handleStdinClosed(reason) {
|
|
638
|
-
stdinClosed = true;
|
|
639
|
-
if (isShuttingDown) {
|
|
640
|
-
return;
|
|
641
|
-
}
|
|
642
|
-
const activeSessionCount = getActiveIpcSessionCount();
|
|
643
|
-
if (activeSessionCount > 0) {
|
|
644
|
-
logger(`[main] ${reason}: deferring shutdown while ${activeSessionCount} IPC session(s) remain.`);
|
|
645
|
-
return;
|
|
646
|
-
}
|
|
647
|
-
void shutdown(reason);
|
|
648
|
-
}
|
|
649
|
-
// stdin close = Claude Code disconnected (most reliable on Windows too)
|
|
650
|
-
process.stdin.on('end', () => handleStdinClosed('stdin ended'));
|
|
651
|
-
process.stdin.on('close', () => handleStdinClosed('stdin closed'));
|
|
652
313
|
// Signal handlers
|
|
653
314
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
654
315
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
655
|
-
// Keep beforeExit for edge cases
|
|
316
|
+
// Keep beforeExit for edge cases
|
|
656
317
|
process.on('beforeExit', () => {
|
|
657
318
|
releaseLock();
|
|
658
319
|
if (logFile) {
|
|
659
320
|
logFile.close();
|
|
660
321
|
}
|
|
661
322
|
});
|
|
662
|
-
// ─── Optional: User-configured external HTTP server (MCP_HTTP_PORT) ───
|
|
663
|
-
const httpPortRaw = process.env.MCP_HTTP_PORT;
|
|
664
|
-
if (httpPortRaw) {
|
|
665
|
-
const httpPort = Number(httpPortRaw);
|
|
666
|
-
if (!Number.isFinite(httpPort) || httpPort <= 0) {
|
|
667
|
-
console.error(`[http] Invalid MCP_HTTP_PORT: ${httpPortRaw}`);
|
|
668
|
-
}
|
|
669
|
-
else {
|
|
670
|
-
const httpHost = process.env.MCP_HTTP_HOST || '127.0.0.1';
|
|
671
|
-
const transports = {};
|
|
672
|
-
const serverHttp = http.createServer(async (req, res) => {
|
|
673
|
-
if (!req.url || !req.method) {
|
|
674
|
-
res.writeHead(400).end();
|
|
675
|
-
return;
|
|
676
|
-
}
|
|
677
|
-
// Basic CORS for local usage (Codex / local tools)
|
|
678
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
679
|
-
res.setHeader('Access-Control-Allow-Headers', 'content-type,mcp-session-id');
|
|
680
|
-
res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id');
|
|
681
|
-
if (req.method === 'OPTIONS') {
|
|
682
|
-
res.writeHead(204).end();
|
|
683
|
-
return;
|
|
684
|
-
}
|
|
685
|
-
const url = new URL(req.url, `http://${httpHost}:${httpPort}`);
|
|
686
|
-
if (url.pathname !== '/mcp') {
|
|
687
|
-
res.writeHead(404).end();
|
|
688
|
-
return;
|
|
689
|
-
}
|
|
690
|
-
const sessionId = req.headers['mcp-session-id'];
|
|
691
|
-
if (req.method === 'POST') {
|
|
692
|
-
let body = '';
|
|
693
|
-
req.on('data', chunk => {
|
|
694
|
-
body += chunk;
|
|
695
|
-
});
|
|
696
|
-
req.on('end', async () => {
|
|
697
|
-
let json;
|
|
698
|
-
try {
|
|
699
|
-
json = body ? JSON.parse(body) : null;
|
|
700
|
-
}
|
|
701
|
-
catch {
|
|
702
|
-
res.writeHead(400).end(JSON.stringify({
|
|
703
|
-
jsonrpc: '2.0',
|
|
704
|
-
error: { code: -32700, message: 'Parse error' },
|
|
705
|
-
id: null,
|
|
706
|
-
}));
|
|
707
|
-
return;
|
|
708
|
-
}
|
|
709
|
-
let transport;
|
|
710
|
-
if (sessionId && transports[sessionId]) {
|
|
711
|
-
transport = transports[sessionId];
|
|
712
|
-
}
|
|
713
|
-
else if (!sessionId && isInitializeRequest(json)) {
|
|
714
|
-
transport = new StreamableHTTPServerTransport({
|
|
715
|
-
sessionIdGenerator: () => randomUUID(),
|
|
716
|
-
onsessioninitialized: newSessionId => {
|
|
717
|
-
transports[newSessionId] = transport;
|
|
718
|
-
},
|
|
719
|
-
});
|
|
720
|
-
transport.onclose = () => {
|
|
721
|
-
if (transport?.sessionId) {
|
|
722
|
-
delete transports[transport.sessionId];
|
|
723
|
-
}
|
|
724
|
-
};
|
|
725
|
-
await server.connect(transport);
|
|
726
|
-
}
|
|
727
|
-
else {
|
|
728
|
-
res.writeHead(400).end(JSON.stringify({
|
|
729
|
-
jsonrpc: '2.0',
|
|
730
|
-
error: {
|
|
731
|
-
code: -32000,
|
|
732
|
-
message: 'Bad Request: No valid session ID provided',
|
|
733
|
-
},
|
|
734
|
-
id: null,
|
|
735
|
-
}));
|
|
736
|
-
return;
|
|
737
|
-
}
|
|
738
|
-
try {
|
|
739
|
-
await transport.handleRequest(req, res, json);
|
|
740
|
-
}
|
|
741
|
-
catch (error) {
|
|
742
|
-
if (!res.headersSent) {
|
|
743
|
-
res.writeHead(500).end(JSON.stringify({
|
|
744
|
-
jsonrpc: '2.0',
|
|
745
|
-
error: {
|
|
746
|
-
code: -32603,
|
|
747
|
-
message: error instanceof Error
|
|
748
|
-
? error.message
|
|
749
|
-
: String(error),
|
|
750
|
-
},
|
|
751
|
-
id: null,
|
|
752
|
-
}));
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
});
|
|
756
|
-
return;
|
|
757
|
-
}
|
|
758
|
-
if (req.method === 'GET' || req.method === 'DELETE') {
|
|
759
|
-
if (!sessionId || !transports[sessionId]) {
|
|
760
|
-
res.writeHead(400).end('Invalid or missing session ID');
|
|
761
|
-
return;
|
|
762
|
-
}
|
|
763
|
-
try {
|
|
764
|
-
await transports[sessionId].handleRequest(req, res);
|
|
765
|
-
}
|
|
766
|
-
catch (error) {
|
|
767
|
-
if (!res.headersSent) {
|
|
768
|
-
res.writeHead(500).end(JSON.stringify({
|
|
769
|
-
jsonrpc: '2.0',
|
|
770
|
-
error: {
|
|
771
|
-
code: -32603,
|
|
772
|
-
message: error instanceof Error ? error.message : String(error),
|
|
773
|
-
},
|
|
774
|
-
id: null,
|
|
775
|
-
}));
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
return;
|
|
779
|
-
}
|
|
780
|
-
res.writeHead(405).end();
|
|
781
|
-
});
|
|
782
|
-
serverHttp.listen(httpPort, httpHost, () => {
|
|
783
|
-
console.error(`[http] MCP Streamable HTTP listening on http://${httpHost}:${httpPort}/mcp`);
|
|
784
|
-
});
|
|
785
|
-
}
|
|
786
|
-
}
|