chrome-ai-bridge 2.4.0 → 2.5.2

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/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 - Extension-only Mode (v2.0.0)
7
+ * Chrome AI Bridge - Daemon Mode (v3.0.0)
8
8
  *
9
- * This MCP server provides ChatGPT/Gemini integration via Chrome extension.
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.
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, PluginLoader } from './plugin-api.js';
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, checkExistingPrimary, updateLockPort, terminatePrimaryProcess, getLockNamespace, cleanupOrphanBridgeProcesses, } from './process-lock.js';
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} (Extension-only mode)`);
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
- // ─── Multi-client routing with retry ───
62
- // Handles concurrent startup of many processes (e.g. tproj 16-pane scenario).
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
- // 2. Lock held by another process — try to connect as Proxy
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
- // Final fallback: one last proxy attempt before giving up
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 for Primary process
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(); // Don't keep process alive for cleanup
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
- function registerTool(tool) {
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
- const transport = new StdioServerTransport();
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
- // ─── IPC HTTP server (for proxy clients) ───
318
- {
319
- const ipcTransports = {};
320
- const ipcSessionLastActivity = new Map();
321
- const initQueue = [];
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
- function drainInitQueue() {
345
- while (initQueue.length > 0 && getSessionLoad() < ipcGuardConfig.maxSessions) {
346
- const waiter = initQueue.shift();
347
- if (!waiter)
348
- break;
349
- clearTimeout(waiter.timeout);
350
- waiter.resolve();
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
- async function waitForInitCapacity() {
354
- if (getSessionLoad() < ipcGuardConfig.maxSessions) {
355
- return;
356
- }
357
- const initWaiterLimit = Math.max(0, Math.min(ipcGuardConfig.reservedInitSlots, ipcGuardConfig.maxQueue));
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
- if (ipcGuardConfig.sessionIdleMs > 0) {
377
- const idleCleanupTimer = setInterval(async () => {
378
- const now = Date.now();
379
- const staleSessionIds = Array.from(ipcSessionLastActivity.entries())
380
- .filter(([, lastActivity]) => now - lastActivity > ipcGuardConfig.sessionIdleMs)
381
- .map(([sessionId]) => sessionId);
382
- if (staleSessionIds.length === 0) {
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
- logger(`[ipc] Closing ${staleSessionIds.length} idle session(s) older than ${ipcGuardConfig.sessionIdleMs}ms.`);
386
- for (const staleSessionId of staleSessionIds) {
387
- try {
388
- await ipcTransports[staleSessionId]?.close();
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 ?? 300000;
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
- }, Math.max(10_000, Math.min(60_000, Math.floor(ipcGuardConfig.sessionIdleMs / 2))));
396
- idleCleanupTimer.unref();
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
- touchPrimaryActivity();
528
- touchIpcSession(sessionId);
215
+ const guard = await toolMutex.acquire();
529
216
  try {
530
- await ipcTransports[sessionId].handleRequest(req, res);
531
- if (req.method === 'DELETE') {
532
- cleanupIpcSession(sessionId);
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
- return;
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
- }, 30_000);
580
- primaryIdleCheckTimer.unref();
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('[main] Primary idle auto-exit is disabled (CAI_PRIMARY_IDLE_MS=0).');
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 handler with timeout
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 where stdin doesn't close
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
- }